mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d295552a2f | |||
| 17d47e963a | |||
| 0e22b3f8dd | |||
| b65bc3c5ed | |||
| 98ad453493 | |||
| 4eef22e04e | |||
| bdec589ea3 | |||
| cf67b98387 | |||
| 93e6811efc | |||
| a6833d8d32 | |||
| 3ebe3c2140 | |||
| d6d5499416 | |||
| 0221f21c12 | |||
| 617445d5ca | |||
| 34c13c8824 | |||
| 49939c1f02 | |||
| abec074a66 | |||
| 46c12ce258 | |||
| 5de119dc2e | |||
| 0abc6f27ef | |||
| 445d3a5788 | |||
| 744a6299a7 | |||
| 345dccbf84 | |||
| 6605269e5b | |||
| fac0d59388 | |||
| 4d6d28a192 | |||
| ebacd1b080 | |||
| 59f5f0dc9b | |||
| 4df3ee9d2e | |||
| aa54a25241 | |||
| 0813c113f0 | |||
| 19fcf427ba | |||
| 336b22bea4 | |||
| 959268b45a | |||
| 309c086976 | |||
| afd8ee61e7 | |||
| 93b1276768 | |||
| 412e265745 | |||
| a3790f1003 | |||
| b76553136e | |||
| dee89462c2 | |||
| ad468587ea | |||
| 41cee4b68d | |||
| 91e24a4a31 | |||
| a1b3c0c7d6 | |||
| 738ecd468c | |||
| c6c2aafc4f | |||
| 7bea93b1b6 | |||
| d346506d34 | |||
| d30c6ff720 | |||
| 80e496d134 | |||
| a933a81ef5 | |||
| 3c977bd715 | |||
| c403973616 | |||
| 7652ccb000 |
@@ -12,4 +12,5 @@
|
||||
"ghcr.io/devcontainers/features/node:1": {},
|
||||
},
|
||||
"postCreateCommand": ".devcontainer/setup.sh",
|
||||
"runArgs": ["--network=host"],
|
||||
}
|
||||
|
||||
Regular → Executable
+1
-1
@@ -5,7 +5,7 @@
|
||||
/frontend/ @rbren @amanape
|
||||
|
||||
# Evaluation code owners
|
||||
/evaluation/ @xingyaoww @neubig
|
||||
/evaluation/ @xingyaoww @neubig
|
||||
|
||||
# Documentation code owners
|
||||
/docs/ @mamoodi
|
||||
|
||||
@@ -33,6 +33,7 @@ body:
|
||||
- Docker command in README
|
||||
- GitHub resolver
|
||||
- Development workflow
|
||||
- CLI
|
||||
- app.all-hands.dev
|
||||
- Other
|
||||
default: 0
|
||||
|
||||
@@ -16,7 +16,6 @@ updates:
|
||||
mcp-packages:
|
||||
patterns:
|
||||
- "mcp"
|
||||
- "mcpm"
|
||||
security-all:
|
||||
applies-to: "security-updates"
|
||||
patterns:
|
||||
|
||||
@@ -313,6 +313,8 @@ jobs:
|
||||
TEST_IN_CI=true \
|
||||
RUN_AS_OPENHANDS=false \
|
||||
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
|
||||
env:
|
||||
DEBUG: "1"
|
||||
|
||||
# Run unit tests with the Docker runtime Docker images as openhands user
|
||||
test_runtime_oh:
|
||||
@@ -378,6 +380,8 @@ jobs:
|
||||
TEST_IN_CI=true \
|
||||
RUN_AS_OPENHANDS=true \
|
||||
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
|
||||
env:
|
||||
DEBUG: "1"
|
||||
|
||||
# The two following jobs (named identically) are to check whether all the runtime tests have passed as the
|
||||
# "All Runtime Tests Passed" is a required job for PRs to merge
|
||||
|
||||
@@ -74,5 +74,11 @@ jobs:
|
||||
run: poetry install --with dev,test,runtime
|
||||
- name: Run Windows unit tests
|
||||
run: poetry run pytest -svv tests/unit/test_windows_bash.py
|
||||
env:
|
||||
DEBUG: "1"
|
||||
- name: Run Windows runtime tests with LocalRuntime
|
||||
run: $env:TEST_RUNTIME="local"; poetry run pytest -svv tests/runtime/test_bash.py
|
||||
env:
|
||||
TEST_RUNTIME: local
|
||||
DEBUG: "1"
|
||||
|
||||
|
||||
@@ -166,7 +166,6 @@ cython_debug/
|
||||
# https://stackoverflow.com/questions/32964920/should-i-commit-the-vscode-folder-to-source-control
|
||||
.vscode/**/*
|
||||
!.vscode/extensions.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
|
||||
|
||||
@@ -37,7 +37,8 @@ repos:
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies:
|
||||
[types-requests, types-setuptools, types-pyyaml, types-toml]
|
||||
[types-requests, types-setuptools, types-pyyaml, types-toml, types-docker, lxml]
|
||||
# To see gaps add `--html-report mypy-report/`
|
||||
entry: mypy --config-file dev_config/python/mypy.ini openhands/
|
||||
always_run: true
|
||||
pass_filenames: false
|
||||
|
||||
+79
-79
@@ -20,7 +20,7 @@
|
||||
"navigation": {
|
||||
"tabs": [
|
||||
{
|
||||
"tab": "Getting started",
|
||||
"tab": "Docs",
|
||||
"pages": [
|
||||
"index",
|
||||
"usage/installation",
|
||||
@@ -31,116 +31,116 @@
|
||||
"pages": [
|
||||
"usage/cloud/openhands-cloud",
|
||||
{
|
||||
"group": "Installation",
|
||||
"group": "Integrations",
|
||||
"pages": [
|
||||
"usage/cloud/github-installation",
|
||||
"usage/cloud/gitlab-installation"
|
||||
]
|
||||
},
|
||||
"usage/cloud/cloud-ui",
|
||||
"usage/cloud/cloud-issue-resolver",
|
||||
"usage/cloud/cloud-api"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Usage Methods",
|
||||
"group": "Running OpenHands on Your Own",
|
||||
"pages": [
|
||||
"usage/local-setup",
|
||||
"usage/how-to/gui-mode",
|
||||
"usage/how-to/cli-mode",
|
||||
"usage/how-to/headless-mode",
|
||||
"usage/how-to/github-action"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "Prompting and Customization",
|
||||
"pages": [
|
||||
"usage/prompting/prompting-best-practices",
|
||||
"usage/prompting/repository",
|
||||
},
|
||||
{
|
||||
"group": "Microagents",
|
||||
"group": "Customization",
|
||||
"pages": [
|
||||
"usage/prompting/microagents-overview",
|
||||
"usage/prompting/microagents-repo",
|
||||
"usage/prompting/microagents-keyword",
|
||||
"usage/prompting/microagents-org",
|
||||
"usage/prompting/microagents-public"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "Advanced Configuration",
|
||||
"pages": [
|
||||
{
|
||||
"group": "LLM Configuration",
|
||||
"pages": [
|
||||
"usage/llms/llms",
|
||||
"usage/prompting/prompting-best-practices",
|
||||
"usage/prompting/repository",
|
||||
{
|
||||
"group": "Providers",
|
||||
"group": "Microagents",
|
||||
"pages": [
|
||||
"usage/llms/azure-llms",
|
||||
"usage/llms/google-llms",
|
||||
"usage/llms/groq",
|
||||
"usage/llms/local-llms",
|
||||
"usage/llms/litellm-proxy",
|
||||
"usage/llms/openai-llms",
|
||||
"usage/llms/openrouter"
|
||||
"usage/prompting/microagents-overview",
|
||||
"usage/prompting/microagents-repo",
|
||||
"usage/prompting/microagents-keyword",
|
||||
"usage/prompting/microagents-org",
|
||||
"usage/prompting/microagents-public"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Runtime Configuration",
|
||||
"group": "Advanced Configuration",
|
||||
"pages": [
|
||||
"usage/runtimes/overview",
|
||||
{
|
||||
"group": "Providers",
|
||||
"pages": [
|
||||
"usage/runtimes/docker",
|
||||
"usage/runtimes/remote",
|
||||
"usage/runtimes/local",
|
||||
{
|
||||
"group": "Third-Party Providers",
|
||||
"pages": [
|
||||
"usage/runtimes/modal",
|
||||
"usage/runtimes/daytona",
|
||||
"usage/runtimes/runloop",
|
||||
"usage/runtimes/e2b"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
{
|
||||
"group": "LLM Configuration",
|
||||
"pages": [
|
||||
"usage/llms/llms",
|
||||
{
|
||||
"group": "Providers",
|
||||
"pages": [
|
||||
"usage/llms/azure-llms",
|
||||
"usage/llms/google-llms",
|
||||
"usage/llms/groq",
|
||||
"usage/llms/local-llms",
|
||||
"usage/llms/litellm-proxy",
|
||||
"usage/llms/openai-llms",
|
||||
"usage/llms/openrouter"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Runtime Configuration",
|
||||
"pages": [
|
||||
"usage/runtimes/overview",
|
||||
{
|
||||
"group": "Providers",
|
||||
"pages": [
|
||||
"usage/runtimes/docker",
|
||||
"usage/runtimes/remote",
|
||||
"usage/runtimes/local",
|
||||
{
|
||||
"group": "Third-Party Providers",
|
||||
"pages": [
|
||||
"usage/runtimes/modal",
|
||||
"usage/runtimes/daytona",
|
||||
"usage/runtimes/runloop",
|
||||
"usage/runtimes/e2b"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"usage/configuration-options",
|
||||
"usage/how-to/custom-sandbox-guide",
|
||||
"usage/search-engine-setup",
|
||||
"usage/mcp"
|
||||
]
|
||||
},
|
||||
"usage/configuration-options",
|
||||
"usage/how-to/custom-sandbox-guide",
|
||||
"usage/search-engine-setup",
|
||||
"usage/mcp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "Troubleshooting & Feedback",
|
||||
"pages": [
|
||||
"usage/troubleshooting/troubleshooting",
|
||||
"usage/feedback"
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "For OpenHands Developers",
|
||||
"pages": [
|
||||
"usage/how-to/development-overview",
|
||||
{
|
||||
"group": "Architecture",
|
||||
"group": "Troubleshooting & Feedback",
|
||||
"pages": [
|
||||
"usage/architecture/backend",
|
||||
"usage/architecture/runtime"
|
||||
"usage/troubleshooting/troubleshooting",
|
||||
"usage/feedback"
|
||||
]
|
||||
},
|
||||
"usage/how-to/debugging",
|
||||
"usage/how-to/evaluation-harness",
|
||||
"usage/how-to/websocket-connection"
|
||||
{
|
||||
"group": "OpenHands Developers",
|
||||
"pages": [
|
||||
"usage/how-to/development-overview",
|
||||
{
|
||||
"group": "Architecture",
|
||||
"pages": [
|
||||
"usage/architecture/backend",
|
||||
"usage/architecture/runtime"
|
||||
]
|
||||
},
|
||||
"usage/how-to/debugging",
|
||||
"usage/how-to/evaluation-harness",
|
||||
"usage/how-to/websocket-connection"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -1,33 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,28 +1,36 @@
|
||||
---
|
||||
title: 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.
|
||||
description: The Cloud UI provides a web interface for interacting with OpenHands. This page explains how to use the
|
||||
OpenHands Cloud UI.
|
||||
---
|
||||
|
||||
## Landing Page
|
||||
|
||||
## Accessing the UI
|
||||
The landing page is where you can:
|
||||
|
||||
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.
|
||||
|
||||
|
||||
## Key Features
|
||||
|
||||
For detailed information about the features available in the OpenHands Cloud UI, please refer to the [Key Features](../key-features) section of the documentation.
|
||||
- [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`.
|
||||
|
||||
## Settings
|
||||
|
||||
The settings page allows you to:
|
||||
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.
|
||||
- [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](../key-features)
|
||||
section of the documentation.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [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.
|
||||
- [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.
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
---
|
||||
title: GitHub Installation
|
||||
description: This guide walks you through the process of installing and configuring OpenHands Cloud for your GitHub repositories.
|
||||
title: GitHub Integration
|
||||
description: This guide walks you through the process of installing OpenHands Cloud for your GitHub repositories. Once
|
||||
set up, it will allow OpenHands to work with your GitHub repository through the Cloud UI or straight from GitHub!
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A GitHub account
|
||||
- Access to OpenHands Cloud
|
||||
- Signed in to [OpenHands Cloud](https://app.all-hands.dev) with [a GitHub account](/usage/cloud/openhands-cloud).
|
||||
|
||||
## Installation Steps
|
||||
## Adding GitHub Repository Access
|
||||
|
||||
1. Log in to [OpenHands Cloud](https://app.all-hands.dev)
|
||||
2. If you haven't connected your GitHub account yet:
|
||||
- Click on `Connect to GitHub`
|
||||
- Review and accept the terms of service
|
||||
- Authorize the OpenHands AI application
|
||||
You can grant OpenHands access to specific GitHub repositories:
|
||||
|
||||
## Adding Repository Access
|
||||
|
||||
You can grant OpenHands access to specific repositories:
|
||||
|
||||
1. Click on `Add GitHub repos`
|
||||
1. Click on `Add GitHub repos` on the landing page.
|
||||
2. Select your organization and choose the specific repositories to grant OpenHands access to.
|
||||
- OpenHands requests short-lived tokens (8-hour expiration) with these permissions:
|
||||
<Accordion title="OpenHands permissions">
|
||||
- OpenHands requests short-lived tokens (8-hour expiration) with these permissions:
|
||||
- Actions: Read and write
|
||||
- Administration: Read-only
|
||||
- Commit statuses: Read and write
|
||||
- Contents: Read and write
|
||||
- Issues: Read and write
|
||||
@@ -35,20 +27,45 @@ You can grant OpenHands access to specific repositories:
|
||||
- Repository access for a user is granted based on:
|
||||
- Permission granted for the repository
|
||||
- User's GitHub permissions (owner/collaborator)
|
||||
3. Click `Install & Authorize`
|
||||
</Accordion>
|
||||
|
||||
3. Click `Install & Authorize`.
|
||||
|
||||
## Modifying Repository Access
|
||||
|
||||
You can modify repository access at any time by visiting the Settings page and selecting `Configure GitHub Repositories` under the `Git` tab.
|
||||
You can modify GitHub repository access at any time by:
|
||||
- Selecting `Add GitHub repos` on the landing page or
|
||||
- Visiting the Settings page and selecting `Configure GitHub Repositories` under the `Git` tab
|
||||
|
||||
## Using OpenHands with GitHub
|
||||
## Working With GitHub Repos in Openhands Cloud
|
||||
|
||||
Once you've granted repository access, you can use OpenHands with your GitHub repositories.
|
||||
Once you've granted GitHub repository access, you can start working with your GitHub repository. Use the `select a repo`
|
||||
and `select a branch` dropdowns to select the appropriate repository and branch you'd like OpenHands to work on. Then
|
||||
click on `Launch` to start the conversation!
|
||||
|
||||
For details on how to use OpenHands with GitHub issues and pull requests, see the [Cloud Issue Resolver](./cloud-issue-resolver) documentation.
|
||||

|
||||
|
||||
## Working on Github Issues and Pull Requests Using Openhands
|
||||
|
||||
Giving GitHub repository access to OpenHands also allows you to work on GitHub issues and pull requests directly.
|
||||
|
||||
### Working with Issues
|
||||
|
||||
On your repository, label an issue with `openhands` or add a message starting with
|
||||
`@openhands`. OpenHands will:
|
||||
1. Comment on the issue to let you know it is working on it.
|
||||
- You can click on the link to track the progress on OpenHands Cloud.
|
||||
2. Open a pull request if it determines that the issue has been successfully resolved.
|
||||
3. Comment on the issue with a summary of the performed tasks and a link to the PR.
|
||||
|
||||
### Working with Pull Requests
|
||||
|
||||
To get OpenHands to work on pull requests, mention `@openhands` in the comments to:
|
||||
- Ask questions
|
||||
- Request updates
|
||||
- Get code explanations
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Access the Cloud UI](./cloud-ui) to interact with the web interface
|
||||
- [Use the Cloud Issue Resolver](./cloud-issue-resolver) to automate code fixes and get assistance
|
||||
- [Use the Cloud API](./cloud-api) to programmatically interact with OpenHands
|
||||
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
|
||||
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands.
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
---
|
||||
title: GitLab Installation
|
||||
description: This guide walks you through the process of installing and configuring OpenHands Cloud for your GitLab repositories.
|
||||
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.
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A GitLab account
|
||||
- Access to OpenHands Cloud
|
||||
- Signed in to [OpenHands Cloud](https://app.all-hands.dev) with [a GitLab account](/usage/cloud/openhands-cloud).
|
||||
|
||||
## Installation Steps
|
||||
## Adding GitLab Repository Access
|
||||
|
||||
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
|
||||
Upon signing into OpenHands Cloud with a GitLab account, OpenHands will have access to your repositories.
|
||||
|
||||
## 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!
|
||||
|
||||

|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Access the Cloud UI](./cloud-ui) to interact with the web interface
|
||||
- [Use the Cloud API](./cloud-api) to programmatically interact with OpenHands
|
||||
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
|
||||
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands.
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
---
|
||||
title: Getting Started
|
||||
description: Getting started with OpenHands Cloud
|
||||
description: Getting started with OpenHands Cloud.
|
||||
---
|
||||
|
||||
OpenHands Cloud is the hosted cloud version of All Hands AI's OpenHands.
|
||||
|
||||
## Accessing OpenHands Cloud
|
||||
|
||||
To get started with OpenHands Cloud, visit [app.all-hands.dev](https://app.all-hands.dev).
|
||||
OpenHands Cloud is the hosted cloud version of All Hands AI's OpenHands. To get started with OpenHands Cloud,
|
||||
visit [app.all-hands.dev](https://app.all-hands.dev).
|
||||
|
||||
You'll be prompted to connect with your GitHub or GitLab account:
|
||||
|
||||
@@ -15,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](./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
|
||||
- [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.
|
||||
|
||||
@@ -9,16 +9,29 @@ This mode is different from the [headless mode](./headless-mode), which is non-i
|
||||
|
||||
### 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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"` |
|
||||
|
||||
+5
-159
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Quick Start
|
||||
description: Running OpenHands on the cloud or your local desktop
|
||||
description: Running OpenHands Cloud or running on your local system.
|
||||
icon: rocket
|
||||
---
|
||||
|
||||
@@ -10,164 +10,10 @@ The easiest way to get started with OpenHands is on OpenHands Cloud, which comes
|
||||
|
||||
To get started with OpenHands Cloud, visit [app.all-hands.dev](https://app.all-hands.dev).
|
||||
|
||||
You'll be prompted to connect with your GitHub or GitLab account:
|
||||
For more information see [getting started with OpenHands Cloud.](/usage/cloud/openhands-cloud)
|
||||
|
||||
1. Click `Log in with GitHub` or `Log in with GitLab`.
|
||||
2. Review the permissions requested by OpenHands and authorize the application.
|
||||
- OpenHands will require certain permissions from your account. To read more about these permissions,
|
||||
you can click the `Learn more` link on the authorization page.
|
||||
## Running OpenHands Locally
|
||||
|
||||
Run OpenHands on your local system and bring your own LLM and API key.
|
||||
|
||||
Once you've connected your account, you can:
|
||||
|
||||
- [Install GitHub Integration](/usage/cloud/github-installation) to use OpenHands with your GitHub repositories
|
||||
- [Install GitLab Integration](/usage/cloud/gitlab-installation) to use OpenHands with your GitLab repositories
|
||||
- [Access the Cloud UI](/usage/cloud/cloud-ui) to interact with the web interface
|
||||
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands
|
||||
- [Set up the Cloud Issue Resolver](/usage/cloud/cloud-issue-resolver) to automate code fixes and provide intelligent assistance
|
||||
|
||||
|
||||
## Running OpenHands on your local desktop
|
||||
|
||||
### System Requirements
|
||||
|
||||
- MacOS with [Docker Desktop support](https://docs.docker.com/desktop/setup/install/mac-install/#system-requirements)
|
||||
- Linux
|
||||
- Windows with [WSL](https://learn.microsoft.com/en-us/windows/wsl/install) and [Docker Desktop support](https://docs.docker.com/desktop/setup/install/windows-install/#system-requirements)
|
||||
|
||||
A system with a modern processor and a minimum of **4GB RAM** is recommended to run OpenHands.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
<AccordionGroup>
|
||||
|
||||
<Accordion title="MacOS">
|
||||
|
||||
**Docker Desktop**
|
||||
|
||||
1. [Install Docker Desktop on Mac](https://docs.docker.com/desktop/setup/install/mac-install).
|
||||
2. Open Docker Desktop, go to `Settings > Advanced` and ensure `Allow the default Docker socket to be used` is enabled.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Linux">
|
||||
|
||||
<Note>
|
||||
Tested with Ubuntu 22.04.
|
||||
</Note>
|
||||
|
||||
**Docker Desktop**
|
||||
|
||||
1. [Install Docker Desktop on Linux](https://docs.docker.com/desktop/setup/install/linux/).
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Windows">
|
||||
|
||||
**WSL**
|
||||
|
||||
1. [Install WSL](https://learn.microsoft.com/en-us/windows/wsl/install).
|
||||
2. Run `wsl --version` in powershell and confirm `Default Version: 2`.
|
||||
|
||||
**Docker Desktop**
|
||||
|
||||
1. [Install Docker Desktop on Windows](https://docs.docker.com/desktop/setup/install/windows-install).
|
||||
2. Open Docker Desktop, go to `Settings` and confirm the following:
|
||||
- General: `Use the WSL 2 based engine` is enabled.
|
||||
- Resources > WSL Integration: `Enable integration with my default WSL distro` is enabled.
|
||||
|
||||
<Note>
|
||||
The docker command below to start the app must be run inside the WSL terminal.
|
||||
</Note>
|
||||
|
||||
</Accordion>
|
||||
|
||||
</AccordionGroup>
|
||||
|
||||
### Start the App
|
||||
|
||||
The easiest way to run OpenHands is in Docker.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.41-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.41-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.41
|
||||
```
|
||||
|
||||
You'll find OpenHands running at http://localhost:3000!
|
||||
|
||||
You can also [connect OpenHands to your local filesystem](https://docs.all-hands.dev/modules/usage/runtimes/docker#connecting-to-your-filesystem),
|
||||
run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode),
|
||||
interact with it via a [friendly CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),
|
||||
or run it on tagged issues with [a GitHub action](https://docs.all-hands.dev/modules/usage/how-to/github-action).
|
||||
|
||||
### Setup
|
||||
|
||||
After launching OpenHands, you **must** select an `LLM Provider` and `LLM Model` and enter a corresponding `API Key`.
|
||||
This can be done during the initial settings popup or by selecting the `Settings`
|
||||
button (gear icon) in the UI.
|
||||
|
||||
If the required model does not exist in the list, in `Settings` under the `LLM` tab, you can toggle `Advanced` options
|
||||
and manually enter it with the correct prefix in the `Custom Model` text box.
|
||||
The `Advanced` options also allow you to specify a `Base URL` if required.
|
||||
|
||||
#### Getting an API Key
|
||||
|
||||
OpenHands requires an API key to access most language models. Here's how to get an API key from the recommended providers:
|
||||
|
||||
<AccordionGroup>
|
||||
|
||||
<Accordion title="Anthropic (Claude)">
|
||||
|
||||
1. [Create an Anthropic account](https://console.anthropic.com/).
|
||||
2. [Generate an API key](https://console.anthropic.com/settings/keys).
|
||||
3. [Set up billing](https://console.anthropic.com/settings/billing).
|
||||
|
||||
Consider setting usage limits to control costs.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="OpenAI">
|
||||
|
||||
1. [Create an OpenAI account](https://platform.openai.com/).
|
||||
2. [Generate an API key](https://platform.openai.com/api-keys).
|
||||
3. [Set up billing](https://platform.openai.com/account/billing/overview).
|
||||
|
||||
</Accordion>
|
||||
|
||||
</AccordionGroup>
|
||||
|
||||
#### Setting Up Search Engine
|
||||
|
||||
OpenHands can be configured to use a search engine to allow the agent to search the web for information when needed.
|
||||
|
||||
Search functionality is enabled by default in OpenHands Cloud. No additional setup is required.
|
||||
|
||||
To enable search functionality in self-hosted OpenHands:
|
||||
|
||||
1. Get a Tavily API key from [tavily.com](https://tavily.com/)
|
||||
2. Enter the API key in the Settings page under `LLM` tab, `Search API Key (Tavily)`
|
||||
|
||||
For more details, see the [Search Engine Setup](/usage/search-engine-setup) guide.
|
||||
|
||||
|
||||
Now you're ready to [get started with OpenHands](./getting-started).
|
||||
|
||||
#### Versions
|
||||
|
||||
The [docker command above](./installation#start-the-app) pulls the most recent stable release of OpenHands. You have other options as well:
|
||||
- For a specific release, replace `$VERSION` in `openhands:$VERSION` and `runtime:$VERSION`, with the version number.
|
||||
We use SemVer so `0.9` will automatically point to the latest `0.9.x` release, and `0` will point to the latest `0.x.x` release.
|
||||
- For the most up-to-date development version, replace `$VERSION` in `openhands:$VERSION` and `runtime:$VERSION`, with `main`.
|
||||
This version is unstable and is recommended for testing or development purposes only.
|
||||
|
||||
For the development workflow, see [Development.md](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md).
|
||||
|
||||
Are you having trouble? Check out our [Troubleshooting Guide](https://docs.all-hands.dev/modules/usage/troubleshooting).
|
||||
For more information see [running OpenHands on your own.](/usage/local-setup)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
title: Getting Started
|
||||
description: Getting started with running OpenHands locally.
|
||||
---
|
||||
|
||||
## Recommended Methods for Running Openhands on Your Local System
|
||||
|
||||
### System Requirements
|
||||
|
||||
- MacOS with [Docker Desktop support](https://docs.docker.com/desktop/setup/install/mac-install/#system-requirements)
|
||||
- Linux
|
||||
- Windows with [WSL](https://learn.microsoft.com/en-us/windows/wsl/install) and [Docker Desktop support](https://docs.docker.com/desktop/setup/install/windows-install/#system-requirements)
|
||||
|
||||
A system with a modern processor and a minimum of **4GB RAM** is recommended to run OpenHands.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
<AccordionGroup>
|
||||
|
||||
<Accordion title="MacOS">
|
||||
|
||||
**Docker Desktop**
|
||||
|
||||
1. [Install Docker Desktop on Mac](https://docs.docker.com/desktop/setup/install/mac-install).
|
||||
2. Open Docker Desktop, go to `Settings > Advanced` and ensure `Allow the default Docker socket to be used` is enabled.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Linux">
|
||||
|
||||
<Note>
|
||||
Tested with Ubuntu 22.04.
|
||||
</Note>
|
||||
|
||||
**Docker Desktop**
|
||||
|
||||
1. [Install Docker Desktop on Linux](https://docs.docker.com/desktop/setup/install/linux/).
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Windows">
|
||||
|
||||
**WSL**
|
||||
|
||||
1. [Install WSL](https://learn.microsoft.com/en-us/windows/wsl/install).
|
||||
2. Run `wsl --version` in powershell and confirm `Default Version: 2`.
|
||||
|
||||
**Docker Desktop**
|
||||
|
||||
1. [Install Docker Desktop on Windows](https://docs.docker.com/desktop/setup/install/windows-install).
|
||||
2. Open Docker Desktop, go to `Settings` and confirm the following:
|
||||
- General: `Use the WSL 2 based engine` is enabled.
|
||||
- Resources > WSL Integration: `Enable integration with my default WSL distro` is enabled.
|
||||
|
||||
<Note>
|
||||
The docker command below to start the app must be run inside the WSL terminal.
|
||||
</Note>
|
||||
|
||||
</Accordion>
|
||||
|
||||
</AccordionGroup>
|
||||
|
||||
### Start the App
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.40-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.40-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.40
|
||||
```
|
||||
|
||||
You'll find OpenHands running at http://localhost:3000!
|
||||
|
||||
### Setup
|
||||
|
||||
After launching OpenHands, you **must** select an `LLM Provider` and `LLM Model` and enter a corresponding `API Key`.
|
||||
This can be done during the initial settings popup or by selecting the `Settings`
|
||||
button (gear icon) in the UI.
|
||||
|
||||
If the required model does not exist in the list, in `Settings` under the `LLM` tab, you can toggle `Advanced` options
|
||||
and manually enter it with the correct prefix in the `Custom Model` text box.
|
||||
The `Advanced` options also allow you to specify a `Base URL` if required.
|
||||
|
||||
#### Getting an API Key
|
||||
|
||||
OpenHands requires an API key to access most language models. Here's how to get an API key from the recommended providers:
|
||||
|
||||
<AccordionGroup>
|
||||
|
||||
<Accordion title="Anthropic (Claude)">
|
||||
|
||||
1. [Create an Anthropic account](https://console.anthropic.com/).
|
||||
2. [Generate an API key](https://console.anthropic.com/settings/keys).
|
||||
3. [Set up billing](https://console.anthropic.com/settings/billing).
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="OpenAI">
|
||||
|
||||
1. [Create an OpenAI account](https://platform.openai.com/).
|
||||
2. [Generate an API key](https://platform.openai.com/api-keys).
|
||||
3. [Set up billing](https://platform.openai.com/account/billing/overview).
|
||||
|
||||
</Accordion>
|
||||
|
||||
<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.
|
||||
|
||||
#### Setting Up Search Engine
|
||||
|
||||
OpenHands can be configured to use a search engine to allow the agent to search the web for information when needed.
|
||||
|
||||
To enable search functionality in OpenHands:
|
||||
|
||||
1. Get a Tavily API key from [tavily.com](https://tavily.com/).
|
||||
2. Enter the Tavily API key in the Settings page under `LLM` tab > `Search API Key (Tavily)`
|
||||
|
||||
For more details, see the [Search Engine Setup](/usage/search-engine-setup) guide.
|
||||
|
||||
|
||||
Now you're ready to [get started with OpenHands](/usage/getting-started).
|
||||
|
||||
### Versions
|
||||
|
||||
The [docker command above](/usage/local-setup#start-the-app) pulls the most recent stable release of OpenHands. You have other options as well:
|
||||
- For a specific release, replace `$VERSION` in `openhands:$VERSION` and `runtime:$VERSION`, with the version number.
|
||||
For example, `0.9` will automatically point to the latest `0.9.x` release, and `0` will point to the latest `0.x.x` release.
|
||||
- For the most up-to-date development version, replace `$VERSION` in `openhands:$VERSION` and `runtime:$VERSION`, with `main`.
|
||||
This version is unstable and is recommended for testing or development purposes only.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Connect OpenHands to your local filesystem.](/usage/runtimes/docker#connecting-to-your-filesystem) to use OpenHands with your GitHub repositories
|
||||
- [Run OpenHands in a scriptable headless mode.](/usage/how-to/headless-mode)
|
||||
- [Run OpenHands with a friendly CLI.](/usage/how-to/cli-mode)
|
||||
- [Run OpenHands on tagged issues with a GitHub action.](/usage/how-to/github-action)
|
||||
@@ -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.
|
||||
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).
|
||||
|
||||
<Note>
|
||||
Loaded microagents take up space in the context window.
|
||||
|
||||
@@ -17,13 +17,45 @@ Frontmatter should be enclosed in triple dashes (---) and may include the follow
|
||||
|-----------|-----------------------------------------|----------|----------------|
|
||||
| `agent` | The agent this microagent applies to | No | 'CodeActAgent' |
|
||||
|
||||
## Example
|
||||
## Creating a Comprehensive Repository Agent
|
||||
|
||||
To create an effective repository agent, you can ask OpenHands to analyze your repository with a prompt like:
|
||||
|
||||
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`.
|
||||
```
|
||||
|
||||
|
||||
@@ -71,7 +71,6 @@ 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.
|
||||
|
||||
|
||||
@@ -6,6 +6,21 @@ import { renderWithProviders } from "test-utils";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import SettingsScreen from "#/routes/settings";
|
||||
import { PaymentForm } from "#/components/features/payment/payment-form";
|
||||
import * as useSettingsModule from "#/hooks/query/use-settings";
|
||||
|
||||
// Mock the useSettings hook
|
||||
vi.mock("#/hooks/query/use-settings", async () => {
|
||||
const actual = await vi.importActual<typeof import("#/hooks/query/use-settings")>("#/hooks/query/use-settings");
|
||||
return {
|
||||
...actual,
|
||||
useSettings: vi.fn().mockReturnValue({
|
||||
data: {
|
||||
EMAIL_VERIFIED: true, // Mock email as verified to prevent redirection
|
||||
},
|
||||
isLoading: false,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the i18next hook
|
||||
vi.mock("react-i18next", async () => {
|
||||
@@ -20,6 +35,7 @@ vi.mock("react-i18next", async () => {
|
||||
"SETTINGS$NAV_CREDITS": "Credits",
|
||||
"SETTINGS$NAV_API_KEYS": "API Keys",
|
||||
"SETTINGS$NAV_LLM": "LLM",
|
||||
"SETTINGS$NAV_USER": "User",
|
||||
"SETTINGS$TITLE": "Settings"
|
||||
};
|
||||
return translations[key] || key;
|
||||
@@ -47,6 +63,10 @@ describe("Settings Billing", () => {
|
||||
Component: () => <div data-testid="git-settings-screen" />,
|
||||
path: "/settings/git",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="user-settings-screen" />,
|
||||
path: "/settings/user",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -47,6 +47,7 @@ const SCAN_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
|
||||
|
||||
// Attributes that typically don't contain user-facing text
|
||||
const NON_TEXT_ATTRIBUTES = [
|
||||
"allow",
|
||||
"className",
|
||||
"i18nKey",
|
||||
"testId",
|
||||
@@ -69,6 +70,7 @@ const NON_TEXT_ATTRIBUTES = [
|
||||
"aria-describedby",
|
||||
"aria-hidden",
|
||||
"role",
|
||||
"sandbox",
|
||||
];
|
||||
|
||||
function shouldIgnorePath(filePath) {
|
||||
@@ -114,6 +116,7 @@ const EXCLUDED_TECHNICAL_STRINGS = [
|
||||
"add-secret-form", // Test ID for secret form
|
||||
"edit-secret-form", // Test ID for secret form
|
||||
"search-api-key-input", // Input name for search API key
|
||||
"noopener,noreferrer", // Options for window.open
|
||||
];
|
||||
|
||||
function isExcludedTechnicalString(str) {
|
||||
|
||||
@@ -1,5 +1,60 @@
|
||||
import axios from "axios";
|
||||
import axios, { AxiosError, AxiosResponse } 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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
|
||||
/**
|
||||
* A component that restricts access to routes based on email verification status.
|
||||
* If EMAIL_VERIFIED is false, only allows access to the /settings/user page.
|
||||
*/
|
||||
export function EmailVerificationGuard({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { data: settings, isLoading } = useSettings();
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
React.useEffect(() => {
|
||||
// If settings are still loading, don't do anything yet
|
||||
if (isLoading) return;
|
||||
|
||||
// If EMAIL_VERIFIED is explicitly false (not undefined or null)
|
||||
if (settings?.EMAIL_VERIFIED === false) {
|
||||
// Allow access to /settings/user but redirect from any other page
|
||||
if (pathname !== "/settings/user") {
|
||||
navigate("/settings/user", { replace: true });
|
||||
}
|
||||
}
|
||||
}, [settings?.EMAIL_VERIFIED, pathname, navigate, isLoading]);
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -69,16 +69,21 @@ export function Sidebar() {
|
||||
<div className="flex items-center justify-center">
|
||||
<AllHandsLogoButton />
|
||||
</div>
|
||||
<NewProjectButton />
|
||||
<NewProjectButton disabled={settings?.EMAIL_VERIFIED === false} />
|
||||
<ConversationPanelButton
|
||||
isOpen={conversationPanelIsOpen}
|
||||
onClick={() => setConversationPanelIsOpen((prev) => !prev)}
|
||||
onClick={() =>
|
||||
settings?.EMAIL_VERIFIED === false
|
||||
? null
|
||||
: setConversationPanelIsOpen((prev) => !prev)
|
||||
}
|
||||
disabled={settings?.EMAIL_VERIFIED === false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row md:flex-col md:items-center gap-[26px] md:mb-4">
|
||||
<DocsButton />
|
||||
<SettingsButton />
|
||||
<DocsButton disabled={settings?.EMAIL_VERIFIED === false} />
|
||||
<SettingsButton disabled={settings?.EMAIL_VERIFIED === false} />
|
||||
<UserActions
|
||||
user={
|
||||
user.data ? { avatar_url: user.data.avatar_url } : undefined
|
||||
|
||||
@@ -8,11 +8,13 @@ import { cn } from "#/utils/utils";
|
||||
interface ConversationPanelButtonProps {
|
||||
isOpen: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ConversationPanelButton({
|
||||
isOpen,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: ConversationPanelButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -22,10 +24,14 @@ export function ConversationPanelButton({
|
||||
tooltip={t(I18nKey.SIDEBAR$CONVERSATIONS)}
|
||||
ariaLabel={t(I18nKey.SIDEBAR$CONVERSATIONS)}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FaListUl
|
||||
size={22}
|
||||
className={cn(isOpen ? "text-white" : "text-[#9099AC]")}
|
||||
className={cn(
|
||||
isOpen ? "text-white" : "text-[#9099AC]",
|
||||
disabled && "opacity-50",
|
||||
)}
|
||||
/>
|
||||
</TooltipButton>
|
||||
);
|
||||
|
||||
@@ -3,15 +3,24 @@ import DocsIcon from "#/icons/academy.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { TooltipButton } from "./tooltip-button";
|
||||
|
||||
export function DocsButton() {
|
||||
interface DocsButtonProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function DocsButton({ disabled = false }: DocsButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<TooltipButton
|
||||
tooltip={t(I18nKey.SIDEBAR$DOCS)}
|
||||
ariaLabel={t(I18nKey.SIDEBAR$DOCS)}
|
||||
href="https://docs.all-hands.dev"
|
||||
disabled={disabled}
|
||||
>
|
||||
<DocsIcon width={28} height={28} className="text-[#9099AC]" />
|
||||
<DocsIcon
|
||||
width={28}
|
||||
height={28}
|
||||
className={`text-[#9099AC] ${disabled ? "opacity-50" : ""}`}
|
||||
/>
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
import PlusIcon from "#/icons/plus.svg?react";
|
||||
import { TooltipButton } from "./tooltip-button";
|
||||
|
||||
export function NewProjectButton() {
|
||||
interface NewProjectButtonProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function NewProjectButton({ disabled = false }: NewProjectButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const startNewProject = t(I18nKey.CONVERSATION$START_NEW);
|
||||
return (
|
||||
@@ -12,6 +16,7 @@ export function NewProjectButton() {
|
||||
ariaLabel={startNewProject}
|
||||
navLinkTo="/"
|
||||
testId="new-project-button"
|
||||
disabled={disabled}
|
||||
>
|
||||
<PlusIcon width={28} height={28} />
|
||||
</TooltipButton>
|
||||
|
||||
@@ -5,9 +5,13 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface SettingsButtonProps {
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function SettingsButton({ onClick }: SettingsButtonProps) {
|
||||
export function SettingsButton({
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: SettingsButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -17,6 +21,7 @@ export function SettingsButton({ onClick }: SettingsButtonProps) {
|
||||
ariaLabel={t(I18nKey.SETTINGS$TITLE)}
|
||||
onClick={onClick}
|
||||
navLinkTo="/settings"
|
||||
disabled={disabled}
|
||||
>
|
||||
<SettingsIcon width={28} height={28} />
|
||||
</TooltipButton>
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface TooltipButtonProps {
|
||||
ariaLabel: string;
|
||||
testId?: string;
|
||||
className?: React.HTMLAttributes<HTMLButtonElement>["className"];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function TooltipButton({
|
||||
@@ -23,9 +24,10 @@ export function TooltipButton({
|
||||
ariaLabel,
|
||||
testId,
|
||||
className,
|
||||
disabled = false,
|
||||
}: TooltipButtonProps) {
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (onClick) {
|
||||
if (onClick && !disabled) {
|
||||
onClick();
|
||||
e.preventDefault();
|
||||
}
|
||||
@@ -37,7 +39,12 @@ export function TooltipButton({
|
||||
aria-label={ariaLabel}
|
||||
data-testid={testId}
|
||||
onClick={handleClick}
|
||||
className={cn("hover:opacity-80", className)}
|
||||
className={cn(
|
||||
"hover:opacity-80",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
@@ -45,7 +52,7 @@ export function TooltipButton({
|
||||
|
||||
let content;
|
||||
|
||||
if (navLinkTo) {
|
||||
if (navLinkTo && !disabled) {
|
||||
content = (
|
||||
<NavLink
|
||||
to={navLinkTo}
|
||||
@@ -63,7 +70,24 @@ export function TooltipButton({
|
||||
{children}
|
||||
</NavLink>
|
||||
);
|
||||
} else if (href) {
|
||||
} else if (navLinkTo && disabled) {
|
||||
// If disabled and has navLinkTo, render a button that looks like a NavLink but doesn't navigate
|
||||
content = (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
data-testid={testId}
|
||||
className={cn(
|
||||
"text-[#9099AC]",
|
||||
"opacity-50 cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
disabled
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
} else if (href && !disabled) {
|
||||
content = (
|
||||
<a
|
||||
href={href}
|
||||
@@ -76,6 +100,19 @@ export function TooltipButton({
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
} else if (href && disabled) {
|
||||
// If disabled and has href, render a button that looks like a link but doesn't navigate
|
||||
content = (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
data-testid={testId}
|
||||
className={cn("opacity-50 cursor-not-allowed", className)}
|
||||
disabled
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
content = buttonContent;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import { useEffect } from "react";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useUserConversation } from "./use-user-conversation";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
const FIVE_MINUTES = 1000 * 60 * 5;
|
||||
|
||||
export const useActiveConversation = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
return useUserConversation(conversationId, (query) => {
|
||||
const userConversation = useUserConversation(conversationId, (query) => {
|
||||
if (query.state.data?.status === "STARTING") {
|
||||
return 3000; // 3 seconds
|
||||
}
|
||||
return FIVE_MINUTES;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const conversation = userConversation.data;
|
||||
OpenHands.setCurrentConversation(conversation || null);
|
||||
}, [
|
||||
conversationId,
|
||||
userConversation.isFetched,
|
||||
userConversation?.data?.status,
|
||||
]);
|
||||
return userConversation;
|
||||
};
|
||||
|
||||
@@ -27,7 +27,8 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
|
||||
apiSettings.enable_proactive_conversation_starters,
|
||||
USER_CONSENTS_TO_ANALYTICS: apiSettings.user_consents_to_analytics,
|
||||
SEARCH_API_KEY: apiSettings.search_api_key || "",
|
||||
|
||||
EMAIL: apiSettings.email || "",
|
||||
EMAIL_VERIFIED: apiSettings.email_verified,
|
||||
MCP_CONFIG: apiSettings.mcp_config,
|
||||
IS_NEW_USER: false,
|
||||
};
|
||||
@@ -44,6 +45,7 @@ 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,
|
||||
|
||||
@@ -23,7 +23,6 @@ export const useUserConversation = (
|
||||
queryKey: ["user", "conversation", cid],
|
||||
queryFn: async () => {
|
||||
const conversation = await OpenHands.getConversation(cid!);
|
||||
OpenHands.setCurrentConversation(conversation);
|
||||
return conversation;
|
||||
},
|
||||
enabled: !!cid,
|
||||
|
||||
@@ -139,7 +139,9 @@ export enum I18nKey {
|
||||
VSCODE$LOADING = "VSCODE$LOADING",
|
||||
VSCODE$URL_NOT_AVAILABLE = "VSCODE$URL_NOT_AVAILABLE",
|
||||
VSCODE$FETCH_ERROR = "VSCODE$FETCH_ERROR",
|
||||
VSCODE$IFRAME_PERMISSIONS = "VSCODE$IFRAME_PERMISSIONS",
|
||||
VSCODE$CROSS_ORIGIN_WARNING = "VSCODE$CROSS_ORIGIN_WARNING",
|
||||
VSCODE$URL_PARSE_ERROR = "VSCODE$URL_PARSE_ERROR",
|
||||
VSCODE$OPEN_IN_NEW_TAB = "VSCODE$OPEN_IN_NEW_TAB",
|
||||
INCREASE_TEST_COVERAGE = "INCREASE_TEST_COVERAGE",
|
||||
AUTO_MERGE_PRS = "AUTO_MERGE_PRS",
|
||||
FIX_README = "FIX_README",
|
||||
@@ -333,6 +335,7 @@ export enum I18nKey {
|
||||
SETTINGS$CONFIRMATION_MODE_TOOLTIP = "SETTINGS$CONFIRMATION_MODE_TOOLTIP",
|
||||
SETTINGS$AGENT_SELECT_ENABLED = "SETTINGS$AGENT_SELECT_ENABLED",
|
||||
SETTINGS$SECURITY_ANALYZER = "SETTINGS$SECURITY_ANALYZER",
|
||||
SETTINGS$SECURITY_ANALYZER_PLACEHOLDER = "SETTINGS$SECURITY_ANALYZER_PLACEHOLDER",
|
||||
SETTINGS$DONT_KNOW_API_KEY = "SETTINGS$DONT_KNOW_API_KEY",
|
||||
SETTINGS$CLICK_FOR_INSTRUCTIONS = "SETTINGS$CLICK_FOR_INSTRUCTIONS",
|
||||
SETTINGS$SAVED = "SETTINGS$SAVED",
|
||||
@@ -553,4 +556,19 @@ export enum I18nKey {
|
||||
TIPS$PROTIP = "TIPS$PROTIP",
|
||||
FEEDBACK$SUBMITTING_LABEL = "FEEDBACK$SUBMITTING_LABEL",
|
||||
FEEDBACK$SUBMITTING_MESSAGE = "FEEDBACK$SUBMITTING_MESSAGE",
|
||||
SETTINGS$NAV_USER = "SETTINGS$NAV_USER",
|
||||
SETTINGS$USER_TITLE = "SETTINGS$USER_TITLE",
|
||||
SETTINGS$USER_EMAIL = "SETTINGS$USER_EMAIL",
|
||||
SETTINGS$USER_EMAIL_LOADING = "SETTINGS$USER_EMAIL_LOADING",
|
||||
SETTINGS$SAVE = "SETTINGS$SAVE",
|
||||
SETTINGS$EMAIL_SAVED_SUCCESSFULLY = "SETTINGS$EMAIL_SAVED_SUCCESSFULLY",
|
||||
SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY = "SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY",
|
||||
SETTINGS$FAILED_TO_SAVE_EMAIL = "SETTINGS$FAILED_TO_SAVE_EMAIL",
|
||||
SETTINGS$SENDING = "SETTINGS$SENDING",
|
||||
SETTINGS$VERIFICATION_EMAIL_SENT = "SETTINGS$VERIFICATION_EMAIL_SENT",
|
||||
SETTINGS$EMAIL_VERIFICATION_REQUIRED = "SETTINGS$EMAIL_VERIFICATION_REQUIRED",
|
||||
SETTINGS$INVALID_EMAIL_FORMAT = "SETTINGS$INVALID_EMAIL_FORMAT",
|
||||
SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE = "SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE",
|
||||
SETTINGS$RESEND_VERIFICATION = "SETTINGS$RESEND_VERIFICATION",
|
||||
SETTINGS$FAILED_TO_RESEND_VERIFICATION = "SETTINGS$FAILED_TO_RESEND_VERIFICATION",
|
||||
}
|
||||
|
||||
@@ -2223,21 +2223,53 @@
|
||||
"tr": "VS Code URL'si alınamadı",
|
||||
"uk": "Не вдалося отримати VS Code URL"
|
||||
},
|
||||
"VSCODE$IFRAME_PERMISSIONS": {
|
||||
"en": "clipboard-read; clipboard-write",
|
||||
"ja": "clipboard-read; clipboard-write",
|
||||
"zh-CN": "clipboard-read; clipboard-write",
|
||||
"zh-TW": "clipboard-read; clipboard-write",
|
||||
"ko-KR": "clipboard-read; clipboard-write",
|
||||
"de": "clipboard-read; clipboard-write",
|
||||
"no": "clipboard-read; clipboard-write",
|
||||
"it": "clipboard-read; clipboard-write",
|
||||
"pt": "clipboard-read; clipboard-write",
|
||||
"es": "clipboard-read; clipboard-write",
|
||||
"ar": "clipboard-read; clipboard-write",
|
||||
"fr": "clipboard-read; clipboard-write",
|
||||
"tr": "clipboard-read; clipboard-write",
|
||||
"uk": "clipboard-read; clipboard-write"
|
||||
"VSCODE$CROSS_ORIGIN_WARNING": {
|
||||
"en": "The code editor cannot be embedded due to browser security restrictions. Cross-origin cookies are being blocked.",
|
||||
"ja": "ブラウザのセキュリティ制限により、コードエディタを埋め込むことができません。クロスオリジンCookieがブロックされています。",
|
||||
"zh-CN": "由于浏览器安全限制,无法嵌入代码编辑器。跨源Cookie被阻止。",
|
||||
"zh-TW": "由於瀏覽器安全限制,無法嵌入代碼編輯器。跨源Cookie被阻止。",
|
||||
"ko-KR": "브라우저 보안 제한으로 인해 코드 편집기를 삽입할 수 없습니다. 교차 출처 쿠키가 차단되고 있습니다.",
|
||||
"de": "Der Code-Editor kann aufgrund von Browser-Sicherheitsbeschränkungen nicht eingebettet werden. Cross-Origin-Cookies werden blockiert.",
|
||||
"no": "Koderedigereren kan ikke bygges inn på grunn av nettleserens sikkerhetsbegrensninger. Cross-origin cookies blir blokkert.",
|
||||
"it": "L'editor di codice non può essere incorporato a causa delle restrizioni di sicurezza del browser. I cookie cross-origin vengono bloccati.",
|
||||
"pt": "O editor de código não pode ser incorporado devido a restrições de segurança do navegador. Cookies de origem cruzada estão sendo bloqueados.",
|
||||
"es": "El editor de código no se puede incrustar debido a las restricciones de seguridad del navegador. Las cookies de origen cruzado están siendo bloqueadas.",
|
||||
"ar": "لا يمكن تضمين محرر التعليمات البرمجية بسبب قيود أمان المتصفح. يتم حظر ملفات تعريف الارتباط عبر المصدر.",
|
||||
"fr": "L'éditeur de code ne peut pas être intégré en raison des restrictions de sécurité du navigateur. Les cookies cross-origin sont bloqués.",
|
||||
"tr": "Tarayıcı güvenlik kısıtlamaları nedeniyle kod düzenleyici yerleştirilemiyor. Çapraz kaynaklı çerezler engelleniyor.",
|
||||
"uk": "Редактор коду не може бути вбудований через обмеження безпеки браузера. Блокуються файли cookie з різних джерел."
|
||||
},
|
||||
"VSCODE$URL_PARSE_ERROR": {
|
||||
"en": "Error parsing URL",
|
||||
"ja": "URLの解析エラー",
|
||||
"zh-CN": "URL解析错误",
|
||||
"zh-TW": "URL解析錯誤",
|
||||
"ko-KR": "URL 구문 분석 오류",
|
||||
"de": "Fehler beim Parsen der URL",
|
||||
"no": "Feil ved parsing av URL",
|
||||
"it": "Errore durante l'analisi dell'URL",
|
||||
"pt": "Erro ao analisar URL",
|
||||
"es": "Error al analizar URL",
|
||||
"ar": "خطأ في تحليل عنوان URL",
|
||||
"fr": "Erreur d'analyse de l'URL",
|
||||
"tr": "URL ayrıştırma hatası",
|
||||
"uk": "Помилка аналізу URL"
|
||||
},
|
||||
"VSCODE$OPEN_IN_NEW_TAB": {
|
||||
"en": "Open in New Tab",
|
||||
"ja": "新しいタブで開く",
|
||||
"zh-CN": "在新标签页中打开",
|
||||
"zh-TW": "在新標籤頁中打開",
|
||||
"ko-KR": "새 탭에서 열기",
|
||||
"de": "In neuem Tab öffnen",
|
||||
"no": "Åpne i ny fane",
|
||||
"it": "Apri in una nuova scheda",
|
||||
"pt": "Abrir em nova aba",
|
||||
"es": "Abrir en nueva pestaña",
|
||||
"ar": "فتح في علامة تبويب جديدة",
|
||||
"fr": "Ouvrir dans un nouvel onglet",
|
||||
"tr": "Yeni Sekmede Aç",
|
||||
"uk": "Відкрити в новій вкладці"
|
||||
},
|
||||
"INCREASE_TEST_COVERAGE": {
|
||||
"en": "Increase test coverage",
|
||||
@@ -5327,6 +5359,22 @@
|
||||
"ja": "セキュリティアナライザー",
|
||||
"uk": "Увімкнути аналізатор безпеки"
|
||||
},
|
||||
"SETTINGS$SECURITY_ANALYZER_PLACEHOLDER":{
|
||||
"en": "Select a security analyzer…",
|
||||
"de": "Wählen Sie einen Sicherheitsanalysator aus…",
|
||||
"zh-CN": "选择一个安全分析器…",
|
||||
"zh-TW": "選擇一個安全分析器…",
|
||||
"ko-KR": "보안 분석기를 선택하세요…",
|
||||
"no": "Velg en sikkerhetsanalysator…",
|
||||
"it": "Seleziona un analizzatore di sicurezza…",
|
||||
"pt": "Selecione um analisador de segurança…",
|
||||
"es": "Seleccione un analizador de seguridad…",
|
||||
"ar": "اختر محلل الأمان…",
|
||||
"fr": "Sélectionnez un analyseur de sécurité…",
|
||||
"tr": "Bir güvenlik analizörü seçin…",
|
||||
"ja": "セキュリティアナライザーを選択…",
|
||||
"uk": "Виберіть аналізатор безпеки…"
|
||||
},
|
||||
"SETTINGS$DONT_KNOW_API_KEY": {
|
||||
"en": "Don't know your API key?",
|
||||
"ja": "APIキーがわかりませんか?",
|
||||
@@ -8846,5 +8894,245 @@
|
||||
"tr": "Geri bildirim gönderiliyor, lütfen bekleyin...",
|
||||
"de": "Feedback senden, bitte warten...",
|
||||
"uk": "Відправляємо відгук, будь ласка, почекайте..."
|
||||
},
|
||||
"SETTINGS$NAV_USER": {
|
||||
"en": "User",
|
||||
"ja": "ユーザー",
|
||||
"zh-CN": "用户",
|
||||
"zh-TW": "用戶",
|
||||
"ko-KR": "사용자",
|
||||
"no": "Bruker",
|
||||
"it": "Utente",
|
||||
"pt": "Usuário",
|
||||
"es": "Usuario",
|
||||
"ar": "المستخدم",
|
||||
"fr": "Utilisateur",
|
||||
"tr": "Kullanıcı",
|
||||
"de": "Benutzer",
|
||||
"uk": "Користувач"
|
||||
},
|
||||
"SETTINGS$USER_TITLE": {
|
||||
"en": "User Information",
|
||||
"ja": "ユーザー情報",
|
||||
"zh-CN": "用户信息",
|
||||
"zh-TW": "用戶信息",
|
||||
"ko-KR": "사용자 정보",
|
||||
"no": "Brukerinformasjon",
|
||||
"it": "Informazioni utente",
|
||||
"pt": "Informações do usuário",
|
||||
"es": "Información del usuario",
|
||||
"ar": "معلومات المستخدم",
|
||||
"fr": "Informations utilisateur",
|
||||
"tr": "Kullanıcı Bilgileri",
|
||||
"de": "Benutzerinformationen",
|
||||
"uk": "Інформація про користувача"
|
||||
},
|
||||
"SETTINGS$USER_EMAIL": {
|
||||
"en": "Email",
|
||||
"ja": "メール",
|
||||
"zh-CN": "邮箱",
|
||||
"zh-TW": "郵箱",
|
||||
"ko-KR": "이메일",
|
||||
"no": "E-post",
|
||||
"it": "Email",
|
||||
"pt": "Email",
|
||||
"es": "Correo electrónico",
|
||||
"ar": "البريد الإلكتروني",
|
||||
"fr": "Email",
|
||||
"tr": "E-posta",
|
||||
"de": "E-Mail",
|
||||
"uk": "Електронна пошта"
|
||||
},
|
||||
"SETTINGS$USER_EMAIL_LOADING": {
|
||||
"en": "Loading...",
|
||||
"ja": "読み込み中...",
|
||||
"zh-CN": "加载中...",
|
||||
"zh-TW": "加載中...",
|
||||
"ko-KR": "로딩 중...",
|
||||
"no": "Laster...",
|
||||
"it": "Caricamento...",
|
||||
"pt": "Carregando...",
|
||||
"es": "Cargando...",
|
||||
"ar": "جار التحميل...",
|
||||
"fr": "Chargement...",
|
||||
"tr": "Yükleniyor...",
|
||||
"de": "Wird geladen...",
|
||||
"uk": "Завантаження..."
|
||||
},
|
||||
"SETTINGS$SAVE": {
|
||||
"en": "Save",
|
||||
"ja": "保存",
|
||||
"zh-CN": "保存",
|
||||
"zh-TW": "儲存",
|
||||
"ko-KR": "저장",
|
||||
"no": "Lagre",
|
||||
"it": "Salva",
|
||||
"pt": "Salvar",
|
||||
"es": "Guardar",
|
||||
"ar": "حفظ",
|
||||
"fr": "Enregistrer",
|
||||
"tr": "Kaydet",
|
||||
"de": "Speichern",
|
||||
"uk": "Зберегти"
|
||||
},
|
||||
"SETTINGS$EMAIL_SAVED_SUCCESSFULLY": {
|
||||
"en": "Email saved successfully",
|
||||
"ja": "メールが正常に保存されました",
|
||||
"zh-CN": "邮箱保存成功",
|
||||
"zh-TW": "郵箱儲存成功",
|
||||
"ko-KR": "이메일이 성공적으로 저장되었습니다",
|
||||
"no": "E-post lagret",
|
||||
"it": "Email salvata con successo",
|
||||
"pt": "Email salvo com sucesso",
|
||||
"es": "Correo electrónico guardado con éxito",
|
||||
"ar": "تم حفظ البريد الإلكتروني بنجاح",
|
||||
"fr": "Email enregistré avec succès",
|
||||
"tr": "E-posta başarıyla kaydedildi",
|
||||
"de": "E-Mail erfolgreich gespeichert",
|
||||
"uk": "Електронну пошту успішно збережено"
|
||||
},
|
||||
"SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY": {
|
||||
"en": "Your email has been verified successfully!",
|
||||
"ja": "メールアドレスの確認が完了しました!",
|
||||
"zh-CN": "您的邮箱已成功验证!",
|
||||
"zh-TW": "您的郵箱已成功驗證!",
|
||||
"ko-KR": "이메일이 성공적으로 인증되었습니다!",
|
||||
"no": "E-posten din er bekreftet!",
|
||||
"it": "La tua email è stata verificata con successo!",
|
||||
"pt": "Seu email foi verificado com sucesso!",
|
||||
"es": "¡Tu correo electrónico ha sido verificado con éxito!",
|
||||
"ar": "تم التحقق من بريدك الإلكتروني بنجاح!",
|
||||
"fr": "Votre email a été vérifié avec succès !",
|
||||
"tr": "E-postanız başarıyla doğrulandı!",
|
||||
"de": "Ihre E-Mail wurde erfolgreich verifiziert!",
|
||||
"uk": "Вашу електронну пошту успішно підтверджено!"
|
||||
},
|
||||
"SETTINGS$FAILED_TO_SAVE_EMAIL": {
|
||||
"en": "Failed to save email",
|
||||
"ja": "メールの保存に失敗しました",
|
||||
"zh-CN": "保存邮箱失败",
|
||||
"zh-TW": "儲存郵箱失敗",
|
||||
"ko-KR": "이메일 저장 실패",
|
||||
"no": "Kunne ikke lagre e-post",
|
||||
"it": "Impossibile salvare l'email",
|
||||
"pt": "Falha ao salvar email",
|
||||
"es": "Error al guardar el correo electrónico",
|
||||
"ar": "فشل في حفظ البريد الإلكتروني",
|
||||
"fr": "Échec de l'enregistrement de l'email",
|
||||
"tr": "E-posta kaydedilemedi",
|
||||
"de": "E-Mail konnte nicht gespeichert werden",
|
||||
"uk": "Не вдалося зберегти електронну пошту"
|
||||
},
|
||||
"SETTINGS$SENDING": {
|
||||
"en": "Sending",
|
||||
"ja": "送信中",
|
||||
"zh-CN": "发送中",
|
||||
"zh-TW": "發送中",
|
||||
"ko-KR": "전송 중",
|
||||
"no": "Sender",
|
||||
"it": "Invio in corso",
|
||||
"pt": "Enviando",
|
||||
"es": "Enviando",
|
||||
"ar": "جاري الإرسال",
|
||||
"fr": "Envoi en cours",
|
||||
"tr": "Gönderiliyor",
|
||||
"de": "Wird gesendet",
|
||||
"uk": "Надсилання"
|
||||
},
|
||||
"SETTINGS$VERIFICATION_EMAIL_SENT": {
|
||||
"en": "Verification email sent",
|
||||
"ja": "確認メールを送信しました",
|
||||
"zh-CN": "验证邮件已发送",
|
||||
"zh-TW": "驗證郵件已發送",
|
||||
"ko-KR": "인증 이메일이 전송되었습니다",
|
||||
"no": "Bekreftelsese-post sendt",
|
||||
"it": "Email di verifica inviata",
|
||||
"pt": "Email de verificação enviado",
|
||||
"es": "Correo de verificación enviado",
|
||||
"ar": "تم إرسال بريد التحقق",
|
||||
"fr": "Email de vérification envoyé",
|
||||
"tr": "Doğrulama e-postası gönderildi",
|
||||
"de": "Bestätigungs-E-Mail gesendet",
|
||||
"uk": "Лист підтвердження надіслано"
|
||||
},
|
||||
"SETTINGS$EMAIL_VERIFICATION_REQUIRED": {
|
||||
"en": "You must verify your email address before using All Hands",
|
||||
"ja": "All Handsを使用する前にメールアドレスを確認する必要があります",
|
||||
"zh-CN": "使用All Hands前,您必须验证您的电子邮件地址",
|
||||
"zh-TW": "使用All Hands前,您必須驗證您的電子郵件地址",
|
||||
"ko-KR": "All Hands를 사용하기 전에 이메일 주소를 확인해야 합니다",
|
||||
"no": "Du må bekrefte e-postadressen din før du bruker All Hands",
|
||||
"it": "Devi verificare il tuo indirizzo email prima di utilizzare All Hands",
|
||||
"pt": "Você deve verificar seu endereço de e-mail antes de usar o All Hands",
|
||||
"es": "Debe verificar su dirección de correo electrónico antes de usar All Hands",
|
||||
"ar": "يجب عليك التحقق من عنوان بريدك الإلكتروني قبل استخدام All Hands",
|
||||
"fr": "Vous devez vérifier votre adresse e-mail avant d'utiliser All Hands",
|
||||
"tr": "All Hands'i kullanmadan önce e-posta adresinizi doğrulamanız gerekiyor",
|
||||
"de": "Sie müssen Ihre E-Mail-Adresse bestätigen, bevor Sie All Hands verwenden können",
|
||||
"uk": "Ви повинні підтвердити свою електронну адресу перед використанням All Hands"
|
||||
},
|
||||
"SETTINGS$INVALID_EMAIL_FORMAT": {
|
||||
"en": "Please enter a valid email address",
|
||||
"ja": "有効なメールアドレスを入力してください",
|
||||
"zh-CN": "请输入有效的电子邮件地址",
|
||||
"zh-TW": "請輸入有效的電子郵件地址",
|
||||
"ko-KR": "유효한 이메일 주소를 입력하세요",
|
||||
"no": "Vennligst skriv inn en gyldig e-postadresse",
|
||||
"it": "Inserisci un indirizzo email valido",
|
||||
"pt": "Por favor, insira um endereço de e-mail válido",
|
||||
"es": "Por favor, introduzca una dirección de correo electrónico válida",
|
||||
"ar": "الرجاء إدخال عنوان بريد إلكتروني صالح",
|
||||
"fr": "Veuillez entrer une adresse e-mail valide",
|
||||
"tr": "Lütfen geçerli bir e-posta adresi girin",
|
||||
"de": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
||||
"uk": "Будь ласка, введіть дійсну електронну адресу"
|
||||
},
|
||||
"SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE": {
|
||||
"en": "Your access is limited until your email is verified. You can only access this settings page.",
|
||||
"ja": "メールが確認されるまでアクセスが制限されています。この設定ページにのみアクセスできます。",
|
||||
"zh-CN": "在验证您的电子邮件之前,您的访问权限受到限制。您只能访问此设置页面。",
|
||||
"zh-TW": "在驗證您的電子郵件之前,您的訪問權限受到限制。您只能訪問此設置頁面。",
|
||||
"ko-KR": "이메일이 확인될 때까지 액세스가 제한됩니다. 이 설정 페이지만 액세스할 수 있습니다.",
|
||||
"no": "Din tilgang er begrenset til e-posten din er bekreftet. Du kan bare få tilgang til denne innstillingssiden.",
|
||||
"it": "Il tuo accesso è limitato fino a quando la tua email non viene verificata. Puoi accedere solo a questa pagina delle impostazioni.",
|
||||
"pt": "Seu acesso é limitado até que seu e-mail seja verificado. Você só pode acessar esta página de configurações.",
|
||||
"es": "Su acceso es limitado hasta que se verifique su correo electrónico. Solo puede acceder a esta página de configuración.",
|
||||
"ar": "وصولك محدود حتى يتم التحقق من بريدك الإلكتروني. يمكنك فقط الوصول إلى صفحة الإعدادات هذه.",
|
||||
"fr": "Votre accès est limité jusqu'à ce que votre e-mail soit vérifié. Vous ne pouvez accéder qu'à cette page de paramètres.",
|
||||
"tr": "E-postanız doğrulanana kadar erişiminiz sınırlıdır. Yalnızca bu ayarlar sayfasına erişebilirsiniz.",
|
||||
"de": "Ihr Zugriff ist eingeschränkt, bis Ihre E-Mail-Adresse bestätigt wurde. Sie können nur auf diese Einstellungsseite zugreifen.",
|
||||
"uk": "Ваш доступ обмежений, доки ваша електронна пошта не буде підтверджена. Ви можете отримати доступ лише до цієї сторінки налаштувань."
|
||||
},
|
||||
"SETTINGS$RESEND_VERIFICATION": {
|
||||
"en": "Resend verification",
|
||||
"ja": "確認メールを再送信",
|
||||
"zh-CN": "重新发送验证",
|
||||
"zh-TW": "重新發送驗證",
|
||||
"ko-KR": "인증 재전송",
|
||||
"no": "Send bekreftelse på nytt",
|
||||
"it": "Rinvia verifica",
|
||||
"pt": "Reenviar verificação",
|
||||
"es": "Reenviar verificación",
|
||||
"ar": "إعادة إرسال التحقق",
|
||||
"fr": "Renvoyer la vérification",
|
||||
"tr": "Doğrulamayı yeniden gönder",
|
||||
"de": "Bestätigung erneut senden",
|
||||
"uk": "Надіслати підтвердження повторно"
|
||||
},
|
||||
"SETTINGS$FAILED_TO_RESEND_VERIFICATION": {
|
||||
"en": "Failed to resend verification email",
|
||||
"ja": "確認メールの再送信に失敗しました",
|
||||
"zh-CN": "重新发送验证邮件失败",
|
||||
"zh-TW": "重新發送驗證郵件失敗",
|
||||
"ko-KR": "인증 이메일 재전송 실패",
|
||||
"no": "Kunne ikke sende bekreftelsese-post på nytt",
|
||||
"it": "Impossibile rinviare l'email di verifica",
|
||||
"pt": "Falha ao reenviar email de verificação",
|
||||
"es": "Error al reenviar el correo de verificación",
|
||||
"ar": "فشل في إعادة إرسال بريد التحقق",
|
||||
"fr": "Échec du renvoi de l'email de vérification",
|
||||
"tr": "Doğrulama e-postası yeniden gönderilemedi",
|
||||
"de": "Bestätigungs-E-Mail konnte nicht erneut gesendet werden",
|
||||
"uk": "Не вдалося повторно надіслати лист підтвердження"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export default [
|
||||
route("settings", "routes/settings.tsx", [
|
||||
index("routes/llm-settings.tsx"),
|
||||
route("mcp", "routes/mcp-settings.tsx"),
|
||||
route("user", "routes/user-settings.tsx"),
|
||||
route("git", "routes/git-settings.tsx"),
|
||||
route("app", "routes/app-settings.tsx"),
|
||||
route("billing", "routes/billing.tsx"),
|
||||
|
||||
@@ -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
|
||||
@@ -469,6 +469,9 @@ function LlmSettingsScreen() {
|
||||
label: analyzer,
|
||||
})) || []
|
||||
}
|
||||
placeholder={t(
|
||||
I18nKey.SETTINGS$SECURITY_ANALYZER_PLACEHOLDER,
|
||||
)}
|
||||
defaultSelectedKey={settings.SECURITY_ANALYZER}
|
||||
isClearable
|
||||
showOptionalTag
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
|
||||
import { useAutoLogin } from "#/hooks/use-auto-login";
|
||||
import { useAuthCallback } from "#/hooks/use-auth-callback";
|
||||
import { LOCAL_STORAGE_KEYS } from "#/utils/local-storage";
|
||||
import { EmailVerificationGuard } from "#/components/features/guards/email-verification-guard";
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
@@ -204,7 +205,9 @@ export default function MainApp() {
|
||||
id="root-outlet"
|
||||
className="h-[calc(100%-50px)] md:h-full w-full relative overflow-auto"
|
||||
>
|
||||
<Outlet />
|
||||
<EmailVerificationGuard>
|
||||
<Outlet />
|
||||
</EmailVerificationGuard>
|
||||
</div>
|
||||
|
||||
{renderAuthModal && (
|
||||
|
||||
@@ -15,6 +15,7 @@ function SettingsScreen() {
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
|
||||
const saasNavItems = [
|
||||
{ to: "/settings/user", text: t("SETTINGS$NAV_USER") },
|
||||
{ to: "/settings/git", text: t("SETTINGS$NAV_GIT") },
|
||||
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
|
||||
{ to: "/settings/billing", text: t("SETTINGS$NAV_CREDITS") },
|
||||
@@ -33,10 +34,11 @@ function SettingsScreen() {
|
||||
React.useEffect(() => {
|
||||
if (isSaas) {
|
||||
if (pathname === "/settings") {
|
||||
navigate("/settings/git");
|
||||
navigate("/settings/user");
|
||||
}
|
||||
} else {
|
||||
const noEnteringPaths = [
|
||||
"/settings/user",
|
||||
"/settings/billing",
|
||||
"/settings/credits",
|
||||
"/settings/api-keys",
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { openHands } from "#/api/open-hands-axios";
|
||||
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
|
||||
|
||||
// Email validation regex pattern
|
||||
const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||
|
||||
function EmailInputSection({
|
||||
email,
|
||||
onEmailChange,
|
||||
onSaveEmail,
|
||||
onResendVerification,
|
||||
isSaving,
|
||||
isResendingVerification,
|
||||
isEmailChanged,
|
||||
emailVerified,
|
||||
isEmailValid,
|
||||
children,
|
||||
}: {
|
||||
email: string;
|
||||
onEmailChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onSaveEmail: () => void;
|
||||
onResendVerification: () => void;
|
||||
isSaving: boolean;
|
||||
isResendingVerification: boolean;
|
||||
isEmailChanged: boolean;
|
||||
emailVerified?: boolean;
|
||||
isEmailValid: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm">{t("SETTINGS$USER_EMAIL")}</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={onEmailChange}
|
||||
className={`text-base text-white p-2 bg-base-tertiary rounded border ${
|
||||
isEmailChanged && !isEmailValid
|
||||
? "border-red-500"
|
||||
: "border-tertiary"
|
||||
} flex-grow focus:outline-none focus:border-transparent focus:ring-0`}
|
||||
placeholder={t("SETTINGS$USER_EMAIL_LOADING")}
|
||||
data-testid="email-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEmailChanged && !isEmailValid && (
|
||||
<div
|
||||
className="text-red-500 text-sm mt-1"
|
||||
data-testid="email-validation-error"
|
||||
>
|
||||
{t("SETTINGS$INVALID_EMAIL_FORMAT")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSaveEmail}
|
||||
disabled={!isEmailChanged || isSaving || !isEmailValid}
|
||||
className="px-4 py-2 rounded bg-primary text-white hover:opacity-80 disabled:opacity-30 disabled:cursor-not-allowed disabled:text-[#0D0F11]"
|
||||
data-testid="save-email-button"
|
||||
>
|
||||
{isSaving ? t("SETTINGS$SAVING") : t("SETTINGS$SAVE")}
|
||||
</button>
|
||||
|
||||
{emailVerified === false && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResendVerification}
|
||||
disabled={isResendingVerification}
|
||||
className="px-4 py-2 rounded bg-primary text-white hover:opacity-80 disabled:opacity-30 disabled:cursor-not-allowed disabled:text-[#0D0F11]"
|
||||
data-testid="resend-verification-button"
|
||||
>
|
||||
{isResendingVerification
|
||||
? t("SETTINGS$SENDING")
|
||||
: t("SETTINGS$RESEND_VERIFICATION")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VerificationAlert() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mt-4"
|
||||
role="alert"
|
||||
>
|
||||
<p className="font-bold">{t("SETTINGS$EMAIL_VERIFICATION_REQUIRED")}</p>
|
||||
<p className="text-sm">
|
||||
{t("SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// These components have been replaced with toast notifications
|
||||
|
||||
function UserSettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { data: settings, isLoading, refetch } = useSettings();
|
||||
const [email, setEmail] = useState("");
|
||||
const [originalEmail, setOriginalEmail] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isResendingVerification, setIsResendingVerification] = useState(false);
|
||||
const [isEmailValid, setIsEmailValid] = useState(true);
|
||||
const queryClient = useQueryClient();
|
||||
const pollingIntervalRef = useRef<number | null>(null);
|
||||
const prevVerificationStatusRef = useRef<boolean | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings?.EMAIL) {
|
||||
setEmail(settings.EMAIL);
|
||||
setOriginalEmail(settings.EMAIL);
|
||||
setIsEmailValid(EMAIL_REGEX.test(settings.EMAIL));
|
||||
}
|
||||
}, [settings?.EMAIL]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pollingIntervalRef.current) {
|
||||
window.clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
|
||||
if (
|
||||
prevVerificationStatusRef.current === false &&
|
||||
settings?.EMAIL_VERIFIED === true
|
||||
) {
|
||||
// Display toast notification instead of setting state
|
||||
displaySuccessToast(t("SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY"));
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
prevVerificationStatusRef.current = settings?.EMAIL_VERIFIED;
|
||||
|
||||
if (settings?.EMAIL_VERIFIED === false) {
|
||||
pollingIntervalRef.current = window.setInterval(() => {
|
||||
refetch();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (pollingIntervalRef.current) {
|
||||
window.clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [settings?.EMAIL_VERIFIED, refetch, queryClient, t]);
|
||||
|
||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newEmail = e.target.value;
|
||||
setEmail(newEmail);
|
||||
setIsEmailValid(EMAIL_REGEX.test(newEmail));
|
||||
};
|
||||
|
||||
const handleSaveEmail = async () => {
|
||||
if (email === originalEmail || !isEmailValid) return;
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await openHands.post("/api/email", { email }, { withCredentials: true });
|
||||
setOriginalEmail(email);
|
||||
// Display toast notification instead of setting state
|
||||
displaySuccessToast(t("SETTINGS$EMAIL_SAVED_SUCCESSFULLY"));
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(t("SETTINGS$FAILED_TO_SAVE_EMAIL"), error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendVerification = async () => {
|
||||
try {
|
||||
setIsResendingVerification(true);
|
||||
await openHands.put("/api/email/verify", {}, { withCredentials: true });
|
||||
// Display toast notification instead of setting state
|
||||
displaySuccessToast(t("SETTINGS$VERIFICATION_EMAIL_SENT"));
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(t("SETTINGS$FAILED_TO_RESEND_VERIFICATION"), error);
|
||||
} finally {
|
||||
setIsResendingVerification(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isEmailChanged = email !== originalEmail;
|
||||
|
||||
return (
|
||||
<div data-testid="user-settings-screen" className="flex flex-col h-full">
|
||||
<div className="p-9 flex flex-col gap-6">
|
||||
{isLoading ? (
|
||||
<div className="animate-pulse h-8 w-64 bg-tertiary rounded" />
|
||||
) : (
|
||||
<EmailInputSection
|
||||
email={email}
|
||||
onEmailChange={handleEmailChange}
|
||||
onSaveEmail={handleSaveEmail}
|
||||
onResendVerification={handleResendVerification}
|
||||
isSaving={isSaving}
|
||||
isResendingVerification={isResendingVerification}
|
||||
isEmailChanged={isEmailChanged}
|
||||
emailVerified={settings?.EMAIL_VERIFIED}
|
||||
isEmailValid={isEmailValid}
|
||||
>
|
||||
{settings?.EMAIL_VERIFIED === false && <VerificationAlert />}
|
||||
</EmailInputSection>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserSettingsScreen;
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { RootState } from "#/store";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { useVSCodeUrl } from "#/hooks/query/use-vscode-url";
|
||||
import { VSCODE_IN_NEW_TAB } from "#/utils/feature-flags";
|
||||
|
||||
function VSCodeTab() {
|
||||
const { t } = useTranslation();
|
||||
@@ -12,6 +13,31 @@ function VSCodeTab() {
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
|
||||
const iframeRef = React.useRef<HTMLIFrameElement>(null);
|
||||
const [isCrossProtocol, setIsCrossProtocol] = useState(false);
|
||||
const [iframeError, setIframeError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.url) {
|
||||
try {
|
||||
const iframeProtocol = new URL(data.url).protocol;
|
||||
const currentProtocol = window.location.protocol;
|
||||
|
||||
// Check if the iframe URL has a different protocol than the current page
|
||||
setIsCrossProtocol(
|
||||
VSCODE_IN_NEW_TAB() || iframeProtocol !== currentProtocol,
|
||||
);
|
||||
} catch (e) {
|
||||
// Silently handle URL parsing errors
|
||||
setIframeError(t("VSCODE$URL_PARSE_ERROR"));
|
||||
}
|
||||
}
|
||||
}, [data?.url]);
|
||||
|
||||
const handleOpenInNewTab = () => {
|
||||
if (data?.url) {
|
||||
window.open(data.url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
};
|
||||
|
||||
if (isRuntimeInactive) {
|
||||
return (
|
||||
@@ -29,14 +55,36 @@ function VSCodeTab() {
|
||||
);
|
||||
}
|
||||
|
||||
if (error || (data && data.error) || !data?.url) {
|
||||
if (error || (data && data.error) || !data?.url || iframeError) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
|
||||
{data?.error || String(error) || t(I18nKey.VSCODE$URL_NOT_AVAILABLE)}
|
||||
{iframeError ||
|
||||
data?.error ||
|
||||
String(error) ||
|
||||
t(I18nKey.VSCODE$URL_NOT_AVAILABLE)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If cross-origin, show a button to open in new tab
|
||||
if (isCrossProtocol) {
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
|
||||
<div className="text-xl text-tertiary-light text-center max-w-md">
|
||||
{t("VSCODE$CROSS_ORIGIN_WARNING")}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenInNewTab}
|
||||
className="px-4 py-2 bg-primary text-white rounded hover:bg-primary-dark transition-colors"
|
||||
>
|
||||
{t("VSCODE$OPEN_IN_NEW_TAB")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If same origin, use the iframe
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<iframe
|
||||
@@ -44,7 +92,7 @@ function VSCodeTab() {
|
||||
title={t(I18nKey.VSCODE$TITLE)}
|
||||
src={data.url}
|
||||
className="w-full h-full border-0"
|
||||
allow={t(I18nKey.VSCODE$IFRAME_PERMISSIONS)}
|
||||
allow="clipboard-read; clipboard-write"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,8 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
ENABLE_PROACTIVE_CONVERSATION_STARTERS: false,
|
||||
SEARCH_API_KEY: "",
|
||||
IS_NEW_USER: true,
|
||||
EMAIL: "",
|
||||
EMAIL_VERIFIED: true, // Default to true to avoid restricting access unnecessarily
|
||||
MCP_CONFIG: {
|
||||
sse_servers: [],
|
||||
stdio_servers: [],
|
||||
|
||||
@@ -45,6 +45,8 @@ export type Settings = {
|
||||
SEARCH_API_KEY?: string;
|
||||
IS_NEW_USER?: boolean;
|
||||
MCP_CONFIG?: MCPConfig;
|
||||
EMAIL?: string;
|
||||
EMAIL_VERIFIED?: boolean;
|
||||
};
|
||||
|
||||
export type ApiSettings = {
|
||||
@@ -68,6 +70,8 @@ export type ApiSettings = {
|
||||
sse_servers: (string | MCPSSEServer)[];
|
||||
stdio_servers: MCPStdioServer[];
|
||||
};
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
};
|
||||
|
||||
export type PostSettings = Settings & {
|
||||
|
||||
@@ -14,5 +14,6 @@ export function loadFeatureFlag(
|
||||
|
||||
export const BILLING_SETTINGS = () => loadFeatureFlag("BILLING_SETTINGS");
|
||||
export const HIDE_LLM_SETTINGS = () => loadFeatureFlag("HIDE_LLM_SETTINGS");
|
||||
export const VSCODE_IN_NEW_TAB = () => loadFeatureFlag("VSCODE_IN_NEW_TAB");
|
||||
export const ENABLE_TRAJECTORY_REPLAY = () =>
|
||||
loadFeatureFlag("TRAJECTORY_REPLAY");
|
||||
|
||||
@@ -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 },
|
||||
@@ -30,7 +30,7 @@ export const TIPS: Tip[] = [
|
||||
},
|
||||
{
|
||||
key: I18nKey.TIPS$GITHUB_HOOK,
|
||||
link: "https://docs.all-hands.dev/usage/cloud/cloud-issue-resolver",
|
||||
link: "https://docs.all-hands.dev/usage/cloud/github-installation#working-on-github-issues-and-pull-requests-using-openhands",
|
||||
},
|
||||
{
|
||||
key: I18nKey.TIPS$BLOG_SIGNUP,
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -117,7 +117,10 @@ 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
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.
|
||||
+95
-26
@@ -12,7 +12,7 @@ from prompt_toolkit import PromptSession, print_formatted_text
|
||||
from prompt_toolkit.application import Application
|
||||
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
|
||||
from prompt_toolkit.document import Document
|
||||
from prompt_toolkit.formatted_text import HTML, FormattedText, StyleAndTextTuples
|
||||
from prompt_toolkit.formatted_text import HTML, StyleAndTextTuples
|
||||
from prompt_toolkit.input import create_input
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
|
||||
@@ -132,51 +132,113 @@ def display_initialization_animation(text: str, is_loaded: asyncio.Event) -> Non
|
||||
|
||||
|
||||
def display_banner(session_id: str) -> None:
|
||||
print_formatted_text(
|
||||
HTML(r"""<gold>
|
||||
banner_text = r"""<gold>
|
||||
___ _ _ _
|
||||
/ _ \ _ __ ___ _ __ | | | | __ _ _ __ __| |___
|
||||
| | | | '_ \ / _ \ '_ \| |_| |/ _` | '_ \ / _` / __|
|
||||
| |_| | |_) | __/ | | | _ | (_| | | | | (_| \__ \
|
||||
\___ /| .__/ \___|_| |_|_| |_|\__,_|_| |_|\__,_|___/
|
||||
|_|
|
||||
</gold>"""),
|
||||
style=DEFAULT_STYLE,
|
||||
</gold>"""
|
||||
|
||||
# Use TextArea with focusable=True to allow text selection
|
||||
banner_container = Frame(
|
||||
TextArea(
|
||||
text=banner_text.replace('<gold>', '').replace('</gold>', ''),
|
||||
read_only=True,
|
||||
style=COLOR_GOLD,
|
||||
wrap_lines=True,
|
||||
focusable=True, # Allow focusing to enable text selection
|
||||
),
|
||||
style=f'fg:{COLOR_GOLD}',
|
||||
)
|
||||
print_container(banner_container)
|
||||
|
||||
print_formatted_text(HTML(f'<grey>OpenHands CLI v{__version__}</grey>'))
|
||||
|
||||
# Call print_formatted_text to maintain compatibility with tests
|
||||
print_formatted_text('')
|
||||
print_formatted_text(HTML(f'<grey>Initialized conversation {session_id}</grey>'))
|
||||
|
||||
version_container = Frame(
|
||||
TextArea(
|
||||
text=f'OpenHands CLI v{__version__}',
|
||||
read_only=True,
|
||||
style=COLOR_GREY,
|
||||
wrap_lines=True,
|
||||
focusable=True, # Allow focusing to enable text selection
|
||||
),
|
||||
style=f'fg:{COLOR_GREY}',
|
||||
)
|
||||
print_container(version_container)
|
||||
|
||||
# Call print_formatted_text to maintain compatibility with tests
|
||||
print_formatted_text('')
|
||||
|
||||
session_container = Frame(
|
||||
TextArea(
|
||||
text=f'Initialized conversation {session_id}',
|
||||
read_only=True,
|
||||
style=COLOR_GREY,
|
||||
wrap_lines=True,
|
||||
focusable=True, # Allow focusing to enable text selection
|
||||
),
|
||||
style=f'fg:{COLOR_GREY}',
|
||||
)
|
||||
print_formatted_text('')
|
||||
print_container(session_container)
|
||||
print_formatted_text('')
|
||||
|
||||
|
||||
def display_welcome_message(message: str = '') -> None:
|
||||
print_formatted_text(
|
||||
HTML("<gold>Let's start building!</gold>\n"), style=DEFAULT_STYLE
|
||||
# Use TextArea with focusable=True to allow text selection
|
||||
welcome_container = Frame(
|
||||
TextArea(
|
||||
text="Let's start building!",
|
||||
read_only=True,
|
||||
style=COLOR_GOLD,
|
||||
wrap_lines=True,
|
||||
focusable=True, # Allow focusing to enable text selection
|
||||
),
|
||||
style=f'fg:{COLOR_GOLD}',
|
||||
)
|
||||
print_container(welcome_container)
|
||||
|
||||
# Call print_formatted_text to maintain compatibility with tests
|
||||
print_formatted_text('')
|
||||
|
||||
if message:
|
||||
print_formatted_text(
|
||||
HTML(f'{message} <grey>Type /help for help</grey>'),
|
||||
style=DEFAULT_STYLE,
|
||||
)
|
||||
message_text = f'{message} Type /help for help'
|
||||
else:
|
||||
print_formatted_text(
|
||||
HTML('What do you want to build? <grey>Type /help for help</grey>'),
|
||||
style=DEFAULT_STYLE,
|
||||
)
|
||||
message_text = 'What do you want to build? Type /help for help'
|
||||
|
||||
message_container = Frame(
|
||||
TextArea(
|
||||
text=message_text,
|
||||
read_only=True,
|
||||
style=COLOR_GREY,
|
||||
wrap_lines=True,
|
||||
focusable=True, # Allow focusing to enable text selection
|
||||
),
|
||||
style=f'fg:{COLOR_GREY}',
|
||||
)
|
||||
print_container(message_container)
|
||||
|
||||
# Call print_formatted_text to maintain compatibility with tests
|
||||
print_formatted_text('')
|
||||
|
||||
|
||||
def display_initial_user_prompt(prompt: str) -> None:
|
||||
print_formatted_text(
|
||||
FormattedText(
|
||||
[
|
||||
('', '\n'),
|
||||
(COLOR_GOLD, '> '),
|
||||
('', prompt),
|
||||
]
|
||||
)
|
||||
# Use TextArea with focusable=True to allow text selection
|
||||
prompt_container = Frame(
|
||||
TextArea(
|
||||
text=f'> {prompt}',
|
||||
read_only=True,
|
||||
style=COLOR_GOLD,
|
||||
wrap_lines=True,
|
||||
focusable=True, # Allow focusing to enable text selection
|
||||
),
|
||||
style=f'fg:{COLOR_GOLD}',
|
||||
)
|
||||
print_formatted_text('')
|
||||
print_container(prompt_container)
|
||||
|
||||
|
||||
# Prompt output display functions
|
||||
@@ -224,6 +286,7 @@ def display_error(error: str) -> None:
|
||||
read_only=True,
|
||||
style='ansired',
|
||||
wrap_lines=True,
|
||||
focusable=True, # Allow focusing to enable text selection
|
||||
),
|
||||
title='Error',
|
||||
style='ansired',
|
||||
@@ -239,6 +302,7 @@ def display_command(event: CmdRunAction) -> None:
|
||||
read_only=True,
|
||||
style=COLOR_GREY,
|
||||
wrap_lines=True,
|
||||
focusable=True, # Allow focusing to enable text selection
|
||||
),
|
||||
title='Command',
|
||||
style='ansiblue',
|
||||
@@ -267,6 +331,7 @@ def display_command_output(output: str) -> None:
|
||||
read_only=True,
|
||||
style=COLOR_GREY,
|
||||
wrap_lines=True,
|
||||
focusable=True, # Allow focusing to enable text selection
|
||||
),
|
||||
title='Command Output',
|
||||
style=f'fg:{COLOR_GREY}',
|
||||
@@ -282,6 +347,7 @@ def display_file_edit(event: FileEditObservation) -> None:
|
||||
read_only=True,
|
||||
wrap_lines=True,
|
||||
lexer=CustomDiffLexer(),
|
||||
focusable=True, # Allow focusing to enable text selection
|
||||
),
|
||||
title='File Edit',
|
||||
style=f'fg:{COLOR_GREY}',
|
||||
@@ -298,6 +364,7 @@ def display_file_read(event: FileReadObservation) -> None:
|
||||
read_only=True,
|
||||
style=COLOR_GREY,
|
||||
wrap_lines=True,
|
||||
focusable=True, # Allow focusing to enable text selection
|
||||
),
|
||||
title='File Read',
|
||||
style=f'fg:{COLOR_GREY}',
|
||||
@@ -316,6 +383,7 @@ def initialize_streaming_output():
|
||||
read_only=True,
|
||||
style=COLOR_GREY,
|
||||
wrap_lines=True,
|
||||
focusable=True, # Allow focusing to enable text selection
|
||||
)
|
||||
container = Frame(
|
||||
streaming_output_text_area,
|
||||
@@ -425,6 +493,7 @@ def display_usage_metrics(usage_metrics: UsageMetrics) -> None:
|
||||
read_only=True,
|
||||
style=COLOR_GREY,
|
||||
wrap_lines=True,
|
||||
focusable=True, # Allow focusing to enable text selection
|
||||
),
|
||||
title='Usage Metrics',
|
||||
style=f'fg:{COLOR_GREY}',
|
||||
|
||||
@@ -73,7 +73,10 @@ class SandboxConfig(BaseModel):
|
||||
runtime_startup_env_vars: dict[str, str] = Field(default_factory=dict)
|
||||
browsergym_eval_env: str | None = Field(default=None)
|
||||
platform: str | None = Field(default=None)
|
||||
close_delay: int = Field(default=15)
|
||||
close_delay: int = Field(
|
||||
default=3600,
|
||||
description='The delay in seconds before closing the sandbox after the agent is done.',
|
||||
)
|
||||
remote_runtime_resource_factor: int = Field(default=1)
|
||||
enable_gpu: bool = Field(default=False)
|
||||
docker_runtime_kwargs: dict | None = Field(default=None)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import os
|
||||
|
||||
from openhands.server.session.conversation_init_data import ConversationInitData
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
|
||||
class ExperimentManager:
|
||||
@staticmethod
|
||||
def run_conversation_variant_test(
|
||||
user_id: str, conversation_id: str, conversation_settings: ConversationInitData
|
||||
) -> ConversationInitData:
|
||||
return conversation_settings
|
||||
|
||||
|
||||
experiment_manager_cls = os.environ.get(
|
||||
'OPENHANDS_EXPERIMENT_MANAGER_CLS',
|
||||
'openhands.experiments.experiment_manager.ExperimentManager',
|
||||
)
|
||||
ExperimentManagerImpl = get_impl(ExperimentManager, experiment_manager_cls)
|
||||
@@ -483,35 +483,30 @@ class GitHubService(BaseGitService, GitService):
|
||||
- PR URL when successful
|
||||
- Error message when unsuccessful
|
||||
"""
|
||||
try:
|
||||
url = f'{self.BASE_URL}/repos/{repo_name}/pulls'
|
||||
|
||||
# Set default body if none provided
|
||||
if not body:
|
||||
body = f'Merging changes from {source_branch} into {target_branch}'
|
||||
url = f'{self.BASE_URL}/repos/{repo_name}/pulls'
|
||||
|
||||
# Prepare the request payload
|
||||
payload = {
|
||||
'title': title,
|
||||
'head': source_branch,
|
||||
'base': target_branch,
|
||||
'body': body,
|
||||
'draft': draft,
|
||||
}
|
||||
# Set default body if none provided
|
||||
if not body:
|
||||
body = f'Merging changes from {source_branch} into {target_branch}'
|
||||
|
||||
# Make the POST request to create the PR
|
||||
response, _ = await self._make_request(
|
||||
url=url, params=payload, method=RequestMethod.POST
|
||||
)
|
||||
# Prepare the request payload
|
||||
payload = {
|
||||
'title': title,
|
||||
'head': source_branch,
|
||||
'base': target_branch,
|
||||
'body': body,
|
||||
'draft': draft,
|
||||
}
|
||||
|
||||
# Return the HTML URL of the created PR
|
||||
if 'html_url' in response:
|
||||
return response['html_url']
|
||||
else:
|
||||
return f'PR created but URL not found in response: {response}'
|
||||
# Make the POST request to create the PR
|
||||
response, _ = await self._make_request(
|
||||
url=url, params=payload, method=RequestMethod.POST
|
||||
)
|
||||
|
||||
# Return the HTML URL of the created PR
|
||||
return response['html_url']
|
||||
|
||||
except Exception as e:
|
||||
return f'Error creating pull request: {str(e)}'
|
||||
|
||||
|
||||
github_service_cls = os.environ.get(
|
||||
|
||||
@@ -476,38 +476,33 @@ class GitLabService(BaseGitService, GitService):
|
||||
- MR URL when successful
|
||||
- Error message when unsuccessful
|
||||
"""
|
||||
try:
|
||||
# Convert string ID to URL-encoded path if needed
|
||||
project_id = str(id).replace('/', '%2F') if isinstance(id, str) else id
|
||||
url = f'{self.BASE_URL}/projects/{project_id}/merge_requests'
|
||||
|
||||
# Set default description if none provided
|
||||
if not description:
|
||||
description = (
|
||||
f'Merging changes from {source_branch} into {target_branch}'
|
||||
)
|
||||
# Convert string ID to URL-encoded path if needed
|
||||
project_id = str(id).replace('/', '%2F') if isinstance(id, str) else id
|
||||
url = f'{self.BASE_URL}/projects/{project_id}/merge_requests'
|
||||
|
||||
# Prepare the request payload
|
||||
payload = {
|
||||
'source_branch': source_branch,
|
||||
'target_branch': target_branch,
|
||||
'title': title,
|
||||
'description': description,
|
||||
}
|
||||
|
||||
# Make the POST request to create the MR
|
||||
response, _ = await self._make_request(
|
||||
url=url, params=payload, method=RequestMethod.POST
|
||||
# Set default description if none provided
|
||||
if not description:
|
||||
description = (
|
||||
f'Merging changes from {source_branch} into {target_branch}'
|
||||
)
|
||||
|
||||
# Return the web URL of the created MR
|
||||
if 'web_url' in response:
|
||||
return response['web_url']
|
||||
else:
|
||||
return f'MR created but URL not found in response: {response}'
|
||||
# Prepare the request payload
|
||||
payload = {
|
||||
'source_branch': source_branch,
|
||||
'target_branch': target_branch,
|
||||
'title': title,
|
||||
'description': description,
|
||||
}
|
||||
|
||||
# Make the POST request to create the MR
|
||||
response, _ = await self._make_request(
|
||||
url=url, params=payload, method=RequestMethod.POST
|
||||
)
|
||||
|
||||
|
||||
return response['web_url']
|
||||
|
||||
except Exception as e:
|
||||
return f'Error creating merge request: {str(e)}'
|
||||
|
||||
|
||||
gitlab_service_cls = os.environ.get(
|
||||
|
||||
@@ -167,11 +167,11 @@ class BaseGitService(ABC):
|
||||
return RateLimitError('GitHub API rate limit exceeded')
|
||||
|
||||
logger.warning(f'Status error on {self.provider} API: {e}')
|
||||
return UnknownException('Unknown error')
|
||||
return UnknownException(f'Unknown error: {e}')
|
||||
|
||||
def handle_http_error(self, e: HTTPError) -> UnknownException:
|
||||
logger.warning(f'HTTP error on {self.provider} API: {type(e).__name__} : {e}')
|
||||
return UnknownException(f'HTTP error {type(e).__name__}')
|
||||
return UnknownException(f'HTTP error {type(e).__name__} : {e}')
|
||||
|
||||
|
||||
class GitService(Protocol):
|
||||
|
||||
-1
@@ -15,4 +15,3 @@ When you're done, make sure to
|
||||
2. Use the `create_pr` tool to open a new PR
|
||||
3. Name the branch using `openhands/` as a prefix (e.g `openhands/update-readme`)
|
||||
4. The PR description should mention that it "fixes" or "closes" the issue number
|
||||
5. Make sure to leave the following sentence at the end of the PR description: `@{{ username }} can click here to [continue refining the PR]({{ conversation_url }})`
|
||||
|
||||
-1
@@ -9,4 +9,3 @@ When you're done, make sure to
|
||||
|
||||
1. Use the `create_pr` tool to open a new PR
|
||||
2. The PR description should mention that it "fixes" or "closes" the issue number
|
||||
3. Make sure to leave the following sentence at the end of the PR description: `@{{ username }} can click here to [continue refining the PR]({{ conversation_url }})`
|
||||
|
||||
-1
@@ -15,4 +15,3 @@ When you're done, make sure to
|
||||
2. Use the `create_mr` tool to open a new MR
|
||||
3. Name the branch using `openhands/` as a prefix (e.g `openhands/update-readme`)
|
||||
4. The MR description should mention that it "fixes" or "closes" the issue number
|
||||
5. Make sure to leave the following sentence at the end of the MR description: `@{{ username }} can click here to [continue refining the MR]({{ conversation_url }})`
|
||||
|
||||
-1
@@ -9,4 +9,3 @@ When you're done, make sure to
|
||||
|
||||
1. Use the `create_mr` tool to open a new MR
|
||||
2. The MR description should mention that it "fixes" or "closes" the issue number
|
||||
3. Make sure to leave the following sentence at the end of the MR description: `@{{ username }} can click here to [continue refining the MR]({{ conversation_url }})`
|
||||
|
||||
-4
@@ -5,7 +5,3 @@ These are a list of text messages attached in order of most recent.
|
||||
{{ message }}
|
||||
{% if not loop.last %}\n\n{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
|
||||
If you opened a pull request, please leave the following comment at the end your summary and pull request description
|
||||
`{{ username }} can click here to [continue refining the PR]({{ conversation_url }})`
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
Please summarize your work.
|
||||
Please send a final message summarizing your work.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
+66
-170
@@ -1,13 +1,12 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
from contextlib import AsyncExitStack
|
||||
from typing import Optional
|
||||
|
||||
from mcp import ClientSession
|
||||
from mcp.client.sse import sse_client
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
from fastmcp import Client
|
||||
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
|
||||
from mcp import McpError
|
||||
from mcp.types import CallToolResult
|
||||
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
|
||||
|
||||
@@ -17,8 +16,7 @@ class MCPClient(BaseModel):
|
||||
A collection of tools that connects to an MCP server and manages available tools through the Model Context Protocol.
|
||||
"""
|
||||
|
||||
session: Optional[ClientSession] = None
|
||||
exit_stack: AsyncExitStack = AsyncExitStack()
|
||||
client: Optional[Client] = None
|
||||
description: str = 'MCP client tools for server interaction'
|
||||
tools: list[MCPClientTool] = Field(default_factory=list)
|
||||
tool_map: dict[str, MCPClientTool] = Field(default_factory=dict)
|
||||
@@ -26,189 +24,87 @@ 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.session:
|
||||
if not self.client:
|
||||
raise RuntimeError('Session not initialized.')
|
||||
|
||||
await self.session.initialize()
|
||||
response = await self.session.list_tools()
|
||||
async with self.client:
|
||||
tools = await self.client.list_tools()
|
||||
|
||||
# Clear existing tools
|
||||
self.tools = []
|
||||
|
||||
# Create proper tool objects for each server tool
|
||||
for tool in response.tools:
|
||||
for tool in tools:
|
||||
server_tool = MCPClientTool(
|
||||
name=tool.name,
|
||||
description=tool.description,
|
||||
inputSchema=tool.inputSchema,
|
||||
session=self.session,
|
||||
session=self.client,
|
||||
)
|
||||
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 response.tools]}'
|
||||
)
|
||||
logger.info(f'Connected to server with tools: {[tool.name for tool in tools]}')
|
||||
|
||||
async def call_tool(self, tool_name: str, args: dict):
|
||||
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:
|
||||
"""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.session:
|
||||
if not self.client:
|
||||
raise RuntimeError('Client session is not available.')
|
||||
return await self.session.call_tool(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')
|
||||
async with self.client:
|
||||
return await self.client.call_tool_mcp(name=tool_name, arguments=args)
|
||||
|
||||
+4
-27
@@ -72,38 +72,22 @@ async def create_mcp_clients(
|
||||
mcp_clients = []
|
||||
|
||||
for server in servers:
|
||||
is_sse = isinstance(server, MCPSSEServerConfig)
|
||||
connection_type = 'SSE' if is_sse else 'SHTTP'
|
||||
is_shttp = isinstance(server, MCPSHTTPServerConfig)
|
||||
connection_type = 'SHTTP' if is_shttp else 'SSE'
|
||||
logger.info(
|
||||
f'Initializing MCP agent for {server} with {connection_type} connection...'
|
||||
)
|
||||
client = MCPClient()
|
||||
|
||||
try:
|
||||
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,
|
||||
)
|
||||
await client.connect_http(server, 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
|
||||
|
||||
|
||||
@@ -143,13 +127,6 @@ 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 []
|
||||
|
||||
@@ -50,6 +50,8 @@ class View(BaseModel):
|
||||
for event in events:
|
||||
if isinstance(event, CondensationAction):
|
||||
forgotten_event_ids.update(event.forgotten)
|
||||
# Make sure we also forget the condensation action itself
|
||||
forgotten_event_ids.add(event.id)
|
||||
|
||||
kept_events = [event for event in events if event.id not in forgotten_event_ids]
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import io
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
@@ -9,7 +10,7 @@ from openhands.core.exceptions import (
|
||||
MicroagentValidationError,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.microagent.types import MicroagentMetadata, MicroagentType
|
||||
from openhands.microagent.types import InputMetadata, MicroagentMetadata, MicroagentType
|
||||
|
||||
|
||||
class BaseMicroagent(BaseModel):
|
||||
@@ -91,13 +92,24 @@ class BaseMicroagent(BaseModel):
|
||||
subclass_map = {
|
||||
MicroagentType.KNOWLEDGE: KnowledgeMicroagent,
|
||||
MicroagentType.REPO_KNOWLEDGE: RepoMicroagent,
|
||||
MicroagentType.TASK: TaskMicroagent,
|
||||
}
|
||||
|
||||
# Infer the agent type:
|
||||
# 1. If triggers exist -> KNOWLEDGE (optional)
|
||||
# 2. Else (no triggers) -> REPO (always active)
|
||||
# 1. If inputs exist -> TASK
|
||||
# 2. If triggers exist -> KNOWLEDGE
|
||||
# 3. Else (no triggers) -> REPO (always active)
|
||||
inferred_type: MicroagentType
|
||||
if metadata.triggers:
|
||||
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:
|
||||
inferred_type = MicroagentType.KNOWLEDGE
|
||||
else:
|
||||
# No triggers, default to REPO
|
||||
@@ -122,7 +134,9 @@ 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
|
||||
@@ -131,8 +145,8 @@ class KnowledgeMicroagent(BaseMicroagent):
|
||||
|
||||
def __init__(self, **data):
|
||||
super().__init__(**data)
|
||||
if self.type != MicroagentType.KNOWLEDGE:
|
||||
raise ValueError('KnowledgeMicroagent must have type KNOWLEDGE')
|
||||
if self.type not in [MicroagentType.KNOWLEDGE, MicroagentType.TASK]:
|
||||
raise ValueError('KnowledgeMicroagent must have type KNOWLEDGE or TASK')
|
||||
|
||||
def match_trigger(self, message: str) -> str | None:
|
||||
"""Match a trigger in the message.
|
||||
@@ -171,6 +185,57 @@ 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]]:
|
||||
@@ -182,7 +247,7 @@ def load_microagents_from_dir(
|
||||
microagent_dir: Path to the microagents directory (e.g. .openhands/microagents)
|
||||
|
||||
Returns:
|
||||
Tuple of (repo_agents, knowledge_agents, task_agents) dictionaries
|
||||
Tuple of (repo_agents, knowledge_agents) dictionaries
|
||||
"""
|
||||
if isinstance(microagent_dir, str):
|
||||
microagent_dir = Path(microagent_dir)
|
||||
@@ -202,6 +267,7 @@ 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
|
||||
|
||||
@@ -12,6 +12,14 @@ 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):
|
||||
@@ -22,6 +30,7 @@ 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
|
||||
)
|
||||
|
||||
@@ -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,21 +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.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
|
||||
|
||||
@@ -254,12 +249,10 @@ class ActionExecutor:
|
||||
# If we get here, the browser is ready
|
||||
logger.debug('Browser is ready')
|
||||
|
||||
async def ainit(self):
|
||||
# bash needs to be initialized first
|
||||
logger.debug('Initializing bash session')
|
||||
def _create_bash_session(self, cwd: str | None = None):
|
||||
if sys.platform == 'win32':
|
||||
self.bash_session = WindowsPowershellSession( # type: ignore[name-defined]
|
||||
work_dir=self._initial_cwd,
|
||||
return WindowsPowershellSession( # type: ignore[name-defined]
|
||||
work_dir=cwd or self._initial_cwd,
|
||||
username=self.username,
|
||||
no_change_timeout_seconds=int(
|
||||
os.environ.get('NO_CHANGE_TIMEOUT_SECONDS', 10)
|
||||
@@ -267,15 +260,21 @@ class ActionExecutor:
|
||||
max_memory_mb=self.max_memory_gb * 1024 if self.max_memory_gb else None,
|
||||
)
|
||||
else:
|
||||
self.bash_session = BashSession(
|
||||
work_dir=self._initial_cwd,
|
||||
bash_session = BashSession(
|
||||
work_dir=cwd or self._initial_cwd,
|
||||
username=self.username,
|
||||
no_change_timeout_seconds=int(
|
||||
os.environ.get('NO_CHANGE_TIMEOUT_SECONDS', 10)
|
||||
),
|
||||
max_memory_mb=self.max_memory_gb * 1024 if self.max_memory_gb else None,
|
||||
)
|
||||
self.bash_session.initialize()
|
||||
bash_session.initialize()
|
||||
return bash_session
|
||||
|
||||
async def ainit(self):
|
||||
# bash needs to be initialized first
|
||||
logger.debug('Initializing bash session')
|
||||
self.bash_session = self._create_bash_session()
|
||||
logger.debug('Bash session initialized')
|
||||
|
||||
# Start browser initialization in the background
|
||||
@@ -388,18 +387,11 @@ class ActionExecutor:
|
||||
self, action: CmdRunAction
|
||||
) -> CmdOutputObservation | ErrorObservation:
|
||||
try:
|
||||
bash_session = self.bash_session
|
||||
if action.is_static:
|
||||
path = action.cwd or self._initial_cwd
|
||||
result = await AsyncBashSession.execute(action.command, path)
|
||||
obs = CmdOutputObservation(
|
||||
content=result.content,
|
||||
exit_code=result.exit_code,
|
||||
command=action.command,
|
||||
)
|
||||
return obs
|
||||
|
||||
assert self.bash_session is not None
|
||||
obs = await call_sync_from_async(self.bash_session.execute, action)
|
||||
bash_session = self._create_bash_session(action.cwd)
|
||||
assert bash_session is not None
|
||||
obs = await call_sync_from_async(bash_session.execute, action)
|
||||
return obs
|
||||
except Exception as e:
|
||||
logger.error(f'Error running command: {e}')
|
||||
@@ -475,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)
|
||||
@@ -485,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)
|
||||
@@ -501,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(
|
||||
@@ -534,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)
|
||||
@@ -658,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,
|
||||
@@ -680,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:
|
||||
@@ -828,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(
|
||||
@@ -842,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()
|
||||
@@ -860,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,
|
||||
@@ -919,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
|
||||
@@ -932,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}')
|
||||
|
||||
|
||||
@@ -400,7 +400,7 @@ class Runtime(FileEditRuntimeMixin):
|
||||
'No repository selected. Initializing a new git repository in the workspace.'
|
||||
)
|
||||
action = CmdRunAction(
|
||||
command='git init',
|
||||
command=f'git init && git config --global --add safe.directory {self.workspace_root}'
|
||||
)
|
||||
self.run_action(action)
|
||||
else:
|
||||
@@ -952,6 +952,9 @@ fi
|
||||
exit_code = 0
|
||||
content = ''
|
||||
|
||||
if isinstance(obs, ErrorObservation):
|
||||
exit_code = -1
|
||||
|
||||
if hasattr(obs, 'exit_code'):
|
||||
exit_code = obs.exit_code
|
||||
if hasattr(obs, 'content'):
|
||||
|
||||
@@ -406,7 +406,7 @@ class ActionExecutionClient(Runtime):
|
||||
'POST',
|
||||
f'{self.action_execution_server_url}/update_mcp_server',
|
||||
json=stdio_tools,
|
||||
timeout=10,
|
||||
timeout=60,
|
||||
)
|
||||
result = response.json()
|
||||
if response.status_code != 200:
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -464,16 +464,13 @@ 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:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from typing import Callable
|
||||
import typing
|
||||
from uuid import UUID
|
||||
|
||||
import docker
|
||||
@@ -41,7 +42,7 @@ APP_PORT_RANGE_1 = (50000, 54999)
|
||||
APP_PORT_RANGE_2 = (55000, 59999)
|
||||
|
||||
|
||||
def _is_retryablewait_until_alive_error(exception):
|
||||
def _is_retryablewait_until_alive_error(exception: Exception) -> bool:
|
||||
if isinstance(exception, tenacity.RetryError):
|
||||
cause = exception.last_attempt.exception()
|
||||
return _is_retryablewait_until_alive_error(cause)
|
||||
@@ -140,10 +141,10 @@ class DockerRuntime(ActionExecutionClient):
|
||||
)
|
||||
|
||||
@property
|
||||
def action_execution_server_url(self):
|
||||
def action_execution_server_url(self) -> str:
|
||||
return self.api_url
|
||||
|
||||
async def connect(self):
|
||||
async def connect(self) -> None:
|
||||
self.send_status_message('STATUS$STARTING_RUNTIME')
|
||||
try:
|
||||
await call_sync_from_async(self._attach_to_container)
|
||||
@@ -164,7 +165,7 @@ class DockerRuntime(ActionExecutionClient):
|
||||
f'Container started: {self.container_name}. VSCode URL: {self.vscode_url}',
|
||||
)
|
||||
|
||||
if DEBUG_RUNTIME:
|
||||
if DEBUG_RUNTIME and self.container:
|
||||
self.log_streamer = LogStreamer(self.container, self.log)
|
||||
else:
|
||||
self.log_streamer = None
|
||||
@@ -264,7 +265,7 @@ class DockerRuntime(ActionExecutionClient):
|
||||
|
||||
return volumes
|
||||
|
||||
def init_container(self):
|
||||
def init_container(self) -> None:
|
||||
self.log('debug', 'Preparing to start container...')
|
||||
self.send_status_message('STATUS$PREPARING_CONTAINER')
|
||||
self._host_port = self._find_available_port(EXECUTION_SERVER_PORT_RANGE)
|
||||
@@ -281,7 +282,7 @@ class DockerRuntime(ActionExecutionClient):
|
||||
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
|
||||
|
||||
use_host_network = self.config.sandbox.use_host_network
|
||||
network_mode: str | None = 'host' if use_host_network else None
|
||||
network_mode: typing.Literal['host'] | None = 'host' if use_host_network else None
|
||||
|
||||
# Initialize port mappings
|
||||
port_mapping: dict[str, list[dict[str, str]]] | None = None
|
||||
@@ -317,15 +318,18 @@ class DockerRuntime(ActionExecutionClient):
|
||||
)
|
||||
|
||||
# Combine environment variables
|
||||
environment = {
|
||||
'port': str(self._container_port),
|
||||
'PYTHONUNBUFFERED': '1',
|
||||
# Passing in the ports means nested runtimes do not come up with their own ports!
|
||||
'VSCODE_PORT': str(self._vscode_port),
|
||||
'APP_PORT_1': self._app_ports[0],
|
||||
'APP_PORT_2': self._app_ports[1],
|
||||
'PIP_BREAK_SYSTEM_PACKAGES': '1',
|
||||
}
|
||||
environment = dict(**self.initial_env_vars)
|
||||
environment.update(
|
||||
{
|
||||
'port': str(self._container_port),
|
||||
'PYTHONUNBUFFERED': '1',
|
||||
# Passing in the ports means nested runtimes do not come up with their own ports!
|
||||
'VSCODE_PORT': str(self._vscode_port),
|
||||
'APP_PORT_1': str(self._app_ports[0]),
|
||||
'APP_PORT_2': str(self._app_ports[1]),
|
||||
'PIP_BREAK_SYSTEM_PACKAGES': '1',
|
||||
}
|
||||
)
|
||||
if self.config.debug or DEBUG:
|
||||
environment['DEBUG'] = 'true'
|
||||
# also update with runtime_startup_env_vars
|
||||
@@ -350,6 +354,8 @@ class DockerRuntime(ActionExecutionClient):
|
||||
command = self.get_action_execution_server_startup_command()
|
||||
|
||||
try:
|
||||
if self.runtime_container_image is None:
|
||||
raise ValueError("Runtime container image is not set")
|
||||
self.container = self.docker_client.containers.run(
|
||||
self.runtime_container_image,
|
||||
command=command,
|
||||
@@ -361,7 +367,7 @@ class DockerRuntime(ActionExecutionClient):
|
||||
name=self.container_name,
|
||||
detach=True,
|
||||
environment=environment,
|
||||
volumes=volumes,
|
||||
volumes=volumes, # type: ignore
|
||||
device_requests=(
|
||||
[docker.types.DeviceRequest(capabilities=[['gpu']], count=-1)]
|
||||
if self.config.sandbox.enable_gpu
|
||||
@@ -371,32 +377,15 @@ class DockerRuntime(ActionExecutionClient):
|
||||
)
|
||||
self.log('debug', f'Container started. Server url: {self.api_url}')
|
||||
self.send_status_message('STATUS$CONTAINER_STARTED')
|
||||
except docker.errors.APIError as e:
|
||||
if '409' in str(e):
|
||||
self.log(
|
||||
'warning',
|
||||
f'Container {self.container_name} already exists. Removing...',
|
||||
)
|
||||
stop_all_containers(self.container_name)
|
||||
return self.init_container()
|
||||
|
||||
else:
|
||||
self.log(
|
||||
'error',
|
||||
f'Error: Instance {self.container_name} FAILED to start container!\n',
|
||||
)
|
||||
self.log('error', str(e))
|
||||
raise e
|
||||
except Exception as e:
|
||||
self.log(
|
||||
'error',
|
||||
f'Error: Instance {self.container_name} FAILED to start container!\n',
|
||||
)
|
||||
self.log('error', str(e))
|
||||
self.close()
|
||||
raise e
|
||||
|
||||
def _attach_to_container(self):
|
||||
def _attach_to_container(self) -> None:
|
||||
self.container = self.docker_client.containers.get(self.container_name)
|
||||
if self.container.status == 'exited':
|
||||
self.container.start()
|
||||
@@ -432,7 +421,7 @@ class DockerRuntime(ActionExecutionClient):
|
||||
reraise=True,
|
||||
wait=tenacity.wait_fixed(2),
|
||||
)
|
||||
def wait_until_alive(self):
|
||||
def wait_until_alive(self) -> None:
|
||||
try:
|
||||
container = self.docker_client.containers.get(self.container_name)
|
||||
if container.status == 'exited':
|
||||
@@ -446,7 +435,7 @@ class DockerRuntime(ActionExecutionClient):
|
||||
|
||||
self.check_if_alive()
|
||||
|
||||
def close(self, rm_all_containers: bool | None = None):
|
||||
def close(self, rm_all_containers: bool | None = None) -> None:
|
||||
"""Closes the DockerRuntime and associated objects
|
||||
|
||||
Parameters:
|
||||
@@ -466,7 +455,7 @@ class DockerRuntime(ActionExecutionClient):
|
||||
)
|
||||
stop_all_containers(close_prefix)
|
||||
|
||||
def _is_port_in_use_docker(self, port):
|
||||
def _is_port_in_use_docker(self, port: int) -> bool:
|
||||
containers = self.docker_client.containers.list()
|
||||
for container in containers:
|
||||
container_ports = container.ports
|
||||
@@ -474,7 +463,9 @@ class DockerRuntime(ActionExecutionClient):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _find_available_port(self, port_range, max_attempts=5):
|
||||
def _find_available_port(
|
||||
self, port_range: tuple[int, int], max_attempts: int = 5
|
||||
) -> int:
|
||||
port = port_range[1]
|
||||
for _ in range(max_attempts):
|
||||
port = find_available_tcp_port(port_range[0], port_range[1])
|
||||
@@ -493,7 +484,7 @@ class DockerRuntime(ActionExecutionClient):
|
||||
return vscode_url
|
||||
|
||||
@property
|
||||
def web_hosts(self):
|
||||
def web_hosts(self) -> dict[str, int]:
|
||||
hosts: dict[str, int] = {}
|
||||
|
||||
host_addr = os.environ.get('DOCKER_HOST_ADDR', 'localhost')
|
||||
@@ -502,7 +493,7 @@ class DockerRuntime(ActionExecutionClient):
|
||||
|
||||
return hosts
|
||||
|
||||
def pause(self):
|
||||
def pause(self) -> None:
|
||||
"""Pause the runtime by stopping the container.
|
||||
This is different from container.stop() as it ensures environment variables are properly preserved."""
|
||||
if not self.container:
|
||||
@@ -515,7 +506,7 @@ class DockerRuntime(ActionExecutionClient):
|
||||
self.container.stop()
|
||||
self.log('debug', f'Container {self.container_name} paused')
|
||||
|
||||
def resume(self):
|
||||
def resume(self) -> None:
|
||||
"""Resume the runtime by starting the container.
|
||||
This is different from container.start() as it ensures environment variables are properly restored."""
|
||||
if not self.container:
|
||||
@@ -529,7 +520,7 @@ class DockerRuntime(ActionExecutionClient):
|
||||
self.wait_until_alive()
|
||||
|
||||
@classmethod
|
||||
async def delete(cls, conversation_id: str):
|
||||
async def delete(cls, conversation_id: str) -> None:
|
||||
docker_client = cls._init_docker_client()
|
||||
try:
|
||||
container_name = CONTAINER_NAME_PREFIX + conversation_id
|
||||
@@ -542,7 +533,7 @@ class DockerRuntime(ActionExecutionClient):
|
||||
finally:
|
||||
docker_client.close()
|
||||
|
||||
def get_action_execution_server_startup_command(self):
|
||||
def get_action_execution_server_startup_command(self) -> list[str]:
|
||||
return get_action_execution_server_startup_command(
|
||||
server_port=self._container_port,
|
||||
plugins=self.plugins,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
{
|
||||
"default": []
|
||||
"mcpServers": {
|
||||
"default": {}
|
||||
},
|
||||
"tools": []
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
MCP Proxy module for OpenHands.
|
||||
"""
|
||||
|
||||
from openhands.runtime.mcp.proxy.manager import MCPProxyManager
|
||||
|
||||
__all__ = ['MCPProxyManager']
|
||||
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
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)
|
||||
@@ -1,54 +0,0 @@
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from openhands.runtime.base import CommandResult
|
||||
|
||||
|
||||
class AsyncBashSession:
|
||||
@staticmethod
|
||||
async def execute(command: str, work_dir: str) -> CommandResult:
|
||||
"""Execute a command in the bash session asynchronously."""
|
||||
work_dir = os.path.abspath(work_dir)
|
||||
|
||||
if not os.path.exists(work_dir):
|
||||
raise ValueError(f'Work directory {work_dir} does not exist.')
|
||||
|
||||
command = command.strip()
|
||||
if not command:
|
||||
return CommandResult(content='', exit_code=0)
|
||||
|
||||
try:
|
||||
process = await asyncio.subprocess.create_subprocess_shell(
|
||||
command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=work_dir,
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(), timeout=30
|
||||
)
|
||||
output = stdout.decode('utf-8')
|
||||
|
||||
if stderr:
|
||||
output = stderr.decode('utf-8')
|
||||
print(f'!##! Error running command: {stderr.decode("utf-8")}')
|
||||
|
||||
return CommandResult(content=output, exit_code=process.returncode or 0)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
process.terminate()
|
||||
|
||||
# Allow a brief moment for cleanup
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=1.0)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill() # Force kill if it doesn't terminate cleanly
|
||||
|
||||
return CommandResult(content='Command timed out.', exit_code=-1)
|
||||
|
||||
except Exception as e:
|
||||
return CommandResult(
|
||||
content=f'Error running command: {str(e)}', exit_code=-1
|
||||
)
|
||||
@@ -17,6 +17,7 @@ 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
|
||||
|
||||
|
||||
@@ -379,9 +380,7 @@ class BashSession:
|
||||
metadata = CmdOutputMetadata() # No metadata available
|
||||
metadata.suffix = (
|
||||
f'\n[The command has no new output after {self.NO_CHANGE_TIMEOUT_SECONDS} seconds. '
|
||||
"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.]'
|
||||
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
|
||||
)
|
||||
command_output = self._get_command_output(
|
||||
command,
|
||||
@@ -414,9 +413,7 @@ class BashSession:
|
||||
metadata = CmdOutputMetadata() # No metadata available
|
||||
metadata.suffix = (
|
||||
f'\n[The command timed out after {timeout} seconds. '
|
||||
"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.]'
|
||||
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
|
||||
)
|
||||
command_output = self._get_command_output(
|
||||
command,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# 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.'
|
||||
)
|
||||
@@ -44,7 +44,7 @@ class GitHandler:
|
||||
Returns:
|
||||
bool: True if inside a Git repository, otherwise False.
|
||||
"""
|
||||
cmd = 'git rev-parse --is-inside-work-tree'
|
||||
cmd = 'git --no-pager rev-parse --is-inside-work-tree'
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.content.strip() == 'true'
|
||||
|
||||
@@ -71,7 +71,7 @@ class GitHandler:
|
||||
Returns:
|
||||
bool: True if the reference exists, otherwise False.
|
||||
"""
|
||||
cmd = f'git rev-parse --verify {ref}'
|
||||
cmd = f'git --no-pager rev-parse --verify {ref}'
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.exit_code == 0
|
||||
|
||||
@@ -86,9 +86,9 @@ class GitHandler:
|
||||
default_branch = self._get_default_branch()
|
||||
|
||||
ref_current_branch = f'origin/{current_branch}'
|
||||
ref_non_default_branch = f'$(git merge-base HEAD "$(git rev-parse --abbrev-ref origin/{default_branch})")'
|
||||
ref_non_default_branch = f'$(git --no-pager merge-base HEAD "$(git --no-pager rev-parse --abbrev-ref origin/{default_branch})")'
|
||||
ref_default_branch = 'origin/' + default_branch
|
||||
ref_new_repo = '$(git rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)' # compares with empty tree
|
||||
ref_new_repo = '$(git --no-pager rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)' # compares with empty tree
|
||||
|
||||
refs = [
|
||||
ref_current_branch,
|
||||
@@ -116,7 +116,7 @@ class GitHandler:
|
||||
if not ref:
|
||||
return ''
|
||||
|
||||
cmd = f'git show {ref}:{file_path}'
|
||||
cmd = f'git --no-pager show {ref}:{file_path}'
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.content if output.exit_code == 0 else ''
|
||||
|
||||
@@ -127,7 +127,7 @@ class GitHandler:
|
||||
Returns:
|
||||
str: The name of the primary branch.
|
||||
"""
|
||||
cmd = 'git remote show origin | grep "HEAD branch"'
|
||||
cmd = 'git --no-pager remote show origin | grep "HEAD branch"'
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.content.split()[-1].strip()
|
||||
|
||||
@@ -138,7 +138,7 @@ class GitHandler:
|
||||
Returns:
|
||||
str: The name of the current branch.
|
||||
"""
|
||||
cmd = 'git rev-parse --abbrev-ref HEAD'
|
||||
cmd = 'git --no-pager rev-parse --abbrev-ref HEAD'
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.content.strip()
|
||||
|
||||
@@ -153,8 +153,12 @@ class GitHandler:
|
||||
if not ref:
|
||||
return []
|
||||
|
||||
diff_cmd = f'git diff --name-status {ref}'
|
||||
diff_cmd = f'git --no-pager diff --name-status {ref}'
|
||||
output = self.execute(diff_cmd, self.cwd)
|
||||
if output.exit_code != 0:
|
||||
raise RuntimeError(
|
||||
f'Failed to get diff for ref {ref} in {self.cwd}. Command output: {output.content}'
|
||||
)
|
||||
return output.content.splitlines()
|
||||
|
||||
def _get_untracked_files(self) -> list[dict[str, str]]:
|
||||
@@ -164,7 +168,7 @@ class GitHandler:
|
||||
Returns:
|
||||
list[dict[str, str]]: A list of dictionaries containing file paths and statuses.
|
||||
"""
|
||||
cmd = 'git ls-files --others --exclude-standard'
|
||||
cmd = 'git --no-pager ls-files --others --exclude-standard'
|
||||
output = self.execute(cmd, self.cwd)
|
||||
obs_list = output.content.splitlines()
|
||||
return (
|
||||
|
||||
@@ -20,6 +20,7 @@ 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')
|
||||
@@ -559,9 +560,7 @@ class WindowsPowershellSession:
|
||||
else:
|
||||
metadata.suffix = (
|
||||
f'\n[The command timed out after {timeout_seconds} seconds. '
|
||||
"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.]'
|
||||
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
|
||||
)
|
||||
|
||||
return CmdOutputObservation(
|
||||
@@ -1331,9 +1330,7 @@ class WindowsPowershellSession:
|
||||
# Align suffix with bash.py timeout message
|
||||
suffix = (
|
||||
f'\n[The command timed out after {timeout_seconds} seconds. '
|
||||
"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.]'
|
||||
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
|
||||
)
|
||||
elif shutdown_requested:
|
||||
# Align suffix with bash.py equivalent (though bash.py might not have specific shutdown message)
|
||||
|
||||
@@ -48,12 +48,12 @@ class ServerConfig(ServerConfigInterface):
|
||||
return config
|
||||
|
||||
|
||||
def load_server_config():
|
||||
def load_server_config() -> ServerConfig:
|
||||
config_cls = os.environ.get('OPENHANDS_CONFIG_CLS', None)
|
||||
logger.info(f'Using config class {config_cls}')
|
||||
|
||||
server_config_cls = get_impl(ServerConfig, config_cls)
|
||||
server_config = server_config_cls()
|
||||
server_config : ServerConfig = server_config_cls()
|
||||
server_config.verify_config()
|
||||
|
||||
return server_config
|
||||
|
||||
@@ -54,6 +54,7 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
docker_client: docker.DockerClient = field(default_factory=docker.from_env)
|
||||
_conversation_store_class: type[ConversationStore] | None = None
|
||||
_starting_conversation_ids: set[str] = field(default_factory=set)
|
||||
_runtime_container_image: str | None = None
|
||||
|
||||
async def __aenter__(self):
|
||||
# No action is required on startup for this implementation
|
||||
@@ -89,7 +90,8 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
"""
|
||||
Get the running agent loops directly from docker.
|
||||
"""
|
||||
names = (container.name for container in self.docker_client.containers.list())
|
||||
containers : list[Container] = self.docker_client.containers.list()
|
||||
names = (container.name or '' for container in containers)
|
||||
conversation_ids = {
|
||||
name[len('openhands-runtime-') :]
|
||||
for name in names
|
||||
@@ -155,6 +157,12 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
try:
|
||||
# Build the runtime container image if it is missing
|
||||
await call_sync_from_async(runtime.maybe_build_runtime_container_image)
|
||||
self._runtime_container_image = runtime.runtime_container_image
|
||||
|
||||
# check that the container already exists...
|
||||
if await self._start_existing_container(runtime):
|
||||
self._starting_conversation_ids.discard(sid)
|
||||
return
|
||||
|
||||
# initialize the container but dont wait for it to start
|
||||
await call_sync_from_async(runtime.init_container)
|
||||
@@ -172,7 +180,7 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
)
|
||||
|
||||
except Exception:
|
||||
self._starting_conversation_ids.remove(sid)
|
||||
self._starting_conversation_ids.discard(sid)
|
||||
raise
|
||||
|
||||
async def _start_conversation(
|
||||
@@ -262,7 +270,7 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
finally:
|
||||
self._starting_conversation_ids.remove(sid)
|
||||
self._starting_conversation_ids.discard(sid)
|
||||
|
||||
async def send_to_event_stream(self, connection_id: str, data: dict):
|
||||
# Not supported - clients should connect directly to the nested server!
|
||||
@@ -287,15 +295,23 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
) as client:
|
||||
response = await client.post(f'{nested_url}/api/conversations/{sid}/stop')
|
||||
response.raise_for_status()
|
||||
|
||||
# Check up to 3 times that client has closed
|
||||
for _ in range(3):
|
||||
response = await client.get(f'{nested_url}/api/conversations/{sid}')
|
||||
if response.status_code == status.HTTP_200_OK and response.json().get('status') == "STOPPED":
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
|
||||
except Exception:
|
||||
logger.exception("error_stopping_container")
|
||||
container.stop()
|
||||
|
||||
async def get_agent_loop_info(self, user_id=None, filter_to_sids=None):
|
||||
async def get_agent_loop_info(self, user_id: str | None = None, filter_to_sids: set[str] | None = None) -> list[AgentLoopInfo]:
|
||||
results = []
|
||||
containers = self.docker_client.containers.list()
|
||||
containers : list[Container] = self.docker_client.containers.list()
|
||||
for container in containers:
|
||||
if not container.name.startswith('openhands-runtime-'):
|
||||
if not container.name or not container.name.startswith('openhands-runtime-'):
|
||||
continue
|
||||
conversation_id = container.name[len('openhands-runtime-') :]
|
||||
if filter_to_sids is not None and conversation_id not in filter_to_sids:
|
||||
@@ -358,11 +374,12 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
def get_nested_url_for_container(self, container: Container) -> str:
|
||||
env = container.attrs['Config']['Env']
|
||||
container_port = int(next(e[5:] for e in env if e.startswith('port=')))
|
||||
conversation_id = container.name[len('openhands-runtime-') :]
|
||||
container_name = container.name or ''
|
||||
conversation_id = container_name[len('openhands-runtime-') :]
|
||||
nested_url = f'{self.config.sandbox.local_runtime_url}:{container_port}/api/conversations/{conversation_id}'
|
||||
return nested_url
|
||||
|
||||
def _get_session_api_key_for_conversation(self, conversation_id: str):
|
||||
def _get_session_api_key_for_conversation(self, conversation_id: str) -> str:
|
||||
jwt_secret = self.config.jwt_secret.get_secret_value() # type:ignore
|
||||
conversation_key = f'{jwt_secret}:{conversation_id}'.encode()
|
||||
session_api_key = (
|
||||
@@ -372,7 +389,7 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
)
|
||||
return session_api_key
|
||||
|
||||
async def ensure_num_conversations_below_limit(self, sid: str, user_id: str | None):
|
||||
async def ensure_num_conversations_below_limit(self, sid: str, user_id: str | None) -> None:
|
||||
response_ids = await self.get_running_agent_loops(user_id)
|
||||
if len(response_ids) >= self.config.max_concurrent_conversations:
|
||||
logger.info(
|
||||
@@ -404,7 +421,7 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
)
|
||||
await self.close_session(oldest_conversation_id)
|
||||
|
||||
def _get_provider_handler(self, settings: Settings):
|
||||
def _get_provider_handler(self, settings: Settings) -> ProviderHandler:
|
||||
provider_tokens = None
|
||||
if isinstance(settings, ConversationInitData):
|
||||
provider_tokens = settings.git_provider_tokens
|
||||
@@ -414,7 +431,7 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
)
|
||||
return provider_handler
|
||||
|
||||
async def _create_runtime(self, sid: str, user_id: str | None, settings: Settings):
|
||||
async def _create_runtime(self, sid: str, user_id: str | None, settings: Settings) -> DockerRuntime:
|
||||
# This session is created here only because it is the easiest way to get a runtime, which
|
||||
# is the easiest way to create the needed docker container
|
||||
session = Session(
|
||||
@@ -447,6 +464,7 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
# We need to be able to specify the nested conversation id within the nested runtime
|
||||
env_vars['ALLOW_SET_CONVERSATION_ID'] = '1'
|
||||
env_vars['WORKSPACE_BASE'] = f'/workspace'
|
||||
env_vars['SANDBOX_CLOSE_DELAY'] = '0'
|
||||
|
||||
# Set up mounted volume for conversation directory within workspace
|
||||
# TODO: Check if we are using the standard event store and file store
|
||||
@@ -456,10 +474,13 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
else:
|
||||
volumes = [v.strip() for v in config.sandbox.volumes.split(',')]
|
||||
conversation_dir = get_conversation_dir(sid, user_id)
|
||||
|
||||
volumes.append(
|
||||
f'{config.file_store_path}/{conversation_dir}:{OpenHandsConfig.model_fields["file_store_path"].default}/{conversation_dir}:rw'
|
||||
f'{config.file_store_path}/{conversation_dir}:/root/.openhands/file_store/{conversation_dir}:rw'
|
||||
)
|
||||
config.sandbox.volumes = ','.join(volumes)
|
||||
if not config.sandbox.runtime_container_image:
|
||||
config.sandbox.runtime_container_image = self._runtime_container_image
|
||||
|
||||
# Currently this eventstream is never used and only exists because one is required in order to create a docker runtime
|
||||
event_stream = EventStream(sid, self.file_store, user_id)
|
||||
@@ -479,6 +500,18 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
|
||||
return runtime
|
||||
|
||||
async def _start_existing_container(self, runtime: DockerRuntime) -> bool:
|
||||
try:
|
||||
container = self.docker_client.containers.get(runtime.container_name)
|
||||
if container:
|
||||
status = container.status
|
||||
if status == 'exited':
|
||||
await call_sync_from_async(container.start)
|
||||
return True
|
||||
return False
|
||||
except docker.errors.NotFound as e:
|
||||
return False
|
||||
|
||||
|
||||
def _last_updated_at_key(conversation: ConversationMetadata) -> float:
|
||||
last_updated_at = conversation.last_updated_at
|
||||
|
||||
@@ -155,6 +155,10 @@ class StandaloneConversationManager(ConversationManager):
|
||||
await conversation.disconnect()
|
||||
self._detached_conversations.pop(sid, None)
|
||||
|
||||
# Implies disconnected sandboxes stay open indefinitely
|
||||
if not self.config.sandbox.close_delay:
|
||||
return
|
||||
|
||||
close_threshold = time.time() - self.config.sandbox.close_delay
|
||||
running_loops = list(self._local_agent_loops_by_sid.items())
|
||||
running_loops.sort(key=lambda item: item[1].last_active_ts)
|
||||
|
||||
@@ -12,6 +12,7 @@ from openhands.events.action import (
|
||||
)
|
||||
from openhands.events.action.agent import RecallAction
|
||||
from openhands.events.async_event_store_wrapper import AsyncEventStoreWrapper
|
||||
from openhands.events.event_store import EventStore
|
||||
from openhands.events.observation import (
|
||||
NullObservation,
|
||||
)
|
||||
@@ -19,6 +20,7 @@ from openhands.events.observation.agent import (
|
||||
AgentStateChangedObservation,
|
||||
)
|
||||
from openhands.events.serialization import event_to_dict
|
||||
from openhands.experiments.experiment_manager import ExperimentManagerImpl
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderToken
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.server.session.conversation_init_data import ConversationInitData
|
||||
@@ -49,7 +51,7 @@ def create_provider_tokens_object(
|
||||
|
||||
|
||||
async def setup_init_convo_settings(
|
||||
user_id: str | None, providers_set: list[ProviderType]
|
||||
user_id: str | None, conversation_id: str, providers_set: list[ProviderType]
|
||||
) -> ConversationInitData:
|
||||
settings_store = await SettingsStoreImpl.get_instance(config, user_id)
|
||||
settings = await settings_store.load()
|
||||
@@ -73,7 +75,11 @@ async def setup_init_convo_settings(
|
||||
if user_secrets:
|
||||
session_init_args['custom_secrets'] = user_secrets.custom_secrets
|
||||
|
||||
return ConversationInitData(**session_init_args)
|
||||
convo_init_data = ConversationInitData(**session_init_args)
|
||||
# We should recreate the same experiment conditions when restarting a conversation
|
||||
return ExperimentManagerImpl.run_conversation_variant_test(
|
||||
user_id, conversation_id, convo_init_data
|
||||
)
|
||||
|
||||
|
||||
@sio.event
|
||||
@@ -119,24 +125,28 @@ async def connect(connection_id: str, environ: dict) -> None:
|
||||
f'User {user_id} is allowed to connect to conversation {conversation_id}'
|
||||
)
|
||||
|
||||
conversation_init_data = await setup_init_convo_settings(user_id, providers_set)
|
||||
agent_loop_info = await conversation_manager.join_conversation(
|
||||
conversation_id,
|
||||
connection_id,
|
||||
conversation_init_data,
|
||||
user_id,
|
||||
)
|
||||
try:
|
||||
event_store = EventStore(
|
||||
conversation_id, conversation_manager.file_store, user_id
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
logger.error(
|
||||
f'Failed to create EventStore for conversation {conversation_id}: {e}'
|
||||
)
|
||||
raise ConnectionRefusedError(f'Failed to access conversation events: {e}')
|
||||
|
||||
logger.info(
|
||||
f'Connected to conversation {conversation_id} with connection_id {connection_id}. Replaying event stream...'
|
||||
f'Replaying event stream for conversation {conversation_id} with connection_id {connection_id}...'
|
||||
)
|
||||
agent_state_changed = None
|
||||
if agent_loop_info is None:
|
||||
raise ConnectionRefusedError('Failed to join conversation')
|
||||
async_store = AsyncEventStoreWrapper(
|
||||
agent_loop_info.event_store, latest_event_id + 1
|
||||
)
|
||||
|
||||
# Create an async store to replay events
|
||||
async_store = AsyncEventStoreWrapper(event_store, latest_event_id + 1)
|
||||
|
||||
# Process all available events
|
||||
async for event in async_store:
|
||||
logger.debug(f'oh_event: {event.__class__.__name__}')
|
||||
|
||||
if isinstance(
|
||||
event,
|
||||
(NullAction, NullObservation, RecallAction),
|
||||
@@ -146,13 +156,33 @@ async def connect(connection_id: str, environ: dict) -> None:
|
||||
agent_state_changed = event
|
||||
else:
|
||||
await sio.emit('oh_event', event_to_dict(event), to=connection_id)
|
||||
|
||||
# Send the agent state changed event last if we have one
|
||||
if agent_state_changed:
|
||||
await sio.emit(
|
||||
'oh_event', event_to_dict(agent_state_changed), to=connection_id
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'Finished replaying event stream for conversation {conversation_id}'
|
||||
)
|
||||
|
||||
conversation_init_data = await setup_init_convo_settings(
|
||||
user_id, conversation_id, providers_set
|
||||
)
|
||||
agent_loop_info = await conversation_manager.join_conversation(
|
||||
conversation_id,
|
||||
connection_id,
|
||||
conversation_init_data,
|
||||
user_id,
|
||||
)
|
||||
|
||||
if agent_loop_info is None:
|
||||
raise ConnectionRefusedError('Failed to join conversation')
|
||||
|
||||
logger.info(
|
||||
f'Successfully joined conversation {conversation_id} with connection_id {connection_id}'
|
||||
)
|
||||
except ConnectionRefusedError:
|
||||
# Close the broken connection after sending an error message
|
||||
asyncio.create_task(sio.disconnect(connection_id))
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import os
|
||||
import re
|
||||
from typing import Annotated
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.exceptions import ToolError
|
||||
from fastmcp.server.dependencies import get_http_request
|
||||
from pydantic import Field
|
||||
|
||||
@@ -9,9 +11,10 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.github.github_service import GithubServiceImpl
|
||||
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.integrations.provider import ProviderToken
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.integrations.service_types import GitService, ProviderType
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
from openhands.server.shared import ConversationStoreImpl, config
|
||||
from openhands.server.shared import ConversationStoreImpl, config, server_config
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.server.user_auth import (
|
||||
get_access_token,
|
||||
get_provider_tokens,
|
||||
@@ -19,10 +22,34 @@ from openhands.server.user_auth import (
|
||||
)
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
|
||||
|
||||
mcp_server = FastMCP('mcp', stateless_http=True, dependencies=get_dependencies())
|
||||
mcp_server = FastMCP(
|
||||
'mcp', stateless_http=True, dependencies=get_dependencies(), mask_error_details=True
|
||||
)
|
||||
|
||||
HOST = f'https://{os.getenv("WEB_HOST", "app.all-hands.dev").strip()}'
|
||||
CONVO_URL = HOST + '/{}'
|
||||
|
||||
|
||||
async def get_convo_link(service: GitService, conversation_id: str, body: str) -> str:
|
||||
"""
|
||||
Appends a followup link, in the PR body, to the OpenHands conversation that opened the PR
|
||||
"""
|
||||
|
||||
if server_config.app_mode != AppMode.SAAS:
|
||||
return body
|
||||
|
||||
user = await service.get_user()
|
||||
username = user.login
|
||||
convo_url = CONVO_URL.format(conversation_id)
|
||||
convo_link = (
|
||||
f'@{username} can click here to [continue refining the PR]({convo_url})'
|
||||
)
|
||||
body += f'\n\n{convo_link}'
|
||||
return body
|
||||
|
||||
|
||||
async def save_pr_metadata(
|
||||
user_id: str, conversation_id: str, tool_result: str
|
||||
user_id: str | None, conversation_id: str, tool_result: str
|
||||
) -> None:
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
|
||||
conversation: ConversationMetadata = await conversation_store.get_metadata(
|
||||
@@ -43,7 +70,11 @@ async def save_pr_metadata(
|
||||
pr_number = int(match_merge_request.group(1))
|
||||
|
||||
if pr_number:
|
||||
logger.info(f'Saving PR number: {pr_number} for convo {conversation_id}')
|
||||
conversation.pr_number.append(pr_number)
|
||||
else:
|
||||
logger.warning(f'Failed to extract PR number for convo {conversation_id}')
|
||||
|
||||
await conversation_store.save_metadata(conversation)
|
||||
|
||||
|
||||
@@ -83,6 +114,11 @@ async def create_pr(
|
||||
base_domain=github_token.host,
|
||||
)
|
||||
|
||||
try:
|
||||
body = await get_convo_link(github_service, conversation_id, body or '')
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to append convo link: {e}')
|
||||
|
||||
try:
|
||||
response = await github_service.create_pr(
|
||||
repo_name=repo_name,
|
||||
@@ -92,11 +128,12 @@ async def create_pr(
|
||||
body=body,
|
||||
)
|
||||
|
||||
if conversation_id and user_id:
|
||||
if conversation_id:
|
||||
await save_pr_metadata(user_id, conversation_id, response)
|
||||
|
||||
except Exception as e:
|
||||
response = str(e)
|
||||
error = f'Error creating pull request: {e}'
|
||||
raise ToolError(str(error))
|
||||
|
||||
return response
|
||||
|
||||
@@ -130,7 +167,7 @@ async def create_mr(
|
||||
else ProviderToken()
|
||||
)
|
||||
|
||||
github_service = GitLabServiceImpl(
|
||||
gitlab_service = GitLabServiceImpl(
|
||||
user_id=github_token.user_id,
|
||||
external_auth_id=user_id,
|
||||
external_auth_token=access_token,
|
||||
@@ -139,7 +176,14 @@ async def create_mr(
|
||||
)
|
||||
|
||||
try:
|
||||
response = await github_service.create_mr(
|
||||
description = await get_convo_link(
|
||||
gitlab_service, conversation_id, description or ''
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to append convo link: {e}')
|
||||
|
||||
try:
|
||||
response = await gitlab_service.create_mr(
|
||||
id=id,
|
||||
source_branch=source_branch,
|
||||
target_branch=target_branch,
|
||||
@@ -151,6 +195,7 @@ async def create_mr(
|
||||
await save_pr_metadata(user_id, conversation_id, response)
|
||||
|
||||
except Exception as e:
|
||||
response = str(e)
|
||||
error = f'Error creating merge request: {e}'
|
||||
raise ToolError(str(error))
|
||||
|
||||
return response
|
||||
|
||||
@@ -15,6 +15,7 @@ from openhands.server.shared import config
|
||||
from openhands.server.user_auth import (
|
||||
get_provider_tokens,
|
||||
get_secrets_store,
|
||||
get_user_settings,
|
||||
get_user_settings_store,
|
||||
)
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
@@ -35,10 +36,9 @@ app = APIRouter(prefix='/api', dependencies=get_dependencies())
|
||||
async def load_settings(
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
settings_store: SettingsStore = Depends(get_user_settings_store),
|
||||
settings: Settings = Depends(get_user_settings),
|
||||
secrets_store: SecretsStore = Depends(get_secrets_store),
|
||||
) -> GETSettingsModel | JSONResponse:
|
||||
settings = await settings_store.load()
|
||||
|
||||
try:
|
||||
if not settings:
|
||||
return JSONResponse(
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import Any
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.experiments.experiment_manager import ExperimentManagerImpl
|
||||
from openhands.integrations.provider import (
|
||||
CUSTOM_SECRETS_TYPE_WITH_JSON_SCHEMA,
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
@@ -78,6 +79,8 @@ async def create_new_conversation(
|
||||
session_init_args['git_provider'] = git_provider
|
||||
session_init_args['conversation_instructions'] = conversation_instructions
|
||||
conversation_init_data = ConversationInitData(**session_init_args)
|
||||
|
||||
|
||||
logger.info('Loading conversation store')
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
|
||||
logger.info('ServerConversation store loaded')
|
||||
@@ -93,6 +96,7 @@ async def create_new_conversation(
|
||||
extra={'user_id': user_id, 'session_id': conversation_id},
|
||||
)
|
||||
|
||||
conversation_init_data = ExperimentManagerImpl.run_conversation_variant_test(user_id, conversation_id, conversation_init_data)
|
||||
conversation_title = get_default_conversation_title(conversation_id)
|
||||
|
||||
logger.info(f'Saving metadata for conversation {conversation_id}')
|
||||
@@ -105,6 +109,7 @@ async def create_new_conversation(
|
||||
selected_repository=selected_repository,
|
||||
selected_branch=selected_branch,
|
||||
git_provider=git_provider,
|
||||
llm_model=settings.llm_model,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -119,8 +124,8 @@ async def create_new_conversation(
|
||||
image_urls=image_urls or [],
|
||||
)
|
||||
|
||||
if attach_convo_id and conversation_instructions:
|
||||
conversation_instructions = conversation_instructions.format(conversation_id)
|
||||
if attach_convo_id:
|
||||
logger.warning('Attaching convo ID is deprecated, skipping process')
|
||||
|
||||
agent_loop_info = await conversation_manager.maybe_start_agent_loop(
|
||||
conversation_id,
|
||||
|
||||
@@ -25,6 +25,10 @@ class DefaultUserAuth(UserAuth):
|
||||
"""The default implementation does not support multi tenancy, so user_id is always None"""
|
||||
return None
|
||||
|
||||
async def get_user_email(self) -> str | None:
|
||||
"""The default implementation does not support multi tenancy, so email is always None"""
|
||||
return None
|
||||
|
||||
async def get_access_token(self) -> SecretStr | None:
|
||||
"""The default implementation does not support multi tenancy, so access_token is always None"""
|
||||
return None
|
||||
|
||||
@@ -38,6 +38,10 @@ class UserAuth(ABC):
|
||||
async def get_user_id(self) -> str | None:
|
||||
"""Get the unique identifier for the current user"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_user_email(self) -> str | None:
|
||||
"""Get the email for the current user"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_access_token(self) -> SecretStr | None:
|
||||
"""Get the access token for the current user"""
|
||||
|
||||
@@ -2,7 +2,7 @@ from fastapi import Depends, HTTPException, Request, status
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
|
||||
from openhands.server.user_auth import get_user_auth, get_user_id
|
||||
from openhands.server.user_auth import get_user_id
|
||||
from openhands.storage.conversation.conversation_store import ConversationStore
|
||||
|
||||
|
||||
@@ -12,8 +12,7 @@ async def get_conversation_store(request: Request) -> ConversationStore | None:
|
||||
)
|
||||
if conversation_store:
|
||||
return conversation_store
|
||||
user_auth = await get_user_auth(request)
|
||||
user_id = await user_auth.get_user_id()
|
||||
user_id = await get_user_id(request)
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
|
||||
request.state.conversation_store = conversation_store
|
||||
return conversation_store
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user