Compare commits

..

42 Commits

Author SHA1 Message Date
Chuck Butkus
60c8480dbd Try 2025-06-05 23:14:45 -04:00
Chuck Butkus
313276207b Update to use toast messages 2025-06-05 15:46:36 -04:00
Chuck Butkus
7e34240d49 Fix email input box 2025-06-05 15:33:59 -04:00
chuckbutkus
76be0ffff9 Merge branch 'main' into allow-email-change 2025-06-05 15:26:33 -04:00
Chuck Butkus
60eb68bd91 User setting refactor 2025-06-05 15:23:04 -04:00
Chuck Butkus
686eb45fae User setting refactor 2025-06-05 15:23:04 -04:00
chuckbutkus
8566cd6ed2 Merge branch 'main' into allow-email-change 2025-06-05 12:19:24 -04:00
chuckbutkus
854e926bac Merge branch 'main' into allow-email-change 2025-06-04 23:15:49 -04:00
openhands
f981a8a254 Update email placeholder to show 'Loading...' instead of 'Email not available' 2025-06-05 03:00:40 +00:00
openhands
3f47187f2f Update buttons to have black text when disabled 2025-06-05 02:43:02 +00:00
openhands
19c4296b07 Update button styles to match the Launch button style 2025-06-05 02:31:05 +00:00
Chuck Butkus
0929936045 Fix lint 2025-06-04 22:10:41 -04:00
openhands
6765673523 Remove default value for email verification restriction message 2025-06-05 01:56:02 +00:00
openhands
846999202d Update email verification success message and add translations 2025-06-05 01:53:35 +00:00
openhands
523d2ff170 Add background polling for email verification status on user settings page 2025-06-05 01:34:56 +00:00
chuckbutkus
edf2269f13 Merge branch 'main' into allow-email-change 2025-06-04 17:21:14 -04:00
openhands
a0bdd4101c Fix settings-with-payment test by adding user settings route and mocking email verification 2025-06-04 19:50:51 +00:00
chuckbutkus
c7ca81f85c Merge branch 'main' into allow-email-change 2025-06-04 15:28:13 -04:00
chuckbutkus
bff22652cb Merge branch 'main' into allow-email-change 2025-06-04 14:27:26 -04:00
Chuck Butkus
330d5a75e7 Fix lint errors 2025-06-04 12:57:46 -04:00
Chuck Butkus
42885c0288 Fix lint errors 2025-06-04 12:48:23 -04:00
Chuck Butkus
8805f34af0 Remove duplication 2025-06-04 02:38:06 -04:00
openhands
45bb6877e6 Update remaining files for EMAIL_VERIFIED restriction 2025-06-04 06:25:38 +00:00
openhands
703efd17ab Restrict app to only show user settings page when EMAIL_VERIFIED is false 2025-06-04 06:17:37 +00:00
openhands
b8884ed447 Add email verification UI improvements: hide resend button when verified and show warning message when not verified 2025-06-04 01:55:52 +00:00
Chuck Butkus
8cfac66cc9 Another email_verified change 2025-06-03 21:40:03 -04:00
Chuck Butkus
bcdec805e2 Add email_verified to settings 2025-06-03 21:35:01 -04:00
Chuck Butkus
2138eeb556 Update 2025-06-03 01:15:17 -04:00
openhands
e00b00b372 Set withCredentials only in user-settings.tsx instead of globally 2025-06-03 04:36:45 +00:00
openhands
5f1f3b1e2d Enable withCredentials to allow cookies to be set from API responses 2025-06-03 04:35:17 +00:00
openhands
45ffac0b78 Add translations for resend verification email functionality 2025-06-02 22:09:42 +00:00
openhands
70a8e1bc0a Move save button to be before resend verification button on the same line 2025-06-02 21:40:22 +00:00
openhands
e74b354137 Add resend verification email button to user settings 2025-06-02 21:06:57 +00:00
Chuck Butkus
56ed63088f Update 2025-06-02 16:23:01 -04:00
openhands
489e32c2c0 Fix email update to use /api/settings endpoint 2025-05-31 19:10:03 +00:00
openhands
c189012f0a Fix email update to use query parameter instead of form data 2025-05-31 19:05:02 +00:00
openhands
2407420e17 Make email field editable and add save button in user settings 2025-05-31 18:59:14 +00:00
chuckbutkus
bb0c47c41a Merge branch 'main' into display-email 2025-05-31 01:14:24 -04:00
Chuck Butkus
83e5276de5 Update User Setting tab 2025-05-31 01:13:38 -04:00
openhands
816082a55b Update User tab to display email from settings instead of git user 2025-05-31 04:12:04 +00:00
Chuck Butkus
82d72b145d Add email to Setting class 2025-05-30 23:59:47 -04:00
Chuck Butkus
f8c3470c91 Add get_user_email from UserAuth 2025-05-30 15:43:08 -04:00
120 changed files with 2072 additions and 2158 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

@@ -313,8 +313,6 @@ jobs:
TEST_IN_CI=true \
RUN_AS_OPENHANDS=false \
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
env:
DEBUG: "1"
# Run unit tests with the Docker runtime Docker images as openhands user
test_runtime_oh:
@@ -380,8 +378,6 @@ jobs:
TEST_IN_CI=true \
RUN_AS_OPENHANDS=true \
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
env:
DEBUG: "1"
# The two following jobs (named identically) are to check whether all the runtime tests have passed as the
# "All Runtime Tests Passed" is a required job for PRs to merge

View File

@@ -74,11 +74,5 @@ jobs:
run: poetry install --with dev,test,runtime
- name: Run Windows unit tests
run: poetry run pytest -svv tests/unit/test_windows_bash.py
env:
DEBUG: "1"
- name: Run Windows runtime tests with LocalRuntime
run: $env:TEST_RUNTIME="local"; poetry run pytest -svv tests/runtime/test_bash.py
env:
TEST_RUNTIME: local
DEBUG: "1"

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`.
```

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

@@ -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
@@ -220,26 +236,6 @@ class OpenHands {
return data;
}
static async startConversation(
conversationId: string,
): Promise<Conversation | null> {
const { data } = await openHands.post<Conversation | null>(
`/api/conversations/${conversationId}/start`,
);
return data;
}
static async stopConversation(
conversationId: string,
): Promise<Conversation | null> {
const { data } = await openHands.post<Conversation | null>(
`/api/conversations/${conversationId}/stop`,
);
return data;
}
/**
* Get the settings from the server or use the default settings if not found
*/

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

@@ -84,7 +84,7 @@ export function AgentStatusBar() {
setStatusMessage(t(I18nKey.STATUS$STARTING_RUNTIME));
setIndicatorColor(IndicatorColor.RED);
} else if (status === WsClientProviderStatus.DISCONNECTED) {
setStatusMessage(t(I18nKey.STATUS$WEBSOCKET_CLOSED));
setStatusMessage(t(I18nKey.STATUS$CONNECTED)); // Using STATUS$CONNECTED instead of STATUS$CONNECTING
setIndicatorColor(IndicatorColor.RED);
} else {
setStatusMessage(AGENT_STATUS_MAP[curAgentState].message);

View File

@@ -122,7 +122,7 @@ export function FileDiffViewer({ path, type }: FileDiffViewerProps) {
modifiedEditor.onDidContentSizeChange(updateEditorHeight);
};
const status = (type === "U" ? STATUS_MAP.A : STATUS_MAP[type]) || "?";
const status = type === "U" ? STATUS_MAP.A : STATUS_MAP[type];
let statusIcon: React.ReactNode;
if (typeof status === "string") {

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,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

@@ -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

@@ -150,8 +150,7 @@ export function WsClientProvider({
const { providers } = useUserProviders();
const messageRateHandler = useRate({ threshold: 250 });
const { data: conversation, refetch: refetchConversation } =
useActiveConversation();
const { data: conversation } = useActiveConversation();
function send(event: Record<string, unknown>) {
if (!sioRef.current) {
@@ -270,11 +269,14 @@ export function WsClientProvider({
sio.io.opts.query.latest_event_id = lastEventRef.current?.id;
updateStatusWhenErrorMessagePresent(data);
setErrorMessage(hasValidMessageProperty(data) ? data.message : "");
setErrorMessage(
hasValidMessageProperty(data)
? data.message
: "The WebSocket connection was closed.",
);
}
function handleError(data: unknown) {
// set status
setStatus(WsClientProviderStatus.DISCONNECTED);
updateStatusWhenErrorMessagePresent(data);
@@ -283,9 +285,6 @@ export function WsClientProvider({
? data.message
: "An unknown error occurred on the WebSocket connection.",
);
// check if something went wrong with the conversation.
refetchConversation();
}
React.useEffect(() => {
@@ -301,19 +300,12 @@ export function WsClientProvider({
if (!conversationId) {
throw new Error("No conversation ID provided");
}
if (
!conversation ||
["STOPPED", "STARTING"].includes(conversation.status)
) {
if (!conversation || conversation.status === "STARTING") {
return () => undefined; // conversation not yet loaded
}
let sio = sioRef.current;
if (sio?.connected) {
sio.disconnect();
}
const lastEvent = lastEventRef.current;
const query = {
latest_event_id: lastEvent?.id ?? -1,

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

@@ -9,7 +9,7 @@ export const useActiveConversation = () => {
const { conversationId } = useConversationId();
const userConversation = useUserConversation(conversationId, (query) => {
if (query.state.data?.status === "STARTING") {
return 3000; // 3 seconds
return 2000; // 2 seconds
}
return FIVE_MINUTES;
});
@@ -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

@@ -45,7 +45,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

@@ -0,0 +1,116 @@
import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { AxiosError } from "axios";
import { openHands } from "#/api/open-hands-axios";
import { Settings } from "#/types/settings";
import { useConfig } from "#/hooks/query/use-config";
/**
* Hook to handle email verification errors (403 with "Email has not been verified" message)
* This hook sets up an axios interceptor that will reload settings and navigate to the user settings page
* when a 403 error with the specific message is encountered.
*/
export const useHandleEmailVerification = () => {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { data: config } = useConfig();
const appMode = config?.APP_MODE;
console.log(`config: ${config}`);
console.log(`AppMode: ${appMode}`);
useEffect(() => {
// Add response interceptor
const interceptorId = openHands.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
console.log(
`Received error ${error.response?.status} with message ${error.response?.data}`,
);
const EMAIL_NOT_VERIFIED = "EmailNotVerifiedError";
// check for email verification error message no matter how it is returned.
const isEmailNotVerified = (() => {
const data = error.response?.data;
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;
})();
// Check if it's a 403 error with the specific message
if (error.response?.status === 403 && isEmailNotVerified) {
console.log("EMAIL VERIFICATION ERROR");
// Only handle this in SAAS mode
console.log(`config1: ${config}`);
console.log(`AppMode1: ${appMode}`);
if (appMode === "saas") {
// Update settings to mark email as unverified
queryClient.setQueryData(
["settings"],
(oldData: Settings | undefined) => {
if (oldData) {
console.log("ADDING EMAIL_VERIFIED is FALSE");
return {
...oldData,
EMAIL_VERIFIED: false,
};
}
console.log("NO CHANGES TO SETTINGS");
return oldData;
},
);
// Invalidate settings to reload them
queryClient.invalidateQueries({ queryKey: ["settings"] });
// Navigate to settings/user page
// The EmailVerificationGuard will handle the redirect
console.log("NAVIGATING to /settings/user");
navigate("/settings/user");
}
} else {
console.log("NOT EMAIL VERIFICATION ERROR");
console.log(typeof error.response?.data);
}
// Continue with the error for other error handlers
return Promise.reject(error);
},
);
// Clean up interceptor when component unmounts
return () => {
openHands.interceptors.response.eject(interceptorId);
};
}, [queryClient, navigate]);
};

View File

@@ -1,6 +1,5 @@
// this file generate by script, don't modify it manually!!!
export enum I18nKey {
STATUS$WEBSOCKET_CLOSED = "STATUS$WEBSOCKET_CLOSED",
HOME$LAUNCH_FROM_SCRATCH = "HOME$LAUNCH_FROM_SCRATCH",
HOME$READ_THIS = "HOME$READ_THIS",
AUTH$LOGGING_BACK_IN = "AUTH$LOGGING_BACK_IN",
@@ -567,7 +566,6 @@ export enum I18nKey {
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

@@ -1,20 +1,4 @@
{
"STATUS$WEBSOCKET_CLOSED": {
"en": "The WebSocket connection was closed.",
"ja": "WebSocket接続が閉じられました。",
"zh-CN": "WebSocket连接已关闭。",
"zh-TW": "WebSocket連接已關閉。",
"ko-KR": "WebSocket 연결이 닫혔습니다.",
"no": "WebSocket-tilkoblingen ble lukket.",
"it": "La connessione WebSocket è stata chiusa.",
"pt": "A conexão WebSocket foi fechada.",
"es": "La conexión WebSocket se ha cerrado.",
"ar": "تم إغلاق اتصال WebSocket.",
"fr": "La connexion WebSocket a été fermée.",
"tr": "WebSocket bağlantısı kapatıldı.",
"de": "Die WebSocket-Verbindung wurde geschlossen.",
"uk": "З'єднання WebSocket було закрито."
},
"HOME$LAUNCH_FROM_SCRATCH": {
"en": "Launch from Scratch",
"ja": "ゼロから始める",
@@ -9071,22 +9055,6 @@
"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": "メールが確認されるまでアクセスが制限されています。この設定ページにのみアクセスできます。",

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

@@ -43,7 +43,7 @@ function AppContent() {
const { t } = useTranslation();
const { data: settings } = useSettings();
const { conversationId } = useConversationId();
const { data: conversation, isFetched, refetch } = useActiveConversation();
const { data: conversation, isFetched } = useActiveConversation();
const { data: isAuthed } = useIsAuthed();
const { curAgentState } = useSelector((state: RootState) => state.agent);
@@ -61,13 +61,8 @@ function AppContent() {
"This conversation does not exist, or you do not have permission to access it.",
);
navigate("/");
} else if (conversation?.status === "STOPPED") {
// start the conversation if the state is stopped on initial load
OpenHands.startConversation(conversation.conversation_id).then(() =>
refetch(),
);
}
}, [conversation?.conversation_id, isFetched, isAuthed]);
}, [conversation, isFetched, isAuthed]);
React.useEffect(() => {
dispatch(clearTerminal());

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

@@ -24,6 +24,7 @@ import { displaySuccessToast } from "#/utils/custom-toast-handlers";
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
import { useAutoLogin } from "#/hooks/use-auto-login";
import { useAuthCallback } from "#/hooks/use-auth-callback";
import { useHandleEmailVerification } from "#/hooks/use-handle-email-verification";
import { LOCAL_STORAGE_KEYS } from "#/utils/local-storage";
import { EmailVerificationGuard } from "#/components/features/guards/email-verification-guard";
@@ -93,6 +94,9 @@ export default function MainApp() {
// Handle authentication callback and set login method after successful authentication
useAuthCallback();
// Set up interceptor for email verification errors
useHandleEmailVerification();
React.useEffect(() => {
// Don't change language when on TOS page
if (!isOnTosPage && settings?.LANGUAGE) {

View File

@@ -5,9 +5,6 @@ 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,
@@ -17,7 +14,6 @@ function EmailInputSection({
isResendingVerification,
isEmailChanged,
emailVerified,
isEmailValid,
children,
}: {
email: string;
@@ -28,7 +24,6 @@ function EmailInputSection({
isResendingVerification: boolean;
isEmailChanged: boolean;
emailVerified?: boolean;
isEmailValid: boolean;
children: React.ReactNode;
}) {
const { t } = useTranslation();
@@ -41,30 +36,17 @@ function EmailInputSection({
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`}
className="text-base text-white p-2 bg-base-tertiary rounded border 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}
disabled={!isEmailChanged || isSaving}
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"
>
@@ -116,7 +98,6 @@ function UserSettingsScreen() {
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);
@@ -125,7 +106,6 @@ function UserSettingsScreen() {
if (settings?.EMAIL) {
setEmail(settings.EMAIL);
setOriginalEmail(settings.EMAIL);
setIsEmailValid(EMAIL_REGEX.test(settings.EMAIL));
}
}, [settings?.EMAIL]);
@@ -163,13 +143,11 @@ function UserSettingsScreen() {
}, [settings?.EMAIL_VERIFIED, refetch, queryClient, t]);
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newEmail = e.target.value;
setEmail(newEmail);
setIsEmailValid(EMAIL_REGEX.test(newEmail));
setEmail(e.target.value);
};
const handleSaveEmail = async () => {
if (email === originalEmail || !isEmailValid) return;
if (email === originalEmail) return;
try {
setIsSaving(true);
await openHands.post("/api/email", { email }, { withCredentials: true });
@@ -216,7 +194,6 @@ function UserSettingsScreen() {
isResendingVerification={isResendingVerification}
isEmailChanged={isEmailChanged}
emailVerified={settings?.EMAIL_VERIFIED}
isEmailValid={isEmailValid}
>
{settings?.EMAIL_VERIFIED === false && <VerificationAlert />}
</EmailInputSection>

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

@@ -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

@@ -505,7 +505,10 @@ class GitHubService(BaseGitService, GitService):
)
# Return the HTML URL of the created PR
return response['html_url']
if 'html_url' in response:
return response['html_url']
else:
return f'PR created but URL not found in response: {response}'

View File

@@ -500,8 +500,12 @@ class GitLabService(BaseGitService, GitService):
url=url, params=payload, method=RequestMethod.POST
)
# Return the web URL of the created MR
if 'web_url' in response:
return response['web_url']
else:
return f'MR created but URL not found in response: {response}'
return response['web_url']

View File

@@ -15,3 +15,4 @@ When you're done, make sure to
2. Use the `create_pr` tool to open a new PR
3. Name the branch using `openhands/` as a prefix (e.g `openhands/update-readme`)
4. The PR description should mention that it "fixes" or "closes" the issue number
5. Make sure to leave the following sentence at the end of the PR description: `@{{ username }} can click here to [continue refining the PR]({{ conversation_url }})`

View File

@@ -9,3 +9,4 @@ When you're done, make sure to
1. Use the `create_pr` tool to open a new PR
2. The PR description should mention that it "fixes" or "closes" the issue number
3. Make sure to leave the following sentence at the end of the PR description: `@{{ username }} can click here to [continue refining the PR]({{ conversation_url }})`

View File

@@ -15,3 +15,4 @@ When you're done, make sure to
2. Use the `create_mr` tool to open a new MR
3. Name the branch using `openhands/` as a prefix (e.g `openhands/update-readme`)
4. The MR description should mention that it "fixes" or "closes" the issue number
5. Make sure to leave the following sentence at the end of the MR description: `@{{ username }} can click here to [continue refining the MR]({{ conversation_url }})`

View File

@@ -9,3 +9,4 @@ When you're done, make sure to
1. Use the `create_mr` tool to open a new MR
2. The MR description should mention that it "fixes" or "closes" the issue number
3. Make sure to leave the following sentence at the end of the MR description: `@{{ username }} can click here to [continue refining the MR]({{ conversation_url }})`

View File

@@ -5,3 +5,7 @@ These are a list of text messages attached in order of most recent.
{{ message }}
{% if not loop.last %}\n\n{% endif %}
{% endfor %}
If you opened a pull request, please leave the following comment at the end your summary and pull request description
`{{ username }} can click here to [continue refining the PR]({{ conversation_url }})`

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,21 @@ 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.async_bash import AsyncBashSession
from openhands.runtime.utils.bash import BashSession
from openhands.runtime.utils.files import insert_lines, read_lines
from openhands.runtime.utils.log_capture import capture_logs
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
@@ -249,10 +254,12 @@ class ActionExecutor:
# If we get here, the browser is ready
logger.debug('Browser is ready')
def _create_bash_session(self, cwd: str | None = None):
async def ainit(self):
# bash needs to be initialized first
logger.debug('Initializing bash session')
if sys.platform == 'win32':
return WindowsPowershellSession( # type: ignore[name-defined]
work_dir=cwd or self._initial_cwd,
self.bash_session = WindowsPowershellSession( # type: ignore[name-defined]
work_dir=self._initial_cwd,
username=self.username,
no_change_timeout_seconds=int(
os.environ.get('NO_CHANGE_TIMEOUT_SECONDS', 10)
@@ -260,21 +267,15 @@ class ActionExecutor:
max_memory_mb=self.max_memory_gb * 1024 if self.max_memory_gb else None,
)
else:
bash_session = BashSession(
work_dir=cwd or self._initial_cwd,
self.bash_session = BashSession(
work_dir=self._initial_cwd,
username=self.username,
no_change_timeout_seconds=int(
os.environ.get('NO_CHANGE_TIMEOUT_SECONDS', 10)
),
max_memory_mb=self.max_memory_gb * 1024 if self.max_memory_gb else None,
)
bash_session.initialize()
return bash_session
async def ainit(self):
# bash needs to be initialized first
logger.debug('Initializing bash session')
self.bash_session = self._create_bash_session()
self.bash_session.initialize()
logger.debug('Bash session initialized')
# Start browser initialization in the background
@@ -387,11 +388,18 @@ class ActionExecutor:
self, action: CmdRunAction
) -> CmdOutputObservation | ErrorObservation:
try:
bash_session = self.bash_session
if action.is_static:
bash_session = self._create_bash_session(action.cwd)
assert bash_session is not None
obs = await call_sync_from_async(bash_session.execute, action)
path = action.cwd or self._initial_cwd
result = await AsyncBashSession.execute(action.command, path)
obs = CmdOutputObservation(
content=result.content,
exit_code=result.exit_code,
command=action.command,
)
return obs
assert self.bash_session is not None
obs = await call_sync_from_async(self.bash_session.execute, action)
return obs
except Exception as e:
logger.error(f'Error running command: {e}')
@@ -467,7 +475,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 +485,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 +501,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 +534,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 +658,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 +680,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 +828,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 +842,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 +860,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 +919,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 +932,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

@@ -400,7 +400,7 @@ class Runtime(FileEditRuntimeMixin):
'No repository selected. Initializing a new git repository in the workspace.'
)
action = CmdRunAction(
command=f'git init && git config --global --add safe.directory {self.workspace_root}'
command='git init',
)
self.run_action(action)
else:
@@ -952,9 +952,6 @@ fi
exit_code = 0
content = ''
if isinstance(obs, ErrorObservation):
exit_code = -1
if hasattr(obs, 'exit_code'):
exit_code = obs.exit_code
if hasattr(obs, 'content'):

View File

@@ -406,7 +406,7 @@ class ActionExecutionClient(Runtime):
'POST',
f'{self.action_execution_server_url}/update_mcp_server',
json=stdio_tools,
timeout=60,
timeout=10,
)
result = response.json()
if response.status_code != 200:
@@ -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,
)
)
@@ -464,13 +464,16 @@ class ActionExecutionClient(Runtime):
)
# Create clients for this specific operation
mcp_clients = await create_mcp_clients(
updated_mcp_config.sse_servers, updated_mcp_config.shttp_servers, self.sid
)
mcp_clients = await create_mcp_clients(updated_mcp_config.sse_servers, updated_mcp_config.shttp_servers, self.sid)
# Call the tool and return the result
# No need for try/finally since disconnect() is now just resetting state
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

@@ -0,0 +1,54 @@
import asyncio
import os
from openhands.runtime.base import CommandResult
class AsyncBashSession:
@staticmethod
async def execute(command: str, work_dir: str) -> CommandResult:
"""Execute a command in the bash session asynchronously."""
work_dir = os.path.abspath(work_dir)
if not os.path.exists(work_dir):
raise ValueError(f'Work directory {work_dir} does not exist.')
command = command.strip()
if not command:
return CommandResult(content='', exit_code=0)
try:
process = await asyncio.subprocess.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=work_dir,
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(), timeout=30
)
output = stdout.decode('utf-8')
if stderr:
output = stderr.decode('utf-8')
print(f'!##! Error running command: {stderr.decode("utf-8")}')
return CommandResult(content=output, exit_code=process.returncode or 0)
except asyncio.TimeoutError:
process.terminate()
# Allow a brief moment for cleanup
try:
await asyncio.wait_for(process.wait(), timeout=1.0)
except asyncio.TimeoutError:
process.kill() # Force kill if it doesn't terminate cleanly
return CommandResult(content='Command timed out.', exit_code=-1)
except Exception as e:
return CommandResult(
content=f'Error running command: {str(e)}', exit_code=-1
)

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

@@ -44,7 +44,7 @@ class GitHandler:
Returns:
bool: True if inside a Git repository, otherwise False.
"""
cmd = 'git --no-pager rev-parse --is-inside-work-tree'
cmd = 'git rev-parse --is-inside-work-tree'
output = self.execute(cmd, self.cwd)
return output.content.strip() == 'true'
@@ -71,7 +71,7 @@ class GitHandler:
Returns:
bool: True if the reference exists, otherwise False.
"""
cmd = f'git --no-pager rev-parse --verify {ref}'
cmd = f'git rev-parse --verify {ref}'
output = self.execute(cmd, self.cwd)
return output.exit_code == 0
@@ -86,9 +86,9 @@ class GitHandler:
default_branch = self._get_default_branch()
ref_current_branch = f'origin/{current_branch}'
ref_non_default_branch = f'$(git --no-pager merge-base HEAD "$(git --no-pager rev-parse --abbrev-ref origin/{default_branch})")'
ref_non_default_branch = f'$(git merge-base HEAD "$(git rev-parse --abbrev-ref origin/{default_branch})")'
ref_default_branch = 'origin/' + default_branch
ref_new_repo = '$(git --no-pager rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)' # compares with empty tree
ref_new_repo = '$(git rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)' # compares with empty tree
refs = [
ref_current_branch,
@@ -116,7 +116,7 @@ class GitHandler:
if not ref:
return ''
cmd = f'git --no-pager show {ref}:{file_path}'
cmd = f'git show {ref}:{file_path}'
output = self.execute(cmd, self.cwd)
return output.content if output.exit_code == 0 else ''
@@ -127,7 +127,7 @@ class GitHandler:
Returns:
str: The name of the primary branch.
"""
cmd = 'git --no-pager remote show origin | grep "HEAD branch"'
cmd = 'git remote show origin | grep "HEAD branch"'
output = self.execute(cmd, self.cwd)
return output.content.split()[-1].strip()
@@ -138,7 +138,7 @@ class GitHandler:
Returns:
str: The name of the current branch.
"""
cmd = 'git --no-pager rev-parse --abbrev-ref HEAD'
cmd = 'git rev-parse --abbrev-ref HEAD'
output = self.execute(cmd, self.cwd)
return output.content.strip()
@@ -153,12 +153,8 @@ class GitHandler:
if not ref:
return []
diff_cmd = f'git --no-pager diff --name-status {ref}'
diff_cmd = f'git diff --name-status {ref}'
output = self.execute(diff_cmd, self.cwd)
if output.exit_code != 0:
raise RuntimeError(
f'Failed to get diff for ref {ref} in {self.cwd}. Command output: {output.content}'
)
return output.content.splitlines()
def _get_untracked_files(self) -> list[dict[str, str]]:
@@ -168,7 +164,7 @@ class GitHandler:
Returns:
list[dict[str, str]]: A list of dictionaries containing file paths and statuses.
"""
cmd = 'git --no-pager ls-files --others --exclude-standard'
cmd = 'git ls-files --others --exclude-standard'
output = self.execute(cmd, self.cwd)
obs_list = output.content.splitlines()
return (

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-') :]
@@ -280,45 +281,13 @@ class DockerNestedConversationManager(ConversationManager):
raise ValueError('unsupported_operation')
async def close_session(self, sid: str):
# First try to graceful stop server.
try:
container = self.docker_client.containers.get(f'openhands-runtime-{sid}')
except docker.errors.NotFound:
return
try:
nested_url = self.get_nested_url_for_container(container)
async with httpx.AsyncClient(
headers={
'X-Session-API-Key': self._get_session_api_key_for_conversation(sid)
}
) as client:
# Stop conversation
response = await client.post(
f'{nested_url}/api/conversations/{sid}/stop'
)
response.raise_for_status()
stop_all_containers(f'openhands-runtime-{sid}')
# 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)})
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 +365,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 +407,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 +439,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 +485,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

@@ -369,9 +369,7 @@ class StandaloneConversationManager(ConversationManager):
f'removing connections: {connection_ids_to_remove}',
extra={'session_id': sid},
)
# Perform a graceful shutdown of each connection
for connection_id in connection_ids_to_remove:
await self.sio.disconnect(connection_id)
self._local_connection_id_to_session_id.pop(connection_id, None)
session = self._local_agent_loops_by_sid.pop(sid, None)

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

View File

@@ -12,7 +12,6 @@ from openhands.events.action import (
)
from openhands.events.action.agent import RecallAction
from openhands.events.async_event_store_wrapper import AsyncEventStoreWrapper
from openhands.events.event_store import EventStore
from openhands.events.observation import (
NullObservation,
)
@@ -125,48 +124,6 @@ async def connect(connection_id: str, environ: dict) -> None:
f'User {user_id} is allowed to connect to conversation {conversation_id}'
)
try:
event_store = EventStore(
conversation_id, conversation_manager.file_store, user_id
)
except FileNotFoundError as e:
logger.error(
f'Failed to create EventStore for conversation {conversation_id}: {e}'
)
raise ConnectionRefusedError(f'Failed to access conversation events: {e}')
logger.info(
f'Replaying event stream for conversation {conversation_id} with connection_id {connection_id}...'
)
agent_state_changed = None
# Create an async store to replay events
async_store = AsyncEventStoreWrapper(event_store, latest_event_id + 1)
# Process all available events
async for event in async_store:
logger.debug(f'oh_event: {event.__class__.__name__}')
if isinstance(
event,
(NullAction, NullObservation, RecallAction),
):
continue
elif isinstance(event, AgentStateChangedObservation):
agent_state_changed = event
else:
await sio.emit('oh_event', event_to_dict(event), to=connection_id)
# Send the agent state changed event last if we have one
if agent_state_changed:
await sio.emit(
'oh_event', event_to_dict(agent_state_changed), to=connection_id
)
logger.info(
f'Finished replaying event stream for conversation {conversation_id}'
)
conversation_init_data = await setup_init_convo_settings(
user_id, conversation_id, providers_set
)
@@ -176,12 +133,32 @@ async def connect(connection_id: str, environ: dict) -> None:
conversation_init_data,
user_id,
)
logger.info(
f'Connected to conversation {conversation_id} with connection_id {connection_id}. Replaying event stream...'
)
agent_state_changed = None
if agent_loop_info is None:
raise ConnectionRefusedError('Failed to join conversation')
async_store = AsyncEventStoreWrapper(
agent_loop_info.event_store, latest_event_id + 1
)
async for event in async_store:
logger.debug(f'oh_event: {event.__class__.__name__}')
if isinstance(
event,
(NullAction, NullObservation, RecallAction),
):
continue
elif isinstance(event, AgentStateChangedObservation):
agent_state_changed = event
else:
await sio.emit('oh_event', event_to_dict(event), to=connection_id)
if agent_state_changed:
await sio.emit(
'oh_event', event_to_dict(agent_state_changed), to=connection_id
)
logger.info(
f'Successfully joined conversation {conversation_id} with connection_id {connection_id}'
f'Finished replaying event stream for conversation {conversation_id}'
)
except ConnectionRefusedError:
# Close the broken connection after sending an error message

View File

@@ -0,0 +1,62 @@
from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import JSONResponse
from openhands.core.logger import openhands_logger as logger
from openhands.events.async_event_store_wrapper import AsyncEventStoreWrapper
from openhands.events.serialization import event_to_dict
from openhands.server.data_models.feedback import FeedbackDataModel, store_feedback
from openhands.server.dependencies import get_dependencies
from openhands.server.utils import get_conversation
from openhands.utils.async_utils import call_sync_from_async
from openhands.server.session.conversation import ServerConversation
app = APIRouter(prefix='/api/conversations/{conversation_id}', dependencies=get_dependencies())
@app.post('/submit-feedback')
async def submit_feedback(request: Request, conversation: ServerConversation = Depends(get_conversation)) -> JSONResponse:
"""Submit user feedback.
This function stores the provided feedback data.
To submit feedback:
```sh
curl -X POST -d '{"email": "test@example.com"}' -H "Authorization:"
```
Args:
request (Request): The incoming request object.
feedback (FeedbackDataModel): The feedback data to be stored.
Returns:
dict: The stored feedback data.
Raises:
HTTPException: If there's an error submitting the feedback.
"""
# Assuming the storage service is already configured in the backend
# and there is a function to handle the storage.
body = await request.json()
async_store = AsyncEventStoreWrapper(
conversation.event_stream, filter_hidden=True
)
trajectory = []
async for event in async_store:
trajectory.append(event_to_dict(event))
feedback = FeedbackDataModel(
email=body.get('email', ''),
version=body.get('version', ''),
permissions=body.get('permissions', 'private'),
polarity=body.get('polarity', ''),
feedback=body.get('polarity', ''),
trajectory=trajectory,
)
try:
feedback_data = await call_sync_from_async(store_feedback, feedback)
return JSONResponse(status_code=status.HTTP_200_OK, content=feedback_data)
except Exception as e:
logger.error(f'Error submitting feedback: {e}')
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={'error': 'Failed to submit feedback'},
)

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