mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e79cc6517e | |||
| 5c6bf5f7a0 |
@@ -0,0 +1,123 @@
|
||||
# OpenHands GitHub Actions Workflow Templates
|
||||
|
||||
This directory contains workflow templates that make it easy to integrate OpenHands AI agent into your GitHub CI/CD workflows.
|
||||
|
||||
## Available Templates
|
||||
|
||||
### 1. OpenHands Code Review (`openhands-code-review.yml`)
|
||||
Automatically reviews pull requests for code quality, security, and best practices.
|
||||
|
||||
**Triggers:** Pull request opened or updated
|
||||
**Permissions:** `contents: read`, `pull-requests: write`, `issues: write`
|
||||
|
||||
### 2. OpenHands Bug Fix (`openhands-bug-fix.yml`)
|
||||
Automatically investigates and fixes bugs when issues are labeled with 'bug'.
|
||||
|
||||
**Triggers:** Issue labeled with 'bug'
|
||||
**Permissions:** `contents: write`, `pull-requests: write`, `issues: write`
|
||||
|
||||
### 3. OpenHands Documentation (`openhands-documentation.yml`)
|
||||
Keeps project documentation up-to-date by reviewing code changes and updating docs.
|
||||
|
||||
**Triggers:** Weekly schedule, manual dispatch, or code changes
|
||||
**Permissions:** `contents: write`, `pull-requests: write`
|
||||
|
||||
### 4. OpenHands Custom Task (`openhands-custom-task.yml`)
|
||||
Run any custom development task with a manual trigger and custom description.
|
||||
|
||||
**Triggers:** Manual dispatch with task description input
|
||||
**Permissions:** `contents: write`, `pull-requests: write`, `issues: write`
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **OpenHands API Key**: Get your API key from [OpenHands](https://app.all-hands.dev)
|
||||
2. **GitHub Repository**: The templates work with any GitHub repository
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Add API Key Secret**:
|
||||
- Go to your repository's Settings → Secrets and variables → Actions
|
||||
- Add a new repository secret named `OPENHANDS_API_KEY`
|
||||
- Set the value to your OpenHands API key
|
||||
|
||||
2. **Use Templates**:
|
||||
- Go to your repository's Actions tab
|
||||
- Click "New workflow"
|
||||
- Look for "OpenHands" templates in the template gallery
|
||||
- Choose the template that fits your needs
|
||||
- Customize as needed and commit
|
||||
|
||||
### Customization
|
||||
|
||||
All templates can be customized:
|
||||
|
||||
- **Prompts**: Modify the task descriptions to fit your specific needs
|
||||
- **Triggers**: Change when workflows run (schedule, events, manual)
|
||||
- **Timeouts**: Adjust polling timeouts based on task complexity
|
||||
- **Permissions**: Modify based on what actions OpenHands needs to perform
|
||||
|
||||
### Example Customizations
|
||||
|
||||
#### Custom Review Criteria
|
||||
```yaml
|
||||
prompt = '''Please review this pull request focusing on:
|
||||
- Performance optimization opportunities
|
||||
- Database query efficiency
|
||||
- API design consistency
|
||||
- Error handling completeness
|
||||
'''
|
||||
```
|
||||
|
||||
#### Specific Documentation Updates
|
||||
```yaml
|
||||
prompt = '''Update the following documentation:
|
||||
1. API reference in docs/api.md
|
||||
2. Installation guide in README.md
|
||||
3. Code examples in docs/examples/
|
||||
'''
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Workflow Trigger**: GitHub event triggers the workflow
|
||||
2. **Setup**: Installs Python and downloads the OpenHands API helper
|
||||
3. **API Call**: Creates a conversation with OpenHands using your prompt
|
||||
4. **Execution**: OpenHands performs the requested task in your repository
|
||||
5. **Results**: OpenHands may create PRs, comments, or other outputs
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **API Key**: Keep your `OPENHANDS_API_KEY` secret secure
|
||||
- **Permissions**: Templates request minimal required permissions
|
||||
- **Repository Access**: OpenHands will have access to your repository during task execution
|
||||
- **Review Changes**: Always review any PRs or changes made by OpenHands
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Missing API Key**: Ensure `OPENHANDS_API_KEY` is set in repository secrets
|
||||
2. **Permission Errors**: Check that workflow permissions match template requirements
|
||||
3. **Timeout Issues**: Increase timeout values for complex tasks
|
||||
4. **Rate Limits**: OpenHands API has rate limits; space out workflow runs if needed
|
||||
|
||||
### Getting Help
|
||||
|
||||
- Check the [OpenHands Documentation](https://docs.all-hands.dev)
|
||||
- Review workflow run logs for detailed error messages
|
||||
- Ensure your repository is accessible and has the necessary permissions
|
||||
|
||||
## Contributing
|
||||
|
||||
To improve these templates:
|
||||
|
||||
1. Test changes thoroughly
|
||||
2. Update documentation
|
||||
3. Follow GitHub Actions best practices
|
||||
4. Consider security implications
|
||||
|
||||
## License
|
||||
|
||||
These templates are provided under the same license as the OpenHands project.
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "OpenHands Bug Fix",
|
||||
"description": "Automatically investigate and fix bugs using OpenHands AI agent. Triggered when issues are labeled with 'bug'.",
|
||||
"iconName": "octicon bug",
|
||||
"categories": [
|
||||
"Bug Fix",
|
||||
"AI",
|
||||
"Automation",
|
||||
"Debugging"
|
||||
],
|
||||
"filePatterns": [
|
||||
".*\\.py$",
|
||||
".*\\.js$",
|
||||
".*\\.ts$",
|
||||
".*\\.jsx$",
|
||||
".*\\.tsx$",
|
||||
".*\\.java$",
|
||||
".*\\.go$",
|
||||
".*\\.rs$",
|
||||
".*\\.cpp$",
|
||||
".*\\.c$"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
name: OpenHands Bug Fix
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
openhands-fix:
|
||||
if: contains(github.event.label.name, 'bug')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install OpenHands API helper
|
||||
run: |
|
||||
curl -O https://raw.githubusercontent.com/All-Hands-AI/OpenHands/main/scripts/openhands_api.py
|
||||
python -m pip install --upgrade pip
|
||||
pip install requests
|
||||
|
||||
- name: Run OpenHands Bug Fix
|
||||
env:
|
||||
OPENHANDS_API_KEY: ${{ secrets.OPENHANDS_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
python -c "
|
||||
import sys
|
||||
sys.path.append('.')
|
||||
from openhands_api import OpenHandsAPI
|
||||
|
||||
# Create bug fix prompt
|
||||
prompt = '''Please investigate and fix the bug described in this GitHub issue:
|
||||
|
||||
Repository: ${{ github.repository }}
|
||||
Issue: #${{ github.event.issue.number }}
|
||||
Title: ${{ github.event.issue.title }}
|
||||
|
||||
Issue Description:
|
||||
${{ github.event.issue.body }}
|
||||
|
||||
Please:
|
||||
1. Analyze the issue and identify the root cause
|
||||
2. Implement a fix with proper error handling
|
||||
3. Add or update tests to prevent regression
|
||||
4. Create a pull request with your changes
|
||||
5. Include a clear description of what was fixed and how
|
||||
|
||||
Make sure to follow the project'\''s coding standards and best practices.
|
||||
'''
|
||||
|
||||
# Start OpenHands conversation
|
||||
client = OpenHandsAPI()
|
||||
response = client.create_conversation(
|
||||
initial_user_msg=prompt,
|
||||
repository='${{ github.repository }}'
|
||||
)
|
||||
|
||||
print(f'Started OpenHands bug fix: {response.get(\"conversation_id\", \"Unknown\")}')
|
||||
|
||||
# Poll for completion (optional - remove if you want fire-and-forget)
|
||||
try:
|
||||
final_response = client.poll_until_stopped(response['conversation_id'], timeout=1200)
|
||||
print(f'Bug fix completed with status: {final_response.get(\"status\", \"Unknown\")}')
|
||||
except Exception as e:
|
||||
print(f'Bug fix may still be running: {e}')
|
||||
"
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "OpenHands Code Review",
|
||||
"description": "Automated code review using OpenHands AI agent. Reviews PRs for code quality, security, and best practices.",
|
||||
"iconName": "octicon code-review",
|
||||
"categories": [
|
||||
"Code Quality",
|
||||
"AI",
|
||||
"Automation",
|
||||
"Python",
|
||||
"JavaScript",
|
||||
"TypeScript"
|
||||
],
|
||||
"filePatterns": [
|
||||
".*\\.py$",
|
||||
".*\\.js$",
|
||||
".*\\.ts$",
|
||||
".*\\.jsx$",
|
||||
".*\\.tsx$",
|
||||
".*\\.java$",
|
||||
".*\\.go$",
|
||||
".*\\.rs$",
|
||||
".*\\.cpp$",
|
||||
".*\\.c$"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
name: OpenHands Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
jobs:
|
||||
openhands-review:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install OpenHands API helper
|
||||
run: |
|
||||
curl -O https://raw.githubusercontent.com/All-Hands-AI/OpenHands/main/scripts/openhands_api.py
|
||||
python -m pip install --upgrade pip
|
||||
pip install requests
|
||||
|
||||
- name: Run OpenHands Code Review
|
||||
env:
|
||||
OPENHANDS_API_KEY: ${{ secrets.OPENHANDS_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
python -c "
|
||||
import sys
|
||||
sys.path.append('.')
|
||||
from openhands_api import OpenHandsAPI
|
||||
|
||||
# Create review prompt
|
||||
prompt = '''Please review this pull request for:
|
||||
- Code quality and best practices
|
||||
- Security vulnerabilities
|
||||
- Performance considerations
|
||||
- Documentation completeness
|
||||
- Test coverage
|
||||
|
||||
Repository: ${{ github.repository }}
|
||||
PR: #${{ github.event.number }}
|
||||
Title: ${{ github.event.pull_request.title }}
|
||||
|
||||
Please provide constructive feedback and suggestions for improvement.
|
||||
If you find any issues, please create a detailed comment explaining the problem and suggesting solutions.
|
||||
'''
|
||||
|
||||
# Start OpenHands conversation
|
||||
client = OpenHandsAPI()
|
||||
response = client.create_conversation(
|
||||
initial_user_msg=prompt,
|
||||
repository='${{ github.repository }}'
|
||||
)
|
||||
|
||||
print(f'Started OpenHands review: {response.get(\"conversation_id\", \"Unknown\")}')
|
||||
|
||||
# Poll for completion (optional - remove if you want fire-and-forget)
|
||||
try:
|
||||
final_response = client.poll_until_stopped(response['conversation_id'], timeout=600)
|
||||
print(f'Review completed with status: {final_response.get(\"status\", \"Unknown\")}')
|
||||
except Exception as e:
|
||||
print(f'Review may still be running: {e}')
|
||||
"
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "OpenHands Custom Task",
|
||||
"description": "Run any custom development task using OpenHands AI agent. Manually triggered with custom task description.",
|
||||
"iconName": "octicon tools",
|
||||
"categories": [
|
||||
"AI",
|
||||
"Automation",
|
||||
"Custom",
|
||||
"Development"
|
||||
],
|
||||
"filePatterns": [
|
||||
".*"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
name: OpenHands Custom Task
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
task_description:
|
||||
description: 'Describe the task you want OpenHands to perform'
|
||||
required: true
|
||||
type: string
|
||||
timeout_minutes:
|
||||
description: 'Timeout in minutes (default: 20)'
|
||||
required: false
|
||||
default: '20'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
openhands-task:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install OpenHands API helper
|
||||
run: |
|
||||
curl -O https://raw.githubusercontent.com/All-Hands-AI/OpenHands/main/scripts/openhands_api.py
|
||||
python -m pip install --upgrade pip
|
||||
pip install requests
|
||||
|
||||
- name: Run OpenHands Custom Task
|
||||
env:
|
||||
OPENHANDS_API_KEY: ${{ secrets.OPENHANDS_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
python -c "
|
||||
import sys
|
||||
sys.path.append('.')
|
||||
from openhands_api import OpenHandsAPI
|
||||
|
||||
# Create custom task prompt
|
||||
prompt = '''${{ github.event.inputs.task_description }}
|
||||
|
||||
Repository: ${{ github.repository }}
|
||||
|
||||
Please complete this task following best practices:
|
||||
- Write clean, well-documented code
|
||||
- Add appropriate tests if needed
|
||||
- Follow the project'\''s coding standards
|
||||
- Create a pull request if changes are made
|
||||
- Provide clear explanations of what was done
|
||||
'''
|
||||
|
||||
# Start OpenHands conversation
|
||||
client = OpenHandsAPI()
|
||||
response = client.create_conversation(
|
||||
initial_user_msg=prompt,
|
||||
repository='${{ github.repository }}'
|
||||
)
|
||||
|
||||
print(f'Started OpenHands custom task: {response.get(\"conversation_id\", \"Unknown\")}')
|
||||
|
||||
# Poll for completion
|
||||
timeout_seconds = int('${{ github.event.inputs.timeout_minutes }}') * 60
|
||||
try:
|
||||
final_response = client.poll_until_stopped(response['conversation_id'], timeout=timeout_seconds)
|
||||
print(f'Custom task completed with status: {final_response.get(\"status\", \"Unknown\")}')
|
||||
except Exception as e:
|
||||
print(f'Custom task may still be running or timed out: {e}')
|
||||
"
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "OpenHands Documentation",
|
||||
"description": "Automatically update and maintain project documentation using OpenHands AI agent. Runs weekly and on code changes.",
|
||||
"iconName": "octicon book",
|
||||
"categories": [
|
||||
"Documentation",
|
||||
"AI",
|
||||
"Automation",
|
||||
"Maintenance"
|
||||
],
|
||||
"filePatterns": [
|
||||
"README.md$",
|
||||
".*\\.md$",
|
||||
"docs/.*",
|
||||
".*\\.py$",
|
||||
".*\\.js$",
|
||||
".*\\.ts$"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
name: OpenHands Documentation
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * 1' # Weekly on Monday at 2 AM UTC
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ $default-branch ]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'lib/**'
|
||||
- '*.py'
|
||||
- '*.js'
|
||||
- '*.ts'
|
||||
|
||||
jobs:
|
||||
openhands-docs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install OpenHands API helper
|
||||
run: |
|
||||
curl -O https://raw.githubusercontent.com/All-Hands-AI/OpenHands/main/scripts/openhands_api.py
|
||||
python -m pip install --upgrade pip
|
||||
pip install requests
|
||||
|
||||
- name: Generate Documentation with OpenHands
|
||||
env:
|
||||
OPENHANDS_API_KEY: ${{ secrets.OPENHANDS_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
python -c "
|
||||
import sys
|
||||
sys.path.append('.')
|
||||
from openhands_api import OpenHandsAPI
|
||||
|
||||
# Create documentation prompt
|
||||
prompt = '''Please review the current codebase and update the project documentation:
|
||||
|
||||
Repository: ${{ github.repository }}
|
||||
|
||||
Tasks:
|
||||
1. Review the README.md and ensure it accurately reflects the current project state
|
||||
2. Update API documentation if there are new endpoints or changes
|
||||
3. Check that code examples in documentation are up-to-date
|
||||
4. Add documentation for any new features or significant changes
|
||||
5. Ensure installation and setup instructions are current
|
||||
6. Update any outdated links or references
|
||||
7. Add or improve code comments where needed
|
||||
|
||||
Please create a pull request with your documentation updates if changes are needed.
|
||||
Focus on clarity, accuracy, and completeness.
|
||||
'''
|
||||
|
||||
# Start OpenHands conversation
|
||||
client = OpenHandsAPI()
|
||||
response = client.create_conversation(
|
||||
initial_user_msg=prompt,
|
||||
repository='${{ github.repository }}'
|
||||
)
|
||||
|
||||
print(f'Started OpenHands documentation update: {response.get(\"conversation_id\", \"Unknown\")}')
|
||||
|
||||
# Poll for completion (optional - remove if you want fire-and-forget)
|
||||
try:
|
||||
final_response = client.poll_until_stopped(response['conversation_id'], timeout=900)
|
||||
print(f'Documentation update completed with status: {final_response.get(\"status\", \"Unknown\")}')
|
||||
except Exception as e:
|
||||
print(f'Documentation update may still be running: {e}')
|
||||
"
|
||||
@@ -1,58 +0,0 @@
|
||||
# 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"
|
||||
@@ -1,23 +0,0 @@
|
||||
name: Dispatch to docs repo
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
repo: ["All-Hands-AI/docs"]
|
||||
steps:
|
||||
- name: Push to docs repo
|
||||
uses: peter-evans/repository-dispatch@v3
|
||||
with:
|
||||
token: ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}
|
||||
repository: ${{ matrix.repo }}
|
||||
event-type: update
|
||||
client-payload: '{"ref": "${{ github.ref }}", "sha": "${{ github.sha }}", "module": "openhands", "branch": "main"}'
|
||||
@@ -1,29 +0,0 @@
|
||||
# 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,11 +235,12 @@ jobs:
|
||||
|
||||
enterprise-preview:
|
||||
name: Enterprise preview
|
||||
if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'deploy')
|
||||
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'))
|
||||
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 (excluding CLI and enterprise)
|
||||
# Run lint on the python code
|
||||
lint-python:
|
||||
name: Lint python
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
@@ -73,24 +73,6 @@ 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,33 +104,3 @@ 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
|
||||
|
||||
+1
-2
@@ -31,8 +31,7 @@ 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
|
||||
# Note: openhands-cli.spec is intentionally tracked for CLI builds
|
||||
# *.spec
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
|
||||
@@ -87,6 +87,8 @@ VSCode Extension:
|
||||
|
||||
If you are starting a pull request (PR), please follow the template in `.github/pull_request_template.md`.
|
||||
|
||||
If you need to add labels when opening a PR, check the existing labels defined on that repository and select from existing ones. Do not invent your own labels.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
These details may or may not be useful for your current task.
|
||||
|
||||
+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.56-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.55-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/stargazers"><img src="https://img.shields.io/github/stars/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="Stargazers"></a>
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/LICENSE"><img src="https://img.shields.io/github/license/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="MIT License"></a>
|
||||
<br/>
|
||||
<a href="https://dub.sh/openhands"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Join our Slack community"></a>
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-3847of6xi-xuYJIPa6YIPg4ElbDWbtSA"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Join our Slack community"></a>
|
||||
<a href="https://discord.gg/ESHStjSjD4"><img src="https://img.shields.io/badge/Discord-Join%20Us-purple?logo=discord&logoColor=white&style=for-the-badge" alt="Join our Discord community"></a>
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/CREDITS.md"><img src="https://img.shields.io/badge/Project-Credits-blue?style=for-the-badge&color=FFE165&logo=github&logoColor=white" alt="Credits"></a>
|
||||
<br/>
|
||||
@@ -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.56-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-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.56
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -142,7 +142,7 @@ troubleshooting resources, and advanced configuration options.
|
||||
OpenHands is a community-driven project, and we welcome contributions from everyone. We do most of our communication
|
||||
through Slack, so this is the best place to start, but we also are happy to have you contact us on Discord or Github:
|
||||
|
||||
- [Join our Slack workspace](https://dub.sh/openhands) - Here we talk about research, architecture, and future development.
|
||||
- [Join our Slack workspace](https://join.slack.com/t/openhands-ai/shared_invite/zt-3847of6xi-xuYJIPa6YIPg4ElbDWbtSA) - Here we talk about research, architecture, and future development.
|
||||
- [Join our Discord server](https://discord.gg/ESHStjSjD4) - This is a community-run server for general discussion, questions, and feedback.
|
||||
- [Read or post Github Issues](https://github.com/All-Hands-AI/OpenHands/issues) - Check out the issues we're working on, or add your own ideas.
|
||||
|
||||
|
||||
+5
-5
@@ -12,7 +12,7 @@
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/stargazers"><img src="https://img.shields.io/github/stars/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="Stargazers"></a>
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/LICENSE"><img src="https://img.shields.io/github/license/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="MIT License"></a>
|
||||
<br/>
|
||||
<a href="https://dub.sh/openhands"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="加入我们的Slack社区"></a>
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-3847of6xi-xuYJIPa6YIPg4ElbDWbtSA"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="加入我们的Slack社区"></a>
|
||||
<a href="https://discord.gg/ESHStjSjD4"><img src="https://img.shields.io/badge/Discord-Join%20Us-purple?logo=discord&logoColor=white&style=for-the-badge" alt="加入我们的Discord社区"></a>
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/CREDITS.md"><img src="https://img.shields.io/badge/Project-Credits-blue?style=for-the-badge&color=FFE165&logo=github&logoColor=white" alt="致谢"></a>
|
||||
<br/>
|
||||
@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-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.56
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
```
|
||||
|
||||
> **注意**: 如果您在0.44版本之前使用过OpenHands,您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。
|
||||
@@ -107,7 +107,7 @@ docker run -it --rm --pull=always \
|
||||
OpenHands是一个社区驱动的项目,我们欢迎每个人的贡献。我们大部分沟通
|
||||
通过Slack进行,因此这是开始的最佳场所,但我们也很乐意您通过Discord或Github与我们联系:
|
||||
|
||||
- [加入我们的Slack工作空间](https://dub.sh/openhands) - 这里我们讨论研究、架构和未来发展。
|
||||
- [加入我们的Slack工作空间](https://join.slack.com/t/openhands-ai/shared_invite/zt-3847of6xi-xuYJIPa6YIPg4ElbDWbtSA) - 这里我们讨论研究、架构和未来发展。
|
||||
- [加入我们的Discord服务器](https://discord.gg/ESHStjSjD4) - 这是一个社区运营的服务器,用于一般讨论、问题和反馈。
|
||||
- [阅读或发布Github问题](https://github.com/All-Hands-AI/OpenHands/issues) - 查看我们正在处理的问题,或添加您自己的想法。
|
||||
|
||||
|
||||
+4
-4
@@ -10,7 +10,7 @@
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/stargazers"><img src="https://img.shields.io/github/stars/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="Stargazers"></a>
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/LICENSE"><img src="https://img.shields.io/github/license/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="MIT License"></a>
|
||||
<br/>
|
||||
<a href="https://dub.sh/openhands"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Slackコミュニティに参加"></a>
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-3847of6xi-xuYJIPa6YIPg4ElbDWbtSA"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Slackコミュニティに参加"></a>
|
||||
<a href="https://discord.gg/ESHStjSjD4"><img src="https://img.shields.io/badge/Discord-Join%20Us-purple?logo=discord&logoColor=white&style=for-the-badge" alt="Discordコミュニティに参加"></a>
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/CREDITS.md"><img src="https://img.shields.io/badge/Project-Credits-blue?style=for-the-badge&color=FFE165&logo=github&logoColor=white" alt="クレジット"></a>
|
||||
<br/>
|
||||
@@ -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.56-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-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.56
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
```
|
||||
|
||||
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。
|
||||
|
||||
@@ -219,14 +219,6 @@ 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')
|
||||
@@ -488,14 +480,3 @@ 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.56-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.55-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/|openhands-cli/)
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
|
||||
- id: end-of-file-fixer
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/|openhands-cli/)
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
|
||||
- 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/|openhands-cli/)
|
||||
exclude: ^(third_party/|enterprise/)
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
entry: ruff format --config dev_config/python/ruff.toml
|
||||
types_or: [python, pyi, jupyter]
|
||||
exclude: ^(third_party/|enterprise/|openhands-cli/)
|
||||
exclude: ^(third_party/|enterprise/)
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.15.0
|
||||
|
||||
@@ -7,7 +7,6 @@ warn_unreachable = True
|
||||
warn_redundant_casts = True
|
||||
no_implicit_optional = True
|
||||
strict_optional = True
|
||||
disable_error_code = type-abstract
|
||||
|
||||
# Exclude third-party runtime directory from type checking
|
||||
exclude = (third_party/|enterprise/)
|
||||
|
||||
+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.56-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.55-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:
|
||||
|
||||
+11
-30
@@ -1,36 +1,17 @@
|
||||
# OpenHands Documentation
|
||||
# Setup
|
||||
|
||||
This directory contains the documentation for OpenHands. The documentation is automatically synchronized with the [All-Hands-AI/docs](https://github.com/All-Hands-AI/docs) repository, which hosts the unified documentation site using Mintlify.
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
The documentation files in this directory are automatically included in the main documentation site via Git submodules. When you make changes to documentation in this repository, they will be automatically synchronized to the docs repository.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Automatic Sync**: When documentation changes are pushed to the `main` branch, a GitHub Action automatically notifies the docs repository
|
||||
2. **Submodule Update**: The docs repository updates its submodule reference to include your latest changes
|
||||
3. **Site Rebuild**: Mintlify automatically rebuilds and deploys the documentation site
|
||||
|
||||
## Making Documentation Changes
|
||||
|
||||
Simply edit the documentation files in this directory as usual. The synchronization happens automatically when changes are merged to the main branch.
|
||||
|
||||
## Local Development
|
||||
|
||||
For local documentation development in this repository only:
|
||||
|
||||
```bash
|
||||
```
|
||||
npm install -g mint
|
||||
# or
|
||||
yarn global add mint
|
||||
|
||||
# Preview local changes
|
||||
mint dev
|
||||
```
|
||||
|
||||
For the complete unified documentation site, work with the [All-Hands-AI/docs](https://github.com/All-Hands-AI/docs) repository.
|
||||
or
|
||||
|
||||
## Configuration
|
||||
```
|
||||
yarn global add mint
|
||||
```
|
||||
|
||||
The Mintlify configuration (`docs.json`) has been moved to the root of the [All-Hands-AI/docs](https://github.com/All-Hands-AI/docs) repository to enable unified documentation across multiple repositories.
|
||||
# Preview
|
||||
|
||||
```
|
||||
mint dev
|
||||
```
|
||||
|
||||
+1
-1
@@ -208,7 +208,7 @@
|
||||
},
|
||||
"footer": {
|
||||
"socials": {
|
||||
"slack": "https://dub.sh/openhands",
|
||||
"slack": "https://join.slack.com/t/openhands-ai/shared_invite/zt-3847of6xi-xuYJIPa6YIPg4ElbDWbtSA",
|
||||
"github": "https://github.com/All-Hands-AI/OpenHands",
|
||||
"discord": "https://discord.gg/ESHStjSjD4"
|
||||
}
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ If you would like to set things up more systematically, you can:
|
||||
1. **Search existing issues**: Check our [GitHub issues](https://github.com/All-Hands-AI/OpenHands/issues) to see if
|
||||
others have encountered the same problem.
|
||||
2. **Join our community**: Get help from other users and developers:
|
||||
- [Slack community](https://dub.sh/openhands)
|
||||
- [Slack community](https://join.slack.com/t/openhands-ai/shared_invite/zt-3847of6xi-xuYJIPa6YIPg4ElbDWbtSA)
|
||||
- [Discord server](https://discord.gg/ESHStjSjD4)
|
||||
3. **Check our troubleshooting guide**: Common issues and solutions are documented in
|
||||
[Troubleshooting](/usage/troubleshooting/troubleshooting).
|
||||
|
||||
@@ -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.56-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-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.56 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55 \
|
||||
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:/workspace:rw" # Format: host_path:container_path:mode
|
||||
export SANDBOX_VOLUMES="/path/to/workspace" # See SANDBOX_VOLUMES docs for details
|
||||
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.56-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-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.56 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55 \
|
||||
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.56-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-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.56
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
```
|
||||
|
||||
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.56
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
Starting OpenHands...
|
||||
Running OpenHands as root
|
||||
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
|
||||
@@ -119,7 +119,7 @@ When started for the first time, OpenHands will prompt you to set up the LLM pro
|
||||
|
||||
That's it! You can now start using OpenHands with the local LLM server.
|
||||
|
||||
If you encounter any issues, let us know on [Slack](https://dub.sh/openhands) or [Discord](https://discord.gg/ESHStjSjD4).
|
||||
If you encounter any issues, let us know on [Slack](https://join.slack.com/t/openhands-ai/shared_invite/zt-3847of6xi-xuYJIPa6YIPg4ElbDWbtSA) or [Discord](https://discord.gg/ESHStjSjD4).
|
||||
|
||||
## Advanced: Alternative LLM Backends
|
||||
|
||||
|
||||
@@ -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.56-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-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.56
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -10,14 +10,11 @@ from experiments.experiment_versions import (
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.experiments.experiment_manager import ExperimentManager
|
||||
from openhands.server.session.conversation_init_data import ConversationInitData
|
||||
|
||||
|
||||
class SaaSExperimentManager(ExperimentManager):
|
||||
@staticmethod
|
||||
def run_conversation_variant_test(
|
||||
user_id, conversation_id, conversation_settings
|
||||
) -> ConversationInitData:
|
||||
def run_conversation_variant_test(user_id, conversation_id, conversation_settings):
|
||||
"""
|
||||
Run conversation variant test and potentially modify the conversation settings
|
||||
based on the PostHog feature flags.
|
||||
@@ -56,7 +53,7 @@ class SaaSExperimentManager(ExperimentManager):
|
||||
@staticmethod
|
||||
def run_config_variant_test(
|
||||
user_id: str, conversation_id: str, config: OpenHandsConfig
|
||||
) -> OpenHandsConfig:
|
||||
):
|
||||
"""
|
||||
Run agent config variant test and potentially modify the OpenHands config
|
||||
based on the current experiment type and PostHog feature flags.
|
||||
|
||||
@@ -14,10 +14,9 @@ from server.constants import (
|
||||
from storage.experiment_assignment_store import ExperimentAssignmentStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.session.conversation_init_data import ConversationInitData
|
||||
|
||||
|
||||
def _get_model_variant(user_id: str | None, conversation_id: str) -> str | None:
|
||||
def _get_model_variant(user_id, conversation_id) -> str | None:
|
||||
if not EXPERIMENT_CLAUDE4_VS_GPT5:
|
||||
logger.info(
|
||||
'experiment_manager:ab_testing:skipped',
|
||||
@@ -105,11 +104,7 @@ def _get_model_variant(user_id: str | None, conversation_id: str) -> str | None:
|
||||
return enabled_variant
|
||||
|
||||
|
||||
def handle_claude4_vs_gpt5_experiment(
|
||||
user_id: str | None,
|
||||
conversation_id: str,
|
||||
conversation_settings: ConversationInitData,
|
||||
) -> ConversationInitData:
|
||||
def handle_claude4_vs_gpt5_experiment(user_id, conversation_id, conversation_settings):
|
||||
"""
|
||||
Handle the LiteLLM model experiment.
|
||||
|
||||
@@ -125,7 +120,7 @@ def handle_claude4_vs_gpt5_experiment(
|
||||
enabled_variant = _get_model_variant(user_id, conversation_id)
|
||||
|
||||
if not enabled_variant:
|
||||
return conversation_settings
|
||||
return None
|
||||
|
||||
# Set the model based on the feature flag variant
|
||||
if enabled_variant == 'gpt5':
|
||||
|
||||
@@ -11,7 +11,6 @@ from server.constants import IS_FEATURE_ENV
|
||||
from storage.experiment_assignment_store import ExperimentAssignmentStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.session.conversation_init_data import ConversationInitData
|
||||
|
||||
|
||||
def _get_condenser_max_step_variant(user_id, conversation_id):
|
||||
@@ -115,10 +114,8 @@ def _get_condenser_max_step_variant(user_id, conversation_id):
|
||||
|
||||
|
||||
def handle_condenser_max_step_experiment(
|
||||
user_id: str | None,
|
||||
conversation_id: str,
|
||||
conversation_settings: ConversationInitData,
|
||||
) -> ConversationInitData:
|
||||
user_id: str, conversation_id: str, conversation_settings
|
||||
):
|
||||
"""
|
||||
Handle the condenser max step experiment for conversation settings.
|
||||
|
||||
|
||||
@@ -390,24 +390,24 @@ class GitHubDataCollector:
|
||||
merged_by = None
|
||||
merge_commit_sha = None
|
||||
if is_merged:
|
||||
merged_by = (pr_data.get('mergedBy') or {}).get('login')
|
||||
merge_commit_sha = (pr_data.get('mergeCommit') or {}).get('oid')
|
||||
merged_by = pr_data.get('mergedBy', {}).get('login')
|
||||
merge_commit_sha = pr_data.get('mergeCommit', {}).get('oid')
|
||||
|
||||
return {
|
||||
'repo_metadata': self._extract_repo_metadata(repo_data),
|
||||
'pr_metadata': {
|
||||
'username': (pr_data.get('author') or {}).get('login'),
|
||||
'number': pr_data.get('number'),
|
||||
'title': pr_data.get('title'),
|
||||
'body': pr_data.get('body'),
|
||||
'username': pr_data.get('author', {}).get('login'),
|
||||
'number': pr_data['number'],
|
||||
'title': pr_data['title'],
|
||||
'body': pr_data['body'],
|
||||
'comments': pr_comments,
|
||||
},
|
||||
'commits': commits,
|
||||
'review_comments': review_comments,
|
||||
'merge_status': {
|
||||
'merged': pr_data.get('merged'),
|
||||
'merged': pr_data['merged'],
|
||||
'merged_by': merged_by,
|
||||
'state': pr_data.get('state'),
|
||||
'state': pr_data['state'],
|
||||
'merge_commit_sha': merge_commit_sha,
|
||||
},
|
||||
'openhands_stats': {
|
||||
|
||||
Generated
+111
-66
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "aiofiles"
|
||||
@@ -1426,73 +1426,73 @@ yaml = ["pyyaml (>=6.0.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "ddtrace"
|
||||
version = "3.13.0"
|
||||
version = "3.12.4"
|
||||
description = "Datadog APM client library"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{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"},
|
||||
{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"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -2325,6 +2325,27 @@ 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"
|
||||
@@ -2663,6 +2684,30 @@ 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"
|
||||
@@ -5387,7 +5432,7 @@ google-api-python-client = "^2.164.0"
|
||||
google-auth-httplib2 = "*"
|
||||
google-auth-oauthlib = "*"
|
||||
google-cloud-aiplatform = "*"
|
||||
google-genai = "*"
|
||||
google-generativeai = "*"
|
||||
html2text = "*"
|
||||
httpx-aiohttp = "^0.1.8"
|
||||
ipywidgets = "^8.1.5"
|
||||
@@ -5438,7 +5483,7 @@ whatthepatch = "^1.0.6"
|
||||
zope-interface = "7.2"
|
||||
|
||||
[package.extras]
|
||||
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)"]
|
||||
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)"]
|
||||
|
||||
[package.source]
|
||||
type = "directory"
|
||||
@@ -10008,4 +10053,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 = "5771671ef2acc36e7b0931c73fa035ca1d329e8dac6827f7a349e1a569c3fd23"
|
||||
content-hash = "0e611931bd3823ee8b6d832b6ef444868a644e21927a9fb72d4aeaab8170028e"
|
||||
|
||||
@@ -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.13.0" #pin to avoid yanked version 3.12.4
|
||||
ddtrace = "^3.5.1"
|
||||
posthog = "^4.2.0"
|
||||
limits = "^5.2.0"
|
||||
coredis = "^4.22.0"
|
||||
|
||||
@@ -275,7 +275,9 @@ class TokenManager:
|
||||
self._check_expiration_and_refresh
|
||||
)
|
||||
if not token_info:
|
||||
logger.info(f'No tokens for user: {username}, identity provider: {idp}')
|
||||
logger.error(
|
||||
f'No tokens for user: {username}, identity provider: {idp}'
|
||||
)
|
||||
raise ValueError(
|
||||
f'No tokens for user: {username}, identity provider: {idp}'
|
||||
)
|
||||
|
||||
@@ -28,7 +28,6 @@ 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 (
|
||||
@@ -37,11 +36,7 @@ from openhands.core.config import (
|
||||
get_llm_config_arg,
|
||||
load_from_toml,
|
||||
)
|
||||
from openhands.core.config.utils import (
|
||||
get_agent_config_arg,
|
||||
get_llms_for_routing_config,
|
||||
get_model_routing_config_arg,
|
||||
)
|
||||
from openhands.core.config.utils import get_agent_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
|
||||
@@ -62,7 +57,6 @@ AGENT_CLS_TO_INST_SUFFIX = {
|
||||
|
||||
|
||||
def get_config(
|
||||
instance: pd.Series,
|
||||
metadata: EvalMetadata,
|
||||
) -> OpenHandsConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
@@ -72,24 +66,13 @@ def get_config(
|
||||
sandbox_config=sandbox_config,
|
||||
runtime='docker',
|
||||
)
|
||||
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
|
||||
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
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)
|
||||
@@ -162,7 +145,7 @@ def process_instance(
|
||||
metadata: EvalMetadata,
|
||||
reset_logger: bool = True,
|
||||
) -> EvalOutput:
|
||||
config = get_config(instance, metadata)
|
||||
config = get_config(metadata)
|
||||
|
||||
# Setup the logger properly, so you can run multi-processing to parallelize the evaluation
|
||||
if reset_logger:
|
||||
|
||||
@@ -47,8 +47,6 @@ 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
|
||||
@@ -246,11 +244,6 @@ 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,
|
||||
@@ -258,10 +251,8 @@ 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,21 +86,28 @@ describe("Content", () => {
|
||||
expect(screen.queryByTestId("connect-git-button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render add secret button in saas mode", async () => {
|
||||
it("should render a button to connect with git if they havent already in saas", 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 called and add secret button should be available
|
||||
// In SAAS mode, getSecrets is still called because the user is authenticated
|
||||
await waitFor(() => expect(getSecretsSpy).toHaveBeenCalled());
|
||||
const button = await screen.findByTestId("add-secret-button");
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("connect-git-button")).not.toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("add-secret-button")).not.toBeInTheDocument(),
|
||||
);
|
||||
const button = await screen.findByTestId("connect-git-button");
|
||||
expect(button).toHaveAttribute("href", "/settings/integrations");
|
||||
});
|
||||
|
||||
it("should render an empty table when there are no existing secrets", async () => {
|
||||
|
||||
Generated
+114
-95
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.56.0",
|
||||
"version": "0.55.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.56.0",
|
||||
"version": "0.55.0",
|
||||
"dependencies": {
|
||||
"@heroui/react": "^2.8.3",
|
||||
"@heroui/use-infinite-scroll": "^2.2.11",
|
||||
@@ -18,9 +18,9 @@
|
||||
"@reduxjs/toolkit": "^2.9.0",
|
||||
"@stripe/react-stripe-js": "^4.0.0",
|
||||
"@stripe/stripe-js": "^7.9.0",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"@tanstack/react-query": "^5.87.0",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@tanstack/react-query": "^5.85.9",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
@@ -31,14 +31,14 @@
|
||||
"downshift": "^9.0.10",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.23.12",
|
||||
"i18next": "^25.5.2",
|
||||
"i18next": "^25.4.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.30",
|
||||
"jose": "^6.1.0",
|
||||
"lucide-react": "^0.542.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.261.7",
|
||||
"posthog-js": "^1.261.6",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -72,7 +72,7 @@
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
@@ -5526,24 +5526,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.13.tgz",
|
||||
"integrity": "sha512-eq3ouolC1oEFOAvOMOBAmfCIqZBJuvWvvYWh5h5iOYfe1HFC6+GZ6EIL0JdM3/niGRJmnrOc+8gl9/HGUaaptw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.12.tgz",
|
||||
"integrity": "sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
"enhanced-resolve": "^5.18.3",
|
||||
"jiti": "^2.5.1",
|
||||
"lightningcss": "1.30.1",
|
||||
"magic-string": "^0.30.18",
|
||||
"magic-string": "^0.30.17",
|
||||
"source-map-js": "^1.2.1",
|
||||
"tailwindcss": "4.1.13"
|
||||
"tailwindcss": "4.1.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.13.tgz",
|
||||
"integrity": "sha512-CPgsM1IpGRa880sMbYmG1s4xhAy3xEt1QULgTJGQmZUeNgXFR7s1YxYygmJyBGtou4SyEosGAGEeYqY7R53bIA==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.12.tgz",
|
||||
"integrity": "sha512-gM5EoKHW/ukmlEtphNwaGx45fGoEmP10v51t9unv55voWh6WrOL19hfuIdo2FjxIaZzw776/BUQg7Pck++cIVw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.4",
|
||||
"tar": "^7.4.3"
|
||||
@@ -5552,27 +5554,28 @@
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-android-arm64": "4.1.13",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.1.13",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.1.13",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.1.13",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.13",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.13",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.1.13",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.1.13",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.1.13",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.1.13",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.13",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.1.13"
|
||||
"@tailwindcss/oxide-android-arm64": "4.1.12",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.1.12",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.1.12",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.1.12",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.12",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.12",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.1.12",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.1.12",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.1.12",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.1.12",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.12",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.1.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||
"version": "4.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.13.tgz",
|
||||
"integrity": "sha512-BrpTrVYyejbgGo57yc8ieE+D6VT9GOgnNdmh5Sac6+t0m+v+sKQevpFVpwX3pBrM2qKrQwJ0c5eDbtjouY/+ew==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.12.tgz",
|
||||
"integrity": "sha512-oNY5pq+1gc4T6QVTsZKwZaGpBb2N1H1fsc1GD4o7yinFySqIuRZ2E4NvGasWc6PhYJwGK2+5YT1f9Tp80zUQZQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -5582,12 +5585,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||
"version": "4.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.13.tgz",
|
||||
"integrity": "sha512-YP+Jksc4U0KHcu76UhRDHq9bx4qtBftp9ShK/7UGfq0wpaP96YVnnjFnj3ZFrUAjc5iECzODl/Ts0AN7ZPOANQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.12.tgz",
|
||||
"integrity": "sha512-cq1qmq2HEtDV9HvZlTtrj671mCdGB93bVY6J29mwCyaMYCP/JaUBXxrQQQm7Qn33AXXASPUb2HFZlWiiHWFytw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -5597,12 +5601,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||
"version": "4.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.13.tgz",
|
||||
"integrity": "sha512-aAJ3bbwrn/PQHDxCto9sxwQfT30PzyYJFG0u/BWZGeVXi5Hx6uuUOQEI2Fa43qvmUjTRQNZnGqe9t0Zntexeuw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.12.tgz",
|
||||
"integrity": "sha512-6UCsIeFUcBfpangqlXay9Ffty9XhFH1QuUFn0WV83W8lGdX8cD5/+2ONLluALJD5+yJ7k8mVtwy3zMZmzEfbLg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -5612,12 +5617,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||
"version": "4.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.13.tgz",
|
||||
"integrity": "sha512-Wt8KvASHwSXhKE/dJLCCWcTSVmBj3xhVhp/aF3RpAhGeZ3sVo7+NTfgiN8Vey/Fi8prRClDs6/f0KXPDTZE6nQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.12.tgz",
|
||||
"integrity": "sha512-JOH/f7j6+nYXIrHobRYCtoArJdMJh5zy5lr0FV0Qu47MID/vqJAY3r/OElPzx1C/wdT1uS7cPq+xdYYelny1ww==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
@@ -5627,12 +5633,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||
"version": "4.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.13.tgz",
|
||||
"integrity": "sha512-mbVbcAsW3Gkm2MGwA93eLtWrwajz91aXZCNSkGTx/R5eb6KpKD5q8Ueckkh9YNboU8RH7jiv+ol/I7ZyQ9H7Bw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.12.tgz",
|
||||
"integrity": "sha512-v4Ghvi9AU1SYgGr3/j38PD8PEe6bRfTnNSUE3YCMIRrrNigCFtHZ2TCm8142X8fcSqHBZBceDx+JlFJEfNg5zQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -5642,12 +5649,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||
"version": "4.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.13.tgz",
|
||||
"integrity": "sha512-wdtfkmpXiwej/yoAkrCP2DNzRXCALq9NVLgLELgLim1QpSfhQM5+ZxQQF8fkOiEpuNoKLp4nKZ6RC4kmeFH0HQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.12.tgz",
|
||||
"integrity": "sha512-YP5s1LmetL9UsvVAKusHSyPlzSRqYyRB0f+Kl/xcYQSPLEw/BvGfxzbH+ihUciePDjiXwHh+p+qbSP3SlJw+6g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -5657,12 +5665,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||
"version": "4.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.13.tgz",
|
||||
"integrity": "sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.12.tgz",
|
||||
"integrity": "sha512-V8pAM3s8gsrXcCv6kCHSuwyb/gPsd863iT+v1PGXC4fSL/OJqsKhfK//v8P+w9ThKIoqNbEnsZqNy+WDnwQqCA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -5672,12 +5681,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||
"version": "4.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.13.tgz",
|
||||
"integrity": "sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.12.tgz",
|
||||
"integrity": "sha512-xYfqYLjvm2UQ3TZggTGrwxjYaLB62b1Wiysw/YE3Yqbh86sOMoTn0feF98PonP7LtjsWOWcXEbGqDL7zv0uW8Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -5687,12 +5697,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||
"version": "4.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.13.tgz",
|
||||
"integrity": "sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.12.tgz",
|
||||
"integrity": "sha512-ha0pHPamN+fWZY7GCzz5rKunlv9L5R8kdh+YNvP5awe3LtuXb5nRi/H27GeL2U+TdhDOptU7T6Is7mdwh5Ar3A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -5702,9 +5713,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||
"version": "4.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.13.tgz",
|
||||
"integrity": "sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.12.tgz",
|
||||
"integrity": "sha512-4tSyu3dW+ktzdEpuk6g49KdEangu3eCYoqPhWNsZgUhyegEda3M9rG0/j1GV/JjVVsj+lG7jWAyrTlLzd/WEBg==",
|
||||
"bundleDependencies": [
|
||||
"@napi-rs/wasm-runtime",
|
||||
"@emnapi/core",
|
||||
@@ -5716,6 +5727,7 @@
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.4.5",
|
||||
@@ -5784,12 +5796,13 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.13.tgz",
|
||||
"integrity": "sha512-dziTNeQXtoQ2KBXmrjCxsuPk3F3CQ/yb7ZNZNA+UkNTeiTGgfeh+gH5Pi7mRncVgcPD2xgHvkFCh/MhZWSgyQg==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz",
|
||||
"integrity": "sha512-iGLyD/cVP724+FGtMWslhcFyg4xyYyM+5F4hGvKA7eifPkXHRAUDFaimu53fpNg9X8dfP75pXx/zFt/jlNF+lg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -5799,12 +5812,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||
"version": "4.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.13.tgz",
|
||||
"integrity": "sha512-3+LKesjXydTkHk5zXX01b5KMzLV1xl2mcktBJkje7rhFUpUlYJy7IMOLqjIRQncLTa1WZZiFY/foAeB5nmaiTw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.12.tgz",
|
||||
"integrity": "sha512-NKIh5rzw6CpEodv/++r0hGLlfgT/gFN+5WNdZtvh6wpU2BpGNgdjvj6H2oFc8nCM839QM1YOhjpgbAONUb4IxA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -5814,15 +5828,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/postcss": {
|
||||
"version": "4.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.13.tgz",
|
||||
"integrity": "sha512-HLgx6YSFKJT7rJqh9oJs/TkBFhxuMOfUKSBEPYwV+t78POOBsdQ7crhZLzwcH3T0UyUuOzU/GK5pk5eKr3wCiQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.12.tgz",
|
||||
"integrity": "sha512-5PpLYhCAwf9SJEeIsSmCDLgyVfdBhdBpzX1OJ87anT9IVR0Z9pjM0FNixCAUAHGnMBGB8K99SwAheXrT0Kh6QQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@tailwindcss/node": "4.1.13",
|
||||
"@tailwindcss/oxide": "4.1.13",
|
||||
"@tailwindcss/node": "4.1.12",
|
||||
"@tailwindcss/oxide": "4.1.12",
|
||||
"postcss": "^8.4.41",
|
||||
"tailwindcss": "4.1.13"
|
||||
"tailwindcss": "4.1.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/typography": {
|
||||
@@ -5842,13 +5857,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/vite": {
|
||||
"version": "4.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.13.tgz",
|
||||
"integrity": "sha512-0PmqLQ010N58SbMTJ7BVJ4I2xopiQn/5i6nlb4JmxzQf8zcS5+m2Cv6tqh+sfDwtIdjoEnOvwsGQ1hkUi8QEHQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.12.tgz",
|
||||
"integrity": "sha512-4pt0AMFDx7gzIrAOIYgYP0KCBuKWqyW8ayrdiLEjoJTT4pKTjrzG/e4uzWtTLDziC+66R9wbUqZBccJalSE5vQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tailwindcss/node": "4.1.13",
|
||||
"@tailwindcss/oxide": "4.1.13",
|
||||
"tailwindcss": "4.1.13"
|
||||
"@tailwindcss/node": "4.1.12",
|
||||
"@tailwindcss/oxide": "4.1.12",
|
||||
"tailwindcss": "4.1.12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^5.2.0 || ^6 || ^7"
|
||||
@@ -5871,20 +5887,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.87.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.87.0.tgz",
|
||||
"integrity": "sha512-gRZig2csRl71i/HEAHlE9TOmMqKKs9WkMAqIUlzagH+sNtgjvqxwaVo2HmfNGe+iDWUak0ratSkiRv0m/Y8ijg==",
|
||||
"version": "5.85.9",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.9.tgz",
|
||||
"integrity": "sha512-5fxb9vwyftYE6KFLhhhDyLr8NO75+Wpu7pmTo+TkwKmMX2oxZDoLwcqGP8ItKSpUMwk3urWgQDZfyWr5Jm9LsQ==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.87.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.87.0.tgz",
|
||||
"integrity": "sha512-3uRCGHo7KWHl6h7ptzLd5CbrjTQP5Q/37aC1cueClkSN4t/OaNFmfGolgs1AoA0kFjP/OZxTY2ytQoifyJzpWQ==",
|
||||
"version": "5.85.9",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.9.tgz",
|
||||
"integrity": "sha512-2T5zgSpcOZXGkH/UObIbIkGmUPQqZqn7esVQFXLOze622h4spgWf5jmvrqAo9dnI13/hyMcNsF1jsoDcb59nJQ==",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.87.0"
|
||||
"@tanstack/query-core": "5.85.9"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -6148,10 +6164,11 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz",
|
||||
"integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==",
|
||||
"version": "24.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
|
||||
"integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.10.0"
|
||||
}
|
||||
@@ -10690,9 +10707,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "25.5.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.5.2.tgz",
|
||||
"integrity": "sha512-lW8Zeh37i/o0zVr+NoCHfNnfvVw+M6FQbRp36ZZ/NyHDJ3NJVpp2HhAUyU9WafL5AssymNoOjMRB48mmx2P6Hw==",
|
||||
"version": "25.4.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.4.2.tgz",
|
||||
"integrity": "sha512-gD4T25a6ovNXsfXY1TwHXXXLnD/K2t99jyYMCSimSCBnBRJVQr5j+VAaU83RJCPzrTGhVQ6dqIga66xO2rtd5g==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -10707,6 +10724,7 @@
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6"
|
||||
},
|
||||
@@ -14352,9 +14370,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.261.7",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.261.7.tgz",
|
||||
"integrity": "sha512-Fjpbz6VfIMsEbKIN/UyTWhU1DGgVIngqoRjPGRolemIMOVzTfI77OZq8WwiBhMug+rU+wNhGCQhC41qRlR5CxA==",
|
||||
"version": "1.261.6",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.261.6.tgz",
|
||||
"integrity": "sha512-tson+4i+T2YkGYlj/oGjFwKRpBFqhM7Xr9ZmXGEtNFkZc6ZQHYCzObeeHT6BbKc5d/dAfMCPtvPCKssARaK6eQ==",
|
||||
"dependencies": {
|
||||
"@posthog/core": "1.0.2",
|
||||
"core-js": "^3.38.1",
|
||||
@@ -16509,9 +16527,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.1.13",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz",
|
||||
"integrity": "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w=="
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz",
|
||||
"integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.2.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.56.0",
|
||||
"version": "0.55.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -17,9 +17,9 @@
|
||||
"@reduxjs/toolkit": "^2.9.0",
|
||||
"@stripe/react-stripe-js": "^4.0.0",
|
||||
"@stripe/stripe-js": "^7.9.0",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"@tanstack/react-query": "^5.87.0",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@tanstack/react-query": "^5.85.9",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
@@ -30,14 +30,14 @@
|
||||
"downshift": "^9.0.10",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.23.12",
|
||||
"i18next": "^25.5.2",
|
||||
"i18next": "^25.4.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.30",
|
||||
"jose": "^6.1.0",
|
||||
"lucide-react": "^0.542.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.261.7",
|
||||
"posthog-js": "^1.261.6",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -96,7 +96,7 @@
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
@@ -11,14 +12,21 @@ 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"
|
||||
@@ -61,6 +69,8 @@ function SecretsSettingsScreen() {
|
||||
setConfirmationModalIsVisible(false);
|
||||
};
|
||||
|
||||
const shouldRenderConnectToGitButton = isSaas && !hasProviderSet;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="secrets-settings-screen"
|
||||
@@ -74,7 +84,20 @@ function SecretsSettingsScreen() {
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{view === "list" && (
|
||||
{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" && (
|
||||
<BrandButton
|
||||
testId="add-secret-button"
|
||||
type="button"
|
||||
|
||||
@@ -26,6 +26,7 @@ Here are some instructions for pushing, but ONLY do this if the user asks you to
|
||||
* Use the `create_pr` tool to create a pull request, if you haven't already
|
||||
* Once you've created your own branch or a pull request, continue to update it. Do NOT create a new one unless you are explicitly asked to. Update the PR title and description as necessary, but don't change the branch name.
|
||||
* Use the main branch as the base branch, unless the user requests otherwise
|
||||
* If you need to add labels when opening a PR, check the existing labels defined on that repository and select from existing ones. Do not invent your own labels.
|
||||
* After opening or updating a pull request, send the user a short message with a link to the pull request.
|
||||
* Do NOT mark a pull request as ready to review unless the user explicitly says so
|
||||
* Do all of the above in as few steps as possible. E.g. you could push changes with one step by running the following bash commands:
|
||||
|
||||
@@ -26,6 +26,7 @@ Here are some instructions for pushing, but ONLY do this if the user asks you to
|
||||
* Use the `create_mr` tool to create a merge request, if you haven't already
|
||||
* Once you've created your own branch or a merge request, continue to update it. Do NOT create a new one unless you are explicitly asked to. Update the PR title and description as necessary, but don't change the branch name.
|
||||
* Use the main branch as the base branch, unless the user requests otherwise
|
||||
* If you need to add labels when opening a MR, check the existing labels defined on that repository and select from existing ones. Do not invent your own labels.
|
||||
* After opening or updating a merge request, send the user a short message with a link to the merge request.
|
||||
* Do all of the above in as few steps as possible. E.g. you could push changes with one step by running the following bash commands:
|
||||
```bash
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
# 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
|
||||
@@ -1,46 +0,0 @@
|
||||
.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
|
||||
@@ -1,45 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,281 +0,0 @@
|
||||
#!/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())
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/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[@]}"
|
||||
@@ -1,63 +0,0 @@
|
||||
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")
|
||||
@@ -1,110 +0,0 @@
|
||||
# -*- 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
|
||||
)
|
||||
@@ -1,3 +0,0 @@
|
||||
"""OpenHands CLI package."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -1,182 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,5 +0,0 @@
|
||||
from openhands_cli.listeners.pause_listener import PauseListener
|
||||
|
||||
__all__ = [
|
||||
"PauseListener",
|
||||
]
|
||||
@@ -1,104 +0,0 @@
|
||||
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)
|
||||
@@ -1,9 +0,0 @@
|
||||
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"
|
||||
@@ -1,29 +0,0 @@
|
||||
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])
|
||||
@@ -1,149 +0,0 @@
|
||||
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
|
||||
@@ -1,32 +0,0 @@
|
||||
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
|
||||
@@ -1,107 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,5 +0,0 @@
|
||||
from openhands_cli.tui.tui import DEFAULT_STYLE
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_STYLE",
|
||||
]
|
||||
@@ -1,205 +0,0 @@
|
||||
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)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# 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)
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
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()
|
||||
@@ -1,14 +0,0 @@
|
||||
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}"
|
||||
@@ -1,17 +0,0 @@
|
||||
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',
|
||||
]
|
||||
@@ -1,81 +0,0 @@
|
||||
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
|
||||
@@ -1,18 +0,0 @@
|
||||
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)
|
||||
@@ -1,157 +0,0 @@
|
||||
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]
|
||||
@@ -1,8 +0,0 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class UserConfirmation(Enum):
|
||||
ACCEPT = "accept"
|
||||
REJECT = "reject"
|
||||
DEFER = "defer"
|
||||
ALWAYS_ACCEPT = "always_accept"
|
||||
@@ -1,148 +0,0 @@
|
||||
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()
|
||||
@@ -1,86 +0,0 @@
|
||||
[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" }
|
||||
@@ -1 +0,0 @@
|
||||
"""Tests for OpenHands CLI."""
|
||||
@@ -1,44 +0,0 @@
|
||||
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()
|
||||
@@ -1,333 +0,0 @@
|
||||
#!/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())
|
||||
@@ -1,132 +0,0 @@
|
||||
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()
|
||||
@@ -1,77 +0,0 @@
|
||||
"""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
|
||||
@@ -1,107 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,89 +0,0 @@
|
||||
"""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'
|
||||
@@ -1,52 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,196 +0,0 @@
|
||||
"""
|
||||
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"
|
||||
@@ -1,126 +0,0 @@
|
||||
#!/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]
|
||||
@@ -1,133 +0,0 @@
|
||||
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()
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
"""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
|
||||
@@ -1,9 +0,0 @@
|
||||
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,9 +92,6 @@ 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,10 +479,11 @@ 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) if api_key and api_key.strip() else None
|
||||
settings.llm_api_key = SecretStr(api_key)
|
||||
settings.llm_base_url = None
|
||||
settings.agent = OH_DEFAULT_AGENT
|
||||
settings.enable_default_condenser = True
|
||||
|
||||
await settings_store.store(settings)
|
||||
|
||||
|
||||
@@ -607,11 +608,12 @@ async def modify_llm_settings_advanced(
|
||||
settings = Settings()
|
||||
|
||||
settings.llm_model = custom_model
|
||||
settings.llm_api_key = SecretStr(api_key) if api_key and api_key.strip() else None
|
||||
settings.llm_api_key = SecretStr(api_key)
|
||||
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)
|
||||
|
||||
|
||||
@@ -683,4 +685,5 @@ 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,7 +13,6 @@ 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
|
||||
@@ -21,8 +20,6 @@ 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,
|
||||
@@ -40,7 +37,6 @@ __all__ = [
|
||||
'LLMConfig',
|
||||
'SandboxConfig',
|
||||
'SecurityConfig',
|
||||
'ModelRoutingConfig',
|
||||
'ExtendedConfig',
|
||||
'load_openhands_config',
|
||||
'load_from_env',
|
||||
@@ -54,6 +50,4 @@ __all__ = [
|
||||
'get_evaluation_parser',
|
||||
'parse_arguments',
|
||||
'setup_config_from_args',
|
||||
'get_model_routing_config_arg',
|
||||
'get_llms_for_routing_config',
|
||||
]
|
||||
|
||||
@@ -7,7 +7,6 @@ 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
|
||||
|
||||
@@ -58,8 +57,6 @@ 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,7 +46,6 @@ 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')
|
||||
@@ -93,7 +92,6 @@ 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')
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
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,7 +30,6 @@ 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,7 +25,6 @@ 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
|
||||
@@ -226,35 +225,6 @@ 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:
|
||||
@@ -357,7 +327,6 @@ 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:
|
||||
@@ -590,41 +559,6 @@ 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:
|
||||
@@ -737,50 +671,6 @@ 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=str(runtime.workspace_root),
|
||||
working_dir=config.workspace_mount_path_in_sandbox,
|
||||
)
|
||||
|
||||
# Add MCP tools to the agent
|
||||
|
||||
@@ -31,15 +31,13 @@ def load_experiment_config(conversation_id: str) -> ExperimentConfig | None:
|
||||
class ExperimentManager:
|
||||
@staticmethod
|
||||
def run_conversation_variant_test(
|
||||
user_id: str | None,
|
||||
conversation_id: str,
|
||||
conversation_settings: ConversationInitData,
|
||||
user_id: str, conversation_id: str, conversation_settings: ConversationInitData
|
||||
) -> ConversationInitData:
|
||||
return conversation_settings
|
||||
|
||||
@staticmethod
|
||||
def run_config_variant_test(
|
||||
user_id: str | None, conversation_id: str, config: OpenHandsConfig
|
||||
user_id: str, conversation_id: str, config: OpenHandsConfig
|
||||
) -> OpenHandsConfig:
|
||||
exp_config = load_experiment_config(conversation_id)
|
||||
if exp_config and exp_config.config:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user