Compare commits

..

35 Commits

Author SHA1 Message Date
openhands 9c1484ecf3 Add custom help messages for team CLI subcommands 2025-06-06 02:47:13 +00:00
openhands 33c80d816e Simplify argument handling for team CLI 2025-06-06 02:46:29 +00:00
openhands c164063d19 Fix help message handling for team CLI subcommands 2025-06-06 02:42:02 +00:00
openhands 30f09f4aec Directly import and run team CLI module 2025-06-06 02:40:46 +00:00
openhands cb87340cb8 Fix subcommand handling in team CLI 2025-06-06 02:39:24 +00:00
openhands 15886acc3f Change --team flag to team subcommand for better argument handling 2025-06-06 02:28:00 +00:00
openhands c64eef19df Update default URL to staging.all-hands.dev 2025-06-06 02:20:26 +00:00
openhands be0bb3f388 Simplify team CLI interface and fix argument handling 2025-06-06 02:19:08 +00:00
openhands c020268f5b Add team CLI interface for OpenHands API 2025-06-06 02:10:17 +00:00
Rohit Malhotra aa54a25241 [Fix]: Broken links from cloud resolver (#8923)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-06-06 01:46:04 +00:00
Robert Brennan 0813c113f0 Fix for running git commands with the proper user (#8898) 2025-06-06 00:20:15 +00:00
tofarr 19fcf427ba Improved WebSocket Error Handling (#8924)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-06-05 16:42:18 -06:00
Rohit Malhotra 336b22bea4 [Fix]: add missing await (#8936) 2025-06-05 21:52:30 +00:00
Xingyao Wang 959268b45a chore(dependency): Update opentelemetry-api to resolve conflict with langfuse (#8930)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-06-05 20:26:51 +00:00
Robert Brennan 309c086976 Fix event stream replay during new connections by replaying before joining (#8818)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-06-05 12:37:02 -07:00
Ray Myers afd8ee61e7 Fix missing None-check in get_conversations (#8927) 2025-06-05 18:55:41 +00:00
Rohit Malhotra 93b1276768 [Feat]: Add experiment manager (#8820) 2025-06-05 14:49:20 -04:00
mamoodi 412e265745 Update OpenHands Cloud and GitHub Integrations (#8922)
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2025-06-05 17:39:35 +00:00
Ray Myers a3790f1003 0.41.0 Release Branch (#8905) 2025-06-05 17:25:18 +00:00
tofarr b76553136e Added feature flag for opening vscode in a new tab (#8917) 2025-06-05 10:32:07 -06:00
Ray Myers dee89462c2 Improve type coverage for nested runtime (#8921) 2025-06-05 16:19:53 +00:00
mamoodi ad468587ea Split quickstart and getting started workflow (#8904) 2025-06-05 10:01:36 -04:00
Calvin Smith 41cee4b68d Add unit tests for View object (#8900)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Calvin Smith <calvin@all-hands.dev>
2025-06-04 19:35:32 -06:00
tofarr 91e24a4a31 Add conversation start and stop endpoints (#8883)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-06-04 16:29:24 -06:00
tofarr a1b3c0c7d6 No more 500 error when jumping between conversations (Nested Runtimes) (#8902) 2025-06-04 15:55:26 -06:00
Mislav Lukach 738ecd468c fix(frontend): add security analyzer placeholder (#8901) 2025-06-04 20:07:23 +00:00
tofarr c6c2aafc4f Assorted fixes for the nested / docker runtimes. (#8899) 2025-06-04 13:56:03 -06:00
mamoodi 7bea93b1b6 Move the documentation tabs from top to left nav (#8892) 2025-06-04 14:46:29 -04:00
Robert Brennan d346506d34 Revert "Unrevert "Add username parameter to AsyncBashSession"" (#8897) 2025-06-04 18:45:28 +00:00
Rohit Malhotra d30c6ff720 (Hotfix): make sure MCP tool error observations are surfaced to agent (#8894) 2025-06-04 18:42:09 +00:00
Robert Brennan 80e496d134 Unrevert "Add username parameter to AsyncBashSession" (#8771)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2025-06-04 14:32:27 -04:00
Anton Sokolchenko a933a81ef5 Increase sandbox close delay in sandbox_config.py to 3600 seconds (#8889)
Co-authored-by: Robert Brennan <accounts@rbren.io>
2025-06-04 18:12:32 +00:00
tofarr 3c977bd715 Fix for nested mount volumes (#8888) 2025-06-04 09:30:57 -06:00
Graham Neubig c403973616 Add return type annotations to docker runtime (#8543)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-06-04 11:24:25 -04:00
tofarr 7652ccb000 Fix VSCode iframe SameSite cookie issue with cross-origin fallback (#8881)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-06-04 09:23:48 -06:00
68 changed files with 2199 additions and 665 deletions
+4
View File
@@ -313,6 +313,8 @@ jobs:
TEST_IN_CI=true \
RUN_AS_OPENHANDS=false \
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
env:
DEBUG: "1"
# Run unit tests with the Docker runtime Docker images as openhands user
test_runtime_oh:
@@ -378,6 +380,8 @@ jobs:
TEST_IN_CI=true \
RUN_AS_OPENHANDS=true \
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
env:
DEBUG: "1"
# The two following jobs (named identically) are to check whether all the runtime tests have passed as the
# "All Runtime Tests Passed" is a required job for PRs to merge
+6
View File
@@ -74,5 +74,11 @@ jobs:
run: poetry install --with dev,test,runtime
- name: Run Windows unit tests
run: poetry run pytest -svv tests/unit/test_windows_bash.py
env:
DEBUG: "1"
- name: Run Windows runtime tests with LocalRuntime
run: $env:TEST_RUNTIME="local"; poetry run pytest -svv tests/runtime/test_bash.py
env:
TEST_RUNTIME: local
DEBUG: "1"
-1
View File
@@ -166,7 +166,6 @@ cython_debug/
# https://stackoverflow.com/questions/32964920/should-i-commit-the-vscode-folder-to-source-control
.vscode/**/*
!.vscode/extensions.json
!.vscode/launch.json
!.vscode/settings.json
!.vscode/tasks.json
+2 -1
View File
@@ -37,7 +37,8 @@ repos:
hooks:
- id: mypy
additional_dependencies:
[types-requests, types-setuptools, types-pyyaml, types-toml]
[types-requests, types-setuptools, types-pyyaml, types-toml, types-docker, lxml]
# To see gaps add `--html-report mypy-report/`
entry: mypy --config-file dev_config/python/mypy.ini openhands/
always_run: true
pass_filenames: false
+79 -78
View File
@@ -20,7 +20,7 @@
"navigation": {
"tabs": [
{
"tab": "Getting started",
"tab": "Docs",
"pages": [
"index",
"usage/installation",
@@ -31,7 +31,7 @@
"pages": [
"usage/cloud/openhands-cloud",
{
"group": "Installation",
"group": "Integrations",
"pages": [
"usage/cloud/github-installation",
"usage/cloud/gitlab-installation"
@@ -43,104 +43,105 @@
]
},
{
"group": "Usage Methods",
"group": "Running OpenHands Locally",
"pages": [
"usage/local-setup",
"usage/how-to/gui-mode",
"usage/how-to/cli-mode",
"usage/how-to/headless-mode",
"usage/how-to/github-action"
]
}
]
},
{
"tab": "Prompting and Customization",
"pages": [
"usage/prompting/prompting-best-practices",
"usage/prompting/repository",
},
{
"group": "Microagents",
"group": "Customization",
"pages": [
"usage/prompting/microagents-overview",
"usage/prompting/microagents-repo",
"usage/prompting/microagents-keyword",
"usage/prompting/microagents-org",
"usage/prompting/microagents-public"
]
}
]
},
{
"tab": "Advanced Configuration",
"pages": [
{
"group": "LLM Configuration",
"pages": [
"usage/llms/llms",
"usage/prompting/prompting-best-practices",
"usage/prompting/repository",
{
"group": "Providers",
"group": "Microagents",
"pages": [
"usage/llms/azure-llms",
"usage/llms/google-llms",
"usage/llms/groq",
"usage/llms/local-llms",
"usage/llms/litellm-proxy",
"usage/llms/openai-llms",
"usage/llms/openrouter"
"usage/prompting/microagents-overview",
"usage/prompting/microagents-repo",
"usage/prompting/microagents-keyword",
"usage/prompting/microagents-org",
"usage/prompting/microagents-public"
]
}
]
},
{
"group": "Runtime Configuration",
"group": "Advanced Configuration",
"pages": [
"usage/runtimes/overview",
{
"group": "Providers",
"pages": [
"usage/runtimes/docker",
"usage/runtimes/remote",
"usage/runtimes/local",
{
"group": "Third-Party Providers",
"pages": [
"usage/runtimes/modal",
"usage/runtimes/daytona",
"usage/runtimes/runloop",
"usage/runtimes/e2b"
]
}
]
}
{
"group": "LLM Configuration",
"pages": [
"usage/llms/llms",
{
"group": "Providers",
"pages": [
"usage/llms/azure-llms",
"usage/llms/google-llms",
"usage/llms/groq",
"usage/llms/local-llms",
"usage/llms/litellm-proxy",
"usage/llms/openai-llms",
"usage/llms/openrouter"
]
}
]
},
{
"group": "Runtime Configuration",
"pages": [
"usage/runtimes/overview",
{
"group": "Providers",
"pages": [
"usage/runtimes/docker",
"usage/runtimes/remote",
"usage/runtimes/local",
{
"group": "Third-Party Providers",
"pages": [
"usage/runtimes/modal",
"usage/runtimes/daytona",
"usage/runtimes/runloop",
"usage/runtimes/e2b"
]
}
]
}
]
},
"usage/configuration-options",
"usage/how-to/custom-sandbox-guide",
"usage/search-engine-setup",
"usage/mcp"
]
},
"usage/configuration-options",
"usage/how-to/custom-sandbox-guide",
"usage/search-engine-setup",
"usage/mcp"
]
},
{
"tab": "Troubleshooting & Feedback",
"pages": [
"usage/troubleshooting/troubleshooting",
"usage/feedback"
]
},
{
"tab": "For OpenHands Developers",
"pages": [
"usage/how-to/development-overview",
{
"group": "Architecture",
"group": "Troubleshooting & Feedback",
"pages": [
"usage/architecture/backend",
"usage/architecture/runtime"
"usage/troubleshooting/troubleshooting",
"usage/feedback"
]
},
"usage/how-to/debugging",
"usage/how-to/evaluation-harness",
"usage/how-to/websocket-connection"
{
"group": "OpenHands Developers",
"pages": [
"usage/how-to/development-overview",
{
"group": "Architecture",
"pages": [
"usage/architecture/backend",
"usage/architecture/runtime"
]
},
"usage/how-to/debugging",
"usage/how-to/evaluation-harness",
"usage/how-to/websocket-connection"
]
}
]
},
{
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+40 -23
View File
@@ -1,30 +1,22 @@
---
title: GitHub Installation
description: This guide walks you through the process of installing and configuring OpenHands Cloud for your GitHub repositories.
title: GitHub Integration
description: This guide walks you through the process of installing OpenHands Cloud for your GitHub repositories. Once
set up, it will allow OpenHands to work with your GitHub repository through the Cloud UI or straight from GitHub issues!
---
## Prerequisites
- A GitHub account
- Access to OpenHands Cloud
- Signed in to [OpenHands Cloud](https://app.all-hands.dev) with [a GitHub account](/usage/cloud/openhands-cloud).
## Installation Steps
## Adding GitHub Repository Access
1. Log in to [OpenHands Cloud](https://app.all-hands.dev)
2. If you haven't connected your GitHub account yet:
- Click on `Connect to GitHub`
- Review and accept the terms of service
- Authorize the OpenHands AI application
You can grant OpenHands access to specific GitHub repositories:
## Adding Repository Access
You can grant OpenHands access to specific repositories:
1. Click on `Add GitHub repos`
1. Click on `Add GitHub repos` on the landing page.
2. Select your organization and choose the specific repositories to grant OpenHands access to.
- OpenHands requests short-lived tokens (8-hour expiration) with these permissions:
<Accordion title="OpenHands permissions">
- OpenHands requests short-lived tokens (8-hour expiration) with these permissions:
- Actions: Read and write
- Administration: Read-only
- Commit statuses: Read and write
- Contents: Read and write
- Issues: Read and write
@@ -35,20 +27,45 @@ You can grant OpenHands access to specific repositories:
- Repository access for a user is granted based on:
- Permission granted for the repository
- User's GitHub permissions (owner/collaborator)
3. Click `Install & Authorize`
</Accordion>
3. Click `Install & Authorize`.
## Modifying Repository Access
You can modify repository access at any time by visiting the Settings page and selecting `Configure GitHub Repositories` under the `Git` tab.
You can modify GitHub repository access at any time by:
- Selecting `Add GitHub repos` on the landing page or
- Visiting the Settings page and selecting `Configure GitHub Repositories` under the `Git` tab
## Using OpenHands with GitHub
## Working With Github Repos in Openhands Cloud
Once you've granted repository access, you can use OpenHands with your GitHub repositories.
Once you've granted GitHub repository access, you can start working with your GitHub repository. Use the `select a repo`
and `select a branch` dropdowns to select the appropriate repository and branch you'd like OpenHands to work on. Then
click on `Launch` to start the session!
For details on how to use OpenHands with GitHub issues and pull requests, see the [Cloud Issue Resolver](./cloud-issue-resolver) documentation.
![Connect Repo](/static/img/connect-repo.png)
## Working on Github Issues and Pull Requests Using Openhands
Giving GitHub repository access to OpenHands also allows you to work on GitHub issues and pull requests directly.
### Working with Issues
On your repository, label an issue with `openhands` or add a message starting with
`@openhands`. OpenHands will:
1. Comment on the issue to let you know it is working on it.
- You can click on the link to track the progress on OpenHands Cloud.
2. Open a pull request if it determines that the issue has been successfully resolved.
3. Comment on the issue with a summary of the performed tasks and a link to the PR.
### Working with Pull Requests
To get OpenHands to work on pull requests, mention `@openhands` in the comments to:
- Ask questions
- Request updates
- Get code explanations
## Next Steps
- [Access the Cloud UI](./cloud-ui) to interact with the web interface
- [Use the Cloud Issue Resolver](./cloud-issue-resolver) to automate code fixes and get assistance
- [Use the Cloud API](./cloud-api) to programmatically interact with OpenHands
+1 -1
View File
@@ -1,5 +1,5 @@
---
title: GitLab Installation
title: GitLab Integration
description: This guide walks you through the process of installing and configuring OpenHands Cloud for your GitLab repositories.
---
+3 -4
View File
@@ -1,13 +1,12 @@
---
title: Getting Started
description: Getting started with OpenHands Cloud
description: Getting started with OpenHands Cloud.
---
OpenHands Cloud is the hosted cloud version of All Hands AI's OpenHands.
## Accessing OpenHands Cloud
To get started with OpenHands Cloud, visit [app.all-hands.dev](https://app.all-hands.dev).
OpenHands Cloud is the hosted cloud version of All Hands AI's OpenHands. To get started with OpenHands Cloud,
visit [app.all-hands.dev](https://app.all-hands.dev).
You'll be prompted to connect with your GitHub or GitLab account:
+5 -159
View File
@@ -1,6 +1,6 @@
---
title: Quick Start
description: Running OpenHands on the cloud or your local desktop
description: Running OpenHands Cloud or running on your local system.
icon: rocket
---
@@ -10,164 +10,10 @@ The easiest way to get started with OpenHands is on OpenHands Cloud, which comes
To get started with OpenHands Cloud, visit [app.all-hands.dev](https://app.all-hands.dev).
You'll be prompted to connect with your GitHub or GitLab account:
For more information see [getting started with OpenHands Cloud.](/usage/cloud/openhands-cloud)
1. Click `Log in with GitHub` or `Log in with GitLab`.
2. Review the permissions requested by OpenHands and authorize the application.
- OpenHands will require certain permissions from your account. To read more about these permissions,
you can click the `Learn more` link on the authorization page.
## Running OpenHands Locally
Run OpenHands on your local system and bring your own LLM and API key.
Once you've connected your account, you can:
- [Install GitHub Integration](/usage/cloud/github-installation) to use OpenHands with your GitHub repositories
- [Install GitLab Integration](/usage/cloud/gitlab-installation) to use OpenHands with your GitLab repositories
- [Access the Cloud UI](/usage/cloud/cloud-ui) to interact with the web interface
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands
- [Set up the Cloud Issue Resolver](/usage/cloud/cloud-issue-resolver) to automate code fixes and provide intelligent assistance
## Running OpenHands on your local desktop
### System Requirements
- MacOS with [Docker Desktop support](https://docs.docker.com/desktop/setup/install/mac-install/#system-requirements)
- Linux
- Windows with [WSL](https://learn.microsoft.com/en-us/windows/wsl/install) and [Docker Desktop support](https://docs.docker.com/desktop/setup/install/windows-install/#system-requirements)
A system with a modern processor and a minimum of **4GB RAM** is recommended to run OpenHands.
### Prerequisites
<AccordionGroup>
<Accordion title="MacOS">
**Docker Desktop**
1. [Install Docker Desktop on Mac](https://docs.docker.com/desktop/setup/install/mac-install).
2. Open Docker Desktop, go to `Settings > Advanced` and ensure `Allow the default Docker socket to be used` is enabled.
</Accordion>
<Accordion title="Linux">
<Note>
Tested with Ubuntu 22.04.
</Note>
**Docker Desktop**
1. [Install Docker Desktop on Linux](https://docs.docker.com/desktop/setup/install/linux/).
</Accordion>
<Accordion title="Windows">
**WSL**
1. [Install WSL](https://learn.microsoft.com/en-us/windows/wsl/install).
2. Run `wsl --version` in powershell and confirm `Default Version: 2`.
**Docker Desktop**
1. [Install Docker Desktop on Windows](https://docs.docker.com/desktop/setup/install/windows-install).
2. Open Docker Desktop, go to `Settings` and confirm the following:
- General: `Use the WSL 2 based engine` is enabled.
- Resources > WSL Integration: `Enable integration with my default WSL distro` is enabled.
<Note>
The docker command below to start the app must be run inside the WSL terminal.
</Note>
</Accordion>
</AccordionGroup>
### Start the App
The easiest way to run OpenHands is in Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.41-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.41-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.41
```
You'll find OpenHands running at http://localhost:3000!
You can also [connect OpenHands to your local filesystem](https://docs.all-hands.dev/modules/usage/runtimes/docker#connecting-to-your-filesystem),
run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode),
interact with it via a [friendly CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),
or run it on tagged issues with [a GitHub action](https://docs.all-hands.dev/modules/usage/how-to/github-action).
### Setup
After launching OpenHands, you **must** select an `LLM Provider` and `LLM Model` and enter a corresponding `API Key`.
This can be done during the initial settings popup or by selecting the `Settings`
button (gear icon) in the UI.
If the required model does not exist in the list, in `Settings` under the `LLM` tab, you can toggle `Advanced` options
and manually enter it with the correct prefix in the `Custom Model` text box.
The `Advanced` options also allow you to specify a `Base URL` if required.
#### Getting an API Key
OpenHands requires an API key to access most language models. Here's how to get an API key from the recommended providers:
<AccordionGroup>
<Accordion title="Anthropic (Claude)">
1. [Create an Anthropic account](https://console.anthropic.com/).
2. [Generate an API key](https://console.anthropic.com/settings/keys).
3. [Set up billing](https://console.anthropic.com/settings/billing).
Consider setting usage limits to control costs.
</Accordion>
<Accordion title="OpenAI">
1. [Create an OpenAI account](https://platform.openai.com/).
2. [Generate an API key](https://platform.openai.com/api-keys).
3. [Set up billing](https://platform.openai.com/account/billing/overview).
</Accordion>
</AccordionGroup>
#### Setting Up Search Engine
OpenHands can be configured to use a search engine to allow the agent to search the web for information when needed.
Search functionality is enabled by default in OpenHands Cloud. No additional setup is required.
To enable search functionality in self-hosted OpenHands:
1. Get a Tavily API key from [tavily.com](https://tavily.com/)
2. Enter the API key in the Settings page under `LLM` tab, `Search API Key (Tavily)`
For more details, see the [Search Engine Setup](/usage/search-engine-setup) guide.
Now you're ready to [get started with OpenHands](./getting-started).
#### Versions
The [docker command above](./installation#start-the-app) pulls the most recent stable release of OpenHands. You have other options as well:
- For a specific release, replace `$VERSION` in `openhands:$VERSION` and `runtime:$VERSION`, with the version number.
We use SemVer so `0.9` will automatically point to the latest `0.9.x` release, and `0` will point to the latest `0.x.x` release.
- For the most up-to-date development version, replace `$VERSION` in `openhands:$VERSION` and `runtime:$VERSION`, with `main`.
This version is unstable and is recommended for testing or development purposes only.
For the development workflow, see [Development.md](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md).
Are you having trouble? Check out our [Troubleshooting Guide](https://docs.all-hands.dev/modules/usage/troubleshooting).
For more information see [running OpenHands locally.](/usage/local-setup)
+143
View File
@@ -0,0 +1,143 @@
---
title: Getting Started
description: Getting started with running OpenHands locally.
---
## Recommended Methods for Running Openhands on Your Local System
### System Requirements
- MacOS with [Docker Desktop support](https://docs.docker.com/desktop/setup/install/mac-install/#system-requirements)
- Linux
- Windows with [WSL](https://learn.microsoft.com/en-us/windows/wsl/install) and [Docker Desktop support](https://docs.docker.com/desktop/setup/install/windows-install/#system-requirements)
A system with a modern processor and a minimum of **4GB RAM** is recommended to run OpenHands.
### Prerequisites
<AccordionGroup>
<Accordion title="MacOS">
**Docker Desktop**
1. [Install Docker Desktop on Mac](https://docs.docker.com/desktop/setup/install/mac-install).
2. Open Docker Desktop, go to `Settings > Advanced` and ensure `Allow the default Docker socket to be used` is enabled.
</Accordion>
<Accordion title="Linux">
<Note>
Tested with Ubuntu 22.04.
</Note>
**Docker Desktop**
1. [Install Docker Desktop on Linux](https://docs.docker.com/desktop/setup/install/linux/).
</Accordion>
<Accordion title="Windows">
**WSL**
1. [Install WSL](https://learn.microsoft.com/en-us/windows/wsl/install).
2. Run `wsl --version` in powershell and confirm `Default Version: 2`.
**Docker Desktop**
1. [Install Docker Desktop on Windows](https://docs.docker.com/desktop/setup/install/windows-install).
2. Open Docker Desktop, go to `Settings` and confirm the following:
- General: `Use the WSL 2 based engine` is enabled.
- Resources > WSL Integration: `Enable integration with my default WSL distro` is enabled.
<Note>
The docker command below to start the app must be run inside the WSL terminal.
</Note>
</Accordion>
</AccordionGroup>
### Start the App
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.40-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.40-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.40
```
You'll find OpenHands running at http://localhost:3000!
### Setup
After launching OpenHands, you **must** select an `LLM Provider` and `LLM Model` and enter a corresponding `API Key`.
This can be done during the initial settings popup or by selecting the `Settings`
button (gear icon) in the UI.
If the required model does not exist in the list, in `Settings` under the `LLM` tab, you can toggle `Advanced` options
and manually enter it with the correct prefix in the `Custom Model` text box.
The `Advanced` options also allow you to specify a `Base URL` if required.
#### Getting an API Key
OpenHands requires an API key to access most language models. Here's how to get an API key from the recommended providers:
<AccordionGroup>
<Accordion title="Anthropic (Claude)">
1. [Create an Anthropic account](https://console.anthropic.com/).
2. [Generate an API key](https://console.anthropic.com/settings/keys).
3. [Set up billing](https://console.anthropic.com/settings/billing).
</Accordion>
<Accordion title="OpenAI">
1. [Create an OpenAI account](https://platform.openai.com/).
2. [Generate an API key](https://platform.openai.com/api-keys).
3. [Set up billing](https://platform.openai.com/account/billing/overview).
</Accordion>
</AccordionGroup>
Consider setting usage limits to control costs.
#### Setting Up Search Engine
OpenHands can be configured to use a search engine to allow the agent to search the web for information when needed.
To enable search functionality in OpenHands:
1. Get a Tavily API key from [tavily.com](https://tavily.com/).
2. Enter the Tavily API key in the Settings page under `LLM` tab > `Search API Key (Tavily)`
For more details, see the [Search Engine Setup](/usage/search-engine-setup) guide.
Now you're ready to [get started with OpenHands](/usage/getting-started).
### Versions
The [docker command above](/usage/local-setup#start-the-app) pulls the most recent stable release of OpenHands. You have other options as well:
- For a specific release, replace `$VERSION` in `openhands:$VERSION` and `runtime:$VERSION`, with the version number.
For example, `0.9` will automatically point to the latest `0.9.x` release, and `0` will point to the latest `0.x.x` release.
- For the most up-to-date development version, replace `$VERSION` in `openhands:$VERSION` and `runtime:$VERSION`, with `main`.
This version is unstable and is recommended for testing or development purposes only.
## Next Steps
- [Connect OpenHands to your local filesystem.](/usage/runtimes/docker#connecting-to-your-filesystem) to use OpenHands with your GitHub repositories
- [Run OpenHands in a scriptable headless mode.](/usage/how-to/headless-mode)
- [Run OpenHands with a friendly CLI.](/usage/how-to/cli-mode)
- [Run OpenHands on tagged issues with a GitHub action.](/usage/how-to/github-action)
+107
View File
@@ -0,0 +1,107 @@
---
title: Team CLI
---
# OpenHands Team CLI
The Team CLI provides a command-line interface for interacting with the OpenHands HTTP and WebSocket APIs. It allows you to create conversations, list existing conversations, and join conversations to interact with the agent.
## Getting Started
To use the Team CLI, you need to have OpenHands installed. You can then use the `team` command to access the Team CLI:
```bash
openhands team [command] [options]
```
## Configuration
The Team CLI uses the following environment variables for configuration:
- `OPENHANDS_API_URL`: The base URL for the OpenHands API (default: `https://staging.all-hands.dev`)
- `OPENHANDS_API_KEY`: The API key for authentication (if required)
You can also specify these values using command-line options:
```bash
openhands team --url https://app.all-hands.dev --api-key your-api-key [command] [options]
```
## Commands
### List Conversations
List all available conversations:
```bash
openhands team list [options]
```
Options:
- `-l, --limit`: Maximum number of conversations to list (default: 20)
### Create a Conversation
Create a new conversation:
```bash
openhands team create [options]
```
Options:
- `-r, --repository`: Repository name (format: owner/repo)
- `-g, --git-provider`: Git provider (github or gitlab)
- `-b, --branch`: Branch name
- `-m, --message`: Initial user message
- `-i, --instructions`: Conversation instructions
- `-j, --join`: Join the conversation after creation
### Join a Conversation
Join an existing conversation:
```bash
openhands team join [conversation_id]
```
## Examples
List all conversations:
```bash
openhands team list
```
Create a new conversation with a GitHub repository:
```bash
openhands team create -r All-Hands-AI/OpenHands -m "Help me understand the codebase"
```
Create a conversation and join it immediately:
```bash
openhands team create -m "Let's build a web app" -j
```
Join an existing conversation:
```bash
openhands team join abc123def456
```
## Using with a Remote Server
To use the Team CLI with a remote OpenHands server:
```bash
export OPENHANDS_API_URL="https://app.all-hands.dev"
export OPENHANDS_API_KEY="your-api-key"
openhands team list
```
Or specify the URL and API key directly:
```bash
openhands team --url https://app.all-hands.dev --api-key your-api-key list
```
@@ -47,6 +47,7 @@ const SCAN_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
// Attributes that typically don't contain user-facing text
const NON_TEXT_ATTRIBUTES = [
"allow",
"className",
"i18nKey",
"testId",
@@ -69,6 +70,7 @@ const NON_TEXT_ATTRIBUTES = [
"aria-describedby",
"aria-hidden",
"role",
"sandbox",
];
function shouldIgnorePath(filePath) {
@@ -114,6 +116,7 @@ const EXCLUDED_TECHNICAL_STRINGS = [
"add-secret-form", // Test ID for secret form
"edit-secret-form", // Test ID for secret form
"search-api-key-input", // Input name for search API key
"noopener,noreferrer", // Options for window.open
];
function isExcludedTechnicalString(str) {
+20
View File
@@ -236,6 +236,26 @@ class OpenHands {
return data;
}
static async startConversation(
conversationId: string,
): Promise<Conversation | null> {
const { data } = await openHands.post<Conversation | null>(
`/api/conversations/${conversationId}/start`,
);
return data;
}
static async stopConversation(
conversationId: string,
): Promise<Conversation | null> {
const { data } = await openHands.post<Conversation | null>(
`/api/conversations/${conversationId}/stop`,
);
return data;
}
/**
* Get the settings from the server or use the default settings if not found
*/
@@ -84,7 +84,7 @@ export function AgentStatusBar() {
setStatusMessage(t(I18nKey.STATUS$STARTING_RUNTIME));
setIndicatorColor(IndicatorColor.RED);
} else if (status === WsClientProviderStatus.DISCONNECTED) {
setStatusMessage(t(I18nKey.STATUS$CONNECTED)); // Using STATUS$CONNECTED instead of STATUS$CONNECTING
setStatusMessage(t(I18nKey.STATUS$WEBSOCKET_CLOSED));
setIndicatorColor(IndicatorColor.RED);
} else {
setStatusMessage(AGENT_STATUS_MAP[curAgentState].message);
@@ -122,7 +122,7 @@ export function FileDiffViewer({ path, type }: FileDiffViewerProps) {
modifiedEditor.onDidContentSizeChange(updateEditorHeight);
};
const status = type === "U" ? STATUS_MAP.A : STATUS_MAP[type];
const status = (type === "U" ? STATUS_MAP.A : STATUS_MAP[type]) || "?";
let statusIcon: React.ReactNode;
if (typeof status === "string") {
+15 -7
View File
@@ -150,7 +150,8 @@ export function WsClientProvider({
const { providers } = useUserProviders();
const messageRateHandler = useRate({ threshold: 250 });
const { data: conversation } = useActiveConversation();
const { data: conversation, refetch: refetchConversation } =
useActiveConversation();
function send(event: Record<string, unknown>) {
if (!sioRef.current) {
@@ -269,14 +270,11 @@ export function WsClientProvider({
sio.io.opts.query.latest_event_id = lastEventRef.current?.id;
updateStatusWhenErrorMessagePresent(data);
setErrorMessage(
hasValidMessageProperty(data)
? data.message
: "The WebSocket connection was closed.",
);
setErrorMessage(hasValidMessageProperty(data) ? data.message : "");
}
function handleError(data: unknown) {
// set status
setStatus(WsClientProviderStatus.DISCONNECTED);
updateStatusWhenErrorMessagePresent(data);
@@ -285,6 +283,9 @@ export function WsClientProvider({
? data.message
: "An unknown error occurred on the WebSocket connection.",
);
// check if something went wrong with the conversation.
refetchConversation();
}
React.useEffect(() => {
@@ -300,12 +301,19 @@ export function WsClientProvider({
if (!conversationId) {
throw new Error("No conversation ID provided");
}
if (!conversation || conversation.status === "STARTING") {
if (
!conversation ||
["STOPPED", "STARTING"].includes(conversation.status)
) {
return () => undefined; // conversation not yet loaded
}
let sio = sioRef.current;
if (sio?.connected) {
sio.disconnect();
}
const lastEvent = lastEventRef.current;
const query = {
latest_event_id: lastEvent?.id ?? -1,
@@ -1,14 +1,22 @@
import { useEffect } from "react";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useUserConversation } from "./use-user-conversation";
import OpenHands from "#/api/open-hands";
const FIVE_MINUTES = 1000 * 60 * 5;
export const useActiveConversation = () => {
const { conversationId } = useConversationId();
return useUserConversation(conversationId, (query) => {
const userConversation = useUserConversation(conversationId, (query) => {
if (query.state.data?.status === "STARTING") {
return 2000; // 2 seconds
return 3000; // 3 seconds
}
return FIVE_MINUTES;
});
useEffect(() => {
const conversation = userConversation.data;
OpenHands.setCurrentConversation(conversation || null);
}, [conversationId, userConversation.isFetched]);
return userConversation;
};
@@ -23,7 +23,6 @@ export const useUserConversation = (
queryKey: ["user", "conversation", cid],
queryFn: async () => {
const conversation = await OpenHands.getConversation(cid!);
OpenHands.setCurrentConversation(conversation);
return conversation;
},
enabled: !!cid,
+5 -1
View File
@@ -1,5 +1,6 @@
// this file generate by script, don't modify it manually!!!
export enum I18nKey {
STATUS$WEBSOCKET_CLOSED = "STATUS$WEBSOCKET_CLOSED",
HOME$LAUNCH_FROM_SCRATCH = "HOME$LAUNCH_FROM_SCRATCH",
HOME$READ_THIS = "HOME$READ_THIS",
AUTH$LOGGING_BACK_IN = "AUTH$LOGGING_BACK_IN",
@@ -138,7 +139,9 @@ export enum I18nKey {
VSCODE$LOADING = "VSCODE$LOADING",
VSCODE$URL_NOT_AVAILABLE = "VSCODE$URL_NOT_AVAILABLE",
VSCODE$FETCH_ERROR = "VSCODE$FETCH_ERROR",
VSCODE$IFRAME_PERMISSIONS = "VSCODE$IFRAME_PERMISSIONS",
VSCODE$CROSS_ORIGIN_WARNING = "VSCODE$CROSS_ORIGIN_WARNING",
VSCODE$URL_PARSE_ERROR = "VSCODE$URL_PARSE_ERROR",
VSCODE$OPEN_IN_NEW_TAB = "VSCODE$OPEN_IN_NEW_TAB",
INCREASE_TEST_COVERAGE = "INCREASE_TEST_COVERAGE",
AUTO_MERGE_PRS = "AUTO_MERGE_PRS",
FIX_README = "FIX_README",
@@ -332,6 +335,7 @@ export enum I18nKey {
SETTINGS$CONFIRMATION_MODE_TOOLTIP = "SETTINGS$CONFIRMATION_MODE_TOOLTIP",
SETTINGS$AGENT_SELECT_ENABLED = "SETTINGS$AGENT_SELECT_ENABLED",
SETTINGS$SECURITY_ANALYZER = "SETTINGS$SECURITY_ANALYZER",
SETTINGS$SECURITY_ANALYZER_PLACEHOLDER = "SETTINGS$SECURITY_ANALYZER_PLACEHOLDER",
SETTINGS$DONT_KNOW_API_KEY = "SETTINGS$DONT_KNOW_API_KEY",
SETTINGS$CLICK_FOR_INSTRUCTIONS = "SETTINGS$CLICK_FOR_INSTRUCTIONS",
SETTINGS$SAVED = "SETTINGS$SAVED",
+79 -15
View File
@@ -1,4 +1,20 @@
{
"STATUS$WEBSOCKET_CLOSED": {
"en": "The WebSocket connection was closed.",
"ja": "WebSocket接続が閉じられました。",
"zh-CN": "WebSocket连接已关闭。",
"zh-TW": "WebSocket連接已關閉。",
"ko-KR": "WebSocket 연결이 닫혔습니다.",
"no": "WebSocket-tilkoblingen ble lukket.",
"it": "La connessione WebSocket è stata chiusa.",
"pt": "A conexão WebSocket foi fechada.",
"es": "La conexión WebSocket se ha cerrado.",
"ar": "تم إغلاق اتصال WebSocket.",
"fr": "La connexion WebSocket a été fermée.",
"tr": "WebSocket bağlantısı kapatıldı.",
"de": "Die WebSocket-Verbindung wurde geschlossen.",
"uk": "З'єднання WebSocket було закрито."
},
"HOME$LAUNCH_FROM_SCRATCH": {
"en": "Launch from Scratch",
"ja": "ゼロから始める",
@@ -2207,21 +2223,53 @@
"tr": "VS Code URL'si alınamadı",
"uk": "Не вдалося отримати VS Code URL"
},
"VSCODE$IFRAME_PERMISSIONS": {
"en": "clipboard-read; clipboard-write",
"ja": "clipboard-read; clipboard-write",
"zh-CN": "clipboard-read; clipboard-write",
"zh-TW": "clipboard-read; clipboard-write",
"ko-KR": "clipboard-read; clipboard-write",
"de": "clipboard-read; clipboard-write",
"no": "clipboard-read; clipboard-write",
"it": "clipboard-read; clipboard-write",
"pt": "clipboard-read; clipboard-write",
"es": "clipboard-read; clipboard-write",
"ar": "clipboard-read; clipboard-write",
"fr": "clipboard-read; clipboard-write",
"tr": "clipboard-read; clipboard-write",
"uk": "clipboard-read; clipboard-write"
"VSCODE$CROSS_ORIGIN_WARNING": {
"en": "The code editor cannot be embedded due to browser security restrictions. Cross-origin cookies are being blocked.",
"ja": "ブラウザのセキュリティ制限により、コードエディタを埋め込むことができません。クロスオリジンCookieがブロックされています。",
"zh-CN": "由于浏览器安全限制,无法嵌入代码编辑器。跨源Cookie被阻止。",
"zh-TW": "由於瀏覽器安全限制,無法嵌入代碼編輯器。跨源Cookie被阻止。",
"ko-KR": "브라우저 보안 제한으로 인해 코드 편집기를 삽입할 수 없습니다. 교차 출처 쿠키가 차단되고 있습니다.",
"de": "Der Code-Editor kann aufgrund von Browser-Sicherheitsbeschränkungen nicht eingebettet werden. Cross-Origin-Cookies werden blockiert.",
"no": "Koderedigereren kan ikke bygges inn på grunn av nettleserens sikkerhetsbegrensninger. Cross-origin cookies blir blokkert.",
"it": "L'editor di codice non può essere incorporato a causa delle restrizioni di sicurezza del browser. I cookie cross-origin vengono bloccati.",
"pt": "O editor de código não pode ser incorporado devido a restrições de segurança do navegador. Cookies de origem cruzada estão sendo bloqueados.",
"es": "El editor de código no se puede incrustar debido a las restricciones de seguridad del navegador. Las cookies de origen cruzado están siendo bloqueadas.",
"ar": "لا يمكن تضمين محرر التعليمات البرمجية بسبب قيود أمان المتصفح. يتم حظر ملفات تعريف الارتباط عبر المصدر.",
"fr": "L'éditeur de code ne peut pas être intégré en raison des restrictions de sécurité du navigateur. Les cookies cross-origin sont bloqués.",
"tr": "Tarayıcı güvenlik kısıtlamaları nedeniyle kod düzenleyici yerleştirilemiyor. Çapraz kaynaklı çerezler engelleniyor.",
"uk": "Редактор коду не може бути вбудований через обмеження безпеки браузера. Блокуються файли cookie з різних джерел."
},
"VSCODE$URL_PARSE_ERROR": {
"en": "Error parsing URL",
"ja": "URLの解析エラー",
"zh-CN": "URL解析错误",
"zh-TW": "URL解析錯誤",
"ko-KR": "URL 구문 분석 오류",
"de": "Fehler beim Parsen der URL",
"no": "Feil ved parsing av URL",
"it": "Errore durante l'analisi dell'URL",
"pt": "Erro ao analisar URL",
"es": "Error al analizar URL",
"ar": "خطأ في تحليل عنوان URL",
"fr": "Erreur d'analyse de l'URL",
"tr": "URL ayrıştırma hatası",
"uk": "Помилка аналізу URL"
},
"VSCODE$OPEN_IN_NEW_TAB": {
"en": "Open in New Tab",
"ja": "新しいタブで開く",
"zh-CN": "在新标签页中打开",
"zh-TW": "在新標籤頁中打開",
"ko-KR": "새 탭에서 열기",
"de": "In neuem Tab öffnen",
"no": "Åpne i ny fane",
"it": "Apri in una nuova scheda",
"pt": "Abrir em nova aba",
"es": "Abrir en nueva pestaña",
"ar": "فتح في علامة تبويب جديدة",
"fr": "Ouvrir dans un nouvel onglet",
"tr": "Yeni Sekmede Aç",
"uk": "Відкрити в новій вкладці"
},
"INCREASE_TEST_COVERAGE": {
"en": "Increase test coverage",
@@ -5311,6 +5359,22 @@
"ja": "セキュリティアナライザー",
"uk": "Увімкнути аналізатор безпеки"
},
"SETTINGS$SECURITY_ANALYZER_PLACEHOLDER":{
"en": "Select a security analyzer…",
"de": "Wählen Sie einen Sicherheitsanalysator aus…",
"zh-CN": "选择一个安全分析器…",
"zh-TW": "選擇一個安全分析器…",
"ko-KR": "보안 분석기를 선택하세요…",
"no": "Velg en sikkerhetsanalysator…",
"it": "Seleziona un analizzatore di sicurezza…",
"pt": "Selecione um analisador de segurança…",
"es": "Seleccione un analizador de seguridad…",
"ar": "اختر محلل الأمان…",
"fr": "Sélectionnez un analyseur de sécurité…",
"tr": "Bir güvenlik analizörü seçin…",
"ja": "セキュリティアナライザーを選択…",
"uk": "Виберіть аналізатор безпеки…"
},
"SETTINGS$DONT_KNOW_API_KEY": {
"en": "Don't know your API key?",
"ja": "APIキーがわかりませんか?",
+7 -2
View File
@@ -43,7 +43,7 @@ function AppContent() {
const { t } = useTranslation();
const { data: settings } = useSettings();
const { conversationId } = useConversationId();
const { data: conversation, isFetched } = useActiveConversation();
const { data: conversation, isFetched, refetch } = useActiveConversation();
const { data: isAuthed } = useIsAuthed();
const { curAgentState } = useSelector((state: RootState) => state.agent);
@@ -61,8 +61,13 @@ function AppContent() {
"This conversation does not exist, or you do not have permission to access it.",
);
navigate("/");
} else if (conversation?.status === "STOPPED") {
// start the conversation if the state is stopped on initial load
OpenHands.startConversation(conversation.conversation_id).then(() =>
refetch(),
);
}
}, [conversation, isFetched, isAuthed]);
}, [conversation?.conversation_id, isFetched, isAuthed]);
React.useEffect(() => {
dispatch(clearTerminal());
+3
View File
@@ -469,6 +469,9 @@ function LlmSettingsScreen() {
label: analyzer,
})) || []
}
placeholder={t(
I18nKey.SETTINGS$SECURITY_ANALYZER_PLACEHOLDER,
)}
defaultSelectedKey={settings.SECURITY_ANALYZER}
isClearable
showOptionalTag
+52 -4
View File
@@ -1,10 +1,11 @@
import React from "react";
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { I18nKey } from "#/i18n/declaration";
import { RootState } from "#/store";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { useVSCodeUrl } from "#/hooks/query/use-vscode-url";
import { VSCODE_IN_NEW_TAB } from "#/utils/feature-flags";
function VSCodeTab() {
const { t } = useTranslation();
@@ -12,6 +13,31 @@ function VSCodeTab() {
const { curAgentState } = useSelector((state: RootState) => state.agent);
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
const iframeRef = React.useRef<HTMLIFrameElement>(null);
const [isCrossProtocol, setIsCrossProtocol] = useState(false);
const [iframeError, setIframeError] = useState<string | null>(null);
useEffect(() => {
if (data?.url) {
try {
const iframeProtocol = new URL(data.url).protocol;
const currentProtocol = window.location.protocol;
// Check if the iframe URL has a different protocol than the current page
setIsCrossProtocol(
VSCODE_IN_NEW_TAB() || iframeProtocol !== currentProtocol,
);
} catch (e) {
// Silently handle URL parsing errors
setIframeError(t("VSCODE$URL_PARSE_ERROR"));
}
}
}, [data?.url]);
const handleOpenInNewTab = () => {
if (data?.url) {
window.open(data.url, "_blank", "noopener,noreferrer");
}
};
if (isRuntimeInactive) {
return (
@@ -29,14 +55,36 @@ function VSCodeTab() {
);
}
if (error || (data && data.error) || !data?.url) {
if (error || (data && data.error) || !data?.url || iframeError) {
return (
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
{data?.error || String(error) || t(I18nKey.VSCODE$URL_NOT_AVAILABLE)}
{iframeError ||
data?.error ||
String(error) ||
t(I18nKey.VSCODE$URL_NOT_AVAILABLE)}
</div>
);
}
// If cross-origin, show a button to open in new tab
if (isCrossProtocol) {
return (
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
<div className="text-xl text-tertiary-light text-center max-w-md">
{t("VSCODE$CROSS_ORIGIN_WARNING")}
</div>
<button
type="button"
onClick={handleOpenInNewTab}
className="px-4 py-2 bg-primary text-white rounded hover:bg-primary-dark transition-colors"
>
{t("VSCODE$OPEN_IN_NEW_TAB")}
</button>
</div>
);
}
// If same origin, use the iframe
return (
<div className="h-full w-full">
<iframe
@@ -44,7 +92,7 @@ function VSCodeTab() {
title={t(I18nKey.VSCODE$TITLE)}
src={data.url}
className="w-full h-full border-0"
allow={t(I18nKey.VSCODE$IFRAME_PERMISSIONS)}
allow="clipboard-read; clipboard-write"
/>
</div>
);
+1
View File
@@ -14,5 +14,6 @@ export function loadFeatureFlag(
export const BILLING_SETTINGS = () => loadFeatureFlag("BILLING_SETTINGS");
export const HIDE_LLM_SETTINGS = () => loadFeatureFlag("HIDE_LLM_SETTINGS");
export const VSCODE_IN_NEW_TAB = () => loadFeatureFlag("VSCODE_IN_NEW_TAB");
export const ENABLE_TRAJECTORY_REPLAY = () =>
loadFeatureFlag("TRAJECTORY_REPLAY");
+31 -1
View File
@@ -1,7 +1,6 @@
import asyncio
import logging
import os
import sys
from prompt_toolkit import print_formatted_text
from prompt_toolkit.formatted_text import HTML
@@ -454,6 +453,37 @@ async def main_with_loop(loop: asyncio.AbstractEventLoop) -> None:
def main():
args = parse_arguments()
# Check if team command is used
if hasattr(args, 'command') and args.command == 'team':
# Import and run the team CLI directly
import sys
from openhands.cli.team import main as team_main
# Get arguments after 'team'
team_args = []
if len(sys.argv) > 2:
# Pass all arguments after 'team'
team_args = sys.argv[2:]
if not team_args:
# If no additional arguments, show help message
print('OpenHands Team CLI')
print('=================')
print('To use the team CLI, run one of the following commands:')
print(' openhands team list - List all conversations')
print(' openhands team create - Create a new conversation')
print(' openhands team join <id> - Join an existing conversation')
print()
print("For more information, run 'openhands team --help'")
return
# Run the team CLI with the arguments
team_main(team_args)
return
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
+549
View File
@@ -0,0 +1,549 @@
"""Team CLI interface for OpenHands.
This module provides a CLI interface for interacting with the OpenHands HTTP and WebSocket APIs.
It allows creating conversations and showing the current list of conversations/statuses.
"""
import argparse
import asyncio
import os
import sys
from datetime import datetime
from typing import Any, Optional
import aiohttp
import socketio
from prompt_toolkit import print_formatted_text
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.shortcuts import clear
from rich.console import Console
from rich.table import Table
from openhands.cli.tui import (
display_banner,
display_event,
display_welcome_message,
read_prompt_input,
)
from openhands.core.schema import AgentState
from openhands.events.action import MessageAction
from openhands.events.serialization import event_from_dict, event_to_dict
class TeamClient:
"""Client for interacting with the OpenHands HTTP and WebSocket APIs."""
def __init__(self, base_url: str, api_key: Optional[str] = None):
"""Initialize the TeamClient.
Args:
base_url: The base URL for the OpenHands API.
api_key: Optional API key for authentication.
"""
self.base_url = base_url.rstrip('/')
self.api_key = api_key
self.sio = socketio.AsyncClient()
self.console = Console()
self.headers = {}
if api_key:
self.headers['Authorization'] = f'Bearer {api_key}'
async def list_conversations(self, limit: int = 20) -> list[dict[str, Any]]:
"""List conversations.
Args:
limit: Maximum number of conversations to return.
Returns:
List of conversation objects.
"""
async with aiohttp.ClientSession(headers=self.headers) as session:
async with session.get(
f'{self.base_url}/api/conversations?limit={limit}'
) as response:
if response.status != 200:
error_text = await response.text()
raise Exception(f'Failed to list conversations: {error_text}')
data = await response.json()
return data.get('results', [])
async def create_conversation(
self,
repository: Optional[str] = None,
git_provider: Optional[str] = None,
selected_branch: Optional[str] = None,
initial_user_msg: Optional[str] = None,
conversation_instructions: Optional[str] = None,
) -> str:
"""Create a new conversation.
Args:
repository: Optional repository name (owner/repo).
git_provider: Optional git provider (github or gitlab).
selected_branch: Optional branch name.
initial_user_msg: Optional initial user message.
conversation_instructions: Optional conversation instructions.
Returns:
The conversation ID.
"""
payload = {
'repository': repository,
'git_provider': git_provider,
'selected_branch': selected_branch,
'initial_user_msg': initial_user_msg,
'conversation_instructions': conversation_instructions,
}
# Remove None values
payload = {k: v for k, v in payload.items() if v is not None}
async with aiohttp.ClientSession(headers=self.headers) as session:
async with session.post(
f'{self.base_url}/api/conversations', json=payload
) as response:
if response.status != 200:
error_text = await response.text()
raise Exception(f'Failed to create conversation: {error_text}')
data = await response.json()
return data.get('conversation_id')
async def get_conversation(self, conversation_id: str) -> dict[str, Any]:
"""Get conversation details.
Args:
conversation_id: The conversation ID.
Returns:
Conversation details.
"""
async with aiohttp.ClientSession(headers=self.headers) as session:
async with session.get(
f'{self.base_url}/api/conversations/{conversation_id}'
) as response:
if response.status != 200:
error_text = await response.text()
raise Exception(f'Failed to get conversation: {error_text}')
return await response.json()
async def connect_to_conversation(
self, conversation_id: str, latest_event_id: int = -1
) -> None:
"""Connect to a conversation via WebSocket.
Args:
conversation_id: The conversation ID.
latest_event_id: The latest event ID to start from.
"""
# Set up event handlers
@self.sio.event
async def connect():
self.console.print('[green]Connected to conversation[/green]')
@self.sio.event
async def disconnect():
self.console.print('[yellow]Disconnected from conversation[/yellow]')
@self.sio.event
async def oh_event(data):
event = event_from_dict(data)
# Create a dummy config object to satisfy the type checker
from openhands.core.config import OpenHandsConfig
dummy_config = OpenHandsConfig()
display_event(event, dummy_config)
# Connect to the WebSocket
query = {
'conversation_id': conversation_id,
'latest_event_id': str(latest_event_id),
}
if self.api_key:
query['session_api_key'] = self.api_key
await self.sio.connect(
f'{self.base_url}',
headers=self.headers,
transports=['websocket'],
socketio_path='socket.io',
wait_timeout=10,
query=query,
)
async def send_message(self, message: str) -> None:
"""Send a message to the conversation.
Args:
message: The message to send.
"""
event = MessageAction(content=message)
event_dict = event_to_dict(event)
await self.sio.emit('oh_user_action', event_dict)
async def disconnect(self) -> None:
"""Disconnect from the WebSocket."""
await self.sio.disconnect()
async def list_conversations_cmd(client: TeamClient, args: argparse.Namespace) -> None:
"""List conversations command.
Args:
client: The TeamClient instance.
args: Command line arguments.
"""
conversations = await client.list_conversations(limit=args.limit)
if not conversations:
print('No conversations found.')
return
table = Table(title='Conversations')
table.add_column('ID', style='cyan')
table.add_column('Title', style='green')
table.add_column('Status', style='magenta')
table.add_column('Repository', style='blue')
table.add_column('Last Updated', style='yellow')
table.add_column('Created', style='yellow')
for convo in conversations:
# Format dates
created_at = datetime.fromisoformat(convo['created_at'].replace('Z', '+00:00'))
last_updated_at = datetime.fromisoformat(
convo['last_updated_at'].replace('Z', '+00:00')
)
created_str = created_at.strftime('%Y-%m-%d %H:%M:%S')
updated_str = last_updated_at.strftime('%Y-%m-%d %H:%M:%S')
# Add row to table
table.add_row(
convo['conversation_id'],
convo['title'],
convo['status'],
convo.get('selected_repository', ''),
updated_str,
created_str,
)
client.console.print(table)
async def create_conversation_cmd(client: TeamClient, args: argparse.Namespace) -> None:
"""Create a conversation command.
Args:
client: The TeamClient instance.
args: Command line arguments.
"""
initial_message = args.message
# If no message provided, prompt for one
if not initial_message:
print_formatted_text(HTML('<green>Enter your initial message:</green>'))
initial_message = input('> ')
try:
conversation_id = await client.create_conversation(
repository=args.repository,
git_provider=args.git_provider,
selected_branch=args.branch,
initial_user_msg=initial_message,
conversation_instructions=args.instructions,
)
print_formatted_text(
HTML(f'<green>Conversation created with ID: {conversation_id}</green>')
)
if args.join:
await join_conversation_cmd(
client, argparse.Namespace(conversation_id=conversation_id)
)
except Exception as e:
print_formatted_text(HTML(f'<red>Error creating conversation: {str(e)}</red>'))
async def join_conversation_cmd(client: TeamClient, args: argparse.Namespace) -> None:
"""Join a conversation command.
Args:
client: The TeamClient instance.
args: Command line arguments.
"""
conversation_id = args.conversation_id
try:
# Get conversation details
conversation = await client.get_conversation(conversation_id)
# Clear screen and show banner
clear()
display_banner(session_id=conversation_id)
# Show conversation title
title = conversation.get('title', 'Untitled Conversation')
display_welcome_message(f'Joined conversation: {title}')
# Connect to the WebSocket
await client.connect_to_conversation(conversation_id)
# Main conversation loop
try:
while True:
next_message = await read_prompt_input(
AgentState.AWAITING_USER_INPUT.value
)
if not next_message.strip():
continue
if next_message.lower() in ['exit', 'quit', '/exit', '/quit']:
break
await client.send_message(next_message)
except KeyboardInterrupt:
print('\nDisconnecting...')
finally:
await client.disconnect()
except Exception as e:
print_formatted_text(HTML(f'<red>Error joining conversation: {str(e)}</red>'))
def get_base_url() -> str:
"""Get the base URL for the OpenHands API.
Returns:
The base URL.
"""
# Check environment variables first
base_url = os.environ.get('OPENHANDS_API_URL')
if base_url:
return base_url
# Default to staging server
return 'https://staging.all-hands.dev'
def get_api_key() -> Optional[str]:
"""Get the API key for authentication.
Returns:
The API key, or None if not found.
"""
return os.environ.get('OPENHANDS_API_KEY')
def setup_parser() -> argparse.ArgumentParser:
"""Set up the argument parser for the team CLI.
Returns:
The argument parser.
"""
parser = argparse.ArgumentParser(description='OpenHands Team CLI')
parser.formatter_class = argparse.ArgumentDefaultsHelpFormatter
# Server configuration
parser.add_argument(
'--url',
help='OpenHands API URL (default: $OPENHANDS_API_URL or https://staging.all-hands.dev)',
)
parser.add_argument(
'--api-key', help='OpenHands API key (default: $OPENHANDS_API_KEY)'
)
subparsers = parser.add_subparsers(dest='command', help='Command to run')
# List conversations command
list_parser = subparsers.add_parser(
'list',
help='List conversations',
description='List all available conversations',
)
list_parser.add_argument(
'-l',
'--limit',
type=int,
default=20,
help='Maximum number of conversations to list',
)
# Add help formatter
list_parser.formatter_class = argparse.ArgumentDefaultsHelpFormatter
# Create conversation command
create_parser = subparsers.add_parser(
'create',
help='Create a new conversation',
description='Create a new conversation with optional repository and message',
)
create_parser.add_argument(
'-r', '--repository', help='Repository name (owner/repo)'
)
create_parser.add_argument(
'-g', '--git-provider', help='Git provider (github or gitlab)'
)
create_parser.add_argument('-b', '--branch', help='Branch name')
create_parser.add_argument('-m', '--message', help='Initial user message')
create_parser.add_argument('-i', '--instructions', help='Conversation instructions')
create_parser.add_argument(
'-j', '--join', action='store_true', help='Join the conversation after creation'
)
# Add help formatter
create_parser.formatter_class = argparse.ArgumentDefaultsHelpFormatter
# Join conversation command
join_parser = subparsers.add_parser(
'join',
help='Join an existing conversation',
description='Join an existing conversation by ID',
)
join_parser.add_argument('conversation_id', help='Conversation ID')
# Add help formatter
join_parser.formatter_class = argparse.ArgumentDefaultsHelpFormatter
return parser
async def main_async(args: argparse.Namespace) -> None:
"""Main async function for the team CLI.
Args:
args: Command line arguments.
"""
# Get base URL and API key
base_url = args.url or get_base_url()
api_key = args.api_key or get_api_key()
# Create client
client = TeamClient(base_url, api_key)
# Run command
if args.command == 'list':
await list_conversations_cmd(client, args)
elif args.command == 'create':
await create_conversation_cmd(client, args)
elif args.command == 'join':
await join_conversation_cmd(client, args)
else:
print('No command specified. Use --help for usage information.')
def main(args: Optional[list[str]] = None) -> None:
"""Main function for the team CLI.
Args:
args: Command line arguments.
"""
parser = setup_parser()
# If no arguments provided, show help
if not args or len(args) == 0:
parser.print_help()
return
# Special case for subcommand help
if (
len(args) >= 2
and args[0] in ['list', 'create', 'join']
and args[1] in ['-h', '--help']
):
# Create a new parser just for this subcommand
if args[0] == 'list':
subparser = argparse.ArgumentParser(
description='List all available conversations',
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
subparser.add_argument(
'-l',
'--limit',
type=int,
default=20,
help='Maximum number of conversations to list',
)
subparser.add_argument(
'--url',
help='OpenHands API URL (default: $OPENHANDS_API_URL or https://staging.all-hands.dev)',
)
subparser.add_argument(
'--api-key',
help='OpenHands API key (default: $OPENHANDS_API_KEY)',
)
subparser.print_help()
return
elif args[0] == 'create':
subparser = argparse.ArgumentParser(
description='Create a new conversation with optional repository and message',
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
subparser.add_argument(
'-r',
'--repository',
help='Repository name (owner/repo)',
)
subparser.add_argument(
'-g',
'--git-provider',
help='Git provider (github or gitlab)',
)
subparser.add_argument('-b', '--branch', help='Branch name')
subparser.add_argument('-m', '--message', help='Initial user message')
subparser.add_argument(
'-i',
'--instructions',
help='Conversation instructions',
)
subparser.add_argument(
'-j',
'--join',
action='store_true',
help='Join the conversation after creation',
)
subparser.add_argument(
'--url',
help='OpenHands API URL (default: $OPENHANDS_API_URL or https://staging.all-hands.dev)',
)
subparser.add_argument(
'--api-key',
help='OpenHands API key (default: $OPENHANDS_API_KEY)',
)
subparser.print_help()
return
elif args[0] == 'join':
subparser = argparse.ArgumentParser(
description='Join an existing conversation by ID',
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
subparser.add_argument('conversation_id', help='Conversation ID')
subparser.add_argument(
'--url',
help='OpenHands API URL (default: $OPENHANDS_API_URL or https://staging.all-hands.dev)',
)
subparser.add_argument(
'--api-key',
help='OpenHands API key (default: $OPENHANDS_API_KEY)',
)
subparser.print_help()
return
try:
parsed_args = parser.parse_args(args)
# If no command specified, show help
if not parsed_args.command:
parser.print_help()
return
# Run the command
asyncio.run(main_async(parsed_args))
except KeyboardInterrupt:
print('\nOperation cancelled by user.')
except Exception as e:
print(f'Error: {str(e)}')
sys.exit(1)
if __name__ == '__main__':
main()
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
# Get the Python executable
PYTHON_EXE=$(which python)
# Run the team CLI
$PYTHON_EXE -m openhands.cli.team "$@"
+76
View File
@@ -0,0 +1,76 @@
"""Create conversation command for the OpenHands Team CLI."""
import argparse
import sys
from typing import Optional
from openhands.cli.team import TeamClient
def setup_parser() -> argparse.ArgumentParser:
"""Set up the argument parser for the create command.
Returns:
The argument parser.
"""
parser = argparse.ArgumentParser(
description='Create a new conversation with optional repository and message',
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument('-r', '--repository', help='Repository name (owner/repo)')
parser.add_argument('-g', '--git-provider', help='Git provider (github or gitlab)')
parser.add_argument('-b', '--branch', help='Branch name')
parser.add_argument('-m', '--message', help='Initial user message')
parser.add_argument('-i', '--instructions', help='Conversation instructions')
parser.add_argument(
'-j', '--join', action='store_true', help='Join the conversation after creation'
)
parser.add_argument(
'--url',
help='OpenHands API URL (default: $OPENHANDS_API_URL or http://localhost:3000)',
)
parser.add_argument(
'--api-key', help='OpenHands API key (default: $OPENHANDS_API_KEY)'
)
return parser
async def create_conversation(args: argparse.Namespace) -> None:
"""Create a conversation command.
Args:
args: Command line arguments.
"""
# Create client
client = TeamClient(args.url, args.api_key)
try:
# Create conversation
await client.create_conversation(
repository=args.repository,
git_provider=args.git_provider,
selected_branch=args.branch,
initial_user_msg=args.message,
conversation_instructions=args.instructions,
)
except Exception as e:
print(f'Error creating conversation: {e}')
sys.exit(1)
def main(args: Optional[list[str]] = None) -> None:
"""Main function for the create command.
Args:
args: Command line arguments.
"""
parser = setup_parser()
parsed_args = parser.parse_args(args)
import asyncio
asyncio.run(create_conversation(parsed_args))
if __name__ == '__main__':
main()
+63
View File
@@ -0,0 +1,63 @@
"""Join conversation command for the OpenHands Team CLI."""
import argparse
import sys
from typing import Optional
from openhands.cli.team import TeamClient, join_conversation_cmd
def setup_parser() -> argparse.ArgumentParser:
"""Set up the argument parser for the join command.
Returns:
The argument parser.
"""
parser = argparse.ArgumentParser(
description='Join an existing conversation by ID',
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument('conversation_id', help='Conversation ID')
parser.add_argument(
'--url',
help='OpenHands API URL (default: $OPENHANDS_API_URL or http://localhost:3000)',
)
parser.add_argument(
'--api-key', help='OpenHands API key (default: $OPENHANDS_API_KEY)'
)
return parser
async def join_conversation(args: argparse.Namespace) -> None:
"""Join a conversation command.
Args:
args: Command line arguments.
"""
# Create client
client = TeamClient(args.url, args.api_key)
try:
# Join conversation
await join_conversation_cmd(client, args)
except Exception as e:
print(f'Error joining conversation: {e}')
sys.exit(1)
def main(args: Optional[list[str]] = None) -> None:
"""Main function for the join command.
Args:
args: Command line arguments.
"""
parser = setup_parser()
parsed_args = parser.parse_args(args)
import asyncio
asyncio.run(join_conversation(parsed_args))
if __name__ == '__main__':
main()
+69
View File
@@ -0,0 +1,69 @@
"""List conversations command for the OpenHands Team CLI."""
import argparse
import sys
from typing import Optional
from openhands.cli.team import TeamClient
def setup_parser() -> argparse.ArgumentParser:
"""Set up the argument parser for the list command.
Returns:
The argument parser.
"""
parser = argparse.ArgumentParser(
description='List all available conversations',
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
'-l',
'--limit',
type=int,
default=20,
help='Maximum number of conversations to list',
)
parser.add_argument(
'--url',
help='OpenHands API URL (default: $OPENHANDS_API_URL or http://localhost:3000)',
)
parser.add_argument(
'--api-key', help='OpenHands API key (default: $OPENHANDS_API_KEY)'
)
return parser
async def list_conversations(args: argparse.Namespace) -> None:
"""List conversations command.
Args:
args: Command line arguments.
"""
# Create client
client = TeamClient(args.url, args.api_key)
try:
# List conversations
await client.list_conversations(limit=args.limit)
except Exception as e:
print(f'Error listing conversations: {e}')
sys.exit(1)
def main(args: Optional[list[str]] = None) -> None:
"""Main function for the list command.
Args:
args: Command line arguments.
"""
parser = setup_parser()
parsed_args = parser.parse_args(args)
import asyncio
asyncio.run(list_conversations(parsed_args))
if __name__ == '__main__':
main()
+4 -1
View File
@@ -73,7 +73,10 @@ class SandboxConfig(BaseModel):
runtime_startup_env_vars: dict[str, str] = Field(default_factory=dict)
browsergym_eval_env: str | None = Field(default=None)
platform: str | None = Field(default=None)
close_delay: int = Field(default=15)
close_delay: int = Field(
default=3600,
description='The delay in seconds before closing the sandbox after the agent is done.',
)
remote_runtime_resource_factor: int = Field(default=1)
enable_gpu: bool = Field(default=False)
docker_runtime_kwargs: dict | None = Field(default=None)
+14 -1
View File
@@ -744,13 +744,26 @@ def get_parser() -> argparse.ArgumentParser:
type=bool,
default=False,
)
# Add team subcommand
subparsers = parser.add_subparsers(dest='command')
subparsers.add_parser(
'team', help='Use team mode to interact with the OpenHands API'
)
# We'll handle the team subcommands separately
return parser
def parse_arguments() -> argparse.Namespace:
"""Parse command line arguments."""
parser = get_parser()
args = parser.parse_args()
# Check if 'team' command is present
if len(sys.argv) > 1 and sys.argv[1] == 'team':
# Only parse known arguments, ignoring any team-specific arguments
args, _ = parser.parse_known_args()
else:
# Parse all arguments normally
args = parser.parse_args()
if args.version:
print(f'OpenHands version: {__version__}')
@@ -0,0 +1,19 @@
import os
from openhands.server.session.conversation_init_data import ConversationInitData
from openhands.utils.import_utils import get_impl
class ExperimentManager:
@staticmethod
def run_conversation_variant_test(
user_id: str, conversation_id: str, conversation_settings: ConversationInitData
) -> ConversationInitData:
return conversation_settings
experiment_manager_cls = os.environ.get(
'OPENHANDS_EXPERIMENT_MANAGER_CLS',
'openhands.experiments.experiment_manager.ExperimentManager',
)
ExperimentManagerImpl = get_impl(ExperimentManager, experiment_manager_cls)
+19 -24
View File
@@ -483,35 +483,30 @@ class GitHubService(BaseGitService, GitService):
- PR URL when successful
- Error message when unsuccessful
"""
try:
url = f'{self.BASE_URL}/repos/{repo_name}/pulls'
# Set default body if none provided
if not body:
body = f'Merging changes from {source_branch} into {target_branch}'
url = f'{self.BASE_URL}/repos/{repo_name}/pulls'
# Prepare the request payload
payload = {
'title': title,
'head': source_branch,
'base': target_branch,
'body': body,
'draft': draft,
}
# Set default body if none provided
if not body:
body = f'Merging changes from {source_branch} into {target_branch}'
# Make the POST request to create the PR
response, _ = await self._make_request(
url=url, params=payload, method=RequestMethod.POST
)
# Prepare the request payload
payload = {
'title': title,
'head': source_branch,
'base': target_branch,
'body': body,
'draft': draft,
}
# Return the HTML URL of the created PR
if 'html_url' in response:
return response['html_url']
else:
return f'PR created but URL not found in response: {response}'
# Make the POST request to create the PR
response, _ = await self._make_request(
url=url, params=payload, method=RequestMethod.POST
)
# Return the HTML URL of the created PR
return response['html_url']
except Exception as e:
return f'Error creating pull request: {str(e)}'
github_service_cls = os.environ.get(
+22 -27
View File
@@ -476,38 +476,33 @@ class GitLabService(BaseGitService, GitService):
- MR URL when successful
- Error message when unsuccessful
"""
try:
# Convert string ID to URL-encoded path if needed
project_id = str(id).replace('/', '%2F') if isinstance(id, str) else id
url = f'{self.BASE_URL}/projects/{project_id}/merge_requests'
# Set default description if none provided
if not description:
description = (
f'Merging changes from {source_branch} into {target_branch}'
)
# Convert string ID to URL-encoded path if needed
project_id = str(id).replace('/', '%2F') if isinstance(id, str) else id
url = f'{self.BASE_URL}/projects/{project_id}/merge_requests'
# Prepare the request payload
payload = {
'source_branch': source_branch,
'target_branch': target_branch,
'title': title,
'description': description,
}
# Make the POST request to create the MR
response, _ = await self._make_request(
url=url, params=payload, method=RequestMethod.POST
# Set default description if none provided
if not description:
description = (
f'Merging changes from {source_branch} into {target_branch}'
)
# Return the web URL of the created MR
if 'web_url' in response:
return response['web_url']
else:
return f'MR created but URL not found in response: {response}'
# Prepare the request payload
payload = {
'source_branch': source_branch,
'target_branch': target_branch,
'title': title,
'description': description,
}
# Make the POST request to create the MR
response, _ = await self._make_request(
url=url, params=payload, method=RequestMethod.POST
)
return response['web_url']
except Exception as e:
return f'Error creating merge request: {str(e)}'
gitlab_service_cls = os.environ.get(
+2 -2
View File
@@ -167,11 +167,11 @@ class BaseGitService(ABC):
return RateLimitError('GitHub API rate limit exceeded')
logger.warning(f'Status error on {self.provider} API: {e}')
return UnknownException('Unknown error')
return UnknownException(f'Unknown error: {e}')
def handle_http_error(self, e: HTTPError) -> UnknownException:
logger.warning(f'HTTP error on {self.provider} API: {type(e).__name__} : {e}')
return UnknownException(f'HTTP error {type(e).__name__}')
return UnknownException(f'HTTP error {type(e).__name__} : {e}')
class GitService(Protocol):
@@ -15,4 +15,3 @@ When you're done, make sure to
2. Use the `create_pr` tool to open a new PR
3. Name the branch using `openhands/` as a prefix (e.g `openhands/update-readme`)
4. The PR description should mention that it "fixes" or "closes" the issue number
5. Make sure to leave the following sentence at the end of the PR description: `@{{ username }} can click here to [continue refining the PR]({{ conversation_url }})`
@@ -9,4 +9,3 @@ When you're done, make sure to
1. Use the `create_pr` tool to open a new PR
2. The PR description should mention that it "fixes" or "closes" the issue number
3. Make sure to leave the following sentence at the end of the PR description: `@{{ username }} can click here to [continue refining the PR]({{ conversation_url }})`
@@ -15,4 +15,3 @@ When you're done, make sure to
2. Use the `create_mr` tool to open a new MR
3. Name the branch using `openhands/` as a prefix (e.g `openhands/update-readme`)
4. The MR description should mention that it "fixes" or "closes" the issue number
5. Make sure to leave the following sentence at the end of the MR description: `@{{ username }} can click here to [continue refining the MR]({{ conversation_url }})`
@@ -9,4 +9,3 @@ When you're done, make sure to
1. Use the `create_mr` tool to open a new MR
2. The MR description should mention that it "fixes" or "closes" the issue number
3. Make sure to leave the following sentence at the end of the MR description: `@{{ username }} can click here to [continue refining the MR]({{ conversation_url }})`
@@ -5,7 +5,3 @@ These are a list of text messages attached in order of most recent.
{{ message }}
{% if not loop.last %}\n\n{% endif %}
{% endfor %}
If you opened a pull request, please leave the following comment at the end your summary and pull request description
`{{ username }} can click here to [continue refining the PR]({{ conversation_url }})`
+2
View File
@@ -50,6 +50,8 @@ class View(BaseModel):
for event in events:
if isinstance(event, CondensationAction):
forgotten_event_ids.update(event.forgotten)
# Make sure we also forget the condensation action itself
forgotten_event_ids.add(event.id)
kept_events = [event for event in events if event.id not in forgotten_event_ids]
+16 -20
View File
@@ -65,7 +65,6 @@ from openhands.runtime.browser.browser_env import BrowserEnv
from openhands.runtime.file_viewer_server import start_file_viewer_server
from openhands.runtime.plugins import ALL_PLUGINS, JupyterPlugin, Plugin, VSCodePlugin
from openhands.runtime.utils import find_available_tcp_port
from openhands.runtime.utils.async_bash import AsyncBashSession
from openhands.runtime.utils.bash import BashSession
from openhands.runtime.utils.files import insert_lines, read_lines
from openhands.runtime.utils.log_capture import capture_logs
@@ -254,12 +253,10 @@ class ActionExecutor:
# If we get here, the browser is ready
logger.debug('Browser is ready')
async def ainit(self):
# bash needs to be initialized first
logger.debug('Initializing bash session')
def _create_bash_session(self, cwd: str | None = None):
if sys.platform == 'win32':
self.bash_session = WindowsPowershellSession( # type: ignore[name-defined]
work_dir=self._initial_cwd,
return WindowsPowershellSession( # type: ignore[name-defined]
work_dir=cwd or self._initial_cwd,
username=self.username,
no_change_timeout_seconds=int(
os.environ.get('NO_CHANGE_TIMEOUT_SECONDS', 10)
@@ -267,15 +264,21 @@ class ActionExecutor:
max_memory_mb=self.max_memory_gb * 1024 if self.max_memory_gb else None,
)
else:
self.bash_session = BashSession(
work_dir=self._initial_cwd,
bash_session = BashSession(
work_dir=cwd or self._initial_cwd,
username=self.username,
no_change_timeout_seconds=int(
os.environ.get('NO_CHANGE_TIMEOUT_SECONDS', 10)
),
max_memory_mb=self.max_memory_gb * 1024 if self.max_memory_gb else None,
)
self.bash_session.initialize()
bash_session.initialize()
return bash_session
async def ainit(self):
# bash needs to be initialized first
logger.debug('Initializing bash session')
self.bash_session = self._create_bash_session()
logger.debug('Bash session initialized')
# Start browser initialization in the background
@@ -388,18 +391,11 @@ class ActionExecutor:
self, action: CmdRunAction
) -> CmdOutputObservation | ErrorObservation:
try:
bash_session = self.bash_session
if action.is_static:
path = action.cwd or self._initial_cwd
result = await AsyncBashSession.execute(action.command, path)
obs = CmdOutputObservation(
content=result.content,
exit_code=result.exit_code,
command=action.command,
)
return obs
assert self.bash_session is not None
obs = await call_sync_from_async(self.bash_session.execute, action)
bash_session = self._create_bash_session(action.cwd)
assert bash_session is not None
obs = await call_sync_from_async(bash_session.execute, action)
return obs
except Exception as e:
logger.error(f'Error running command: {e}')
+4 -1
View File
@@ -400,7 +400,7 @@ class Runtime(FileEditRuntimeMixin):
'No repository selected. Initializing a new git repository in the workspace.'
)
action = CmdRunAction(
command='git init',
command=f'git init && git config --global --add safe.directory {self.workspace_root}'
)
self.run_action(action)
else:
@@ -952,6 +952,9 @@ fi
exit_code = 0
content = ''
if isinstance(obs, ErrorObservation):
exit_code = -1
if hasattr(obs, 'exit_code'):
exit_code = obs.exit_code
if hasattr(obs, 'content'):
@@ -406,7 +406,7 @@ class ActionExecutionClient(Runtime):
'POST',
f'{self.action_execution_server_url}/update_mcp_server',
json=stdio_tools,
timeout=10,
timeout=60,
)
result = response.json()
if response.status_code != 200:
@@ -464,7 +464,9 @@ class ActionExecutionClient(Runtime):
)
# Create clients for this specific operation
mcp_clients = await create_mcp_clients(updated_mcp_config.sse_servers, updated_mcp_config.shttp_servers, self.sid)
mcp_clients = await create_mcp_clients(
updated_mcp_config.sse_servers, updated_mcp_config.shttp_servers, self.sid
)
# Call the tool and return the result
# No need for try/finally since disconnect() is now just resetting state
+34 -43
View File
@@ -1,6 +1,7 @@
import os
from functools import lru_cache
from typing import Callable
import typing
from uuid import UUID
import docker
@@ -41,7 +42,7 @@ APP_PORT_RANGE_1 = (50000, 54999)
APP_PORT_RANGE_2 = (55000, 59999)
def _is_retryablewait_until_alive_error(exception):
def _is_retryablewait_until_alive_error(exception: Exception) -> bool:
if isinstance(exception, tenacity.RetryError):
cause = exception.last_attempt.exception()
return _is_retryablewait_until_alive_error(cause)
@@ -140,10 +141,10 @@ class DockerRuntime(ActionExecutionClient):
)
@property
def action_execution_server_url(self):
def action_execution_server_url(self) -> str:
return self.api_url
async def connect(self):
async def connect(self) -> None:
self.send_status_message('STATUS$STARTING_RUNTIME')
try:
await call_sync_from_async(self._attach_to_container)
@@ -164,7 +165,7 @@ class DockerRuntime(ActionExecutionClient):
f'Container started: {self.container_name}. VSCode URL: {self.vscode_url}',
)
if DEBUG_RUNTIME:
if DEBUG_RUNTIME and self.container:
self.log_streamer = LogStreamer(self.container, self.log)
else:
self.log_streamer = None
@@ -264,7 +265,7 @@ class DockerRuntime(ActionExecutionClient):
return volumes
def init_container(self):
def init_container(self) -> None:
self.log('debug', 'Preparing to start container...')
self.send_status_message('STATUS$PREPARING_CONTAINER')
self._host_port = self._find_available_port(EXECUTION_SERVER_PORT_RANGE)
@@ -281,7 +282,7 @@ class DockerRuntime(ActionExecutionClient):
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
use_host_network = self.config.sandbox.use_host_network
network_mode: str | None = 'host' if use_host_network else None
network_mode: typing.Literal['host'] | None = 'host' if use_host_network else None
# Initialize port mappings
port_mapping: dict[str, list[dict[str, str]]] | None = None
@@ -317,15 +318,18 @@ class DockerRuntime(ActionExecutionClient):
)
# Combine environment variables
environment = {
'port': str(self._container_port),
'PYTHONUNBUFFERED': '1',
# Passing in the ports means nested runtimes do not come up with their own ports!
'VSCODE_PORT': str(self._vscode_port),
'APP_PORT_1': self._app_ports[0],
'APP_PORT_2': self._app_ports[1],
'PIP_BREAK_SYSTEM_PACKAGES': '1',
}
environment = dict(**self.initial_env_vars)
environment.update(
{
'port': str(self._container_port),
'PYTHONUNBUFFERED': '1',
# Passing in the ports means nested runtimes do not come up with their own ports!
'VSCODE_PORT': str(self._vscode_port),
'APP_PORT_1': str(self._app_ports[0]),
'APP_PORT_2': str(self._app_ports[1]),
'PIP_BREAK_SYSTEM_PACKAGES': '1',
}
)
if self.config.debug or DEBUG:
environment['DEBUG'] = 'true'
# also update with runtime_startup_env_vars
@@ -350,6 +354,8 @@ class DockerRuntime(ActionExecutionClient):
command = self.get_action_execution_server_startup_command()
try:
if self.runtime_container_image is None:
raise ValueError("Runtime container image is not set")
self.container = self.docker_client.containers.run(
self.runtime_container_image,
command=command,
@@ -361,7 +367,7 @@ class DockerRuntime(ActionExecutionClient):
name=self.container_name,
detach=True,
environment=environment,
volumes=volumes,
volumes=volumes, # type: ignore
device_requests=(
[docker.types.DeviceRequest(capabilities=[['gpu']], count=-1)]
if self.config.sandbox.enable_gpu
@@ -371,32 +377,15 @@ class DockerRuntime(ActionExecutionClient):
)
self.log('debug', f'Container started. Server url: {self.api_url}')
self.send_status_message('STATUS$CONTAINER_STARTED')
except docker.errors.APIError as e:
if '409' in str(e):
self.log(
'warning',
f'Container {self.container_name} already exists. Removing...',
)
stop_all_containers(self.container_name)
return self.init_container()
else:
self.log(
'error',
f'Error: Instance {self.container_name} FAILED to start container!\n',
)
self.log('error', str(e))
raise e
except Exception as e:
self.log(
'error',
f'Error: Instance {self.container_name} FAILED to start container!\n',
)
self.log('error', str(e))
self.close()
raise e
def _attach_to_container(self):
def _attach_to_container(self) -> None:
self.container = self.docker_client.containers.get(self.container_name)
if self.container.status == 'exited':
self.container.start()
@@ -432,7 +421,7 @@ class DockerRuntime(ActionExecutionClient):
reraise=True,
wait=tenacity.wait_fixed(2),
)
def wait_until_alive(self):
def wait_until_alive(self) -> None:
try:
container = self.docker_client.containers.get(self.container_name)
if container.status == 'exited':
@@ -446,7 +435,7 @@ class DockerRuntime(ActionExecutionClient):
self.check_if_alive()
def close(self, rm_all_containers: bool | None = None):
def close(self, rm_all_containers: bool | None = None) -> None:
"""Closes the DockerRuntime and associated objects
Parameters:
@@ -466,7 +455,7 @@ class DockerRuntime(ActionExecutionClient):
)
stop_all_containers(close_prefix)
def _is_port_in_use_docker(self, port):
def _is_port_in_use_docker(self, port: int) -> bool:
containers = self.docker_client.containers.list()
for container in containers:
container_ports = container.ports
@@ -474,7 +463,9 @@ class DockerRuntime(ActionExecutionClient):
return True
return False
def _find_available_port(self, port_range, max_attempts=5):
def _find_available_port(
self, port_range: tuple[int, int], max_attempts: int = 5
) -> int:
port = port_range[1]
for _ in range(max_attempts):
port = find_available_tcp_port(port_range[0], port_range[1])
@@ -493,7 +484,7 @@ class DockerRuntime(ActionExecutionClient):
return vscode_url
@property
def web_hosts(self):
def web_hosts(self) -> dict[str, int]:
hosts: dict[str, int] = {}
host_addr = os.environ.get('DOCKER_HOST_ADDR', 'localhost')
@@ -502,7 +493,7 @@ class DockerRuntime(ActionExecutionClient):
return hosts
def pause(self):
def pause(self) -> None:
"""Pause the runtime by stopping the container.
This is different from container.stop() as it ensures environment variables are properly preserved."""
if not self.container:
@@ -515,7 +506,7 @@ class DockerRuntime(ActionExecutionClient):
self.container.stop()
self.log('debug', f'Container {self.container_name} paused')
def resume(self):
def resume(self) -> None:
"""Resume the runtime by starting the container.
This is different from container.start() as it ensures environment variables are properly restored."""
if not self.container:
@@ -529,7 +520,7 @@ class DockerRuntime(ActionExecutionClient):
self.wait_until_alive()
@classmethod
async def delete(cls, conversation_id: str):
async def delete(cls, conversation_id: str) -> None:
docker_client = cls._init_docker_client()
try:
container_name = CONTAINER_NAME_PREFIX + conversation_id
@@ -542,7 +533,7 @@ class DockerRuntime(ActionExecutionClient):
finally:
docker_client.close()
def get_action_execution_server_startup_command(self):
def get_action_execution_server_startup_command(self) -> list[str]:
return get_action_execution_server_startup_command(
server_port=self._container_port,
plugins=self.plugins,
-54
View File
@@ -1,54 +0,0 @@
import asyncio
import os
from openhands.runtime.base import CommandResult
class AsyncBashSession:
@staticmethod
async def execute(command: str, work_dir: str) -> CommandResult:
"""Execute a command in the bash session asynchronously."""
work_dir = os.path.abspath(work_dir)
if not os.path.exists(work_dir):
raise ValueError(f'Work directory {work_dir} does not exist.')
command = command.strip()
if not command:
return CommandResult(content='', exit_code=0)
try:
process = await asyncio.subprocess.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=work_dir,
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(), timeout=30
)
output = stdout.decode('utf-8')
if stderr:
output = stderr.decode('utf-8')
print(f'!##! Error running command: {stderr.decode("utf-8")}')
return CommandResult(content=output, exit_code=process.returncode or 0)
except asyncio.TimeoutError:
process.terminate()
# Allow a brief moment for cleanup
try:
await asyncio.wait_for(process.wait(), timeout=1.0)
except asyncio.TimeoutError:
process.kill() # Force kill if it doesn't terminate cleanly
return CommandResult(content='Command timed out.', exit_code=-1)
except Exception as e:
return CommandResult(
content=f'Error running command: {str(e)}', exit_code=-1
)
+13 -9
View File
@@ -44,7 +44,7 @@ class GitHandler:
Returns:
bool: True if inside a Git repository, otherwise False.
"""
cmd = 'git rev-parse --is-inside-work-tree'
cmd = 'git --no-pager rev-parse --is-inside-work-tree'
output = self.execute(cmd, self.cwd)
return output.content.strip() == 'true'
@@ -71,7 +71,7 @@ class GitHandler:
Returns:
bool: True if the reference exists, otherwise False.
"""
cmd = f'git rev-parse --verify {ref}'
cmd = f'git --no-pager rev-parse --verify {ref}'
output = self.execute(cmd, self.cwd)
return output.exit_code == 0
@@ -86,9 +86,9 @@ class GitHandler:
default_branch = self._get_default_branch()
ref_current_branch = f'origin/{current_branch}'
ref_non_default_branch = f'$(git merge-base HEAD "$(git rev-parse --abbrev-ref origin/{default_branch})")'
ref_non_default_branch = f'$(git --no-pager merge-base HEAD "$(git --no-pager rev-parse --abbrev-ref origin/{default_branch})")'
ref_default_branch = 'origin/' + default_branch
ref_new_repo = '$(git rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)' # compares with empty tree
ref_new_repo = '$(git --no-pager rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)' # compares with empty tree
refs = [
ref_current_branch,
@@ -116,7 +116,7 @@ class GitHandler:
if not ref:
return ''
cmd = f'git show {ref}:{file_path}'
cmd = f'git --no-pager show {ref}:{file_path}'
output = self.execute(cmd, self.cwd)
return output.content if output.exit_code == 0 else ''
@@ -127,7 +127,7 @@ class GitHandler:
Returns:
str: The name of the primary branch.
"""
cmd = 'git remote show origin | grep "HEAD branch"'
cmd = 'git --no-pager remote show origin | grep "HEAD branch"'
output = self.execute(cmd, self.cwd)
return output.content.split()[-1].strip()
@@ -138,7 +138,7 @@ class GitHandler:
Returns:
str: The name of the current branch.
"""
cmd = 'git rev-parse --abbrev-ref HEAD'
cmd = 'git --no-pager rev-parse --abbrev-ref HEAD'
output = self.execute(cmd, self.cwd)
return output.content.strip()
@@ -153,8 +153,12 @@ class GitHandler:
if not ref:
return []
diff_cmd = f'git diff --name-status {ref}'
diff_cmd = f'git --no-pager diff --name-status {ref}'
output = self.execute(diff_cmd, self.cwd)
if output.exit_code != 0:
raise RuntimeError(
f'Failed to get diff for ref {ref} in {self.cwd}. Command output: {output.content}'
)
return output.content.splitlines()
def _get_untracked_files(self) -> list[dict[str, str]]:
@@ -164,7 +168,7 @@ class GitHandler:
Returns:
list[dict[str, str]]: A list of dictionaries containing file paths and statuses.
"""
cmd = 'git ls-files --others --exclude-standard'
cmd = 'git --no-pager ls-files --others --exclude-standard'
output = self.execute(cmd, self.cwd)
obs_list = output.content.splitlines()
return (
+2 -2
View File
@@ -48,12 +48,12 @@ class ServerConfig(ServerConfigInterface):
return config
def load_server_config():
def load_server_config() -> ServerConfig:
config_cls = os.environ.get('OPENHANDS_CONFIG_CLS', None)
logger.info(f'Using config class {config_cls}')
server_config_cls = get_impl(ServerConfig, config_cls)
server_config = server_config_cls()
server_config : ServerConfig = server_config_cls()
server_config.verify_config()
return server_config
@@ -54,6 +54,7 @@ class DockerNestedConversationManager(ConversationManager):
docker_client: docker.DockerClient = field(default_factory=docker.from_env)
_conversation_store_class: type[ConversationStore] | None = None
_starting_conversation_ids: set[str] = field(default_factory=set)
_runtime_container_image: str | None = None
async def __aenter__(self):
# No action is required on startup for this implementation
@@ -89,7 +90,8 @@ class DockerNestedConversationManager(ConversationManager):
"""
Get the running agent loops directly from docker.
"""
names = (container.name for container in self.docker_client.containers.list())
containers : list[Container] = self.docker_client.containers.list()
names = (container.name or '' for container in containers)
conversation_ids = {
name[len('openhands-runtime-') :]
for name in names
@@ -155,6 +157,12 @@ class DockerNestedConversationManager(ConversationManager):
try:
# Build the runtime container image if it is missing
await call_sync_from_async(runtime.maybe_build_runtime_container_image)
self._runtime_container_image = runtime.runtime_container_image
# check that the container already exists...
if await self._start_existing_container(runtime):
self._starting_conversation_ids.discard(sid)
return
# initialize the container but dont wait for it to start
await call_sync_from_async(runtime.init_container)
@@ -172,7 +180,7 @@ class DockerNestedConversationManager(ConversationManager):
)
except Exception:
self._starting_conversation_ids.remove(sid)
self._starting_conversation_ids.discard(sid)
raise
async def _start_conversation(
@@ -262,7 +270,7 @@ class DockerNestedConversationManager(ConversationManager):
)
assert response.status_code == status.HTTP_200_OK
finally:
self._starting_conversation_ids.remove(sid)
self._starting_conversation_ids.discard(sid)
async def send_to_event_stream(self, connection_id: str, data: dict):
# Not supported - clients should connect directly to the nested server!
@@ -273,13 +281,29 @@ class DockerNestedConversationManager(ConversationManager):
raise ValueError('unsupported_operation')
async def close_session(self, sid: str):
stop_all_containers(f'openhands-runtime-{sid}')
# First try to graceful stop server.
try:
container = self.docker_client.containers.get(f'openhands-runtime-{sid}')
except docker.errors.NotFound as e:
return
try:
nested_url = self.get_nested_url_for_container(container)
async with httpx.AsyncClient(
headers={
'X-Session-API-Key': self._get_session_api_key_for_conversation(sid)
}
) as client:
response = await client.post(f'{nested_url}/api/conversations/{sid}/stop')
response.raise_for_status()
except Exception:
logger.exception("error_stopping_container")
container.stop()
async def get_agent_loop_info(self, user_id=None, filter_to_sids=None):
async def get_agent_loop_info(self, user_id: str | None = None, filter_to_sids: set[str] | None = None) -> list[AgentLoopInfo]:
results = []
containers = self.docker_client.containers.list()
containers : list[Container] = self.docker_client.containers.list()
for container in containers:
if not container.name.startswith('openhands-runtime-'):
if not container.name or not container.name.startswith('openhands-runtime-'):
continue
conversation_id = container.name[len('openhands-runtime-') :]
if filter_to_sids is not None and conversation_id not in filter_to_sids:
@@ -342,11 +366,12 @@ class DockerNestedConversationManager(ConversationManager):
def get_nested_url_for_container(self, container: Container) -> str:
env = container.attrs['Config']['Env']
container_port = int(next(e[5:] for e in env if e.startswith('port=')))
conversation_id = container.name[len('openhands-runtime-') :]
container_name = container.name or ''
conversation_id = container_name[len('openhands-runtime-') :]
nested_url = f'{self.config.sandbox.local_runtime_url}:{container_port}/api/conversations/{conversation_id}'
return nested_url
def _get_session_api_key_for_conversation(self, conversation_id: str):
def _get_session_api_key_for_conversation(self, conversation_id: str) -> str:
jwt_secret = self.config.jwt_secret.get_secret_value() # type:ignore
conversation_key = f'{jwt_secret}:{conversation_id}'.encode()
session_api_key = (
@@ -356,7 +381,7 @@ class DockerNestedConversationManager(ConversationManager):
)
return session_api_key
async def ensure_num_conversations_below_limit(self, sid: str, user_id: str | None):
async def ensure_num_conversations_below_limit(self, sid: str, user_id: str | None) -> None:
response_ids = await self.get_running_agent_loops(user_id)
if len(response_ids) >= self.config.max_concurrent_conversations:
logger.info(
@@ -388,7 +413,7 @@ class DockerNestedConversationManager(ConversationManager):
)
await self.close_session(oldest_conversation_id)
def _get_provider_handler(self, settings: Settings):
def _get_provider_handler(self, settings: Settings) -> ProviderHandler:
provider_tokens = None
if isinstance(settings, ConversationInitData):
provider_tokens = settings.git_provider_tokens
@@ -398,7 +423,7 @@ class DockerNestedConversationManager(ConversationManager):
)
return provider_handler
async def _create_runtime(self, sid: str, user_id: str | None, settings: Settings):
async def _create_runtime(self, sid: str, user_id: str | None, settings: Settings) -> DockerRuntime:
# This session is created here only because it is the easiest way to get a runtime, which
# is the easiest way to create the needed docker container
session = Session(
@@ -431,6 +456,7 @@ class DockerNestedConversationManager(ConversationManager):
# We need to be able to specify the nested conversation id within the nested runtime
env_vars['ALLOW_SET_CONVERSATION_ID'] = '1'
env_vars['WORKSPACE_BASE'] = f'/workspace'
env_vars['SANDBOX_CLOSE_DELAY'] = '0'
# Set up mounted volume for conversation directory within workspace
# TODO: Check if we are using the standard event store and file store
@@ -440,10 +466,13 @@ class DockerNestedConversationManager(ConversationManager):
else:
volumes = [v.strip() for v in config.sandbox.volumes.split(',')]
conversation_dir = get_conversation_dir(sid, user_id)
volumes.append(
f'{config.file_store_path}/{conversation_dir}:{OpenHandsConfig.model_fields["file_store_path"].default}/{conversation_dir}:rw'
f'{config.file_store_path}/{conversation_dir}:/root/.openhands/file_store/{conversation_dir}:rw'
)
config.sandbox.volumes = ','.join(volumes)
if not config.sandbox.runtime_container_image:
config.sandbox.runtime_container_image = self._runtime_container_image
# Currently this eventstream is never used and only exists because one is required in order to create a docker runtime
event_stream = EventStream(sid, self.file_store, user_id)
@@ -463,6 +492,18 @@ class DockerNestedConversationManager(ConversationManager):
return runtime
async def _start_existing_container(self, runtime: DockerRuntime) -> bool:
try:
container = self.docker_client.containers.get(runtime.container_name)
if container:
status = container.status
if status == 'exited':
await call_sync_from_async(container.start)
return True
return False
except docker.errors.NotFound as e:
return False
def _last_updated_at_key(conversation: ConversationMetadata) -> float:
last_updated_at = conversation.last_updated_at
@@ -111,7 +111,8 @@ class StandaloneConversationManager(ConversationManager):
return None
end_time = time.time()
logger.info(
f'ServerConversation {c.sid} connected in {end_time - start_time} seconds'
f'ServerConversation {c.sid} connected in {end_time - start_time} seconds',
extra={'session_id': sid}
)
self._active_conversations[sid] = (c, 1)
return c
@@ -154,6 +155,10 @@ class StandaloneConversationManager(ConversationManager):
await conversation.disconnect()
self._detached_conversations.pop(sid, None)
# Implies disconnected sandboxes stay open indefinitely
if not self.config.sandbox.close_delay:
return
close_threshold = time.time() - self.config.sandbox.close_delay
running_loops = list(self._local_agent_loops_by_sid.items())
running_loops.sort(key=lambda item: item[1].last_active_ts)
@@ -364,7 +369,9 @@ class StandaloneConversationManager(ConversationManager):
f'removing connections: {connection_ids_to_remove}',
extra={'session_id': sid},
)
# Perform a graceful shutdown of each connection
for connection_id in connection_ids_to_remove:
await self.sio.disconnect(connection_id)
self._local_connection_id_to_session_id.pop(connection_id, None)
session = self._local_agent_loops_by_sid.pop(sid, None)
+45 -15
View File
@@ -12,6 +12,7 @@ from openhands.events.action import (
)
from openhands.events.action.agent import RecallAction
from openhands.events.async_event_store_wrapper import AsyncEventStoreWrapper
from openhands.events.event_store import EventStore
from openhands.events.observation import (
NullObservation,
)
@@ -19,6 +20,7 @@ from openhands.events.observation.agent import (
AgentStateChangedObservation,
)
from openhands.events.serialization import event_to_dict
from openhands.experiments.experiment_manager import ExperimentManagerImpl
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderToken
from openhands.integrations.service_types import ProviderType
from openhands.server.session.conversation_init_data import ConversationInitData
@@ -49,7 +51,7 @@ def create_provider_tokens_object(
async def setup_init_convo_settings(
user_id: str | None, providers_set: list[ProviderType]
user_id: str | None, conversation_id: str, providers_set: list[ProviderType]
) -> ConversationInitData:
settings_store = await SettingsStoreImpl.get_instance(config, user_id)
settings = await settings_store.load()
@@ -73,7 +75,11 @@ async def setup_init_convo_settings(
if user_secrets:
session_init_args['custom_secrets'] = user_secrets.custom_secrets
return ConversationInitData(**session_init_args)
convo_init_data = ConversationInitData(**session_init_args)
# We should recreate the same experiment conditions when restarting a conversation
return ExperimentManagerImpl.run_conversation_variant_test(
user_id, conversation_id, convo_init_data
)
@sio.event
@@ -119,24 +125,28 @@ async def connect(connection_id: str, environ: dict) -> None:
f'User {user_id} is allowed to connect to conversation {conversation_id}'
)
conversation_init_data = await setup_init_convo_settings(user_id, providers_set)
agent_loop_info = await conversation_manager.join_conversation(
conversation_id,
connection_id,
conversation_init_data,
user_id,
)
try:
event_store = EventStore(
conversation_id, conversation_manager.file_store, user_id
)
except FileNotFoundError as e:
logger.error(
f'Failed to create EventStore for conversation {conversation_id}: {e}'
)
raise ConnectionRefusedError(f'Failed to access conversation events: {e}')
logger.info(
f'Connected to conversation {conversation_id} with connection_id {connection_id}. Replaying event stream...'
f'Replaying event stream for conversation {conversation_id} with connection_id {connection_id}...'
)
agent_state_changed = None
if agent_loop_info is None:
raise ConnectionRefusedError('Failed to join conversation')
async_store = AsyncEventStoreWrapper(
agent_loop_info.event_store, latest_event_id + 1
)
# Create an async store to replay events
async_store = AsyncEventStoreWrapper(event_store, latest_event_id + 1)
# Process all available events
async for event in async_store:
logger.debug(f'oh_event: {event.__class__.__name__}')
if isinstance(
event,
(NullAction, NullObservation, RecallAction),
@@ -146,13 +156,33 @@ async def connect(connection_id: str, environ: dict) -> None:
agent_state_changed = event
else:
await sio.emit('oh_event', event_to_dict(event), to=connection_id)
# Send the agent state changed event last if we have one
if agent_state_changed:
await sio.emit(
'oh_event', event_to_dict(agent_state_changed), to=connection_id
)
logger.info(
f'Finished replaying event stream for conversation {conversation_id}'
)
conversation_init_data = await setup_init_convo_settings(
user_id, conversation_id, providers_set
)
agent_loop_info = await conversation_manager.join_conversation(
conversation_id,
connection_id,
conversation_init_data,
user_id,
)
if agent_loop_info is None:
raise ConnectionRefusedError('Failed to join conversation')
logger.info(
f'Successfully joined conversation {conversation_id} with connection_id {connection_id}'
)
except ConnectionRefusedError:
# Close the broken connection after sending an error message
asyncio.create_task(sio.disconnect(connection_id))
+1 -1
View File
@@ -145,5 +145,5 @@ async def search_events(
@app.post('/events')
async def add_event(request: Request, conversation: ServerConversation = Depends(get_conversation)):
data = request.json()
conversation_manager.send_to_event_stream(conversation.sid, data)
await conversation_manager.send_to_event_stream(conversation.sid, data)
return JSONResponse({'success': True})
+113 -5
View File
@@ -1,4 +1,3 @@
import asyncio
import os
import uuid
from datetime import datetime, timezone
@@ -36,6 +35,7 @@ from openhands.server.user_auth import (
get_provider_tokens,
get_user_id,
get_user_secrets,
get_user_settings,
)
from openhands.server.user_auth.user_auth import AuthType
from openhands.server.utils import get_conversation_store
@@ -45,6 +45,7 @@ from openhands.storage.data_models.conversation_metadata import (
ConversationTrigger,
)
from openhands.storage.data_models.conversation_status import ConversationStatus
from openhands.storage.data_models.settings import Settings
from openhands.storage.data_models.user_secrets import UserSecrets
from openhands.utils.async_utils import wait_all
from openhands.utils.conversation_summary import get_default_conversation_title
@@ -68,10 +69,11 @@ class InitSessionRequest(BaseModel):
model_config = {'extra': 'forbid'}
class InitSessionResponse(BaseModel):
class ConversationResponse(BaseModel):
status: str
conversation_id: str
message: str | None = None
conversation_status: ConversationStatus | None = None
@app.post('/conversations')
@@ -81,7 +83,7 @@ async def new_conversation(
provider_tokens: PROVIDER_TOKEN_TYPE = Depends(get_provider_tokens),
user_secrets: UserSecrets = Depends(get_user_secrets),
auth_type: AuthType | None = Depends(get_auth_type),
) -> InitSessionResponse:
) -> ConversationResponse:
"""Initialize a new session or join an existing one.
After successful initialization, the client should connect to the WebSocket
@@ -126,7 +128,7 @@ async def new_conversation(
await provider_handler.verify_repo_provider(repository, git_provider)
conversation_id = getattr(data, 'conversation_id', None) or uuid.uuid4().hex
await create_new_conversation(
agent_loop_info = await create_new_conversation(
user_id=user_id,
git_provider_tokens=provider_tokens,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
@@ -141,9 +143,10 @@ async def new_conversation(
conversation_id=conversation_id,
)
return InitSessionResponse(
return ConversationResponse(
status='ok',
conversation_id=conversation_id,
conversation_status=agent_loop_info.status,
)
except MissingSettingsError as e:
return JSONResponse(
@@ -303,3 +306,108 @@ async def _get_conversation_info(
extra={'session_id': conversation.conversation_id},
)
return None
@app.post('/conversations/{conversation_id}/start')
async def start_conversation(
conversation_id: str,
user_id: str = Depends(get_user_id),
settings: Settings = Depends(get_user_settings),
conversation_store: ConversationStore = Depends(get_conversation_store),
) -> ConversationResponse:
"""Start an agent loop for a conversation.
This endpoint calls the conversation_manager's maybe_start_agent_loop method
to start a conversation. If the conversation is already running, it will
return the existing agent loop info.
"""
logger.info(f'Starting conversation: {conversation_id}')
try:
# Check that the conversation exists
try:
await conversation_store.get_metadata(conversation_id)
except Exception:
return JSONResponse(
content={
'status': 'error',
'conversation_id': conversation_id,
},
status_code=status.HTTP_404_NOT_FOUND,
)
# Start the agent loop
agent_loop_info = await conversation_manager.maybe_start_agent_loop(
sid=conversation_id,
settings=settings,
user_id=user_id,
)
return ConversationResponse(
status='ok',
conversation_id=conversation_id,
conversation_status=agent_loop_info.status,
)
except Exception as e:
logger.error(
f'Error starting conversation {conversation_id}: {str(e)}',
extra={'session_id': conversation_id},
)
return JSONResponse(
content={
'status': 'error',
'conversation_id': conversation_id,
'message': f'Failed to start conversation: {str(e)}',
},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@app.post('/conversations/{conversation_id}/stop')
async def stop_conversation(
conversation_id: str,
user_id: str = Depends(get_user_id),
) -> ConversationResponse:
"""Stop an agent loop for a conversation.
This endpoint calls the conversation_manager's close_session method
to stop a conversation.
"""
logger.info(f'Stopping conversation: {conversation_id}')
try:
# Check if the conversation is running
agent_loop_info = await conversation_manager.get_agent_loop_info(user_id=user_id, filter_to_sids={conversation_id})
conversation_status = agent_loop_info[0].status if agent_loop_info else ConversationStatus.STOPPED
if conversation_status not in (ConversationStatus.STARTING, ConversationStatus.RUNNING):
return ConversationResponse(
status='ok',
conversation_id=conversation_id,
message='Conversation was not running',
conversation_status=conversation_status,
)
# Stop the conversation
await conversation_manager.close_session(conversation_id)
return ConversationResponse(
status='ok',
conversation_id=conversation_id,
message='Conversation stopped successfully',
conversation_status=conversation_status,
)
except Exception as e:
logger.error(
f'Error stopping conversation {conversation_id}: {str(e)}',
extra={'session_id': conversation_id},
)
return JSONResponse(
content={
'status': 'error',
'conversation_id': conversation_id,
'message': f'Failed to stop conversation: {str(e)}',
},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
+48 -7
View File
@@ -1,7 +1,9 @@
import os
import re
from typing import Annotated
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from fastmcp.server.dependencies import get_http_request
from pydantic import Field
@@ -9,9 +11,10 @@ from openhands.core.logger import openhands_logger as logger
from openhands.integrations.github.github_service import GithubServiceImpl
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
from openhands.integrations.provider import ProviderToken
from openhands.integrations.service_types import ProviderType
from openhands.integrations.service_types import GitService, ProviderType
from openhands.server.dependencies import get_dependencies
from openhands.server.shared import ConversationStoreImpl, config
from openhands.server.shared import ConversationStoreImpl, config, server_config
from openhands.server.types import AppMode
from openhands.server.user_auth import (
get_access_token,
get_provider_tokens,
@@ -19,7 +22,31 @@ from openhands.server.user_auth import (
)
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
mcp_server = FastMCP('mcp', stateless_http=True, dependencies=get_dependencies())
mcp_server = FastMCP(
'mcp', stateless_http=True, dependencies=get_dependencies(), mask_error_details=True
)
HOST = f'https://{os.getenv("WEB_HOST", "app.all-hands.dev").strip()}'
CONVO_URL = HOST + '/{}'
async def get_convo_link(service: GitService, conversation_id: str, body: str) -> str:
"""
Appends a followup link, in the PR body, to the OpenHands conversation that opened the PR
"""
if server_config.app_mode != AppMode.SAAS:
return body
user = await service.get_user()
username = user.login
convo_url = CONVO_URL.format(conversation_id)
convo_link = (
f'@{username} can click here to [continue refining the PR]({convo_url})'
)
body += f'\n\n{convo_link}'
return body
async def save_pr_metadata(
user_id: str, conversation_id: str, tool_result: str
@@ -83,6 +110,11 @@ async def create_pr(
base_domain=github_token.host,
)
try:
body = await get_convo_link(github_service, conversation_id, body or '')
except Exception as e:
logger.warning(f'Failed to append convo link: {e}')
try:
response = await github_service.create_pr(
repo_name=repo_name,
@@ -96,7 +128,8 @@ async def create_pr(
await save_pr_metadata(user_id, conversation_id, response)
except Exception as e:
response = str(e)
error = f'Error creating pull request: {e}'
raise ToolError(str(error))
return response
@@ -130,7 +163,7 @@ async def create_mr(
else ProviderToken()
)
github_service = GitLabServiceImpl(
gitlab_service = GitLabServiceImpl(
user_id=github_token.user_id,
external_auth_id=user_id,
external_auth_token=access_token,
@@ -139,7 +172,14 @@ async def create_mr(
)
try:
response = await github_service.create_mr(
description = await get_convo_link(
gitlab_service, conversation_id, description or ''
)
except Exception as e:
logger.warning(f'Failed to append convo link: {e}')
try:
response = await gitlab_service.create_mr(
id=id,
source_branch=source_branch,
target_branch=target_branch,
@@ -151,6 +191,7 @@ async def create_mr(
await save_pr_metadata(user_id, conversation_id, response)
except Exception as e:
response = str(e)
error = f'Error creating merge request: {e}'
raise ToolError(str(error))
return response
@@ -4,6 +4,7 @@ from typing import Any
from openhands.core.logger import openhands_logger as logger
from openhands.events.action.message import MessageAction
from openhands.experiments.experiment_manager import ExperimentManagerImpl
from openhands.integrations.provider import (
CUSTOM_SECRETS_TYPE_WITH_JSON_SCHEMA,
PROVIDER_TOKEN_TYPE,
@@ -78,6 +79,8 @@ async def create_new_conversation(
session_init_args['git_provider'] = git_provider
session_init_args['conversation_instructions'] = conversation_instructions
conversation_init_data = ConversationInitData(**session_init_args)
logger.info('Loading conversation store')
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
logger.info('ServerConversation store loaded')
@@ -93,6 +96,7 @@ async def create_new_conversation(
extra={'user_id': user_id, 'session_id': conversation_id},
)
conversation_init_data = ExperimentManagerImpl.run_conversation_variant_test(user_id, conversation_id, conversation_init_data)
conversation_title = get_default_conversation_title(conversation_id)
logger.info(f'Saving metadata for conversation {conversation_id}')
@@ -105,6 +109,7 @@ async def create_new_conversation(
selected_repository=selected_repository,
selected_branch=selected_branch,
git_provider=git_provider,
llm_model=settings.llm_model,
)
)
@@ -119,8 +124,8 @@ async def create_new_conversation(
image_urls=image_urls or [],
)
if attach_convo_id and conversation_instructions:
conversation_instructions = conversation_instructions.format(conversation_id)
if attach_convo_id:
logger.warning('Attaching convo ID is deprecated, skipping process')
agent_loop_info = await conversation_manager.maybe_start_agent_loop(
conversation_id,
+13 -4
View File
@@ -1,7 +1,8 @@
from fastapi import Depends, Request
from fastapi import Depends, HTTPException, Request, status
from openhands.core.logger import openhands_logger as logger
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
from openhands.server.user_auth import get_user_auth, get_user_id
from openhands.server.user_auth import get_user_id
from openhands.storage.conversation.conversation_store import ConversationStore
@@ -11,8 +12,7 @@ async def get_conversation_store(request: Request) -> ConversationStore | None:
)
if conversation_store:
return conversation_store
user_auth = await get_user_auth(request)
user_id = await user_auth.get_user_id()
user_id = await get_user_id(request)
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
request.state.conversation_store = conversation_store
return conversation_store
@@ -25,6 +25,15 @@ async def get_conversation(
conversation = await conversation_manager.attach_to_conversation(
conversation_id, user_id
)
if not conversation:
logger.warn(
f'get_conversation: conversation {conversation_id} not found, attach_to_conversation returned None',
extra={'session_id': conversation_id, 'user_id': user_id},
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'Conversation {conversation_id} not found',
)
try:
yield conversation
finally:
@@ -25,6 +25,7 @@ class ConversationMetadata:
trigger: ConversationTrigger | None = None
pr_number: list[int] = field(default_factory=list)
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
llm_model: str | None = None
# Cost and token metrics
accumulated_cost: float = 0.0
prompt_tokens: int = 0
Generated
+61 -58
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.1.1 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 = "aiohappyeyeballs"
@@ -373,7 +373,7 @@ description = "LTS Port of Python audioop"
optional = false
python-versions = ">=3.13"
groups = ["main"]
markers = "python_version >= \"3.13\""
markers = "python_version == \"3.13\""
files = [
{file = "audioop_lts-0.2.1-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd1345ae99e17e6910f47ce7d52673c6a1a70820d78b67de1b7abb3af29c426a"},
{file = "audioop_lts-0.2.1-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:e175350da05d2087e12cea8e72a70a1a8b14a17e92ed2022952a4419689ede5e"},
@@ -2969,8 +2969,8 @@ files = [
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"},
{version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""},
]
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"
@@ -2992,8 +2992,8 @@ googleapis-common-protos = ">=1.56.2,<2.0.0"
grpcio = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
grpcio-status = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
proto-plus = [
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0"},
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
]
protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<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,<7.0.0"
requests = ">=2.18.0,<3.0.0"
@@ -3211,8 +3211,8 @@ google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0"
grpc-google-iam-v1 = ">=0.14.0,<1.0.0"
proto-plus = [
{version = ">=1.22.3,<2.0.0"},
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0", markers = "python_version < \"3.13\""},
]
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,<7.0.0"
@@ -6456,102 +6456,106 @@ et-xmlfile = "*"
[[package]]
name = "opentelemetry-api"
version = "1.25.0"
version = "1.34.0"
description = "OpenTelemetry Python API"
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_api-1.25.0-py3-none-any.whl", hash = "sha256:757fa1aa020a0f8fa139f8959e53dec2051cc26b832e76fa839a6d76ecefd737"},
{file = "opentelemetry_api-1.25.0.tar.gz", hash = "sha256:77c4985f62f2614e42ce77ee4c9da5fa5f0bc1e1821085e9a47533a9323ae869"},
{file = "opentelemetry_api-1.34.0-py3-none-any.whl", hash = "sha256:390b81984affe4453180820ca518de55e3be051111e70cc241bb3b0071ca3a2c"},
{file = "opentelemetry_api-1.34.0.tar.gz", hash = "sha256:48d167589134799093005b7f7f347c69cc67859c693b17787f334fbe8871279f"},
]
[package.dependencies]
deprecated = ">=1.2.6"
importlib-metadata = ">=6.0,<=7.1"
importlib-metadata = ">=6.0,<8.8.0"
typing-extensions = ">=4.5.0"
[[package]]
name = "opentelemetry-exporter-otlp-proto-common"
version = "1.25.0"
version = "1.34.0"
description = "OpenTelemetry Protobuf encoding"
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_exporter_otlp_proto_common-1.25.0-py3-none-any.whl", hash = "sha256:15637b7d580c2675f70246563363775b4e6de947871e01d0f4e3881d1848d693"},
{file = "opentelemetry_exporter_otlp_proto_common-1.25.0.tar.gz", hash = "sha256:c93f4e30da4eee02bacd1e004eb82ce4da143a2f8e15b987a9f603e0a85407d3"},
{file = "opentelemetry_exporter_otlp_proto_common-1.34.0-py3-none-any.whl", hash = "sha256:a5ab7a9b7c3c7ba957c8ddcb08c0c93b1d732e066f544682a250ecf4d7a9ceef"},
{file = "opentelemetry_exporter_otlp_proto_common-1.34.0.tar.gz", hash = "sha256:5916d9ceda8c733adbec5e9cecf654fbf359e9f619ff43214277076fba888557"},
]
[package.dependencies]
opentelemetry-proto = "1.25.0"
opentelemetry-proto = "1.34.0"
[[package]]
name = "opentelemetry-exporter-otlp-proto-grpc"
version = "1.25.0"
version = "1.34.0"
description = "OpenTelemetry Collector Protobuf over gRPC Exporter"
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_exporter_otlp_proto_grpc-1.25.0-py3-none-any.whl", hash = "sha256:3131028f0c0a155a64c430ca600fd658e8e37043cb13209f0109db5c1a3e4eb4"},
{file = "opentelemetry_exporter_otlp_proto_grpc-1.25.0.tar.gz", hash = "sha256:c0b1661415acec5af87625587efa1ccab68b873745ca0ee96b69bb1042087eac"},
{file = "opentelemetry_exporter_otlp_proto_grpc-1.34.0-py3-none-any.whl", hash = "sha256:31c41017af85833242d49beb07bde7341b0a145f0b898ee383f3e3019037afb1"},
{file = "opentelemetry_exporter_otlp_proto_grpc-1.34.0.tar.gz", hash = "sha256:a634425340f506d5ebf641c92d88eb873754d4c5259b5b816afb234c6f87b37d"},
]
[package.dependencies]
deprecated = ">=1.2.6"
googleapis-common-protos = ">=1.52,<2.0"
grpcio = ">=1.0.0,<2.0.0"
grpcio = [
{version = ">=1.63.2,<2.0.0", markers = "python_version < \"3.13\""},
{version = ">=1.66.2,<2.0.0", markers = "python_version >= \"3.13\""},
]
opentelemetry-api = ">=1.15,<2.0"
opentelemetry-exporter-otlp-proto-common = "1.25.0"
opentelemetry-proto = "1.25.0"
opentelemetry-sdk = ">=1.25.0,<1.26.0"
opentelemetry-exporter-otlp-proto-common = "1.34.0"
opentelemetry-proto = "1.34.0"
opentelemetry-sdk = ">=1.34.0,<1.35.0"
typing-extensions = ">=4.5.0"
[[package]]
name = "opentelemetry-proto"
version = "1.25.0"
version = "1.34.0"
description = "OpenTelemetry Python Proto"
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_proto-1.25.0-py3-none-any.whl", hash = "sha256:f07e3341c78d835d9b86665903b199893befa5e98866f63d22b00d0b7ca4972f"},
{file = "opentelemetry_proto-1.25.0.tar.gz", hash = "sha256:35b6ef9dc4a9f7853ecc5006738ad40443701e52c26099e197895cbda8b815a3"},
{file = "opentelemetry_proto-1.34.0-py3-none-any.whl", hash = "sha256:ffb1f1b27552fda5a1cd581e34243cc0b6f134fb14c1c2a33cc3b4b208c9bf97"},
{file = "opentelemetry_proto-1.34.0.tar.gz", hash = "sha256:73e40509b692630a47192888424f7e0b8fb19d9ecf2f04e6f708170cd3346dfe"},
]
[package.dependencies]
protobuf = ">=3.19,<5.0"
protobuf = ">=5.0,<6.0"
[[package]]
name = "opentelemetry-sdk"
version = "1.25.0"
version = "1.34.0"
description = "OpenTelemetry Python SDK"
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_sdk-1.25.0-py3-none-any.whl", hash = "sha256:d97ff7ec4b351692e9d5a15af570c693b8715ad78b8aafbec5c7100fe966b4c9"},
{file = "opentelemetry_sdk-1.25.0.tar.gz", hash = "sha256:ce7fc319c57707ef5bf8b74fb9f8ebdb8bfafbe11898410e0d2a761d08a98ec7"},
{file = "opentelemetry_sdk-1.34.0-py3-none-any.whl", hash = "sha256:7850bcd5b5c95f9aae48603d6592bdad5c7bdef50c03e06393f8f457d891fe32"},
{file = "opentelemetry_sdk-1.34.0.tar.gz", hash = "sha256:719559622afcd515c2aec462ccb749ba2e70075a01df45837623643814d33716"},
]
[package.dependencies]
opentelemetry-api = "1.25.0"
opentelemetry-semantic-conventions = "0.46b0"
typing-extensions = ">=3.7.4"
opentelemetry-api = "1.34.0"
opentelemetry-semantic-conventions = "0.55b0"
typing-extensions = ">=4.5.0"
[[package]]
name = "opentelemetry-semantic-conventions"
version = "0.46b0"
version = "0.55b0"
description = "OpenTelemetry Semantic Conventions"
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_semantic_conventions-0.46b0-py3-none-any.whl", hash = "sha256:6daef4ef9fa51d51855d9f8e0ccd3a1bd59e0e545abe99ac6203804e36ab3e07"},
{file = "opentelemetry_semantic_conventions-0.46b0.tar.gz", hash = "sha256:fbc982ecbb6a6e90869b15c1673be90bd18c8a56ff1cffc0864e38e2edffaefa"},
{file = "opentelemetry_semantic_conventions-0.55b0-py3-none-any.whl", hash = "sha256:63bb15b67377700e51c422d0d24092ca6ce9f3a4cb6f032375aa8af1fc2aab65"},
{file = "opentelemetry_semantic_conventions-0.55b0.tar.gz", hash = "sha256:933d2e20c2dbc0f9b2f4f52138282875b4b14c66c491f5273bcdef1781368e9c"},
]
[package.dependencies]
opentelemetry-api = "1.25.0"
opentelemetry-api = "1.34.0"
typing-extensions = ">=4.5.0"
[[package]]
name = "overrides"
@@ -7171,23 +7175,23 @@ testing = ["google-api-core (>=1.31.5)"]
[[package]]
name = "protobuf"
version = "4.25.8"
version = "5.29.5"
description = ""
optional = false
python-versions = ">=3.8"
groups = ["main", "evaluation"]
files = [
{file = "protobuf-4.25.8-cp310-abi3-win32.whl", hash = "sha256:504435d831565f7cfac9f0714440028907f1975e4bed228e58e72ecfff58a1e0"},
{file = "protobuf-4.25.8-cp310-abi3-win_amd64.whl", hash = "sha256:bd551eb1fe1d7e92c1af1d75bdfa572eff1ab0e5bf1736716814cdccdb2360f9"},
{file = "protobuf-4.25.8-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:ca809b42f4444f144f2115c4c1a747b9a404d590f18f37e9402422033e464e0f"},
{file = "protobuf-4.25.8-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:9ad7ef62d92baf5a8654fbb88dac7fa5594cfa70fd3440488a5ca3bfc6d795a7"},
{file = "protobuf-4.25.8-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:83e6e54e93d2b696a92cad6e6efc924f3850f82b52e1563778dfab8b355101b0"},
{file = "protobuf-4.25.8-cp38-cp38-win32.whl", hash = "sha256:27d498ffd1f21fb81d987a041c32d07857d1d107909f5134ba3350e1ce80a4af"},
{file = "protobuf-4.25.8-cp38-cp38-win_amd64.whl", hash = "sha256:d552c53d0415449c8d17ced5c341caba0d89dbf433698e1436c8fa0aae7808a3"},
{file = "protobuf-4.25.8-cp39-cp39-win32.whl", hash = "sha256:077ff8badf2acf8bc474406706ad890466274191a48d0abd3bd6987107c9cde5"},
{file = "protobuf-4.25.8-cp39-cp39-win_amd64.whl", hash = "sha256:f4510b93a3bec6eba8fd8f1093e9d7fb0d4a24d1a81377c10c0e5bbfe9e4ed24"},
{file = "protobuf-4.25.8-py3-none-any.whl", hash = "sha256:15a0af558aa3b13efef102ae6e4f3efac06f1eea11afb3a57db2901447d9fb59"},
{file = "protobuf-4.25.8.tar.gz", hash = "sha256:6135cf8affe1fc6f76cced2641e4ea8d3e59518d1f24ae41ba97bcad82d397cd"},
{file = "protobuf-5.29.5-cp310-abi3-win32.whl", hash = "sha256:3f1c6468a2cfd102ff4703976138844f78ebd1fb45f49011afc5139e9e283079"},
{file = "protobuf-5.29.5-cp310-abi3-win_amd64.whl", hash = "sha256:3f76e3a3675b4a4d867b52e4a5f5b78a2ef9565549d4037e06cf7b0942b1d3fc"},
{file = "protobuf-5.29.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e38c5add5a311f2a6eb0340716ef9b039c1dfa428b28f25a7838ac329204a671"},
{file = "protobuf-5.29.5-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:fa18533a299d7ab6c55a238bf8629311439995f2e7eca5caaff08663606e9015"},
{file = "protobuf-5.29.5-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:63848923da3325e1bf7e9003d680ce6e14b07e55d0473253a690c3a8b8fd6e61"},
{file = "protobuf-5.29.5-cp38-cp38-win32.whl", hash = "sha256:ef91363ad4faba7b25d844ef1ada59ff1604184c0bcd8b39b8a6bef15e1af238"},
{file = "protobuf-5.29.5-cp38-cp38-win_amd64.whl", hash = "sha256:7318608d56b6402d2ea7704ff1e1e4597bee46d760e7e4dd42a3d45e24b87f2e"},
{file = "protobuf-5.29.5-cp39-cp39-win32.whl", hash = "sha256:6f642dc9a61782fa72b90878af134c5afe1917c89a568cd3476d758d3c3a0736"},
{file = "protobuf-5.29.5-cp39-cp39-win_amd64.whl", hash = "sha256:470f3af547ef17847a28e1f47200a1cbf0ba3ff57b7de50d22776607cd2ea353"},
{file = "protobuf-5.29.5-py3-none-any.whl", hash = "sha256:6cf42630262c59b2d8de33954443d94b746c952b01434fc58a417fdbd2e84bd5"},
{file = "protobuf-5.29.5.tar.gz", hash = "sha256:bc1463bafd4b0929216c35f437a8e28731a2b7fe3d98bb77a600efced5a15c84"},
]
[[package]]
@@ -9335,7 +9339,6 @@ files = [
{file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"},
{file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"},
]
markers = {evaluation = "platform_system == \"Linux\" and platform_machine == \"x86_64\""}
[package.extras]
check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""]
@@ -9578,7 +9581,7 @@ description = "Standard library aifc redistribution. \"dead battery\"."
optional = false
python-versions = "*"
groups = ["main"]
markers = "python_version >= \"3.13\""
markers = "python_version == \"3.13\""
files = [
{file = "standard_aifc-3.13.0-py3-none-any.whl", hash = "sha256:f7ae09cc57de1224a0dd8e3eb8f73830be7c3d0bc485de4c1f82b4a7f645ac66"},
{file = "standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43"},
@@ -9595,7 +9598,7 @@ description = "Standard library chunk redistribution. \"dead battery\"."
optional = false
python-versions = "*"
groups = ["main"]
markers = "python_version >= \"3.13\""
markers = "python_version == \"3.13\""
files = [
{file = "standard_chunk-3.13.0-py3-none-any.whl", hash = "sha256:17880a26c285189c644bd5bd8f8ed2bdb795d216e3293e6dbe55bbd848e2982c"},
{file = "standard_chunk-3.13.0.tar.gz", hash = "sha256:4ac345d37d7e686d2755e01836b8d98eda0d1a3ee90375e597ae43aaf064d654"},
@@ -11757,4 +11760,4 @@ cffi = ["cffi (>=1.11)"]
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "eaa84e30dbafb061a75b4b173a8ba16542c4a03ab74583c55ab282cd6119e430"
content-hash = "d9f6c24fa80dd191f180af0c802ea11ecf514d86aaa421cb19a9bb497362c101"
+11 -10
View File
@@ -20,12 +20,12 @@ packages = [
[tool.poetry.dependencies]
python = "^3.12,<3.14"
litellm = "^1.60.0, !=1.64.4, !=1.67.*" # avoid 1.64.4 (known bug) & 1.67.* (known bug #10272)
aiohttp = ">=3.9.0,!=3.11.13" # Pin to avoid yanked version 3.11.13
google-generativeai = "*" # To use litellm with Gemini Pro API
google-api-python-client = "^2.164.0" # For Google Sheets API
google-auth-httplib2 = "*" # For Google Sheets authentication
google-auth-oauthlib = "*" # For Google Sheets OAuth
litellm = "^1.60.0, !=1.64.4, !=1.67.*" # avoid 1.64.4 (known bug) & 1.67.* (known bug #10272)
aiohttp = ">=3.9.0,!=3.11.13" # Pin to avoid yanked version 3.11.13
google-generativeai = "*" # To use litellm with Gemini Pro API
google-api-python-client = "^2.164.0" # For Google Sheets API
google-auth-httplib2 = "*" # For Google Sheets authentication
google-auth-oauthlib = "*" # For Google Sheets OAuth
termcolor = "*"
docker = "*"
fastapi = "*"
@@ -34,7 +34,7 @@ types-toml = "*"
uvicorn = "*"
numpy = "*"
json-repair = "*"
browsergym-core = "0.13.3" # integrate browsergym-core as the browsing interface
browsergym-core = "0.13.3" # integrate browsergym-core as the browsing interface
html2text = "*"
e2b = ">=1.0.5,<1.4.0"
pexpect = "*"
@@ -48,10 +48,11 @@ dirhash = "*"
tornado = "*"
python-dotenv = "*"
rapidfuzz = "^3.9.0"
rich = "^13.7.0"
whatthepatch = "^1.0.6"
protobuf = "^4.21.6,<5.0.0" # chromadb currently fails on 5.0+
opentelemetry-api = "1.25.0"
opentelemetry-exporter-otlp-proto-grpc = "1.25.0"
protobuf = "^5.0.0,<6.0.0" # Updated to support newer opentelemetry
opentelemetry-api = "^1.33.1"
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
modal = ">=0.66.26,<0.78.0"
runloop-api-client = "0.33.0"
libtmux = ">=0.37,<0.40"
+8 -6
View File
@@ -826,17 +826,19 @@ async def test_context_window_exceeded_error_handling(
# size (because we return a message action, which triggers a recall, which
# triggers a recall response). But if the pre/post-views are on the turn
# when we throw the context window exceeded error, we should see the
# post-step view compressed (or rather, a CondensationAction added).
# post-step view compressed (condensation effects should be visible).
for index, (first_view, second_view) in enumerate(
zip(step_state.views[:-1], step_state.views[1:])
):
if index == error_after:
# Verify that the CondensationAction is present in the second view (after error)
# but not in the first view (before error)
# Verify that no CondensationAction is present in either view
# (CondensationAction events are never included in views)
assert not any(isinstance(e, CondensationAction) for e in first_view.events)
assert any(isinstance(e, CondensationAction) for e in second_view.events)
# The length might not strictly decrease due to CondensationAction being added
assert len(first_view) == len(second_view)
assert not any(
isinstance(e, CondensationAction) for e in second_view.events
)
# The view length should be compressed due to condensation effects
assert len(first_view) > len(second_view)
else:
# Before the error, the view length should increase
assert len(first_view) < len(second_view)
+9 -5
View File
@@ -20,8 +20,8 @@ from openhands.server.data_models.conversation_info_result_set import (
ConversationInfoResultSet,
)
from openhands.server.routes.manage_conversations import (
ConversationResponse,
InitSessionRequest,
InitSessionResponse,
delete_conversation,
get_conversation,
new_conversation,
@@ -250,6 +250,7 @@ async def test_new_conversation_success(provider_handler_mock):
conversation_id='test_conversation_id',
url='https://my-conversation.com',
session_api_key=None,
status=ConversationStatus.RUNNING,
)
test_request = InitSessionRequest(
@@ -263,7 +264,7 @@ async def test_new_conversation_success(provider_handler_mock):
response = await create_new_test_conversation(test_request)
# Verify the response
assert isinstance(response, InitSessionResponse)
assert isinstance(response, ConversationResponse)
assert response.status == 'ok'
# Don't check the exact conversation_id as it's now generated dynamically
assert response.conversation_id is not None
@@ -293,6 +294,7 @@ async def test_new_conversation_with_suggested_task(provider_handler_mock):
conversation_id='test_conversation_id',
url='https://my-conversation.com',
session_api_key=None,
status=ConversationStatus.RUNNING,
)
# Mock SuggestedTask.get_prompt_for_task
@@ -321,7 +323,7 @@ async def test_new_conversation_with_suggested_task(provider_handler_mock):
response = await create_new_test_conversation(test_request)
# Verify the response
assert isinstance(response, InitSessionResponse)
assert isinstance(response, ConversationResponse)
assert response.status == 'ok'
# Don't check the exact conversation_id as it's now generated dynamically
assert response.conversation_id is not None
@@ -479,6 +481,7 @@ async def test_new_conversation_with_bearer_auth(provider_handler_mock):
conversation_id='test_conversation_id',
url='https://my-conversation.com',
session_api_key=None,
status=ConversationStatus.RUNNING,
)
# Create the request object
@@ -492,7 +495,7 @@ async def test_new_conversation_with_bearer_auth(provider_handler_mock):
response = await create_new_test_conversation(test_request, AuthType.BEARER)
# Verify the response
assert isinstance(response, InitSessionResponse)
assert isinstance(response, ConversationResponse)
assert response.status == 'ok'
# Verify that create_new_conversation was called with REMOTE_API_KEY trigger
@@ -516,6 +519,7 @@ async def test_new_conversation_with_null_repository():
conversation_id='test_conversation_id',
url='https://my-conversation.com',
session_api_key=None,
status=ConversationStatus.RUNNING,
)
# Create the request object with null repository
@@ -529,7 +533,7 @@ async def test_new_conversation_with_null_repository():
response = await create_new_test_conversation(test_request)
# Verify the response
assert isinstance(response, InitSessionResponse)
assert isinstance(response, ConversationResponse)
assert response.status == 'ok'
# Verify that create_new_conversation was called with None repository
+78 -40
View File
@@ -46,28 +46,40 @@ class TestGitHandler(unittest.TestCase):
def _setup_git_repos(self):
"""Set up real git repositories for testing."""
# Set up origin repository
self._execute_command('git init --initial-branch=main', self.origin_dir)
self._execute_command(
"git config user.email 'test@example.com'", self.origin_dir
'git --no-pager init --initial-branch=main', self.origin_dir
)
self._execute_command(
"git --no-pager config user.email 'test@example.com'", self.origin_dir
)
self._execute_command(
"git --no-pager config user.name 'Test User'", self.origin_dir
)
self._execute_command("git config user.name 'Test User'", self.origin_dir)
# Create a file and commit it
with open(os.path.join(self.origin_dir, 'file1.txt'), 'w') as f:
f.write('Original content')
self._execute_command('git add file1.txt', self.origin_dir)
self._execute_command("git commit -m 'Initial commit'", self.origin_dir)
self._execute_command('git --no-pager add file1.txt', self.origin_dir)
self._execute_command(
"git --no-pager commit -m 'Initial commit'", self.origin_dir
)
# Clone the origin repository to local
self._execute_command(f'git clone {self.origin_dir} {self.local_dir}')
self._execute_command(
"git config user.email 'test@example.com'", self.local_dir
f'git --no-pager clone {self.origin_dir} {self.local_dir}'
)
self._execute_command(
"git --no-pager config user.email 'test@example.com'", self.local_dir
)
self._execute_command(
"git --no-pager config user.name 'Test User'", self.local_dir
)
self._execute_command("git config user.name 'Test User'", self.local_dir)
# Create a feature branch in the local repository
self._execute_command('git checkout -b feature-branch', self.local_dir)
self._execute_command(
'git --no-pager checkout -b feature-branch', self.local_dir
)
# Modify a file and create a new file
with open(os.path.join(self.local_dir, 'file1.txt'), 'w') as f:
@@ -77,32 +89,40 @@ class TestGitHandler(unittest.TestCase):
f.write('New file content')
# Add and commit file1.txt changes to create a baseline
self._execute_command('git add file1.txt', self.local_dir)
self._execute_command("git commit -m 'Update file1.txt'", self.local_dir)
self._execute_command('git --no-pager add file1.txt', self.local_dir)
self._execute_command(
"git --no-pager commit -m 'Update file1.txt'", self.local_dir
)
# Add and commit file2.txt, then modify it
self._execute_command('git add file2.txt', self.local_dir)
self._execute_command("git commit -m 'Add file2.txt'", self.local_dir)
self._execute_command('git --no-pager add file2.txt', self.local_dir)
self._execute_command(
"git --no-pager commit -m 'Add file2.txt'", self.local_dir
)
# Modify file2.txt and stage it
with open(os.path.join(self.local_dir, 'file2.txt'), 'w') as f:
f.write('Modified new file content')
self._execute_command('git add file2.txt', self.local_dir)
self._execute_command('git --no-pager add file2.txt', self.local_dir)
# Create a file that will be deleted
with open(os.path.join(self.local_dir, 'file3.txt'), 'w') as f:
f.write('File to be deleted')
self._execute_command('git add file3.txt', self.local_dir)
self._execute_command("git commit -m 'Add file3.txt'", self.local_dir)
self._execute_command('git rm file3.txt', self.local_dir)
self._execute_command('git --no-pager add file3.txt', self.local_dir)
self._execute_command(
"git --no-pager commit -m 'Add file3.txt'", self.local_dir
)
self._execute_command('git --no-pager rm file3.txt', self.local_dir)
# Modify file1.txt again but don't stage it (unstaged change)
with open(os.path.join(self.local_dir, 'file1.txt'), 'w') as f:
f.write('Modified content again')
# Push the feature branch to origin
self._execute_command('git push -u origin feature-branch', self.local_dir)
self._execute_command(
'git --no-pager push -u origin feature-branch', self.local_dir
)
def test_is_git_repo(self):
"""Test that _is_git_repo returns True for a git repository."""
@@ -111,7 +131,7 @@ class TestGitHandler(unittest.TestCase):
# Verify the command was executed
self.assertTrue(
any(
cmd == 'git rev-parse --is-inside-work-tree'
cmd == 'git --no-pager rev-parse --is-inside-work-tree'
for cmd, _ in self.executed_commands
)
)
@@ -124,7 +144,7 @@ class TestGitHandler(unittest.TestCase):
# Verify the command was executed
self.assertTrue(
any(
cmd == 'git remote show origin | grep "HEAD branch"'
cmd == 'git --no-pager remote show origin | grep "HEAD branch"'
for cmd, _ in self.executed_commands
)
)
@@ -133,11 +153,12 @@ class TestGitHandler(unittest.TestCase):
"""Test that _get_current_branch returns the correct branch name."""
branch = self.git_handler._get_current_branch()
self.assertEqual(branch, 'feature-branch')
print('executed commands:', self.executed_commands)
# Verify the command was executed
self.assertTrue(
any(
cmd == 'git rev-parse --abbrev-ref HEAD'
cmd == 'git --no-pager rev-parse --abbrev-ref HEAD'
for cmd, _ in self.executed_commands
)
)
@@ -152,7 +173,7 @@ class TestGitHandler(unittest.TestCase):
verify_commands = [
cmd
for cmd, _ in self.executed_commands
if cmd.startswith('git rev-parse --verify')
if cmd.startswith('git --no-pager rev-parse --verify')
]
# First should check origin/feature-branch (current branch)
@@ -162,13 +183,17 @@ class TestGitHandler(unittest.TestCase):
self.assertEqual(ref, 'origin/feature-branch')
# Verify the ref exists
result = self._execute_command(f'git rev-parse --verify {ref}', self.local_dir)
result = self._execute_command(
f'git --no-pager rev-parse --verify {ref}', self.local_dir
)
self.assertEqual(result.exit_code, 0)
def test_get_valid_ref_without_origin_current_branch(self):
"""Test that _get_valid_ref falls back to default branch when current branch doesn't exist in origin."""
# Create a new branch that doesn't exist in origin
self._execute_command('git checkout -b new-local-branch', self.local_dir)
self._execute_command(
'git --no-pager checkout -b new-local-branch', self.local_dir
)
# Clear the executed commands to start fresh
self.executed_commands = []
@@ -180,7 +205,7 @@ class TestGitHandler(unittest.TestCase):
verify_commands = [
cmd
for cmd, _ in self.executed_commands
if cmd.startswith('git rev-parse --verify')
if cmd.startswith('git --no-pager rev-parse --verify')
]
# Should have tried origin/new-local-branch first (which doesn't exist)
@@ -193,7 +218,9 @@ class TestGitHandler(unittest.TestCase):
self.assertTrue(ref == 'origin/main' or 'merge-base' in ref)
# Verify the ref exists
result = self._execute_command(f'git rev-parse --verify {ref}', self.local_dir)
result = self._execute_command(
f'git --no-pager rev-parse --verify {ref}', self.local_dir
)
self.assertEqual(result.exit_code, 0)
def test_get_valid_ref_without_origin(self):
@@ -203,15 +230,21 @@ class TestGitHandler(unittest.TestCase):
os.makedirs(no_origin_dir, exist_ok=True)
# Initialize git repo without origin
self._execute_command('git init', no_origin_dir)
self._execute_command("git config user.email 'test@example.com'", no_origin_dir)
self._execute_command("git config user.name 'Test User'", no_origin_dir)
self._execute_command('git --no-pager init', no_origin_dir)
self._execute_command(
"git --no-pager config user.email 'test@example.com'", no_origin_dir
)
self._execute_command(
"git --no-pager config user.name 'Test User'", no_origin_dir
)
# Create a file and commit it
with open(os.path.join(no_origin_dir, 'file1.txt'), 'w') as f:
f.write('Content in repo without origin')
self._execute_command('git add file1.txt', no_origin_dir)
self._execute_command("git commit -m 'Initial commit'", no_origin_dir)
self._execute_command('git --no-pager add file1.txt', no_origin_dir)
self._execute_command(
"git --no-pager commit -m 'Initial commit'", no_origin_dir
)
# Create a custom GitHandler with a modified _get_default_branch method for this test
class TestGitHandler(GitHandler):
@@ -234,19 +267,20 @@ class TestGitHandler(unittest.TestCase):
# Verify that git commands were executed
self.assertTrue(
any(
cmd.startswith('git rev-parse --verify')
cmd.startswith('git --no-pager rev-parse --verify')
for cmd, _ in self.executed_commands
)
)
# Should have fallen back to the empty tree ref
self.assertEqual(
ref, '$(git rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)'
ref,
'$(git --no-pager rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)',
)
# Verify the ref exists (the empty tree ref always exists)
result = self._execute_command(
'git rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904',
'git --no-pager rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904',
no_origin_dir,
)
self.assertEqual(result.exit_code, 0)
@@ -258,7 +292,9 @@ class TestGitHandler(unittest.TestCase):
# Should have called _get_valid_ref and then git show
show_commands = [
cmd for cmd, _ in self.executed_commands if cmd.startswith('git show')
cmd
for cmd, _ in self.executed_commands
if cmd.startswith('git --no-pager show')
]
self.assertTrue(any('file1.txt' in cmd for cmd in show_commands))
@@ -277,7 +313,7 @@ class TestGitHandler(unittest.TestCase):
# Let's create a new file to ensure it shows up in the diff
with open(os.path.join(self.local_dir, 'new_file.txt'), 'w') as f:
f.write('New file content')
self._execute_command('git add new_file.txt', self.local_dir)
self._execute_command('git --no-pager add new_file.txt', self.local_dir)
files = self.git_handler._get_changed_files()
self.assertTrue(files)
@@ -291,7 +327,9 @@ class TestGitHandler(unittest.TestCase):
# Should have called _get_valid_ref and then git diff
diff_commands = [
cmd for cmd, _ in self.executed_commands if cmd.startswith('git diff')
cmd
for cmd, _ in self.executed_commands
if cmd.startswith('git --no-pager diff')
]
self.assertTrue(diff_commands)
@@ -309,7 +347,7 @@ class TestGitHandler(unittest.TestCase):
# Verify the command was executed
self.assertTrue(
any(
cmd == 'git ls-files --others --exclude-standard'
cmd == 'git --no-pager ls-files --others --exclude-standard'
for cmd, _ in self.executed_commands
)
)
@@ -323,7 +361,7 @@ class TestGitHandler(unittest.TestCase):
# Create a new file and stage it
with open(os.path.join(self.local_dir, 'new_file2.txt'), 'w') as f:
f.write('New file 2 content')
self._execute_command('git add new_file2.txt', self.local_dir)
self._execute_command('git --no-pager add new_file2.txt', self.local_dir)
changes = self.git_handler.get_git_changes()
self.assertIsNotNone(changes)
@@ -353,7 +391,7 @@ class TestGitHandler(unittest.TestCase):
)
self.assertTrue(
any(
'git show' in cmd and 'file1.txt' in cmd
'git --no-pager show' in cmd and 'file1.txt' in cmd
for cmd, _ in self.executed_commands
)
)
+86
View File
@@ -0,0 +1,86 @@
from unittest.mock import AsyncMock, patch
import pytest
from openhands.integrations.service_types import GitService
from openhands.server.routes.mcp import get_convo_link
from openhands.server.types import AppMode
@pytest.mark.asyncio
async def test_get_convo_link_non_saas_mode():
"""Test get_convo_link in non-SAAS mode."""
# Mock GitService
mock_service = AsyncMock(spec=GitService)
# Test with non-SAAS mode
with patch('openhands.server.routes.mcp.server_config') as mock_config:
mock_config.app_mode = AppMode.OSS
# Call the function
result = await get_convo_link(
service=mock_service, conversation_id='test-convo-id', body='Original body'
)
# Verify the result
assert result == 'Original body'
# Verify that get_user was not called
mock_service.get_user.assert_not_called()
@pytest.mark.asyncio
async def test_get_convo_link_saas_mode():
"""Test get_convo_link in SAAS mode."""
# Mock GitService and user
mock_service = AsyncMock(spec=GitService)
mock_user = AsyncMock()
mock_user.login = 'testuser'
mock_service.get_user.return_value = mock_user
# Test with SAAS mode
with (
patch('openhands.server.routes.mcp.server_config') as mock_config,
patch('openhands.server.routes.mcp.CONVO_URL', 'https://test.example.com/{}'),
):
mock_config.app_mode = AppMode.SAAS
# Call the function
result = await get_convo_link(
service=mock_service, conversation_id='test-convo-id', body='Original body'
)
# Verify the result
expected_link = '@testuser can click here to [continue refining the PR](https://test.example.com/test-convo-id)'
assert result == f'Original body\n\n{expected_link}'
# Verify that get_user was called
mock_service.get_user.assert_called_once()
@pytest.mark.asyncio
async def test_get_convo_link_empty_body():
"""Test get_convo_link with an empty body."""
# Mock GitService and user
mock_service = AsyncMock(spec=GitService)
mock_user = AsyncMock()
mock_user.login = 'testuser'
mock_service.get_user.return_value = mock_user
# Test with SAAS mode and empty body
with (
patch('openhands.server.routes.mcp.server_config') as mock_config,
patch('openhands.server.routes.mcp.CONVO_URL', 'https://test.example.com/{}'),
):
mock_config.app_mode = AppMode.SAAS
# Call the function
result = await get_convo_link(
service=mock_service, conversation_id='test-convo-id', body=''
)
# Verify the result
expected_link = '@testuser can click here to [continue refining the PR](https://test.example.com/test-convo-id)'
assert result == f'\n\n{expected_link}'
# Verify that get_user was called
mock_service.get_user.assert_called_once()
+8 -2
View File
@@ -234,7 +234,10 @@ async def test_clone_or_init_repo_no_repo_with_user_id(temp_dir):
# Verify that git init was called
assert len(runtime.run_action_calls) == 1
assert isinstance(runtime.run_action_calls[0], CmdRunAction)
assert runtime.run_action_calls[0].command == 'git init'
assert (
runtime.run_action_calls[0].command
== f'git init && git config --global --add safe.directory {runtime.workspace_root}'
)
assert result == ''
@@ -255,7 +258,10 @@ async def test_clone_or_init_repo_no_repo_no_user_id_no_workspace_base(temp_dir)
# Verify that git init was called
assert len(runtime.run_action_calls) == 1
assert isinstance(runtime.run_action_calls[0], CmdRunAction)
assert runtime.run_action_calls[0].command == 'git init'
assert (
runtime.run_action_calls[0].command
== f'git init && git config --global --add safe.directory {runtime.workspace_root}'
)
assert result == ''
@@ -167,6 +167,7 @@ async def test_add_to_local_event_stream():
@pytest.mark.asyncio
async def test_cleanup_session_connections():
sio = get_mock_sio()
sio.disconnect = AsyncMock() # Mock the disconnect method
async with StandaloneConversationManager(
sio, OpenHandsConfig(), InMemoryFileStore(), MonitoringListener()
) as conversation_manager:
@@ -181,6 +182,7 @@ async def test_cleanup_session_connections():
await conversation_manager._close_session('session1')
# Check that connections were removed from the dictionary
remaining_connections = conversation_manager._local_connection_id_to_session_id
assert 'conn1' not in remaining_connections
assert 'conn2' not in remaining_connections
@@ -188,3 +190,8 @@ async def test_cleanup_session_connections():
assert 'conn4' in remaining_connections
assert remaining_connections['conn3'] == 'session2'
assert remaining_connections['conn4'] == 'session2'
# Check that disconnect was called for each connection
assert sio.disconnect.await_count == 2
sio.disconnect.assert_any_call('conn1')
sio.disconnect.assert_any_call('conn2')
+104
View File
@@ -0,0 +1,104 @@
from openhands.events.action.agent import CondensationAction
from openhands.events.action.message import MessageAction
from openhands.events.event import Event
from openhands.events.observation.agent import AgentCondensationObservation
from openhands.memory.view import View
def test_view_preserves_uncondensed_lists() -> None:
"""Tests that the view preserves event lists that don't contain condensation actions."""
events: list[Event] = [MessageAction(content=f'Event {i}') for i in range(5)]
set_ids(events)
view = View.from_events(events)
assert len(view) == 5
assert view.events == events
def test_view_forgets_events() -> None:
"""Tests that views drop forgotten events and the condensation actions."""
events: list[Event] = [
*[MessageAction(content=f'Event {i}') for i in range(5)],
CondensationAction(forgotten_event_ids=[0, 1, 2, 3, 4]),
]
set_ids(events)
view = View.from_events(events)
assert view.events == [] # All events forgotten and condensation action removed
def test_view_keeps_non_forgotten_events() -> None:
"""Tests that views keep non-forgotten events."""
for forgotten_event_id in range(5):
events: list[Event] = [
*[MessageAction(content=f'Event {i}') for i in range(5)],
# Instead of forgetting all events like in
# `test_view_forgets_events`, in this test we only want to forget
# one of the events. That way we can check that the rest of the
# events are preserved.
CondensationAction(forgotten_event_ids=[forgotten_event_id]),
]
set_ids(events)
view = View.from_events(events)
assert (
view.events
== events[:forgotten_event_id] + events[(forgotten_event_id + 1) : 5]
)
def test_view_inserts_summary() -> None:
"""Tests that views insert a summary observation at the specified offset."""
for offset in range(5):
events: list[Event] = [
*[MessageAction(content=f'Event {i}') for i in range(5)],
CondensationAction(
forgotten_event_ids=[], summary='My Summary', summary_offset=offset
),
]
set_ids(events)
view = View.from_events(events)
assert len(view) == 6 # 5 message events + 1 summary observation
for index, event in enumerate(view):
print(index, event.content)
if index == offset:
assert isinstance(event, AgentCondensationObservation)
assert event.content == 'My Summary'
# Events before where the summary is inserted will have content
# matching their index.
elif index < offset:
assert isinstance(event, MessageAction)
assert event.content == f'Event {index}'
# Events after where the summary is inserted will be offset by one
# from the original list.
else:
assert isinstance(event, MessageAction)
assert event.content == f'Event {index - 1}'
def test_no_condensation_action_in_view() -> None:
"""Ensure that CondensationAction events are never present in the resulting view."""
events: list[Event] = [
MessageAction(content='Event 0'),
MessageAction(content='Event 1'),
CondensationAction(forgotten_event_ids=[0]),
MessageAction(content='Event 2'),
MessageAction(content='Event 3'),
]
set_ids(events)
view = View.from_events(events)
# Check that no CondensationAction is present in the view
for event in view:
assert not isinstance(event, CondensationAction)
# The view should only contain the non-forgotten MessageActions
assert len(view) == 3 # Event 1, Event 2, Event 3 (Event 0 was forgotten)
def set_ids(events: list[Event]) -> None:
"""Set the IDs of the events in the list to their index."""
for i, e in enumerate(events):
e._id = i # type: ignore