Compare commits

..

7 Commits

Author SHA1 Message Date
openhands
eb348a5f3d Remove KeyboardInterrupt exit behavior from main chat loop
- Change KeyboardInterrupt handler to continue loop instead of exiting
- Let signal handler manage Ctrl+C behavior completely
- Only exit on explicit /exit command or outer KeyboardInterrupt

This ensures that Ctrl+C during agent processing returns to chat loop
instead of exiting the entire application.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-03 17:09:16 +00:00
openhands
099dcb787f Fix Ctrl+C behavior to return to chat loop instead of exiting
- Remove os._exit(1) from second Ctrl+C handler
- Reset Ctrl+C counter after force killing process
- Add graceful handling in SimpleProcessRunner for killed processes
- Show user-friendly message that they can continue sending messages

This allows users to stop a running agent and continue with new messages
instead of having to restart the entire CLI application.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-03 17:07:47 +00:00
openhands
b3034a0d75 Fix multiprocessing serialization issues in SimpleProcessRunner
- Pass conversation_id and message_data instead of full objects to subprocess
- Recreate conversation and message objects in the subprocess
- Extract text content from Message objects for serialization
- Store conversation_id as string for subprocess recreation

This fixes the 'cannot pickle _asyncio.Future object' error by avoiding
passing non-serializable objects between processes.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-03 16:59:29 +00:00
openhands
459e224d37 Fix Message creation to include required role field
- Add role='user' to Message constructor in agent_chat.py
- This fixes the validation error when processing user messages

Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-03 16:57:02 +00:00
openhands
97f13b7100 Fix SimpleProcessRunner to use proper SDK imports
- Replace incorrect openhands.core.main imports with openhands.sdk
- Use existing ConversationRunner from runner.py instead of run_controller
- Update SimpleProcessRunner to accept BaseConversation instead of setup function
- Update agent_chat.py to create conversation first, then pass to SimpleProcessRunner
- Fix process_message to use proper Message object with TextContent

This ensures the openhands-cli remains standalone and only uses the SDK library
as intended, without importing from the main OpenHands codebase.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-03 16:51:47 +00:00
openhands
6ecaca5b3c Simplify Ctrl+C handling implementation
- Replace complex ProcessSignalHandler with SimpleSignalHandler
  - Direct signal handling in main process instead of queue communication
  - Simple Ctrl+C counting with immediate force kill on second press
  - Reset functionality to clear count when starting new operations

- Replace ProcessBasedConversationRunner with SimpleProcessRunner
  - Minimal multiprocessing - only process_message runs in subprocess
  - Direct method calls for status, settings, and other operations
  - No unnecessary queue communication

- Update agent_chat.py to use simplified components
  - Reset Ctrl+C count when starting new message processing
  - Direct method calls for commands that don't need process isolation
  - Cleaner error handling and resource cleanup

- Update simple_main.py imports

Fixes issues where second Ctrl+C wouldn't register properly due to
complex queue-based communication and race conditions.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-03 16:46:43 +00:00
openhands
5351702d3a Implement improved Ctrl+C handling for OpenHands CLI
- First Ctrl+C attempts graceful pause of agent
- Second Ctrl+C (within 3 seconds) kills process immediately
- Added SignalHandler and ProcessSignalHandler classes for signal management
- Implemented ProcessBasedConversationRunner for separate process execution
- Modified pause_listener to remove Ctrl+C handling (now handled by signal handler)
- Updated agent_chat.py to use process-based runner with new signal management
- Updated simple_main.py to install basic signal handler
- Added comprehensive test script and documentation

Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-03 16:24:23 +00:00
87 changed files with 1872 additions and 3342 deletions

View File

@@ -7,8 +7,5 @@ git config --global --add safe.directory "$(realpath .)"
# Install `nc`
sudo apt update && sudo apt install netcat -y
# Install `uv` and `uvx`
wget -qO- https://astral.sh/uv/install.sh | sh
# Do common setup tasks
source .openhands/setup.sh

73
.github/scripts/check_version_consistency.py vendored Executable file
View File

@@ -0,0 +1,73 @@
#!/usr/bin/env python3
import os
import re
import sys
def find_version_references(directory: str) -> tuple[set[str], set[str]]:
openhands_versions = set()
runtime_versions = set()
version_pattern_openhands = re.compile(r'openhands:(\d{1})\.(\d{2})')
version_pattern_runtime = re.compile(r'runtime:(\d{1})\.(\d{2})')
for root, _, files in os.walk(directory):
# Skip .git directory and docs/build directory
if '.git' in root or 'docs/build' in root:
continue
for file in files:
if file.endswith(
('.md', '.yml', '.yaml', '.txt', '.html', '.py', '.js', '.ts')
):
file_path = os.path.join(root, file)
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Find all openhands version references
matches = version_pattern_openhands.findall(content)
if matches:
print(f'Found openhands version {matches} in {file_path}')
openhands_versions.update(matches)
# Find all runtime version references
matches = version_pattern_runtime.findall(content)
if matches:
print(f'Found runtime version {matches} in {file_path}')
runtime_versions.update(matches)
except Exception as e:
print(f'Error reading {file_path}: {e}', file=sys.stderr)
return openhands_versions, runtime_versions
def main():
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
print(f'Checking version consistency in {repo_root}')
openhands_versions, runtime_versions = find_version_references(repo_root)
print(f'Found openhands versions: {sorted(openhands_versions)}')
print(f'Found runtime versions: {sorted(runtime_versions)}')
exit_code = 0
if len(openhands_versions) > 1:
print('Error: Multiple openhands versions found:', file=sys.stderr)
print('Found versions:', sorted(openhands_versions), file=sys.stderr)
exit_code = 1
elif len(openhands_versions) == 0:
print('Warning: No openhands version references found', file=sys.stderr)
if len(runtime_versions) > 1:
print('Error: Multiple runtime versions found:', file=sys.stderr)
print('Found versions:', sorted(runtime_versions), file=sys.stderr)
exit_code = 1
elif len(runtime_versions) == 0:
print('Warning: No runtime version references found', file=sys.stderr)
sys.exit(exit_code)
if __name__ == '__main__':
main()

View File

@@ -19,31 +19,11 @@ jobs:
if: github.event.label.name == 'deploy'
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: Find Comment
uses: peter-evans/find-comment@v3
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: |
⚠️ Enterprise preview
- name: Comment warning on PR
uses: peter-evans/create-or-update-comment@v4
with:
issue-number: ${{ github.event.pull_request.number }}
comment-id: ${{ steps.find-comment.outputs.comment-id }}
edit-mode: replace
body: |
⚠️ Enterprise preview: you can check the build for progress and errors here:
https://github.com/OpenHands/deploy/actions/workflows/deploy.yaml
# This should match the version in ghcr-build.yml
- name: Trigger remote job
run: |
curl --fail-with-body -sS -X POST \
-H "Authorization: Bearer ${{ secrets.OPENHANDS_AGENT_PAT }}" \
-H "Authorization: Bearer ${{ secrets.PAT_TOKEN }}" \
-H "Accept: application/vnd.github+json" \
-d "{\"ref\": \"main\", \"inputs\": {\"openhandsPrNumber\": \"${{ github.event.pull_request.number }}\", \"deployEnvironment\": \"feature\", \"enterpriseImageTag\": \"pr-${{ github.event.pull_request.number }}\" }}" \
https://api.github.com/repos/OpenHands/deploy/actions/workflows/deploy.yaml/dispatches

View File

@@ -86,7 +86,7 @@ jobs:
# Builds the runtime Docker images
ghcr_build_runtime:
name: Build Runtime Image
name: Build Image
runs-on: blacksmith-8vcpu-ubuntu-2204
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
permissions:
@@ -256,7 +256,7 @@ jobs:
test_runtime_root:
name: RT Unit Tests (Root)
needs: [ghcr_build_runtime, define-matrix]
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: blacksmith-8vcpu-ubuntu-2204
strategy:
fail-fast: false
matrix:
@@ -298,7 +298,7 @@ jobs:
# We install pytest-xdist in order to run tests across CPUs
poetry run pip install pytest-xdist
# Install to be able to retry on failures for flakey tests
# Install to be able to retry on failures for flaky tests
poetry run pip install pytest-rerunfailures
image_name=ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
@@ -311,14 +311,14 @@ jobs:
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
TEST_IN_CI=true \
RUN_AS_OPENHANDS=false \
poetry run pytest -n 5 -raRs --reruns 2 --reruns-delay 3 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
poetry run pytest -n 0 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
env:
DEBUG: "1"
# Run unit tests with the Docker runtime Docker images as openhands user
test_runtime_oh:
name: RT Unit Tests (openhands)
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: blacksmith-8vcpu-ubuntu-2204
needs: [ghcr_build_runtime, define-matrix]
strategy:
matrix:
@@ -370,7 +370,7 @@ jobs:
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
TEST_IN_CI=true \
RUN_AS_OPENHANDS=true \
poetry run pytest -n 5 -raRs --reruns 2 --reruns-delay 3 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
poetry run pytest -n 0 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
env:
DEBUG: "1"

View File

@@ -90,3 +90,16 @@ jobs:
- name: Run pre-commit hooks
working-directory: ./openhands-cli
run: pre-commit run --all-files --config ./dev_config/python/.pre-commit-config.yaml
# Check version consistency across documentation
check-version-consistency:
name: Check version consistency
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v4
- name: Set up python
uses: useblacksmith/setup-python@v6
with:
python-version: 3.12
- name: Run version consistency check
run: .github/scripts/check_version_consistency.py

View File

@@ -48,10 +48,7 @@ jobs:
python-version: ${{ matrix.python-version }}
cache: "poetry"
- name: Install Python dependencies using Poetry
run: |
poetry install --with dev,test,runtime
poetry run pip install pytest-xdist
poetry run pip install pytest-rerunfailures
run: poetry install --with dev,test,runtime
- name: Build Environment
run: make build
- name: Run Unit Tests
@@ -59,7 +56,7 @@ jobs:
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
run: PYTHONPATH=".:$PYTHONPATH" TEST_RUNTIME=cli poetry run pytest -s tests/runtime/test_bash.py --cov=openhands --cov-branch
env:
COVERAGE_FILE: ".coverage.runtime.${{ matrix.python_version }}"
- name: Store coverage file
@@ -91,7 +88,7 @@ jobs:
- name: Install Python dependencies using Poetry
run: poetry install --with dev,test,runtime
- name: Run Windows unit tests
run: poetry run pytest -svv tests/runtime//test_windows_bash.py
run: poetry run pytest -svv tests/unit/runtime/utils/test_windows_bash.py
env:
PYTHONPATH: ".;$env:PYTHONPATH"
DEBUG: "1"
@@ -176,6 +173,7 @@ jobs:
path: ".coverage.openhands-cli.${{ matrix.python-version }}"
include-hidden-files: true
coverage-comment:
name: Coverage Comment
if: github.event_name == 'pull_request'

1
CNAME
View File

@@ -1 +0,0 @@
docs.all-hands.dev

View File

@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:0.61-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:0.60-nikolaik`
## Develop inside Docker container

View File

@@ -82,17 +82,17 @@ You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)
You can also run OpenHands directly with Docker:
```bash
docker pull docker.openhands.dev/openhands/runtime:0.61-nikolaik
docker pull docker.openhands.dev/openhands/runtime:0.60-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:0.61-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:0.60-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.openhands.dev/openhands/openhands:0.61
docker.openhands.dev/openhands/openhands:0.60
```
</details>

View File

@@ -12,7 +12,7 @@ services:
- SANDBOX_API_HOSTNAME=host.docker.internal
- DOCKER_HOST_ADDR=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:0.61-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:0.60-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:0.61-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:0.60-nikolaik}
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

325
enterprise/poetry.lock generated
View File

@@ -201,20 +201,19 @@ files = [
[[package]]
name = "anthropic"
version = "0.72.0"
version = "0.65.0"
description = "The official Python library for the anthropic API"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "anthropic-0.72.0-py3-none-any.whl", hash = "sha256:0e9f5a7582f038cab8efbb4c959e49ef654a56bfc7ba2da51b5a7b8a84de2e4d"},
{file = "anthropic-0.72.0.tar.gz", hash = "sha256:8971fe76dcffc644f74ac3883069beb1527641115ae0d6eb8fa21c1ce4082f7a"},
{file = "anthropic-0.65.0-py3-none-any.whl", hash = "sha256:ba9d9f82678046c74ddf5698ca06d9f5b0f599cfac922ab0d5921638eb448d98"},
{file = "anthropic-0.65.0.tar.gz", hash = "sha256:6b6b6942574e54342050dfd42b8d856a8366b171daec147df3b80be4722733b9"},
]
[package.dependencies]
anyio = ">=3.5.0,<5"
distro = ">=1.7.0,<2"
docstring-parser = ">=0.15,<1"
google-auth = {version = ">=2,<3", extras = ["requests"], optional = true, markers = "extra == \"vertex\""}
httpx = ">=0.25.0,<1"
jiter = ">=0.4.0,<1"
@@ -223,7 +222,7 @@ sniffio = "*"
typing-extensions = ">=4.10,<5"
[package.extras]
aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.9)"]
aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.8)"]
bedrock = ["boto3 (>=1.28.57)", "botocore (>=1.31.57)"]
vertex = ["google-auth[requests] (>=2,<3)"]
@@ -682,34 +681,31 @@ crt = ["awscrt (==0.27.6)"]
[[package]]
name = "browser-use"
version = "0.9.5"
version = "0.7.10"
description = "Make websites accessible for AI agents"
optional = false
python-versions = "<4.0,>=3.11"
groups = ["main"]
files = [
{file = "browser_use-0.9.5-py3-none-any.whl", hash = "sha256:4a2e92847204d1ded269026a99cb0cc0e60e38bd2751fa3f58aedd78f00b4e67"},
{file = "browser_use-0.9.5.tar.gz", hash = "sha256:f8285fe253b149d01769a7084883b4cf4db351e2f38e26302c157bcbf14a703f"},
{file = "browser_use-0.7.10-py3-none-any.whl", hash = "sha256:669e12571a0c0c4c93e5fd26abf9e2534eb9bacbc510328aedcab795bd8906a9"},
{file = "browser_use-0.7.10.tar.gz", hash = "sha256:f93ce59e06906c12d120360dee4aa33d83618ddf7c9a575dd0ac517d2de7ccbc"},
]
[package.dependencies]
aiohttp = "3.12.15"
anthropic = ">=0.68.1,<1.0.0"
anthropic = ">=0.58.2,<1.0.0"
anyio = ">=4.9.0"
authlib = ">=1.6.0"
bubus = ">=1.5.6"
cdp-use = ">=1.4.0"
click = ">=8.1.8"
cloudpickle = ">=3.1.1"
google-api-core = ">=2.25.0"
google-api-python-client = ">=2.174.0"
google-auth = ">=2.40.3"
google-auth-oauthlib = ">=1.2.2"
google-genai = ">=1.29.0,<2.0.0"
groq = ">=0.30.0"
html2text = ">=2025.4.15"
httpx = ">=0.28.1"
inquirerpy = ">=0.3.4"
markdownify = ">=1.2.0"
mcp = ">=1.10.1"
ollama = ">=0.5.1"
openai = ">=1.99.2,<2.0.0"
@@ -724,20 +720,16 @@ pypdf = ">=5.7.0"
python-dotenv = ">=1.0.1"
reportlab = ">=4.0.0"
requests = ">=2.32.3"
rich = ">=14.0.0"
screeninfo = {version = ">=0.8.1", markers = "platform_system != \"darwin\""}
typing-extensions = ">=4.12.2"
uuid7 = ">=0.1.0"
[package.extras]
all = ["agentmail (==0.0.59)", "boto3 (>=1.38.45)", "botocore (>=1.37.23)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)", "oci (>=2.126.4)", "textual (>=3.2.0)"]
all = ["agentmail (>=0.0.53)", "boto3 (>=1.38.45)", "botocore (>=1.37.23)", "click (>=8.1.8)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)", "rich (>=14.0.0)", "textual (>=3.2.0)"]
aws = ["boto3 (>=1.38.45)"]
cli = ["textual (>=3.2.0)"]
cli-oci = ["oci (>=2.126.4)", "textual (>=3.2.0)"]
code = ["matplotlib (>=3.9.0)", "numpy (>=2.3.2)", "pandas (>=2.2.0)", "tabulate (>=0.9.0)"]
eval = ["anyio (>=4.9.0)", "datamodel-code-generator (>=0.26.0)", "lmnr[all] (==0.7.17)", "psutil (>=7.0.0)"]
examples = ["agentmail (==0.0.59)", "botocore (>=1.37.23)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)"]
oci = ["oci (>=2.126.4)"]
cli = ["click (>=8.1.8)", "rich (>=14.0.0)", "textual (>=3.2.0)"]
eval = ["anyio (>=4.9.0)", "browserbase (==1.4.0)", "datamodel-code-generator (>=0.26.0)", "hyperbrowser (==0.47.0)", "lmnr[all] (==0.7.10)", "psutil (>=7.0.0)"]
examples = ["agentmail (>=0.0.53)", "botocore (>=1.37.23)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)"]
video = ["imageio[ffmpeg] (>=2.37.0)", "numpy (>=2.3.2)"]
[[package]]
@@ -3533,25 +3525,6 @@ files = [
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
]
[[package]]
name = "inquirerpy"
version = "0.3.4"
description = "Python port of Inquirer.js (A collection of common interactive command-line user interfaces)"
optional = false
python-versions = ">=3.7,<4.0"
groups = ["main"]
files = [
{file = "InquirerPy-0.3.4-py3-none-any.whl", hash = "sha256:c65fdfbac1fa00e3ee4fb10679f4d3ed7a012abf4833910e63c295827fe2a7d4"},
{file = "InquirerPy-0.3.4.tar.gz", hash = "sha256:89d2ada0111f337483cb41ae31073108b2ec1e618a49d7110b0d7ade89fc197e"},
]
[package.dependencies]
pfzy = ">=0.3.1,<0.4.0"
prompt-toolkit = ">=3.0.1,<4.0.0"
[package.extras]
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
[[package]]
name = "installer"
version = "0.7.0"
@@ -4607,62 +4580,6 @@ files = [
{file = "llvmlite-0.44.0.tar.gz", hash = "sha256:07667d66a5d150abed9157ab6c0b9393c9356f229784a4385c02f99e94fc94d4"},
]
[[package]]
name = "lmnr"
version = "0.7.20"
description = "Python SDK for Laminar"
optional = false
python-versions = "<4,>=3.10"
groups = ["main"]
files = [
{file = "lmnr-0.7.20-py3-none-any.whl", hash = "sha256:5f9fa7444e6f96c25e097f66484ff29e632bdd1de0e9346948bf5595f4a8af38"},
{file = "lmnr-0.7.20.tar.gz", hash = "sha256:1f484cd618db2d71af65f90a0b8b36d20d80dc91a5138b811575c8677bf7c4fd"},
]
[package.dependencies]
grpcio = ">=1"
httpx = ">=0.24.0"
opentelemetry-api = ">=1.33.0"
opentelemetry-exporter-otlp-proto-grpc = ">=1.33.0"
opentelemetry-exporter-otlp-proto-http = ">=1.33.0"
opentelemetry-instrumentation = ">=0.54b0"
opentelemetry-instrumentation-threading = ">=0.57b0"
opentelemetry-sdk = ">=1.33.0"
opentelemetry-semantic-conventions = ">=0.54b0"
opentelemetry-semantic-conventions-ai = ">=0.4.13"
orjson = ">=3.0.0"
packaging = ">=22.0"
pydantic = ">=2.0.3,<3.0.0"
python-dotenv = ">=1.0"
tenacity = ">=8.0"
tqdm = ">=4.0"
[package.extras]
alephalpha = ["opentelemetry-instrumentation-alephalpha (>=0.47.1)"]
all = ["opentelemetry-instrumentation-alephalpha (>=0.47.1)", "opentelemetry-instrumentation-bedrock (>=0.47.1)", "opentelemetry-instrumentation-chromadb (>=0.47.1)", "opentelemetry-instrumentation-cohere (>=0.47.1)", "opentelemetry-instrumentation-crewai (>=0.47.1)", "opentelemetry-instrumentation-haystack (>=0.47.1)", "opentelemetry-instrumentation-lancedb (>=0.47.1)", "opentelemetry-instrumentation-langchain (>=0.47.1)", "opentelemetry-instrumentation-llamaindex (>=0.47.1)", "opentelemetry-instrumentation-marqo (>=0.47.1)", "opentelemetry-instrumentation-mcp (>=0.47.1)", "opentelemetry-instrumentation-milvus (>=0.47.1)", "opentelemetry-instrumentation-mistralai (>=0.47.1)", "opentelemetry-instrumentation-ollama (>=0.47.1)", "opentelemetry-instrumentation-pinecone (>=0.47.1)", "opentelemetry-instrumentation-qdrant (>=0.47.1)", "opentelemetry-instrumentation-replicate (>=0.47.1)", "opentelemetry-instrumentation-sagemaker (>=0.47.1)", "opentelemetry-instrumentation-together (>=0.47.1)", "opentelemetry-instrumentation-transformers (>=0.47.1)", "opentelemetry-instrumentation-vertexai (>=0.47.1)", "opentelemetry-instrumentation-watsonx (>=0.47.1)", "opentelemetry-instrumentation-weaviate (>=0.47.1)"]
bedrock = ["opentelemetry-instrumentation-bedrock (>=0.47.1)"]
chromadb = ["opentelemetry-instrumentation-chromadb (>=0.47.1)"]
cohere = ["opentelemetry-instrumentation-cohere (>=0.47.1)"]
crewai = ["opentelemetry-instrumentation-crewai (>=0.47.1)"]
haystack = ["opentelemetry-instrumentation-haystack (>=0.47.1)"]
lancedb = ["opentelemetry-instrumentation-lancedb (>=0.47.1)"]
langchain = ["opentelemetry-instrumentation-langchain (>=0.47.1)"]
llamaindex = ["opentelemetry-instrumentation-llamaindex (>=0.47.1)"]
marqo = ["opentelemetry-instrumentation-marqo (>=0.47.1)"]
mcp = ["opentelemetry-instrumentation-mcp (>=0.47.1)"]
milvus = ["opentelemetry-instrumentation-milvus (>=0.47.1)"]
mistralai = ["opentelemetry-instrumentation-mistralai (>=0.47.1)"]
ollama = ["opentelemetry-instrumentation-ollama (>=0.47.1)"]
pinecone = ["opentelemetry-instrumentation-pinecone (>=0.47.1)"]
qdrant = ["opentelemetry-instrumentation-qdrant (>=0.47.1)"]
replicate = ["opentelemetry-instrumentation-replicate (>=0.47.1)"]
sagemaker = ["opentelemetry-instrumentation-sagemaker (>=0.47.1)"]
together = ["opentelemetry-instrumentation-together (>=0.47.1)"]
transformers = ["opentelemetry-instrumentation-transformers (>=0.47.1)"]
vertexai = ["opentelemetry-instrumentation-vertexai (>=0.47.1)"]
watsonx = ["opentelemetry-instrumentation-watsonx (>=0.47.1)"]
weaviate = ["opentelemetry-instrumentation-weaviate (>=0.47.1)"]
[[package]]
name = "lxml"
version = "6.0.1"
@@ -5820,7 +5737,7 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
[[package]]
name = "openhands-agent-server"
version = "1.0.0a5"
version = "1.0.0a4"
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
optional = false
python-versions = ">=3.12"
@@ -5841,14 +5758,14 @@ wsproto = ">=1.2.0"
[package.source]
type = "git"
url = "https://github.com/OpenHands/software-agent-sdk.git"
reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
resolved_reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
url = "https://github.com/OpenHands/agent-sdk.git"
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
subdirectory = "openhands-agent-server"
[[package]]
name = "openhands-ai"
version = "0.0.0-post.5514+7c9e66194"
version = "0.0.0-post.5456+15c207c40"
description = "OpenHands: Code Less, Make More"
optional = false
python-versions = "^3.12,<3.14"
@@ -5884,14 +5801,13 @@ jupyter_kernel_gateway = "*"
kubernetes = "^33.1.0"
libtmux = ">=0.46.2"
litellm = ">=1.74.3, <1.78.0, !=1.64.4, !=1.67.*"
lmnr = "^0.7.20"
memory-profiler = "^0.61.0"
numpy = "*"
openai = "1.99.9"
openhands-aci = "0.3.2"
openhands-agent-server = {git = "https://github.com/OpenHands/software-agent-sdk.git", rev = "d5995c31c55e488d4ab0372d292973bc6fad71f1", subdirectory = "openhands-agent-server"}
openhands-sdk = {git = "https://github.com/OpenHands/software-agent-sdk.git", rev = "d5995c31c55e488d4ab0372d292973bc6fad71f1", subdirectory = "openhands-sdk"}
openhands-tools = {git = "https://github.com/OpenHands/software-agent-sdk.git", rev = "d5995c31c55e488d4ab0372d292973bc6fad71f1", subdirectory = "openhands-tools"}
openhands-agent-server = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "3d8af53b2f0259dc98555a4acd4238f90e0afbce", subdirectory = "openhands-agent-server"}
openhands-sdk = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "3d8af53b2f0259dc98555a4acd4238f90e0afbce", subdirectory = "openhands-sdk"}
openhands-tools = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "3d8af53b2f0259dc98555a4acd4238f90e0afbce", subdirectory = "openhands-tools"}
opentelemetry-api = "^1.33.1"
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
pathspec = "^0.12.1"
@@ -5947,7 +5863,7 @@ url = ".."
[[package]]
name = "openhands-sdk"
version = "1.0.0a5"
version = "1.0.0a4"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
@@ -5959,7 +5875,6 @@ develop = false
fastmcp = ">=2.11.3"
httpx = ">=0.27.0"
litellm = ">=1.77.7.dev9"
lmnr = ">=0.7.20"
pydantic = ">=2.11.7"
python-frontmatter = ">=1.1.0"
python-json-logger = ">=3.3.0"
@@ -5971,14 +5886,14 @@ boto3 = ["boto3 (>=1.35.0)"]
[package.source]
type = "git"
url = "https://github.com/OpenHands/software-agent-sdk.git"
reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
resolved_reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
url = "https://github.com/OpenHands/agent-sdk.git"
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
subdirectory = "openhands-sdk"
[[package]]
name = "openhands-tools"
version = "1.0.0a5"
version = "1.0.0a4"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
@@ -5989,7 +5904,7 @@ develop = false
[package.dependencies]
bashlex = ">=0.18"
binaryornot = ">=0.4.4"
browser-use = ">=0.8.0"
browser-use = ">=0.7.7"
cachetools = "*"
func-timeout = ">=4.3.5"
libtmux = ">=0.46.2"
@@ -5998,9 +5913,9 @@ pydantic = ">=2.11.7"
[package.source]
type = "git"
url = "https://github.com/OpenHands/software-agent-sdk.git"
reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
resolved_reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
url = "https://github.com/OpenHands/agent-sdk.git"
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
subdirectory = "openhands-tools"
[[package]]
@@ -6073,62 +5988,6 @@ opentelemetry-proto = "1.36.0"
opentelemetry-sdk = ">=1.36.0,<1.37.0"
typing-extensions = ">=4.6.0"
[[package]]
name = "opentelemetry-exporter-otlp-proto-http"
version = "1.36.0"
description = "OpenTelemetry Collector Protobuf over HTTP Exporter"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_exporter_otlp_proto_http-1.36.0-py3-none-any.whl", hash = "sha256:3d769f68e2267e7abe4527f70deb6f598f40be3ea34c6adc35789bea94a32902"},
{file = "opentelemetry_exporter_otlp_proto_http-1.36.0.tar.gz", hash = "sha256:dd3637f72f774b9fc9608ab1ac479f8b44d09b6fb5b2f3df68a24ad1da7d356e"},
]
[package.dependencies]
googleapis-common-protos = ">=1.52,<2.0"
opentelemetry-api = ">=1.15,<2.0"
opentelemetry-exporter-otlp-proto-common = "1.36.0"
opentelemetry-proto = "1.36.0"
opentelemetry-sdk = ">=1.36.0,<1.37.0"
requests = ">=2.7,<3.0"
typing-extensions = ">=4.5.0"
[[package]]
name = "opentelemetry-instrumentation"
version = "0.57b0"
description = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_instrumentation-0.57b0-py3-none-any.whl", hash = "sha256:9109280f44882e07cec2850db28210b90600ae9110b42824d196de357cbddf7e"},
{file = "opentelemetry_instrumentation-0.57b0.tar.gz", hash = "sha256:f2a30135ba77cdea2b0e1df272f4163c154e978f57214795d72f40befd4fcf05"},
]
[package.dependencies]
opentelemetry-api = ">=1.4,<2.0"
opentelemetry-semantic-conventions = "0.57b0"
packaging = ">=18.0"
wrapt = ">=1.0.0,<2.0.0"
[[package]]
name = "opentelemetry-instrumentation-threading"
version = "0.57b0"
description = "Thread context propagation support for OpenTelemetry"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_instrumentation_threading-0.57b0-py3-none-any.whl", hash = "sha256:adfd64857c8c78d6111cf80552311e1713bad64272dd81abdd61f07b892a161b"},
{file = "opentelemetry_instrumentation_threading-0.57b0.tar.gz", hash = "sha256:06fa4c98d6bfe4670e7532497670ac202db42afa647ff770aedce0e422421c6e"},
]
[package.dependencies]
opentelemetry-api = ">=1.12,<2.0"
opentelemetry-instrumentation = "0.57b0"
wrapt = ">=1.0.0,<2.0.0"
[[package]]
name = "opentelemetry-proto"
version = "1.36.0"
@@ -6177,115 +6036,6 @@ files = [
opentelemetry-api = "1.36.0"
typing-extensions = ">=4.5.0"
[[package]]
name = "opentelemetry-semantic-conventions-ai"
version = "0.4.13"
description = "OpenTelemetry Semantic Conventions Extension for Large Language Models"
optional = false
python-versions = "<4,>=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_semantic_conventions_ai-0.4.13-py3-none-any.whl", hash = "sha256:883a30a6bb5deaec0d646912b5f9f6dcbb9f6f72557b73d0f2560bf25d13e2d5"},
{file = "opentelemetry_semantic_conventions_ai-0.4.13.tar.gz", hash = "sha256:94efa9fb4ffac18c45f54a3a338ffeb7eedb7e1bb4d147786e77202e159f0036"},
]
[[package]]
name = "orjson"
version = "3.11.4"
description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "orjson-3.11.4-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e3aa2118a3ece0d25489cbe48498de8a5d580e42e8d9979f65bf47900a15aba1"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a69ab657a4e6733133a3dca82768f2f8b884043714e8d2b9ba9f52b6efef5c44"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3740bffd9816fc0326ddc406098a3a8f387e42223f5f455f2a02a9f834ead80c"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65fd2f5730b1bf7f350c6dc896173d3460d235c4be007af73986d7cd9a2acd23"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fdc3ae730541086158d549c97852e2eea6820665d4faf0f41bf99df41bc11ea"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e10b4d65901da88845516ce9f7f9736f9638d19a1d483b3883dc0182e6e5edba"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb6a03a678085f64b97f9d4a9ae69376ce91a3a9e9b56a82b1580d8e1d501aff"},
{file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2c82e4f0b1c712477317434761fbc28b044c838b6b1240d895607441412371ac"},
{file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:d58c166a18f44cc9e2bad03a327dc2d1a3d2e85b847133cfbafd6bfc6719bd79"},
{file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:94f206766bf1ea30e1382e4890f763bd1eefddc580e08fec1ccdc20ddd95c827"},
{file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:41bf25fb39a34cf8edb4398818523277ee7096689db352036a9e8437f2f3ee6b"},
{file = "orjson-3.11.4-cp310-cp310-win32.whl", hash = "sha256:fa9627eba4e82f99ca6d29bc967f09aba446ee2b5a1ea728949ede73d313f5d3"},
{file = "orjson-3.11.4-cp310-cp310-win_amd64.whl", hash = "sha256:23ef7abc7fca96632d8174ac115e668c1e931b8fe4dde586e92a500bf1914dcc"},
{file = "orjson-3.11.4-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:5e59d23cd93ada23ec59a96f215139753fbfe3a4d989549bcb390f8c00370b39"},
{file = "orjson-3.11.4-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:5c3aedecfc1beb988c27c79d52ebefab93b6c3921dbec361167e6559aba2d36d"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da9e5301f1c2caa2a9a4a303480d79c9ad73560b2e7761de742ab39fe59d9175"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8873812c164a90a79f65368f8f96817e59e35d0cc02786a5356f0e2abed78040"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5d7feb0741ebb15204e748f26c9638e6665a5fa93c37a2c73d64f1669b0ddc63"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ee5487fefee21e6910da4c2ee9eef005bee568a0879834df86f888d2ffbdd9"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d40d46f348c0321df01507f92b95a377240c4ec31985225a6668f10e2676f9a"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95713e5fc8af84d8edc75b785d2386f653b63d62b16d681687746734b4dfc0be"},
{file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ad73ede24f9083614d6c4ca9a85fe70e33be7bf047ec586ee2363bc7418fe4d7"},
{file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:842289889de515421f3f224ef9c1f1efb199a32d76d8d2ca2706fa8afe749549"},
{file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3b2427ed5791619851c52a1261b45c233930977e7de8cf36de05636c708fa905"},
{file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3c36e524af1d29982e9b190573677ea02781456b2e537d5840e4538a5ec41907"},
{file = "orjson-3.11.4-cp311-cp311-win32.whl", hash = "sha256:87255b88756eab4a68ec61837ca754e5d10fa8bc47dc57f75cedfeaec358d54c"},
{file = "orjson-3.11.4-cp311-cp311-win_amd64.whl", hash = "sha256:e2d5d5d798aba9a0e1fede8d853fa899ce2cb930ec0857365f700dffc2c7af6a"},
{file = "orjson-3.11.4-cp311-cp311-win_arm64.whl", hash = "sha256:6bb6bb41b14c95d4f2702bce9975fda4516f1db48e500102fc4d8119032ff045"},
{file = "orjson-3.11.4-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:d4371de39319d05d3f482f372720b841c841b52f5385bd99c61ed69d55d9ab50"},
{file = "orjson-3.11.4-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e41fd3b3cac850eaae78232f37325ed7d7436e11c471246b87b2cd294ec94853"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600e0e9ca042878c7fdf189cf1b028fe2c1418cc9195f6cb9824eb6ed99cb938"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7bbf9b333f1568ef5da42bc96e18bf30fd7f8d54e9ae066d711056add508e415"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4806363144bb6e7297b8e95870e78d30a649fdc4e23fc84daa80c8ebd366ce44"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad355e8308493f527d41154e9053b86a5be892b3b359a5c6d5d95cda23601cb2"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a7517482667fb9f0ff1b2f16fe5829296ed7a655d04d68cd9711a4d8a4e708"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97eb5942c7395a171cbfecc4ef6701fc3c403e762194683772df4c54cfbb2210"},
{file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:149d95d5e018bdd822e3f38c103b1a7c91f88d38a88aada5c4e9b3a73a244241"},
{file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:624f3951181eb46fc47dea3d221554e98784c823e7069edb5dbd0dc826ac909b"},
{file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:03bfa548cf35e3f8b3a96c4e8e41f753c686ff3d8e182ce275b1751deddab58c"},
{file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:525021896afef44a68148f6ed8a8bf8375553d6066c7f48537657f64823565b9"},
{file = "orjson-3.11.4-cp312-cp312-win32.whl", hash = "sha256:b58430396687ce0f7d9eeb3dd47761ca7d8fda8e9eb92b3077a7a353a75efefa"},
{file = "orjson-3.11.4-cp312-cp312-win_amd64.whl", hash = "sha256:c6dbf422894e1e3c80a177133c0dda260f81428f9de16d61041949f6a2e5c140"},
{file = "orjson-3.11.4-cp312-cp312-win_arm64.whl", hash = "sha256:d38d2bc06d6415852224fcc9c0bfa834c25431e466dc319f0edd56cca81aa96e"},
{file = "orjson-3.11.4-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:2d6737d0e616a6e053c8b4acc9eccea6b6cce078533666f32d140e4f85002534"},
{file = "orjson-3.11.4-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:afb14052690aa328cc118a8e09f07c651d301a72e44920b887c519b313d892ff"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38aa9e65c591febb1b0aed8da4d469eba239d434c218562df179885c94e1a3ad"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f2cf4dfaf9163b0728d061bebc1e08631875c51cd30bf47cb9e3293bfbd7dcd5"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89216ff3dfdde0e4070932e126320a1752c9d9a758d6a32ec54b3b9334991a6a"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9daa26ca8e97fae0ce8aa5d80606ef8f7914e9b129b6b5df9104266f764ce436"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c8b2769dc31883c44a9cd126560327767f848eb95f99c36c9932f51090bfce9"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1469d254b9884f984026bd9b0fa5bbab477a4bfe558bba6848086f6d43eb5e73"},
{file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:68e44722541983614e37117209a194e8c3ad07838ccb3127d96863c95ec7f1e0"},
{file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8e7805fda9672c12be2f22ae124dcd7b03928d6c197544fe12174b86553f3196"},
{file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:04b69c14615fb4434ab867bf6f38b2d649f6f300af30a6705397e895f7aec67a"},
{file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:639c3735b8ae7f970066930e58cf0ed39a852d417c24acd4a25fc0b3da3c39a6"},
{file = "orjson-3.11.4-cp313-cp313-win32.whl", hash = "sha256:6c13879c0d2964335491463302a6ca5ad98105fc5db3565499dcb80b1b4bd839"},
{file = "orjson-3.11.4-cp313-cp313-win_amd64.whl", hash = "sha256:09bf242a4af98732db9f9a1ec57ca2604848e16f132e3f72edfd3c5c96de009a"},
{file = "orjson-3.11.4-cp313-cp313-win_arm64.whl", hash = "sha256:a85f0adf63319d6c1ba06fb0dbf997fced64a01179cf17939a6caca662bf92de"},
{file = "orjson-3.11.4-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:42d43a1f552be1a112af0b21c10a5f553983c2a0938d2bbb8ecd8bc9fb572803"},
{file = "orjson-3.11.4-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:26a20f3fbc6c7ff2cb8e89c4c5897762c9d88cf37330c6a117312365d6781d54"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e3f20be9048941c7ffa8fc523ccbd17f82e24df1549d1d1fe9317712d19938e"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aac364c758dc87a52e68e349924d7e4ded348dedff553889e4d9f22f74785316"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d5c54a6d76e3d741dcc3f2707f8eeb9ba2a791d3adbf18f900219b62942803b1"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f28485bdca8617b79d44627f5fb04336897041dfd9fa66d383a49d09d86798bc"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bfc2a484cad3585e4ba61985a6062a4c2ed5c7925db6d39f1fa267c9d166487f"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e34dbd508cb91c54f9c9788923daca129fe5b55c5b4eebe713bf5ed3791280cf"},
{file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b13c478fa413d4b4ee606ec8e11c3b2e52683a640b006bb586b3041c2ca5f606"},
{file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:724ca721ecc8a831b319dcd72cfa370cc380db0bf94537f08f7edd0a7d4e1780"},
{file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:977c393f2e44845ce1b540e19a786e9643221b3323dae190668a98672d43fb23"},
{file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e539e382cf46edec157ad66b0b0872a90d829a6b71f17cb633d6c160a223155"},
{file = "orjson-3.11.4-cp314-cp314-win32.whl", hash = "sha256:d63076d625babab9db5e7836118bdfa086e60f37d8a174194ae720161eb12394"},
{file = "orjson-3.11.4-cp314-cp314-win_amd64.whl", hash = "sha256:0a54d6635fa3aaa438ae32e8570b9f0de36f3f6562c308d2a2a452e8b0592db1"},
{file = "orjson-3.11.4-cp314-cp314-win_arm64.whl", hash = "sha256:78b999999039db3cf58f6d230f524f04f75f129ba3d1ca2ed121f8657e575d3d"},
{file = "orjson-3.11.4-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:405261b0a8c62bcbd8e2931c26fdc08714faf7025f45531541e2b29e544b545b"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:af02ff34059ee9199a3546f123a6ab4c86caf1708c79042caf0820dc290a6d4f"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0b2eba969ea4203c177c7b38b36c69519e6067ee68c34dc37081fac74c796e10"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0baa0ea43cfa5b008a28d3c07705cf3ada40e5d347f0f44994a64b1b7b4b5350"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80fd082f5dcc0e94657c144f1b2a3a6479c44ad50be216cf0c244e567f5eae19"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e3704d35e47d5bee811fb1cbd8599f0b4009b14d451c4c57be5a7e25eb89a13"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:caa447f2b5356779d914658519c874cf3b7629e99e63391ed519c28c8aea4919"},
{file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:bba5118143373a86f91dadb8df41d9457498226698ebdf8e11cbb54d5b0e802d"},
{file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:622463ab81d19ef3e06868b576551587de8e4d518892d1afab71e0fbc1f9cffc"},
{file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3e0a700c4b82144b72946b6629968df9762552ee1344bfdb767fecdd634fbd5a"},
{file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6e18a5c15e764e5f3fc569b47872450b4bcea24f2a6354c0a0e95ad21045d5a9"},
{file = "orjson-3.11.4-cp39-cp39-win32.whl", hash = "sha256:fb1c37c71cad991ef4d89c7a634b5ffb4447dbd7ae3ae13e8f5ee7f1775e7ab1"},
{file = "orjson-3.11.4-cp39-cp39-win_amd64.whl", hash = "sha256:e2985ce8b8c42d00492d0ed79f2bd2b6460d00f2fa671dfde4bf2e02f49bf5c6"},
{file = "orjson-3.11.4.tar.gz", hash = "sha256:39485f4ab4c9b30a3943cfe99e1a213c4776fb69e8abd68f66b83d5a0b0fdc6d"},
]
[[package]]
name = "packaging"
version = "25.0"
@@ -6502,21 +6252,6 @@ files = [
[package.dependencies]
ptyprocess = ">=0.5"
[[package]]
name = "pfzy"
version = "0.3.4"
description = "Python port of the fzy fuzzy string matching algorithm"
optional = false
python-versions = ">=3.7,<4.0"
groups = ["main"]
files = [
{file = "pfzy-0.3.4-py3-none-any.whl", hash = "sha256:5f50d5b2b3207fa72e7ec0ef08372ef652685470974a107d0d4999fc5a903a96"},
{file = "pfzy-0.3.4.tar.gz", hash = "sha256:717ea765dd10b63618e7298b2d98efd819e0b30cd5905c9707223dceeb94b3f1"},
]
[package.extras]
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
[[package]]
name = "pg8000"
version = "1.31.5"

View File

@@ -50,7 +50,7 @@ SUBSCRIPTION_PRICE_DATA = {
},
}
DEFAULT_INITIAL_BUDGET = float(os.environ.get('DEFAULT_INITIAL_BUDGET', '10'))
DEFAULT_INITIAL_BUDGET = float(os.environ.get('DEFAULT_INITIAL_BUDGET', '20'))
STRIPE_API_KEY = os.environ.get('STRIPE_API_KEY', None)
STRIPE_WEBHOOK_SECRET = os.environ.get('STRIPE_WEBHOOK_SECRET', None)
REQUIRE_PAYMENT = os.environ.get('REQUIRE_PAYMENT', '0') in ('1', 'true')

View File

@@ -35,7 +35,6 @@ class SaasConversationStore(ConversationStore):
session.query(StoredConversationMetadata)
.filter(StoredConversationMetadata.user_id == self.user_id)
.filter(StoredConversationMetadata.conversation_id == conversation_id)
.filter(StoredConversationMetadata.conversation_version == 'V0')
)
def _to_external_model(self, conversation_metadata: StoredConversationMetadata):
@@ -124,7 +123,6 @@ class SaasConversationStore(ConversationStore):
conversations = (
session.query(StoredConversationMetadata)
.filter(StoredConversationMetadata.user_id == self.user_id)
.filter(StoredConversationMetadata.conversation_version == 'V0')
.order_by(StoredConversationMetadata.created_at.desc())
.offset(offset)
.limit(limit + 1)

View File

@@ -243,7 +243,7 @@ async def test_update_settings_with_litellm_default(
# Check that the URL and most of the JSON payload match what we expect
assert call_args['json']['user_email'] == 'testy@tester.com'
assert call_args['json']['models'] == []
assert call_args['json']['max_budget'] == 10.0
assert call_args['json']['max_budget'] == 20.0
assert call_args['json']['user_id'] == 'user-id'
assert call_args['json']['teams'] == ['test_team']
assert call_args['json']['auto_create_key'] is True

View File

@@ -268,7 +268,7 @@ describe("useWebSocket", () => {
});
// onError handler should have been called
expect(onErrorSpy).toHaveBeenCalled();
expect(onErrorSpy).toHaveBeenCalledOnce();
});
it("should provide sendMessage function to send messages to WebSocket", async () => {

View File

@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.61.0",
"version": "0.60.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.61.0",
"version": "0.60.0",
"dependencies": {
"@heroui/react": "^2.8.4",
"@heroui/use-infinite-scroll": "^2.2.11",

View File

@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.61.0",
"version": "0.60.0",
"private": true,
"type": "module",
"engines": {

View File

@@ -11,6 +11,7 @@ import type {
V1AppConversationStartTask,
V1AppConversationStartTaskPage,
V1AppConversation,
V1SandboxInfo,
} from "./v1-conversation-service.types";
class V1ConversationService {
@@ -212,6 +213,36 @@ class V1ConversationService {
return data;
}
/**
* Pause a V1 sandbox
* Calls the /api/v1/sandboxes/{id}/pause endpoint
*
* @param sandboxId The sandbox ID to pause
* @returns Success response
*/
static async pauseSandbox(sandboxId: string): Promise<{ success: boolean }> {
const { data } = await openHands.post<{ success: boolean }>(
`/api/v1/sandboxes/${sandboxId}/pause`,
{},
);
return data;
}
/**
* Resume a V1 sandbox
* Calls the /api/v1/sandboxes/{id}/resume endpoint
*
* @param sandboxId The sandbox ID to resume
* @returns Success response
*/
static async resumeSandbox(sandboxId: string): Promise<{ success: boolean }> {
const { data } = await openHands.post<{ success: boolean }>(
`/api/v1/sandboxes/${sandboxId}/resume`,
{},
);
return data;
}
/**
* Batch get V1 app conversations by their IDs
* Returns null for any missing conversations
@@ -238,6 +269,32 @@ class V1ConversationService {
return data;
}
/**
* Batch get V1 sandboxes by their IDs
* Returns null for any missing sandboxes
*
* @param ids Array of sandbox IDs (max 100)
* @returns Array of sandboxes or null for missing ones
*/
static async batchGetSandboxes(
ids: string[],
): Promise<(V1SandboxInfo | null)[]> {
if (ids.length === 0) {
return [];
}
if (ids.length > 100) {
throw new Error("Cannot request more than 100 sandboxes at once");
}
const params = new URLSearchParams();
ids.forEach((id) => params.append("id", id));
const { data } = await openHands.get<(V1SandboxInfo | null)[]>(
`/api/v1/sandboxes?${params.toString()}`,
);
return data;
}
/**
* Upload a single file to the V1 conversation workspace
* V1 API endpoint: POST /api/file/upload/{path}
@@ -288,6 +345,24 @@ class V1ConversationService {
const { data } = await openHands.get<{ runtime_id: string }>(url);
return data;
}
/**
* Get the count of events for a conversation
* Uses the V1 API endpoint: GET /api/v1/events/count
*
* @param conversationId The conversation ID to get event count for
* @returns The number of events in the conversation
*/
static async getEventCount(conversationId: string): Promise<number> {
const params = new URLSearchParams();
params.append("conversation_id__eq", conversationId);
const { data } = await openHands.get<number>(
`/api/v1/events/count?${params.toString()}`,
);
return data;
}
}
export default V1ConversationService;

View File

@@ -1,6 +1,5 @@
import { ConversationTrigger } from "../open-hands.types";
import { Provider } from "#/types/settings";
import { V1SandboxStatus } from "../sandbox-service/sandbox-service.types";
// V1 API Types for requests
// Note: This represents the serialized API format, not the internal TextContent/ImageContent types
@@ -65,7 +64,14 @@ export interface V1AppConversationStartTaskPage {
next_page_id: string | null;
}
export type V1ConversationExecutionStatus =
export type V1SandboxStatus =
| "MISSING"
| "STARTING"
| "RUNNING"
| "STOPPED"
| "PAUSED";
export type V1AgentExecutionStatus =
| "RUNNING"
| "AWAITING_USER_INPUT"
| "AWAITING_USER_CONFIRMATION"
@@ -88,7 +94,22 @@ export interface V1AppConversation {
created_at: string;
updated_at: string;
sandbox_status: V1SandboxStatus;
execution_status: V1ConversationExecutionStatus | null;
agent_status: V1AgentExecutionStatus | null;
conversation_url: string | null;
session_api_key: string | null;
}
export interface V1ExposedUrl {
name: string;
url: string;
}
export interface V1SandboxInfo {
id: string;
created_by_user_id: string | null;
sandbox_spec_id: string;
status: V1SandboxStatus;
session_api_key: string | null;
exposed_urls: V1ExposedUrl[] | null;
created_at: string;
}

View File

@@ -5,7 +5,6 @@ import type {
ConfirmationResponseRequest,
ConfirmationResponseResponse,
} from "./event-service.types";
import { openHands } from "../open-hands-axios";
class EventService {
/**
@@ -37,14 +36,6 @@ class EventService {
return data;
}
static async getEventCount(conversationId: string): Promise<number> {
const params = new URLSearchParams();
params.append("conversation_id__eq", conversationId);
const { data } = await openHands.get<number>(
`/api/v1/events/count?${params.toString()}`,
);
return data;
}
}
export default EventService;

View File

@@ -1,52 +0,0 @@
// sandbox-service.api.ts
// This file contains API methods for /api/v1/sandboxes endpoints.
import { openHands } from "../open-hands-axios";
import type { V1SandboxInfo } from "./sandbox-service.types";
export class SandboxService {
/**
* Pause a V1 sandbox
* Calls the /api/v1/sandboxes/{id}/pause endpoint
*/
static async pauseSandbox(sandboxId: string): Promise<{ success: boolean }> {
const { data } = await openHands.post<{ success: boolean }>(
`/api/v1/sandboxes/${sandboxId}/pause`,
{},
);
return data;
}
/**
* Resume a V1 sandbox
* Calls the /api/v1/sandboxes/{id}/resume endpoint
*/
static async resumeSandbox(sandboxId: string): Promise<{ success: boolean }> {
const { data } = await openHands.post<{ success: boolean }>(
`/api/v1/sandboxes/${sandboxId}/resume`,
{},
);
return data;
}
/**
* Batch get V1 sandboxes by their IDs
* Returns null for any missing sandboxes
*/
static async batchGetSandboxes(
ids: string[],
): Promise<(V1SandboxInfo | null)[]> {
if (ids.length === 0) {
return [];
}
if (ids.length > 100) {
throw new Error("Cannot request more than 100 sandboxes at once");
}
const params = new URLSearchParams();
ids.forEach((id) => params.append("id", id));
const { data } = await openHands.get<(V1SandboxInfo | null)[]>(
`/api/v1/sandboxes?${params.toString()}`,
);
return data;
}
}

View File

@@ -1,24 +0,0 @@
// sandbox-service.types.ts
// This file contains types for Sandbox API.
export type V1SandboxStatus =
| "MISSING"
| "STARTING"
| "RUNNING"
| "STOPPED"
| "PAUSED";
export interface V1ExposedUrl {
name: string;
url: string;
}
export interface V1SandboxInfo {
id: string;
created_by_user_id: string | null;
sandbox_spec_id: string;
status: V1SandboxStatus;
session_api_key: string | null;
exposed_urls: V1ExposedUrl[] | null;
created_at: string;
}

View File

@@ -97,29 +97,24 @@ export function ChatInterface() {
const isV1Conversation = conversation?.conversation_version === "V1";
// Track when we should show V1 messages (after DOM has rendered)
const [showV1Messages, setShowV1Messages] = React.useState(false);
const prevV1LoadingRef = React.useRef(
// Instantly scroll to bottom when history loading completes
const prevLoadingHistoryRef = React.useRef(
conversationWebSocket?.isLoadingHistory,
);
// Wait for DOM to render before showing V1 messages
React.useEffect(() => {
const wasLoading = prevV1LoadingRef.current;
const wasLoading = prevLoadingHistoryRef.current;
const isLoading = conversationWebSocket?.isLoadingHistory;
if (wasLoading && !isLoading) {
// Loading just finished - wait for next frame to ensure DOM is ready
requestAnimationFrame(() => {
setShowV1Messages(true);
// When history loading transitions from true to false, instantly scroll to bottom
if (wasLoading && !isLoading && scrollRef.current) {
scrollRef.current.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "instant",
});
} else if (isLoading) {
// Reset when loading starts
setShowV1Messages(false);
}
prevV1LoadingRef.current = isLoading;
}, [conversationWebSocket?.isLoadingHistory]);
prevLoadingHistoryRef.current = isLoading;
}, [conversationWebSocket?.isLoadingHistory, scrollRef]);
// Filter V0 events
const v0Events = storeEvents
@@ -257,7 +252,7 @@ export function ChatInterface() {
</div>
)}
{(conversationWebSocket?.isLoadingHistory || !showV1Messages) &&
{conversationWebSocket?.isLoadingHistory &&
isV1Conversation &&
!isTask && (
<div className="flex justify-center">
@@ -274,7 +269,7 @@ export function ChatInterface() {
/>
)}
{showV1Messages && v1UserEventsExist && (
{!conversationWebSocket?.isLoadingHistory && v1UserEventsExist && (
<V1Messages messages={v1UiEvents} allEvents={v1FullEvents} />
)}
</div>

View File

@@ -56,8 +56,7 @@ export function AgentStatus({
const shouldShownAgentStop = curAgentState === AgentState.RUNNING;
const shouldShownAgentResume =
curAgentState === AgentState.STOPPED || curAgentState === AgentState.PAUSED;
const shouldShownAgentResume = curAgentState === AgentState.STOPPED;
// Update global state when agent loading condition changes
useEffect(() => {

View File

@@ -5,16 +5,12 @@ type ConversationTabNavProps = {
icon: ComponentType<{ className: string }>;
onClick(): void;
isActive?: boolean;
label?: string;
className?: string;
};
export function ConversationTabNav({
icon: Icon,
onClick,
isActive,
label,
className,
}: ConversationTabNavProps) {
return (
<button
@@ -23,21 +19,18 @@ export function ConversationTabNav({
onClick();
}}
className={cn(
"flex items-center gap-2 rounded-md cursor-pointer",
"pl-1.5 pr-2 py-1",
"p-1 rounded-md cursor-pointer",
"text-[#9299AA] bg-[#0D0F11]",
isActive && "bg-[#25272D] text-white",
isActive
? "hover:text-white hover:bg-tertiary"
: "hover:text-white hover:bg-[#0D0F11]",
isActive ? "focus-within:text-white" : "focus-within:text-[#9299AA]",
className,
isActive
? "focus-within:text-white focus-within:bg-tertiary"
: "focus-within:text-[#9299AA] focus-within:bg-[#0D0F11]",
)}
>
<Icon className={cn("w-5 h-5 text-inherit flex-shrink-0")} />
{isActive && label && (
<span className="text-sm font-medium whitespace-nowrap">{label}</span>
)}
<Icon className={cn("w-5 h-5 text-inherit")} />
</button>
);
}

View File

@@ -92,7 +92,6 @@ export function ConversationTabs() {
onClick: () => onTabSelected("editor"),
tooltipContent: t(I18nKey.COMMON$CHANGES),
tooltipAriaLabel: t(I18nKey.COMMON$CHANGES),
label: t(I18nKey.COMMON$CHANGES),
},
{
isActive: isTabActive("vscode"),
@@ -100,7 +99,6 @@ export function ConversationTabs() {
onClick: () => onTabSelected("vscode"),
tooltipContent: <VSCodeTooltipContent />,
tooltipAriaLabel: t(I18nKey.COMMON$CODE),
label: t(I18nKey.COMMON$CODE),
},
{
isActive: isTabActive("terminal"),
@@ -108,8 +106,6 @@ export function ConversationTabs() {
onClick: () => onTabSelected("terminal"),
tooltipContent: t(I18nKey.COMMON$TERMINAL),
tooltipAriaLabel: t(I18nKey.COMMON$TERMINAL),
label: t(I18nKey.COMMON$TERMINAL),
className: "pl-2",
},
{
isActive: isTabActive("served"),
@@ -117,7 +113,6 @@ export function ConversationTabs() {
onClick: () => onTabSelected("served"),
tooltipContent: t(I18nKey.COMMON$APP),
tooltipAriaLabel: t(I18nKey.COMMON$APP),
label: t(I18nKey.COMMON$APP),
},
{
isActive: isTabActive("browser"),
@@ -125,7 +120,6 @@ export function ConversationTabs() {
onClick: () => onTabSelected("browser"),
tooltipContent: t(I18nKey.COMMON$BROWSER),
tooltipAriaLabel: t(I18nKey.COMMON$BROWSER),
label: t(I18nKey.COMMON$BROWSER),
},
];
@@ -138,15 +132,7 @@ export function ConversationTabs() {
>
{tabs.map(
(
{
icon,
onClick,
isActive,
tooltipContent,
tooltipAriaLabel,
label,
className,
},
{ icon, onClick, isActive, tooltipContent, tooltipAriaLabel },
index,
) => (
<ChatActionTooltip
@@ -158,8 +144,6 @@ export function ConversationTabs() {
icon={icon}
onClick={onClick}
isActive={isActive}
label={label}
className={className}
/>
</ChatActionTooltip>
),

View File

@@ -131,7 +131,7 @@ export function RepositorySelectionForm({
onBranchSelect={handleBranchSelection}
defaultBranch={defaultBranch}
placeholder="Select branch..."
className="max-w-full"
className="max-w-[500px]"
disabled={!selectedRepository || isLoadingSettings}
/>
);

View File

@@ -28,7 +28,7 @@ import {
import { handleActionEventCacheInvalidation } from "#/utils/cache-utils";
import { buildWebSocketUrl } from "#/utils/websocket-url";
import type { V1SendMessageRequest } from "#/api/conversation-service/v1-conversation-service.types";
import EventService from "#/api/event-service/event-service.api";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
// eslint-disable-next-line @typescript-eslint/naming-convention
export type V1_WebSocketConnectionState =
@@ -67,7 +67,7 @@ export function ConversationWebSocketProvider({
const { addEvent } = useEventStore();
const { setErrorMessage, removeErrorMessage } = useErrorMessageStore();
const { removeOptimisticUserMessage } = useOptimisticUserMessageStore();
const { setExecutionStatus } = useV1ConversationStateStore();
const { setAgentStatus } = useV1ConversationStateStore();
const { appendInput, appendOutput } = useCommandStore();
// History loading state
@@ -154,10 +154,10 @@ export function ConversationWebSocketProvider({
// TODO: Tests
if (isConversationStateUpdateEvent(event)) {
if (isFullStateConversationStateUpdateEvent(event)) {
setExecutionStatus(event.value.execution_status);
setAgentStatus(event.value.agent_status);
}
if (isAgentStatusConversationStateUpdateEvent(event)) {
setExecutionStatus(event.value);
setAgentStatus(event.value);
}
}
@@ -184,7 +184,7 @@ export function ConversationWebSocketProvider({
removeOptimisticUserMessage,
queryClient,
conversationId,
setExecutionStatus,
setAgentStatus,
appendInput,
appendOutput,
],
@@ -211,7 +211,8 @@ export function ConversationWebSocketProvider({
// Fetch expected event count for history loading detection
if (conversationId) {
try {
const count = await EventService.getEventCount(conversationId);
const count =
await V1ConversationService.getEventCount(conversationId);
setExpectedEventCount(count);
// If no events expected, mark as loaded immediately

View File

@@ -2,7 +2,6 @@ import { QueryClient } from "@tanstack/react-query";
import { Provider } from "#/types/settings";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { SandboxService } from "#/api/sandbox-service/sandbox-service.api";
/**
* Gets the conversation version from the cache
@@ -49,7 +48,7 @@ const fetchV1ConversationData = async (
*/
export const pauseV1ConversationSandbox = async (conversationId: string) => {
const { sandboxId } = await fetchV1ConversationData(conversationId);
return SandboxService.pauseSandbox(sandboxId);
return V1ConversationService.pauseSandbox(sandboxId);
};
/**
@@ -76,7 +75,7 @@ export const stopV0Conversation = async (conversationId: string) =>
*/
export const resumeV1ConversationSandbox = async (conversationId: string) => {
const { sandboxId } = await fetchV1ConversationData(conversationId);
return SandboxService.resumeSandbox(sandboxId);
return V1ConversationService.resumeSandbox(sandboxId);
};
/**

View File

@@ -1,10 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { SandboxService } from "#/api/sandbox-service/sandbox-service.api";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
export const useBatchSandboxes = (ids: string[]) =>
useQuery({
queryKey: ["sandboxes", "batch", ids],
queryFn: () => SandboxService.batchGetSandboxes(ids),
queryFn: () => V1ConversationService.batchGetSandboxes(ids),
enabled: ids.length > 0,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes

View File

@@ -3,30 +3,30 @@ import { useAgentStore } from "#/stores/agent-store";
import { useV1ConversationStateStore } from "#/stores/v1-conversation-state-store";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { AgentState } from "#/types/agent-state";
import { V1ExecutionStatus } from "#/types/v1/core/base/common";
import { V1AgentStatus } from "#/types/v1/core/base/common";
/**
* Maps V1 agent status to V0 AgentState
*/
function mapV1StatusToV0State(status: V1ExecutionStatus | null): AgentState {
function mapV1StatusToV0State(status: V1AgentStatus | null): AgentState {
if (!status) {
return AgentState.LOADING;
}
switch (status) {
case V1ExecutionStatus.IDLE:
case V1AgentStatus.IDLE:
return AgentState.AWAITING_USER_INPUT;
case V1ExecutionStatus.RUNNING:
case V1AgentStatus.RUNNING:
return AgentState.RUNNING;
case V1ExecutionStatus.PAUSED:
case V1AgentStatus.PAUSED:
return AgentState.PAUSED;
case V1ExecutionStatus.WAITING_FOR_CONFIRMATION:
case V1AgentStatus.WAITING_FOR_CONFIRMATION:
return AgentState.AWAITING_USER_CONFIRMATION;
case V1ExecutionStatus.FINISHED:
case V1AgentStatus.FINISHED:
return AgentState.FINISHED;
case V1ExecutionStatus.ERROR:
case V1AgentStatus.ERROR:
return AgentState.ERROR;
case V1ExecutionStatus.STUCK:
case V1AgentStatus.STUCK:
return AgentState.ERROR; // Map STUCK to ERROR for now
default:
return AgentState.LOADING;
@@ -41,9 +41,7 @@ function mapV1StatusToV0State(status: V1ExecutionStatus | null): AgentState {
export function useAgentState() {
const { data: conversation } = useActiveConversation();
const v0State = useAgentStore((state) => state.curAgentState);
const v1Status = useV1ConversationStateStore(
(state) => state.execution_status,
);
const v1Status = useV1ConversationStateStore((state) => state.agent_status);
const isV1Conversation = conversation?.conversation_version === "V1";

View File

@@ -1,10 +1,4 @@
import {
RefObject,
useState,
useCallback,
useRef,
useLayoutEffect,
} from "react";
import { RefObject, useEffect, useState, useCallback, useRef } from "react";
export function useScrollToBottom(scrollRef: RefObject<HTMLDivElement | null>) {
// Track whether we should auto-scroll to the bottom when content changes
@@ -71,20 +65,20 @@ export function useScrollToBottom(scrollRef: RefObject<HTMLDivElement | null>) {
}, [scrollRef]);
// Auto-scroll effect that runs when content changes
// Use useLayoutEffect to scroll after DOM updates but before paint
useLayoutEffect(() => {
useEffect(() => {
// Only auto-scroll if autoscroll is enabled
if (autoscroll) {
const dom = scrollRef.current;
if (dom) {
// Scroll to bottom - this will trigger on any DOM change
dom.scrollTo({
top: dom.scrollHeight,
behavior: "smooth",
requestAnimationFrame(() => {
dom.scrollTo({
top: dom.scrollHeight,
behavior: "smooth",
});
});
}
}
}); // No dependency array - runs after every render to follow new content
});
return {
scrollRef,

View File

@@ -1,13 +1,13 @@
import { create } from "zustand";
import { V1ExecutionStatus } from "#/types/v1/core/base/common";
import { V1AgentStatus } from "#/types/v1/core/base/common";
interface V1ConversationStateStore {
execution_status: V1ExecutionStatus | null;
agent_status: V1AgentStatus | null;
/**
* Set the agent status
*/
setExecutionStatus: (execution_status: V1ExecutionStatus) => void;
setAgentStatus: (agent_status: V1AgentStatus) => void;
/**
* Reset the store to initial state
@@ -17,11 +17,10 @@ interface V1ConversationStateStore {
export const useV1ConversationStateStore = create<V1ConversationStateStore>(
(set) => ({
execution_status: null,
agent_status: null,
setExecutionStatus: (execution_status: V1ExecutionStatus) =>
set({ execution_status }),
setAgentStatus: (agent_status: V1AgentStatus) => set({ agent_status }),
reset: () => set({ execution_status: null }),
reset: () => set({ agent_status: null }),
}),
);

View File

@@ -64,7 +64,7 @@ export enum SecurityRisk {
}
// Agent status
export enum V1ExecutionStatus {
export enum V1AgentStatus {
IDLE = "idle",
RUNNING = "running",
PAUSED = "paused",

View File

@@ -1,11 +1,11 @@
import { BaseEvent } from "../base/event";
import { V1ExecutionStatus } from "../base/common";
import { V1AgentStatus } from "../base/common";
/**
* Conversation state value types
*/
export interface ConversationState {
execution_status: V1ExecutionStatus;
agent_status: V1AgentStatus;
// Add other conversation state fields here as needed
}
@@ -19,12 +19,12 @@ interface ConversationStateUpdateEventBase extends BaseEvent {
* Unique key for this state update event.
* Can be "full_state" for full state snapshots or field names for partial updates.
*/
key: "full_state" | "execution_status"; // Extend with other keys as needed
key: "full_state" | "agent_status"; // Extend with other keys as needed
/**
* Conversation state updates
*/
value: ConversationState | V1ExecutionStatus;
value: ConversationState | V1AgentStatus;
}
// Narrowed interfaces for full state update event
@@ -37,8 +37,8 @@ export interface ConversationStateUpdateEventFullState
// Narrowed interface for agent status update event
export interface ConversationStateUpdateEventAgentStatus
extends ConversationStateUpdateEventBase {
key: "execution_status";
value: V1ExecutionStatus;
key: "agent_status";
value: V1AgentStatus;
}
// Conversation state update event - contains conversation state updates

View File

@@ -136,7 +136,7 @@ export const isFullStateConversationStateUpdateEvent = (
export const isAgentStatusConversationStateUpdateEvent = (
event: ConversationStateUpdateEvent,
): event is ConversationStateUpdateEventAgentStatus =>
event.key === "execution_status";
event.key === "agent_status";
// =============================================================================
// TEMPORARY COMPATIBILITY TYPE GUARDS

View File

@@ -19,4 +19,3 @@ export const ENABLE_TRAJECTORY_REPLAY = () =>
loadFeatureFlag("TRAJECTORY_REPLAY");
export const USE_V1_CONVERSATION_API = () =>
loadFeatureFlag("USE_V1_CONVERSATION_API");
export const USE_PLANNING_AGENT = () => loadFeatureFlag("USE_PLANNING_AGENT");

View File

@@ -0,0 +1,101 @@
# Ctrl+C Implementation for OpenHands CLI
## Overview
This implementation adds improved Ctrl+C handling to the OpenHands CLI where:
1. **First Ctrl+C**: Attempts graceful pause of the agent
2. **Second Ctrl+C** (within 3 seconds): Immediately kills the process
## Architecture
### Signal Handling (`signal_handler.py`)
**SignalHandler Class:**
- Tracks Ctrl+C presses with a 3-second timeout
- First press: calls graceful shutdown callback
- Second press: forces immediate exit with `os._exit(1)`
**ProcessSignalHandler Class:**
- Manages conversation runner processes
- Implements graceful shutdown by terminating the process
- Provides clean installation/uninstallation of signal handlers
### Process Management (`process_runner.py`)
**ProcessBasedConversationRunner Class:**
- Runs conversation in a separate process using `multiprocessing`
- Provides inter-process communication via queues
- Supports commands: process_message, get_status, toggle_confirmation_mode, resume
- Handles process lifecycle (start, stop, cleanup)
### Modified Components
**Pause Listener (`listeners/pause_listener.py`):**
- Removed Ctrl+C and Ctrl+D handling (now handled by signal handler)
- Only handles Ctrl+P for pause functionality
**Agent Chat (`agent_chat.py`):**
- Integrated ProcessSignalHandler for Ctrl+C management
- Updated to use ProcessBasedConversationRunner
- All commands (/new, /status, /confirm, /resume) work with process-based approach
- Proper cleanup in finally block
**Simple Main (`simple_main.py`):**
- Added basic SignalHandler installation for graceful shutdown
## Key Features
### Graceful Shutdown
- First Ctrl+C sends SIGTERM to conversation process
- Gives 2 seconds for graceful shutdown
- Shows appropriate user feedback
### Immediate Termination
- Second Ctrl+C within 3 seconds forces immediate exit
- Uses `os._exit(1)` to bypass Python cleanup
- Ensures agent stops immediately
### Process Communication
- Queue-based communication between main and conversation processes
- Status queries work across process boundaries
- Command handling preserved for all CLI features
### Error Handling
- Proper exception handling in both processes
- Cleanup of resources in finally blocks
- Fallback KeyboardInterrupt handlers
## Usage
The implementation is transparent to users:
- Press Ctrl+C once to pause the agent gracefully
- Press Ctrl+C again within 3 seconds to force immediate termination
- All existing CLI commands continue to work
## Testing
A test script `test_ctrl_c.py` is provided to verify the signal handling behavior:
```bash
uv run python test_ctrl_c.py
```
## Files Modified/Created
**New Files:**
- `openhands_cli/signal_handler.py` - Signal handling classes
- `openhands_cli/process_runner.py` - Process-based conversation runner
- `test_ctrl_c.py` - Test script for Ctrl+C behavior
**Modified Files:**
- `openhands_cli/listeners/pause_listener.py` - Removed Ctrl+C handling
- `openhands_cli/agent_chat.py` - Integrated new signal handling and process runner
- `openhands_cli/simple_main.py` - Added basic signal handler
## Dependencies
Uses standard Python libraries:
- `signal` - For signal handling
- `multiprocessing` - For separate process execution
- `queue` - For inter-process communication
- `threading` - For thread-safe signal counting
- `time` - For timeout management

View File

@@ -0,0 +1,88 @@
# Ctrl+C Handling Improvements
## Summary
Simplified the overly complex Ctrl+C handling implementation in the OpenHands CLI to make it more reliable and easier to understand.
## Problems Addressed
1. **Second Ctrl+C not registering properly** - The original implementation had complex queue-based communication that could miss signals
2. **Overly complex multiprocessing** - Many methods were unnecessarily wrapped in separate processes
3. **No reset of Ctrl+C count** - The count wasn't reset when starting new message processing
4. **Unnecessary queue communication** - Status and settings methods didn't need separate processes
## Solution
### 1. Simplified Signal Handler (`simple_signal_handler.py`)
- **Direct signal handling** in the main process instead of complex queue communication
- **Simple Ctrl+C counting** with immediate force kill on second press within 3 seconds
- **Clear process management** with direct process termination
- **Reset functionality** to clear count when starting new operations
Key features:
- First Ctrl+C: Graceful termination (SIGTERM)
- Second Ctrl+C (within 3 seconds): Force kill (SIGKILL)
- Automatic count reset after 3 seconds
- Manual count reset via `reset_count()`
### 2. Simplified Process Runner (`simple_process_runner.py`)
- **Minimal multiprocessing** - Only the `process_message` method runs in a subprocess
- **Direct method calls** for status, settings, and other operations
- **Simple API** with clear process lifecycle management
- **No queue communication** for methods that don't need it
Key features:
- `process_message()`: Runs in subprocess for isolation
- `get_status()`, `get_settings()`, etc.: Run directly in main process
- `cleanup()`: Simple process termination
- `current_process` property for signal handler integration
### 3. Updated Main CLI (`agent_chat.py`)
- **Simplified imports** using the new signal handler and process runner
- **Reset Ctrl+C count** when starting new message processing
- **Direct method calls** for commands that don't need process isolation
- **Cleaner error handling** and resource cleanup
## Files Modified
### New Files
- `openhands_cli/simple_signal_handler.py` - Simplified signal handling
- `openhands_cli/simple_process_runner.py` - Minimal process wrapper
### Modified Files
- `openhands_cli/agent_chat.py` - Updated to use simplified components
- `openhands_cli/simple_main.py` - Updated imports
### Test Files
- `test_basic_signal.py` - Basic signal handler test
- `manual_test_ctrl_c.py` - Manual Ctrl+C testing
## Key Improvements
1. **Reliability**: Direct signal handling eliminates race conditions
2. **Simplicity**: Removed complex queue-based communication
3. **Performance**: Most operations run directly in main process
4. **Maintainability**: Clear, simple code that's easy to understand
5. **User Experience**: Consistent Ctrl+C behavior with immediate force kill option
## Testing
The implementation includes test scripts to verify:
- Basic signal handler functionality
- Ctrl+C counting and reset behavior
- Process termination (graceful and force)
- Integration with the CLI
## Usage
The simplified implementation maintains the same external API:
- First Ctrl+C: Attempts graceful pause/termination
- Second Ctrl+C (within 3 seconds): Force kills the process immediately
- Count resets automatically or when starting new operations
## Migration
The changes are backward compatible with the existing CLI interface. The complex `ProcessSignalHandler` and `ProcessBasedConversationRunner` classes are replaced with simpler equivalents that provide the same functionality with better reliability.

View File

@@ -17,6 +17,8 @@ from prompt_toolkit import print_formatted_text
from prompt_toolkit.formatted_text import HTML
from openhands_cli.runner import ConversationRunner
from openhands_cli.simple_process_runner import SimpleProcessRunner
from openhands_cli.simple_signal_handler import SimpleSignalHandler
from openhands_cli.setup import (
MissingAgentSpec,
setup_conversation,
@@ -95,120 +97,144 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
# Track session start time for uptime calculation
session_start_time = datetime.now()
# Create conversation runner to handle state machine logic
runner = None
# Create simple signal handler and session
signal_handler = SimpleSignalHandler()
signal_handler.install()
session = get_session_prompter()
# Set up conversation
conversation = setup_conversation(conversation_id)
# Create simple process runner
process_runner = SimpleProcessRunner(conversation)
# Main chat loop
while True:
try:
# Get user input
user_input = session.prompt(
HTML('<gold>> </gold>'),
multiline=False,
)
try:
# Main chat loop
while True:
try:
# Get user input
user_input = session.prompt(
HTML('<gold>> </gold>'),
multiline=False,
)
if not user_input.strip():
continue
if not user_input.strip():
continue
# Handle commands
command = user_input.strip().lower()
# Handle commands
command = user_input.strip().lower()
message = Message(
role='user',
content=[TextContent(text=user_input)],
)
message = Message(
role='user',
content=[TextContent(text=user_input)],
)
if command == '/exit':
exit_confirmation = exit_session_confirmation()
if exit_confirmation == UserConfirmation.ACCEPT:
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
_print_exit_hint(conversation_id)
break
if command == '/exit':
exit_confirmation = exit_session_confirmation()
if exit_confirmation == UserConfirmation.ACCEPT:
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
_print_exit_hint(conversation_id)
break
elif command == '/settings':
settings_screen = SettingsScreen(runner.conversation if runner else None)
settings_screen.display_settings()
continue
elif command == '/settings':
# For process-based runner, we can't directly access the conversation
# TODO: Implement settings access through process communication if needed
settings_screen = SettingsScreen(None)
settings_screen.display_settings()
continue
elif command == '/mcp':
mcp_screen = MCPScreen()
mcp_screen.display_mcp_info(initialized_agent)
continue
elif command == '/mcp':
mcp_screen = MCPScreen()
mcp_screen.display_mcp_info(initialized_agent)
continue
elif command == '/clear':
display_welcome(conversation_id)
continue
elif command == '/clear':
display_welcome(conversation_id)
continue
elif command == '/new':
elif command == '/new':
try:
# Clean up existing process runner
if process_runner:
process_runner.cleanup()
# Create fresh conversation with new process runner
conversation_id = uuid.uuid4()
conversation = setup_conversation(conversation_id)
process_runner = SimpleProcessRunner(conversation)
display_welcome(conversation_id, resume=False)
print_formatted_text(
HTML('<green>✓ Started fresh conversation</green>')
)
continue
except Exception as e:
print_formatted_text(
HTML(f'<red>Error starting fresh conversation: {e}</red>')
)
continue
elif command == '/help':
display_help()
continue
elif command == '/status':
status = process_runner.get_status()
print_formatted_text(HTML(f'<yellow>Conversation ID:</yellow> {status["conversation_id"]}'))
print_formatted_text(HTML(f'<yellow>Agent State:</yellow> {status.get("agent_state", "Unknown")}'))
print_formatted_text(HTML(f'<yellow>Process Running:</yellow> {status["is_running"]}'))
continue
elif command == '/confirm':
result = process_runner.toggle_confirmation_mode()
mode_text = "Enabled" if result else "Disabled"
print_formatted_text(HTML(f'<yellow>Confirmation mode: {mode_text}</yellow>'))
continue
elif command == '/resume':
try:
process_runner.resume()
print_formatted_text(HTML('<green>Agent resumed</green>'))
except Exception as e:
print_formatted_text(HTML(f'<red>Failed to resume: {e}</red>'))
continue
# Reset Ctrl+C count when starting new message processing
signal_handler.reset_count()
# Process the message
try:
# Start a fresh conversation (no resume ID = new conversation)
conversation_id = uuid.uuid4()
runner = None
conversation = None
display_welcome(conversation_id, resume=False)
print_formatted_text(
HTML('<green>✓ Started fresh conversation</green>')
)
continue
# Set the current process for signal handling
signal_handler.set_process(process_runner.current_process)
# Create message object
message = Message(role='user', content=[TextContent(text=user_input)])
result = process_runner.process_message(message)
print() # Add spacing for successful processing
except Exception as e:
print_formatted_text(
HTML(f'<red>Error starting fresh conversation: {e}</red>')
)
continue
print_formatted_text(HTML(f'<red>Failed to process message: {e}</red>'))
finally:
# Clear the process reference
signal_handler.set_process(None)
elif command == '/help':
display_help()
except KeyboardInterrupt:
# KeyboardInterrupt should be handled by the signal handler now
# Just continue the loop - the signal handler manages the process
continue
except Exception as e:
print_formatted_text(HTML(f'<red>Error in chat loop: {e}</red>'))
continue
elif command == '/status':
display_status(conversation, session_start_time=session_start_time)
continue
except KeyboardInterrupt:
# Final fallback for KeyboardInterrupt - only exit if we're not in the main loop
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
_print_exit_hint(conversation_id)
elif command == '/confirm':
runner.toggle_confirmation_mode()
new_status = (
'enabled' if runner.is_confirmation_mode_active else 'disabled'
)
print_formatted_text(
HTML(f'<yellow>Confirmation mode {new_status}</yellow>')
)
continue
elif command == '/resume':
if not runner:
print_formatted_text(
HTML('<yellow>No active conversation running...</yellow>')
)
continue
conversation = runner.conversation
if not (
conversation.state.agent_status == AgentExecutionStatus.PAUSED
or conversation.state.agent_status
== AgentExecutionStatus.WAITING_FOR_CONFIRMATION
):
print_formatted_text(
HTML('<red>No paused conversation to resume...</red>')
)
continue
# Resume without new message
message = None
if not runner or not conversation:
conversation = setup_conversation(conversation_id)
runner = ConversationRunner(conversation)
runner.process_message(message)
print() # Add spacing
except KeyboardInterrupt:
exit_confirmation = exit_session_confirmation()
if exit_confirmation == UserConfirmation.ACCEPT:
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
_print_exit_hint(conversation_id)
break
# Clean up terminal state
_restore_tty()
finally:
# Clean up resources
if process_runner:
process_runner.cleanup()
signal_handler.uninstall()
# Clean up terminal state
_restore_tty()

View File

@@ -1,3 +1,4 @@
from openhands_cli.listeners.loading_listener import LoadingContext
from openhands_cli.listeners.pause_listener import PauseListener
__all__ = ['PauseListener']
__all__ = ['PauseListener', 'LoadingContext']

View File

@@ -0,0 +1,63 @@
"""
Loading animation utilities for OpenHands CLI.
Provides animated loading screens during agent initialization.
"""
import sys
import threading
import time
def display_initialization_animation(text: str, is_loaded: threading.Event) -> None:
"""Display a spinning animation while agent is being initialized.
Args:
text: The text to display alongside the animation
is_loaded: Threading event that signals when loading is complete
"""
ANIMATION_FRAMES = ['', '', '', '', '', '', '', '', '', '']
i = 0
while not is_loaded.is_set():
sys.stdout.write('\n')
sys.stdout.write(
f'\033[s\033[J\033[38;2;255;215;0m[{ANIMATION_FRAMES[i % len(ANIMATION_FRAMES)]}] {text}\033[0m\033[u\033[1A'
)
sys.stdout.flush()
time.sleep(0.1)
i += 1
sys.stdout.write('\r' + ' ' * (len(text) + 10) + '\r')
sys.stdout.flush()
class LoadingContext:
"""Context manager for displaying loading animations in a separate thread."""
def __init__(self, text: str):
"""Initialize the loading context.
Args:
text: The text to display during loading
"""
self.text = text
self.is_loaded = threading.Event()
self.loading_thread: threading.Thread | None = None
def __enter__(self) -> 'LoadingContext':
"""Start the loading animation in a separate thread."""
self.loading_thread = threading.Thread(
target=display_initialization_animation,
args=(self.text, self.is_loaded),
daemon=True,
)
self.loading_thread.start()
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
"""Stop the loading animation and clean up the thread."""
self.is_loaded.set()
if self.loading_thread:
self.loading_thread.join(
timeout=1.0
) # Wait up to 1 second for thread to finish

View File

@@ -31,8 +31,9 @@ class PauseListener(threading.Thread):
for key_press in self._input.read_keys():
pause_detected = pause_detected or key_press.key == Keys.ControlP
pause_detected = pause_detected or key_press.key == Keys.ControlC
pause_detected = pause_detected or key_press.key == Keys.ControlD
# Note: Ctrl+C and Ctrl+D are now handled by the signal handler
# pause_detected = pause_detected or key_press.key == Keys.ControlC
# pause_detected = pause_detected or key_press.key == Keys.ControlD
return pause_detected

View File

@@ -0,0 +1,314 @@
"""
Process-based conversation runner for handling agent execution in a separate process.
This allows for immediate termination of the agent when needed while maintaining
the ability to gracefully pause on the first Ctrl+C.
"""
import multiprocessing
import queue
import signal
import threading
import time
from enum import Enum
from typing import Any, Dict, Optional
from openhands.sdk import BaseConversation, Message
from openhands.sdk.conversation.state import AgentExecutionStatus
from prompt_toolkit import HTML, print_formatted_text
from openhands_cli.runner import ConversationRunner
class ProcessCommand(Enum):
"""Commands that can be sent to the conversation process."""
PROCESS_MESSAGE = "process_message"
PAUSE = "pause"
RESUME = "resume"
TOGGLE_CONFIRMATION = "toggle_confirmation"
GET_STATUS = "get_status"
SHUTDOWN = "shutdown"
class ProcessResponse(Enum):
"""Response types from the conversation process."""
SUCCESS = "success"
ERROR = "error"
STATUS = "status"
def conversation_worker(
conversation_id: str,
command_queue: multiprocessing.Queue,
response_queue: multiprocessing.Queue,
setup_conversation_func: Any, # Function to setup conversation
) -> None:
"""Worker function that runs in a separate process to handle conversation."""
# Set up signal handling in the worker process
def signal_handler(signum, frame):
print_formatted_text(HTML('<yellow>Conversation process received termination signal.</yellow>'))
return
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal.SIG_IGN) # Ignore SIGINT in worker process
try:
# Setup conversation in the worker process
conversation = setup_conversation_func(conversation_id)
runner = ConversationRunner(conversation)
response_queue.put({
"type": ProcessResponse.SUCCESS,
"message": "Conversation process initialized"
})
while True:
try:
# Check for commands with timeout
try:
command_data = command_queue.get(timeout=0.1)
except queue.Empty:
continue
command = command_data.get("command")
args = command_data.get("args", {})
if command == ProcessCommand.SHUTDOWN:
break
elif command == ProcessCommand.PROCESS_MESSAGE:
message = args.get("message")
try:
runner.process_message(message)
response_queue.put({
"type": ProcessResponse.SUCCESS,
"message": "Message processed"
})
except Exception as e:
response_queue.put({
"type": ProcessResponse.ERROR,
"message": f"Error processing message: {e}"
})
elif command == ProcessCommand.PAUSE:
try:
runner.conversation.pause()
response_queue.put({
"type": ProcessResponse.SUCCESS,
"message": "Conversation paused"
})
except Exception as e:
response_queue.put({
"type": ProcessResponse.ERROR,
"message": f"Error pausing conversation: {e}"
})
elif command == ProcessCommand.RESUME:
try:
runner.process_message(None) # Resume without new message
response_queue.put({
"type": ProcessResponse.SUCCESS,
"message": "Conversation resumed"
})
except Exception as e:
response_queue.put({
"type": ProcessResponse.ERROR,
"message": f"Error resuming conversation: {e}"
})
elif command == ProcessCommand.TOGGLE_CONFIRMATION:
try:
runner.toggle_confirmation_mode()
new_status = 'enabled' if runner.is_confirmation_mode_active else 'disabled'
response_queue.put({
"type": ProcessResponse.SUCCESS,
"message": f"Confirmation mode {new_status}"
})
except Exception as e:
response_queue.put({
"type": ProcessResponse.ERROR,
"message": f"Error toggling confirmation mode: {e}"
})
elif command == ProcessCommand.GET_STATUS:
try:
status = {
"agent_status": runner.conversation.state.agent_status,
"confirmation_mode": runner.is_confirmation_mode_active
}
response_queue.put({
"type": ProcessResponse.STATUS,
"data": status
})
except Exception as e:
response_queue.put({
"type": ProcessResponse.ERROR,
"message": f"Error getting status: {e}"
})
except Exception as e:
response_queue.put({
"type": ProcessResponse.ERROR,
"message": f"Unexpected error in conversation worker: {e}"
})
except Exception as e:
response_queue.put({
"type": ProcessResponse.ERROR,
"message": f"Failed to initialize conversation process: {e}"
})
class ProcessBasedConversationRunner:
"""Manages a conversation runner in a separate process."""
def __init__(self, conversation_id: str, setup_conversation_func: Any):
self.conversation_id = conversation_id
self.setup_conversation_func = setup_conversation_func
self.process: Optional[multiprocessing.Process] = None
self.command_queue: Optional[multiprocessing.Queue] = None
self.response_queue: Optional[multiprocessing.Queue] = None
self.is_running = False
def start(self) -> bool:
"""Start the conversation process."""
if self.is_running:
return True
try:
# Create queues for communication
self.command_queue = multiprocessing.Queue()
self.response_queue = multiprocessing.Queue()
# Start the worker process
self.process = multiprocessing.Process(
target=conversation_worker,
args=(
self.conversation_id,
self.command_queue,
self.response_queue,
self.setup_conversation_func
)
)
self.process.start()
# Wait for initialization confirmation
try:
response = self.response_queue.get(timeout=10.0)
if response["type"] == ProcessResponse.SUCCESS:
self.is_running = True
return True
else:
print_formatted_text(HTML(f'<red>Failed to initialize conversation process: {response.get("message", "Unknown error")}</red>'))
self.stop()
return False
except queue.Empty:
print_formatted_text(HTML('<red>Timeout waiting for conversation process to initialize</red>'))
self.stop()
return False
except Exception as e:
print_formatted_text(HTML(f'<red>Error starting conversation process: {e}</red>'))
return False
def stop(self) -> None:
"""Stop the conversation process."""
if not self.is_running:
return
try:
if self.command_queue:
self.command_queue.put({"command": ProcessCommand.SHUTDOWN})
if self.process:
self.process.join(timeout=2.0)
if self.process.is_alive():
self.process.terminate()
self.process.join(timeout=1.0)
if self.process.is_alive():
self.process.kill()
except Exception as e:
print_formatted_text(HTML(f'<yellow>Warning: Error stopping conversation process: {e}</yellow>'))
finally:
self.is_running = False
self.process = None
self.command_queue = None
self.response_queue = None
def send_command(self, command: ProcessCommand, args: Optional[Dict] = None, timeout: float = 5.0) -> Optional[Dict]:
"""Send a command to the conversation process and wait for response."""
if not self.is_running or not self.command_queue or not self.response_queue:
return None
try:
command_data = {"command": command, "args": args or {}}
self.command_queue.put(command_data)
response = self.response_queue.get(timeout=timeout)
return response
except queue.Empty:
print_formatted_text(HTML(f'<yellow>Timeout waiting for response to {command.value}</yellow>'))
return None
except Exception as e:
print_formatted_text(HTML(f'<red>Error sending command {command.value}: {e}</red>'))
return None
def process_message(self, message: Optional[Message]) -> bool:
"""Process a message through the conversation."""
response = self.send_command(ProcessCommand.PROCESS_MESSAGE, {"message": message})
if response and response["type"] == ProcessResponse.SUCCESS:
return True
elif response:
print_formatted_text(HTML(f'<red>{response.get("message", "Unknown error")}</red>'))
return False
def pause(self) -> bool:
"""Pause the conversation."""
response = self.send_command(ProcessCommand.PAUSE)
if response and response["type"] == ProcessResponse.SUCCESS:
return True
elif response:
print_formatted_text(HTML(f'<red>{response.get("message", "Unknown error")}</red>'))
return False
def resume(self) -> bool:
"""Resume the conversation."""
response = self.send_command(ProcessCommand.RESUME)
if response and response["type"] == ProcessResponse.SUCCESS:
return True
elif response:
print_formatted_text(HTML(f'<red>{response.get("message", "Unknown error")}</red>'))
return False
def toggle_confirmation_mode(self) -> Optional[str]:
"""Toggle confirmation mode and return the new status."""
response = self.send_command(ProcessCommand.TOGGLE_CONFIRMATION)
if response and response["type"] == ProcessResponse.SUCCESS:
return response.get("message")
elif response:
print_formatted_text(HTML(f'<red>{response.get("message", "Unknown error")}</red>'))
return None
def get_status(self) -> Optional[Dict]:
"""Get the current status of the conversation."""
response = self.send_command(ProcessCommand.GET_STATUS)
if response and response["type"] == ProcessResponse.STATUS:
return response.get("data")
elif response:
print_formatted_text(HTML(f'<red>{response.get("message", "Unknown error")}</red>'))
return None
def is_alive(self) -> bool:
"""Check if the conversation process is alive."""
return self.is_running and self.process and self.process.is_alive()
def force_terminate(self) -> None:
"""Force terminate the conversation process immediately."""
if self.process and self.process.is_alive():
self.process.kill()
self.process.join(timeout=1.0)
self.is_running = False

View File

@@ -6,6 +6,7 @@ from openhands.sdk import Agent, BaseConversation, Conversation, Workspace, regi
from openhands.tools.execute_bash import BashTool
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.task_tracker import TaskTrackerTool
from openhands_cli.listeners import LoadingContext
from openhands_cli.locations import CONVERSATIONS_DIR, WORK_DIR
from openhands_cli.tui.settings.store import AgentStore
from openhands.sdk.security.confirmation_policy import (
@@ -69,29 +70,26 @@ def setup_conversation(
MissingAgentSpec: If agent specification is not found or invalid.
"""
print_formatted_text(
HTML(f'<white>Initializing agent...</white>')
)
with LoadingContext('Initializing OpenHands agent...'):
agent = load_agent_specs(str(conversation_id))
agent = load_agent_specs(str(conversation_id))
if not include_security_analyzer:
# Remove security analyzer from agent spec
agent = agent.model_copy(
update={"security_analyzer": None}
)
if not include_security_analyzer:
# Remove security analyzer from agent spec
agent = agent.model_copy(
update={"security_analyzer": None}
# Create conversation - agent context is now set in AgentStore.load()
conversation: BaseConversation = Conversation(
agent=agent,
workspace=Workspace(working_dir=WORK_DIR),
# Conversation will add /<conversation_id> to this path
persistence_dir=CONVERSATIONS_DIR,
conversation_id=conversation_id,
)
# Create conversation - agent context is now set in AgentStore.load()
conversation: BaseConversation = Conversation(
agent=agent,
workspace=Workspace(working_dir=WORK_DIR),
# Conversation will add /<conversation_id> to this path
persistence_dir=CONVERSATIONS_DIR,
conversation_id=conversation_id,
)
if include_security_analyzer:
conversation.set_confirmation_policy(AlwaysConfirm())
if include_security_analyzer:
conversation.set_confirmation_policy(AlwaysConfirm())
print_formatted_text(
HTML(f'<green>✓ Agent initialized with model: {agent.llm.model}</green>')

View File

@@ -0,0 +1,113 @@
"""
Signal handling for graceful shutdown and immediate termination.
This module provides a signal handler that tracks Ctrl+C presses:
- First Ctrl+C: Attempt graceful pause of the agent
- Second Ctrl+C: Immediately terminate the process
"""
import signal
import threading
import time
from typing import Callable, Optional
from prompt_toolkit import HTML, print_formatted_text
class SignalHandler:
"""Handles SIGINT (Ctrl+C) with graceful shutdown on first press and immediate termination on second."""
def __init__(self, graceful_shutdown_callback: Optional[Callable] = None):
self.graceful_shutdown_callback = graceful_shutdown_callback
self.sigint_count = 0
self.last_sigint_time = 0.0
self.sigint_timeout = 3.0 # Reset counter after 3 seconds
self.lock = threading.Lock()
self.original_handler = None
def install(self) -> None:
"""Install the signal handler."""
self.original_handler = signal.signal(signal.SIGINT, self._handle_sigint)
def uninstall(self) -> None:
"""Restore the original signal handler."""
if self.original_handler is not None:
signal.signal(signal.SIGINT, self.original_handler)
self.original_handler = None
def _handle_sigint(self, signum: int, frame) -> None:
"""Handle SIGINT (Ctrl+C) signal."""
current_time = time.time()
with self.lock:
# Reset counter if too much time has passed since last Ctrl+C
if current_time - self.last_sigint_time > self.sigint_timeout:
self.sigint_count = 0
self.sigint_count += 1
self.last_sigint_time = current_time
if self.sigint_count == 1:
# First Ctrl+C: attempt graceful shutdown
print_formatted_text(HTML('\n<yellow>Received Ctrl+C. Attempting to pause agent gracefully...</yellow>'))
print_formatted_text(HTML('<grey>Press Ctrl+C again within 3 seconds to force immediate termination.</grey>'))
if self.graceful_shutdown_callback:
try:
self.graceful_shutdown_callback()
except Exception as e:
print_formatted_text(HTML(f'<red>Error during graceful shutdown: {e}</red>'))
elif self.sigint_count >= 2:
# Second Ctrl+C: immediate termination
print_formatted_text(HTML('\n<red>Received second Ctrl+C. Terminating immediately...</red>'))
self.uninstall()
# Force immediate exit
import os
os._exit(1)
class ProcessSignalHandler:
"""Signal handler for managing conversation runner processes."""
def __init__(self):
self.conversation_process = None
self.signal_handler = None
def set_conversation_process(self, process) -> None:
"""Set the conversation process to manage."""
self.conversation_process = process
def graceful_shutdown(self) -> None:
"""Attempt graceful shutdown of the conversation process."""
if hasattr(self, 'conversation_process') and self.conversation_process and self.conversation_process.is_alive():
print_formatted_text(HTML('<yellow>Pausing agent once current step is completed...</yellow>'))
# Send SIGTERM to the process for graceful shutdown
self.conversation_process.terminate()
# Give it a moment to shut down gracefully
self.conversation_process.join(timeout=2.0)
if self.conversation_process.is_alive():
print_formatted_text(HTML('<yellow>Agent is taking time to pause. Press Ctrl+C again to force termination.</yellow>'))
else:
print_formatted_text(HTML('<green>Agent paused successfully.</green>'))
else:
print_formatted_text(HTML('<yellow>No active conversation process to pause.</yellow>'))
def install_handler(self) -> None:
"""Install the signal handler."""
self.signal_handler = SignalHandler(graceful_shutdown_callback=self.graceful_shutdown)
self.signal_handler.install()
def uninstall_handler(self) -> None:
"""Uninstall the signal handler."""
if self.signal_handler:
self.signal_handler.uninstall()
self.signal_handler = None
def force_terminate(self) -> None:
"""Force terminate the conversation process."""
if self.conversation_process and self.conversation_process.is_alive():
self.conversation_process.kill()
self.conversation_process.join(timeout=1.0)

View File

@@ -18,6 +18,7 @@ from prompt_toolkit import print_formatted_text
from prompt_toolkit.formatted_text import HTML
from openhands_cli.argparsers.main_parser import create_main_parser
from openhands_cli.simple_signal_handler import SimpleSignalHandler
def main() -> None:
@@ -30,8 +31,15 @@ def main() -> None:
parser = create_main_parser()
args = parser.parse_args()
# Install basic signal handler for the main process
# The agent_chat module will install its own more sophisticated handler
signal_handler = SimpleSignalHandler()
try:
if args.command == 'serve':
# For GUI mode, use basic signal handling
signal_handler.install()
# Import gui_launcher only when needed
from openhands_cli.gui_launcher import launch_gui_server
@@ -41,7 +49,7 @@ def main() -> None:
# Import agent_chat only when needed
from openhands_cli.agent_chat import run_cli_entry
# Start agent chat
# Start agent chat (it will install its own signal handler)
run_cli_entry(resume_conversation_id=args.resume)
except KeyboardInterrupt:
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
@@ -53,6 +61,8 @@ def main() -> None:
traceback.print_exc()
raise
finally:
signal_handler.uninstall()
if __name__ == '__main__':

View File

@@ -0,0 +1,160 @@
"""
Simple process-based conversation runner for OpenHands CLI.
Only the actual conversation running (process_message) is wrapped in a separate process.
All other methods run in the main process.
"""
import multiprocessing
from typing import Any, Optional
from openhands.sdk import BaseConversation, Message
from openhands_cli.runner import ConversationRunner
def _run_conversation_in_process(conversation_id: str, message_data: Optional[dict], result_queue: multiprocessing.Queue):
"""Run the conversation in a separate process."""
try:
from openhands_cli.setup import setup_conversation
from openhands.sdk import Message, TextContent
import uuid
# Recreate conversation in this process
conv_id = uuid.UUID(conversation_id)
conversation = setup_conversation(conv_id)
# Create conversation runner
runner = ConversationRunner(conversation)
if message_data:
# Recreate message from data
message = Message(
role=message_data['role'],
content=[TextContent(text=message_data['content_text'])]
)
# Process the message
runner.process_message(message)
# Put success result in the queue
result_queue.put(('success', None))
except KeyboardInterrupt:
result_queue.put(('interrupted', None))
except Exception as e:
result_queue.put(('error', str(e)))
class SimpleProcessRunner:
"""Simple conversation runner that only uses multiprocessing for the actual conversation."""
def __init__(self, conversation: BaseConversation):
"""Initialize the process runner.
Args:
conversation: The conversation instance
"""
self.conversation = conversation
self.conversation_id = str(conversation.conversation_id)
self.current_process: Optional[multiprocessing.Process] = None
self.result_queue: Optional[multiprocessing.Queue] = None
# Create a runner for main process operations
self.runner = ConversationRunner(conversation)
def process_message(self, message: Optional[Message]) -> bool:
"""Process a message in a separate process.
Args:
message: The user message to process
Returns:
True if successful, False otherwise
"""
# Create queue for result
self.result_queue = multiprocessing.Queue()
# Prepare message data for serialization
message_data = None
if message:
# Extract text content from the message
content_text = ""
for content in message.content:
if hasattr(content, 'text'):
content_text += content.text
message_data = {
'role': message.role,
'content_text': content_text
}
# Create and start process
self.current_process = multiprocessing.Process(
target=_run_conversation_in_process,
args=(self.conversation_id, message_data, self.result_queue)
)
self.current_process.start()
# Wait for result
try:
result_type, result_data = self.result_queue.get()
self.current_process.join()
if result_type == 'success':
return True
elif result_type == 'interrupted':
print("Agent was interrupted by user")
return False
else:
print(f"Process error: {result_data}")
return False
except Exception as e:
# Check if process was killed by signal handler
if self.current_process and not self.current_process.is_alive():
# Process was killed, likely by Ctrl+C handler
return False
# Clean up if process is still alive
if self.current_process and self.current_process.is_alive():
self.current_process.terminate()
self.current_process.join(timeout=2)
if self.current_process.is_alive():
self.current_process.kill()
self.current_process.join()
raise e
finally:
self.current_process = None
self.result_queue = None
def get_status(self) -> dict:
"""Get conversation status (runs in main process)."""
return {
'conversation_id': self.conversation.id,
'agent_status': self.conversation.state.agent_status.value if self.conversation.state else 'unknown',
'is_running': self.current_process is not None and self.current_process.is_alive()
}
def toggle_confirmation_mode(self) -> bool:
"""Toggle confirmation mode (runs in main process)."""
self.runner.toggle_confirmation_mode()
# Update our conversation reference
self.conversation = self.runner.conversation
return self.conversation.is_confirmation_mode_active
def resume(self) -> None:
"""Resume the agent (runs in main process)."""
# This would be handled by the conversation state
pass
def cleanup(self) -> None:
"""Clean up resources."""
if self.current_process and self.current_process.is_alive():
self.current_process.terminate()
self.current_process.join(timeout=2)
if self.current_process.is_alive():
self.current_process.kill()
self.current_process.join()
# Clean up conversation resources if needed
if hasattr(self.conversation, 'close'):
self.conversation.close()

View File

@@ -0,0 +1,68 @@
"""
Simple signal handling for Ctrl+C behavior in OpenHands CLI.
- First Ctrl+C: Attempt graceful pause of the agent
- Second Ctrl+C: Immediately kill the process
"""
import signal
import time
from typing import Optional
from prompt_toolkit import HTML, print_formatted_text
class SimpleSignalHandler:
"""Simple signal handler that tracks Ctrl+C presses and manages a subprocess."""
def __init__(self):
self.ctrl_c_count = 0
self.last_ctrl_c_time = 0.0
self.timeout = 3.0 # Reset counter after 3 seconds
self.original_handler = None
self.current_process: Optional[object] = None
def install(self) -> None:
"""Install the signal handler."""
self.original_handler = signal.signal(signal.SIGINT, self._handle_ctrl_c)
def uninstall(self) -> None:
"""Restore the original signal handler."""
if self.original_handler is not None:
signal.signal(signal.SIGINT, self.original_handler)
self.original_handler = None
def reset_count(self) -> None:
"""Reset the Ctrl+C count (called when starting new message processing)."""
self.ctrl_c_count = 0
self.last_ctrl_c_time = 0.0
def set_process(self, process) -> None:
"""Set the current process to manage."""
self.current_process = process
def _handle_ctrl_c(self, signum: int, frame) -> None:
"""Handle Ctrl+C signal."""
current_time = time.time()
# Reset counter if too much time has passed
if current_time - self.last_ctrl_c_time > self.timeout:
self.ctrl_c_count = 0
self.ctrl_c_count += 1
self.last_ctrl_c_time = current_time
if self.ctrl_c_count == 1:
print_formatted_text(HTML('<yellow>Received Ctrl+C. Attempting to pause agent...</yellow>'))
if self.current_process and self.current_process.is_alive():
self.current_process.terminate()
print_formatted_text(HTML('<yellow>Press Ctrl+C again within 3 seconds to force kill.</yellow>'))
else:
print_formatted_text(HTML('<yellow>No active process to pause.</yellow>'))
else:
print_formatted_text(HTML('<red>Received second Ctrl+C. Force killing process...</red>'))
if self.current_process and self.current_process.is_alive():
self.current_process.kill()
# Reset the counter so user can continue with new messages
self.reset_count()
print_formatted_text(HTML('<green>Process stopped. You can continue sending messages.</green>'))

View File

@@ -45,7 +45,9 @@ class AgentStore:
system_message_suffix=f'You current working directory is: {WORK_DIR}',
)
mcp_config: dict = self.load_mcp_configuration()
additional_mcp_config = self.load_mcp_configuration()
mcp_config: dict = agent.mcp_config.copy().get('mcpServers', {})
mcp_config.update(additional_mcp_config)
# Update LLM metadata with current information
agent_llm_metadata = get_llm_metadata(

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""
Test script to verify Ctrl+C behavior in the OpenHands CLI.
This script simulates the signal handling behavior to test:
1. First Ctrl+C attempts graceful pause
2. Second Ctrl+C (within 3 seconds) kills process immediately
"""
import signal
import time
import multiprocessing
from openhands_cli.signal_handler import ProcessSignalHandler
def mock_conversation_process():
"""Mock conversation process that runs indefinitely"""
print("Mock conversation process started...")
try:
while True:
print("Agent is working...")
time.sleep(2)
except KeyboardInterrupt:
print("Mock conversation process received KeyboardInterrupt")
except Exception as e:
print(f"Mock conversation process error: {e}")
finally:
print("Mock conversation process ending")
def test_signal_handling():
"""Test the signal handling behavior"""
print("Testing Ctrl+C signal handling...")
print("Instructions:")
print("1. Press Ctrl+C once - should attempt graceful pause")
print("2. Press Ctrl+C again within 3 seconds - should kill immediately")
print("3. Wait more than 3 seconds between presses to test timeout reset")
print()
# Create and start mock process
process = multiprocessing.Process(target=mock_conversation_process)
process.start()
# Install signal handler
signal_handler = ProcessSignalHandler()
signal_handler.install_handler()
signal_handler.set_conversation_process(process)
try:
print("Process started. Press Ctrl+C to test signal handling...")
print("Process PID:", process.pid)
# Wait for process to finish or be killed
while process.is_alive():
time.sleep(0.5)
print(f"Process finished with exit code: {process.exitcode}")
except KeyboardInterrupt:
print("Main process received KeyboardInterrupt")
finally:
# Clean up
signal_handler.uninstall_handler()
if process.is_alive():
process.terminate()
process.join(timeout=2)
if process.is_alive():
process.kill()
process.join()
print("Test completed")
if __name__ == "__main__":
test_signal_handling()

View File

@@ -2,18 +2,12 @@
from unittest.mock import MagicMock, patch
from uuid import UUID
from prompt_toolkit.input.defaults import create_pipe_input
from prompt_toolkit.output.defaults import DummyOutput
from openhands_cli.setup import (
MissingAgentSpec,
verify_agent_exists_or_setup_agent,
)
from openhands_cli.setup import MissingAgentSpec, verify_agent_exists_or_setup_agent, setup_conversation
from openhands_cli.user_actions import UserConfirmation
@patch("openhands_cli.setup.load_agent_specs")
@patch('openhands_cli.setup.load_agent_specs')
def test_verify_agent_exists_or_setup_agent_success(mock_load_agent_specs):
"""Test that verify_agent_exists_or_setup_agent returns agent successfully."""
# Mock the agent object
@@ -28,10 +22,11 @@ def test_verify_agent_exists_or_setup_agent_success(mock_load_agent_specs):
mock_load_agent_specs.assert_called_once_with()
@patch("openhands_cli.setup.SettingsScreen")
@patch("openhands_cli.setup.load_agent_specs")
@patch('openhands_cli.setup.SettingsScreen')
@patch('openhands_cli.setup.load_agent_specs')
def test_verify_agent_exists_or_setup_agent_missing_agent_spec(
mock_load_agent_specs, mock_settings_screen_class
mock_load_agent_specs,
mock_settings_screen_class
):
"""Test that verify_agent_exists_or_setup_agent handles MissingAgentSpec exception."""
# Mock the SettingsScreen instance
@@ -42,7 +37,7 @@ def test_verify_agent_exists_or_setup_agent_missing_agent_spec(
mock_agent = MagicMock()
mock_load_agent_specs.side_effect = [
MissingAgentSpec("Agent spec missing"),
mock_agent,
mock_agent
]
# Call the function
@@ -56,11 +51,14 @@ def test_verify_agent_exists_or_setup_agent_missing_agent_spec(
mock_settings_screen.configure_settings.assert_called_once_with(first_time=True)
@patch("openhands_cli.agent_chat.exit_session_confirmation")
@patch("openhands_cli.agent_chat.get_session_prompter")
@patch("openhands_cli.agent_chat.setup_conversation")
@patch("openhands_cli.agent_chat.verify_agent_exists_or_setup_agent")
@patch("openhands_cli.agent_chat.ConversationRunner")
@patch('openhands_cli.agent_chat.exit_session_confirmation')
@patch('openhands_cli.agent_chat.get_session_prompter')
@patch('openhands_cli.agent_chat.setup_conversation')
@patch('openhands_cli.agent_chat.verify_agent_exists_or_setup_agent')
@patch('openhands_cli.agent_chat.ConversationRunner')
def test_new_command_resets_confirmation_mode(
mock_runner_cls,
mock_verify_agent,
@@ -76,35 +74,27 @@ def test_new_command_resets_confirmation_mode(
mock_verify_agent.return_value = mock_agent
# Mock conversation - only one is created when /new is called
conv1 = MagicMock()
conv1.id = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
conv1 = MagicMock(); conv1.id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
mock_setup_conversation.return_value = conv1
# One runner instance for the conversation
runner1 = MagicMock()
runner1.is_confirmation_mode_active = True
runner1 = MagicMock(); runner1.is_confirmation_mode_active = True
mock_runner_cls.return_value = runner1
# Real session fed by a pipe (no interactive confirmation now)
from openhands_cli.user_actions.utils import (
get_session_prompter as real_get_session_prompter,
)
from openhands_cli.user_actions.utils import get_session_prompter as real_get_session_prompter
with create_pipe_input() as pipe:
output = DummyOutput()
session = real_get_session_prompter(input=pipe, output=output)
mock_get_session_prompter.return_value = session
from openhands_cli.agent_chat import run_cli_entry
# Trigger /new
# First user message should trigger runner creation
# Then /exit (exit will be auto-accepted)
for ch in "/new\rhello\r/exit\r":
# Trigger /new, then /exit (exit will be auto-accepted)
for ch in "/new\r/exit\r":
pipe.send_text(ch)
run_cli_entry(None)
# Assert we created one runner for the conversation when a message was processed after /new
# Assert we created one runner for the conversation when /new was called
assert mock_runner_cls.call_count == 1
assert mock_runner_cls.call_args_list[0].args[0] is conv1

View File

@@ -39,43 +39,43 @@ def run_resume_command_test(commands, agent_status=None, expect_runner_created=T
patch('openhands_cli.agent_chat.setup_conversation') as mock_setup_conversation, \
patch('openhands_cli.agent_chat.verify_agent_exists_or_setup_agent') as mock_verify_agent, \
patch('openhands_cli.agent_chat.ConversationRunner') as mock_runner_cls:
# Auto-accept the exit prompt to avoid interactive UI
mock_exit_confirm.return_value = UserConfirmation.ACCEPT
# Mock agent verification to succeed
mock_agent = MagicMock()
mock_verify_agent.return_value = mock_agent
# Mock conversation setup
conv = MagicMock()
conv.id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
if agent_status:
conv.state.agent_status = agent_status
mock_setup_conversation.return_value = conv
# Mock runner
runner = MagicMock()
runner.conversation = conv
mock_runner_cls.return_value = runner
# Real session fed by a pipe
from openhands_cli.user_actions.utils import get_session_prompter as real_get_session_prompter
with create_pipe_input() as pipe:
output = DummyOutput()
session = real_get_session_prompter(input=pipe, output=output)
mock_get_session_prompter.return_value = session
from openhands_cli.agent_chat import run_cli_entry
# Send commands
for ch in commands:
pipe.send_text(ch)
# Capture printed output
with patch('openhands_cli.agent_chat.print_formatted_text') as mock_print:
run_cli_entry(None)
return mock_runner_cls, runner, mock_print
@@ -94,16 +94,16 @@ def test_resume_command_warnings(commands, expected_warning, expect_runner_creat
"""Test /resume command shows appropriate warnings."""
# Set agent status to FINISHED for the "conversation exists but not paused" test
agent_status = AgentExecutionStatus.FINISHED if expect_runner_created else None
mock_runner_cls, runner, mock_print = run_resume_command_test(
commands, agent_status=agent_status, expect_runner_created=expect_runner_created
)
# Verify warning message was printed
warning_calls = [call for call in mock_print.call_args_list
warning_calls = [call for call in mock_print.call_args_list
if expected_warning in str(call)]
assert len(warning_calls) > 0, f"Expected warning about {expected_warning}"
# Verify runner creation expectation
if expect_runner_created:
assert mock_runner_cls.call_count == 1
@@ -124,24 +124,24 @@ def test_resume_command_warnings(commands, expected_warning, expect_runner_creat
def test_resume_command_successful_resume(agent_status):
"""Test /resume command successfully resumes paused/waiting conversations."""
commands = "hello\r/resume\r/exit\r"
mock_runner_cls, runner, mock_print = run_resume_command_test(
commands, agent_status=agent_status, expect_runner_created=True
)
# Verify runner was created and process_message was called
assert mock_runner_cls.call_count == 1
# Verify process_message was called twice: once with the initial message, once with None for resume
assert runner.process_message.call_count == 2
# Check the calls to process_message
calls = runner.process_message.call_args_list
# First call should have a message (the "hello" message)
first_call_args = calls[0][0]
assert first_call_args[0] is not None, "First call should have a message"
# Second call should have None (the /resume command)
second_call_args = calls[1][0]
assert second_call_args[0] is None, "Second call should have None message for resume"
assert second_call_args[0] is None, "Second call should have None message for resume"

View File

@@ -1,114 +0,0 @@
"""Minimal tests: mcp.json overrides persisted agent MCP servers."""
import json
from pathlib import Path
from unittest.mock import patch
import pytest
from pydantic import SecretStr
from openhands.sdk import Agent, LLM
from openhands_cli.locations import MCP_CONFIG_FILE, AGENT_SETTINGS_PATH
from openhands_cli.tui.settings.store import AgentStore
# ---------------------- tiny helpers ----------------------
def write_json(path: Path, obj: dict) -> None:
path.write_text(json.dumps(obj))
def write_agent(root: Path, agent: Agent) -> None:
(root / AGENT_SETTINGS_PATH).write_text(
agent.model_dump_json(context={"expose_secrets": True})
)
# ---------------------- fixtures ----------------------
@pytest.fixture
def persistence_dir(tmp_path, monkeypatch) -> Path:
# Create root dir and point AgentStore at it
root = tmp_path / "openhands"
root.mkdir()
monkeypatch.setattr("openhands_cli.tui.settings.store.PERSISTENCE_DIR", str(root))
return root
@pytest.fixture
def agent_store() -> AgentStore:
return AgentStore()
# ---------------------- tests ----------------------
@patch("openhands_cli.tui.settings.store.get_default_tools", return_value=[])
@patch("openhands_cli.tui.settings.store.get_llm_metadata", return_value={})
def test_load_overrides_persisted_mcp_with_mcp_json_file(
mock_meta,
mock_tools,
persistence_dir,
agent_store
):
"""If agent has MCP servers, mcp.json must replace them entirely."""
# Persist an agent that already contains MCP servers
persisted_agent = Agent(
llm=LLM(model="gpt-4", api_key=SecretStr("k"), usage_id="svc"),
tools=[],
mcp_config={
"mcpServers": {
"persistent_server": {"command": "python", "args": ["-m", "old_server"]}
}
},
)
write_agent(persistence_dir, persisted_agent)
# Create mcp.json with different servers (this must fully override)
write_json(
persistence_dir / MCP_CONFIG_FILE,
{
"mcpServers": {
"file_server": {"command": "uvx", "args": ["mcp-server-fetch"]}
}
},
)
loaded = agent_store.load()
assert loaded is not None
# Expect ONLY the MCP json file's config
assert loaded.mcp_config == {
"mcpServers": {
"file_server": {
"command": "uvx",
"args": ["mcp-server-fetch"],
"env": {},
"transport": "stdio",
}
}
}
@patch("openhands_cli.tui.settings.store.get_default_tools", return_value=[])
@patch("openhands_cli.tui.settings.store.get_llm_metadata", return_value={})
def test_load_when_mcp_file_missing_ignores_persisted_mcp(
mock_meta,
mock_tools,
persistence_dir,
agent_store
):
"""If mcp.json is absent, loaded agent.mcp_config should be empty (persisted MCP ignored)."""
persisted_agent = Agent(
llm=LLM(model="gpt-4", api_key=SecretStr("k"), usage_id="svc"),
tools=[],
mcp_config={
"mcpServers": {
"persistent_server": {"command": "python", "args": ["-m", "old_server"]}
}
},
)
write_agent(persistence_dir, persisted_agent)
# No mcp.json created
loaded = agent_store.load()
assert loaded is not None
assert loaded.mcp_config == {} # persisted MCP is ignored if file is missin

View File

@@ -73,6 +73,8 @@ class TestConfirmationMode:
persistence_dir=ANY,
conversation_id=mock_conversation_id,
)
# Verify print_formatted_text was called
mock_print.assert_called_once()
def test_setup_conversation_raises_missing_agent_spec(self) -> None:
"""Test that setup_conversation raises MissingAgentSpec when agent is not found."""

View File

@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""
Unit tests for the loading animation functionality.
"""
import threading
import time
import unittest
from unittest.mock import patch
from openhands_cli.listeners.loading_listener import (
LoadingContext,
display_initialization_animation,
)
class TestLoadingAnimation(unittest.TestCase):
"""Test cases for loading animation functionality."""
def test_loading_context_manager(self):
"""Test that LoadingContext works as a context manager."""
with LoadingContext('Test loading...') as ctx:
self.assertIsInstance(ctx, LoadingContext)
self.assertEqual(ctx.text, 'Test loading...')
self.assertIsInstance(ctx.is_loaded, threading.Event)
self.assertIsNotNone(ctx.loading_thread)
# Give the thread a moment to start
time.sleep(0.1)
self.assertTrue(ctx.loading_thread.is_alive())
# After exiting context, thread should be stopped
time.sleep(0.1)
self.assertFalse(ctx.loading_thread.is_alive())
@patch('sys.stdout')
def test_animation_writes_while_running_and_stops_after(self, mock_stdout):
"""Ensure stdout is written while animation runs and stops after it ends."""
is_loaded = threading.Event()
animation_thread = threading.Thread(
target=display_initialization_animation,
args=('Test output', is_loaded),
daemon=True,
)
animation_thread.start()
# Let it run a bit and check calls
time.sleep(0.2)
calls_while_running = mock_stdout.write.call_count
self.assertGreater(calls_while_running, 0, 'Expected writes while spinner runs')
# Stop animation
is_loaded.set()
time.sleep(0.2)
animation_thread.join(timeout=1.0)
calls_after_stop = mock_stdout.write.call_count
# Wait a moment to detect any stray writes after thread finished
time.sleep(0.2)
self.assertEqual(
calls_after_stop,
mock_stdout.write.call_count,
'No extra writes should occur after animation stops',
)
if __name__ == '__main__':
unittest.main()

View File

@@ -57,16 +57,6 @@ class AppConversationInfoService(ABC):
]
)
@abstractmethod
async def delete_app_conversation_info(self, conversation_id: UUID) -> bool:
"""Delete a conversation info from the database.
Args:
conversation_id: The ID of the conversation to delete.
Returns True if the conversation was deleted successfully, False otherwise.
"""
# Mutators
@abstractmethod

View File

@@ -11,7 +11,7 @@ from openhands.app_server.event_callback.event_callback_models import (
)
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
from openhands.integrations.service_types import ProviderType
from openhands.sdk.conversation.state import ConversationExecutionStatus
from openhands.sdk.conversation.state import AgentExecutionStatus
from openhands.sdk.llm import MetricsSnapshot
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
@@ -57,7 +57,7 @@ class AppConversation(AppConversationInfo): # type: ignore
default=SandboxStatus.MISSING,
description='Current sandbox status. Will be MISSING if the sandbox does not exist.',
)
execution_status: ConversationExecutionStatus | None = Field(
agent_status: AgentExecutionStatus | None = Field(
default=None,
description='Current agent status. Will be None if the sandbox_status is not RUNNING',
)

View File

@@ -95,21 +95,6 @@ class AppConversationService(ABC):
"""Run the setup scripts for the project and yield status updates"""
yield task
@abstractmethod
async def delete_app_conversation(self, conversation_id: UUID) -> bool:
"""Delete a V1 conversation and all its associated data.
Args:
conversation_id: The UUID of the conversation to delete.
This method should:
1. Delete the conversation from the database
2. Call the agent server to delete the conversation
3. Clean up any related data
Returns True if the conversation was deleted successfully, False otherwise.
"""
class AppConversationServiceInjector(
DiscriminatedUnionMixin, Injector[AppConversationService], ABC

View File

@@ -56,16 +56,6 @@ class AppConversationStartTaskService(ABC):
Return the stored task
"""
@abstractmethod
async def delete_app_conversation_start_tasks(self, conversation_id: UUID) -> bool:
"""Delete all start tasks associated with a conversation.
Args:
conversation_id: The ID of the conversation to delete tasks for.
Returns True if any tasks were deleted successfully, False otherwise.
"""
class AppConversationStartTaskServiceInjector(
DiscriminatedUnionMixin, Injector[AppConversationStartTaskService], ABC

View File

@@ -39,9 +39,6 @@ from openhands.app_server.app_conversation.app_conversation_start_task_service i
from openhands.app_server.app_conversation.git_app_conversation_service import (
GitAppConversationService,
)
from openhands.app_server.app_conversation.sql_app_conversation_info_service import (
SQLAppConversationInfoService,
)
from openhands.app_server.errors import SandboxError
from openhands.app_server.sandbox.docker_sandbox_service import DockerSandboxService
from openhands.app_server.sandbox.sandbox_models import (
@@ -222,7 +219,7 @@ class LiveStatusAppConversationService(GitAppConversationService):
app_conversation_info = AppConversationInfo(
id=info.id,
# TODO: As of writing, StartConversationRequest from AgentServer does not have a title
title=f'Conversation {info.id.hex}',
title=f'Conversation {info.id}',
sandbox_id=sandbox.id,
created_by_user_id=user_id,
llm_model=start_conversation_request.agent.llm.model,
@@ -337,9 +334,7 @@ class LiveStatusAppConversationService(GitAppConversationService):
if app_conversation_info is None:
return None
sandbox_status = sandbox.status if sandbox else SandboxStatus.MISSING
execution_status = (
conversation_info.execution_status if conversation_info else None
)
agent_status = conversation_info.agent_status if conversation_info else None
conversation_url = None
session_api_key = None
if sandbox and sandbox.exposed_urls:
@@ -358,7 +353,7 @@ class LiveStatusAppConversationService(GitAppConversationService):
return AppConversation(
**app_conversation_info.model_dump(),
sandbox_status=sandbox_status,
execution_status=execution_status,
agent_status=agent_status,
conversation_url=conversation_url,
session_api_key=session_api_key,
)
@@ -534,101 +529,6 @@ class LiveStatusAppConversationService(GitAppConversationService):
f'Successfully updated agent-server conversation {conversation_id} title to "{new_title}"'
)
async def delete_app_conversation(self, conversation_id: UUID) -> bool:
"""Delete a V1 conversation and all its associated data.
Args:
conversation_id: The UUID of the conversation to delete.
"""
# Check if we have the required SQL implementation for transactional deletion
if not isinstance(
self.app_conversation_info_service, SQLAppConversationInfoService
):
_logger.error(
f'Cannot delete V1 conversation {conversation_id}: SQL implementation required for transactional deletion',
extra={'conversation_id': str(conversation_id)},
)
return False
try:
# First, fetch the conversation to get the full object needed for agent server deletion
app_conversation = await self.get_app_conversation(conversation_id)
if not app_conversation:
_logger.warning(
f'V1 conversation {conversation_id} not found for deletion',
extra={'conversation_id': str(conversation_id)},
)
return False
# Delete from agent server if sandbox is running
await self._delete_from_agent_server(app_conversation)
# Delete from database using the conversation info from app_conversation
# AppConversation extends AppConversationInfo, so we can use it directly
return await self._delete_from_database(app_conversation)
except Exception as e:
_logger.error(
f'Error deleting V1 conversation {conversation_id}: {e}',
extra={'conversation_id': str(conversation_id)},
exc_info=True,
)
return False
async def _delete_from_agent_server(
self, app_conversation: AppConversation
) -> None:
"""Delete conversation from agent server if sandbox is running."""
conversation_id = app_conversation.id
if not (
app_conversation.sandbox_status == SandboxStatus.RUNNING
and app_conversation.session_api_key
):
return
try:
# Get sandbox info to find agent server URL
sandbox = await self.sandbox_service.get_sandbox(
app_conversation.sandbox_id
)
if sandbox and sandbox.exposed_urls:
agent_server_url = self._get_agent_server_url(sandbox)
# Call agent server delete API
response = await self.httpx_client.delete(
f'{agent_server_url}/api/conversations/{conversation_id}',
headers={'X-Session-API-Key': app_conversation.session_api_key},
timeout=30.0,
)
response.raise_for_status()
except Exception as e:
_logger.warning(
f'Failed to delete conversation from agent server: {e}',
extra={'conversation_id': str(conversation_id)},
)
# Continue with database cleanup even if agent server call fails
async def _delete_from_database(
self, app_conversation_info: AppConversationInfo
) -> bool:
"""Delete conversation from database.
Args:
app_conversation_info: The app conversation info to delete (already fetched).
"""
# The session is already managed by the dependency injection system
# No need for explicit transaction management here
deleted_info = (
await self.app_conversation_info_service.delete_app_conversation_info(
app_conversation_info.id
)
)
deleted_tasks = await self.app_conversation_start_task_service.delete_app_conversation_start_tasks(
app_conversation_info.id
)
return deleted_info or deleted_tasks
class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector):
sandbox_startup_timeout: int = Field(

View File

@@ -273,7 +273,7 @@ class SQLAppConversationInfoService(AppConversationInfoService):
user_id = await self.user_context.get_user_id()
if user_id:
query = select(StoredConversationMetadata).where(
StoredConversationMetadata.conversation_id == str(info.id)
StoredConversationMetadata.conversation_id == info.id
)
result = await self.db_session.execute(query)
existing = result.scalar_one_or_none()
@@ -356,9 +356,9 @@ class SQLAppConversationInfoService(AppConversationInfoService):
sandbox_id=stored.sandbox_id,
selected_repository=stored.selected_repository,
selected_branch=stored.selected_branch,
git_provider=(
ProviderType(stored.git_provider) if stored.git_provider else None
),
git_provider=ProviderType(stored.git_provider)
if stored.git_provider
else None,
title=stored.title,
trigger=ConversationTrigger(stored.trigger) if stored.trigger else None,
pr_number=stored.pr_number,
@@ -375,33 +375,6 @@ class SQLAppConversationInfoService(AppConversationInfoService):
value = value.replace(tzinfo=UTC)
return value
async def delete_app_conversation_info(self, conversation_id: UUID) -> bool:
"""Delete a conversation info from the database.
Args:
conversation_id: The ID of the conversation to delete.
Returns True if the conversation was deleted successfully, False otherwise.
"""
from sqlalchemy import delete
# Build secure delete query with user context filtering
delete_query = delete(StoredConversationMetadata).where(
StoredConversationMetadata.conversation_id == str(conversation_id)
)
# Apply user security filtering - only allow deletion of conversations owned by the current user
user_id = await self.user_context.get_user_id()
if user_id:
delete_query = delete_query.where(
StoredConversationMetadata.user_id == user_id
)
# Execute the secure delete query
result = await self.db_session.execute(delete_query)
return result.rowcount > 0
class SQLAppConversationInfoServiceInjector(AppConversationInfoServiceInjector):
async def inject(

View File

@@ -180,11 +180,9 @@ class SQLAppConversationStartTaskService(AppConversationStartTaskService):
# Return tasks in the same order as requested, with None for missing ones
return [
(
AppConversationStartTask(**row2dict(tasks_by_id[task_id]))
if task_id in tasks_by_id
else None
)
AppConversationStartTask(**row2dict(tasks_by_id[task_id]))
if task_id in tasks_by_id
else None
for task_id in task_ids
]
@@ -221,29 +219,6 @@ class SQLAppConversationStartTaskService(AppConversationStartTaskService):
await self.session.commit()
return task
async def delete_app_conversation_start_tasks(self, conversation_id: UUID) -> bool:
"""Delete all start tasks associated with a conversation.
Args:
conversation_id: The ID of the conversation to delete tasks for.
"""
from sqlalchemy import delete
# Build secure delete query with user filter if user_id is set
delete_query = delete(StoredAppConversationStartTask).where(
StoredAppConversationStartTask.app_conversation_id == conversation_id
)
if self.user_id:
delete_query = delete_query.where(
StoredAppConversationStartTask.created_by_user_id == self.user_id
)
result = await self.session.execute(delete_query)
# Return True if any rows were affected
return result.rowcount > 0
class SQLAppConversationStartTaskServiceInjector(
AppConversationStartTaskServiceInjector

View File

@@ -1,16 +1,13 @@
"""Event Callback router for OpenHands Server."""
import asyncio
import importlib
import logging
import pkgutil
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import APIKeyHeader
from jwt import InvalidTokenError
from openhands import tools # type: ignore[attr-defined]
from openhands.agent_server.models import ConversationInfo, Success
from openhands.app_server.app_conversation.app_conversation_info_service import (
AppConversationInfoService,
@@ -100,7 +97,8 @@ async def on_conversation_update(
app_conversation_info = AppConversationInfo(
id=conversation_info.id,
title=existing.title or f'Conversation {conversation_info.id.hex}',
# TODO: As of writing, ConversationInfo from AgentServer does not have a title
title=existing.title or f'Conversation {conversation_info.id}',
sandbox_id=sandbox_info.id,
created_by_user_id=sandbox_info.created_by_user_id,
llm_model=conversation_info.agent.llm.model,
@@ -188,16 +186,3 @@ async def _run_callbacks_in_bg_and_close(
# We don't use asynio.gather here because callbacks must be run in sequence.
for event in events:
await event_callback_service.execute_callbacks(conversation_id, event)
def _import_all_tools():
"""We need to import all tools so that they are available for deserialization in webhooks."""
for _, name, is_pkg in pkgutil.walk_packages(tools.__path__, tools.__name__ + '.'):
if is_pkg: # Check if it's a subpackage
try:
importlib.import_module(name)
except ImportError as e:
_logger.error(f"Warning: Could not import subpackage '{name}': {e}")
_import_all_tools()

View File

@@ -47,7 +47,6 @@ from openhands.app_server.utils.sql_utils import Base, UtcDateTime
_logger = logging.getLogger(__name__)
WEBHOOK_CALLBACK_VARIABLE = 'OH_WEBHOOKS_0_BASE_URL'
ALLOW_CORS_ORIGINS_VARIABLE = 'OH_ALLOW_CORS_ORIGINS_0'
polling_task: asyncio.Task | None = None
POD_STATUS_MAPPING = {
'ready': SandboxStatus.RUNNING,
@@ -129,10 +128,22 @@ class RemoteSandboxService(SandboxService):
f'Error getting runtime: {stored.id}', stack_info=True
)
status = self._get_sandbox_status_from_runtime(runtime)
# Get session_api_key and exposed urls
if runtime:
# Translate status
status = None
pod_status = runtime['pod_status'].lower()
if pod_status:
status = POD_STATUS_MAPPING.get(pod_status, None)
# If we failed to get the status from the pod status, fall back to status
if status is None:
runtime_status = runtime.get('status')
if runtime_status:
status = STATUS_MAPPING.get(runtime_status.lower(), None)
if status is None:
status = SandboxStatus.MISSING
session_api_key = runtime['session_api_key']
if status == SandboxStatus.RUNNING:
exposed_urls = []
@@ -154,6 +165,7 @@ class RemoteSandboxService(SandboxService):
exposed_urls = None
else:
session_api_key = None
status = SandboxStatus.MISSING
exposed_urls = None
sandbox_spec_id = stored.sandbox_spec_id
@@ -167,32 +179,6 @@ class RemoteSandboxService(SandboxService):
created_at=stored.created_at,
)
def _get_sandbox_status_from_runtime(
self, runtime: dict[str, Any] | None
) -> SandboxStatus:
"""Derive a SandboxStatus from the runtime info. The legacy logic for getting
the status of a runtime is inconsistent. It is divided between a "status" which
cannot be trusted (It sometimes returns "running" for cases when the pod is
still starting) and a "pod_status" which is not returned for list
operations."""
if not runtime:
return SandboxStatus.MISSING
status = None
pod_status = runtime['pod_status'].lower()
if pod_status:
status = POD_STATUS_MAPPING.get(pod_status, None)
# If we failed to get the status from the pod status, fall back to status
if status is None:
runtime_status = runtime.get('status')
if runtime_status:
status = STATUS_MAPPING.get(runtime_status.lower(), None)
if status is None:
return SandboxStatus.MISSING
return status
async def _secure_select(self):
query = select(StoredRemoteSandbox)
user_id = await self.user_context.get_user_id()
@@ -227,9 +213,6 @@ class RemoteSandboxService(SandboxService):
environment[WEBHOOK_CALLBACK_VARIABLE] = (
f'{self.web_url}/api/v1/webhooks/{sandbox_id}'
)
# We specify CORS settings only if there is a public facing url - otherwise
# we are probably in local development and the only url in use is localhost
environment[ALLOW_CORS_ORIGINS_VARIABLE] = self.web_url
return environment
@@ -631,7 +614,6 @@ class RemoteSandboxServiceInjector(SandboxServiceInjector):
)
# If no public facing web url is defined, poll for changes as callbacks will be unavailable.
# This is primarily used for local development rather than production
config = get_global_config()
web_url = config.web_url
if web_url is None:

View File

@@ -66,7 +66,6 @@ class SandboxService(ABC):
async def pause_old_sandboxes(self, max_num_sandboxes: int) -> list[str]:
"""Stop the oldest sandboxes if there are more than max_num_sandboxes running.
In a multi user environment, this will pause sandboxes only for the current user.
Args:
max_num_sandboxes: Maximum number of sandboxes to keep running

View File

@@ -11,7 +11,7 @@ from openhands.sdk.utils.models import DiscriminatedUnionMixin
# The version of the agent server to use for deployments.
# Typically this will be the same as the values from the pyproject.toml
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:d5995c3-python'
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:3d8af53-python'
class SandboxSpecService(ABC):

View File

@@ -148,10 +148,7 @@ def load_from_toml(cfg: OpenHandsConfig, toml_file: str = 'config.toml') -> None
try:
with open(toml_file, 'r', encoding='utf-8') as toml_contents:
toml_config = toml.load(toml_contents)
except FileNotFoundError as e:
logger.openhands_logger.info(
f'{toml_file} not found: {e}. Toml values have not been applied.'
)
except FileNotFoundError:
return
except toml.TomlDecodeError as e:
logger.openhands_logger.warning(
@@ -505,7 +502,7 @@ def get_agent_config_arg(
with open(toml_file, 'r', encoding='utf-8') as toml_contents:
toml_config = toml.load(toml_contents)
except FileNotFoundError as e:
logger.openhands_logger.info(f'Config file not found: {e}')
logger.openhands_logger.error(f'Config file not found: {e}')
return None
except toml.TomlDecodeError as e:
logger.openhands_logger.error(
@@ -569,7 +566,7 @@ def get_llm_config_arg(
with open(toml_file, 'r', encoding='utf-8') as toml_contents:
toml_config = toml.load(toml_contents)
except FileNotFoundError as e:
logger.openhands_logger.info(f'Config file not found: {e}')
logger.openhands_logger.error(f'Config file not found: {e}')
return None
except toml.TomlDecodeError as e:
logger.openhands_logger.error(
@@ -604,10 +601,7 @@ def get_llms_for_routing_config(toml_file: str = 'config.toml') -> dict[str, LLM
try:
with open(toml_file, 'r', encoding='utf-8') as toml_contents:
toml_config = toml.load(toml_contents)
except FileNotFoundError as e:
logger.openhands_logger.info(
f'Config file not found: {e}. Toml values have not been applied.'
)
except FileNotFoundError:
return llms_for_routing
except toml.TomlDecodeError as e:
logger.openhands_logger.error(
@@ -670,7 +664,7 @@ def get_condenser_config_arg(
with open(toml_file, 'r', encoding='utf-8') as toml_contents:
toml_config = toml.load(toml_contents)
except FileNotFoundError as e:
logger.openhands_logger.info(f'Config file not found: {toml_file}. Error: {e}')
logger.openhands_logger.error(f'Config file not found: {toml_file}. Error: {e}')
return None
except toml.TomlDecodeError as e:
logger.openhands_logger.error(
@@ -756,7 +750,7 @@ def get_model_routing_config_arg(toml_file: str = 'config.toml') -> ModelRouting
with open(toml_file, 'r', encoding='utf-8') as toml_contents:
toml_config = toml.load(toml_contents)
except FileNotFoundError as e:
logger.openhands_logger.info(f'Config file not found: {toml_file}. Error: {e}')
logger.openhands_logger.error(f'Config file not found: {toml_file}. Error: {e}')
return default_cfg
except toml.TomlDecodeError as e:
logger.openhands_logger.error(

View File

@@ -23,7 +23,6 @@ from openhands.resolver.patching import apply_diff, parse_patch
from openhands.resolver.resolver_output import ResolverOutput
from openhands.resolver.utils import identify_token
from openhands.utils.async_utils import GENERAL_TIMEOUT, call_async_from_sync
from openhands.utils.environment import get_effective_llm_base_url
def apply_patch(repo_dir: str, patch: str) -> None:
@@ -708,16 +707,10 @@ def main() -> None:
)
api_key = my_args.llm_api_key or os.environ['LLM_API_KEY']
model_name = my_args.llm_model or os.environ['LLM_MODEL']
base_url = my_args.llm_base_url or os.environ.get('LLM_BASE_URL')
resolved_base_url = get_effective_llm_base_url(
model_name,
base_url,
)
llm_config = LLMConfig(
model=model_name,
model=my_args.llm_model or os.environ['LLM_MODEL'],
api_key=SecretStr(api_key) if api_key else None,
base_url=resolved_base_url,
base_url=my_args.llm_base_url or os.environ.get('LLM_BASE_URL', None),
)
if not os.path.exists(my_args.output_dir):

View File

@@ -57,8 +57,8 @@ if TYPE_CHECKING:
# Import Windows PowerShell support if on Windows
if sys.platform == 'win32':
try:
from openhands.runtime.utils.windows_bash import WindowsPowershellSession
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 = """

View File

@@ -40,7 +40,7 @@ Two configuration options are required to use the Kubernetes runtime:
2. **Runtime Container Image**: Specify the container image to use for the runtime environment
```toml
[sandbox]
runtime_container_image = "docker.openhands.dev/openhands/runtime:0.61-nikolaik"
runtime_container_image = "docker.openhands.dev/openhands/runtime:0.60-nikolaik"
```
#### Additional Kubernetes Options

View File

@@ -50,7 +50,7 @@ from openhands.integrations.service_types import (
)
from openhands.runtime import get_runtime_cls
from openhands.runtime.runtime_status import RuntimeStatus
from openhands.sdk.conversation.state import ConversationExecutionStatus
from openhands.sdk.conversation.state import AgentExecutionStatus
from openhands.server.data_models.agent_loop_info import AgentLoopInfo
from openhands.server.data_models.conversation_info import ConversationInfo
from openhands.server.data_models.conversation_info_result_set import (
@@ -91,7 +91,6 @@ from openhands.storage.locations import get_experiment_config_filename
from openhands.storage.settings.settings_store import SettingsStore
from openhands.utils.async_utils import wait_all
from openhands.utils.conversation_summary import get_default_conversation_title
from openhands.utils.environment import get_effective_llm_base_url
app = APIRouter(prefix='/api', dependencies=get_dependencies())
app_conversation_service_dependency = depends_app_conversation_service()
@@ -466,59 +465,15 @@ async def get_conversation(
async def delete_conversation(
conversation_id: str = Depends(validate_conversation_id),
user_id: str | None = Depends(get_user_id),
app_conversation_service: AppConversationService = app_conversation_service_dependency,
) -> bool:
# Try V1 conversation first
v1_result = await _try_delete_v1_conversation(
conversation_id, app_conversation_service
)
if v1_result is not None:
return v1_result
# V0 conversation logic
return await _delete_v0_conversation(conversation_id, user_id)
async def _try_delete_v1_conversation(
conversation_id: str, app_conversation_service: AppConversationService
) -> bool | None:
"""Try to delete a V1 conversation. Returns None if not a V1 conversation."""
try:
conversation_uuid = uuid.UUID(conversation_id)
# Check if it's a V1 conversation by trying to get it
app_conversation = await app_conversation_service.get_app_conversation(
conversation_uuid
)
if app_conversation:
# This is a V1 conversation, delete it using the app conversation service
# Pass the conversation ID for secure deletion
return await app_conversation_service.delete_app_conversation(
app_conversation.id
)
except (ValueError, TypeError):
# Not a valid UUID, continue with V0 logic
pass
except Exception:
# Some other error, continue with V0 logic
pass
return None
async def _delete_v0_conversation(conversation_id: str, user_id: str | None) -> bool:
"""Delete a V0 conversation using the legacy logic."""
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
try:
await conversation_store.get_metadata(conversation_id)
except FileNotFoundError:
return False
# Stop the conversation if it's running
is_running = await conversation_manager.is_agent_loop_running(conversation_id)
if is_running:
await conversation_manager.close_session(conversation_id)
# Clean up runtime and metadata
runtime_cls = get_runtime_cls(config.runtime)
await runtime_cls.delete(conversation_id)
await conversation_store.delete_metadata(conversation_id)
@@ -546,15 +501,10 @@ async def get_prompt(
# placeholder for error handling
raise ValueError('Settings not found')
settings_base_url = settings.llm_base_url
effective_base_url = get_effective_llm_base_url(
settings.llm_model,
settings_base_url,
)
llm_config = LLMConfig(
model=settings.llm_model or '',
api_key=settings.llm_api_key,
base_url=effective_base_url,
base_url=settings.llm_base_url,
)
prompt_template = generate_prompt_template(stringified_events)
@@ -1110,154 +1060,47 @@ def add_experiment_config_for_conversation(
return False
def _parse_combined_page_id(page_id: str | None) -> tuple[str | None, str | None]:
"""Parse combined page_id to extract separate V0 and V1 page_ids.
Args:
page_id: Combined page_id (base64-encoded JSON) or legacy V0 page_id
Returns:
Tuple of (v0_page_id, v1_page_id)
"""
v0_page_id = None
v1_page_id = None
if page_id:
try:
# Try to parse as JSON first
page_data = json.loads(base64.b64decode(page_id))
v0_page_id = page_data.get('v0')
v1_page_id = page_data.get('v1')
except (json.JSONDecodeError, TypeError, Exception):
# Fallback: treat as v0 page_id for backward compatibility
# This catches base64 decode errors and any other parsing issues
v0_page_id = page_id
return v0_page_id, v1_page_id
async def _fetch_v1_conversations_safe(
app_conversation_service: AppConversationService,
v1_page_id: str | None,
limit: int,
) -> tuple[list[ConversationInfo], str | None]:
"""Safely fetch V1 conversations with error handling.
Args:
app_conversation_service: App conversation service for V1
v1_page_id: Page ID for V1 pagination
limit: Maximum number of results
Returns:
Tuple of (v1_conversations, v1_next_page_id)
"""
v1_conversations = []
v1_next_page_id = None
try:
age_filter_date = None
if config.conversation_max_age_seconds:
age_filter_date = datetime.now(timezone.utc) - timedelta(
seconds=config.conversation_max_age_seconds
)
app_conversation_page = await app_conversation_service.search_app_conversations(
page_id=v1_page_id,
limit=limit,
created_at__gte=age_filter_date,
)
v1_conversations = [
_to_conversation_info(app_conv) for app_conv in app_conversation_page.items
]
v1_next_page_id = app_conversation_page.next_page_id
except Exception as e:
# V1 system might not be available or initialized yet
logger.debug(f'V1 conversation service not available: {str(e)}')
return v1_conversations, v1_next_page_id
async def _process_v0_conversations(
conversation_metadata_result_set,
) -> list[ConversationInfo]:
"""Process V0 conversations with age filtering and agent loop info.
Args:
conversation_metadata_result_set: Result set from V0 conversation store
Returns:
List of processed ConversationInfo objects
"""
# Apply age filter to V0 conversations
v0_filtered_results = _filter_conversations_by_age(
conversation_metadata_result_set.results,
config.conversation_max_age_seconds,
)
v0_conversation_ids = set(
conversation.conversation_id for conversation in v0_filtered_results
)
# Get agent loop info for V0 conversations
await conversation_manager.get_connections(filter_to_sids=v0_conversation_ids)
v0_agent_loop_info = await conversation_manager.get_agent_loop_info(
filter_to_sids=v0_conversation_ids
)
v0_agent_loop_info_by_conversation_id = {
info.conversation_id: info for info in v0_agent_loop_info
}
# Convert to ConversationInfo objects
v0_conversations = await wait_all(
_get_conversation_info(
conversation=conversation,
num_connections=sum(
1
for conversation_id in v0_agent_loop_info_by_conversation_id.values()
if conversation_id == conversation.conversation_id
),
agent_loop_info=v0_agent_loop_info_by_conversation_id.get(
conversation.conversation_id
),
)
for conversation in v0_filtered_results
)
return v0_conversations
async def _apply_microagent_filters(
conversations: list[ConversationInfo],
@app.get('/microagent-management/conversations')
async def get_microagent_management_conversations(
selected_repository: str,
provider_handler: ProviderHandler,
) -> list[ConversationInfo]:
"""Apply microagent management specific filters to conversations.
page_id: str | None = None,
limit: int = 20,
conversation_store: ConversationStore = Depends(get_conversation_store),
provider_tokens: PROVIDER_TOKEN_TYPE = Depends(get_provider_tokens),
) -> ConversationInfoResultSet:
"""Get conversations for the microagent management page with pagination support.
Filters conversations by:
- Trigger type (MICROAGENT_MANAGEMENT)
- Repository match
- PR status (only open PRs)
This endpoint returns conversations with conversation_trigger = 'microagent_management'
and only includes conversations with active PRs. Pagination is supported.
Args:
conversations: List of conversations to filter
selected_repository: Repository to filter by
provider_handler: Handler for checking PR status
Returns:
Filtered list of conversations
page_id: Optional page ID for pagination
limit: Maximum number of results per page (default: 20)
selected_repository: Optional repository filter to limit results to a specific repository
conversation_store: Conversation store dependency
provider_tokens: Provider tokens for checking PR status
"""
filtered = []
for conversation in conversations:
conversation_metadata_result_set = await conversation_store.search(page_id, limit)
# Apply age filter first using common function
filtered_results = _filter_conversations_by_age(
conversation_metadata_result_set.results, config.conversation_max_age_seconds
)
# Check if the last PR is active (not closed/merged)
provider_handler = ProviderHandler(provider_tokens)
# Apply additional filters
final_filtered_results = []
for conversation in filtered_results:
# Only include microagent_management conversations
if conversation.trigger != ConversationTrigger.MICROAGENT_MANAGEMENT:
continue
# Apply repository filter
# Apply repository filter if specified
if conversation.selected_repository != selected_repository:
continue
# Check if PR is still open
if (
conversation.pr_number
and len(conversation.pr_number) > 0
@@ -1272,101 +1115,12 @@ async def _apply_microagent_filters(
# Skip this conversation if the PR is closed/merged
continue
filtered.append(conversation)
final_filtered_results.append(conversation)
return filtered
def _create_combined_page_id(
v0_next_page_id: str | None, v1_next_page_id: str | None
) -> str | None:
"""Create a combined page_id from V0 and V1 page_ids.
Args:
v0_next_page_id: Next page ID for V0 conversations
v1_next_page_id: Next page ID for V1 conversations
Returns:
Base64-encoded JSON combining both page_ids, or None if no next pages
"""
if not v0_next_page_id and not v1_next_page_id:
return None
next_page_data = {
'v0': v0_next_page_id,
'v1': v1_next_page_id,
}
return base64.b64encode(json.dumps(next_page_data).encode()).decode()
@app.get('/microagent-management/conversations')
async def get_microagent_management_conversations(
selected_repository: str,
page_id: str | None = None,
limit: int = 20,
conversation_store: ConversationStore = Depends(get_conversation_store),
provider_tokens: PROVIDER_TOKEN_TYPE = Depends(get_provider_tokens),
app_conversation_service: AppConversationService = app_conversation_service_dependency,
) -> ConversationInfoResultSet:
"""Get conversations for the microagent management page with pagination support.
This endpoint returns conversations with conversation_trigger = 'microagent_management'
and only includes conversations with active PRs. Pagination is supported.
Args:
page_id: Optional page ID for pagination
limit: Maximum number of results per page (default: 20)
selected_repository: Repository filter to limit results to a specific repository
conversation_store: Conversation store dependency
provider_tokens: Provider tokens for checking PR status
app_conversation_service: App conversation service for V1 conversations
Returns:
ConversationInfoResultSet with filtered and paginated results
"""
# Parse page_id to extract V0 and V1 components
v0_page_id, v1_page_id = _parse_combined_page_id(page_id)
# Fetch V0 conversations
conversation_metadata_result_set = await conversation_store.search(
v0_page_id, limit
return await _build_conversation_result_set(
final_filtered_results, conversation_metadata_result_set.next_page_id
)
# Fetch V1 conversations (with graceful error handling)
v1_conversations, v1_next_page_id = await _fetch_v1_conversations_safe(
app_conversation_service, v1_page_id, limit
)
# Process V0 conversations
v0_conversations = await _process_v0_conversations(conversation_metadata_result_set)
# Apply microagent-specific filters
provider_handler = ProviderHandler(provider_tokens)
v0_filtered = await _apply_microagent_filters(
v0_conversations, selected_repository, provider_handler
)
v1_filtered = await _apply_microagent_filters(
v1_conversations, selected_repository, provider_handler
)
# Combine and sort results
all_conversations = v0_filtered + v1_filtered
all_conversations.sort(
key=lambda x: x.created_at or datetime.min.replace(tzinfo=timezone.utc),
reverse=True,
)
# Limit to requested number of results
final_results = all_conversations[:limit]
# Create combined page_id for pagination
next_page_id = _create_combined_page_id(
conversation_metadata_result_set.next_page_id, v1_next_page_id
)
return ConversationInfoResultSet(results=final_results, next_page_id=next_page_id)
def _to_conversation_info(app_conversation: AppConversation) -> ConversationInfo:
"""Convert a V1 AppConversation into an old style ConversationInfo"""
@@ -1387,16 +1141,16 @@ def _to_conversation_info(app_conversation: AppConversation) -> ConversationInfo
if conversation_status == ConversationStatus.RUNNING:
runtime_status_mapping = {
ConversationExecutionStatus.ERROR: RuntimeStatus.ERROR,
ConversationExecutionStatus.IDLE: RuntimeStatus.READY,
ConversationExecutionStatus.RUNNING: RuntimeStatus.READY,
ConversationExecutionStatus.PAUSED: RuntimeStatus.READY,
ConversationExecutionStatus.WAITING_FOR_CONFIRMATION: RuntimeStatus.READY,
ConversationExecutionStatus.FINISHED: RuntimeStatus.READY,
ConversationExecutionStatus.STUCK: RuntimeStatus.ERROR,
AgentExecutionStatus.ERROR: RuntimeStatus.ERROR,
AgentExecutionStatus.IDLE: RuntimeStatus.READY,
AgentExecutionStatus.RUNNING: RuntimeStatus.READY,
AgentExecutionStatus.PAUSED: RuntimeStatus.READY,
AgentExecutionStatus.WAITING_FOR_CONFIRMATION: RuntimeStatus.READY,
AgentExecutionStatus.FINISHED: RuntimeStatus.READY,
AgentExecutionStatus.STUCK: RuntimeStatus.ERROR,
}
runtime_status = runtime_status_mapping.get(
app_conversation.execution_status, RuntimeStatus.ERROR
app_conversation.agent_status, RuntimeStatus.ERROR
)
else:
runtime_status = None

View File

@@ -10,7 +10,6 @@ from openhands.events.event_store import EventStore
from openhands.llm.llm_registry import LLMRegistry
from openhands.storage.data_models.settings import Settings
from openhands.storage.files import FileStore
from openhands.utils.environment import get_effective_llm_base_url
async def generate_conversation_title(
@@ -115,15 +114,10 @@ async def auto_generate_title(
try:
if settings and settings.llm_model:
# Create LLM config from settings
settings_base_url = settings.llm_base_url
effective_base_url = get_effective_llm_base_url(
settings.llm_model,
settings_base_url,
)
llm_config = LLMConfig(
model=settings.llm_model,
api_key=settings.llm_api_key,
base_url=effective_base_url,
base_url=settings.llm_base_url,
)
# Try to generate title using LLM

View File

@@ -1,58 +0,0 @@
from __future__ import annotations
import os
from functools import lru_cache
from pathlib import Path
LEMONADE_DOCKER_BASE_URL = 'http://host.docker.internal:8000/api/v1/'
_LEMONADE_PROVIDER_NAME = 'lemonade'
_LEMONADE_MODEL_PREFIX = 'lemonade/'
@lru_cache(maxsize=1)
def is_running_in_docker() -> bool:
"""Best-effort detection for Docker containers."""
docker_env_markers = (
Path('/.dockerenv'),
Path('/run/.containerenv'),
)
if any(marker.exists() for marker in docker_env_markers):
return True
if os.environ.get('DOCKER_CONTAINER') == 'true':
return True
try:
with Path('/proc/self/cgroup').open('r', encoding='utf-8') as cgroup_file:
for line in cgroup_file:
if any(token in line for token in ('docker', 'containerd', 'kubepods')):
return True
except FileNotFoundError:
pass
return False
def is_lemonade_provider(
model: str | None,
custom_provider: str | None = None,
) -> bool:
provider = (custom_provider or '').strip().lower()
if provider == _LEMONADE_PROVIDER_NAME:
return True
return (model or '').startswith(_LEMONADE_MODEL_PREFIX)
def get_effective_llm_base_url(
model: str | None,
base_url: str | None,
custom_provider: str | None = None,
) -> str | None:
"""Return the runtime LLM base URL with provider-specific overrides."""
if (
base_url in (None, '')
and is_lemonade_provider(model, custom_provider)
and is_running_in_docker()
):
return LEMONADE_DOCKER_BASE_URL
return base_url

View File

@@ -1,4 +1,3 @@
import os
from copy import deepcopy
from openhands.core.config.openhands_config import OpenHandsConfig
@@ -6,7 +5,6 @@ from openhands.llm.llm_registry import LLMRegistry
from openhands.server.services.conversation_stats import ConversationStats
from openhands.storage import get_file_store
from openhands.storage.data_models.settings import Settings
from openhands.utils.environment import get_effective_llm_base_url
def setup_llm_config(config: OpenHandsConfig, settings: Settings) -> OpenHandsConfig:
@@ -16,19 +14,7 @@ def setup_llm_config(config: OpenHandsConfig, settings: Settings) -> OpenHandsCo
llm_config = config.get_llm_config()
llm_config.model = settings.llm_model or ''
llm_config.api_key = settings.llm_api_key
env_base_url = os.environ.get('LLM_BASE_URL')
settings_base_url = settings.llm_base_url
# Use env_base_url if available, otherwise fall back to settings_base_url
base_url_to_use = (
env_base_url if env_base_url not in (None, '') else settings_base_url
)
llm_config.base_url = get_effective_llm_base_url(
llm_config.model,
base_url_to_use,
llm_config.custom_llm_provider,
)
llm_config.base_url = settings.llm_base_url
config.set_llm_config(llm_config)
return config

348
poetry.lock generated
View File

@@ -254,20 +254,19 @@ files = [
[[package]]
name = "anthropic"
version = "0.72.0"
version = "0.59.0"
description = "The official Python library for the anthropic API"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "anthropic-0.72.0-py3-none-any.whl", hash = "sha256:0e9f5a7582f038cab8efbb4c959e49ef654a56bfc7ba2da51b5a7b8a84de2e4d"},
{file = "anthropic-0.72.0.tar.gz", hash = "sha256:8971fe76dcffc644f74ac3883069beb1527641115ae0d6eb8fa21c1ce4082f7a"},
{file = "anthropic-0.59.0-py3-none-any.whl", hash = "sha256:cbc8b3dccef66ad6435c4fa1d317e5ebb092399a4b88b33a09dc4bf3944c3183"},
{file = "anthropic-0.59.0.tar.gz", hash = "sha256:d710d1ef0547ebbb64b03f219e44ba078e83fc83752b96a9b22e9726b523fd8f"},
]
[package.dependencies]
anyio = ">=3.5.0,<5"
distro = ">=1.7.0,<2"
docstring-parser = ">=0.15,<1"
google-auth = {version = ">=2,<3", extras = ["requests"], optional = true, markers = "extra == \"vertex\""}
httpx = ">=0.25.0,<1"
jiter = ">=0.4.0,<1"
@@ -276,7 +275,7 @@ sniffio = "*"
typing-extensions = ">=4.10,<5"
[package.extras]
aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.9)"]
aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.8)"]
bedrock = ["boto3 (>=1.28.57)", "botocore (>=1.31.57)"]
vertex = ["google-auth[requests] (>=2,<3)"]
@@ -1205,19 +1204,19 @@ botocore = ["botocore"]
[[package]]
name = "browser-use"
version = "0.8.0"
version = "0.7.10"
description = "Make websites accessible for AI agents"
optional = false
python-versions = "<4.0,>=3.11"
groups = ["main"]
files = [
{file = "browser_use-0.8.0-py3-none-any.whl", hash = "sha256:b7c299e38ec1c1aec42a236cc6ad2268a366226940d6ff9d88ed461afd5a1cc3"},
{file = "browser_use-0.8.0.tar.gz", hash = "sha256:2136eb3251424f712a08ee379c9337237c2f93b29b566807db599cf94e6abb5e"},
{file = "browser_use-0.7.10-py3-none-any.whl", hash = "sha256:669e12571a0c0c4c93e5fd26abf9e2534eb9bacbc510328aedcab795bd8906a9"},
{file = "browser_use-0.7.10.tar.gz", hash = "sha256:f93ce59e06906c12d120360dee4aa33d83618ddf7c9a575dd0ac517d2de7ccbc"},
]
[package.dependencies]
aiohttp = "3.12.15"
anthropic = ">=0.68.1,<1.0.0"
anthropic = ">=0.58.2,<1.0.0"
anyio = ">=4.9.0"
authlib = ">=1.6.0"
bubus = ">=1.5.6"
@@ -1249,11 +1248,11 @@ typing-extensions = ">=4.12.2"
uuid7 = ">=0.1.0"
[package.extras]
all = ["agentmail (==0.0.59)", "boto3 (>=1.38.45)", "botocore (>=1.37.23)", "click (>=8.1.8)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)", "rich (>=14.0.0)", "textual (>=3.2.0)"]
all = ["agentmail (>=0.0.53)", "boto3 (>=1.38.45)", "botocore (>=1.37.23)", "click (>=8.1.8)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)", "rich (>=14.0.0)", "textual (>=3.2.0)"]
aws = ["boto3 (>=1.38.45)"]
cli = ["click (>=8.1.8)", "rich (>=14.0.0)", "textual (>=3.2.0)"]
eval = ["anyio (>=4.9.0)", "browserbase (==1.4.0)", "datamodel-code-generator (>=0.26.0)", "hyperbrowser (==0.47.0)", "lmnr[all] (==0.7.17)", "psutil (>=7.0.0)"]
examples = ["agentmail (==0.0.59)", "botocore (>=1.37.23)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)"]
eval = ["anyio (>=4.9.0)", "browserbase (==1.4.0)", "datamodel-code-generator (>=0.26.0)", "hyperbrowser (==0.47.0)", "lmnr[all] (==0.7.10)", "psutil (>=7.0.0)"]
examples = ["agentmail (>=0.0.53)", "botocore (>=1.37.23)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)"]
video = ["imageio[ffmpeg] (>=2.37.0)", "numpy (>=2.3.2)"]
[[package]]
@@ -5626,62 +5625,6 @@ proxy = ["PyJWT (>=2.8.0,<3.0.0)", "apscheduler (>=3.10.4,<4.0.0)", "azure-ident
semantic-router = ["semantic-router ; python_version >= \"3.9\""]
utils = ["numpydoc"]
[[package]]
name = "lmnr"
version = "0.7.20"
description = "Python SDK for Laminar"
optional = false
python-versions = "<4,>=3.10"
groups = ["main"]
files = [
{file = "lmnr-0.7.20-py3-none-any.whl", hash = "sha256:5f9fa7444e6f96c25e097f66484ff29e632bdd1de0e9346948bf5595f4a8af38"},
{file = "lmnr-0.7.20.tar.gz", hash = "sha256:1f484cd618db2d71af65f90a0b8b36d20d80dc91a5138b811575c8677bf7c4fd"},
]
[package.dependencies]
grpcio = ">=1"
httpx = ">=0.24.0"
opentelemetry-api = ">=1.33.0"
opentelemetry-exporter-otlp-proto-grpc = ">=1.33.0"
opentelemetry-exporter-otlp-proto-http = ">=1.33.0"
opentelemetry-instrumentation = ">=0.54b0"
opentelemetry-instrumentation-threading = ">=0.57b0"
opentelemetry-sdk = ">=1.33.0"
opentelemetry-semantic-conventions = ">=0.54b0"
opentelemetry-semantic-conventions-ai = ">=0.4.13"
orjson = ">=3.0.0"
packaging = ">=22.0"
pydantic = ">=2.0.3,<3.0.0"
python-dotenv = ">=1.0"
tenacity = ">=8.0"
tqdm = ">=4.0"
[package.extras]
alephalpha = ["opentelemetry-instrumentation-alephalpha (>=0.47.1)"]
all = ["opentelemetry-instrumentation-alephalpha (>=0.47.1)", "opentelemetry-instrumentation-bedrock (>=0.47.1)", "opentelemetry-instrumentation-chromadb (>=0.47.1)", "opentelemetry-instrumentation-cohere (>=0.47.1)", "opentelemetry-instrumentation-crewai (>=0.47.1)", "opentelemetry-instrumentation-haystack (>=0.47.1)", "opentelemetry-instrumentation-lancedb (>=0.47.1)", "opentelemetry-instrumentation-langchain (>=0.47.1)", "opentelemetry-instrumentation-llamaindex (>=0.47.1)", "opentelemetry-instrumentation-marqo (>=0.47.1)", "opentelemetry-instrumentation-mcp (>=0.47.1)", "opentelemetry-instrumentation-milvus (>=0.47.1)", "opentelemetry-instrumentation-mistralai (>=0.47.1)", "opentelemetry-instrumentation-ollama (>=0.47.1)", "opentelemetry-instrumentation-pinecone (>=0.47.1)", "opentelemetry-instrumentation-qdrant (>=0.47.1)", "opentelemetry-instrumentation-replicate (>=0.47.1)", "opentelemetry-instrumentation-sagemaker (>=0.47.1)", "opentelemetry-instrumentation-together (>=0.47.1)", "opentelemetry-instrumentation-transformers (>=0.47.1)", "opentelemetry-instrumentation-vertexai (>=0.47.1)", "opentelemetry-instrumentation-watsonx (>=0.47.1)", "opentelemetry-instrumentation-weaviate (>=0.47.1)"]
bedrock = ["opentelemetry-instrumentation-bedrock (>=0.47.1)"]
chromadb = ["opentelemetry-instrumentation-chromadb (>=0.47.1)"]
cohere = ["opentelemetry-instrumentation-cohere (>=0.47.1)"]
crewai = ["opentelemetry-instrumentation-crewai (>=0.47.1)"]
haystack = ["opentelemetry-instrumentation-haystack (>=0.47.1)"]
lancedb = ["opentelemetry-instrumentation-lancedb (>=0.47.1)"]
langchain = ["opentelemetry-instrumentation-langchain (>=0.47.1)"]
llamaindex = ["opentelemetry-instrumentation-llamaindex (>=0.47.1)"]
marqo = ["opentelemetry-instrumentation-marqo (>=0.47.1)"]
mcp = ["opentelemetry-instrumentation-mcp (>=0.47.1)"]
milvus = ["opentelemetry-instrumentation-milvus (>=0.47.1)"]
mistralai = ["opentelemetry-instrumentation-mistralai (>=0.47.1)"]
ollama = ["opentelemetry-instrumentation-ollama (>=0.47.1)"]
pinecone = ["opentelemetry-instrumentation-pinecone (>=0.47.1)"]
qdrant = ["opentelemetry-instrumentation-qdrant (>=0.47.1)"]
replicate = ["opentelemetry-instrumentation-replicate (>=0.47.1)"]
sagemaker = ["opentelemetry-instrumentation-sagemaker (>=0.47.1)"]
together = ["opentelemetry-instrumentation-together (>=0.47.1)"]
transformers = ["opentelemetry-instrumentation-transformers (>=0.47.1)"]
vertexai = ["opentelemetry-instrumentation-vertexai (>=0.47.1)"]
watsonx = ["opentelemetry-instrumentation-watsonx (>=0.47.1)"]
weaviate = ["opentelemetry-instrumentation-weaviate (>=0.47.1)"]
[[package]]
name = "lxml"
version = "5.4.0"
@@ -7329,15 +7272,13 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
[[package]]
name = "openhands-agent-server"
version = "1.0.0a6"
version = "1.0.0a4"
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_agent_server-1.0.0a6-py3-none-any.whl", hash = "sha256:72b0da038ede018c55c64f0ac99bc5d991af173627efc63de87d54b3cd69134c"},
{file = "openhands_agent_server-1.0.0a6.tar.gz", hash = "sha256:8c6fbceb07990e3caf7f8797082d1bb614b9f7339bd00576c24fd34a956a03b4"},
]
files = []
develop = false
[package.dependencies]
aiosqlite = ">=0.19"
@@ -7350,23 +7291,27 @@ uvicorn = ">=0.31.1"
websockets = ">=12"
wsproto = ">=1.2.0"
[package.source]
type = "git"
url = "https://github.com/OpenHands/agent-sdk.git"
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
subdirectory = "openhands-agent-server"
[[package]]
name = "openhands-sdk"
version = "1.0.0a6"
version = "1.0.0a4"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_sdk-1.0.0a6-py3-none-any.whl", hash = "sha256:0b0b579fc48a5b7eaa418ca66188206ba00f4d883997bc29291bc1745e0b7ddc"},
{file = "openhands_sdk-1.0.0a6.tar.gz", hash = "sha256:01daff435c5f94037b9b4ba85054097ca6235982a9b0fee00341279d4c4b5a01"},
]
files = []
develop = false
[package.dependencies]
fastmcp = ">=2.11.3"
httpx = ">=0.27.0"
litellm = ">=1.77.7.dev9"
lmnr = ">=0.7.20"
pydantic = ">=2.11.7"
python-frontmatter = ">=1.1.0"
python-json-logger = ">=3.3.0"
@@ -7376,28 +7321,40 @@ websockets = ">=12"
[package.extras]
boto3 = ["boto3 (>=1.35.0)"]
[package.source]
type = "git"
url = "https://github.com/OpenHands/agent-sdk.git"
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
subdirectory = "openhands-sdk"
[[package]]
name = "openhands-tools"
version = "1.0.0a6"
version = "1.0.0a4"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_tools-1.0.0a6-py3-none-any.whl", hash = "sha256:55b75016f7e3930e4365393a026726eeffae027363d03862a17a8cebc1aed670"},
{file = "openhands_tools-1.0.0a6.tar.gz", hash = "sha256:4d5382f3e1cab9d23c1ef7ea8e36e821083886d6d4b019100cbf897e3b0cd3be"},
]
files = []
develop = false
[package.dependencies]
bashlex = ">=0.18"
binaryornot = ">=0.4.4"
browser-use = ">=0.8.0"
browser-use = ">=0.7.7"
cachetools = "*"
func-timeout = ">=4.3.5"
libtmux = ">=0.46.2"
openhands-sdk = "*"
pydantic = ">=2.11.7"
[package.source]
type = "git"
url = "https://github.com/OpenHands/agent-sdk.git"
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
subdirectory = "openhands-tools"
[[package]]
name = "openpyxl"
version = "3.1.5"
@@ -7415,14 +7372,14 @@ et-xmlfile = "*"
[[package]]
name = "opentelemetry-api"
version = "1.38.0"
version = "1.34.1"
description = "OpenTelemetry Python API"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_api-1.38.0-py3-none-any.whl", hash = "sha256:2891b0197f47124454ab9f0cf58f3be33faca394457ac3e09daba13ff50aa582"},
{file = "opentelemetry_api-1.38.0.tar.gz", hash = "sha256:f4c193b5e8acb0912b06ac5b16321908dd0843d75049c091487322284a3eea12"},
{file = "opentelemetry_api-1.34.1-py3-none-any.whl", hash = "sha256:b7df4cb0830d5a6c29ad0c0691dbae874d8daefa934b8b1d642de48323d32a8c"},
{file = "opentelemetry_api-1.34.1.tar.gz", hash = "sha256:64f0bd06d42824843731d05beea88d4d4b6ae59f9fe347ff7dfa2cc14233bbb3"},
]
[package.dependencies]
@@ -7431,256 +7388,91 @@ typing-extensions = ">=4.5.0"
[[package]]
name = "opentelemetry-exporter-otlp-proto-common"
version = "1.38.0"
version = "1.34.1"
description = "OpenTelemetry Protobuf encoding"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_exporter_otlp_proto_common-1.38.0-py3-none-any.whl", hash = "sha256:03cb76ab213300fe4f4c62b7d8f17d97fcfd21b89f0b5ce38ea156327ddda74a"},
{file = "opentelemetry_exporter_otlp_proto_common-1.38.0.tar.gz", hash = "sha256:e333278afab4695aa8114eeb7bf4e44e65c6607d54968271a249c180b2cb605c"},
{file = "opentelemetry_exporter_otlp_proto_common-1.34.1-py3-none-any.whl", hash = "sha256:8e2019284bf24d3deebbb6c59c71e6eef3307cd88eff8c633e061abba33f7e87"},
{file = "opentelemetry_exporter_otlp_proto_common-1.34.1.tar.gz", hash = "sha256:b59a20a927facd5eac06edaf87a07e49f9e4a13db487b7d8a52b37cb87710f8b"},
]
[package.dependencies]
opentelemetry-proto = "1.38.0"
opentelemetry-proto = "1.34.1"
[[package]]
name = "opentelemetry-exporter-otlp-proto-grpc"
version = "1.38.0"
version = "1.34.1"
description = "OpenTelemetry Collector Protobuf over gRPC Exporter"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_exporter_otlp_proto_grpc-1.38.0-py3-none-any.whl", hash = "sha256:7c49fd9b4bd0dbe9ba13d91f764c2d20b0025649a6e4ac35792fb8d84d764bc7"},
{file = "opentelemetry_exporter_otlp_proto_grpc-1.38.0.tar.gz", hash = "sha256:2473935e9eac71f401de6101d37d6f3f0f1831db92b953c7dcc912536158ebd6"},
{file = "opentelemetry_exporter_otlp_proto_grpc-1.34.1-py3-none-any.whl", hash = "sha256:04bb8b732b02295be79f8a86a4ad28fae3d4ddb07307a98c7aa6f331de18cca6"},
{file = "opentelemetry_exporter_otlp_proto_grpc-1.34.1.tar.gz", hash = "sha256:7c841b90caa3aafcfc4fee58487a6c71743c34c6dc1787089d8b0578bbd794dd"},
]
[package.dependencies]
googleapis-common-protos = ">=1.57,<2.0"
googleapis-common-protos = ">=1.52,<2.0"
grpcio = [
{version = ">=1.66.2,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.63.2,<2.0.0", markers = "python_version < \"3.13\""},
]
opentelemetry-api = ">=1.15,<2.0"
opentelemetry-exporter-otlp-proto-common = "1.38.0"
opentelemetry-proto = "1.38.0"
opentelemetry-sdk = ">=1.38.0,<1.39.0"
typing-extensions = ">=4.6.0"
[[package]]
name = "opentelemetry-exporter-otlp-proto-http"
version = "1.38.0"
description = "OpenTelemetry Collector Protobuf over HTTP Exporter"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_exporter_otlp_proto_http-1.38.0-py3-none-any.whl", hash = "sha256:84b937305edfc563f08ec69b9cb2298be8188371217e867c1854d77198d0825b"},
{file = "opentelemetry_exporter_otlp_proto_http-1.38.0.tar.gz", hash = "sha256:f16bd44baf15cbe07633c5112ffc68229d0edbeac7b37610be0b2def4e21e90b"},
]
[package.dependencies]
googleapis-common-protos = ">=1.52,<2.0"
opentelemetry-api = ">=1.15,<2.0"
opentelemetry-exporter-otlp-proto-common = "1.38.0"
opentelemetry-proto = "1.38.0"
opentelemetry-sdk = ">=1.38.0,<1.39.0"
requests = ">=2.7,<3.0"
opentelemetry-exporter-otlp-proto-common = "1.34.1"
opentelemetry-proto = "1.34.1"
opentelemetry-sdk = ">=1.34.1,<1.35.0"
typing-extensions = ">=4.5.0"
[[package]]
name = "opentelemetry-instrumentation"
version = "0.59b0"
description = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_instrumentation-0.59b0-py3-none-any.whl", hash = "sha256:44082cc8fe56b0186e87ee8f7c17c327c4c2ce93bdbe86496e600985d74368ee"},
{file = "opentelemetry_instrumentation-0.59b0.tar.gz", hash = "sha256:6010f0faaacdaf7c4dff8aac84e226d23437b331dcda7e70367f6d73a7db1adc"},
]
[package.dependencies]
opentelemetry-api = ">=1.4,<2.0"
opentelemetry-semantic-conventions = "0.59b0"
packaging = ">=18.0"
wrapt = ">=1.0.0,<2.0.0"
[[package]]
name = "opentelemetry-instrumentation-threading"
version = "0.59b0"
description = "Thread context propagation support for OpenTelemetry"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_instrumentation_threading-0.59b0-py3-none-any.whl", hash = "sha256:76da2fc01fe1dccebff6581080cff9e42ac7b27cc61eb563f3c4435c727e8eca"},
{file = "opentelemetry_instrumentation_threading-0.59b0.tar.gz", hash = "sha256:ce5658730b697dcbc0e0d6d13643a69fd8aeb1b32fa8db3bade8ce114c7975f3"},
]
[package.dependencies]
opentelemetry-api = ">=1.12,<2.0"
opentelemetry-instrumentation = "0.59b0"
wrapt = ">=1.0.0,<2.0.0"
[[package]]
name = "opentelemetry-proto"
version = "1.38.0"
version = "1.34.1"
description = "OpenTelemetry Python Proto"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_proto-1.38.0-py3-none-any.whl", hash = "sha256:b6ebe54d3217c42e45462e2a1ae28c3e2bf2ec5a5645236a490f55f45f1a0a18"},
{file = "opentelemetry_proto-1.38.0.tar.gz", hash = "sha256:88b161e89d9d372ce723da289b7da74c3a8354a8e5359992be813942969ed468"},
{file = "opentelemetry_proto-1.34.1-py3-none-any.whl", hash = "sha256:eb4bb5ac27f2562df2d6857fc557b3a481b5e298bc04f94cc68041f00cebcbd2"},
{file = "opentelemetry_proto-1.34.1.tar.gz", hash = "sha256:16286214e405c211fc774187f3e4bbb1351290b8dfb88e8948af209ce85b719e"},
]
[package.dependencies]
protobuf = ">=5.0,<7.0"
protobuf = ">=5.0,<6.0"
[[package]]
name = "opentelemetry-sdk"
version = "1.38.0"
version = "1.34.1"
description = "OpenTelemetry Python SDK"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_sdk-1.38.0-py3-none-any.whl", hash = "sha256:1c66af6564ecc1553d72d811a01df063ff097cdc82ce188da9951f93b8d10f6b"},
{file = "opentelemetry_sdk-1.38.0.tar.gz", hash = "sha256:93df5d4d871ed09cb4272305be4d996236eedb232253e3ab864c8620f051cebe"},
{file = "opentelemetry_sdk-1.34.1-py3-none-any.whl", hash = "sha256:308effad4059562f1d92163c61c8141df649da24ce361827812c40abb2a1e96e"},
{file = "opentelemetry_sdk-1.34.1.tar.gz", hash = "sha256:8091db0d763fcd6098d4781bbc80ff0971f94e260739aa6afe6fd379cdf3aa4d"},
]
[package.dependencies]
opentelemetry-api = "1.38.0"
opentelemetry-semantic-conventions = "0.59b0"
opentelemetry-api = "1.34.1"
opentelemetry-semantic-conventions = "0.55b1"
typing-extensions = ">=4.5.0"
[[package]]
name = "opentelemetry-semantic-conventions"
version = "0.59b0"
version = "0.55b1"
description = "OpenTelemetry Semantic Conventions"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_semantic_conventions-0.59b0-py3-none-any.whl", hash = "sha256:35d3b8833ef97d614136e253c1da9342b4c3c083bbaf29ce31d572a1c3825eed"},
{file = "opentelemetry_semantic_conventions-0.59b0.tar.gz", hash = "sha256:7a6db3f30d70202d5bf9fa4b69bc866ca6a30437287de6c510fb594878aed6b0"},
{file = "opentelemetry_semantic_conventions-0.55b1-py3-none-any.whl", hash = "sha256:5da81dfdf7d52e3d37f8fe88d5e771e191de924cfff5f550ab0b8f7b2409baed"},
{file = "opentelemetry_semantic_conventions-0.55b1.tar.gz", hash = "sha256:ef95b1f009159c28d7a7849f5cbc71c4c34c845bb514d66adfdf1b3fff3598b3"},
]
[package.dependencies]
opentelemetry-api = "1.38.0"
opentelemetry-api = "1.34.1"
typing-extensions = ">=4.5.0"
[[package]]
name = "opentelemetry-semantic-conventions-ai"
version = "0.4.13"
description = "OpenTelemetry Semantic Conventions Extension for Large Language Models"
optional = false
python-versions = "<4,>=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_semantic_conventions_ai-0.4.13-py3-none-any.whl", hash = "sha256:883a30a6bb5deaec0d646912b5f9f6dcbb9f6f72557b73d0f2560bf25d13e2d5"},
{file = "opentelemetry_semantic_conventions_ai-0.4.13.tar.gz", hash = "sha256:94efa9fb4ffac18c45f54a3a338ffeb7eedb7e1bb4d147786e77202e159f0036"},
]
[[package]]
name = "orjson"
version = "3.11.4"
description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "orjson-3.11.4-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e3aa2118a3ece0d25489cbe48498de8a5d580e42e8d9979f65bf47900a15aba1"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a69ab657a4e6733133a3dca82768f2f8b884043714e8d2b9ba9f52b6efef5c44"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3740bffd9816fc0326ddc406098a3a8f387e42223f5f455f2a02a9f834ead80c"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65fd2f5730b1bf7f350c6dc896173d3460d235c4be007af73986d7cd9a2acd23"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fdc3ae730541086158d549c97852e2eea6820665d4faf0f41bf99df41bc11ea"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e10b4d65901da88845516ce9f7f9736f9638d19a1d483b3883dc0182e6e5edba"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb6a03a678085f64b97f9d4a9ae69376ce91a3a9e9b56a82b1580d8e1d501aff"},
{file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2c82e4f0b1c712477317434761fbc28b044c838b6b1240d895607441412371ac"},
{file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:d58c166a18f44cc9e2bad03a327dc2d1a3d2e85b847133cfbafd6bfc6719bd79"},
{file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:94f206766bf1ea30e1382e4890f763bd1eefddc580e08fec1ccdc20ddd95c827"},
{file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:41bf25fb39a34cf8edb4398818523277ee7096689db352036a9e8437f2f3ee6b"},
{file = "orjson-3.11.4-cp310-cp310-win32.whl", hash = "sha256:fa9627eba4e82f99ca6d29bc967f09aba446ee2b5a1ea728949ede73d313f5d3"},
{file = "orjson-3.11.4-cp310-cp310-win_amd64.whl", hash = "sha256:23ef7abc7fca96632d8174ac115e668c1e931b8fe4dde586e92a500bf1914dcc"},
{file = "orjson-3.11.4-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:5e59d23cd93ada23ec59a96f215139753fbfe3a4d989549bcb390f8c00370b39"},
{file = "orjson-3.11.4-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:5c3aedecfc1beb988c27c79d52ebefab93b6c3921dbec361167e6559aba2d36d"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da9e5301f1c2caa2a9a4a303480d79c9ad73560b2e7761de742ab39fe59d9175"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8873812c164a90a79f65368f8f96817e59e35d0cc02786a5356f0e2abed78040"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5d7feb0741ebb15204e748f26c9638e6665a5fa93c37a2c73d64f1669b0ddc63"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ee5487fefee21e6910da4c2ee9eef005bee568a0879834df86f888d2ffbdd9"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d40d46f348c0321df01507f92b95a377240c4ec31985225a6668f10e2676f9a"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95713e5fc8af84d8edc75b785d2386f653b63d62b16d681687746734b4dfc0be"},
{file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ad73ede24f9083614d6c4ca9a85fe70e33be7bf047ec586ee2363bc7418fe4d7"},
{file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:842289889de515421f3f224ef9c1f1efb199a32d76d8d2ca2706fa8afe749549"},
{file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3b2427ed5791619851c52a1261b45c233930977e7de8cf36de05636c708fa905"},
{file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3c36e524af1d29982e9b190573677ea02781456b2e537d5840e4538a5ec41907"},
{file = "orjson-3.11.4-cp311-cp311-win32.whl", hash = "sha256:87255b88756eab4a68ec61837ca754e5d10fa8bc47dc57f75cedfeaec358d54c"},
{file = "orjson-3.11.4-cp311-cp311-win_amd64.whl", hash = "sha256:e2d5d5d798aba9a0e1fede8d853fa899ce2cb930ec0857365f700dffc2c7af6a"},
{file = "orjson-3.11.4-cp311-cp311-win_arm64.whl", hash = "sha256:6bb6bb41b14c95d4f2702bce9975fda4516f1db48e500102fc4d8119032ff045"},
{file = "orjson-3.11.4-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:d4371de39319d05d3f482f372720b841c841b52f5385bd99c61ed69d55d9ab50"},
{file = "orjson-3.11.4-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e41fd3b3cac850eaae78232f37325ed7d7436e11c471246b87b2cd294ec94853"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600e0e9ca042878c7fdf189cf1b028fe2c1418cc9195f6cb9824eb6ed99cb938"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7bbf9b333f1568ef5da42bc96e18bf30fd7f8d54e9ae066d711056add508e415"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4806363144bb6e7297b8e95870e78d30a649fdc4e23fc84daa80c8ebd366ce44"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad355e8308493f527d41154e9053b86a5be892b3b359a5c6d5d95cda23601cb2"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a7517482667fb9f0ff1b2f16fe5829296ed7a655d04d68cd9711a4d8a4e708"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97eb5942c7395a171cbfecc4ef6701fc3c403e762194683772df4c54cfbb2210"},
{file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:149d95d5e018bdd822e3f38c103b1a7c91f88d38a88aada5c4e9b3a73a244241"},
{file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:624f3951181eb46fc47dea3d221554e98784c823e7069edb5dbd0dc826ac909b"},
{file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:03bfa548cf35e3f8b3a96c4e8e41f753c686ff3d8e182ce275b1751deddab58c"},
{file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:525021896afef44a68148f6ed8a8bf8375553d6066c7f48537657f64823565b9"},
{file = "orjson-3.11.4-cp312-cp312-win32.whl", hash = "sha256:b58430396687ce0f7d9eeb3dd47761ca7d8fda8e9eb92b3077a7a353a75efefa"},
{file = "orjson-3.11.4-cp312-cp312-win_amd64.whl", hash = "sha256:c6dbf422894e1e3c80a177133c0dda260f81428f9de16d61041949f6a2e5c140"},
{file = "orjson-3.11.4-cp312-cp312-win_arm64.whl", hash = "sha256:d38d2bc06d6415852224fcc9c0bfa834c25431e466dc319f0edd56cca81aa96e"},
{file = "orjson-3.11.4-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:2d6737d0e616a6e053c8b4acc9eccea6b6cce078533666f32d140e4f85002534"},
{file = "orjson-3.11.4-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:afb14052690aa328cc118a8e09f07c651d301a72e44920b887c519b313d892ff"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38aa9e65c591febb1b0aed8da4d469eba239d434c218562df179885c94e1a3ad"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f2cf4dfaf9163b0728d061bebc1e08631875c51cd30bf47cb9e3293bfbd7dcd5"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89216ff3dfdde0e4070932e126320a1752c9d9a758d6a32ec54b3b9334991a6a"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9daa26ca8e97fae0ce8aa5d80606ef8f7914e9b129b6b5df9104266f764ce436"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c8b2769dc31883c44a9cd126560327767f848eb95f99c36c9932f51090bfce9"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1469d254b9884f984026bd9b0fa5bbab477a4bfe558bba6848086f6d43eb5e73"},
{file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:68e44722541983614e37117209a194e8c3ad07838ccb3127d96863c95ec7f1e0"},
{file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8e7805fda9672c12be2f22ae124dcd7b03928d6c197544fe12174b86553f3196"},
{file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:04b69c14615fb4434ab867bf6f38b2d649f6f300af30a6705397e895f7aec67a"},
{file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:639c3735b8ae7f970066930e58cf0ed39a852d417c24acd4a25fc0b3da3c39a6"},
{file = "orjson-3.11.4-cp313-cp313-win32.whl", hash = "sha256:6c13879c0d2964335491463302a6ca5ad98105fc5db3565499dcb80b1b4bd839"},
{file = "orjson-3.11.4-cp313-cp313-win_amd64.whl", hash = "sha256:09bf242a4af98732db9f9a1ec57ca2604848e16f132e3f72edfd3c5c96de009a"},
{file = "orjson-3.11.4-cp313-cp313-win_arm64.whl", hash = "sha256:a85f0adf63319d6c1ba06fb0dbf997fced64a01179cf17939a6caca662bf92de"},
{file = "orjson-3.11.4-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:42d43a1f552be1a112af0b21c10a5f553983c2a0938d2bbb8ecd8bc9fb572803"},
{file = "orjson-3.11.4-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:26a20f3fbc6c7ff2cb8e89c4c5897762c9d88cf37330c6a117312365d6781d54"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e3f20be9048941c7ffa8fc523ccbd17f82e24df1549d1d1fe9317712d19938e"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aac364c758dc87a52e68e349924d7e4ded348dedff553889e4d9f22f74785316"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d5c54a6d76e3d741dcc3f2707f8eeb9ba2a791d3adbf18f900219b62942803b1"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f28485bdca8617b79d44627f5fb04336897041dfd9fa66d383a49d09d86798bc"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bfc2a484cad3585e4ba61985a6062a4c2ed5c7925db6d39f1fa267c9d166487f"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e34dbd508cb91c54f9c9788923daca129fe5b55c5b4eebe713bf5ed3791280cf"},
{file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b13c478fa413d4b4ee606ec8e11c3b2e52683a640b006bb586b3041c2ca5f606"},
{file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:724ca721ecc8a831b319dcd72cfa370cc380db0bf94537f08f7edd0a7d4e1780"},
{file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:977c393f2e44845ce1b540e19a786e9643221b3323dae190668a98672d43fb23"},
{file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e539e382cf46edec157ad66b0b0872a90d829a6b71f17cb633d6c160a223155"},
{file = "orjson-3.11.4-cp314-cp314-win32.whl", hash = "sha256:d63076d625babab9db5e7836118bdfa086e60f37d8a174194ae720161eb12394"},
{file = "orjson-3.11.4-cp314-cp314-win_amd64.whl", hash = "sha256:0a54d6635fa3aaa438ae32e8570b9f0de36f3f6562c308d2a2a452e8b0592db1"},
{file = "orjson-3.11.4-cp314-cp314-win_arm64.whl", hash = "sha256:78b999999039db3cf58f6d230f524f04f75f129ba3d1ca2ed121f8657e575d3d"},
{file = "orjson-3.11.4-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:405261b0a8c62bcbd8e2931c26fdc08714faf7025f45531541e2b29e544b545b"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:af02ff34059ee9199a3546f123a6ab4c86caf1708c79042caf0820dc290a6d4f"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0b2eba969ea4203c177c7b38b36c69519e6067ee68c34dc37081fac74c796e10"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0baa0ea43cfa5b008a28d3c07705cf3ada40e5d347f0f44994a64b1b7b4b5350"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80fd082f5dcc0e94657c144f1b2a3a6479c44ad50be216cf0c244e567f5eae19"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e3704d35e47d5bee811fb1cbd8599f0b4009b14d451c4c57be5a7e25eb89a13"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:caa447f2b5356779d914658519c874cf3b7629e99e63391ed519c28c8aea4919"},
{file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:bba5118143373a86f91dadb8df41d9457498226698ebdf8e11cbb54d5b0e802d"},
{file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:622463ab81d19ef3e06868b576551587de8e4d518892d1afab71e0fbc1f9cffc"},
{file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3e0a700c4b82144b72946b6629968df9762552ee1344bfdb767fecdd634fbd5a"},
{file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6e18a5c15e764e5f3fc569b47872450b4bcea24f2a6354c0a0e95ad21045d5a9"},
{file = "orjson-3.11.4-cp39-cp39-win32.whl", hash = "sha256:fb1c37c71cad991ef4d89c7a634b5ffb4447dbd7ae3ae13e8f5ee7f1775e7ab1"},
{file = "orjson-3.11.4-cp39-cp39-win_amd64.whl", hash = "sha256:e2985ce8b8c42d00492d0ed79f2bd2b6460d00f2fa671dfde4bf2e02f49bf5c6"},
{file = "orjson-3.11.4.tar.gz", hash = "sha256:39485f4ab4c9b30a3943cfe99e1a213c4776fb69e8abd68f66b83d5a0b0fdc6d"},
]
[[package]]
name = "overrides"
version = "7.7.0"
@@ -16729,4 +16521,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "57ed6b7f4613e668fd1d0e10a21f7c915cdbb9c7b906a0b71a8ba222733c082d"
content-hash = "88c894ef3b6bb22b5e0f0dd92f3cede5f4145cb5b52d1970ff0e1d1780e7a4c9"

View File

@@ -6,7 +6,7 @@ requires = [
[tool.poetry]
name = "openhands-ai"
version = "0.61.0"
version = "0.60.0"
description = "OpenHands: Code Less, Make More"
authors = [ "OpenHands" ]
license = "MIT"
@@ -113,17 +113,16 @@ e2b-code-interpreter = { version = "^2.0.0", optional = true }
pybase62 = "^1.0.0"
# V1 dependencies
#openhands-agent-server = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-agent-server", rev = "be9725b459c0afabc18cfba89acf11dc756b42f0" }
#openhands-sdk = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-sdk", rev = "be9725b459c0afabc18cfba89acf11dc756b42f0" }
#openhands-tools = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-tools", rev = "be9725b459c0afabc18cfba89acf11dc756b42f0" }
openhands-sdk = "1.0.0a6"
openhands-agent-server = "1.0.0a6"
openhands-tools = "1.0.0a6"
openhands-agent-server = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-agent-server", rev = "3d8af53b2f0259dc98555a4acd4238f90e0afbce" }
openhands-sdk = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-sdk", rev = "3d8af53b2f0259dc98555a4acd4238f90e0afbce" }
openhands-tools = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-tools", rev = "3d8af53b2f0259dc98555a4acd4238f90e0afbce" }
#openhands-sdk = "1.0.0a5"
#openhands-agent-server = "1.0.0a5"
#openhands-tools = "1.0.0a5"
python-jose = { version = ">=3.3", extras = [ "cryptography" ] }
sqlalchemy = { extras = [ "asyncio" ], version = "^2.0.40" }
pg8000 = "^1.31.5"
asyncpg = "^0.30.0"
lmnr = "^0.7.20"
[tool.poetry.extras]
third_party_runtimes = [ "e2b-code-interpreter", "modal", "runloop-api-client", "daytona" ]

View File

@@ -17,7 +17,6 @@ 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
from openhands.runtime.plugins import AgentSkillsRequirement, JupyterRequirement
from openhands.runtime.utils.port_lock import find_available_port_with_lock
from openhands.storage import get_file_store
from openhands.utils.async_utils import call_async_from_sync
@@ -295,49 +294,9 @@ def _load_runtime(
return runtime, runtime.config
# Port range for test HTTP servers (separate from runtime ports to avoid conflicts)
TEST_HTTP_SERVER_PORT_RANGE = (18000, 18999)
@pytest.fixture
def dynamic_port(request):
"""Allocate a dynamic port with locking to prevent race conditions in parallel tests.
This fixture uses the existing port locking system to ensure that parallel test
workers don't try to use the same port for HTTP servers.
Returns:
int: An available port number that is locked for this test
"""
result = find_available_port_with_lock(
min_port=TEST_HTTP_SERVER_PORT_RANGE[0],
max_port=TEST_HTTP_SERVER_PORT_RANGE[1],
max_attempts=20,
bind_address='0.0.0.0',
lock_timeout=2.0,
)
if result is None:
pytest.fail(
f'Could not allocate a dynamic port in range {TEST_HTTP_SERVER_PORT_RANGE}'
)
port, port_lock = result
logger.info(f'Allocated dynamic port {port} for test {request.node.name}')
def cleanup():
if port_lock:
port_lock.release()
logger.info(f'Released dynamic port {port} for test {request.node.name}')
request.addfinalizer(cleanup)
return port
# Export necessary function
__all__ = [
'_load_runtime',
'_get_host_folder',
'_remove_folder',
'dynamic_port',
]

View File

@@ -51,11 +51,12 @@ def get_platform_command(linux_cmd, windows_cmd):
return windows_cmd if is_windows() else linux_cmd
def test_bash_server(temp_dir, runtime_cls, run_as_openhands, dynamic_port):
@pytest.mark.skip(reason='This test is flaky')
def test_bash_server(temp_dir, runtime_cls, run_as_openhands):
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
try:
# Use python -u for unbuffered output, potentially helping capture initial output on Windows
action = CmdRunAction(command=f'python -u -m http.server {dynamic_port}')
action = CmdRunAction(command='python -u -m http.server 8081')
action.set_hard_timeout(1)
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -110,7 +111,7 @@ def test_bash_server(temp_dir, runtime_cls, run_as_openhands, dynamic_port):
assert config.workspace_mount_path_in_sandbox in obs.metadata.working_dir
# run it again!
action = CmdRunAction(command=f'python -u -m http.server {dynamic_port}')
action = CmdRunAction(command='python -u -m http.server 8081')
action.set_hard_timeout(1)
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -122,9 +123,9 @@ def test_bash_server(temp_dir, runtime_cls, run_as_openhands, dynamic_port):
_close_test_runtime(runtime)
def test_bash_background_server(temp_dir, runtime_cls, run_as_openhands, dynamic_port):
def test_bash_background_server(temp_dir, runtime_cls, run_as_openhands):
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
server_port = dynamic_port
server_port = 8081
try:
# Start the server, expect it to timeout (run in background manner)
action = CmdRunAction(f'python3 -m http.server {server_port} &')

View File

@@ -123,21 +123,17 @@ def find_element_by_tag_and_attributes(
return None
def test_browser_disabled(temp_dir, runtime_cls, run_as_openhands, dynamic_port):
def test_browser_disabled(temp_dir, runtime_cls, run_as_openhands):
runtime, _ = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=False
)
action_cmd = CmdRunAction(
command=f'python3 -m http.server {dynamic_port} > server.log 2>&1 &'
)
action_cmd = CmdRunAction(command='python3 -m http.server 8000 > server.log 2>&1 &')
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
action_browse = BrowseURLAction(
url=f'http://localhost:{dynamic_port}', return_axtree=False
)
action_browse = BrowseURLAction(url='http://localhost:8000', return_axtree=False)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -147,15 +143,13 @@ def test_browser_disabled(temp_dir, runtime_cls, run_as_openhands, dynamic_port)
_close_test_runtime(runtime)
def test_simple_browse(temp_dir, runtime_cls, run_as_openhands, dynamic_port):
def test_simple_browse(temp_dir, runtime_cls, run_as_openhands):
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
)
# Test browse
action_cmd = CmdRunAction(
command=f'python3 -m http.server {dynamic_port} > server.log 2>&1 &'
)
action_cmd = CmdRunAction(command='python3 -m http.server 8000 > server.log 2>&1 &')
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -170,19 +164,17 @@ def test_simple_browse(temp_dir, runtime_cls, run_as_openhands, dynamic_port):
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.exit_code == 0
action_browse = BrowseURLAction(
url=f'http://localhost:{dynamic_port}', return_axtree=False
)
action_browse = BrowseURLAction(url='http://localhost:8000', return_axtree=False)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert f'http://localhost:{dynamic_port}' in obs.url
assert 'http://localhost:8000' in obs.url
assert not obs.error
assert obs.open_pages_urls == [f'http://localhost:{dynamic_port}/']
assert obs.open_pages_urls == ['http://localhost:8000/']
assert obs.active_page_index == 0
assert obs.last_browser_action == f'goto("http://localhost:{dynamic_port}")'
assert obs.last_browser_action == 'goto("http://localhost:8000")'
assert obs.last_browser_action_error == ''
assert 'Directory listing for /' in obs.content
assert 'server.log' in obs.content
@@ -197,9 +189,7 @@ def test_simple_browse(temp_dir, runtime_cls, run_as_openhands, dynamic_port):
_close_test_runtime(runtime)
def test_browser_navigation_actions(
temp_dir, runtime_cls, run_as_openhands, dynamic_port
):
def test_browser_navigation_actions(temp_dir, runtime_cls, run_as_openhands):
"""Test browser navigation actions: goto, go_back, go_forward, noop."""
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
@@ -244,7 +234,7 @@ def test_browser_navigation_actions(
# Start HTTP server
action_cmd = CmdRunAction(
command=f'python3 -m http.server {dynamic_port} > server.log 2>&1 &'
command='python3 -m http.server 8000 > server.log 2>&1 &'
)
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
@@ -259,7 +249,7 @@ def test_browser_navigation_actions(
# Test goto action
action_browse = BrowseInteractiveAction(
browser_actions=f'goto("http://localhost:{dynamic_port}/page1.html")',
browser_actions='goto("http://localhost:8000/page1.html")',
return_axtree=False,
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
@@ -269,7 +259,7 @@ def test_browser_navigation_actions(
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error
assert 'Page 1' in obs.content
assert f'http://localhost:{dynamic_port}/page1.html' in obs.url
assert 'http://localhost:8000/page1.html' in obs.url
# Test noop action (should not change page)
action_browse = BrowseInteractiveAction(
@@ -282,11 +272,11 @@ def test_browser_navigation_actions(
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error
assert 'Page 1' in obs.content
assert f'http://localhost:{dynamic_port}/page1.html' in obs.url
assert 'http://localhost:8000/page1.html' in obs.url
# Navigate to page 2
action_browse = BrowseInteractiveAction(
browser_actions=f'goto("http://localhost:{dynamic_port}/page2.html")',
browser_actions='goto("http://localhost:8000/page2.html")',
return_axtree=False,
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
@@ -296,7 +286,7 @@ def test_browser_navigation_actions(
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error
assert 'Page 2' in obs.content
assert f'http://localhost:{dynamic_port}/page2.html' in obs.url
assert 'http://localhost:8000/page2.html' in obs.url
# Test go_back action
action_browse = BrowseInteractiveAction(
@@ -309,7 +299,7 @@ def test_browser_navigation_actions(
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error
assert 'Page 1' in obs.content
assert f'http://localhost:{dynamic_port}/page1.html' in obs.url
assert 'http://localhost:8000/page1.html' in obs.url
# Test go_forward action
action_browse = BrowseInteractiveAction(
@@ -322,7 +312,7 @@ def test_browser_navigation_actions(
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error
assert 'Page 2' in obs.content
assert f'http://localhost:{dynamic_port}/page2.html' in obs.url
assert 'http://localhost:8000/page2.html' in obs.url
# Clean up
action_cmd = CmdRunAction(command='pkill -f "python3 -m http.server" || true')
@@ -334,9 +324,7 @@ def test_browser_navigation_actions(
_close_test_runtime(runtime)
def test_browser_form_interactions(
temp_dir, runtime_cls, run_as_openhands, dynamic_port
):
def test_browser_form_interactions(temp_dir, runtime_cls, run_as_openhands):
"""Test browser form interaction actions: fill, click, select_option, clear."""
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
@@ -382,7 +370,7 @@ def test_browser_form_interactions(
# Start HTTP server
action_cmd = CmdRunAction(
command=f'python3 -m http.server {dynamic_port} > server.log 2>&1 &'
command='python3 -m http.server 8000 > server.log 2>&1 &'
)
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
@@ -397,7 +385,7 @@ def test_browser_form_interactions(
# Navigate to form page
action_browse = BrowseInteractiveAction(
browser_actions=f'goto("http://localhost:{dynamic_port}/form.html")',
browser_actions='goto("http://localhost:8000/form.html")',
return_axtree=True, # Need axtree to get element bids
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
@@ -552,9 +540,7 @@ fill("{textarea_bid}", "This is a test message")
_close_test_runtime(runtime)
def test_browser_interactive_actions(
temp_dir, runtime_cls, run_as_openhands, dynamic_port
):
def test_browser_interactive_actions(temp_dir, runtime_cls, run_as_openhands):
"""Test browser interactive actions: scroll, hover, fill, press, focus."""
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
@@ -601,7 +587,7 @@ def test_browser_interactive_actions(
# Start HTTP server
action_cmd = CmdRunAction(
command=f'python3 -m http.server {dynamic_port} > server.log 2>&1 &'
command='python3 -m http.server 8000 > server.log 2>&1 &'
)
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
@@ -616,7 +602,7 @@ def test_browser_interactive_actions(
# Navigate to scroll page
action_browse = BrowseInteractiveAction(
browser_actions=f'goto("http://localhost:{dynamic_port}/scroll.html")',
browser_actions='goto("http://localhost:8000/scroll.html")',
return_axtree=True,
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
@@ -762,7 +748,7 @@ scroll(0, 400)
_close_test_runtime(runtime)
def test_browser_file_upload(temp_dir, runtime_cls, run_as_openhands, dynamic_port):
def test_browser_file_upload(temp_dir, runtime_cls, run_as_openhands):
"""Test browser file upload action."""
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
@@ -813,7 +799,7 @@ def test_browser_file_upload(temp_dir, runtime_cls, run_as_openhands, dynamic_po
# Start HTTP server
action_cmd = CmdRunAction(
command=f'python3 -m http.server {dynamic_port} > server.log 2>&1 &'
command='python3 -m http.server 8000 > server.log 2>&1 &'
)
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
@@ -828,7 +814,7 @@ def test_browser_file_upload(temp_dir, runtime_cls, run_as_openhands, dynamic_po
# Navigate to upload page
action_browse = BrowseInteractiveAction(
browser_actions=f'goto("http://localhost:{dynamic_port}/upload.html")',
browser_actions='goto("http://localhost:8000/upload.html")',
return_axtree=True,
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
@@ -1063,8 +1049,7 @@ def test_read_png_browse(temp_dir, runtime_cls, run_as_openhands):
_close_test_runtime(runtime)
@pytest.mark.skip(reason='This test is flaky')
def test_download_file(temp_dir, runtime_cls, run_as_openhands, dynamic_port):
def test_download_file(temp_dir, runtime_cls, run_as_openhands):
"""Test downloading a file using the browser."""
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
@@ -1157,7 +1142,7 @@ def test_download_file(temp_dir, runtime_cls, run_as_openhands, dynamic_port):
# Start HTTP server
action_cmd = CmdRunAction(
command=f'python3 -m http.server {dynamic_port} > server.log 2>&1 &'
command='python3 -m http.server 8000 > server.log 2>&1 &'
)
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
@@ -1172,19 +1157,19 @@ def test_download_file(temp_dir, runtime_cls, run_as_openhands, dynamic_port):
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
# Browse to the HTML page
action_browse = BrowseURLAction(url=f'http://localhost:{dynamic_port}/')
action_browse = BrowseURLAction(url='http://localhost:8000/download_test.html')
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
# Verify the browser observation
assert isinstance(obs, BrowserOutputObservation)
assert f'http://localhost:{dynamic_port}/download_test.html' in obs.url
assert 'http://localhost:8000/download_test.html' in obs.url
assert not obs.error
assert 'Download Test Page' in obs.content
# Go to the PDF file url directly - this should trigger download
file_url = f'http://localhost:{dynamic_port}/{test_file_name}'
file_url = f'http://localhost:8000/{test_file_name}'
action_browse = BrowseInteractiveAction(
browser_actions=f'goto("{file_url}")',
)

View File

@@ -140,9 +140,7 @@ def test_default_activated_tools():
@pytest.mark.skip('This test is flaky')
@pytest.mark.asyncio
async def test_fetch_mcp_via_stdio(
temp_dir, runtime_cls, run_as_openhands, dynamic_port
):
async def test_fetch_mcp_via_stdio(temp_dir, runtime_cls, run_as_openhands):
mcp_stdio_server_config = MCPStdioServerConfig(
name='fetch', command='uvx', args=['mcp-server-fetch']
)
@@ -156,9 +154,7 @@ async def test_fetch_mcp_via_stdio(
)
# Test browser server
action_cmd = CmdRunAction(
command=f'python3 -m http.server {dynamic_port} > server.log 2>&1 &'
)
action_cmd = CmdRunAction(command='python3 -m http.server 8080 > server.log 2>&1 &')
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -173,9 +169,7 @@ async def test_fetch_mcp_via_stdio(
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.exit_code == 0
mcp_action = MCPAction(
name='fetch', arguments={'url': f'http://localhost:{dynamic_port}'}
)
mcp_action = MCPAction(name='fetch', arguments={'url': 'http://localhost:8080'})
obs = await runtime.call_tool_mcp(mcp_action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, MCPObservation), (
@@ -188,7 +182,7 @@ async def test_fetch_mcp_via_stdio(
assert result_json['content'][0]['type'] == 'text'
assert (
result_json['content'][0]['text']
== f'Contents of http://localhost:{dynamic_port}/:\n---\n\n* <.downloads/>\n* <server.log>\n\n---'
== 'Contents of http://localhost:8080/:\n---\n\n* <.downloads/>\n* <server.log>\n\n---'
)
runtime.close()
@@ -229,7 +223,7 @@ async def test_filesystem_mcp_via_sse(
@pytest.mark.skip('This test is flaky')
@pytest.mark.asyncio
async def test_both_stdio_and_sse_mcp(
temp_dir, runtime_cls, run_as_openhands, sse_mcp_docker_server, dynamic_port
temp_dir, runtime_cls, run_as_openhands, sse_mcp_docker_server
):
sse_server_info = sse_mcp_docker_server
sse_url = sse_server_info['url']
@@ -265,7 +259,7 @@ async def test_both_stdio_and_sse_mcp(
# ======= Test stdio server =======
# Test browser server
action_cmd_http = CmdRunAction(
command=f'python3 -m http.server {dynamic_port} > server.log 2>&1 &'
command='python3 -m http.server 8080 > server.log 2>&1 &'
)
logger.info(action_cmd_http, extra={'msg_type': 'ACTION'})
obs_http = runtime.run_action(action_cmd_http)
@@ -286,7 +280,7 @@ async def test_both_stdio_and_sse_mcp(
# And FastMCP Proxy will pre-pend the server name (in this case, `fetch`)
# to the tool name, so the full tool name becomes `fetch_fetch`
name='fetch',
arguments={'url': f'http://localhost:{dynamic_port}'},
arguments={'url': 'http://localhost:8080'},
)
obs_fetch = await runtime.call_tool_mcp(mcp_action_fetch)
logger.info(obs_fetch, extra={'msg_type': 'OBSERVATION'})
@@ -300,7 +294,7 @@ async def test_both_stdio_and_sse_mcp(
assert result_json['content'][0]['type'] == 'text'
assert (
result_json['content'][0]['text']
== f'Contents of http://localhost:{dynamic_port}/:\n---\n\n* <.downloads/>\n* <server.log>\n\n---'
== 'Contents of http://localhost:8080/:\n---\n\n* <.downloads/>\n* <server.log>\n\n---'
)
finally:
if runtime:
@@ -311,7 +305,7 @@ async def test_both_stdio_and_sse_mcp(
@pytest.mark.skip('This test is flaky')
@pytest.mark.asyncio
async def test_microagent_and_one_stdio_mcp_in_config(
temp_dir, runtime_cls, run_as_openhands, dynamic_port
temp_dir, runtime_cls, run_as_openhands
):
runtime = None
try:
@@ -356,7 +350,7 @@ async def test_microagent_and_one_stdio_mcp_in_config(
# ======= Test the stdio server added by the microagent =======
# Test browser server
action_cmd_http = CmdRunAction(
command=f'python3 -m http.server {dynamic_port} > server.log 2>&1 &'
command='python3 -m http.server 8080 > server.log 2>&1 &'
)
logger.info(action_cmd_http, extra={'msg_type': 'ACTION'})
obs_http = runtime.run_action(action_cmd_http)
@@ -373,7 +367,7 @@ async def test_microagent_and_one_stdio_mcp_in_config(
assert obs_cat.exit_code == 0
mcp_action_fetch = MCPAction(
name='fetch_fetch', arguments={'url': f'http://localhost:{dynamic_port}'}
name='fetch_fetch', arguments={'url': 'http://localhost:8080'}
)
obs_fetch = await runtime.call_tool_mcp(mcp_action_fetch)
logger.info(obs_fetch, extra={'msg_type': 'OBSERVATION'})
@@ -387,7 +381,7 @@ async def test_microagent_and_one_stdio_mcp_in_config(
assert result_json['content'][0]['type'] == 'text'
assert (
result_json['content'][0]['text']
== f'Contents of http://localhost:{dynamic_port}/:\n---\n\n* <.downloads/>\n* <server.log>\n\n---'
== 'Contents of http://localhost:8080/:\n---\n\n* <.downloads/>\n* <server.log>\n\n---'
)
finally:
if runtime:

View File

@@ -1,947 +0,0 @@
"""Tests for RemoteSandboxService.
This module tests the RemoteSandboxService implementation, focusing on:
- Remote runtime API communication and error handling
- Sandbox lifecycle management (start, pause, resume, delete)
- Status mapping from remote runtime to internal sandbox statuses
- Environment variable injection for CORS and webhooks
- Data transformation from remote runtime to SandboxInfo objects
- User-scoped sandbox operations and security
- Pagination and search functionality
- Error handling for HTTP failures and edge cases
"""
from datetime import datetime, timezone
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from openhands.app_server.errors import SandboxError
from openhands.app_server.sandbox.remote_sandbox_service import (
ALLOW_CORS_ORIGINS_VARIABLE,
POD_STATUS_MAPPING,
STATUS_MAPPING,
WEBHOOK_CALLBACK_VARIABLE,
RemoteSandboxService,
StoredRemoteSandbox,
)
from openhands.app_server.sandbox.sandbox_models import (
AGENT_SERVER,
VSCODE,
WORKER_1,
WORKER_2,
SandboxInfo,
SandboxStatus,
)
from openhands.app_server.sandbox.sandbox_spec_models import SandboxSpecInfo
from openhands.app_server.user.user_context import UserContext
@pytest.fixture
def mock_sandbox_spec_service():
"""Mock SandboxSpecService for testing."""
mock_service = AsyncMock()
mock_spec = SandboxSpecInfo(
id='test-image:latest',
command=['/usr/local/bin/openhands-agent-server', '--port', '60000'],
initial_env={'TEST_VAR': 'test_value'},
working_dir='/workspace/project',
)
mock_service.get_default_sandbox_spec.return_value = mock_spec
mock_service.get_sandbox_spec.return_value = mock_spec
return mock_service
@pytest.fixture
def mock_user_context():
"""Mock UserContext for testing."""
mock_context = AsyncMock(spec=UserContext)
mock_context.get_user_id.return_value = 'test-user-123'
return mock_context
@pytest.fixture
def mock_httpx_client():
"""Mock httpx.AsyncClient for testing."""
return AsyncMock(spec=httpx.AsyncClient)
@pytest.fixture
def mock_db_session():
"""Mock database session for testing."""
return AsyncMock(spec=AsyncSession)
@pytest.fixture
def remote_sandbox_service(
mock_sandbox_spec_service, mock_user_context, mock_httpx_client, mock_db_session
):
"""Create RemoteSandboxService instance with mocked dependencies."""
return RemoteSandboxService(
sandbox_spec_service=mock_sandbox_spec_service,
api_url='https://api.example.com',
api_key='test-api-key',
web_url='https://web.example.com',
resource_factor=1,
runtime_class='gvisor',
start_sandbox_timeout=120,
max_num_sandboxes=10,
user_context=mock_user_context,
httpx_client=mock_httpx_client,
db_session=mock_db_session,
)
def create_runtime_data(
session_id: str = 'test-sandbox-123',
status: str = 'running',
pod_status: str = 'ready',
url: str = 'https://sandbox.example.com',
session_api_key: str = 'test-session-key',
runtime_id: str = 'runtime-456',
) -> dict[str, Any]:
"""Helper function to create runtime data for testing."""
return {
'session_id': session_id,
'status': status,
'pod_status': pod_status,
'url': url,
'session_api_key': session_api_key,
'runtime_id': runtime_id,
}
def create_stored_sandbox(
sandbox_id: str = 'test-sandbox-123',
user_id: str = 'test-user-123',
spec_id: str = 'test-image:latest',
created_at: datetime | None = None,
) -> StoredRemoteSandbox:
"""Helper function to create StoredRemoteSandbox for testing."""
if created_at is None:
created_at = datetime.now(timezone.utc)
return StoredRemoteSandbox(
id=sandbox_id,
created_by_user_id=user_id,
sandbox_spec_id=spec_id,
created_at=created_at,
)
class TestRemoteSandboxService:
"""Test cases for RemoteSandboxService core functionality."""
@pytest.mark.asyncio
async def test_send_runtime_api_request_success(self, remote_sandbox_service):
"""Test successful API request to remote runtime."""
# Setup
mock_response = MagicMock()
mock_response.json.return_value = {'result': 'success'}
remote_sandbox_service.httpx_client.request.return_value = mock_response
# Execute
response = await remote_sandbox_service._send_runtime_api_request(
'GET', '/test-endpoint', json={'test': 'data'}
)
# Verify
assert response == mock_response
remote_sandbox_service.httpx_client.request.assert_called_once_with(
'GET',
'https://api.example.com/test-endpoint',
headers={'X-API-Key': 'test-api-key'},
json={'test': 'data'},
)
@pytest.mark.asyncio
async def test_send_runtime_api_request_timeout(self, remote_sandbox_service):
"""Test API request timeout handling."""
# Setup
remote_sandbox_service.httpx_client.request.side_effect = (
httpx.TimeoutException('Request timeout')
)
# Execute & Verify
with pytest.raises(httpx.TimeoutException):
await remote_sandbox_service._send_runtime_api_request('GET', '/test')
@pytest.mark.asyncio
async def test_send_runtime_api_request_http_error(self, remote_sandbox_service):
"""Test API request HTTP error handling."""
# Setup
remote_sandbox_service.httpx_client.request.side_effect = httpx.HTTPError(
'HTTP error'
)
# Execute & Verify
with pytest.raises(httpx.HTTPError):
await remote_sandbox_service._send_runtime_api_request('GET', '/test')
class TestStatusMapping:
"""Test cases for status mapping functionality."""
@pytest.mark.asyncio
async def test_get_sandbox_status_from_runtime_with_pod_status(
self, remote_sandbox_service
):
"""Test status mapping using pod_status."""
runtime_data = create_runtime_data(pod_status='ready')
status = remote_sandbox_service._get_sandbox_status_from_runtime(runtime_data)
assert status == SandboxStatus.RUNNING
@pytest.mark.asyncio
async def test_get_sandbox_status_from_runtime_fallback_to_status(
self, remote_sandbox_service
):
"""Test status mapping fallback to status field."""
runtime_data = create_runtime_data(
pod_status='unknown_pod_status', status='running'
)
status = remote_sandbox_service._get_sandbox_status_from_runtime(runtime_data)
assert status == SandboxStatus.RUNNING
@pytest.mark.asyncio
async def test_get_sandbox_status_from_runtime_no_runtime(
self, remote_sandbox_service
):
"""Test status mapping with no runtime data."""
status = remote_sandbox_service._get_sandbox_status_from_runtime(None)
assert status == SandboxStatus.MISSING
@pytest.mark.asyncio
async def test_get_sandbox_status_from_runtime_unknown_status(
self, remote_sandbox_service
):
"""Test status mapping with unknown status values."""
runtime_data = create_runtime_data(
pod_status='unknown_pod', status='unknown_status'
)
status = remote_sandbox_service._get_sandbox_status_from_runtime(runtime_data)
assert status == SandboxStatus.MISSING
@pytest.mark.asyncio
async def test_pod_status_mapping_coverage(self, remote_sandbox_service):
"""Test all pod status mappings are handled correctly."""
test_cases = [
('ready', SandboxStatus.RUNNING),
('pending', SandboxStatus.STARTING),
('running', SandboxStatus.STARTING),
('failed', SandboxStatus.ERROR),
('unknown', SandboxStatus.ERROR),
('crashloopbackoff', SandboxStatus.ERROR),
]
for pod_status, expected_status in test_cases:
runtime_data = create_runtime_data(pod_status=pod_status)
status = remote_sandbox_service._get_sandbox_status_from_runtime(
runtime_data
)
assert status == expected_status, f'Failed for pod_status: {pod_status}'
@pytest.mark.asyncio
async def test_status_mapping_coverage(self, remote_sandbox_service):
"""Test all status mappings are handled correctly."""
test_cases = [
('running', SandboxStatus.RUNNING),
('paused', SandboxStatus.PAUSED),
('stopped', SandboxStatus.MISSING),
('starting', SandboxStatus.STARTING),
('error', SandboxStatus.ERROR),
]
for status, expected_status in test_cases:
# Use empty pod_status to force fallback to status field
runtime_data = create_runtime_data(pod_status='', status=status)
result = remote_sandbox_service._get_sandbox_status_from_runtime(
runtime_data
)
assert result == expected_status, f'Failed for status: {status}'
class TestEnvironmentInitialization:
"""Test cases for environment variable initialization."""
@pytest.mark.asyncio
async def test_init_environment_with_web_url(self, remote_sandbox_service):
"""Test environment initialization with web_url set."""
# Setup
sandbox_spec = SandboxSpecInfo(
id='test-image',
command=['test'],
initial_env={'EXISTING_VAR': 'existing_value'},
working_dir='/workspace',
)
sandbox_id = 'test-sandbox-123'
# Execute
environment = await remote_sandbox_service._init_environment(
sandbox_spec, sandbox_id
)
# Verify
expected_webhook_url = (
'https://web.example.com/api/v1/webhooks/test-sandbox-123'
)
assert environment['EXISTING_VAR'] == 'existing_value'
assert environment[WEBHOOK_CALLBACK_VARIABLE] == expected_webhook_url
assert environment[ALLOW_CORS_ORIGINS_VARIABLE] == 'https://web.example.com'
@pytest.mark.asyncio
async def test_init_environment_without_web_url(self, remote_sandbox_service):
"""Test environment initialization without web_url."""
# Setup
remote_sandbox_service.web_url = None
sandbox_spec = SandboxSpecInfo(
id='test-image',
command=['test'],
initial_env={'EXISTING_VAR': 'existing_value'},
working_dir='/workspace',
)
sandbox_id = 'test-sandbox-123'
# Execute
environment = await remote_sandbox_service._init_environment(
sandbox_spec, sandbox_id
)
# Verify
assert environment['EXISTING_VAR'] == 'existing_value'
assert WEBHOOK_CALLBACK_VARIABLE not in environment
assert ALLOW_CORS_ORIGINS_VARIABLE not in environment
class TestSandboxInfoConversion:
"""Test cases for converting stored sandbox and runtime data to SandboxInfo."""
@pytest.mark.asyncio
async def test_to_sandbox_info_with_running_runtime(self, remote_sandbox_service):
"""Test conversion to SandboxInfo with running runtime."""
# Setup
stored_sandbox = create_stored_sandbox()
runtime_data = create_runtime_data(status='running', pod_status='ready')
# Execute
sandbox_info = await remote_sandbox_service._to_sandbox_info(
stored_sandbox, runtime_data
)
# Verify
assert sandbox_info.id == 'test-sandbox-123'
assert sandbox_info.created_by_user_id == 'test-user-123'
assert sandbox_info.sandbox_spec_id == 'test-image:latest'
assert sandbox_info.status == SandboxStatus.RUNNING
assert sandbox_info.session_api_key == 'test-session-key'
assert len(sandbox_info.exposed_urls) == 4
# Check exposed URLs
url_names = [url.name for url in sandbox_info.exposed_urls]
assert AGENT_SERVER in url_names
assert VSCODE in url_names
assert WORKER_1 in url_names
assert WORKER_2 in url_names
@pytest.mark.asyncio
async def test_to_sandbox_info_with_starting_runtime(self, remote_sandbox_service):
"""Test conversion to SandboxInfo with starting runtime."""
# Setup
stored_sandbox = create_stored_sandbox()
runtime_data = create_runtime_data(status='running', pod_status='pending')
# Execute
sandbox_info = await remote_sandbox_service._to_sandbox_info(
stored_sandbox, runtime_data
)
# Verify
assert sandbox_info.status == SandboxStatus.STARTING
assert sandbox_info.session_api_key == 'test-session-key'
assert sandbox_info.exposed_urls is None
@pytest.mark.asyncio
async def test_to_sandbox_info_without_runtime(self, remote_sandbox_service):
"""Test conversion to SandboxInfo without runtime data."""
# Setup
stored_sandbox = create_stored_sandbox()
remote_sandbox_service._get_runtime = AsyncMock(
side_effect=Exception('Runtime not found')
)
# Execute
sandbox_info = await remote_sandbox_service._to_sandbox_info(stored_sandbox)
# Verify
assert sandbox_info.status == SandboxStatus.MISSING
assert sandbox_info.session_api_key is None
assert sandbox_info.exposed_urls is None
@pytest.mark.asyncio
async def test_to_sandbox_info_loads_runtime_when_none_provided(
self, remote_sandbox_service
):
"""Test that runtime data is loaded when not provided."""
# Setup
stored_sandbox = create_stored_sandbox()
runtime_data = create_runtime_data()
remote_sandbox_service._get_runtime = AsyncMock(return_value=runtime_data)
# Execute
sandbox_info = await remote_sandbox_service._to_sandbox_info(stored_sandbox)
# Verify
remote_sandbox_service._get_runtime.assert_called_once_with('test-sandbox-123')
assert sandbox_info.status == SandboxStatus.RUNNING
class TestSandboxLifecycle:
"""Test cases for sandbox lifecycle operations."""
@pytest.mark.asyncio
async def test_start_sandbox_success(
self, remote_sandbox_service, mock_sandbox_spec_service
):
"""Test successful sandbox start."""
# Setup
mock_response = MagicMock()
mock_response.json.return_value = create_runtime_data()
remote_sandbox_service.httpx_client.request.return_value = mock_response
remote_sandbox_service.pause_old_sandboxes = AsyncMock(return_value=[])
# Mock database operations
remote_sandbox_service.db_session.add = MagicMock()
remote_sandbox_service.db_session.commit = AsyncMock()
# Execute
with patch('base62.encodebytes', return_value='test-sandbox-123'):
sandbox_info = await remote_sandbox_service.start_sandbox()
# Verify
assert sandbox_info.id == 'test-sandbox-123'
assert (
sandbox_info.status == SandboxStatus.STARTING
) # pod_status is 'pending' by default
remote_sandbox_service.pause_old_sandboxes.assert_called_once_with(
9
) # max_num_sandboxes - 1
remote_sandbox_service.db_session.add.assert_called_once()
remote_sandbox_service.db_session.commit.assert_called_once()
@pytest.mark.asyncio
async def test_start_sandbox_with_specific_spec(
self, remote_sandbox_service, mock_sandbox_spec_service
):
"""Test starting sandbox with specific sandbox spec."""
# Setup
mock_response = MagicMock()
mock_response.json.return_value = create_runtime_data()
remote_sandbox_service.httpx_client.request.return_value = mock_response
remote_sandbox_service.pause_old_sandboxes = AsyncMock(return_value=[])
remote_sandbox_service.db_session.add = MagicMock()
remote_sandbox_service.db_session.commit = AsyncMock()
# Execute
with patch('base62.encodebytes', return_value='test-sandbox-123'):
await remote_sandbox_service.start_sandbox('custom-spec-id')
# Verify
mock_sandbox_spec_service.get_sandbox_spec.assert_called_once_with(
'custom-spec-id'
)
@pytest.mark.asyncio
async def test_start_sandbox_spec_not_found(
self, remote_sandbox_service, mock_sandbox_spec_service
):
"""Test starting sandbox with non-existent spec."""
# Setup
mock_sandbox_spec_service.get_sandbox_spec.return_value = None
remote_sandbox_service.pause_old_sandboxes = AsyncMock(return_value=[])
# Execute & Verify
with pytest.raises(ValueError, match='Sandbox Spec not found'):
await remote_sandbox_service.start_sandbox('non-existent-spec')
@pytest.mark.asyncio
async def test_start_sandbox_http_error(self, remote_sandbox_service):
"""Test sandbox start with HTTP error."""
# Setup
remote_sandbox_service.httpx_client.request.side_effect = httpx.HTTPError(
'API Error'
)
remote_sandbox_service.pause_old_sandboxes = AsyncMock(return_value=[])
remote_sandbox_service.db_session.add = MagicMock()
remote_sandbox_service.db_session.commit = AsyncMock()
# Execute & Verify
with patch('base62.encodebytes', return_value='test-sandbox-123'):
with pytest.raises(SandboxError, match='Failed to start sandbox'):
await remote_sandbox_service.start_sandbox()
@pytest.mark.asyncio
async def test_start_sandbox_with_sysbox_runtime(self, remote_sandbox_service):
"""Test sandbox start with sysbox runtime class."""
# Setup
remote_sandbox_service.runtime_class = 'sysbox'
mock_response = MagicMock()
mock_response.json.return_value = create_runtime_data()
remote_sandbox_service.httpx_client.request.return_value = mock_response
remote_sandbox_service.pause_old_sandboxes = AsyncMock(return_value=[])
remote_sandbox_service.db_session.add = MagicMock()
remote_sandbox_service.db_session.commit = AsyncMock()
# Execute
with patch('base62.encodebytes', return_value='test-sandbox-123'):
await remote_sandbox_service.start_sandbox()
# Verify runtime_class is included in request
call_args = remote_sandbox_service.httpx_client.request.call_args
request_data = call_args[1]['json']
assert request_data['runtime_class'] == 'sysbox-runc'
@pytest.mark.asyncio
async def test_resume_sandbox_success(self, remote_sandbox_service):
"""Test successful sandbox resume."""
# Setup
stored_sandbox = create_stored_sandbox()
runtime_data = create_runtime_data()
remote_sandbox_service._get_stored_sandbox = AsyncMock(
return_value=stored_sandbox
)
remote_sandbox_service._get_runtime = AsyncMock(return_value=runtime_data)
remote_sandbox_service.pause_old_sandboxes = AsyncMock(return_value=[])
mock_response = MagicMock()
mock_response.status_code = 200
remote_sandbox_service.httpx_client.request.return_value = mock_response
# Execute
result = await remote_sandbox_service.resume_sandbox('test-sandbox-123')
# Verify
assert result is True
remote_sandbox_service.pause_old_sandboxes.assert_called_once_with(9)
remote_sandbox_service.httpx_client.request.assert_called_once_with(
'POST',
'https://api.example.com/resume',
headers={'X-API-Key': 'test-api-key'},
json={'runtime_id': 'runtime-456'},
)
@pytest.mark.asyncio
async def test_resume_sandbox_not_found(self, remote_sandbox_service):
"""Test resuming non-existent sandbox."""
# Setup
remote_sandbox_service._get_stored_sandbox = AsyncMock(return_value=None)
remote_sandbox_service.pause_old_sandboxes = AsyncMock(return_value=[])
# Execute
result = await remote_sandbox_service.resume_sandbox('non-existent')
# Verify
assert result is False
@pytest.mark.asyncio
async def test_resume_sandbox_runtime_not_found(self, remote_sandbox_service):
"""Test resuming sandbox when runtime returns 404."""
# Setup
stored_sandbox = create_stored_sandbox()
runtime_data = create_runtime_data()
remote_sandbox_service._get_stored_sandbox = AsyncMock(
return_value=stored_sandbox
)
remote_sandbox_service._get_runtime = AsyncMock(return_value=runtime_data)
remote_sandbox_service.pause_old_sandboxes = AsyncMock(return_value=[])
mock_response = MagicMock()
mock_response.status_code = 404
remote_sandbox_service.httpx_client.request.return_value = mock_response
# Execute
result = await remote_sandbox_service.resume_sandbox('test-sandbox-123')
# Verify
assert result is False
@pytest.mark.asyncio
async def test_pause_sandbox_success(self, remote_sandbox_service):
"""Test successful sandbox pause."""
# Setup
stored_sandbox = create_stored_sandbox()
runtime_data = create_runtime_data()
remote_sandbox_service._get_stored_sandbox = AsyncMock(
return_value=stored_sandbox
)
remote_sandbox_service._get_runtime = AsyncMock(return_value=runtime_data)
mock_response = MagicMock()
mock_response.status_code = 200
remote_sandbox_service.httpx_client.request.return_value = mock_response
# Execute
result = await remote_sandbox_service.pause_sandbox('test-sandbox-123')
# Verify
assert result is True
remote_sandbox_service.httpx_client.request.assert_called_once_with(
'POST',
'https://api.example.com/pause',
headers={'X-API-Key': 'test-api-key'},
json={'runtime_id': 'runtime-456'},
)
@pytest.mark.asyncio
async def test_delete_sandbox_success(self, remote_sandbox_service):
"""Test successful sandbox deletion."""
# Setup
stored_sandbox = create_stored_sandbox()
runtime_data = create_runtime_data()
remote_sandbox_service._get_stored_sandbox = AsyncMock(
return_value=stored_sandbox
)
remote_sandbox_service._get_runtime = AsyncMock(return_value=runtime_data)
remote_sandbox_service.db_session.delete = AsyncMock()
remote_sandbox_service.db_session.commit = AsyncMock()
mock_response = MagicMock()
mock_response.status_code = 200
remote_sandbox_service.httpx_client.request.return_value = mock_response
# Execute
result = await remote_sandbox_service.delete_sandbox('test-sandbox-123')
# Verify
assert result is True
remote_sandbox_service.db_session.delete.assert_called_once_with(stored_sandbox)
remote_sandbox_service.db_session.commit.assert_called_once()
remote_sandbox_service.httpx_client.request.assert_called_once_with(
'POST',
'https://api.example.com/stop',
headers={'X-API-Key': 'test-api-key'},
json={'runtime_id': 'runtime-456'},
)
@pytest.mark.asyncio
async def test_delete_sandbox_runtime_not_found_ignored(
self, remote_sandbox_service
):
"""Test sandbox deletion when runtime returns 404 (should be ignored)."""
# Setup
stored_sandbox = create_stored_sandbox()
runtime_data = create_runtime_data()
remote_sandbox_service._get_stored_sandbox = AsyncMock(
return_value=stored_sandbox
)
remote_sandbox_service._get_runtime = AsyncMock(return_value=runtime_data)
remote_sandbox_service.db_session.delete = AsyncMock()
remote_sandbox_service.db_session.commit = AsyncMock()
mock_response = MagicMock()
mock_response.status_code = 404
remote_sandbox_service.httpx_client.request.return_value = mock_response
# Execute
result = await remote_sandbox_service.delete_sandbox('test-sandbox-123')
# Verify
assert result is True # 404 should be ignored for delete operations
class TestSandboxSearch:
"""Test cases for sandbox search and retrieval."""
@pytest.mark.asyncio
async def test_search_sandboxes_basic(self, remote_sandbox_service):
"""Test basic sandbox search functionality."""
# Setup
stored_sandboxes = [
create_stored_sandbox('sb1'),
create_stored_sandbox('sb2'),
]
mock_scalars = MagicMock()
mock_scalars.all.return_value = stored_sandboxes
mock_result = MagicMock()
mock_result.scalars.return_value = mock_scalars
remote_sandbox_service.db_session.execute = AsyncMock(return_value=mock_result)
remote_sandbox_service._to_sandbox_info = AsyncMock(
side_effect=lambda stored: SandboxInfo(
id=stored.id,
created_by_user_id=stored.created_by_user_id,
sandbox_spec_id=stored.sandbox_spec_id,
status=SandboxStatus.RUNNING,
session_api_key='test-key',
created_at=stored.created_at,
)
)
# Execute
result = await remote_sandbox_service.search_sandboxes()
# Verify
assert len(result.items) == 2
assert result.next_page_id is None
assert result.items[0].id == 'sb1'
assert result.items[1].id == 'sb2'
@pytest.mark.asyncio
async def test_search_sandboxes_with_pagination(self, remote_sandbox_service):
"""Test sandbox search with pagination."""
# Setup - return limit + 1 items to trigger pagination
stored_sandboxes = [
create_stored_sandbox(f'sb{i}') for i in range(6)
] # limit=5, so 6 items
mock_scalars = MagicMock()
mock_scalars.all.return_value = stored_sandboxes
mock_result = MagicMock()
mock_result.scalars.return_value = mock_scalars
remote_sandbox_service.db_session.execute = AsyncMock(return_value=mock_result)
remote_sandbox_service._to_sandbox_info = AsyncMock(
side_effect=lambda stored: SandboxInfo(
id=stored.id,
created_by_user_id=stored.created_by_user_id,
sandbox_spec_id=stored.sandbox_spec_id,
status=SandboxStatus.RUNNING,
session_api_key='test-key',
created_at=stored.created_at,
)
)
# Execute
result = await remote_sandbox_service.search_sandboxes(limit=5)
# Verify
assert len(result.items) == 5 # Should be limited to 5
assert result.next_page_id == '5' # Next page offset
@pytest.mark.asyncio
async def test_search_sandboxes_with_page_id(self, remote_sandbox_service):
"""Test sandbox search with page_id offset."""
# Setup
stored_sandboxes = [create_stored_sandbox('sb1')]
mock_scalars = MagicMock()
mock_scalars.all.return_value = stored_sandboxes
mock_result = MagicMock()
mock_result.scalars.return_value = mock_scalars
remote_sandbox_service.db_session.execute = AsyncMock(return_value=mock_result)
remote_sandbox_service._to_sandbox_info = AsyncMock(
side_effect=lambda stored: SandboxInfo(
id=stored.id,
created_by_user_id=stored.created_by_user_id,
sandbox_spec_id=stored.sandbox_spec_id,
status=SandboxStatus.RUNNING,
session_api_key='test-key',
created_at=stored.created_at,
)
)
# Execute
await remote_sandbox_service.search_sandboxes(page_id='10', limit=5)
# Verify that offset was applied to the query
# Note: We can't easily verify the exact SQL query, but we can verify the method was called
remote_sandbox_service.db_session.execute.assert_called_once()
@pytest.mark.asyncio
async def test_get_sandbox_exists(self, remote_sandbox_service):
"""Test getting an existing sandbox."""
# Setup
stored_sandbox = create_stored_sandbox()
remote_sandbox_service._get_stored_sandbox = AsyncMock(
return_value=stored_sandbox
)
remote_sandbox_service._to_sandbox_info = AsyncMock(
return_value=SandboxInfo(
id='test-sandbox-123',
created_by_user_id='test-user-123',
sandbox_spec_id='test-image:latest',
status=SandboxStatus.RUNNING,
session_api_key='test-key',
created_at=stored_sandbox.created_at,
)
)
# Execute
result = await remote_sandbox_service.get_sandbox('test-sandbox-123')
# Verify
assert result is not None
assert result.id == 'test-sandbox-123'
remote_sandbox_service._get_stored_sandbox.assert_called_once_with(
'test-sandbox-123'
)
@pytest.mark.asyncio
async def test_get_sandbox_not_exists(self, remote_sandbox_service):
"""Test getting a non-existent sandbox."""
# Setup
remote_sandbox_service._get_stored_sandbox = AsyncMock(return_value=None)
# Execute
result = await remote_sandbox_service.get_sandbox('non-existent')
# Verify
assert result is None
class TestUserSecurity:
"""Test cases for user-scoped operations and security."""
@pytest.mark.asyncio
async def test_secure_select_with_user_id(self, remote_sandbox_service):
"""Test that _secure_select filters by user ID."""
# Setup
remote_sandbox_service.user_context.get_user_id.return_value = 'test-user-123'
# Execute
await remote_sandbox_service._secure_select()
# Verify
# Note: We can't easily test the exact SQL query structure, but we can verify
# that get_user_id was called, which means user filtering should be applied
remote_sandbox_service.user_context.get_user_id.assert_called_once()
@pytest.mark.asyncio
async def test_secure_select_without_user_id(self, remote_sandbox_service):
"""Test that _secure_select works when user ID is None."""
# Setup
remote_sandbox_service.user_context.get_user_id.return_value = None
# Execute
await remote_sandbox_service._secure_select()
# Verify
remote_sandbox_service.user_context.get_user_id.assert_called_once()
class TestErrorHandling:
"""Test cases for error handling scenarios."""
@pytest.mark.asyncio
async def test_resume_sandbox_http_error(self, remote_sandbox_service):
"""Test resume sandbox with HTTP error."""
# Setup
stored_sandbox = create_stored_sandbox()
runtime_data = create_runtime_data()
remote_sandbox_service._get_stored_sandbox = AsyncMock(
return_value=stored_sandbox
)
remote_sandbox_service._get_runtime = AsyncMock(return_value=runtime_data)
remote_sandbox_service.pause_old_sandboxes = AsyncMock(return_value=[])
remote_sandbox_service.httpx_client.request.side_effect = httpx.HTTPError(
'API Error'
)
# Execute
result = await remote_sandbox_service.resume_sandbox('test-sandbox-123')
# Verify
assert result is False
@pytest.mark.asyncio
async def test_pause_sandbox_http_error(self, remote_sandbox_service):
"""Test pause sandbox with HTTP error."""
# Setup
stored_sandbox = create_stored_sandbox()
runtime_data = create_runtime_data()
remote_sandbox_service._get_stored_sandbox = AsyncMock(
return_value=stored_sandbox
)
remote_sandbox_service._get_runtime = AsyncMock(return_value=runtime_data)
remote_sandbox_service.httpx_client.request.side_effect = httpx.HTTPError(
'API Error'
)
# Execute
result = await remote_sandbox_service.pause_sandbox('test-sandbox-123')
# Verify
assert result is False
@pytest.mark.asyncio
async def test_delete_sandbox_http_error(self, remote_sandbox_service):
"""Test delete sandbox with HTTP error."""
# Setup
stored_sandbox = create_stored_sandbox()
runtime_data = create_runtime_data()
remote_sandbox_service._get_stored_sandbox = AsyncMock(
return_value=stored_sandbox
)
remote_sandbox_service._get_runtime = AsyncMock(return_value=runtime_data)
remote_sandbox_service.db_session.delete = AsyncMock()
remote_sandbox_service.db_session.commit = AsyncMock()
remote_sandbox_service.httpx_client.request.side_effect = httpx.HTTPError(
'API Error'
)
# Execute
result = await remote_sandbox_service.delete_sandbox('test-sandbox-123')
# Verify
assert result is False
class TestUtilityFunctions:
"""Test cases for utility functions."""
def test_build_service_url(self):
"""Test _build_service_url function."""
from openhands.app_server.sandbox.remote_sandbox_service import (
_build_service_url,
)
# Test HTTPS URL
result = _build_service_url('https://sandbox.example.com/path', 'vscode')
assert result == 'https://vscode-sandbox.example.com/path'
# Test HTTP URL
result = _build_service_url('http://localhost:8000', 'work-1')
assert result == 'http://work-1-localhost:8000'
class TestConstants:
"""Test cases for constants and mappings."""
def test_pod_status_mapping_completeness(self):
"""Test that POD_STATUS_MAPPING covers expected statuses."""
expected_statuses = [
'ready',
'pending',
'running',
'failed',
'unknown',
'crashloopbackoff',
]
for status in expected_statuses:
assert status in POD_STATUS_MAPPING, f'Missing pod status: {status}'
def test_status_mapping_completeness(self):
"""Test that STATUS_MAPPING covers expected statuses."""
expected_statuses = ['running', 'paused', 'stopped', 'starting', 'error']
for status in expected_statuses:
assert status in STATUS_MAPPING, f'Missing status: {status}'
def test_environment_variable_constants(self):
"""Test that environment variable constants are defined."""
assert WEBHOOK_CALLBACK_VARIABLE == 'OH_WEBHOOKS_0_BASE_URL'
assert ALLOW_CORS_ORIGINS_VARIABLE == 'OH_ALLOW_CORS_ORIGINS_0'

View File

@@ -1,5 +1,6 @@
import os
import sys
import tempfile
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -29,11 +30,18 @@ pytestmark = pytest.mark.skipif(
@pytest.fixture
def windows_bash_session(temp_dir):
def temp_work_dir():
"""Create a temporary directory for testing."""
with tempfile.TemporaryDirectory() as temp_dir:
yield temp_dir
@pytest.fixture
def windows_bash_session(temp_work_dir):
"""Create a WindowsPowershellSession instance for testing."""
# Instantiate the class. Initialization happens in __init__.
session = WindowsPowershellSession(
work_dir=temp_dir,
work_dir=temp_work_dir,
username=None,
)
assert session._initialized # Should be true after __init__
@@ -161,8 +169,8 @@ def test_command_timeout(windows_bash_session):
assert abs(duration - test_timeout_sec) < 0.5 # Allow some buffer
def test_long_running_command(windows_bash_session, dynamic_port):
action = CmdRunAction(command=f'python -u -m http.server {dynamic_port}')
def test_long_running_command(windows_bash_session):
action = CmdRunAction(command='python -u -m http.server 8081')
action.set_hard_timeout(1)
result = windows_bash_session.execute(action)
@@ -187,7 +195,7 @@ def test_long_running_command(windows_bash_session, dynamic_port):
assert result.exit_code == 0
# Verify the server is actually stopped by starting another one on the same port
action = CmdRunAction(command=f'python -u -m http.server {dynamic_port}')
action = CmdRunAction(command='python -u -m http.server 8081')
action.set_hard_timeout(1) # Set a short timeout to check if it starts
result = windows_bash_session.execute(action)
@@ -239,10 +247,10 @@ def test_multiple_commands_rejected_and_individual_execution(windows_bash_sessio
results.append(obs.content.strip()) # Strip trailing newlines for comparison
def test_working_directory(windows_bash_session, temp_dir):
def test_working_directory(windows_bash_session, temp_work_dir):
"""Test working directory handling."""
initial_cwd = windows_bash_session._cwd
abs_temp_work_dir = os.path.abspath(temp_dir)
abs_temp_work_dir = os.path.abspath(temp_work_dir)
assert initial_cwd == abs_temp_work_dir
# Create a subdirectory
@@ -406,7 +414,7 @@ def test_runspace_state_after_error(windows_bash_session):
assert valid_result.exit_code == 0
def test_stateful_file_operations(windows_bash_session, temp_dir):
def test_stateful_file_operations(windows_bash_session, temp_work_dir):
"""Test file operations to verify runspace state persistence.
This test verifies that:
@@ -414,7 +422,7 @@ def test_stateful_file_operations(windows_bash_session, temp_dir):
2. File operations work correctly relative to the current directory
3. The runspace maintains state for path-dependent operations
"""
abs_temp_work_dir = os.path.abspath(temp_dir)
abs_temp_work_dir = os.path.abspath(temp_work_dir)
# 1. Create a subdirectory
sub_dir_name = 'file_test_dir'
@@ -574,10 +582,10 @@ def test_interactive_input(windows_bash_session):
assert result.exit_code == 1
def test_windows_path_handling(windows_bash_session, temp_dir):
def test_windows_path_handling(windows_bash_session, temp_work_dir):
"""Test that os.chdir works with both forward slashes and escaped backslashes on Windows."""
# Create a test directory
test_dir = Path(temp_dir) / 'test_dir'
test_dir = Path(temp_work_dir) / 'test_dir'
test_dir.mkdir()
# Test both path formats

View File

@@ -909,12 +909,6 @@ async def test_delete_conversation():
# Return the mock store from get_instance
mock_get_instance.return_value = mock_store
# Create a mock app conversation service
mock_app_conversation_service = MagicMock()
mock_app_conversation_service.get_app_conversation = AsyncMock(
return_value=None
)
# Mock the conversation manager
with patch(
'openhands.server.routes.manage_conversations.conversation_manager'
@@ -932,9 +926,7 @@ async def test_delete_conversation():
# Call delete_conversation
result = await delete_conversation(
conversation_id='some_conversation_id',
user_id='12345',
app_conversation_service=mock_app_conversation_service,
'some_conversation_id', user_id='12345'
)
# Verify the result
@@ -951,288 +943,6 @@ async def test_delete_conversation():
)
@pytest.mark.asyncio
async def test_delete_v1_conversation_success():
"""Test successful deletion of a V1 conversation."""
from uuid import uuid4
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversation,
)
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
from openhands.sdk.conversation.state import ConversationExecutionStatus
conversation_uuid = uuid4()
conversation_id = str(conversation_uuid)
# Mock the app conversation service
with patch(
'openhands.server.routes.manage_conversations.app_conversation_service_dependency'
) as mock_service_dep:
mock_service = MagicMock()
mock_service_dep.return_value = mock_service
# Mock the conversation exists
mock_app_conversation = AppConversation(
id=conversation_uuid,
created_by_user_id='test_user',
sandbox_id='test-sandbox-id',
title='Test V1 Conversation',
sandbox_status=SandboxStatus.RUNNING,
execution_status=ConversationExecutionStatus.RUNNING,
session_api_key='test-api-key',
selected_repository='test/repo',
selected_branch='main',
git_provider=ProviderType.GITHUB,
trigger=ConversationTrigger.GUI,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
mock_service.get_app_conversation = AsyncMock(
return_value=mock_app_conversation
)
mock_service.delete_app_conversation = AsyncMock(return_value=True)
# Call delete_conversation with V1 conversation ID
result = await delete_conversation(
conversation_id=conversation_id,
user_id='test_user',
app_conversation_service=mock_service,
)
# Verify the result
assert result is True
# Verify that get_app_conversation was called
mock_service.get_app_conversation.assert_called_once_with(conversation_uuid)
# Verify that delete_app_conversation was called with the conversation ID
mock_service.delete_app_conversation.assert_called_once_with(conversation_uuid)
@pytest.mark.asyncio
async def test_delete_v1_conversation_not_found():
"""Test deletion of a V1 conversation that doesn't exist."""
from uuid import uuid4
conversation_uuid = uuid4()
conversation_id = str(conversation_uuid)
# Mock the app conversation service
with patch(
'openhands.server.routes.manage_conversations.app_conversation_service_dependency'
) as mock_service_dep:
mock_service = MagicMock()
mock_service_dep.return_value = mock_service
# Mock the conversation doesn't exist
mock_service.get_app_conversation = AsyncMock(return_value=None)
mock_service.delete_app_conversation = AsyncMock(return_value=False)
# Call delete_conversation with V1 conversation ID
result = await delete_conversation(
conversation_id=conversation_id,
user_id='test_user',
app_conversation_service=mock_service,
)
# Verify the result
assert result is False
# Verify that get_app_conversation was called
mock_service.get_app_conversation.assert_called_once_with(conversation_uuid)
# Verify that delete_app_conversation was NOT called
mock_service.delete_app_conversation.assert_not_called()
@pytest.mark.asyncio
async def test_delete_v1_conversation_invalid_uuid():
"""Test deletion with invalid UUID falls back to V0 logic."""
conversation_id = 'invalid-uuid-format'
# Mock the app conversation service
with patch(
'openhands.server.routes.manage_conversations.app_conversation_service_dependency'
) as mock_service_dep:
mock_service = MagicMock()
mock_service_dep.return_value = mock_service
# Mock V0 conversation logic
with patch(
'openhands.server.routes.manage_conversations.ConversationStoreImpl.get_instance'
) as mock_get_instance:
mock_store = MagicMock()
mock_store.get_metadata = AsyncMock(
return_value=ConversationMetadata(
conversation_id=conversation_id,
title='Test V0 Conversation',
created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'),
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'),
selected_repository='test/repo',
user_id='test_user',
)
)
mock_store.delete_metadata = AsyncMock()
mock_get_instance.return_value = mock_store
# Mock conversation manager
with patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_manager:
mock_manager.is_agent_loop_running = AsyncMock(return_value=False)
mock_manager.get_connections = AsyncMock(return_value={})
# Mock runtime
with patch(
'openhands.server.routes.manage_conversations.get_runtime_cls'
) as mock_get_runtime_cls:
mock_runtime_cls = MagicMock()
mock_runtime_cls.delete = AsyncMock()
mock_get_runtime_cls.return_value = mock_runtime_cls
# Call delete_conversation
result = await delete_conversation(
conversation_id=conversation_id,
user_id='test_user',
app_conversation_service=mock_service,
)
# Verify the result
assert result is True
# Verify V0 logic was used
mock_store.delete_metadata.assert_called_once_with(conversation_id)
mock_runtime_cls.delete.assert_called_once_with(conversation_id)
@pytest.mark.asyncio
async def test_delete_v1_conversation_service_error():
"""Test deletion when app conversation service raises an error."""
from uuid import uuid4
conversation_uuid = uuid4()
conversation_id = str(conversation_uuid)
# Mock the app conversation service
with patch(
'openhands.server.routes.manage_conversations.app_conversation_service_dependency'
) as mock_service_dep:
mock_service = MagicMock()
mock_service_dep.return_value = mock_service
# Mock service error
mock_service.get_app_conversation = AsyncMock(
side_effect=Exception('Service error')
)
# Mock V0 conversation logic as fallback
with patch(
'openhands.server.routes.manage_conversations.ConversationStoreImpl.get_instance'
) as mock_get_instance:
mock_store = MagicMock()
mock_store.get_metadata = AsyncMock(
return_value=ConversationMetadata(
conversation_id=conversation_id,
title='Test V0 Conversation',
created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'),
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'),
selected_repository='test/repo',
user_id='test_user',
)
)
mock_store.delete_metadata = AsyncMock()
mock_get_instance.return_value = mock_store
# Mock conversation manager
with patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_manager:
mock_manager.is_agent_loop_running = AsyncMock(return_value=False)
mock_manager.get_connections = AsyncMock(return_value={})
# Mock runtime
with patch(
'openhands.server.routes.manage_conversations.get_runtime_cls'
) as mock_get_runtime_cls:
mock_runtime_cls = MagicMock()
mock_runtime_cls.delete = AsyncMock()
mock_get_runtime_cls.return_value = mock_runtime_cls
# Call delete_conversation
result = await delete_conversation(
conversation_id=conversation_id,
user_id='test_user',
app_conversation_service=mock_service,
)
# Verify the result (should fallback to V0)
assert result is True
# Verify V0 logic was used
mock_store.delete_metadata.assert_called_once_with(conversation_id)
mock_runtime_cls.delete.assert_called_once_with(conversation_id)
@pytest.mark.asyncio
async def test_delete_v1_conversation_with_agent_server():
"""Test V1 conversation deletion with agent server integration."""
from uuid import uuid4
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversation,
)
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
from openhands.sdk.conversation.state import ConversationExecutionStatus
conversation_uuid = uuid4()
conversation_id = str(conversation_uuid)
# Mock the app conversation service
with patch(
'openhands.server.routes.manage_conversations.app_conversation_service_dependency'
) as mock_service_dep:
mock_service = MagicMock()
mock_service_dep.return_value = mock_service
# Mock the conversation exists with running sandbox
mock_app_conversation = AppConversation(
id=conversation_uuid,
created_by_user_id='test_user',
sandbox_id='test-sandbox-id',
title='Test V1 Conversation',
sandbox_status=SandboxStatus.RUNNING,
execution_status=ConversationExecutionStatus.RUNNING,
session_api_key='test-api-key',
selected_repository='test/repo',
selected_branch='main',
git_provider=ProviderType.GITHUB,
trigger=ConversationTrigger.GUI,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
mock_service.get_app_conversation = AsyncMock(
return_value=mock_app_conversation
)
mock_service.delete_app_conversation = AsyncMock(return_value=True)
# Call delete_conversation with V1 conversation ID
result = await delete_conversation(
conversation_id=conversation_id,
user_id='test_user',
app_conversation_service=mock_service,
)
# Verify the result
assert result is True
# Verify that get_app_conversation was called
mock_service.get_app_conversation.assert_called_once_with(conversation_uuid)
# Verify that delete_app_conversation was called with the conversation ID
mock_service.delete_app_conversation.assert_called_once_with(conversation_uuid)
@pytest.mark.asyncio
async def test_new_conversation_with_bearer_auth(provider_handler_mock):
"""Test creating a new conversation with bearer authentication."""

View File

@@ -1,13 +1,8 @@
import base64
import json
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from openhands.app_server.app_conversation.app_conversation_service import (
AppConversationService,
)
from openhands.integrations.provider import ProviderHandler
from openhands.server.data_models.conversation_info_result_set import (
ConversationInfoResultSet,
@@ -22,54 +17,6 @@ from openhands.storage.data_models.conversation_metadata import (
)
def _create_mock_app_conversation_service():
"""Create a mock AppConversationService that returns empty V1 results."""
mock_service = MagicMock(spec=AppConversationService)
mock_service.search_app_conversations = AsyncMock(
return_value=MagicMock(items=[], next_page_id=None)
)
return mock_service
def _decode_combined_page_id(page_id: str | None) -> dict:
"""Decode a combined page_id to get v0 and v1 components."""
if not page_id:
return {'v0': None, 'v1': None}
try:
return json.loads(base64.b64decode(page_id))
except Exception:
# Legacy format - just v0
return {'v0': page_id, 'v1': None}
async def _mock_wait_all(coros):
"""Mock implementation of wait_all that properly awaits coroutines."""
results = []
for coro in coros:
if hasattr(coro, '__await__'):
results.append(await coro)
else:
results.append(coro)
return results
def _setup_common_mocks():
"""Set up common mocks used by all tests."""
return {
'config': patch('openhands.server.routes.manage_conversations.config'),
'conversation_manager': patch(
'openhands.server.routes.manage_conversations.conversation_manager'
),
'wait_all': patch(
'openhands.server.routes.manage_conversations.wait_all',
side_effect=_mock_wait_all,
),
'provider_handler': patch(
'openhands.server.routes.manage_conversations.ProviderHandler'
),
}
@pytest.mark.asyncio
async def test_get_microagent_management_conversations_success():
"""Test successful retrieval of microagent management conversations."""
@@ -117,30 +64,24 @@ async def test_get_microagent_management_conversations_success():
mock_provider_handler = MagicMock(spec=ProviderHandler)
mock_provider_handler.is_pr_open = AsyncMock(return_value=True)
# Mock app conversation service
mock_app_conversation_service = _create_mock_app_conversation_service()
with (
patch(
'openhands.server.routes.manage_conversations.ProviderHandler',
return_value=mock_provider_handler,
),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_conv_mgr,
patch(
'openhands.server.routes.manage_conversations.wait_all',
side_effect=_mock_wait_all,
),
):
# Mock the build result function
mock_build_result.return_value = ConversationInfoResultSet(
results=[], next_page_id='next_page_456'
)
# Mock config
mock_config.conversation_max_age_seconds = 86400 # 24 hours
# Mock conversation manager
mock_conv_mgr.get_connections = AsyncMock(return_value=[])
mock_conv_mgr.get_agent_loop_info = AsyncMock(return_value=[])
# Call the function with correct parameter order
result = await get_microagent_management_conversations(
selected_repository=selected_repository,
@@ -148,16 +89,11 @@ async def test_get_microagent_management_conversations_success():
limit=limit,
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
app_conversation_service=mock_app_conversation_service,
)
# Verify the result
assert isinstance(result, ConversationInfoResultSet)
# Decode the combined page_id to verify v0 component
decoded_page_id = _decode_combined_page_id(result.next_page_id)
assert decoded_page_id['v0'] == 'next_page_456'
assert decoded_page_id['v1'] is None
assert result.next_page_id == 'next_page_456'
# Verify conversation store was called correctly
mock_conversation_store.search.assert_called_once_with(page_id, limit)
@@ -178,31 +114,26 @@ async def test_get_microagent_management_conversations_no_results():
# Mock provider tokens
mock_provider_tokens = {'github': 'token_123'}
mock_app_conversation_service = _create_mock_app_conversation_service()
with (
patch('openhands.server.routes.manage_conversations.ProviderHandler'),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_conv_mgr,
patch(
'openhands.server.routes.manage_conversations.wait_all',
side_effect=_mock_wait_all,
),
):
# Mock the build result function
mock_build_result.return_value = ConversationInfoResultSet(
results=[], next_page_id=None
)
# Mock config
mock_config.conversation_max_age_seconds = 86400
mock_conv_mgr.get_connections = AsyncMock(return_value=[])
mock_conv_mgr.get_agent_loop_info = AsyncMock(return_value=[])
# Call the function with required selected_repository parameter
result = await get_microagent_management_conversations(
selected_repository='owner/repo',
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
app_conversation_service=mock_app_conversation_service,
)
# Verify the result
@@ -253,34 +184,29 @@ async def test_get_microagent_management_conversations_filter_by_repository():
mock_provider_handler = MagicMock(spec=ProviderHandler)
mock_provider_handler.is_pr_open = AsyncMock(return_value=True)
mock_app_conversation_service = _create_mock_app_conversation_service()
with (
patch(
'openhands.server.routes.manage_conversations.ProviderHandler',
return_value=mock_provider_handler,
),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_conv_mgr,
patch(
'openhands.server.routes.manage_conversations.wait_all',
side_effect=_mock_wait_all,
),
):
# Mock the build result function - only repo1 should be included
mock_build_result.return_value = ConversationInfoResultSet(
results=[mock_conversations[0]], next_page_id=None
)
# Mock config
mock_config.conversation_max_age_seconds = 86400
mock_conv_mgr.get_connections = AsyncMock(return_value=[])
mock_conv_mgr.get_agent_loop_info = AsyncMock(return_value=[])
# Call the function with repository filter
result = await get_microagent_management_conversations(
selected_repository='owner/repo1',
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
app_conversation_service=mock_app_conversation_service,
)
# Verify only conversations from the specified repository are returned
@@ -331,34 +257,29 @@ async def test_get_microagent_management_conversations_filter_by_trigger():
mock_provider_handler = MagicMock(spec=ProviderHandler)
mock_provider_handler.is_pr_open = AsyncMock(return_value=True)
mock_app_conversation_service = _create_mock_app_conversation_service()
with (
patch(
'openhands.server.routes.manage_conversations.ProviderHandler',
return_value=mock_provider_handler,
),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_conv_mgr,
patch(
'openhands.server.routes.manage_conversations.wait_all',
side_effect=_mock_wait_all,
),
):
# Mock the build result function - only microagent_management should be included
mock_build_result.return_value = ConversationInfoResultSet(
results=[mock_conversations[0]], next_page_id=None
)
# Mock config
mock_config.conversation_max_age_seconds = 86400
mock_conv_mgr.get_connections = AsyncMock(return_value=[])
mock_conv_mgr.get_agent_loop_info = AsyncMock(return_value=[])
# Call the function
result = await get_microagent_management_conversations(
selected_repository='owner/repo',
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
app_conversation_service=mock_app_conversation_service,
)
# Verify only microagent_management conversations are returned
@@ -409,34 +330,29 @@ async def test_get_microagent_management_conversations_filter_inactive_pr():
mock_provider_handler = MagicMock(spec=ProviderHandler)
mock_provider_handler.is_pr_open = AsyncMock(side_effect=[True, False])
mock_app_conversation_service = _create_mock_app_conversation_service()
with (
patch(
'openhands.server.routes.manage_conversations.ProviderHandler',
return_value=mock_provider_handler,
),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_conv_mgr,
patch(
'openhands.server.routes.manage_conversations.wait_all',
side_effect=_mock_wait_all,
),
):
# Mock the build result function - only active PR should be included
mock_build_result.return_value = ConversationInfoResultSet(
results=[mock_conversations[0]], next_page_id=None
)
# Mock config
mock_config.conversation_max_age_seconds = 86400
mock_conv_mgr.get_connections = AsyncMock(return_value=[])
mock_conv_mgr.get_agent_loop_info = AsyncMock(return_value=[])
# Call the function
result = await get_microagent_management_conversations(
selected_repository='owner/repo',
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
app_conversation_service=mock_app_conversation_service,
)
# Verify only conversations with active PRs are returned
@@ -477,34 +393,29 @@ async def test_get_microagent_management_conversations_no_pr_number():
# Mock provider handler
mock_provider_handler = MagicMock(spec=ProviderHandler)
mock_app_conversation_service = _create_mock_app_conversation_service()
with (
patch(
'openhands.server.routes.manage_conversations.ProviderHandler',
return_value=mock_provider_handler,
),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_conv_mgr,
patch(
'openhands.server.routes.manage_conversations.wait_all',
side_effect=_mock_wait_all,
),
):
# Mock the build result function
mock_build_result.return_value = ConversationInfoResultSet(
results=mock_conversations, next_page_id=None
)
# Mock config
mock_config.conversation_max_age_seconds = 86400
mock_conv_mgr.get_connections = AsyncMock(return_value=[])
mock_conv_mgr.get_agent_loop_info = AsyncMock(return_value=[])
# Call the function
result = await get_microagent_management_conversations(
selected_repository='owner/repo',
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
app_conversation_service=mock_app_conversation_service,
)
# Verify conversation without PR number is included
@@ -545,34 +456,29 @@ async def test_get_microagent_management_conversations_no_repository():
# Mock provider handler
mock_provider_handler = MagicMock(spec=ProviderHandler)
mock_app_conversation_service = _create_mock_app_conversation_service()
with (
patch(
'openhands.server.routes.manage_conversations.ProviderHandler',
return_value=mock_provider_handler,
),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_conv_mgr,
patch(
'openhands.server.routes.manage_conversations.wait_all',
side_effect=_mock_wait_all,
),
):
# Mock the build result function - conversation should be filtered out due to repository mismatch
mock_build_result.return_value = ConversationInfoResultSet(
results=[], next_page_id=None
)
# Mock config
mock_config.conversation_max_age_seconds = 86400
mock_conv_mgr.get_connections = AsyncMock(return_value=[])
mock_conv_mgr.get_agent_loop_info = AsyncMock(return_value=[])
# Call the function
result = await get_microagent_management_conversations(
selected_repository='owner/repo',
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
app_conversation_service=mock_app_conversation_service,
)
# Verify conversation without repository is filtered out
@@ -626,34 +532,29 @@ async def test_get_microagent_management_conversations_age_filter():
mock_provider_handler = MagicMock(spec=ProviderHandler)
mock_provider_handler.is_pr_open = AsyncMock(return_value=True)
mock_app_conversation_service = _create_mock_app_conversation_service()
with (
patch(
'openhands.server.routes.manage_conversations.ProviderHandler',
return_value=mock_provider_handler,
),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_conv_mgr,
patch(
'openhands.server.routes.manage_conversations.wait_all',
side_effect=_mock_wait_all,
),
):
# Mock the build result function - only recent conversation should be included
mock_build_result.return_value = ConversationInfoResultSet(
results=[recent_conversation], next_page_id=None
)
# Mock config with short max age
mock_config.conversation_max_age_seconds = 3600 # 1 hour
mock_conv_mgr.get_connections = AsyncMock(return_value=[])
mock_conv_mgr.get_agent_loop_info = AsyncMock(return_value=[])
# Call the function
result = await get_microagent_management_conversations(
selected_repository='owner/repo',
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
app_conversation_service=mock_app_conversation_service,
)
# Verify only recent conversation is returned
@@ -673,25 +574,21 @@ async def test_get_microagent_management_conversations_pagination():
# Mock provider tokens
mock_provider_tokens = {'github': 'token_123'}
mock_app_conversation_service = _create_mock_app_conversation_service()
with (
patch('openhands.server.routes.manage_conversations.ProviderHandler'),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_conv_mgr,
patch(
'openhands.server.routes.manage_conversations.wait_all',
side_effect=_mock_wait_all,
),
):
# Mock the build result function
mock_build_result.return_value = ConversationInfoResultSet(
results=[], next_page_id='next_page_789'
)
# Mock config
mock_config.conversation_max_age_seconds = 86400
mock_conv_mgr.get_connections = AsyncMock(return_value=[])
mock_conv_mgr.get_agent_loop_info = AsyncMock(return_value=[])
# Call the function with pagination parameters
result = await get_microagent_management_conversations(
selected_repository='owner/repo',
@@ -699,15 +596,11 @@ async def test_get_microagent_management_conversations_pagination():
limit=5,
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
app_conversation_service=mock_app_conversation_service,
)
# Verify pagination parameters were passed correctly
mock_conversation_store.search.assert_called_once_with('test_page', 5)
# Decode and verify the next_page_id
decoded_page_id = _decode_combined_page_id(result.next_page_id)
assert decoded_page_id['v0'] == 'next_page_789'
assert result.next_page_id == 'next_page_789'
@pytest.mark.asyncio
@@ -722,31 +615,26 @@ async def test_get_microagent_management_conversations_default_parameters():
# Mock provider tokens
mock_provider_tokens = {'github': 'token_123'}
mock_app_conversation_service = _create_mock_app_conversation_service()
with (
patch('openhands.server.routes.manage_conversations.ProviderHandler'),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_conv_mgr,
patch(
'openhands.server.routes.manage_conversations.wait_all',
side_effect=_mock_wait_all,
),
):
# Mock the build result function
mock_build_result.return_value = ConversationInfoResultSet(
results=[], next_page_id=None
)
# Mock config
mock_config.conversation_max_age_seconds = 86400
mock_conv_mgr.get_connections = AsyncMock(return_value=[])
mock_conv_mgr.get_agent_loop_info = AsyncMock(return_value=[])
# Call the function without parameters (selected_repository is required)
result = await get_microagent_management_conversations(
selected_repository='owner/repo',
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
app_conversation_service=mock_app_conversation_service,
)
# Verify default values were used

View File

@@ -1,32 +0,0 @@
import pytest
from openhands.utils import environment
@pytest.fixture(autouse=True)
def clear_docker_cache():
if hasattr(environment.is_running_in_docker, 'cache_clear'):
environment.is_running_in_docker.cache_clear()
yield
if hasattr(environment.is_running_in_docker, 'cache_clear'):
environment.is_running_in_docker.cache_clear()
def test_get_effective_base_url_lemonade_in_docker(monkeypatch):
monkeypatch.setattr(environment, 'is_running_in_docker', lambda: True)
result = environment.get_effective_llm_base_url('lemonade/example', None)
assert result == environment.LEMONADE_DOCKER_BASE_URL
def test_get_effective_base_url_lemonade_outside_docker(monkeypatch):
monkeypatch.setattr(environment, 'is_running_in_docker', lambda: False)
base_url = 'http://localhost:8000/api/v1/'
result = environment.get_effective_llm_base_url('lemonade/example', base_url)
assert result == base_url
def test_get_effective_base_url_non_lemonade(monkeypatch):
monkeypatch.setattr(environment, 'is_running_in_docker', lambda: True)
base_url = 'https://api.example.com'
result = environment.get_effective_llm_base_url('openai/gpt-4', base_url)
assert result == base_url