Compare commits

..

9 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
121 changed files with 2638 additions and 2306 deletions

View File

@@ -12,5 +12,4 @@
"ghcr.io/devcontainers/features/node:1": {},
},
"postCreateCommand": ".devcontainer/setup.sh",
"runArgs": ["--network=host"],
}

0
.devcontainer/setup.sh Executable file → Normal file
View File

2
.github/CODEOWNERS vendored
View File

@@ -5,7 +5,7 @@
/frontend/ @rbren @amanape
# Evaluation code owners
/evaluation/ @xingyaoww @neubig
/evaluation/ @xingyaoww @neubig
# Documentation code owners
/docs/ @mamoodi

View File

@@ -33,7 +33,6 @@ body:
- Docker command in README
- GitHub resolver
- Development workflow
- CLI
- app.all-hands.dev
- Other
default: 0

View File

@@ -16,6 +16,7 @@ updates:
mcp-packages:
patterns:
- "mcp"
- "mcpm"
security-all:
applies-to: "security-updates"
patterns:

View File

@@ -38,11 +38,12 @@
]
},
"usage/cloud/cloud-ui",
"usage/cloud/cloud-issue-resolver",
"usage/cloud/cloud-api"
]
},
{
"group": "Running OpenHands on Your Own",
"group": "Running OpenHands Locally",
"pages": [
"usage/local-setup",
"usage/how-to/gui-mode",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -1,11 +1,9 @@
---
title: Cloud API
description: OpenHands Cloud provides a REST API that allows you to programmatically interact with OpenHands.
This guide explains how to obtain an API key and use the API to start conversations and retrieve their status.
description: OpenHands Cloud provides a REST API that allows you to programmatically interact with the service. This guide explains how to obtain an API key and use the API to start conversations.
---
For the available API endpoints, refer to the
[OpenHands API Reference](https://docs.all-hands.dev/api-reference).
For more detailed information about the API, refer to the [OpenHands API Reference](https://docs.all-hands.dev/swagger-ui/).
## Obtaining an API Key
@@ -18,7 +16,7 @@ To use the OpenHands Cloud API, you'll need to generate an API key:
5. Give your key a descriptive name (Example: "Development" or "Production") and select `Create`.
6. Copy the generated API key and store it securely. It will only be shown once.
![API Key Generation](/static/img/api-key-generation.png)
![API Key Generation](/static/img/docs/api-key-generation.png)
## API Usage
@@ -35,81 +33,87 @@ To start a new conversation with OpenHands to perform a task, you'll need to mak
#### Examples
<details>
<summary>cURL</summary>
<Accordion title="cURL">
```bash
curl -X POST "https://app.all-hands.dev/api/conversations" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"initial_user_msg": "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
"repository": "yourusername/your-repo"
}'
```
</Accordion>
```bash
curl -X POST "https://app.all-hands.dev/api/conversations" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"initial_user_msg": "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
"repository": "yourusername/your-repo"
}'
```
</details>
<Accordion title="Python (with requests)">
```python
import requests
<details>
<summary>Python (with requests)</summary>
api_key = "YOUR_API_KEY"
url = "https://app.all-hands.dev/api/conversations"
```python
import requests
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
api_key = "YOUR_API_KEY"
url = "https://app.all-hands.dev/api/conversations"
data = {
"initial_user_msg": "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
"repository": "yourusername/your-repo"
}
response = requests.post(url, headers=headers, json=data)
conversation = response.json()
print(f"Conversation Link: https://app.all-hands.dev/conversations/{conversation['conversation_id']}")
print(f"Status: {conversation['status']}")
```
</Accordion>
<Accordion title="TypeScript/JavaScript (with fetch)">
```typescript
const apiKey = "YOUR_API_KEY";
const url = "https://app.all-hands.dev/api/conversations";
const headers = {
"Authorization": `Bearer ${apiKey}`,
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
};
}
const data = {
initial_user_msg: "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
repository: "yourusername/your-repo"
};
data = {
"initial_user_msg": "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
"repository": "yourusername/your-repo"
}
async function startConversation() {
try {
const response = await fetch(url, {
method: "POST",
headers: headers,
body: JSON.stringify(data)
});
response = requests.post(url, headers=headers, json=data)
conversation = response.json()
const conversation = await response.json();
print(f"Conversation Link: https://app.all-hands.dev/conversations/{conversation['conversation_id']}")
print(f"Status: {conversation['status']}")
```
</details>
console.log(`Conversation Link: https://app.all-hands.dev/conversations/${conversation.id}`);
console.log(`Status: ${conversation.status}`);
<details>
<summary>TypeScript/JavaScript (with fetch)</summary>
return conversation;
} catch (error) {
console.error("Error starting conversation:", error);
}
```typescript
const apiKey = "YOUR_API_KEY";
const url = "https://app.all-hands.dev/api/conversations";
const headers = {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json"
};
const data = {
initial_user_msg: "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
repository: "yourusername/your-repo"
};
async function startConversation() {
try {
const response = await fetch(url, {
method: "POST",
headers: headers,
body: JSON.stringify(data)
});
const conversation = await response.json();
console.log(`Conversation Link: https://app.all-hands.dev/conversations/${conversation.id}`);
console.log(`Status: ${conversation.status}`);
return conversation;
} catch (error) {
console.error("Error starting conversation:", error);
}
}
startConversation();
```
</Accordion>
startConversation();
```
</details>
#### Response
@@ -141,12 +145,14 @@ GET https://app.all-hands.dev/api/conversations/{conversation_id}
#### Example
<Accordion title="cURL">
```bash
curl -X GET "https://app.all-hands.dev/api/conversations/{conversation_id}" \
-H "Authorization: Bearer YOUR_API_KEY"
```
</Accordion>
<details>
<summary>cURL</summary>
```bash
curl -X GET "https://app.all-hands.dev/api/conversations/{conversation_id}" \
-H "Authorization: Bearer YOUR_API_KEY"
```
</details>
#### Response

View File

@@ -0,0 +1,33 @@
---
title: Cloud Issue Resolver
description: The Cloud Issue Resolver automates code fixes and provides intelligent assistance for your repositories on GitHub.
---
## Setup
The Cloud Issue Resolver is available automatically when you grant OpenHands Cloud repository access:
- [GitHub repository access](./github-installation#adding-repository-access)
## Usage
After granting OpenHands Cloud repository access, you can use the Cloud Issue Resolver on issues and pull requests in your repositories.
### 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 comments to:
- Ask questions
- Request updates
- Get code explanations
OpenHands will:
1. Comment to let you know it is working on it
2. Perform the requested task

View File

@@ -1,36 +1,28 @@
---
title: Cloud UI
description: The Cloud UI provides a web interface for interacting with OpenHands. This page explains how to use the
OpenHands Cloud UI.
description: The Cloud UI provides a web interface for interacting with OpenHands AI. This page explains how to access and use the OpenHands Cloud UI.
---
## Landing Page
The landing page is where you can:
## Accessing the UI
- [Add GitHub repository access](/usage/cloud/github-installation#adding-github-repository-access) to OpenHands.
- [Select a GitHub repo](/usage/cloud/github-installation#working-with-github-repos-in-openhands-cloud) or
[a GitLab repo](/usage/cloud/gitlab-installation#working-with-gitlab-repos-in-openhands-cloud) to start working on.
- See `Suggested Tasks` for repositories that OpenHands has access to.
- Launch an empty conversation using `Launch from Scratch`.
The OpenHands Cloud UI can be accessed at [app.all-hands.dev](https://app.all-hands.dev). You'll need to sign in with your GitHub or GitLab account to access the interface.
## Settings
The Settings page allows you to:
- [Configure GitHub repository access](/usage/cloud/github-installation#modifying-repository-access) for OpenHands.
- Set application settings like your preferred language, notifications and other preferences.
- Add credits to your account.
- Generate custom secrets.
- Create API keys to work with OpenHands programmatically.
## Key Features
For an overview of the key features available inside a conversation, please refer to the [Key Features](/usage/key-features)
section of the documentation.
For detailed information about the features available in the OpenHands Cloud UI, please refer to the [Key Features](../key-features) section of the documentation.
## Settings
The settings page allows you to:
- Configure your account preferences.
- Manage repository access.
- Generate API keys for programmatic access.
- Generate custom secrets for the agent.
## Next Steps
- [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.
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands.
- [Use the Cloud Issue Resolver](./cloud-issue-resolver) to automate code fixes and get assistance.
- [Learn about the Cloud API](./cloud-api) for programmatic access.

View File

@@ -1,7 +1,7 @@
---
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!
set up, it will allow OpenHands to work with your GitHub repository through the Cloud UI or straight from GitHub issues!
---
## Prerequisites
@@ -37,11 +37,11 @@ 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
## Working With GitHub Repos in Openhands Cloud
## Working With Github Repos in Openhands Cloud
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 conversation!
click on `Launch` to start the session!
![Connect Repo](/static/img/connect-repo.png)
@@ -67,5 +67,5 @@ To get OpenHands to work on pull requests, mention `@openhands` in the comments
## Next Steps
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands.
- [Access the Cloud UI](./cloud-ui) to interact with the web interface
- [Use the Cloud API](./cloud-api) to programmatically interact with OpenHands

View File

@@ -1,25 +1,23 @@
---
title: GitLab Integration
description: This guide walks you through the process of installing OpenHands Cloud for your GitLab repositories. Once
set up, it will allow OpenHands to work with your GitLab repository.
description: This guide walks you through the process of installing and configuring OpenHands Cloud for your GitLab repositories.
---
## Prerequisites
- Signed in to [OpenHands Cloud](https://app.all-hands.dev) with [a GitLab account](/usage/cloud/openhands-cloud).
- A GitLab account
- Access to OpenHands Cloud
## Adding GitLab Repository Access
## Installation Steps
Upon signing into OpenHands Cloud with a GitLab account, OpenHands will have access to your repositories.
1. Log in to [OpenHands Cloud](https://app.all-hands.dev)
2. If you haven't connected your GitLab account yet:
- Click on `Log in with GitLab`
- Authorize the OpenHands application
## Working With GitLab Repos in Openhands Cloud
After signing in with a Gitlab account, 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 conversation!
![Connect Repo](/static/img/connect-repo.png)
## Next Steps
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands.
- [Access the Cloud UI](./cloud-ui) to interact with the web interface
- [Use the Cloud API](./cloud-api) to programmatically interact with OpenHands

View File

@@ -14,13 +14,13 @@ You'll be prompted to connect with your GitHub or GitLab account:
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.
3. Review and accept the `terms of service` and select `Continue`.
## Next Steps
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.
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands.
- [Install GitHub Integration](./github-installation) to use OpenHands with your GitHub repositories
- [Install GitLab Integration](./gitlab-installation) to use OpenHands with your GitLab repositories
- [Access the Cloud UI](./cloud-ui) to interact with the web interface
- [Use the Cloud API](./cloud-api) to programmatically interact with OpenHands
- [Set up the Cloud Issue Resolver](./cloud-issue-resolver) to automate code fixes and provide intelligent assistance

View File

@@ -1,39 +1,24 @@
---
title: CLI
description: The Command-Line Interface (CLI) provides a powerful interface that lets you engage with OpenHands
directly from your terminal.
title: CLI Mode
description: CLI mode provides a powerful interactive Command-Line Interface (CLI) that lets you engage with OpenHands directly from your terminal.
---
This mode is different from the [headless mode](/usage/how-to/headless-mode), which is non-interactive and better
for scripting.
This mode is different from the [headless mode](./headless-mode), which is non-interactive and better for scripting.
## Getting Started
### Running with Python
1. Install OpenHands using pip:
```bash
pip install openhands-ai
```
1. Ensure you have followed the [Development setup instructions](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md).
2. Set your model, API key, and other preferences using environment variables or with the [`config.toml`](https://github.com/All-Hands-AI/OpenHands/blob/main/config.template.toml) file.
3. Launch an interactive OpenHands conversation from the command line:
```bash
openhands
poetry run python -m openhands.cli.main
```
This command opens an interactive prompt where you can type tasks or commands and get responses from OpenHands.
#### For Developers
If you have cloned the repository, you can run the CLI directly using Poetry:
```bash
poetry run python -m openhands.cli.main
```
### Running with Docker
1. Set the following environment variables in your terminal:

View File

@@ -46,7 +46,7 @@ This will produce a new image called `custom-image`, which will be available in
## Using the Docker Command
When running OpenHands using [the docker command](/usage/local-setup#start-the-app), replace
When running OpenHands using [the docker command](/usage/installation#start-the-app), replace
`-e SANDBOX_RUNTIME_CONTAINER_IMAGE=...` with `-e SANDBOX_BASE_CONTAINER_IMAGE=<custom image name>`:
```commandline

View File

@@ -48,6 +48,6 @@ The customization options you can set are:
| `LLM_MODEL` | Variable | Set the LLM to use with OpenHands | `LLM_MODEL="anthropic/claude-3-5-sonnet-20241022"` |
| `OPENHANDS_MAX_ITER` | Variable | Set max limit for agent iterations | `OPENHANDS_MAX_ITER=10` |
| `OPENHANDS_MACRO` | Variable | Customize default macro for invoking the resolver | `OPENHANDS_MACRO=@resolveit` |
| `OPENHANDS_BASE_CONTAINER_IMAGE` | Variable | Custom Sandbox ([learn more](/usage/how-to/custom-sandbox-guide)) | `OPENHANDS_BASE_CONTAINER_IMAGE="custom_image"` |
| `OPENHANDS_BASE_CONTAINER_IMAGE` | Variable | Custom Sandbox ([learn more](https://docs.all-hands.dev/modules/usage/how-to/custom-sandbox-guide)) | `OPENHANDS_BASE_CONTAINER_IMAGE="custom_image"` |
| `TARGET_BRANCH` | Variable | Merge to branch other than `main` | `TARGET_BRANCH="dev"` |
| `TARGET_RUNNER` | Variable | Target runner to execute the agent workflow (default ubuntu-latest) | `TARGET_RUNNER="custom-runner"` |

View File

@@ -1,13 +1,14 @@
---
title: GUI
description: High level overview of the Graphical User Interface (GUI) in OpenHands.
title: GUI Mode
description: OpenHands provides a Graphical User Interface (GUI) mode for interacting with the AI assistant.
---
## Prerequisites
## Installation and Setup
- [OpenHands is running](/usage/local-setup)
1. Follow the installation instructions to install OpenHands.
2. After running the command, access OpenHands at [http://localhost:3000](http://localhost:3000).
## Overview
## Interacting with the GUI
### Initial Setup
@@ -18,23 +19,16 @@ description: High level overview of the Graphical User Interface (GUI) in OpenHa
3. Enter the corresponding `API Key` for your chosen provider.
4. Click `Save Changes` to apply the settings.
### Settings
### Version Control Tokens
You can use the Settings page at any time to:
OpenHands supports multiple version control providers. You can configure tokens for multiple providers simultaneously.
- Setup the LLM provider and model for OpenHands.
- [Setup the search engine](/usage/search-engine-setup).
- [Configure MCP servers](/usage/mcp).
- [Connect to GitHub](/usage/how-to/gui-mode#github-setup) and [connect to GitLab](/usage/how-to/gui-mode#gitlab-setup)
- Set application settings like your preferred language, notifications and other preferences.
- Generate custom secrets.
#### GitHub Setup
#### GitHub Token Setup
OpenHands automatically exports a `GITHUB_TOKEN` to the shell environment if provided:
<AccordionGroup>
<Accordion title="Setting Up a GitHub Token">
<details>
<summary>Setting Up a GitHub Token</summary>
1. **Generate a Personal Access Token (PAT)**:
- On GitHub, go to Settings > Developer Settings > Personal Access Tokens > Tokens (classic).
@@ -43,11 +37,16 @@ OpenHands automatically exports a `GITHUB_TOKEN` to the shell environment if pro
- `repo` (Full control of private repositories)
- **Fine-Grained Tokens**
- All Repositories (You can select specific repositories, but this will impact what returns in repo search)
- Minimal Permissions (Select `Meta Data = Read-only` read for search, `Pull Requests = Read and Write` and `Content = Read and Write` for branch creation)
- Minimal Permissions ( Select `Meta Data = Read-only` read for search, `Pull Requests = Read and Write` and `Content = Read and Write` for branch creation)
2. **Enter Token in OpenHands**:
- In the Settings page, navigate to the `Git` tab.
- Click the Settings button (gear icon).
- Navigate to the `Git` tab.
- Paste your token in the `GitHub Token` field.
- Click `Save Changes` to apply the changes.
</details>
<details>
<summary>Organizational Token Policies</summary>
If you're working with organizational repositories, additional setup may be required:
@@ -60,12 +59,15 @@ OpenHands automatically exports a `GITHUB_TOKEN` to the shell environment if pro
- Look for the organization under `Organization access`.
- If required, click `Enable SSO` next to your organization.
- Complete the SSO authorization process.
</Accordion>
</details>
<details>
<summary>Troubleshooting</summary>
<Accordion title="Troubleshooting">
Common issues and solutions:
- **Token Not Recognized**:
- Ensure the token is properly saved in settings.
- Check that the token hasn't expired.
- Verify the token has the required scopes.
- Try regenerating the token.
@@ -79,15 +81,15 @@ OpenHands automatically exports a `GITHUB_TOKEN` to the shell environment if pro
- The app will show a green checkmark if the token is valid.
- Try accessing a repository to confirm permissions.
- Check the browser console for any error messages.
</Accordion>
</AccordionGroup>
</details>
#### GitLab Setup
#### GitLab Token Setup
OpenHands automatically exports a `GITLAB_TOKEN` to the shell environment if provided:
<AccordionGroup>
<Accordion title="Setting Up a GitLab Token">
<details>
<summary>Setting Up a GitLab Token</summary>
1. **Generate a Personal Access Token (PAT)**:
- On GitLab, go to User Settings > Access Tokens.
- Create a new token with the following scopes:
@@ -97,12 +99,15 @@ OpenHands automatically exports a `GITLAB_TOKEN` to the shell environment if pro
- `write_repository` (Write repository)
- Set an expiration date or leave it blank for a non-expiring token.
2. **Enter Token in OpenHands**:
- In the Settings page, navigate to the `Git` tab.
- Click the Settings button (gear icon).
- Navigate to the `Git` tab.
- Paste your token in the `GitLab Token` field.
- Click `Save Changes` to apply the changes.
</Accordion>
</details>
<details>
<summary>Troubleshooting</summary>
<Accordion title="Troubleshooting">
Common issues and solutions:
- **Token Not Recognized**:
@@ -114,30 +119,25 @@ OpenHands automatically exports a `GITLAB_TOKEN` to the shell environment if pro
- Verify project access permissions.
- Check if the token has the necessary scopes.
- For group/organization repositories, ensure you have proper access.
</Accordion>
</AccordionGroup>
</details>
#### Advanced Settings
### Advanced Settings
The `Advanced` settings allows configuration of additional LLM settings. Inside the Settings page, under the `LLM` tab,
toggle `Advanced` options to access additional settings.
1. Inside the Settings page, under the `LLM` tab, toggle `Advanced` options to access additional settings.
2. Use the `Custom Model` text box to manually enter a model if it's not in the list.
3. Specify a `Base URL` if required by your LLM provider.
- Custom Model: Use the `Custom Model` text box to manually enter a model. Make sure to use the correct prefix based on litellm docs.
- Base URL: Specify a `Base URL` if required by your LLM provider.
- Memory Condensation: The memory condenser manages the LLM's context by ensuring only the most important and relevant information is presented.
- Confirmation Mode: Enabling this mode will cause OpenHands to confirm an action with the user before performing it.
### Interacting with the AI
### Key Features
For an overview of the key features available inside a conversation, please refer to the [Key Features](/usage/key-features)
section of the documentation.
1. Type your prompt in the input box.
2. Click the send button or press Enter to submit your message.
3. The AI will process your input and provide a response in the chat window.
4. You can continue the conversation by asking follow-up questions or providing additional information.
## Tips for Effective Use
- Be specific in your requests to get the most accurate and helpful responses, as described in the [prompting best practices](../prompting/prompting-best-practices).
- Use one of the recommended models, as described in the [LLMs section](usage/llms/llms.md).
## Other Ways to Run Openhands
- [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 GitHub issues with a GitHub action.](/usage/how-to/github-action)
Remember, the GUI mode of OpenHands is designed to make your interaction with the AI assistant as smooth and intuitive
as possible. Don't hesitate to explore its features to maximize your productivity.

View File

@@ -1,10 +1,9 @@
---
title: Headless
description: You can run OpenHands with a single command, without starting the web application. This makes it easy to
write scripts and automate tasks with OpenHands.
title: Headless Mode
description: You can run OpenHands with a single command, without starting the web application. This makes it easy to write scripts and automate tasks with OpenHands.
---
This is different from [the CLI](./cli-mode), which is interactive, and better for active development.
This is different from [CLI Mode](./cli-mode), which is interactive, and better for active development.
## With Python

View File

@@ -12,8 +12,8 @@ To get started with OpenHands Cloud, visit [app.all-hands.dev](https://app.all-h
For more information see [getting started with OpenHands Cloud.](/usage/cloud/openhands-cloud)
## Running OpenHands on Your Own
## Running OpenHands Locally
Run OpenHands on your local system and bring your own LLM and API key.
For more information see [running OpenHands on your own.](/usage/local-setup)
For more information see [running OpenHands locally.](/usage/local-setup)

View File

@@ -48,7 +48,7 @@ We recommend using [LMStudio](https://lmstudio.ai/) for serving these models loc
### Start OpenHands with locally served model
Check [the installation guide](/usage/local-setup) to make sure you have all the prerequisites for running OpenHands.
Check [the installation guide](https://docs.all-hands.dev/modules/usage/installation) to make sure you have all the prerequisites for running OpenHands.
```bash
export LMSTUDIO_MODEL_NAME="imported-models/uncategorized/devstralq4_k_m.gguf" # <- Replace this with the model name you copied from LMStudio

View File

@@ -1,6 +1,6 @@
---
title: Getting Started
description: Getting started with running OpenHands on your own.
description: Getting started with running OpenHands locally.
---
## Recommended Methods for Running Openhands on Your Local System
@@ -62,17 +62,17 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
### Start the App
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.41-nikolaik
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.41-nikolaik \
-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.41
docker.all-hands.dev/all-hands-ai/openhands:0.40
```
You'll find OpenHands running at http://localhost:3000!
@@ -109,14 +109,6 @@ OpenHands requires an API key to access most language models. Here's how to get
</Accordion>
<Accordion title="Google (Gemini)">
1. Create a Google account if you don't already have one.
2. [Generate an API key](https://aistudio.google.com/apikey).
3. [Set up billing](https://aistudio.google.com/usage?tab=billing).
</Accordion>
</AccordionGroup>
Consider setting usage limits to control costs.
@@ -132,6 +124,7 @@ To enable search functionality in OpenHands:
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

View File

@@ -11,7 +11,7 @@ Currently OpenHands supports the following types of microagents:
- [Keyword-Triggered Microagents](./microagents-keyword): Guidelines activated by specific keywords in prompts.
To customize OpenHands' behavior, create a .openhands/microagents/ directory in the root of your repository and
add `<microagent_name>.md` files inside. For repository-specific guidelines, you can ask OpenHands to analyze your repository and create a comprehensive `repo.md` file (see [General Microagents](./microagents-repo) for details).
add `<microagent_name>.md` files inside.
<Note>
Loaded microagents take up space in the context window.

View File

@@ -17,45 +17,13 @@ Frontmatter should be enclosed in triple dashes (---) and may include the follow
|-----------|-----------------------------------------|----------|----------------|
| `agent` | The agent this microagent applies to | No | 'CodeActAgent' |
## Creating a Comprehensive Repository Agent
To create an effective repository agent, you can ask OpenHands to analyze your repository with a prompt like:
## Example
General microagent file example located at `.openhands/microagents/repo.md`:
```
Please browse the repository, look at the documentation and relevant code, and understand the purpose of this repository.
Specifically, I want you to create a `.openhands/microagents/repo.md` file. This file should contain succinct information that summarizes:
1. The purpose of this repository
2. The general setup of this repo
3. A brief description of the structure of this repo
Read all the GitHub workflows under .github/ of the repository (if this folder exists) to understand the CI checks (e.g., linter, pre-commit), and include those in the repo.md file.
```
This approach helps OpenHands capture repository context efficiently, reducing the need for repeated searches during conversations and ensuring more accurate solutions.
## Example Content
A comprehensive repository agent file (`.openhands/microagents/repo.md`) should include:
```
# Repository Purpose
This project is a TODO application that allows users to track TODO items.
# Setup Instructions
To set it up, you can run `npm run build`.
# Repository Structure
- `/src`: Core application code
- `/tests`: Test suite
- `/docs`: Documentation
- `/.github`: CI/CD workflows
# CI/CD Workflows
- `lint.yml`: Runs ESLint on all JavaScript files
- `test.yml`: Runs the test suite on pull requests
# Development Guidelines
Always make sure the tests are passing before committing changes. You can run the tests by running `npm run test`.
```

107
docs/usage/team-cli.mdx Normal file
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
```

View File

@@ -71,27 +71,10 @@ EVAL_CONDENSER=summarizer_for_eval \
The name is up to you, but should match a name defined in your `config.toml` file. The last argument in the command specifies the condenser configuration to use. In this case, `summarizer_for_eval` is used, which refers to the LLM-based summarizing condenser as defined above.
If no condenser configuration is specified, the 'noop' condenser will be used by default, which keeps the full conversation history.
```
For other configurations specific to evaluation, such as `save_trajectory_path`, these are typically set in the `get_config` function of the respective `run_infer.py` file for each benchmark.
### Enabling LLM-Based Editor Tools
The LLM-Based Editor tool (currently supported only for SWE-Bench) can be enabled by setting:
```bash
export ENABLE_LLM_EDITOR=true
```
You can set the config for the Editor LLM as:
```toml
[llm.draft_editor]
base_url = "http://localhost:9002/v1"
model = "hosted_vllm/lite_coder_qwen_editor_3B"
api_key = ""
temperature = 0.7
max_input_tokens = 10500
max_output_tokens = 10500
```
## Supported Benchmarks
The OpenHands evaluation harness supports a wide variety of benchmarks across [software engineering](#software-engineering), [web browsing](#web-browsing), [miscellaneous assistance](#misc-assistance), and [real-world](#real-world) tasks.

View File

@@ -42,7 +42,7 @@ from openhands.core.config import (
AgentConfig,
OpenHandsConfig,
get_llm_config_arg,
get_parser
get_parser,
)
from openhands.core.config.condenser_config import NoOpCondenserConfig
from openhands.core.config.utils import get_condenser_config_arg
@@ -62,7 +62,6 @@ from openhands.utils.shutdown_listener import sleep_if_should_continue
USE_HINT_TEXT = os.environ.get('USE_HINT_TEXT', 'false').lower() == 'true'
RUN_WITH_BROWSING = os.environ.get('RUN_WITH_BROWSING', 'false').lower() == 'true'
ENABLE_LLM_EDITOR = os.environ.get('ENABLE_LLM_EDITOR', 'false').lower() == 'true'
BenchMode = Literal['swe', 'swt', 'swt-ci']
@@ -255,19 +254,15 @@ def get_config(
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(
update_llm_config_for_completions_logging(
metadata.llm_config, metadata.eval_output_dir, instance['instance_id']
)
)
# get 'draft_editor' config if exists
config.set_llm_config(get_llm_config_arg('draft_editor'), 'draft_editor')
agent_config = AgentConfig(
enable_jupyter=False,
enable_browsing=RUN_WITH_BROWSING,
enable_llm_editor=ENABLE_LLM_EDITOR,
enable_llm_editor=False,
enable_mcp=False,
condenser=metadata.condenser_config,
enable_prompt_extensions=False,

View File

@@ -0,0 +1,76 @@
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { TrajectoryActions } from "#/components/features/trajectory/trajectory-actions";
describe("TrajectoryActions", () => {
const user = userEvent.setup();
const onPositiveFeedback = vi.fn();
const onNegativeFeedback = vi.fn();
const onExportTrajectory = vi.fn();
afterEach(() => {
vi.clearAllMocks();
});
it("should render correctly", () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
const actions = screen.getByTestId("feedback-actions");
within(actions).getByTestId("positive-feedback");
within(actions).getByTestId("negative-feedback");
within(actions).getByTestId("export-trajectory");
});
it("should call onPositiveFeedback when positive feedback is clicked", async () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
const positiveFeedback = screen.getByTestId("positive-feedback");
await user.click(positiveFeedback);
expect(onPositiveFeedback).toHaveBeenCalled();
});
it("should call onNegativeFeedback when negative feedback is clicked", async () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
const negativeFeedback = screen.getByTestId("negative-feedback");
await user.click(negativeFeedback);
expect(onNegativeFeedback).toHaveBeenCalled();
});
it("should call onExportTrajectory when export button is clicked", async () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
const exportButton = screen.getByTestId("export-trajectory");
await user.click(exportButton);
expect(onExportTrajectory).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, vi } from "vitest";
// Mock useParams before importing components
vi.mock("react-router", async () => {
const actual = await vi.importActual("react-router");
return {
...(actual as object),
useParams: () => ({ conversationId: "test-conversation-id" }),
};
});
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import { FeedbackForm } from "#/components/features/feedback/feedback-form";
import { I18nKey } from "#/i18n/declaration";
describe("FeedbackForm", () => {
const user = userEvent.setup();
const onCloseMock = vi.fn();
afterEach(() => {
vi.clearAllMocks();
});
it("should render correctly", () => {
renderWithProviders(
<FeedbackForm polarity="positive" onClose={onCloseMock} />,
);
screen.getByLabelText(I18nKey.FEEDBACK$EMAIL_LABEL);
screen.getByLabelText(I18nKey.FEEDBACK$PRIVATE_LABEL);
screen.getByLabelText(I18nKey.FEEDBACK$PUBLIC_LABEL);
screen.getByRole("button", { name: I18nKey.FEEDBACK$SHARE_LABEL });
screen.getByRole("button", { name: I18nKey.FEEDBACK$CANCEL_LABEL });
});
it("should switch between private and public permissions", async () => {
renderWithProviders(
<FeedbackForm polarity="positive" onClose={onCloseMock} />,
);
const privateRadio = screen.getByLabelText(I18nKey.FEEDBACK$PRIVATE_LABEL);
const publicRadio = screen.getByLabelText(I18nKey.FEEDBACK$PUBLIC_LABEL);
expect(privateRadio).toBeChecked(); // private is the default value
expect(publicRadio).not.toBeChecked();
await user.click(publicRadio);
expect(publicRadio).toBeChecked();
expect(privateRadio).not.toBeChecked();
await user.click(privateRadio);
expect(privateRadio).toBeChecked();
expect(publicRadio).not.toBeChecked();
});
it("should call onClose when the close button is clicked", async () => {
renderWithProviders(
<FeedbackForm polarity="positive" onClose={onCloseMock} />,
);
await user.click(
screen.getByRole("button", { name: I18nKey.FEEDBACK$CANCEL_LABEL }),
);
expect(onCloseMock).toHaveBeenCalled();
});
});

View File

@@ -6,21 +6,6 @@ import { renderWithProviders } from "test-utils";
import OpenHands from "#/api/open-hands";
import SettingsScreen from "#/routes/settings";
import { PaymentForm } from "#/components/features/payment/payment-form";
import * as useSettingsModule from "#/hooks/query/use-settings";
// Mock the useSettings hook
vi.mock("#/hooks/query/use-settings", async () => {
const actual = await vi.importActual<typeof import("#/hooks/query/use-settings")>("#/hooks/query/use-settings");
return {
...actual,
useSettings: vi.fn().mockReturnValue({
data: {
EMAIL_VERIFIED: true, // Mock email as verified to prevent redirection
},
isLoading: false,
}),
};
});
// Mock the i18next hook
vi.mock("react-i18next", async () => {
@@ -35,7 +20,6 @@ vi.mock("react-i18next", async () => {
"SETTINGS$NAV_CREDITS": "Credits",
"SETTINGS$NAV_API_KEYS": "API Keys",
"SETTINGS$NAV_LLM": "LLM",
"SETTINGS$NAV_USER": "User",
"SETTINGS$TITLE": "Settings"
};
return translations[key] || key;
@@ -63,10 +47,6 @@ describe("Settings Billing", () => {
Component: () => <div data-testid="git-settings-screen" />,
path: "/settings/git",
},
{
Component: () => <div data-testid="user-settings-screen" />,
path: "/settings/user",
},
],
},
]);

View File

@@ -1,60 +1,5 @@
import axios, { AxiosError, AxiosResponse } from "axios";
import axios from "axios";
export const openHands = axios.create({
baseURL: `${window.location.protocol}//${import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host}`,
});
// Helper function to check if a response contains an email verification error
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const checkForEmailVerificationError = (data: any): boolean => {
const EMAIL_NOT_VERIFIED = "EmailNotVerifiedError";
if (typeof data === "string") {
return data.includes(EMAIL_NOT_VERIFIED);
}
if (typeof data === "object" && data !== null) {
if ("message" in data) {
const { message } = data;
if (typeof message === "string") {
return message.includes(EMAIL_NOT_VERIFIED);
}
if (Array.isArray(message)) {
return message.some(
(msg) => typeof msg === "string" && msg.includes(EMAIL_NOT_VERIFIED),
);
}
}
// Search any values in object in case message key is different
return Object.values(data).some(
(value) =>
(typeof value === "string" && value.includes(EMAIL_NOT_VERIFIED)) ||
(Array.isArray(value) &&
value.some(
(v) => typeof v === "string" && v.includes(EMAIL_NOT_VERIFIED),
)),
);
}
return false;
};
// Set up the global interceptor
openHands.interceptors.response.use(
(response: AxiosResponse) => response,
(error: AxiosError) => {
// Check if it's a 403 error with the email verification message
if (
error.response?.status === 403 &&
checkForEmailVerificationError(error.response?.data)
) {
if (window.location.pathname !== "/settings/user") {
window.location.reload();
}
}
// Continue with the error for other error handlers
return Promise.reject(error);
},
);

View File

@@ -1,5 +1,7 @@
import { AxiosHeaders } from "axios";
import {
Feedback,
FeedbackResponse,
GitHubAccessTokenResponse,
GetConfigResponse,
GetVSCodeUrlResponse,
@@ -93,6 +95,20 @@ class OpenHands {
return headers;
}
/**
* Send feedback to the server
* @param data Feedback data
* @returns The stored feedback data
*/
static async submitFeedback(
conversationId: string,
feedback: Feedback,
): Promise<FeedbackResponse> {
const url = `/api/conversations/${conversationId}/submit-feedback`;
const { data } = await openHands.post<FeedbackResponse>(url, feedback);
return data;
}
/**
* Authenticate with GitHub token
* @returns Response with authentication status and user info if successful

View File

@@ -14,6 +14,17 @@ export interface FileUploadSuccessResponse {
skipped_files: { name: string; reason: string }[];
}
export interface FeedbackBodyResponse {
message: string;
feedback_id: string;
password: string;
}
export interface FeedbackResponse {
statusCode: number;
body: FeedbackBodyResponse;
}
export interface GitHubAccessTokenResponse {
access_token: string;
}
@@ -23,6 +34,15 @@ export interface AuthenticationResponse {
login?: string; // Only present when allow list is enabled
}
export interface Feedback {
version: string;
email: string;
token: string;
polarity: "positive" | "negative";
permissions: "public" | "private";
trajectory: unknown[];
}
export interface GetConfigResponse {
APP_MODE: "saas" | "oss";
APP_SLUG?: string;

View File

@@ -11,6 +11,7 @@ import { InteractiveChatBox } from "./interactive-chat-box";
import { RootState } from "#/store";
import { AgentState } from "#/types/agent-state";
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { FeedbackModal } from "../feedback/feedback-modal";
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
import { TypingIndicator } from "./typing-indicator";
import { useWsClient } from "#/context/ws-client-provider";
@@ -49,6 +50,10 @@ export function ChatInterface() {
const { curAgentState } = useSelector((state: RootState) => state.agent);
const [feedbackPolarity, setFeedbackPolarity] = React.useState<
"positive" | "negative"
>("positive");
const [feedbackModalIsOpen, setFeedbackModalIsOpen] = React.useState(false);
const [messageToSend, setMessageToSend] = React.useState<string | null>(null);
const { selectedRepository, replayJson } = useSelector(
(state: RootState) => state.initialQuery,
@@ -91,6 +96,13 @@ export function ChatInterface() {
send(generateAgentStateChangeEvent(AgentState.STOPPED));
};
const onClickShareFeedbackActionButton = async (
polarity: "positive" | "negative",
) => {
setFeedbackModalIsOpen(true);
setFeedbackPolarity(polarity);
};
const onClickExportTrajectoryButton = () => {
if (!params.conversationId) {
displayErrorToast(t(I18nKey.CONVERSATION$DOWNLOAD_ERROR));
@@ -152,6 +164,12 @@ export function ChatInterface() {
<div className="flex flex-col gap-[6px] px-4 pb-4">
<div className="flex justify-between relative">
<TrajectoryActions
onPositiveFeedback={() =>
onClickShareFeedbackActionButton("positive")
}
onNegativeFeedback={() =>
onClickShareFeedbackActionButton("negative")
}
onExportTrajectory={() => onClickExportTrajectoryButton()}
/>
@@ -176,6 +194,12 @@ export function ChatInterface() {
onChange={setMessageToSend}
/>
</div>
<FeedbackModal
isOpen={feedbackModalIsOpen}
onClose={() => setFeedbackModalIsOpen(false)}
polarity={feedbackPolarity}
/>
</div>
);
}

View File

@@ -0,0 +1,152 @@
import React from "react";
import hotToast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { Feedback } from "#/api/open-hands.types";
import { useSubmitFeedback } from "#/hooks/mutation/use-submit-feedback";
import { BrandButton } from "../settings/brand-button";
const FEEDBACK_VERSION = "1.0";
const VIEWER_PAGE = "https://www.all-hands.dev/share";
interface FeedbackFormProps {
onClose: () => void;
polarity: "positive" | "negative";
}
export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) {
const { t } = useTranslation();
const copiedToClipboardToast = () => {
hotToast(t(I18nKey.FEEDBACK$PASSWORD_COPIED_MESSAGE), {
icon: "📋",
position: "bottom-right",
});
};
const onPressToast = (password: string) => {
navigator.clipboard.writeText(password);
copiedToClipboardToast();
};
const shareFeedbackToast = (
message: string,
link: string,
password: string,
) => {
hotToast(
<div className="flex flex-col gap-1">
<span>{message}</span>
<a
data-testid="toast-share-url"
className="text-blue-500 underline"
onClick={() => onPressToast(password)}
href={link}
target="_blank"
rel="noreferrer"
>
{t(I18nKey.FEEDBACK$GO_TO_FEEDBACK)}
</a>
<span onClick={() => onPressToast(password)} className="cursor-pointer">
{t(I18nKey.FEEDBACK$PASSWORD)}: {password}{" "}
<span className="text-gray-500">
({t(I18nKey.FEEDBACK$COPY_LABEL)})
</span>
</span>
</div>,
{ duration: 10000 },
);
};
const { mutate: submitFeedback, isPending } = useSubmitFeedback();
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
const formData = new FormData(event.currentTarget);
const email = formData.get("email")?.toString() || "";
const permissions = (formData.get("permissions")?.toString() ||
"private") as "private" | "public";
const feedback: Feedback = {
version: FEEDBACK_VERSION,
email,
polarity,
permissions,
trajectory: [],
token: "",
};
submitFeedback(
{ feedback },
{
onSuccess: (data) => {
const { message, feedback_id, password } = data.body; // eslint-disable-line
const link = `${VIEWER_PAGE}?share_id=${feedback_id}`;
shareFeedbackToast(message, link, password);
onClose();
},
},
);
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-6 w-full">
<label className="flex flex-col gap-2">
<span className="text-xs text-neutral-400">
{t(I18nKey.FEEDBACK$EMAIL_LABEL)}
</span>
<input
required
name="email"
type="email"
placeholder={t(I18nKey.FEEDBACK$EMAIL_PLACEHOLDER)}
className="bg-[#27272A] px-3 py-[10px] rounded"
/>
</label>
<div className="flex gap-4 text-neutral-400">
<label className="flex gap-2 cursor-pointer">
<input
name="permissions"
value="private"
type="radio"
defaultChecked
/>
{t(I18nKey.FEEDBACK$PRIVATE_LABEL)}
</label>
<label className="flex gap-2 cursor-pointer">
<input name="permissions" value="public" type="radio" />
{t(I18nKey.FEEDBACK$PUBLIC_LABEL)}
</label>
</div>
<div className="flex gap-2">
<BrandButton
type="submit"
variant="primary"
className="grow"
isDisabled={isPending}
>
{isPending
? t(I18nKey.FEEDBACK$SUBMITTING_LABEL)
: t(I18nKey.FEEDBACK$SHARE_LABEL)}
</BrandButton>
<BrandButton
type="button"
variant="secondary"
className="grow"
onClick={onClose}
isDisabled={isPending}
>
{t(I18nKey.FEEDBACK$CANCEL_LABEL)}
</BrandButton>
</div>
{isPending && (
<p className="text-sm text-center text-neutral-400">
{t(I18nKey.FEEDBACK$SUBMITTING_MESSAGE)}
</p>
)}
</form>
);
}

View File

@@ -0,0 +1,34 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import {
BaseModalTitle,
BaseModalDescription,
} from "#/components/shared/modals/confirmation-modals/base-modal";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import { FeedbackForm } from "./feedback-form";
interface FeedbackModalProps {
onClose: () => void;
isOpen: boolean;
polarity: "positive" | "negative";
}
export function FeedbackModal({
onClose,
isOpen,
polarity,
}: FeedbackModalProps) {
const { t } = useTranslation();
if (!isOpen) return null;
return (
<ModalBackdrop onClose={onClose}>
<ModalBody className="border border-tertiary">
<BaseModalTitle title={t(I18nKey.FEEDBACK$TITLE)} />
<BaseModalDescription description={t(I18nKey.FEEDBACK$DESCRIPTION)} />
<FeedbackForm onClose={onClose} polarity={polarity} />
</ModalBody>
</ModalBackdrop>
);
}

View File

@@ -1,32 +0,0 @@
import React from "react";
import { useLocation, useNavigate } from "react-router";
import { useSettings } from "#/hooks/query/use-settings";
/**
* A component that restricts access to routes based on email verification status.
* If EMAIL_VERIFIED is false, only allows access to the /settings/user page.
*/
export function EmailVerificationGuard({
children,
}: {
children: React.ReactNode;
}) {
const { data: settings, isLoading } = useSettings();
const navigate = useNavigate();
const { pathname } = useLocation();
React.useEffect(() => {
// If settings are still loading, don't do anything yet
if (isLoading) return;
// If EMAIL_VERIFIED is explicitly false (not undefined or null)
if (settings?.EMAIL_VERIFIED === false) {
// Allow access to /settings/user but redirect from any other page
if (pathname !== "/settings/user") {
navigate("/settings/user", { replace: true });
}
}
}, [settings?.EMAIL_VERIFIED, pathname, navigate, isLoading]);
return children;
}

View File

@@ -69,21 +69,16 @@ export function Sidebar() {
<div className="flex items-center justify-center">
<AllHandsLogoButton />
</div>
<NewProjectButton disabled={settings?.EMAIL_VERIFIED === false} />
<NewProjectButton />
<ConversationPanelButton
isOpen={conversationPanelIsOpen}
onClick={() =>
settings?.EMAIL_VERIFIED === false
? null
: setConversationPanelIsOpen((prev) => !prev)
}
disabled={settings?.EMAIL_VERIFIED === false}
onClick={() => setConversationPanelIsOpen((prev) => !prev)}
/>
</div>
<div className="flex flex-row md:flex-col md:items-center gap-[26px] md:mb-4">
<DocsButton disabled={settings?.EMAIL_VERIFIED === false} />
<SettingsButton disabled={settings?.EMAIL_VERIFIED === false} />
<DocsButton />
<SettingsButton />
<UserActions
user={
user.data ? { avatar_url: user.data.avatar_url } : undefined

View File

@@ -1,19 +1,37 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import ThumbsUpIcon from "#/icons/thumbs-up.svg?react";
import ThumbDownIcon from "#/icons/thumbs-down.svg?react";
import ExportIcon from "#/icons/export.svg?react";
import { TrajectoryActionButton } from "#/components/shared/buttons/trajectory-action-button";
interface TrajectoryActionsProps {
onPositiveFeedback: () => void;
onNegativeFeedback: () => void;
onExportTrajectory: () => void;
}
export function TrajectoryActions({
onPositiveFeedback,
onNegativeFeedback,
onExportTrajectory,
}: TrajectoryActionsProps) {
const { t } = useTranslation();
return (
<div data-testid="trajectory-actions" className="flex gap-1">
<div data-testid="feedback-actions" className="flex gap-1">
<TrajectoryActionButton
testId="positive-feedback"
onClick={onPositiveFeedback}
icon={<ThumbsUpIcon width={15} height={15} />}
tooltip={t(I18nKey.BUTTON$MARK_HELPFUL)}
/>
<TrajectoryActionButton
testId="negative-feedback"
onClick={onNegativeFeedback}
icon={<ThumbDownIcon width={15} height={15} />}
tooltip={t(I18nKey.BUTTON$MARK_NOT_HELPFUL)}
/>
<TrajectoryActionButton
testId="export-trajectory"
onClick={onExportTrajectory}

View File

@@ -8,13 +8,11 @@ import { cn } from "#/utils/utils";
interface ConversationPanelButtonProps {
isOpen: boolean;
onClick: () => void;
disabled?: boolean;
}
export function ConversationPanelButton({
isOpen,
onClick,
disabled = false,
}: ConversationPanelButtonProps) {
const { t } = useTranslation();
@@ -24,14 +22,10 @@ export function ConversationPanelButton({
tooltip={t(I18nKey.SIDEBAR$CONVERSATIONS)}
ariaLabel={t(I18nKey.SIDEBAR$CONVERSATIONS)}
onClick={onClick}
disabled={disabled}
>
<FaListUl
size={22}
className={cn(
isOpen ? "text-white" : "text-[#9099AC]",
disabled && "opacity-50",
)}
className={cn(isOpen ? "text-white" : "text-[#9099AC]")}
/>
</TooltipButton>
);

View File

@@ -3,24 +3,15 @@ import DocsIcon from "#/icons/academy.svg?react";
import { I18nKey } from "#/i18n/declaration";
import { TooltipButton } from "./tooltip-button";
interface DocsButtonProps {
disabled?: boolean;
}
export function DocsButton({ disabled = false }: DocsButtonProps) {
export function DocsButton() {
const { t } = useTranslation();
return (
<TooltipButton
tooltip={t(I18nKey.SIDEBAR$DOCS)}
ariaLabel={t(I18nKey.SIDEBAR$DOCS)}
href="https://docs.all-hands.dev"
disabled={disabled}
>
<DocsIcon
width={28}
height={28}
className={`text-[#9099AC] ${disabled ? "opacity-50" : ""}`}
/>
<DocsIcon width={28} height={28} className="text-[#9099AC]" />
</TooltipButton>
);
}

View File

@@ -3,11 +3,7 @@ import { I18nKey } from "#/i18n/declaration";
import PlusIcon from "#/icons/plus.svg?react";
import { TooltipButton } from "./tooltip-button";
interface NewProjectButtonProps {
disabled?: boolean;
}
export function NewProjectButton({ disabled = false }: NewProjectButtonProps) {
export function NewProjectButton() {
const { t } = useTranslation();
const startNewProject = t(I18nKey.CONVERSATION$START_NEW);
return (
@@ -16,7 +12,6 @@ export function NewProjectButton({ disabled = false }: NewProjectButtonProps) {
ariaLabel={startNewProject}
navLinkTo="/"
testId="new-project-button"
disabled={disabled}
>
<PlusIcon width={28} height={28} />
</TooltipButton>

View File

@@ -5,13 +5,9 @@ import { I18nKey } from "#/i18n/declaration";
interface SettingsButtonProps {
onClick?: () => void;
disabled?: boolean;
}
export function SettingsButton({
onClick,
disabled = false,
}: SettingsButtonProps) {
export function SettingsButton({ onClick }: SettingsButtonProps) {
const { t } = useTranslation();
return (
@@ -21,7 +17,6 @@ export function SettingsButton({
ariaLabel={t(I18nKey.SETTINGS$TITLE)}
onClick={onClick}
navLinkTo="/settings"
disabled={disabled}
>
<SettingsIcon width={28} height={28} />
</TooltipButton>

View File

@@ -12,7 +12,6 @@ export interface TooltipButtonProps {
ariaLabel: string;
testId?: string;
className?: React.HTMLAttributes<HTMLButtonElement>["className"];
disabled?: boolean;
}
export function TooltipButton({
@@ -24,10 +23,9 @@ export function TooltipButton({
ariaLabel,
testId,
className,
disabled = false,
}: TooltipButtonProps) {
const handleClick = (e: React.MouseEvent) => {
if (onClick && !disabled) {
if (onClick) {
onClick();
e.preventDefault();
}
@@ -39,12 +37,7 @@ export function TooltipButton({
aria-label={ariaLabel}
data-testid={testId}
onClick={handleClick}
className={cn(
"hover:opacity-80",
disabled && "opacity-50 cursor-not-allowed",
className,
)}
disabled={disabled}
className={cn("hover:opacity-80", className)}
>
{children}
</button>
@@ -52,7 +45,7 @@ export function TooltipButton({
let content;
if (navLinkTo && !disabled) {
if (navLinkTo) {
content = (
<NavLink
to={navLinkTo}
@@ -70,24 +63,7 @@ export function TooltipButton({
{children}
</NavLink>
);
} else if (navLinkTo && disabled) {
// If disabled and has navLinkTo, render a button that looks like a NavLink but doesn't navigate
content = (
<button
type="button"
aria-label={ariaLabel}
data-testid={testId}
className={cn(
"text-[#9099AC]",
"opacity-50 cursor-not-allowed",
className,
)}
disabled
>
{children}
</button>
);
} else if (href && !disabled) {
} else if (href) {
content = (
<a
href={href}
@@ -100,19 +76,6 @@ export function TooltipButton({
{children}
</a>
);
} else if (href && disabled) {
// If disabled and has href, render a button that looks like a link but doesn't navigate
content = (
<button
type="button"
aria-label={ariaLabel}
data-testid={testId}
className={cn("opacity-50 cursor-not-allowed", className)}
disabled
>
{children}
</button>
);
} else {
content = buttonContent;
}

View File

@@ -96,7 +96,7 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
testId="llm-api-key-help-anchor"
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
href="https://docs.all-hands.dev/usage/local-setup#getting-an-api-key"
href="https://docs.all-hands.dev/usage/installation#getting-an-api-key"
/>
</div>

View File

@@ -0,0 +1,22 @@
import { useMutation } from "@tanstack/react-query";
import { Feedback } from "#/api/open-hands.types";
import OpenHands from "#/api/open-hands";
import { useConversationId } from "#/hooks/use-conversation-id";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
type SubmitFeedbackArgs = {
feedback: Feedback;
};
export const useSubmitFeedback = () => {
const { conversationId } = useConversationId();
return useMutation({
mutationFn: ({ feedback }: SubmitFeedbackArgs) =>
OpenHands.submitFeedback(conversationId, feedback),
onError: (error) => {
displayErrorToast(error.message);
},
retry: 2,
retryDelay: 500,
});
};

View File

@@ -17,10 +17,6 @@ export const useActiveConversation = () => {
useEffect(() => {
const conversation = userConversation.data;
OpenHands.setCurrentConversation(conversation || null);
}, [
conversationId,
userConversation.isFetched,
userConversation?.data?.status,
]);
}, [conversationId, userConversation.isFetched]);
return userConversation;
};

View File

@@ -27,8 +27,7 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
apiSettings.enable_proactive_conversation_starters,
USER_CONSENTS_TO_ANALYTICS: apiSettings.user_consents_to_analytics,
SEARCH_API_KEY: apiSettings.search_api_key || "",
EMAIL: apiSettings.email || "",
EMAIL_VERIFIED: apiSettings.email_verified,
MCP_CONFIG: apiSettings.mcp_config,
IS_NEW_USER: false,
};
@@ -45,7 +44,6 @@ export const useSettings = () => {
// would want to show the modal immediately if the
// settings are not found
retry: (_, error) => error.status !== 404,
refetchOnWindowFocus: false,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
enabled: !isOnTosPage && !!userIsAuthenticated,

View File

@@ -556,19 +556,4 @@ export enum I18nKey {
TIPS$PROTIP = "TIPS$PROTIP",
FEEDBACK$SUBMITTING_LABEL = "FEEDBACK$SUBMITTING_LABEL",
FEEDBACK$SUBMITTING_MESSAGE = "FEEDBACK$SUBMITTING_MESSAGE",
SETTINGS$NAV_USER = "SETTINGS$NAV_USER",
SETTINGS$USER_TITLE = "SETTINGS$USER_TITLE",
SETTINGS$USER_EMAIL = "SETTINGS$USER_EMAIL",
SETTINGS$USER_EMAIL_LOADING = "SETTINGS$USER_EMAIL_LOADING",
SETTINGS$SAVE = "SETTINGS$SAVE",
SETTINGS$EMAIL_SAVED_SUCCESSFULLY = "SETTINGS$EMAIL_SAVED_SUCCESSFULLY",
SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY = "SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY",
SETTINGS$FAILED_TO_SAVE_EMAIL = "SETTINGS$FAILED_TO_SAVE_EMAIL",
SETTINGS$SENDING = "SETTINGS$SENDING",
SETTINGS$VERIFICATION_EMAIL_SENT = "SETTINGS$VERIFICATION_EMAIL_SENT",
SETTINGS$EMAIL_VERIFICATION_REQUIRED = "SETTINGS$EMAIL_VERIFICATION_REQUIRED",
SETTINGS$INVALID_EMAIL_FORMAT = "SETTINGS$INVALID_EMAIL_FORMAT",
SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE = "SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE",
SETTINGS$RESEND_VERIFICATION = "SETTINGS$RESEND_VERIFICATION",
SETTINGS$FAILED_TO_RESEND_VERIFICATION = "SETTINGS$FAILED_TO_RESEND_VERIFICATION",
}

View File

@@ -8894,245 +8894,5 @@
"tr": "Geri bildirim gönderiliyor, lütfen bekleyin...",
"de": "Feedback senden, bitte warten...",
"uk": "Відправляємо відгук, будь ласка, почекайте..."
},
"SETTINGS$NAV_USER": {
"en": "User",
"ja": "ユーザー",
"zh-CN": "用户",
"zh-TW": "用戶",
"ko-KR": "사용자",
"no": "Bruker",
"it": "Utente",
"pt": "Usuário",
"es": "Usuario",
"ar": "المستخدم",
"fr": "Utilisateur",
"tr": "Kullanıcı",
"de": "Benutzer",
"uk": "Користувач"
},
"SETTINGS$USER_TITLE": {
"en": "User Information",
"ja": "ユーザー情報",
"zh-CN": "用户信息",
"zh-TW": "用戶信息",
"ko-KR": "사용자 정보",
"no": "Brukerinformasjon",
"it": "Informazioni utente",
"pt": "Informações do usuário",
"es": "Información del usuario",
"ar": "معلومات المستخدم",
"fr": "Informations utilisateur",
"tr": "Kullanıcı Bilgileri",
"de": "Benutzerinformationen",
"uk": "Інформація про користувача"
},
"SETTINGS$USER_EMAIL": {
"en": "Email",
"ja": "メール",
"zh-CN": "邮箱",
"zh-TW": "郵箱",
"ko-KR": "이메일",
"no": "E-post",
"it": "Email",
"pt": "Email",
"es": "Correo electrónico",
"ar": "البريد الإلكتروني",
"fr": "Email",
"tr": "E-posta",
"de": "E-Mail",
"uk": "Електронна пошта"
},
"SETTINGS$USER_EMAIL_LOADING": {
"en": "Loading...",
"ja": "読み込み中...",
"zh-CN": "加载中...",
"zh-TW": "加載中...",
"ko-KR": "로딩 중...",
"no": "Laster...",
"it": "Caricamento...",
"pt": "Carregando...",
"es": "Cargando...",
"ar": "جار التحميل...",
"fr": "Chargement...",
"tr": "Yükleniyor...",
"de": "Wird geladen...",
"uk": "Завантаження..."
},
"SETTINGS$SAVE": {
"en": "Save",
"ja": "保存",
"zh-CN": "保存",
"zh-TW": "儲存",
"ko-KR": "저장",
"no": "Lagre",
"it": "Salva",
"pt": "Salvar",
"es": "Guardar",
"ar": "حفظ",
"fr": "Enregistrer",
"tr": "Kaydet",
"de": "Speichern",
"uk": "Зберегти"
},
"SETTINGS$EMAIL_SAVED_SUCCESSFULLY": {
"en": "Email saved successfully",
"ja": "メールが正常に保存されました",
"zh-CN": "邮箱保存成功",
"zh-TW": "郵箱儲存成功",
"ko-KR": "이메일이 성공적으로 저장되었습니다",
"no": "E-post lagret",
"it": "Email salvata con successo",
"pt": "Email salvo com sucesso",
"es": "Correo electrónico guardado con éxito",
"ar": "تم حفظ البريد الإلكتروني بنجاح",
"fr": "Email enregistré avec succès",
"tr": "E-posta başarıyla kaydedildi",
"de": "E-Mail erfolgreich gespeichert",
"uk": "Електронну пошту успішно збережено"
},
"SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY": {
"en": "Your email has been verified successfully!",
"ja": "メールアドレスの確認が完了しました!",
"zh-CN": "您的邮箱已成功验证!",
"zh-TW": "您的郵箱已成功驗證!",
"ko-KR": "이메일이 성공적으로 인증되었습니다!",
"no": "E-posten din er bekreftet!",
"it": "La tua email è stata verificata con successo!",
"pt": "Seu email foi verificado com sucesso!",
"es": "¡Tu correo electrónico ha sido verificado con éxito!",
"ar": "تم التحقق من بريدك الإلكتروني بنجاح!",
"fr": "Votre email a été vérifié avec succès !",
"tr": "E-postanız başarıyla doğrulandı!",
"de": "Ihre E-Mail wurde erfolgreich verifiziert!",
"uk": "Вашу електронну пошту успішно підтверджено!"
},
"SETTINGS$FAILED_TO_SAVE_EMAIL": {
"en": "Failed to save email",
"ja": "メールの保存に失敗しました",
"zh-CN": "保存邮箱失败",
"zh-TW": "儲存郵箱失敗",
"ko-KR": "이메일 저장 실패",
"no": "Kunne ikke lagre e-post",
"it": "Impossibile salvare l'email",
"pt": "Falha ao salvar email",
"es": "Error al guardar el correo electrónico",
"ar": "فشل في حفظ البريد الإلكتروني",
"fr": "Échec de l'enregistrement de l'email",
"tr": "E-posta kaydedilemedi",
"de": "E-Mail konnte nicht gespeichert werden",
"uk": "Не вдалося зберегти електронну пошту"
},
"SETTINGS$SENDING": {
"en": "Sending",
"ja": "送信中",
"zh-CN": "发送中",
"zh-TW": "發送中",
"ko-KR": "전송 중",
"no": "Sender",
"it": "Invio in corso",
"pt": "Enviando",
"es": "Enviando",
"ar": "جاري الإرسال",
"fr": "Envoi en cours",
"tr": "Gönderiliyor",
"de": "Wird gesendet",
"uk": "Надсилання"
},
"SETTINGS$VERIFICATION_EMAIL_SENT": {
"en": "Verification email sent",
"ja": "確認メールを送信しました",
"zh-CN": "验证邮件已发送",
"zh-TW": "驗證郵件已發送",
"ko-KR": "인증 이메일이 전송되었습니다",
"no": "Bekreftelsese-post sendt",
"it": "Email di verifica inviata",
"pt": "Email de verificação enviado",
"es": "Correo de verificación enviado",
"ar": "تم إرسال بريد التحقق",
"fr": "Email de vérification envoyé",
"tr": "Doğrulama e-postası gönderildi",
"de": "Bestätigungs-E-Mail gesendet",
"uk": "Лист підтвердження надіслано"
},
"SETTINGS$EMAIL_VERIFICATION_REQUIRED": {
"en": "You must verify your email address before using All Hands",
"ja": "All Handsを使用する前にメールアドレスを確認する必要があります",
"zh-CN": "使用All Hands前您必须验证您的电子邮件地址",
"zh-TW": "使用All Hands前您必須驗證您的電子郵件地址",
"ko-KR": "All Hands를 사용하기 전에 이메일 주소를 확인해야 합니다",
"no": "Du må bekrefte e-postadressen din før du bruker All Hands",
"it": "Devi verificare il tuo indirizzo email prima di utilizzare All Hands",
"pt": "Você deve verificar seu endereço de e-mail antes de usar o All Hands",
"es": "Debe verificar su dirección de correo electrónico antes de usar All Hands",
"ar": "يجب عليك التحقق من عنوان بريدك الإلكتروني قبل استخدام All Hands",
"fr": "Vous devez vérifier votre adresse e-mail avant d'utiliser All Hands",
"tr": "All Hands'i kullanmadan önce e-posta adresinizi doğrulamanız gerekiyor",
"de": "Sie müssen Ihre E-Mail-Adresse bestätigen, bevor Sie All Hands verwenden können",
"uk": "Ви повинні підтвердити свою електронну адресу перед використанням All Hands"
},
"SETTINGS$INVALID_EMAIL_FORMAT": {
"en": "Please enter a valid email address",
"ja": "有効なメールアドレスを入力してください",
"zh-CN": "请输入有效的电子邮件地址",
"zh-TW": "請輸入有效的電子郵件地址",
"ko-KR": "유효한 이메일 주소를 입력하세요",
"no": "Vennligst skriv inn en gyldig e-postadresse",
"it": "Inserisci un indirizzo email valido",
"pt": "Por favor, insira um endereço de e-mail válido",
"es": "Por favor, introduzca una dirección de correo electrónico válida",
"ar": "الرجاء إدخال عنوان بريد إلكتروني صالح",
"fr": "Veuillez entrer une adresse e-mail valide",
"tr": "Lütfen geçerli bir e-posta adresi girin",
"de": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"uk": "Будь ласка, введіть дійсну електронну адресу"
},
"SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE": {
"en": "Your access is limited until your email is verified. You can only access this settings page.",
"ja": "メールが確認されるまでアクセスが制限されています。この設定ページにのみアクセスできます。",
"zh-CN": "在验证您的电子邮件之前,您的访问权限受到限制。您只能访问此设置页面。",
"zh-TW": "在驗證您的電子郵件之前,您的訪問權限受到限制。您只能訪問此設置頁面。",
"ko-KR": "이메일이 확인될 때까지 액세스가 제한됩니다. 이 설정 페이지만 액세스할 수 있습니다.",
"no": "Din tilgang er begrenset til e-posten din er bekreftet. Du kan bare få tilgang til denne innstillingssiden.",
"it": "Il tuo accesso è limitato fino a quando la tua email non viene verificata. Puoi accedere solo a questa pagina delle impostazioni.",
"pt": "Seu acesso é limitado até que seu e-mail seja verificado. Você só pode acessar esta página de configurações.",
"es": "Su acceso es limitado hasta que se verifique su correo electrónico. Solo puede acceder a esta página de configuración.",
"ar": "وصولك محدود حتى يتم التحقق من بريدك الإلكتروني. يمكنك فقط الوصول إلى صفحة الإعدادات هذه.",
"fr": "Votre accès est limité jusqu'à ce que votre e-mail soit vérifié. Vous ne pouvez accéder qu'à cette page de paramètres.",
"tr": "E-postanız doğrulanana kadar erişiminiz sınırlıdır. Yalnızca bu ayarlar sayfasına erişebilirsiniz.",
"de": "Ihr Zugriff ist eingeschränkt, bis Ihre E-Mail-Adresse bestätigt wurde. Sie können nur auf diese Einstellungsseite zugreifen.",
"uk": "Ваш доступ обмежений, доки ваша електронна пошта не буде підтверджена. Ви можете отримати доступ лише до цієї сторінки налаштувань."
},
"SETTINGS$RESEND_VERIFICATION": {
"en": "Resend verification",
"ja": "確認メールを再送信",
"zh-CN": "重新发送验证",
"zh-TW": "重新發送驗證",
"ko-KR": "인증 재전송",
"no": "Send bekreftelse på nytt",
"it": "Rinvia verifica",
"pt": "Reenviar verificação",
"es": "Reenviar verificación",
"ar": "إعادة إرسال التحقق",
"fr": "Renvoyer la vérification",
"tr": "Doğrulamayı yeniden gönder",
"de": "Bestätigung erneut senden",
"uk": "Надіслати підтвердження повторно"
},
"SETTINGS$FAILED_TO_RESEND_VERIFICATION": {
"en": "Failed to resend verification email",
"ja": "確認メールの再送信に失敗しました",
"zh-CN": "重新发送验证邮件失败",
"zh-TW": "重新發送驗證郵件失敗",
"ko-KR": "인증 이메일 재전송 실패",
"no": "Kunne ikke sende bekreftelsese-post på nytt",
"it": "Impossibile rinviare l'email di verifica",
"pt": "Falha ao reenviar email de verificação",
"es": "Error al reenviar el correo de verificación",
"ar": "فشل في إعادة إرسال بريد التحقق",
"fr": "Échec du renvoi de l'email de vérification",
"tr": "Doğrulama e-postası yeniden gönderilemedi",
"de": "Bestätigungs-E-Mail konnte nicht erneut gesendet werden",
"uk": "Не вдалося повторно надіслати лист підтвердження"
}
}

View File

@@ -0,0 +1,5 @@
<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M11.8749 1.75H3.91861C3.47998 1.75015 3.05528 1.90407 2.71841 2.18499C2.38154 2.4659 2.15382 2.85603 2.07486 3.2875L1.28111 7.6625C1.23166 7.93277 1.2422 8.21062 1.31201 8.47636C1.38182 8.74211 1.50917 8.98927 1.68507 9.20035C1.86097 9.41142 2.08111 9.58126 2.32991 9.69785C2.57872 9.81443 2.8501 9.87491 3.12486 9.875H5.97486L5.62486 10.7688C5.47928 11.1601 5.4308 11.5809 5.48357 11.995C5.53635 12.4092 5.68881 12.8044 5.92787 13.1467C6.16694 13.489 6.48547 13.7683 6.85615 13.9604C7.22683 14.1526 7.63859 14.2519 8.05611 14.25C8.17634 14.2497 8.29394 14.2148 8.39482 14.1494C8.4957 14.084 8.57557 13.9909 8.62486 13.8813L10.4061 9.875H11.8749C12.3721 9.875 12.8491 9.67746 13.2007 9.32583C13.5523 8.97419 13.7499 8.49728 13.7499 8V3.625C13.7499 3.12772 13.5523 2.65081 13.2007 2.29917C12.8491 1.94754 12.3721 1.75 11.8749 1.75ZM9.37486 9.11875L7.67486 12.9438C7.50092 12.8911 7.3396 12.8034 7.20083 12.6861C7.06206 12.5688 6.94878 12.4242 6.86798 12.2615C6.78717 12.0987 6.74055 11.9211 6.73099 11.7396C6.72143 11.5581 6.74912 11.3766 6.81236 11.2062L7.14361 10.3125C7.2142 10.1236 7.23803 9.92041 7.21307 9.72029C7.18811 9.52018 7.1151 9.32907 7.00028 9.16329C6.88546 8.9975 6.73223 8.86196 6.55367 8.76823C6.37511 8.67449 6.17653 8.62535 5.97486 8.625H3.12486C3.03304 8.62515 2.94232 8.60507 2.85914 8.56618C2.77597 8.52729 2.70238 8.47055 2.64361 8.4C2.58341 8.33042 2.5393 8.24841 2.51445 8.15982C2.4896 8.07123 2.48462 7.97824 2.49986 7.8875L3.29361 3.5125C3.32024 3.3669 3.39767 3.23548 3.51212 3.14162C3.62657 3.04777 3.77062 2.99759 3.91861 3H9.37486V9.11875ZM12.4999 8C12.4999 8.16576 12.434 8.32473 12.3168 8.44194C12.1996 8.55915 12.0406 8.625 11.8749 8.625H10.6249V3H11.8749C12.0406 3 12.1996 3.06585 12.3168 3.18306C12.434 3.30027 12.4999 3.45924 12.4999 3.625V8Z"
fill="white" />
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,5 @@
<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.3125 6.80003C13.1369 6.58918 12.9171 6.41945 12.6687 6.30282C12.4204 6.18619 12.1494 6.1255 11.875 6.12503H9.025L9.375 5.23128C9.52058 4.83995 9.56907 4.41916 9.51629 4.00498C9.46351 3.5908 9.31106 3.19561 9.07199 2.8533C8.83293 2.51099 8.51439 2.23178 8.14371 2.03962C7.77303 1.84746 7.36127 1.74809 6.94375 1.75003C6.82352 1.75028 6.70592 1.7852 6.60504 1.8506C6.50417 1.91601 6.42429 2.00912 6.375 2.11878L4.59375 6.12503H3.125C2.62772 6.12503 2.15081 6.32257 1.79917 6.6742C1.44754 7.02583 1.25 7.50275 1.25 8.00003V12.375C1.25 12.8723 1.44754 13.3492 1.79917 13.7009C2.15081 14.0525 2.62772 14.25 3.125 14.25H11.0812C11.5199 14.2499 11.9446 14.096 12.2815 13.815C12.6183 13.5341 12.846 13.144 12.925 12.7125L13.7188 8.33753C13.7678 8.06714 13.7569 7.78927 13.6867 7.52358C13.6165 7.25788 13.4887 7.01087 13.3125 6.80003ZM4.375 13H3.125C2.95924 13 2.80027 12.9342 2.68306 12.817C2.56585 12.6998 2.5 12.5408 2.5 12.375V8.00003C2.5 7.83427 2.56585 7.6753 2.68306 7.55809C2.80027 7.44088 2.95924 7.37503 3.125 7.37503H4.375V13ZM12.5 8.11253L11.7062 12.4875C11.6796 12.6331 11.6022 12.7646 11.4877 12.8584C11.3733 12.9523 11.2292 13.0024 11.0812 13H5.625V6.88128L7.325 3.05628C7.49999 3.10729 7.6625 3.19403 7.80229 3.31102C7.94207 3.428 8.05608 3.57269 8.13712 3.73596C8.21817 3.89923 8.26449 4.07752 8.27316 4.25959C8.28183 4.44166 8.25266 4.62355 8.1875 4.79378L7.85625 5.68753C7.78567 5.87644 7.76184 6.07962 7.7868 6.27973C7.81176 6.47985 7.88476 6.67095 7.99958 6.83674C8.11441 7.00253 8.26763 7.13807 8.44619 7.2318C8.62475 7.32554 8.82333 7.37468 9.025 7.37503H11.875C11.9668 7.37488 12.0575 7.39496 12.1407 7.43385C12.2239 7.47274 12.2975 7.52948 12.3563 7.60003C12.4165 7.66961 12.4606 7.75162 12.4854 7.84021C12.5103 7.9288 12.5152 8.02179 12.5 8.11253Z"
fill="white" />
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -12,7 +12,6 @@ export default [
route("settings", "routes/settings.tsx", [
index("routes/llm-settings.tsx"),
route("mcp", "routes/mcp-settings.tsx"),
route("user", "routes/user-settings.tsx"),
route("git", "routes/git-settings.tsx"),
route("app", "routes/app-settings.tsx"),
route("billing", "routes/billing.tsx"),

View File

@@ -304,7 +304,7 @@ function LlmSettingsScreen() {
testId="llm-api-key-help-anchor"
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
href="https://docs.all-hands.dev/usage/local-setup#getting-an-api-key"
href="https://docs.all-hands.dev/usage/installation#getting-an-api-key"
/>
<SettingsInput
@@ -379,7 +379,7 @@ function LlmSettingsScreen() {
testId="llm-api-key-help-anchor-advanced"
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
href="https://docs.all-hands.dev/usage/local-setup#getting-an-api-key"
href="https://docs.all-hands.dev/usage/installation#getting-an-api-key"
/>
<SettingsInput

View File

@@ -25,7 +25,6 @@ import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
import { useAutoLogin } from "#/hooks/use-auto-login";
import { useAuthCallback } from "#/hooks/use-auth-callback";
import { LOCAL_STORAGE_KEYS } from "#/utils/local-storage";
import { EmailVerificationGuard } from "#/components/features/guards/email-verification-guard";
export function ErrorBoundary() {
const error = useRouteError();
@@ -205,9 +204,7 @@ export default function MainApp() {
id="root-outlet"
className="h-[calc(100%-50px)] md:h-full w-full relative overflow-auto"
>
<EmailVerificationGuard>
<Outlet />
</EmailVerificationGuard>
<Outlet />
</div>
{renderAuthModal && (

View File

@@ -15,7 +15,6 @@ function SettingsScreen() {
const isSaas = config?.APP_MODE === "saas";
const saasNavItems = [
{ to: "/settings/user", text: t("SETTINGS$NAV_USER") },
{ to: "/settings/git", text: t("SETTINGS$NAV_GIT") },
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
{ to: "/settings/billing", text: t("SETTINGS$NAV_CREDITS") },
@@ -34,11 +33,10 @@ function SettingsScreen() {
React.useEffect(() => {
if (isSaas) {
if (pathname === "/settings") {
navigate("/settings/user");
navigate("/settings/git");
}
} else {
const noEnteringPaths = [
"/settings/user",
"/settings/billing",
"/settings/credits",
"/settings/api-keys",

View File

@@ -1,229 +0,0 @@
import React, { useState, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import { openHands } from "#/api/open-hands-axios";
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
// Email validation regex pattern
const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
function EmailInputSection({
email,
onEmailChange,
onSaveEmail,
onResendVerification,
isSaving,
isResendingVerification,
isEmailChanged,
emailVerified,
isEmailValid,
children,
}: {
email: string;
onEmailChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onSaveEmail: () => void;
onResendVerification: () => void;
isSaving: boolean;
isResendingVerification: boolean;
isEmailChanged: boolean;
emailVerified?: boolean;
isEmailValid: boolean;
children: React.ReactNode;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm">{t("SETTINGS$USER_EMAIL")}</label>
<div className="flex items-center gap-3">
<input
type="email"
value={email}
onChange={onEmailChange}
className={`text-base text-white p-2 bg-base-tertiary rounded border ${
isEmailChanged && !isEmailValid
? "border-red-500"
: "border-tertiary"
} flex-grow focus:outline-none focus:border-transparent focus:ring-0`}
placeholder={t("SETTINGS$USER_EMAIL_LOADING")}
data-testid="email-input"
/>
</div>
{isEmailChanged && !isEmailValid && (
<div
className="text-red-500 text-sm mt-1"
data-testid="email-validation-error"
>
{t("SETTINGS$INVALID_EMAIL_FORMAT")}
</div>
)}
<div className="flex items-center gap-3 mt-2">
<button
type="button"
onClick={onSaveEmail}
disabled={!isEmailChanged || isSaving || !isEmailValid}
className="px-4 py-2 rounded bg-primary text-white hover:opacity-80 disabled:opacity-30 disabled:cursor-not-allowed disabled:text-[#0D0F11]"
data-testid="save-email-button"
>
{isSaving ? t("SETTINGS$SAVING") : t("SETTINGS$SAVE")}
</button>
{emailVerified === false && (
<button
type="button"
onClick={onResendVerification}
disabled={isResendingVerification}
className="px-4 py-2 rounded bg-primary text-white hover:opacity-80 disabled:opacity-30 disabled:cursor-not-allowed disabled:text-[#0D0F11]"
data-testid="resend-verification-button"
>
{isResendingVerification
? t("SETTINGS$SENDING")
: t("SETTINGS$RESEND_VERIFICATION")}
</button>
)}
</div>
{children}
</div>
</div>
);
}
function VerificationAlert() {
const { t } = useTranslation();
return (
<div
className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mt-4"
role="alert"
>
<p className="font-bold">{t("SETTINGS$EMAIL_VERIFICATION_REQUIRED")}</p>
<p className="text-sm">
{t("SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE")}
</p>
</div>
);
}
// These components have been replaced with toast notifications
function UserSettingsScreen() {
const { t } = useTranslation();
const { data: settings, isLoading, refetch } = useSettings();
const [email, setEmail] = useState("");
const [originalEmail, setOriginalEmail] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [isResendingVerification, setIsResendingVerification] = useState(false);
const [isEmailValid, setIsEmailValid] = useState(true);
const queryClient = useQueryClient();
const pollingIntervalRef = useRef<number | null>(null);
const prevVerificationStatusRef = useRef<boolean | undefined>(undefined);
useEffect(() => {
if (settings?.EMAIL) {
setEmail(settings.EMAIL);
setOriginalEmail(settings.EMAIL);
setIsEmailValid(EMAIL_REGEX.test(settings.EMAIL));
}
}, [settings?.EMAIL]);
useEffect(() => {
if (pollingIntervalRef.current) {
window.clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
if (
prevVerificationStatusRef.current === false &&
settings?.EMAIL_VERIFIED === true
) {
// Display toast notification instead of setting state
displaySuccessToast(t("SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY"));
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
}, 2000);
}
prevVerificationStatusRef.current = settings?.EMAIL_VERIFIED;
if (settings?.EMAIL_VERIFIED === false) {
pollingIntervalRef.current = window.setInterval(() => {
refetch();
}, 5000);
}
return () => {
if (pollingIntervalRef.current) {
window.clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
};
}, [settings?.EMAIL_VERIFIED, refetch, queryClient, t]);
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newEmail = e.target.value;
setEmail(newEmail);
setIsEmailValid(EMAIL_REGEX.test(newEmail));
};
const handleSaveEmail = async () => {
if (email === originalEmail || !isEmailValid) return;
try {
setIsSaving(true);
await openHands.post("/api/email", { email }, { withCredentials: true });
setOriginalEmail(email);
// Display toast notification instead of setting state
displaySuccessToast(t("SETTINGS$EMAIL_SAVED_SUCCESSFULLY"));
queryClient.invalidateQueries({ queryKey: ["settings"] });
} catch (error) {
// eslint-disable-next-line no-console
console.error(t("SETTINGS$FAILED_TO_SAVE_EMAIL"), error);
} finally {
setIsSaving(false);
}
};
const handleResendVerification = async () => {
try {
setIsResendingVerification(true);
await openHands.put("/api/email/verify", {}, { withCredentials: true });
// Display toast notification instead of setting state
displaySuccessToast(t("SETTINGS$VERIFICATION_EMAIL_SENT"));
} catch (error) {
// eslint-disable-next-line no-console
console.error(t("SETTINGS$FAILED_TO_RESEND_VERIFICATION"), error);
} finally {
setIsResendingVerification(false);
}
};
const isEmailChanged = email !== originalEmail;
return (
<div data-testid="user-settings-screen" className="flex flex-col h-full">
<div className="p-9 flex flex-col gap-6">
{isLoading ? (
<div className="animate-pulse h-8 w-64 bg-tertiary rounded" />
) : (
<EmailInputSection
email={email}
onEmailChange={handleEmailChange}
onSaveEmail={handleSaveEmail}
onResendVerification={handleResendVerification}
isSaving={isSaving}
isResendingVerification={isResendingVerification}
isEmailChanged={isEmailChanged}
emailVerified={settings?.EMAIL_VERIFIED}
isEmailValid={isEmailValid}
>
{settings?.EMAIL_VERIFIED === false && <VerificationAlert />}
</EmailInputSection>
)}
</div>
</div>
);
}
export default UserSettingsScreen;

View File

@@ -19,8 +19,6 @@ export const DEFAULT_SETTINGS: Settings = {
ENABLE_PROACTIVE_CONVERSATION_STARTERS: false,
SEARCH_API_KEY: "",
IS_NEW_USER: true,
EMAIL: "",
EMAIL_VERIFIED: true, // Default to true to avoid restricting access unnecessarily
MCP_CONFIG: {
sse_servers: [],
stdio_servers: [],

View File

@@ -45,8 +45,6 @@ export type Settings = {
SEARCH_API_KEY?: string;
IS_NEW_USER?: boolean;
MCP_CONFIG?: MCPConfig;
EMAIL?: string;
EMAIL_VERIFIED?: boolean;
};
export type ApiSettings = {
@@ -70,8 +68,6 @@ export type ApiSettings = {
sse_servers: (string | MCPSSEServer)[];
stdio_servers: MCPStdioServer[];
};
email?: string;
email_verified?: boolean;
};
export type PostSettings = Settings & {

View File

@@ -12,7 +12,7 @@ export const TIPS: Tip[] = [
},
{
key: I18nKey.TIPS$SETUP_SCRIPT,
link: "https://docs.all-hands.dev/usage/prompting/repository#setup-script",
link: "https://docs.all-hands.dev/usage/customization/repository",
},
{ key: I18nKey.TIPS$VSCODE_INSTANCE },
{ key: I18nKey.TIPS$SAVE_WORK },
@@ -30,7 +30,7 @@ export const TIPS: Tip[] = [
},
{
key: I18nKey.TIPS$GITHUB_HOOK,
link: "https://docs.all-hands.dev/usage/cloud/github-installation#working-on-github-issues-and-pull-requests-using-openhands",
link: "https://docs.all-hands.dev/usage/cloud/cloud-issue-resolver",
},
{
key: I18nKey.TIPS$BLOG_SIGNUP,
@@ -38,7 +38,7 @@ export const TIPS: Tip[] = [
},
{
key: I18nKey.TIPS$API_USAGE,
link: "https://docs.all-hands.dev/api-reference/health-check",
link: "https://docs.all-hands.dev/swagger-ui/",
},
];

View File

@@ -117,10 +117,7 @@ You can see an example of a repo agent in [the agent for the OpenHands repo itse
- Include repository structure details
- Specify testing and build procedures
- List environment requirements
- Document CI workflows and checks
- Include information about code quality standards
- Maintain up-to-date team practices
- Consider using OpenHands to generate a comprehensive repo.md (see [Creating a Repository Agent](#creating-a-repository-agent))
- YAML frontmatter is optional - files without frontmatter will be loaded with default settings
### Submission Process

View File

@@ -37,4 +37,5 @@ When creating a new microagent:
For detailed information, see:
- [Microagents Overview](https://docs.all-hands.dev/usage/prompting/microagents-overview)
- [Microagents Syntax](https://docs.all-hands.dev/usage/prompting/microagents-syntax)
- [Example GitHub Microagent](https://github.com/All-Hands-AI/OpenHands/blob/main/microagents/github.md)

View File

@@ -1,65 +0,0 @@
---
name: add_repo_inst
version: 1.0.0
author: openhands
agent: CodeActAgent
triggers:
- /add_repo_inst
inputs:
- name: REPO_FOLDER_NAME
description: "Branch for the agent to work on"
---
Please browse the current repository under /workspace/{{ REPO_FOLDER_NAME }}, look at the documentation and relevant code, and understand the purpose of this repository.
Specifically, I want you to create a `.openhands/microagents/repo.md` file. This file should contain succinct information that summarizes (1) the purpose of this repository, (2) the general setup of this repo, and (3) a brief description of the structure of this repo.
Here's an example:
```markdown
---
name: repo
type: repo
agent: CodeActAgent
---
This repository contains the code for OpenHands, an automated AI software engineer. It has a Python backend
(in the `openhands` directory) and React frontend (in the `frontend` directory).
## General Setup:
To set up the entire repo, including frontend and backend, run `make build`.
You don't need to do this unless the user asks you to, or if you're trying to run the entire application.
Before pushing any changes, you should ensure that any lint errors or simple test errors have been fixed.
* If you've made changes to the backend, you should run `pre-commit run --all-files --config ./dev_config/python/.pre-commit-config.yaml`
* If you've made changes to the frontend, you should run `cd frontend && npm run lint:fix && npm run build ; cd ..`
If either command fails, it may have automatically fixed some issues. You should fix any issues that weren't automatically fixed,
then re-run the command to ensure it passes.
## Repository Structure
Backend:
- Located in the `openhands` directory
- Testing:
- All tests are in `tests/unit/test_*.py`
- To test new code, run `poetry run pytest tests/unit/test_xxx.py` where `xxx` is the appropriate file for the current functionality
- Write all tests with pytest
Frontend:
- Located in the `frontend` directory
- Prerequisites: A recent version of NodeJS / NPM
- Setup: Run `npm install` in the frontend directory
- Testing:
- Run tests: `npm run test`
- To run specific tests: `npm run test -- -t "TestName"`
- Building:
- Build for production: `npm run build`
- Environment Variables:
- Set in `frontend/.env` or as environment variables
- Available variables: VITE_BACKEND_HOST, VITE_USE_TLS, VITE_INSECURE_SKIP_VERIFY, VITE_FRONTEND_PORT
- Internationalization:
- Generate i18n declaration file: `npm run make-i18n`
```
Now, please write a similar markdown for the current repository.
Read all the GitHub workflows under .github/ of the repository (if this folder exists) to understand the CI checks (e.g., linter, pre-commit), and include those in the repo.md file.

View File

@@ -1,19 +0,0 @@
---
name: address_pr_comments
version: 1.0.0
author: openhands
agent: CodeActAgent
triggers:
- /address_pr_comments
inputs:
- name: PR_URL
description: "URL of the pull request"
- name: BRANCH_NAME
description: "Branch name corresponds to the pull request"
---
First, check the branch {{ BRANCH_NAME }} and read the diff against the main branch to understand the purpose.
This branch corresponds to this PR {{ PR_URL }}
Next, you should use the GitHub API to read the reviews and comments on this PR and address them.

View File

@@ -1,23 +0,0 @@
---
name: fix_test
version: 1.0.0
author: openhands
agent: CodeActAgent
triggers:
- /fix_test
inputs:
- name: BRANCH_NAME
description: "Branch for the agent to work on"
- name: TEST_COMMAND_TO_RUN
description: "The test command you want the agent to work on. For example, `pytest tests/unit/test_bash_parsing.py`"
- name: FUNCTION_TO_FIX
description: "The name of function to fix"
- name: FILE_FOR_FUNCTION
description: "The path of the file that contains the function"
---
Can you check out branch "{{ BRANCH_NAME }}", and run {{ TEST_COMMAND_TO_RUN }}.
Help me fix these tests to pass by fixing the {{ FUNCTION_TO_FIX }} function in file {{ FILE_FOR_FUNCTION }}.
PLEASE DO NOT modify the tests by yourself -- Let me know if you think some of the tests are incorrect.

View File

@@ -1,21 +0,0 @@
---
name: update_pr_description
version: 1.0.0
author: openhands
agent: CodeActAgent
triggers:
- /update_pr_description
inputs:
- name: PR_URL
description: "URL of the pull request"
type: string
validation:
pattern: "^https://github.com/.+/.+/pull/[0-9]+$"
- name: BRANCH_NAME
description: "Branch name corresponds to the pull request"
type: string
---
Please check the branch "{{ BRANCH_NAME }}" and look at the diff against the main branch. This branch belongs to this PR "{{ PR_URL }}".
Once you understand the purpose of the diff, please use Github API to read the existing PR description, and update it to be more reflective of the changes we've made when necessary.

View File

@@ -1,19 +0,0 @@
---
name: update_test
version: 1.0.0
author: openhands
agent: CodeActAgent
triggers:
- /update_test
inputs:
- name: BRANCH_NAME
description: "Branch for the agent to work on"
- name: TEST_COMMAND_TO_RUN
description: "The test command you want the agent to work on. For example, `pytest tests/unit/test_bash_parsing.py`"
---
Can you check out branch "{{ BRANCH_NAME }}", and run {{ TEST_COMMAND_TO_RUN }}.
The current implementation of the code is correct BUT the test functions {{ FUNCTION_TO_FIX }} in file {{ FILE_FOR_FUNCTION }} are failing.
Please update the test file so that they pass with the current version of the implementation.

View File

@@ -141,9 +141,6 @@ def response_to_actions(
content=arguments['content'],
start=arguments.get('start', 1),
end=arguments.get('end', -1),
impl_source=arguments.get(
'impl_source', FileEditSource.LLM_BASED_EDIT
),
)
elif (
tool_call.function.name

View File

@@ -2,18 +2,10 @@ from litellm import ChatCompletionToolParam, ChatCompletionToolParamFunctionChun
_FILE_EDIT_DESCRIPTION = """Edit a file in plain-text format.
* The assistant can edit files by specifying the file path and providing a draft of the new file content.
* The draft content doesn't need to be exactly the same as the existing file; the assistant may skip unchanged lines using comments like `# ... existing code ...` to indicate unchanged sections.
* The draft content doesn't need to be exactly the same as the existing file; the assistant may skip unchanged lines using comments like `# unchanged` to indicate unchanged sections.
* IMPORTANT: For large files (e.g., > 300 lines), specify the range of lines to edit using `start` and `end` (1-indexed, inclusive). The range should be smaller than 300 lines.
* -1 indicates the last line of the file when used as the `start` or `end` value.
* Keep at least one unchanged line before the changed section and after the changed section wherever possible.
* Make sure to set the `start` and `end` to include all the lines in the original file referred to in the draft of the new file content. Failure to do so will result in bad edits.
* To append to a file, set both `start` and `end` to `-1`.
* If the file doesn't exist, a new file will be created with the provided content.
* IMPORTANT: Make sure you include all the required indentations for each line of code in the draft, otherwise the edited code will be incorrectly indented.
* IMPORTANT: Make sure that the first line of the draft is also properly indented and has the required whitespaces.
* IMPORTANT: NEVER include or make references to lines from outside the `start` and `end` range in the draft.
* IMPORTANT: Start the content with a comment in the format: #EDIT: Reason for edit
* IMPORTANT: If you are not appending to the file, avoid setting `start` and `end` to the same value.
**Example 1: general edit for short files**
For example, given an existing file `/path/to/file.py` that looks like this:
@@ -41,12 +33,13 @@ The assistant wants to edit the file to look like this:
The assistant may produce an edit action like this:
path="/path/to/file.txt" start=1 end=-1
content=```
#EDIT: I want to change the value of y to 2
class MyClass:
def __init__(self):
# ... existing code ...
# no changes before
self.y = 2
# self.z is removed
# MyClass().z is removed
print(MyClass().y)
```
@@ -65,7 +58,6 @@ For example, given an existing file `/path/to/file.py` that looks like this:
To append the following lines to the file:
```python
#EDIT: I want to print the value of y
print(MyClass().y)
```
@@ -101,9 +93,9 @@ The assistant wants to edit the file to look like this:
(2000 more lines below)
The assistant may produce an edit action like this:
path="/path/to/file.txt" start=1002 end=1008
path="/path/to/file.txt" start=1001 end=1008
content=```
#EDIT: I want to change the value of y to 2
class MyClass:
def __init__(self):
# no changes before
self.y = 2

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
openhands/cli/team.py Normal file
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
openhands/cli/team_cli.sh Executable file
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 "$@"

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()

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()

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()

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__}')

View File

@@ -3,7 +3,6 @@ from typing import Iterable
from urllib.parse import urlencode
import httpx # type: ignore
from fastapi import status
from openhands.events.event import Event
from openhands.events.event_filter import EventFilter
@@ -43,9 +42,6 @@ class NestedEventStore(EventStoreABC):
if self.session_api_key:
headers['X-Session-API-Key'] = self.session_api_key
response = httpx.get(url, headers=headers)
if response.status_code == status.HTTP_404_NOT_FOUND:
# Follow pattern of event store not throwing errors on not found
return
result_set = response.json()
for result in result_set['events']:
event = event_from_dict(result)

View File

@@ -186,18 +186,9 @@ class EventStream(EventStore):
if event.id is not None:
# Write the event to the store - this can take some time
event_json = json.dumps(data)
filename = self._get_filename_for_id(event.id, self.user_id)
if len(event_json) > 1_000_000: # Roughly 1MB in bytes, ignoring encoding
logger.warning(
f'Saving event JSON over 1MB: {len(event_json):,} bytes, filename: {filename}',
extra={
'user_id': self.user_id,
'session_id': self.sid,
'size': len(event_json),
},
)
self.file_store.write(filename, event_json)
self.file_store.write(
self._get_filename_for_id(event.id, self.user_id), json.dumps(data)
)
# Store the cache page last - if it is not present during reads then it will simply be bypassed.
self._store_cache_page(current_write_page)

View File

@@ -1,8 +1,5 @@
Please send a final message summarizing your work.
Please summarize your work.
If you simply answered a question, this final message should re-state the answer to the question.
If you made changes, please first double-check the git diff, think carefully about the user's request(s), and check:
1. whether the request has been completely addressed and all of the instructions have been followed faithfully (in checklist format if appropriate).
2. whether the changes are concise (if there are any extraneous changes not important to addressing the user's request they should be reverted).
If the request has been addressed and the changes are concise, then push your changes to the remote branch and send a final message summarizing the changes.
If you answered a question, please re-state the answer to the question
If you made changes, please create a concise overview on whether the request has been addressed successfully or if there are were issues with the attempt.
If successful, make sure your changes are pushed to the remote branch.

View File

@@ -22,7 +22,6 @@ from openhands.llm.tool_names import (
BROWSER_TOOL_NAME,
EXECUTE_BASH_TOOL_NAME,
FINISH_TOOL_NAME,
LLM_BASED_EDIT_TOOL_NAME,
STR_REPLACE_EDITOR_TOOL_NAME,
)
@@ -252,58 +251,6 @@ noop(1000) # Wait for page to load
USER: EXECUTION RESULT of [browser]:
[Browser shows the numbers in a table format]
"""
},
'edit_file': {
'create_file': """
ASSISTANT: There is no `app.py` file in the current directory. Let me create a Python file `app.py`:
<function=edit_file>
<parameter=path>/workspace/app.py</parameter>
<parameter=start>1</parameter>
<parameter=end>-1</parameter>
<parameter=content>
from flask import Flask
app = Flask(__name__)
@app.route('/')
def index():
numbers = list(range(1, 11))
return str(numbers)
if __name__ == '__main__':
app.run(port=5000)
</parameter>
</function>
USER: EXECUTION RESULT of [edit_file]:
File created successfully at: /workspace/app.py
""",
'edit_file': """
ASSISTANT:
Now let me display the numbers in a table format:
<function=edit_file>
<parameter=path>/workspace/app.py</parameter>
<parameter=start>6</parameter>
<parameter=end>9</parameter>
<parameter=content>
numbers = list(range(1, 11))
return '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'
# ... existing code ...
if __name__ == '__main__':
</parameter>
</function>
USER: EXECUTION RESULT of [edit_file]:
The file /workspace/app.py has been edited. Here's the result of running `cat -n` on a snippet of /workspace/app.py:
3
4 @app.route('/')
5 def index():
6 numbers = list(range(1, 11))
7 return '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'
8
9 if __name__ == '__main__':
10 app.run(port=5000)
Review the changes and make sure they are as expected. Edit the file again if necessary.
""",
},
'finish': {
'task_completed': """
@@ -332,8 +279,6 @@ def get_example_for_tools(tools: list[dict]) -> str:
available_tools.add('browser')
elif name == FINISH_TOOL_NAME:
available_tools.add('finish')
elif name == LLM_BASED_EDIT_TOOL_NAME:
available_tools.add('edit_file')
if not available_tools:
return ''
@@ -352,8 +297,6 @@ USER: Create a list of numbers from 1 to 10, and display them in a web page at p
if 'str_replace_editor' in available_tools:
example += TOOL_EXAMPLES['str_replace_editor']['create_file']
elif 'edit_file' in available_tools:
example += TOOL_EXAMPLES['edit_file']['create_file']
if 'execute_bash' in available_tools:
example += TOOL_EXAMPLES['execute_bash']['run_server']
@@ -366,8 +309,6 @@ USER: Create a list of numbers from 1 to 10, and display them in a web page at p
if 'str_replace_editor' in available_tools:
example += TOOL_EXAMPLES['str_replace_editor']['edit_file']
elif 'edit_file' in available_tools:
example += TOOL_EXAMPLES['edit_file']['edit_file']
if 'execute_bash' in available_tools:
example += TOOL_EXAMPLES['execute_bash']['run_server_again']

View File

@@ -4,4 +4,3 @@ EXECUTE_BASH_TOOL_NAME = 'execute_bash'
STR_REPLACE_EDITOR_TOOL_NAME = 'str_replace_editor'
BROWSER_TOOL_NAME = 'browser'
FINISH_TOOL_NAME = 'finish'
LLM_BASED_EDIT_TOOL_NAME = 'edit_file'

View File

@@ -1,12 +1,13 @@
import asyncio
import datetime
from contextlib import AsyncExitStack
from typing import Optional
from fastmcp import Client
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
from mcp import McpError
from mcp.types import CallToolResult
from mcp import ClientSession
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamablehttp_client
from pydantic import BaseModel, Field
from openhands.core.config.mcp_config import MCPSHTTPServerConfig, MCPSSEServerConfig
from openhands.core.logger import openhands_logger as logger
from openhands.mcp.tool import MCPClientTool
@@ -16,7 +17,8 @@ class MCPClient(BaseModel):
A collection of tools that connects to an MCP server and manages available tools through the Model Context Protocol.
"""
client: Optional[Client] = None
session: Optional[ClientSession] = None
exit_stack: AsyncExitStack = AsyncExitStack()
description: str = 'MCP client tools for server interaction'
tools: list[MCPClientTool] = Field(default_factory=list)
tool_map: dict[str, MCPClientTool] = Field(default_factory=dict)
@@ -24,87 +26,189 @@ class MCPClient(BaseModel):
class Config:
arbitrary_types_allowed = True
async def connect_sse(
self,
server_url: str,
api_key: str | None = None,
conversation_id: str | None = None,
timeout: float = 30.0,
) -> None:
"""Connect to an MCP server using SSE transport.
Args:
server_url: The URL of the SSE server to connect to.
timeout: Connection timeout in seconds. Default is 30 seconds.
"""
if not server_url:
raise ValueError('Server URL is required.')
if self.session:
await self.disconnect()
try:
# Use asyncio.wait_for to enforce the timeout
async def connect_with_timeout():
headers = (
{
'Authorization': f'Bearer {api_key}',
's': api_key, # We need this for action execution server's MCP Router
'X-Session-API-Key': api_key, # We need this for Remote Runtime
}
if api_key
else {}
)
if conversation_id:
headers['X-OpenHands-Conversation-ID'] = conversation_id
# Convert float timeout to datetime.timedelta for consistency
timeout_delta = datetime.timedelta(seconds=timeout)
streams_context = sse_client(
url=server_url,
headers=headers if headers else None,
timeout=timeout,
)
streams = await self.exit_stack.enter_async_context(streams_context)
# For SSE client, we only get read_stream and write_stream (2 values)
read_stream, write_stream = streams
self.session = await self.exit_stack.enter_async_context(
ClientSession(
read_stream, write_stream, read_timeout_seconds=timeout_delta
)
)
await self._initialize_and_list_tools()
# Apply timeout to the entire connection process
await asyncio.wait_for(connect_with_timeout(), timeout=timeout)
except asyncio.TimeoutError:
logger.error(
f'Connection to {server_url} timed out after {timeout} seconds'
)
await self.disconnect() # Clean up resources
raise # Re-raise the TimeoutError
except Exception as e:
logger.error(f'Error connecting to {server_url}: {str(e)}')
await self.disconnect() # Clean up resources
raise
async def _initialize_and_list_tools(self) -> None:
"""Initialize session and populate tool map."""
if not self.client:
if not self.session:
raise RuntimeError('Session not initialized.')
async with self.client:
tools = await self.client.list_tools()
await self.session.initialize()
response = await self.session.list_tools()
# Clear existing tools
self.tools = []
# Create proper tool objects for each server tool
for tool in tools:
for tool in response.tools:
server_tool = MCPClientTool(
name=tool.name,
description=tool.description,
inputSchema=tool.inputSchema,
session=self.client,
session=self.session,
)
self.tool_map[tool.name] = server_tool
self.tools.append(server_tool)
logger.info(f'Connected to server with tools: {[tool.name for tool in tools]}')
logger.info(
f'Connected to server with tools: {[tool.name for tool in response.tools]}'
)
async def connect_http(
self,
server: MCPSSEServerConfig | MCPSHTTPServerConfig,
conversation_id: str | None = None,
timeout: float = 30.0,
):
"""Connect to MCP server using SHTTP or SSE transport"""
server_url = server.url
api_key = server.api_key
if not server_url:
raise ValueError('Server URL is required.')
try:
headers = (
{
'Authorization': f'Bearer {api_key}',
's': api_key, # We need this for action execution server's MCP Router
'X-Session-API-Key': api_key, # We need this for Remote Runtime
}
if api_key
else {}
)
if conversation_id:
headers['X-OpenHands-ServerConversation-ID'] = conversation_id
# Instantiate custom transports due to custom headers
if isinstance(server, MCPSHTTPServerConfig):
transport = StreamableHttpTransport(
url=server_url,
headers=headers if headers else None,
)
else:
transport = SSETransport(
url=server_url,
headers=headers if headers else None,
)
self.client = Client(transport, timeout=timeout)
await self._initialize_and_list_tools()
except McpError as e:
logger.error(f'McpError connecting to {server_url}: {e}')
raise # Re-raise the error
except Exception as e:
logger.error(f'Error connecting to {server_url}: {e}')
raise
async def call_tool(self, tool_name: str, args: dict) -> CallToolResult:
async def call_tool(self, tool_name: str, args: dict):
"""Call a tool on the MCP server."""
if tool_name not in self.tool_map:
raise ValueError(f'Tool {tool_name} not found.')
# The MCPClientTool is primarily for metadata; use the session to call the actual tool.
if not self.client:
if not self.session:
raise RuntimeError('Client session is not available.')
return await self.session.call_tool(name=tool_name, arguments=args)
async with self.client:
return await self.client.call_tool_mcp(name=tool_name, arguments=args)
async def connect_shttp(
self,
server_url: str,
api_key: str | None = None,
conversation_id: str | None = None,
timeout: float = 30.0,
) -> None:
"""Connect to an MCP server using StreamableHTTP transport.
Args:
server_url: The URL of the StreamableHTTP server to connect to.
api_key: Optional API key for authentication.
conversation_id: Optional conversation ID for session tracking.
timeout: Connection timeout in seconds. Default is 30 seconds.
"""
if not server_url:
raise ValueError('Server URL is required.')
if self.session:
await self.disconnect()
try:
# Use asyncio.wait_for to enforce the timeout
async def connect_with_timeout():
headers = (
{
'Authorization': f'Bearer {api_key}',
's': api_key, # We need this for action execution server's MCP Router
'X-Session-API-Key': api_key, # We need this for Remote Runtime
}
if api_key
else {}
)
if conversation_id:
headers['X-OpenHands-Conversation-ID'] = conversation_id
# Convert float timeout to datetime.timedelta
timeout_delta = datetime.timedelta(seconds=timeout)
sse_read_timeout_delta = datetime.timedelta(
seconds=timeout * 10
) # 10x longer for read timeout
streams_context = streamablehttp_client(
url=server_url,
headers=headers if headers else None,
timeout=timeout_delta,
sse_read_timeout=sse_read_timeout_delta,
)
streams = await self.exit_stack.enter_async_context(streams_context)
# For StreamableHTTP client, we get read_stream, write_stream, and get_session_id (3 values)
read_stream, write_stream, _ = streams
self.session = await self.exit_stack.enter_async_context(
ClientSession(
read_stream, write_stream, read_timeout_seconds=timeout_delta
)
)
await self._initialize_and_list_tools()
# Apply timeout to the entire connection process
await asyncio.wait_for(connect_with_timeout(), timeout=timeout)
except asyncio.TimeoutError:
logger.error(
f'Connection to {server_url} timed out after {timeout} seconds'
)
await self.disconnect() # Clean up resources
raise # Re-raise the TimeoutError
except Exception as e:
logger.error(f'Error connecting to {server_url}: {str(e)}')
await self.disconnect() # Clean up resources
raise
async def disconnect(self) -> None:
"""Disconnect from the MCP server and clean up resources."""
if self.session:
try:
# Close the session first
if hasattr(self.session, 'close'):
await self.session.close()
# Then close the exit stack
await self.exit_stack.aclose()
except Exception as e:
logger.error(f'Error during disconnect: {str(e)}')
finally:
self.session = None
self.tools = []
logger.info('Disconnected from MCP server')

View File

@@ -72,22 +72,38 @@ async def create_mcp_clients(
mcp_clients = []
for server in servers:
is_shttp = isinstance(server, MCPSHTTPServerConfig)
connection_type = 'SHTTP' if is_shttp else 'SSE'
is_sse = isinstance(server, MCPSSEServerConfig)
connection_type = 'SSE' if is_sse else 'SHTTP'
logger.info(
f'Initializing MCP agent for {server} with {connection_type} connection...'
)
client = MCPClient()
try:
await client.connect_http(server, conversation_id=conversation_id)
if is_sse:
await client.connect_sse(
server.url,
api_key=server.api_key,
conversation_id=conversation_id,
)
else:
await client.connect_shttp(
server.url,
api_key=server.api_key,
conversation_id=conversation_id,
)
# Only add the client to the list after a successful connection
mcp_clients.append(client)
except Exception as e:
logger.error(f'Failed to connect to {server}: {str(e)}', exc_info=True)
try:
await client.disconnect()
except Exception as disconnect_error:
logger.error(
f'Error during disconnect after failed connection: {str(disconnect_error)}'
)
return mcp_clients
@@ -127,6 +143,13 @@ async def fetch_mcp_tools_from_config(
# Convert tools to the format expected by the agent
mcp_tools = convert_mcp_clients_to_tools(mcp_clients)
# Always disconnect clients to clean up resources
for mcp_client in mcp_clients:
try:
await mcp_client.disconnect()
except Exception as disconnect_error:
logger.error(f'Error disconnecting MCP client: {str(disconnect_error)}')
except Exception as e:
logger.error(f'Error fetching MCP tools: {str(e)}')
return []

View File

@@ -1,5 +1,4 @@
import io
import re
from pathlib import Path
from typing import Union
@@ -10,7 +9,7 @@ from openhands.core.exceptions import (
MicroagentValidationError,
)
from openhands.core.logger import openhands_logger as logger
from openhands.microagent.types import InputMetadata, MicroagentMetadata, MicroagentType
from openhands.microagent.types import MicroagentMetadata, MicroagentType
class BaseMicroagent(BaseModel):
@@ -92,24 +91,13 @@ class BaseMicroagent(BaseModel):
subclass_map = {
MicroagentType.KNOWLEDGE: KnowledgeMicroagent,
MicroagentType.REPO_KNOWLEDGE: RepoMicroagent,
MicroagentType.TASK: TaskMicroagent,
}
# Infer the agent type:
# 1. If inputs exist -> TASK
# 2. If triggers exist -> KNOWLEDGE
# 3. Else (no triggers) -> REPO (always active)
# 1. If triggers exist -> KNOWLEDGE (optional)
# 2. Else (no triggers) -> REPO (always active)
inferred_type: MicroagentType
if metadata.inputs:
inferred_type = MicroagentType.TASK
# Add a trigger for the agent name if not already present
trigger = f'/{metadata.name}'
if not metadata.triggers or trigger not in metadata.triggers:
if not metadata.triggers:
metadata.triggers = [trigger]
else:
metadata.triggers.append(trigger)
elif metadata.triggers:
if metadata.triggers:
inferred_type = MicroagentType.KNOWLEDGE
else:
# No triggers, default to REPO
@@ -134,9 +122,7 @@ class BaseMicroagent(BaseModel):
class KnowledgeMicroagent(BaseMicroagent):
"""Knowledge micro-agents provide specialized expertise that's triggered by keywords in conversations.
They help with:
"""Knowledge micro-agents provide specialized expertise that's triggered by keywords in conversations. They help with:
- Language best practices
- Framework guidelines
- Common patterns
@@ -145,8 +131,8 @@ class KnowledgeMicroagent(BaseMicroagent):
def __init__(self, **data):
super().__init__(**data)
if self.type not in [MicroagentType.KNOWLEDGE, MicroagentType.TASK]:
raise ValueError('KnowledgeMicroagent must have type KNOWLEDGE or TASK')
if self.type != MicroagentType.KNOWLEDGE:
raise ValueError('KnowledgeMicroagent must have type KNOWLEDGE')
def match_trigger(self, message: str) -> str | None:
"""Match a trigger in the message.
@@ -185,57 +171,6 @@ class RepoMicroagent(BaseMicroagent):
)
class TaskMicroagent(KnowledgeMicroagent):
"""TaskMicroagent is a special type of KnowledgeMicroagent that requires user input.
These microagents are triggered by a special format: "/{agent_name}"
and will prompt the user for any required inputs before proceeding.
"""
def __init__(self, **data):
super().__init__(**data)
if self.type != MicroagentType.TASK:
raise ValueError(
f'TaskMicroagent initialized with incorrect type: {self.type}'
)
# Append a prompt to ask for missing variables
self._append_missing_variables_prompt()
def _append_missing_variables_prompt(self) -> None:
"""Append a prompt to ask for missing variables."""
# Check if the content contains any variables or has inputs defined
if not self.requires_user_input() and not self.metadata.inputs:
return
prompt = "\n\nIf the user didn't provide any of these variables, ask the user to provide them first before the agent can proceed with the task."
self.content += prompt
def extract_variables(self, content: str) -> list[str]:
"""Extract variables from the content.
Variables are in the format ${variable_name}.
"""
pattern = r'\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}'
matches = re.findall(pattern, content)
return matches
def requires_user_input(self) -> bool:
"""Check if this microagent requires user input.
Returns True if the content contains variables in the format ${variable_name}.
"""
# Check if the content contains any variables
variables = self.extract_variables(self.content)
logger.debug(f'This microagent requires user input: {variables}')
return len(variables) > 0
@property
def inputs(self) -> list[InputMetadata]:
"""Get the inputs for this microagent."""
return self.metadata.inputs
def load_microagents_from_dir(
microagent_dir: Union[str, Path],
) -> tuple[dict[str, RepoMicroagent], dict[str, KnowledgeMicroagent]]:
@@ -247,7 +182,7 @@ def load_microagents_from_dir(
microagent_dir: Path to the microagents directory (e.g. .openhands/microagents)
Returns:
Tuple of (repo_agents, knowledge_agents) dictionaries
Tuple of (repo_agents, knowledge_agents, task_agents) dictionaries
"""
if isinstance(microagent_dir, str):
microagent_dir = Path(microagent_dir)
@@ -267,7 +202,6 @@ def load_microagents_from_dir(
if isinstance(agent, RepoMicroagent):
repo_agents[agent.name] = agent
elif isinstance(agent, KnowledgeMicroagent):
# Both KnowledgeMicroagent and TaskMicroagent go into knowledge_agents
knowledge_agents[agent.name] = agent
except MicroagentValidationError as e:
# For validation errors, include the original exception

View File

@@ -12,14 +12,6 @@ class MicroagentType(str, Enum):
KNOWLEDGE = 'knowledge' # Optional microagent, triggered by keywords
REPO_KNOWLEDGE = 'repo' # Always active microagent
TASK = 'task' # Special type for task microagents that require user input
class InputMetadata(BaseModel):
"""Metadata for task microagent inputs."""
name: str
description: str
class MicroagentMetadata(BaseModel):
@@ -30,7 +22,6 @@ class MicroagentMetadata(BaseModel):
version: str = Field(default='1.0.0')
agent: str = Field(default='CodeActAgent')
triggers: list[str] = [] # optional, only exists for knowledge microagents
inputs: list[InputMetadata] = [] # optional, only exists for task microagents
mcp_tools: MCPConfig | None = (
None # optional, for microagents that provide additional MCP tools
)

View File

@@ -9,6 +9,7 @@ import argparse
import asyncio
import base64
import json
import logging
import mimetypes
import os
import shutil
@@ -25,6 +26,8 @@ from fastapi import Depends, FastAPI, HTTPException, Request, UploadFile
from fastapi.exceptions import RequestValidationError
from fastapi.responses import FileResponse, JSONResponse
from fastapi.security import APIKeyHeader
from mcpm import MCPRouter, RouterConfig
from mcpm.router.router import logger as mcp_router_logger
from openhands_aci.editor.editor import OHEditor
from openhands_aci.editor.exceptions import ToolError
from openhands_aci.editor.results import ToolResult
@@ -34,7 +37,6 @@ from starlette.background import BackgroundTask
from starlette.exceptions import HTTPException as StarletteHTTPException
from uvicorn import run
from openhands.core.config.mcp_config import MCPStdioServerConfig
from openhands.core.exceptions import BrowserUnavailableException
from openhands.core.logger import openhands_logger as logger
from openhands.events.action import (
@@ -61,18 +63,20 @@ from openhands.events.serialization import event_from_dict, event_to_dict
from openhands.runtime.browser import browse
from openhands.runtime.browser.browser_env import BrowserEnv
from openhands.runtime.file_viewer_server import start_file_viewer_server
# Import our custom MCP Proxy Manager
from openhands.runtime.mcp.proxy import MCPProxyManager
from openhands.runtime.plugins import ALL_PLUGINS, JupyterPlugin, Plugin, VSCodePlugin
from openhands.runtime.utils import find_available_tcp_port
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
from openhands.runtime.utils.memory_monitor import MemoryMonitor
from openhands.runtime.utils.runtime_init import init_user_and_working_directory
from openhands.runtime.utils.system_stats import get_system_stats
from openhands.utils.async_utils import call_sync_from_async, wait_all
# Set MCP router logger to the same level as the main logger
mcp_router_logger.setLevel(logger.getEffectiveLevel())
if sys.platform == 'win32':
from openhands.runtime.utils.windows_bash import WindowsPowershellSession
@@ -467,7 +471,7 @@ class ActionExecutor:
filepath = self._resolve_path(action.path, working_dir)
try:
if filepath.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.gif')):
with open(filepath, 'rb') as file:
with open(filepath, 'rb') as file: # noqa: ASYNC101
image_data = file.read()
encoded_image = base64.b64encode(image_data).decode('utf-8')
mime_type, _ = mimetypes.guess_type(filepath)
@@ -477,13 +481,13 @@ class ActionExecutor:
return FileReadObservation(path=filepath, content=encoded_image)
elif filepath.lower().endswith('.pdf'):
with open(filepath, 'rb') as file:
with open(filepath, 'rb') as file: # noqa: ASYNC101
pdf_data = file.read()
encoded_pdf = base64.b64encode(pdf_data).decode('utf-8')
encoded_pdf = f'data:application/pdf;base64,{encoded_pdf}'
return FileReadObservation(path=filepath, content=encoded_pdf)
elif filepath.lower().endswith(('.mp4', '.webm', '.ogg')):
with open(filepath, 'rb') as file:
with open(filepath, 'rb') as file: # noqa: ASYNC101
video_data = file.read()
encoded_video = base64.b64encode(video_data).decode('utf-8')
mime_type, _ = mimetypes.guess_type(filepath)
@@ -493,7 +497,7 @@ class ActionExecutor:
return FileReadObservation(path=filepath, content=encoded_video)
with open(filepath, 'r', encoding='utf-8') as file:
with open(filepath, 'r', encoding='utf-8') as file: # noqa: ASYNC101
lines = read_lines(file.readlines(), action.start, action.end)
except FileNotFoundError:
return ErrorObservation(
@@ -526,7 +530,7 @@ class ActionExecutor:
mode = 'w' if not file_exists else 'r+'
try:
with open(filepath, mode, encoding='utf-8') as file:
with open(filepath, mode, encoding='utf-8') as file: # noqa: ASYNC101
if mode != 'w':
all_lines = file.readlines()
new_file = insert_lines(insert, all_lines, action.start, action.end)
@@ -650,11 +654,14 @@ if __name__ == '__main__':
plugins_to_load.append(ALL_PLUGINS[plugin]()) # type: ignore
client: ActionExecutor | None = None
mcp_proxy_manager: MCPProxyManager | None = None
mcp_router: MCPRouter | None = None
MCP_ROUTER_PROFILE_PATH = os.path.join(
os.path.dirname(__file__), 'mcp', 'config.json'
)
@asynccontextmanager
async def lifespan(app: FastAPI):
global client, mcp_proxy_manager
global client, mcp_router
logger.info('Initializing ActionExecutor...')
client = ActionExecutor(
plugins_to_load,
@@ -669,36 +676,63 @@ if __name__ == '__main__':
# Check if we're on Windows
is_windows = sys.platform == 'win32'
# Initialize and mount MCP Proxy Manager (skip on Windows)
# Initialize and mount MCP Router (skip on Windows)
if is_windows:
logger.info('Skipping MCP Proxy initialization on Windows')
mcp_proxy_manager = None
logger.info('Skipping MCP Router initialization on Windows')
mcp_router = None
else:
logger.info('Initializing MCP Proxy Manager...')
# Create a MCP Proxy Manager
mcp_proxy_manager = MCPProxyManager(
auth_enabled=bool(SESSION_API_KEY),
api_key=SESSION_API_KEY,
logger_level=logger.getEffectiveLevel(),
logger.info('Initializing MCP Router...')
mcp_router = MCPRouter(
profile_path=MCP_ROUTER_PROFILE_PATH,
router_config=RouterConfig(
api_key=SESSION_API_KEY,
auth_enabled=bool(SESSION_API_KEY),
),
)
mcp_proxy_manager.initialize()
# Mount the proxy to the app
allowed_origins = ['*']
try:
await mcp_proxy_manager.mount_to_app(app, allowed_origins)
except Exception as e:
logger.error(f'Error mounting MCP Proxy: {e}', exc_info=True)
raise RuntimeError(f'Cannot mount MCP Proxy: {e}')
sse_app = await mcp_router.get_sse_server_app(
allow_origins=allowed_origins, include_lifespan=False
)
# Only mount SSE app if MCP Router is initialized (not on Windows)
if mcp_router is not None:
# Check for route conflicts before mounting
main_app_routes = {route.path for route in app.routes}
sse_app_routes = {route.path for route in sse_app.routes}
conflicting_routes = main_app_routes.intersection(sse_app_routes)
if conflicting_routes:
logger.error(f'Route conflicts detected: {conflicting_routes}')
raise RuntimeError(
f'Cannot mount SSE app - conflicting routes found: {conflicting_routes}'
)
app.mount('/', sse_app)
logger.info(
f'Mounted MCP Router SSE app at root path with allowed origins: {allowed_origins}'
)
# Additional debug logging
if logger.isEnabledFor(logging.DEBUG):
logger.debug('Main app routes:')
for route in main_app_routes:
logger.debug(f' {route}')
logger.debug('MCP SSE server app routes:')
for route in sse_app_routes:
logger.debug(f' {route}')
yield
# Clean up & release the resources
logger.info('Shutting down MCP Proxy Manager...')
if mcp_proxy_manager:
del mcp_proxy_manager
mcp_proxy_manager = None
logger.info('Shutting down MCP Router...')
if mcp_router:
try:
await mcp_router.shutdown()
logger.info('MCP Router shutdown successfully.')
except Exception as e:
logger.error(f'Error shutting down MCP Router: {e}', exc_info=True)
else:
logger.info('MCP Proxy Manager instance not found for shutdown.')
logger.info('MCP Router instance not found for shutdown.')
logger.info('Closing ActionExecutor...')
if client:
@@ -790,9 +824,6 @@ if __name__ == '__main__':
# Check if we're on Windows
is_windows = sys.platform == 'win32'
# Access the global mcp_proxy_manager variable
global mcp_proxy_manager
if is_windows:
# On Windows, just return a success response without doing anything
logger.info(
@@ -807,10 +838,17 @@ if __name__ == '__main__':
)
# Non-Windows implementation
if mcp_proxy_manager is None:
raise HTTPException(
status_code=500, detail='MCP Proxy Manager is not initialized'
)
assert mcp_router is not None
assert os.path.exists(MCP_ROUTER_PROFILE_PATH)
# Use synchronous file operations outside of async function
def read_profile():
with open(MCP_ROUTER_PROFILE_PATH, 'r') as f:
return json.load(f)
current_profile = read_profile()
assert 'default' in current_profile
assert isinstance(current_profile['default'], list)
# Get the request body
mcp_tools_to_sync = await request.json()
@@ -818,17 +856,31 @@ if __name__ == '__main__':
raise HTTPException(
status_code=400, detail='Request must be a list of MCP tools to sync'
)
logger.info(
f'Updating MCP server with tools: {json.dumps(mcp_tools_to_sync, indent=2)}'
f'Updating MCP server to: {json.dumps(mcp_tools_to_sync, indent=2)}.\nPrevious profile: {json.dumps(current_profile, indent=2)}'
)
mcp_tools_to_sync = [MCPStdioServerConfig(**tool) for tool in mcp_tools_to_sync]
try:
await mcp_proxy_manager.update_and_remount(app, mcp_tools_to_sync, ['*'])
logger.info('MCP Proxy Manager updated and remounted successfully')
router_error_log = ''
except Exception as e:
logger.error(f'Error updating MCP Proxy Manager: {e}', exc_info=True)
router_error_log = str(e)
current_profile['default'] = mcp_tools_to_sync
# Use synchronous file operations outside of async function
def write_profile(profile):
with open(MCP_ROUTER_PROFILE_PATH, 'w') as f:
json.dump(profile, f)
write_profile(current_profile)
# Manually reload the profile and update the servers
mcp_router.profile_manager.reload()
servers_wait_for_update = mcp_router.get_unique_servers()
async with capture_logs('mcpm.router.router') as log_capture:
await mcp_router.update_servers(servers_wait_for_update)
router_error_log = log_capture.getvalue()
logger.info(
f'MCP router updated successfully with unique servers: {servers_wait_for_update}'
)
if router_error_log:
logger.warning(f'Some MCP servers failed to be added: {router_error_log}')
return JSONResponse(
status_code=200,
@@ -863,7 +915,7 @@ if __name__ == '__main__':
)
zip_path = os.path.join(full_dest_path, file.filename)
with open(zip_path, 'wb') as buffer:
with open(zip_path, 'wb') as buffer: # noqa: ASYNC101
shutil.copyfileobj(file.file, buffer)
# Extract the zip file
@@ -876,7 +928,7 @@ if __name__ == '__main__':
else:
# For single file uploads
file_path = os.path.join(full_dest_path, file.filename)
with open(file_path, 'wb') as buffer:
with open(file_path, 'wb') as buffer: # noqa: ASYNC101
shutil.copyfileobj(file.file, buffer)
logger.debug(f'Uploaded file {file.filename} to {destination}')

View File

@@ -435,7 +435,7 @@ class ActionExecutionClient(Runtime):
# We should always include the runtime as an MCP server whenever there's > 0 stdio servers
updated_mcp_config.sse_servers.append(
MCPSSEServerConfig(
url=self.action_execution_server_url.rstrip('/') + '/mcp/sse',
url=self.action_execution_server_url.rstrip('/') + '/sse',
api_key=self.session_api_key,
)
)
@@ -471,6 +471,11 @@ class ActionExecutionClient(Runtime):
# Call the tool and return the result
# No need for try/finally since disconnect() is now just resetting state
result = await call_tool_mcp_handler(mcp_clients, action)
# Reset client state (no active connections to worry about)
for client in mcp_clients:
await client.disconnect()
return result
def close(self) -> None:

View File

@@ -307,10 +307,8 @@ class LocalRuntime(ActionExecutionClient):
env['PATH'] = f'{python_bin_path}{os.pathsep}{env.get("PATH", "")}'
logger.debug(f'Updated PATH for subprocesses: {env["PATH"]}')
# Check dependencies using the derived env_root_path if not skipped
if os.getenv('SKIP_DEPENDENCY_CHECK', '') != '1':
check_dependencies(code_repo_path, env_root_path)
# Check dependencies using the derived env_root_path
check_dependencies(code_repo_path, env_root_path)
self.server_process = subprocess.Popen( # noqa: S603
cmd,
stdout=subprocess.PIPE,
@@ -408,8 +406,8 @@ class LocalRuntime(ActionExecutionClient):
return port
@tenacity.retry(
wait=tenacity.wait_fixed(2),
stop=tenacity.stop_after_delay(120) | stop_if_should_exit(),
wait=tenacity.wait_exponential(min=1, max=10),
stop=tenacity.stop_after_attempt(10) | stop_if_should_exit(),
before_sleep=lambda retry_state: logger.debug(
f'Waiting for server to be ready... (attempt {retry_state.attempt_number})'
),

View File

@@ -1,6 +1,3 @@
{
"mcpServers": {
"default": {}
},
"tools": []
"default": []
}

View File

@@ -1,71 +0,0 @@
# MCP Proxy Manager
This module provides a manager class for handling FastMCP proxy instances in OpenHands, including initialization, configuration, and mounting to FastAPI applications.
## Overview
The `MCPProxyManager` class encapsulates all the functionality related to creating, configuring, and managing FastMCP proxy instances. It simplifies the process of:
1. Initializing a FastMCP proxy
2. Configuring the proxy with tools
3. Mounting the proxy to a FastAPI application
4. Updating the proxy configuration
5. Shutting down the proxy
## Usage
### Basic Usage
```python
from openhands.runtime.mcp.proxy import MCPProxyManager
from fastapi import FastAPI
# Create a FastAPI app
app = FastAPI()
# Create a proxy manager
proxy_manager = MCPProxyManager(
name="MyProxyServer",
auth_enabled=True,
api_key="my-api-key"
)
# Initialize the proxy
proxy_manager.initialize()
# Mount the proxy to the app
await proxy_manager.mount_to_app(app, allow_origins=["*"])
# Update the tools configuration
tools = [
{
"name": "my_tool",
"description": "My tool description",
"parameters": {...}
}
]
proxy_manager.update_tools(tools)
# Update and remount the proxy
await proxy_manager.update_and_remount(app, tools, allow_origins=["*"])
# Shutdown the proxy
await proxy_manager.shutdown()
```
### In-Memory Configuration
The `MCPProxyManager` maintains the configuration in-memory, eliminating the need for file-based configuration. This makes it easier to update the configuration and reduces the complexity of the code.
## Benefits
1. **Simplified API**: The `MCPProxyManager` provides a simple and intuitive API for managing FastMCP proxies.
2. **In-Memory Configuration**: Configuration is maintained in-memory, eliminating the need for file I/O operations.
3. **Improved Error Handling**: The manager provides better error handling and logging for proxy operations.
4. **Cleaner Code**: By encapsulating proxy-related functionality in a dedicated class, the code is more maintainable and easier to understand.
## Implementation Details
The `MCPProxyManager` uses the `FastMCP.as_proxy()` method to create a proxy server. It manages the lifecycle of the proxy, including initialization, configuration updates, and shutdown.
When updating the tools configuration, the manager creates a new proxy with the updated configuration and remounts it to the FastAPI application, ensuring that the proxy is always up-to-date with the latest configuration.

View File

@@ -1,7 +0,0 @@
"""
MCP Proxy module for OpenHands.
"""
from openhands.runtime.mcp.proxy.manager import MCPProxyManager
__all__ = ['MCPProxyManager']

View File

@@ -1,138 +0,0 @@
"""
MCP Proxy Manager for OpenHands.
This module provides a manager class for handling FastMCP proxy instances,
including initialization, configuration, and mounting to FastAPI applications.
"""
import logging
from typing import Any, Optional
from fastapi import FastAPI
from fastmcp import FastMCP
from fastmcp.utilities.logging import get_logger as fastmcp_get_logger
from openhands.core.config.mcp_config import MCPStdioServerConfig
logger = logging.getLogger(__name__)
fastmcp_logger = fastmcp_get_logger('fastmcp')
class MCPProxyManager:
"""
Manager for FastMCP proxy instances.
This class encapsulates all the functionality related to creating, configuring,
and managing FastMCP proxy instances, including mounting them to FastAPI applications.
"""
def __init__(
self,
auth_enabled: bool = False,
api_key: Optional[str] = None,
logger_level: Optional[int] = None,
):
"""
Initialize the MCP Proxy Manager.
Args:
name: Name of the proxy server
auth_enabled: Whether authentication is enabled
api_key: API key for authentication (required if auth_enabled is True)
logger_level: Logging level for the FastMCP logger
"""
self.auth_enabled = auth_enabled
self.api_key = api_key
self.proxy: Optional[FastMCP] = None
# Initialize with a valid configuration format for FastMCP
self.config: dict[str, Any] = {
'mcpServers': {},
}
# Configure FastMCP logger
if logger_level is not None:
fastmcp_logger.setLevel(logger_level)
def initialize(self) -> None:
"""
Initialize the FastMCP proxy with the current configuration.
"""
if len(self.config['mcpServers']) == 0:
logger.info(
f"No MCP servers configured for FastMCP Proxy, skipping initialization."
)
return None
# Create a new proxy with the current configuration
self.proxy = FastMCP.as_proxy(
self.config,
auth_enabled=self.auth_enabled,
api_key=self.api_key,
)
logger.info(f"FastMCP Proxy initialized successfully")
async def mount_to_app(
self, app: FastAPI, allow_origins: Optional[list[str]] = None
) -> None:
"""
Mount the SSE server app to a FastAPI application.
Args:
app: FastAPI application to mount to
allow_origins: List of allowed origins for CORS
"""
if len(self.config['mcpServers']) == 0:
logger.info(
f"No MCP servers configured for FastMCP Proxy, skipping mount."
)
return
if not self.proxy:
raise ValueError('FastMCP Proxy is not initialized')
# Get the SSE app
# mcp_app = self.proxy.http_app(path='/shttp')
mcp_app = self.proxy.http_app(path='/sse', transport='sse')
app.mount('/mcp', mcp_app)
# Remove any existing mounts at root path
if '/mcp' in app.routes:
app.routes.remove('/mcp')
app.mount('/', mcp_app)
logger.info(f"Mounted FastMCP Proxy app at /mcp")
async def update_and_remount(
self,
app: FastAPI,
stdio_servers: list[MCPStdioServerConfig],
allow_origins: Optional[list[str]] = None,
) -> None:
"""
Update the tools configuration and remount the proxy to the app.
This is a convenience method that combines updating the tools,
shutting down the existing proxy, initializing a new one, and
mounting it to the app.
Args:
app: FastAPI application to mount to
tools: List of tool configurations
allow_origins: List of allowed origins for CORS
"""
tools = {
t.name: t.model_dump()
for t in stdio_servers
}
self.config['mcpServers'] = tools
del self.proxy
self.proxy = None
# Initialize a new proxy
self.initialize()
# Mount the new proxy to the app
await self.mount_to_app(app, allow_origins)

View File

@@ -17,7 +17,6 @@ from openhands.events.observation.commands import (
CmdOutputMetadata,
CmdOutputObservation,
)
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
from openhands.utils.shutdown_listener import should_continue
@@ -380,7 +379,9 @@ class BashSession:
metadata = CmdOutputMetadata() # No metadata available
metadata.suffix = (
f'\n[The command has no new output after {self.NO_CHANGE_TIMEOUT_SECONDS} seconds. '
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
)
command_output = self._get_command_output(
command,
@@ -413,7 +414,9 @@ class BashSession:
metadata = CmdOutputMetadata() # No metadata available
metadata.suffix = (
f'\n[The command timed out after {timeout} seconds. '
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
)
command_output = self._get_command_output(
command,

View File

@@ -1,7 +0,0 @@
# Common timeout message that can be used across different timeout scenarios
TIMEOUT_MESSAGE_TEMPLATE = (
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'send keys to interrupt/kill the command, '
'or use the timeout parameter in execute_bash for future commands.'
)

View File

@@ -4,7 +4,7 @@ import tempfile
from abc import ABC, abstractmethod
from typing import Any
from openhands_aci.utils.diff import get_diff # type: ignore
from openhands_aci.utils.diff import get_diff
from openhands.core.config import OpenHandsConfig
from openhands.core.logger import openhands_logger as logger
@@ -26,31 +26,39 @@ from openhands.llm.llm import LLM
from openhands.llm.metrics import Metrics
from openhands.utils.chunk_localizer import Chunk, get_top_k_chunk_matches
SYS_MSG = """Your job is to produce a new version of the file based on the old version and the
provided draft of the new version. The provided draft may be incomplete (it may skip lines) and/or incorrectly indented. You should try to apply the changes present in the draft to the old version, and output a new version of the file.
NOTE:
- The output file should be COMPLETE and CORRECTLY INDENTED. Do not omit any lines, and do not change any lines that are not part of the changes.
- You should output the new version of the file by wrapping the new version of the file content in a ``` block.
- If there's no explicit comment to remove the existing code, we should keep them and append the new code to the end of the file.
- If there's placeholder comments like `# no changes before` or `# no changes here`, we should replace these comments with the original code near the placeholder comments.
"""
USER_MSG = """
Code changes will be provided in the form of a draft. You will need to apply the draft to the original code.
The original code will be enclosed within `<original_code>` tags.
The draft will be enclosed within `<update_snippet>` tags.
You need to output the update code within `<updated_code>` tags.
HERE IS THE OLD VERSION OF THE FILE:
```
{old_contents}
```
Within the `<updated_code>` tag, include only the final code after updation. Do not include any explanations or other content within these tags.
HERE IS THE DRAFT OF THE NEW VERSION OF THE FILE:
```
{draft_changes}
```
<original_code>{old_contents}</original_code>
<update_snippet>{draft_changes}</update_snippet>
"""
GIVE ME THE NEW VERSION OF THE FILE.
IMPORTANT:
- There should be NO placeholder comments like `# no changes before` or `# no changes here`. They should be replaced with the original code near the placeholder comments.
- The output file should be COMPLETE and CORRECTLY INDENTED. Do not omit any lines, and do not change any lines that are not part of the changes.
""".strip()
def _extract_code(string: str) -> str | None:
pattern = r'<updated_code>(.*?)</updated_code>'
pattern = r'```(?:\w*\n)?(.*?)```'
matches = re.findall(pattern, string, re.DOTALL)
if not matches:
return None
content = str(matches[0])
if content.startswith('#EDIT:'):
#Remove first line
content = content[content.find('\n') + 1:]
return content
return str(matches[0])
def get_new_file_contents(
@@ -58,6 +66,7 @@ def get_new_file_contents(
) -> str | None:
while num_retries > 0:
messages = [
{'role': 'system', 'content': SYS_MSG},
{
'role': 'user',
'content': USER_MSG.format(

View File

@@ -38,7 +38,7 @@ def send_request(
session: HttpSession,
method: str,
url: str,
timeout: int = 60,
timeout: int = 10,
**kwargs: Any,
) -> httpx.Response:
response = session.request(method, url, timeout=timeout, **kwargs)

View File

@@ -20,7 +20,6 @@ from openhands.events.observation.commands import (
CmdOutputMetadata,
CmdOutputObservation,
)
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
from openhands.utils.shutdown_listener import should_continue
pythonnet.load('coreclr')
@@ -560,7 +559,9 @@ class WindowsPowershellSession:
else:
metadata.suffix = (
f'\n[The command timed out after {timeout_seconds} seconds. '
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
)
return CmdOutputObservation(
@@ -1330,7 +1331,9 @@ class WindowsPowershellSession:
# Align suffix with bash.py timeout message
suffix = (
f'\n[The command timed out after {timeout_seconds} seconds. '
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
)
elif shutdown_requested:
# Align suffix with bash.py equivalent (though bash.py might not have specific shutdown message)

View File

@@ -15,6 +15,7 @@ from fastapi import (
import openhands.agenthub # noqa F401 (we import this to get the agents registered)
from openhands import __version__
from openhands.server.routes.conversation import app as conversation_api_router
from openhands.server.routes.feedback import app as feedback_api_router
from openhands.server.routes.files import app as files_api_router
from openhands.server.routes.git import app as git_api_router
from openhands.server.routes.health import add_health_endpoints
@@ -62,6 +63,7 @@ app = FastAPI(
app.include_router(public_api_router)
app.include_router(files_api_router)
app.include_router(security_api_router)
app.include_router(feedback_api_router)
app.include_router(conversation_api_router)
app.include_router(manage_conversation_api_router)
app.include_router(settings_router)

View File

@@ -22,6 +22,7 @@ from openhands.events.nested_event_store import NestedEventStore
from openhands.events.stream import EventStream
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderHandler
from openhands.llm.llm import LLM
from openhands.runtime.impl.docker.containers import stop_all_containers
from openhands.runtime.impl.docker.docker_runtime import DockerRuntime
from openhands.server.config.server_config import ServerConfig
from openhands.server.conversation_manager.conversation_manager import (
@@ -89,7 +90,7 @@ class DockerNestedConversationManager(ConversationManager):
"""
Get the running agent loops directly from docker.
"""
containers: list[Container] = 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-') :]
@@ -283,7 +284,7 @@ class DockerNestedConversationManager(ConversationManager):
# First try to graceful stop server.
try:
container = self.docker_client.containers.get(f'openhands-runtime-{sid}')
except docker.errors.NotFound:
except docker.errors.NotFound as e:
return
try:
nested_url = self.get_nested_url_for_container(container)
@@ -292,33 +293,17 @@ class DockerNestedConversationManager(ConversationManager):
'X-Session-API-Key': self._get_session_api_key_for_conversation(sid)
}
) as client:
# Stop conversation
response = await client.post(
f'{nested_url}/api/conversations/{sid}/stop'
)
response = await client.post(f'{nested_url}/api/conversations/{sid}/stop')
response.raise_for_status()
# Check up to 3 times that client has closed
for _ in range(3):
response = await client.get(f'{nested_url}/api/conversations/{sid}')
response.raise_for_status()
if response.json().get('status') == 'STOPPED':
break
await asyncio.sleep(1)
except Exception as e:
logger.warning('error_stopping_container', extra={"sid": sid, "error": str(e)})
except Exception:
logger.exception("error_stopping_container")
container.stop()
async def get_agent_loop_info(
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
) -> list[AgentLoopInfo]:
async def get_agent_loop_info(self, user_id: str | None = None, filter_to_sids: set[str] | None = None) -> list[AgentLoopInfo]:
results = []
containers: list[Container] = self.docker_client.containers.list()
containers : list[Container] = self.docker_client.containers.list()
for container in containers:
if not container.name or 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:
@@ -396,9 +381,7 @@ class DockerNestedConversationManager(ConversationManager):
)
return session_api_key
async def ensure_num_conversations_below_limit(
self, sid: str, user_id: str | None
) -> 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(
@@ -440,9 +423,7 @@ class DockerNestedConversationManager(ConversationManager):
)
return provider_handler
async def _create_runtime(
self, sid: str, user_id: str | None, settings: Settings
) -> DockerRuntime:
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(
@@ -474,9 +455,8 @@ class DockerNestedConversationManager(ConversationManager):
env_vars['SESSION_API_KEY'] = self._get_session_api_key_for_conversation(sid)
# 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'] = '/workspace'
env_vars['WORKSPACE_BASE'] = f'/workspace'
env_vars['SANDBOX_CLOSE_DELAY'] = '0'
env_vars['SKIP_DEPENDENCY_CHECK'] = '1'
# Set up mounted volume for conversation directory within workspace
# TODO: Check if we are using the standard event store and file store
@@ -521,7 +501,7 @@ class DockerNestedConversationManager(ConversationManager):
await call_sync_from_async(container.start)
return True
return False
except docker.errors.NotFound:
except docker.errors.NotFound as e:
return False

View File

@@ -0,0 +1,45 @@
import json
from typing import Any, Literal
import httpx
from pydantic import BaseModel
from openhands.core.logger import openhands_logger as logger
class FeedbackDataModel(BaseModel):
version: str
email: str
polarity: Literal['positive', 'negative']
feedback: Literal[
'positive', 'negative'
] # TODO: remove this, its here for backward compatibility
permissions: Literal['public', 'private']
trajectory: list[dict[str, Any]] | None
FEEDBACK_URL = 'https://share-od-trajectory-3u9bw9tx.uc.gateway.dev/share_od_trajectory'
def store_feedback(feedback: FeedbackDataModel) -> dict[str, str]:
# Start logging
feedback.feedback = feedback.polarity
display_feedback = feedback.model_dump()
if 'trajectory' in display_feedback:
display_feedback['trajectory'] = (
f'elided [length: {len(display_feedback["trajectory"])}'
)
if 'token' in display_feedback:
display_feedback['token'] = 'elided'
logger.debug(f'Got feedback: {display_feedback}')
# Start actual request
response = httpx.post(
FEEDBACK_URL,
headers={'Content-Type': 'application/json'},
json=feedback.model_dump(),
)
if response.status_code != 200:
raise ValueError(f'Failed to store feedback: {response.text}')
response_data: dict[str, str] = json.loads(response.text)
logger.debug(f'Stored feedback: {response.text}')
return response_data

Some files were not shown because too many files have changed in this diff Show More