mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc16da8fd2 | |||
| bd3ff43c67 | |||
| 0fe5b808af | |||
| 6c49686ff0 | |||
| 17212bb2f2 | |||
| 9d9f931e95 | |||
| 6fe9680474 | |||
| 53c80d1c92 | |||
| 401262f353 | |||
| 34c13c8824 | |||
| 58845b01a3 | |||
| 469d184157 | |||
| 4837c4dc74 | |||
| 49939c1f02 | |||
| abec074a66 | |||
| 6763f21cc3 | |||
| 32e610ac1d | |||
| 46c12ce258 | |||
| 5de119dc2e | |||
| 0abc6f27ef | |||
| 445d3a5788 | |||
| 744a6299a7 | |||
| 345dccbf84 | |||
| 6605269e5b | |||
| fac0d59388 | |||
| 4d6d28a192 | |||
| ebacd1b080 | |||
| 59f5f0dc9b | |||
| 4df3ee9d2e | |||
| 85c65391ca | |||
| c444dbfbbf | |||
| dd988d0f14 | |||
| 6f1a74e286 | |||
| 7b956b6103 | |||
| 34b097115d | |||
| 3e4ab4f379 | |||
| 54cd9f7e44 | |||
| 802b765f98 | |||
| 18c88f99ff | |||
| f3934be07b | |||
| 6ce9f49d1e | |||
| fc07622b20 | |||
| da935f9d8f | |||
| 642cc52a1a | |||
| 4c361ab9e5 | |||
| 5dfa1bb6eb | |||
| a07cf972a5 | |||
| f2e3bc3254 | |||
| 3790ec7d60 | |||
| 3c0719309e | |||
| 0236e0943e | |||
| cd464c0022 | |||
| 4519a7f4f3 | |||
| fdc591330b | |||
| 98e454e82c | |||
| e088d2d24a | |||
| 58c574af1e | |||
| 405f0069f8 | |||
| f26d770d03 | |||
| bf2c3de219 | |||
| 7c35ce16e5 | |||
| f4024ccd94 | |||
| b55bfed831 | |||
| cb0994027f | |||
| bcc9bd0b9a | |||
| 6c144e6b5a | |||
| e90b841b0d | |||
| a1e6ed4dff | |||
| ad6311d3cd |
@@ -12,4 +12,5 @@
|
||||
"ghcr.io/devcontainers/features/node:1": {},
|
||||
},
|
||||
"postCreateCommand": ".devcontainer/setup.sh",
|
||||
"runArgs": ["--network=host"],
|
||||
}
|
||||
|
||||
Regular → Executable
+1
-1
@@ -5,7 +5,7 @@
|
||||
/frontend/ @rbren @amanape
|
||||
|
||||
# Evaluation code owners
|
||||
/evaluation/ @xingyaoww @neubig
|
||||
/evaluation/ @xingyaoww @neubig
|
||||
|
||||
# Documentation code owners
|
||||
/docs/ @mamoodi
|
||||
|
||||
@@ -38,7 +38,6 @@
|
||||
]
|
||||
},
|
||||
"usage/cloud/cloud-ui",
|
||||
"usage/cloud/cloud-issue-resolver",
|
||||
"usage/cloud/cloud-api"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
title: Cloud Issue Resolver
|
||||
description: The Cloud Issue Resolver automates code fixes and provides intelligent assistance for your repositories on GitHub.
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
The Cloud Issue Resolver is available automatically when you grant OpenHands Cloud repository access:
|
||||
- [GitHub repository access](./github-installation#adding-repository-access)
|
||||
|
||||
## Usage
|
||||
|
||||
After granting OpenHands Cloud repository access, you can use the Cloud Issue Resolver on issues and pull requests in your repositories.
|
||||
|
||||
### Working with Issues
|
||||
|
||||
On your repository, label an issue with `openhands` or add a message starting with
|
||||
`@openhands`. OpenHands will:
|
||||
1. Comment on the issue to let you know it is working on it
|
||||
- You can click on the link to track the progress on OpenHands Cloud
|
||||
2. Open a pull request if it determines that the issue has been successfully resolved
|
||||
3. Comment on the issue with a summary of the performed tasks and a link to the PR
|
||||
|
||||
### Working with Pull Requests
|
||||
|
||||
To get OpenHands to work on pull requests, mention `@openhands` in comments to:
|
||||
- Ask questions
|
||||
- Request updates
|
||||
- Get code explanations
|
||||
|
||||
OpenHands will:
|
||||
1. Comment to let you know it is working on it
|
||||
2. Perform the requested task
|
||||
@@ -1,28 +1,36 @@
|
||||
---
|
||||
title: Cloud UI
|
||||
description: The Cloud UI provides a web interface for interacting with OpenHands AI. This page explains how to access and use the OpenHands Cloud UI.
|
||||
description: The Cloud UI provides a web interface for interacting with OpenHands. This page explains how to use the
|
||||
OpenHands Cloud UI.
|
||||
---
|
||||
|
||||
## Landing Page
|
||||
|
||||
## Accessing the UI
|
||||
The landing page is where you can:
|
||||
|
||||
The OpenHands Cloud UI can be accessed at [app.all-hands.dev](https://app.all-hands.dev). You'll need to sign in with your GitHub or GitLab account to access the interface.
|
||||
|
||||
|
||||
## Key Features
|
||||
|
||||
For detailed information about the features available in the OpenHands Cloud UI, please refer to the [Key Features](../key-features) section of the documentation.
|
||||
- [Add GitHub repository access](/usage/cloud/github-installation#adding-github-repository-access) to OpenHands.
|
||||
- [Select a GitHub repo](/usage/cloud/github-installation#working-with-github-repos-in-openhands-cloud) or
|
||||
[a GitLab repo](/usage/cloud/gitlab-installation#working-with-gitlab-repos-in-openhands-cloud) to start working on.
|
||||
- See `Suggested Tasks` for repositories that OpenHands has access to.
|
||||
- Launch an empty conversation using `Launch from Scratch`.
|
||||
|
||||
## Settings
|
||||
|
||||
The settings page allows you to:
|
||||
The Settings page allows you to:
|
||||
|
||||
- Configure your account preferences.
|
||||
- Manage repository access.
|
||||
- Generate API keys for programmatic access.
|
||||
- Generate custom secrets for the agent.
|
||||
- [Configure GitHub repository access](/usage/cloud/github-installation#modifying-repository-access) for OpenHands.
|
||||
- Set application settings like your preferred language, notifications and other preferences.
|
||||
- Add credits to your account.
|
||||
- Generate custom secrets.
|
||||
- Create API keys to work with OpenHands programmatically.
|
||||
|
||||
## Key Features
|
||||
|
||||
For an overview of the key features available inside a conversation, please refer to the [Key Features](../key-features)
|
||||
section of the documentation.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Use the Cloud Issue Resolver](./cloud-issue-resolver) to automate code fixes and get assistance.
|
||||
- [Learn about the Cloud API](./cloud-api) for programmatic access.
|
||||
- [Install GitHub Integration](/usage/cloud/github-installation) to use OpenHands with your GitHub repositories.
|
||||
- [Install GitLab Integration](/usage/cloud/gitlab-installation) to use OpenHands with your GitLab repositories.
|
||||
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands.
|
||||
|
||||
@@ -1,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 issues!
|
||||
set up, it will allow OpenHands to work with your GitHub repository through the Cloud UI or straight from GitHub!
|
||||
---
|
||||
|
||||
## 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 session!
|
||||
click on `Launch` to start the conversation!
|
||||
|
||||

|
||||
|
||||
@@ -67,5 +67,5 @@ To get OpenHands to work on pull requests, mention `@openhands` in the comments
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Access the Cloud UI](./cloud-ui) to interact with the web interface
|
||||
- [Use the Cloud API](./cloud-api) to programmatically interact with OpenHands
|
||||
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
|
||||
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands.
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
---
|
||||
title: GitLab Integration
|
||||
description: This guide walks you through the process of installing and configuring OpenHands Cloud for your GitLab repositories.
|
||||
description: This guide walks you through the process of installing OpenHands Cloud for your GitLab repositories. Once
|
||||
set up, it will allow OpenHands to work with your GitLab repository.
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A GitLab account
|
||||
- Access to OpenHands Cloud
|
||||
- Signed in to [OpenHands Cloud](https://app.all-hands.dev) with [a GitLab account](/usage/cloud/openhands-cloud).
|
||||
|
||||
## Installation Steps
|
||||
## Adding GitLab Repository Access
|
||||
|
||||
1. Log in to [OpenHands Cloud](https://app.all-hands.dev)
|
||||
2. If you haven't connected your GitLab account yet:
|
||||
- Click on `Log in with GitLab`
|
||||
- Authorize the OpenHands application
|
||||
Upon signing into OpenHands Cloud with a GitLab account, OpenHands will have access to your repositories.
|
||||
|
||||
## Working With GitLab Repos in Openhands Cloud
|
||||
|
||||
After signing in with a Gitlab account, use the `select a repo` and `select a branch` dropdowns to select the
|
||||
appropriate repository and branch you'd like OpenHands to work on. Then click on `Launch` to start the conversation!
|
||||
|
||||

|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Access the Cloud UI](./cloud-ui) to interact with the web interface
|
||||
- [Use the Cloud API](./cloud-api) to programmatically interact with OpenHands
|
||||
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
|
||||
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands.
|
||||
|
||||
@@ -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](./github-installation) to use OpenHands with your GitHub repositories
|
||||
- [Install GitLab Integration](./gitlab-installation) to use OpenHands with your GitLab repositories
|
||||
- [Access the Cloud UI](./cloud-ui) to interact with the web interface
|
||||
- [Use the Cloud API](./cloud-api) to programmatically interact with OpenHands
|
||||
- [Set up the Cloud Issue Resolver](./cloud-issue-resolver) to automate code fixes and provide intelligent assistance
|
||||
- [Install GitHub Integration](/usage/cloud/github-installation) to use OpenHands with your GitHub repositories.
|
||||
- [Install GitLab Integration](/usage/cloud/gitlab-installation) to use OpenHands with your GitLab repositories.
|
||||
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
|
||||
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands.
|
||||
|
||||
@@ -109,6 +109,14 @@ 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.
|
||||
|
||||
@@ -5,26 +5,111 @@ description: Keyword-triggered microagents provide OpenHands with specific instr
|
||||
|
||||
## Usage
|
||||
|
||||
These microagents are only loaded when a prompt includes one of the trigger words.
|
||||
Keyword-triggered microagents are only loaded when a prompt includes one of the trigger words. There are two types of keyword-triggered microagents:
|
||||
|
||||
1. **Standard Keyword Microagents**: Triggered by keywords embedded in text
|
||||
2. **Command-Style Microagents**: Triggered by command-style inputs (e.g., `/fix_test`) that can prompt for user input
|
||||
|
||||
Additionally, there's a special type of microagent that's always active:
|
||||
|
||||
3. **Repository Microagents**: Always active for a specific repository, providing repository-specific context and tools
|
||||
|
||||
## Frontmatter Syntax
|
||||
|
||||
Frontmatter is required for keyword-triggered microagents. It must be placed at the top of the file,
|
||||
above the guidelines.
|
||||
above the guidelines. Enclose the frontmatter in triple dashes (---).
|
||||
|
||||
Enclose the frontmatter in triple dashes (---) and include the following fields:
|
||||
### Standard Keyword Microagents
|
||||
|
||||
For standard keyword microagents, include the following fields:
|
||||
|
||||
| Field | Description | Required | Default |
|
||||
|------------|--------------------------------------------------|----------|------------------|
|
||||
| `name` | The name of the microagent | No | Filename |
|
||||
| `type` | The type of microagent (`knowledge`) | No | Inferred |
|
||||
| `triggers` | A list of keywords that activate the microagent. | Yes | None |
|
||||
| `agent` | The agent this microagent applies to. | No | 'CodeActAgent' |
|
||||
|
||||
### Command-Style Microagents
|
||||
|
||||
## Example
|
||||
For command-style microagents that require user input, include the following fields:
|
||||
|
||||
Keyword-triggered microagent file example located at `.openhands/microagents/yummy.md`:
|
||||
```
|
||||
| Field | Description | Required | Default |
|
||||
|------------|------------------------------------------------------------|----------|------------------|
|
||||
| `name` | The name of the microagent | No | Filename |
|
||||
| `type` | The type of microagent (`task`) | No | Inferred |
|
||||
| `triggers` | A list of command triggers (e.g., `/fix_test`) | No | `/[name]` |
|
||||
| `inputs` | A list of input variables the microagent requires | Yes | None |
|
||||
|
||||
### Repository Microagents
|
||||
|
||||
Repository microagents are always active for a specific repository. They provide repository-specific context and tools.
|
||||
|
||||
| Field | Description | Required | Default |
|
||||
|------------|------------------------------------------------------------|----------|------------------|
|
||||
| `name` | The name of the microagent | No | Filename |
|
||||
| `type` | The type of microagent (`repo`) | No | Inferred |
|
||||
|
||||
#### Repository Microagent Example
|
||||
|
||||
Here's an example of a repository microagent:
|
||||
|
||||
```yaml
|
||||
---
|
||||
# The type field is optional and will be inferred as 'repo' when no triggers are present
|
||||
---
|
||||
|
||||
# Repository Guidelines
|
||||
|
||||
This repository follows these coding standards:
|
||||
1. Use PEP 8 for Python code
|
||||
2. Use ESLint for JavaScript code
|
||||
3. Write unit tests for all new features
|
||||
```
|
||||
|
||||
This microagent is always active when working with the repository and provides repository-specific guidelines.
|
||||
|
||||
### MCP Tools Support
|
||||
|
||||
Microagents can also provide additional MCP (Model-Code-Prompt) tools to the agent. This is useful for extending the agent's capabilities with custom tools.
|
||||
|
||||
| Field | Description | Required | Default |
|
||||
|--------------|-----------------------------------------------------------|----------|------------------|
|
||||
| `mcp_tools` | Configuration for additional MCP tools | No | None |
|
||||
|
||||
#### MCP Tools Example
|
||||
|
||||
Here's an example of a microagent that provides an additional MCP tool (the `fetch` tool for accessing web content):
|
||||
|
||||
```yaml
|
||||
---
|
||||
# The type field is optional and will be inferred as 'repo' when no triggers are present
|
||||
mcp_tools:
|
||||
stdio_servers:
|
||||
- name: "fetch"
|
||||
command: uvx
|
||||
args:
|
||||
- mcp-server-fetch
|
||||
---
|
||||
```
|
||||
|
||||
This microagent is a repository microagent (always active) that adds the `fetch` tool to the agent's capabilities.
|
||||
|
||||
Each input in the `inputs` list requires:
|
||||
|
||||
| Field | Description | Required |
|
||||
|---------------|--------------------------------------------------|----------|
|
||||
| `name` | The name of the input variable | Yes |
|
||||
| `description` | A description of what the input should contain | Yes |
|
||||
|
||||
|
||||
## Examples
|
||||
|
||||
### Standard Keyword Microagent Example
|
||||
|
||||
Standard keyword microagent file example located at `.openhands/microagents/yummy.md`:
|
||||
```yaml
|
||||
---
|
||||
# The type field is optional and will be inferred as 'knowledge' when triggers are present
|
||||
triggers:
|
||||
- yummyhappy
|
||||
- happyyummy
|
||||
@@ -33,4 +118,58 @@ triggers:
|
||||
The user has said the magic word. Respond with "That was delicious!"
|
||||
```
|
||||
|
||||
[See examples of microagents triggered by keywords in the official OpenHands repository](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents)
|
||||
### Command-Style Microagent Example
|
||||
|
||||
Command-style microagent file example located at `.openhands/microagents/fix_test.md`:
|
||||
```yaml
|
||||
---
|
||||
# The type field is optional and will be inferred as 'task' when inputs are present
|
||||
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.
|
||||
```
|
||||
|
||||
## Using Command-Style Microagents
|
||||
|
||||
Command-style microagents are designed to streamline common development tasks by providing structured templates for specific operations. They are triggered using a command-style format and will prompt the user for any required inputs.
|
||||
|
||||
### How to Use
|
||||
|
||||
1. Type `/` in the chat input to see available command-style microagents
|
||||
2. Select a microagent from the dropdown or type its name (e.g., `/fix_test`)
|
||||
3. The agent will prompt you for any required inputs
|
||||
4. Provide the requested information
|
||||
5. The agent will execute the task with your inputs
|
||||
|
||||
### Template Variables
|
||||
|
||||
In the body of a command-style microagent, you can reference input variables using the `{{ VARIABLE_NAME }}` syntax. These will be replaced with the user-provided values when the microagent is triggered.
|
||||
|
||||
### Available Command-Style Microagents
|
||||
|
||||
OpenHands includes several built-in command-style microagents:
|
||||
|
||||
| Command | Description |
|
||||
|----------------------|-------------------------------------------------------|
|
||||
| `/fix_test` | Fix failing tests by modifying a specific function |
|
||||
| `/update_test` | Update tests for a new implementation |
|
||||
| `/update_pr` | Update a pull request description |
|
||||
| `/address_pr_comments` | Address comments on a pull request |
|
||||
| `/add_repo_instruction` | Add instructions to the repository microagent |
|
||||
|
||||
[See examples of microagents in the official OpenHands repository](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents)
|
||||
|
||||
@@ -8,10 +8,10 @@ description: Microagents are specialized prompts that enhance OpenHands with dom
|
||||
Currently OpenHands supports the following types of microagents:
|
||||
|
||||
- [General Microagents](./microagents-repo): General guidelines for OpenHands about the repository.
|
||||
- [Keyword-Triggered Microagents](./microagents-keyword): Guidelines activated by specific keywords in prompts.
|
||||
- [Keyword-Triggered Microagents](./microagents-keyword): Guidelines activated by specific keywords in prompts, including command-style microagents that prompt for user inputs.
|
||||
|
||||
To customize OpenHands' behavior, create a .openhands/microagents/ directory in the root of your repository and
|
||||
add `<microagent_name>.md` files inside.
|
||||
add `<microagent_name>.md` files inside. For repository-specific guidelines, you can ask OpenHands to analyze your repository and create a comprehensive `repo.md` file (see [General Microagents](./microagents-repo) for details).
|
||||
|
||||
<Note>
|
||||
Loaded microagents take up space in the context window.
|
||||
@@ -34,7 +34,7 @@ some-repository/
|
||||
Each microagent file may include frontmatter that provides additional information. In some cases, this frontmatter
|
||||
is required:
|
||||
|
||||
| Microagent Type | Required |
|
||||
|---------------------------------|----------|
|
||||
| `General Microagents` | No |
|
||||
| `Keyword-Triggered Microagents` | Yes |
|
||||
| Microagent Type | Required |
|
||||
|------------------------------------------------|----------|
|
||||
| `General Microagents` | No |
|
||||
| `Keyword-Triggered Microagents (all types)` | Yes |
|
||||
|
||||
@@ -17,13 +17,45 @@ Frontmatter should be enclosed in triple dashes (---) and may include the follow
|
||||
|-----------|-----------------------------------------|----------|----------------|
|
||||
| `agent` | The agent this microagent applies to | No | 'CodeActAgent' |
|
||||
|
||||
## Example
|
||||
## Creating a Comprehensive Repository Agent
|
||||
|
||||
To create an effective repository agent, you can ask OpenHands to analyze your repository with a prompt like:
|
||||
|
||||
General microagent file example located at `.openhands/microagents/repo.md`:
|
||||
```
|
||||
Please browse the repository, look at the documentation and relevant code, and understand the purpose of this repository.
|
||||
|
||||
Specifically, I want you to create a `.openhands/microagents/repo.md` file. This file should contain succinct information that summarizes:
|
||||
1. The purpose of this repository
|
||||
2. The general setup of this repo
|
||||
3. A brief description of the structure of this repo
|
||||
|
||||
Read all the GitHub workflows under .github/ of the repository (if this folder exists) to understand the CI checks (e.g., linter, pre-commit), and include those in the repo.md file.
|
||||
```
|
||||
|
||||
This approach helps OpenHands capture repository context efficiently, reducing the need for repeated searches during conversations and ensuring more accurate solutions.
|
||||
|
||||
## Example Content
|
||||
|
||||
A comprehensive repository agent file (`.openhands/microagents/repo.md`) should include:
|
||||
|
||||
```
|
||||
# Repository Purpose
|
||||
This project is a TODO application that allows users to track TODO items.
|
||||
|
||||
# Setup Instructions
|
||||
To set it up, you can run `npm run build`.
|
||||
|
||||
# Repository Structure
|
||||
- `/src`: Core application code
|
||||
- `/tests`: Test suite
|
||||
- `/docs`: Documentation
|
||||
- `/.github`: CI/CD workflows
|
||||
|
||||
# CI/CD Workflows
|
||||
- `lint.yml`: Runs ESLint on all JavaScript files
|
||||
- `test.yml`: Runs the test suite on pull requests
|
||||
|
||||
# Development Guidelines
|
||||
Always make sure the tests are passing before committing changes. You can run the tests by running `npm run test`.
|
||||
```
|
||||
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
---
|
||||
title: Team CLI
|
||||
---
|
||||
|
||||
# OpenHands Team CLI
|
||||
|
||||
The Team CLI provides a command-line interface for interacting with the OpenHands HTTP and WebSocket APIs. It allows you to create conversations, list existing conversations, and join conversations to interact with the agent.
|
||||
|
||||
## Getting Started
|
||||
|
||||
To use the Team CLI, you need to have OpenHands installed. You can then use the `team` command to access the Team CLI:
|
||||
|
||||
```bash
|
||||
openhands team [command] [options]
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The Team CLI uses the following environment variables for configuration:
|
||||
|
||||
- `OPENHANDS_API_URL`: The base URL for the OpenHands API (default: `https://staging.all-hands.dev`)
|
||||
- `OPENHANDS_API_KEY`: The API key for authentication (if required)
|
||||
|
||||
You can also specify these values using command-line options:
|
||||
|
||||
```bash
|
||||
openhands team --url https://app.all-hands.dev --api-key your-api-key [command] [options]
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### List Conversations
|
||||
|
||||
List all available conversations:
|
||||
|
||||
```bash
|
||||
openhands team list [options]
|
||||
```
|
||||
|
||||
Options:
|
||||
- `-l, --limit`: Maximum number of conversations to list (default: 20)
|
||||
|
||||
### Create a Conversation
|
||||
|
||||
Create a new conversation:
|
||||
|
||||
```bash
|
||||
openhands team create [options]
|
||||
```
|
||||
|
||||
Options:
|
||||
- `-r, --repository`: Repository name (format: owner/repo)
|
||||
- `-g, --git-provider`: Git provider (github or gitlab)
|
||||
- `-b, --branch`: Branch name
|
||||
- `-m, --message`: Initial user message
|
||||
- `-i, --instructions`: Conversation instructions
|
||||
- `-j, --join`: Join the conversation after creation
|
||||
|
||||
### Join a Conversation
|
||||
|
||||
Join an existing conversation:
|
||||
|
||||
```bash
|
||||
openhands team join [conversation_id]
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
List all conversations:
|
||||
|
||||
```bash
|
||||
openhands team list
|
||||
```
|
||||
|
||||
Create a new conversation with a GitHub repository:
|
||||
|
||||
```bash
|
||||
openhands team create -r All-Hands-AI/OpenHands -m "Help me understand the codebase"
|
||||
```
|
||||
|
||||
Create a conversation and join it immediately:
|
||||
|
||||
```bash
|
||||
openhands team create -m "Let's build a web app" -j
|
||||
```
|
||||
|
||||
Join an existing conversation:
|
||||
|
||||
```bash
|
||||
openhands team join abc123def456
|
||||
```
|
||||
|
||||
## Using with a Remote Server
|
||||
|
||||
To use the Team CLI with a remote OpenHands server:
|
||||
|
||||
```bash
|
||||
export OPENHANDS_API_URL="https://app.all-hands.dev"
|
||||
export OPENHANDS_API_KEY="your-api-key"
|
||||
openhands team list
|
||||
```
|
||||
|
||||
Or specify the URL and API key directly:
|
||||
|
||||
```bash
|
||||
openhands team --url https://app.all-hands.dev --api-key your-api-key list
|
||||
```
|
||||
@@ -71,7 +71,6 @@ EVAL_CONDENSER=summarizer_for_eval \
|
||||
The name is up to you, but should match a name defined in your `config.toml` file. The last argument in the command specifies the condenser configuration to use. In this case, `summarizer_for_eval` is used, which refers to the LLM-based summarizing condenser as defined above.
|
||||
|
||||
If no condenser configuration is specified, the 'noop' condenser will be used by default, which keeps the full conversation history.
|
||||
```
|
||||
|
||||
For other configurations specific to evaluation, such as `save_trajectory_path`, these are typically set in the `get_config` function of the respective `run_infer.py` file for each benchmark.
|
||||
|
||||
|
||||
@@ -6,6 +6,21 @@ import { renderWithProviders } from "test-utils";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import SettingsScreen from "#/routes/settings";
|
||||
import { PaymentForm } from "#/components/features/payment/payment-form";
|
||||
import * as useSettingsModule from "#/hooks/query/use-settings";
|
||||
|
||||
// Mock the useSettings hook
|
||||
vi.mock("#/hooks/query/use-settings", async () => {
|
||||
const actual = await vi.importActual<typeof import("#/hooks/query/use-settings")>("#/hooks/query/use-settings");
|
||||
return {
|
||||
...actual,
|
||||
useSettings: vi.fn().mockReturnValue({
|
||||
data: {
|
||||
EMAIL_VERIFIED: true, // Mock email as verified to prevent redirection
|
||||
},
|
||||
isLoading: false,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the i18next hook
|
||||
vi.mock("react-i18next", async () => {
|
||||
@@ -20,6 +35,7 @@ vi.mock("react-i18next", async () => {
|
||||
"SETTINGS$NAV_CREDITS": "Credits",
|
||||
"SETTINGS$NAV_API_KEYS": "API Keys",
|
||||
"SETTINGS$NAV_LLM": "LLM",
|
||||
"SETTINGS$NAV_USER": "User",
|
||||
"SETTINGS$TITLE": "Settings"
|
||||
};
|
||||
return translations[key] || key;
|
||||
@@ -47,6 +63,10 @@ describe("Settings Billing", () => {
|
||||
Component: () => <div data-testid="git-settings-screen" />,
|
||||
path: "/settings/git",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="user-settings-screen" />,
|
||||
path: "/settings/user",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1,5 +1,60 @@
|
||||
import axios from "axios";
|
||||
import axios, { AxiosError, AxiosResponse } from "axios";
|
||||
|
||||
export const openHands = axios.create({
|
||||
baseURL: `${window.location.protocol}//${import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host}`,
|
||||
});
|
||||
|
||||
// Helper function to check if a response contains an email verification error
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const checkForEmailVerificationError = (data: any): boolean => {
|
||||
const EMAIL_NOT_VERIFIED = "EmailNotVerifiedError";
|
||||
|
||||
if (typeof data === "string") {
|
||||
return data.includes(EMAIL_NOT_VERIFIED);
|
||||
}
|
||||
|
||||
if (typeof data === "object" && data !== null) {
|
||||
if ("message" in data) {
|
||||
const { message } = data;
|
||||
if (typeof message === "string") {
|
||||
return message.includes(EMAIL_NOT_VERIFIED);
|
||||
}
|
||||
if (Array.isArray(message)) {
|
||||
return message.some(
|
||||
(msg) => typeof msg === "string" && msg.includes(EMAIL_NOT_VERIFIED),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Search any values in object in case message key is different
|
||||
return Object.values(data).some(
|
||||
(value) =>
|
||||
(typeof value === "string" && value.includes(EMAIL_NOT_VERIFIED)) ||
|
||||
(Array.isArray(value) &&
|
||||
value.some(
|
||||
(v) => typeof v === "string" && v.includes(EMAIL_NOT_VERIFIED),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Set up the global interceptor
|
||||
openHands.interceptors.response.use(
|
||||
(response: AxiosResponse) => response,
|
||||
(error: AxiosError) => {
|
||||
// Check if it's a 403 error with the email verification message
|
||||
if (
|
||||
error.response?.status === 403 &&
|
||||
checkForEmailVerificationError(error.response?.data)
|
||||
) {
|
||||
if (window.location.pathname !== "/settings/user") {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
// Continue with the error for other error handlers
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
|
||||
/**
|
||||
* A component that restricts access to routes based on email verification status.
|
||||
* If EMAIL_VERIFIED is false, only allows access to the /settings/user page.
|
||||
*/
|
||||
export function EmailVerificationGuard({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { data: settings, isLoading } = useSettings();
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
React.useEffect(() => {
|
||||
// If settings are still loading, don't do anything yet
|
||||
if (isLoading) return;
|
||||
|
||||
// If EMAIL_VERIFIED is explicitly false (not undefined or null)
|
||||
if (settings?.EMAIL_VERIFIED === false) {
|
||||
// Allow access to /settings/user but redirect from any other page
|
||||
if (pathname !== "/settings/user") {
|
||||
navigate("/settings/user", { replace: true });
|
||||
}
|
||||
}
|
||||
}, [settings?.EMAIL_VERIFIED, pathname, navigate, isLoading]);
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -69,16 +69,21 @@ export function Sidebar() {
|
||||
<div className="flex items-center justify-center">
|
||||
<AllHandsLogoButton />
|
||||
</div>
|
||||
<NewProjectButton />
|
||||
<NewProjectButton disabled={settings?.EMAIL_VERIFIED === false} />
|
||||
<ConversationPanelButton
|
||||
isOpen={conversationPanelIsOpen}
|
||||
onClick={() => setConversationPanelIsOpen((prev) => !prev)}
|
||||
onClick={() =>
|
||||
settings?.EMAIL_VERIFIED === false
|
||||
? null
|
||||
: setConversationPanelIsOpen((prev) => !prev)
|
||||
}
|
||||
disabled={settings?.EMAIL_VERIFIED === false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row md:flex-col md:items-center gap-[26px] md:mb-4">
|
||||
<DocsButton />
|
||||
<SettingsButton />
|
||||
<DocsButton disabled={settings?.EMAIL_VERIFIED === false} />
|
||||
<SettingsButton disabled={settings?.EMAIL_VERIFIED === false} />
|
||||
<UserActions
|
||||
user={
|
||||
user.data ? { avatar_url: user.data.avatar_url } : undefined
|
||||
|
||||
@@ -8,11 +8,13 @@ import { cn } from "#/utils/utils";
|
||||
interface ConversationPanelButtonProps {
|
||||
isOpen: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ConversationPanelButton({
|
||||
isOpen,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: ConversationPanelButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -22,10 +24,14 @@ export function ConversationPanelButton({
|
||||
tooltip={t(I18nKey.SIDEBAR$CONVERSATIONS)}
|
||||
ariaLabel={t(I18nKey.SIDEBAR$CONVERSATIONS)}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FaListUl
|
||||
size={22}
|
||||
className={cn(isOpen ? "text-white" : "text-[#9099AC]")}
|
||||
className={cn(
|
||||
isOpen ? "text-white" : "text-[#9099AC]",
|
||||
disabled && "opacity-50",
|
||||
)}
|
||||
/>
|
||||
</TooltipButton>
|
||||
);
|
||||
|
||||
@@ -3,15 +3,24 @@ import DocsIcon from "#/icons/academy.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { TooltipButton } from "./tooltip-button";
|
||||
|
||||
export function DocsButton() {
|
||||
interface DocsButtonProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function DocsButton({ disabled = false }: DocsButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<TooltipButton
|
||||
tooltip={t(I18nKey.SIDEBAR$DOCS)}
|
||||
ariaLabel={t(I18nKey.SIDEBAR$DOCS)}
|
||||
href="https://docs.all-hands.dev"
|
||||
disabled={disabled}
|
||||
>
|
||||
<DocsIcon width={28} height={28} className="text-[#9099AC]" />
|
||||
<DocsIcon
|
||||
width={28}
|
||||
height={28}
|
||||
className={`text-[#9099AC] ${disabled ? "opacity-50" : ""}`}
|
||||
/>
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
import PlusIcon from "#/icons/plus.svg?react";
|
||||
import { TooltipButton } from "./tooltip-button";
|
||||
|
||||
export function NewProjectButton() {
|
||||
interface NewProjectButtonProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function NewProjectButton({ disabled = false }: NewProjectButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const startNewProject = t(I18nKey.CONVERSATION$START_NEW);
|
||||
return (
|
||||
@@ -12,6 +16,7 @@ export function NewProjectButton() {
|
||||
ariaLabel={startNewProject}
|
||||
navLinkTo="/"
|
||||
testId="new-project-button"
|
||||
disabled={disabled}
|
||||
>
|
||||
<PlusIcon width={28} height={28} />
|
||||
</TooltipButton>
|
||||
|
||||
@@ -5,9 +5,13 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface SettingsButtonProps {
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function SettingsButton({ onClick }: SettingsButtonProps) {
|
||||
export function SettingsButton({
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: SettingsButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -17,6 +21,7 @@ export function SettingsButton({ onClick }: SettingsButtonProps) {
|
||||
ariaLabel={t(I18nKey.SETTINGS$TITLE)}
|
||||
onClick={onClick}
|
||||
navLinkTo="/settings"
|
||||
disabled={disabled}
|
||||
>
|
||||
<SettingsIcon width={28} height={28} />
|
||||
</TooltipButton>
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface TooltipButtonProps {
|
||||
ariaLabel: string;
|
||||
testId?: string;
|
||||
className?: React.HTMLAttributes<HTMLButtonElement>["className"];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function TooltipButton({
|
||||
@@ -23,9 +24,10 @@ export function TooltipButton({
|
||||
ariaLabel,
|
||||
testId,
|
||||
className,
|
||||
disabled = false,
|
||||
}: TooltipButtonProps) {
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (onClick) {
|
||||
if (onClick && !disabled) {
|
||||
onClick();
|
||||
e.preventDefault();
|
||||
}
|
||||
@@ -37,7 +39,12 @@ export function TooltipButton({
|
||||
aria-label={ariaLabel}
|
||||
data-testid={testId}
|
||||
onClick={handleClick}
|
||||
className={cn("hover:opacity-80", className)}
|
||||
className={cn(
|
||||
"hover:opacity-80",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
@@ -45,7 +52,7 @@ export function TooltipButton({
|
||||
|
||||
let content;
|
||||
|
||||
if (navLinkTo) {
|
||||
if (navLinkTo && !disabled) {
|
||||
content = (
|
||||
<NavLink
|
||||
to={navLinkTo}
|
||||
@@ -63,7 +70,24 @@ export function TooltipButton({
|
||||
{children}
|
||||
</NavLink>
|
||||
);
|
||||
} else if (href) {
|
||||
} else if (navLinkTo && disabled) {
|
||||
// If disabled and has navLinkTo, render a button that looks like a NavLink but doesn't navigate
|
||||
content = (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
data-testid={testId}
|
||||
className={cn(
|
||||
"text-[#9099AC]",
|
||||
"opacity-50 cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
disabled
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
} else if (href && !disabled) {
|
||||
content = (
|
||||
<a
|
||||
href={href}
|
||||
@@ -76,6 +100,19 @@ export function TooltipButton({
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
} else if (href && disabled) {
|
||||
// If disabled and has href, render a button that looks like a link but doesn't navigate
|
||||
content = (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
data-testid={testId}
|
||||
className={cn("opacity-50 cursor-not-allowed", className)}
|
||||
disabled
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
content = buttonContent;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ export const useActiveConversation = () => {
|
||||
useEffect(() => {
|
||||
const conversation = userConversation.data;
|
||||
OpenHands.setCurrentConversation(conversation || null);
|
||||
}, [conversationId, userConversation.isFetched]);
|
||||
}, [
|
||||
conversationId,
|
||||
userConversation.isFetched,
|
||||
userConversation?.data?.status,
|
||||
]);
|
||||
return userConversation;
|
||||
};
|
||||
|
||||
@@ -27,7 +27,8 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
|
||||
apiSettings.enable_proactive_conversation_starters,
|
||||
USER_CONSENTS_TO_ANALYTICS: apiSettings.user_consents_to_analytics,
|
||||
SEARCH_API_KEY: apiSettings.search_api_key || "",
|
||||
|
||||
EMAIL: apiSettings.email || "",
|
||||
EMAIL_VERIFIED: apiSettings.email_verified,
|
||||
MCP_CONFIG: apiSettings.mcp_config,
|
||||
IS_NEW_USER: false,
|
||||
};
|
||||
@@ -44,6 +45,7 @@ export const useSettings = () => {
|
||||
// would want to show the modal immediately if the
|
||||
// settings are not found
|
||||
retry: (_, error) => error.status !== 404,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
enabled: !isOnTosPage && !!userIsAuthenticated,
|
||||
|
||||
@@ -556,4 +556,19 @@ export enum I18nKey {
|
||||
TIPS$PROTIP = "TIPS$PROTIP",
|
||||
FEEDBACK$SUBMITTING_LABEL = "FEEDBACK$SUBMITTING_LABEL",
|
||||
FEEDBACK$SUBMITTING_MESSAGE = "FEEDBACK$SUBMITTING_MESSAGE",
|
||||
SETTINGS$NAV_USER = "SETTINGS$NAV_USER",
|
||||
SETTINGS$USER_TITLE = "SETTINGS$USER_TITLE",
|
||||
SETTINGS$USER_EMAIL = "SETTINGS$USER_EMAIL",
|
||||
SETTINGS$USER_EMAIL_LOADING = "SETTINGS$USER_EMAIL_LOADING",
|
||||
SETTINGS$SAVE = "SETTINGS$SAVE",
|
||||
SETTINGS$EMAIL_SAVED_SUCCESSFULLY = "SETTINGS$EMAIL_SAVED_SUCCESSFULLY",
|
||||
SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY = "SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY",
|
||||
SETTINGS$FAILED_TO_SAVE_EMAIL = "SETTINGS$FAILED_TO_SAVE_EMAIL",
|
||||
SETTINGS$SENDING = "SETTINGS$SENDING",
|
||||
SETTINGS$VERIFICATION_EMAIL_SENT = "SETTINGS$VERIFICATION_EMAIL_SENT",
|
||||
SETTINGS$EMAIL_VERIFICATION_REQUIRED = "SETTINGS$EMAIL_VERIFICATION_REQUIRED",
|
||||
SETTINGS$INVALID_EMAIL_FORMAT = "SETTINGS$INVALID_EMAIL_FORMAT",
|
||||
SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE = "SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE",
|
||||
SETTINGS$RESEND_VERIFICATION = "SETTINGS$RESEND_VERIFICATION",
|
||||
SETTINGS$FAILED_TO_RESEND_VERIFICATION = "SETTINGS$FAILED_TO_RESEND_VERIFICATION",
|
||||
}
|
||||
|
||||
@@ -8894,5 +8894,245 @@
|
||||
"tr": "Geri bildirim gönderiliyor, lütfen bekleyin...",
|
||||
"de": "Feedback senden, bitte warten...",
|
||||
"uk": "Відправляємо відгук, будь ласка, почекайте..."
|
||||
},
|
||||
"SETTINGS$NAV_USER": {
|
||||
"en": "User",
|
||||
"ja": "ユーザー",
|
||||
"zh-CN": "用户",
|
||||
"zh-TW": "用戶",
|
||||
"ko-KR": "사용자",
|
||||
"no": "Bruker",
|
||||
"it": "Utente",
|
||||
"pt": "Usuário",
|
||||
"es": "Usuario",
|
||||
"ar": "المستخدم",
|
||||
"fr": "Utilisateur",
|
||||
"tr": "Kullanıcı",
|
||||
"de": "Benutzer",
|
||||
"uk": "Користувач"
|
||||
},
|
||||
"SETTINGS$USER_TITLE": {
|
||||
"en": "User Information",
|
||||
"ja": "ユーザー情報",
|
||||
"zh-CN": "用户信息",
|
||||
"zh-TW": "用戶信息",
|
||||
"ko-KR": "사용자 정보",
|
||||
"no": "Brukerinformasjon",
|
||||
"it": "Informazioni utente",
|
||||
"pt": "Informações do usuário",
|
||||
"es": "Información del usuario",
|
||||
"ar": "معلومات المستخدم",
|
||||
"fr": "Informations utilisateur",
|
||||
"tr": "Kullanıcı Bilgileri",
|
||||
"de": "Benutzerinformationen",
|
||||
"uk": "Інформація про користувача"
|
||||
},
|
||||
"SETTINGS$USER_EMAIL": {
|
||||
"en": "Email",
|
||||
"ja": "メール",
|
||||
"zh-CN": "邮箱",
|
||||
"zh-TW": "郵箱",
|
||||
"ko-KR": "이메일",
|
||||
"no": "E-post",
|
||||
"it": "Email",
|
||||
"pt": "Email",
|
||||
"es": "Correo electrónico",
|
||||
"ar": "البريد الإلكتروني",
|
||||
"fr": "Email",
|
||||
"tr": "E-posta",
|
||||
"de": "E-Mail",
|
||||
"uk": "Електронна пошта"
|
||||
},
|
||||
"SETTINGS$USER_EMAIL_LOADING": {
|
||||
"en": "Loading...",
|
||||
"ja": "読み込み中...",
|
||||
"zh-CN": "加载中...",
|
||||
"zh-TW": "加載中...",
|
||||
"ko-KR": "로딩 중...",
|
||||
"no": "Laster...",
|
||||
"it": "Caricamento...",
|
||||
"pt": "Carregando...",
|
||||
"es": "Cargando...",
|
||||
"ar": "جار التحميل...",
|
||||
"fr": "Chargement...",
|
||||
"tr": "Yükleniyor...",
|
||||
"de": "Wird geladen...",
|
||||
"uk": "Завантаження..."
|
||||
},
|
||||
"SETTINGS$SAVE": {
|
||||
"en": "Save",
|
||||
"ja": "保存",
|
||||
"zh-CN": "保存",
|
||||
"zh-TW": "儲存",
|
||||
"ko-KR": "저장",
|
||||
"no": "Lagre",
|
||||
"it": "Salva",
|
||||
"pt": "Salvar",
|
||||
"es": "Guardar",
|
||||
"ar": "حفظ",
|
||||
"fr": "Enregistrer",
|
||||
"tr": "Kaydet",
|
||||
"de": "Speichern",
|
||||
"uk": "Зберегти"
|
||||
},
|
||||
"SETTINGS$EMAIL_SAVED_SUCCESSFULLY": {
|
||||
"en": "Email saved successfully",
|
||||
"ja": "メールが正常に保存されました",
|
||||
"zh-CN": "邮箱保存成功",
|
||||
"zh-TW": "郵箱儲存成功",
|
||||
"ko-KR": "이메일이 성공적으로 저장되었습니다",
|
||||
"no": "E-post lagret",
|
||||
"it": "Email salvata con successo",
|
||||
"pt": "Email salvo com sucesso",
|
||||
"es": "Correo electrónico guardado con éxito",
|
||||
"ar": "تم حفظ البريد الإلكتروني بنجاح",
|
||||
"fr": "Email enregistré avec succès",
|
||||
"tr": "E-posta başarıyla kaydedildi",
|
||||
"de": "E-Mail erfolgreich gespeichert",
|
||||
"uk": "Електронну пошту успішно збережено"
|
||||
},
|
||||
"SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY": {
|
||||
"en": "Your email has been verified successfully!",
|
||||
"ja": "メールアドレスの確認が完了しました!",
|
||||
"zh-CN": "您的邮箱已成功验证!",
|
||||
"zh-TW": "您的郵箱已成功驗證!",
|
||||
"ko-KR": "이메일이 성공적으로 인증되었습니다!",
|
||||
"no": "E-posten din er bekreftet!",
|
||||
"it": "La tua email è stata verificata con successo!",
|
||||
"pt": "Seu email foi verificado com sucesso!",
|
||||
"es": "¡Tu correo electrónico ha sido verificado con éxito!",
|
||||
"ar": "تم التحقق من بريدك الإلكتروني بنجاح!",
|
||||
"fr": "Votre email a été vérifié avec succès !",
|
||||
"tr": "E-postanız başarıyla doğrulandı!",
|
||||
"de": "Ihre E-Mail wurde erfolgreich verifiziert!",
|
||||
"uk": "Вашу електронну пошту успішно підтверджено!"
|
||||
},
|
||||
"SETTINGS$FAILED_TO_SAVE_EMAIL": {
|
||||
"en": "Failed to save email",
|
||||
"ja": "メールの保存に失敗しました",
|
||||
"zh-CN": "保存邮箱失败",
|
||||
"zh-TW": "儲存郵箱失敗",
|
||||
"ko-KR": "이메일 저장 실패",
|
||||
"no": "Kunne ikke lagre e-post",
|
||||
"it": "Impossibile salvare l'email",
|
||||
"pt": "Falha ao salvar email",
|
||||
"es": "Error al guardar el correo electrónico",
|
||||
"ar": "فشل في حفظ البريد الإلكتروني",
|
||||
"fr": "Échec de l'enregistrement de l'email",
|
||||
"tr": "E-posta kaydedilemedi",
|
||||
"de": "E-Mail konnte nicht gespeichert werden",
|
||||
"uk": "Не вдалося зберегти електронну пошту"
|
||||
},
|
||||
"SETTINGS$SENDING": {
|
||||
"en": "Sending",
|
||||
"ja": "送信中",
|
||||
"zh-CN": "发送中",
|
||||
"zh-TW": "發送中",
|
||||
"ko-KR": "전송 중",
|
||||
"no": "Sender",
|
||||
"it": "Invio in corso",
|
||||
"pt": "Enviando",
|
||||
"es": "Enviando",
|
||||
"ar": "جاري الإرسال",
|
||||
"fr": "Envoi en cours",
|
||||
"tr": "Gönderiliyor",
|
||||
"de": "Wird gesendet",
|
||||
"uk": "Надсилання"
|
||||
},
|
||||
"SETTINGS$VERIFICATION_EMAIL_SENT": {
|
||||
"en": "Verification email sent",
|
||||
"ja": "確認メールを送信しました",
|
||||
"zh-CN": "验证邮件已发送",
|
||||
"zh-TW": "驗證郵件已發送",
|
||||
"ko-KR": "인증 이메일이 전송되었습니다",
|
||||
"no": "Bekreftelsese-post sendt",
|
||||
"it": "Email di verifica inviata",
|
||||
"pt": "Email de verificação enviado",
|
||||
"es": "Correo de verificación enviado",
|
||||
"ar": "تم إرسال بريد التحقق",
|
||||
"fr": "Email de vérification envoyé",
|
||||
"tr": "Doğrulama e-postası gönderildi",
|
||||
"de": "Bestätigungs-E-Mail gesendet",
|
||||
"uk": "Лист підтвердження надіслано"
|
||||
},
|
||||
"SETTINGS$EMAIL_VERIFICATION_REQUIRED": {
|
||||
"en": "You must verify your email address before using All Hands",
|
||||
"ja": "All Handsを使用する前にメールアドレスを確認する必要があります",
|
||||
"zh-CN": "使用All Hands前,您必须验证您的电子邮件地址",
|
||||
"zh-TW": "使用All Hands前,您必須驗證您的電子郵件地址",
|
||||
"ko-KR": "All Hands를 사용하기 전에 이메일 주소를 확인해야 합니다",
|
||||
"no": "Du må bekrefte e-postadressen din før du bruker All Hands",
|
||||
"it": "Devi verificare il tuo indirizzo email prima di utilizzare All Hands",
|
||||
"pt": "Você deve verificar seu endereço de e-mail antes de usar o All Hands",
|
||||
"es": "Debe verificar su dirección de correo electrónico antes de usar All Hands",
|
||||
"ar": "يجب عليك التحقق من عنوان بريدك الإلكتروني قبل استخدام All Hands",
|
||||
"fr": "Vous devez vérifier votre adresse e-mail avant d'utiliser All Hands",
|
||||
"tr": "All Hands'i kullanmadan önce e-posta adresinizi doğrulamanız gerekiyor",
|
||||
"de": "Sie müssen Ihre E-Mail-Adresse bestätigen, bevor Sie All Hands verwenden können",
|
||||
"uk": "Ви повинні підтвердити свою електронну адресу перед використанням All Hands"
|
||||
},
|
||||
"SETTINGS$INVALID_EMAIL_FORMAT": {
|
||||
"en": "Please enter a valid email address",
|
||||
"ja": "有効なメールアドレスを入力してください",
|
||||
"zh-CN": "请输入有效的电子邮件地址",
|
||||
"zh-TW": "請輸入有效的電子郵件地址",
|
||||
"ko-KR": "유효한 이메일 주소를 입력하세요",
|
||||
"no": "Vennligst skriv inn en gyldig e-postadresse",
|
||||
"it": "Inserisci un indirizzo email valido",
|
||||
"pt": "Por favor, insira um endereço de e-mail válido",
|
||||
"es": "Por favor, introduzca una dirección de correo electrónico válida",
|
||||
"ar": "الرجاء إدخال عنوان بريد إلكتروني صالح",
|
||||
"fr": "Veuillez entrer une adresse e-mail valide",
|
||||
"tr": "Lütfen geçerli bir e-posta adresi girin",
|
||||
"de": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
||||
"uk": "Будь ласка, введіть дійсну електронну адресу"
|
||||
},
|
||||
"SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE": {
|
||||
"en": "Your access is limited until your email is verified. You can only access this settings page.",
|
||||
"ja": "メールが確認されるまでアクセスが制限されています。この設定ページにのみアクセスできます。",
|
||||
"zh-CN": "在验证您的电子邮件之前,您的访问权限受到限制。您只能访问此设置页面。",
|
||||
"zh-TW": "在驗證您的電子郵件之前,您的訪問權限受到限制。您只能訪問此設置頁面。",
|
||||
"ko-KR": "이메일이 확인될 때까지 액세스가 제한됩니다. 이 설정 페이지만 액세스할 수 있습니다.",
|
||||
"no": "Din tilgang er begrenset til e-posten din er bekreftet. Du kan bare få tilgang til denne innstillingssiden.",
|
||||
"it": "Il tuo accesso è limitato fino a quando la tua email non viene verificata. Puoi accedere solo a questa pagina delle impostazioni.",
|
||||
"pt": "Seu acesso é limitado até que seu e-mail seja verificado. Você só pode acessar esta página de configurações.",
|
||||
"es": "Su acceso es limitado hasta que se verifique su correo electrónico. Solo puede acceder a esta página de configuración.",
|
||||
"ar": "وصولك محدود حتى يتم التحقق من بريدك الإلكتروني. يمكنك فقط الوصول إلى صفحة الإعدادات هذه.",
|
||||
"fr": "Votre accès est limité jusqu'à ce que votre e-mail soit vérifié. Vous ne pouvez accéder qu'à cette page de paramètres.",
|
||||
"tr": "E-postanız doğrulanana kadar erişiminiz sınırlıdır. Yalnızca bu ayarlar sayfasına erişebilirsiniz.",
|
||||
"de": "Ihr Zugriff ist eingeschränkt, bis Ihre E-Mail-Adresse bestätigt wurde. Sie können nur auf diese Einstellungsseite zugreifen.",
|
||||
"uk": "Ваш доступ обмежений, доки ваша електронна пошта не буде підтверджена. Ви можете отримати доступ лише до цієї сторінки налаштувань."
|
||||
},
|
||||
"SETTINGS$RESEND_VERIFICATION": {
|
||||
"en": "Resend verification",
|
||||
"ja": "確認メールを再送信",
|
||||
"zh-CN": "重新发送验证",
|
||||
"zh-TW": "重新發送驗證",
|
||||
"ko-KR": "인증 재전송",
|
||||
"no": "Send bekreftelse på nytt",
|
||||
"it": "Rinvia verifica",
|
||||
"pt": "Reenviar verificação",
|
||||
"es": "Reenviar verificación",
|
||||
"ar": "إعادة إرسال التحقق",
|
||||
"fr": "Renvoyer la vérification",
|
||||
"tr": "Doğrulamayı yeniden gönder",
|
||||
"de": "Bestätigung erneut senden",
|
||||
"uk": "Надіслати підтвердження повторно"
|
||||
},
|
||||
"SETTINGS$FAILED_TO_RESEND_VERIFICATION": {
|
||||
"en": "Failed to resend verification email",
|
||||
"ja": "確認メールの再送信に失敗しました",
|
||||
"zh-CN": "重新发送验证邮件失败",
|
||||
"zh-TW": "重新發送驗證郵件失敗",
|
||||
"ko-KR": "인증 이메일 재전송 실패",
|
||||
"no": "Kunne ikke sende bekreftelsese-post på nytt",
|
||||
"it": "Impossibile rinviare l'email di verifica",
|
||||
"pt": "Falha ao reenviar email de verificação",
|
||||
"es": "Error al reenviar el correo de verificación",
|
||||
"ar": "فشل في إعادة إرسال بريد التحقق",
|
||||
"fr": "Échec du renvoi de l'email de vérification",
|
||||
"tr": "Doğrulama e-postası yeniden gönderilemedi",
|
||||
"de": "Bestätigungs-E-Mail konnte nicht erneut gesendet werden",
|
||||
"uk": "Не вдалося повторно надіслати лист підтвердження"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export default [
|
||||
route("settings", "routes/settings.tsx", [
|
||||
index("routes/llm-settings.tsx"),
|
||||
route("mcp", "routes/mcp-settings.tsx"),
|
||||
route("user", "routes/user-settings.tsx"),
|
||||
route("git", "routes/git-settings.tsx"),
|
||||
route("app", "routes/app-settings.tsx"),
|
||||
route("billing", "routes/billing.tsx"),
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
|
||||
import { useAutoLogin } from "#/hooks/use-auto-login";
|
||||
import { useAuthCallback } from "#/hooks/use-auth-callback";
|
||||
import { LOCAL_STORAGE_KEYS } from "#/utils/local-storage";
|
||||
import { EmailVerificationGuard } from "#/components/features/guards/email-verification-guard";
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
@@ -204,7 +205,9 @@ export default function MainApp() {
|
||||
id="root-outlet"
|
||||
className="h-[calc(100%-50px)] md:h-full w-full relative overflow-auto"
|
||||
>
|
||||
<Outlet />
|
||||
<EmailVerificationGuard>
|
||||
<Outlet />
|
||||
</EmailVerificationGuard>
|
||||
</div>
|
||||
|
||||
{renderAuthModal && (
|
||||
|
||||
@@ -15,6 +15,7 @@ function SettingsScreen() {
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
|
||||
const saasNavItems = [
|
||||
{ to: "/settings/user", text: t("SETTINGS$NAV_USER") },
|
||||
{ to: "/settings/git", text: t("SETTINGS$NAV_GIT") },
|
||||
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
|
||||
{ to: "/settings/billing", text: t("SETTINGS$NAV_CREDITS") },
|
||||
@@ -33,10 +34,11 @@ function SettingsScreen() {
|
||||
React.useEffect(() => {
|
||||
if (isSaas) {
|
||||
if (pathname === "/settings") {
|
||||
navigate("/settings/git");
|
||||
navigate("/settings/user");
|
||||
}
|
||||
} else {
|
||||
const noEnteringPaths = [
|
||||
"/settings/user",
|
||||
"/settings/billing",
|
||||
"/settings/credits",
|
||||
"/settings/api-keys",
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { openHands } from "#/api/open-hands-axios";
|
||||
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
|
||||
|
||||
// Email validation regex pattern
|
||||
const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||
|
||||
function EmailInputSection({
|
||||
email,
|
||||
onEmailChange,
|
||||
onSaveEmail,
|
||||
onResendVerification,
|
||||
isSaving,
|
||||
isResendingVerification,
|
||||
isEmailChanged,
|
||||
emailVerified,
|
||||
isEmailValid,
|
||||
children,
|
||||
}: {
|
||||
email: string;
|
||||
onEmailChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onSaveEmail: () => void;
|
||||
onResendVerification: () => void;
|
||||
isSaving: boolean;
|
||||
isResendingVerification: boolean;
|
||||
isEmailChanged: boolean;
|
||||
emailVerified?: boolean;
|
||||
isEmailValid: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm">{t("SETTINGS$USER_EMAIL")}</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={onEmailChange}
|
||||
className={`text-base text-white p-2 bg-base-tertiary rounded border ${
|
||||
isEmailChanged && !isEmailValid
|
||||
? "border-red-500"
|
||||
: "border-tertiary"
|
||||
} flex-grow focus:outline-none focus:border-transparent focus:ring-0`}
|
||||
placeholder={t("SETTINGS$USER_EMAIL_LOADING")}
|
||||
data-testid="email-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEmailChanged && !isEmailValid && (
|
||||
<div
|
||||
className="text-red-500 text-sm mt-1"
|
||||
data-testid="email-validation-error"
|
||||
>
|
||||
{t("SETTINGS$INVALID_EMAIL_FORMAT")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSaveEmail}
|
||||
disabled={!isEmailChanged || isSaving || !isEmailValid}
|
||||
className="px-4 py-2 rounded bg-primary text-white hover:opacity-80 disabled:opacity-30 disabled:cursor-not-allowed disabled:text-[#0D0F11]"
|
||||
data-testid="save-email-button"
|
||||
>
|
||||
{isSaving ? t("SETTINGS$SAVING") : t("SETTINGS$SAVE")}
|
||||
</button>
|
||||
|
||||
{emailVerified === false && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResendVerification}
|
||||
disabled={isResendingVerification}
|
||||
className="px-4 py-2 rounded bg-primary text-white hover:opacity-80 disabled:opacity-30 disabled:cursor-not-allowed disabled:text-[#0D0F11]"
|
||||
data-testid="resend-verification-button"
|
||||
>
|
||||
{isResendingVerification
|
||||
? t("SETTINGS$SENDING")
|
||||
: t("SETTINGS$RESEND_VERIFICATION")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VerificationAlert() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mt-4"
|
||||
role="alert"
|
||||
>
|
||||
<p className="font-bold">{t("SETTINGS$EMAIL_VERIFICATION_REQUIRED")}</p>
|
||||
<p className="text-sm">
|
||||
{t("SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// These components have been replaced with toast notifications
|
||||
|
||||
function UserSettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { data: settings, isLoading, refetch } = useSettings();
|
||||
const [email, setEmail] = useState("");
|
||||
const [originalEmail, setOriginalEmail] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isResendingVerification, setIsResendingVerification] = useState(false);
|
||||
const [isEmailValid, setIsEmailValid] = useState(true);
|
||||
const queryClient = useQueryClient();
|
||||
const pollingIntervalRef = useRef<number | null>(null);
|
||||
const prevVerificationStatusRef = useRef<boolean | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings?.EMAIL) {
|
||||
setEmail(settings.EMAIL);
|
||||
setOriginalEmail(settings.EMAIL);
|
||||
setIsEmailValid(EMAIL_REGEX.test(settings.EMAIL));
|
||||
}
|
||||
}, [settings?.EMAIL]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pollingIntervalRef.current) {
|
||||
window.clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
|
||||
if (
|
||||
prevVerificationStatusRef.current === false &&
|
||||
settings?.EMAIL_VERIFIED === true
|
||||
) {
|
||||
// Display toast notification instead of setting state
|
||||
displaySuccessToast(t("SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY"));
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
prevVerificationStatusRef.current = settings?.EMAIL_VERIFIED;
|
||||
|
||||
if (settings?.EMAIL_VERIFIED === false) {
|
||||
pollingIntervalRef.current = window.setInterval(() => {
|
||||
refetch();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (pollingIntervalRef.current) {
|
||||
window.clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [settings?.EMAIL_VERIFIED, refetch, queryClient, t]);
|
||||
|
||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newEmail = e.target.value;
|
||||
setEmail(newEmail);
|
||||
setIsEmailValid(EMAIL_REGEX.test(newEmail));
|
||||
};
|
||||
|
||||
const handleSaveEmail = async () => {
|
||||
if (email === originalEmail || !isEmailValid) return;
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await openHands.post("/api/email", { email }, { withCredentials: true });
|
||||
setOriginalEmail(email);
|
||||
// Display toast notification instead of setting state
|
||||
displaySuccessToast(t("SETTINGS$EMAIL_SAVED_SUCCESSFULLY"));
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(t("SETTINGS$FAILED_TO_SAVE_EMAIL"), error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendVerification = async () => {
|
||||
try {
|
||||
setIsResendingVerification(true);
|
||||
await openHands.put("/api/email/verify", {}, { withCredentials: true });
|
||||
// Display toast notification instead of setting state
|
||||
displaySuccessToast(t("SETTINGS$VERIFICATION_EMAIL_SENT"));
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(t("SETTINGS$FAILED_TO_RESEND_VERIFICATION"), error);
|
||||
} finally {
|
||||
setIsResendingVerification(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isEmailChanged = email !== originalEmail;
|
||||
|
||||
return (
|
||||
<div data-testid="user-settings-screen" className="flex flex-col h-full">
|
||||
<div className="p-9 flex flex-col gap-6">
|
||||
{isLoading ? (
|
||||
<div className="animate-pulse h-8 w-64 bg-tertiary rounded" />
|
||||
) : (
|
||||
<EmailInputSection
|
||||
email={email}
|
||||
onEmailChange={handleEmailChange}
|
||||
onSaveEmail={handleSaveEmail}
|
||||
onResendVerification={handleResendVerification}
|
||||
isSaving={isSaving}
|
||||
isResendingVerification={isResendingVerification}
|
||||
isEmailChanged={isEmailChanged}
|
||||
emailVerified={settings?.EMAIL_VERIFIED}
|
||||
isEmailValid={isEmailValid}
|
||||
>
|
||||
{settings?.EMAIL_VERIFIED === false && <VerificationAlert />}
|
||||
</EmailInputSection>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserSettingsScreen;
|
||||
@@ -19,6 +19,8 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
ENABLE_PROACTIVE_CONVERSATION_STARTERS: false,
|
||||
SEARCH_API_KEY: "",
|
||||
IS_NEW_USER: true,
|
||||
EMAIL: "",
|
||||
EMAIL_VERIFIED: true, // Default to true to avoid restricting access unnecessarily
|
||||
MCP_CONFIG: {
|
||||
sse_servers: [],
|
||||
stdio_servers: [],
|
||||
|
||||
@@ -45,6 +45,8 @@ export type Settings = {
|
||||
SEARCH_API_KEY?: string;
|
||||
IS_NEW_USER?: boolean;
|
||||
MCP_CONFIG?: MCPConfig;
|
||||
EMAIL?: string;
|
||||
EMAIL_VERIFIED?: boolean;
|
||||
};
|
||||
|
||||
export type ApiSettings = {
|
||||
@@ -68,6 +70,8 @@ export type ApiSettings = {
|
||||
sse_servers: (string | MCPSSEServer)[];
|
||||
stdio_servers: MCPStdioServer[];
|
||||
};
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
};
|
||||
|
||||
export type PostSettings = Settings & {
|
||||
|
||||
@@ -30,7 +30,7 @@ export const TIPS: Tip[] = [
|
||||
},
|
||||
{
|
||||
key: I18nKey.TIPS$GITHUB_HOOK,
|
||||
link: "https://docs.all-hands.dev/usage/cloud/cloud-issue-resolver",
|
||||
link: "https://docs.all-hands.dev/usage/cloud/github-installation#working-on-github-issues-and-pull-requests-using-openhands",
|
||||
},
|
||||
{
|
||||
key: I18nKey.TIPS$BLOG_SIGNUP,
|
||||
|
||||
@@ -117,7 +117,10 @@ You can see an example of a repo agent in [the agent for the OpenHands repo itse
|
||||
- Include repository structure details
|
||||
- Specify testing and build procedures
|
||||
- List environment requirements
|
||||
- Document CI workflows and checks
|
||||
- Include information about code quality standards
|
||||
- Maintain up-to-date team practices
|
||||
- Consider using OpenHands to generate a comprehensive repo.md (see [Creating a Repository Agent](#creating-a-repository-agent))
|
||||
- YAML frontmatter is optional - files without frontmatter will be loaded with default settings
|
||||
|
||||
### Submission Process
|
||||
|
||||
+13
-16
@@ -1,20 +1,17 @@
|
||||
---
|
||||
name: add_agent
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- new agent
|
||||
- new microagent
|
||||
- create agent
|
||||
- create an agent
|
||||
- create microagent
|
||||
- create a microagent
|
||||
- add agent
|
||||
- add an agent
|
||||
- add microagent
|
||||
- add a microagent
|
||||
- microagent template
|
||||
- new agent
|
||||
- new microagent
|
||||
- create agent
|
||||
- create an agent
|
||||
- create microagent
|
||||
- create a microagent
|
||||
- add agent
|
||||
- add an agent
|
||||
- add microagent
|
||||
- add a microagent
|
||||
- microagent template
|
||||
type: knowledge
|
||||
---
|
||||
|
||||
This agent helps create new microagents in the `.openhands/microagents` directory by providing guidance and templates.
|
||||
@@ -38,4 +35,4 @@ 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)
|
||||
- [Example GitHub Microagent](https://github.com/All-Hands-AI/OpenHands/blob/main/microagents/github.md)
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
inputs:
|
||||
- description: Branch for the agent to work on
|
||||
name: REPO_FOLDER_NAME
|
||||
triggers:
|
||||
- /add_repo_inst
|
||||
---
|
||||
|
||||
Please browse the current repository under /workspace/{{ REPO_FOLDER_NAME }}, look at the documentation and relevant code, and understand the purpose of this repository.
|
||||
|
||||
Specifically, I want you to create a `.openhands/microagents/repo.md` file. This file should contain succinct information that summarizes (1) the purpose of this repository, (2) the general setup of this repo, and (3) a brief description of the structure of this repo.
|
||||
|
||||
Here's an example:
|
||||
```markdown
|
||||
---
|
||||
name: repo
|
||||
type: repo
|
||||
agent: CodeActAgent
|
||||
---
|
||||
|
||||
This repository contains the code for OpenHands, an automated AI software engineer. It has a Python backend
|
||||
(in the `openhands` directory) and React frontend (in the `frontend` directory).
|
||||
|
||||
## General Setup:
|
||||
To set up the entire repo, including frontend and backend, run `make build`.
|
||||
You don't need to do this unless the user asks you to, or if you're trying to run the entire application.
|
||||
|
||||
Before pushing any changes, you should ensure that any lint errors or simple test errors have been fixed.
|
||||
|
||||
* If you've made changes to the backend, you should run `pre-commit run --all-files --config ./dev_config/python/.pre-commit-config.yaml`
|
||||
* If you've made changes to the frontend, you should run `cd frontend && npm run lint:fix && npm run build ; cd ..`
|
||||
|
||||
If either command fails, it may have automatically fixed some issues. You should fix any issues that weren't automatically fixed,
|
||||
then re-run the command to ensure it passes.
|
||||
|
||||
## Repository Structure
|
||||
Backend:
|
||||
- Located in the `openhands` directory
|
||||
- Testing:
|
||||
- All tests are in `tests/unit/test_*.py`
|
||||
- To test new code, run `poetry run pytest tests/unit/test_xxx.py` where `xxx` is the appropriate file for the current functionality
|
||||
- Write all tests with pytest
|
||||
|
||||
Frontend:
|
||||
- Located in the `frontend` directory
|
||||
- Prerequisites: A recent version of NodeJS / NPM
|
||||
- Setup: Run `npm install` in the frontend directory
|
||||
- Testing:
|
||||
- Run tests: `npm run test`
|
||||
- To run specific tests: `npm run test -- -t "TestName"`
|
||||
- Building:
|
||||
- Build for production: `npm run build`
|
||||
- Environment Variables:
|
||||
- Set in `frontend/.env` or as environment variables
|
||||
- Available variables: VITE_BACKEND_HOST, VITE_USE_TLS, VITE_INSECURE_SKIP_VERIFY, VITE_FRONTEND_PORT
|
||||
- Internationalization:
|
||||
- Generate i18n declaration file: `npm run make-i18n`
|
||||
```
|
||||
|
||||
Now, please write a similar markdown for the current repository.
|
||||
Read all the GitHub workflows under .github/ of the repository (if this folder exists) to understand the CI checks (e.g., linter, pre-commit), and include those in the repo.md file.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
inputs:
|
||||
- description: URL of the pull request
|
||||
name: PR_URL
|
||||
- description: Branch name corresponds to the pull request
|
||||
name: BRANCH_NAME
|
||||
triggers:
|
||||
- /address_pr_comments
|
||||
---
|
||||
|
||||
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.
|
||||
@@ -1,10 +1,7 @@
|
||||
---
|
||||
name: agent_memory
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- /remember
|
||||
type: knowledge
|
||||
---
|
||||
|
||||
* Repository memory: Use .openhands/microagents/repo.md under each repository root to store and access important information.
|
||||
@@ -29,4 +26,4 @@ triggers:
|
||||
- If you've only explored a portion of the codebase, clearly note this limitation in the repository structure documentation
|
||||
- If you don't know the essential commands for working with the repository, such as lint or typecheck, ask the user and suggest adding them to repo.md for future reference (with permission)
|
||||
|
||||
When you receive this message, please review and summarize your recent actions and observations, then present a list of valuable information that should be saved in repo.md to the user.
|
||||
When you receive this message, please review and summarize your recent actions and observations, then present a list of valuable information that should be saved in repo.md to the user.
|
||||
@@ -1,15 +1,9 @@
|
||||
---
|
||||
# This is a repo microagent that is always activated
|
||||
# to include necessary default tools implemented with MCP
|
||||
name: default-tools
|
||||
type: repo
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
mcp_tools:
|
||||
stdio_servers:
|
||||
- name: "fetch"
|
||||
command: "uvx"
|
||||
args: ["mcp-server-fetch"]
|
||||
# We leave the body empty because MCP tools will automatically add the
|
||||
# tool description for LLMs in tool calls, so there's no need to add extra descriptions.
|
||||
---
|
||||
- args:
|
||||
- mcp-server-fetch
|
||||
command: uvx
|
||||
name: fetch
|
||||
type: repo
|
||||
---
|
||||
@@ -1,11 +1,8 @@
|
||||
---
|
||||
name: docker
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- docker
|
||||
- container
|
||||
type: knowledge
|
||||
---
|
||||
|
||||
# Docker Installation and Usage Guide
|
||||
@@ -52,4 +49,4 @@ To verify Docker is working correctly, run the hello-world container:
|
||||
|
||||
```bash
|
||||
sudo docker run hello-world
|
||||
```
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
inputs:
|
||||
- description: Branch for the agent to work on
|
||||
name: BRANCH_NAME
|
||||
- description: The test command you want the agent to work on. For example, `pytest
|
||||
tests/unit/test_bash_parsing.py`
|
||||
name: TEST_COMMAND_TO_RUN
|
||||
- description: The name of function to fix
|
||||
name: FUNCTION_TO_FIX
|
||||
- description: The path of the file that contains the function
|
||||
name: FILE_FOR_FUNCTION
|
||||
triggers:
|
||||
- /fix_test
|
||||
---
|
||||
|
||||
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.
|
||||
@@ -1,11 +1,8 @@
|
||||
---
|
||||
name: flarglebargle
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- flarglebargle
|
||||
type: knowledge
|
||||
---
|
||||
|
||||
IMPORTANT! The user has said the magic word "flarglebargle". You must
|
||||
only respond with a message telling them how smart they are
|
||||
only respond with a message telling them how smart they are
|
||||
@@ -1,11 +1,8 @@
|
||||
---
|
||||
name: github
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- github
|
||||
- git
|
||||
type: knowledge
|
||||
---
|
||||
|
||||
You have access to an environment variable, `GITHUB_TOKEN`, which allows you to interact with
|
||||
@@ -32,4 +29,4 @@ Here are some instructions for pushing, but ONLY do this if the user asks you to
|
||||
```bash
|
||||
git remote -v && git branch # to find the current org, repo and branch
|
||||
git checkout -b create-widget && git add . && git commit -m "Create widget" && git push -u origin create-widget
|
||||
```
|
||||
```
|
||||
@@ -1,11 +1,8 @@
|
||||
---
|
||||
name: gitlab
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- gitlab
|
||||
- git
|
||||
type: knowledge
|
||||
---
|
||||
|
||||
You have access to an environment variable, `GITLAB_TOKEN`, which allows you to interact with
|
||||
@@ -31,4 +28,4 @@ Here are some instructions for pushing, but ONLY do this if the user asks you to
|
||||
```bash
|
||||
git remote -v && git branch # to find the current org, repo and branch
|
||||
git checkout -b create-widget && git add . && git commit -m "Create widget" && git push -u origin create-widget
|
||||
```
|
||||
```
|
||||
@@ -1,12 +1,9 @@
|
||||
---
|
||||
name: kubernetes
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- kubernetes
|
||||
- k8s
|
||||
- kube
|
||||
type: knowledge
|
||||
---
|
||||
|
||||
# Kubernetes Local Development with KIND
|
||||
@@ -47,4 +44,4 @@ Create a basic KIND cluster:
|
||||
|
||||
```bash
|
||||
kind create cluster
|
||||
```
|
||||
```
|
||||
+2
-5
@@ -1,11 +1,8 @@
|
||||
---
|
||||
name: npm
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- npm
|
||||
type: knowledge
|
||||
---
|
||||
|
||||
When using npm to install packages, you will not be able to use an interactive shell, and it may be hard to confirm your actions.
|
||||
As an alternative, you can pipe in the output of the unix "yes" command to confirm your actions.
|
||||
As an alternative, you can pipe in the output of the unix "yes" command to confirm your actions.
|
||||
@@ -1,10 +1,7 @@
|
||||
---
|
||||
name: pdflatex
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- pdflatex
|
||||
type: knowledge
|
||||
---
|
||||
|
||||
PdfLatex is a tool that converts Latex sources into PDF. This is specifically very important for researchers, as they use it to publish their findings. It could be installed very easily using Linux terminal, though this seems an annoying task on Windows. Installation commands are given below.
|
||||
@@ -33,4 +30,4 @@ Once installed as above, you may be able to create PDF files from latex sources
|
||||
pdflatex latex_source_name.tex
|
||||
```
|
||||
|
||||
Ref: http://kkpradeeban.blogspot.com/2014/04/installing-latexpdflatex-on-ubuntu.html
|
||||
Ref: http://kkpradeeban.blogspot.com/2014/04/installing-latexpdflatex-on-ubuntu.html
|
||||
+8
-10
@@ -1,15 +1,13 @@
|
||||
---
|
||||
name: security
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- security
|
||||
- vulnerability
|
||||
- authentication
|
||||
- authorization
|
||||
- permissions
|
||||
- security
|
||||
- vulnerability
|
||||
- authentication
|
||||
- authorization
|
||||
- permissions
|
||||
type: knowledge
|
||||
---
|
||||
|
||||
This document provides guidance on security best practices
|
||||
|
||||
You should always be considering security implications when developing.
|
||||
@@ -31,4 +29,4 @@ You should always complete the task requested. If there are security concerns pl
|
||||
- Never expose sensitive information in error messages
|
||||
- Log security events appropriately
|
||||
- Implement proper exception handling
|
||||
- Use secure error reporting mechanisms
|
||||
- Use secure error reporting mechanisms
|
||||
+9
-12
@@ -1,16 +1,13 @@
|
||||
---
|
||||
name: SSH Microagent
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- ssh
|
||||
- remote server
|
||||
- remote machine
|
||||
- remote host
|
||||
- remote connection
|
||||
- secure shell
|
||||
- ssh keys
|
||||
- ssh
|
||||
- remote server
|
||||
- remote machine
|
||||
- remote host
|
||||
- remote connection
|
||||
- secure shell
|
||||
- ssh keys
|
||||
type: knowledge
|
||||
---
|
||||
|
||||
# SSH Microagent
|
||||
@@ -134,4 +131,4 @@ chmod 600 ~/.ssh/id_ed25519
|
||||
chmod 644 ~/.ssh/id_ed25519.pub
|
||||
# Set correct permissions for SSH directory
|
||||
chmod 700 ~/.ssh
|
||||
```
|
||||
```
|
||||
@@ -1,12 +1,9 @@
|
||||
---
|
||||
name: swift-linux
|
||||
type: knowledge
|
||||
agent: CodeActAgent
|
||||
version: 1.0.0
|
||||
triggers:
|
||||
- swift-linux
|
||||
- swift-debian
|
||||
- swift-installation
|
||||
triggers:
|
||||
- swift-linux
|
||||
- swift-debian
|
||||
- swift-installation
|
||||
type: knowledge
|
||||
---
|
||||
|
||||
# Swift Installation Guide for Debian Linux
|
||||
@@ -80,4 +77,4 @@ Verify that Swift is correctly installed by running:
|
||||
|
||||
```bash
|
||||
swift --version
|
||||
```
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
inputs:
|
||||
- description: URL of the pull request
|
||||
name: PR_URL
|
||||
type: string
|
||||
validation:
|
||||
pattern: ^https://github.com/.+/.+/pull/[0-9]+$
|
||||
- description: Branch name corresponds to the pull request
|
||||
name: BRANCH_NAME
|
||||
type: string
|
||||
triggers:
|
||||
- /update_pr_description
|
||||
---
|
||||
|
||||
Please check the branch "{{ BRANCH_NAME }}" and look at the diff against the main branch. This branch belongs to this PR "{{ PR_URL }}".
|
||||
|
||||
Once you understand the purpose of the diff, please use Github API to read the existing PR description, and update it to be more reflective of the changes we've made when necessary.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
inputs:
|
||||
- description: Branch for the agent to work on
|
||||
name: BRANCH_NAME
|
||||
- description: The test command you want the agent to work on. For example, `pytest
|
||||
tests/unit/test_bash_parsing.py`
|
||||
name: TEST_COMMAND_TO_RUN
|
||||
triggers:
|
||||
- /update_test
|
||||
---
|
||||
|
||||
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.
|
||||
+1
-31
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from prompt_toolkit import print_formatted_text
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
@@ -453,37 +454,6 @@ async def main_with_loop(loop: asyncio.AbstractEventLoop) -> None:
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_arguments()
|
||||
|
||||
# Check if team command is used
|
||||
if hasattr(args, 'command') and args.command == 'team':
|
||||
# Import and run the team CLI directly
|
||||
import sys
|
||||
|
||||
from openhands.cli.team import main as team_main
|
||||
|
||||
# Get arguments after 'team'
|
||||
team_args = []
|
||||
if len(sys.argv) > 2:
|
||||
# Pass all arguments after 'team'
|
||||
team_args = sys.argv[2:]
|
||||
|
||||
if not team_args:
|
||||
# If no additional arguments, show help message
|
||||
print('OpenHands Team CLI')
|
||||
print('=================')
|
||||
print('To use the team CLI, run one of the following commands:')
|
||||
print(' openhands team list - List all conversations')
|
||||
print(' openhands team create - Create a new conversation')
|
||||
print(' openhands team join <id> - Join an existing conversation')
|
||||
print()
|
||||
print("For more information, run 'openhands team --help'")
|
||||
return
|
||||
|
||||
# Run the team CLI with the arguments
|
||||
team_main(team_args)
|
||||
return
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
|
||||
@@ -1,549 +0,0 @@
|
||||
"""Team CLI interface for OpenHands.
|
||||
|
||||
This module provides a CLI interface for interacting with the OpenHands HTTP and WebSocket APIs.
|
||||
It allows creating conversations and showing the current list of conversations/statuses.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
import aiohttp
|
||||
import socketio
|
||||
from prompt_toolkit import print_formatted_text
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
from prompt_toolkit.shortcuts import clear
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from openhands.cli.tui import (
|
||||
display_banner,
|
||||
display_event,
|
||||
display_welcome_message,
|
||||
read_prompt_input,
|
||||
)
|
||||
from openhands.core.schema import AgentState
|
||||
from openhands.events.action import MessageAction
|
||||
from openhands.events.serialization import event_from_dict, event_to_dict
|
||||
|
||||
|
||||
class TeamClient:
|
||||
"""Client for interacting with the OpenHands HTTP and WebSocket APIs."""
|
||||
|
||||
def __init__(self, base_url: str, api_key: Optional[str] = None):
|
||||
"""Initialize the TeamClient.
|
||||
|
||||
Args:
|
||||
base_url: The base URL for the OpenHands API.
|
||||
api_key: Optional API key for authentication.
|
||||
"""
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.api_key = api_key
|
||||
self.sio = socketio.AsyncClient()
|
||||
self.console = Console()
|
||||
self.headers = {}
|
||||
if api_key:
|
||||
self.headers['Authorization'] = f'Bearer {api_key}'
|
||||
|
||||
async def list_conversations(self, limit: int = 20) -> list[dict[str, Any]]:
|
||||
"""List conversations.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of conversations to return.
|
||||
|
||||
Returns:
|
||||
List of conversation objects.
|
||||
"""
|
||||
async with aiohttp.ClientSession(headers=self.headers) as session:
|
||||
async with session.get(
|
||||
f'{self.base_url}/api/conversations?limit={limit}'
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
raise Exception(f'Failed to list conversations: {error_text}')
|
||||
data = await response.json()
|
||||
return data.get('results', [])
|
||||
|
||||
async def create_conversation(
|
||||
self,
|
||||
repository: Optional[str] = None,
|
||||
git_provider: Optional[str] = None,
|
||||
selected_branch: Optional[str] = None,
|
||||
initial_user_msg: Optional[str] = None,
|
||||
conversation_instructions: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Create a new conversation.
|
||||
|
||||
Args:
|
||||
repository: Optional repository name (owner/repo).
|
||||
git_provider: Optional git provider (github or gitlab).
|
||||
selected_branch: Optional branch name.
|
||||
initial_user_msg: Optional initial user message.
|
||||
conversation_instructions: Optional conversation instructions.
|
||||
|
||||
Returns:
|
||||
The conversation ID.
|
||||
"""
|
||||
payload = {
|
||||
'repository': repository,
|
||||
'git_provider': git_provider,
|
||||
'selected_branch': selected_branch,
|
||||
'initial_user_msg': initial_user_msg,
|
||||
'conversation_instructions': conversation_instructions,
|
||||
}
|
||||
# Remove None values
|
||||
payload = {k: v for k, v in payload.items() if v is not None}
|
||||
|
||||
async with aiohttp.ClientSession(headers=self.headers) as session:
|
||||
async with session.post(
|
||||
f'{self.base_url}/api/conversations', json=payload
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
raise Exception(f'Failed to create conversation: {error_text}')
|
||||
data = await response.json()
|
||||
return data.get('conversation_id')
|
||||
|
||||
async def get_conversation(self, conversation_id: str) -> dict[str, Any]:
|
||||
"""Get conversation details.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID.
|
||||
|
||||
Returns:
|
||||
Conversation details.
|
||||
"""
|
||||
async with aiohttp.ClientSession(headers=self.headers) as session:
|
||||
async with session.get(
|
||||
f'{self.base_url}/api/conversations/{conversation_id}'
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
raise Exception(f'Failed to get conversation: {error_text}')
|
||||
return await response.json()
|
||||
|
||||
async def connect_to_conversation(
|
||||
self, conversation_id: str, latest_event_id: int = -1
|
||||
) -> None:
|
||||
"""Connect to a conversation via WebSocket.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID.
|
||||
latest_event_id: The latest event ID to start from.
|
||||
"""
|
||||
|
||||
# Set up event handlers
|
||||
@self.sio.event
|
||||
async def connect():
|
||||
self.console.print('[green]Connected to conversation[/green]')
|
||||
|
||||
@self.sio.event
|
||||
async def disconnect():
|
||||
self.console.print('[yellow]Disconnected from conversation[/yellow]')
|
||||
|
||||
@self.sio.event
|
||||
async def oh_event(data):
|
||||
event = event_from_dict(data)
|
||||
# Create a dummy config object to satisfy the type checker
|
||||
from openhands.core.config import OpenHandsConfig
|
||||
|
||||
dummy_config = OpenHandsConfig()
|
||||
display_event(event, dummy_config)
|
||||
|
||||
# Connect to the WebSocket
|
||||
query = {
|
||||
'conversation_id': conversation_id,
|
||||
'latest_event_id': str(latest_event_id),
|
||||
}
|
||||
if self.api_key:
|
||||
query['session_api_key'] = self.api_key
|
||||
|
||||
await self.sio.connect(
|
||||
f'{self.base_url}',
|
||||
headers=self.headers,
|
||||
transports=['websocket'],
|
||||
socketio_path='socket.io',
|
||||
wait_timeout=10,
|
||||
query=query,
|
||||
)
|
||||
|
||||
async def send_message(self, message: str) -> None:
|
||||
"""Send a message to the conversation.
|
||||
|
||||
Args:
|
||||
message: The message to send.
|
||||
"""
|
||||
event = MessageAction(content=message)
|
||||
event_dict = event_to_dict(event)
|
||||
await self.sio.emit('oh_user_action', event_dict)
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from the WebSocket."""
|
||||
await self.sio.disconnect()
|
||||
|
||||
|
||||
async def list_conversations_cmd(client: TeamClient, args: argparse.Namespace) -> None:
|
||||
"""List conversations command.
|
||||
|
||||
Args:
|
||||
client: The TeamClient instance.
|
||||
args: Command line arguments.
|
||||
"""
|
||||
conversations = await client.list_conversations(limit=args.limit)
|
||||
|
||||
if not conversations:
|
||||
print('No conversations found.')
|
||||
return
|
||||
|
||||
table = Table(title='Conversations')
|
||||
table.add_column('ID', style='cyan')
|
||||
table.add_column('Title', style='green')
|
||||
table.add_column('Status', style='magenta')
|
||||
table.add_column('Repository', style='blue')
|
||||
table.add_column('Last Updated', style='yellow')
|
||||
table.add_column('Created', style='yellow')
|
||||
|
||||
for convo in conversations:
|
||||
# Format dates
|
||||
created_at = datetime.fromisoformat(convo['created_at'].replace('Z', '+00:00'))
|
||||
last_updated_at = datetime.fromisoformat(
|
||||
convo['last_updated_at'].replace('Z', '+00:00')
|
||||
)
|
||||
|
||||
created_str = created_at.strftime('%Y-%m-%d %H:%M:%S')
|
||||
updated_str = last_updated_at.strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# Add row to table
|
||||
table.add_row(
|
||||
convo['conversation_id'],
|
||||
convo['title'],
|
||||
convo['status'],
|
||||
convo.get('selected_repository', ''),
|
||||
updated_str,
|
||||
created_str,
|
||||
)
|
||||
|
||||
client.console.print(table)
|
||||
|
||||
|
||||
async def create_conversation_cmd(client: TeamClient, args: argparse.Namespace) -> None:
|
||||
"""Create a conversation command.
|
||||
|
||||
Args:
|
||||
client: The TeamClient instance.
|
||||
args: Command line arguments.
|
||||
"""
|
||||
initial_message = args.message
|
||||
|
||||
# If no message provided, prompt for one
|
||||
if not initial_message:
|
||||
print_formatted_text(HTML('<green>Enter your initial message:</green>'))
|
||||
initial_message = input('> ')
|
||||
|
||||
try:
|
||||
conversation_id = await client.create_conversation(
|
||||
repository=args.repository,
|
||||
git_provider=args.git_provider,
|
||||
selected_branch=args.branch,
|
||||
initial_user_msg=initial_message,
|
||||
conversation_instructions=args.instructions,
|
||||
)
|
||||
|
||||
print_formatted_text(
|
||||
HTML(f'<green>Conversation created with ID: {conversation_id}</green>')
|
||||
)
|
||||
|
||||
if args.join:
|
||||
await join_conversation_cmd(
|
||||
client, argparse.Namespace(conversation_id=conversation_id)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print_formatted_text(HTML(f'<red>Error creating conversation: {str(e)}</red>'))
|
||||
|
||||
|
||||
async def join_conversation_cmd(client: TeamClient, args: argparse.Namespace) -> None:
|
||||
"""Join a conversation command.
|
||||
|
||||
Args:
|
||||
client: The TeamClient instance.
|
||||
args: Command line arguments.
|
||||
"""
|
||||
conversation_id = args.conversation_id
|
||||
|
||||
try:
|
||||
# Get conversation details
|
||||
conversation = await client.get_conversation(conversation_id)
|
||||
|
||||
# Clear screen and show banner
|
||||
clear()
|
||||
display_banner(session_id=conversation_id)
|
||||
|
||||
# Show conversation title
|
||||
title = conversation.get('title', 'Untitled Conversation')
|
||||
display_welcome_message(f'Joined conversation: {title}')
|
||||
|
||||
# Connect to the WebSocket
|
||||
await client.connect_to_conversation(conversation_id)
|
||||
|
||||
# Main conversation loop
|
||||
try:
|
||||
while True:
|
||||
next_message = await read_prompt_input(
|
||||
AgentState.AWAITING_USER_INPUT.value
|
||||
)
|
||||
|
||||
if not next_message.strip():
|
||||
continue
|
||||
|
||||
if next_message.lower() in ['exit', 'quit', '/exit', '/quit']:
|
||||
break
|
||||
|
||||
await client.send_message(next_message)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print('\nDisconnecting...')
|
||||
finally:
|
||||
await client.disconnect()
|
||||
|
||||
except Exception as e:
|
||||
print_formatted_text(HTML(f'<red>Error joining conversation: {str(e)}</red>'))
|
||||
|
||||
|
||||
def get_base_url() -> str:
|
||||
"""Get the base URL for the OpenHands API.
|
||||
|
||||
Returns:
|
||||
The base URL.
|
||||
"""
|
||||
# Check environment variables first
|
||||
base_url = os.environ.get('OPENHANDS_API_URL')
|
||||
if base_url:
|
||||
return base_url
|
||||
|
||||
# Default to staging server
|
||||
return 'https://staging.all-hands.dev'
|
||||
|
||||
|
||||
def get_api_key() -> Optional[str]:
|
||||
"""Get the API key for authentication.
|
||||
|
||||
Returns:
|
||||
The API key, or None if not found.
|
||||
"""
|
||||
return os.environ.get('OPENHANDS_API_KEY')
|
||||
|
||||
|
||||
def setup_parser() -> argparse.ArgumentParser:
|
||||
"""Set up the argument parser for the team CLI.
|
||||
|
||||
Returns:
|
||||
The argument parser.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description='OpenHands Team CLI')
|
||||
parser.formatter_class = argparse.ArgumentDefaultsHelpFormatter
|
||||
|
||||
# Server configuration
|
||||
parser.add_argument(
|
||||
'--url',
|
||||
help='OpenHands API URL (default: $OPENHANDS_API_URL or https://staging.all-hands.dev)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--api-key', help='OpenHands API key (default: $OPENHANDS_API_KEY)'
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest='command', help='Command to run')
|
||||
|
||||
# List conversations command
|
||||
list_parser = subparsers.add_parser(
|
||||
'list',
|
||||
help='List conversations',
|
||||
description='List all available conversations',
|
||||
)
|
||||
list_parser.add_argument(
|
||||
'-l',
|
||||
'--limit',
|
||||
type=int,
|
||||
default=20,
|
||||
help='Maximum number of conversations to list',
|
||||
)
|
||||
# Add help formatter
|
||||
list_parser.formatter_class = argparse.ArgumentDefaultsHelpFormatter
|
||||
|
||||
# Create conversation command
|
||||
create_parser = subparsers.add_parser(
|
||||
'create',
|
||||
help='Create a new conversation',
|
||||
description='Create a new conversation with optional repository and message',
|
||||
)
|
||||
create_parser.add_argument(
|
||||
'-r', '--repository', help='Repository name (owner/repo)'
|
||||
)
|
||||
create_parser.add_argument(
|
||||
'-g', '--git-provider', help='Git provider (github or gitlab)'
|
||||
)
|
||||
create_parser.add_argument('-b', '--branch', help='Branch name')
|
||||
create_parser.add_argument('-m', '--message', help='Initial user message')
|
||||
create_parser.add_argument('-i', '--instructions', help='Conversation instructions')
|
||||
create_parser.add_argument(
|
||||
'-j', '--join', action='store_true', help='Join the conversation after creation'
|
||||
)
|
||||
# Add help formatter
|
||||
create_parser.formatter_class = argparse.ArgumentDefaultsHelpFormatter
|
||||
|
||||
# Join conversation command
|
||||
join_parser = subparsers.add_parser(
|
||||
'join',
|
||||
help='Join an existing conversation',
|
||||
description='Join an existing conversation by ID',
|
||||
)
|
||||
join_parser.add_argument('conversation_id', help='Conversation ID')
|
||||
# Add help formatter
|
||||
join_parser.formatter_class = argparse.ArgumentDefaultsHelpFormatter
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
async def main_async(args: argparse.Namespace) -> None:
|
||||
"""Main async function for the team CLI.
|
||||
|
||||
Args:
|
||||
args: Command line arguments.
|
||||
"""
|
||||
# Get base URL and API key
|
||||
base_url = args.url or get_base_url()
|
||||
api_key = args.api_key or get_api_key()
|
||||
|
||||
# Create client
|
||||
client = TeamClient(base_url, api_key)
|
||||
|
||||
# Run command
|
||||
if args.command == 'list':
|
||||
await list_conversations_cmd(client, args)
|
||||
elif args.command == 'create':
|
||||
await create_conversation_cmd(client, args)
|
||||
elif args.command == 'join':
|
||||
await join_conversation_cmd(client, args)
|
||||
else:
|
||||
print('No command specified. Use --help for usage information.')
|
||||
|
||||
|
||||
def main(args: Optional[list[str]] = None) -> None:
|
||||
"""Main function for the team CLI.
|
||||
|
||||
Args:
|
||||
args: Command line arguments.
|
||||
"""
|
||||
parser = setup_parser()
|
||||
|
||||
# If no arguments provided, show help
|
||||
if not args or len(args) == 0:
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
# Special case for subcommand help
|
||||
if (
|
||||
len(args) >= 2
|
||||
and args[0] in ['list', 'create', 'join']
|
||||
and args[1] in ['-h', '--help']
|
||||
):
|
||||
# Create a new parser just for this subcommand
|
||||
if args[0] == 'list':
|
||||
subparser = argparse.ArgumentParser(
|
||||
description='List all available conversations',
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
subparser.add_argument(
|
||||
'-l',
|
||||
'--limit',
|
||||
type=int,
|
||||
default=20,
|
||||
help='Maximum number of conversations to list',
|
||||
)
|
||||
subparser.add_argument(
|
||||
'--url',
|
||||
help='OpenHands API URL (default: $OPENHANDS_API_URL or https://staging.all-hands.dev)',
|
||||
)
|
||||
subparser.add_argument(
|
||||
'--api-key',
|
||||
help='OpenHands API key (default: $OPENHANDS_API_KEY)',
|
||||
)
|
||||
subparser.print_help()
|
||||
return
|
||||
elif args[0] == 'create':
|
||||
subparser = argparse.ArgumentParser(
|
||||
description='Create a new conversation with optional repository and message',
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
subparser.add_argument(
|
||||
'-r',
|
||||
'--repository',
|
||||
help='Repository name (owner/repo)',
|
||||
)
|
||||
subparser.add_argument(
|
||||
'-g',
|
||||
'--git-provider',
|
||||
help='Git provider (github or gitlab)',
|
||||
)
|
||||
subparser.add_argument('-b', '--branch', help='Branch name')
|
||||
subparser.add_argument('-m', '--message', help='Initial user message')
|
||||
subparser.add_argument(
|
||||
'-i',
|
||||
'--instructions',
|
||||
help='Conversation instructions',
|
||||
)
|
||||
subparser.add_argument(
|
||||
'-j',
|
||||
'--join',
|
||||
action='store_true',
|
||||
help='Join the conversation after creation',
|
||||
)
|
||||
subparser.add_argument(
|
||||
'--url',
|
||||
help='OpenHands API URL (default: $OPENHANDS_API_URL or https://staging.all-hands.dev)',
|
||||
)
|
||||
subparser.add_argument(
|
||||
'--api-key',
|
||||
help='OpenHands API key (default: $OPENHANDS_API_KEY)',
|
||||
)
|
||||
subparser.print_help()
|
||||
return
|
||||
elif args[0] == 'join':
|
||||
subparser = argparse.ArgumentParser(
|
||||
description='Join an existing conversation by ID',
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
subparser.add_argument('conversation_id', help='Conversation ID')
|
||||
subparser.add_argument(
|
||||
'--url',
|
||||
help='OpenHands API URL (default: $OPENHANDS_API_URL or https://staging.all-hands.dev)',
|
||||
)
|
||||
subparser.add_argument(
|
||||
'--api-key',
|
||||
help='OpenHands API key (default: $OPENHANDS_API_KEY)',
|
||||
)
|
||||
subparser.print_help()
|
||||
return
|
||||
|
||||
try:
|
||||
parsed_args = parser.parse_args(args)
|
||||
|
||||
# If no command specified, show help
|
||||
if not parsed_args.command:
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
# Run the command
|
||||
asyncio.run(main_async(parsed_args))
|
||||
except KeyboardInterrupt:
|
||||
print('\nOperation cancelled by user.')
|
||||
except Exception as e:
|
||||
print(f'Error: {str(e)}')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Get the Python executable
|
||||
PYTHON_EXE=$(which python)
|
||||
|
||||
# Run the team CLI
|
||||
$PYTHON_EXE -m openhands.cli.team "$@"
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Create conversation command for the OpenHands Team CLI."""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from openhands.cli.team import TeamClient
|
||||
|
||||
|
||||
def setup_parser() -> argparse.ArgumentParser:
|
||||
"""Set up the argument parser for the create command.
|
||||
|
||||
Returns:
|
||||
The argument parser.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Create a new conversation with optional repository and message',
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
parser.add_argument('-r', '--repository', help='Repository name (owner/repo)')
|
||||
parser.add_argument('-g', '--git-provider', help='Git provider (github or gitlab)')
|
||||
parser.add_argument('-b', '--branch', help='Branch name')
|
||||
parser.add_argument('-m', '--message', help='Initial user message')
|
||||
parser.add_argument('-i', '--instructions', help='Conversation instructions')
|
||||
parser.add_argument(
|
||||
'-j', '--join', action='store_true', help='Join the conversation after creation'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--url',
|
||||
help='OpenHands API URL (default: $OPENHANDS_API_URL or http://localhost:3000)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--api-key', help='OpenHands API key (default: $OPENHANDS_API_KEY)'
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
async def create_conversation(args: argparse.Namespace) -> None:
|
||||
"""Create a conversation command.
|
||||
|
||||
Args:
|
||||
args: Command line arguments.
|
||||
"""
|
||||
# Create client
|
||||
client = TeamClient(args.url, args.api_key)
|
||||
|
||||
try:
|
||||
# Create conversation
|
||||
await client.create_conversation(
|
||||
repository=args.repository,
|
||||
git_provider=args.git_provider,
|
||||
selected_branch=args.branch,
|
||||
initial_user_msg=args.message,
|
||||
conversation_instructions=args.instructions,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f'Error creating conversation: {e}')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main(args: Optional[list[str]] = None) -> None:
|
||||
"""Main function for the create command.
|
||||
|
||||
Args:
|
||||
args: Command line arguments.
|
||||
"""
|
||||
parser = setup_parser()
|
||||
parsed_args = parser.parse_args(args)
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(create_conversation(parsed_args))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,63 +0,0 @@
|
||||
"""Join conversation command for the OpenHands Team CLI."""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from openhands.cli.team import TeamClient, join_conversation_cmd
|
||||
|
||||
|
||||
def setup_parser() -> argparse.ArgumentParser:
|
||||
"""Set up the argument parser for the join command.
|
||||
|
||||
Returns:
|
||||
The argument parser.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Join an existing conversation by ID',
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
parser.add_argument('conversation_id', help='Conversation ID')
|
||||
parser.add_argument(
|
||||
'--url',
|
||||
help='OpenHands API URL (default: $OPENHANDS_API_URL or http://localhost:3000)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--api-key', help='OpenHands API key (default: $OPENHANDS_API_KEY)'
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
async def join_conversation(args: argparse.Namespace) -> None:
|
||||
"""Join a conversation command.
|
||||
|
||||
Args:
|
||||
args: Command line arguments.
|
||||
"""
|
||||
# Create client
|
||||
client = TeamClient(args.url, args.api_key)
|
||||
|
||||
try:
|
||||
# Join conversation
|
||||
await join_conversation_cmd(client, args)
|
||||
except Exception as e:
|
||||
print(f'Error joining conversation: {e}')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main(args: Optional[list[str]] = None) -> None:
|
||||
"""Main function for the join command.
|
||||
|
||||
Args:
|
||||
args: Command line arguments.
|
||||
"""
|
||||
parser = setup_parser()
|
||||
parsed_args = parser.parse_args(args)
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(join_conversation(parsed_args))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,69 +0,0 @@
|
||||
"""List conversations command for the OpenHands Team CLI."""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from openhands.cli.team import TeamClient
|
||||
|
||||
|
||||
def setup_parser() -> argparse.ArgumentParser:
|
||||
"""Set up the argument parser for the list command.
|
||||
|
||||
Returns:
|
||||
The argument parser.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='List all available conversations',
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
'-l',
|
||||
'--limit',
|
||||
type=int,
|
||||
default=20,
|
||||
help='Maximum number of conversations to list',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--url',
|
||||
help='OpenHands API URL (default: $OPENHANDS_API_URL or http://localhost:3000)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--api-key', help='OpenHands API key (default: $OPENHANDS_API_KEY)'
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
async def list_conversations(args: argparse.Namespace) -> None:
|
||||
"""List conversations command.
|
||||
|
||||
Args:
|
||||
args: Command line arguments.
|
||||
"""
|
||||
# Create client
|
||||
client = TeamClient(args.url, args.api_key)
|
||||
|
||||
try:
|
||||
# List conversations
|
||||
await client.list_conversations(limit=args.limit)
|
||||
except Exception as e:
|
||||
print(f'Error listing conversations: {e}')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main(args: Optional[list[str]] = None) -> None:
|
||||
"""Main function for the list command.
|
||||
|
||||
Args:
|
||||
args: Command line arguments.
|
||||
"""
|
||||
parser = setup_parser()
|
||||
parsed_args = parser.parse_args(args)
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(list_conversations(parsed_args))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -744,26 +744,13 @@ def get_parser() -> argparse.ArgumentParser:
|
||||
type=bool,
|
||||
default=False,
|
||||
)
|
||||
# Add team subcommand
|
||||
subparsers = parser.add_subparsers(dest='command')
|
||||
subparsers.add_parser(
|
||||
'team', help='Use team mode to interact with the OpenHands API'
|
||||
)
|
||||
# We'll handle the team subcommands separately
|
||||
return parser
|
||||
|
||||
|
||||
def parse_arguments() -> argparse.Namespace:
|
||||
"""Parse command line arguments."""
|
||||
parser = get_parser()
|
||||
|
||||
# Check if 'team' command is present
|
||||
if len(sys.argv) > 1 and sys.argv[1] == 'team':
|
||||
# Only parse known arguments, ignoring any team-specific arguments
|
||||
args, _ = parser.parse_known_args()
|
||||
else:
|
||||
# Parse all arguments normally
|
||||
args = parser.parse_args()
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.version:
|
||||
print(f'OpenHands version: {__version__}')
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
Please summarize your work.
|
||||
Please send a final message summarizing your work.
|
||||
|
||||
If you answered a question, please re-state the answer to the question
|
||||
If you made changes, please create a concise overview on whether the request has been addressed successfully or if there are were issues with the attempt.
|
||||
If successful, make sure your changes are pushed to the remote branch.
|
||||
If you simply answered a question, this final message should re-state the answer to the question.
|
||||
|
||||
If you made changes, please first double-check the git diff, think carefully about the user's request(s), and check:
|
||||
1. whether the request has been completely addressed and all of the instructions have been followed faithfully (in checklist format if appropriate).
|
||||
2. whether the changes are concise (if there are any extraneous changes not important to addressing the user's request they should be reverted).
|
||||
If the request has been addressed and the changes are concise, then push your changes to the remote branch and send a final message summarizing the changes.
|
||||
|
||||
+66
-170
@@ -1,13 +1,12 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
from contextlib import AsyncExitStack
|
||||
from typing import Optional
|
||||
|
||||
from mcp import ClientSession
|
||||
from mcp.client.sse import sse_client
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
from fastmcp import Client
|
||||
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
|
||||
from mcp import McpError
|
||||
from mcp.types import CallToolResult
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from openhands.core.config.mcp_config import MCPSHTTPServerConfig, MCPSSEServerConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.mcp.tool import MCPClientTool
|
||||
|
||||
@@ -17,8 +16,7 @@ class MCPClient(BaseModel):
|
||||
A collection of tools that connects to an MCP server and manages available tools through the Model Context Protocol.
|
||||
"""
|
||||
|
||||
session: Optional[ClientSession] = None
|
||||
exit_stack: AsyncExitStack = AsyncExitStack()
|
||||
client: Optional[Client] = None
|
||||
description: str = 'MCP client tools for server interaction'
|
||||
tools: list[MCPClientTool] = Field(default_factory=list)
|
||||
tool_map: dict[str, MCPClientTool] = Field(default_factory=dict)
|
||||
@@ -26,189 +24,87 @@ class MCPClient(BaseModel):
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
async def connect_sse(
|
||||
self,
|
||||
server_url: str,
|
||||
api_key: str | None = None,
|
||||
conversation_id: str | None = None,
|
||||
timeout: float = 30.0,
|
||||
) -> None:
|
||||
"""Connect to an MCP server using SSE transport.
|
||||
|
||||
Args:
|
||||
server_url: The URL of the SSE server to connect to.
|
||||
timeout: Connection timeout in seconds. Default is 30 seconds.
|
||||
"""
|
||||
if not server_url:
|
||||
raise ValueError('Server URL is required.')
|
||||
if self.session:
|
||||
await self.disconnect()
|
||||
|
||||
try:
|
||||
# Use asyncio.wait_for to enforce the timeout
|
||||
async def connect_with_timeout():
|
||||
headers = (
|
||||
{
|
||||
'Authorization': f'Bearer {api_key}',
|
||||
's': api_key, # We need this for action execution server's MCP Router
|
||||
'X-Session-API-Key': api_key, # We need this for Remote Runtime
|
||||
}
|
||||
if api_key
|
||||
else {}
|
||||
)
|
||||
|
||||
if conversation_id:
|
||||
headers['X-OpenHands-Conversation-ID'] = conversation_id
|
||||
|
||||
# Convert float timeout to datetime.timedelta for consistency
|
||||
timeout_delta = datetime.timedelta(seconds=timeout)
|
||||
|
||||
streams_context = sse_client(
|
||||
url=server_url,
|
||||
headers=headers if headers else None,
|
||||
timeout=timeout,
|
||||
)
|
||||
streams = await self.exit_stack.enter_async_context(streams_context)
|
||||
# For SSE client, we only get read_stream and write_stream (2 values)
|
||||
read_stream, write_stream = streams
|
||||
self.session = await self.exit_stack.enter_async_context(
|
||||
ClientSession(
|
||||
read_stream, write_stream, read_timeout_seconds=timeout_delta
|
||||
)
|
||||
)
|
||||
await self._initialize_and_list_tools()
|
||||
|
||||
# Apply timeout to the entire connection process
|
||||
await asyncio.wait_for(connect_with_timeout(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(
|
||||
f'Connection to {server_url} timed out after {timeout} seconds'
|
||||
)
|
||||
await self.disconnect() # Clean up resources
|
||||
raise # Re-raise the TimeoutError
|
||||
except Exception as e:
|
||||
logger.error(f'Error connecting to {server_url}: {str(e)}')
|
||||
await self.disconnect() # Clean up resources
|
||||
raise
|
||||
|
||||
async def _initialize_and_list_tools(self) -> None:
|
||||
"""Initialize session and populate tool map."""
|
||||
if not self.session:
|
||||
if not self.client:
|
||||
raise RuntimeError('Session not initialized.')
|
||||
|
||||
await self.session.initialize()
|
||||
response = await self.session.list_tools()
|
||||
async with self.client:
|
||||
tools = await self.client.list_tools()
|
||||
|
||||
# Clear existing tools
|
||||
self.tools = []
|
||||
|
||||
# Create proper tool objects for each server tool
|
||||
for tool in response.tools:
|
||||
for tool in tools:
|
||||
server_tool = MCPClientTool(
|
||||
name=tool.name,
|
||||
description=tool.description,
|
||||
inputSchema=tool.inputSchema,
|
||||
session=self.session,
|
||||
session=self.client,
|
||||
)
|
||||
self.tool_map[tool.name] = server_tool
|
||||
self.tools.append(server_tool)
|
||||
|
||||
logger.info(
|
||||
f'Connected to server with tools: {[tool.name for tool in response.tools]}'
|
||||
)
|
||||
logger.info(f'Connected to server with tools: {[tool.name for tool in tools]}')
|
||||
|
||||
async def call_tool(self, tool_name: str, args: dict):
|
||||
async def connect_http(
|
||||
self,
|
||||
server: MCPSSEServerConfig | MCPSHTTPServerConfig,
|
||||
conversation_id: str | None = None,
|
||||
timeout: float = 30.0,
|
||||
):
|
||||
"""Connect to MCP server using SHTTP or SSE transport"""
|
||||
server_url = server.url
|
||||
api_key = server.api_key
|
||||
|
||||
if not server_url:
|
||||
raise ValueError('Server URL is required.')
|
||||
|
||||
try:
|
||||
headers = (
|
||||
{
|
||||
'Authorization': f'Bearer {api_key}',
|
||||
's': api_key, # We need this for action execution server's MCP Router
|
||||
'X-Session-API-Key': api_key, # We need this for Remote Runtime
|
||||
}
|
||||
if api_key
|
||||
else {}
|
||||
)
|
||||
|
||||
if conversation_id:
|
||||
headers['X-OpenHands-ServerConversation-ID'] = conversation_id
|
||||
|
||||
# Instantiate custom transports due to custom headers
|
||||
if isinstance(server, MCPSHTTPServerConfig):
|
||||
transport = StreamableHttpTransport(
|
||||
url=server_url,
|
||||
headers=headers if headers else None,
|
||||
)
|
||||
else:
|
||||
transport = SSETransport(
|
||||
url=server_url,
|
||||
headers=headers if headers else None,
|
||||
)
|
||||
|
||||
self.client = Client(transport, timeout=timeout)
|
||||
|
||||
await self._initialize_and_list_tools()
|
||||
except McpError as e:
|
||||
logger.error(f'McpError connecting to {server_url}: {e}')
|
||||
raise # Re-raise the error
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error connecting to {server_url}: {e}')
|
||||
raise
|
||||
|
||||
async def call_tool(self, tool_name: str, args: dict) -> CallToolResult:
|
||||
"""Call a tool on the MCP server."""
|
||||
if tool_name not in self.tool_map:
|
||||
raise ValueError(f'Tool {tool_name} not found.')
|
||||
# The MCPClientTool is primarily for metadata; use the session to call the actual tool.
|
||||
if not self.session:
|
||||
if not self.client:
|
||||
raise RuntimeError('Client session is not available.')
|
||||
return await self.session.call_tool(name=tool_name, arguments=args)
|
||||
|
||||
async def connect_shttp(
|
||||
self,
|
||||
server_url: str,
|
||||
api_key: str | None = None,
|
||||
conversation_id: str | None = None,
|
||||
timeout: float = 30.0,
|
||||
) -> None:
|
||||
"""Connect to an MCP server using StreamableHTTP transport.
|
||||
|
||||
Args:
|
||||
server_url: The URL of the StreamableHTTP server to connect to.
|
||||
api_key: Optional API key for authentication.
|
||||
conversation_id: Optional conversation ID for session tracking.
|
||||
timeout: Connection timeout in seconds. Default is 30 seconds.
|
||||
"""
|
||||
if not server_url:
|
||||
raise ValueError('Server URL is required.')
|
||||
if self.session:
|
||||
await self.disconnect()
|
||||
|
||||
try:
|
||||
# Use asyncio.wait_for to enforce the timeout
|
||||
async def connect_with_timeout():
|
||||
headers = (
|
||||
{
|
||||
'Authorization': f'Bearer {api_key}',
|
||||
's': api_key, # We need this for action execution server's MCP Router
|
||||
'X-Session-API-Key': api_key, # We need this for Remote Runtime
|
||||
}
|
||||
if api_key
|
||||
else {}
|
||||
)
|
||||
|
||||
if conversation_id:
|
||||
headers['X-OpenHands-Conversation-ID'] = conversation_id
|
||||
|
||||
# Convert float timeout to datetime.timedelta
|
||||
timeout_delta = datetime.timedelta(seconds=timeout)
|
||||
sse_read_timeout_delta = datetime.timedelta(
|
||||
seconds=timeout * 10
|
||||
) # 10x longer for read timeout
|
||||
|
||||
streams_context = streamablehttp_client(
|
||||
url=server_url,
|
||||
headers=headers if headers else None,
|
||||
timeout=timeout_delta,
|
||||
sse_read_timeout=sse_read_timeout_delta,
|
||||
)
|
||||
streams = await self.exit_stack.enter_async_context(streams_context)
|
||||
# For StreamableHTTP client, we get read_stream, write_stream, and get_session_id (3 values)
|
||||
read_stream, write_stream, _ = streams
|
||||
self.session = await self.exit_stack.enter_async_context(
|
||||
ClientSession(
|
||||
read_stream, write_stream, read_timeout_seconds=timeout_delta
|
||||
)
|
||||
)
|
||||
await self._initialize_and_list_tools()
|
||||
|
||||
# Apply timeout to the entire connection process
|
||||
await asyncio.wait_for(connect_with_timeout(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(
|
||||
f'Connection to {server_url} timed out after {timeout} seconds'
|
||||
)
|
||||
await self.disconnect() # Clean up resources
|
||||
raise # Re-raise the TimeoutError
|
||||
except Exception as e:
|
||||
logger.error(f'Error connecting to {server_url}: {str(e)}')
|
||||
await self.disconnect() # Clean up resources
|
||||
raise
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from the MCP server and clean up resources."""
|
||||
if self.session:
|
||||
try:
|
||||
# Close the session first
|
||||
if hasattr(self.session, 'close'):
|
||||
await self.session.close()
|
||||
# Then close the exit stack
|
||||
await self.exit_stack.aclose()
|
||||
except Exception as e:
|
||||
logger.error(f'Error during disconnect: {str(e)}')
|
||||
finally:
|
||||
self.session = None
|
||||
self.tools = []
|
||||
logger.info('Disconnected from MCP server')
|
||||
async with self.client:
|
||||
return await self.client.call_tool_mcp(name=tool_name, arguments=args)
|
||||
|
||||
+4
-27
@@ -72,38 +72,22 @@ async def create_mcp_clients(
|
||||
mcp_clients = []
|
||||
|
||||
for server in servers:
|
||||
is_sse = isinstance(server, MCPSSEServerConfig)
|
||||
connection_type = 'SSE' if is_sse else 'SHTTP'
|
||||
is_shttp = isinstance(server, MCPSHTTPServerConfig)
|
||||
connection_type = 'SHTTP' if is_shttp else 'SSE'
|
||||
logger.info(
|
||||
f'Initializing MCP agent for {server} with {connection_type} connection...'
|
||||
)
|
||||
client = MCPClient()
|
||||
|
||||
try:
|
||||
if is_sse:
|
||||
await client.connect_sse(
|
||||
server.url,
|
||||
api_key=server.api_key,
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
else:
|
||||
await client.connect_shttp(
|
||||
server.url,
|
||||
api_key=server.api_key,
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
await client.connect_http(server, conversation_id=conversation_id)
|
||||
|
||||
# Only add the client to the list after a successful connection
|
||||
mcp_clients.append(client)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to connect to {server}: {str(e)}', exc_info=True)
|
||||
try:
|
||||
await client.disconnect()
|
||||
except Exception as disconnect_error:
|
||||
logger.error(
|
||||
f'Error during disconnect after failed connection: {str(disconnect_error)}'
|
||||
)
|
||||
|
||||
return mcp_clients
|
||||
|
||||
|
||||
@@ -143,13 +127,6 @@ async def fetch_mcp_tools_from_config(
|
||||
# Convert tools to the format expected by the agent
|
||||
mcp_tools = convert_mcp_clients_to_tools(mcp_clients)
|
||||
|
||||
# Always disconnect clients to clean up resources
|
||||
for mcp_client in mcp_clients:
|
||||
try:
|
||||
await mcp_client.disconnect()
|
||||
except Exception as disconnect_error:
|
||||
logger.error(f'Error disconnecting MCP client: {str(disconnect_error)}')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error fetching MCP tools: {str(e)}')
|
||||
return []
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import io
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
@@ -9,7 +10,7 @@ from openhands.core.exceptions import (
|
||||
MicroagentValidationError,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.microagent.types import MicroagentMetadata, MicroagentType
|
||||
from openhands.microagent.types import InputMetadata, MicroagentMetadata, MicroagentType
|
||||
|
||||
|
||||
class BaseMicroagent(BaseModel):
|
||||
@@ -91,13 +92,26 @@ class BaseMicroagent(BaseModel):
|
||||
subclass_map = {
|
||||
MicroagentType.KNOWLEDGE: KnowledgeMicroagent,
|
||||
MicroagentType.REPO_KNOWLEDGE: RepoMicroagent,
|
||||
MicroagentType.TASK: TaskMicroagent,
|
||||
}
|
||||
|
||||
# Infer the agent type:
|
||||
# 1. If triggers exist -> KNOWLEDGE (optional)
|
||||
# 2. Else (no triggers) -> REPO (always active)
|
||||
# 1. If inputs exist -> TASK
|
||||
# 2. If triggers exist -> KNOWLEDGE
|
||||
# 3. Else (no triggers) -> REPO (always active)
|
||||
inferred_type: MicroagentType
|
||||
if metadata.triggers:
|
||||
if metadata.inputs:
|
||||
inferred_type = MicroagentType.TASK
|
||||
# Add a trigger for the agent name if not already present
|
||||
# Use derived_name if available, otherwise use metadata.name
|
||||
agent_name = derived_name if derived_name is not None and (metadata.name == 'default' or not metadata.name) else metadata.name
|
||||
trigger = f'/{agent_name}'
|
||||
if not metadata.triggers or trigger not in metadata.triggers:
|
||||
if not metadata.triggers:
|
||||
metadata.triggers = [trigger]
|
||||
else:
|
||||
metadata.triggers.append(trigger)
|
||||
elif metadata.triggers:
|
||||
inferred_type = MicroagentType.KNOWLEDGE
|
||||
else:
|
||||
# No triggers, default to REPO
|
||||
@@ -109,7 +123,11 @@ class BaseMicroagent(BaseModel):
|
||||
raise ValueError(f'Could not determine microagent type for: {path}')
|
||||
|
||||
# Use derived_name if available (from relative path), otherwise fallback to metadata.name
|
||||
agent_name = derived_name if derived_name is not None else metadata.name
|
||||
# If metadata.name is still the default 'default', use the derived_name
|
||||
if derived_name is not None and (metadata.name == 'default' or not metadata.name):
|
||||
agent_name = derived_name
|
||||
else:
|
||||
agent_name = metadata.name
|
||||
|
||||
agent_class = subclass_map[inferred_type]
|
||||
return agent_class(
|
||||
@@ -122,7 +140,9 @@ class BaseMicroagent(BaseModel):
|
||||
|
||||
|
||||
class KnowledgeMicroagent(BaseMicroagent):
|
||||
"""Knowledge micro-agents provide specialized expertise that's triggered by keywords in conversations. They help with:
|
||||
"""Knowledge micro-agents provide specialized expertise that's triggered by keywords in conversations.
|
||||
|
||||
They help with:
|
||||
- Language best practices
|
||||
- Framework guidelines
|
||||
- Common patterns
|
||||
@@ -131,8 +151,8 @@ class KnowledgeMicroagent(BaseMicroagent):
|
||||
|
||||
def __init__(self, **data):
|
||||
super().__init__(**data)
|
||||
if self.type != MicroagentType.KNOWLEDGE:
|
||||
raise ValueError('KnowledgeMicroagent must have type KNOWLEDGE')
|
||||
if self.type not in [MicroagentType.KNOWLEDGE, MicroagentType.TASK]:
|
||||
raise ValueError('KnowledgeMicroagent must have type KNOWLEDGE or TASK')
|
||||
|
||||
def match_trigger(self, message: str) -> str | None:
|
||||
"""Match a trigger in the message.
|
||||
@@ -171,6 +191,57 @@ class RepoMicroagent(BaseMicroagent):
|
||||
)
|
||||
|
||||
|
||||
class TaskMicroagent(KnowledgeMicroagent):
|
||||
"""TaskMicroagent is a special type of KnowledgeMicroagent that requires user input.
|
||||
|
||||
These microagents are triggered by a special format: "/{agent_name}"
|
||||
and will prompt the user for any required inputs before proceeding.
|
||||
"""
|
||||
|
||||
def __init__(self, **data):
|
||||
super().__init__(**data)
|
||||
if self.type != MicroagentType.TASK:
|
||||
raise ValueError(
|
||||
f'TaskMicroagent initialized with incorrect type: {self.type}'
|
||||
)
|
||||
|
||||
# Append a prompt to ask for missing variables
|
||||
self._append_missing_variables_prompt()
|
||||
|
||||
def _append_missing_variables_prompt(self) -> None:
|
||||
"""Append a prompt to ask for missing variables."""
|
||||
# Check if the content contains any variables or has inputs defined
|
||||
if not self.requires_user_input() and not self.metadata.inputs:
|
||||
return
|
||||
|
||||
prompt = "\n\nIf the user didn't provide any of these variables, ask the user to provide them first before the agent can proceed with the task."
|
||||
self.content += prompt
|
||||
|
||||
def extract_variables(self, content: str) -> list[str]:
|
||||
"""Extract variables from the content.
|
||||
|
||||
Variables are in the format ${variable_name}.
|
||||
"""
|
||||
pattern = r'\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}'
|
||||
matches = re.findall(pattern, content)
|
||||
return matches
|
||||
|
||||
def requires_user_input(self) -> bool:
|
||||
"""Check if this microagent requires user input.
|
||||
|
||||
Returns True if the content contains variables in the format ${variable_name}.
|
||||
"""
|
||||
# Check if the content contains any variables
|
||||
variables = self.extract_variables(self.content)
|
||||
logger.debug(f'This microagent requires user input: {variables}')
|
||||
return len(variables) > 0
|
||||
|
||||
@property
|
||||
def inputs(self) -> list[InputMetadata]:
|
||||
"""Get the inputs for this microagent."""
|
||||
return self.metadata.inputs
|
||||
|
||||
|
||||
def load_microagents_from_dir(
|
||||
microagent_dir: Union[str, Path],
|
||||
) -> tuple[dict[str, RepoMicroagent], dict[str, KnowledgeMicroagent]]:
|
||||
@@ -182,7 +253,7 @@ def load_microagents_from_dir(
|
||||
microagent_dir: Path to the microagents directory (e.g. .openhands/microagents)
|
||||
|
||||
Returns:
|
||||
Tuple of (repo_agents, knowledge_agents, task_agents) dictionaries
|
||||
Tuple of (repo_agents, knowledge_agents) dictionaries
|
||||
"""
|
||||
if isinstance(microagent_dir, str):
|
||||
microagent_dir = Path(microagent_dir)
|
||||
@@ -202,6 +273,7 @@ def load_microagents_from_dir(
|
||||
if isinstance(agent, RepoMicroagent):
|
||||
repo_agents[agent.name] = agent
|
||||
elif isinstance(agent, KnowledgeMicroagent):
|
||||
# Both KnowledgeMicroagent and TaskMicroagent go into knowledge_agents
|
||||
knowledge_agents[agent.name] = agent
|
||||
except MicroagentValidationError as e:
|
||||
# For validation errors, include the original exception
|
||||
|
||||
@@ -12,6 +12,14 @@ class MicroagentType(str, Enum):
|
||||
|
||||
KNOWLEDGE = 'knowledge' # Optional microagent, triggered by keywords
|
||||
REPO_KNOWLEDGE = 'repo' # Always active microagent
|
||||
TASK = 'task' # Special type for task microagents that require user input
|
||||
|
||||
|
||||
class InputMetadata(BaseModel):
|
||||
"""Metadata for task microagent inputs."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
|
||||
|
||||
class MicroagentMetadata(BaseModel):
|
||||
@@ -19,9 +27,12 @@ class MicroagentMetadata(BaseModel):
|
||||
|
||||
name: str = 'default'
|
||||
type: MicroagentType = Field(default=MicroagentType.REPO_KNOWLEDGE)
|
||||
version: str = Field(default='1.0.0')
|
||||
agent: str = Field(default='CodeActAgent')
|
||||
# Keep these fields for backward compatibility but they're not used
|
||||
version: str = Field(default='1.0.0', exclude=True)
|
||||
agent: str = Field(default='CodeActAgent', exclude=True)
|
||||
author: str = Field(default='', exclude=True)
|
||||
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
|
||||
)
|
||||
|
||||
@@ -471,11 +471,6 @@ class ActionExecutionClient(Runtime):
|
||||
# Call the tool and return the result
|
||||
# No need for try/finally since disconnect() is now just resetting state
|
||||
result = await call_tool_mcp_handler(mcp_clients, action)
|
||||
|
||||
# Reset client state (no active connections to worry about)
|
||||
for client in mcp_clients:
|
||||
await client.disconnect()
|
||||
|
||||
return result
|
||||
|
||||
def close(self) -> None:
|
||||
|
||||
@@ -17,6 +17,7 @@ from openhands.events.observation.commands import (
|
||||
CmdOutputMetadata,
|
||||
CmdOutputObservation,
|
||||
)
|
||||
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
|
||||
|
||||
@@ -379,9 +380,7 @@ class BashSession:
|
||||
metadata = CmdOutputMetadata() # No metadata available
|
||||
metadata.suffix = (
|
||||
f'\n[The command has no new output after {self.NO_CHANGE_TIMEOUT_SECONDS} seconds. '
|
||||
"You may wait longer to see additional output by sending empty command '', "
|
||||
'send other commands to interact with the current process, '
|
||||
'or send keys to interrupt/kill the command.]'
|
||||
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
|
||||
)
|
||||
command_output = self._get_command_output(
|
||||
command,
|
||||
@@ -414,9 +413,7 @@ class BashSession:
|
||||
metadata = CmdOutputMetadata() # No metadata available
|
||||
metadata.suffix = (
|
||||
f'\n[The command timed out after {timeout} seconds. '
|
||||
"You may wait longer to see additional output by sending empty command '', "
|
||||
'send other commands to interact with the current process, '
|
||||
'or send keys to interrupt/kill the command.]'
|
||||
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
|
||||
)
|
||||
command_output = self._get_command_output(
|
||||
command,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Common timeout message that can be used across different timeout scenarios
|
||||
TIMEOUT_MESSAGE_TEMPLATE = (
|
||||
"You may wait longer to see additional output by sending empty command '', "
|
||||
'send other commands to interact with the current process, '
|
||||
'send keys to interrupt/kill the command, '
|
||||
'or use the timeout parameter in execute_bash for future commands.'
|
||||
)
|
||||
@@ -20,6 +20,7 @@ from openhands.events.observation.commands import (
|
||||
CmdOutputMetadata,
|
||||
CmdOutputObservation,
|
||||
)
|
||||
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
|
||||
pythonnet.load('coreclr')
|
||||
@@ -559,9 +560,7 @@ class WindowsPowershellSession:
|
||||
else:
|
||||
metadata.suffix = (
|
||||
f'\n[The command timed out after {timeout_seconds} seconds. '
|
||||
"You may wait longer to see additional output by sending empty command '', "
|
||||
'send other commands to interact with the current process, '
|
||||
'or send keys to interrupt/kill the command.]'
|
||||
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
|
||||
)
|
||||
|
||||
return CmdOutputObservation(
|
||||
@@ -1331,9 +1330,7 @@ class WindowsPowershellSession:
|
||||
# Align suffix with bash.py timeout message
|
||||
suffix = (
|
||||
f'\n[The command timed out after {timeout_seconds} seconds. '
|
||||
"You may wait longer to see additional output by sending empty command '', "
|
||||
'send other commands to interact with the current process, '
|
||||
'or send keys to interrupt/kill the command.]'
|
||||
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
|
||||
)
|
||||
elif shutdown_requested:
|
||||
# Align suffix with bash.py equivalent (though bash.py might not have specific shutdown message)
|
||||
|
||||
@@ -49,7 +49,7 @@ async def get_convo_link(service: GitService, conversation_id: str, body: str) -
|
||||
|
||||
|
||||
async def save_pr_metadata(
|
||||
user_id: str, conversation_id: str, tool_result: str
|
||||
user_id: str | None, conversation_id: str, tool_result: str
|
||||
) -> None:
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
|
||||
conversation: ConversationMetadata = await conversation_store.get_metadata(
|
||||
@@ -70,7 +70,11 @@ async def save_pr_metadata(
|
||||
pr_number = int(match_merge_request.group(1))
|
||||
|
||||
if pr_number:
|
||||
logger.info(f'Saving PR number: {pr_number} for convo {conversation_id}')
|
||||
conversation.pr_number.append(pr_number)
|
||||
else:
|
||||
logger.warning(f'Failed to extract PR number for convo {conversation_id}')
|
||||
|
||||
await conversation_store.save_metadata(conversation)
|
||||
|
||||
|
||||
@@ -124,7 +128,7 @@ async def create_pr(
|
||||
body=body,
|
||||
)
|
||||
|
||||
if conversation_id and user_id:
|
||||
if conversation_id:
|
||||
await save_pr_metadata(user_id, conversation_id, response)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -15,6 +15,7 @@ from openhands.server.shared import config
|
||||
from openhands.server.user_auth import (
|
||||
get_provider_tokens,
|
||||
get_secrets_store,
|
||||
get_user_settings,
|
||||
get_user_settings_store,
|
||||
)
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
@@ -35,10 +36,9 @@ app = APIRouter(prefix='/api', dependencies=get_dependencies())
|
||||
async def load_settings(
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
settings_store: SettingsStore = Depends(get_user_settings_store),
|
||||
settings: Settings = Depends(get_user_settings),
|
||||
secrets_store: SecretsStore = Depends(get_secrets_store),
|
||||
) -> GETSettingsModel | JSONResponse:
|
||||
settings = await settings_store.load()
|
||||
|
||||
try:
|
||||
if not settings:
|
||||
return JSONResponse(
|
||||
|
||||
@@ -25,6 +25,10 @@ class DefaultUserAuth(UserAuth):
|
||||
"""The default implementation does not support multi tenancy, so user_id is always None"""
|
||||
return None
|
||||
|
||||
async def get_user_email(self) -> str | None:
|
||||
"""The default implementation does not support multi tenancy, so email is always None"""
|
||||
return None
|
||||
|
||||
async def get_access_token(self) -> SecretStr | None:
|
||||
"""The default implementation does not support multi tenancy, so access_token is always None"""
|
||||
return None
|
||||
|
||||
@@ -38,6 +38,10 @@ class UserAuth(ABC):
|
||||
async def get_user_id(self) -> str | None:
|
||||
"""Get the unique identifier for the current user"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_user_email(self) -> str | None:
|
||||
"""Get the email for the current user"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_access_token(self) -> SecretStr | None:
|
||||
"""Get the access token for the current user"""
|
||||
|
||||
@@ -40,6 +40,8 @@ class Settings(BaseModel):
|
||||
sandbox_runtime_container_image: str | None = None
|
||||
mcp_config: MCPConfig | None = None
|
||||
search_api_key: SecretStr | None = None
|
||||
email: str | None = None
|
||||
email_verified: bool | None = None
|
||||
|
||||
model_config = {
|
||||
'validate_assignment': True,
|
||||
|
||||
@@ -48,7 +48,6 @@ dirhash = "*"
|
||||
tornado = "*"
|
||||
python-dotenv = "*"
|
||||
rapidfuzz = "^3.9.0"
|
||||
rich = "^13.7.0"
|
||||
whatthepatch = "^1.0.6"
|
||||
protobuf = "^5.0.0,<6.0.0" # Updated to support newer opentelemetry
|
||||
opentelemetry-api = "^1.33.1"
|
||||
|
||||
@@ -16,6 +16,16 @@ 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
|
||||
@@ -56,10 +66,7 @@ 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 (
|
||||
"[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
|
||||
)
|
||||
assert get_timeout_suffix(1.0) in obs.metadata.suffix
|
||||
|
||||
action = CmdRunAction(command='C-c', is_input=True)
|
||||
action.set_hard_timeout(30)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for microagent loading in runtime."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -13,7 +14,13 @@ 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 import KnowledgeMicroagent, RepoMicroagent
|
||||
from openhands.microagent.microagent import (
|
||||
BaseMicroagent,
|
||||
KnowledgeMicroagent,
|
||||
RepoMicroagent,
|
||||
TaskMicroagent,
|
||||
)
|
||||
from openhands.microagent.types import MicroagentType
|
||||
|
||||
|
||||
def _create_test_microagents(test_dir: str):
|
||||
@@ -173,6 +180,176 @@ 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
|
||||
@@ -196,9 +373,9 @@ def test_default_tools_microagent_exists():
|
||||
assert 'type: repo' in content, 'default-tools.md should be a repo microagent'
|
||||
|
||||
# Verify it has the fetch tool configured
|
||||
assert 'name: "fetch"' in content, 'default-tools.md should have a fetch tool'
|
||||
assert 'command: "uvx"' in content, 'default-tools.md should use uvx command'
|
||||
assert 'args: ["mcp-server-fetch"]' in content, (
|
||||
assert 'name: "fetch"' in content or 'name: fetch' in content, 'default-tools.md should have a fetch tool'
|
||||
assert 'command: uvx' in content, 'default-tools.md should use uvx command'
|
||||
assert 'mcp-server-fetch' in content, (
|
||||
'default-tools.md should use mcp-server-fetch'
|
||||
)
|
||||
|
||||
|
||||
@@ -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, or send keys to interrupt/kill the command.]"
|
||||
"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.]"
|
||||
},
|
||||
"hidden": false
|
||||
},
|
||||
|
||||
@@ -5,6 +5,15 @@ 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():
|
||||
@@ -83,12 +92,7 @@ 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 == (
|
||||
'\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.suffix == get_no_change_timeout_suffix(2)
|
||||
assert obs.metadata.prefix == ''
|
||||
|
||||
# Continue watching output
|
||||
@@ -96,12 +100,7 @@ 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 == (
|
||||
'\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.suffix == get_no_change_timeout_suffix(2)
|
||||
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
|
||||
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
|
||||
|
||||
@@ -142,12 +141,7 @@ 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 == (
|
||||
'\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.suffix == get_no_change_timeout_suffix(3)
|
||||
assert obs.metadata.prefix == ''
|
||||
|
||||
# Send input
|
||||
@@ -164,36 +158,21 @@ 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 == (
|
||||
'\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.suffix == get_no_change_timeout_suffix(3)
|
||||
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 == (
|
||||
'\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.suffix == get_no_change_timeout_suffix(3)
|
||||
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 == (
|
||||
'\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.suffix == get_no_change_timeout_suffix(3)
|
||||
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
|
||||
|
||||
obs = session.execute(CmdRunAction('EOF', is_input=True))
|
||||
@@ -216,12 +195,7 @@ def test_ctrl_c():
|
||||
)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert 'looping' in obs.content
|
||||
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.suffix == get_no_change_timeout_suffix(2)
|
||||
assert obs.metadata.prefix == ''
|
||||
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
|
||||
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
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)
|
||||
@@ -2,6 +2,7 @@ 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
|
||||
|
||||
@@ -10,22 +11,24 @@ 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
|
||||
invalid_url = 'http://non-existent-domain-that-will-timeout.invalid'
|
||||
server = MCPSSEServerConfig(
|
||||
url='http://non-existent-domain-that-will-timeout.invalid'
|
||||
)
|
||||
|
||||
# Temporarily modify the default timeout for the MCPClient.connect_sse method
|
||||
original_connect_sse = MCPClient.connect_sse
|
||||
# Temporarily modify the default timeout for the MCPClient.connect_http method
|
||||
original_connect_connect_http = MCPClient.connect_http
|
||||
|
||||
# Create a wrapper that calls the original method but with a shorter timeout
|
||||
async def connect_sse_with_short_timeout(self, server_url, timeout=30.0):
|
||||
return await original_connect_sse(self, server_url, timeout=0.5)
|
||||
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)
|
||||
|
||||
try:
|
||||
# Replace the method with our wrapper
|
||||
MCPClient.connect_sse = connect_sse_with_short_timeout
|
||||
MCPClient.connect_http = connect_http_with_short_timeout
|
||||
|
||||
# Call create_mcp_clients with the invalid URL
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
clients = await create_mcp_clients([invalid_url], [])
|
||||
clients = await create_mcp_clients([server], [])
|
||||
end_time = asyncio.get_event_loop().time()
|
||||
|
||||
# Verify that no clients were successfully connected
|
||||
@@ -38,7 +41,7 @@ async def test_create_mcp_clients_timeout_with_invalid_url():
|
||||
)
|
||||
finally:
|
||||
# Restore the original method
|
||||
MCPClient.connect_sse = original_connect_sse
|
||||
MCPClient.connect_http = original_connect_connect_http
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -48,16 +51,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_sse method
|
||||
original_connect_sse = MCPClient.connect_sse
|
||||
# Temporarily modify the default timeout for the MCPClient.connect_http method
|
||||
original_connect_http = MCPClient.connect_http
|
||||
|
||||
# Create a wrapper that calls the original method but with a shorter timeout
|
||||
async def connect_sse_with_short_timeout(self, server_url, timeout=30.0):
|
||||
return await original_connect_sse(self, server_url, timeout=1.0)
|
||||
async def connect_http_with_short_timeout(self, server_url, timeout=30.0):
|
||||
return await original_connect_http(self, server_url, timeout=1.0)
|
||||
|
||||
try:
|
||||
# Replace the method with our wrapper
|
||||
MCPClient.connect_sse = connect_sse_with_short_timeout
|
||||
MCPClient.connect_http = connect_http_with_short_timeout
|
||||
|
||||
# Call create_mcp_clients with the unreachable URL
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
@@ -73,4 +76,4 @@ async def test_create_mcp_clients_with_unreachable_host():
|
||||
)
|
||||
finally:
|
||||
# Restore the original method
|
||||
MCPClient.connect_sse = original_connect_sse
|
||||
MCPClient.connect_http = original_connect_http
|
||||
|
||||
@@ -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_sse is called
|
||||
async def mock_connect_sse(*args, **kwargs):
|
||||
# Configure the mock to raise a TimeoutError when connect_http is called
|
||||
async def mock_connect_http(*args, **kwargs):
|
||||
await asyncio.sleep(0.1) # Simulate some delay
|
||||
raise asyncio.TimeoutError('Connection timed out')
|
||||
|
||||
mock_client.connect_sse.side_effect = mock_connect_sse
|
||||
mock_client.connect_http.side_effect = mock_connect_http
|
||||
mock_client.disconnect = mock.AsyncMock()
|
||||
|
||||
# Mock the MCPClient constructor to return our mock
|
||||
@@ -35,11 +35,8 @@ async def test_sse_connection_timeout():
|
||||
# Verify that no clients were successfully connected
|
||||
assert len(clients) == 0
|
||||
|
||||
# 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
|
||||
# Verify that connect_http was called for each server
|
||||
assert mock_client.connect_http.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -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_sse = AsyncMock()
|
||||
mock_client_instance.connect_http = 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_sse was called with correct parameters
|
||||
mock_client_instance.connect_sse.assert_any_call(
|
||||
'http://server1:8080', api_key=None, conversation_id=None
|
||||
# Check that connect_http was called with correct parameters
|
||||
mock_client_instance.connect_http.assert_any_call(
|
||||
server_configs[0], conversation_id=None
|
||||
)
|
||||
mock_client_instance.connect_sse.assert_any_call(
|
||||
'http://server2:8080', api_key='test-key', conversation_id=None
|
||||
mock_client_instance.connect_http.assert_any_call(
|
||||
server_configs[1], conversation_id=None
|
||||
)
|
||||
|
||||
|
||||
@@ -56,11 +56,10 @@ 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_sse.side_effect = [
|
||||
mock_client_instance.connect_http.side_effect = [
|
||||
None, # Success
|
||||
Exception('Connection failed'), # Failure
|
||||
]
|
||||
mock_client_instance.disconnect = AsyncMock()
|
||||
|
||||
server_configs = [
|
||||
MCPSSEServerConfig(url='http://server1:8080'),
|
||||
@@ -71,7 +70,6 @@ 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():
|
||||
|
||||
@@ -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: task
|
||||
type: invalid_type
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
@@ -196,7 +196,8 @@ 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: "task"' in error_msg
|
||||
assert 'Invalid "type" value: "invalid_type"' 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
|
||||
|
||||
@@ -28,6 +28,9 @@ class MockUserAuth(UserAuth):
|
||||
async def get_user_id(self) -> str | None:
|
||||
return 'test-user'
|
||||
|
||||
async def get_user_email(self) -> str | None:
|
||||
return 'test-email@whatever.com'
|
||||
|
||||
async def get_access_token(self) -> SecretStr | None:
|
||||
return SecretStr('test-token')
|
||||
|
||||
|
||||
@@ -27,6 +27,9 @@ class MockUserAuth(UserAuth):
|
||||
async def get_user_id(self) -> str | None:
|
||||
return 'test-user'
|
||||
|
||||
async def get_user_email(self) -> str | None:
|
||||
return 'test-email@whatever.com'
|
||||
|
||||
async def get_access_token(self) -> SecretStr | None:
|
||||
return SecretStr('test-token')
|
||||
|
||||
|
||||
@@ -12,6 +12,16 @@ 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(
|
||||
@@ -168,10 +178,7 @@ 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 (
|
||||
"[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 get_timeout_suffix(1.0) in result.metadata.suffix
|
||||
assert result.exit_code == -1
|
||||
|
||||
# The action timed out, but the command should be still running
|
||||
|
||||
Reference in New Issue
Block a user