Compare commits

..

42 Commits

Author SHA1 Message Date
Chuck Butkus 60c8480dbd Try 2025-06-05 23:14:45 -04:00
Chuck Butkus 313276207b Update to use toast messages 2025-06-05 15:46:36 -04:00
Chuck Butkus 7e34240d49 Fix email input box 2025-06-05 15:33:59 -04:00
chuckbutkus 76be0ffff9 Merge branch 'main' into allow-email-change 2025-06-05 15:26:33 -04:00
Chuck Butkus 60eb68bd91 User setting refactor 2025-06-05 15:23:04 -04:00
Chuck Butkus 686eb45fae User setting refactor 2025-06-05 15:23:04 -04:00
chuckbutkus 8566cd6ed2 Merge branch 'main' into allow-email-change 2025-06-05 12:19:24 -04:00
chuckbutkus 854e926bac Merge branch 'main' into allow-email-change 2025-06-04 23:15:49 -04:00
openhands f981a8a254 Update email placeholder to show 'Loading...' instead of 'Email not available' 2025-06-05 03:00:40 +00:00
openhands 3f47187f2f Update buttons to have black text when disabled 2025-06-05 02:43:02 +00:00
openhands 19c4296b07 Update button styles to match the Launch button style 2025-06-05 02:31:05 +00:00
Chuck Butkus 0929936045 Fix lint 2025-06-04 22:10:41 -04:00
openhands 6765673523 Remove default value for email verification restriction message 2025-06-05 01:56:02 +00:00
openhands 846999202d Update email verification success message and add translations 2025-06-05 01:53:35 +00:00
openhands 523d2ff170 Add background polling for email verification status on user settings page 2025-06-05 01:34:56 +00:00
chuckbutkus edf2269f13 Merge branch 'main' into allow-email-change 2025-06-04 17:21:14 -04:00
openhands a0bdd4101c Fix settings-with-payment test by adding user settings route and mocking email verification 2025-06-04 19:50:51 +00:00
chuckbutkus c7ca81f85c Merge branch 'main' into allow-email-change 2025-06-04 15:28:13 -04:00
chuckbutkus bff22652cb Merge branch 'main' into allow-email-change 2025-06-04 14:27:26 -04:00
Chuck Butkus 330d5a75e7 Fix lint errors 2025-06-04 12:57:46 -04:00
Chuck Butkus 42885c0288 Fix lint errors 2025-06-04 12:48:23 -04:00
Chuck Butkus 8805f34af0 Remove duplication 2025-06-04 02:38:06 -04:00
openhands 45bb6877e6 Update remaining files for EMAIL_VERIFIED restriction 2025-06-04 06:25:38 +00:00
openhands 703efd17ab Restrict app to only show user settings page when EMAIL_VERIFIED is false 2025-06-04 06:17:37 +00:00
openhands b8884ed447 Add email verification UI improvements: hide resend button when verified and show warning message when not verified 2025-06-04 01:55:52 +00:00
Chuck Butkus 8cfac66cc9 Another email_verified change 2025-06-03 21:40:03 -04:00
Chuck Butkus bcdec805e2 Add email_verified to settings 2025-06-03 21:35:01 -04:00
Chuck Butkus 2138eeb556 Update 2025-06-03 01:15:17 -04:00
openhands e00b00b372 Set withCredentials only in user-settings.tsx instead of globally 2025-06-03 04:36:45 +00:00
openhands 5f1f3b1e2d Enable withCredentials to allow cookies to be set from API responses 2025-06-03 04:35:17 +00:00
openhands 45ffac0b78 Add translations for resend verification email functionality 2025-06-02 22:09:42 +00:00
openhands 70a8e1bc0a Move save button to be before resend verification button on the same line 2025-06-02 21:40:22 +00:00
openhands e74b354137 Add resend verification email button to user settings 2025-06-02 21:06:57 +00:00
Chuck Butkus 56ed63088f Update 2025-06-02 16:23:01 -04:00
openhands 489e32c2c0 Fix email update to use /api/settings endpoint 2025-05-31 19:10:03 +00:00
openhands c189012f0a Fix email update to use query parameter instead of form data 2025-05-31 19:05:02 +00:00
openhands 2407420e17 Make email field editable and add save button in user settings 2025-05-31 18:59:14 +00:00
chuckbutkus bb0c47c41a Merge branch 'main' into display-email 2025-05-31 01:14:24 -04:00
Chuck Butkus 83e5276de5 Update User Setting tab 2025-05-31 01:13:38 -04:00
openhands 816082a55b Update User tab to display email from settings instead of git user 2025-05-31 04:12:04 +00:00
Chuck Butkus 82d72b145d Add email to Setting class 2025-05-30 23:59:47 -04:00
Chuck Butkus f8c3470c91 Add get_user_email from UserAuth 2025-05-30 15:43:08 -04:00
96 changed files with 1125 additions and 1705 deletions
-1
View File
@@ -12,5 +12,4 @@
"ghcr.io/devcontainers/features/node:1": {},
},
"postCreateCommand": ".devcontainer/setup.sh",
"runArgs": ["--network=host"],
}
Executable → Regular
View File
+1 -1
View File
@@ -5,7 +5,7 @@
/frontend/ @rbren @amanape
# Evaluation code owners
/evaluation/ @xingyaoww @neubig
/evaluation/ @xingyaoww @neubig
# Documentation code owners
/docs/ @mamoodi
-1
View File
@@ -33,7 +33,6 @@ body:
- Docker command in README
- GitHub resolver
- Development workflow
- CLI
- app.all-hands.dev
- Other
default: 0
+1
View File
@@ -16,6 +16,7 @@ updates:
mcp-packages:
patterns:
- "mcp"
- "mcpm"
security-all:
applies-to: "security-updates"
patterns:
-4
View File
@@ -313,8 +313,6 @@ jobs:
TEST_IN_CI=true \
RUN_AS_OPENHANDS=false \
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
env:
DEBUG: "1"
# Run unit tests with the Docker runtime Docker images as openhands user
test_runtime_oh:
@@ -380,8 +378,6 @@ jobs:
TEST_IN_CI=true \
RUN_AS_OPENHANDS=true \
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
env:
DEBUG: "1"
# The two following jobs (named identically) are to check whether all the runtime tests have passed as the
# "All Runtime Tests Passed" is a required job for PRs to merge
-6
View File
@@ -74,11 +74,5 @@ jobs:
run: poetry install --with dev,test,runtime
- name: Run Windows unit tests
run: poetry run pytest -svv tests/unit/test_windows_bash.py
env:
DEBUG: "1"
- name: Run Windows runtime tests with LocalRuntime
run: $env:TEST_RUNTIME="local"; poetry run pytest -svv tests/runtime/test_bash.py
env:
TEST_RUNTIME: local
DEBUG: "1"
+2 -1
View File
@@ -38,11 +38,12 @@
]
},
"usage/cloud/cloud-ui",
"usage/cloud/cloud-issue-resolver",
"usage/cloud/cloud-api"
]
},
{
"group": "Running OpenHands on Your Own",
"group": "Running OpenHands Locally",
"pages": [
"usage/local-setup",
"usage/how-to/gui-mode",
+33
View File
@@ -0,0 +1,33 @@
---
title: Cloud Issue Resolver
description: The Cloud Issue Resolver automates code fixes and provides intelligent assistance for your repositories on GitHub.
---
## Setup
The Cloud Issue Resolver is available automatically when you grant OpenHands Cloud repository access:
- [GitHub repository access](./github-installation#adding-repository-access)
## Usage
After granting OpenHands Cloud repository access, you can use the Cloud Issue Resolver on issues and pull requests in your repositories.
### Working with Issues
On your repository, label an issue with `openhands` or add a message starting with
`@openhands`. OpenHands will:
1. Comment on the issue to let you know it is working on it
- You can click on the link to track the progress on OpenHands Cloud
2. Open a pull request if it determines that the issue has been successfully resolved
3. Comment on the issue with a summary of the performed tasks and a link to the PR
### Working with Pull Requests
To get OpenHands to work on pull requests, mention `@openhands` in comments to:
- Ask questions
- Request updates
- Get code explanations
OpenHands will:
1. Comment to let you know it is working on it
2. Perform the requested task
+15 -23
View File
@@ -1,36 +1,28 @@
---
title: Cloud UI
description: The Cloud UI provides a web interface for interacting with OpenHands. This page explains how to use the
OpenHands Cloud UI.
description: The Cloud UI provides a web interface for interacting with OpenHands AI. This page explains how to access and use the OpenHands Cloud UI.
---
## Landing Page
The landing page is where you can:
## Accessing the UI
- [Add GitHub repository access](/usage/cloud/github-installation#adding-github-repository-access) to OpenHands.
- [Select a GitHub repo](/usage/cloud/github-installation#working-with-github-repos-in-openhands-cloud) or
[a GitLab repo](/usage/cloud/gitlab-installation#working-with-gitlab-repos-in-openhands-cloud) to start working on.
- See `Suggested Tasks` for repositories that OpenHands has access to.
- Launch an empty conversation using `Launch from Scratch`.
The OpenHands Cloud UI can be accessed at [app.all-hands.dev](https://app.all-hands.dev). You'll need to sign in with your GitHub or GitLab account to access the interface.
## Settings
The Settings page allows you to:
- [Configure GitHub repository access](/usage/cloud/github-installation#modifying-repository-access) for OpenHands.
- Set application settings like your preferred language, notifications and other preferences.
- Add credits to your account.
- Generate custom secrets.
- Create API keys to work with OpenHands programmatically.
## Key Features
For an overview of the key features available inside a conversation, please refer to the [Key Features](../key-features)
section of the documentation.
For detailed information about the features available in the OpenHands Cloud UI, please refer to the [Key Features](../key-features) section of the documentation.
## Settings
The settings page allows you to:
- Configure your account preferences.
- Manage repository access.
- Generate API keys for programmatic access.
- Generate custom secrets for the agent.
## Next Steps
- [Install GitHub Integration](/usage/cloud/github-installation) to use OpenHands with your GitHub repositories.
- [Install GitLab Integration](/usage/cloud/gitlab-installation) to use OpenHands with your GitLab repositories.
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands.
- [Use the Cloud Issue Resolver](./cloud-issue-resolver) to automate code fixes and get assistance.
- [Learn about the Cloud API](./cloud-api) for programmatic access.
+5 -5
View File
@@ -1,7 +1,7 @@
---
title: GitHub Integration
description: This guide walks you through the process of installing OpenHands Cloud for your GitHub repositories. Once
set up, it will allow OpenHands to work with your GitHub repository through the Cloud UI or straight from GitHub!
set up, it will allow OpenHands to work with your GitHub repository through the Cloud UI or straight from GitHub issues!
---
## Prerequisites
@@ -37,11 +37,11 @@ You can modify GitHub repository access at any time by:
- Selecting `Add GitHub repos` on the landing page or
- Visiting the Settings page and selecting `Configure GitHub Repositories` under the `Git` tab
## Working With GitHub Repos in Openhands Cloud
## Working With Github Repos in Openhands Cloud
Once you've granted GitHub repository access, you can start working with your GitHub repository. Use the `select a repo`
and `select a branch` dropdowns to select the appropriate repository and branch you'd like OpenHands to work on. Then
click on `Launch` to start the conversation!
click on `Launch` to start the session!
![Connect Repo](/static/img/connect-repo.png)
@@ -67,5 +67,5 @@ To get OpenHands to work on pull requests, mention `@openhands` in the comments
## Next Steps
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands.
- [Access the Cloud UI](./cloud-ui) to interact with the web interface
- [Use the Cloud API](./cloud-api) to programmatically interact with OpenHands
+10 -12
View File
@@ -1,25 +1,23 @@
---
title: GitLab Integration
description: This guide walks you through the process of installing OpenHands Cloud for your GitLab repositories. Once
set up, it will allow OpenHands to work with your GitLab repository.
description: This guide walks you through the process of installing and configuring OpenHands Cloud for your GitLab repositories.
---
## Prerequisites
- Signed in to [OpenHands Cloud](https://app.all-hands.dev) with [a GitLab account](/usage/cloud/openhands-cloud).
- A GitLab account
- Access to OpenHands Cloud
## Adding GitLab Repository Access
## Installation Steps
Upon signing into OpenHands Cloud with a GitLab account, OpenHands will have access to your repositories.
1. Log in to [OpenHands Cloud](https://app.all-hands.dev)
2. If you haven't connected your GitLab account yet:
- Click on `Log in with GitLab`
- Authorize the OpenHands application
## Working With GitLab Repos in Openhands Cloud
After signing in with a Gitlab account, use the `select a repo` and `select a branch` dropdowns to select the
appropriate repository and branch you'd like OpenHands to work on. Then click on `Launch` to start the conversation!
![Connect Repo](/static/img/connect-repo.png)
## Next Steps
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands.
- [Access the Cloud UI](./cloud-ui) to interact with the web interface
- [Use the Cloud API](./cloud-api) to programmatically interact with OpenHands
+5 -5
View File
@@ -14,13 +14,13 @@ You'll be prompted to connect with your GitHub or GitLab account:
2. Review the permissions requested by OpenHands and authorize the application.
- OpenHands will require certain permissions from your account. To read more about these permissions,
you can click the `Learn more` link on the authorization page.
3. Review and accept the `terms of service` and select `Continue`.
## Next Steps
Once you've connected your account, you can:
- [Install GitHub Integration](/usage/cloud/github-installation) to use OpenHands with your GitHub repositories.
- [Install GitLab Integration](/usage/cloud/gitlab-installation) to use OpenHands with your GitLab repositories.
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands.
- [Install GitHub Integration](./github-installation) to use OpenHands with your GitHub repositories
- [Install GitLab Integration](./gitlab-installation) to use OpenHands with your GitLab repositories
- [Access the Cloud UI](./cloud-ui) to interact with the web interface
- [Use the Cloud API](./cloud-api) to programmatically interact with OpenHands
- [Set up the Cloud Issue Resolver](./cloud-issue-resolver) to automate code fixes and provide intelligent assistance
+2 -15
View File
@@ -9,29 +9,16 @@ This mode is different from the [headless mode](./headless-mode), which is non-i
### Running with Python
1. Install OpenHands using pip:
```bash
pip install openhands-ai
```
1. Ensure you have followed the [Development setup instructions](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md).
2. Set your model, API key, and other preferences using environment variables or with the [`config.toml`](https://github.com/All-Hands-AI/OpenHands/blob/main/config.template.toml) file.
3. Launch an interactive OpenHands conversation from the command line:
```bash
openhands
poetry run python -m openhands.cli.main
```
This command opens an interactive prompt where you can type tasks or commands and get responses from OpenHands.
#### For Developers
If you have cloned the repository, you can run the CLI directly using Poetry:
```bash
poetry run python -m openhands.cli.main
```
### Running with Docker
1. Set the following environment variables in your terminal:
+1 -1
View File
@@ -46,7 +46,7 @@ This will produce a new image called `custom-image`, which will be available in
## Using the Docker Command
When running OpenHands using [the docker command](/usage/local-setup#start-the-app), replace
When running OpenHands using [the docker command](/usage/installation#start-the-app), replace
`-e SANDBOX_RUNTIME_CONTAINER_IMAGE=...` with `-e SANDBOX_BASE_CONTAINER_IMAGE=<custom image name>`:
```commandline
+1 -1
View File
@@ -48,6 +48,6 @@ The customization options you can set are:
| `LLM_MODEL` | Variable | Set the LLM to use with OpenHands | `LLM_MODEL="anthropic/claude-3-5-sonnet-20241022"` |
| `OPENHANDS_MAX_ITER` | Variable | Set max limit for agent iterations | `OPENHANDS_MAX_ITER=10` |
| `OPENHANDS_MACRO` | Variable | Customize default macro for invoking the resolver | `OPENHANDS_MACRO=@resolveit` |
| `OPENHANDS_BASE_CONTAINER_IMAGE` | Variable | Custom Sandbox ([learn more](/usage/how-to/custom-sandbox-guide)) | `OPENHANDS_BASE_CONTAINER_IMAGE="custom_image"` |
| `OPENHANDS_BASE_CONTAINER_IMAGE` | Variable | Custom Sandbox ([learn more](https://docs.all-hands.dev/modules/usage/how-to/custom-sandbox-guide)) | `OPENHANDS_BASE_CONTAINER_IMAGE="custom_image"` |
| `TARGET_BRANCH` | Variable | Merge to branch other than `main` | `TARGET_BRANCH="dev"` |
| `TARGET_RUNNER` | Variable | Target runner to execute the agent workflow (default ubuntu-latest) | `TARGET_RUNNER="custom-runner"` |
+1 -1
View File
@@ -16,4 +16,4 @@ For more information see [getting started with OpenHands Cloud.](/usage/cloud/op
Run OpenHands on your local system and bring your own LLM and API key.
For more information see [running OpenHands on your own.](/usage/local-setup)
For more information see [running OpenHands locally.](/usage/local-setup)
+1 -1
View File
@@ -48,7 +48,7 @@ We recommend using [LMStudio](https://lmstudio.ai/) for serving these models loc
### Start OpenHands with locally served model
Check [the installation guide](/usage/local-setup) to make sure you have all the prerequisites for running OpenHands.
Check [the installation guide](https://docs.all-hands.dev/modules/usage/installation) to make sure you have all the prerequisites for running OpenHands.
```bash
export LMSTUDIO_MODEL_NAME="imported-models/uncategorized/devstralq4_k_m.gguf" # <- Replace this with the model name you copied from LMStudio
-8
View File
@@ -109,14 +109,6 @@ OpenHands requires an API key to access most language models. Here's how to get
</Accordion>
<Accordion title="Google (Gemini)">
1. Create a Google account if you don't already have one.
2. [Generate an API key](https://aistudio.google.com/apikey).
3. [Set up billing](https://aistudio.google.com/usage?tab=billing).
</Accordion>
</AccordionGroup>
Consider setting usage limits to control costs.
@@ -11,7 +11,7 @@ Currently OpenHands supports the following types of microagents:
- [Keyword-Triggered Microagents](./microagents-keyword): Guidelines activated by specific keywords in prompts.
To customize OpenHands' behavior, create a .openhands/microagents/ directory in the root of your repository and
add `<microagent_name>.md` files inside. For repository-specific guidelines, you can ask OpenHands to analyze your repository and create a comprehensive `repo.md` file (see [General Microagents](./microagents-repo) for details).
add `<microagent_name>.md` files inside.
<Note>
Loaded microagents take up space in the context window.
+2 -34
View File
@@ -17,45 +17,13 @@ Frontmatter should be enclosed in triple dashes (---) and may include the follow
|-----------|-----------------------------------------|----------|----------------|
| `agent` | The agent this microagent applies to | No | 'CodeActAgent' |
## Creating a Comprehensive Repository Agent
To create an effective repository agent, you can ask OpenHands to analyze your repository with a prompt like:
## Example
General microagent file example located at `.openhands/microagents/repo.md`:
```
Please browse the repository, look at the documentation and relevant code, and understand the purpose of this repository.
Specifically, I want you to create a `.openhands/microagents/repo.md` file. This file should contain succinct information that summarizes:
1. The purpose of this repository
2. The general setup of this repo
3. A brief description of the structure of this repo
Read all the GitHub workflows under .github/ of the repository (if this folder exists) to understand the CI checks (e.g., linter, pre-commit), and include those in the repo.md file.
```
This approach helps OpenHands capture repository context efficiently, reducing the need for repeated searches during conversations and ensuring more accurate solutions.
## Example Content
A comprehensive repository agent file (`.openhands/microagents/repo.md`) should include:
```
# Repository Purpose
This project is a TODO application that allows users to track TODO items.
# Setup Instructions
To set it up, you can run `npm run build`.
# Repository Structure
- `/src`: Core application code
- `/tests`: Test suite
- `/docs`: Documentation
- `/.github`: CI/CD workflows
# CI/CD Workflows
- `lint.yml`: Runs ESLint on all JavaScript files
- `test.yml`: Runs the test suite on pull requests
# Development Guidelines
Always make sure the tests are passing before committing changes. You can run the tests by running `npm run test`.
```
+1
View File
@@ -71,6 +71,7 @@ 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.
+1 -56
View File
@@ -1,60 +1,5 @@
import axios, { AxiosError, AxiosResponse } from "axios";
import axios from "axios";
export const openHands = axios.create({
baseURL: `${window.location.protocol}//${import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host}`,
});
// Helper function to check if a response contains an email verification error
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const checkForEmailVerificationError = (data: any): boolean => {
const EMAIL_NOT_VERIFIED = "EmailNotVerifiedError";
if (typeof data === "string") {
return data.includes(EMAIL_NOT_VERIFIED);
}
if (typeof data === "object" && data !== null) {
if ("message" in data) {
const { message } = data;
if (typeof message === "string") {
return message.includes(EMAIL_NOT_VERIFIED);
}
if (Array.isArray(message)) {
return message.some(
(msg) => typeof msg === "string" && msg.includes(EMAIL_NOT_VERIFIED),
);
}
}
// Search any values in object in case message key is different
return Object.values(data).some(
(value) =>
(typeof value === "string" && value.includes(EMAIL_NOT_VERIFIED)) ||
(Array.isArray(value) &&
value.some(
(v) => typeof v === "string" && v.includes(EMAIL_NOT_VERIFIED),
)),
);
}
return false;
};
// Set up the global interceptor
openHands.interceptors.response.use(
(response: AxiosResponse) => response,
(error: AxiosError) => {
// Check if it's a 403 error with the email verification message
if (
error.response?.status === 403 &&
checkForEmailVerificationError(error.response?.data)
) {
if (window.location.pathname !== "/settings/user") {
window.location.reload();
}
}
// Continue with the error for other error handlers
return Promise.reject(error);
},
);
-20
View File
@@ -236,26 +236,6 @@ class OpenHands {
return data;
}
static async startConversation(
conversationId: string,
): Promise<Conversation | null> {
const { data } = await openHands.post<Conversation | null>(
`/api/conversations/${conversationId}/start`,
);
return data;
}
static async stopConversation(
conversationId: string,
): Promise<Conversation | null> {
const { data } = await openHands.post<Conversation | null>(
`/api/conversations/${conversationId}/stop`,
);
return data;
}
/**
* Get the settings from the server or use the default settings if not found
*/
@@ -84,7 +84,7 @@ export function AgentStatusBar() {
setStatusMessage(t(I18nKey.STATUS$STARTING_RUNTIME));
setIndicatorColor(IndicatorColor.RED);
} else if (status === WsClientProviderStatus.DISCONNECTED) {
setStatusMessage(t(I18nKey.STATUS$WEBSOCKET_CLOSED));
setStatusMessage(t(I18nKey.STATUS$CONNECTED)); // Using STATUS$CONNECTED instead of STATUS$CONNECTING
setIndicatorColor(IndicatorColor.RED);
} else {
setStatusMessage(AGENT_STATUS_MAP[curAgentState].message);
@@ -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") {
@@ -96,7 +96,7 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
testId="llm-api-key-help-anchor"
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
href="https://docs.all-hands.dev/usage/local-setup#getting-an-api-key"
href="https://docs.all-hands.dev/usage/installation#getting-an-api-key"
/>
</div>
+7 -15
View File
@@ -150,8 +150,7 @@ export function WsClientProvider({
const { providers } = useUserProviders();
const messageRateHandler = useRate({ threshold: 250 });
const { data: conversation, refetch: refetchConversation } =
useActiveConversation();
const { data: conversation } = useActiveConversation();
function send(event: Record<string, unknown>) {
if (!sioRef.current) {
@@ -270,11 +269,14 @@ export function WsClientProvider({
sio.io.opts.query.latest_event_id = lastEventRef.current?.id;
updateStatusWhenErrorMessagePresent(data);
setErrorMessage(hasValidMessageProperty(data) ? data.message : "");
setErrorMessage(
hasValidMessageProperty(data)
? data.message
: "The WebSocket connection was closed.",
);
}
function handleError(data: unknown) {
// set status
setStatus(WsClientProviderStatus.DISCONNECTED);
updateStatusWhenErrorMessagePresent(data);
@@ -283,9 +285,6 @@ export function WsClientProvider({
? data.message
: "An unknown error occurred on the WebSocket connection.",
);
// check if something went wrong with the conversation.
refetchConversation();
}
React.useEffect(() => {
@@ -301,19 +300,12 @@ export function WsClientProvider({
if (!conversationId) {
throw new Error("No conversation ID provided");
}
if (
!conversation ||
["STOPPED", "STARTING"].includes(conversation.status)
) {
if (!conversation || conversation.status === "STARTING") {
return () => undefined; // conversation not yet loaded
}
let sio = sioRef.current;
if (sio?.connected) {
sio.disconnect();
}
const lastEvent = lastEventRef.current;
const query = {
latest_event_id: lastEvent?.id ?? -1,
@@ -9,7 +9,7 @@ export const useActiveConversation = () => {
const { conversationId } = useConversationId();
const userConversation = useUserConversation(conversationId, (query) => {
if (query.state.data?.status === "STARTING") {
return 3000; // 3 seconds
return 2000; // 2 seconds
}
return FIVE_MINUTES;
});
@@ -17,10 +17,6 @@ export const useActiveConversation = () => {
useEffect(() => {
const conversation = userConversation.data;
OpenHands.setCurrentConversation(conversation || null);
}, [
conversationId,
userConversation.isFetched,
userConversation?.data?.status,
]);
}, [conversationId, userConversation.isFetched]);
return userConversation;
};
-1
View File
@@ -45,7 +45,6 @@ export const useSettings = () => {
// would want to show the modal immediately if the
// settings are not found
retry: (_, error) => error.status !== 404,
refetchOnWindowFocus: false,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
enabled: !isOnTosPage && !!userIsAuthenticated,
@@ -0,0 +1,116 @@
import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { AxiosError } from "axios";
import { openHands } from "#/api/open-hands-axios";
import { Settings } from "#/types/settings";
import { useConfig } from "#/hooks/query/use-config";
/**
* Hook to handle email verification errors (403 with "Email has not been verified" message)
* This hook sets up an axios interceptor that will reload settings and navigate to the user settings page
* when a 403 error with the specific message is encountered.
*/
export const useHandleEmailVerification = () => {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { data: config } = useConfig();
const appMode = config?.APP_MODE;
console.log(`config: ${config}`);
console.log(`AppMode: ${appMode}`);
useEffect(() => {
// Add response interceptor
const interceptorId = openHands.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
console.log(
`Received error ${error.response?.status} with message ${error.response?.data}`,
);
const EMAIL_NOT_VERIFIED = "EmailNotVerifiedError";
// check for email verification error message no matter how it is returned.
const isEmailNotVerified = (() => {
const data = error.response?.data;
if (typeof data === "string") {
return data.includes(EMAIL_NOT_VERIFIED);
}
if (typeof data === "object" && data !== null) {
if ("message" in data) {
const { message } = data;
if (typeof message === "string") {
return message.includes(EMAIL_NOT_VERIFIED);
}
if (Array.isArray(message)) {
return message.some(
(msg) =>
typeof msg === "string" && msg.includes(EMAIL_NOT_VERIFIED),
);
}
}
// Search any values in object in case message key is different
return Object.values(data).some(
(value) =>
(typeof value === "string" &&
value.includes(EMAIL_NOT_VERIFIED)) ||
(Array.isArray(value) &&
value.some(
(v) =>
typeof v === "string" && v.includes(EMAIL_NOT_VERIFIED),
)),
);
}
return false;
})();
// Check if it's a 403 error with the specific message
if (error.response?.status === 403 && isEmailNotVerified) {
console.log("EMAIL VERIFICATION ERROR");
// Only handle this in SAAS mode
console.log(`config1: ${config}`);
console.log(`AppMode1: ${appMode}`);
if (appMode === "saas") {
// Update settings to mark email as unverified
queryClient.setQueryData(
["settings"],
(oldData: Settings | undefined) => {
if (oldData) {
console.log("ADDING EMAIL_VERIFIED is FALSE");
return {
...oldData,
EMAIL_VERIFIED: false,
};
}
console.log("NO CHANGES TO SETTINGS");
return oldData;
},
);
// Invalidate settings to reload them
queryClient.invalidateQueries({ queryKey: ["settings"] });
// Navigate to settings/user page
// The EmailVerificationGuard will handle the redirect
console.log("NAVIGATING to /settings/user");
navigate("/settings/user");
}
} else {
console.log("NOT EMAIL VERIFICATION ERROR");
console.log(typeof error.response?.data);
}
// Continue with the error for other error handlers
return Promise.reject(error);
},
);
// Clean up interceptor when component unmounts
return () => {
openHands.interceptors.response.eject(interceptorId);
};
}, [queryClient, navigate]);
};
-2
View File
@@ -1,6 +1,5 @@
// this file generate by script, don't modify it manually!!!
export enum I18nKey {
STATUS$WEBSOCKET_CLOSED = "STATUS$WEBSOCKET_CLOSED",
HOME$LAUNCH_FROM_SCRATCH = "HOME$LAUNCH_FROM_SCRATCH",
HOME$READ_THIS = "HOME$READ_THIS",
AUTH$LOGGING_BACK_IN = "AUTH$LOGGING_BACK_IN",
@@ -567,7 +566,6 @@ export enum I18nKey {
SETTINGS$SENDING = "SETTINGS$SENDING",
SETTINGS$VERIFICATION_EMAIL_SENT = "SETTINGS$VERIFICATION_EMAIL_SENT",
SETTINGS$EMAIL_VERIFICATION_REQUIRED = "SETTINGS$EMAIL_VERIFICATION_REQUIRED",
SETTINGS$INVALID_EMAIL_FORMAT = "SETTINGS$INVALID_EMAIL_FORMAT",
SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE = "SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE",
SETTINGS$RESEND_VERIFICATION = "SETTINGS$RESEND_VERIFICATION",
SETTINGS$FAILED_TO_RESEND_VERIFICATION = "SETTINGS$FAILED_TO_RESEND_VERIFICATION",
-32
View File
@@ -1,20 +1,4 @@
{
"STATUS$WEBSOCKET_CLOSED": {
"en": "The WebSocket connection was closed.",
"ja": "WebSocket接続が閉じられました。",
"zh-CN": "WebSocket连接已关闭。",
"zh-TW": "WebSocket連接已關閉。",
"ko-KR": "WebSocket 연결이 닫혔습니다.",
"no": "WebSocket-tilkoblingen ble lukket.",
"it": "La connessione WebSocket è stata chiusa.",
"pt": "A conexão WebSocket foi fechada.",
"es": "La conexión WebSocket se ha cerrado.",
"ar": "تم إغلاق اتصال WebSocket.",
"fr": "La connexion WebSocket a été fermée.",
"tr": "WebSocket bağlantısı kapatıldı.",
"de": "Die WebSocket-Verbindung wurde geschlossen.",
"uk": "З'єднання WebSocket було закрито."
},
"HOME$LAUNCH_FROM_SCRATCH": {
"en": "Launch from Scratch",
"ja": "ゼロから始める",
@@ -9071,22 +9055,6 @@
"de": "Sie müssen Ihre E-Mail-Adresse bestätigen, bevor Sie All Hands verwenden können",
"uk": "Ви повинні підтвердити свою електронну адресу перед використанням All Hands"
},
"SETTINGS$INVALID_EMAIL_FORMAT": {
"en": "Please enter a valid email address",
"ja": "有効なメールアドレスを入力してください",
"zh-CN": "请输入有效的电子邮件地址",
"zh-TW": "請輸入有效的電子郵件地址",
"ko-KR": "유효한 이메일 주소를 입력하세요",
"no": "Vennligst skriv inn en gyldig e-postadresse",
"it": "Inserisci un indirizzo email valido",
"pt": "Por favor, insira um endereço de e-mail válido",
"es": "Por favor, introduzca una dirección de correo electrónico válida",
"ar": "الرجاء إدخال عنوان بريد إلكتروني صالح",
"fr": "Veuillez entrer une adresse e-mail valide",
"tr": "Lütfen geçerli bir e-posta adresi girin",
"de": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"uk": "Будь ласка, введіть дійсну електронну адресу"
},
"SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE": {
"en": "Your access is limited until your email is verified. You can only access this settings page.",
"ja": "メールが確認されるまでアクセスが制限されています。この設定ページにのみアクセスできます。",
+2 -7
View File
@@ -43,7 +43,7 @@ function AppContent() {
const { t } = useTranslation();
const { data: settings } = useSettings();
const { conversationId } = useConversationId();
const { data: conversation, isFetched, refetch } = useActiveConversation();
const { data: conversation, isFetched } = useActiveConversation();
const { data: isAuthed } = useIsAuthed();
const { curAgentState } = useSelector((state: RootState) => state.agent);
@@ -61,13 +61,8 @@ function AppContent() {
"This conversation does not exist, or you do not have permission to access it.",
);
navigate("/");
} else if (conversation?.status === "STOPPED") {
// start the conversation if the state is stopped on initial load
OpenHands.startConversation(conversation.conversation_id).then(() =>
refetch(),
);
}
}, [conversation?.conversation_id, isFetched, isAuthed]);
}, [conversation, isFetched, isAuthed]);
React.useEffect(() => {
dispatch(clearTerminal());
+2 -2
View File
@@ -304,7 +304,7 @@ function LlmSettingsScreen() {
testId="llm-api-key-help-anchor"
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
href="https://docs.all-hands.dev/usage/local-setup#getting-an-api-key"
href="https://docs.all-hands.dev/usage/installation#getting-an-api-key"
/>
<SettingsInput
@@ -379,7 +379,7 @@ function LlmSettingsScreen() {
testId="llm-api-key-help-anchor-advanced"
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
href="https://docs.all-hands.dev/usage/local-setup#getting-an-api-key"
href="https://docs.all-hands.dev/usage/installation#getting-an-api-key"
/>
<SettingsInput
+4
View File
@@ -24,6 +24,7 @@ import { displaySuccessToast } from "#/utils/custom-toast-handlers";
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
import { useAutoLogin } from "#/hooks/use-auto-login";
import { useAuthCallback } from "#/hooks/use-auth-callback";
import { useHandleEmailVerification } from "#/hooks/use-handle-email-verification";
import { LOCAL_STORAGE_KEYS } from "#/utils/local-storage";
import { EmailVerificationGuard } from "#/components/features/guards/email-verification-guard";
@@ -93,6 +94,9 @@ export default function MainApp() {
// Handle authentication callback and set login method after successful authentication
useAuthCallback();
// Set up interceptor for email verification errors
useHandleEmailVerification();
React.useEffect(() => {
// Don't change language when on TOS page
if (!isOnTosPage && settings?.LANGUAGE) {
+4 -27
View File
@@ -5,9 +5,6 @@ import { useSettings } from "#/hooks/query/use-settings";
import { openHands } from "#/api/open-hands-axios";
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
// Email validation regex pattern
const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
function EmailInputSection({
email,
onEmailChange,
@@ -17,7 +14,6 @@ function EmailInputSection({
isResendingVerification,
isEmailChanged,
emailVerified,
isEmailValid,
children,
}: {
email: string;
@@ -28,7 +24,6 @@ function EmailInputSection({
isResendingVerification: boolean;
isEmailChanged: boolean;
emailVerified?: boolean;
isEmailValid: boolean;
children: React.ReactNode;
}) {
const { t } = useTranslation();
@@ -41,30 +36,17 @@ function EmailInputSection({
type="email"
value={email}
onChange={onEmailChange}
className={`text-base text-white p-2 bg-base-tertiary rounded border ${
isEmailChanged && !isEmailValid
? "border-red-500"
: "border-tertiary"
} flex-grow focus:outline-none focus:border-transparent focus:ring-0`}
className="text-base text-white p-2 bg-base-tertiary rounded border border-tertiary flex-grow focus:outline-none focus:border-transparent focus:ring-0"
placeholder={t("SETTINGS$USER_EMAIL_LOADING")}
data-testid="email-input"
/>
</div>
{isEmailChanged && !isEmailValid && (
<div
className="text-red-500 text-sm mt-1"
data-testid="email-validation-error"
>
{t("SETTINGS$INVALID_EMAIL_FORMAT")}
</div>
)}
<div className="flex items-center gap-3 mt-2">
<button
type="button"
onClick={onSaveEmail}
disabled={!isEmailChanged || isSaving || !isEmailValid}
disabled={!isEmailChanged || isSaving}
className="px-4 py-2 rounded bg-primary text-white hover:opacity-80 disabled:opacity-30 disabled:cursor-not-allowed disabled:text-[#0D0F11]"
data-testid="save-email-button"
>
@@ -116,7 +98,6 @@ function UserSettingsScreen() {
const [originalEmail, setOriginalEmail] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [isResendingVerification, setIsResendingVerification] = useState(false);
const [isEmailValid, setIsEmailValid] = useState(true);
const queryClient = useQueryClient();
const pollingIntervalRef = useRef<number | null>(null);
const prevVerificationStatusRef = useRef<boolean | undefined>(undefined);
@@ -125,7 +106,6 @@ function UserSettingsScreen() {
if (settings?.EMAIL) {
setEmail(settings.EMAIL);
setOriginalEmail(settings.EMAIL);
setIsEmailValid(EMAIL_REGEX.test(settings.EMAIL));
}
}, [settings?.EMAIL]);
@@ -163,13 +143,11 @@ function UserSettingsScreen() {
}, [settings?.EMAIL_VERIFIED, refetch, queryClient, t]);
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newEmail = e.target.value;
setEmail(newEmail);
setIsEmailValid(EMAIL_REGEX.test(newEmail));
setEmail(e.target.value);
};
const handleSaveEmail = async () => {
if (email === originalEmail || !isEmailValid) return;
if (email === originalEmail) return;
try {
setIsSaving(true);
await openHands.post("/api/email", { email }, { withCredentials: true });
@@ -216,7 +194,6 @@ function UserSettingsScreen() {
isResendingVerification={isResendingVerification}
isEmailChanged={isEmailChanged}
emailVerified={settings?.EMAIL_VERIFIED}
isEmailValid={isEmailValid}
>
{settings?.EMAIL_VERIFIED === false && <VerificationAlert />}
</EmailInputSection>
+3 -3
View File
@@ -12,7 +12,7 @@ export const TIPS: Tip[] = [
},
{
key: I18nKey.TIPS$SETUP_SCRIPT,
link: "https://docs.all-hands.dev/usage/prompting/repository#setup-script",
link: "https://docs.all-hands.dev/usage/customization/repository",
},
{ key: I18nKey.TIPS$VSCODE_INSTANCE },
{ key: I18nKey.TIPS$SAVE_WORK },
@@ -30,7 +30,7 @@ export const TIPS: Tip[] = [
},
{
key: I18nKey.TIPS$GITHUB_HOOK,
link: "https://docs.all-hands.dev/usage/cloud/github-installation#working-on-github-issues-and-pull-requests-using-openhands",
link: "https://docs.all-hands.dev/usage/cloud/cloud-issue-resolver",
},
{
key: I18nKey.TIPS$BLOG_SIGNUP,
@@ -38,7 +38,7 @@ export const TIPS: Tip[] = [
},
{
key: I18nKey.TIPS$API_USAGE,
link: "https://docs.all-hands.dev/api-reference/health-check",
link: "https://docs.all-hands.dev/swagger-ui/",
},
];
-3
View File
@@ -117,10 +117,7 @@ You can see an example of a repo agent in [the agent for the OpenHands repo itse
- Include repository structure details
- Specify testing and build procedures
- List environment requirements
- Document CI workflows and checks
- Include information about code quality standards
- Maintain up-to-date team practices
- Consider using OpenHands to generate a comprehensive repo.md (see [Creating a Repository Agent](#creating-a-repository-agent))
- YAML frontmatter is optional - files without frontmatter will be loaded with default settings
### Submission Process
+1
View File
@@ -37,4 +37,5 @@ When creating a new microagent:
For detailed information, see:
- [Microagents Overview](https://docs.all-hands.dev/usage/prompting/microagents-overview)
- [Microagents Syntax](https://docs.all-hands.dev/usage/prompting/microagents-syntax)
- [Example GitHub Microagent](https://github.com/All-Hands-AI/OpenHands/blob/main/microagents/github.md)
-65
View File
@@ -1,65 +0,0 @@
---
name: add_repo_inst
version: 1.0.0
author: openhands
agent: CodeActAgent
triggers:
- /add_repo_inst
inputs:
- name: REPO_FOLDER_NAME
description: "Branch for the agent to work on"
---
Please browse the current repository under /workspace/{{ REPO_FOLDER_NAME }}, look at the documentation and relevant code, and understand the purpose of this repository.
Specifically, I want you to create a `.openhands/microagents/repo.md` file. This file should contain succinct information that summarizes (1) the purpose of this repository, (2) the general setup of this repo, and (3) a brief description of the structure of this repo.
Here's an example:
```markdown
---
name: repo
type: repo
agent: CodeActAgent
---
This repository contains the code for OpenHands, an automated AI software engineer. It has a Python backend
(in the `openhands` directory) and React frontend (in the `frontend` directory).
## General Setup:
To set up the entire repo, including frontend and backend, run `make build`.
You don't need to do this unless the user asks you to, or if you're trying to run the entire application.
Before pushing any changes, you should ensure that any lint errors or simple test errors have been fixed.
* If you've made changes to the backend, you should run `pre-commit run --all-files --config ./dev_config/python/.pre-commit-config.yaml`
* If you've made changes to the frontend, you should run `cd frontend && npm run lint:fix && npm run build ; cd ..`
If either command fails, it may have automatically fixed some issues. You should fix any issues that weren't automatically fixed,
then re-run the command to ensure it passes.
## Repository Structure
Backend:
- Located in the `openhands` directory
- Testing:
- All tests are in `tests/unit/test_*.py`
- To test new code, run `poetry run pytest tests/unit/test_xxx.py` where `xxx` is the appropriate file for the current functionality
- Write all tests with pytest
Frontend:
- Located in the `frontend` directory
- Prerequisites: A recent version of NodeJS / NPM
- Setup: Run `npm install` in the frontend directory
- Testing:
- Run tests: `npm run test`
- To run specific tests: `npm run test -- -t "TestName"`
- Building:
- Build for production: `npm run build`
- Environment Variables:
- Set in `frontend/.env` or as environment variables
- Available variables: VITE_BACKEND_HOST, VITE_USE_TLS, VITE_INSECURE_SKIP_VERIFY, VITE_FRONTEND_PORT
- Internationalization:
- Generate i18n declaration file: `npm run make-i18n`
```
Now, please write a similar markdown for the current repository.
Read all the GitHub workflows under .github/ of the repository (if this folder exists) to understand the CI checks (e.g., linter, pre-commit), and include those in the repo.md file.
-19
View File
@@ -1,19 +0,0 @@
---
name: address_pr_comments
version: 1.0.0
author: openhands
agent: CodeActAgent
triggers:
- /address_pr_comments
inputs:
- name: PR_URL
description: "URL of the pull request"
- name: BRANCH_NAME
description: "Branch name corresponds to the pull request"
---
First, check the branch {{ BRANCH_NAME }} and read the diff against the main branch to understand the purpose.
This branch corresponds to this PR {{ PR_URL }}
Next, you should use the GitHub API to read the reviews and comments on this PR and address them.
-23
View File
@@ -1,23 +0,0 @@
---
name: fix_test
version: 1.0.0
author: openhands
agent: CodeActAgent
triggers:
- /fix_test
inputs:
- name: BRANCH_NAME
description: "Branch for the agent to work on"
- name: TEST_COMMAND_TO_RUN
description: "The test command you want the agent to work on. For example, `pytest tests/unit/test_bash_parsing.py`"
- name: FUNCTION_TO_FIX
description: "The name of function to fix"
- name: FILE_FOR_FUNCTION
description: "The path of the file that contains the function"
---
Can you check out branch "{{ BRANCH_NAME }}", and run {{ TEST_COMMAND_TO_RUN }}.
Help me fix these tests to pass by fixing the {{ FUNCTION_TO_FIX }} function in file {{ FILE_FOR_FUNCTION }}.
PLEASE DO NOT modify the tests by yourself -- Let me know if you think some of the tests are incorrect.
-21
View File
@@ -1,21 +0,0 @@
---
name: update_pr_description
version: 1.0.0
author: openhands
agent: CodeActAgent
triggers:
- /update_pr_description
inputs:
- name: PR_URL
description: "URL of the pull request"
type: string
validation:
pattern: "^https://github.com/.+/.+/pull/[0-9]+$"
- name: BRANCH_NAME
description: "Branch name corresponds to the pull request"
type: string
---
Please check the branch "{{ BRANCH_NAME }}" and look at the diff against the main branch. This branch belongs to this PR "{{ PR_URL }}".
Once you understand the purpose of the diff, please use Github API to read the existing PR description, and update it to be more reflective of the changes we've made when necessary.
-19
View File
@@ -1,19 +0,0 @@
---
name: update_test
version: 1.0.0
author: openhands
agent: CodeActAgent
triggers:
- /update_test
inputs:
- name: BRANCH_NAME
description: "Branch for the agent to work on"
- name: TEST_COMMAND_TO_RUN
description: "The test command you want the agent to work on. For example, `pytest tests/unit/test_bash_parsing.py`"
---
Can you check out branch "{{ BRANCH_NAME }}", and run {{ TEST_COMMAND_TO_RUN }}.
The current implementation of the code is correct BUT the test functions {{ FUNCTION_TO_FIX }} in file {{ FILE_FOR_FUNCTION }} are failing.
Please update the test file so that they pass with the current version of the implementation.
+26 -95
View File
@@ -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, StyleAndTextTuples
from prompt_toolkit.formatted_text import HTML, FormattedText, 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,113 +132,51 @@ def display_initialization_animation(text: str, is_loaded: asyncio.Event) -> Non
def display_banner(session_id: str) -> None:
banner_text = r"""<gold>
print_formatted_text(
HTML(r"""<gold>
___ _ _ _
/ _ \ _ __ ___ _ __ | | | | __ _ _ __ __| |___
| | | | '_ \ / _ \ '_ \| |_| |/ _` | '_ \ / _` / __|
| |_| | |_) | __/ | | | _ | (_| | | | | (_| \__ \
\___ /| .__/ \___|_| |_|_| |_|\__,_|_| |_|\__,_|___/
|_|
</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}',
</gold>"""),
style=DEFAULT_STYLE,
)
print_container(banner_container)
# Call print_formatted_text to maintain compatibility with tests
print_formatted_text(HTML(f'<grey>OpenHands CLI v{__version__}</grey>'))
print_formatted_text('')
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(HTML(f'<grey>Initialized conversation {session_id}</grey>'))
print_formatted_text('')
def display_welcome_message(message: str = '') -> None:
# 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_formatted_text(
HTML("<gold>Let's start building!</gold>\n"), style=DEFAULT_STYLE
)
print_container(welcome_container)
# Call print_formatted_text to maintain compatibility with tests
print_formatted_text('')
if message:
message_text = f'{message} Type /help for help'
print_formatted_text(
HTML(f'{message} <grey>Type /help for help</grey>'),
style=DEFAULT_STYLE,
)
else:
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('')
print_formatted_text(
HTML('What do you want to build? <grey>Type /help for help</grey>'),
style=DEFAULT_STYLE,
)
def display_initial_user_prompt(prompt: str) -> None:
# 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(
FormattedText(
[
('', '\n'),
(COLOR_GOLD, '> '),
('', prompt),
]
)
)
print_formatted_text('')
print_container(prompt_container)
# Prompt output display functions
@@ -286,7 +224,6 @@ 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',
@@ -302,7 +239,6 @@ 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',
@@ -331,7 +267,6 @@ 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}',
@@ -347,7 +282,6 @@ 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}',
@@ -364,7 +298,6 @@ 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}',
@@ -383,7 +316,6 @@ 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,
@@ -493,7 +425,6 @@ 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}',
-4
View File
@@ -3,7 +3,6 @@ from typing import Iterable
from urllib.parse import urlencode
import httpx # type: ignore
from fastapi import status
from openhands.events.event import Event
from openhands.events.event_filter import EventFilter
@@ -43,9 +42,6 @@ class NestedEventStore(EventStoreABC):
if self.session_api_key:
headers['X-Session-API-Key'] = self.session_api_key
response = httpx.get(url, headers=headers)
if response.status_code == status.HTTP_404_NOT_FOUND:
# Follow pattern of event store not throwing errors on not found
return
result_set = response.json()
for result in result_set['events']:
event = event_from_dict(result)
@@ -505,7 +505,10 @@ class GitHubService(BaseGitService, GitService):
)
# Return the HTML URL of the created PR
return response['html_url']
if 'html_url' in response:
return response['html_url']
else:
return f'PR created but URL not found in response: {response}'
@@ -500,8 +500,12 @@ class GitLabService(BaseGitService, GitService):
url=url, params=payload, method=RequestMethod.POST
)
# Return the web URL of the created MR
if 'web_url' in response:
return response['web_url']
else:
return f'MR created but URL not found in response: {response}'
return response['web_url']
@@ -15,3 +15,4 @@ When you're done, make sure to
2. Use the `create_pr` tool to open a new PR
3. Name the branch using `openhands/` as a prefix (e.g `openhands/update-readme`)
4. The PR description should mention that it "fixes" or "closes" the issue number
5. Make sure to leave the following sentence at the end of the PR description: `@{{ username }} can click here to [continue refining the PR]({{ conversation_url }})`
@@ -9,3 +9,4 @@ When you're done, make sure to
1. Use the `create_pr` tool to open a new PR
2. The PR description should mention that it "fixes" or "closes" the issue number
3. Make sure to leave the following sentence at the end of the PR description: `@{{ username }} can click here to [continue refining the PR]({{ conversation_url }})`
@@ -15,3 +15,4 @@ When you're done, make sure to
2. Use the `create_mr` tool to open a new MR
3. Name the branch using `openhands/` as a prefix (e.g `openhands/update-readme`)
4. The MR description should mention that it "fixes" or "closes" the issue number
5. Make sure to leave the following sentence at the end of the MR description: `@{{ username }} can click here to [continue refining the MR]({{ conversation_url }})`
@@ -9,3 +9,4 @@ When you're done, make sure to
1. Use the `create_mr` tool to open a new MR
2. The MR description should mention that it "fixes" or "closes" the issue number
3. Make sure to leave the following sentence at the end of the MR description: `@{{ username }} can click here to [continue refining the MR]({{ conversation_url }})`
@@ -5,3 +5,7 @@ These are a list of text messages attached in order of most recent.
{{ message }}
{% if not loop.last %}\n\n{% endif %}
{% endfor %}
If you opened a pull request, please leave the following comment at the end your summary and pull request description
`{{ username }} can click here to [continue refining the PR]({{ conversation_url }})`
@@ -1,8 +1,5 @@
Please send a final message summarizing your work.
Please summarize your work.
If you simply answered a question, this final message should re-state the answer to the question.
If you made changes, please first double-check the git diff, think carefully about the user's request(s), and check:
1. whether the request has been completely addressed and all of the instructions have been followed faithfully (in checklist format if appropriate).
2. whether the changes are concise (if there are any extraneous changes not important to addressing the user's request they should be reverted).
If the request has been addressed and the changes are concise, then push your changes to the remote branch and send a final message summarizing the changes.
If you answered a question, please re-state the answer to the question
If you made changes, please create a concise overview on whether the request has been addressed successfully or if there are were issues with the attempt.
If successful, make sure your changes are pushed to the remote branch.
+170 -66
View File
@@ -1,12 +1,13 @@
import asyncio
import datetime
from contextlib import AsyncExitStack
from typing import Optional
from fastmcp import Client
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
from mcp import McpError
from mcp.types import CallToolResult
from mcp import ClientSession
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamablehttp_client
from pydantic import BaseModel, Field
from openhands.core.config.mcp_config import MCPSHTTPServerConfig, MCPSSEServerConfig
from openhands.core.logger import openhands_logger as logger
from openhands.mcp.tool import MCPClientTool
@@ -16,7 +17,8 @@ class MCPClient(BaseModel):
A collection of tools that connects to an MCP server and manages available tools through the Model Context Protocol.
"""
client: Optional[Client] = None
session: Optional[ClientSession] = None
exit_stack: AsyncExitStack = AsyncExitStack()
description: str = 'MCP client tools for server interaction'
tools: list[MCPClientTool] = Field(default_factory=list)
tool_map: dict[str, MCPClientTool] = Field(default_factory=dict)
@@ -24,87 +26,189 @@ class MCPClient(BaseModel):
class Config:
arbitrary_types_allowed = True
async def connect_sse(
self,
server_url: str,
api_key: str | None = None,
conversation_id: str | None = None,
timeout: float = 30.0,
) -> None:
"""Connect to an MCP server using SSE transport.
Args:
server_url: The URL of the SSE server to connect to.
timeout: Connection timeout in seconds. Default is 30 seconds.
"""
if not server_url:
raise ValueError('Server URL is required.')
if self.session:
await self.disconnect()
try:
# Use asyncio.wait_for to enforce the timeout
async def connect_with_timeout():
headers = (
{
'Authorization': f'Bearer {api_key}',
's': api_key, # We need this for action execution server's MCP Router
'X-Session-API-Key': api_key, # We need this for Remote Runtime
}
if api_key
else {}
)
if conversation_id:
headers['X-OpenHands-Conversation-ID'] = conversation_id
# Convert float timeout to datetime.timedelta for consistency
timeout_delta = datetime.timedelta(seconds=timeout)
streams_context = sse_client(
url=server_url,
headers=headers if headers else None,
timeout=timeout,
)
streams = await self.exit_stack.enter_async_context(streams_context)
# For SSE client, we only get read_stream and write_stream (2 values)
read_stream, write_stream = streams
self.session = await self.exit_stack.enter_async_context(
ClientSession(
read_stream, write_stream, read_timeout_seconds=timeout_delta
)
)
await self._initialize_and_list_tools()
# Apply timeout to the entire connection process
await asyncio.wait_for(connect_with_timeout(), timeout=timeout)
except asyncio.TimeoutError:
logger.error(
f'Connection to {server_url} timed out after {timeout} seconds'
)
await self.disconnect() # Clean up resources
raise # Re-raise the TimeoutError
except Exception as e:
logger.error(f'Error connecting to {server_url}: {str(e)}')
await self.disconnect() # Clean up resources
raise
async def _initialize_and_list_tools(self) -> None:
"""Initialize session and populate tool map."""
if not self.client:
if not self.session:
raise RuntimeError('Session not initialized.')
async with self.client:
tools = await self.client.list_tools()
await self.session.initialize()
response = await self.session.list_tools()
# Clear existing tools
self.tools = []
# Create proper tool objects for each server tool
for tool in tools:
for tool in response.tools:
server_tool = MCPClientTool(
name=tool.name,
description=tool.description,
inputSchema=tool.inputSchema,
session=self.client,
session=self.session,
)
self.tool_map[tool.name] = server_tool
self.tools.append(server_tool)
logger.info(f'Connected to server with tools: {[tool.name for tool in tools]}')
logger.info(
f'Connected to server with tools: {[tool.name for tool in response.tools]}'
)
async def connect_http(
self,
server: MCPSSEServerConfig | MCPSHTTPServerConfig,
conversation_id: str | None = None,
timeout: float = 30.0,
):
"""Connect to MCP server using SHTTP or SSE transport"""
server_url = server.url
api_key = server.api_key
if not server_url:
raise ValueError('Server URL is required.')
try:
headers = (
{
'Authorization': f'Bearer {api_key}',
's': api_key, # We need this for action execution server's MCP Router
'X-Session-API-Key': api_key, # We need this for Remote Runtime
}
if api_key
else {}
)
if conversation_id:
headers['X-OpenHands-ServerConversation-ID'] = conversation_id
# Instantiate custom transports due to custom headers
if isinstance(server, MCPSHTTPServerConfig):
transport = StreamableHttpTransport(
url=server_url,
headers=headers if headers else None,
)
else:
transport = SSETransport(
url=server_url,
headers=headers if headers else None,
)
self.client = Client(transport, timeout=timeout)
await self._initialize_and_list_tools()
except McpError as e:
logger.error(f'McpError connecting to {server_url}: {e}')
raise # Re-raise the error
except Exception as e:
logger.error(f'Error connecting to {server_url}: {e}')
raise
async def call_tool(self, tool_name: str, args: dict) -> CallToolResult:
async def call_tool(self, tool_name: str, args: dict):
"""Call a tool on the MCP server."""
if tool_name not in self.tool_map:
raise ValueError(f'Tool {tool_name} not found.')
# The MCPClientTool is primarily for metadata; use the session to call the actual tool.
if not self.client:
if not self.session:
raise RuntimeError('Client session is not available.')
return await self.session.call_tool(name=tool_name, arguments=args)
async with self.client:
return await self.client.call_tool_mcp(name=tool_name, arguments=args)
async def connect_shttp(
self,
server_url: str,
api_key: str | None = None,
conversation_id: str | None = None,
timeout: float = 30.0,
) -> None:
"""Connect to an MCP server using StreamableHTTP transport.
Args:
server_url: The URL of the StreamableHTTP server to connect to.
api_key: Optional API key for authentication.
conversation_id: Optional conversation ID for session tracking.
timeout: Connection timeout in seconds. Default is 30 seconds.
"""
if not server_url:
raise ValueError('Server URL is required.')
if self.session:
await self.disconnect()
try:
# Use asyncio.wait_for to enforce the timeout
async def connect_with_timeout():
headers = (
{
'Authorization': f'Bearer {api_key}',
's': api_key, # We need this for action execution server's MCP Router
'X-Session-API-Key': api_key, # We need this for Remote Runtime
}
if api_key
else {}
)
if conversation_id:
headers['X-OpenHands-Conversation-ID'] = conversation_id
# Convert float timeout to datetime.timedelta
timeout_delta = datetime.timedelta(seconds=timeout)
sse_read_timeout_delta = datetime.timedelta(
seconds=timeout * 10
) # 10x longer for read timeout
streams_context = streamablehttp_client(
url=server_url,
headers=headers if headers else None,
timeout=timeout_delta,
sse_read_timeout=sse_read_timeout_delta,
)
streams = await self.exit_stack.enter_async_context(streams_context)
# For StreamableHTTP client, we get read_stream, write_stream, and get_session_id (3 values)
read_stream, write_stream, _ = streams
self.session = await self.exit_stack.enter_async_context(
ClientSession(
read_stream, write_stream, read_timeout_seconds=timeout_delta
)
)
await self._initialize_and_list_tools()
# Apply timeout to the entire connection process
await asyncio.wait_for(connect_with_timeout(), timeout=timeout)
except asyncio.TimeoutError:
logger.error(
f'Connection to {server_url} timed out after {timeout} seconds'
)
await self.disconnect() # Clean up resources
raise # Re-raise the TimeoutError
except Exception as e:
logger.error(f'Error connecting to {server_url}: {str(e)}')
await self.disconnect() # Clean up resources
raise
async def disconnect(self) -> None:
"""Disconnect from the MCP server and clean up resources."""
if self.session:
try:
# Close the session first
if hasattr(self.session, 'close'):
await self.session.close()
# Then close the exit stack
await self.exit_stack.aclose()
except Exception as e:
logger.error(f'Error during disconnect: {str(e)}')
finally:
self.session = None
self.tools = []
logger.info('Disconnected from MCP server')
+27 -4
View File
@@ -72,22 +72,38 @@ async def create_mcp_clients(
mcp_clients = []
for server in servers:
is_shttp = isinstance(server, MCPSHTTPServerConfig)
connection_type = 'SHTTP' if is_shttp else 'SSE'
is_sse = isinstance(server, MCPSSEServerConfig)
connection_type = 'SSE' if is_sse else 'SHTTP'
logger.info(
f'Initializing MCP agent for {server} with {connection_type} connection...'
)
client = MCPClient()
try:
await client.connect_http(server, conversation_id=conversation_id)
if is_sse:
await client.connect_sse(
server.url,
api_key=server.api_key,
conversation_id=conversation_id,
)
else:
await client.connect_shttp(
server.url,
api_key=server.api_key,
conversation_id=conversation_id,
)
# Only add the client to the list after a successful connection
mcp_clients.append(client)
except Exception as e:
logger.error(f'Failed to connect to {server}: {str(e)}', exc_info=True)
try:
await client.disconnect()
except Exception as disconnect_error:
logger.error(
f'Error during disconnect after failed connection: {str(disconnect_error)}'
)
return mcp_clients
@@ -127,6 +143,13 @@ async def fetch_mcp_tools_from_config(
# Convert tools to the format expected by the agent
mcp_tools = convert_mcp_clients_to_tools(mcp_clients)
# Always disconnect clients to clean up resources
for mcp_client in mcp_clients:
try:
await mcp_client.disconnect()
except Exception as disconnect_error:
logger.error(f'Error disconnecting MCP client: {str(disconnect_error)}')
except Exception as e:
logger.error(f'Error fetching MCP tools: {str(e)}')
return []
+8 -74
View File
@@ -1,5 +1,4 @@
import io
import re
from pathlib import Path
from typing import Union
@@ -10,7 +9,7 @@ from openhands.core.exceptions import (
MicroagentValidationError,
)
from openhands.core.logger import openhands_logger as logger
from openhands.microagent.types import InputMetadata, MicroagentMetadata, MicroagentType
from openhands.microagent.types import MicroagentMetadata, MicroagentType
class BaseMicroagent(BaseModel):
@@ -92,24 +91,13 @@ class BaseMicroagent(BaseModel):
subclass_map = {
MicroagentType.KNOWLEDGE: KnowledgeMicroagent,
MicroagentType.REPO_KNOWLEDGE: RepoMicroagent,
MicroagentType.TASK: TaskMicroagent,
}
# Infer the agent type:
# 1. If inputs exist -> TASK
# 2. If triggers exist -> KNOWLEDGE
# 3. Else (no triggers) -> REPO (always active)
# 1. If triggers exist -> KNOWLEDGE (optional)
# 2. Else (no triggers) -> REPO (always active)
inferred_type: MicroagentType
if metadata.inputs:
inferred_type = MicroagentType.TASK
# Add a trigger for the agent name if not already present
trigger = f'/{metadata.name}'
if not metadata.triggers or trigger not in metadata.triggers:
if not metadata.triggers:
metadata.triggers = [trigger]
else:
metadata.triggers.append(trigger)
elif metadata.triggers:
if metadata.triggers:
inferred_type = MicroagentType.KNOWLEDGE
else:
# No triggers, default to REPO
@@ -134,9 +122,7 @@ class BaseMicroagent(BaseModel):
class KnowledgeMicroagent(BaseMicroagent):
"""Knowledge micro-agents provide specialized expertise that's triggered by keywords in conversations.
They help with:
"""Knowledge micro-agents provide specialized expertise that's triggered by keywords in conversations. They help with:
- Language best practices
- Framework guidelines
- Common patterns
@@ -145,8 +131,8 @@ class KnowledgeMicroagent(BaseMicroagent):
def __init__(self, **data):
super().__init__(**data)
if self.type not in [MicroagentType.KNOWLEDGE, MicroagentType.TASK]:
raise ValueError('KnowledgeMicroagent must have type KNOWLEDGE or TASK')
if self.type != MicroagentType.KNOWLEDGE:
raise ValueError('KnowledgeMicroagent must have type KNOWLEDGE')
def match_trigger(self, message: str) -> str | None:
"""Match a trigger in the message.
@@ -185,57 +171,6 @@ class RepoMicroagent(BaseMicroagent):
)
class TaskMicroagent(KnowledgeMicroagent):
"""TaskMicroagent is a special type of KnowledgeMicroagent that requires user input.
These microagents are triggered by a special format: "/{agent_name}"
and will prompt the user for any required inputs before proceeding.
"""
def __init__(self, **data):
super().__init__(**data)
if self.type != MicroagentType.TASK:
raise ValueError(
f'TaskMicroagent initialized with incorrect type: {self.type}'
)
# Append a prompt to ask for missing variables
self._append_missing_variables_prompt()
def _append_missing_variables_prompt(self) -> None:
"""Append a prompt to ask for missing variables."""
# Check if the content contains any variables or has inputs defined
if not self.requires_user_input() and not self.metadata.inputs:
return
prompt = "\n\nIf the user didn't provide any of these variables, ask the user to provide them first before the agent can proceed with the task."
self.content += prompt
def extract_variables(self, content: str) -> list[str]:
"""Extract variables from the content.
Variables are in the format ${variable_name}.
"""
pattern = r'\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}'
matches = re.findall(pattern, content)
return matches
def requires_user_input(self) -> bool:
"""Check if this microagent requires user input.
Returns True if the content contains variables in the format ${variable_name}.
"""
# Check if the content contains any variables
variables = self.extract_variables(self.content)
logger.debug(f'This microagent requires user input: {variables}')
return len(variables) > 0
@property
def inputs(self) -> list[InputMetadata]:
"""Get the inputs for this microagent."""
return self.metadata.inputs
def load_microagents_from_dir(
microagent_dir: Union[str, Path],
) -> tuple[dict[str, RepoMicroagent], dict[str, KnowledgeMicroagent]]:
@@ -247,7 +182,7 @@ def load_microagents_from_dir(
microagent_dir: Path to the microagents directory (e.g. .openhands/microagents)
Returns:
Tuple of (repo_agents, knowledge_agents) dictionaries
Tuple of (repo_agents, knowledge_agents, task_agents) dictionaries
"""
if isinstance(microagent_dir, str):
microagent_dir = Path(microagent_dir)
@@ -267,7 +202,6 @@ def load_microagents_from_dir(
if isinstance(agent, RepoMicroagent):
repo_agents[agent.name] = agent
elif isinstance(agent, KnowledgeMicroagent):
# Both KnowledgeMicroagent and TaskMicroagent go into knowledge_agents
knowledge_agents[agent.name] = agent
except MicroagentValidationError as e:
# For validation errors, include the original exception
-9
View File
@@ -12,14 +12,6 @@ class MicroagentType(str, Enum):
KNOWLEDGE = 'knowledge' # Optional microagent, triggered by keywords
REPO_KNOWLEDGE = 'repo' # Always active microagent
TASK = 'task' # Special type for task microagents that require user input
class InputMetadata(BaseModel):
"""Metadata for task microagent inputs."""
name: str
description: str
class MicroagentMetadata(BaseModel):
@@ -30,7 +22,6 @@ class MicroagentMetadata(BaseModel):
version: str = Field(default='1.0.0')
agent: str = Field(default='CodeActAgent')
triggers: list[str] = [] # optional, only exists for knowledge microagents
inputs: list[InputMetadata] = [] # optional, only exists for task microagents
mcp_tools: MCPConfig | None = (
None # optional, for microagents that provide additional MCP tools
)
+122 -66
View File
@@ -9,6 +9,7 @@ import argparse
import asyncio
import base64
import json
import logging
import mimetypes
import os
import shutil
@@ -25,6 +26,8 @@ from fastapi import Depends, FastAPI, HTTPException, Request, UploadFile
from fastapi.exceptions import RequestValidationError
from fastapi.responses import FileResponse, JSONResponse
from fastapi.security import APIKeyHeader
from mcpm import MCPRouter, RouterConfig
from mcpm.router.router import logger as mcp_router_logger
from openhands_aci.editor.editor import OHEditor
from openhands_aci.editor.exceptions import ToolError
from openhands_aci.editor.results import ToolResult
@@ -34,7 +37,6 @@ from starlette.background import BackgroundTask
from starlette.exceptions import HTTPException as StarletteHTTPException
from uvicorn import run
from openhands.core.config.mcp_config import MCPStdioServerConfig
from openhands.core.exceptions import BrowserUnavailableException
from openhands.core.logger import openhands_logger as logger
from openhands.events.action import (
@@ -61,18 +63,21 @@ from openhands.events.serialization import event_from_dict, event_to_dict
from openhands.runtime.browser import browse
from openhands.runtime.browser.browser_env import BrowserEnv
from openhands.runtime.file_viewer_server import start_file_viewer_server
# Import our custom MCP Proxy Manager
from openhands.runtime.mcp.proxy import MCPProxyManager
from openhands.runtime.plugins import ALL_PLUGINS, JupyterPlugin, Plugin, VSCodePlugin
from openhands.runtime.utils import find_available_tcp_port
from openhands.runtime.utils.async_bash import AsyncBashSession
from openhands.runtime.utils.bash import BashSession
from openhands.runtime.utils.files import insert_lines, read_lines
from openhands.runtime.utils.log_capture import capture_logs
from openhands.runtime.utils.memory_monitor import MemoryMonitor
from openhands.runtime.utils.runtime_init import init_user_and_working_directory
from openhands.runtime.utils.system_stats import get_system_stats
from openhands.utils.async_utils import call_sync_from_async, wait_all
# Set MCP router logger to the same level as the main logger
mcp_router_logger.setLevel(logger.getEffectiveLevel())
if sys.platform == 'win32':
from openhands.runtime.utils.windows_bash import WindowsPowershellSession
@@ -249,10 +254,12 @@ class ActionExecutor:
# If we get here, the browser is ready
logger.debug('Browser is ready')
def _create_bash_session(self, cwd: str | None = None):
async def ainit(self):
# bash needs to be initialized first
logger.debug('Initializing bash session')
if sys.platform == 'win32':
return WindowsPowershellSession( # type: ignore[name-defined]
work_dir=cwd or self._initial_cwd,
self.bash_session = WindowsPowershellSession( # type: ignore[name-defined]
work_dir=self._initial_cwd,
username=self.username,
no_change_timeout_seconds=int(
os.environ.get('NO_CHANGE_TIMEOUT_SECONDS', 10)
@@ -260,21 +267,15 @@ class ActionExecutor:
max_memory_mb=self.max_memory_gb * 1024 if self.max_memory_gb else None,
)
else:
bash_session = BashSession(
work_dir=cwd or self._initial_cwd,
self.bash_session = BashSession(
work_dir=self._initial_cwd,
username=self.username,
no_change_timeout_seconds=int(
os.environ.get('NO_CHANGE_TIMEOUT_SECONDS', 10)
),
max_memory_mb=self.max_memory_gb * 1024 if self.max_memory_gb else None,
)
bash_session.initialize()
return bash_session
async def ainit(self):
# bash needs to be initialized first
logger.debug('Initializing bash session')
self.bash_session = self._create_bash_session()
self.bash_session.initialize()
logger.debug('Bash session initialized')
# Start browser initialization in the background
@@ -387,11 +388,18 @@ class ActionExecutor:
self, action: CmdRunAction
) -> CmdOutputObservation | ErrorObservation:
try:
bash_session = self.bash_session
if action.is_static:
bash_session = self._create_bash_session(action.cwd)
assert bash_session is not None
obs = await call_sync_from_async(bash_session.execute, action)
path = action.cwd or self._initial_cwd
result = await AsyncBashSession.execute(action.command, path)
obs = CmdOutputObservation(
content=result.content,
exit_code=result.exit_code,
command=action.command,
)
return obs
assert self.bash_session is not None
obs = await call_sync_from_async(self.bash_session.execute, action)
return obs
except Exception as e:
logger.error(f'Error running command: {e}')
@@ -467,7 +475,7 @@ class ActionExecutor:
filepath = self._resolve_path(action.path, working_dir)
try:
if filepath.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.gif')):
with open(filepath, 'rb') as file:
with open(filepath, 'rb') as file: # noqa: ASYNC101
image_data = file.read()
encoded_image = base64.b64encode(image_data).decode('utf-8')
mime_type, _ = mimetypes.guess_type(filepath)
@@ -477,13 +485,13 @@ class ActionExecutor:
return FileReadObservation(path=filepath, content=encoded_image)
elif filepath.lower().endswith('.pdf'):
with open(filepath, 'rb') as file:
with open(filepath, 'rb') as file: # noqa: ASYNC101
pdf_data = file.read()
encoded_pdf = base64.b64encode(pdf_data).decode('utf-8')
encoded_pdf = f'data:application/pdf;base64,{encoded_pdf}'
return FileReadObservation(path=filepath, content=encoded_pdf)
elif filepath.lower().endswith(('.mp4', '.webm', '.ogg')):
with open(filepath, 'rb') as file:
with open(filepath, 'rb') as file: # noqa: ASYNC101
video_data = file.read()
encoded_video = base64.b64encode(video_data).decode('utf-8')
mime_type, _ = mimetypes.guess_type(filepath)
@@ -493,7 +501,7 @@ class ActionExecutor:
return FileReadObservation(path=filepath, content=encoded_video)
with open(filepath, 'r', encoding='utf-8') as file:
with open(filepath, 'r', encoding='utf-8') as file: # noqa: ASYNC101
lines = read_lines(file.readlines(), action.start, action.end)
except FileNotFoundError:
return ErrorObservation(
@@ -526,7 +534,7 @@ class ActionExecutor:
mode = 'w' if not file_exists else 'r+'
try:
with open(filepath, mode, encoding='utf-8') as file:
with open(filepath, mode, encoding='utf-8') as file: # noqa: ASYNC101
if mode != 'w':
all_lines = file.readlines()
new_file = insert_lines(insert, all_lines, action.start, action.end)
@@ -650,11 +658,14 @@ if __name__ == '__main__':
plugins_to_load.append(ALL_PLUGINS[plugin]()) # type: ignore
client: ActionExecutor | None = None
mcp_proxy_manager: MCPProxyManager | None = None
mcp_router: MCPRouter | None = None
MCP_ROUTER_PROFILE_PATH = os.path.join(
os.path.dirname(__file__), 'mcp', 'config.json'
)
@asynccontextmanager
async def lifespan(app: FastAPI):
global client, mcp_proxy_manager
global client, mcp_router
logger.info('Initializing ActionExecutor...')
client = ActionExecutor(
plugins_to_load,
@@ -669,36 +680,63 @@ if __name__ == '__main__':
# Check if we're on Windows
is_windows = sys.platform == 'win32'
# Initialize and mount MCP Proxy Manager (skip on Windows)
# Initialize and mount MCP Router (skip on Windows)
if is_windows:
logger.info('Skipping MCP Proxy initialization on Windows')
mcp_proxy_manager = None
logger.info('Skipping MCP Router initialization on Windows')
mcp_router = None
else:
logger.info('Initializing MCP Proxy Manager...')
# Create a MCP Proxy Manager
mcp_proxy_manager = MCPProxyManager(
auth_enabled=bool(SESSION_API_KEY),
api_key=SESSION_API_KEY,
logger_level=logger.getEffectiveLevel(),
logger.info('Initializing MCP Router...')
mcp_router = MCPRouter(
profile_path=MCP_ROUTER_PROFILE_PATH,
router_config=RouterConfig(
api_key=SESSION_API_KEY,
auth_enabled=bool(SESSION_API_KEY),
),
)
mcp_proxy_manager.initialize()
# Mount the proxy to the app
allowed_origins = ['*']
try:
await mcp_proxy_manager.mount_to_app(app, allowed_origins)
except Exception as e:
logger.error(f'Error mounting MCP Proxy: {e}', exc_info=True)
raise RuntimeError(f'Cannot mount MCP Proxy: {e}')
sse_app = await mcp_router.get_sse_server_app(
allow_origins=allowed_origins, include_lifespan=False
)
# Only mount SSE app if MCP Router is initialized (not on Windows)
if mcp_router is not None:
# Check for route conflicts before mounting
main_app_routes = {route.path for route in app.routes}
sse_app_routes = {route.path for route in sse_app.routes}
conflicting_routes = main_app_routes.intersection(sse_app_routes)
if conflicting_routes:
logger.error(f'Route conflicts detected: {conflicting_routes}')
raise RuntimeError(
f'Cannot mount SSE app - conflicting routes found: {conflicting_routes}'
)
app.mount('/', sse_app)
logger.info(
f'Mounted MCP Router SSE app at root path with allowed origins: {allowed_origins}'
)
# Additional debug logging
if logger.isEnabledFor(logging.DEBUG):
logger.debug('Main app routes:')
for route in main_app_routes:
logger.debug(f' {route}')
logger.debug('MCP SSE server app routes:')
for route in sse_app_routes:
logger.debug(f' {route}')
yield
# Clean up & release the resources
logger.info('Shutting down MCP Proxy Manager...')
if mcp_proxy_manager:
del mcp_proxy_manager
mcp_proxy_manager = None
logger.info('Shutting down MCP Router...')
if mcp_router:
try:
await mcp_router.shutdown()
logger.info('MCP Router shutdown successfully.')
except Exception as e:
logger.error(f'Error shutting down MCP Router: {e}', exc_info=True)
else:
logger.info('MCP Proxy Manager instance not found for shutdown.')
logger.info('MCP Router instance not found for shutdown.')
logger.info('Closing ActionExecutor...')
if client:
@@ -790,9 +828,6 @@ if __name__ == '__main__':
# Check if we're on Windows
is_windows = sys.platform == 'win32'
# Access the global mcp_proxy_manager variable
global mcp_proxy_manager
if is_windows:
# On Windows, just return a success response without doing anything
logger.info(
@@ -807,10 +842,17 @@ if __name__ == '__main__':
)
# Non-Windows implementation
if mcp_proxy_manager is None:
raise HTTPException(
status_code=500, detail='MCP Proxy Manager is not initialized'
)
assert mcp_router is not None
assert os.path.exists(MCP_ROUTER_PROFILE_PATH)
# Use synchronous file operations outside of async function
def read_profile():
with open(MCP_ROUTER_PROFILE_PATH, 'r') as f:
return json.load(f)
current_profile = read_profile()
assert 'default' in current_profile
assert isinstance(current_profile['default'], list)
# Get the request body
mcp_tools_to_sync = await request.json()
@@ -818,17 +860,31 @@ if __name__ == '__main__':
raise HTTPException(
status_code=400, detail='Request must be a list of MCP tools to sync'
)
logger.info(
f'Updating MCP server with tools: {json.dumps(mcp_tools_to_sync, indent=2)}'
f'Updating MCP server to: {json.dumps(mcp_tools_to_sync, indent=2)}.\nPrevious profile: {json.dumps(current_profile, indent=2)}'
)
mcp_tools_to_sync = [MCPStdioServerConfig(**tool) for tool in mcp_tools_to_sync]
try:
await mcp_proxy_manager.update_and_remount(app, mcp_tools_to_sync, ['*'])
logger.info('MCP Proxy Manager updated and remounted successfully')
router_error_log = ''
except Exception as e:
logger.error(f'Error updating MCP Proxy Manager: {e}', exc_info=True)
router_error_log = str(e)
current_profile['default'] = mcp_tools_to_sync
# Use synchronous file operations outside of async function
def write_profile(profile):
with open(MCP_ROUTER_PROFILE_PATH, 'w') as f:
json.dump(profile, f)
write_profile(current_profile)
# Manually reload the profile and update the servers
mcp_router.profile_manager.reload()
servers_wait_for_update = mcp_router.get_unique_servers()
async with capture_logs('mcpm.router.router') as log_capture:
await mcp_router.update_servers(servers_wait_for_update)
router_error_log = log_capture.getvalue()
logger.info(
f'MCP router updated successfully with unique servers: {servers_wait_for_update}'
)
if router_error_log:
logger.warning(f'Some MCP servers failed to be added: {router_error_log}')
return JSONResponse(
status_code=200,
@@ -863,7 +919,7 @@ if __name__ == '__main__':
)
zip_path = os.path.join(full_dest_path, file.filename)
with open(zip_path, 'wb') as buffer:
with open(zip_path, 'wb') as buffer: # noqa: ASYNC101
shutil.copyfileobj(file.file, buffer)
# Extract the zip file
@@ -876,7 +932,7 @@ if __name__ == '__main__':
else:
# For single file uploads
file_path = os.path.join(full_dest_path, file.filename)
with open(file_path, 'wb') as buffer:
with open(file_path, 'wb') as buffer: # noqa: ASYNC101
shutil.copyfileobj(file.file, buffer)
logger.debug(f'Uploaded file {file.filename} to {destination}')
+1 -4
View File
@@ -400,7 +400,7 @@ class Runtime(FileEditRuntimeMixin):
'No repository selected. Initializing a new git repository in the workspace.'
)
action = CmdRunAction(
command=f'git init && git config --global --add safe.directory {self.workspace_root}'
command='git init',
)
self.run_action(action)
else:
@@ -952,9 +952,6 @@ fi
exit_code = 0
content = ''
if isinstance(obs, ErrorObservation):
exit_code = -1
if hasattr(obs, 'exit_code'):
exit_code = obs.exit_code
if hasattr(obs, 'content'):
@@ -406,7 +406,7 @@ class ActionExecutionClient(Runtime):
'POST',
f'{self.action_execution_server_url}/update_mcp_server',
json=stdio_tools,
timeout=60,
timeout=10,
)
result = response.json()
if response.status_code != 200:
@@ -435,7 +435,7 @@ class ActionExecutionClient(Runtime):
# We should always include the runtime as an MCP server whenever there's > 0 stdio servers
updated_mcp_config.sse_servers.append(
MCPSSEServerConfig(
url=self.action_execution_server_url.rstrip('/') + '/mcp/sse',
url=self.action_execution_server_url.rstrip('/') + '/sse',
api_key=self.session_api_key,
)
)
@@ -464,13 +464,16 @@ class ActionExecutionClient(Runtime):
)
# Create clients for this specific operation
mcp_clients = await create_mcp_clients(
updated_mcp_config.sse_servers, updated_mcp_config.shttp_servers, self.sid
)
mcp_clients = await create_mcp_clients(updated_mcp_config.sse_servers, updated_mcp_config.shttp_servers, self.sid)
# Call the tool and return the result
# No need for try/finally since disconnect() is now just resetting state
result = await call_tool_mcp_handler(mcp_clients, action)
# Reset client state (no active connections to worry about)
for client in mcp_clients:
await client.disconnect()
return result
def close(self) -> None:
+1 -4
View File
@@ -1,6 +1,3 @@
{
"mcpServers": {
"default": {}
},
"tools": []
"default": []
}
-71
View File
@@ -1,71 +0,0 @@
# MCP Proxy Manager
This module provides a manager class for handling FastMCP proxy instances in OpenHands, including initialization, configuration, and mounting to FastAPI applications.
## Overview
The `MCPProxyManager` class encapsulates all the functionality related to creating, configuring, and managing FastMCP proxy instances. It simplifies the process of:
1. Initializing a FastMCP proxy
2. Configuring the proxy with tools
3. Mounting the proxy to a FastAPI application
4. Updating the proxy configuration
5. Shutting down the proxy
## Usage
### Basic Usage
```python
from openhands.runtime.mcp.proxy import MCPProxyManager
from fastapi import FastAPI
# Create a FastAPI app
app = FastAPI()
# Create a proxy manager
proxy_manager = MCPProxyManager(
name="MyProxyServer",
auth_enabled=True,
api_key="my-api-key"
)
# Initialize the proxy
proxy_manager.initialize()
# Mount the proxy to the app
await proxy_manager.mount_to_app(app, allow_origins=["*"])
# Update the tools configuration
tools = [
{
"name": "my_tool",
"description": "My tool description",
"parameters": {...}
}
]
proxy_manager.update_tools(tools)
# Update and remount the proxy
await proxy_manager.update_and_remount(app, tools, allow_origins=["*"])
# Shutdown the proxy
await proxy_manager.shutdown()
```
### In-Memory Configuration
The `MCPProxyManager` maintains the configuration in-memory, eliminating the need for file-based configuration. This makes it easier to update the configuration and reduces the complexity of the code.
## Benefits
1. **Simplified API**: The `MCPProxyManager` provides a simple and intuitive API for managing FastMCP proxies.
2. **In-Memory Configuration**: Configuration is maintained in-memory, eliminating the need for file I/O operations.
3. **Improved Error Handling**: The manager provides better error handling and logging for proxy operations.
4. **Cleaner Code**: By encapsulating proxy-related functionality in a dedicated class, the code is more maintainable and easier to understand.
## Implementation Details
The `MCPProxyManager` uses the `FastMCP.as_proxy()` method to create a proxy server. It manages the lifecycle of the proxy, including initialization, configuration updates, and shutdown.
When updating the tools configuration, the manager creates a new proxy with the updated configuration and remounts it to the FastAPI application, ensuring that the proxy is always up-to-date with the latest configuration.
-7
View File
@@ -1,7 +0,0 @@
"""
MCP Proxy module for OpenHands.
"""
from openhands.runtime.mcp.proxy.manager import MCPProxyManager
__all__ = ['MCPProxyManager']
-138
View File
@@ -1,138 +0,0 @@
"""
MCP Proxy Manager for OpenHands.
This module provides a manager class for handling FastMCP proxy instances,
including initialization, configuration, and mounting to FastAPI applications.
"""
import logging
from typing import Any, Optional
from fastapi import FastAPI
from fastmcp import FastMCP
from fastmcp.utilities.logging import get_logger as fastmcp_get_logger
from openhands.core.config.mcp_config import MCPStdioServerConfig
logger = logging.getLogger(__name__)
fastmcp_logger = fastmcp_get_logger('fastmcp')
class MCPProxyManager:
"""
Manager for FastMCP proxy instances.
This class encapsulates all the functionality related to creating, configuring,
and managing FastMCP proxy instances, including mounting them to FastAPI applications.
"""
def __init__(
self,
auth_enabled: bool = False,
api_key: Optional[str] = None,
logger_level: Optional[int] = None,
):
"""
Initialize the MCP Proxy Manager.
Args:
name: Name of the proxy server
auth_enabled: Whether authentication is enabled
api_key: API key for authentication (required if auth_enabled is True)
logger_level: Logging level for the FastMCP logger
"""
self.auth_enabled = auth_enabled
self.api_key = api_key
self.proxy: Optional[FastMCP] = None
# Initialize with a valid configuration format for FastMCP
self.config: dict[str, Any] = {
'mcpServers': {},
}
# Configure FastMCP logger
if logger_level is not None:
fastmcp_logger.setLevel(logger_level)
def initialize(self) -> None:
"""
Initialize the FastMCP proxy with the current configuration.
"""
if len(self.config['mcpServers']) == 0:
logger.info(
f"No MCP servers configured for FastMCP Proxy, skipping initialization."
)
return None
# Create a new proxy with the current configuration
self.proxy = FastMCP.as_proxy(
self.config,
auth_enabled=self.auth_enabled,
api_key=self.api_key,
)
logger.info(f"FastMCP Proxy initialized successfully")
async def mount_to_app(
self, app: FastAPI, allow_origins: Optional[list[str]] = None
) -> None:
"""
Mount the SSE server app to a FastAPI application.
Args:
app: FastAPI application to mount to
allow_origins: List of allowed origins for CORS
"""
if len(self.config['mcpServers']) == 0:
logger.info(
f"No MCP servers configured for FastMCP Proxy, skipping mount."
)
return
if not self.proxy:
raise ValueError('FastMCP Proxy is not initialized')
# Get the SSE app
# mcp_app = self.proxy.http_app(path='/shttp')
mcp_app = self.proxy.http_app(path='/sse', transport='sse')
app.mount('/mcp', mcp_app)
# Remove any existing mounts at root path
if '/mcp' in app.routes:
app.routes.remove('/mcp')
app.mount('/', mcp_app)
logger.info(f"Mounted FastMCP Proxy app at /mcp")
async def update_and_remount(
self,
app: FastAPI,
stdio_servers: list[MCPStdioServerConfig],
allow_origins: Optional[list[str]] = None,
) -> None:
"""
Update the tools configuration and remount the proxy to the app.
This is a convenience method that combines updating the tools,
shutting down the existing proxy, initializing a new one, and
mounting it to the app.
Args:
app: FastAPI application to mount to
tools: List of tool configurations
allow_origins: List of allowed origins for CORS
"""
tools = {
t.name: t.model_dump()
for t in stdio_servers
}
self.config['mcpServers'] = tools
del self.proxy
self.proxy = None
# Initialize a new proxy
self.initialize()
# Mount the new proxy to the app
await self.mount_to_app(app, allow_origins)
+54
View File
@@ -0,0 +1,54 @@
import asyncio
import os
from openhands.runtime.base import CommandResult
class AsyncBashSession:
@staticmethod
async def execute(command: str, work_dir: str) -> CommandResult:
"""Execute a command in the bash session asynchronously."""
work_dir = os.path.abspath(work_dir)
if not os.path.exists(work_dir):
raise ValueError(f'Work directory {work_dir} does not exist.')
command = command.strip()
if not command:
return CommandResult(content='', exit_code=0)
try:
process = await asyncio.subprocess.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=work_dir,
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(), timeout=30
)
output = stdout.decode('utf-8')
if stderr:
output = stderr.decode('utf-8')
print(f'!##! Error running command: {stderr.decode("utf-8")}')
return CommandResult(content=output, exit_code=process.returncode or 0)
except asyncio.TimeoutError:
process.terminate()
# Allow a brief moment for cleanup
try:
await asyncio.wait_for(process.wait(), timeout=1.0)
except asyncio.TimeoutError:
process.kill() # Force kill if it doesn't terminate cleanly
return CommandResult(content='Command timed out.', exit_code=-1)
except Exception as e:
return CommandResult(
content=f'Error running command: {str(e)}', exit_code=-1
)
+6 -3
View File
@@ -17,7 +17,6 @@ from openhands.events.observation.commands import (
CmdOutputMetadata,
CmdOutputObservation,
)
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
from openhands.utils.shutdown_listener import should_continue
@@ -380,7 +379,9 @@ class BashSession:
metadata = CmdOutputMetadata() # No metadata available
metadata.suffix = (
f'\n[The command has no new output after {self.NO_CHANGE_TIMEOUT_SECONDS} seconds. '
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
)
command_output = self._get_command_output(
command,
@@ -413,7 +414,9 @@ class BashSession:
metadata = CmdOutputMetadata() # No metadata available
metadata.suffix = (
f'\n[The command timed out after {timeout} seconds. '
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
)
command_output = self._get_command_output(
command,
@@ -1,7 +0,0 @@
# Common timeout message that can be used across different timeout scenarios
TIMEOUT_MESSAGE_TEMPLATE = (
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'send keys to interrupt/kill the command, '
'or use the timeout parameter in execute_bash for future commands.'
)
+9 -13
View File
@@ -44,7 +44,7 @@ class GitHandler:
Returns:
bool: True if inside a Git repository, otherwise False.
"""
cmd = 'git --no-pager rev-parse --is-inside-work-tree'
cmd = 'git rev-parse --is-inside-work-tree'
output = self.execute(cmd, self.cwd)
return output.content.strip() == 'true'
@@ -71,7 +71,7 @@ class GitHandler:
Returns:
bool: True if the reference exists, otherwise False.
"""
cmd = f'git --no-pager rev-parse --verify {ref}'
cmd = f'git rev-parse --verify {ref}'
output = self.execute(cmd, self.cwd)
return output.exit_code == 0
@@ -86,9 +86,9 @@ class GitHandler:
default_branch = self._get_default_branch()
ref_current_branch = f'origin/{current_branch}'
ref_non_default_branch = f'$(git --no-pager merge-base HEAD "$(git --no-pager rev-parse --abbrev-ref origin/{default_branch})")'
ref_non_default_branch = f'$(git merge-base HEAD "$(git rev-parse --abbrev-ref origin/{default_branch})")'
ref_default_branch = 'origin/' + default_branch
ref_new_repo = '$(git --no-pager rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)' # compares with empty tree
ref_new_repo = '$(git rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)' # compares with empty tree
refs = [
ref_current_branch,
@@ -116,7 +116,7 @@ class GitHandler:
if not ref:
return ''
cmd = f'git --no-pager show {ref}:{file_path}'
cmd = f'git show {ref}:{file_path}'
output = self.execute(cmd, self.cwd)
return output.content if output.exit_code == 0 else ''
@@ -127,7 +127,7 @@ class GitHandler:
Returns:
str: The name of the primary branch.
"""
cmd = 'git --no-pager remote show origin | grep "HEAD branch"'
cmd = 'git remote show origin | grep "HEAD branch"'
output = self.execute(cmd, self.cwd)
return output.content.split()[-1].strip()
@@ -138,7 +138,7 @@ class GitHandler:
Returns:
str: The name of the current branch.
"""
cmd = 'git --no-pager rev-parse --abbrev-ref HEAD'
cmd = 'git rev-parse --abbrev-ref HEAD'
output = self.execute(cmd, self.cwd)
return output.content.strip()
@@ -153,12 +153,8 @@ class GitHandler:
if not ref:
return []
diff_cmd = f'git --no-pager diff --name-status {ref}'
diff_cmd = f'git diff --name-status {ref}'
output = self.execute(diff_cmd, self.cwd)
if output.exit_code != 0:
raise RuntimeError(
f'Failed to get diff for ref {ref} in {self.cwd}. Command output: {output.content}'
)
return output.content.splitlines()
def _get_untracked_files(self) -> list[dict[str, str]]:
@@ -168,7 +164,7 @@ class GitHandler:
Returns:
list[dict[str, str]]: A list of dictionaries containing file paths and statuses.
"""
cmd = 'git --no-pager ls-files --others --exclude-standard'
cmd = 'git ls-files --others --exclude-standard'
output = self.execute(cmd, self.cwd)
obs_list = output.content.splitlines()
return (
+6 -3
View File
@@ -20,7 +20,6 @@ from openhands.events.observation.commands import (
CmdOutputMetadata,
CmdOutputObservation,
)
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
from openhands.utils.shutdown_listener import should_continue
pythonnet.load('coreclr')
@@ -560,7 +559,9 @@ class WindowsPowershellSession:
else:
metadata.suffix = (
f'\n[The command timed out after {timeout_seconds} seconds. '
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
)
return CmdOutputObservation(
@@ -1330,7 +1331,9 @@ class WindowsPowershellSession:
# Align suffix with bash.py timeout message
suffix = (
f'\n[The command timed out after {timeout_seconds} seconds. '
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
"You may wait longer to see additional output by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys to interrupt/kill the command.]'
)
elif shutdown_requested:
# Align suffix with bash.py equivalent (though bash.py might not have specific shutdown message)
@@ -281,31 +281,7 @@ class DockerNestedConversationManager(ConversationManager):
raise ValueError('unsupported_operation')
async def close_session(self, sid: str):
# First try to graceful stop server.
try:
container = self.docker_client.containers.get(f'openhands-runtime-{sid}')
except docker.errors.NotFound as e:
return
try:
nested_url = self.get_nested_url_for_container(container)
async with httpx.AsyncClient(
headers={
'X-Session-API-Key': self._get_session_api_key_for_conversation(sid)
}
) as client:
response = await client.post(f'{nested_url}/api/conversations/{sid}/stop')
response.raise_for_status()
# 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()
stop_all_containers(f'openhands-runtime-{sid}')
async def get_agent_loop_info(self, user_id: str | None = None, filter_to_sids: set[str] | None = None) -> list[AgentLoopInfo]:
results = []
@@ -369,9 +369,7 @@ class StandaloneConversationManager(ConversationManager):
f'removing connections: {connection_ids_to_remove}',
extra={'session_id': sid},
)
# Perform a graceful shutdown of each connection
for connection_id in connection_ids_to_remove:
await self.sio.disconnect(connection_id)
self._local_connection_id_to_session_id.pop(connection_id, None)
session = self._local_agent_loops_by_sid.pop(sid, None)
+23 -46
View File
@@ -12,7 +12,6 @@ from openhands.events.action import (
)
from openhands.events.action.agent import RecallAction
from openhands.events.async_event_store_wrapper import AsyncEventStoreWrapper
from openhands.events.event_store import EventStore
from openhands.events.observation import (
NullObservation,
)
@@ -125,48 +124,6 @@ async def connect(connection_id: str, environ: dict) -> None:
f'User {user_id} is allowed to connect to conversation {conversation_id}'
)
try:
event_store = EventStore(
conversation_id, conversation_manager.file_store, user_id
)
except FileNotFoundError as e:
logger.error(
f'Failed to create EventStore for conversation {conversation_id}: {e}'
)
raise ConnectionRefusedError(f'Failed to access conversation events: {e}')
logger.info(
f'Replaying event stream for conversation {conversation_id} with connection_id {connection_id}...'
)
agent_state_changed = None
# Create an async store to replay events
async_store = AsyncEventStoreWrapper(event_store, latest_event_id + 1)
# Process all available events
async for event in async_store:
logger.debug(f'oh_event: {event.__class__.__name__}')
if isinstance(
event,
(NullAction, NullObservation, RecallAction),
):
continue
elif isinstance(event, AgentStateChangedObservation):
agent_state_changed = event
else:
await sio.emit('oh_event', event_to_dict(event), to=connection_id)
# Send the agent state changed event last if we have one
if agent_state_changed:
await sio.emit(
'oh_event', event_to_dict(agent_state_changed), to=connection_id
)
logger.info(
f'Finished replaying event stream for conversation {conversation_id}'
)
conversation_init_data = await setup_init_convo_settings(
user_id, conversation_id, providers_set
)
@@ -176,12 +133,32 @@ async def connect(connection_id: str, environ: dict) -> None:
conversation_init_data,
user_id,
)
logger.info(
f'Connected to conversation {conversation_id} with connection_id {connection_id}. Replaying event stream...'
)
agent_state_changed = None
if agent_loop_info is None:
raise ConnectionRefusedError('Failed to join conversation')
async_store = AsyncEventStoreWrapper(
agent_loop_info.event_store, latest_event_id + 1
)
async for event in async_store:
logger.debug(f'oh_event: {event.__class__.__name__}')
if isinstance(
event,
(NullAction, NullObservation, RecallAction),
):
continue
elif isinstance(event, AgentStateChangedObservation):
agent_state_changed = event
else:
await sio.emit('oh_event', event_to_dict(event), to=connection_id)
if agent_state_changed:
await sio.emit(
'oh_event', event_to_dict(agent_state_changed), to=connection_id
)
logger.info(
f'Successfully joined conversation {conversation_id} with connection_id {connection_id}'
f'Finished replaying event stream for conversation {conversation_id}'
)
except ConnectionRefusedError:
# Close the broken connection after sending an error message
+9 -51
View File
@@ -1,4 +1,3 @@
import os
import re
from typing import Annotated
@@ -11,10 +10,9 @@ 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 GitService, ProviderType
from openhands.integrations.service_types import ProviderType
from openhands.server.dependencies import get_dependencies
from openhands.server.shared import ConversationStoreImpl, config, server_config
from openhands.server.types import AppMode
from openhands.server.shared import ConversationStoreImpl, config
from openhands.server.user_auth import (
get_access_token,
get_provider_tokens,
@@ -22,34 +20,10 @@ 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(), 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
mcp_server = FastMCP('mcp', stateless_http=True, dependencies=get_dependencies(), mask_error_details=True)
async def save_pr_metadata(
user_id: str | None, conversation_id: str, tool_result: str
user_id: str, conversation_id: str, tool_result: str
) -> None:
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
conversation: ConversationMetadata = await conversation_store.get_metadata(
@@ -70,11 +44,7 @@ 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)
@@ -114,11 +84,6 @@ 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,
@@ -128,11 +93,11 @@ async def create_pr(
body=body,
)
if conversation_id:
if conversation_id and user_id:
await save_pr_metadata(user_id, conversation_id, response)
except Exception as e:
error = f'Error creating pull request: {e}'
error = f"Error creating pull request: {e}"
raise ToolError(str(error))
return response
@@ -167,7 +132,7 @@ async def create_mr(
else ProviderToken()
)
gitlab_service = GitLabServiceImpl(
github_service = GitLabServiceImpl(
user_id=github_token.user_id,
external_auth_id=user_id,
external_auth_token=access_token,
@@ -176,14 +141,7 @@ async def create_mr(
)
try:
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(
response = await github_service.create_mr(
id=id,
source_branch=source_branch,
target_branch=target_branch,
@@ -195,7 +153,7 @@ async def create_mr(
await save_pr_metadata(user_id, conversation_id, response)
except Exception as e:
error = f'Error creating merge request: {e}'
error = f"Error creating merge request: {e}"
raise ToolError(str(error))
return response
@@ -124,8 +124,8 @@ async def create_new_conversation(
image_urls=image_urls or [],
)
if attach_convo_id:
logger.warning('Attaching convo ID is deprecated, skipping process')
if attach_convo_id and conversation_instructions:
conversation_instructions = conversation_instructions.format(conversation_id)
agent_loop_info = await conversation_manager.maybe_start_agent_loop(
conversation_id,
+1 -1
View File
@@ -12,7 +12,7 @@ async def get_conversation_store(request: Request) -> ConversationStore | None:
)
if conversation_store:
return conversation_store
user_id = await get_user_id(request)
user_id = get_user_id(request)
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
request.state.conversation_store = conversation_store
return conversation_store
Generated
+202 -59
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand.
[[package]]
name = "aiohappyeyeballs"
@@ -373,7 +373,7 @@ description = "LTS Port of Python audioop"
optional = false
python-versions = ">=3.13"
groups = ["main"]
markers = "python_version == \"3.13\""
markers = "python_version >= \"3.13\""
files = [
{file = "audioop_lts-0.2.1-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd1345ae99e17e6910f47ce7d52673c6a1a70820d78b67de1b7abb3af29c426a"},
{file = "audioop_lts-0.2.1-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:e175350da05d2087e12cea8e72a70a1a8b14a17e92ed2022952a4419689ede5e"},
@@ -2116,6 +2116,52 @@ files = [
{file = "docstring_parser-0.16.tar.gz", hash = "sha256:538beabd0af1e2db0146b6bd3caa526c35a34d61af9fd2887f3a8a27a739aa6e"},
]
[[package]]
name = "duckdb"
version = "1.3.0"
description = "DuckDB in-process database"
optional = false
python-versions = ">=3.7.0"
groups = ["main"]
files = [
{file = "duckdb-1.3.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:fc65c1e97aa010359c43c0342ea423e6efa3cb8c8e3f133b0765451ce674e3db"},
{file = "duckdb-1.3.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:8fc91b629646679e33806342510335ccbbeaf2b823186f0ae829fd48e7a63c66"},
{file = "duckdb-1.3.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:1a69b970553fd015c557238d427ef00be3c8ed58c3bc3641aef987e33f8bf614"},
{file = "duckdb-1.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1003e84c07b84680cee6d06e4795b6e861892474704f7972058594a52c7473cf"},
{file = "duckdb-1.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:992239b54ca6f015ad0ed0d80f3492c065313c4641df0a226183b8860cb7f5b0"},
{file = "duckdb-1.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0ba1c5af59e8147216149b814b1970b8f7e3c240494a9688171390db3c504b29"},
{file = "duckdb-1.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:57b794ca28e22b23bd170506cb1d4704a3608e67f0fe33273db9777b69bdf26a"},
{file = "duckdb-1.3.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:60a58b85929754abb21db1e739d2f53eaef63e6015e62ba58eae3425030e7935"},
{file = "duckdb-1.3.0-cp311-cp311-macosx_12_0_universal2.whl", hash = "sha256:1d46b5a20f078b1b2284243e02a1fde7e12cbb8d205fce62e4700bcfe6a09881"},
{file = "duckdb-1.3.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:0044e5ffb2d46308099640a92f99980a44e12bb68642aa9e6b08acbf300d64a1"},
{file = "duckdb-1.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cb813de2ca2f5e7c77392a67bdcaa174bfd69ebbfdfc983024af270c77a0447"},
{file = "duckdb-1.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7a0c993eb6df2b30b189ad747f3aea1b0b87b78ab7f80c6e7c57117b6e8dbfb0"},
{file = "duckdb-1.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6728e209570d36ece66dd7249e5d6055326321137cd807f26300733283930cd4"},
{file = "duckdb-1.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:7e652b7c8dbdb91a94fd7d543d3e115d24a25aa0791a373a852e20cb7bb21154"},
{file = "duckdb-1.3.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:f24038fe9b83dcbaeafb1ed76ec3b3f38943c1c8d27ab464ad384db8a6658b61"},
{file = "duckdb-1.3.0-cp312-cp312-macosx_12_0_universal2.whl", hash = "sha256:956c85842841bef68f4a5388c6b225b933151a7c06d568390fc895fc44607913"},
{file = "duckdb-1.3.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:efe883d822ed56fcfbb6a7b397c13f6a0d2eaeb3bc4ef4510f84fadb3dfe416d"},
{file = "duckdb-1.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3872a3a1b80ffba5264ea236a3754d0c41d3c7b01bdf8cdcb1c180fc1b8dc8e2"},
{file = "duckdb-1.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30bf45ad78a5a997f378863e036e917b481d18d685e5c977cd0a3faf2e31fbaf"},
{file = "duckdb-1.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85cbd8e1d65df8a0780023baf5045d3033fabd154799bc9ea6d9ab5728f41eb3"},
{file = "duckdb-1.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:8754c40dac0f26d9fb0363bbb5df02f7a61ce6a6728d5efc02c3bc925d7c89c3"},
{file = "duckdb-1.3.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:176b9818d940c52ac7f31c64a98cf172d7c19d2a006017c9c4e9c06c246e36bf"},
{file = "duckdb-1.3.0-cp313-cp313-macosx_12_0_universal2.whl", hash = "sha256:03981f7e8793f07a4a9a2ba387640e71d0a99ebcaf8693ab09f96d59e628b713"},
{file = "duckdb-1.3.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:a177d55a38a62fdf79b59a0eaa32531a1dbb443265f6d67f64992cc1e82b755c"},
{file = "duckdb-1.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b1c30e3749823147d5578bc3f01f35d1a0433a1c768908d946056ec8d6e1757e"},
{file = "duckdb-1.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5855f3a564baf22eeeab70c120b51f5a11914f1f1634f03382daeb6b1dea4c62"},
{file = "duckdb-1.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1fac15a48056f7c2739cf8800873063ba2f691e91a9b2fc167658a401ca76a"},
{file = "duckdb-1.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:fbdfc1c0b83b90f780ae74038187ee696bb56ab727a289752372d7ec42dda65b"},
{file = "duckdb-1.3.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:5f6b5d725546ad30abc125a6813734b493fea694bc3123e991c480744573c2f1"},
{file = "duckdb-1.3.0-cp39-cp39-macosx_12_0_universal2.whl", hash = "sha256:fcbcc9b956b06cf5ee94629438ecab88de89b08b5620fcda93665c222ab18cd4"},
{file = "duckdb-1.3.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:2d32f2d44105e1705d8a0fb6d6d246fd69aff82c80ad23293266244b66b69012"},
{file = "duckdb-1.3.0-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0aa7a5c0dcb780850e6da1227fb1d552af8e1a5091e02667ab6ace61ab49ce6c"},
{file = "duckdb-1.3.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cb254fd5405f3edbd7d962ba39c72e4ab90b37cb4d0e34846089796c8078419"},
{file = "duckdb-1.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a7d337b58c59fd2cd9faae531b05d940f8d92bdc2e14cb6e9a5a37675ad2742d"},
{file = "duckdb-1.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3cea3a345755c7dbcb58403dbab8befd499c82f0d27f893a4c1d4b8cf56ec54"},
{file = "duckdb-1.3.0.tar.gz", hash = "sha256:09aaa4b1dca24f4d1f231e7ae66b6413e317b7e04e2753541d42df6c8113fac7"},
]
[[package]]
name = "dulwich"
version = "0.22.8"
@@ -3166,7 +3212,7 @@ google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0"
grpc-google-iam-v1 = ">=0.14.0,<1.0.0"
proto-plus = [
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0"},
{version = ">=1.22.3,<2.0.0", markers = "python_version < \"3.13\""},
]
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
@@ -5352,6 +5398,30 @@ cli = ["python-dotenv (>=1.0.0)", "typer (>=0.12.4)"]
rich = ["rich (>=13.9.4)"]
ws = ["websockets (>=15.0.1)"]
[[package]]
name = "mcpm"
version = "1.12.0"
description = "MCPM - Model Context Protocol Manager"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "mcpm-1.12.0-py3-none-any.whl", hash = "sha256:ed3a87300420bcdb9cd12ef290179fda5bd51eb2f4cd3e793084d83eed91b249"},
{file = "mcpm-1.12.0.tar.gz", hash = "sha256:e9d2b852b90d7fd62dede584f035dd6b2b3d068d233e96b82aead835f81a911a"},
]
[package.dependencies]
click = ">=8.1.3"
duckdb = ">=1.2.2"
mcp = ">=1.8.0"
prompt-toolkit = ">=3.0.0"
psutil = ">=7.0.0"
pydantic = ">=2.5.1"
requests = ">=2.28.0"
rich = ">=12.0.0"
ruamel-yaml = ">=0.18.10"
watchfiles = ">=1.0.4"
[[package]]
name = "mdurl"
version = "0.1.2"
@@ -6386,106 +6456,102 @@ et-xmlfile = "*"
[[package]]
name = "opentelemetry-api"
version = "1.34.0"
version = "1.25.0"
description = "OpenTelemetry Python API"
optional = false
python-versions = ">=3.9"
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "opentelemetry_api-1.34.0-py3-none-any.whl", hash = "sha256:390b81984affe4453180820ca518de55e3be051111e70cc241bb3b0071ca3a2c"},
{file = "opentelemetry_api-1.34.0.tar.gz", hash = "sha256:48d167589134799093005b7f7f347c69cc67859c693b17787f334fbe8871279f"},
{file = "opentelemetry_api-1.25.0-py3-none-any.whl", hash = "sha256:757fa1aa020a0f8fa139f8959e53dec2051cc26b832e76fa839a6d76ecefd737"},
{file = "opentelemetry_api-1.25.0.tar.gz", hash = "sha256:77c4985f62f2614e42ce77ee4c9da5fa5f0bc1e1821085e9a47533a9323ae869"},
]
[package.dependencies]
importlib-metadata = ">=6.0,<8.8.0"
typing-extensions = ">=4.5.0"
deprecated = ">=1.2.6"
importlib-metadata = ">=6.0,<=7.1"
[[package]]
name = "opentelemetry-exporter-otlp-proto-common"
version = "1.34.0"
version = "1.25.0"
description = "OpenTelemetry Protobuf encoding"
optional = false
python-versions = ">=3.9"
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "opentelemetry_exporter_otlp_proto_common-1.34.0-py3-none-any.whl", hash = "sha256:a5ab7a9b7c3c7ba957c8ddcb08c0c93b1d732e066f544682a250ecf4d7a9ceef"},
{file = "opentelemetry_exporter_otlp_proto_common-1.34.0.tar.gz", hash = "sha256:5916d9ceda8c733adbec5e9cecf654fbf359e9f619ff43214277076fba888557"},
{file = "opentelemetry_exporter_otlp_proto_common-1.25.0-py3-none-any.whl", hash = "sha256:15637b7d580c2675f70246563363775b4e6de947871e01d0f4e3881d1848d693"},
{file = "opentelemetry_exporter_otlp_proto_common-1.25.0.tar.gz", hash = "sha256:c93f4e30da4eee02bacd1e004eb82ce4da143a2f8e15b987a9f603e0a85407d3"},
]
[package.dependencies]
opentelemetry-proto = "1.34.0"
opentelemetry-proto = "1.25.0"
[[package]]
name = "opentelemetry-exporter-otlp-proto-grpc"
version = "1.34.0"
version = "1.25.0"
description = "OpenTelemetry Collector Protobuf over gRPC Exporter"
optional = false
python-versions = ">=3.9"
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "opentelemetry_exporter_otlp_proto_grpc-1.34.0-py3-none-any.whl", hash = "sha256:31c41017af85833242d49beb07bde7341b0a145f0b898ee383f3e3019037afb1"},
{file = "opentelemetry_exporter_otlp_proto_grpc-1.34.0.tar.gz", hash = "sha256:a634425340f506d5ebf641c92d88eb873754d4c5259b5b816afb234c6f87b37d"},
{file = "opentelemetry_exporter_otlp_proto_grpc-1.25.0-py3-none-any.whl", hash = "sha256:3131028f0c0a155a64c430ca600fd658e8e37043cb13209f0109db5c1a3e4eb4"},
{file = "opentelemetry_exporter_otlp_proto_grpc-1.25.0.tar.gz", hash = "sha256:c0b1661415acec5af87625587efa1ccab68b873745ca0ee96b69bb1042087eac"},
]
[package.dependencies]
deprecated = ">=1.2.6"
googleapis-common-protos = ">=1.52,<2.0"
grpcio = [
{version = ">=1.66.2,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.63.2,<2.0.0", markers = "python_version < \"3.13\""},
]
grpcio = ">=1.0.0,<2.0.0"
opentelemetry-api = ">=1.15,<2.0"
opentelemetry-exporter-otlp-proto-common = "1.34.0"
opentelemetry-proto = "1.34.0"
opentelemetry-sdk = ">=1.34.0,<1.35.0"
typing-extensions = ">=4.5.0"
opentelemetry-exporter-otlp-proto-common = "1.25.0"
opentelemetry-proto = "1.25.0"
opentelemetry-sdk = ">=1.25.0,<1.26.0"
[[package]]
name = "opentelemetry-proto"
version = "1.34.0"
version = "1.25.0"
description = "OpenTelemetry Python Proto"
optional = false
python-versions = ">=3.9"
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "opentelemetry_proto-1.34.0-py3-none-any.whl", hash = "sha256:ffb1f1b27552fda5a1cd581e34243cc0b6f134fb14c1c2a33cc3b4b208c9bf97"},
{file = "opentelemetry_proto-1.34.0.tar.gz", hash = "sha256:73e40509b692630a47192888424f7e0b8fb19d9ecf2f04e6f708170cd3346dfe"},
{file = "opentelemetry_proto-1.25.0-py3-none-any.whl", hash = "sha256:f07e3341c78d835d9b86665903b199893befa5e98866f63d22b00d0b7ca4972f"},
{file = "opentelemetry_proto-1.25.0.tar.gz", hash = "sha256:35b6ef9dc4a9f7853ecc5006738ad40443701e52c26099e197895cbda8b815a3"},
]
[package.dependencies]
protobuf = ">=5.0,<6.0"
protobuf = ">=3.19,<5.0"
[[package]]
name = "opentelemetry-sdk"
version = "1.34.0"
version = "1.25.0"
description = "OpenTelemetry Python SDK"
optional = false
python-versions = ">=3.9"
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "opentelemetry_sdk-1.34.0-py3-none-any.whl", hash = "sha256:7850bcd5b5c95f9aae48603d6592bdad5c7bdef50c03e06393f8f457d891fe32"},
{file = "opentelemetry_sdk-1.34.0.tar.gz", hash = "sha256:719559622afcd515c2aec462ccb749ba2e70075a01df45837623643814d33716"},
{file = "opentelemetry_sdk-1.25.0-py3-none-any.whl", hash = "sha256:d97ff7ec4b351692e9d5a15af570c693b8715ad78b8aafbec5c7100fe966b4c9"},
{file = "opentelemetry_sdk-1.25.0.tar.gz", hash = "sha256:ce7fc319c57707ef5bf8b74fb9f8ebdb8bfafbe11898410e0d2a761d08a98ec7"},
]
[package.dependencies]
opentelemetry-api = "1.34.0"
opentelemetry-semantic-conventions = "0.55b0"
typing-extensions = ">=4.5.0"
opentelemetry-api = "1.25.0"
opentelemetry-semantic-conventions = "0.46b0"
typing-extensions = ">=3.7.4"
[[package]]
name = "opentelemetry-semantic-conventions"
version = "0.55b0"
version = "0.46b0"
description = "OpenTelemetry Semantic Conventions"
optional = false
python-versions = ">=3.9"
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "opentelemetry_semantic_conventions-0.55b0-py3-none-any.whl", hash = "sha256:63bb15b67377700e51c422d0d24092ca6ce9f3a4cb6f032375aa8af1fc2aab65"},
{file = "opentelemetry_semantic_conventions-0.55b0.tar.gz", hash = "sha256:933d2e20c2dbc0f9b2f4f52138282875b4b14c66c491f5273bcdef1781368e9c"},
{file = "opentelemetry_semantic_conventions-0.46b0-py3-none-any.whl", hash = "sha256:6daef4ef9fa51d51855d9f8e0ccd3a1bd59e0e545abe99ac6203804e36ab3e07"},
{file = "opentelemetry_semantic_conventions-0.46b0.tar.gz", hash = "sha256:fbc982ecbb6a6e90869b15c1673be90bd18c8a56ff1cffc0864e38e2edffaefa"},
]
[package.dependencies]
opentelemetry-api = "1.34.0"
typing-extensions = ">=4.5.0"
opentelemetry-api = "1.25.0"
[[package]]
name = "overrides"
@@ -7105,23 +7171,23 @@ testing = ["google-api-core (>=1.31.5)"]
[[package]]
name = "protobuf"
version = "5.29.5"
version = "4.25.8"
description = ""
optional = false
python-versions = ">=3.8"
groups = ["main", "evaluation"]
files = [
{file = "protobuf-5.29.5-cp310-abi3-win32.whl", hash = "sha256:3f1c6468a2cfd102ff4703976138844f78ebd1fb45f49011afc5139e9e283079"},
{file = "protobuf-5.29.5-cp310-abi3-win_amd64.whl", hash = "sha256:3f76e3a3675b4a4d867b52e4a5f5b78a2ef9565549d4037e06cf7b0942b1d3fc"},
{file = "protobuf-5.29.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e38c5add5a311f2a6eb0340716ef9b039c1dfa428b28f25a7838ac329204a671"},
{file = "protobuf-5.29.5-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:fa18533a299d7ab6c55a238bf8629311439995f2e7eca5caaff08663606e9015"},
{file = "protobuf-5.29.5-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:63848923da3325e1bf7e9003d680ce6e14b07e55d0473253a690c3a8b8fd6e61"},
{file = "protobuf-5.29.5-cp38-cp38-win32.whl", hash = "sha256:ef91363ad4faba7b25d844ef1ada59ff1604184c0bcd8b39b8a6bef15e1af238"},
{file = "protobuf-5.29.5-cp38-cp38-win_amd64.whl", hash = "sha256:7318608d56b6402d2ea7704ff1e1e4597bee46d760e7e4dd42a3d45e24b87f2e"},
{file = "protobuf-5.29.5-cp39-cp39-win32.whl", hash = "sha256:6f642dc9a61782fa72b90878af134c5afe1917c89a568cd3476d758d3c3a0736"},
{file = "protobuf-5.29.5-cp39-cp39-win_amd64.whl", hash = "sha256:470f3af547ef17847a28e1f47200a1cbf0ba3ff57b7de50d22776607cd2ea353"},
{file = "protobuf-5.29.5-py3-none-any.whl", hash = "sha256:6cf42630262c59b2d8de33954443d94b746c952b01434fc58a417fdbd2e84bd5"},
{file = "protobuf-5.29.5.tar.gz", hash = "sha256:bc1463bafd4b0929216c35f437a8e28731a2b7fe3d98bb77a600efced5a15c84"},
{file = "protobuf-4.25.8-cp310-abi3-win32.whl", hash = "sha256:504435d831565f7cfac9f0714440028907f1975e4bed228e58e72ecfff58a1e0"},
{file = "protobuf-4.25.8-cp310-abi3-win_amd64.whl", hash = "sha256:bd551eb1fe1d7e92c1af1d75bdfa572eff1ab0e5bf1736716814cdccdb2360f9"},
{file = "protobuf-4.25.8-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:ca809b42f4444f144f2115c4c1a747b9a404d590f18f37e9402422033e464e0f"},
{file = "protobuf-4.25.8-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:9ad7ef62d92baf5a8654fbb88dac7fa5594cfa70fd3440488a5ca3bfc6d795a7"},
{file = "protobuf-4.25.8-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:83e6e54e93d2b696a92cad6e6efc924f3850f82b52e1563778dfab8b355101b0"},
{file = "protobuf-4.25.8-cp38-cp38-win32.whl", hash = "sha256:27d498ffd1f21fb81d987a041c32d07857d1d107909f5134ba3350e1ce80a4af"},
{file = "protobuf-4.25.8-cp38-cp38-win_amd64.whl", hash = "sha256:d552c53d0415449c8d17ced5c341caba0d89dbf433698e1436c8fa0aae7808a3"},
{file = "protobuf-4.25.8-cp39-cp39-win32.whl", hash = "sha256:077ff8badf2acf8bc474406706ad890466274191a48d0abd3bd6987107c9cde5"},
{file = "protobuf-4.25.8-cp39-cp39-win_amd64.whl", hash = "sha256:f4510b93a3bec6eba8fd8f1093e9d7fb0d4a24d1a81377c10c0e5bbfe9e4ed24"},
{file = "protobuf-4.25.8-py3-none-any.whl", hash = "sha256:15a0af558aa3b13efef102ae6e4f3efac06f1eea11afb3a57db2901447d9fb59"},
{file = "protobuf-4.25.8.tar.gz", hash = "sha256:6135cf8affe1fc6f76cced2641e4ea8d3e59518d1f24ae41ba97bcad82d397cd"},
]
[[package]]
@@ -8867,6 +8933,82 @@ files = [
[package.dependencies]
pyasn1 = ">=0.1.3"
[[package]]
name = "ruamel-yaml"
version = "0.18.12"
description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "ruamel.yaml-0.18.12-py3-none-any.whl", hash = "sha256:790ba4c48b6a6e6b12b532a7308779eb12d2aaab3a80fdb8389216f28ea2b287"},
{file = "ruamel.yaml-0.18.12.tar.gz", hash = "sha256:5a38fd5ce39d223bebb9e3a6779e86b9427a03fb0bf9f270060f8b149cffe5e2"},
]
[package.dependencies]
"ruamel.yaml.clib" = {version = ">=0.2.7", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.14\""}
[package.extras]
docs = ["mercurial (>5.7)", "ryd"]
jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"]
[[package]]
name = "ruamel-yaml-clib"
version = "0.2.12"
description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml"
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "platform_python_implementation == \"CPython\""
files = [
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:11f891336688faf5156a36293a9c362bdc7c88f03a8a027c2c1d8e0bcde998e5"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:a606ef75a60ecf3d924613892cc603b154178ee25abb3055db5062da811fd969"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd5415dded15c3822597455bc02bcd66e81ef8b7a48cb71a33628fc9fdde39df"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:d84318609196d6bd6da0edfa25cedfbabd8dbde5140a0a23af29ad4b8f91fb1e"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb43a269eb827806502c7c8efb7ae7e9e9d0573257a46e8e952f4d4caba4f31e"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:943f32bc9dedb3abff9879edc134901df92cfce2c3d5c9348f172f62eb2d771d"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c3829bb364fdb8e0332c9931ecf57d9be3519241323c5274bd82f709cebc0c"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:e7e3736715fbf53e9be2a79eb4db68e4ed857017344d697e8b9749444ae57475"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7e75b4965e1d4690e93021adfcecccbca7d61c7bddd8e22406ef2ff20d74ef"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:fc4b630cd3fa2cf7fce38afa91d7cfe844a9f75d7f0f36393fa98815e911d987"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:bc5f1e1c28e966d61d2519f2a3d451ba989f9ea0f2307de7bc45baa526de9e45"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a0e060aace4c24dcaf71023bbd7d42674e3b230f7e7b97317baf1e953e5b519"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2f1c3765db32be59d18ab3953f43ab62a761327aafc1594a2a1fbe038b8b8a7"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d85252669dc32f98ebcd5d36768f5d4faeaeaa2d655ac0473be490ecdae3c285"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e143ada795c341b56de9418c58d028989093ee611aa27ffb9b7f609c00d813ed"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2c59aa6170b990d8d2719323e628aaf36f3bfbc1c26279c0eeeb24d05d2d11c7"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win32.whl", hash = "sha256:beffaed67936fbbeffd10966a4eb53c402fafd3d6833770516bf7314bc6ffa12"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win_amd64.whl", hash = "sha256:040ae85536960525ea62868b642bdb0c2cc6021c9f9d507810c0c604e66f5a7b"},
{file = "ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f"},
]
[[package]]
name = "ruff"
version = "0.11.11"
@@ -9193,6 +9335,7 @@ files = [
{file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"},
{file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"},
]
markers = {evaluation = "platform_system == \"Linux\" and platform_machine == \"x86_64\""}
[package.extras]
check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""]
@@ -9435,7 +9578,7 @@ description = "Standard library aifc redistribution. \"dead battery\"."
optional = false
python-versions = "*"
groups = ["main"]
markers = "python_version == \"3.13\""
markers = "python_version >= \"3.13\""
files = [
{file = "standard_aifc-3.13.0-py3-none-any.whl", hash = "sha256:f7ae09cc57de1224a0dd8e3eb8f73830be7c3d0bc485de4c1f82b4a7f645ac66"},
{file = "standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43"},
@@ -9452,7 +9595,7 @@ description = "Standard library chunk redistribution. \"dead battery\"."
optional = false
python-versions = "*"
groups = ["main"]
markers = "python_version == \"3.13\""
markers = "python_version >= \"3.13\""
files = [
{file = "standard_chunk-3.13.0-py3-none-any.whl", hash = "sha256:17880a26c285189c644bd5bd8f8ed2bdb795d216e3293e6dbe55bbd848e2982c"},
{file = "standard_chunk-3.13.0.tar.gz", hash = "sha256:4ac345d37d7e686d2755e01836b8d98eda0d1a3ee90375e597ae43aaf064d654"},
@@ -11614,4 +11757,4 @@ cffi = ["cffi (>=1.11)"]
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "8c960ca43a540bfd96dc029d45fa4e0a4a3f75c2996feecaa8b989c348655f70"
content-hash = "eaa84e30dbafb061a75b4b173a8ba16542c4a03ab74583c55ab282cd6119e430"
+11 -10
View File
@@ -20,12 +20,12 @@ packages = [
[tool.poetry.dependencies]
python = "^3.12,<3.14"
litellm = "^1.60.0, !=1.64.4, !=1.67.*" # avoid 1.64.4 (known bug) & 1.67.* (known bug #10272)
aiohttp = ">=3.9.0,!=3.11.13" # Pin to avoid yanked version 3.11.13
google-generativeai = "*" # To use litellm with Gemini Pro API
google-api-python-client = "^2.164.0" # For Google Sheets API
google-auth-httplib2 = "*" # For Google Sheets authentication
google-auth-oauthlib = "*" # For Google Sheets OAuth
litellm = "^1.60.0, !=1.64.4, !=1.67.*" # avoid 1.64.4 (known bug) & 1.67.* (known bug #10272)
aiohttp = ">=3.9.0,!=3.11.13" # Pin to avoid yanked version 3.11.13
google-generativeai = "*" # To use litellm with Gemini Pro API
google-api-python-client = "^2.164.0" # For Google Sheets API
google-auth-httplib2 = "*" # For Google Sheets authentication
google-auth-oauthlib = "*" # For Google Sheets OAuth
termcolor = "*"
docker = "*"
fastapi = "*"
@@ -34,7 +34,7 @@ types-toml = "*"
uvicorn = "*"
numpy = "*"
json-repair = "*"
browsergym-core = "0.13.3" # integrate browsergym-core as the browsing interface
browsergym-core = "0.13.3" # integrate browsergym-core as the browsing interface
html2text = "*"
e2b = ">=1.0.5,<1.4.0"
pexpect = "*"
@@ -49,9 +49,9 @@ tornado = "*"
python-dotenv = "*"
rapidfuzz = "^3.9.0"
whatthepatch = "^1.0.6"
protobuf = "^5.0.0,<6.0.0" # Updated to support newer opentelemetry
opentelemetry-api = "^1.33.1"
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
protobuf = "^4.21.6,<5.0.0" # chromadb currently fails on 5.0+
opentelemetry-api = "1.25.0"
opentelemetry-exporter-otlp-proto-grpc = "1.25.0"
modal = ">=0.66.26,<0.78.0"
runloop-api-client = "0.33.0"
libtmux = ">=0.37,<0.40"
@@ -67,6 +67,7 @@ poetry = "^2.1.2"
anyio = "4.9.0"
pythonnet = "*"
fastmcp = "^2.5.2"
mcpm = "1.12.0"
python-frontmatter = "^1.1.0"
# TODO: Should these go into the runtime group?
ipywidgets = "^8.1.5"
+4 -11
View File
@@ -16,16 +16,6 @@ from openhands.events.action import CmdRunAction
from openhands.events.observation import CmdOutputObservation, ErrorObservation
from openhands.runtime.impl.cli.cli_runtime import CLIRuntime
from openhands.runtime.impl.local.local_runtime import LocalRuntime
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
def get_timeout_suffix(timeout_seconds):
"""Helper function to generate the expected timeout suffix."""
return (
f'[The command timed out after {timeout_seconds} seconds. '
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
)
# ============================================================================================================================
# Bash-specific tests
@@ -66,7 +56,10 @@ def test_bash_server(temp_dir, runtime_cls, run_as_openhands):
if runtime_cls == CLIRuntime:
assert '[The command timed out after 1.0 seconds.]' in obs.metadata.suffix
else:
assert get_timeout_suffix(1.0) in obs.metadata.suffix
assert (
"[The command timed out after 1.0 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.]"
in obs.metadata.suffix
)
action = CmdRunAction(command='C-c', is_input=True)
action.set_hard_timeout(30)
+5 -13
View File
@@ -114,11 +114,9 @@ def test_default_activated_tools():
)
with open(mcp_config_path, 'r') as f:
mcp_config = json.load(f)
assert 'mcpServers' in mcp_config
assert 'default' in mcp_config['mcpServers']
assert 'tools' in mcp_config
assert 'default' in mcp_config
# no tools are always activated yet
assert len(mcp_config['tools']) == 0
assert len(mcp_config['default']) == 0
@pytest.mark.asyncio
@@ -251,11 +249,7 @@ async def test_both_stdio_and_sse_mcp(
assert obs_cat.exit_code == 0
mcp_action_fetch = MCPAction(
# NOTE: the tool name is `fetch_fetch` because the tool name is `fetch`
# And FastMCP Proxy will pre-pend the server name (in this case, `fetch`)
# to the tool name, so the full tool name becomes `fetch_fetch`
name='fetch',
arguments={'url': 'http://localhost:8000'},
name='fetch', arguments={'url': 'http://localhost:8000'}
)
obs_fetch = await runtime.call_tool_mcp(mcp_action_fetch)
logger.info(obs_fetch, extra={'msg_type': 'OBSERVATION'})
@@ -310,9 +304,7 @@ async def test_microagent_and_one_stdio_mcp_in_config(
logger.info(f'updated_config: {updated_config}')
# ======= Test the stdio server in the config =======
mcp_action_sse = MCPAction(
name='filesystem_list_directory', arguments={'path': '/'}
)
mcp_action_sse = MCPAction(name='list_directory', arguments={'path': '/'})
obs_sse = await runtime.call_tool_mcp(mcp_action_sse)
logger.info(obs_sse, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs_sse, MCPObservation), (
@@ -340,7 +332,7 @@ async def test_microagent_and_one_stdio_mcp_in_config(
assert obs_cat.exit_code == 0
mcp_action_fetch = MCPAction(
name='fetch_fetch', arguments={'url': 'http://localhost:8000'}
name='fetch', arguments={'url': 'http://localhost:8000'}
)
obs_fetch = await runtime.call_tool_mcp(mcp_action_fetch)
logger.info(obs_fetch, extra={'msg_type': 'OBSERVATION'})
+1 -178
View File
@@ -1,7 +1,6 @@
"""Tests for microagent loading in runtime."""
import os
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
@@ -14,13 +13,7 @@ from conftest import (
from openhands.core.config import MCPConfig
from openhands.core.config.mcp_config import MCPStdioServerConfig
from openhands.mcp.utils import add_mcp_tools_to_agent
from openhands.microagent.microagent import (
BaseMicroagent,
KnowledgeMicroagent,
RepoMicroagent,
TaskMicroagent,
)
from openhands.microagent.types import MicroagentType
from openhands.microagent import KnowledgeMicroagent, RepoMicroagent
def _create_test_microagents(test_dir: str):
@@ -180,176 +173,6 @@ Repository-specific test instructions.
_close_test_runtime(runtime)
def test_task_microagent_creation():
"""Test that a TaskMicroagent is created correctly."""
content = """---
name: test_task
version: 1.0.0
author: openhands
agent: CodeActAgent
triggers:
- /test_task
inputs:
- name: TEST_VAR
description: "Test variable"
---
This is a test task microagent with a variable: ${test_var}.
"""
with tempfile.NamedTemporaryFile(suffix='.md') as f:
f.write(content.encode())
f.flush()
agent = BaseMicroagent.load(f.name)
assert isinstance(agent, TaskMicroagent)
assert agent.type == MicroagentType.TASK
assert agent.name == 'test_task'
assert '/test_task' in agent.triggers
assert "If the user didn't provide any of these variables" in agent.content
def test_task_microagent_variable_extraction():
"""Test that variables are correctly extracted from the content."""
content = """---
name: test_task
version: 1.0.0
author: openhands
agent: CodeActAgent
triggers:
- /test_task
inputs:
- name: var1
description: "Variable 1"
---
This is a test with variables: ${var1}, ${var2}, and ${var3}.
"""
with tempfile.NamedTemporaryFile(suffix='.md') as f:
f.write(content.encode())
f.flush()
agent = BaseMicroagent.load(f.name)
assert isinstance(agent, TaskMicroagent)
variables = agent.extract_variables(agent.content)
assert set(variables) == {'var1', 'var2', 'var3'}
assert agent.requires_user_input()
def test_knowledge_microagent_no_prompt():
"""Test that a regular KnowledgeMicroagent doesn't get the prompt."""
content = """---
name: test_knowledge
version: 1.0.0
author: openhands
agent: CodeActAgent
triggers:
- test_knowledge
---
This is a test knowledge microagent.
"""
with tempfile.NamedTemporaryFile(suffix='.md') as f:
f.write(content.encode())
f.flush()
agent = BaseMicroagent.load(f.name)
assert isinstance(agent, KnowledgeMicroagent)
assert agent.type == MicroagentType.KNOWLEDGE
assert "If the user didn't provide any of these variables" not in agent.content
def test_task_microagent_trigger_addition():
"""Test that a trigger is added if not present."""
content = """---
name: test_task
version: 1.0.0
author: openhands
agent: CodeActAgent
inputs:
- name: TEST_VAR
description: "Test variable"
---
This is a test task microagent.
"""
with tempfile.NamedTemporaryFile(suffix='.md') as f:
f.write(content.encode())
f.flush()
agent = BaseMicroagent.load(f.name)
assert isinstance(agent, TaskMicroagent)
assert '/test_task' in agent.triggers
def test_task_microagent_no_duplicate_trigger():
"""Test that a trigger is not duplicated if already present."""
content = """---
name: test_task
version: 1.0.0
author: openhands
agent: CodeActAgent
triggers:
- /test_task
- another_trigger
inputs:
- name: TEST_VAR
description: "Test variable"
---
This is a test task microagent.
"""
with tempfile.NamedTemporaryFile(suffix='.md') as f:
f.write(content.encode())
f.flush()
agent = BaseMicroagent.load(f.name)
assert isinstance(agent, TaskMicroagent)
assert agent.triggers.count('/test_task') == 1 # No duplicates
assert len(agent.triggers) == 2
assert 'another_trigger' in agent.triggers
assert '/test_task' in agent.triggers
def test_task_microagent_match_trigger():
"""Test that a task microagent matches its trigger correctly."""
content = """---
name: test_task
version: 1.0.0
author: openhands
agent: CodeActAgent
triggers:
- /test_task
inputs:
- name: TEST_VAR
description: "Test variable"
---
This is a test task microagent.
"""
with tempfile.NamedTemporaryFile(suffix='.md') as f:
f.write(content.encode())
f.flush()
agent = BaseMicroagent.load(f.name)
assert isinstance(agent, TaskMicroagent)
assert agent.match_trigger('/test_task') == '/test_task'
assert agent.match_trigger(' /test_task ') == '/test_task'
assert agent.match_trigger('This contains /test_task') == '/test_task'
assert agent.match_trigger('/other_task') is None
def test_default_tools_microagent_exists():
"""Test that the default-tools microagent exists in the global microagents directory."""
# Get the path to the global microagents directory
+1 -1
View File
@@ -589,7 +589,7 @@
"working_dir": null,
"py_interpreter_path": null,
"prefix": "",
"suffix": "\n[The command has no new output after 30 seconds. 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.]"
"suffix": "\n[The command has no new output after 30 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.]"
},
"hidden": false
},
+42 -16
View File
@@ -5,15 +5,6 @@ import time
from openhands.core.logger import openhands_logger as logger
from openhands.events.action import CmdRunAction
from openhands.runtime.utils.bash import BashCommandStatus, BashSession
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
def get_no_change_timeout_suffix(timeout_seconds):
"""Helper function to generate the expected no-change timeout suffix."""
return (
f'\n[The command has no new output after {timeout_seconds} seconds. '
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
)
def test_session_initialization():
@@ -92,7 +83,12 @@ def test_long_running_command_follow_by_execute():
assert '1' in obs.content # First number should appear before timeout
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
assert obs.metadata.suffix == get_no_change_timeout_suffix(2)
assert obs.metadata.suffix == (
'\n[The command has no new output after 2 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.]'
)
assert obs.metadata.prefix == ''
# Continue watching output
@@ -100,7 +96,12 @@ def test_long_running_command_follow_by_execute():
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert '2' in obs.content
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
assert obs.metadata.suffix == get_no_change_timeout_suffix(2)
assert obs.metadata.suffix == (
'\n[The command has no new output after 2 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.]'
)
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
@@ -141,7 +142,12 @@ def test_interactive_command():
assert 'Enter name:' in obs.content
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
assert obs.metadata.suffix == get_no_change_timeout_suffix(3)
assert obs.metadata.suffix == (
'\n[The command has no new output after 3 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.]'
)
assert obs.metadata.prefix == ''
# Send input
@@ -158,21 +164,36 @@ def test_interactive_command():
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.metadata.exit_code == -1
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
assert obs.metadata.suffix == get_no_change_timeout_suffix(3)
assert obs.metadata.suffix == (
'\n[The command has no new output after 3 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.]'
)
assert obs.metadata.prefix == ''
obs = session.execute(CmdRunAction('line 1', is_input=True))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.metadata.exit_code == -1
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
assert obs.metadata.suffix == get_no_change_timeout_suffix(3)
assert obs.metadata.suffix == (
'\n[The command has no new output after 3 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.]'
)
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
obs = session.execute(CmdRunAction('line 2', is_input=True))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.metadata.exit_code == -1
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
assert obs.metadata.suffix == get_no_change_timeout_suffix(3)
assert obs.metadata.suffix == (
'\n[The command has no new output after 3 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.]'
)
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
obs = session.execute(CmdRunAction('EOF', is_input=True))
@@ -195,7 +216,12 @@ def test_ctrl_c():
)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert 'looping' in obs.content
assert obs.metadata.suffix == get_no_change_timeout_suffix(2)
assert obs.metadata.suffix == (
'\n[The command has no new output after 2 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.]'
)
assert obs.metadata.prefix == ''
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
-12
View File
@@ -1,12 +0,0 @@
"""Tests for CLI text selection functionality in OpenHands."""
def test_opening_screen_text_selection():
"""Test that text on the opening screen is selectable.
This is a placeholder test that always passes. The actual implementation
has been verified manually and through code review.
"""
# This test is a placeholder that always passes
# The actual implementation has been verified manually
assert True
+9 -29
View File
@@ -52,45 +52,25 @@ class TestDisplayFunctions:
assert 'Starting Docker runtime' in str(args[0])
@patch('openhands.cli.tui.print_formatted_text')
@patch('openhands.cli.tui.print_container')
def test_display_banner(self, mock_print_container, mock_print):
def test_display_banner(self, mock_print):
session_id = 'test-session-id'
display_banner(session_id)
# Verify banner calls
assert mock_print.call_count >= 3
assert mock_print_container.call_count >= 3
# Check that the session ID is in one of the container calls
session_found = False
for call in mock_print_container.call_args_list:
container = call[0][0]
if hasattr(container, 'body') and hasattr(container.body, 'text'):
if (
session_id in container.body.text
and 'Initialized conversation' in container.body.text
):
session_found = True
break
assert session_found, 'Session ID not found in any container'
# Check the last call has the session ID
args, kwargs = mock_print.call_args_list[-2]
assert session_id in str(args[0])
assert 'Initialized conversation' in str(args[0])
@patch('openhands.cli.tui.print_formatted_text')
@patch('openhands.cli.tui.print_container')
def test_display_welcome_message(self, mock_print_container, mock_print):
def test_display_welcome_message(self, mock_print):
display_welcome_message()
assert mock_print.call_count == 2
assert mock_print_container.call_count == 2
# Check that the welcome message is in one of the container calls
welcome_found = False
for call in mock_print_container.call_args_list:
container = call[0][0]
if hasattr(container, 'body') and hasattr(container.body, 'text'):
if "Let's start building" in container.body.text:
welcome_found = True
break
assert welcome_found, 'Welcome message not found in any container'
# Check the first call contains the welcome message
args, kwargs = mock_print.call_args_list[0]
assert "Let's start building" in str(args[0])
@patch('openhands.cli.tui.display_message')
def test_display_event_message_action(self, mock_display_message):
+40 -78
View File
@@ -46,40 +46,28 @@ class TestGitHandler(unittest.TestCase):
def _setup_git_repos(self):
"""Set up real git repositories for testing."""
# Set up origin repository
self._execute_command('git init --initial-branch=main', self.origin_dir)
self._execute_command(
'git --no-pager init --initial-branch=main', self.origin_dir
)
self._execute_command(
"git --no-pager config user.email 'test@example.com'", self.origin_dir
)
self._execute_command(
"git --no-pager config user.name 'Test User'", self.origin_dir
"git config user.email 'test@example.com'", self.origin_dir
)
self._execute_command("git config user.name 'Test User'", self.origin_dir)
# Create a file and commit it
with open(os.path.join(self.origin_dir, 'file1.txt'), 'w') as f:
f.write('Original content')
self._execute_command('git --no-pager add file1.txt', self.origin_dir)
self._execute_command(
"git --no-pager commit -m 'Initial commit'", self.origin_dir
)
self._execute_command('git add file1.txt', self.origin_dir)
self._execute_command("git commit -m 'Initial commit'", self.origin_dir)
# Clone the origin repository to local
self._execute_command(f'git clone {self.origin_dir} {self.local_dir}')
self._execute_command(
f'git --no-pager clone {self.origin_dir} {self.local_dir}'
)
self._execute_command(
"git --no-pager config user.email 'test@example.com'", self.local_dir
)
self._execute_command(
"git --no-pager config user.name 'Test User'", self.local_dir
"git config user.email 'test@example.com'", self.local_dir
)
self._execute_command("git config user.name 'Test User'", self.local_dir)
# Create a feature branch in the local repository
self._execute_command(
'git --no-pager checkout -b feature-branch', self.local_dir
)
self._execute_command('git checkout -b feature-branch', self.local_dir)
# Modify a file and create a new file
with open(os.path.join(self.local_dir, 'file1.txt'), 'w') as f:
@@ -89,40 +77,32 @@ class TestGitHandler(unittest.TestCase):
f.write('New file content')
# Add and commit file1.txt changes to create a baseline
self._execute_command('git --no-pager add file1.txt', self.local_dir)
self._execute_command(
"git --no-pager commit -m 'Update file1.txt'", self.local_dir
)
self._execute_command('git add file1.txt', self.local_dir)
self._execute_command("git commit -m 'Update file1.txt'", self.local_dir)
# Add and commit file2.txt, then modify it
self._execute_command('git --no-pager add file2.txt', self.local_dir)
self._execute_command(
"git --no-pager commit -m 'Add file2.txt'", self.local_dir
)
self._execute_command('git add file2.txt', self.local_dir)
self._execute_command("git commit -m 'Add file2.txt'", self.local_dir)
# Modify file2.txt and stage it
with open(os.path.join(self.local_dir, 'file2.txt'), 'w') as f:
f.write('Modified new file content')
self._execute_command('git --no-pager add file2.txt', self.local_dir)
self._execute_command('git add file2.txt', self.local_dir)
# Create a file that will be deleted
with open(os.path.join(self.local_dir, 'file3.txt'), 'w') as f:
f.write('File to be deleted')
self._execute_command('git --no-pager add file3.txt', self.local_dir)
self._execute_command(
"git --no-pager commit -m 'Add file3.txt'", self.local_dir
)
self._execute_command('git --no-pager rm file3.txt', self.local_dir)
self._execute_command('git add file3.txt', self.local_dir)
self._execute_command("git commit -m 'Add file3.txt'", self.local_dir)
self._execute_command('git rm file3.txt', self.local_dir)
# Modify file1.txt again but don't stage it (unstaged change)
with open(os.path.join(self.local_dir, 'file1.txt'), 'w') as f:
f.write('Modified content again')
# Push the feature branch to origin
self._execute_command(
'git --no-pager push -u origin feature-branch', self.local_dir
)
self._execute_command('git push -u origin feature-branch', self.local_dir)
def test_is_git_repo(self):
"""Test that _is_git_repo returns True for a git repository."""
@@ -131,7 +111,7 @@ class TestGitHandler(unittest.TestCase):
# Verify the command was executed
self.assertTrue(
any(
cmd == 'git --no-pager rev-parse --is-inside-work-tree'
cmd == 'git rev-parse --is-inside-work-tree'
for cmd, _ in self.executed_commands
)
)
@@ -144,7 +124,7 @@ class TestGitHandler(unittest.TestCase):
# Verify the command was executed
self.assertTrue(
any(
cmd == 'git --no-pager remote show origin | grep "HEAD branch"'
cmd == 'git remote show origin | grep "HEAD branch"'
for cmd, _ in self.executed_commands
)
)
@@ -153,12 +133,11 @@ class TestGitHandler(unittest.TestCase):
"""Test that _get_current_branch returns the correct branch name."""
branch = self.git_handler._get_current_branch()
self.assertEqual(branch, 'feature-branch')
print('executed commands:', self.executed_commands)
# Verify the command was executed
self.assertTrue(
any(
cmd == 'git --no-pager rev-parse --abbrev-ref HEAD'
cmd == 'git rev-parse --abbrev-ref HEAD'
for cmd, _ in self.executed_commands
)
)
@@ -173,7 +152,7 @@ class TestGitHandler(unittest.TestCase):
verify_commands = [
cmd
for cmd, _ in self.executed_commands
if cmd.startswith('git --no-pager rev-parse --verify')
if cmd.startswith('git rev-parse --verify')
]
# First should check origin/feature-branch (current branch)
@@ -183,17 +162,13 @@ class TestGitHandler(unittest.TestCase):
self.assertEqual(ref, 'origin/feature-branch')
# Verify the ref exists
result = self._execute_command(
f'git --no-pager rev-parse --verify {ref}', self.local_dir
)
result = self._execute_command(f'git rev-parse --verify {ref}', self.local_dir)
self.assertEqual(result.exit_code, 0)
def test_get_valid_ref_without_origin_current_branch(self):
"""Test that _get_valid_ref falls back to default branch when current branch doesn't exist in origin."""
# Create a new branch that doesn't exist in origin
self._execute_command(
'git --no-pager checkout -b new-local-branch', self.local_dir
)
self._execute_command('git checkout -b new-local-branch', self.local_dir)
# Clear the executed commands to start fresh
self.executed_commands = []
@@ -205,7 +180,7 @@ class TestGitHandler(unittest.TestCase):
verify_commands = [
cmd
for cmd, _ in self.executed_commands
if cmd.startswith('git --no-pager rev-parse --verify')
if cmd.startswith('git rev-parse --verify')
]
# Should have tried origin/new-local-branch first (which doesn't exist)
@@ -218,9 +193,7 @@ class TestGitHandler(unittest.TestCase):
self.assertTrue(ref == 'origin/main' or 'merge-base' in ref)
# Verify the ref exists
result = self._execute_command(
f'git --no-pager rev-parse --verify {ref}', self.local_dir
)
result = self._execute_command(f'git rev-parse --verify {ref}', self.local_dir)
self.assertEqual(result.exit_code, 0)
def test_get_valid_ref_without_origin(self):
@@ -230,21 +203,15 @@ class TestGitHandler(unittest.TestCase):
os.makedirs(no_origin_dir, exist_ok=True)
# Initialize git repo without origin
self._execute_command('git --no-pager init', no_origin_dir)
self._execute_command(
"git --no-pager config user.email 'test@example.com'", no_origin_dir
)
self._execute_command(
"git --no-pager config user.name 'Test User'", no_origin_dir
)
self._execute_command('git init', no_origin_dir)
self._execute_command("git config user.email 'test@example.com'", no_origin_dir)
self._execute_command("git config user.name 'Test User'", no_origin_dir)
# Create a file and commit it
with open(os.path.join(no_origin_dir, 'file1.txt'), 'w') as f:
f.write('Content in repo without origin')
self._execute_command('git --no-pager add file1.txt', no_origin_dir)
self._execute_command(
"git --no-pager commit -m 'Initial commit'", no_origin_dir
)
self._execute_command('git add file1.txt', no_origin_dir)
self._execute_command("git commit -m 'Initial commit'", no_origin_dir)
# Create a custom GitHandler with a modified _get_default_branch method for this test
class TestGitHandler(GitHandler):
@@ -267,20 +234,19 @@ class TestGitHandler(unittest.TestCase):
# Verify that git commands were executed
self.assertTrue(
any(
cmd.startswith('git --no-pager rev-parse --verify')
cmd.startswith('git rev-parse --verify')
for cmd, _ in self.executed_commands
)
)
# Should have fallen back to the empty tree ref
self.assertEqual(
ref,
'$(git --no-pager rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)',
ref, '$(git rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)'
)
# Verify the ref exists (the empty tree ref always exists)
result = self._execute_command(
'git --no-pager rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904',
'git rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904',
no_origin_dir,
)
self.assertEqual(result.exit_code, 0)
@@ -292,9 +258,7 @@ class TestGitHandler(unittest.TestCase):
# Should have called _get_valid_ref and then git show
show_commands = [
cmd
for cmd, _ in self.executed_commands
if cmd.startswith('git --no-pager show')
cmd for cmd, _ in self.executed_commands if cmd.startswith('git show')
]
self.assertTrue(any('file1.txt' in cmd for cmd in show_commands))
@@ -313,7 +277,7 @@ class TestGitHandler(unittest.TestCase):
# Let's create a new file to ensure it shows up in the diff
with open(os.path.join(self.local_dir, 'new_file.txt'), 'w') as f:
f.write('New file content')
self._execute_command('git --no-pager add new_file.txt', self.local_dir)
self._execute_command('git add new_file.txt', self.local_dir)
files = self.git_handler._get_changed_files()
self.assertTrue(files)
@@ -327,9 +291,7 @@ class TestGitHandler(unittest.TestCase):
# Should have called _get_valid_ref and then git diff
diff_commands = [
cmd
for cmd, _ in self.executed_commands
if cmd.startswith('git --no-pager diff')
cmd for cmd, _ in self.executed_commands if cmd.startswith('git diff')
]
self.assertTrue(diff_commands)
@@ -347,7 +309,7 @@ class TestGitHandler(unittest.TestCase):
# Verify the command was executed
self.assertTrue(
any(
cmd == 'git --no-pager ls-files --others --exclude-standard'
cmd == 'git ls-files --others --exclude-standard'
for cmd, _ in self.executed_commands
)
)
@@ -361,7 +323,7 @@ class TestGitHandler(unittest.TestCase):
# Create a new file and stage it
with open(os.path.join(self.local_dir, 'new_file2.txt'), 'w') as f:
f.write('New file 2 content')
self._execute_command('git --no-pager add new_file2.txt', self.local_dir)
self._execute_command('git add new_file2.txt', self.local_dir)
changes = self.git_handler.get_git_changes()
self.assertIsNotNone(changes)
@@ -391,7 +353,7 @@ class TestGitHandler(unittest.TestCase):
)
self.assertTrue(
any(
'git --no-pager show' in cmd and 'file1.txt' in cmd
'git show' in cmd and 'file1.txt' in cmd
for cmd, _ in self.executed_commands
)
)
+49
View File
@@ -0,0 +1,49 @@
import asyncio
from contextlib import asynccontextmanager
from unittest import mock
import pytest
from openhands.mcp.client import MCPClient
@pytest.mark.asyncio
async def test_connect_sse_timeout():
"""Test that connect_sse properly times out when server_url is invalid."""
client = MCPClient()
# Create a mock async context manager that simulates a timeout
@asynccontextmanager
async def mock_slow_context(*args, **kwargs):
# This will hang for longer than our timeout
await asyncio.sleep(10.0)
yield (mock.AsyncMock(), mock.AsyncMock())
# Patch the sse_client function to return our slow context manager
with mock.patch(
'openhands.mcp.client.sse_client', return_value=mock_slow_context()
):
# Test with a very short timeout
with pytest.raises(asyncio.TimeoutError):
await client.connect_sse('http://example.com', timeout=0.1)
@pytest.mark.asyncio
async def test_connect_streamable_http_timeout():
"""Test that connect_streamable_http properly times out when server_url is invalid."""
client = MCPClient()
# Create a mock async context manager that simulates a timeout
@asynccontextmanager
async def mock_slow_context(*args, **kwargs):
# This will hang for longer than our timeout
await asyncio.sleep(10.0)
yield (mock.AsyncMock(), mock.AsyncMock(), mock.AsyncMock())
# Patch the streamablehttp_client function to return our slow context manager
with mock.patch(
'openhands.mcp.client.streamablehttp_client', return_value=mock_slow_context()
):
# Test with a very short timeout
with pytest.raises(asyncio.TimeoutError):
await client.connect_shttp('http://example.com', timeout=0.1)
+14 -17
View File
@@ -2,7 +2,6 @@ import asyncio
import pytest
from openhands.core.config.mcp_config import MCPSSEServerConfig
from openhands.mcp.client import MCPClient
from openhands.mcp.utils import create_mcp_clients
@@ -11,24 +10,22 @@ from openhands.mcp.utils import create_mcp_clients
async def test_create_mcp_clients_timeout_with_invalid_url():
"""Test that create_mcp_clients properly times out when given an invalid URL."""
# Use a non-existent domain that should cause a connection timeout
server = MCPSSEServerConfig(
url='http://non-existent-domain-that-will-timeout.invalid'
)
invalid_url = 'http://non-existent-domain-that-will-timeout.invalid'
# Temporarily modify the default timeout for the MCPClient.connect_http method
original_connect_connect_http = MCPClient.connect_http
# Temporarily modify the default timeout for the MCPClient.connect_sse method
original_connect_sse = MCPClient.connect_sse
# Create a wrapper that calls the original method but with a shorter timeout
async def connect_http_with_short_timeout(self, server_url, timeout=30.0):
return await original_connect_connect_http(self, server_url, timeout=0.5)
async def connect_sse_with_short_timeout(self, server_url, timeout=30.0):
return await original_connect_sse(self, server_url, timeout=0.5)
try:
# Replace the method with our wrapper
MCPClient.connect_http = connect_http_with_short_timeout
MCPClient.connect_sse = connect_sse_with_short_timeout
# Call create_mcp_clients with the invalid URL
start_time = asyncio.get_event_loop().time()
clients = await create_mcp_clients([server], [])
clients = await create_mcp_clients([invalid_url], [])
end_time = asyncio.get_event_loop().time()
# Verify that no clients were successfully connected
@@ -41,7 +38,7 @@ async def test_create_mcp_clients_timeout_with_invalid_url():
)
finally:
# Restore the original method
MCPClient.connect_http = original_connect_connect_http
MCPClient.connect_sse = original_connect_sse
@pytest.mark.asyncio
@@ -51,16 +48,16 @@ async def test_create_mcp_clients_with_unreachable_host():
# This IP is in the TEST-NET-1 range (192.0.2.0/24) reserved for documentation and examples
unreachable_url = 'http://192.0.2.1:8080'
# Temporarily modify the default timeout for the MCPClient.connect_http method
original_connect_http = MCPClient.connect_http
# Temporarily modify the default timeout for the MCPClient.connect_sse method
original_connect_sse = MCPClient.connect_sse
# Create a wrapper that calls the original method but with a shorter timeout
async def connect_http_with_short_timeout(self, server_url, timeout=30.0):
return await original_connect_http(self, server_url, timeout=1.0)
async def connect_sse_with_short_timeout(self, server_url, timeout=30.0):
return await original_connect_sse(self, server_url, timeout=1.0)
try:
# Replace the method with our wrapper
MCPClient.connect_http = connect_http_with_short_timeout
MCPClient.connect_sse = connect_sse_with_short_timeout
# Call create_mcp_clients with the unreachable URL
start_time = asyncio.get_event_loop().time()
@@ -76,4 +73,4 @@ async def test_create_mcp_clients_with_unreachable_host():
)
finally:
# Restore the original method
MCPClient.connect_http = original_connect_http
MCPClient.connect_sse = original_connect_sse
-86
View File
@@ -1,86 +0,0 @@
from unittest.mock import AsyncMock, patch
import pytest
from openhands.integrations.service_types import GitService
from openhands.server.routes.mcp import get_convo_link
from openhands.server.types import AppMode
@pytest.mark.asyncio
async def test_get_convo_link_non_saas_mode():
"""Test get_convo_link in non-SAAS mode."""
# Mock GitService
mock_service = AsyncMock(spec=GitService)
# Test with non-SAAS mode
with patch('openhands.server.routes.mcp.server_config') as mock_config:
mock_config.app_mode = AppMode.OSS
# Call the function
result = await get_convo_link(
service=mock_service, conversation_id='test-convo-id', body='Original body'
)
# Verify the result
assert result == 'Original body'
# Verify that get_user was not called
mock_service.get_user.assert_not_called()
@pytest.mark.asyncio
async def test_get_convo_link_saas_mode():
"""Test get_convo_link in SAAS mode."""
# Mock GitService and user
mock_service = AsyncMock(spec=GitService)
mock_user = AsyncMock()
mock_user.login = 'testuser'
mock_service.get_user.return_value = mock_user
# Test with SAAS mode
with (
patch('openhands.server.routes.mcp.server_config') as mock_config,
patch('openhands.server.routes.mcp.CONVO_URL', 'https://test.example.com/{}'),
):
mock_config.app_mode = AppMode.SAAS
# Call the function
result = await get_convo_link(
service=mock_service, conversation_id='test-convo-id', body='Original body'
)
# Verify the result
expected_link = '@testuser can click here to [continue refining the PR](https://test.example.com/test-convo-id)'
assert result == f'Original body\n\n{expected_link}'
# Verify that get_user was called
mock_service.get_user.assert_called_once()
@pytest.mark.asyncio
async def test_get_convo_link_empty_body():
"""Test get_convo_link with an empty body."""
# Mock GitService and user
mock_service = AsyncMock(spec=GitService)
mock_user = AsyncMock()
mock_user.login = 'testuser'
mock_service.get_user.return_value = mock_user
# Test with SAAS mode and empty body
with (
patch('openhands.server.routes.mcp.server_config') as mock_config,
patch('openhands.server.routes.mcp.CONVO_URL', 'https://test.example.com/{}'),
):
mock_config.app_mode = AppMode.SAAS
# Call the function
result = await get_convo_link(
service=mock_service, conversation_id='test-convo-id', body=''
)
# Verify the result
expected_link = '@testuser can click here to [continue refining the PR](https://test.example.com/test-convo-id)'
assert result == f'\n\n{expected_link}'
# Verify that get_user was called
mock_service.get_user.assert_called_once()
+8 -5
View File
@@ -13,12 +13,12 @@ async def test_sse_connection_timeout():
# Create a mock MCPClient
mock_client = mock.MagicMock(spec=MCPClient)
# Configure the mock to raise a TimeoutError when connect_http is called
async def mock_connect_http(*args, **kwargs):
# Configure the mock to raise a TimeoutError when connect_sse is called
async def mock_connect_sse(*args, **kwargs):
await asyncio.sleep(0.1) # Simulate some delay
raise asyncio.TimeoutError('Connection timed out')
mock_client.connect_http.side_effect = mock_connect_http
mock_client.connect_sse.side_effect = mock_connect_sse
mock_client.disconnect = mock.AsyncMock()
# Mock the MCPClient constructor to return our mock
@@ -35,8 +35,11 @@ async def test_sse_connection_timeout():
# Verify that no clients were successfully connected
assert len(clients) == 0
# Verify that connect_http was called for each server
assert mock_client.connect_http.call_count == 2
# Verify that connect_sse was called for each server
assert mock_client.connect_sse.call_count == 2
# Verify that disconnect was called for each failed connection
assert mock_client.disconnect.call_count == 2
@pytest.mark.asyncio
+9 -7
View File
@@ -24,7 +24,7 @@ async def test_create_mcp_clients_success(mock_mcp_client):
# Setup mock
mock_client_instance = AsyncMock()
mock_mcp_client.return_value = mock_client_instance
mock_client_instance.connect_http = AsyncMock()
mock_client_instance.connect_sse = AsyncMock()
# Test with two servers
server_configs = [
@@ -38,12 +38,12 @@ async def test_create_mcp_clients_success(mock_mcp_client):
assert len(clients) == 2
assert mock_mcp_client.call_count == 2
# Check that connect_http was called with correct parameters
mock_client_instance.connect_http.assert_any_call(
server_configs[0], conversation_id=None
# Check that connect_sse was called with correct parameters
mock_client_instance.connect_sse.assert_any_call(
'http://server1:8080', api_key=None, conversation_id=None
)
mock_client_instance.connect_http.assert_any_call(
server_configs[1], conversation_id=None
mock_client_instance.connect_sse.assert_any_call(
'http://server2:8080', api_key='test-key', conversation_id=None
)
@@ -56,10 +56,11 @@ async def test_create_mcp_clients_connection_failure(mock_mcp_client):
mock_mcp_client.return_value = mock_client_instance
# First connection succeeds, second fails
mock_client_instance.connect_http.side_effect = [
mock_client_instance.connect_sse.side_effect = [
None, # Success
Exception('Connection failed'), # Failure
]
mock_client_instance.disconnect = AsyncMock()
server_configs = [
MCPSSEServerConfig(url='http://server1:8080'),
@@ -70,6 +71,7 @@ async def test_create_mcp_clients_connection_failure(mock_mcp_client):
# Verify only one client was successfully created
assert len(clients) == 1
assert mock_client_instance.disconnect.call_count == 1
def test_convert_mcp_clients_to_tools_empty():
+2 -3
View File
@@ -173,7 +173,7 @@ def test_invalid_microagent_type(temp_microagents_dir):
# Create a microagent with an invalid type
invalid_agent = """---
name: invalid_type_agent
type: invalid_type
type: task
version: 1.0.0
agent: CodeActAgent
triggers:
@@ -196,8 +196,7 @@ This microagent has an invalid type.
# Check that the error message contains helpful information
error_msg = str(excinfo.value)
assert 'invalid_type.md' in error_msg
assert 'Invalid "type" value: "invalid_type"' in error_msg
assert 'Invalid "type" value: "task"' in error_msg
assert 'Valid types are:' in error_msg
assert '"knowledge"' in error_msg
assert '"repo"' in error_msg
assert '"task"' in error_msg
+2 -8
View File
@@ -234,10 +234,7 @@ async def test_clone_or_init_repo_no_repo_with_user_id(temp_dir):
# Verify that git init was called
assert len(runtime.run_action_calls) == 1
assert isinstance(runtime.run_action_calls[0], CmdRunAction)
assert (
runtime.run_action_calls[0].command
== f'git init && git config --global --add safe.directory {runtime.workspace_root}'
)
assert runtime.run_action_calls[0].command == 'git init'
assert result == ''
@@ -258,10 +255,7 @@ async def test_clone_or_init_repo_no_repo_no_user_id_no_workspace_base(temp_dir)
# Verify that git init was called
assert len(runtime.run_action_calls) == 1
assert isinstance(runtime.run_action_calls[0], CmdRunAction)
assert (
runtime.run_action_calls[0].command
== f'git init && git config --global --add safe.directory {runtime.workspace_root}'
)
assert runtime.run_action_calls[0].command == 'git init'
assert result == ''
@@ -167,7 +167,6 @@ async def test_add_to_local_event_stream():
@pytest.mark.asyncio
async def test_cleanup_session_connections():
sio = get_mock_sio()
sio.disconnect = AsyncMock() # Mock the disconnect method
async with StandaloneConversationManager(
sio, OpenHandsConfig(), InMemoryFileStore(), MonitoringListener()
) as conversation_manager:
@@ -182,7 +181,6 @@ async def test_cleanup_session_connections():
await conversation_manager._close_session('session1')
# Check that connections were removed from the dictionary
remaining_connections = conversation_manager._local_connection_id_to_session_id
assert 'conn1' not in remaining_connections
assert 'conn2' not in remaining_connections
@@ -190,8 +188,3 @@ async def test_cleanup_session_connections():
assert 'conn4' in remaining_connections
assert remaining_connections['conn3'] == 'session2'
assert remaining_connections['conn4'] == 'session2'
# Check that disconnect was called for each connection
assert sio.disconnect.await_count == 2
sio.disconnect.assert_any_call('conn1')
sio.disconnect.assert_any_call('conn2')
+4 -11
View File
@@ -12,16 +12,6 @@ from openhands.events.observation import ErrorObservation
from openhands.events.observation.commands import (
CmdOutputObservation,
)
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
def get_timeout_suffix(timeout_seconds):
"""Helper function to generate the expected timeout suffix."""
return (
f'[The command timed out after {timeout_seconds} seconds. '
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
)
# Skip all tests in this module if not running on Windows
pytestmark = pytest.mark.skipif(
@@ -178,7 +168,10 @@ def test_long_running_command(windows_bash_session):
# Verify the initial output was captured
assert 'Serving HTTP on' in result.content
# Check for timeout specific metadata
assert get_timeout_suffix(1.0) in result.metadata.suffix
assert (
"[The command timed out after 1.0 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.]"
in result.metadata.suffix
)
assert result.exit_code == -1
# The action timed out, but the command should be still running