mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5552c33426 | |||
| fb46099d3d | |||
| e6ddf09897 | |||
| d9f311a398 | |||
| f3d74ab807 | |||
| 6dbbf76231 | |||
| 1231b78aea | |||
| 9003f40096 | |||
| f70f649745 | |||
| 7939bd694b | |||
| 916bb85244 | |||
| 4ef1dde5f6 | |||
| cf982e0134 | |||
| b08238c841 | |||
| 831084df4c | |||
| eb4dacb577 | |||
| 8e71459601 | |||
| fc29815aa0 | |||
| a809d74b7d | |||
| b090d097ed | |||
| 79f32a34a0 | |||
| 805bc5608e | |||
| 61e1957cee | |||
| a25826a5f9 | |||
| df9320f8ab | |||
| af0ab5a9f2 | |||
| 9960d11d08 | |||
| d5d5e265f8 |
@@ -0,0 +1,58 @@
|
||||
# Workflow that builds and tests the CLI binary executable
|
||||
name: CLI - Build and Test Binary
|
||||
|
||||
# Run on pushes to main branch and all pull requests, but only when CLI files change
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "openhands-cli/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- "openhands-cli/**"
|
||||
|
||||
# Cancel previous runs if a new commit is pushed
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-test-binary:
|
||||
name: Build and test binary executable
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.12
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v3
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: openhands-cli
|
||||
run: |
|
||||
uv sync
|
||||
|
||||
- name: Build binary executable
|
||||
working-directory: openhands-cli
|
||||
run: |
|
||||
./build.sh --install-pyinstaller | tee output.log
|
||||
echo "Full output:"
|
||||
cat output.log
|
||||
|
||||
if grep -q "❌" output.log; then
|
||||
echo "❌ Found failure marker in output"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Build & test finished without ❌ markers"
|
||||
@@ -0,0 +1,29 @@
|
||||
# Feature branch preview for enterprise code
|
||||
name: Enterprise Preview
|
||||
|
||||
# Run on PRs labeled
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
# Match ghcr-build.yml, but don't interrupt it.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# This must happen for the PR Docker workflow when the label is present,
|
||||
# and also if it's added after the fact. Thus, it exists in both places.
|
||||
enterprise-preview:
|
||||
name: Enterprise preview
|
||||
if: github.event.label.name == 'deploy'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
# 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.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/All-Hands-AI/deploy/actions/workflows/deploy.yaml/dispatches
|
||||
@@ -235,12 +235,11 @@ jobs:
|
||||
|
||||
enterprise-preview:
|
||||
name: Enterprise preview
|
||||
if: |
|
||||
(github.event_name == 'pull_request' && github.event.action == 'labeled' && github.event.label.name == 'deploy') ||
|
||||
(github.event_name == 'pull_request' && github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'deploy'))
|
||||
if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'deploy')
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
needs: [ghcr_build_enterprise]
|
||||
steps:
|
||||
# This should match the version in enterprise-preview.yml
|
||||
- name: Trigger remote job
|
||||
run: |
|
||||
curl --fail-with-body -sS -X POST \
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
npm run make-i18n && tsc
|
||||
npm run check-translation-completeness
|
||||
|
||||
# Run lint on the python code
|
||||
# Run lint on the python code (excluding CLI and enterprise)
|
||||
lint-python:
|
||||
name: Lint python
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
@@ -73,6 +73,24 @@ jobs:
|
||||
working-directory: ./enterprise
|
||||
run: pre-commit run --all-files --config ./dev_config/python/.pre-commit-config.yaml
|
||||
|
||||
lint-cli-python:
|
||||
name: Lint CLI python
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: "pip"
|
||||
- name: Install pre-commit
|
||||
run: pip install pre-commit==3.7.0
|
||||
- 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
|
||||
|
||||
@@ -104,3 +104,33 @@ jobs:
|
||||
- name: Run Unit Tests
|
||||
working-directory: ./enterprise
|
||||
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest --forked -n auto -svv -p no:ddtrace -p no:ddtrace.pytest_bdd -p no:ddtrace.pytest_benchmark ./tests/unit
|
||||
|
||||
# Run CLI unit tests
|
||||
test-cli-python:
|
||||
name: CLI Unit Tests
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v3
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./openhands-cli
|
||||
run: |
|
||||
uv sync --group dev
|
||||
|
||||
- name: Run CLI unit tests
|
||||
working-directory: ./openhands-cli
|
||||
run: |
|
||||
uv run pytest -v
|
||||
|
||||
+2
-1
@@ -31,7 +31,8 @@ requirements.txt
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
# Note: openhands-cli.spec is intentionally tracked for CLI builds
|
||||
# *.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
|
||||
+1
-1
@@ -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/all-hands-ai/runtime:0.55-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.56-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -79,17 +79,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.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-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.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.56
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
+3
-3
@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-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.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.56
|
||||
```
|
||||
|
||||
> **注意**: 如果您在0.44版本之前使用过OpenHands,您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。
|
||||
|
||||
+3
-3
@@ -42,17 +42,17 @@ OpenHandsはDockerを利用してローカル環境でも実行できます。
|
||||
> 公共ネットワークで実行していますか?[Hardened Docker Installation Guide](https://docs.all-hands.dev/usage/runtimes/docker#hardened-docker-installation)を参照して、ネットワークバインディングの制限や追加のセキュリティ対策を実施してください。
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-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.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.56
|
||||
```
|
||||
|
||||
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。
|
||||
|
||||
@@ -219,6 +219,14 @@ correct_num = 5
|
||||
api_key = ""
|
||||
model = "gpt-4o"
|
||||
|
||||
# Example routing LLM configuration for multimodal model routing
|
||||
# Uncomment and configure to enable model routing with a secondary model
|
||||
#[llm.secondary_model]
|
||||
#model = "kimi-k2"
|
||||
#api_key = ""
|
||||
#for_routing = true
|
||||
#max_input_tokens = 128000
|
||||
|
||||
|
||||
#################################### Agent ###################################
|
||||
# Configuration for agents (group name starts with 'agent')
|
||||
@@ -480,3 +488,14 @@ type = "noop"
|
||||
|
||||
# Run the runtime sandbox container in privileged mode for use with docker-in-docker
|
||||
#privileged = false
|
||||
|
||||
#################################### Model Routing ############################
|
||||
# Configuration for experimental model routing feature
|
||||
# Enables intelligent switching between different LLM models for specific purposes
|
||||
##############################################################################
|
||||
[model_routing]
|
||||
# Router to use for model selection
|
||||
# Available options:
|
||||
# - "noop_router" (default): No routing, always uses primary LLM
|
||||
# - "multimodal_router": A router that switches between primary and secondary models, depending on whether the input is multimodal or not
|
||||
#router_name = "noop_router"
|
||||
|
||||
@@ -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/all-hands-ai/runtime:0.55-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.56-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -3,9 +3,9 @@ repos:
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/|openhands-cli/)
|
||||
- id: end-of-file-fixer
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/|openhands-cli/)
|
||||
- id: check-yaml
|
||||
args: ["--allow-multiple-documents"]
|
||||
- id: debug-statements
|
||||
@@ -28,12 +28,12 @@ repos:
|
||||
entry: ruff check --config dev_config/python/ruff.toml
|
||||
types_or: [python, pyi, jupyter]
|
||||
args: [--fix, --unsafe-fixes]
|
||||
exclude: ^(third_party/|enterprise/)
|
||||
exclude: ^(third_party/|enterprise/|openhands-cli/)
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
entry: ruff format --config dev_config/python/ruff.toml
|
||||
types_or: [python, pyi, jupyter]
|
||||
exclude: ^(third_party/|enterprise/)
|
||||
exclude: ^(third_party/|enterprise/|openhands-cli/)
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.15.0
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.56-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:
|
||||
|
||||
@@ -113,7 +113,7 @@ The conversation history will be saved in `~/.openhands/sessions`.
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -122,7 +122,7 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.56 \
|
||||
python -m openhands.cli.entry --override-cli-mode true
|
||||
```
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ Set environment variables and run the Docker command:
|
||||
|
||||
```bash
|
||||
# Set required environment variables
|
||||
export SANDBOX_VOLUMES="/path/to/workspace" # See SANDBOX_VOLUMES docs for details
|
||||
export SANDBOX_VOLUMES="/path/to/workspace:/workspace:rw" # Format: host_path:container_path:mode
|
||||
export LLM_MODEL="anthropic/claude-sonnet-4-20250514"
|
||||
export LLM_API_KEY="your-api-key"
|
||||
export SANDBOX_SELECTED_REPO="owner/repo-name" # Optional: requires GITHUB_TOKEN
|
||||
@@ -61,7 +61,7 @@ export GITHUB_TOKEN="your-token" # Required for repository operations
|
||||
# Run OpenHands
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -73,7 +73,7 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.56 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
|
||||
@@ -68,23 +68,23 @@ Download and install the LM Studio desktop app from [lmstudio.ai](https://lmstud
|
||||
1. Check [the installation guide](/usage/local-setup) and ensure all prerequisites are met before running OpenHands, then run:
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-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.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.56
|
||||
```
|
||||
|
||||
2. Wait until the server is running (see log below):
|
||||
```
|
||||
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.56
|
||||
Starting OpenHands...
|
||||
Running OpenHands as root
|
||||
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
|
||||
|
||||
@@ -116,17 +116,17 @@ Note that you'll still need `uv` installed for the default MCP servers to work p
|
||||
<Accordion title="Docker Command (Click to expand)">
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-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.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.56
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -390,24 +390,24 @@ class GitHubDataCollector:
|
||||
merged_by = None
|
||||
merge_commit_sha = None
|
||||
if is_merged:
|
||||
merged_by = pr_data.get('mergedBy', {}).get('login')
|
||||
merge_commit_sha = pr_data.get('mergeCommit', {}).get('oid')
|
||||
merged_by = (pr_data.get('mergedBy') or {}).get('login')
|
||||
merge_commit_sha = (pr_data.get('mergeCommit') or {}).get('oid')
|
||||
|
||||
return {
|
||||
'repo_metadata': self._extract_repo_metadata(repo_data),
|
||||
'pr_metadata': {
|
||||
'username': pr_data.get('author', {}).get('login'),
|
||||
'number': pr_data['number'],
|
||||
'title': pr_data['title'],
|
||||
'body': pr_data['body'],
|
||||
'username': (pr_data.get('author') or {}).get('login'),
|
||||
'number': pr_data.get('number'),
|
||||
'title': pr_data.get('title'),
|
||||
'body': pr_data.get('body'),
|
||||
'comments': pr_comments,
|
||||
},
|
||||
'commits': commits,
|
||||
'review_comments': review_comments,
|
||||
'merge_status': {
|
||||
'merged': pr_data['merged'],
|
||||
'merged': pr_data.get('merged'),
|
||||
'merged_by': merged_by,
|
||||
'state': pr_data['state'],
|
||||
'state': pr_data.get('state'),
|
||||
'merge_commit_sha': merge_commit_sha,
|
||||
},
|
||||
'openhands_stats': {
|
||||
|
||||
Generated
+66
-111
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "aiofiles"
|
||||
@@ -1426,73 +1426,73 @@ yaml = ["pyyaml (>=6.0.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "ddtrace"
|
||||
version = "3.12.4"
|
||||
version = "3.13.0"
|
||||
description = "Datadog APM client library"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "ddtrace-3.12.4-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:222dc483f22a065795f473cad6fc6e798ecf9da9f4fc99ca87f1ba70f34d21b1"},
|
||||
{file = "ddtrace-3.12.4-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:196f114a70b75320876f6861c10435c6d4ea50e0f406328b0862a021c344d002"},
|
||||
{file = "ddtrace-3.12.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4200e8b057b29ce3ba0889a9d423e4d105b0ba35d4bd58ba2670763018909623"},
|
||||
{file = "ddtrace-3.12.4-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7fc1449d511e04e8b2596eee6d1ad2d3420dff23f6dfd8a899c5e3e03dfe8ba5"},
|
||||
{file = "ddtrace-3.12.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ebae69206957837341cd94bbe78e5242395f7571455dfe911b56ea2f7404ada"},
|
||||
{file = "ddtrace-3.12.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a08cd25234358a2427494d4059ee12afc83e083bad65f2bd62417fd935caa737"},
|
||||
{file = "ddtrace-3.12.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fbe90ff2c914c753116807ddffde9065ecbf9944bdc4932862c3f5835485004d"},
|
||||
{file = "ddtrace-3.12.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1b3be9452bc76f730203b86272f8312c7e195b3125f964900df3f41c39ec0c94"},
|
||||
{file = "ddtrace-3.12.4-cp310-cp310-win32.whl", hash = "sha256:b331bc0c3000cea1fd70febcf004b5a617c63b9050094f08100891a23638986d"},
|
||||
{file = "ddtrace-3.12.4-cp310-cp310-win_amd64.whl", hash = "sha256:018d19e2a1e7585df65d938ae51c385d673e8001b66827a47e499ade3b227ad2"},
|
||||
{file = "ddtrace-3.12.4-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:0de9563bad27007fd64059e3b5bb3a791184e39619fdb096044e68a454b4427b"},
|
||||
{file = "ddtrace-3.12.4-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:d0c5b84d066ca3d60da9636df526382416dae4288f66fcdaca7a2e765ca2f0bd"},
|
||||
{file = "ddtrace-3.12.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff1812b1d7e8344088a978f1d4f621257fe1ad5d8efc07317a3c90c280e5bdc4"},
|
||||
{file = "ddtrace-3.12.4-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd0ac6ba50d36689bf0eeadc88ce91b60bc863036f3dea90dd5656f39bce3ac4"},
|
||||
{file = "ddtrace-3.12.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8f99761f946b2b7cc2ea4cba821a7a94d05a9eb8cd8a3feabdb49eeacc18bb9"},
|
||||
{file = "ddtrace-3.12.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c4f66c48eca7d6759766fcaf24ac3a65e712e62ae7b1f521a7da2b8d7f101849"},
|
||||
{file = "ddtrace-3.12.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:42d46f17baaa5040e4f438544603033af8eeec32067c3712a9e620392d75f484"},
|
||||
{file = "ddtrace-3.12.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aa0606a07e7d05881f2ef1172f4175733ae3006bfc3c7cfd58b82ea3ed75c914"},
|
||||
{file = "ddtrace-3.12.4-cp311-cp311-win32.whl", hash = "sha256:efde4b33502f3897993a564ee56d0ea30a65d658d616d16c5ef23c850d0e3417"},
|
||||
{file = "ddtrace-3.12.4-cp311-cp311-win_amd64.whl", hash = "sha256:7d6117fabcd98d3a696d1f80314c9b9e4325b362b31714551efd729a02152ff1"},
|
||||
{file = "ddtrace-3.12.4-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:734d782d9f64de378f632516554b9da0dfbf54cf1bb7be4bb1085165e7c052ad"},
|
||||
{file = "ddtrace-3.12.4-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:fbf2543856b4ed5a1d6ac59c82f8c76cef5f4ef65361d59f60ce01db92a4c8d1"},
|
||||
{file = "ddtrace-3.12.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:751ce0410405113286bd558fd402f8a58f5b455cee4deb467ae9ae87e5713547"},
|
||||
{file = "ddtrace-3.12.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd804c06d62926cc18a354987f7d5c1fecd1da30983041d3f98bc402d9d23713"},
|
||||
{file = "ddtrace-3.12.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e55b911d5b9f1bd73731870962809f9089677f4d3736d52587b4ba76eee56962"},
|
||||
{file = "ddtrace-3.12.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8cc90fdcd7f021d06383b88c0e40726706c06088dddd528e31cf3c65a9fea9"},
|
||||
{file = "ddtrace-3.12.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:585b7b26f03c64390c800e180304639b4226c34c533f16bc6cd9c328ee4f727a"},
|
||||
{file = "ddtrace-3.12.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe967af58f2e0033caa977c512a4bfb7af3c6f5ad57e9bdef9241609a4d8a99b"},
|
||||
{file = "ddtrace-3.12.4-cp312-cp312-win32.whl", hash = "sha256:fe03b8f513513e28c35bc792cd7ef0602b21cbcfe71d17a2dd962aee23e980d9"},
|
||||
{file = "ddtrace-3.12.4-cp312-cp312-win_amd64.whl", hash = "sha256:9fd79c44ecffb36ac5b3168f0f196778ed0dd538beb07961ce10e06b8045af35"},
|
||||
{file = "ddtrace-3.12.4-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:2edf755f4bfd823ce8b560c233cb17137ef79d097bc1ade7914f684b39011bcb"},
|
||||
{file = "ddtrace-3.12.4-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:6dad7ca193810beb931e81b7430dd074a53bf8f8bd5bdc19acd198d460b2438a"},
|
||||
{file = "ddtrace-3.12.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9de7aa6b6ea3d41f8f20c5e00dd85b2f2b3bb1591f3b7deab5d4c527620c3cb3"},
|
||||
{file = "ddtrace-3.12.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80e0acbbe85365f113bf6e57f77a82f0e0612a7a4cb57f16e9e184748a2bc478"},
|
||||
{file = "ddtrace-3.12.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46de7dd48256d8e347f2ab436644bd8946d3605caedb150eb46327a9f5b005b6"},
|
||||
{file = "ddtrace-3.12.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d5c9ddacecb0072292360813b453129998ca293e13c542fa51771c7734ef03a"},
|
||||
{file = "ddtrace-3.12.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d0b694838e6c7ea2da6de7ccd7b866ec439c49fa40b68ac46f657163cb571d93"},
|
||||
{file = "ddtrace-3.12.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e89a17cdb4b5442b97a219e8522b9c665cf7a5116f7e97049dd145f837bad5b1"},
|
||||
{file = "ddtrace-3.12.4-cp313-cp313-win32.whl", hash = "sha256:d0b3ec8228950e7ff68c39537630cd12880656d96461ef021d6484b2df8dba84"},
|
||||
{file = "ddtrace-3.12.4-cp313-cp313-win_amd64.whl", hash = "sha256:fad78414731b242e86016a124299f2f41575ccf58444edca777b425dbd9faf0c"},
|
||||
{file = "ddtrace-3.12.4-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:9f639f70f1689ec1a1049cd64132491ee09bcfe7609d73f8c220e38261611045"},
|
||||
{file = "ddtrace-3.12.4-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:6b5b150e9d362f7242159dd5a5a7107f1be091282c0ee69301fb7ede60f28d3c"},
|
||||
{file = "ddtrace-3.12.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eda3b6ebd275f7f7272f45f4e8ee0e0720c1e217c80140270f8c5e415e11133e"},
|
||||
{file = "ddtrace-3.12.4-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe644904b44d39a93eb40fb033aef26a03e4096d135ee844b71ed49d1bd647ad"},
|
||||
{file = "ddtrace-3.12.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62a48fc36308919afb1fae22a268a96cff3448f1feb860db97d130498ddfa428"},
|
||||
{file = "ddtrace-3.12.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:77de49365f55033d7e14b544f92d0cae71969b78c4ab8642c3340124e0200739"},
|
||||
{file = "ddtrace-3.12.4-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:87fbd5126f8339bcb508a52455f58b0c92870a1c3748849a4d6543198b5f8752"},
|
||||
{file = "ddtrace-3.12.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5845d7c2ed46b44e02bd5d36ca7f8e80a4e942683473c867393b9fd4553f9d64"},
|
||||
{file = "ddtrace-3.12.4-cp38-cp38-win32.whl", hash = "sha256:ebde5af8c5d98f435d7dec960c97151142a4b302e94c20da79ed58fe8a08052e"},
|
||||
{file = "ddtrace-3.12.4-cp38-cp38-win_amd64.whl", hash = "sha256:18dfe9a1a02bfa4ef4f614122135509f454abeff625039b764bc461462ba0923"},
|
||||
{file = "ddtrace-3.12.4-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:e78957120c64bd56ce5592bc10587d7c0d1ca68f21f5b46f6a18dafbc43ad234"},
|
||||
{file = "ddtrace-3.12.4-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:3936243dc989b8e8e3bb004262abe68a1cc3e0b9356671c01233b84d2c837903"},
|
||||
{file = "ddtrace-3.12.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed76d10787fc288ea94808ce601df243fc3953c7142baefac446015bed799790"},
|
||||
{file = "ddtrace-3.12.4-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c1d3f7f93146653f8ed06d8cd54030b2c902ceca6de55f6df7f40d23037181e"},
|
||||
{file = "ddtrace-3.12.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f5ab24c82fc7532386b02530f90fed2964718cea296adf6d35fc31bd30d301d"},
|
||||
{file = "ddtrace-3.12.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:30bd9e57923a99d5b4e6562976e9f7307d685caff1544b3d2f7438e6ef8e87e8"},
|
||||
{file = "ddtrace-3.12.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3bf18fd5898940fb7f236b4c9796f0ee517eb755fd0c17965d3a0342f865ee5a"},
|
||||
{file = "ddtrace-3.12.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8ff1c70da37c05a29f0be091b0fdc6bb1d91d448f56861c51df614649441070c"},
|
||||
{file = "ddtrace-3.12.4-cp39-cp39-win32.whl", hash = "sha256:66c007170698e3d12638d03e80f02e93c3bb3e55e96a7f5517e638056562ec1a"},
|
||||
{file = "ddtrace-3.12.4-cp39-cp39-win_amd64.whl", hash = "sha256:a4f2dabbc95e5c6bf4c43eb141e94021789c81a929588f4000f876f89882c124"},
|
||||
{file = "ddtrace-3.12.4.tar.gz", hash = "sha256:c422977fc4f6e9ba7d4eef9b7e6ce00f8b81c68b034682c6a63eb5c9670e37d8"},
|
||||
{file = "ddtrace-3.13.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:12122a8e7089ab40cad2cd6bb51834859aa0a27babf3256a73630e6ee2315455"},
|
||||
{file = "ddtrace-3.13.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:02fab2c444b87f290850b3d750e17ccdf49ace3baf8ff3305e8147f6fdf0dc50"},
|
||||
{file = "ddtrace-3.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a003ffa4649dab4971d3557ce2d85eb2c5d335ebc7152196cbf780171fd4b5e1"},
|
||||
{file = "ddtrace-3.13.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:52b2458b6f0f4725156d46c6cb5410f98568a61cc890bb270515c9caad3a522d"},
|
||||
{file = "ddtrace-3.13.0-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:9160222e476e18af95ef687bd548f8e86b3815896bf7cd1d42a9b43005e058e2"},
|
||||
{file = "ddtrace-3.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:464e245c2114c722ad4240b73b1c598f83cc1c7bdc9001aec3083f914c1cacc0"},
|
||||
{file = "ddtrace-3.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:21901a58e938dbeba0ca6c49b8ba1480d07eea5b057845ae4ff3a706d833137f"},
|
||||
{file = "ddtrace-3.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:40e00faced483a3eac0b499cf191a38fbf8bb060a3872029ee3299871f87bdd9"},
|
||||
{file = "ddtrace-3.13.0-cp310-cp310-win32.whl", hash = "sha256:d15593cb804d74094df1a71167a70136b7616579259ce2b26279f2762354e709"},
|
||||
{file = "ddtrace-3.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:5de44e7c595d25745665fa1cc44c0f0b4c7ad79be06d0de74f6e0edb2c8ec351"},
|
||||
{file = "ddtrace-3.13.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:68c38ac75cc3668e9284873f5e84c3e104880d68c3891ed13614e0614c46f5b0"},
|
||||
{file = "ddtrace-3.13.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:6d8811c4b7397384aff7e54b7399647f4c1c0e9167792cb45adb2d3553fc20a2"},
|
||||
{file = "ddtrace-3.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:029b6e6c50984b1976c6b0970e60184919dab9514441d08683a50a5d52a05326"},
|
||||
{file = "ddtrace-3.13.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8de2a060400ee89422ecfd3269dfd2e113f4f9dae00f6fcd3ed9e53e2223a26a"},
|
||||
{file = "ddtrace-3.13.0-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:bb0738048ea0e49e6bec9be2bf5c68a24d7ea3b27bf956147378366aacb4ca4b"},
|
||||
{file = "ddtrace-3.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:04cf4776c52cfb19914bf6e84242d110197d15426c34e45b14fa63d9085767d5"},
|
||||
{file = "ddtrace-3.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c32774e90593ebb264d53d6523b71243b9ba794ae5689e38ad522afddd06c0b"},
|
||||
{file = "ddtrace-3.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a01f99b0287c2bbd8b305e0cb54b382eaf2a0fe89ba82f2f68fcbdd9fed040cd"},
|
||||
{file = "ddtrace-3.13.0-cp311-cp311-win32.whl", hash = "sha256:b37efa3e7b487bd60e6fb89186d98c1ad1727871074f3519c9ca92feea7e5cd0"},
|
||||
{file = "ddtrace-3.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:112e4d96f02f94247528b65f046c69d360d6eca75b9e7cd2f95fde1c14e2002e"},
|
||||
{file = "ddtrace-3.13.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:13ac5bc306df5719d00a8b1f6925efbb9dd0ba5e121edcc2acfef24c57b3deb5"},
|
||||
{file = "ddtrace-3.13.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:b3bdfc3cabab85f91a4f24264a2d0f6f74984a5b5994c62072c6e3b5e05320f3"},
|
||||
{file = "ddtrace-3.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:11b10f8dfadb4b1372aee820be6c22071138ede2ddb32f73486255d5879b283f"},
|
||||
{file = "ddtrace-3.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a3d68007602797f280c971a286c3f05bdff66c12a68a3e0bd67cb5bbc1c4a67a"},
|
||||
{file = "ddtrace-3.13.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:abd00a5b83d85a951dd976a59c8673bedacdc1ea9e6acb8e72545f73bddc7879"},
|
||||
{file = "ddtrace-3.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5dbe392b2182e6dd617e946cf41da7e3207387b912809ebe8338b794b08750b2"},
|
||||
{file = "ddtrace-3.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6b38b4ad9e3f1b3421022587748f6a687ed722eae16033392fc875b5c67d6c5a"},
|
||||
{file = "ddtrace-3.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f38a1545495c8db3318621400a3d407db457e3550a397e39cf883f41919e1dc8"},
|
||||
{file = "ddtrace-3.13.0-cp312-cp312-win32.whl", hash = "sha256:e01bb1b305b777001d310911bd73d1fd88c9c212258caaf65f1422a0dbef1a3b"},
|
||||
{file = "ddtrace-3.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:8dbb9aa23a36599754932e79df28eb07fdd3aaca515297bf58dfcdac608273da"},
|
||||
{file = "ddtrace-3.13.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:397a68e476d8bd9aa14f8c097bc9014510948e76a0110842ab6f5fa1143ad153"},
|
||||
{file = "ddtrace-3.13.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:fab1b06476169e2cf6a098130c44eeb3d9d8205b5a91ae8afdb7d2b4d2d0b0be"},
|
||||
{file = "ddtrace-3.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:653f75c3e838366108464f9555120f61ef0589974f346ed2c2c9cb3001d3fc6a"},
|
||||
{file = "ddtrace-3.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80f694c3d3984c9bd3bd7818268be7ece02071c67671c6d8c815e6888ae4e78c"},
|
||||
{file = "ddtrace-3.13.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:be16f9c0583767db13403e78ac7ac7b4c103e8b7eaac6deef7c897408f24b940"},
|
||||
{file = "ddtrace-3.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c5490a715fbb70ee03840c6a3146c76d7bfa27d5b679ce4c1a7b368eff7dee9f"},
|
||||
{file = "ddtrace-3.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:45235a81c828e2d6bdb4ac1bbe55582c190bc27e8820eeae5c0478ea11f1ed81"},
|
||||
{file = "ddtrace-3.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a9374a8cf405169a9eab7791cc94d5dc5753eefe806b5bee9909eef3d5e339d"},
|
||||
{file = "ddtrace-3.13.0-cp313-cp313-win32.whl", hash = "sha256:6bc1648a1c046e6061e29d94d2003c17820cc3a7f1c24322dab654abe9bb30db"},
|
||||
{file = "ddtrace-3.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8823e95f69dd3fc8a884d092fdc54a3c3078daf0f90e824fceda7e0f26acbc70"},
|
||||
{file = "ddtrace-3.13.0-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:338932a8511a815d5198ec09d55f6850fcb9c679a1b50a3a28fdc0ff99bd800a"},
|
||||
{file = "ddtrace-3.13.0-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:c14fe68cfc1c11b9d560a3026e3e5dcdd59b725b6ce79cda66d23a26b37751e6"},
|
||||
{file = "ddtrace-3.13.0-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3fd70631f5c70ccafde14df98a9f807e537222f13d6f03fa08bf1308eaf89301"},
|
||||
{file = "ddtrace-3.13.0-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09c71f464afb05d7f1a2758112f4feaf2bca39daa22a6c3f75999227eb40e2ec"},
|
||||
{file = "ddtrace-3.13.0-cp38-cp38-manylinux_2_28_i686.whl", hash = "sha256:481b13365e3cf100bf35f305bd0680695fa369e67a9ec4e1b41788df62ac1d0b"},
|
||||
{file = "ddtrace-3.13.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:0d99ebbef96f406e0436bd21a92354c3c338fc6a8fe85d0a26fe942bc563b721"},
|
||||
{file = "ddtrace-3.13.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:28086003f1c5ce3e84239eea9d624afcc386b38f2115c3438ea49beff84ff861"},
|
||||
{file = "ddtrace-3.13.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:f280e80560f5c953bb16b168bed1b6f7d527ef98f81860422500040ee57a7aba"},
|
||||
{file = "ddtrace-3.13.0-cp38-cp38-win32.whl", hash = "sha256:82f0b76c83e368c686594f42809d727143ee89a879d1a76cde9f75d4cea07cb4"},
|
||||
{file = "ddtrace-3.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:dd7b3a9933b11b2fce4dd4cb34ee465bc3c87024444a2e6a5a653f424bae8e37"},
|
||||
{file = "ddtrace-3.13.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:c1ce2123615e4618050ec7fc96e296283f23c45eddcf3a2fe94386f7513795a4"},
|
||||
{file = "ddtrace-3.13.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:9dae3459edd5cc7a1124596b524b743b1d2bddf4155ca9679c599740ad71546d"},
|
||||
{file = "ddtrace-3.13.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d36d0cf84a39b29f88dcb06a20fc3f2c7a9eca8eb1fd5d15bc5a51de095962c"},
|
||||
{file = "ddtrace-3.13.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d4a55277a3db32fee06030fd0dbf77c2e867541c3e4b65e68e46b03971401173"},
|
||||
{file = "ddtrace-3.13.0-cp39-cp39-manylinux_2_28_i686.whl", hash = "sha256:cb97593d9739f0be6647e19edc6fc6998dfba3e78fb9d2df5fef9ebfb117aa85"},
|
||||
{file = "ddtrace-3.13.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f905e5bb2db4c154fca25ded15c3e1d633951db2d6ed2989f630ee3afd589cc0"},
|
||||
{file = "ddtrace-3.13.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:de3ecc6428330117ef063ef6a90326669a9a4cf3e766674228ec384edca52bb1"},
|
||||
{file = "ddtrace-3.13.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eec340ef5152e971dc6ab075945dfa7c41285f8441bea0a78f5f4cd1f6b9aab6"},
|
||||
{file = "ddtrace-3.13.0-cp39-cp39-win32.whl", hash = "sha256:8c2831f928393f934bfe9f9b5f0eeb22a0f5c88fbebe32cc5106b24409847d6b"},
|
||||
{file = "ddtrace-3.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:e04f4c41e7216422e9cd101bee70a823f56dddb8333158e1e72b73332e1a311d"},
|
||||
{file = "ddtrace-3.13.0.tar.gz", hash = "sha256:d7d3d82795d29cf2385aa692ee5c65e469ebfa34469941055af66eae2eefa374"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -2325,27 +2325,6 @@ gitdb = ">=4.0.1,<5"
|
||||
doc = ["sphinx (>=7.1.2,<7.2)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"]
|
||||
test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock ; python_version < \"3.8\"", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions ; python_version < \"3.11\""]
|
||||
|
||||
[[package]]
|
||||
name = "google-ai-generativelanguage"
|
||||
version = "0.6.15"
|
||||
description = "Google Ai Generativelanguage API client library"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "google_ai_generativelanguage-0.6.15-py3-none-any.whl", hash = "sha256:5a03ef86377aa184ffef3662ca28f19eeee158733e45d7947982eb953c6ebb6c"},
|
||||
{file = "google_ai_generativelanguage-0.6.15.tar.gz", hash = "sha256:8f6d9dc4c12b065fe2d0289026171acea5183ebf2d0b11cefe12f3821e159ec3"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]}
|
||||
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev"
|
||||
proto-plus = [
|
||||
{version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""},
|
||||
{version = ">=1.22.3,<2.0.0dev"},
|
||||
]
|
||||
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev"
|
||||
|
||||
[[package]]
|
||||
name = "google-api-core"
|
||||
version = "2.25.1"
|
||||
@@ -2684,30 +2663,6 @@ websockets = ">=13.0.0,<15.1.0"
|
||||
[package.extras]
|
||||
aiohttp = ["aiohttp (<4.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "google-generativeai"
|
||||
version = "0.8.5"
|
||||
description = "Google Generative AI High level API client library and tools."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "google_generativeai-0.8.5-py3-none-any.whl", hash = "sha256:22b420817fb263f8ed520b33285f45976d5b21e904da32b80d4fd20c055123a2"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
google-ai-generativelanguage = "0.6.15"
|
||||
google-api-core = "*"
|
||||
google-api-python-client = "*"
|
||||
google-auth = ">=2.15.0"
|
||||
protobuf = "*"
|
||||
pydantic = "*"
|
||||
tqdm = "*"
|
||||
typing-extensions = "*"
|
||||
|
||||
[package.extras]
|
||||
dev = ["Pillow", "absl-py", "black", "ipython", "nose2", "pandas", "pytype", "pyyaml"]
|
||||
|
||||
[[package]]
|
||||
name = "google-resumable-media"
|
||||
version = "2.7.2"
|
||||
@@ -5432,7 +5387,7 @@ google-api-python-client = "^2.164.0"
|
||||
google-auth-httplib2 = "*"
|
||||
google-auth-oauthlib = "*"
|
||||
google-cloud-aiplatform = "*"
|
||||
google-generativeai = "*"
|
||||
google-genai = "*"
|
||||
html2text = "*"
|
||||
httpx-aiohttp = "^0.1.8"
|
||||
ipywidgets = "^8.1.5"
|
||||
@@ -5483,7 +5438,7 @@ whatthepatch = "^1.0.6"
|
||||
zope-interface = "7.2"
|
||||
|
||||
[package.extras]
|
||||
third-party-runtimes = ["daytona (==0.24.2)", "e2b (>=1.0.5,<1.8.0)", "modal (>=0.66.26,<1.2.0)", "runloop-api-client (==0.50.0)"]
|
||||
third-party-runtimes = ["daytona (==0.24.2)", "e2b-code-interpreter (>=2.0.0,<3.0.0)", "modal (>=0.66.26,<1.2.0)", "runloop-api-client (==0.50.0)"]
|
||||
|
||||
[package.source]
|
||||
type = "directory"
|
||||
@@ -10053,4 +10008,4 @@ cffi = ["cffi (>=1.17) ; python_version >= \"3.13\" and platform_python_implemen
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "0e611931bd3823ee8b6d832b6ef444868a644e21927a9fb72d4aeaab8170028e"
|
||||
content-hash = "5771671ef2acc36e7b0931c73fa035ca1d329e8dac6827f7a349e1a569c3fd23"
|
||||
|
||||
@@ -37,7 +37,7 @@ sqlalchemy = { extras = [ "asyncio" ], version = "^2.0.40" }
|
||||
resend = "^2.7.0"
|
||||
tenacity = "^9.1.2"
|
||||
slack-sdk = "^3.35.0"
|
||||
ddtrace = "^3.5.1"
|
||||
ddtrace = "3.13.0" #pin to avoid yanked version 3.12.4
|
||||
posthog = "^4.2.0"
|
||||
limits = "^5.2.0"
|
||||
coredis = "^4.22.0"
|
||||
|
||||
@@ -275,9 +275,7 @@ class TokenManager:
|
||||
self._check_expiration_and_refresh
|
||||
)
|
||||
if not token_info:
|
||||
logger.error(
|
||||
f'No tokens for user: {username}, identity provider: {idp}'
|
||||
)
|
||||
logger.info(f'No tokens for user: {username}, identity provider: {idp}')
|
||||
raise ValueError(
|
||||
f'No tokens for user: {username}, identity provider: {idp}'
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@ from evaluation.utils.shared import (
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
update_llm_config_for_completions_logging,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
@@ -36,7 +37,11 @@ from openhands.core.config import (
|
||||
get_llm_config_arg,
|
||||
load_from_toml,
|
||||
)
|
||||
from openhands.core.config.utils import get_agent_config_arg
|
||||
from openhands.core.config.utils import (
|
||||
get_agent_config_arg,
|
||||
get_llms_for_routing_config,
|
||||
get_model_routing_config_arg,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
from openhands.events.action import AgentFinishAction, CmdRunAction, MessageAction
|
||||
@@ -57,6 +62,7 @@ AGENT_CLS_TO_INST_SUFFIX = {
|
||||
|
||||
|
||||
def get_config(
|
||||
instance: pd.Series,
|
||||
metadata: EvalMetadata,
|
||||
) -> OpenHandsConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
@@ -66,13 +72,24 @@ def get_config(
|
||||
sandbox_config=sandbox_config,
|
||||
runtime='docker',
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
config.set_llm_config(
|
||||
update_llm_config_for_completions_logging(
|
||||
metadata.llm_config, metadata.eval_output_dir, instance['instance_id']
|
||||
)
|
||||
)
|
||||
model_routing_config = get_model_routing_config_arg()
|
||||
model_routing_config.llms_for_routing = (
|
||||
get_llms_for_routing_config()
|
||||
) # Populate with LLMs for routing from config.toml file
|
||||
|
||||
if metadata.agent_config:
|
||||
metadata.agent_config.model_routing = model_routing_config
|
||||
config.set_agent_config(metadata.agent_config, metadata.agent_class)
|
||||
else:
|
||||
logger.info('Agent config not provided, using default settings')
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
agent_config.enable_prompt_extensions = False
|
||||
agent_config.model_routing = model_routing_config
|
||||
|
||||
config_copy = copy.deepcopy(config)
|
||||
load_from_toml(config_copy)
|
||||
@@ -145,7 +162,7 @@ def process_instance(
|
||||
metadata: EvalMetadata,
|
||||
reset_logger: bool = True,
|
||||
) -> EvalOutput:
|
||||
config = get_config(metadata)
|
||||
config = get_config(instance, metadata)
|
||||
|
||||
# Setup the logger properly, so you can run multi-processing to parallelize the evaluation
|
||||
if reset_logger:
|
||||
|
||||
@@ -47,6 +47,8 @@ from openhands.core.config import (
|
||||
get_agent_config_arg,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_llms_for_routing_config,
|
||||
get_model_routing_config_arg,
|
||||
)
|
||||
from openhands.core.config.condenser_config import NoOpCondenserConfig
|
||||
from openhands.core.config.utils import get_condenser_config_arg
|
||||
@@ -244,6 +246,11 @@ def get_config(
|
||||
# get 'draft_editor' config if exists
|
||||
config.set_llm_config(get_llm_config_arg('draft_editor'), 'draft_editor')
|
||||
|
||||
model_routing_config = get_model_routing_config_arg()
|
||||
model_routing_config.llms_for_routing = (
|
||||
get_llms_for_routing_config()
|
||||
) # Populate with LLMs for routing from config.toml file
|
||||
|
||||
agent_config = AgentConfig(
|
||||
enable_jupyter=False,
|
||||
enable_browsing=RUN_WITH_BROWSING,
|
||||
@@ -251,8 +258,10 @@ def get_config(
|
||||
enable_mcp=False,
|
||||
condenser=metadata.condenser_config,
|
||||
enable_prompt_extensions=False,
|
||||
model_routing=model_routing_config,
|
||||
)
|
||||
config.set_agent_config(agent_config)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -86,28 +86,21 @@ describe("Content", () => {
|
||||
expect(screen.queryByTestId("connect-git-button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a button to connect with git if they havent already in saas", async () => {
|
||||
it("should render add secret button in saas mode", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
|
||||
// @ts-expect-error - only return the config we need
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
});
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {},
|
||||
});
|
||||
|
||||
renderSecretsSettings();
|
||||
|
||||
// In SAAS mode, getSecrets is still called because the user is authenticated
|
||||
// In SAAS mode, getSecrets is called and add secret button should be available
|
||||
await waitFor(() => expect(getSecretsSpy).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("add-secret-button")).not.toBeInTheDocument(),
|
||||
);
|
||||
const button = await screen.findByTestId("connect-git-button");
|
||||
expect(button).toHaveAttribute("href", "/settings/integrations");
|
||||
const button = await screen.findByTestId("add-secret-button");
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("connect-git-button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render an empty table when there are no existing secrets", async () => {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.55.0",
|
||||
"version": "0.56.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.55.0",
|
||||
"version": "0.56.0",
|
||||
"dependencies": {
|
||||
"@heroui/react": "^2.8.3",
|
||||
"@heroui/use-infinite-scroll": "^2.2.11",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.55.0",
|
||||
"version": "0.56.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGetSecrets } from "#/hooks/query/use-get-secrets";
|
||||
import { useDeleteSecret } from "#/hooks/mutation/use-delete-secret";
|
||||
@@ -12,21 +11,14 @@ import {
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { ConfirmationModal } from "#/components/shared/modals/confirmation-modal";
|
||||
import { GetSecretsResponse } from "#/api/secrets-service.types";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
|
||||
function SecretsSettingsScreen() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: config } = useConfig();
|
||||
const { data: secrets, isLoading: isLoadingSecrets } = useGetSecrets();
|
||||
const { mutate: deleteSecret } = useDeleteSecret();
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
const hasProviderSet = providers.length > 0;
|
||||
|
||||
const [view, setView] = React.useState<
|
||||
"list" | "add-secret-form" | "edit-secret-form"
|
||||
@@ -69,8 +61,6 @@ function SecretsSettingsScreen() {
|
||||
setConfirmationModalIsVisible(false);
|
||||
};
|
||||
|
||||
const shouldRenderConnectToGitButton = isSaas && !hasProviderSet;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="secrets-settings-screen"
|
||||
@@ -84,20 +74,7 @@ function SecretsSettingsScreen() {
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{shouldRenderConnectToGitButton && (
|
||||
<Link
|
||||
to="/settings/integrations"
|
||||
data-testid="connect-git-button"
|
||||
type="button"
|
||||
className="self-start"
|
||||
>
|
||||
<BrandButton type="button" variant="secondary">
|
||||
{t(I18nKey.SECRETS$CONNECT_GIT_PROVIDER)}
|
||||
</BrandButton>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{!shouldRenderConnectToGitButton && view === "list" && (
|
||||
{view === "list" && (
|
||||
<BrandButton
|
||||
testId="add-secret-button"
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual Environment
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage.*
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
# Note: We keep our custom spec file in version control
|
||||
# *.spec
|
||||
@@ -0,0 +1,46 @@
|
||||
.PHONY: help install install-dev test format clean run
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "OpenHands CLI - Available commands:"
|
||||
@echo " install - Install the package"
|
||||
@echo " install-dev - Install with development dependencies"
|
||||
@echo " test - Run tests"
|
||||
@echo " format - Format code with ruff"
|
||||
@echo " clean - Clean build artifacts"
|
||||
@echo " run - Run the CLI"
|
||||
|
||||
# Install the package
|
||||
install:
|
||||
uv sync
|
||||
|
||||
# Install with development dependencies
|
||||
install-dev:
|
||||
uv sync --group dev
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
uv run pytest
|
||||
|
||||
# Format code
|
||||
format:
|
||||
uv run ruff format openhands_cli/
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
rm -rf .venv/
|
||||
find . -type d -name "__pycache__" -exec rm -rf {} +
|
||||
find . -type f -name "*.pyc" -delete
|
||||
|
||||
# Run the CLI
|
||||
run:
|
||||
uv run openhands-cli
|
||||
|
||||
# Install UV if not present
|
||||
install-uv:
|
||||
@if ! command -v uv &> /dev/null; then \
|
||||
echo "Installing UV..."; \
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh; \
|
||||
else \
|
||||
echo "UV is already installed"; \
|
||||
fi
|
||||
@@ -0,0 +1,45 @@
|
||||
# OpenHands CLI
|
||||
|
||||
A lightweight CLI/TUI to interact with the OpenHands agent (powered by agent-sdk). Build and run locally or as a single executable.
|
||||
|
||||
## Quickstart
|
||||
|
||||
- Prerequisites: Python 3.12+, curl
|
||||
- Install uv (package manager):
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
# Restart your shell so "uv" is on PATH, or follow the installer hint
|
||||
```
|
||||
|
||||
### Run the CLI locally
|
||||
```bash
|
||||
# Install dependencies (incl. dev tools)
|
||||
make install-dev
|
||||
|
||||
# Optional: install pre-commit hooks
|
||||
make install-pre-commit-hooks
|
||||
|
||||
# Start the CLI
|
||||
make run
|
||||
# or
|
||||
uv run openhands-cli
|
||||
```
|
||||
|
||||
Tip: Set your model key (one of) so the agent can talk to an LLM:
|
||||
```bash
|
||||
export OPENAI_API_KEY=...
|
||||
# or
|
||||
export LITELLM_API_KEY=...
|
||||
```
|
||||
|
||||
### Build a standalone executable
|
||||
```bash
|
||||
# Build (installs PyInstaller if needed)
|
||||
./build.sh --install-pyinstaller
|
||||
|
||||
# The binary will be in dist/
|
||||
./dist/openhands-cli # macOS/Linux
|
||||
# dist/openhands-cli.exe # Windows
|
||||
```
|
||||
|
||||
For advanced development (adding deps, updating the spec file, debugging builds), see Development.md.
|
||||
Executable
+281
@@ -0,0 +1,281 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build script for OpenHands CLI using PyInstaller.
|
||||
|
||||
This script packages the OpenHands CLI into a standalone executable binary
|
||||
using PyInstaller with the custom spec file.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from openhands_cli.locations import PERSISTENCE_DIR, WORK_DIR, AGENT_SETTINGS_PATH
|
||||
from openhands.sdk.preset.default import get_default_agent
|
||||
from openhands.sdk import LLM
|
||||
import time
|
||||
import select
|
||||
|
||||
dummy_agent = get_default_agent(
|
||||
llm=LLM(model='dummy-model', api_key='dummy-key'),
|
||||
working_dir=WORK_DIR,
|
||||
persistence_dir=PERSISTENCE_DIR,
|
||||
cli_mode=True
|
||||
)
|
||||
|
||||
# =================================================
|
||||
# SECTION: Build Binary
|
||||
# =================================================
|
||||
|
||||
|
||||
|
||||
def clean_build_directories() -> None:
|
||||
"""Clean up previous build artifacts."""
|
||||
print('🧹 Cleaning up previous build artifacts...')
|
||||
|
||||
build_dirs = ['build', 'dist', '__pycache__']
|
||||
for dir_name in build_dirs:
|
||||
if os.path.exists(dir_name):
|
||||
print(f' Removing {dir_name}/')
|
||||
shutil.rmtree(dir_name)
|
||||
|
||||
# Clean up .pyc files
|
||||
for root, _dirs, files in os.walk('.'):
|
||||
for file in files:
|
||||
if file.endswith('.pyc'):
|
||||
os.remove(os.path.join(root, file))
|
||||
|
||||
print('✅ Cleanup complete!')
|
||||
|
||||
|
||||
def check_pyinstaller() -> bool:
|
||||
"""Check if PyInstaller is available."""
|
||||
try:
|
||||
subprocess.run(
|
||||
['uv', 'run', 'pyinstaller', '--version'], check=True, capture_output=True
|
||||
)
|
||||
return True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
print(
|
||||
'❌ PyInstaller is not available. Use --install-pyinstaller flag or install manually with:'
|
||||
)
|
||||
print(' uv add --dev pyinstaller')
|
||||
return False
|
||||
|
||||
def build_executable(
|
||||
spec_file: str = 'openhands-cli.spec',
|
||||
clean: bool = True,
|
||||
) -> bool:
|
||||
"""Build the executable using PyInstaller."""
|
||||
if clean:
|
||||
clean_build_directories()
|
||||
|
||||
# Check if PyInstaller is available (installation is handled by build.sh)
|
||||
if not check_pyinstaller():
|
||||
return False
|
||||
|
||||
print(f'🔨 Building executable using {spec_file}...')
|
||||
|
||||
try:
|
||||
# Run PyInstaller with uv
|
||||
cmd = ['uv', 'run', 'pyinstaller', spec_file, '--clean']
|
||||
|
||||
print(f'Running: {" ".join(cmd)}')
|
||||
subprocess.run(cmd, check=True, capture_output=True, text=True)
|
||||
|
||||
print('✅ Build completed successfully!')
|
||||
|
||||
# Check if the executable was created
|
||||
dist_dir = Path('dist')
|
||||
if dist_dir.exists():
|
||||
executables = list(dist_dir.glob('*'))
|
||||
if executables:
|
||||
print('📁 Executable(s) created in dist/:')
|
||||
for exe in executables:
|
||||
size = exe.stat().st_size / (1024 * 1024) # Size in MB
|
||||
print(f' - {exe.name} ({size:.1f} MB)')
|
||||
else:
|
||||
print('⚠️ No executables found in dist/ directory')
|
||||
|
||||
return True
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f'❌ Build failed: {e}')
|
||||
if e.stdout:
|
||||
print('STDOUT:', e.stdout)
|
||||
if e.stderr:
|
||||
print('STDERR:', e.stderr)
|
||||
return False
|
||||
|
||||
|
||||
# =================================================
|
||||
# SECTION: Test and profile binary
|
||||
# =================================================
|
||||
|
||||
WELCOME_MARKERS = ["welcome", "openhands cli", "type /help", "available commands", ">"]
|
||||
|
||||
def _is_welcome(line: str) -> bool:
|
||||
s = line.strip().lower()
|
||||
return any(marker in s for marker in WELCOME_MARKERS)
|
||||
|
||||
def test_executable() -> bool:
|
||||
"""Test the built executable, measuring boot time and total test time."""
|
||||
print('🧪 Testing the built executable...')
|
||||
|
||||
spec_path = os.path.join(PERSISTENCE_DIR, AGENT_SETTINGS_PATH)
|
||||
|
||||
specs_path = Path(os.path.expanduser(spec_path))
|
||||
if specs_path.exists():
|
||||
print(f"⚠️ Using existing settings at {specs_path}")
|
||||
else:
|
||||
print(f"💾 Creating dummy settings at {specs_path}")
|
||||
specs_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
specs_path.write_text(dummy_agent.model_dump_json())
|
||||
|
||||
exe_path = Path('dist/openhands-cli')
|
||||
if not exe_path.exists():
|
||||
exe_path = Path('dist/openhands-cli.exe')
|
||||
if not exe_path.exists():
|
||||
print('❌ Executable not found!')
|
||||
return False
|
||||
|
||||
try:
|
||||
if os.name != 'nt':
|
||||
os.chmod(exe_path, 0o755)
|
||||
|
||||
boot_start = time.time()
|
||||
proc = subprocess.Popen(
|
||||
[str(exe_path)],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
env={**os.environ},
|
||||
)
|
||||
|
||||
# --- Wait for welcome ---
|
||||
deadline = boot_start + 30
|
||||
saw_welcome = False
|
||||
captured = []
|
||||
|
||||
while time.time() < deadline:
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
rlist, _, _ = select.select([proc.stdout], [], [], 0.2)
|
||||
if not rlist:
|
||||
continue
|
||||
line = proc.stdout.readline()
|
||||
if not line:
|
||||
continue
|
||||
captured.append(line)
|
||||
if _is_welcome(line):
|
||||
saw_welcome = True
|
||||
break
|
||||
|
||||
if not saw_welcome:
|
||||
print("❌ Did not detect welcome prompt")
|
||||
try: proc.kill()
|
||||
except Exception: pass
|
||||
return False
|
||||
|
||||
boot_end = time.time()
|
||||
print(f"⏱️ Boot to welcome: {boot_end - boot_start:.2f} seconds")
|
||||
|
||||
# --- Run /help then /exit ---
|
||||
if proc.stdin is None:
|
||||
print("❌ stdin unavailable")
|
||||
proc.kill()
|
||||
return False
|
||||
|
||||
proc.stdin.write("/help\n/exit\n")
|
||||
proc.stdin.flush()
|
||||
out, _ = proc.communicate(timeout=30)
|
||||
|
||||
total_end = time.time()
|
||||
full_output = ''.join(captured) + (out or '')
|
||||
|
||||
print(f"⏱️ End-to-end test time: {total_end - boot_start:.2f} seconds")
|
||||
|
||||
if "available commands" in full_output.lower():
|
||||
print("✅ Executable starts, welcome detected, and /help works")
|
||||
return True
|
||||
else:
|
||||
print("❌ /help output not found")
|
||||
print("Output preview:", full_output[-500:])
|
||||
return False
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
print("❌ Executable test timed out")
|
||||
try: proc.kill()
|
||||
except Exception: pass
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ Error testing executable: {e}")
|
||||
try: proc.kill()
|
||||
except Exception: pass
|
||||
return False
|
||||
|
||||
|
||||
|
||||
# =================================================
|
||||
# SECTION: Main
|
||||
# =================================================
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main function."""
|
||||
parser = argparse.ArgumentParser(description='Build OpenHands CLI executable')
|
||||
parser.add_argument(
|
||||
'--spec', default='openhands-cli.spec', help='PyInstaller spec file to use'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no-clean', action='store_true', help='Skip cleaning build directories'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no-test', action='store_true', help='Skip testing the built executable'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--install-pyinstaller',
|
||||
action='store_true',
|
||||
help='Install PyInstaller using uv before building',
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--no-build', action='store_true', help='Skip testing the built executable'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print('🚀 OpenHands CLI Build Script')
|
||||
print('=' * 40)
|
||||
|
||||
# Check if spec file exists
|
||||
if not os.path.exists(args.spec):
|
||||
print(f"❌ Spec file '{args.spec}' not found!")
|
||||
return 1
|
||||
|
||||
# Build the executable
|
||||
if not args.no_build and not build_executable(
|
||||
args.spec, clean=not args.no_clean
|
||||
):
|
||||
return 1
|
||||
|
||||
# Test the executable
|
||||
if not args.no_test:
|
||||
if not test_executable():
|
||||
print('❌ Executable test failed, build process failed')
|
||||
return 1
|
||||
|
||||
print('\n🎉 Build process completed!')
|
||||
print("📁 Check the 'dist/' directory for your executable")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Shell script wrapper for building OpenHands CLI executable.
|
||||
#
|
||||
# This script provides a simple interface to build the OpenHands CLI
|
||||
# using PyInstaller with uv package management.
|
||||
#
|
||||
|
||||
set -e # Exit on any error
|
||||
|
||||
echo "🚀 OpenHands CLI Build Script"
|
||||
echo "=============================="
|
||||
|
||||
# Check if uv is available
|
||||
if ! command -v uv &> /dev/null; then
|
||||
echo "❌ uv is required but not found! Please install uv first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse arguments to check for --install-pyinstaller
|
||||
INSTALL_PYINSTALLER=false
|
||||
PYTHON_ARGS=()
|
||||
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--install-pyinstaller)
|
||||
INSTALL_PYINSTALLER=true
|
||||
PYTHON_ARGS+=("$arg")
|
||||
;;
|
||||
*)
|
||||
PYTHON_ARGS+=("$arg")
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Install PyInstaller if requested
|
||||
if [ "$INSTALL_PYINSTALLER" = true ]; then
|
||||
echo "📦 Installing PyInstaller with uv..."
|
||||
if uv add --dev pyinstaller; then
|
||||
echo "✅ PyInstaller installed successfully with uv!"
|
||||
else
|
||||
echo "❌ Failed to install PyInstaller"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Run the Python build script using uv
|
||||
uv run python build.py "${PYTHON_ARGS[@]}"
|
||||
@@ -0,0 +1,63 @@
|
||||
import atexit, os, sys, time
|
||||
from collections import defaultdict
|
||||
|
||||
ENABLE = os.getenv("IMPORT_PROFILING", "0") not in ("", "0", "false", "False")
|
||||
OUT = "dist/import_profiler.csv"
|
||||
THRESHOLD_MS = float(os.getenv("IMPORT_PROFILING_THRESHOLD_MS", "0"))
|
||||
|
||||
if ENABLE:
|
||||
timings = defaultdict(float) # module -> total seconds (first load only)
|
||||
counts = defaultdict(int) # module -> number of first-loads (should be 1)
|
||||
max_dur = defaultdict(float) # module -> max single load seconds
|
||||
|
||||
try:
|
||||
import importlib._bootstrap as _bootstrap # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
_bootstrap = None
|
||||
|
||||
start_time = time.perf_counter()
|
||||
|
||||
if _bootstrap is not None:
|
||||
_orig_find_and_load = _bootstrap._find_and_load
|
||||
|
||||
def _timed_find_and_load(name, import_):
|
||||
preloaded = name in sys.modules # cache hit?
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
return _orig_find_and_load(name, import_)
|
||||
finally:
|
||||
if not preloaded:
|
||||
dt = time.perf_counter() - t0
|
||||
timings[name] += dt
|
||||
counts[name] += 1
|
||||
if dt > max_dur[name]:
|
||||
max_dur[name] = dt
|
||||
|
||||
_bootstrap._find_and_load = _timed_find_and_load
|
||||
|
||||
@atexit.register
|
||||
def _dump_import_profile():
|
||||
def ms(s): return f"{s*1000:.3f}"
|
||||
items = [
|
||||
(name, counts[name], timings[name], max_dur[name])
|
||||
for name in timings
|
||||
if timings[name]*1000 >= THRESHOLD_MS
|
||||
]
|
||||
items.sort(key=lambda x: x[2], reverse=True)
|
||||
try:
|
||||
with open(OUT, "w", encoding="utf-8") as f:
|
||||
f.write("module,count,total_ms,max_ms\n")
|
||||
for name, cnt, tot_s, max_s in items:
|
||||
f.write(f"{name},{cnt},{ms(tot_s)},{ms(max_s)}\n")
|
||||
# brief summary
|
||||
if items:
|
||||
w = max(len(n) for n, *_ in items[:25])
|
||||
sys.stderr.write("\n=== Import Time Profile (first-load only) ===\n")
|
||||
sys.stderr.write(f"{'module'.ljust(w)} count total_ms max_ms\n")
|
||||
for name, cnt, tot_s, max_s in items[:25]:
|
||||
sys.stderr.write(
|
||||
f"{name.ljust(w)} {str(cnt).rjust(5)} {ms(tot_s).rjust(8)} {ms(max_s).rjust(7)}\n"
|
||||
)
|
||||
sys.stderr.write(f"\nImport profile written to: {OUT}\n")
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"[import-profiler] failed to write profile: {e}\n")
|
||||
@@ -0,0 +1,110 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
"""
|
||||
PyInstaller spec file for OpenHands CLI.
|
||||
|
||||
This spec file configures PyInstaller to create a standalone executable
|
||||
for the OpenHands CLI application.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
import sys
|
||||
from PyInstaller.utils.hooks import (
|
||||
collect_submodules,
|
||||
collect_data_files,
|
||||
copy_metadata
|
||||
)
|
||||
|
||||
|
||||
|
||||
# Get the project root directory (current working directory when running PyInstaller)
|
||||
project_root = Path.cwd()
|
||||
|
||||
a = Analysis(
|
||||
['openhands_cli/simple_main.py'],
|
||||
pathex=[str(project_root)],
|
||||
binaries=[],
|
||||
datas=[
|
||||
# Include any data files that might be needed
|
||||
# Add more data files here if needed in the future
|
||||
*collect_data_files('tiktoken'),
|
||||
*collect_data_files('tiktoken_ext'),
|
||||
*collect_data_files('litellm'),
|
||||
*collect_data_files('fastmcp'),
|
||||
*collect_data_files('mcp'),
|
||||
# Include Jinja prompt templates required by the agent SDK
|
||||
*collect_data_files('openhands.sdk.agent', includes=['prompts/*.j2']),
|
||||
# Include package metadata for importlib.metadata
|
||||
*copy_metadata('fastmcp'),
|
||||
],
|
||||
hiddenimports=[
|
||||
# Explicitly include modules that might not be detected automatically
|
||||
*collect_submodules('openhands_cli'),
|
||||
*collect_submodules('prompt_toolkit'),
|
||||
# Include OpenHands SDK submodules explicitly to avoid resolution issues
|
||||
*collect_submodules('openhands.sdk'),
|
||||
*collect_submodules('openhands.tools'),
|
||||
*collect_submodules('tiktoken'),
|
||||
*collect_submodules('tiktoken_ext'),
|
||||
*collect_submodules('litellm'),
|
||||
*collect_submodules('fastmcp'),
|
||||
# Include mcp but exclude CLI parts that require typer
|
||||
'mcp.types',
|
||||
'mcp.client',
|
||||
'mcp.server',
|
||||
'mcp.shared',
|
||||
'openhands.tools.execute_bash',
|
||||
'openhands.tools.str_replace_editor',
|
||||
'openhands.tools.task_tracker',
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
# runtime_hooks=[str(project_root / "hooks" / "rthook_profile_imports.py")],
|
||||
excludes=[
|
||||
# Exclude unnecessary modules to reduce binary size
|
||||
'tkinter',
|
||||
'matplotlib',
|
||||
'numpy',
|
||||
'scipy',
|
||||
'pandas',
|
||||
'IPython',
|
||||
'jupyter',
|
||||
'notebook',
|
||||
# Exclude mcp CLI parts that cause issues
|
||||
'mcp.cli',
|
||||
'prompt_toolkit.contrib.ssh',
|
||||
'fastmcp.cli',
|
||||
'boto3',
|
||||
'botocore',
|
||||
'posthog',
|
||||
'browser-use',
|
||||
'openhands.tools.browser_use'
|
||||
],
|
||||
noarchive=False,
|
||||
# IMPORTANT: do not use optimize=2 (-OO) because it strips docstrings used by PLY/bashlex grammar
|
||||
optimize=0,
|
||||
)
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name='openhands-cli',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=True, # Strip debug symbols to reduce size
|
||||
upx=True, # Use UPX compression if available
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True, # CLI application needs console
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon=None, # Add icon path here if you have one
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""OpenHands CLI package."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Agent chat functionality for OpenHands CLI.
|
||||
Provides a conversation interface with an AI agent using OpenHands patterns.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openhands.sdk import Message, TextContent
|
||||
from openhands.sdk.conversation.state import AgentExecutionStatus
|
||||
from prompt_toolkit import PromptSession, print_formatted_text
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
from openhands_cli.runner import ConversationRunner
|
||||
from openhands_cli.setup import setup_agent
|
||||
from openhands_cli.tui.settings.settings_screen import SettingsScreen
|
||||
from openhands_cli.tui.tui import CommandCompleter, display_help, display_welcome
|
||||
from openhands_cli.user_actions import UserConfirmation, exit_session_confirmation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _fast_exit():
|
||||
"""Perform fast exit to avoid waiting for thread cleanup."""
|
||||
import os
|
||||
import threading
|
||||
|
||||
# Give threads a brief moment to clean up
|
||||
active_threads = [t for t in threading.enumerate() if t != threading.current_thread()]
|
||||
if active_threads:
|
||||
# Wait briefly for daemon threads to finish
|
||||
import time
|
||||
time.sleep(0.01)
|
||||
|
||||
# Force exit to avoid waiting for any remaining cleanup
|
||||
os._exit(0)
|
||||
|
||||
|
||||
def run_cli_entry() -> None:
|
||||
"""Run the agent chat session using the agent SDK.
|
||||
|
||||
Raises:
|
||||
AgentSetupError: If agent setup fails
|
||||
KeyboardInterrupt: If user interrupts the session
|
||||
EOFError: If EOF is encountered
|
||||
"""
|
||||
# Import heavy dependencies only when needed
|
||||
from openhands_cli.setup import setup_agent
|
||||
from openhands_cli.tui.settings.settings_screen import SettingsScreen
|
||||
from openhands_cli.tui.tui import display_welcome, CommandCompleter
|
||||
from openhands_cli.runner import ConversationRunner
|
||||
from prompt_toolkit import PromptSession
|
||||
|
||||
conversation = setup_agent()
|
||||
settings_screen = SettingsScreen()
|
||||
|
||||
while not conversation:
|
||||
settings_screen.handle_basic_settings(escapable=False)
|
||||
conversation = setup_agent()
|
||||
|
||||
# Generate session ID
|
||||
session_id = str(uuid.uuid4())[:8]
|
||||
|
||||
display_welcome(session_id)
|
||||
|
||||
# Create prompt session with command completer
|
||||
session = PromptSession(completer=CommandCompleter())
|
||||
|
||||
# Create conversation runner to handle state machine logic
|
||||
runner = ConversationRunner(conversation)
|
||||
|
||||
# Main chat loop
|
||||
while True:
|
||||
try:
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
|
||||
# Get user input
|
||||
user_input = session.prompt(
|
||||
HTML("<gold>> </gold>"),
|
||||
multiline=False,
|
||||
)
|
||||
|
||||
if not user_input.strip():
|
||||
continue
|
||||
|
||||
# Handle commands
|
||||
command = user_input.strip().lower()
|
||||
|
||||
# Import SDK components only when needed
|
||||
from openhands.sdk import Message, TextContent
|
||||
|
||||
message = Message(
|
||||
role="user",
|
||||
content=[TextContent(text=user_input)],
|
||||
)
|
||||
|
||||
if command == "/exit":
|
||||
from openhands_cli.user_actions import UserConfirmation, exit_session_confirmation
|
||||
from prompt_toolkit import print_formatted_text
|
||||
|
||||
exit_confirmation = exit_session_confirmation()
|
||||
if exit_confirmation == UserConfirmation.ACCEPT:
|
||||
print_formatted_text(HTML("\n<yellow>Goodbye! 👋</yellow>"))
|
||||
_fast_exit()
|
||||
break
|
||||
|
||||
elif command == "/settings":
|
||||
settings_screen = SettingsScreen(conversation)
|
||||
settings_screen.display_settings()
|
||||
continue
|
||||
|
||||
elif command == "/clear":
|
||||
display_welcome(session_id)
|
||||
continue
|
||||
elif command == "/help":
|
||||
from openhands_cli.tui.tui import display_help
|
||||
display_help()
|
||||
continue
|
||||
elif command == "/status":
|
||||
from prompt_toolkit import print_formatted_text
|
||||
|
||||
print_formatted_text(HTML(f"<grey>Session ID: {session_id}</grey>"))
|
||||
print_formatted_text(HTML("<grey>Status: Active</grey>"))
|
||||
confirmation_status = (
|
||||
"enabled" if conversation.state.confirmation_mode else "disabled"
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML(f"<grey>Confirmation mode: {confirmation_status}</grey>")
|
||||
)
|
||||
continue
|
||||
elif command == "/confirm":
|
||||
from prompt_toolkit import print_formatted_text
|
||||
|
||||
current_mode = runner.confirmation_mode
|
||||
runner.set_confirmation_mode(not current_mode)
|
||||
new_status = "enabled" if not current_mode else "disabled"
|
||||
print_formatted_text(
|
||||
HTML(f"<yellow>Confirmation mode {new_status}</yellow>")
|
||||
)
|
||||
continue
|
||||
elif command == "/new":
|
||||
from prompt_toolkit import print_formatted_text
|
||||
|
||||
print_formatted_text(
|
||||
HTML("<yellow>Starting new conversation...</yellow>")
|
||||
)
|
||||
session_id = str(uuid.uuid4())[:8]
|
||||
display_welcome(session_id)
|
||||
continue
|
||||
elif command == "/resume":
|
||||
from openhands.sdk.conversation.state import AgentExecutionStatus
|
||||
from prompt_toolkit import print_formatted_text
|
||||
|
||||
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
|
||||
|
||||
runner.process_message(message)
|
||||
|
||||
print() # Add spacing
|
||||
|
||||
except KeyboardInterrupt:
|
||||
from openhands_cli.user_actions import UserConfirmation, exit_session_confirmation
|
||||
from prompt_toolkit import print_formatted_text
|
||||
|
||||
exit_confirmation = exit_session_confirmation()
|
||||
if exit_confirmation == UserConfirmation.ACCEPT:
|
||||
print_formatted_text(HTML("\n<yellow>Goodbye! 👋</yellow>"))
|
||||
_fast_exit()
|
||||
break
|
||||
@@ -0,0 +1,5 @@
|
||||
from openhands_cli.listeners.pause_listener import PauseListener
|
||||
|
||||
__all__ = [
|
||||
"PauseListener",
|
||||
]
|
||||
@@ -0,0 +1,104 @@
|
||||
import threading
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openhands.sdk import Conversation
|
||||
from prompt_toolkit import HTML, print_formatted_text
|
||||
from prompt_toolkit.input import Input
|
||||
from prompt_toolkit.keys import Keys
|
||||
|
||||
|
||||
class PauseListener(threading.Thread):
|
||||
"""Background key listener that triggers pause on Ctrl-P.
|
||||
|
||||
Starts and stops around agent run() loops to avoid interfering with user prompts.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
on_pause: Callable,
|
||||
input_source=None, # used to pipe inputs for unit tests
|
||||
):
|
||||
super().__init__(daemon=True)
|
||||
self.on_pause = on_pause
|
||||
self._stop_event = threading.Event()
|
||||
self._pause_event = threading.Event()
|
||||
|
||||
# Lazy import to avoid startup cost
|
||||
if input_source is None:
|
||||
from prompt_toolkit.input import create_input
|
||||
self._input = create_input()
|
||||
else:
|
||||
self._input = input_source
|
||||
|
||||
def _detect_pause_key_presses(self) -> bool:
|
||||
from prompt_toolkit.keys import Keys
|
||||
|
||||
pause_detected = False
|
||||
|
||||
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
|
||||
|
||||
return pause_detected
|
||||
|
||||
def _execute_pause(self) -> None:
|
||||
from prompt_toolkit import HTML, print_formatted_text
|
||||
|
||||
self._pause_event.set() # Mark pause event occurred
|
||||
print_formatted_text(HTML(""))
|
||||
print_formatted_text(
|
||||
HTML("<gold>Pausing agent once step is completed...</gold>")
|
||||
)
|
||||
try:
|
||||
self.on_pause()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
with self._input.raw_mode():
|
||||
# User hasn't paused and pause listener hasn't been shut down
|
||||
while not (self.is_paused() or self.is_stopped()):
|
||||
if self._detect_pause_key_presses():
|
||||
self._execute_pause()
|
||||
finally:
|
||||
try:
|
||||
self._input.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the listener and ensure quick shutdown."""
|
||||
self._stop_event.set()
|
||||
|
||||
# Force close input to break out of read_keys() loop quickly
|
||||
try:
|
||||
if hasattr(self._input, 'close'):
|
||||
self._input.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def is_stopped(self) -> bool:
|
||||
return self._stop_event.is_set()
|
||||
|
||||
def is_paused(self) -> bool:
|
||||
return self._pause_event.is_set()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def pause_listener(
|
||||
conversation, input_source=None
|
||||
) -> Iterator[PauseListener]:
|
||||
"""Ensure PauseListener always starts/stops cleanly."""
|
||||
listener = PauseListener(on_pause=conversation.pause, input_source=input_source)
|
||||
listener.start()
|
||||
try:
|
||||
yield listener
|
||||
finally:
|
||||
listener.stop()
|
||||
# Give the thread a moment to shut down cleanly
|
||||
listener.join(timeout=0.1)
|
||||
@@ -0,0 +1,9 @@
|
||||
import os
|
||||
|
||||
# Configuration directory for storing agent settings and CLI configuration
|
||||
PERSISTENCE_DIR = os.path.expanduser("~/.openhands")
|
||||
|
||||
# Working directory for agent operations (current directory where CLI is run)
|
||||
WORK_DIR = os.getcwd()
|
||||
|
||||
AGENT_SETTINGS_PATH = "agent_settings.json"
|
||||
@@ -0,0 +1,29 @@
|
||||
from prompt_toolkit.styles import Style, merge_styles
|
||||
from prompt_toolkit.styles.base import BaseStyle
|
||||
from prompt_toolkit.styles.defaults import default_ui_style
|
||||
|
||||
# Centralized helper for CLI styles so we can safely merge our custom colors
|
||||
# with prompt_toolkit's default UI style. This preserves completion menu and
|
||||
# fuzzy-match visibility across different terminal themes (e.g., Ubuntu).
|
||||
|
||||
COLOR_GOLD = "#FFD700"
|
||||
COLOR_GREY = "#808080"
|
||||
COLOR_AGENT_BLUE = "#4682B4" # Steel blue - readable on light/dark backgrounds
|
||||
|
||||
|
||||
def get_cli_style() -> BaseStyle:
|
||||
base = default_ui_style()
|
||||
custom = Style.from_dict(
|
||||
{
|
||||
"gold": COLOR_GOLD,
|
||||
"grey": COLOR_GREY,
|
||||
"prompt": f"{COLOR_GOLD} bold",
|
||||
# Ensure good contrast for fuzzy matches on the selected completion row
|
||||
# across terminals/themes (e.g., Ubuntu GNOME, Alacritty, Kitty).
|
||||
# See https://github.com/All-Hands-AI/OpenHands/issues/10330
|
||||
"completion-menu.completion.current fuzzymatch.outside": "fg:#ffffff bg:#888888",
|
||||
"selected": COLOR_GOLD,
|
||||
"risk-high": "#FF0000 bold", # Red bold for HIGH risk
|
||||
}
|
||||
)
|
||||
return merge_styles([base, custom])
|
||||
@@ -0,0 +1,149 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openhands.sdk import Conversation, Message
|
||||
from openhands.sdk.security.confirmation_policy import AlwaysConfirm, NeverConfirm
|
||||
from openhands.sdk.conversation.state import AgentExecutionStatus
|
||||
from openhands.sdk.event.utils import get_unmatched_actions
|
||||
from prompt_toolkit import HTML, print_formatted_text
|
||||
from openhands_cli.listeners.pause_listener import PauseListener, pause_listener
|
||||
from openhands_cli.user_actions import ask_user_confirmation
|
||||
from openhands_cli.user_actions.types import UserConfirmation
|
||||
|
||||
|
||||
class ConversationRunner:
|
||||
"""Handles the conversation state machine logic cleanly."""
|
||||
|
||||
def __init__(self, conversation):
|
||||
self.conversation = conversation
|
||||
self.confirmation_mode = False
|
||||
|
||||
def set_confirmation_mode(self, confirmation_mode: bool) -> None:
|
||||
from openhands.sdk.security.confirmation_policy import AlwaysConfirm, NeverConfirm
|
||||
|
||||
self.confirmation_mode = confirmation_mode
|
||||
|
||||
if confirmation_mode:
|
||||
self.conversation.set_confirmation_policy(AlwaysConfirm())
|
||||
else:
|
||||
self.conversation.set_confirmation_policy(NeverConfirm())
|
||||
|
||||
def _start_listener(self) -> None:
|
||||
from openhands_cli.listeners.pause_listener import PauseListener
|
||||
|
||||
self.listener = PauseListener(on_pause=self.conversation.pause)
|
||||
self.listener.start()
|
||||
|
||||
def _print_run_status(self) -> None:
|
||||
from openhands.sdk.conversation.state import AgentExecutionStatus
|
||||
from prompt_toolkit import HTML, print_formatted_text
|
||||
|
||||
print_formatted_text("")
|
||||
if self.conversation.state.agent_status == AgentExecutionStatus.PAUSED:
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
"<yellow>Resuming paused conversation...</yellow><grey> (Press Ctrl-P to pause)</grey>"
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
"<yellow>Agent running...</yellow><grey> (Press Ctrl-P to pause)</grey>"
|
||||
)
|
||||
)
|
||||
print_formatted_text("")
|
||||
|
||||
def process_message(self, message) -> None:
|
||||
"""Process a user message through the conversation.
|
||||
|
||||
Args:
|
||||
message: The user message to process
|
||||
"""
|
||||
|
||||
self._print_run_status()
|
||||
|
||||
# Send message to conversation
|
||||
if message:
|
||||
self.conversation.send_message(message)
|
||||
|
||||
if self.confirmation_mode:
|
||||
self._run_with_confirmation()
|
||||
else:
|
||||
self._run_without_confirmation()
|
||||
|
||||
def _run_without_confirmation(self) -> None:
|
||||
from openhands_cli.listeners.pause_listener import pause_listener
|
||||
|
||||
with pause_listener(self.conversation):
|
||||
self.conversation.run()
|
||||
|
||||
def _run_with_confirmation(self) -> None:
|
||||
from openhands.sdk.conversation.state import AgentExecutionStatus
|
||||
from openhands_cli.listeners.pause_listener import pause_listener
|
||||
from openhands_cli.user_actions.types import UserConfirmation
|
||||
|
||||
# If agent was paused, resume with confirmation request
|
||||
if (
|
||||
self.conversation.state.agent_status
|
||||
== AgentExecutionStatus.WAITING_FOR_CONFIRMATION
|
||||
):
|
||||
user_confirmation = self._handle_confirmation_request()
|
||||
if user_confirmation == UserConfirmation.DEFER:
|
||||
return
|
||||
|
||||
while True:
|
||||
with pause_listener(self.conversation) as listener:
|
||||
self.conversation.run()
|
||||
|
||||
if listener.is_paused():
|
||||
break
|
||||
|
||||
# In confirmation mode, agent either finishes or waits for user confirmation
|
||||
if self.conversation.state.agent_status == AgentExecutionStatus.FINISHED:
|
||||
break
|
||||
|
||||
elif (
|
||||
self.conversation.state.agent_status
|
||||
== AgentExecutionStatus.WAITING_FOR_CONFIRMATION
|
||||
):
|
||||
user_confirmation = self._handle_confirmation_request()
|
||||
if user_confirmation == UserConfirmation.DEFER:
|
||||
return
|
||||
|
||||
else:
|
||||
raise Exception("Infinite loop")
|
||||
|
||||
def _handle_confirmation_request(self):
|
||||
"""Handle confirmation request from user.
|
||||
|
||||
Returns:
|
||||
UserConfirmation indicating the user's choice
|
||||
"""
|
||||
from openhands.sdk.event.utils import get_unmatched_actions
|
||||
from openhands_cli.user_actions import ask_user_confirmation
|
||||
from openhands_cli.user_actions.types import UserConfirmation
|
||||
from prompt_toolkit import HTML, print_formatted_text
|
||||
|
||||
pending_actions = get_unmatched_actions(self.conversation.state.events)
|
||||
|
||||
if pending_actions:
|
||||
user_confirmation, reason = ask_user_confirmation(pending_actions)
|
||||
if user_confirmation == UserConfirmation.REJECT:
|
||||
self.conversation.reject_pending_actions(
|
||||
reason or "User rejected the actions"
|
||||
)
|
||||
elif user_confirmation == UserConfirmation.DEFER:
|
||||
self.conversation.pause()
|
||||
elif user_confirmation == UserConfirmation.ALWAYS_ACCEPT:
|
||||
# Disable confirmation mode when user selects "Always proceed"
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
"<yellow>Confirmation mode disabled. Agent will proceed without asking.</yellow>"
|
||||
)
|
||||
)
|
||||
self.set_confirmation_mode(False)
|
||||
|
||||
return user_confirmation
|
||||
|
||||
return UserConfirmation.ACCEPT
|
||||
@@ -0,0 +1,32 @@
|
||||
from openhands.sdk import (
|
||||
Agent,
|
||||
Conversation
|
||||
)
|
||||
from openhands_cli.tui.settings.store import AgentStore
|
||||
from prompt_toolkit import HTML, print_formatted_text
|
||||
from openhands.tools.execute_bash import BashTool
|
||||
from openhands.tools.str_replace_editor import FileEditorTool
|
||||
from openhands.tools.task_tracker import TaskTrackerTool
|
||||
from openhands.sdk import register_tool
|
||||
|
||||
register_tool("BashTool", BashTool)
|
||||
register_tool("FileEditorTool", FileEditorTool)
|
||||
register_tool("TaskTrackerTool", TaskTrackerTool)
|
||||
|
||||
def setup_agent() -> Conversation | None:
|
||||
"""
|
||||
Setup the agent with environment variables.
|
||||
"""
|
||||
|
||||
agent_store = AgentStore()
|
||||
agent = agent_store.load()
|
||||
if not agent:
|
||||
return None
|
||||
|
||||
# Create agent
|
||||
conversation = Conversation(agent=agent)
|
||||
|
||||
print_formatted_text(
|
||||
HTML(f"<green>✓ Agent initialized with model: {agent.llm.model}</green>")
|
||||
)
|
||||
return conversation
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple main entry point for OpenHands CLI.
|
||||
This is a simplified version that demonstrates the TUI functionality.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from prompt_toolkit import print_formatted_text
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point for the OpenHands CLI.
|
||||
|
||||
Raises:
|
||||
ImportError: If agent chat dependencies are missing
|
||||
Exception: On other error conditions
|
||||
"""
|
||||
# Handle --help early to avoid heavy imports
|
||||
if len(sys.argv) > 1 and sys.argv[1] in ('--help', '-h', 'help'):
|
||||
from prompt_toolkit import print_formatted_text
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
|
||||
print_formatted_text(HTML("<b>OpenHands CLI</b>"))
|
||||
print_formatted_text(HTML(""))
|
||||
print_formatted_text(HTML("A command-line interface for OpenHands AI agent."))
|
||||
print_formatted_text(HTML(""))
|
||||
print_formatted_text(HTML("<b>Usage:</b>"))
|
||||
print_formatted_text(HTML(" openhands-cli Start interactive chat"))
|
||||
print_formatted_text(HTML(" openhands-cli --help Show this help"))
|
||||
print_formatted_text(HTML(""))
|
||||
print_formatted_text(HTML("<b>Interactive Commands:</b>"))
|
||||
print_formatted_text(HTML(" /help Show available commands"))
|
||||
print_formatted_text(HTML(" /settings Open settings"))
|
||||
print_formatted_text(HTML(" /exit Exit the application"))
|
||||
print_formatted_text(HTML(" /clear Clear the screen"))
|
||||
print_formatted_text(HTML(" /status Show session status"))
|
||||
print_formatted_text(HTML(" /confirm Toggle confirmation mode"))
|
||||
print_formatted_text(HTML(" /new Start new conversation"))
|
||||
print_formatted_text(HTML(" /resume Resume paused conversation"))
|
||||
print_formatted_text(HTML(""))
|
||||
print_formatted_text(HTML("<b>Keyboard Shortcuts:</b>"))
|
||||
print_formatted_text(HTML(" Ctrl+C Exit (with confirmation)"))
|
||||
print_formatted_text(HTML(" Ctrl+P Pause agent execution"))
|
||||
return
|
||||
|
||||
try:
|
||||
# Import agent chat only when actually needed
|
||||
from openhands_cli.agent_chat import run_cli_entry
|
||||
|
||||
# Start agent chat directly by default
|
||||
run_cli_entry()
|
||||
|
||||
except ImportError as e:
|
||||
from prompt_toolkit import print_formatted_text
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
|
||||
print_formatted_text(
|
||||
HTML(f"<red>Error: Agent chat requires additional dependencies: {e}</red>")
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML("<yellow>Please ensure the agent SDK is properly installed.</yellow>")
|
||||
)
|
||||
raise
|
||||
except KeyboardInterrupt:
|
||||
from prompt_toolkit import print_formatted_text
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
|
||||
print_formatted_text(HTML("\n<yellow>Goodbye! 👋</yellow>"))
|
||||
_fast_exit()
|
||||
except EOFError:
|
||||
from prompt_toolkit import print_formatted_text
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
|
||||
print_formatted_text(HTML("\n<yellow>Goodbye! 👋</yellow>"))
|
||||
_fast_exit()
|
||||
except Exception as e:
|
||||
from prompt_toolkit import print_formatted_text
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
|
||||
print_formatted_text(HTML(f"<red>Error starting agent chat: {e}</red>"))
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise
|
||||
|
||||
|
||||
def _fast_exit():
|
||||
"""Perform fast exit to avoid waiting for thread cleanup."""
|
||||
import os
|
||||
import threading
|
||||
|
||||
# Give threads a brief moment to clean up
|
||||
active_threads = [t for t in threading.enumerate() if t != threading.current_thread()]
|
||||
if active_threads:
|
||||
# Wait briefly for daemon threads to finish
|
||||
import time
|
||||
time.sleep(0.01)
|
||||
|
||||
# Force exit to avoid waiting for any remaining cleanup
|
||||
os._exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
from openhands_cli.tui.tui import DEFAULT_STYLE
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_STYLE",
|
||||
]
|
||||
@@ -0,0 +1,205 @@
|
||||
import os
|
||||
from openhands_cli.locations import AGENT_SETTINGS_PATH, PERSISTENCE_DIR, WORK_DIR
|
||||
from openhands_cli.tui.settings.store import AgentStore
|
||||
from openhands_cli.user_actions.settings_action import (
|
||||
SettingsType,
|
||||
choose_llm_model,
|
||||
choose_llm_provider,
|
||||
prompt_api_key,
|
||||
save_settings_confirmation,
|
||||
settings_type_confirmation,
|
||||
prompt_custom_model,
|
||||
prompt_base_url,
|
||||
choose_memory_condensation,
|
||||
)
|
||||
from openhands_cli.tui.utils import StepCounter
|
||||
from prompt_toolkit import HTML, print_formatted_text
|
||||
from openhands.sdk import Conversation, LLM, LocalFileStore
|
||||
from openhands.sdk.preset.default import get_default_agent
|
||||
from prompt_toolkit.shortcuts import print_container
|
||||
from prompt_toolkit.widgets import Frame, TextArea
|
||||
|
||||
from openhands_cli.pt_style import COLOR_GREY
|
||||
|
||||
class SettingsScreen:
|
||||
def __init__(self, conversation: Conversation | None = None):
|
||||
self.file_store = LocalFileStore(PERSISTENCE_DIR)
|
||||
self.agent_store = AgentStore()
|
||||
self.conversation = conversation
|
||||
|
||||
def display_settings(self) -> None:
|
||||
agent_spec = self.agent_store.load()
|
||||
if not agent_spec:
|
||||
return
|
||||
|
||||
llm = agent_spec.llm
|
||||
advanced_llm_settings = True if llm.base_url else False
|
||||
|
||||
# Prepare labels and values based on settings
|
||||
labels_and_values = []
|
||||
if not advanced_llm_settings:
|
||||
# Attempt to determine provider, fallback if not directly available
|
||||
provider = llm.model.split('/')[0] if '/' in llm.model else 'Unknown'
|
||||
|
||||
labels_and_values.extend(
|
||||
[
|
||||
(" LLM Provider", str(provider)),
|
||||
(" LLM Model", str(llm.model)),
|
||||
]
|
||||
)
|
||||
else:
|
||||
labels_and_values.extend(
|
||||
[
|
||||
(" Custom Model", llm.model),
|
||||
(" Base URL", llm.base_url),
|
||||
|
||||
]
|
||||
)
|
||||
labels_and_values.extend([
|
||||
(" API Key", "********" if llm.api_key else "Not Set"),
|
||||
(" Confirmation Mode", "Enabled" if self.conversation.state.confirmation_policy else "Disabled"),
|
||||
(" Memory Condensation", "Enabled" if agent_spec.condenser else "Disabled"),
|
||||
(" Configuration File", os.path.join(PERSISTENCE_DIR, AGENT_SETTINGS_PATH))
|
||||
])
|
||||
|
||||
# Calculate max widths for alignment
|
||||
# Ensure values are strings for len() calculation
|
||||
str_labels_and_values = [
|
||||
(label, str(value)) for label, value in labels_and_values
|
||||
]
|
||||
max_label_width = (
|
||||
max(len(label) for label, _ in str_labels_and_values)
|
||||
if str_labels_and_values
|
||||
else 0
|
||||
)
|
||||
|
||||
# Construct the summary text with aligned columns
|
||||
settings_lines = [
|
||||
f"{label + ':':<{max_label_width + 1}} {value:<}" # Changed value alignment to left (<)
|
||||
for label, value in str_labels_and_values
|
||||
]
|
||||
settings_text = "\n".join(settings_lines)
|
||||
|
||||
container = Frame(
|
||||
TextArea(
|
||||
text=settings_text,
|
||||
read_only=True,
|
||||
style=COLOR_GREY,
|
||||
wrap_lines=True,
|
||||
),
|
||||
title="Settings",
|
||||
style=f"fg:{COLOR_GREY}",
|
||||
)
|
||||
|
||||
print_container(container)
|
||||
|
||||
self.configure_settings()
|
||||
|
||||
def configure_settings(self):
|
||||
try:
|
||||
settings_type = settings_type_confirmation()
|
||||
except KeyboardInterrupt:
|
||||
return
|
||||
|
||||
if settings_type == SettingsType.BASIC:
|
||||
self.handle_basic_settings()
|
||||
elif settings_type == SettingsType.ADVANCED:
|
||||
self.handle_advanced_settings()
|
||||
|
||||
def handle_basic_settings(self, escapable=True):
|
||||
step_counter = StepCounter(3)
|
||||
try:
|
||||
provider = choose_llm_provider(step_counter, escapable=escapable)
|
||||
llm_model = choose_llm_model(step_counter, provider, escapable=escapable)
|
||||
api_key = prompt_api_key(
|
||||
step_counter,
|
||||
provider,
|
||||
self.conversation.agent.llm.api_key if self.conversation else None,
|
||||
escapable=escapable
|
||||
)
|
||||
save_settings_confirmation()
|
||||
except KeyboardInterrupt:
|
||||
print_formatted_text(HTML('\n<red>Cancelled settings change.</red>'))
|
||||
return
|
||||
|
||||
# Store the collected settings for persistence
|
||||
self._save_llm_settings(f"{provider}/{llm_model}", api_key)
|
||||
|
||||
def handle_advanced_settings(self, escapable=True):
|
||||
"""Handle advanced settings configuration with clean step-by-step flow."""
|
||||
step_counter = StepCounter(4)
|
||||
try:
|
||||
custom_model = prompt_custom_model(step_counter)
|
||||
base_url = prompt_base_url(step_counter)
|
||||
api_key = prompt_api_key(
|
||||
step_counter,
|
||||
custom_model.split('/')[0] if len(custom_model.split('/')) > 1 else '',
|
||||
self.conversation.agent.llm.api_key if self.conversation else None,
|
||||
escapable=escapable
|
||||
)
|
||||
memory_condensation = choose_memory_condensation(step_counter)
|
||||
|
||||
# Confirm save
|
||||
save_settings_confirmation()
|
||||
except KeyboardInterrupt:
|
||||
print_formatted_text(HTML('\n<red>Cancelled settings change.</red>'))
|
||||
return
|
||||
|
||||
# Store the collected settings for persistence
|
||||
self._save_advanced_settings(
|
||||
custom_model,
|
||||
base_url,
|
||||
api_key,
|
||||
memory_condensation
|
||||
)
|
||||
|
||||
def _save_llm_settings(
|
||||
self,
|
||||
model,
|
||||
api_key,
|
||||
base_url: str | None = None
|
||||
) -> None:
|
||||
llm = LLM(
|
||||
model=model,
|
||||
api_key=api_key,
|
||||
base_url=base_url
|
||||
)
|
||||
|
||||
agent = self.agent_store.load()
|
||||
if not agent:
|
||||
agent = get_default_agent(
|
||||
llm=llm,
|
||||
working_dir=WORK_DIR,
|
||||
cli_mode=True
|
||||
)
|
||||
|
||||
agent = agent.model_copy(update={"llm": llm})
|
||||
self.agent_store.save(agent)
|
||||
|
||||
|
||||
def _save_advanced_settings(
|
||||
self,
|
||||
custom_model: str,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
memory_condensation: bool
|
||||
):
|
||||
self._save_llm_settings(
|
||||
custom_model,
|
||||
api_key,
|
||||
base_url=base_url
|
||||
)
|
||||
|
||||
agent_spec = self.agent_store.load()
|
||||
if not agent_spec:
|
||||
return
|
||||
|
||||
|
||||
if not memory_condensation:
|
||||
agent_spec.model_copy(update={"condenser": None})
|
||||
|
||||
self.agent_store.save(agent_spec)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# openhands_cli/settings/store.py
|
||||
from __future__ import annotations
|
||||
import os
|
||||
from openhands.sdk import LocalFileStore, Agent
|
||||
from openhands.sdk.preset.default import get_default_tools
|
||||
from openhands_cli.locations import AGENT_SETTINGS_PATH, PERSISTENCE_DIR, WORK_DIR
|
||||
from prompt_toolkit import HTML, print_formatted_text
|
||||
|
||||
|
||||
class AgentStore:
|
||||
"""Single source of truth for persisting/retrieving AgentSpec."""
|
||||
def __init__(self) -> None:
|
||||
self.file_store = LocalFileStore(root=PERSISTENCE_DIR)
|
||||
|
||||
def load(self) -> Agent | None:
|
||||
try:
|
||||
str_spec = self.file_store.read(AGENT_SETTINGS_PATH)
|
||||
agent = Agent.model_validate_json(str_spec)
|
||||
|
||||
# Update tools with most recent working directory
|
||||
updated_tools = get_default_tools(
|
||||
working_dir=WORK_DIR,
|
||||
persistence_dir=PERSISTENCE_DIR,
|
||||
enable_browser=False
|
||||
)
|
||||
agent = agent.model_copy(update={"tools": updated_tools})
|
||||
|
||||
return agent
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except Exception:
|
||||
print_formatted_text(HTML("\n<red>Agent configuration file is corrupted!</red>"))
|
||||
return None
|
||||
|
||||
def save(self, agent: Agent) -> None:
|
||||
serialized_spec = agent.model_dump_json(context={"expose_secrets": True})
|
||||
self.file_store.write(AGENT_SETTINGS_PATH, serialized_spec)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
from prompt_toolkit import print_formatted_text
|
||||
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
|
||||
from prompt_toolkit.document import Document
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
from prompt_toolkit.shortcuts import clear
|
||||
|
||||
from openhands_cli import __version__
|
||||
from openhands_cli.pt_style import get_cli_style
|
||||
|
||||
DEFAULT_STYLE = get_cli_style()
|
||||
|
||||
# Available commands with descriptions
|
||||
COMMANDS = {
|
||||
"/exit": "Exit the application",
|
||||
"/help": "Display available commands",
|
||||
"/clear": "Clear the screen",
|
||||
"/status": "Display conversation details",
|
||||
"/confirm": "Toggle confirmation mode on/off",
|
||||
"/new": "Create a new conversation",
|
||||
"/resume": "Resume a paused conversation",
|
||||
"/settings": "Display and modify current settings",
|
||||
}
|
||||
|
||||
|
||||
class CommandCompleter(Completer):
|
||||
"""Custom completer for commands with interactive dropdown."""
|
||||
|
||||
def get_completions(
|
||||
self, document: Document, complete_event: CompleteEvent
|
||||
) -> Generator[Completion, None, None]:
|
||||
text = document.text_before_cursor.lstrip()
|
||||
if text.startswith("/"):
|
||||
for command, description in COMMANDS.items():
|
||||
if command.startswith(text):
|
||||
yield Completion(
|
||||
command,
|
||||
start_position=-len(text),
|
||||
display_meta=description,
|
||||
style="bg:ansidarkgray fg:gold",
|
||||
)
|
||||
|
||||
|
||||
def display_banner(session_id: str) -> None:
|
||||
print_formatted_text(
|
||||
HTML(r"""<gold>
|
||||
___ _ _ _
|
||||
/ _ \ _ __ ___ _ __ | | | | __ _ _ __ __| |___
|
||||
| | | | '_ \ / _ \ '_ \| |_| |/ _` | '_ \ / _` / __|
|
||||
| |_| | |_) | __/ | | | _ | (_| | | | | (_| \__ \
|
||||
\___ /| .__/ \___|_| |_|_| |_|\__,_|_| |_|\__,_|___/
|
||||
|_|
|
||||
</gold>"""),
|
||||
style=DEFAULT_STYLE,
|
||||
)
|
||||
|
||||
print_formatted_text(HTML(f"<grey>OpenHands CLI v{__version__}</grey>"))
|
||||
|
||||
print_formatted_text("")
|
||||
print_formatted_text(HTML(f"<grey>Initialized conversation {session_id}</grey>"))
|
||||
print_formatted_text("")
|
||||
|
||||
|
||||
def display_help() -> None:
|
||||
"""Display help information about available commands."""
|
||||
print_formatted_text("")
|
||||
print_formatted_text(HTML("<gold>🤖 OpenHands CLI Help</gold>"))
|
||||
print_formatted_text(HTML("<grey>Available commands:</grey>"))
|
||||
print_formatted_text("")
|
||||
|
||||
for command, description in COMMANDS.items():
|
||||
print_formatted_text(HTML(f" <white>{command}</white> - {description}"))
|
||||
|
||||
print_formatted_text("")
|
||||
print_formatted_text(HTML("<grey>Tips:</grey>"))
|
||||
print_formatted_text(" • Type / and press Tab to see command suggestions")
|
||||
print_formatted_text(" • Use arrow keys to navigate through suggestions")
|
||||
print_formatted_text(" • Press Enter to select a command")
|
||||
print_formatted_text("")
|
||||
|
||||
|
||||
def display_welcome(session_id: str = "chat") -> None:
|
||||
"""Display welcome message."""
|
||||
clear()
|
||||
display_banner(session_id)
|
||||
print_formatted_text(HTML("<gold>Let's start building!</gold>"))
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
"<green>What do you want to build? <grey>Type /help for help</grey></green>"
|
||||
)
|
||||
)
|
||||
print()
|
||||
@@ -0,0 +1,14 @@
|
||||
class StepCounter:
|
||||
"""Automatically manages step numbering for settings flows."""
|
||||
|
||||
def __init__(self, total_steps: int):
|
||||
self.current_step = 0
|
||||
self.total_steps = total_steps
|
||||
|
||||
def next_step(self, prompt: str) -> str:
|
||||
"""Get the next step prompt with automatic numbering."""
|
||||
self.current_step += 1
|
||||
return f"(Step {self.current_step}/{self.total_steps}) {prompt}"
|
||||
|
||||
def existing_step(self, prompt: str) -> str:
|
||||
return f"(Step {self.current_step}/{self.total_steps}) {prompt}"
|
||||
@@ -0,0 +1,17 @@
|
||||
from openhands_cli.user_actions.agent_action import ask_user_confirmation
|
||||
from openhands_cli.user_actions.exit_session import (
|
||||
exit_session_confirmation,
|
||||
)
|
||||
from openhands_cli.user_actions.settings_action import (
|
||||
choose_llm_provider,
|
||||
settings_type_confirmation,
|
||||
)
|
||||
from openhands_cli.user_actions.types import UserConfirmation
|
||||
|
||||
__all__ = [
|
||||
'ask_user_confirmation',
|
||||
'exit_session_confirmation',
|
||||
'UserConfirmation',
|
||||
'settings_type_confirmation',
|
||||
'choose_llm_provider',
|
||||
]
|
||||
@@ -0,0 +1,81 @@
|
||||
from prompt_toolkit import HTML, print_formatted_text
|
||||
|
||||
from openhands_cli.user_actions.types import UserConfirmation
|
||||
from openhands_cli.user_actions.utils import cli_confirm, cli_text_input
|
||||
|
||||
|
||||
def ask_user_confirmation(pending_actions: list) -> tuple[UserConfirmation, str]:
|
||||
"""Ask user to confirm pending actions.
|
||||
|
||||
Args:
|
||||
pending_actions: List of pending actions from the agent
|
||||
|
||||
Returns:
|
||||
Tuple of (UserConfirmation, reason) where reason is provided when rejecting with reason
|
||||
"""
|
||||
|
||||
reason = ""
|
||||
|
||||
if not pending_actions:
|
||||
return UserConfirmation.ACCEPT, reason
|
||||
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
f"<yellow>🔍 Agent created {len(pending_actions)} action(s) and is waiting for confirmation:</yellow>"
|
||||
)
|
||||
)
|
||||
|
||||
for i, action in enumerate(pending_actions, 1):
|
||||
tool_name = getattr(action, "tool_name", "[unknown tool]")
|
||||
print("tool name", tool_name)
|
||||
action_content = (
|
||||
str(getattr(action, "action", ""))[:100].replace("\n", " ")
|
||||
or "[unknown action]"
|
||||
)
|
||||
print("action_content", action_content)
|
||||
print_formatted_text(
|
||||
HTML(f"<grey> {i}. {tool_name}: {action_content}...</grey>")
|
||||
)
|
||||
|
||||
question = "Choose an option:"
|
||||
options = [
|
||||
"Yes, proceed",
|
||||
"No, reject (w/o reason)",
|
||||
"No, reject with reason",
|
||||
"Always proceed (don't ask again)",
|
||||
]
|
||||
|
||||
try:
|
||||
index = cli_confirm(question, options, escapable=True)
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print_formatted_text(HTML("\n<red>No input received; pausing agent.</red>"))
|
||||
return UserConfirmation.DEFER, reason
|
||||
|
||||
if index == 0:
|
||||
return UserConfirmation.ACCEPT, reason
|
||||
elif index == 1:
|
||||
return UserConfirmation.REJECT, reason
|
||||
elif index == 2:
|
||||
try:
|
||||
reason_result = cli_text_input(
|
||||
'Please enter your reason for rejecting these actions: '
|
||||
)
|
||||
except Exception:
|
||||
return UserConfirmation.DEFER, ''
|
||||
|
||||
# Support both string return and (reason, cancelled) tuple for tests
|
||||
cancelled = False
|
||||
if isinstance(reason_result, tuple) and len(reason_result) >= 1:
|
||||
reason = reason_result[0] or ''
|
||||
cancelled = bool(reason_result[1]) if len(reason_result) > 1 else False
|
||||
else:
|
||||
reason = str(reason_result or '').strip()
|
||||
|
||||
if cancelled:
|
||||
return UserConfirmation.DEFER, ''
|
||||
|
||||
return UserConfirmation.REJECT, reason
|
||||
elif index == 3:
|
||||
return UserConfirmation.ALWAYS_ACCEPT, reason
|
||||
|
||||
return UserConfirmation.REJECT, reason
|
||||
@@ -0,0 +1,18 @@
|
||||
from openhands_cli.user_actions.types import UserConfirmation
|
||||
from openhands_cli.user_actions.utils import cli_confirm
|
||||
|
||||
|
||||
def exit_session_confirmation() -> UserConfirmation:
|
||||
"""
|
||||
Ask user to confirm exiting session.
|
||||
"""
|
||||
|
||||
question = "Terminate session?"
|
||||
options = ["Yes, proceed", "No, dismiss"]
|
||||
index = cli_confirm(question, options) # Blocking UI, not escapable
|
||||
|
||||
options_mapping = {
|
||||
0: UserConfirmation.ACCEPT, # User accepts termination session
|
||||
1: UserConfirmation.REJECT, # User does not terminate session
|
||||
}
|
||||
return options_mapping.get(index, UserConfirmation.REJECT)
|
||||
@@ -0,0 +1,157 @@
|
||||
from enum import Enum
|
||||
|
||||
from openhands_cli.tui.utils import StepCounter
|
||||
from prompt_toolkit.completion import FuzzyWordCompleter
|
||||
from pydantic import SecretStr
|
||||
|
||||
|
||||
from openhands.sdk.llm import (
|
||||
VERIFIED_MODELS,
|
||||
UNVERIFIED_MODELS_EXCLUDING_BEDROCK
|
||||
)
|
||||
|
||||
from openhands_cli.user_actions.utils import cli_confirm, cli_text_input
|
||||
from prompt_toolkit.validation import Validator, ValidationError
|
||||
|
||||
|
||||
class NonEmptyValueValidator(Validator):
|
||||
def validate(self, document):
|
||||
text = document.text
|
||||
if not text:
|
||||
raise ValidationError(
|
||||
message="API key cannot be empty. Please enter a valid API key."
|
||||
)
|
||||
|
||||
|
||||
class SettingsType(Enum):
|
||||
BASIC = 'basic'
|
||||
ADVANCED = 'advanced'
|
||||
|
||||
|
||||
def settings_type_confirmation() -> SettingsType:
|
||||
question = 'Which settings would you like to modify?'
|
||||
choices = [
|
||||
'LLM (Basic)',
|
||||
'LLM (Advanced)',
|
||||
'Go back',
|
||||
]
|
||||
|
||||
index = cli_confirm(question, choices)
|
||||
|
||||
if choices[index] == 'Go back':
|
||||
raise KeyboardInterrupt
|
||||
|
||||
options_map = {
|
||||
0: SettingsType.BASIC,
|
||||
1: SettingsType.ADVANCED
|
||||
}
|
||||
|
||||
return options_map.get(index)
|
||||
|
||||
|
||||
def choose_llm_provider(step_counter: StepCounter, escapable=True) -> str:
|
||||
question = step_counter.next_step('Select LLM Provider (TAB for options, CTRL-c to cancel): ')
|
||||
options = list(VERIFIED_MODELS.keys()).copy() + list(UNVERIFIED_MODELS_EXCLUDING_BEDROCK.keys()).copy()
|
||||
alternate_option = 'Select another provider'
|
||||
|
||||
display_options = options[:4] + [alternate_option]
|
||||
|
||||
index = cli_confirm(question, display_options, escapable=escapable)
|
||||
chosen_option = display_options[index]
|
||||
if display_options[index] != alternate_option:
|
||||
return chosen_option
|
||||
|
||||
question = step_counter.existing_step('Type LLM Provider (TAB to complete, CTRL-c to cancel): ')
|
||||
return cli_text_input(
|
||||
question, escapable=True, completer=FuzzyWordCompleter(options, WORD=True)
|
||||
)
|
||||
|
||||
|
||||
def choose_llm_model(step_counter: StepCounter, provider: str, escapable=True) -> str:
|
||||
"""Choose LLM model using spec-driven approach. Return (model, deferred)."""
|
||||
|
||||
models = VERIFIED_MODELS.get(provider, []) + UNVERIFIED_MODELS_EXCLUDING_BEDROCK.get(provider, [])
|
||||
|
||||
if provider == 'openhands':
|
||||
question = (
|
||||
step_counter.next_step('Select Available OpenHands Model:\n')
|
||||
+ 'LLM usage is billed at the providers’ rates with no markup. Details: https://docs.all-hands.dev/usage/llms/openhands-llms'
|
||||
)
|
||||
else:
|
||||
question = step_counter.next_step('Select LLM Model (TAB for options, CTRL-c to cancel): ')
|
||||
alternate_option = 'Select another model'
|
||||
display_options = models[:4] + [alternate_option]
|
||||
index = cli_confirm(question, display_options, escapable=escapable)
|
||||
chosen_option = display_options[index]
|
||||
|
||||
if chosen_option != alternate_option:
|
||||
return chosen_option
|
||||
|
||||
question = step_counter.existing_step('Type model id (TAB to complete, CTRL-c to cancel): ')
|
||||
|
||||
return cli_text_input(
|
||||
question, escapable=True, completer=FuzzyWordCompleter(models, WORD=True)
|
||||
)
|
||||
|
||||
|
||||
|
||||
def prompt_api_key(
|
||||
step_counter: StepCounter,
|
||||
provider: str,
|
||||
existing_api_key: SecretStr | None = None,
|
||||
escapable=True
|
||||
) -> str:
|
||||
helper_text = (
|
||||
"\nYou can find your OpenHands LLM API Key in the API Keys tab of OpenHands Cloud: "
|
||||
"https://app.all-hands.dev/settings/api-keys\n"
|
||||
if provider == "openhands"
|
||||
else ""
|
||||
)
|
||||
|
||||
if existing_api_key:
|
||||
masked_key = existing_api_key.get_secret_value()[:3] + '***'
|
||||
question = f'Enter API Key [{masked_key}] (CTRL-c to cancel, ENTER to keep current, type new to change): '
|
||||
# For existing keys, allow empty input to keep current key
|
||||
validator = None
|
||||
else:
|
||||
question = 'Enter API Key (CTRL-c to cancel): '
|
||||
# For new keys, require non-empty input
|
||||
validator = NonEmptyValueValidator()
|
||||
|
||||
question = helper_text + step_counter.next_step(question)
|
||||
return cli_text_input(question, escapable=escapable, validator=validator, is_password=True)
|
||||
|
||||
|
||||
# Advanced settings functions
|
||||
def prompt_custom_model(step_counter: StepCounter, escapable=True) -> str:
|
||||
"""Prompt for custom model name."""
|
||||
question = step_counter.next_step("Custom Model (CTRL-c to cancel): ")
|
||||
return cli_text_input(question, escapable=escapable)
|
||||
|
||||
|
||||
def prompt_base_url(step_counter: StepCounter, escapable=True) -> str:
|
||||
"""Prompt for base URL."""
|
||||
question = step_counter.next_step("Base URL (CTRL-c to cancel): ")
|
||||
return cli_text_input(question, escapable=escapable, validator=NonEmptyValueValidator())
|
||||
|
||||
|
||||
def choose_memory_condensation(step_counter: StepCounter, escapable=True) -> bool:
|
||||
"""Choose memory condensation setting."""
|
||||
question = step_counter.next_step("Memory Condensation (CTRL-c to cancel): ")
|
||||
choices = ['Enable', 'Disable']
|
||||
|
||||
index = cli_confirm(question, choices, escapable=escapable)
|
||||
return index == 0 # True for Enable, False for Disable
|
||||
|
||||
|
||||
def save_settings_confirmation() -> bool:
|
||||
"""Prompt user to confirm saving settings."""
|
||||
question = 'Save new settings? (They will take effect after restart)'
|
||||
discard = 'No, discard'
|
||||
options = ['Yes, save', discard]
|
||||
|
||||
index = cli_confirm(question, options)
|
||||
if options[index] == discard:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
return options[index]
|
||||
@@ -0,0 +1,8 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class UserConfirmation(Enum):
|
||||
ACCEPT = "accept"
|
||||
REJECT = "reject"
|
||||
DEFER = "defer"
|
||||
ALWAYS_ACCEPT = "always_accept"
|
||||
@@ -0,0 +1,148 @@
|
||||
from prompt_toolkit.application import Application
|
||||
from prompt_toolkit.completion import Completer
|
||||
from prompt_toolkit.input.base import Input
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
|
||||
from prompt_toolkit.layout.containers import HSplit, Window
|
||||
from prompt_toolkit.layout.controls import FormattedTextControl
|
||||
from prompt_toolkit.layout.dimension import Dimension
|
||||
from prompt_toolkit.layout.layout import Layout
|
||||
from prompt_toolkit.output.base import Output
|
||||
from prompt_toolkit.shortcuts import prompt
|
||||
from prompt_toolkit.validation import Validator
|
||||
|
||||
from openhands_cli.tui import DEFAULT_STYLE
|
||||
|
||||
|
||||
def build_keybindings(
|
||||
choices: list[str], selected: list[int], escapable: bool
|
||||
) -> KeyBindings:
|
||||
"""Create keybindings for the confirm UI. Split for testability."""
|
||||
kb = KeyBindings()
|
||||
|
||||
@kb.add("up")
|
||||
def _handle_up(event: KeyPressEvent) -> None:
|
||||
selected[0] = (selected[0] - 1) % len(choices)
|
||||
|
||||
@kb.add("down")
|
||||
def _handle_down(event: KeyPressEvent) -> None:
|
||||
selected[0] = (selected[0] + 1) % len(choices)
|
||||
|
||||
@kb.add("enter")
|
||||
def _handle_enter(event: KeyPressEvent) -> None:
|
||||
event.app.exit(result=selected[0])
|
||||
|
||||
if escapable:
|
||||
|
||||
@kb.add("c-c") # Ctrl+C
|
||||
def _handle_hard_interrupt(event: KeyPressEvent) -> None:
|
||||
event.app.exit(exception=KeyboardInterrupt())
|
||||
|
||||
@kb.add("c-p") # Ctrl+P
|
||||
def _handle_pause_interrupt(event: KeyPressEvent) -> None:
|
||||
event.app.exit(exception=KeyboardInterrupt())
|
||||
|
||||
@kb.add("escape") # Escape key
|
||||
def _handle_escape(event: KeyPressEvent) -> None:
|
||||
event.app.exit(exception=KeyboardInterrupt())
|
||||
|
||||
return kb
|
||||
|
||||
|
||||
def build_layout(question: str, choices: list[str], selected_ref: list[int]) -> Layout:
|
||||
"""Create the layout for the confirm UI. Split for testability."""
|
||||
|
||||
def get_choice_text() -> list[tuple[str, str]]:
|
||||
lines: list[tuple[str, str]] = []
|
||||
lines.append(("class:question", f"{question}\n\n"))
|
||||
for i, choice in enumerate(choices):
|
||||
is_selected = i == selected_ref[0]
|
||||
prefix = "> " if is_selected else " "
|
||||
style = "class:selected" if is_selected else "class:unselected"
|
||||
lines.append((style, f"{prefix}{choice}\n"))
|
||||
return lines
|
||||
|
||||
content_window = Window(
|
||||
FormattedTextControl(get_choice_text),
|
||||
always_hide_cursor=True,
|
||||
height=Dimension(max=8),
|
||||
)
|
||||
return Layout(HSplit([content_window]))
|
||||
|
||||
|
||||
def cli_confirm(
|
||||
question: str = "Are you sure?",
|
||||
choices: list[str] | None = None,
|
||||
initial_selection: int = 0,
|
||||
escapable: bool = False,
|
||||
input: Input | None = None, # strictly for unit testing
|
||||
output: Output | None = None, # strictly for unit testing
|
||||
) -> int:
|
||||
"""Display a confirmation prompt with the given question and choices.
|
||||
|
||||
Returns the index of the selected choice.
|
||||
"""
|
||||
if choices is None:
|
||||
choices = ["Yes", "No"]
|
||||
selected = [initial_selection] # Using list to allow modification in closure
|
||||
|
||||
kb = build_keybindings(choices, selected, escapable)
|
||||
layout = build_layout(question, choices, selected)
|
||||
|
||||
app = Application(
|
||||
layout=layout,
|
||||
key_bindings=kb,
|
||||
style=DEFAULT_STYLE,
|
||||
full_screen=False,
|
||||
input=input,
|
||||
output=output,
|
||||
)
|
||||
|
||||
return int(app.run(in_thread=True))
|
||||
|
||||
|
||||
def cli_text_input(
|
||||
question: str,
|
||||
escapable: bool = True,
|
||||
completer: Completer | None = None,
|
||||
validator: Validator = None,
|
||||
is_password: bool = False
|
||||
) -> str:
|
||||
"""Prompt user to enter text input with optional validation.
|
||||
|
||||
Args:
|
||||
question: The prompt question to display
|
||||
escapable: Whether the user can escape with Ctrl+C or Ctrl+P
|
||||
completer: Optional completer for tab completion
|
||||
validator: Optional callable that takes a string and returns True if valid.
|
||||
If validation fails, the callable should display error messages
|
||||
and the user will be reprompted.
|
||||
|
||||
Returns:
|
||||
The validated user input string (stripped of whitespace)
|
||||
"""
|
||||
|
||||
kb = KeyBindings()
|
||||
|
||||
if escapable:
|
||||
|
||||
@kb.add('c-c')
|
||||
def _(event: KeyPressEvent) -> None:
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
@kb.add('c-p')
|
||||
def _(event: KeyPressEvent) -> None:
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
|
||||
reason = str(
|
||||
prompt(
|
||||
question,
|
||||
style=DEFAULT_STYLE,
|
||||
key_bindings=kb,
|
||||
completer=completer,
|
||||
is_password=is_password,
|
||||
validator=validator
|
||||
)
|
||||
)
|
||||
return reason.strip()
|
||||
@@ -0,0 +1,86 @@
|
||||
[build-system]
|
||||
build-backend = "hatchling.build"
|
||||
requires = [ "hatchling>=1.25" ]
|
||||
|
||||
[project]
|
||||
name = "openhands-cli"
|
||||
version = "0.1.0"
|
||||
description = "OpenHands CLI - Terminal User Interface for OpenHands AI Agent"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
authors = [ { name = "OpenHands Team", email = "contact@all-hands.dev" } ]
|
||||
requires-python = ">=3.12"
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
]
|
||||
dependencies = [
|
||||
"openhands-sdk",
|
||||
"openhands-tools",
|
||||
"prompt-toolkit>=3",
|
||||
"typer>=0.17.4",
|
||||
]
|
||||
|
||||
# Dev-only tools with uv groups: `uv sync --group dev`
|
||||
scripts.openhands-cli = "openhands_cli.simple_main:main"
|
||||
|
||||
[dependency-groups]
|
||||
# Hatchling wheel target: include the package directory
|
||||
dev = [
|
||||
"black>=23",
|
||||
"flake8>=6",
|
||||
"isort>=5",
|
||||
"mypy>=1",
|
||||
"pre-commit>=4.3",
|
||||
"pyinstaller>=6.15",
|
||||
"pytest>=8.4.1",
|
||||
"ruff>=0.11.8",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = [ "openhands_cli" ]
|
||||
|
||||
# uv source pins for internal packages
|
||||
|
||||
[tool.black]
|
||||
line-length = 88
|
||||
target-version = [ "py312" ]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 88
|
||||
|
||||
format.indent-style = "space"
|
||||
format.quote-style = "double"
|
||||
format.line-ending = "auto"
|
||||
format.skip-magic-trailing-comma = false
|
||||
lint.select = [
|
||||
"B", # flake8-bugbear
|
||||
"C4", # flake8-comprehensions
|
||||
"E", # pycodestyle errors
|
||||
"F", # pyflakes
|
||||
"I", # isort
|
||||
"UP", # pyupgrade
|
||||
"W", # pycodestyle warnings
|
||||
]
|
||||
lint.ignore = [
|
||||
"B008", # calls in argument defaults
|
||||
"C901", # too complex
|
||||
"E501", # line too long (black handles)
|
||||
]
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
line_length = 88
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = true
|
||||
ignore_missing_imports = true
|
||||
|
||||
[tool.uv.sources]
|
||||
openhands-sdk = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands/sdk", rev = "68fed9e285f9e5fd42f8aa2c6932acb7f86bc351" }
|
||||
openhands-tools = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands/tools", rev = "68fed9e285f9e5fd42f8aa2c6932acb7f86bc351" }
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for OpenHands CLI."""
|
||||
@@ -0,0 +1,44 @@
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
# Fixture: mock_verified_models - Simplified model data
|
||||
@pytest.fixture
|
||||
def mock_verified_models():
|
||||
with (
|
||||
patch("openhands_cli.user_actions.settings_action.VERIFIED_MODELS", {
|
||||
"openai": ["gpt-4o", "gpt-4o-mini"],
|
||||
"anthropic": ["claude-3-5-sonnet", "claude-3-5-haiku"],
|
||||
}),
|
||||
patch("openhands_cli.user_actions.settings_action.UNVERIFIED_MODELS_EXCLUDING_BEDROCK", {
|
||||
"openai": ["gpt-custom"],
|
||||
"anthropic": [],
|
||||
"custom": ["my-model"],
|
||||
}),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
# Fixture: mock_cli_interactions - Reusable CLI mock patterns
|
||||
@pytest.fixture
|
||||
def mock_cli_interactions():
|
||||
class Mocks:
|
||||
def __init__(self):
|
||||
self.p_confirm = patch("openhands_cli.user_actions.settings_action.cli_confirm")
|
||||
self.p_text = patch("openhands_cli.user_actions.settings_action.cli_text_input")
|
||||
self.cli_confirm = None
|
||||
self.cli_text_input = None
|
||||
|
||||
def start(self):
|
||||
self.cli_confirm = self.p_confirm.start()
|
||||
self.cli_text_input = self.p_text.start()
|
||||
return self
|
||||
|
||||
def stop(self):
|
||||
self.p_confirm.stop()
|
||||
self.p_text.stop()
|
||||
|
||||
mocks = Mocks().start()
|
||||
try:
|
||||
yield mocks
|
||||
finally:
|
||||
mocks.stop()
|
||||
@@ -0,0 +1,333 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for confirmation mode functionality in OpenHands CLI.
|
||||
"""
|
||||
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from openhands.sdk import ActionBase
|
||||
from openhands.sdk.security.confirmation_policy import AlwaysConfirm, NeverConfirm
|
||||
from prompt_toolkit.input.defaults import create_pipe_input
|
||||
from prompt_toolkit.output.defaults import DummyOutput
|
||||
|
||||
from openhands_cli.runner import ConversationRunner
|
||||
from openhands_cli.setup import setup_agent
|
||||
from openhands_cli.user_actions import agent_action, ask_user_confirmation, utils
|
||||
from openhands_cli.user_actions.types import UserConfirmation
|
||||
from tests.utils import _send_keys
|
||||
|
||||
|
||||
class MockAction(ActionBase):
|
||||
"""Mock action schema for testing."""
|
||||
|
||||
command: str
|
||||
|
||||
|
||||
class TestConfirmationMode:
|
||||
"""Test suite for confirmation mode functionality."""
|
||||
|
||||
def test_setup_agent_creates_conversation(self) -> None:
|
||||
"""Test that setup_agent creates a conversation successfully."""
|
||||
with patch.dict(os.environ, {'LLM_MODEL': 'test-model'}):
|
||||
with (
|
||||
patch('openhands_cli.setup.Agent') as mock_agent_class,
|
||||
patch('openhands_cli.setup.Conversation') as mock_conversation_class,
|
||||
patch('openhands_cli.setup.AgentStore') as mock_agent_store_class,
|
||||
patch('openhands_cli.setup.print_formatted_text') as mock_print,
|
||||
patch('openhands_cli.setup.HTML') as mock_html,
|
||||
):
|
||||
# Mock AgentStore
|
||||
mock_agent_store_instance = MagicMock()
|
||||
mock_agent_instance = MagicMock()
|
||||
mock_agent_instance.llm.model = 'test-model'
|
||||
mock_agent_store_instance.load.return_value = mock_agent_instance
|
||||
mock_agent_store_class.return_value = mock_agent_store_instance
|
||||
|
||||
# Mock Conversation constructor to return a mock conversation
|
||||
mock_conversation_instance = MagicMock()
|
||||
mock_conversation_class.return_value = mock_conversation_instance
|
||||
|
||||
result = setup_agent()
|
||||
|
||||
# Verify conversation was created and returned
|
||||
assert result == mock_conversation_instance
|
||||
mock_agent_store_class.assert_called_once()
|
||||
mock_agent_store_instance.load.assert_called_once()
|
||||
mock_conversation_class.assert_called_once_with(agent=mock_agent_instance)
|
||||
# Verify print_formatted_text was called
|
||||
mock_print.assert_called_once()
|
||||
|
||||
def test_conversation_runner_set_confirmation_mode(self) -> None:
|
||||
"""Test that ConversationRunner can set confirmation mode."""
|
||||
|
||||
mock_conversation = MagicMock()
|
||||
runner = ConversationRunner(mock_conversation)
|
||||
|
||||
# Test enabling confirmation mode
|
||||
runner.set_confirmation_mode(True)
|
||||
assert runner.confirmation_mode is True
|
||||
mock_conversation.set_confirmation_policy.assert_called_with(AlwaysConfirm())
|
||||
|
||||
# Test disabling confirmation mode
|
||||
runner.set_confirmation_mode(False)
|
||||
assert runner.confirmation_mode is False
|
||||
mock_conversation.set_confirmation_policy.assert_called_with(NeverConfirm())
|
||||
|
||||
def test_conversation_runner_initial_state(self) -> None:
|
||||
"""Test that ConversationRunner starts with confirmation mode disabled."""
|
||||
|
||||
mock_conversation = MagicMock()
|
||||
runner = ConversationRunner(mock_conversation)
|
||||
|
||||
# Verify initial state
|
||||
assert runner.confirmation_mode is False
|
||||
|
||||
def test_ask_user_confirmation_empty_actions(self) -> None:
|
||||
"""Test that ask_user_confirmation returns ACCEPT for empty actions list."""
|
||||
result, reason = ask_user_confirmation([])
|
||||
assert result == UserConfirmation.ACCEPT
|
||||
assert reason == ''
|
||||
|
||||
@patch('openhands_cli.user_actions.agent_action.cli_confirm')
|
||||
def test_ask_user_confirmation_yes(self, mock_cli_confirm: Any) -> None:
|
||||
"""Test that ask_user_confirmation returns ACCEPT when user selects yes."""
|
||||
mock_cli_confirm.return_value = 0 # First option (Yes, proceed)
|
||||
|
||||
mock_action = MagicMock()
|
||||
mock_action.tool_name = 'bash'
|
||||
mock_action.action = 'ls -la'
|
||||
|
||||
result, reason = ask_user_confirmation([mock_action])
|
||||
assert result == UserConfirmation.ACCEPT
|
||||
assert reason == ''
|
||||
|
||||
@patch('openhands_cli.user_actions.agent_action.cli_confirm')
|
||||
def test_ask_user_confirmation_no(self, mock_cli_confirm: Any) -> None:
|
||||
"""Test that ask_user_confirmation returns REJECT when user selects no."""
|
||||
mock_cli_confirm.return_value = 1 # Second option (No, reject)
|
||||
|
||||
mock_action = MagicMock()
|
||||
mock_action.tool_name = 'bash'
|
||||
mock_action.action = 'rm -rf /'
|
||||
|
||||
result, reason = ask_user_confirmation([mock_action])
|
||||
assert result == UserConfirmation.REJECT
|
||||
assert reason == ''
|
||||
|
||||
@patch('openhands_cli.user_actions.agent_action.cli_confirm')
|
||||
def test_ask_user_confirmation_y_shorthand(self, mock_cli_confirm: Any) -> None:
|
||||
"""Test that ask_user_confirmation accepts first option as yes."""
|
||||
mock_cli_confirm.return_value = 0 # First option (Yes, proceed)
|
||||
|
||||
mock_action = MagicMock()
|
||||
mock_action.tool_name = 'bash'
|
||||
mock_action.action = 'echo hello'
|
||||
|
||||
result, reason = ask_user_confirmation([mock_action])
|
||||
assert result == UserConfirmation.ACCEPT
|
||||
assert reason == ''
|
||||
|
||||
@patch('openhands_cli.user_actions.agent_action.cli_confirm')
|
||||
def test_ask_user_confirmation_n_shorthand(self, mock_cli_confirm: Any) -> None:
|
||||
"""Test that ask_user_confirmation accepts second option as no."""
|
||||
mock_cli_confirm.return_value = 1 # Second option (No, reject)
|
||||
|
||||
mock_action = MagicMock()
|
||||
mock_action.tool_name = 'bash'
|
||||
mock_action.action = 'dangerous command'
|
||||
|
||||
result, reason = ask_user_confirmation([mock_action])
|
||||
assert result == UserConfirmation.REJECT
|
||||
assert reason == ''
|
||||
|
||||
@patch('openhands_cli.user_actions.agent_action.cli_confirm')
|
||||
def test_ask_user_confirmation_invalid_then_yes(
|
||||
self, mock_cli_confirm: Any
|
||||
) -> None:
|
||||
"""Test that ask_user_confirmation handles selection and accepts yes."""
|
||||
mock_cli_confirm.return_value = 0 # First option (Yes, proceed)
|
||||
|
||||
mock_action = MagicMock()
|
||||
mock_action.tool_name = 'bash'
|
||||
mock_action.action = 'echo test'
|
||||
|
||||
result, reason = ask_user_confirmation([mock_action])
|
||||
assert result == UserConfirmation.ACCEPT
|
||||
assert reason == ''
|
||||
assert mock_cli_confirm.call_count == 1
|
||||
|
||||
@patch('openhands_cli.user_actions.agent_action.cli_confirm')
|
||||
def test_ask_user_confirmation_keyboard_interrupt(
|
||||
self, mock_cli_confirm: Any
|
||||
) -> None:
|
||||
"""Test that ask_user_confirmation handles KeyboardInterrupt gracefully."""
|
||||
mock_cli_confirm.side_effect = KeyboardInterrupt()
|
||||
|
||||
mock_action = MagicMock()
|
||||
mock_action.tool_name = 'bash'
|
||||
mock_action.action = 'echo test'
|
||||
|
||||
result, reason = ask_user_confirmation([mock_action])
|
||||
assert result == UserConfirmation.DEFER
|
||||
assert reason == ''
|
||||
|
||||
@patch('openhands_cli.user_actions.agent_action.cli_confirm')
|
||||
def test_ask_user_confirmation_eof_error(self, mock_cli_confirm: Any) -> None:
|
||||
"""Test that ask_user_confirmation handles EOFError gracefully."""
|
||||
mock_cli_confirm.side_effect = EOFError()
|
||||
|
||||
mock_action = MagicMock()
|
||||
mock_action.tool_name = 'bash'
|
||||
mock_action.action = 'echo test'
|
||||
|
||||
result, reason = ask_user_confirmation([mock_action])
|
||||
assert result == UserConfirmation.DEFER
|
||||
assert reason == ''
|
||||
|
||||
def test_ask_user_confirmation_multiple_actions(self) -> None:
|
||||
"""Test that ask_user_confirmation displays multiple actions correctly."""
|
||||
with (
|
||||
patch(
|
||||
'openhands_cli.user_actions.agent_action.cli_confirm'
|
||||
) as mock_cli_confirm,
|
||||
patch(
|
||||
'openhands_cli.user_actions.agent_action.print_formatted_text'
|
||||
) as mock_print,
|
||||
):
|
||||
mock_cli_confirm.return_value = 0 # First option (Yes, proceed)
|
||||
|
||||
mock_action1 = MagicMock()
|
||||
mock_action1.tool_name = 'bash'
|
||||
mock_action1.action = 'ls -la'
|
||||
|
||||
mock_action2 = MagicMock()
|
||||
mock_action2.tool_name = 'str_replace_editor'
|
||||
mock_action2.action = 'create file.txt'
|
||||
|
||||
result, reason = ask_user_confirmation([mock_action1, mock_action2])
|
||||
assert result == UserConfirmation.ACCEPT
|
||||
assert reason == ''
|
||||
|
||||
# Verify that both actions were displayed
|
||||
assert mock_print.call_count >= 3 # Header + 2 actions
|
||||
|
||||
@patch('openhands_cli.user_actions.agent_action.cli_text_input')
|
||||
@patch('openhands_cli.user_actions.agent_action.cli_confirm')
|
||||
def test_ask_user_confirmation_no_with_reason(
|
||||
self, mock_cli_confirm: Any, mock_cli_text_input: Any
|
||||
) -> None:
|
||||
"""Test that ask_user_confirmation returns REJECT when user selects 'No (with reason)'."""
|
||||
mock_cli_confirm.return_value = 2 # Third option (No, with reason)
|
||||
mock_cli_text_input.return_value = ('This action is too risky', False)
|
||||
|
||||
mock_action = MagicMock()
|
||||
mock_action.tool_name = 'bash'
|
||||
mock_action.action = 'rm -rf /'
|
||||
|
||||
result, reason = ask_user_confirmation([mock_action])
|
||||
assert result == UserConfirmation.REJECT
|
||||
assert reason == 'This action is too risky'
|
||||
mock_cli_text_input.assert_called_once()
|
||||
|
||||
@patch('openhands_cli.user_actions.agent_action.cli_text_input')
|
||||
@patch('openhands_cli.user_actions.agent_action.cli_confirm')
|
||||
def test_ask_user_confirmation_no_with_reason_cancelled(
|
||||
self, mock_cli_confirm: Any, mock_cli_text_input: Any
|
||||
) -> None:
|
||||
"""Test that ask_user_confirmation falls back to DEFER when reason input is cancelled."""
|
||||
mock_cli_confirm.return_value = 2 # Third option (No, with reason)
|
||||
mock_cli_text_input.return_value = ('', True) # User cancelled reason input
|
||||
|
||||
mock_action = MagicMock()
|
||||
mock_action.tool_name = 'bash'
|
||||
mock_action.action = 'dangerous command'
|
||||
|
||||
result, reason = ask_user_confirmation([mock_action])
|
||||
assert result == UserConfirmation.DEFER
|
||||
assert reason == ''
|
||||
mock_cli_text_input.assert_called_once()
|
||||
|
||||
def test_user_confirmation_is_escapable_e2e(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""E2E: non-escapable should ignore Ctrl-C/Ctrl-P/Esc; only Enter returns."""
|
||||
real_cli_confirm = utils.cli_confirm
|
||||
|
||||
with create_pipe_input() as pipe:
|
||||
output = DummyOutput()
|
||||
|
||||
def wrapper(
|
||||
question: str,
|
||||
choices: list[str] | None = None,
|
||||
initial_selection: int = 0,
|
||||
escapable: bool = False,
|
||||
**extra: object,
|
||||
) -> int:
|
||||
# keep original params; inject test IO
|
||||
return real_cli_confirm(
|
||||
question=question,
|
||||
choices=choices,
|
||||
initial_selection=initial_selection,
|
||||
escapable=escapable,
|
||||
input=pipe,
|
||||
output=output,
|
||||
)
|
||||
|
||||
# Patch the symbol the caller uses
|
||||
monkeypatch.setattr(agent_action, 'cli_confirm', wrapper, raising=True)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as ex:
|
||||
fut = ex.submit(
|
||||
ask_user_confirmation, [MockAction(command='echo hello world')]
|
||||
)
|
||||
|
||||
_send_keys(pipe, '\x03') # Ctrl-C (ignored)
|
||||
result, reason = fut.result(timeout=2.0)
|
||||
assert result == UserConfirmation.DEFER # escaped confirmation view
|
||||
assert reason == ''
|
||||
|
||||
@patch('openhands_cli.user_actions.agent_action.cli_confirm')
|
||||
def test_ask_user_confirmation_always_accept(self, mock_cli_confirm: Any) -> None:
|
||||
"""Test that ask_user_confirmation returns ALWAYS_ACCEPT when user selects fourth option."""
|
||||
mock_cli_confirm.return_value = 3 # Fourth option (Always proceed)
|
||||
|
||||
mock_action = MagicMock()
|
||||
mock_action.tool_name = 'bash'
|
||||
mock_action.action = 'echo test'
|
||||
|
||||
result, reason = ask_user_confirmation([mock_action])
|
||||
assert result == UserConfirmation.ALWAYS_ACCEPT
|
||||
assert reason == ''
|
||||
|
||||
def test_conversation_runner_handles_always_accept(self) -> None:
|
||||
"""Test that ConversationRunner disables confirmation mode when ALWAYS_ACCEPT is returned."""
|
||||
mock_conversation = MagicMock()
|
||||
runner = ConversationRunner(mock_conversation)
|
||||
|
||||
# Enable confirmation mode first
|
||||
runner.set_confirmation_mode(True)
|
||||
assert runner.confirmation_mode is True
|
||||
|
||||
# Mock get_unmatched_actions to return some actions
|
||||
with patch('openhands_cli.runner.get_unmatched_actions') as mock_get_actions:
|
||||
mock_action = MagicMock()
|
||||
mock_action.tool_name = 'bash'
|
||||
mock_action.action = 'echo test'
|
||||
mock_get_actions.return_value = [mock_action]
|
||||
|
||||
# Mock ask_user_confirmation to return ALWAYS_ACCEPT
|
||||
with patch('openhands_cli.runner.ask_user_confirmation') as mock_ask:
|
||||
mock_ask.return_value = (UserConfirmation.ALWAYS_ACCEPT, '')
|
||||
|
||||
# Mock print_formatted_text to avoid output during test
|
||||
with patch('openhands_cli.runner.print_formatted_text'):
|
||||
result = runner._handle_confirmation_request()
|
||||
|
||||
# Verify that confirmation mode was disabled
|
||||
assert result == UserConfirmation.ALWAYS_ACCEPT
|
||||
assert runner.confirmation_mode is False
|
||||
mock_conversation.set_confirmation_policy.assert_called_with(NeverConfirm())
|
||||
@@ -0,0 +1,132 @@
|
||||
from typing import Any, Self
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from openhands.sdk import Conversation, ConversationCallbackType
|
||||
from openhands.sdk.agent.base import AgentBase
|
||||
from openhands.sdk.conversation import ConversationState
|
||||
from openhands.sdk.llm import LLM
|
||||
from pydantic import ConfigDict, SecretStr, model_validator
|
||||
from openhands.sdk.conversation.state import AgentExecutionStatus
|
||||
|
||||
from openhands_cli.runner import ConversationRunner
|
||||
from openhands_cli.user_actions.types import UserConfirmation
|
||||
|
||||
|
||||
class FakeLLM(LLM):
|
||||
@model_validator(mode="after")
|
||||
def _set_env_side_effects(self) -> Self:
|
||||
return self
|
||||
|
||||
|
||||
def default_config() -> dict[str, Any]:
|
||||
return {
|
||||
"model": "gpt-4o",
|
||||
"api_key": SecretStr("test_key"),
|
||||
"num_retries": 2,
|
||||
"retry_min_wait": 1,
|
||||
"retry_max_wait": 2,
|
||||
}
|
||||
|
||||
|
||||
class FakeAgent(AgentBase):
|
||||
model_config = ConfigDict(frozen=False)
|
||||
step_count: int = 0
|
||||
finish_on_step: int | None = None
|
||||
|
||||
def init_state(
|
||||
self, state: ConversationState, on_event: ConversationCallbackType
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
def step(
|
||||
self, state: ConversationState, on_event: ConversationCallbackType
|
||||
) -> None:
|
||||
self.step_count += 1
|
||||
if self.step_count == self.finish_on_step:
|
||||
state.agent_status = AgentExecutionStatus.FINISHED
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def agent() -> FakeAgent:
|
||||
llm = LLM(**default_config(), service_id="test-service")
|
||||
return FakeAgent(llm=llm, tools=[])
|
||||
|
||||
|
||||
class TestConversationRunner:
|
||||
@pytest.mark.parametrize('agent_status', [AgentExecutionStatus.RUNNING, AgentExecutionStatus.PAUSED])
|
||||
def test_non_confirmation_mode_runs_once(self, agent: FakeAgent, agent_status: AgentExecutionStatus) -> None:
|
||||
"""
|
||||
1. Confirmation mode is not on
|
||||
2. Process message resumes paused conversation or continues running conversation
|
||||
"""
|
||||
|
||||
convo = Conversation(agent)
|
||||
convo.max_iteration_per_run = 1
|
||||
convo.state.agent_status = agent_status
|
||||
cr = ConversationRunner(convo)
|
||||
cr.set_confirmation_mode(False)
|
||||
cr.process_message(message=None)
|
||||
|
||||
assert agent.step_count == 1
|
||||
assert convo.state.agent_status != AgentExecutionStatus.PAUSED
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'confirmation, final_status, expected_run_calls',
|
||||
[
|
||||
# Case 1: Agent waiting for confirmation; user DEFERS -> early return, no run()
|
||||
(UserConfirmation.DEFER, AgentExecutionStatus.WAITING_FOR_CONFIRMATION, 0),
|
||||
# Case 2: Agent waiting for confirmation; user ACCEPTS -> run() once, break (finished=True)
|
||||
(UserConfirmation.ACCEPT, AgentExecutionStatus.FINISHED, 1),
|
||||
],
|
||||
)
|
||||
def test_confirmation_mode_waiting_and_user_decision_controls_run(
|
||||
self,
|
||||
agent: FakeAgent,
|
||||
confirmation: UserConfirmation,
|
||||
final_status: AgentExecutionStatus,
|
||||
expected_run_calls: int,
|
||||
) -> None:
|
||||
"""
|
||||
1. Agent may be paused but is waiting for consent on actions
|
||||
2. If paused, we should have asked for confirmation on action
|
||||
3. If not paused, we should still ask for confirmation on actions
|
||||
4. If deferred no run call to agent should be made
|
||||
5. If accepted, run call to agent should be made
|
||||
|
||||
"""
|
||||
if final_status == AgentExecutionStatus.FINISHED:
|
||||
agent.finish_on_step = 1
|
||||
convo = Conversation(agent)
|
||||
convo.state.agent_status = AgentExecutionStatus.WAITING_FOR_CONFIRMATION
|
||||
cr = ConversationRunner(convo)
|
||||
cr.set_confirmation_mode(True)
|
||||
with patch.object(
|
||||
cr, "_handle_confirmation_request", return_value=confirmation
|
||||
) as mock_confirmation_request:
|
||||
cr.process_message(message=None)
|
||||
mock_confirmation_request.assert_called_once()
|
||||
assert agent.step_count == expected_run_calls
|
||||
assert convo.state.agent_status == final_status
|
||||
|
||||
def test_confirmation_mode_not_waiting__runs_once_when_finished_true(
|
||||
self, agent: FakeAgent
|
||||
) -> None:
|
||||
"""
|
||||
1. Agent was not waiting
|
||||
2. Agent finished without any actions
|
||||
3. Conversation should finished without asking user for instructions
|
||||
"""
|
||||
agent.finish_on_step = 1
|
||||
convo = Conversation(agent)
|
||||
convo.state.agent_status = AgentExecutionStatus.PAUSED
|
||||
|
||||
cr = ConversationRunner(convo)
|
||||
cr.set_confirmation_mode(True)
|
||||
|
||||
with patch.object(cr, "_handle_confirmation_request") as _mock_h:
|
||||
cr.process_message(message=None)
|
||||
|
||||
# No confirmation was needed up front; we still expect exactly one run.
|
||||
assert agent.step_count == 1
|
||||
_mock_h.assert_not_called()
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Tests to demonstrate the fix for WORK_DIR and PERSISTENCE_DIR separation."""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch, MagicMock
|
||||
from openhands.sdk import Agent, LLM, ToolSpec
|
||||
from openhands_cli.locations import WORK_DIR, PERSISTENCE_DIR
|
||||
from openhands_cli.tui.settings.store import AgentStore
|
||||
from openhands.sdk.preset.default import get_default_tools
|
||||
|
||||
|
||||
class TestDirectorySeparation:
|
||||
"""Test that WORK_DIR and PERSISTENCE_DIR are properly separated."""
|
||||
|
||||
def test_work_dir_and_persistence_dir_are_different(self):
|
||||
"""Test that WORK_DIR and PERSISTENCE_DIR are separate directories."""
|
||||
# WORK_DIR should be the current working directory
|
||||
assert WORK_DIR == os.getcwd()
|
||||
|
||||
# PERSISTENCE_DIR should be ~/.openhands
|
||||
expected_config_dir = os.path.expanduser("~/.openhands")
|
||||
assert PERSISTENCE_DIR == expected_config_dir
|
||||
|
||||
# They should be different
|
||||
assert WORK_DIR != PERSISTENCE_DIR
|
||||
|
||||
def test_agent_store_uses_persistence_dir(self):
|
||||
"""Test that AgentStore uses PERSISTENCE_DIR for file storage."""
|
||||
agent_store = AgentStore()
|
||||
assert agent_store.file_store.root == PERSISTENCE_DIR
|
||||
|
||||
|
||||
class TestToolSpecFix:
|
||||
"""Test that tool specs are replaced with default tools using current directory."""
|
||||
|
||||
def test_tools_replaced_with_default_tools_on_load(self):
|
||||
"""Test that entire tools list is replaced with default tools when loading agent."""
|
||||
# Create a mock agent with different tools and working directories
|
||||
original_working_dir = "/some/other/path"
|
||||
mock_agent = Agent(
|
||||
llm=LLM(model="test/model", api_key="test-key"),
|
||||
tools=[
|
||||
ToolSpec(name="BashTool", params={"working_dir": original_working_dir}),
|
||||
ToolSpec(name="FileEditorTool", params={"workspace_root": original_working_dir}),
|
||||
ToolSpec(name="TaskTrackerTool", params={"save_dir": "value"}),
|
||||
]
|
||||
)
|
||||
|
||||
# Mock the file store to return our test agent
|
||||
with patch('openhands_cli.tui.settings.store.LocalFileStore') as mock_file_store:
|
||||
mock_store_instance = MagicMock()
|
||||
mock_file_store.return_value = mock_store_instance
|
||||
mock_store_instance.read.return_value = mock_agent.model_dump_json()
|
||||
|
||||
agent_store = AgentStore()
|
||||
loaded_agent = agent_store.load()
|
||||
|
||||
# Verify the agent was loaded
|
||||
assert loaded_agent is not None
|
||||
|
||||
# Verify that tools are replaced with default tools
|
||||
assert len(loaded_agent.tools) == 3 # BashTool, FileEditorTool, TaskTrackerTool
|
||||
|
||||
tool_names = [tool.name for tool in loaded_agent.tools]
|
||||
assert "BashTool" in tool_names
|
||||
assert "FileEditorTool" in tool_names
|
||||
assert "TaskTrackerTool" in tool_names
|
||||
|
||||
for tool_spec in loaded_agent.tools:
|
||||
if tool_spec.name == "BashTool":
|
||||
assert tool_spec.params["working_dir"] == WORK_DIR
|
||||
assert tool_spec.params["working_dir"] != original_working_dir
|
||||
elif tool_spec.name == "FileEditorTool":
|
||||
assert tool_spec.params["workspace_root"] == WORK_DIR
|
||||
assert tool_spec.params["workspace_root"] != original_working_dir
|
||||
elif tool_spec.name == "TaskTrackerTool":
|
||||
# TaskTrackerTool should use WORK_DIR/.openhands_tasks
|
||||
assert tool_spec.params["save_dir"] == PERSISTENCE_DIR
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for exit_session_confirmation functionality in OpenHands CLI.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from prompt_toolkit.input.defaults import create_pipe_input
|
||||
from prompt_toolkit.output.defaults import DummyOutput
|
||||
|
||||
from openhands_cli.user_actions import (
|
||||
exit_session,
|
||||
exit_session_confirmation,
|
||||
utils,
|
||||
)
|
||||
from openhands_cli.user_actions.types import UserConfirmation
|
||||
from tests.utils import _send_keys
|
||||
|
||||
QUESTION = 'Terminate session?'
|
||||
OPTIONS = ['Yes, proceed', 'No, dismiss']
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def confirm_patch() -> Iterator[MagicMock]:
|
||||
"""Patch cli_confirm once per test and yield the mock."""
|
||||
with patch('openhands_cli.user_actions.exit_session.cli_confirm') as m:
|
||||
yield m
|
||||
|
||||
|
||||
def _assert_called_once_with_defaults(mock_cli_confirm: MagicMock) -> None:
|
||||
"""Ensure the question/options are correct and 'escapable' is not enabled."""
|
||||
mock_cli_confirm.assert_called_once()
|
||||
args, kwargs = mock_cli_confirm.call_args
|
||||
# Positional args
|
||||
assert args == (QUESTION, OPTIONS)
|
||||
# Should not opt into escapable mode
|
||||
assert 'escapable' not in kwargs or kwargs['escapable'] is False
|
||||
|
||||
|
||||
class TestExitSessionConfirmation:
|
||||
"""Test suite for exit_session_confirmation functionality."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'index,expected',
|
||||
[
|
||||
(0, UserConfirmation.ACCEPT), # Yes
|
||||
(1, UserConfirmation.REJECT), # No
|
||||
(999, UserConfirmation.REJECT), # Invalid => default reject
|
||||
(-1, UserConfirmation.REJECT), # Negative => default reject
|
||||
],
|
||||
)
|
||||
def test_index_mapping(
|
||||
self, confirm_patch: MagicMock, index: int, expected: UserConfirmation
|
||||
) -> None:
|
||||
"""All index-to-result mappings, including invalid/negative, in one place."""
|
||||
confirm_patch.return_value = index
|
||||
|
||||
result = exit_session_confirmation()
|
||||
|
||||
assert isinstance(result, UserConfirmation)
|
||||
assert result == expected
|
||||
_assert_called_once_with_defaults(confirm_patch)
|
||||
|
||||
def test_exit_session_confirmation_non_escapable_e2e(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""E2E: non-escapable should ignore Ctrl-C/Ctrl-P/Esc; only Enter returns."""
|
||||
real_cli_confirm = utils.cli_confirm
|
||||
|
||||
with create_pipe_input() as pipe:
|
||||
output = DummyOutput()
|
||||
|
||||
def wrapper(
|
||||
question: str,
|
||||
choices: list[str] | None = None,
|
||||
initial_selection: int = 0,
|
||||
escapable: bool = False,
|
||||
**extra: object,
|
||||
) -> int:
|
||||
# keep original params; inject test IO
|
||||
return real_cli_confirm(
|
||||
question=question,
|
||||
choices=choices,
|
||||
initial_selection=initial_selection,
|
||||
escapable=escapable,
|
||||
input=pipe,
|
||||
output=output,
|
||||
)
|
||||
|
||||
# Patch the symbol the caller uses
|
||||
monkeypatch.setattr(exit_session, 'cli_confirm', wrapper, raising=True)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as ex:
|
||||
fut = ex.submit(exit_session_confirmation)
|
||||
|
||||
_send_keys(pipe, '\x03') # Ctrl-C (ignored)
|
||||
_send_keys(pipe, '\x10') # Ctrl-P (ignored)
|
||||
_send_keys(pipe, '\x1b') # Esc (ignored)
|
||||
|
||||
_send_keys(pipe, '\x1b[B') # Arrow Down to "No, dismiss"
|
||||
_send_keys(pipe, '\r') # Enter
|
||||
|
||||
result = fut.result(timeout=2.0)
|
||||
assert result == UserConfirmation.REJECT
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Tests for main entry point functionality."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands_cli import simple_main
|
||||
|
||||
|
||||
class TestMainEntryPoint:
|
||||
"""Test the main entry point behavior."""
|
||||
|
||||
@patch('openhands_cli.agent_chat.setup_agent')
|
||||
@patch('openhands_cli.agent_chat.ConversationRunner')
|
||||
@patch('openhands_cli.agent_chat.PromptSession')
|
||||
def test_main_starts_agent_chat_directly(
|
||||
self, mock_prompt_session: MagicMock, mock_runner: MagicMock, mock_setup_agent: MagicMock
|
||||
) -> None:
|
||||
"""Test that main() starts agent chat directly when setup succeeds."""
|
||||
# Mock setup_agent to return a valid conversation
|
||||
mock_conversation = MagicMock()
|
||||
mock_setup_agent.return_value = mock_conversation
|
||||
|
||||
# Mock prompt session to raise KeyboardInterrupt to exit the loop
|
||||
mock_prompt_session.return_value.prompt.side_effect = KeyboardInterrupt()
|
||||
|
||||
# Should complete without raising an exception (graceful exit)
|
||||
simple_main.main()
|
||||
|
||||
# Should call setup_agent
|
||||
mock_setup_agent.assert_called_once()
|
||||
|
||||
@patch('openhands_cli.simple_main.run_cli_entry')
|
||||
def test_main_handles_import_error(self, mock_run_agent_chat: MagicMock) -> None:
|
||||
"""Test that main() handles ImportError gracefully."""
|
||||
mock_run_agent_chat.side_effect = ImportError('Missing dependency')
|
||||
|
||||
# Should raise ImportError (re-raised after handling)
|
||||
with pytest.raises(ImportError) as exc_info:
|
||||
simple_main.main()
|
||||
|
||||
assert str(exc_info.value) == 'Missing dependency'
|
||||
|
||||
@patch('openhands_cli.agent_chat.setup_agent')
|
||||
@patch('openhands_cli.agent_chat.ConversationRunner')
|
||||
@patch('openhands_cli.agent_chat.PromptSession')
|
||||
def test_main_handles_keyboard_interrupt(
|
||||
self, mock_prompt_session: MagicMock, mock_runner: MagicMock, mock_setup_agent: MagicMock
|
||||
) -> None:
|
||||
"""Test that main() handles KeyboardInterrupt gracefully."""
|
||||
# Mock setup_agent to return a valid conversation
|
||||
mock_conversation = MagicMock()
|
||||
mock_setup_agent.return_value = mock_conversation
|
||||
|
||||
# Mock prompt session to raise KeyboardInterrupt
|
||||
mock_prompt_session.return_value.prompt.side_effect = KeyboardInterrupt()
|
||||
|
||||
# Should complete without raising an exception (graceful exit)
|
||||
simple_main.main()
|
||||
|
||||
@patch('openhands_cli.agent_chat.setup_agent')
|
||||
@patch('openhands_cli.agent_chat.ConversationRunner')
|
||||
@patch('openhands_cli.agent_chat.PromptSession')
|
||||
def test_main_handles_eof_error(
|
||||
self, mock_prompt_session: MagicMock, mock_runner: MagicMock, mock_setup_agent: MagicMock
|
||||
) -> None:
|
||||
"""Test that main() handles EOFError gracefully."""
|
||||
# Mock setup_agent to return a valid conversation
|
||||
mock_conversation = MagicMock()
|
||||
mock_setup_agent.return_value = mock_conversation
|
||||
|
||||
# Mock prompt session to raise EOFError
|
||||
mock_prompt_session.return_value.prompt.side_effect = EOFError()
|
||||
|
||||
# Should complete without raising an exception (graceful exit)
|
||||
simple_main.main()
|
||||
|
||||
@patch('openhands_cli.simple_main.run_cli_entry')
|
||||
def test_main_handles_general_exception(
|
||||
self, mock_run_agent_chat: MagicMock
|
||||
) -> None:
|
||||
"""Test that main() handles general exceptions."""
|
||||
mock_run_agent_chat.side_effect = Exception('Unexpected error')
|
||||
|
||||
# Should raise Exception (re-raised after handling)
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
simple_main.main()
|
||||
|
||||
assert str(exc_info.value) == 'Unexpected error'
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for pause listener in OpenHands CLI.
|
||||
"""
|
||||
|
||||
import time
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from openhands.sdk import Conversation
|
||||
from prompt_toolkit.input.defaults import create_pipe_input
|
||||
|
||||
from openhands_cli.listeners.pause_listener import PauseListener, pause_listener
|
||||
|
||||
|
||||
class TestPauseListener:
|
||||
"""Test suite for PauseListener class."""
|
||||
|
||||
def test_pause_listener_stop(self) -> None:
|
||||
"""Test PauseListener stop functionality."""
|
||||
mock_callback = MagicMock()
|
||||
listener = PauseListener(on_pause=mock_callback)
|
||||
|
||||
listener.start()
|
||||
|
||||
# Initially not paused
|
||||
assert not listener.is_paused()
|
||||
assert listener.is_alive()
|
||||
|
||||
# Stop the listener
|
||||
listener.stop()
|
||||
|
||||
# Listner was shutdown not paused
|
||||
assert not listener.is_paused()
|
||||
assert listener.is_stopped()
|
||||
|
||||
def test_pause_listener_context_manager(self) -> None:
|
||||
"""Test pause_listener context manager."""
|
||||
mock_conversation = MagicMock(spec=Conversation)
|
||||
|
||||
with create_pipe_input() as pipe:
|
||||
with pause_listener(mock_conversation, pipe) as listener:
|
||||
assert isinstance(listener, PauseListener)
|
||||
assert listener.on_pause == mock_conversation.pause
|
||||
# Listener should be started (daemon thread)
|
||||
assert listener.is_alive()
|
||||
assert not listener.is_paused()
|
||||
pipe.send_text('\x10') # Ctrl-P
|
||||
time.sleep(0.1)
|
||||
assert listener.is_paused()
|
||||
|
||||
assert listener.is_stopped()
|
||||
assert not listener.is_alive()
|
||||
@@ -0,0 +1,196 @@
|
||||
"""
|
||||
Performance tests for the OpenHands CLI.
|
||||
|
||||
These tests ensure that the CLI startup and shutdown times remain fast.
|
||||
"""
|
||||
|
||||
import time
|
||||
import subprocess
|
||||
import sys
|
||||
import signal
|
||||
import os
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TestCLIPerformance:
|
||||
"""Test CLI performance characteristics."""
|
||||
|
||||
def test_help_performance(self):
|
||||
"""Test that --help is fast (< 0.5s)."""
|
||||
env = os.environ.copy()
|
||||
env['PYTHONPATH'] = str(Path(__file__).parent.parent)
|
||||
|
||||
start_time = time.time()
|
||||
result = subprocess.run([
|
||||
sys.executable, '-m', 'openhands_cli.simple_main', '--help'
|
||||
], capture_output=True, text=True, env=env, timeout=10)
|
||||
help_time = time.time() - start_time
|
||||
|
||||
assert result.returncode == 0, f"--help failed: {result.stderr}"
|
||||
assert help_time < 0.5, f"--help took {help_time:.3f}s, should be < 0.5s"
|
||||
assert "usage:" in result.stdout.lower(), "Help output should contain usage information"
|
||||
|
||||
def test_import_performance(self):
|
||||
"""Test that importing the main module is fast (< 0.1s)."""
|
||||
start_time = time.time()
|
||||
|
||||
# Import in a subprocess to avoid affecting other tests
|
||||
result = subprocess.run([
|
||||
sys.executable, '-c',
|
||||
'import openhands_cli.simple_main'
|
||||
], capture_output=True, text=True,
|
||||
env={'PYTHONPATH': str(Path(__file__).parent.parent)},
|
||||
timeout=5)
|
||||
|
||||
import_time = time.time() - start_time
|
||||
|
||||
assert result.returncode == 0, f"Import failed: {result.stderr}"
|
||||
assert import_time < 0.1, f"Import took {import_time:.3f}s, should be < 0.1s"
|
||||
|
||||
def test_shutdown_performance(self):
|
||||
"""Test that CLI shutdown is fast (< 0.2s)."""
|
||||
env = os.environ.copy()
|
||||
env['PYTHONPATH'] = str(Path(__file__).parent.parent)
|
||||
|
||||
# Start the CLI process
|
||||
proc = subprocess.Popen([
|
||||
sys.executable, '-m', 'openhands_cli.simple_main'
|
||||
],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=env,
|
||||
text=True
|
||||
)
|
||||
|
||||
# Give it a moment to start up
|
||||
time.sleep(0.1)
|
||||
|
||||
# Send SIGINT to trigger shutdown
|
||||
shutdown_start = time.time()
|
||||
proc.send_signal(signal.SIGINT)
|
||||
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
shutdown_time = time.time() - shutdown_start
|
||||
|
||||
assert shutdown_time < 0.2, f"Shutdown took {shutdown_time:.3f}s, should be < 0.2s"
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
pytest.fail("Process didn't shut down within timeout")
|
||||
|
||||
def test_lazy_loading_effectiveness(self):
|
||||
"""Test that lazy loading prevents heavy modules from being imported early."""
|
||||
# Test in subprocess to avoid affecting other tests
|
||||
result = subprocess.run([
|
||||
sys.executable, '-c', '''
|
||||
import sys
|
||||
modules_before = set(sys.modules.keys())
|
||||
|
||||
import openhands_cli.simple_main
|
||||
|
||||
modules_after = set(sys.modules.keys())
|
||||
new_modules = modules_after - modules_before
|
||||
|
||||
# Check that heavy modules are not loaded
|
||||
heavy_modules = [
|
||||
"openhands.sdk",
|
||||
"prompt_toolkit.application",
|
||||
"prompt_toolkit.shortcuts",
|
||||
]
|
||||
|
||||
loaded_heavy = [mod for mod in heavy_modules if any(mod in m for m in new_modules)]
|
||||
|
||||
if loaded_heavy:
|
||||
print(f"HEAVY_MODULES_LOADED: {loaded_heavy}")
|
||||
exit(1)
|
||||
else:
|
||||
print("LAZY_LOADING_OK")
|
||||
exit(0)
|
||||
'''
|
||||
], capture_output=True, text=True,
|
||||
env={'PYTHONPATH': str(Path(__file__).parent.parent)},
|
||||
timeout=5)
|
||||
|
||||
assert result.returncode == 0, f"Lazy loading test failed: {result.stdout}"
|
||||
assert "LAZY_LOADING_OK" in result.stdout, "Lazy loading should prevent heavy module imports"
|
||||
|
||||
def test_startup_performance(self):
|
||||
"""Test that CLI startup is reasonable (< 1.0s)."""
|
||||
env = os.environ.copy()
|
||||
env['PYTHONPATH'] = str(Path(__file__).parent.parent)
|
||||
|
||||
start_time = time.time()
|
||||
proc = subprocess.Popen([
|
||||
sys.executable, '-m', 'openhands_cli.simple_main'
|
||||
],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=env,
|
||||
text=True
|
||||
)
|
||||
|
||||
# Give it a moment to start up
|
||||
time.sleep(0.1)
|
||||
startup_time = time.time() - start_time
|
||||
|
||||
# Clean up
|
||||
proc.send_signal(signal.SIGINT)
|
||||
try:
|
||||
proc.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
|
||||
assert startup_time < 1.0, f"Startup took {startup_time:.3f}s, should be < 1.0s"
|
||||
|
||||
|
||||
class TestPerformanceRegression:
|
||||
"""Test for performance regressions."""
|
||||
|
||||
def test_shutdown_time_regression(self):
|
||||
"""Ensure shutdown time doesn't regress beyond acceptable limits."""
|
||||
env = os.environ.copy()
|
||||
env['PYTHONPATH'] = str(Path(__file__).parent.parent)
|
||||
|
||||
# Test multiple times to get consistent results
|
||||
shutdown_times = []
|
||||
|
||||
for _ in range(3):
|
||||
proc = subprocess.Popen([
|
||||
sys.executable, '-m', 'openhands_cli.simple_main'
|
||||
],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=env,
|
||||
text=True
|
||||
)
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
shutdown_start = time.time()
|
||||
proc.send_signal(signal.SIGINT)
|
||||
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
shutdown_time = time.time() - shutdown_start
|
||||
shutdown_times.append(shutdown_time)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
pytest.fail("Process didn't shut down within timeout")
|
||||
|
||||
avg_shutdown_time = sum(shutdown_times) / len(shutdown_times)
|
||||
max_shutdown_time = max(shutdown_times)
|
||||
|
||||
# Ensure average shutdown time is good
|
||||
assert avg_shutdown_time < 0.15, f"Average shutdown time {avg_shutdown_time:.3f}s should be < 0.15s"
|
||||
|
||||
# Ensure no single shutdown takes too long
|
||||
assert max_shutdown_time < 0.3, f"Max shutdown time {max_shutdown_time:.3f}s should be < 0.3s"
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Core Settings Logic tests
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from prompt_toolkit.completion import FuzzyWordCompleter
|
||||
from prompt_toolkit.validation import ValidationError
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands_cli.user_actions.settings_action import (
|
||||
NonEmptyValueValidator,
|
||||
SettingsType,
|
||||
choose_llm_model,
|
||||
choose_llm_provider,
|
||||
prompt_api_key,
|
||||
settings_type_confirmation,
|
||||
)
|
||||
|
||||
|
||||
# -------------------------------
|
||||
# Settings type selection
|
||||
# -------------------------------
|
||||
|
||||
def test_settings_type_selection(mock_cli_interactions: Any) -> None:
|
||||
mocks = mock_cli_interactions
|
||||
|
||||
# Basic
|
||||
mocks.cli_confirm.return_value = 0
|
||||
assert settings_type_confirmation() == SettingsType.BASIC
|
||||
|
||||
# Cancel/Go back
|
||||
mocks.cli_confirm.return_value = 2
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
settings_type_confirmation()
|
||||
|
||||
|
||||
# -------------------------------
|
||||
# Provider selection flows
|
||||
# -------------------------------
|
||||
|
||||
def test_provider_selection_with_predefined_options(mock_verified_models: Any, mock_cli_interactions: Any) -> None:
|
||||
from openhands_cli.tui.utils import StepCounter
|
||||
mocks = mock_cli_interactions
|
||||
# first option among display_options is index 0
|
||||
mocks.cli_confirm.return_value = 0
|
||||
step_counter = StepCounter(1)
|
||||
result = choose_llm_provider(step_counter)
|
||||
assert result == 'openai'
|
||||
|
||||
|
||||
def test_provider_selection_with_custom_input(mock_verified_models: Any, mock_cli_interactions: Any) -> None:
|
||||
from openhands_cli.tui.utils import StepCounter
|
||||
mocks = mock_cli_interactions
|
||||
# Due to overlapping provider keys between VERIFIED and UNVERIFIED in fixture,
|
||||
# display_options contains 4 providers (with duplicates) + alternate at index 4
|
||||
mocks.cli_confirm.return_value = 4
|
||||
mocks.cli_text_input.return_value = "my-provider"
|
||||
step_counter = StepCounter(1)
|
||||
result = choose_llm_provider(step_counter)
|
||||
assert result == "my-provider"
|
||||
|
||||
# Verify fuzzy completer passed
|
||||
_, kwargs = mocks.cli_text_input.call_args
|
||||
assert isinstance(kwargs["completer"], FuzzyWordCompleter)
|
||||
|
||||
|
||||
# -------------------------------
|
||||
# Model selection flows
|
||||
# -------------------------------
|
||||
|
||||
def test_model_selection_flows(mock_verified_models: Any, mock_cli_interactions: Any) -> None:
|
||||
from openhands_cli.tui.utils import StepCounter
|
||||
mocks = mock_cli_interactions
|
||||
|
||||
# Direct pick from predefined list
|
||||
mocks.cli_confirm.return_value = 0
|
||||
step_counter = StepCounter(1)
|
||||
result = choose_llm_model(step_counter, "openai")
|
||||
assert result in ["gpt-4o"]
|
||||
|
||||
# Choose custom model via input
|
||||
mocks.cli_confirm.return_value = 4 # for provider with >=4 models this would be alt; in our data openai has 3 -> alt index is 3
|
||||
mocks.cli_text_input.return_value = "custom-model"
|
||||
# Adjust to actual alt index produced by code (len(models[:4]) yields 3 + 1 alt -> index 3)
|
||||
mocks.cli_confirm.return_value = 3
|
||||
step_counter2 = StepCounter(1)
|
||||
result2 = choose_llm_model(step_counter2, "openai")
|
||||
assert result2 == "custom-model"
|
||||
|
||||
|
||||
# -------------------------------
|
||||
# API key validation and prompting
|
||||
# -------------------------------
|
||||
|
||||
def test_api_key_validation_and_prompting(mock_cli_interactions: Any) -> None:
|
||||
# Validator standalone
|
||||
validator = NonEmptyValueValidator()
|
||||
doc = MagicMock(); doc.text = "sk-abc"
|
||||
validator.validate(doc)
|
||||
|
||||
doc_empty = MagicMock(); doc_empty.text = ""
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate(doc_empty)
|
||||
|
||||
# Prompting for new key enforces validator
|
||||
from openhands_cli.tui.utils import StepCounter
|
||||
mocks = mock_cli_interactions
|
||||
mocks.cli_text_input.return_value = "sk-new"
|
||||
step_counter = StepCounter(1)
|
||||
new_key = prompt_api_key(step_counter, 'provider')
|
||||
assert new_key == "sk-new"
|
||||
assert mocks.cli_text_input.call_args[1]["validator"] is not None
|
||||
|
||||
# Prompting with existing key shows mask and no validator
|
||||
mocks.cli_text_input.reset_mock()
|
||||
mocks.cli_text_input.return_value = "sk-updated"
|
||||
existing = SecretStr("sk-existing-123")
|
||||
step_counter2 = StepCounter(1)
|
||||
updated = prompt_api_key(step_counter2, 'provider', existing)
|
||||
assert updated == "sk-updated"
|
||||
assert mocks.cli_text_input.call_args[1]["validator"] is None
|
||||
assert "sk-***" in mocks.cli_text_input.call_args[0][0]
|
||||
@@ -0,0 +1,133 @@
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
from openhands_cli.tui.settings.settings_screen import SettingsScreen
|
||||
from pathlib import Path
|
||||
|
||||
from openhands.sdk import LLM, Conversation, LocalFileStore
|
||||
from openhands.sdk.preset.default import get_default_agent
|
||||
from openhands_cli.tui.settings.store import AgentStore
|
||||
from openhands_cli.user_actions.settings_action import SettingsType
|
||||
from pydantic import SecretStr
|
||||
import pytest
|
||||
|
||||
def read_json(path: Path) -> dict:
|
||||
with open(path, "r") as f:
|
||||
return json.load(f)
|
||||
|
||||
def make_screen_with_conversation(model="openai/gpt-4o-mini", api_key="sk-xyz"):
|
||||
llm = LLM(model=model, api_key=SecretStr(api_key))
|
||||
# Conversation(agent) signature may vary across versions; adapt if needed:
|
||||
from openhands.sdk.agent import Agent
|
||||
agent = Agent(llm=llm, tools=[])
|
||||
conv = Conversation(agent)
|
||||
return SettingsScreen(conversation=conv)
|
||||
|
||||
def seed_file(path: Path, model: str = "openai/gpt-4o-mini", api_key: str = "sk-old"):
|
||||
store = AgentStore()
|
||||
store.file_store = LocalFileStore(root=str(path))
|
||||
agent = get_default_agent(
|
||||
llm=LLM(model=model, api_key=SecretStr(api_key)),
|
||||
working_dir=str(path)
|
||||
)
|
||||
store.save(agent)
|
||||
|
||||
|
||||
def test_llm_settings_save_and_load(tmp_path: Path):
|
||||
"""Test that the settings screen can save basic LLM settings."""
|
||||
screen = SettingsScreen(conversation=None)
|
||||
|
||||
# Mock the spec store to verify settings are saved
|
||||
with patch.object(screen.agent_store, 'save') as mock_save:
|
||||
screen._save_llm_settings(
|
||||
model="openai/gpt-4o-mini",
|
||||
api_key="sk-test-123"
|
||||
)
|
||||
|
||||
# Verify that save was called
|
||||
mock_save.assert_called_once()
|
||||
|
||||
# Get the agent spec that was saved
|
||||
saved_spec = mock_save.call_args[0][0]
|
||||
assert saved_spec.llm.model == "openai/gpt-4o-mini"
|
||||
assert saved_spec.llm.api_key.get_secret_value() == "sk-test-123"
|
||||
|
||||
|
||||
def test_first_time_setup_workflow(tmp_path: Path):
|
||||
"""Test that the basic settings workflow completes without errors."""
|
||||
screen = SettingsScreen()
|
||||
|
||||
with (
|
||||
patch("openhands_cli.tui.settings.settings_screen.settings_type_confirmation", return_value=SettingsType.BASIC),
|
||||
patch("openhands_cli.tui.settings.settings_screen.choose_llm_provider", return_value="openai"),
|
||||
patch("openhands_cli.tui.settings.settings_screen.choose_llm_model", return_value="gpt-4o-mini"),
|
||||
patch("openhands_cli.tui.settings.settings_screen.prompt_api_key", return_value="sk-first"),
|
||||
patch("openhands_cli.tui.settings.settings_screen.save_settings_confirmation", return_value=True),
|
||||
):
|
||||
# The workflow should complete without errors
|
||||
screen.configure_settings()
|
||||
|
||||
# Since the current implementation doesn't save to file, we just verify the workflow completed
|
||||
assert True # If we get here, the workflow completed successfully
|
||||
|
||||
|
||||
def test_update_existing_settings_workflow(tmp_path: Path):
|
||||
"""Test that the settings update workflow completes without errors."""
|
||||
settings_path = tmp_path / "agent_settings.json"
|
||||
seed_file(settings_path, model="openai/gpt-4o-mini", api_key="sk-old")
|
||||
screen = make_screen_with_conversation(model="openai/gpt-4o-mini", api_key="sk-old")
|
||||
|
||||
with (
|
||||
patch("openhands_cli.tui.settings.settings_screen.settings_type_confirmation", return_value=SettingsType.BASIC),
|
||||
patch("openhands_cli.tui.settings.settings_screen.choose_llm_provider", return_value="anthropic"),
|
||||
patch("openhands_cli.tui.settings.settings_screen.choose_llm_model", return_value="claude-3-5-sonnet"),
|
||||
patch("openhands_cli.tui.settings.settings_screen.prompt_api_key", return_value="sk-updated"),
|
||||
patch("openhands_cli.tui.settings.settings_screen.save_settings_confirmation", return_value=True),
|
||||
):
|
||||
# The workflow should complete without errors
|
||||
screen.configure_settings()
|
||||
|
||||
# Since the current implementation doesn't save to file, we just verify the workflow completed
|
||||
assert True # If we get here, the workflow completed successfully
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"step_to_cancel",
|
||||
["type", "provider", "model", "apikey", "save"],
|
||||
)
|
||||
def test_workflow_cancellation_at_each_step(tmp_path: Path, step_to_cancel: str):
|
||||
screen = make_screen_with_conversation()
|
||||
|
||||
# Base happy-path patches
|
||||
patches = {
|
||||
"settings_type_confirmation": MagicMock(return_value=SettingsType.BASIC),
|
||||
"choose_llm_provider": MagicMock(return_value="openai"),
|
||||
"choose_llm_model": MagicMock(return_value="gpt-4o-mini"),
|
||||
"prompt_api_key": MagicMock(return_value="sk-new"),
|
||||
"save_settings_confirmation": MagicMock(return_value=True),
|
||||
}
|
||||
|
||||
# Turn one step into a cancel
|
||||
if step_to_cancel == "type":
|
||||
patches["settings_type_confirmation"].side_effect = KeyboardInterrupt()
|
||||
elif step_to_cancel == "provider":
|
||||
patches["choose_llm_provider"].side_effect = KeyboardInterrupt()
|
||||
elif step_to_cancel == "model":
|
||||
patches["choose_llm_model"].side_effect = KeyboardInterrupt()
|
||||
elif step_to_cancel == "apikey":
|
||||
patches["prompt_api_key"].side_effect = KeyboardInterrupt()
|
||||
elif step_to_cancel == "save":
|
||||
patches["save_settings_confirmation"].side_effect = KeyboardInterrupt()
|
||||
|
||||
with (
|
||||
patch("openhands_cli.tui.settings.settings_screen.settings_type_confirmation", patches["settings_type_confirmation"]),
|
||||
patch("openhands_cli.tui.settings.settings_screen.choose_llm_provider", patches["choose_llm_provider"]),
|
||||
patch("openhands_cli.tui.settings.settings_screen.choose_llm_model", patches["choose_llm_model"]),
|
||||
patch("openhands_cli.tui.settings.settings_screen.prompt_api_key", patches["prompt_api_key"]),
|
||||
patch("openhands_cli.tui.settings.settings_screen.save_settings_confirmation", patches["save_settings_confirmation"]),
|
||||
patch.object(screen.agent_store, 'save') as mock_save,
|
||||
):
|
||||
screen.configure_settings()
|
||||
|
||||
# No settings should be saved on cancel
|
||||
mock_save.assert_not_called()
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Tests for TUI functionality."""
|
||||
|
||||
from prompt_toolkit.completion import CompleteEvent
|
||||
from prompt_toolkit.document import Document
|
||||
|
||||
from openhands_cli.tui.tui import COMMANDS, CommandCompleter
|
||||
|
||||
|
||||
class TestCommandCompleter:
|
||||
"""Test the CommandCompleter class."""
|
||||
|
||||
def test_command_completion_with_slash(self) -> None:
|
||||
"""Test that commands are completed when starting with /."""
|
||||
completer = CommandCompleter()
|
||||
document = Document('/')
|
||||
completions = list(completer.get_completions(document, CompleteEvent()))
|
||||
|
||||
# Should return all available commands
|
||||
assert len(completions) == len(COMMANDS)
|
||||
|
||||
# Check that all commands are included
|
||||
completion_texts = [c.text for c in completions]
|
||||
for command in COMMANDS.keys():
|
||||
assert command in completion_texts
|
||||
|
||||
def test_command_completion_partial_match(self) -> None:
|
||||
"""Test that partial command matches work correctly."""
|
||||
completer = CommandCompleter()
|
||||
document = Document('/ex')
|
||||
completions = list(completer.get_completions(document, CompleteEvent()))
|
||||
|
||||
# Should return only /exit
|
||||
assert len(completions) == 1
|
||||
assert completions[0].text == '/exit'
|
||||
# display_meta is a FormattedText object, so we need to check its content
|
||||
# Extract the text from FormattedText
|
||||
meta_text = completions[0].display_meta
|
||||
if hasattr(meta_text, '_formatted_text'):
|
||||
# Extract text from FormattedText
|
||||
text_content = ''.join([item[1] for item in meta_text._formatted_text])
|
||||
else:
|
||||
text_content = str(meta_text)
|
||||
assert COMMANDS['/exit'] in text_content
|
||||
|
||||
def test_command_completion_no_slash(self) -> None:
|
||||
"""Test that no completions are returned without /."""
|
||||
completer = CommandCompleter()
|
||||
document = Document('help')
|
||||
completions = list(completer.get_completions(document, CompleteEvent()))
|
||||
|
||||
# Should return no completions
|
||||
assert len(completions) == 0
|
||||
|
||||
def test_command_completion_no_match(self) -> None:
|
||||
"""Test that no completions are returned for non-matching commands."""
|
||||
completer = CommandCompleter()
|
||||
document = Document('/nonexistent')
|
||||
completions = list(completer.get_completions(document, CompleteEvent()))
|
||||
|
||||
# Should return no completions
|
||||
assert len(completions) == 0
|
||||
|
||||
def test_command_completion_styling(self) -> None:
|
||||
"""Test that completions have proper styling."""
|
||||
completer = CommandCompleter()
|
||||
document = Document('/help')
|
||||
completions = list(completer.get_completions(document, CompleteEvent()))
|
||||
|
||||
assert len(completions) == 1
|
||||
completion = completions[0]
|
||||
assert completion.style == 'bg:ansidarkgray fg:gold'
|
||||
assert completion.start_position == -5 # Length of "/help"
|
||||
|
||||
|
||||
def test_commands_dict() -> None:
|
||||
"""Test that COMMANDS dictionary contains expected commands."""
|
||||
expected_commands = {
|
||||
'/exit',
|
||||
'/help',
|
||||
'/clear',
|
||||
'/status',
|
||||
'/confirm',
|
||||
'/new',
|
||||
'/resume',
|
||||
'/settings',
|
||||
}
|
||||
assert set(COMMANDS.keys()) == expected_commands
|
||||
|
||||
# Check that all commands have descriptions
|
||||
for command, description in COMMANDS.items():
|
||||
assert isinstance(command, str)
|
||||
assert command.startswith('/')
|
||||
assert isinstance(description, str)
|
||||
assert len(description) > 0
|
||||
@@ -0,0 +1,9 @@
|
||||
import time
|
||||
|
||||
from prompt_toolkit.input import PipeInput
|
||||
|
||||
|
||||
def _send_keys(pipe: PipeInput, text: str, delay: float = 0.05) -> None:
|
||||
"""Helper: small delay then send keys to avoid race with app.run()."""
|
||||
time.sleep(delay)
|
||||
pipe.send_text(text)
|
||||
Generated
+5404
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,9 @@ class CodeActAgent(Agent):
|
||||
self.condenser = Condenser.from_config(self.config.condenser, llm_registry)
|
||||
logger.debug(f'Using condenser: {type(self.condenser)}')
|
||||
|
||||
# Override with router if needed
|
||||
self.llm = self.llm_registry.get_router(self.config)
|
||||
|
||||
@property
|
||||
def prompt_manager(self) -> PromptManager:
|
||||
if self._prompt_manager is None:
|
||||
|
||||
@@ -479,11 +479,10 @@ async def modify_llm_settings_basic(
|
||||
settings = Settings()
|
||||
|
||||
settings.llm_model = f'{provider}{organized_models[provider]["separator"]}{model}'
|
||||
settings.llm_api_key = SecretStr(api_key)
|
||||
settings.llm_api_key = SecretStr(api_key) if api_key and api_key.strip() else None
|
||||
settings.llm_base_url = None
|
||||
settings.agent = OH_DEFAULT_AGENT
|
||||
settings.enable_default_condenser = True
|
||||
|
||||
await settings_store.store(settings)
|
||||
|
||||
|
||||
@@ -608,12 +607,11 @@ async def modify_llm_settings_advanced(
|
||||
settings = Settings()
|
||||
|
||||
settings.llm_model = custom_model
|
||||
settings.llm_api_key = SecretStr(api_key)
|
||||
settings.llm_api_key = SecretStr(api_key) if api_key and api_key.strip() else None
|
||||
settings.llm_base_url = base_url
|
||||
settings.agent = agent
|
||||
settings.confirmation_mode = enable_confirmation_mode
|
||||
settings.enable_default_condenser = enable_memory_condensation
|
||||
|
||||
await settings_store.store(settings)
|
||||
|
||||
|
||||
@@ -685,5 +683,4 @@ async def modify_search_api_settings(
|
||||
settings = Settings()
|
||||
|
||||
settings.search_api_key = SecretStr(search_api_key) if search_api_key else None
|
||||
|
||||
await settings_store.store(settings)
|
||||
|
||||
@@ -13,6 +13,7 @@ from openhands.core.config.config_utils import (
|
||||
from openhands.core.config.extended_config import ExtendedConfig
|
||||
from openhands.core.config.llm_config import LLMConfig
|
||||
from openhands.core.config.mcp_config import MCPConfig
|
||||
from openhands.core.config.model_routing_config import ModelRoutingConfig
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.core.config.sandbox_config import SandboxConfig
|
||||
from openhands.core.config.security_config import SecurityConfig
|
||||
@@ -20,6 +21,8 @@ from openhands.core.config.utils import (
|
||||
finalize_config,
|
||||
get_agent_config_arg,
|
||||
get_llm_config_arg,
|
||||
get_llms_for_routing_config,
|
||||
get_model_routing_config_arg,
|
||||
load_from_env,
|
||||
load_from_toml,
|
||||
load_openhands_config,
|
||||
@@ -37,6 +40,7 @@ __all__ = [
|
||||
'LLMConfig',
|
||||
'SandboxConfig',
|
||||
'SecurityConfig',
|
||||
'ModelRoutingConfig',
|
||||
'ExtendedConfig',
|
||||
'load_openhands_config',
|
||||
'load_from_env',
|
||||
@@ -50,4 +54,6 @@ __all__ = [
|
||||
'get_evaluation_parser',
|
||||
'parse_arguments',
|
||||
'setup_config_from_args',
|
||||
'get_model_routing_config_arg',
|
||||
'get_llms_for_routing_config',
|
||||
]
|
||||
|
||||
@@ -7,6 +7,7 @@ from openhands.core.config.condenser_config import (
|
||||
ConversationWindowCondenserConfig,
|
||||
)
|
||||
from openhands.core.config.extended_config import ExtendedConfig
|
||||
from openhands.core.config.model_routing_config import ModelRoutingConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
@@ -57,6 +58,8 @@ class AgentConfig(BaseModel):
|
||||
# handled.
|
||||
default_factory=lambda: ConversationWindowCondenserConfig()
|
||||
)
|
||||
model_routing: ModelRoutingConfig = Field(default_factory=ModelRoutingConfig)
|
||||
"""Model routing configuration settings."""
|
||||
extended: ExtendedConfig = Field(default_factory=lambda: ExtendedConfig({}))
|
||||
"""Extended configuration for the agent."""
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ class LLMConfig(BaseModel):
|
||||
reasoning_effort: The effort to put into reasoning. This is a string that can be one of 'low', 'medium', 'high', or 'none'. Can apply to all reasoning models.
|
||||
seed: The seed to use for the LLM.
|
||||
safety_settings: Safety settings for models that support them (like Mistral AI and Gemini).
|
||||
for_routing: Whether this LLM is used for routing. This is set to True for models used in conjunction with the main LLM in the model routing feature.
|
||||
"""
|
||||
|
||||
model: str = Field(default='claude-sonnet-4-20250514')
|
||||
@@ -92,6 +93,7 @@ class LLMConfig(BaseModel):
|
||||
default=None,
|
||||
description='Safety settings for models that support them (like Mistral AI and Gemini)',
|
||||
)
|
||||
for_routing: bool = Field(default=False)
|
||||
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
|
||||
from openhands.core.config.llm_config import LLMConfig
|
||||
|
||||
|
||||
class ModelRoutingConfig(BaseModel):
|
||||
"""Configuration for model routing.
|
||||
|
||||
Attributes:
|
||||
router_name (str): The name of the router to use. Default is 'noop_router'.
|
||||
llms_for_routing (dict[str, LLMConfig]): A dictionary mapping config names of LLMs for routing to their configurations.
|
||||
"""
|
||||
|
||||
router_name: str = Field(default='noop_router')
|
||||
llms_for_routing: dict[str, LLMConfig] = Field(default_factory=dict)
|
||||
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
|
||||
@classmethod
|
||||
def from_toml_section(cls, data: dict) -> dict[str, 'ModelRoutingConfig']:
|
||||
"""
|
||||
Create a mapping of ModelRoutingConfig instances from a toml dictionary representing the [model_routing] section.
|
||||
|
||||
The configuration is built from all keys in data.
|
||||
|
||||
Returns:
|
||||
dict[str, ModelRoutingConfig]: A mapping where the key "model_routing" corresponds to the [model_routing] configuration
|
||||
"""
|
||||
|
||||
# Initialize the result mapping
|
||||
model_routing_mapping: dict[str, ModelRoutingConfig] = {}
|
||||
|
||||
# Try to create the configuration instance
|
||||
try:
|
||||
model_routing_mapping['model_routing'] = cls.model_validate(data)
|
||||
except ValidationError as e:
|
||||
raise ValueError(f'Invalid model routing configuration: {e}')
|
||||
|
||||
return model_routing_mapping
|
||||
@@ -30,6 +30,7 @@ class OpenHandsConfig(BaseModel):
|
||||
The default configuration is stored under the 'agent' key.
|
||||
default_agent: Name of the default agent to use.
|
||||
sandbox: Sandbox configuration settings.
|
||||
security: Security configuration settings.
|
||||
runtime: Runtime environment identifier.
|
||||
file_store: Type of file store to use.
|
||||
file_store_path: Path to the file store.
|
||||
|
||||
@@ -25,6 +25,7 @@ from openhands.core.config.extended_config import ExtendedConfig
|
||||
from openhands.core.config.kubernetes_config import KubernetesConfig
|
||||
from openhands.core.config.llm_config import LLMConfig
|
||||
from openhands.core.config.mcp_config import MCPConfig
|
||||
from openhands.core.config.model_routing_config import ModelRoutingConfig
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.core.config.sandbox_config import SandboxConfig
|
||||
from openhands.core.config.security_config import SecurityConfig
|
||||
@@ -225,6 +226,35 @@ def load_from_toml(cfg: OpenHandsConfig, toml_file: str = 'config.toml') -> None
|
||||
# Re-raise ValueError from SecurityConfig.from_toml_section
|
||||
raise ValueError('Error in [security] section in config.toml')
|
||||
|
||||
if 'model_routing' in toml_config:
|
||||
try:
|
||||
model_routing_mapping = ModelRoutingConfig.from_toml_section(
|
||||
toml_config['model_routing']
|
||||
)
|
||||
# We only use the base model routing config for now
|
||||
if 'model_routing' in model_routing_mapping:
|
||||
default_agent_config = cfg.get_agent_config()
|
||||
default_agent_config.model_routing = model_routing_mapping[
|
||||
'model_routing'
|
||||
]
|
||||
|
||||
# Construct the llms_for_routing by filtering llms with for_routing = True
|
||||
llms_for_routing_dict = {}
|
||||
for llm_name, llm_config in cfg.llms.items():
|
||||
if llm_config and llm_config.for_routing:
|
||||
llms_for_routing_dict[llm_name] = llm_config
|
||||
default_agent_config.model_routing.llms_for_routing = (
|
||||
llms_for_routing_dict
|
||||
)
|
||||
|
||||
logger.openhands_logger.debug(
|
||||
'Default model routing configuration loaded from config toml and assigned to default agent'
|
||||
)
|
||||
except (TypeError, KeyError, ValidationError) as e:
|
||||
logger.openhands_logger.warning(
|
||||
f'Cannot parse [model_routing] config from toml, values have not been applied.\nError: {e}'
|
||||
)
|
||||
|
||||
# Process sandbox section if present
|
||||
if 'sandbox' in toml_config:
|
||||
try:
|
||||
@@ -327,6 +357,7 @@ def load_from_toml(cfg: OpenHandsConfig, toml_file: str = 'config.toml') -> None
|
||||
'condenser',
|
||||
'mcp',
|
||||
'kubernetes',
|
||||
'model_routing',
|
||||
}
|
||||
for key in toml_config:
|
||||
if key.lower() not in known_sections:
|
||||
@@ -559,6 +590,41 @@ def get_llm_config_arg(
|
||||
return None
|
||||
|
||||
|
||||
def get_llms_for_routing_config(toml_file: str = 'config.toml') -> dict[str, LLMConfig]:
|
||||
"""Get the LLMs that are configured for routing from the config file.
|
||||
|
||||
This function will return a dictionary of LLMConfig objects that are configured
|
||||
for routing, i.e., those with `for_routing` set to True.
|
||||
|
||||
Args:
|
||||
toml_file: Path to the configuration file to read from. Defaults to 'config.toml'.
|
||||
|
||||
Returns:
|
||||
dict[str, LLMConfig]: A dictionary of LLMConfig objects for routing.
|
||||
"""
|
||||
llms_for_routing: dict[str, LLMConfig] = {}
|
||||
|
||||
try:
|
||||
with open(toml_file, 'r', encoding='utf-8') as toml_contents:
|
||||
toml_config = toml.load(toml_contents)
|
||||
except FileNotFoundError:
|
||||
return llms_for_routing
|
||||
except toml.TomlDecodeError as e:
|
||||
logger.openhands_logger.error(
|
||||
f'Cannot parse LLM configs from {toml_file}. Exception: {e}'
|
||||
)
|
||||
return llms_for_routing
|
||||
|
||||
llm_configs = LLMConfig.from_toml_section(toml_config.get('llm', {}))
|
||||
|
||||
if llm_configs:
|
||||
for llm_name, llm_config in llm_configs.items():
|
||||
if llm_config.for_routing:
|
||||
llms_for_routing[llm_name] = llm_config
|
||||
|
||||
return llms_for_routing
|
||||
|
||||
|
||||
def get_condenser_config_arg(
|
||||
condenser_config_arg: str, toml_file: str = 'config.toml'
|
||||
) -> CondenserConfig | None:
|
||||
@@ -671,6 +737,50 @@ def get_condenser_config_arg(
|
||||
return None
|
||||
|
||||
|
||||
def get_model_routing_config_arg(toml_file: str = 'config.toml') -> ModelRoutingConfig:
|
||||
"""Get the model routing settings from the config file. We only support the default model routing config [model_routing].
|
||||
|
||||
Args:
|
||||
toml_file: Path to the configuration file to read from. Defaults to 'config.toml'.
|
||||
|
||||
Returns:
|
||||
ModelRoutingConfig: The ModelRoutingConfig object with the settings from the config file, or the object with default values if not found/error.
|
||||
"""
|
||||
logger.openhands_logger.debug(
|
||||
f"Loading model routing config ['model_routing'] from {toml_file}"
|
||||
)
|
||||
default_cfg = ModelRoutingConfig()
|
||||
|
||||
# load the toml file
|
||||
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.error(f'Config file not found: {toml_file}. Error: {e}')
|
||||
return default_cfg
|
||||
except toml.TomlDecodeError as e:
|
||||
logger.openhands_logger.error(
|
||||
f'Cannot parse model routing group [model_routing] from {toml_file}. Exception: {e}'
|
||||
)
|
||||
return default_cfg
|
||||
|
||||
# Update the model routing config with the specified section
|
||||
if 'model_routing' in toml_config:
|
||||
try:
|
||||
model_routing_data = toml_config['model_routing']
|
||||
return ModelRoutingConfig(**model_routing_data)
|
||||
except ValidationError as e:
|
||||
logger.openhands_logger.error(
|
||||
f'Invalid model routing configuration for [model_routing]: {e}'
|
||||
)
|
||||
return default_cfg
|
||||
|
||||
logger.openhands_logger.warning(
|
||||
f'Model routing config section [model_routing] not found in {toml_file}'
|
||||
)
|
||||
return default_cfg
|
||||
|
||||
|
||||
def parse_arguments() -> argparse.Namespace:
|
||||
"""Parse command line arguments."""
|
||||
parser = get_headless_parser()
|
||||
|
||||
@@ -139,7 +139,7 @@ async def run_controller(
|
||||
selected_repository=config.sandbox.selected_repo,
|
||||
repo_directory=repo_directory,
|
||||
conversation_instructions=conversation_instructions,
|
||||
working_dir=config.workspace_mount_path_in_sandbox,
|
||||
working_dir=str(runtime.workspace_root),
|
||||
)
|
||||
|
||||
# Add MCP tools to the agent
|
||||
|
||||
@@ -335,26 +335,6 @@ class ProviderHandler:
|
||||
unique_repos.append(repo)
|
||||
return unique_repos
|
||||
|
||||
def _infer_provider_from_repo_name(self, repo_name: str) -> ProviderType:
|
||||
"""Infer the git provider from repository name or URL.
|
||||
|
||||
Args:
|
||||
repo_name: Repository name or URL
|
||||
|
||||
Returns:
|
||||
Inferred ProviderType, defaults to GitHub if cannot determine
|
||||
"""
|
||||
repo_lower = repo_name.lower()
|
||||
|
||||
# Check for provider domains in the repo name/URL
|
||||
if 'gitlab.com' in repo_lower or 'gitlab' in repo_lower:
|
||||
return ProviderType.GITLAB
|
||||
elif 'bitbucket.org' in repo_lower or 'bitbucket' in repo_lower:
|
||||
return ProviderType.BITBUCKET
|
||||
else:
|
||||
# Default to GitHub for unknown or github.com
|
||||
return ProviderType.GITHUB
|
||||
|
||||
async def set_event_stream_secrets(
|
||||
self,
|
||||
event_stream: EventStream,
|
||||
@@ -639,24 +619,13 @@ class ProviderHandler:
|
||||
Returns:
|
||||
Authenticated git URL if credentials are available, otherwise regular HTTPS URL
|
||||
"""
|
||||
# Initialize variables with defaults
|
||||
provider = self._infer_provider_from_repo_name(repo_name)
|
||||
# Keep the original repo_name as provided by default
|
||||
|
||||
try:
|
||||
repository = await self.verify_repo_provider(repo_name)
|
||||
# Update with verified information if successful
|
||||
provider = repository.git_provider
|
||||
repo_name = repository.full_name
|
||||
except AuthenticationError:
|
||||
raise Exception('Git provider authentication issue when getting remote URL')
|
||||
except Exception as e:
|
||||
# Handle network errors by falling back to public URL
|
||||
logger.warning(
|
||||
f'Repository verification failed (possibly offline): {e}. '
|
||||
f'Using public HTTPS URL for repository: {repo_name}'
|
||||
)
|
||||
# Use the inferred provider and original repo_name (already set above)
|
||||
|
||||
provider = repository.git_provider
|
||||
repo_name = repository.full_name
|
||||
|
||||
domain = self.PROVIDER_DOMAINS[provider]
|
||||
|
||||
|
||||
@@ -108,10 +108,24 @@ class LLMRegistry:
|
||||
def get_active_llm(self) -> LLM:
|
||||
return self.active_agent_llm
|
||||
|
||||
def _set_active_llm(self, service_id) -> None:
|
||||
if service_id not in self.service_to_llm:
|
||||
raise ValueError(f'Unrecognized service ID: {service_id}')
|
||||
self.active_agent_llm = self.service_to_llm[service_id]
|
||||
def get_router(self, agent_config: AgentConfig) -> 'LLM':
|
||||
"""
|
||||
Get a router instance that inherits from LLM.
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
from openhands.llm.router import RouterLLM
|
||||
|
||||
router_name = agent_config.model_routing.router_name
|
||||
|
||||
if router_name == 'noop_router':
|
||||
# Return the main LLM directly (no routing)
|
||||
return self.get_llm_from_agent_config('agent', agent_config)
|
||||
|
||||
return RouterLLM.from_config(
|
||||
agent_config=agent_config,
|
||||
llm_registry=self,
|
||||
retry_listener=self.retry_listner,
|
||||
)
|
||||
|
||||
def subscribe(self, callback: Callable[[RegistryEvent], None]) -> None:
|
||||
self.subscriber = callback
|
||||
|
||||
@@ -122,6 +122,7 @@ SUPPORTS_STOP_WORDS_FALSE_PATTERNS: list[str] = [
|
||||
'o1*',
|
||||
# grok-4 specific model name (basename)
|
||||
'grok-4-0709',
|
||||
'grok-code-fast-1',
|
||||
# DeepSeek R1 family
|
||||
'deepseek-r1-0528*',
|
||||
]
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Model Routing Module
|
||||
|
||||
**⚠️ Experimental Feature**: This module is experimental and under active development.
|
||||
|
||||
## Overview
|
||||
|
||||
Model routing enables OpenHands to switch between different LLM models during a conversation. An example use case is routing between a primary (expensive, multimodal) model and a secondary (cheaper, text-only) model.
|
||||
|
||||
## Available Routers
|
||||
|
||||
- **`noop_router`** (default): No routing, always uses primary LLM
|
||||
- **`multimodal_router`**: A router that switches based on:
|
||||
- Routes to primary model for images or when secondary model's context limit is exceeded
|
||||
- Uses secondary model for text-only requests within its context limit
|
||||
|
||||
## Configuration
|
||||
|
||||
Add to your `config.toml`:
|
||||
|
||||
```toml
|
||||
# Main LLM (primary model)
|
||||
[llm]
|
||||
model = "claude-sonnet-4"
|
||||
api_key = "your-api-key"
|
||||
|
||||
# Secondary model for routing
|
||||
[llm.secondary_model]
|
||||
model = "kimi-k2"
|
||||
api_key = "your-api-key"
|
||||
for_routing = true
|
||||
|
||||
# Enable routing
|
||||
[model_routing]
|
||||
router_name = "multimodal_router"
|
||||
```
|
||||
|
||||
## Extending
|
||||
|
||||
Create custom routers by inheriting from `BaseRouter` and implementing `set_active_llm()`. Register in `ROUTER_REGISTRY`.
|
||||
@@ -0,0 +1,8 @@
|
||||
from openhands.llm.router.base import ROUTER_LLM_REGISTRY, RouterLLM
|
||||
from openhands.llm.router.rule_based.impl import MultimodalRouter
|
||||
|
||||
__all__ = [
|
||||
'RouterLLM',
|
||||
'ROUTER_LLM_REGISTRY',
|
||||
'MultimodalRouter',
|
||||
]
|
||||
@@ -0,0 +1,164 @@
|
||||
import copy
|
||||
from abc import abstractmethod
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
from openhands.core.config import AgentConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.message import Message
|
||||
from openhands.llm.llm import LLM
|
||||
from openhands.llm.metrics import Metrics
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
|
||||
ROUTER_LLM_REGISTRY: dict[str, type['RouterLLM']] = {}
|
||||
|
||||
|
||||
class RouterLLM(LLM):
|
||||
"""
|
||||
Base class for multiple LLM acting as a unified LLM.
|
||||
|
||||
This class provides a foundation for implementing model routing by inheriting from LLM,
|
||||
allowing routers to work with multiple underlying LLM models while presenting a unified
|
||||
LLM interface to consumers.
|
||||
|
||||
Key features:
|
||||
- Works with multiple LLMs configured via llms_for_routing
|
||||
- Delegates all other operations/properties to the selected LLM
|
||||
- Provides routing interface through _select_llm() method
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent_config: AgentConfig,
|
||||
llm_registry: 'LLMRegistry',
|
||||
service_id: str = 'router_llm',
|
||||
metrics: Metrics | None = None,
|
||||
retry_listener: Callable[[int, int], None] | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize RouterLLM with multiple LLM support.
|
||||
"""
|
||||
self.llm_registry = llm_registry
|
||||
self.model_routing_config = agent_config.model_routing
|
||||
|
||||
# Get the primary agent LLM
|
||||
self.primary_llm = llm_registry.get_llm_from_agent_config('agent', agent_config)
|
||||
|
||||
# Instantiate all the LLM instances for routing
|
||||
llms_for_routing_config = self.model_routing_config.llms_for_routing
|
||||
self.llms_for_routing = {
|
||||
config_name: self.llm_registry.get_llm(
|
||||
f'llm_for_routing.{config_name}', config=llm_config
|
||||
)
|
||||
for config_name, llm_config in llms_for_routing_config.items()
|
||||
}
|
||||
|
||||
# All available LLMs for routing (set this BEFORE calling super().__init__)
|
||||
self.available_llms = {'primary': self.primary_llm, **self.llms_for_routing}
|
||||
|
||||
# Create router config based on primary LLM
|
||||
router_config = copy.deepcopy(self.primary_llm.config)
|
||||
|
||||
# Update model name to indicate this is a router
|
||||
llm_names = [self.primary_llm.config.model]
|
||||
if self.model_routing_config.llms_for_routing:
|
||||
llm_names.extend(
|
||||
config.model
|
||||
for config in self.model_routing_config.llms_for_routing.values()
|
||||
)
|
||||
router_config.model = f'router({",".join(llm_names)})'
|
||||
|
||||
# Initialize parent LLM class
|
||||
super().__init__(
|
||||
config=router_config,
|
||||
service_id=service_id,
|
||||
metrics=metrics,
|
||||
retry_listener=retry_listener,
|
||||
)
|
||||
|
||||
# Current LLM state
|
||||
self._current_llm = self.primary_llm # Default to primary LLM
|
||||
self._last_routing_decision = 'primary'
|
||||
|
||||
logger.info(
|
||||
f'RouterLLM initialized with {len(self.available_llms)} LLMs: {list(self.available_llms.keys())}'
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def _select_llm(self, messages: list[Message]) -> str:
|
||||
"""
|
||||
Select which LLM to use based on messages and events.
|
||||
"""
|
||||
pass
|
||||
|
||||
def _get_llm_by_key(self, llm_key: str) -> LLM:
|
||||
"""
|
||||
Get LLM instance by key.
|
||||
"""
|
||||
if llm_key not in self.available_llms:
|
||||
raise ValueError(
|
||||
f'Unknown LLM key: {llm_key}. Available: {list(self.available_llms.keys())}'
|
||||
)
|
||||
return self.available_llms[llm_key]
|
||||
|
||||
@property
|
||||
def completion(self) -> Callable:
|
||||
"""
|
||||
Override completion to route to appropriate LLM.
|
||||
|
||||
This method intercepts completion calls and routes them to the appropriate
|
||||
underlying LLM based on the routing logic implemented in _select_llm().
|
||||
"""
|
||||
|
||||
def router_completion(*args: Any, **kwargs: Any) -> Any:
|
||||
# Extract messages for routing decision
|
||||
messages = kwargs.get('messages', [])
|
||||
if args and not messages:
|
||||
messages = args[0] if args else []
|
||||
|
||||
# Select appropriate LLM
|
||||
selected_llm_key = self._select_llm(messages)
|
||||
selected_llm = self._get_llm_by_key(selected_llm_key)
|
||||
|
||||
# Update current state
|
||||
self._current_llm = selected_llm
|
||||
self._last_routing_decision = selected_llm_key
|
||||
|
||||
logger.debug(
|
||||
f'RouterLLM routing to {selected_llm_key} ({selected_llm.config.model})'
|
||||
)
|
||||
|
||||
# Delegate to selected LLM
|
||||
return selected_llm.completion(*args, **kwargs)
|
||||
|
||||
return router_completion
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""String representation of the router."""
|
||||
return f'{self.__class__.__name__}(llms={list(self.available_llms.keys())})'
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Detailed string representation of the router."""
|
||||
return (
|
||||
f'{self.__class__.__name__}('
|
||||
f'primary={self.primary_llm.config.model}, '
|
||||
f'routing_llms={[llm.config.model for llm in self.llms_for_routing.values()]}, '
|
||||
f'current={self._last_routing_decision})'
|
||||
)
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Delegate other attributes/methods to the active LLM."""
|
||||
return getattr(self._current_llm, name)
|
||||
|
||||
@classmethod
|
||||
def from_config(
|
||||
cls, llm_registry: 'LLMRegistry', agent_config: AgentConfig, **kwargs
|
||||
) -> 'RouterLLM':
|
||||
"""Factory method to create a RouterLLM instance from configuration."""
|
||||
router_cls = ROUTER_LLM_REGISTRY.get(agent_config.model_routing.router_name)
|
||||
if not router_cls:
|
||||
raise ValueError(
|
||||
f'Router LLM {agent_config.model_routing.router_name} not found.'
|
||||
)
|
||||
return router_cls(agent_config, llm_registry, **kwargs)
|
||||
@@ -0,0 +1,74 @@
|
||||
from openhands.core.config import AgentConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.message import Message
|
||||
from openhands.llm.llm import LLM
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
from openhands.llm.router.base import ROUTER_LLM_REGISTRY, RouterLLM
|
||||
|
||||
|
||||
class MultimodalRouter(RouterLLM):
|
||||
SECONDARY_MODEL_CONFIG_NAME = 'secondary_model'
|
||||
ROUTER_NAME = 'multimodal_router'
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent_config: AgentConfig,
|
||||
llm_registry: LLMRegistry,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(agent_config, llm_registry, **kwargs)
|
||||
|
||||
self._validate_model_routing_config(self.llms_for_routing)
|
||||
|
||||
# States
|
||||
self.max_token_exceeded = False
|
||||
|
||||
def _select_llm(self, messages: list[Message]) -> str:
|
||||
"""Select LLM based on multimodal content and token limits."""
|
||||
route_to_primary = False
|
||||
|
||||
# Check for multimodal content in messages
|
||||
for message in messages:
|
||||
if message.contains_image:
|
||||
logger.info(
|
||||
'Multimodal content detected in messages. Routing to the primary model.'
|
||||
)
|
||||
route_to_primary = True
|
||||
|
||||
if not route_to_primary and self.max_token_exceeded:
|
||||
route_to_primary = True
|
||||
|
||||
# Check if `messages` exceeds context window of the secondary model
|
||||
# Assuming the secondary model has a lower context window limit compared to the primary model
|
||||
secondary_llm = self.available_llms.get(self.SECONDARY_MODEL_CONFIG_NAME)
|
||||
if secondary_llm and (
|
||||
secondary_llm.config.max_input_tokens
|
||||
and secondary_llm.get_token_count(messages)
|
||||
> secondary_llm.config.max_input_tokens
|
||||
):
|
||||
logger.warning(
|
||||
f"Messages having {secondary_llm.get_token_count(messages)} tokens, exceed secondary model's max input tokens ({secondary_llm.config.max_input_tokens} tokens). "
|
||||
'Routing to the primary model.'
|
||||
)
|
||||
self.max_token_exceeded = True
|
||||
route_to_primary = True
|
||||
|
||||
if route_to_primary:
|
||||
logger.info('Routing to the primary model...')
|
||||
return 'primary'
|
||||
else:
|
||||
logger.info('Routing to the secondary model...')
|
||||
return self.SECONDARY_MODEL_CONFIG_NAME
|
||||
|
||||
def vision_is_active(self):
|
||||
return self.primary_llm.vision_is_active()
|
||||
|
||||
def _validate_model_routing_config(self, llms_for_routing: dict[str, LLM]):
|
||||
if self.SECONDARY_MODEL_CONFIG_NAME not in llms_for_routing:
|
||||
raise ValueError(
|
||||
f'Secondary LLM config {self.SECONDARY_MODEL_CONFIG_NAME} not found.'
|
||||
)
|
||||
|
||||
|
||||
# Register the router
|
||||
ROUTER_LLM_REGISTRY[MultimodalRouter.ROUTER_NAME] = MultimodalRouter
|
||||
@@ -863,7 +863,7 @@ fi
|
||||
# If the instructions file is not found in the workspace root, try to load it from the repo root
|
||||
self.log(
|
||||
'debug',
|
||||
f'.openhands_instructions not present, trying to load from repository {microagents_dir=}',
|
||||
f'.openhands_instructions not present, trying to load from repository microagents_dir={microagents_dir}',
|
||||
)
|
||||
obs = self.read(
|
||||
FileReadAction(path=str(repo_root / '.openhands_instructions'))
|
||||
|
||||
@@ -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.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik"
|
||||
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik"
|
||||
```
|
||||
|
||||
#### Additional Kubernetes Options
|
||||
|
||||
@@ -69,9 +69,17 @@ RUN (if getent passwd 1000 | grep -q pn; then userdel pn; fi) && \
|
||||
(if getent group 1000 | grep -q pn; then groupdel pn; fi) && \
|
||||
(if getent group 1000 | grep -q ubuntu; then groupdel ubuntu; fi)
|
||||
|
||||
# Create openhands group and user
|
||||
RUN groupadd -g 1000 openhands && \
|
||||
useradd -u 1000 -g 1000 -m -s /bin/bash openhands && \
|
||||
# Create openhands group and user (with fallback IDs if 1000 is taken)
|
||||
RUN (if getent group 1000 >/dev/null 2>&1; then \
|
||||
groupadd openhands; \
|
||||
else \
|
||||
groupadd -g 1000 openhands; \
|
||||
fi) && \
|
||||
(if getent passwd 1000 >/dev/null 2>&1; then \
|
||||
useradd -g openhands -m -s /bin/bash openhands; \
|
||||
else \
|
||||
useradd -u 1000 -g openhands -m -s /bin/bash openhands; \
|
||||
fi) && \
|
||||
usermod -aG sudo openhands && \
|
||||
echo 'openhands ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers && \
|
||||
# Set empty password for openhands user to allow passwordless su
|
||||
|
||||
@@ -28,7 +28,6 @@ from openhands.integrations.provider import (
|
||||
ProviderHandler,
|
||||
)
|
||||
from openhands.integrations.service_types import (
|
||||
AuthenticationError,
|
||||
CreateMicroagent,
|
||||
ProviderType,
|
||||
SuggestedTask,
|
||||
@@ -244,19 +243,7 @@ async def new_conversation(
|
||||
if repository:
|
||||
provider_handler = ProviderHandler(provider_tokens)
|
||||
# Check against git_provider, otherwise check all provider apis
|
||||
# Only verify if we have valid provider tokens and can connect
|
||||
try:
|
||||
await provider_handler.verify_repo_provider(repository, git_provider)
|
||||
except AuthenticationError:
|
||||
# Re-raise authentication errors as they indicate invalid tokens
|
||||
raise
|
||||
except Exception as e:
|
||||
# Log network/connection errors but allow conversation to proceed
|
||||
# This enables offline usage when no network connectivity is available
|
||||
logger.warning(
|
||||
f'Repository verification failed (possibly offline): {e}. '
|
||||
f'Proceeding with conversation creation for repository: {repository}'
|
||||
)
|
||||
await provider_handler.verify_repo_provider(repository, git_provider)
|
||||
|
||||
conversation_id = getattr(data, 'conversation_id', None) or uuid.uuid4().hex
|
||||
agent_loop_info = await create_new_conversation(
|
||||
|
||||
@@ -105,7 +105,10 @@ async def start_conversation(
|
||||
session_init_args = {**settings.__dict__, **session_init_args}
|
||||
# We could use litellm.check_valid_key for a more accurate check,
|
||||
# but that would run a tiny inference.
|
||||
if (
|
||||
model_name = settings.llm_model or ''
|
||||
is_bedrock_model = model_name.startswith('bedrock/')
|
||||
|
||||
if not is_bedrock_model and (
|
||||
not settings.llm_api_key
|
||||
or settings.llm_api_key.get_secret_value().isspace()
|
||||
):
|
||||
@@ -113,6 +116,8 @@ async def start_conversation(
|
||||
raise LLMAuthenticationError(
|
||||
'Error authenticating with the LLM provider. Please check your API key'
|
||||
)
|
||||
elif is_bedrock_model:
|
||||
logger.info(f'Bedrock model detected ({model_name}), API key not required')
|
||||
|
||||
else:
|
||||
logger.warning('Settings not present, not starting conversation')
|
||||
|
||||
@@ -63,7 +63,7 @@ class ConversationStats:
|
||||
serialized_metrics = base64.b64encode(pickled).decode('utf-8')
|
||||
self.file_store.write(self.metrics_path, serialized_metrics)
|
||||
logger.info(
|
||||
'Saved converation stats',
|
||||
'Saved conversation stats',
|
||||
extra={'conversation_id': self.conversation_id},
|
||||
)
|
||||
|
||||
|
||||
@@ -63,9 +63,14 @@ class Settings(BaseModel):
|
||||
if api_key is None:
|
||||
return None
|
||||
|
||||
# Get the secret value to check if it's empty
|
||||
secret_value = api_key.get_secret_value()
|
||||
if not secret_value or not secret_value.strip():
|
||||
return None
|
||||
|
||||
context = info.context
|
||||
if context and context.get('expose_secrets', False):
|
||||
return api_key.get_secret_value()
|
||||
return secret_value
|
||||
|
||||
return pydantic_encoder(api_key)
|
||||
|
||||
|
||||
Generated
+25
-52
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "aiofiles"
|
||||
@@ -2257,15 +2257,15 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "e2b"
|
||||
version = "1.7.0"
|
||||
version = "2.0.0"
|
||||
description = "E2B SDK that give agents cloud environments"
|
||||
optional = true
|
||||
python-versions = "<4.0,>=3.9"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"third-party-runtimes\""
|
||||
files = [
|
||||
{file = "e2b-1.7.0-py3-none-any.whl", hash = "sha256:6bd3d935249fcf5684494a97178d4d58446b4ed4018ac09087e4000046e82aab"},
|
||||
{file = "e2b-1.7.0.tar.gz", hash = "sha256:7783408c2cdf7aee9b088d31759364f2b13b21100cc4e132ba36fd84cfc72e31"},
|
||||
{file = "e2b-2.0.0-py3-none-any.whl", hash = "sha256:a6621b905cb2a883a9c520736ae98343a6184fc90c29b4f2f079d720294a0df0"},
|
||||
{file = "e2b-2.0.0.tar.gz", hash = "sha256:4d033d937b0a09b8428e73233321a913cbaef8e7299fc731579c656e9d53a144"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -2273,10 +2273,28 @@ attrs = ">=23.2.0"
|
||||
httpcore = ">=1.0.5,<2.0.0"
|
||||
httpx = ">=0.27.0,<1.0.0"
|
||||
packaging = ">=24.1"
|
||||
protobuf = ">=5.29.4,<6.0.0"
|
||||
protobuf = ">=4.21.0"
|
||||
python-dateutil = ">=2.8.2"
|
||||
typing-extensions = ">=4.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "e2b-code-interpreter"
|
||||
version = "2.0.0"
|
||||
description = "E2B Code Interpreter - Stateful code execution"
|
||||
optional = true
|
||||
python-versions = "<4.0,>=3.9"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"third-party-runtimes\""
|
||||
files = [
|
||||
{file = "e2b_code_interpreter-2.0.0-py3-none-any.whl", hash = "sha256:273642d4dd78f09327fb1553fe4f7ddcf17892b78f98236e038d29985e42dca5"},
|
||||
{file = "e2b_code_interpreter-2.0.0.tar.gz", hash = "sha256:19136916be8de60bfd0a678742501d1d0335442bb6e86405c7dd6f98059b73c4"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
attrs = ">=21.3.0"
|
||||
e2b = ">=2.0.0,<3.0.0"
|
||||
httpx = ">=0.20.0,<1.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "english-words"
|
||||
version = "2.0.1"
|
||||
@@ -2981,27 +2999,6 @@ gitdb = ">=4.0.1,<5"
|
||||
doc = ["sphinx (>=7.1.2,<7.2)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"]
|
||||
test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock ; python_version < \"3.8\"", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions ; python_version < \"3.11\""]
|
||||
|
||||
[[package]]
|
||||
name = "google-ai-generativelanguage"
|
||||
version = "0.6.15"
|
||||
description = "Google Ai Generativelanguage API client library"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "google_ai_generativelanguage-0.6.15-py3-none-any.whl", hash = "sha256:5a03ef86377aa184ffef3662ca28f19eeee158733e45d7947982eb953c6ebb6c"},
|
||||
{file = "google_ai_generativelanguage-0.6.15.tar.gz", hash = "sha256:8f6d9dc4c12b065fe2d0289026171acea5183ebf2d0b11cefe12f3821e159ec3"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]}
|
||||
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev"
|
||||
proto-plus = [
|
||||
{version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""},
|
||||
{version = ">=1.22.3,<2.0.0dev"},
|
||||
]
|
||||
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev"
|
||||
|
||||
[[package]]
|
||||
name = "google-api-core"
|
||||
version = "2.25.0"
|
||||
@@ -3336,30 +3333,6 @@ requests = ">=2.28.1,<3.0.0"
|
||||
typing-extensions = ">=4.11.0,<5.0.0"
|
||||
websockets = ">=13.0.0,<15.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "google-generativeai"
|
||||
version = "0.8.5"
|
||||
description = "Google Generative AI High level API client library and tools."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "google_generativeai-0.8.5-py3-none-any.whl", hash = "sha256:22b420817fb263f8ed520b33285f45976d5b21e904da32b80d4fd20c055123a2"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
google-ai-generativelanguage = "0.6.15"
|
||||
google-api-core = "*"
|
||||
google-api-python-client = "*"
|
||||
google-auth = ">=2.15.0"
|
||||
protobuf = "*"
|
||||
pydantic = "*"
|
||||
tqdm = "*"
|
||||
typing-extensions = "*"
|
||||
|
||||
[package.extras]
|
||||
dev = ["Pillow", "absl-py", "black", "ipython", "nose2", "pandas", "pytype", "pyyaml"]
|
||||
|
||||
[[package]]
|
||||
name = "google-resumable-media"
|
||||
version = "2.7.2"
|
||||
@@ -11845,9 +11818,9 @@ cffi = {version = ">=1.11", markers = "platform_python_implementation == \"PyPy\
|
||||
cffi = ["cffi (>=1.11)"]
|
||||
|
||||
[extras]
|
||||
third-party-runtimes = ["daytona", "e2b", "modal", "runloop-api-client"]
|
||||
third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api-client"]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "a0ae2cee596dde71f89c06e9669efda58ee8f8f019fad3dbe9df068005c32904"
|
||||
content-hash = "6c7bc9a39d6875e09966872a5d579e73b5cb739d1bad89d3a7dde829541cec16"
|
||||
|
||||
+4
-4
@@ -6,7 +6,7 @@ requires = [
|
||||
|
||||
[tool.poetry]
|
||||
name = "openhands-ai"
|
||||
version = "0.55.0"
|
||||
version = "0.56.0"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
authors = [ "OpenHands" ]
|
||||
license = "MIT"
|
||||
@@ -29,7 +29,7 @@ python = "^3.12,<3.14"
|
||||
litellm = "^1.74.3, !=1.64.4, !=1.67.*" # avoid 1.64.4 (known bug) & 1.67.* (known bug #10272)
|
||||
openai = "1.99.9" # Pin due to litellm incompatibility with >=1.100.0 (BerriAI/litellm#13711)
|
||||
aiohttp = ">=3.9.0,!=3.11.13" # Pin to avoid yanked version 3.11.13
|
||||
google-generativeai = "*" # To use litellm with Gemini Pro API
|
||||
google-genai = "*" # To use litellm with Gemini Pro API
|
||||
google-api-python-client = "^2.164.0" # For Google Sheets API
|
||||
google-auth-httplib2 = "*" # For Google Sheets authentication
|
||||
google-auth-oauthlib = "*" # For Google Sheets OAuth
|
||||
@@ -96,14 +96,14 @@ memory-profiler = "^0.61.0"
|
||||
jupyter_kernel_gateway = "*"
|
||||
|
||||
# Third-party runtime dependencies (optional)
|
||||
e2b = { version = ">=1.0.5,<1.8.0", optional = true }
|
||||
modal = { version = ">=0.66.26,<1.2.0", optional = true }
|
||||
runloop-api-client = { version = "0.50.0", optional = true }
|
||||
daytona = { version = "0.24.2", optional = true }
|
||||
httpx-aiohttp = "^0.1.8"
|
||||
e2b-code-interpreter = { version = "^2.0.0", optional = true }
|
||||
|
||||
[tool.poetry.extras]
|
||||
third_party_runtimes = [ "e2b", "modal", "runloop-api-client", "daytona" ]
|
||||
third_party_runtimes = [ "e2b-code-interpreter", "modal", "runloop-api-client", "daytona" ]
|
||||
|
||||
[tool.poetry.group.dev]
|
||||
optional = true
|
||||
|
||||
@@ -443,6 +443,8 @@ def test_sandbox_volumes(monkeypatch, default_config):
|
||||
'SANDBOX_VOLUMES',
|
||||
'/host/path1:/container/path1,/host/path2:/container/path2:ro',
|
||||
)
|
||||
# Clear any existing workspace mount path to test default behavior
|
||||
monkeypatch.delenv('WORKSPACE_MOUNT_PATH_IN_SANDBOX', raising=False)
|
||||
|
||||
load_from_env(default_config, os.environ)
|
||||
finalize_config(default_config)
|
||||
@@ -465,6 +467,8 @@ def test_sandbox_volumes(monkeypatch, default_config):
|
||||
def test_sandbox_volumes_with_mode(monkeypatch, default_config):
|
||||
# Test SANDBOX_VOLUMES with read-only mode (no explicit /workspace mount)
|
||||
monkeypatch.setenv('SANDBOX_VOLUMES', '/host/path1:/container/path1:ro')
|
||||
# Clear any existing workspace mount path to test default behavior
|
||||
monkeypatch.delenv('WORKSPACE_MOUNT_PATH_IN_SANDBOX', raising=False)
|
||||
|
||||
load_from_env(default_config, os.environ)
|
||||
finalize_config(default_config)
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
"""Tests for provider offline functionality and variable scope issues."""
|
||||
|
||||
from types import MappingProxyType
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.integrations.provider import ProviderHandler, ProviderToken, ProviderType
|
||||
from openhands.integrations.service_types import AuthenticationError
|
||||
|
||||
|
||||
class TestProviderOfflineFunctionality:
|
||||
"""Test offline functionality and variable scope in ProviderHandler."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider_handler(self):
|
||||
"""Create a ProviderHandler instance for testing."""
|
||||
tokens = MappingProxyType(
|
||||
{
|
||||
ProviderType.GITHUB: ProviderToken(token='test_token'),
|
||||
ProviderType.GITLAB: ProviderToken(token='gitlab_token'),
|
||||
}
|
||||
)
|
||||
return ProviderHandler(provider_tokens=tokens)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_authenticated_git_url_network_error_handling(
|
||||
self, provider_handler
|
||||
):
|
||||
"""Test that network errors are properly handled with fallback to inferred provider.
|
||||
|
||||
After the fix, variables are properly initialized before the try block,
|
||||
ensuring they're always available regardless of which exception path is taken.
|
||||
"""
|
||||
repo_name = 'test-owner/test-repo'
|
||||
|
||||
# Mock verify_repo_provider to raise a non-AuthenticationError exception
|
||||
# This simulates a network error or other exception during offline operation
|
||||
with patch.object(provider_handler, 'verify_repo_provider') as mock_verify:
|
||||
# Simulate a network error (not AuthenticationError)
|
||||
mock_verify.side_effect = ConnectionError('Network unreachable')
|
||||
|
||||
# After the fix, this should work correctly with proper variable initialization
|
||||
result = await provider_handler.get_authenticated_git_url(repo_name)
|
||||
|
||||
# Should return a GitHub URL with token (inferred from repo name)
|
||||
assert result == 'https://test_token@github.com/test-owner/test-repo.git'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_authenticated_git_url_proper_variable_scope(
|
||||
self, provider_handler
|
||||
):
|
||||
"""Test that verifies the variables are properly scoped after the fix.
|
||||
|
||||
This test ensures that after fixing the code structure, the variables
|
||||
'provider' and 'repo_name' are properly initialized and available
|
||||
regardless of which exception path is taken.
|
||||
"""
|
||||
repo_name = 'test-owner/test-repo'
|
||||
|
||||
# Test with network error - should use inferred provider and original repo_name
|
||||
with patch.object(provider_handler, 'verify_repo_provider') as mock_verify:
|
||||
mock_verify.side_effect = ConnectionError('Network unreachable')
|
||||
|
||||
result = await provider_handler.get_authenticated_git_url(repo_name)
|
||||
|
||||
# Should return authenticated URL with inferred GitHub provider
|
||||
assert result == 'https://test_token@github.com/test-owner/test-repo.git'
|
||||
|
||||
# Test with successful verification - should use verified provider and repo_name
|
||||
mock_repository = AsyncMock()
|
||||
mock_repository.git_provider = ProviderType.GITLAB
|
||||
mock_repository.full_name = 'verified-owner/verified-repo'
|
||||
|
||||
with patch.object(provider_handler, 'verify_repo_provider') as mock_verify:
|
||||
mock_verify.return_value = mock_repository
|
||||
|
||||
result = await provider_handler.get_authenticated_git_url(repo_name)
|
||||
|
||||
# Should return authenticated GitLab URL with verified details
|
||||
assert (
|
||||
result
|
||||
== 'https://oauth2:gitlab_token@gitlab.com/verified-owner/verified-repo.git'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_authenticated_git_url_auth_error_handling(
|
||||
self, provider_handler
|
||||
):
|
||||
"""Test that AuthenticationError is properly handled and re-raised."""
|
||||
repo_name = 'test-owner/test-repo'
|
||||
|
||||
# Mock verify_repo_provider to raise AuthenticationError
|
||||
with patch.object(provider_handler, 'verify_repo_provider') as mock_verify:
|
||||
mock_verify.side_effect = AuthenticationError('Invalid token')
|
||||
|
||||
# AuthenticationError should be re-raised as a generic Exception
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await provider_handler.get_authenticated_git_url(repo_name)
|
||||
|
||||
assert 'Git provider authentication issue when getting remote URL' in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_authenticated_git_url_successful_case(self, provider_handler):
|
||||
"""Test the successful case where repository verification works."""
|
||||
repo_name = 'test-owner/test-repo'
|
||||
|
||||
# Mock a successful repository verification
|
||||
mock_repository = AsyncMock()
|
||||
mock_repository.git_provider = ProviderType.GITHUB
|
||||
mock_repository.full_name = 'test-owner/test-repo'
|
||||
|
||||
with patch.object(provider_handler, 'verify_repo_provider') as mock_verify:
|
||||
mock_verify.return_value = mock_repository
|
||||
|
||||
result = await provider_handler.get_authenticated_git_url(repo_name)
|
||||
|
||||
# Should return an authenticated GitHub URL
|
||||
assert result == 'https://test_token@github.com/test-owner/test-repo.git'
|
||||
@@ -286,6 +286,8 @@ def test_prompt_cache_haiku_variants():
|
||||
def test_stop_words_grok_provider_prefixed():
|
||||
assert get_features('xai/grok-4-0709').supports_stop_words is False
|
||||
assert get_features('grok-4-0709').supports_stop_words is False
|
||||
assert get_features('xai/grok-code-fast-1').supports_stop_words is False
|
||||
assert get_features('grok-code-fast-1').supports_stop_words is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -294,6 +296,7 @@ def test_stop_words_grok_provider_prefixed():
|
||||
'o1-mini',
|
||||
'o1-2024-12-17',
|
||||
'xai/grok-4-0709',
|
||||
'xai/grok-code-fast-1',
|
||||
'deepseek/DeepSeek-R1-0528:671b-Q4_K_XL',
|
||||
'DeepSeek-R1-0528',
|
||||
],
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
"""Test offline conversation creation functionality."""
|
||||
|
||||
|
||||
def test_offline_repository_verification_logic():
|
||||
"""Test the logic for handling offline repository verification.
|
||||
|
||||
This test validates that our fix correctly handles different exception types:
|
||||
- AuthenticationError should be re-raised (invalid tokens)
|
||||
- Other exceptions should be logged and ignored (network issues)
|
||||
"""
|
||||
|
||||
# Define a mock AuthenticationError for testing
|
||||
class AuthenticationError(Exception):
|
||||
pass
|
||||
|
||||
# Test case 1: AuthenticationError should be re-raised
|
||||
def test_auth_error_handling():
|
||||
"""Simulate the exception handling logic in our fix."""
|
||||
try:
|
||||
# Simulate AuthenticationError from repository verification
|
||||
raise AuthenticationError('Invalid token')
|
||||
except AuthenticationError:
|
||||
# This should be re-raised
|
||||
return 'auth_error_reraised'
|
||||
except Exception:
|
||||
# This should not be reached for AuthenticationError
|
||||
return 'other_error_ignored'
|
||||
|
||||
# Test case 2: Network errors should be ignored
|
||||
def test_network_error_handling():
|
||||
"""Simulate the exception handling logic in our fix."""
|
||||
try:
|
||||
# Simulate network error from repository verification
|
||||
raise Exception('Network unreachable')
|
||||
except Exception as e:
|
||||
# Check if it's an AuthenticationError
|
||||
if isinstance(e, AuthenticationError):
|
||||
return 'auth_error_reraised'
|
||||
else:
|
||||
# Log and ignore other errors (network issues)
|
||||
return 'network_error_ignored'
|
||||
|
||||
# Run the tests
|
||||
assert test_auth_error_handling() == 'auth_error_reraised'
|
||||
assert test_network_error_handling() == 'network_error_ignored'
|
||||
|
||||
|
||||
def test_repository_verification_skip_logic():
|
||||
"""Test that repository verification can be skipped when appropriate."""
|
||||
|
||||
# Define a mock AuthenticationError for testing
|
||||
class AuthenticationError(Exception):
|
||||
pass
|
||||
|
||||
def simulate_conversation_creation_with_repo(
|
||||
repository, has_network_error=False, has_auth_error=False
|
||||
):
|
||||
"""Simulate the conversation creation logic with our fix."""
|
||||
if repository:
|
||||
# Simulate provider handler creation
|
||||
# provider_handler = ProviderHandler(provider_tokens)
|
||||
|
||||
try:
|
||||
# Simulate repository verification
|
||||
if has_auth_error:
|
||||
raise AuthenticationError('Invalid token')
|
||||
elif has_network_error:
|
||||
raise Exception('Network unreachable')
|
||||
else:
|
||||
# Successful verification
|
||||
pass
|
||||
except Exception as e:
|
||||
if isinstance(e, AuthenticationError):
|
||||
# Re-raise authentication errors
|
||||
raise
|
||||
else:
|
||||
# Log and ignore network errors
|
||||
print(
|
||||
f'Repository verification failed (possibly offline): {e}. Proceeding with conversation creation.'
|
||||
)
|
||||
|
||||
# Continue with conversation creation
|
||||
return 'conversation_created'
|
||||
|
||||
# Test successful verification
|
||||
result = simulate_conversation_creation_with_repo(
|
||||
'test/repo', has_network_error=False, has_auth_error=False
|
||||
)
|
||||
assert result == 'conversation_created'
|
||||
|
||||
# Test network error (should proceed)
|
||||
result = simulate_conversation_creation_with_repo(
|
||||
'test/repo', has_network_error=True, has_auth_error=False
|
||||
)
|
||||
assert result == 'conversation_created'
|
||||
|
||||
# Test authentication error (should raise)
|
||||
try:
|
||||
simulate_conversation_creation_with_repo(
|
||||
'test/repo', has_network_error=False, has_auth_error=True
|
||||
)
|
||||
raise AssertionError('Should have raised AuthenticationError')
|
||||
except AuthenticationError:
|
||||
pass # Expected
|
||||
|
||||
# Test no repository (should proceed)
|
||||
result = simulate_conversation_creation_with_repo(None)
|
||||
assert result == 'conversation_created'
|
||||
|
||||
|
||||
def test_provider_inference_logic():
|
||||
"""Test the provider inference logic for offline scenarios."""
|
||||
|
||||
# Mock the ProviderType enum
|
||||
class ProviderType:
|
||||
GITHUB = 'github'
|
||||
GITLAB = 'gitlab'
|
||||
BITBUCKET = 'bitbucket'
|
||||
|
||||
def infer_provider_from_repo_name(repo_name: str):
|
||||
"""Simulate the provider inference logic."""
|
||||
repo_lower = repo_name.lower()
|
||||
|
||||
# Check for provider domains in the repo name/URL
|
||||
if 'gitlab.com' in repo_lower or 'gitlab' in repo_lower:
|
||||
return ProviderType.GITLAB
|
||||
elif 'bitbucket.org' in repo_lower or 'bitbucket' in repo_lower:
|
||||
return ProviderType.BITBUCKET
|
||||
else:
|
||||
# Default to GitHub for unknown or github.com
|
||||
return ProviderType.GITHUB
|
||||
|
||||
# Test various repository name formats
|
||||
assert infer_provider_from_repo_name('owner/repo') == ProviderType.GITHUB
|
||||
assert (
|
||||
infer_provider_from_repo_name('https://github.com/owner/repo')
|
||||
== ProviderType.GITHUB
|
||||
)
|
||||
assert (
|
||||
infer_provider_from_repo_name('https://gitlab.com/owner/repo')
|
||||
== ProviderType.GITLAB
|
||||
)
|
||||
assert (
|
||||
infer_provider_from_repo_name('https://bitbucket.org/owner/repo')
|
||||
== ProviderType.BITBUCKET
|
||||
)
|
||||
assert infer_provider_from_repo_name('gitlab-owner/repo') == ProviderType.GITLAB
|
||||
assert (
|
||||
infer_provider_from_repo_name('bitbucket-owner/repo') == ProviderType.BITBUCKET
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
test_offline_repository_verification_logic()
|
||||
test_repository_verification_skip_logic()
|
||||
test_provider_inference_logic()
|
||||
print(
|
||||
'✅ All tests passed! Offline conversation creation logic is working correctly.'
|
||||
)
|
||||
@@ -27,6 +27,7 @@ class FakeEventStream:
|
||||
class FakeRuntime:
|
||||
def __init__(self):
|
||||
self.event_stream = FakeEventStream()
|
||||
self.workspace_root = '/workspace'
|
||||
|
||||
async def connect(self):
|
||||
return None
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user