Compare commits

...

28 Commits

Author SHA1 Message Date
openhands c5f31b643e Resolve merge conflict in MCP Proxy Manager 2025-06-10 15:51:28 +00:00
Rene Leonhardt 07862c32cb chore(docker): update docker base images (#8796)
Co-authored-by: Xingyao Wang <xingyaoww@gmail.com>
2025-06-10 22:48:46 +08:00
Emmanuel Ferdman e04f876df9 Migrate to modern logger interface in server utils (#8965)
Signed-off-by: Emmanuel Ferdman <emmanuelferdman@gmail.com>
2025-06-10 10:25:06 -04:00
Mislav Lukach 78d707de83 chore(billing): add stripe powered by (#9016)
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-06-10 18:10:09 +04:00
sp.wack 058153292f fix(ui): startup message ui (#9007) 2025-06-10 16:50:18 +04:00
dependabot[bot] 53b5e08804 chore(deps): bump the version-all group across 1 directory with 15 updates (#9027)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-09 18:04:14 -04:00
Ray Myers 7cee7dca64 chore - log size of large events (#9024) 2025-06-09 16:47:40 -05:00
mamoodi e12a62d006 Update GUI docs (#9020) 2025-06-09 15:38:44 -04:00
llamantino 77a0c5e073 feat: increase requests timeout to 60s (#8974)
Co-authored-by: llamantino <12345678+yourusername@users.noreply.github.com>
2025-06-09 12:42:03 -06:00
Tim O'Farrell e5d21e003d Added environment variable allowing skipping dependency checks (#9010) 2025-06-09 11:14:39 -06:00
mamoodi c6a4324bda Update Cloud API docs (#9008) 2025-06-09 11:42:37 -04:00
Tim O'Farrell 9ac8f011fe Converted exponential backoff to fixed (#9006) 2025-06-09 09:02:52 -06:00
Leander Maben d84befe28f Adding LLM Based Editing capability (#8677)
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
Co-authored-by: Engel Nyst <engel.nyst@gmail.com>
2025-06-09 21:57:20 +08:00
mamoodi 4eef22e04e Fix some broken links (#9005) 2025-06-09 13:37:00 +00:00
Graham Neubig 93e6811efc Add CLI option to bug template installation dropdown (#9002)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-06-09 08:49:47 -04:00
Graham Neubig 3ebe3c2140 Update CLI mode documentation to recommend pip install (#8967)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-06-09 08:13:09 -04:00
Xingyao Wang d6d5499416 refactor(MCP): Replace MCPRouter with FastMCP Proxy (#8877)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-06-08 22:03:18 +00:00
Tim O'Farrell 0221f21c12 Wait for nested container graceful shutdown (#8969) 2025-06-08 13:43:34 -06:00
Tim O'Farrell 617445d5ca Nested event store search no longer throwing errors on 404 (#8985) 2025-06-08 13:41:58 -06:00
Xingyao Wang 5730db327a Merge branch 'main' into docs/update-runtime-testing-documentation 2025-06-07 13:45:52 -04:00
Xingyao Wang 24a03738a8 Merge branch 'main' into docs/update-runtime-testing-documentation 2025-06-05 17:21:21 -04:00
Xingyao Wang 3a093c13b8 revert stuff 2025-06-05 17:20:55 -04:00
openhands e367ced954 docs: update runtime testing documentation and environment setup 2025-06-05 21:18:05 +00:00
Xingyao Wang 4c2c1bd8eb Merge branch 'main' into refactor-mcprouter-to-fastmcp 2025-06-05 15:09:19 -04:00
openhands 20b1f37fbd Fix test_default_activated_tools to match new MCP config structure 2025-06-03 19:13:16 +00:00
openhands 2bae39ef30 Refactor MCP Proxy implementation to use MCPProxyManager
- Created new MCPProxyManager class to encapsulate proxy functionality
- Removed file-based configuration in favor of in-memory configuration
- Updated action_execution_server.py to use the new MCPProxyManager
- Added comprehensive documentation for the new module
- Improved error handling and logging
2025-06-03 18:32:47 +00:00
Xingyao Wang 9f8bcf8729 Merge commit 'a348840534dc10f20e57e80b6a80a08ac7a030f0' into refactor-mcprouter-to-fastmcp 2025-06-03 14:16:13 -04:00
openhands 137eec4dd1 Refactor MCPRouter to use FastMCP Proxy 2025-06-02 20:02:12 +00:00
54 changed files with 1153 additions and 781 deletions
+22 -4
View File
@@ -1,5 +1,23 @@
# NodeJS
frontend/node_modules
config.toml
.envrc
.env
.git
# Configuration (except pyproject.toml)
*.ini
*.toml
!pyproject.toml
*.yml
# Documentation (except README.md)
*.md
!README.md
# Hidden files and directories
.*
__pycache__
# Unneded files and directories
/dev_config/
/docs/
/evaluation/
/tests/
CITATION.cff
+1
View File
@@ -33,6 +33,7 @@ body:
- Docker command in README
- GitHub resolver
- Development workflow
- CLI
- app.all-hands.dev
- Other
default: 0
+6 -1
View File
@@ -16,7 +16,6 @@ updates:
mcp-packages:
patterns:
- "mcp"
- "mcpm"
security-all:
applies-to: "security-updates"
patterns:
@@ -73,3 +72,9 @@ updates:
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "docker"
directories:
- "containers/*"
schedule:
interval: "weekly"
+90 -7
View File
@@ -2,8 +2,6 @@ This repository contains the code for OpenHands, an automated AI software engine
(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.
IMPORTANT: Before making any changes to the codebase, ALWAYS run `make install-pre-commit-hooks` to ensure pre-commit hooks are properly installed.
@@ -21,13 +19,91 @@ then re-run the command to ensure it passes. Common issues include:
- Trailing whitespace
- Missing newlines at end of files
## Testing and Debugging
### Environment Setup for Testing
- Run `make build` to install all dependencies (only necessary for running tests):
```bash
make build
```
**IMPORTANT**: When using `execute_bash` to run `make build` or similar long-running commands, set the `timeout` parameter to a high value (e.g., 600 seconds):
```
execute_bash(command="make build", timeout=600)
```
#### Docker Installation
**NOTE: Docker installation is ONLY required for running runtime tests with the Docker runtime.**
- Install Docker on Debian-based systems:
```bash
sudo apt-get update
sudo apt-get install -y apt-transport-https ca-certificates curl gnupg lsb-release
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io
```
- Start Docker daemon (in container environments without systemd):
```bash
sudo dockerd > /tmp/docker.log 2>&1 & sleep 5
```
- Verify Docker installation:
```bash
sudo docker run hello-world
```
#### Development Environment Setup
- Before running `make run`, ensure netcat is installed:
```bash
sudo apt-get install -y netcat-openbsd
```
### Unit Tests
- All unit 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
### Runtime Tests
- Runtime tests are in `tests/runtime/test_*.py`
- Run tests with different runtime implementations by setting the `TEST_RUNTIME` environment variable:
```bash
# Use Docker runtime (default)
DEBUG=1 poetry run pytest -vvxss tests/runtime/test_bash.py
# Use CLI runtime (more reliable in some environments)
DEBUG=1 TEST_RUNTIME=cli poetry run pytest -vvxss tests/runtime/test_bash.py
# Run a specific test
DEBUG=1 TEST_RUNTIME=cli poetry run pytest -vvxss tests/runtime/test_bash.py::test_bash_server
```
- **IMPORTANT**: Runtime tests can take a long time to run, especially when building Docker images. Set a high timeout value:
```
execute_bash(command="DEBUG=1 poetry run pytest -vvxss tests/runtime/test_bash.py", timeout=600)
```
- The `DEBUG=1` flag enables more verbose logging
- The `-vvxss` flags make the test output more verbose and stop after the first failure
### Debugging Docker Issues
- Check Docker container status:
```bash
sudo docker ps -a
```
- View Docker logs:
```bash
sudo docker logs <container_id>
```
- Check Docker daemon logs:
```bash
sudo cat /tmp/docker.log | tail -n 100
```
- Check OpenHands logs:
```bash
cat logs/openhands_*.log | grep -i error | tail -n 20
```
## 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
@@ -50,6 +126,13 @@ Frontend:
If you are starting a pull request (PR), please follow the template in `.github/pull_request_template.md`.
## Runtime Architecture
- OpenHands uses a Docker-based runtime for secure execution of agent actions
- The runtime builds a custom Docker image based on a specified base image
- The image includes OpenHands-specific code and the runtime client
- The runtime client executes actions in the sandboxed environment and returns observations
- More details in the [runtime architecture documentation](https://docs.all-hands.dev/usage/architecture/runtime)
## Implementation Details
These details may or may not be useful for your current task.
@@ -80,4 +163,4 @@ These details may or may not be useful for your current task.
- Add the translation key to `frontend/src/i18n/declaration.ts`
2. Add the setting to the backend:
- Add the setting to the `Settings` model in `openhands/storage/data_models/settings.py`
- Update any relevant backend code to apply the setting (e.g., in session creation)
- Update any relevant backend code to apply the setting (e.g., in session creation)
+11 -15
View File
@@ -1,16 +1,16 @@
ARG OPENHANDS_BUILD_VERSION=dev
FROM node:21.7.2-bookworm-slim AS frontend-builder
FROM node:22.16.0-bookworm-slim AS frontend-builder
WORKDIR /app
COPY ./frontend/package.json frontend/package-lock.json ./
RUN npm install -g npm@10.5.1
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY ./frontend ./
COPY frontend ./
RUN npm run build
FROM python:3.12.3-slim AS backend-builder
FROM python:3.12.10-slim AS base
FROM base AS backend-builder
WORKDIR /app
ENV PYTHONPATH='/app'
@@ -22,17 +22,18 @@ ENV POETRY_NO_INTERACTION=1 \
RUN apt-get update -y \
&& apt-get install -y curl make git build-essential \
&& python3 -m pip install poetry==1.8.2 --break-system-packages
&& python3 -m pip install poetry --break-system-packages
COPY ./pyproject.toml ./poetry.lock ./
COPY pyproject.toml poetry.lock ./
RUN touch README.md
RUN export POETRY_CACHE_DIR && poetry install --no-root && rm -rf $POETRY_CACHE_DIR
FROM python:3.12.3-slim AS openhands-app
FROM base AS openhands-app
WORKDIR /app
ARG OPENHANDS_BUILD_VERSION #re-declare for this section
# re-declare for this section
ARG OPENHANDS_BUILD_VERSION
ENV RUN_AS_OPENHANDS=true
# A random number--we need this to be different from the user's UID on the host machine
@@ -74,12 +75,7 @@ COPY --chown=openhands:app --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${V
COPY --chown=openhands:app --chmod=770 ./microagents ./microagents
COPY --chown=openhands:app --chmod=770 ./openhands ./openhands
COPY --chown=openhands:app --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
COPY --chown=openhands:app --chmod=770 ./openhands/agenthub ./openhands/agenthub
COPY --chown=openhands:app ./pyproject.toml ./pyproject.toml
COPY --chown=openhands:app ./poetry.lock ./poetry.lock
COPY --chown=openhands:app ./README.md ./README.md
COPY --chown=openhands:app ./MANIFEST.in ./MANIFEST.in
COPY --chown=openhands:app ./LICENSE ./LICENSE
COPY --chown=openhands:app pyproject.toml poetry.lock README.md MANIFEST.in LICENSE ./
# This is run as "openhands" user, and will create __pycache__ with openhands:openhands ownership
RUN python openhands/core/download.py # No-op to download assets
+1 -1
View File
@@ -42,7 +42,7 @@
]
},
{
"group": "Running OpenHands Locally",
"group": "Running OpenHands on Your Own",
"pages": [
"usage/local-setup",
"usage/how-to/gui-mode",
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

+79 -85
View File
@@ -1,9 +1,11 @@
---
title: Cloud API
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.
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.
---
For more detailed information about the API, refer to the [OpenHands API Reference](https://docs.all-hands.dev/swagger-ui/).
For the available API endpoints, refer to the
[OpenHands API Reference](https://docs.all-hands.dev/api-reference).
## Obtaining an API Key
@@ -16,7 +18,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/docs/api-key-generation.png)
![API Key Generation](/static/img/api-key-generation.png)
## API Usage
@@ -33,87 +35,81 @@ To start a new conversation with OpenHands to perform a task, you'll need to mak
#### Examples
<details>
<summary>cURL</summary>
```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="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>
<details>
<summary>Python (with requests)</summary>
<Accordion title="Python (with requests)">
```python
import requests
```python
import requests
api_key = "YOUR_API_KEY"
url = "https://app.all-hands.dev/api/conversations"
api_key = "YOUR_API_KEY"
url = "https://app.all-hands.dev/api/conversations"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
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']}")
```
</details>
<details>
<summary>TypeScript/JavaScript (with fetch)</summary>
```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);
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
}
startConversation();
```
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"
}
</details>
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}`,
"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>
#### Response
@@ -145,14 +141,12 @@ GET https://app.all-hands.dev/api/conversations/{conversation_id}
#### Example
<details>
<summary>cURL</summary>
```bash
curl -X GET "https://app.all-hands.dev/api/conversations/{conversation_id}" \
-H "Authorization: Bearer YOUR_API_KEY"
```
</details>
<Accordion title="cURL">
```bash
curl -X GET "https://app.all-hands.dev/api/conversations/{conversation_id}" \
-H "Authorization: Bearer YOUR_API_KEY"
```
</Accordion>
#### Response
+1 -1
View File
@@ -26,7 +26,7 @@ The Settings page allows you to:
## Key Features
For an overview of the key features available inside a conversation, please refer to the [Key Features](../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.
## Next Steps
+20 -5
View File
@@ -1,24 +1,39 @@
---
title: CLI Mode
description: CLI mode provides a powerful interactive Command-Line Interface (CLI) that lets you engage with OpenHands directly from your terminal.
title: CLI
description: The Command-Line Interface (CLI) provides a powerful interface that lets you engage with OpenHands
directly from your terminal.
---
This mode is different from the [headless mode](./headless-mode), which is non-interactive and better for scripting.
This mode is different from the [headless mode](/usage/how-to/headless-mode), which is non-interactive and better
for scripting.
## Getting Started
### Running with Python
1. Ensure you have followed the [Development setup instructions](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md).
1. Install OpenHands using pip:
```bash
pip install openhands-ai
```
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
poetry run python -m openhands.cli.main
openhands
```
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:
+1 -1
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/installation#start-the-app), replace
When running OpenHands using [the docker command](/usage/local-setup#start-the-app), replace
`-e SANDBOX_RUNTIME_CONTAINER_IMAGE=...` with `-e SANDBOX_BASE_CONTAINER_IMAGE=<custom image name>`:
```commandline
+1 -1
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](https://docs.all-hands.dev/modules/usage/how-to/custom-sandbox-guide)) | `OPENHANDS_BASE_CONTAINER_IMAGE="custom_image"` |
| `OPENHANDS_BASE_CONTAINER_IMAGE` | Variable | Custom Sandbox ([learn more](/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"` |
+46 -46
View File
@@ -1,14 +1,13 @@
---
title: GUI Mode
description: OpenHands provides a Graphical User Interface (GUI) mode for interacting with the AI assistant.
title: GUI
description: High level overview of the Graphical User Interface (GUI) in OpenHands.
---
## Installation and Setup
## Prerequisites
1. Follow the installation instructions to install OpenHands.
2. After running the command, access OpenHands at [http://localhost:3000](http://localhost:3000).
- [OpenHands is running](/usage/local-setup)
## Interacting with the GUI
## Overview
### Initial Setup
@@ -19,16 +18,23 @@ description: OpenHands provides a Graphical User Interface (GUI) mode for intera
3. Enter the corresponding `API Key` for your chosen provider.
4. Click `Save Changes` to apply the settings.
### Version Control Tokens
### Settings
OpenHands supports multiple version control providers. You can configure tokens for multiple providers simultaneously.
You can use the Settings page at any time to:
#### GitHub Token Setup
- 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
OpenHands automatically exports a `GITHUB_TOKEN` to the shell environment if provided:
<details>
<summary>Setting Up a GitHub Token</summary>
<AccordionGroup>
<Accordion title="Setting Up a GitHub Token">
1. **Generate a Personal Access Token (PAT)**:
- On GitHub, go to Settings > Developer Settings > Personal Access Tokens > Tokens (classic).
@@ -37,16 +43,11 @@ 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**:
- Click the Settings button (gear icon).
- Navigate to the `Git` tab.
- In the Settings page, 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:
@@ -59,15 +60,12 @@ 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.
</details>
<details>
<summary>Troubleshooting</summary>
</Accordion>
<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.
@@ -81,15 +79,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.
</details>
</Accordion>
</AccordionGroup>
#### GitLab Token Setup
#### GitLab Setup
OpenHands automatically exports a `GITLAB_TOKEN` to the shell environment if provided:
<details>
<summary>Setting Up a GitLab Token</summary>
<AccordionGroup>
<Accordion title="Setting Up a GitLab Token">
1. **Generate a Personal Access Token (PAT)**:
- On GitLab, go to User Settings > Access Tokens.
- Create a new token with the following scopes:
@@ -99,15 +97,12 @@ 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**:
- Click the Settings button (gear icon).
- Navigate to the `Git` tab.
- In the Settings page, navigate to the `Git` tab.
- Paste your token in the `GitLab Token` field.
- Click `Save Changes` to apply the changes.
</details>
<details>
<summary>Troubleshooting</summary>
</Accordion>
<Accordion title="Troubleshooting">
Common issues and solutions:
- **Token Not Recognized**:
@@ -119,25 +114,30 @@ 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.
</details>
</Accordion>
</AccordionGroup>
### Advanced Settings
#### Advanced 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.
The `Advanced` settings allows configuration of additional LLM settings. Inside the Settings page, under the `LLM` tab,
toggle `Advanced` options to access additional settings.
### Interacting with the AI
- 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.
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.
### 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.
## 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).
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.
## 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)
+4 -3
View File
@@ -1,9 +1,10 @@
---
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.
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.
---
This is different from [CLI Mode](./cli-mode), which is interactive, and better for active development.
This is different from [the CLI](./cli-mode), which is interactive, and better for active development.
## With Python
+2 -2
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 Locally
## Running OpenHands on Your Own
Run OpenHands on your local system and bring your own LLM and API key.
For more information see [running OpenHands locally.](/usage/local-setup)
For more information see [running OpenHands on your own.](/usage/local-setup)
+1 -1
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](https://docs.all-hands.dev/modules/usage/installation) to make sure you have all the prerequisites for running OpenHands.
Check [the installation guide](/usage/local-setup) 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
+4 -5
View File
@@ -1,6 +1,6 @@
---
title: Getting Started
description: Getting started with running OpenHands locally.
description: Getting started with running OpenHands on your own.
---
## 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.40-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.41-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.40-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.41-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.40
docker.all-hands.dev/all-hands-ai/openhands:0.41
```
You'll find OpenHands running at http://localhost:3000!
@@ -132,7 +132,6 @@ 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
+18
View File
@@ -74,6 +74,24 @@ If no condenser configuration is specified, the 'noop' condenser will be used by
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.
+7 -2
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,6 +62,7 @@ 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']
@@ -254,15 +255,19 @@ 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=False,
enable_llm_editor=ENABLE_LLM_EDITOR,
enable_mcp=False,
condenser=metadata.condenser_config,
enable_prompt_extensions=False,
@@ -4,7 +4,6 @@ import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import type { Message } from "#/message";
import { SUGGESTIONS } from "#/utils/suggestions";
import { WsClientProviderStatus } from "#/context/ws-client-provider";
import { ChatInterface } from "#/components/features/chat/chat-interface";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -19,7 +18,7 @@ describe("Empty state", () => {
const { useWsClient: useWsClientMock } = vi.hoisted(() => ({
useWsClient: vi.fn(() => ({
send: sendMock,
status: WsClientProviderStatus.CONNECTED,
status: "CONNECTED",
isLoadingMessages: false,
})),
}));
@@ -64,7 +63,7 @@ describe("Empty state", () => {
// this is to test that the message is in the UI before the socket is called
useWsClientMock.mockImplementation(() => ({
send: sendMock,
status: WsClientProviderStatus.CONNECTED,
status: "CONNECTED",
isLoadingMessages: false,
}));
const user = userEvent.setup();
@@ -87,7 +86,7 @@ describe("Empty state", () => {
async () => {
useWsClientMock.mockImplementation(() => ({
send: sendMock,
status: WsClientProviderStatus.CONNECTED,
status: "CONNECTED",
isLoadingMessages: false,
}));
const user = userEvent.setup();
@@ -101,7 +100,7 @@ describe("Empty state", () => {
useWsClientMock.mockImplementation(() => ({
send: sendMock,
status: WsClientProviderStatus.CONNECTED,
status: "CONNECTED",
isLoadingMessages: false,
}));
rerender(<ChatInterface />);
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" style="enable-background:new 0 0 468 222.5" version="1.1" viewBox="0 0 468 222.5">
<style>
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#635bff}
</style>
<path d="M414 113.4c0-25.6-12.4-45.8-36.1-45.8-23.8 0-38.2 20.2-38.2 45.6 0 30.1 17 45.3 41.4 45.3 11.9 0 20.9-2.7 27.7-6.5v-20c-6.8 3.4-14.6 5.5-24.5 5.5-9.7 0-18.3-3.4-19.4-15.2h48.9c0-1.3.2-6.5.2-8.9zm-49.4-9.5c0-11.3 6.9-16 13.2-16 6.1 0 12.6 4.7 12.6 16h-25.8zM301.1 67.6c-9.8 0-16.1 4.6-19.6 7.8l-1.3-6.2h-22v116.6l25-5.3.1-28.3c3.6 2.6 8.9 6.3 17.7 6.3 17.9 0 34.2-14.4 34.2-46.1-.1-29-16.6-44.8-34.1-44.8zm-6 68.9c-5.9 0-9.4-2.1-11.8-4.7l-.1-37.1c2.6-2.9 6.2-4.9 11.9-4.9 9.1 0 15.4 10.2 15.4 23.3 0 13.4-6.2 23.4-15.4 23.4zM223.8 61.7l25.1-5.4V36l-25.1 5.3zM223.8 69.3h25.1v87.5h-25.1zM196.9 76.7l-1.6-7.4h-21.6v87.5h25V97.5c5.9-7.7 15.9-6.3 19-5.2v-23c-3.2-1.2-14.9-3.4-20.8 7.4zM146.9 47.6l-24.4 5.2-.1 80.1c0 14.8 11.1 25.7 25.9 25.7 8.2 0 14.2-1.5 17.5-3.3V135c-3.2 1.3-19 5.9-19-8.9V90.6h19V69.3h-19l.1-21.7zM79.3 94.7c0-3.9 3.2-5.4 8.5-5.4 7.6 0 17.2 2.3 24.8 6.4V72.2c-8.3-3.3-16.5-4.6-24.8-4.6C67.5 67.6 54 78.2 54 95.9c0 27.6 38 23.2 38 35.1 0 4.6-4 6.1-9.6 6.1-8.3 0-18.9-3.4-27.3-8v23.8c9.3 4 18.7 5.7 27.3 5.7 20.8 0 35.1-10.3 35.1-28.2-.1-29.8-38.2-24.5-38.2-35.7z" class="st0"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -9,10 +9,7 @@ import {
AGENT_STATUS_MAP,
IndicatorColor,
} from "../../agent-status-map.constant";
import {
useWsClient,
WsClientProviderStatus,
} from "#/context/ws-client-provider";
import { useWsClient } from "#/context/ws-client-provider";
import { useNotification } from "#/hooks/useNotification";
import { browserTab } from "#/utils/browser-tab";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
@@ -80,10 +77,13 @@ export function AgentStatusBar() {
);
React.useEffect(() => {
if (conversation?.status === "STARTING") {
if (conversation?.status === "CONNECTING") {
setStatusMessage(t(I18nKey.STATUS$CONNECTING_TO_RUNTIME));
setIndicatorColor(IndicatorColor.YELLOW);
} else if (conversation?.status === "STARTING") {
setStatusMessage(t(I18nKey.STATUS$STARTING_RUNTIME));
setIndicatorColor(IndicatorColor.RED);
} else if (status === WsClientProviderStatus.DISCONNECTED) {
} else if (status === "DISCONNECTED") {
setStatusMessage(t(I18nKey.STATUS$WEBSOCKET_CLOSED));
setIndicatorColor(IndicatorColor.RED);
} else {
@@ -2,9 +2,20 @@ import ColdIcon from "./state-indicators/cold.svg?react";
import RunningIcon from "./state-indicators/running.svg?react";
type SVGIcon = React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
export type ProjectStatus = "RUNNING" | "STOPPED" | "STARTING";
export type ProjectStatus =
| "RUNNING"
| "STOPPED"
| "STARTING"
| "CONNECTING"
| "CONNECTED"
| "DISCONNECTED";
const INDICATORS: Record<ProjectStatus, SVGIcon> = {
type ProjectStatusWithIcon = Exclude<
ProjectStatus,
"CONNECTING" | "CONNECTED" | "DISCONNECTED"
>;
const INDICATORS: Record<ProjectStatusWithIcon, SVGIcon> = {
STOPPED: ColdIcon,
RUNNING: RunningIcon,
STARTING: ColdIcon,
@@ -17,6 +28,7 @@ interface ConversationStateIndicatorProps {
export function ConversationStateIndicator({
status,
}: ConversationStateIndicatorProps) {
// @ts-expect-error - Type 'ProjectStatus' is not assignable to type 'ProjectStatusWithIcon'.
const StateIcon = INDICATORS[status];
return (
@@ -9,6 +9,7 @@ import { BrandButton } from "../settings/brand-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { amountIsValid } from "#/utils/amount-is-valid";
import { I18nKey } from "#/i18n/declaration";
import { PoweredByStripeTag } from "./powered-by-stripe-tag";
export function PaymentForm() {
const { t } = useTranslation();
@@ -79,6 +80,7 @@ export function PaymentForm() {
{t(I18nKey.PAYMENT$ADD_CREDIT)}
</BrandButton>
{isPending && <LoadingSpinner size="small" />}
<PoweredByStripeTag />
</div>
</div>
</form>
@@ -0,0 +1,16 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import stripeLogo from "#/assets/stripe.svg";
export function PoweredByStripeTag() {
const { t } = useTranslation();
return (
<div className="flex flex-row items-center">
<span className="text-medium font-semi-bold">
{t(I18nKey.BILLING$POWERED_BY)}
</span>
<img src={stripeLogo} alt="Stripe" className="h-8" />
</div>
);
}
@@ -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/installation#getting-an-api-key"
href="https://docs.all-hands.dev/usage/local-setup#getting-an-api-key"
/>
</div>
+8 -15
View File
@@ -28,6 +28,7 @@ import {
} from "#/types/core/guards";
import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
import { useWSErrorMessage } from "#/hooks/use-ws-error-message";
import { ProjectStatus } from "#/components/features/conversation-panel/conversation-state-indicator";
const hasValidMessageProperty = (obj: unknown): obj is { message: string } =>
typeof obj === "object" &&
@@ -67,14 +68,8 @@ const isMessageAction = (
): event is UserMessageAction | AssistantMessageAction =>
isUserMessage(event) || isAssistantMessage(event);
export enum WsClientProviderStatus {
CONNECTED,
DISCONNECTED,
CONNECTING,
}
interface UseWsClient {
status: WsClientProviderStatus;
status: ProjectStatus;
isLoadingMessages: boolean;
events: Record<string, unknown>[];
parsedEvents: (OpenHandsAction | OpenHandsObservation)[];
@@ -82,7 +77,7 @@ interface UseWsClient {
}
const WsClientContext = React.createContext<UseWsClient>({
status: WsClientProviderStatus.DISCONNECTED,
status: "DISCONNECTED",
isLoadingMessages: true,
events: [],
parsedEvents: [],
@@ -139,9 +134,7 @@ export function WsClientProvider({
const { setErrorMessage, removeErrorMessage } = useWSErrorMessage();
const queryClient = useQueryClient();
const sioRef = React.useRef<Socket | null>(null);
const [status, setStatus] = React.useState(
WsClientProviderStatus.DISCONNECTED,
);
const [status, setStatus] = React.useState<ProjectStatus>("CONNECTING");
const [events, setEvents] = React.useState<Record<string, unknown>[]>([]);
const [parsedEvents, setParsedEvents] = React.useState<
(OpenHandsAction | OpenHandsObservation)[]
@@ -162,7 +155,7 @@ export function WsClientProvider({
}
function handleConnect() {
setStatus(WsClientProviderStatus.CONNECTED);
setStatus("CONNECTED");
removeErrorMessage();
}
@@ -261,7 +254,7 @@ export function WsClientProvider({
}
function handleDisconnect(data: unknown) {
setStatus(WsClientProviderStatus.DISCONNECTED);
setStatus("DISCONNECTED");
const sio = sioRef.current;
if (!sio) {
return;
@@ -275,7 +268,7 @@ export function WsClientProvider({
function handleError(data: unknown) {
// set status
setStatus(WsClientProviderStatus.DISCONNECTED);
setStatus("DISCONNECTED");
updateStatusWhenErrorMessagePresent(data);
setErrorMessage(
@@ -294,7 +287,7 @@ export function WsClientProvider({
// reset events when conversationId changes
setEvents([]);
setParsedEvents([]);
setStatus(WsClientProviderStatus.DISCONNECTED);
setStatus("CONNECTING");
}, [conversationId]);
React.useEffect(() => {
@@ -1,9 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import React from "react";
import {
useWsClient,
WsClientProviderStatus,
} from "#/context/ws-client-provider";
import { useWsClient } from "#/context/ws-client-provider";
import { useConversationId } from "#/hooks/use-conversation-id";
import OpenHands from "#/api/open-hands";
@@ -17,7 +14,7 @@ export const useConversationConfig = () => {
if (!conversationId) throw new Error("No conversation ID");
return OpenHands.getRuntimeId(conversationId);
},
enabled: status !== WsClientProviderStatus.DISCONNECTED && !!conversationId,
enabled: status !== "DISCONNECTED" && !!conversationId,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
+11
View File
@@ -1,5 +1,15 @@
// this file generate by script, don't modify it manually!!!
export enum I18nKey {
MICROAGENT$NO_REPOSITORY_FOUND = "MICROAGENT$NO_REPOSITORY_FOUND",
MICROAGENT$ADD_TO_MICROAGENT = "MICROAGENT$ADD_TO_MICROAGENT",
MICROAGENT$WHAT_TO_ADD = "MICROAGENT$WHAT_TO_ADD",
MICROAGENT$WHERE_TO_PUT = "MICROAGENT$WHERE_TO_PUT",
MICROAGENT$ADD_TRIGGER = "MICROAGENT$ADD_TRIGGER",
MICROAGENT$WAIT_FOR_RUNTIME = "MICROAGENT$WAIT_FOR_RUNTIME",
MICROAGENT$ADDING_CONTEXT = "MICROAGENT$ADDING_CONTEXT",
MICROAGENT$VIEW_CONVERSATION = "MICROAGENT$VIEW_CONVERSATION",
MICROAGENT$SUCCESS_PR_READY = "MICROAGENT$SUCCESS_PR_READY",
STATUS$CONNECTING_TO_RUNTIME = "STATUS$CONNECTING_TO_RUNTIME",
STATUS$WEBSOCKET_CLOSED = "STATUS$WEBSOCKET_CLOSED",
HOME$LAUNCH_FROM_SCRATCH = "HOME$LAUNCH_FROM_SCRATCH",
HOME$READ_THIS = "HOME$READ_THIS",
@@ -470,6 +480,7 @@ export enum I18nKey {
BILLING$YOUVE_GOT_50 = "BILLING$YOUVE_GOT_50",
BILLING$ERROR_WHILE_CREATING_SESSION = "BILLING$ERROR_WHILE_CREATING_SESSION",
BILLING$CLAIM_YOUR_50 = "BILLING$CLAIM_YOUR_50",
BILLING$POWERED_BY = "BILLING$POWERED_BY",
BILLING$PROCEED_TO_STRIPE = "BILLING$PROCEED_TO_STRIPE",
BILLING$YOURE_IN = "BILLING$YOURE_IN",
PAYMENT$ADD_FUNDS = "PAYMENT$ADD_FUNDS",
+32
View File
@@ -5855,6 +5855,22 @@
"tr": "Konuşmalar",
"uk": "Розмови"
},
"STATUS$CONNECTING_TO_RUNTIME": {
"en": "Connecting to runtime...",
"zh-CN": "正在连接到运行时...",
"zh-TW": "正在連接到執行時...",
"de": "Verbinde mit der Laufzeitumgebung...",
"ko-KR": "런타임에 연결 중...",
"no": "Kobler til kjøretidsmiljø...",
"it": "Connessione all'ambiente di esecuzione in corso...",
"pt": "Conectando ao ambiente de execução...",
"es": "Conectando al entorno de ejecución...",
"ar": "جارٍ الاتصال ببيئة التشغيل...",
"fr": "Connexion à l'environnement d'exécution en cours...",
"tr": "Çalışma zamanı ortamına bağlanılıyor...",
"ja": "ランタイムに接続中",
"uk": "Підключення до середовища виконання..."
},
"STATUS$STARTING_RUNTIME": {
"en": "Starting runtime...",
"zh-CN": "启动运行时...",
@@ -7519,6 +7535,22 @@
"de": "Fügen Sie eine Kreditkarte mit Stripe hinzu, um $50 zu erhalten. <b>Wir belasten Sie nicht ohne vorherige Zustimmung!</b>",
"uk": "Додайте кредитну картку до Stripe, щоб отримати свої 50 доларів. <b>Ми не стягуватимемо з вас плату без попереднього запиту!</b>"
},
"BILLING$POWERED_BY": {
"en": "Powered by",
"ja": "提供:",
"zh-CN": "技术支持:",
"zh-TW": "技術支援:",
"ko-KR": "제공: ",
"no": "Drevet av",
"it": "Offerto da",
"pt": "Oferecido por",
"es": "Ofrecido por",
"ar": "مشغل بواسطة",
"fr": "Propulsé par",
"tr": "Tarafından desteklenmektedir",
"de": "Bereitgestellt von",
"uk": "Працює на базі"
},
"BILLING$PROCEED_TO_STRIPE": {
"en": "Add Billing Info",
"ja": "請求情報を追加",
+2 -2
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/installation#getting-an-api-key"
href="https://docs.all-hands.dev/usage/local-setup#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/installation#getting-an-api-key"
href="https://docs.all-hands.dev/usage/local-setup#getting-an-api-key"
/>
<SettingsInput
+2 -2
View File
@@ -12,7 +12,7 @@ export const TIPS: Tip[] = [
},
{
key: I18nKey.TIPS$SETUP_SCRIPT,
link: "https://docs.all-hands.dev/usage/customization/repository",
link: "https://docs.all-hands.dev/usage/prompting/repository#setup-script",
},
{ key: I18nKey.TIPS$VSCODE_INSTANCE },
{ key: I18nKey.TIPS$SAVE_WORK },
@@ -38,7 +38,7 @@ export const TIPS: Tip[] = [
},
{
key: I18nKey.TIPS$API_USAGE,
link: "https://docs.all-hands.dev/swagger-ui/",
link: "https://docs.all-hands.dev/api-reference/health-check",
},
];
-1
View File
@@ -37,5 +37,4 @@ 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)
@@ -141,6 +141,9 @@ 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
@@ -2,10 +2,18 @@ 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 `# unchanged` 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 `# ... existing code ...` 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:
@@ -33,13 +41,12 @@ 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):
# no changes before
# ... existing code ...
self.y = 2
# self.z is removed
# MyClass().z is removed
print(MyClass().y)
```
@@ -58,6 +65,7 @@ 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)
```
@@ -93,9 +101,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=1001 end=1008
path="/path/to/file.txt" start=1002 end=1008
content=```
class MyClass:
#EDIT: I want to change the value of y to 2
def __init__(self):
# no changes before
self.y = 2
+4
View File
@@ -3,6 +3,7 @@ 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
@@ -42,6 +43,9 @@ 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)
+12 -3
View File
@@ -186,9 +186,18 @@ class EventStream(EventStore):
if event.id is not None:
# Write the event to the store - this can take some time
self.file_store.write(
self._get_filename_for_id(event.id, self.user_id), json.dumps(data)
)
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)
# 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)
+59
View File
@@ -22,6 +22,7 @@ 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,
)
@@ -251,6 +252,58 @@ 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': """
@@ -279,6 +332,8 @@ 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 ''
@@ -297,6 +352,8 @@ 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']
@@ -309,6 +366,8 @@ 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']
+1
View File
@@ -4,3 +4,4 @@ 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'
+50 -102
View File
@@ -9,7 +9,6 @@ import argparse
import asyncio
import base64
import json
import logging
import mimetypes
import os
import shutil
@@ -26,8 +25,6 @@ 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
@@ -37,6 +34,7 @@ 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 (
@@ -63,20 +61,18 @@ from openhands.events.serialization import event_from_dict, event_to_dict
from openhands.runtime.browser import browse
from openhands.runtime.browser.browser_env import BrowserEnv
from openhands.runtime.file_viewer_server import start_file_viewer_server
# Import our custom MCP Proxy Manager
from openhands.runtime.mcp.proxy import MCPProxyManager
from openhands.runtime.plugins import ALL_PLUGINS, JupyterPlugin, Plugin, VSCodePlugin
from openhands.runtime.utils import find_available_tcp_port
from openhands.runtime.utils.bash import BashSession
from openhands.runtime.utils.files import insert_lines, read_lines
from openhands.runtime.utils.log_capture import capture_logs
from openhands.runtime.utils.memory_monitor import MemoryMonitor
from openhands.runtime.utils.runtime_init import init_user_and_working_directory
from openhands.runtime.utils.system_stats import get_system_stats
from openhands.utils.async_utils import call_sync_from_async, wait_all
# Set MCP router logger to the same level as the main logger
mcp_router_logger.setLevel(logger.getEffectiveLevel())
if sys.platform == 'win32':
from openhands.runtime.utils.windows_bash import WindowsPowershellSession
@@ -471,7 +467,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: # noqa: ASYNC101
with open(filepath, 'rb') as file:
image_data = file.read()
encoded_image = base64.b64encode(image_data).decode('utf-8')
mime_type, _ = mimetypes.guess_type(filepath)
@@ -481,13 +477,13 @@ class ActionExecutor:
return FileReadObservation(path=filepath, content=encoded_image)
elif filepath.lower().endswith('.pdf'):
with open(filepath, 'rb') as file: # noqa: ASYNC101
with open(filepath, 'rb') as file:
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: # noqa: ASYNC101
with open(filepath, 'rb') as file:
video_data = file.read()
encoded_video = base64.b64encode(video_data).decode('utf-8')
mime_type, _ = mimetypes.guess_type(filepath)
@@ -497,7 +493,7 @@ class ActionExecutor:
return FileReadObservation(path=filepath, content=encoded_video)
with open(filepath, 'r', encoding='utf-8') as file: # noqa: ASYNC101
with open(filepath, 'r', encoding='utf-8') as file:
lines = read_lines(file.readlines(), action.start, action.end)
except FileNotFoundError:
return ErrorObservation(
@@ -530,7 +526,7 @@ class ActionExecutor:
mode = 'w' if not file_exists else 'r+'
try:
with open(filepath, mode, encoding='utf-8') as file: # noqa: ASYNC101
with open(filepath, mode, encoding='utf-8') as file:
if mode != 'w':
all_lines = file.readlines()
new_file = insert_lines(insert, all_lines, action.start, action.end)
@@ -654,14 +650,11 @@ if __name__ == '__main__':
plugins_to_load.append(ALL_PLUGINS[plugin]()) # type: ignore
client: ActionExecutor | None = None
mcp_router: MCPRouter | None = None
MCP_ROUTER_PROFILE_PATH = os.path.join(
os.path.dirname(__file__), 'mcp', 'config.json'
)
mcp_proxy_manager: MCPProxyManager | None = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global client, mcp_router
global client, mcp_proxy_manager
logger.info('Initializing ActionExecutor...')
client = ActionExecutor(
plugins_to_load,
@@ -676,63 +669,36 @@ if __name__ == '__main__':
# Check if we're on Windows
is_windows = sys.platform == 'win32'
# Initialize and mount MCP Router (skip on Windows)
# Initialize and mount MCP Proxy Manager (skip on Windows)
if is_windows:
logger.info('Skipping MCP Router initialization on Windows')
mcp_router = None
logger.info('Skipping MCP Proxy initialization on Windows')
mcp_proxy_manager = None
else:
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),
),
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(),
)
mcp_proxy_manager.initialize()
# Mount the proxy to the app
allowed_origins = ['*']
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}')
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}')
yield
# Clean up & release the resources
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)
logger.info('Shutting down MCP Proxy Manager...')
if mcp_proxy_manager:
del mcp_proxy_manager
mcp_proxy_manager = None
else:
logger.info('MCP Router instance not found for shutdown.')
logger.info('MCP Proxy Manager instance not found for shutdown.')
logger.info('Closing ActionExecutor...')
if client:
@@ -824,6 +790,9 @@ 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(
@@ -838,17 +807,10 @@ if __name__ == '__main__':
)
# Non-Windows implementation
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)
if mcp_proxy_manager is None:
raise HTTPException(
status_code=500, detail='MCP Proxy Manager is not initialized'
)
# Get the request body
mcp_tools_to_sync = await request.json()
@@ -856,31 +818,17 @@ 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 to: {json.dumps(mcp_tools_to_sync, indent=2)}.\nPrevious profile: {json.dumps(current_profile, indent=2)}'
f'Updating MCP server with tools: {json.dumps(mcp_tools_to_sync, indent=2)}'
)
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}')
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)
return JSONResponse(
status_code=200,
@@ -915,7 +863,7 @@ if __name__ == '__main__':
)
zip_path = os.path.join(full_dest_path, file.filename)
with open(zip_path, 'wb') as buffer: # noqa: ASYNC101
with open(zip_path, 'wb') as buffer:
shutil.copyfileobj(file.file, buffer)
# Extract the zip file
@@ -928,7 +876,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: # noqa: ASYNC101
with open(file_path, 'wb') as buffer:
shutil.copyfileobj(file.file, buffer)
logger.debug(f'Uploaded file {file.filename} to {destination}')
@@ -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('/') + '/sse',
url=self.action_execution_server_url.rstrip('/') + '/mcp/sse',
api_key=self.session_api_key,
)
)
@@ -307,8 +307,10 @@ 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
check_dependencies(code_repo_path, env_root_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)
self.server_process = subprocess.Popen( # noqa: S603
cmd,
stdout=subprocess.PIPE,
@@ -406,8 +408,8 @@ class LocalRuntime(ActionExecutionClient):
return port
@tenacity.retry(
wait=tenacity.wait_exponential(min=1, max=10),
stop=tenacity.stop_after_attempt(10) | stop_if_should_exit(),
wait=tenacity.wait_fixed(2),
stop=tenacity.stop_after_delay(120) | stop_if_should_exit(),
before_sleep=lambda retry_state: logger.debug(
f'Waiting for server to be ready... (attempt {retry_state.attempt_number})'
),
+4 -1
View File
@@ -1,3 +1,6 @@
{
"default": []
"mcpServers": {
"default": {}
},
"tools": []
}
+71
View File
@@ -0,0 +1,71 @@
# 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.
+7
View File
@@ -0,0 +1,7 @@
"""
MCP Proxy module for OpenHands.
"""
from openhands.runtime.mcp.proxy.manager import MCPProxyManager
__all__ = ['MCPProxyManager']
+131
View File
@@ -0,0 +1,131 @@
"""
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:
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(
'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('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('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('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
stdio_servers: List of stdio server 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)
+17 -26
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
from openhands_aci.utils.diff import get_diff # type: ignore
from openhands.core.config import OpenHandsConfig
from openhands.core.logger import openhands_logger as logger
@@ -26,39 +26,31 @@ 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 = """
HERE IS THE OLD VERSION OF THE FILE:
```
{old_contents}
```
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 DRAFT OF THE NEW VERSION OF THE FILE:
```
{draft_changes}
```
Within the `<updated_code>` tag, include only the final code after updation. Do not include any explanations or other content within these tags.
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()
<original_code>{old_contents}</original_code>
<update_snippet>{draft_changes}</update_snippet>
"""
def _extract_code(string: str) -> str | None:
pattern = r'```(?:\w*\n)?(.*?)```'
pattern = r'<updated_code>(.*?)</updated_code>'
matches = re.findall(pattern, string, re.DOTALL)
if not matches:
return None
return str(matches[0])
content = str(matches[0])
if content.startswith('#EDIT:'):
#Remove first line
content = content[content.find('\n') + 1:]
return content
def get_new_file_contents(
@@ -66,7 +58,6 @@ def get_new_file_contents(
) -> str | None:
while num_retries > 0:
messages = [
{'role': 'system', 'content': SYS_MSG},
{
'role': 'user',
'content': USER_MSG.format(
+1 -1
View File
@@ -38,7 +38,7 @@ def send_request(
session: HttpSession,
method: str,
url: str,
timeout: int = 10,
timeout: int = 60,
**kwargs: Any,
) -> httpx.Response:
response = session.request(method, url, timeout=timeout, **kwargs)
@@ -22,7 +22,6 @@ 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 (
@@ -90,7 +89,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-') :]
@@ -284,7 +283,7 @@ class DockerNestedConversationManager(ConversationManager):
# First try to graceful stop server.
try:
container = self.docker_client.containers.get(f'openhands-runtime-{sid}')
except docker.errors.NotFound as e:
except docker.errors.NotFound:
return
try:
nested_url = self.get_nested_url_for_container(container)
@@ -293,17 +292,33 @@ class DockerNestedConversationManager(ConversationManager):
'X-Session-API-Key': self._get_session_api_key_for_conversation(sid)
}
) as client:
response = await client.post(f'{nested_url}/api/conversations/{sid}/stop')
# Stop conversation
response = await client.post(
f'{nested_url}/api/conversations/{sid}/stop'
)
response.raise_for_status()
except Exception:
logger.exception("error_stopping_container")
# 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:
@@ -381,7 +396,9 @@ 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(
@@ -423,7 +440,9 @@ 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(
@@ -455,8 +474,9 @@ 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'] = f'/workspace'
env_vars['WORKSPACE_BASE'] = '/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
@@ -501,7 +521,7 @@ class DockerNestedConversationManager(ConversationManager):
await call_sync_from_async(container.start)
return True
return False
except docker.errors.NotFound as e:
except docker.errors.NotFound:
return False
+1 -1
View File
@@ -26,7 +26,7 @@ async def get_conversation(
conversation_id, user_id
)
if not conversation:
logger.warn(
logger.warning(
f'get_conversation: conversation {conversation_id} not found, attach_to_conversation returned None',
extra={'session_id': conversation_id, 'user_id': user_id},
)
Generated
+299 -393
View File
File diff suppressed because one or more lines are too long
+5 -6
View File
@@ -36,7 +36,7 @@ numpy = "*"
json-repair = "*"
browsergym-core = "0.13.3" # integrate browsergym-core as the browsing interface
html2text = "*"
e2b = ">=1.0.5,<1.4.0"
e2b = ">=1.0.5,<1.6.0"
pexpect = "*"
jinja2 = "^3.1.3"
python-multipart = "*"
@@ -53,7 +53,7 @@ protobuf = "^5.0.0,<6.0.0" # Updated to support newer op
opentelemetry-api = "^1.33.1"
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
modal = ">=0.66.26,<0.78.0"
runloop-api-client = "0.33.0"
runloop-api-client = "0.39.0"
libtmux = ">=0.37,<0.40"
pygithub = "^2.5.0"
joblib = "*"
@@ -67,7 +67,6 @@ poetry = "^2.1.2"
anyio = "4.9.0"
pythonnet = "*"
fastmcp = "^2.5.2"
mcpm = "1.12.0"
python-frontmatter = "^1.1.0"
# TODO: Should these go into the runtime group?
ipywidgets = "^8.1.5"
@@ -81,7 +80,7 @@ bashlex = "^0.18"
# TODO: These are integrations that should probably be optional
redis = ">=5.2,<7.0"
minio = "^7.2.8"
daytona-sdk = "0.18.1"
daytona-sdk = "0.20.0"
stripe = ">=11.5,<13.0"
google-cloud-aiplatform = "*"
anthropic = { extras = [ "vertex" ], version = "*" }
@@ -91,8 +90,8 @@ boto3 = "*"
optional = true
[tool.poetry.group.dev.dependencies]
ruff = "0.11.11"
mypy = "1.15.0"
ruff = "0.11.13"
mypy = "1.16.0"
pre-commit = "4.2.0"
build = "*"
types-setuptools = "*"
+13 -5
View File
@@ -114,9 +114,11 @@ def test_default_activated_tools():
)
with open(mcp_config_path, 'r') as f:
mcp_config = json.load(f)
assert 'default' in mcp_config
assert 'mcpServers' in mcp_config
assert 'default' in mcp_config['mcpServers']
assert 'tools' in mcp_config
# no tools are always activated yet
assert len(mcp_config['default']) == 0
assert len(mcp_config['tools']) == 0
@pytest.mark.asyncio
@@ -249,7 +251,11 @@ async def test_both_stdio_and_sse_mcp(
assert obs_cat.exit_code == 0
mcp_action_fetch = MCPAction(
name='fetch', arguments={'url': 'http://localhost:8000'}
# NOTE: the tool name is `fetch_fetch` because the tool name is `fetch`
# And FastMCP Proxy will pre-pend the server name (in this case, `fetch`)
# to the tool name, so the full tool name becomes `fetch_fetch`
name='fetch',
arguments={'url': 'http://localhost:8000'},
)
obs_fetch = await runtime.call_tool_mcp(mcp_action_fetch)
logger.info(obs_fetch, extra={'msg_type': 'OBSERVATION'})
@@ -304,7 +310,9 @@ async def test_microagent_and_one_stdio_mcp_in_config(
logger.info(f'updated_config: {updated_config}')
# ======= Test the stdio server in the config =======
mcp_action_sse = MCPAction(name='list_directory', arguments={'path': '/'})
mcp_action_sse = MCPAction(
name='filesystem_list_directory', arguments={'path': '/'}
)
obs_sse = await runtime.call_tool_mcp(mcp_action_sse)
logger.info(obs_sse, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs_sse, MCPObservation), (
@@ -332,7 +340,7 @@ async def test_microagent_and_one_stdio_mcp_in_config(
assert obs_cat.exit_code == 0
mcp_action_fetch = MCPAction(
name='fetch', arguments={'url': 'http://localhost:8000'}
name='fetch_fetch', arguments={'url': 'http://localhost:8000'}
)
obs_fetch = await runtime.call_tool_mcp(mcp_action_fetch)
logger.info(obs_fetch, extra={'msg_type': 'OBSERVATION'})