Compare commits

..

1 Commits

Author SHA1 Message Date
Robert Brennan
473fea06c9 fix up github error handling 2025-01-06 07:58:32 -05:00
126 changed files with 4021 additions and 5427 deletions

View File

@@ -18,24 +18,24 @@ diverse, inclusive, and healthy community.
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people.
* Being respectful of differing opinions, viewpoints, and experiences.
* Giving and gracefully accepting constructive feedback.
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience.
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall
community.
community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of
any kind.
* Trolling, insulting or derogatory comments, and personal or political attacks.
* Public or private harassment.
any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address,
without their explicit permission.
without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting.
professional setting
## Enforcement Responsibilities
@@ -61,7 +61,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
contact@all-hands.dev.
contact@all-hands.dev
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the

View File

@@ -11,11 +11,11 @@ To understand the codebase, please refer to the README in each module:
- [agenthub](./openhands/agenthub/README.md)
- [server](./openhands/server/README.md)
## Setting up Your Development Environment
## Setting up your development environment
We have a separate doc [Development.md](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md) that tells you how to set up a development workflow.
## How Can I Contribute?
## How can I contribute?
There are many ways that you can contribute:
@@ -23,7 +23,7 @@ There are many ways that you can contribute:
2. **Send feedback** after each session by [clicking the thumbs-up thumbs-down buttons](https://docs.all-hands.dev/modules/usage/feedback), so we can see where things are working and failing, and also build an open dataset for training code agents.
3. **Improve the Codebase** by sending [PRs](#sending-pull-requests-to-openhands) (see details below). In particular, we have some [good first issues](https://github.com/All-Hands-AI/OpenHands/labels/good%20first%20issue) that may be ones to start on.
## What Can I Build?
## What can I build?
Here are a few ways you can help improve the codebase.
#### UI/UX
@@ -35,7 +35,7 @@ of the application, please open an issue first, or better, join the #frontend ch
to gather consensus from our design team first.
#### Improving the agent
Our main agent is the CodeAct agent. You can [see its prompts here](https://github.com/All-Hands-AI/OpenHands/tree/main/openhands/agenthub/codeact_agent).
Our main agent is the CodeAct agent. You can [see its prompts here](https://github.com/All-Hands-AI/OpenHands/tree/main/openhands/agenthub/codeact_agent)
Changes to these prompts, and to the underlying behavior in Python, can have a huge impact on user experience.
You can try modifying the prompts to see how they change the behavior of the agent as you use the app
@@ -63,7 +63,7 @@ At the moment, we have two kinds of tests: [`unit`](./tests/unit) and [`integrat
## Sending Pull Requests to OpenHands
You'll need to fork our repository to send us a Pull Request. You can learn more
about how to fork a GitHub repo and open a PR with your changes in [this article](https://medium.com/swlh/forks-and-pull-requests-how-to-contribute-to-github-repos-8843fac34ce8).
about how to fork a GitHub repo and open a PR with your changes in [this article](https://medium.com/swlh/forks-and-pull-requests-how-to-contribute-to-github-repos-8843fac34ce8)
### Pull Request title
As described [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json), a valid PR title should begin with one of the following prefixes:
@@ -103,7 +103,7 @@ Further, if you see an issue you like, please leave a "thumbs-up" or a comment,
### Making Pull Requests
We're generally happy to consider all pull requests with the evaluation process varying based on the type of change:
We're generally happy to consider all [PRs](https://github.com/All-Hands-AI/OpenHands/pulls), with the evaluation process varying based on the type of change:
#### For Small Improvements

View File

@@ -3,7 +3,7 @@ This guide is for people working on OpenHands and editing the source code.
If you wish to contribute your changes, check out the [CONTRIBUTING.md](https://github.com/All-Hands-AI/OpenHands/blob/main/CONTRIBUTING.md) on how to clone and setup the project initially before moving on.
Otherwise, you can clone the OpenHands project directly.
## Start the Server for Development
## Start the server for development
### 1. Requirements
* Linux, Mac OS, or [WSL on Windows](https://learn.microsoft.com/en-us/windows/wsl/install) [Ubuntu <= 22.04]
* [Docker](https://docs.docker.com/engine/install/) (For those on MacOS, make sure to allow the default Docker socket to be used from advanced settings!)
@@ -58,7 +58,7 @@ See [our documentation](https://docs.all-hands.dev/modules/usage/llms) for recom
### 4. Running the application
#### Option A: Run the Full Application
Once the setup is complete, this command starts both the backend and frontend servers, allowing you to interact with OpenHands:
Once the setup is complete, launching OpenHands is as simple as running a single command. This command starts both the backend and frontend servers seamlessly, allowing you to interact with OpenHands:
```bash
make run
```
@@ -75,11 +75,11 @@ make run
```
### 6. LLM Debugging
If you encounter any issues with the Language Model (LM) or you're simply curious, export DEBUG=1 in the environment and restart the backend.
OpenHands will log the prompts and responses in the logs/llm/CURRENT_DATE directory, allowing you to identify the causes.
If you encounter any issues with the Language Model (LM) or you're simply curious, you can inspect the actual LLM prompts and responses. To do so, export DEBUG=1 in the environment and restart the backend.
OpenHands will then log the prompts and responses in the logs/llm/CURRENT_DATE directory, allowing you to identify the causes.
### 7. Help
Need help or info on available targets and commands? Use the help command for all the guidance you need with OpenHands.
Need assistance or information on available targets and commands? The help command provides all the necessary guidance to ensure a smooth experience with OpenHands.
```bash
make help
```
@@ -93,14 +93,14 @@ poetry run pytest ./tests/unit/test_*.py
```
### 9. Add or update dependency
1. Add your dependency in `pyproject.toml` or use `poetry add xxx`.
2. Update the poetry.lock file via `poetry lock --no-update`.
1. Add your dependency in `pyproject.toml` or use `poetry add xxx`
2. Update the poetry.lock file via `poetry lock --no-update`
### 9. Use existing Docker image
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image by
setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.19-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.18-nikolaik`
## Develop inside Docker container
@@ -110,7 +110,7 @@ TL;DR
make docker-dev
```
See more details [here](./containers/dev/README.md).
See more details [here](./containers/dev/README.md)
If you are just interested in running `OpenHands` without installing all the required tools on your host.

View File

@@ -2,8 +2,8 @@
These are the procedures and guidelines on how issues are triaged in this repo by the maintainers.
## General
* Most issues must be tagged with **enhancement** or **bug**.
* Issues may be tagged with what it relates to (**backend**, **frontend**, **agent quality**, etc.).
* Most issues must be tagged with **enhancement** or **bug**
* Issues may be tagged with what it relates to (**backend**, **frontend**, **agent quality**, etc.)
## Severity
* **Low**: Minor issues or affecting single user.
@@ -11,10 +11,10 @@ These are the procedures and guidelines on how issues are triaged in this repo b
* **Critical**: Affecting all users or potential security issues.
## Effort
* Issues may be estimated with effort required (**small effort**, **medium effort**, **large effort**).
* Issues may be estimated with effort required (**small effort**, **medium effort**, **large effort**)
## Difficulty
* Issues with low implementation difficulty may be tagged with **good first issue**.
* Issues with low implementation difficulty may be tagged with **good first issue**
## Not Enough Information
* User is asked to provide more information (logs, how to reproduce, etc.) when the issue is not clear.

View File

@@ -43,17 +43,17 @@ See the [Installation](https://docs.all-hands.dev/modules/usage/installation) gu
system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.19
docker.all-hands.dev/all-hands-ai/openhands:0.18
```
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!

View File

@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.19-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.18-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -11,7 +11,7 @@ services:
- BACKEND_HOST=${BACKEND_HOST:-"0.0.0.0"}
- SANDBOX_API_HOSTNAME=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.19-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.18-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -52,7 +52,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -61,7 +61,7 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.19 \
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
python -m openhands.core.cli
```

View File

@@ -46,7 +46,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -56,6 +56,6 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.19 \
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
```

View File

@@ -13,16 +13,16 @@
La façon la plus simple d'exécuter OpenHands est avec Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.19
docker.all-hands.dev/all-hands-ai/openhands:0.18
```
Vous pouvez également exécuter OpenHands en mode [headless scriptable](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), en tant que [CLI interactive](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), ou en utilisant l'[Action GitHub OpenHands](https://docs.all-hands.dev/modules/usage/how-to/github-action).

View File

@@ -13,7 +13,7 @@ C'est le Runtime par défaut qui est utilisé lorsque vous démarrez OpenHands.
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```

View File

@@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -59,7 +59,7 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.19 \
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
python -m openhands.core.cli
```

View File

@@ -47,7 +47,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -57,6 +57,6 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.19 \
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
```

View File

@@ -11,16 +11,16 @@
在 Docker 中运行 OpenHands 是最简单的方式。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.19
docker.all-hands.dev/all-hands-ai/openhands:0.18
```
你也可以在可脚本化的[无头模式](https://docs.all-hands.dev/modules/usage/how-to/headless-mode)下运行 OpenHands作为[交互式 CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),或使用 [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action)。

View File

@@ -11,7 +11,7 @@
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```

View File

@@ -35,7 +35,7 @@ To run OpenHands in CLI mode with Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -45,7 +45,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.19 \
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
python -m openhands.core.cli
```

View File

@@ -32,7 +32,7 @@ To run OpenHands in Headless mode with Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -43,7 +43,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.19 \
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
python -m openhands.core.main -t "write a bash script that prints hi"
```

View File

@@ -11,17 +11,17 @@
The easiest way to run OpenHands is in Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.19
docker.all-hands.dev/all-hands-ai/openhands:0.18
```
You'll find OpenHands running at http://localhost:3000!

View File

@@ -1,12 +1,10 @@
# Repository Micro-Agents
# Customizing Agent Behavior
OpenHands can be customized to work more effectively with specific repositories by providing repository-specific context
and guidelines. This section explains how to optimize OpenHands for your project.
OpenHands can be customized to work more effectively with specific repositories by providing repository-specific context and guidelines. This section explains how to optimize OpenHands for your project.
## Repository Configuration
You can customize OpenHands' behavior for your repository by creating a `.openhands/microagents/` directory in your repository's root.
At minimum, it should contain the file
You can customize OpenHands' behavior for your repository by creating a `.openhands` directory in your repository's root. At minimum, it should contain the file
`.openhands/microagents/repo.md`, which includes instructions that will
be given to the agent every time it works with this repository.
@@ -41,8 +39,7 @@ Guidelines:
### Customizing Prompts
You may also add customized prompts to the `.openhands/microagents/repo.md` file when working with a repository.
These could:
When working with a repository:
- **Reference Project Standards**: Mention specific coding standards or patterns used in your project.
- **Include Context**: Reference relevant documentation or existing implementations.
@@ -57,10 +54,14 @@ The component should use our shared styling from src/styles/components.
### Best Practices for Repository Customization
- **Keep Instructions Updated**: Regularly update your `.openhands/microagents/` directory as your project evolves.
- **Keep Instructions Updated**: Regularly update your `.openhands` directory as your project evolves.
- **Be Specific**: Include specific paths, patterns, and requirements unique to your project.
- **Document Dependencies**: List all tools and dependencies required for development.
- **Include Examples**: Provide examples of good code patterns from your project.
- **Specify Conventions**: Document naming conventions, file organization, and code style preferences.
By customizing OpenHands for your repository, you'll get more accurate and consistent results that align with your project's standards and requirements.
## Other Microagents
You can create other instructions in the `.openhands/microagents/` directory
that will be sent to the agent if a particular keyword is found, like `test`, `frontend`, or `migration`. See [Micro-Agents](microagents.md) for more information.

View File

@@ -1,31 +1,17 @@
# Public Micro-Agents
# Micro-Agents
OpenHands uses specialized micro-agents to handle specific tasks and contexts efficiently. These micro-agents are small,
focused components that provide specialized behavior and knowledge for particular scenarios.
OpenHands uses specialized micro-agents to handle specific tasks and contexts efficiently. These micro-agents are small, focused components that provide specialized behavior and knowledge for particular scenarios.
## Overview
Public micro-agents are defined in markdown files under the
[`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge) directory.
Each micro-agent is configured with:
Micro-agents are defined in markdown files under the `openhands/agenthub/codeact_agent/micro/` directory. Each micro-agent is configured with:
- A unique name.
- The agent type (typically CodeActAgent).
- Trigger keywords that activate the agent.
- Specific instructions and capabilities.
### Integration
Public micro-agents are automatically integrated into OpenHands' workflow. They:
- Monitor incoming commands for their trigger words.
- Activate when relevant triggers are detected.
- Apply their specialized knowledge and capabilities.
- Follow their specific guidelines and restrictions.
## Available Public Micro-Agents
For more information about specific micro-agents, refer to their individual documentation files in
the [`micro-agents`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents) directory.
## Available Micro-Agents
### GitHub Agent
**File**: `github.md`
@@ -43,14 +29,6 @@ Key features:
- Git configuration management
- API-first approach for GitHub operations
Usage Example:
```bash
git checkout -b feature-branch
git commit -m "Add new feature"
git push origin feature-branch
```
### NPM Agent
**File**: `npm.md`
**Triggers**: `npm`
@@ -60,15 +38,9 @@ Specializes in handling npm package management with specific focus on:
- Automated confirmation handling using Unix 'yes' command.
- Package installation automation.
Usage Example:
### Custom Micro-Agents
```bash
yes | npm install package-name
```
### Custom Public Micro-Agents
You can create your own public micro-agents by adding new markdown files to the `microagents/knowledge/` directory.
You can create your own micro-agents by adding new markdown files to the micro-agents directory.
Each file should follow this structure:
```markdown
@@ -83,29 +55,43 @@ triggers:
Instructions and capabilities for the micro-agent...
```
## Working With Public Micro-Agents
## Best Practices
When working with public micro-agents:
When working with micro-agents:
- **Use Appropriate Triggers**: Ensure your commands include the relevant trigger words to activate the correct micro-agent.
- **Follow Agent Guidelines**: Each agent has specific instructions and limitations. Respect these for optimal results.
- **API-First Approach**: When available, use API endpoints rather than web interfaces.
- **Automation Friendly**: Design commands that work well in non-interactive environments.
## Contributing a Public Micro-Agent
## Integration
Best practices for creating public micro-agents:
Micro-agents are automatically integrated into OpenHands' workflow. They:
- Monitor incoming commands for their trigger words.
- Activate when relevant triggers are detected.
- Apply their specialized knowledge and capabilities.
- Follow their specific guidelines and restrictions.
- **Clear Scope**: Keep the micro-agent focused on a specific domain or task.
- **Explicit Instructions**: Provide clear, unambiguous guidelines.
- **Useful Examples**: Include practical examples of common use cases.
- **Safety First**: Include necessary warnings and constraints.
- **Integration Awareness**: Consider how the micro-agent interacts with other components.
## Example Usage
To contribute a new micro-agent to OpenHands:
```bash
# GitHub agent example
git checkout -b feature-branch
git commit -m "Add new feature"
git push origin feature-branch
### 1. Plan the Public Micro-Agent
# NPM agent example
yes | npm install package-name
```
Before creating a public micro-agent, consider:
For more information about specific agents, refer to their individual documentation files in the micro-agents directory.
## Contributing a Micro-Agent
To contribute a new micro-agent to OpenHands, follow these guidelines:
### 1. Planning Your Micro-Agent
Before creating a micro-agent, consider:
- What specific problem or use case will it address?
- What unique capabilities or knowledge should it have?
- What trigger words make sense for activating it?
@@ -113,11 +99,11 @@ Before creating a public micro-agent, consider:
### 2. File Structure
Create a new markdown file in `microagents/knowledge/` with a descriptive name (e.g., `docker.md` for a Docker-focused agent).
Create a new markdown file in `openhands/agenthub/codeact_agent/micro/` with a descriptive name (e.g., `docker.md` for a Docker-focused agent).
### 3. Required Components
The micro-agent file must include:
Your micro-agent file must include:
- **Front Matter**: YAML metadata at the start of the file:
```markdown
@@ -147,7 +133,15 @@ Examples of usage:
[Example 2]
```
### 4. Testing the Public Micro-Agent
### 4. Best Practices for Micro-Agent Development
- **Clear Scope**: Keep the agent focused on a specific domain or task.
- **Explicit Instructions**: Provide clear, unambiguous guidelines.
- **Useful Examples**: Include practical examples of common use cases.
- **Safety First**: Include necessary warnings and constraints.
- **Integration Awareness**: Consider how the agent interacts with other components.
### 5. Testing Your Micro-Agent
Before submitting:
- Test the agent with various prompts.
@@ -155,14 +149,7 @@ Before submitting:
- Ensure instructions are clear and comprehensive.
- Check for potential conflicts with existing agents.
### 5. Submission Process
Submit a pull request with:
- The new micro-agent file.
- Updated documentation if needed.
- Description of the agent's purpose and capabilities.
### Example Public Micro-Agent Implementation
### 6. Example Implementation
Here's a template for a new micro-agent:
@@ -210,5 +197,14 @@ Remember to:
- Optimize for build time and image size
```
### 7. Submission Process
1. Create your micro-agent file in the correct directory.
2. Test thoroughly.
3. Submit a pull request with:
- The new micro-agent file.
- Updated documentation if needed.
- Description of the agent's purpose and capabilities.
Remember that micro-agents are a powerful way to extend OpenHands' capabilities in specific domains. Well-designed
agents can significantly improve the system's ability to handle specialized tasks.

View File

@@ -16,7 +16,7 @@ some flags being passed to `docker run` that make this possible:
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```

1318
docs/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,10 +15,10 @@
"typecheck": "tsc"
},
"dependencies": {
"@docusaurus/core": "^3.7.0",
"@docusaurus/plugin-content-pages": "^3.7.0",
"@docusaurus/preset-classic": "^3.7.0",
"@docusaurus/theme-mermaid": "^3.7.0",
"@docusaurus/core": "^3.6.3",
"@docusaurus/plugin-content-pages": "^3.6.3",
"@docusaurus/preset-classic": "^3.6.3",
"@docusaurus/theme-mermaid": "^3.6.3",
"@mdx-js/react": "^3.1.0",
"clsx": "^2.0.0",
"prism-react-renderer": "^2.4.1",
@@ -29,7 +29,7 @@
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.5.1",
"@docusaurus/tsconfig": "^3.7.0",
"@docusaurus/tsconfig": "^3.6.3",
"@docusaurus/types": "^3.5.1",
"typescript": "~5.7.2"
},

View File

@@ -23,21 +23,15 @@ const sidebars: SidebarsConfig = {
id: 'usage/prompting/prompting-best-practices',
},
{
type: 'category',
label: 'Micro-Agents',
items: [
{
type: 'doc',
label: 'Public',
id: 'usage/prompting/microagents-public',
},
{
type: 'doc',
label: 'Repository',
id: 'usage/prompting/microagents-repo',
},
],
}
type: 'doc',
label: 'Customization',
id: 'usage/prompting/customization',
},
{
type: 'doc',
label: 'Microagents',
id: 'usage/prompting/microagents',
},
],
},
{

View File

@@ -204,7 +204,7 @@ Then, in a separate Python environment with `streamlit` library, you can run the
```bash
# Make sure you are inside the cloned `evaluation` repo
conda activate streamlit # if you follow the optional conda env setup above
streamlit run app.py --server.port 8501 --server.address 0.0.0.0
streamlit app.py --server.port 8501 --server.address 0.0.0.0
```
Then you can access the SWE-Bench trajectory visualizer at `localhost:8501`.

View File

@@ -15,7 +15,6 @@ from evaluation.utils.shared import (
EvalOutput,
assert_and_raise,
codeact_user_response,
get_metrics,
is_fatal_evaluation_error,
make_metadata,
prepare_dataset,
@@ -149,7 +148,6 @@ def get_config(
codeact_enable_jupyter=False,
codeact_enable_browsing=RUN_WITH_BROWSING,
codeact_enable_llm_editor=False,
condenser=metadata.condenser_config,
)
config.set_agent_config(agent_config)
return config
@@ -450,7 +448,7 @@ def process_instance(
# NOTE: this is NO LONGER the event stream, but an agent history that includes delegate agent's events
histories = [event_to_dict(event) for event in state.history]
metrics = get_metrics(state)
metrics = state.metrics.get() if state.metrics else None
# Save the output
output = EvalOutput(

View File

@@ -17,10 +17,6 @@ from tqdm import tqdm
from openhands.controller.state.state import State
from openhands.core.config import LLMConfig
from openhands.core.config.condenser_config import (
CondenserConfig,
NoOpCondenserConfig,
)
from openhands.core.exceptions import (
AgentRuntimeBuildError,
AgentRuntimeDisconnectedError,
@@ -37,7 +33,6 @@ from openhands.events.action.message import MessageAction
from openhands.events.event import Event
from openhands.events.serialization.event import event_to_dict
from openhands.events.utils import get_pairs_from_events
from openhands.memory.condenser import get_condensation_metadata
class EvalMetadata(BaseModel):
@@ -50,17 +45,11 @@ class EvalMetadata(BaseModel):
dataset: str | None = None
data_split: str | None = None
details: dict[str, Any] | None = None
condenser_config: CondenserConfig | None = None
def model_dump(self, *args, **kwargs):
dumped_dict = super().model_dump(*args, **kwargs)
# avoid leaking sensitive information
dumped_dict['llm_config'] = self.llm_config.to_safe_dict()
if hasattr(self.condenser_config, 'llm_config'):
dumped_dict['condenser_config']['llm_config'] = (
self.condenser_config.llm_config.to_safe_dict()
)
return dumped_dict
def model_dump_json(self, *args, **kwargs):
@@ -68,11 +57,6 @@ class EvalMetadata(BaseModel):
dumped_dict = json.loads(dumped)
# avoid leaking sensitive information
dumped_dict['llm_config'] = self.llm_config.to_safe_dict()
if hasattr(self.condenser_config, 'llm_config'):
dumped_dict['condenser_config']['llm_config'] = (
self.condenser_config.llm_config.to_safe_dict()
)
logger.debug(f'Dumped metadata: {dumped_dict}')
return json.dumps(dumped_dict)
@@ -208,7 +192,6 @@ def make_metadata(
eval_output_dir: str,
data_split: str | None = None,
details: dict[str, Any] | None = None,
condenser_config: CondenserConfig | None = None,
) -> EvalMetadata:
model_name = llm_config.model.split('/')[-1]
model_path = model_name.replace(':', '_').replace('@', '-')
@@ -239,9 +222,6 @@ def make_metadata(
dataset=dataset_name,
data_split=data_split,
details=details,
condenser_config=condenser_config
if condenser_config
else NoOpCondenserConfig(),
)
metadata_json = metadata.model_dump_json()
logger.info(f'Metadata: {metadata_json}')
@@ -571,10 +551,3 @@ def is_fatal_evaluation_error(error: str | None) -> bool:
return True
return False
def get_metrics(state: State) -> dict[str, Any]:
"""Extract metrics from the state."""
metrics = state.metrics.get() if state.metrics else {}
metrics['condenser'] = get_condensation_metadata(state)
return metrics

View File

@@ -2,13 +2,13 @@ import { describe, expect, it, vi } from "vitest";
import { retrieveLatestGitHubCommit } from "../../src/api/github";
describe("retrieveLatestGitHubCommit", () => {
const { openHandsGetMock } = vi.hoisted(() => ({
openHandsGetMock: vi.fn(),
const { githubGetMock } = vi.hoisted(() => ({
githubGetMock: vi.fn(),
}));
vi.mock("../../src/api/open-hands-axios", () => ({
openHands: {
get: openHandsGetMock,
vi.mock("../../src/api/github-axios-instance", () => ({
github: {
get: githubGetMock,
},
}));
@@ -20,7 +20,7 @@ describe("retrieveLatestGitHubCommit", () => {
},
};
openHandsGetMock.mockResolvedValueOnce({
githubGetMock.mockResolvedValueOnce({
data: [mockCommit],
});
@@ -31,7 +31,7 @@ describe("retrieveLatestGitHubCommit", () => {
it("should return null when repository is empty", async () => {
const error = new Error("Repository is empty");
(error as any).response = { status: 409 };
openHandsGetMock.mockRejectedValueOnce(error);
githubGetMock.mockRejectedValueOnce(error);
const result = await retrieveLatestGitHubCommit("user/empty-repo");
expect(result).toBeNull();
@@ -40,7 +40,7 @@ describe("retrieveLatestGitHubCommit", () => {
it("should throw error for other error cases", async () => {
const error = new Error("Network error");
(error as any).response = { status: 500 };
openHandsGetMock.mockRejectedValueOnce(error);
githubGetMock.mockRejectedValueOnce(error);
await expect(retrieveLatestGitHubCommit("user/repo")).rejects.toThrow();
});

View File

@@ -3,7 +3,6 @@ import { afterEach, describe, expect, it, test, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { formatTimeDelta } from "#/utils/format-time-delta";
import { ConversationCard } from "#/components/features/conversation-panel/conversation-card";
import { clickOnEditButton } from "./utils";
describe("ConversationCard", () => {
const onClick = vi.fn();
@@ -20,9 +19,9 @@ describe("ConversationCard", () => {
onDelete={onDelete}
onClick={onClick}
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
name="Conversation 1"
repo={null}
lastUpdated="2021-10-01T12:00:00Z"
/>,
);
const expectedDate = `${formatTimeDelta(new Date("2021-10-01T12:00:00Z"))} ago`;
@@ -34,20 +33,20 @@ describe("ConversationCard", () => {
within(card).getByText(expectedDate);
});
it("should render the selectedRepository if available", () => {
it("should render the repo if available", () => {
const { rerender } = render(
<ConversationCard
onDelete={onDelete}
onClick={onClick}
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
name="Conversation 1"
repo={null}
lastUpdated="2021-10-01T12:00:00Z"
/>,
);
expect(
screen.queryByTestId("conversation-card-selected-repository"),
screen.queryByTestId("conversation-card-repo"),
).not.toBeInTheDocument();
rerender(
@@ -55,13 +54,13 @@ describe("ConversationCard", () => {
onDelete={onDelete}
onClick={onClick}
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository="org/selectedRepository"
lastUpdatedAt="2021-10-01T12:00:00Z"
name="Conversation 1"
repo="org/repo"
lastUpdated="2021-10-01T12:00:00Z"
/>,
);
screen.getByTestId("conversation-card-selected-repository");
screen.getByTestId("conversation-card-repo");
});
it("should call onClick when the card is clicked", async () => {
@@ -71,9 +70,9 @@ describe("ConversationCard", () => {
onDelete={onDelete}
onClick={onClick}
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
name="Conversation 1"
repo={null}
lastUpdated="2021-10-01T12:00:00Z"
/>,
);
@@ -90,9 +89,9 @@ describe("ConversationCard", () => {
onDelete={onDelete}
onClick={onClick}
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
name="Conversation 1"
repo={null}
lastUpdated="2021-10-01T12:00:00Z"
/>,
);
@@ -115,9 +114,9 @@ describe("ConversationCard", () => {
onClick={onClick}
onDelete={onDelete}
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
name="Conversation 1"
repo={null}
lastUpdated="2021-10-01T12:00:00Z"
/>,
);
@@ -132,23 +131,21 @@ describe("ConversationCard", () => {
expect(onDelete).toHaveBeenCalled();
});
test("clicking the selectedRepository should not trigger the onClick handler", async () => {
test("clicking the repo should not trigger the onClick handler", async () => {
const user = userEvent.setup();
render(
<ConversationCard
onClick={onClick}
onDelete={onDelete}
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository="org/selectedRepository"
lastUpdatedAt="2021-10-01T12:00:00Z"
name="Conversation 1"
repo="org/repo"
lastUpdated="2021-10-01T12:00:00Z"
/>,
);
const selectedRepository = screen.getByTestId(
"conversation-card-selected-repository",
);
await user.click(selectedRepository);
const repo = screen.getByTestId("conversation-card-repo");
await user.click(repo);
expect(onClick).not.toHaveBeenCalled();
});
@@ -159,22 +156,14 @@ describe("ConversationCard", () => {
<ConversationCard
onClick={onClick}
onDelete={onDelete}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
name="Conversation 1"
repo={null}
lastUpdated="2021-10-01T12:00:00Z"
onChangeTitle={onChangeTitle}
/>,
);
const title = screen.getByTestId("conversation-card-title");
expect(title).toBeDisabled();
await clickOnEditButton(user);
expect(title).toBeEnabled();
expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
// expect to be focused
expect(document.activeElement).toBe(title);
await user.clear(title);
await user.type(title, "New Conversation Name ");
@@ -182,7 +171,6 @@ describe("ConversationCard", () => {
expect(onChangeTitle).toHaveBeenCalledWith("New Conversation Name");
expect(title).toHaveValue("New Conversation Name");
expect(title).toBeDisabled();
});
it("should reset title and not call onChangeTitle when the title is empty", async () => {
@@ -192,14 +180,12 @@ describe("ConversationCard", () => {
onClick={onClick}
onDelete={onDelete}
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
name="Conversation 1"
repo={null}
lastUpdated="2021-10-01T12:00:00Z"
/>,
);
await clickOnEditButton(user);
const title = screen.getByTestId("conversation-card-title");
await user.clear(title);
@@ -216,9 +202,9 @@ describe("ConversationCard", () => {
onClick={onClick}
onDelete={onDelete}
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
name="Conversation 1"
repo={null}
lastUpdated="2021-10-01T12:00:00Z"
/>,
);
@@ -235,9 +221,9 @@ describe("ConversationCard", () => {
onClick={onClick}
onDelete={onDelete}
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
name="Conversation 1"
repo={null}
lastUpdated="2021-10-01T12:00:00Z"
/>,
);
@@ -253,19 +239,19 @@ describe("ConversationCard", () => {
});
describe("state indicator", () => {
it("should render the 'STOPPED' indicator by default", () => {
it("should render the 'cold' indicator by default", () => {
render(
<ConversationCard
onClick={onClick}
onDelete={onDelete}
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
name="Conversation 1"
repo={null}
lastUpdated="2021-10-01T12:00:00Z"
/>,
);
screen.getByTestId("STOPPED-indicator");
screen.getByTestId("cold-indicator");
});
it("should render the other indicators when provided", () => {
@@ -274,15 +260,15 @@ describe("ConversationCard", () => {
onClick={onClick}
onDelete={onDelete}
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
status="RUNNING"
name="Conversation 1"
repo={null}
lastUpdated="2021-10-01T12:00:00Z"
state="warm"
/>,
);
expect(screen.queryByTestId("STOPPED-indicator")).not.toBeInTheDocument();
screen.getByTestId("RUNNING-indicator");
expect(screen.queryByTestId("cold-indicator")).not.toBeInTheDocument();
screen.getByTestId("warm-indicator");
});
});
});

View File

@@ -9,7 +9,6 @@ import userEvent from "@testing-library/user-event";
import { ConversationPanel } from "#/components/features/conversation-panel/conversation-panel";
import OpenHands from "#/api/open-hands";
import { AuthProvider } from "#/context/auth-context";
import { clickOnEditButton } from "./utils";
describe("ConversationPanel", () => {
const onCloseMock = vi.fn();
@@ -53,8 +52,6 @@ describe("ConversationPanel", () => {
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// NOTE that we filter out conversations that don't have a created_at property
// (mock data has 4 conversations, but only 3 have a created_at property)
expect(cards).toHaveLength(3);
});
@@ -172,15 +169,13 @@ describe("ConversationPanel", () => {
const cards = await screen.findAllByTestId("conversation-card");
const title = within(cards[0]).getByTestId("conversation-card-title");
await clickOnEditButton(user);
await user.clear(title);
await user.type(title, "Conversation 1 Renamed");
await user.tab();
// Ensure the conversation is renamed
expect(updateUserConversationSpy).toHaveBeenCalledWith("3", {
title: "Conversation 1 Renamed",
name: "Conversation 1 Renamed",
});
});
@@ -201,8 +196,6 @@ describe("ConversationPanel", () => {
// Ensure the conversation is not renamed
expect(updateUserConversationSpy).not.toHaveBeenCalled();
await clickOnEditButton(user);
await user.type(title, "Conversation 1");
await user.click(title);
await user.tab();
@@ -224,4 +217,51 @@ describe("ConversationPanel", () => {
expect(onCloseMock).toHaveBeenCalledOnce();
});
describe("New Conversation Button", () => {
it("should display a confirmation modal when clicking", async () => {
const user = userEvent.setup();
renderConversationPanel();
expect(
screen.queryByTestId("confirm-new-conversation-modal"),
).not.toBeInTheDocument();
const newProjectButton = screen.getByTestId("new-conversation-button");
await user.click(newProjectButton);
const modal = screen.getByTestId("confirm-new-conversation-modal");
expect(modal).toBeInTheDocument();
});
it("should call endSession and close panel after confirming", async () => {
const user = userEvent.setup();
renderConversationPanel();
const newProjectButton = screen.getByTestId("new-conversation-button");
await user.click(newProjectButton);
const confirmButton = screen.getByText("Confirm");
await user.click(confirmButton);
expect(endSessionMock).toHaveBeenCalledOnce();
expect(onCloseMock).toHaveBeenCalledOnce();
});
it("should close the modal when cancelling", async () => {
const user = userEvent.setup();
renderConversationPanel();
const newProjectButton = screen.getByTestId("new-conversation-button");
await user.click(newProjectButton);
const cancelButton = screen.getByText("Cancel");
await user.click(cancelButton);
expect(endSessionMock).not.toHaveBeenCalled();
expect(
screen.queryByTestId("confirm-new-conversation-modal"),
).not.toBeInTheDocument();
});
});
});

View File

@@ -1,12 +0,0 @@
import { screen, within } from "@testing-library/react";
import { UserEvent } from "@testing-library/user-event";
export const clickOnEditButton = async (user: UserEvent) => {
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const menu = screen.getByTestId("context-menu");
const editButton = within(menu).getByTestId("edit-button");
await user.click(editButton);
};

View File

@@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
import { renderWithProviders } from "test-utils";
import { createRoutesStub } from "react-router";
import { Sidebar } from "#/components/features/sidebar/sidebar";
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
const renderSidebar = () => {
const RouterStub = createRoutesStub([
@@ -18,7 +18,7 @@ const renderSidebar = () => {
};
describe("Sidebar", () => {
it.skipIf(!MULTI_CONVERSATION_UI)(
it.skipIf(!MULTI_CONVO_UI_IS_ENABLED)(
"should have the conversation panel open by default",
() => {
renderSidebar();
@@ -26,7 +26,7 @@ describe("Sidebar", () => {
},
);
it.skipIf(!MULTI_CONVERSATION_UI)(
it.skipIf(!MULTI_CONVO_UI_IS_ENABLED)(
"should toggle the conversation panel",
async () => {
const user = userEvent.setup();

View File

@@ -1,35 +0,0 @@
import { screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { renderWithProviders } from "test-utils";
import { RuntimeSizeSelector } from "#/components/shared/modals/settings/runtime-size-selector";
const renderRuntimeSizeSelector = () =>
renderWithProviders(<RuntimeSizeSelector isDisabled={false} />);
describe("RuntimeSizeSelector", () => {
it("should show both runtime size options", () => {
renderRuntimeSizeSelector();
// The options are in the hidden select element
const select = screen.getByRole("combobox", { hidden: true });
expect(select).toHaveValue("1");
expect(select).toHaveDisplayValue("1x (2 core, 8G)");
expect(select.children).toHaveLength(3); // Empty option + 2 size options
});
it("should show the full description text for disabled options", async () => {
renderRuntimeSizeSelector();
// Click the button to open the dropdown
const button = screen.getByRole("button", {
name: "1x (2 core, 8G) SETTINGS_FORM$RUNTIME_SIZE_LABEL",
});
button.click();
// Wait for the dropdown to open and find the description text
const description = await screen.findByText(
"Runtime sizes over 1 are disabled by default, please contact contact@all-hands.dev to get access to larger runtimes.",
);
expect(description).toBeInTheDocument();
expect(description).toHaveClass("whitespace-normal", "break-words");
});
});

View File

@@ -1,45 +0,0 @@
import { screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { createRoutesStub } from "react-router";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { SettingsForm } from "#/components/shared/modals/settings/settings-form";
import OpenHands from "#/api/open-hands";
describe("SettingsForm", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "123",
});
const RouterStub = createRoutesStub([
{
Component: () => (
<SettingsForm
settings={DEFAULT_SETTINGS}
models={[]}
agents={[]}
securityAnalyzers={[]}
onClose={() => {}}
/>
),
path: "/",
},
]);
it("should not show runtime size selector by default", () => {
renderWithProviders(<RouterStub />);
expect(screen.queryByText("Runtime Size")).not.toBeInTheDocument();
});
it("should show runtime size selector when advanced options are enabled", async () => {
renderWithProviders(<RouterStub />);
const advancedSwitch = screen.getByRole("switch", {
name: "SETTINGS_FORM$ADVANCED_OPTIONS_LABEL",
});
fireEvent.click(advancedSwitch);
await screen.findByText("SETTINGS_FORM$RUNTIME_SIZE_LABEL");
});
});

View File

@@ -1,30 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import * as ChatSlice from "#/state/chat-slice";
import {
updateStatusWhenErrorMessagePresent,
} from "#/context/ws-client-provider";
describe("Propagate error message", () => {
it("should do nothing when no message was passed from server", () => {
const addErrorMessageSpy = vi.spyOn(ChatSlice, "addErrorMessage");
updateStatusWhenErrorMessagePresent(null)
updateStatusWhenErrorMessagePresent(undefined)
updateStatusWhenErrorMessagePresent({})
updateStatusWhenErrorMessagePresent({message: null})
expect(addErrorMessageSpy).not.toHaveBeenCalled();
});
it("should display error to user when present", () => {
const message = "We have a problem!"
const addErrorMessageSpy = vi.spyOn(ChatSlice, "addErrorMessage")
updateStatusWhenErrorMessagePresent({message})
expect(addErrorMessageSpy).toHaveBeenCalledWith({
message,
status_update: true,
type: 'error'
});
});
});

View File

@@ -5,7 +5,7 @@ import { screen, waitFor } from "@testing-library/react";
import toast from "react-hot-toast";
import App from "#/routes/_oh.app/route";
import OpenHands from "#/api/open-hands";
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
describe("App", () => {
const RouteStub = createRoutesStub([
@@ -35,7 +35,7 @@ describe("App", () => {
await screen.findByTestId("app-route");
});
it.skipIf(!MULTI_CONVERSATION_UI)(
it.skipIf(!MULTI_CONVO_UI_IS_ENABLED)(
"should call endSession if the user does not have permission to view conversation",
async () => {
const errorToastSpy = vi.spyOn(toast, "error");
@@ -59,11 +59,10 @@ describe("App", () => {
getConversationSpy.mockResolvedValue({
conversation_id: "9999",
last_updated_at: "",
created_at: "",
title: "",
selected_repository: "",
status: "STOPPED",
lastUpdated: "",
name: "",
repo: "",
state: "cold",
});
const { rerender } = renderWithProviders(
<RouteStub initialEntries={["/conversation/9999"]} />,

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.19.0",
"version": "0.18.0",
"private": true,
"type": "module",
"engines": {
@@ -8,32 +8,32 @@
},
"dependencies": {
"@monaco-editor/react": "^4.7.0-rc.0",
"@nextui-org/react": "^2.6.11",
"@nextui-org/react": "^2.6.10",
"@react-router/node": "^7.1.1",
"@react-router/serve": "^7.1.1",
"@react-types/shared": "^3.25.0",
"@reduxjs/toolkit": "^2.5.0",
"@tanstack/react-query": "^5.63.0",
"@tanstack/react-query": "^5.62.12",
"@vitejs/plugin-react": "^4.3.2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.7.9",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"i18next": "^24.2.1",
"i18next": "^24.2.0",
"i18next-browser-languagedetector": "^8.0.2",
"i18next-http-backend": "^3.0.1",
"isbot": "^5.1.20",
"isbot": "^5.1.19",
"jose": "^5.9.4",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.205.0",
"posthog-js": "^1.203.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-highlight": "^0.15.0",
"react-hot-toast": "^2.5.1",
"react-i18next": "^15.4.0",
"react-icons": "^5.4.0",
"react-markdown": "^9.0.3",
"react-markdown": "^9.0.1",
"react-redux": "^9.2.0",
"react-router": "^7.1.1",
"react-syntax-highlighter": "^15.6.1",
@@ -78,13 +78,13 @@
"@mswjs/socket.io-binding": "^0.1.1",
"@playwright/test": "^1.49.1",
"@react-router/dev": "^7.1.1",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.62.16",
"@tailwindcss/typography": "^0.5.15",
"@tanstack/eslint-plugin-query": "^5.62.9",
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^22.10.5",
"@types/react": "^19.0.3",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@types/react-highlight": "^0.12.8",
"@types/react-syntax-highlighter": "^15.5.13",

View File

@@ -0,0 +1,103 @@
import axios, { AxiosError } from "axios";
const github = axios.create({
baseURL: "https://api.github.com",
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
const setAuthTokenHeader = (token: string) => {
github.defaults.headers.common.Authorization = `Bearer ${token}`;
};
const removeAuthTokenHeader = () => {
if (github.defaults.headers.common.Authorization) {
delete github.defaults.headers.common.Authorization;
}
};
/**
* Checks if response has attributes to perform refresh
*/
const canRefresh = (error: unknown): boolean =>
!!(
error instanceof AxiosError &&
error.config &&
error.response &&
error.response.status
);
/**
* Checks if the data is a GitHub error response
* @param data The data to check
* @returns Boolean indicating if the data is a GitHub error response
*/
export const isGitHubErrorReponse = <T extends object | Array<unknown>>(
data: T | GitHubErrorReponse | null,
): data is GitHubErrorReponse =>
!!data && "message" in data && data.message !== undefined;
// Axios interceptor to handle token refresh
const setupAxiosInterceptors = (
refreshToken: () => Promise<boolean>,
logout: () => void,
) => {
github.interceptors.response.use(
// Pass successful responses through
(response) => {
const parsedData = response.data;
if (isGitHubErrorReponse(parsedData)) {
const error = new AxiosError(
"Failed",
"",
response.config,
response.request,
response,
);
throw error;
}
return response;
},
// Retry request exactly once if token is expired
async (error) => {
if (!canRefresh(error)) {
return Promise.reject(new Error("Failed to refresh token"));
}
const originalRequest = error.config;
// Check if the error is due to an expired token
if (
error.response.status === 401 &&
!originalRequest._retry // Prevent infinite retry loops
) {
originalRequest._retry = true;
try {
const refreshed = await refreshToken();
if (refreshed) {
return await github(originalRequest);
}
logout();
return await Promise.reject(new Error("Failed to refresh token"));
} catch (refreshError) {
// If token refresh fails, evict the user
logout();
return Promise.reject(refreshError);
}
}
// If the error is not due to an expired token, propagate the error
return Promise.reject(error);
},
);
};
export {
github,
setAuthTokenHeader,
removeAuthTokenHeader,
setupAxiosInterceptors,
};

View File

@@ -1,18 +1,14 @@
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
import { github } from "./github-axios-instance";
import { openHands } from "./open-hands-axios";
export const isGitHubErrorReponse = <T extends object | Array<unknown>>(
data: T | GitHubErrorReponse | null,
): data is GitHubErrorReponse =>
!!data && "message" in data && data.message !== undefined;
/**
* Given the user, retrieves app installations IDs for OpenHands Github App
* Uses user access token for Github App
*/
export const retrieveGitHubAppInstallations = async (): Promise<number[]> => {
const response = await openHands.get<GithubAppInstallation>(
"/api/github/installations",
const response = await github.get<GithubAppInstallation>(
"/user/installations",
);
return response.data.installations.map((installation) => installation.id);
@@ -92,8 +88,20 @@ export const retrieveGitHubUserRepositories = async (
* @returns The authenticated user or an error response
*/
export const retrieveGitHubUser = async () => {
const response = await openHands.get<GitHubUser>("/api/github/user");
return response.data;
const response = await github.get<GitHubUser>("/user");
const { data } = response;
const user: GitHubUser = {
id: data.id,
login: data.login,
avatar_url: data.avatar_url,
company: data.company,
name: data.name,
email: data.email,
};
return user;
};
export const searchPublicRepositories = async (
@@ -102,11 +110,11 @@ export const searchPublicRepositories = async (
sort: "" | "updated" | "stars" | "forks" = "stars",
order: "desc" | "asc" = "desc",
): Promise<GitHubRepository[]> => {
const response = await openHands.get<{ items: GitHubRepository[] }>(
"/api/github/search/repositories",
const response = await github.get<{ items: GitHubRepository[] }>(
"/search/repositories",
{
params: {
query,
q: query,
per_page,
sort,
order,
@@ -120,9 +128,8 @@ export const retrieveLatestGitHubCommit = async (
repository: string,
): Promise<GitHubCommit | null> => {
try {
const [owner, repo] = repository.split("/");
const response = await openHands.get<GitHubCommit[]>(
`/api/github/repos/${owner}/${repo}/commits`,
const response = await github.get<GitHubCommit[]>(
`/repos/${repository}/commits`,
{
params: {
per_page: 1,

View File

@@ -9,7 +9,6 @@ import {
GetVSCodeUrlResponse,
AuthenticateResponse,
Conversation,
ResultSet,
} from "./open-hands.types";
import { openHands } from "./open-hands-axios";
import { ApiSettings } from "#/services/settings";
@@ -223,10 +222,8 @@ class OpenHands {
}
static async getUserConversations(): Promise<Conversation[]> {
const { data } = await openHands.get<ResultSet<Conversation>>(
"/api/conversations?limit=9",
);
return data.results;
const { data } = await openHands.get<Conversation[]>("/api/conversations");
return data;
}
static async deleteUserConversation(conversationId: string): Promise<void> {
@@ -235,9 +232,9 @@ class OpenHands {
static async updateUserConversation(
conversationId: string,
conversation: Partial<Omit<Conversation, "conversation_id">>,
conversation: Partial<Omit<Conversation, "id">>,
): Promise<void> {
await openHands.patch(`/api/conversations/${conversationId}`, conversation);
await openHands.put(`/api/conversations/${conversationId}`, conversation);
}
static async createConversation(

View File

@@ -1,4 +1,4 @@
import { ProjectStatus } from "#/components/features/conversation-panel/conversation-state-indicator";
import { ProjectState } from "#/components/features/conversation-panel/conversation-state-indicator";
export interface ErrorResponse {
error: string;
@@ -62,14 +62,8 @@ export interface AuthenticateResponse {
export interface Conversation {
conversation_id: string;
title: string;
selected_repository: string | null;
last_updated_at: string;
created_at: string;
status: ProjectStatus;
}
export interface ResultSet<T> {
results: T[];
next_page_id: string | null;
name: string;
repo: string | null;
lastUpdated: string;
state: ProjectState;
}

View File

@@ -18,7 +18,7 @@ export function ContextMenu({
<ul
data-testid={testId}
ref={ref}
className={cn("bg-[#404040] rounded-md w-[140px]", className)}
className={cn("bg-[#404040] rounded-md w-[224px]", className)}
>
{children}
</ul>

View File

@@ -25,14 +25,10 @@ export function ConfirmDeleteModal({
<div className="flex flex-col gap-2 w-full">
<ModalButton
onClick={onConfirm}
className="bg-danger font-bold"
className="bg-[#4465DB]"
text="Confirm"
/>
<ModalButton
onClick={onCancel}
className="bg-neutral-500 font-bold"
text="Cancel"
/>
<ModalButton onClick={onCancel} className="bg-danger" text="Cancel" />
</div>
</ModalBody>
</ModalBackdrop>

View File

@@ -1,32 +0,0 @@
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { ContextMenu } from "../context-menu/context-menu";
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
interface ConversationCardContextMenuProps {
onClose: () => void;
onDelete: (event: React.MouseEvent<HTMLButtonElement>) => void;
onEdit: (event: React.MouseEvent<HTMLButtonElement>) => void;
}
export function ConversationCardContextMenu({
onClose,
onDelete,
onEdit,
}: ConversationCardContextMenuProps) {
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
return (
<ContextMenu
ref={ref}
testId="context-menu"
className="left-full float-right"
>
<ContextMenuListItem testId="delete-button" onClick={onDelete}>
Delete
</ContextMenuListItem>
<ContextMenuListItem testId="edit-button" onClick={onEdit}>
Edit Title
</ContextMenuListItem>
</ContextMenu>
);
}

View File

@@ -2,33 +2,33 @@ import React from "react";
import { formatTimeDelta } from "#/utils/format-time-delta";
import { ConversationRepoLink } from "./conversation-repo-link";
import {
ProjectStatus,
ProjectState,
ConversationStateIndicator,
} from "./conversation-state-indicator";
import { ContextMenu } from "../context-menu/context-menu";
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
import { EllipsisButton } from "./ellipsis-button";
import { ConversationCardContextMenu } from "./conversation-card-context-menu";
interface ConversationCardProps {
interface ProjectCardProps {
onClick: () => void;
onDelete: () => void;
onChangeTitle: (title: string) => void;
title: string;
selectedRepository: string | null;
lastUpdatedAt: string; // ISO 8601
status?: ProjectStatus;
name: string;
repo: string | null;
lastUpdated: string; // ISO 8601
state?: ProjectState;
}
export function ConversationCard({
onClick,
onDelete,
onChangeTitle,
title,
selectedRepository,
lastUpdatedAt,
status = "STOPPED",
}: ConversationCardProps) {
name,
repo,
lastUpdated,
state = "cold",
}: ProjectCardProps) {
const [contextMenuVisible, setContextMenuVisible] = React.useState(false);
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
const inputRef = React.useRef<HTMLInputElement>(null);
const handleBlur = () => {
@@ -38,15 +38,7 @@ export function ConversationCard({
inputRef.current!.value = trimmed;
} else {
// reset the value if it's empty
inputRef.current!.value = title;
}
setTitleMode("view");
};
const handleKeyUp = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.currentTarget.blur();
inputRef.current!.value = name;
}
};
@@ -59,62 +51,51 @@ export function ConversationCard({
onDelete();
};
const handleEdit = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
setTitleMode("edit");
setContextMenuVisible(false);
};
React.useEffect(() => {
if (titleMode === "edit") {
inputRef.current?.focus();
}
}, [titleMode]);
return (
<div
data-testid="conversation-card"
onClick={onClick}
className="h-[100px] w-full px-[18px] py-4 border-b border-neutral-600 cursor-pointer"
className="h-[100px] w-full px-[18px] py-4 border-b border-neutral-600"
>
<div className="flex items-center justify-between space-x-1">
<div className="flex items-center justify-between">
<input
data-testid="conversation-card-title"
ref={inputRef}
disabled={titleMode === "view"}
data-testid="conversation-card-title"
onClick={handleInputClick}
onBlur={handleBlur}
onKeyUp={handleKeyUp}
type="text"
defaultValue={title}
className="text-sm leading-6 font-semibold bg-transparent w-full"
defaultValue={name}
className="text-sm leading-6 font-semibold bg-transparent"
/>
<div className="flex items-center gap-2 relative">
<ConversationStateIndicator status={status} />
<ConversationStateIndicator state={state} />
<EllipsisButton
onClick={(event) => {
event.stopPropagation();
setContextMenuVisible((prev) => !prev);
}}
/>
{contextMenuVisible && (
<ContextMenu testId="context-menu" className="absolute left-full">
<ContextMenuListItem
testId="delete-button"
onClick={handleDelete}
>
Delete
</ContextMenuListItem>
</ContextMenu>
)}
</div>
</div>
{contextMenuVisible && (
<ConversationCardContextMenu
onClose={() => setContextMenuVisible(false)}
onDelete={handleDelete}
onEdit={handleEdit}
/>
)}
{selectedRepository && (
{repo && (
<ConversationRepoLink
selectedRepository={selectedRepository}
repo={repo}
onClick={(e) => e.stopPropagation()}
/>
)}
<p className="text-xs text-neutral-400">
<time>{formatTimeDelta(new Date(lastUpdatedAt))} ago</time>
<time>{formatTimeDelta(new Date(lastUpdated))} ago</time>
</p>
</div>
);

View File

@@ -1,22 +0,0 @@
import ReactDOM from "react-dom";
interface ConversationPanelWrapperProps {
isOpen: boolean;
}
export function ConversationPanelWrapper({
isOpen,
children,
}: React.PropsWithChildren<ConversationPanelWrapperProps>) {
if (!isOpen) return null;
const portalTarget = document.getElementById("root-outlet");
if (!portalTarget) return null;
return ReactDOM.createPortal(
<div className="absolute h-full w-full left-0 top-0 z-20 bg-black/80 rounded-xl">
{children}
</div>,
portalTarget,
);
}

View File

@@ -1,14 +1,14 @@
import React from "react";
import { useNavigate, useParams } from "react-router";
import { useLocation, useNavigate, useParams } from "react-router";
import { ConversationCard } from "./conversation-card";
import { useUserConversations } from "#/hooks/query/use-user-conversations";
import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation";
import { ConfirmDeleteModal } from "./confirm-delete-modal";
import { NewConversationButton } from "./new-conversation-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { useUpdateConversation } from "#/hooks/mutation/use-update-conversation";
import { useEndSession } from "#/hooks/use-end-session";
import { ExitConversationModal } from "./exit-conversation-modal";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
interface ConversationPanelProps {
onClose: () => void;
@@ -17,8 +17,9 @@ interface ConversationPanelProps {
export function ConversationPanel({ onClose }: ConversationPanelProps) {
const { conversationId: cid } = useParams();
const navigate = useNavigate();
const location = useLocation();
const endSession = useEndSession();
const ref = useClickOutsideElement<HTMLDivElement>(onClose);
const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] =
React.useState(false);
@@ -59,7 +60,7 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
if (oldTitle !== newTitle)
updateConversation({
id: conversationId,
conversation: { title: newTitle },
conversation: { name: newTitle },
});
};
@@ -70,11 +71,15 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
return (
<div
ref={ref}
data-testid="conversation-panel"
className="w-[350px] h-full border border-neutral-700 bg-neutral-800 rounded-xl overflow-y-auto"
className="w-[350px] h-full border border-neutral-700 bg-neutral-800 rounded-xl"
>
<div className="pt-4 px-4 flex items-center justify-between">
{location.pathname.startsWith("/conversation") && (
<NewConversationButton
onClick={() => setConfirmExitConversationModalVisible(true)}
/>
)}
{isFetching && <LoadingSpinner size="small" />}
</div>
{error && (
@@ -93,12 +98,12 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
onClick={() => handleClickCard(project.conversation_id)}
onDelete={() => handleDeleteProject(project.conversation_id)}
onChangeTitle={(title) =>
handleChangeTitle(project.conversation_id, project.title, title)
handleChangeTitle(project.conversation_id, project.name, title)
}
title={project.title}
selectedRepository={project.selected_repository}
lastUpdatedAt={project.last_updated_at}
status={project.status}
name={project.name}
repo={project.repo}
lastUpdated={project.lastUpdated}
state={project.state}
/>
))}

View File

@@ -1,21 +1,21 @@
interface ConversationRepoLinkProps {
selectedRepository: string;
repo: string;
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
}
export function ConversationRepoLink({
selectedRepository,
repo,
onClick,
}: ConversationRepoLinkProps) {
return (
<a
data-testid="conversation-card-selected-repository"
href={`https://github.com/${selectedRepository}`}
data-testid="conversation-card-repo"
href={`https://github.com/${repo}`}
target="_blank noopener noreferrer"
onClick={onClick}
className="text-xs text-neutral-400 hover:text-neutral-200"
>
{selectedRepository}
{repo}
</a>
);
}

View File

@@ -1,25 +1,39 @@
import ColdIcon from "./state-indicators/cold.svg?react";
import CoolingIcon from "./state-indicators/cooling.svg?react";
import FinishedIcon from "./state-indicators/finished.svg?react";
import RunningIcon from "./state-indicators/running.svg?react";
import WaitingIcon from "./state-indicators/waiting.svg?react";
import WarmIcon from "./state-indicators/warm.svg?react";
type SVGIcon = React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
export type ProjectStatus = "RUNNING" | "STOPPED";
export type ProjectState =
| "cold"
| "cooling"
| "finished"
| "running"
| "waiting"
| "warm";
const INDICATORS: Record<ProjectStatus, SVGIcon> = {
STOPPED: ColdIcon,
RUNNING: RunningIcon,
const INDICATORS: Record<ProjectState, SVGIcon> = {
cold: ColdIcon,
cooling: CoolingIcon,
finished: FinishedIcon,
running: RunningIcon,
waiting: WaitingIcon,
warm: WarmIcon,
};
interface ConversationStateIndicatorProps {
status: ProjectStatus;
state: ProjectState;
}
export function ConversationStateIndicator({
status,
state,
}: ConversationStateIndicatorProps) {
const StateIcon = INDICATORS[status];
const StateIcon = INDICATORS[state];
return (
<div data-testid={`${status}-indicator`}>
<div data-testid={`${state}-indicator`}>
<StateIcon />
</div>
);

View File

@@ -5,7 +5,7 @@ import { GitHubRepositorySelector } from "./github-repo-selector";
import { ModalButton } from "#/components/shared/buttons/modal-button";
import { ConnectToGitHubModal } from "#/components/shared/modals/connect-to-github-modal";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { isGitHubErrorReponse } from "#/api/github";
import { isGitHubErrorReponse } from "#/api/github-axios-instance";
import { useAppRepositories } from "#/hooks/query/use-app-repositories";
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";

View File

@@ -16,24 +16,16 @@ export function ProjectMenuDetails({
}: ProjectMenuDetailsProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col min-w-0">
<div className="flex flex-col">
<a
href={`https://github.com/${repoName}`}
target="_blank"
rel="noreferrer noopener"
className="flex items-center gap-2 min-w-0"
className="flex items-center gap-2"
>
{avatar && (
<img
src={avatar}
alt=""
className="w-4 h-4 rounded-full flex-shrink-0"
/>
)}
<span className="text-sm leading-6 font-semibold truncate flex-1">
{repoName}
</span>
<ExternalLinkIcon width={16} height={16} className="flex-shrink-0" />
{avatar && <img src={avatar} alt="" className="w-4 h-4 rounded-full" />}
<span className="text-sm leading-6 font-semibold">{repoName}</span>
<ExternalLinkIcon width={16} height={16} />
</a>
<a
href={lastCommit.html_url}

View File

@@ -1,6 +1,6 @@
import React from "react";
import { FaListUl } from "react-icons/fa";
import { useDispatch } from "react-redux";
import { useLocation } from "react-router";
import FolderIcon from "#/icons/docs.svg?react";
import { useAuth } from "#/context/auth-context";
import { useGitHubUser } from "#/hooks/query/use-github-user";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
@@ -11,20 +11,16 @@ import { ExitProjectButton } from "#/components/shared/buttons/exit-project-butt
import { SettingsButton } from "#/components/shared/buttons/settings-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
import { ExitProjectConfirmationModal } from "#/components/shared/modals/exit-project-confirmation-modal";
import { SettingsModal } from "#/components/shared/modals/settings/settings-modal";
import { useSettingsUpToDate } from "#/context/settings-up-to-date-context";
import { useSettings } from "#/hooks/query/use-settings";
import { ConversationPanel } from "../conversation-panel/conversation-panel";
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
import { useEndSession } from "#/hooks/use-end-session";
import { setCurrentAgentState } from "#/state/agent-slice";
import { AgentState } from "#/types/agent-state";
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
import { ConversationPanelWrapper } from "../conversation-panel/conversation-panel-wrapper";
import { cn } from "#/utils/utils";
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
export function Sidebar() {
const dispatch = useDispatch();
const endSession = useEndSession();
const location = useLocation();
const user = useGitHubUser();
const { data: isAuthed } = useIsAuthed();
const { logout } = useAuth();
@@ -34,9 +30,11 @@ export function Sidebar() {
const [accountSettingsModalOpen, setAccountSettingsModalOpen] =
React.useState(false);
const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false);
const [conversationPanelIsOpen, setConversationPanelIsOpen] =
const [startNewProjectModalIsOpen, setStartNewProjectModalIsOpen] =
React.useState(false);
const [conversationPanelIsOpen, setConversationPanelIsOpen] = React.useState(
MULTI_CONVO_UI_IS_ENABLED,
);
React.useEffect(() => {
// If the github token is invalid, open the account settings modal again
@@ -45,11 +43,6 @@ export function Sidebar() {
}
}, [user.isError]);
const handleEndSession = () => {
dispatch(setCurrentAgentState(AgentState.LOADING));
endSession();
};
const handleAccountSettingsModalClose = () => {
// If the user closes the modal without connecting to GitHub,
// we need to log them out to clear the invalid token from the
@@ -58,30 +51,22 @@ export function Sidebar() {
setAccountSettingsModalOpen(false);
};
const handleClickLogo = () => {
if (location.pathname.startsWith("/conversations/"))
setStartNewProjectModalIsOpen(true);
};
const showSettingsModal =
isAuthed && (!settingsAreUpToDate || settingsModalIsOpen);
return (
<>
<aside className="h-[40px] md:h-auto px-1 flex flex-row md:flex-col gap-1">
<aside className="h-[40px] md:h-auto px-1 flex flex-row md:flex-col gap-1 relative">
<nav className="flex flex-row md:flex-col items-center gap-[18px]">
<div className="w-[34px] h-[34px] flex items-center justify-center mb-7">
<AllHandsLogoButton onClick={handleEndSession} />
<div className="w-[34px] h-[34px] flex items-center justify-center">
<AllHandsLogoButton onClick={handleClickLogo} />
</div>
{user.isLoading && <LoadingSpinner size="small" />}
<ExitProjectButton onClick={handleEndSession} />
{MULTI_CONVERSATION_UI && (
<TooltipButton
data-testid="toggle-conversation-panel"
tooltip="Conversations"
ariaLabel="Conversations"
onClick={() => setConversationPanelIsOpen((prev) => !prev)}
>
<FaListUl size={22} />
</TooltipButton>
)}
<DocsButton />
<SettingsButton onClick={() => setSettingsModalIsOpen(true)} />
{!user.isLoading && (
<UserActions
user={
@@ -91,14 +76,33 @@ export function Sidebar() {
onClickAccountSettings={() => setAccountSettingsModalOpen(true)}
/>
)}
<SettingsButton onClick={() => setSettingsModalIsOpen(true)} />
{MULTI_CONVO_UI_IS_ENABLED && (
<button
data-testid="toggle-conversation-panel"
type="button"
onClick={() => setConversationPanelIsOpen((prev) => !prev)}
className={cn(
conversationPanelIsOpen ? "border-b-2 border-[#FFE165]" : "",
)}
>
<FolderIcon width={28} height={28} />
</button>
)}
<DocsButton />
<ExitProjectButton
onClick={() => setStartNewProjectModalIsOpen(true)}
/>
</nav>
{conversationPanelIsOpen && (
<ConversationPanelWrapper isOpen={conversationPanelIsOpen}>
<div
className="absolute h-full left-[calc(100%+12px)] top-0 z-20" // 12px padding (sidebar parent)
>
<ConversationPanel
onClose={() => setConversationPanelIsOpen(false)}
/>
</ConversationPanelWrapper>
</div>
)}
</aside>
@@ -112,6 +116,11 @@ export function Sidebar() {
onClose={() => setSettingsModalIsOpen(false)}
/>
))}
{startNewProjectModalIsOpen && (
<ExitProjectConfirmationModal
onClose={() => setStartNewProjectModalIsOpen(false)}
/>
)}
</>
);
}

View File

@@ -1,8 +1,8 @@
import { Tooltip } from "@nextui-org/react";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import DefaultUserAvatar from "#/icons/default-user.svg?react";
import { cn } from "#/utils/utils";
import { Avatar } from "./avatar";
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
interface UserAvatarProps {
onClick: () => void;
@@ -11,11 +11,10 @@ interface UserAvatarProps {
}
export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
return (
<TooltipButton
testId="user-avatar"
tooltip="Account settings"
ariaLabel="Account settings"
const buttonContent = (
<button
data-testid="user-avatar"
type="button"
onClick={onClick}
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center border-2 border-gray-200",
@@ -31,6 +30,12 @@ export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
/>
)}
{isLoading && <LoadingSpinner size="small" />}
</TooltipButton>
</button>
);
return (
<Tooltip content="Account settings" closeDelay={100}>
{buttonContent}
</Tooltip>
);
}

View File

@@ -12,7 +12,7 @@ export function AllHandsLogoButton({ onClick }: AllHandsLogoButtonProps) {
ariaLabel="All Hands Logo"
onClick={onClick}
>
<AllHandsLogo width={44} height={30} />
<AllHandsLogo width={34} height={23} />
</TooltipButton>
);
}

View File

@@ -13,7 +13,7 @@ export function ExitProjectButton({ onClick }: ExitProjectButtonProps) {
onClick={onClick}
testId="new-project-button"
>
<NewProjectIcon width={26} height={26} />
<NewProjectIcon width={28} height={28} />
</TooltipButton>
);
}

View File

@@ -1,4 +1,4 @@
import { FaCog } from "react-icons/fa";
import CogTooth from "#/assets/cog-tooth";
import { TooltipButton } from "./tooltip-button";
interface SettingsButtonProps {
@@ -13,7 +13,7 @@ export function SettingsButton({ onClick }: SettingsButtonProps) {
ariaLabel="Settings"
onClick={onClick}
>
<FaCog size={24} />
<CogTooth />
</TooltipButton>
);
}

View File

@@ -1,6 +1,5 @@
import { Tooltip } from "@nextui-org/react";
import React, { ReactNode } from "react";
import { cn } from "#/utils/utils";
import { ReactNode } from "react";
interface TooltipButtonProps {
children: ReactNode;
@@ -9,7 +8,6 @@ interface TooltipButtonProps {
href?: string;
ariaLabel: string;
testId?: string;
className?: React.HTMLAttributes<HTMLButtonElement>["className"];
}
export function TooltipButton({
@@ -19,7 +17,6 @@ export function TooltipButton({
href,
ariaLabel,
testId,
className,
}: TooltipButtonProps) {
const buttonContent = (
<button
@@ -27,7 +24,7 @@ export function TooltipButton({
aria-label={ariaLabel}
data-testid={testId}
onClick={onClick}
className={cn("hover:opacity-80", className)}
className="w-8 h-8 rounded-full hover:opacity-80 flex items-center justify-center"
>
{children}
</button>
@@ -38,7 +35,7 @@ export function TooltipButton({
href={href}
target="_blank"
rel="noreferrer noopener"
className={cn("hover:opacity-80", className)}
className="w-8 h-8 rounded-full hover:opacity-80 flex items-center justify-center"
aria-label={ariaLabel}
>
{children}
@@ -48,7 +45,7 @@ export function TooltipButton({
);
return (
<Tooltip content={tooltip} closeDelay={100} placement="right">
<Tooltip content={tooltip} closeDelay={100}>
{content}
</Tooltip>
);

View File

@@ -20,7 +20,7 @@ export function AdvancedOptionSwitch({
<Switch
isDisabled={isDisabled}
name="use-advanced-options"
defaultSelected={showAdvancedOptions}
isSelected={showAdvancedOptions}
onValueChange={setShowAdvancedOptions}
classNames={{
thumb: cn(

View File

@@ -1,52 +0,0 @@
import { useTranslation } from "react-i18next";
import { Select, SelectItem } from "@nextui-org/react";
interface RuntimeSizeSelectorProps {
isDisabled: boolean;
defaultValue?: number;
}
export function RuntimeSizeSelector({
isDisabled,
defaultValue,
}: RuntimeSizeSelectorProps) {
const { t } = useTranslation();
return (
<fieldset className="flex flex-col gap-2">
<label
htmlFor="runtime-size"
className="font-[500] text-[#A3A3A3] text-xs"
>
{t("SETTINGS_FORM$RUNTIME_SIZE_LABEL")}
</label>
<Select
id="runtime-size"
name="runtime-size"
defaultSelectedKeys={[String(defaultValue || 1)]}
isDisabled={isDisabled}
aria-label={t("SETTINGS_FORM$RUNTIME_SIZE_LABEL")}
classNames={{
trigger: "bg-[#27272A] rounded-md text-sm px-3 py-[10px]",
}}
>
<SelectItem key="1" value={1}>
1x (2 core, 8G)
</SelectItem>
<SelectItem
key="2"
value={2}
isDisabled
classNames={{
description:
"whitespace-normal break-words min-w-[300px] max-w-[300px]",
base: "min-w-[300px] max-w-[300px]",
}}
description="Runtime sizes over 1 are disabled by default, please contact contact@all-hands.dev to get access to larger runtimes."
>
2x (4 core, 16G)
</SelectItem>
</Select>
</fieldset>
);
}

View File

@@ -21,9 +21,6 @@ import { ModalBackdrop } from "../modal-backdrop";
import { ModelSelector } from "./model-selector";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { RuntimeSizeSelector } from "./runtime-size-selector";
import { useConfig } from "#/hooks/query/use-config";
interface SettingsFormProps {
disabled?: boolean;
settings: Settings;
@@ -43,7 +40,6 @@ export function SettingsForm({
}: SettingsFormProps) {
const { mutateAsync: saveSettings } = useSaveSettings();
const endSession = useEndSession();
const { data: config } = useConfig();
const location = useLocation();
const { t } = useTranslation();
@@ -101,8 +97,6 @@ export function SettingsForm({
posthog.capture("settings_saved", {
LLM_MODEL: newSettings.LLM_MODEL,
LLM_API_KEY: newSettings.LLM_API_KEY ? "SET" : "UNSET",
REMOTE_RUNTIME_RESOURCE_FACTOR:
newSettings.REMOTE_RUNTIME_RESOURCE_FACTOR,
});
};
@@ -128,8 +122,6 @@ export function SettingsForm({
}
};
const isSaasMode = config?.APP_MODE === "saas";
return (
<div>
<form
@@ -172,21 +164,16 @@ export function SettingsForm({
isSet={settings.LLM_API_KEY === "SET"}
/>
{showAdvancedOptions && (
<AgentInput
isDisabled={!!disabled}
defaultValue={settings.AGENT}
agents={agents}
/>
)}
{showAdvancedOptions && (
<>
<AgentInput
isDisabled={!!disabled}
defaultValue={settings.AGENT}
agents={agents}
/>
{isSaasMode && (
<RuntimeSizeSelector
isDisabled={!!disabled}
defaultValue={settings.REMOTE_RUNTIME_RESOURCE_FACTOR}
/>
)}
<SecurityAnalyzerInput
isDisabled={!!disabled}
defaultValue={settings.SECURITY_ANALYZER}

View File

@@ -2,9 +2,14 @@ import posthog from "posthog-js";
import React from "react";
import OpenHands from "#/api/open-hands";
import {
removeGitHubTokenHeader,
setGitHubTokenHeader,
removeGitHubTokenHeader as removeOpenHandsGitHubTokenHeader,
setGitHubTokenHeader as setOpenHandsGitHubTokenHeader,
} from "#/api/open-hands-axios";
import {
setAuthTokenHeader as setGitHubAuthTokenHeader,
removeAuthTokenHeader as removeGitHubAuthTokenHeader,
setupAxiosInterceptors as setupGithubAxiosInterceptors,
} from "#/api/github-axios-instance";
interface AuthContextType {
gitHubToken: string | null;
@@ -32,7 +37,8 @@ function AuthProvider({ children }: React.PropsWithChildren) {
localStorage.removeItem("ghToken");
localStorage.removeItem("userId");
removeGitHubTokenHeader();
removeOpenHandsGitHubTokenHeader();
removeGitHubAuthTokenHeader();
};
const setGitHubToken = (token: string | null) => {
@@ -40,7 +46,8 @@ function AuthProvider({ children }: React.PropsWithChildren) {
if (token) {
localStorage.setItem("ghToken", token);
setGitHubTokenHeader(token);
setOpenHandsGitHubTokenHeader(token);
setGitHubAuthTokenHeader(token);
} else {
clearGitHubToken();
}
@@ -80,6 +87,7 @@ function AuthProvider({ children }: React.PropsWithChildren) {
setGitHubToken(storedGitHubToken);
setUserId(userId);
setupGithubAxiosInterceptors(refreshToken, logout);
}, []);
const value = React.useMemo(

View File

@@ -2,18 +2,12 @@ import posthog from "posthog-js";
import React from "react";
import { io, Socket } from "socket.io-client";
import EventLogger from "#/utils/event-logger";
import {
handleAssistantMessage,
handleStatusMessage,
} from "#/services/actions";
import { handleAssistantMessage } from "#/services/actions";
import { useRate } from "#/hooks/use-rate";
import { OpenHandsParsedEvent } from "#/types/core";
import {
AssistantMessageAction,
UserMessageAction,
} from "#/types/core/actions";
import { AgentStateChangeObservation } from "#/types/core/observations";
const isOpenHandsEvent = (event: unknown): event is OpenHandsParsedEvent =>
const isOpenHandsMessage = (event: unknown): event is OpenHandsParsedEvent =>
typeof event === "object" &&
event !== null &&
"id" in event &&
@@ -21,26 +15,10 @@ const isOpenHandsEvent = (event: unknown): event is OpenHandsParsedEvent =>
"message" in event &&
"timestamp" in event;
const isUserMessage = (
const isAgentStateChangeObservation = (
event: OpenHandsParsedEvent,
): event is UserMessageAction =>
"source" in event &&
"type" in event &&
event.source === "user" &&
event.type === "message";
const isAssistantMessage = (
event: OpenHandsParsedEvent,
): event is AssistantMessageAction =>
"source" in event &&
"type" in event &&
event.source === "agent" &&
event.type === "message";
const isMessageAction = (
event: OpenHandsParsedEvent,
): event is UserMessageAction | AssistantMessageAction =>
isUserMessage(event) || isAssistantMessage(event);
): event is AgentStateChangeObservation =>
"observation" in event && event.observation === "agent_state_changed";
export enum WsClientProviderStatus {
CONNECTED,
@@ -65,28 +43,16 @@ const WsClientContext = React.createContext<UseWsClient>({
interface WsClientProviderProps {
conversationId: string;
}
export function updateStatusWhenErrorMessagePresent(data: unknown) {
if (
data &&
typeof data === "object" &&
"message" in data &&
typeof data.message === "string"
) {
handleStatusMessage({
type: "error",
message: data.message,
status_update: true,
});
}
ghToken: string | null;
}
export function WsClientProvider({
ghToken,
conversationId,
children,
}: React.PropsWithChildren<WsClientProviderProps>) {
const sioRef = React.useRef<Socket | null>(null);
const ghTokenRef = React.useRef<string | null>(ghToken);
const [status, setStatus] = React.useState(
WsClientProviderStatus.DISCONNECTED,
);
@@ -108,7 +74,7 @@ export function WsClientProvider({
}
function handleMessage(event: Record<string, unknown>) {
if (isOpenHandsEvent(event) && isMessageAction(event)) {
if (isOpenHandsMessage(event) && !isAgentStateChangeObservation(event)) {
messageRateHandler.record(new Date().getTime());
}
setEvents((prevEvents) => [...prevEvents, event]);
@@ -119,7 +85,7 @@ export function WsClientProvider({
handleAssistantMessage(event);
}
function handleDisconnect(data: unknown) {
function handleDisconnect() {
setStatus(WsClientProviderStatus.DISCONNECTED);
const sio = sioRef.current;
if (!sio) {
@@ -127,19 +93,13 @@ export function WsClientProvider({
}
sio.io.opts.query = sio.io.opts.query || {};
sio.io.opts.query.latest_event_id = lastEventRef.current?.id;
updateStatusWhenErrorMessagePresent(data);
}
function handleError(data: unknown) {
setStatus(WsClientProviderStatus.DISCONNECTED);
updateStatusWhenErrorMessagePresent(data);
function handleError() {
posthog.capture("socket_error");
setStatus(WsClientProviderStatus.DISCONNECTED);
}
React.useEffect(() => {
lastEventRef.current = null;
}, [conversationId]);
React.useEffect(() => {
if (!conversationId) {
throw new Error("No conversation ID provided");
@@ -158,6 +118,9 @@ export function WsClientProvider({
sio = io(baseUrl, {
transports: ["websocket"],
auth: {
github_token: ghToken || undefined,
},
query,
});
sio.on("connect", handleConnect);
@@ -167,6 +130,7 @@ export function WsClientProvider({
sio.on("disconnect", handleDisconnect);
sioRef.current = sio;
ghTokenRef.current = ghToken;
return () => {
sio.off("connect", handleConnect);
@@ -175,7 +139,7 @@ export function WsClientProvider({
sio.off("connect_failed", handleError);
sio.off("disconnect", handleDisconnect);
};
}, [conversationId]);
}, [ghToken, conversationId]);
React.useEffect(
() => () => {

View File

@@ -13,18 +13,13 @@ export const useCreateConversation = () => {
const { gitHubToken } = useAuth();
const queryClient = useQueryClient();
const { selectedRepository, files, importedProjectZip } = useSelector(
const { selectedRepository, files } = useSelector(
(state: RootState) => state.initialQuery,
);
return useMutation({
mutationFn: (variables: { q?: string }) => {
if (
!variables.q?.trim() &&
!selectedRepository &&
files.length === 0 &&
!importedProjectZip
) {
if (!variables.q?.trim() && !selectedRepository && files.length === 0) {
throw new Error("No query provided");
}

View File

@@ -1,16 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useConversation } from "#/context/conversation-context";
type UploadFilesArgs = {
files: File[];
};
export const useUploadFiles = () => {
const { conversationId } = useConversation();
return useMutation({
mutationFn: ({ files }: UploadFilesArgs) =>
OpenHands.uploadFiles(conversationId, files),
});
};

View File

@@ -1,11 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
export const useUserConversation = (cid: string | null) =>
useQuery({
queryKey: ["user", "conversation", cid],
queryFn: () => OpenHands.getConversation(cid!),
enabled: MULTI_CONVERSATION_UI && !!cid,
enabled: MULTI_CONVO_UI_IS_ENABLED && !!cid,
retry: false,
});

View File

@@ -18,8 +18,6 @@ const getSettingsQueryFn = async () => {
CONFIRMATION_MODE: apiSettings.confirmation_mode,
SECURITY_ANALYZER: apiSettings.security_analyzer,
LLM_API_KEY: apiSettings.llm_api_key,
REMOTE_RUNTIME_RESOURCE_FACTOR:
apiSettings.remote_runtime_resource_factor,
};
}

View File

@@ -3,8 +3,8 @@
import React from "react";
import { useSettingsUpToDate } from "#/context/settings-up-to-date-context";
import {
getCurrentSettingsVersion,
DEFAULT_SETTINGS,
getCurrentSettingsVersion,
getLocalStorageSettings,
} from "#/services/settings";
import { useSaveSettings } from "./mutation/use-save-settings";

View File

@@ -426,20 +426,6 @@
"fr": "Réinitialiser aux valeurs par défaut",
"tr": "Varsayılanlara Sıfırla"
},
"SETTINGS_FORM$RUNTIME_SIZE_LABEL": {
"en": "Runtime Settings",
"zh-CN": "运行时设置",
"de": "Laufzeiteinstellungen",
"ko-KR": "런타임 설정",
"no": "Kjøretidsinnstillinger",
"zh-TW": "運行時設定",
"it": "Impostazioni Runtime",
"pt": "Configurações de Runtime",
"es": "Configuración de Runtime",
"ar": "إعدادات وقت التشغيل",
"fr": "Paramètres d'exécution",
"tr": "Çalışma Zamanı Ayarları"
},
"CONFIGURATION$SETTINGS_NEED_UPDATE_MESSAGE": {
"en": "We've changed some settings in the latest update. Take a minute to review.",
"de": "Mit dem letzten Update haben wir ein paar Einstellungen geändert. Bitte kontrollieren Ihre Einstellungen.",

View File

@@ -1,9 +1,5 @@
import { delay, http, HttpResponse } from "msw";
import {
GetConfigResponse,
Conversation,
ResultSet,
} from "#/api/open-hands.types";
import { GetConfigResponse, Conversation } from "#/api/open-hands.types";
import { DEFAULT_SETTINGS } from "#/services/settings";
const userPreferences = {
@@ -21,33 +17,26 @@ const userPreferences = {
const conversations: Conversation[] = [
{
conversation_id: "1",
title: "My New Project",
selected_repository: null,
last_updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
status: "RUNNING",
name: "My New Project",
repo: null,
lastUpdated: new Date().toISOString(),
state: "running",
},
{
conversation_id: "2",
title: "Repo Testing",
selected_repository: "octocat/hello-world",
name: "Repo Testing",
repo: "octocat/hello-world",
// 2 days ago
last_updated_at: new Date(
Date.now() - 2 * 24 * 60 * 60 * 1000,
).toISOString(),
created_at: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
status: "STOPPED",
lastUpdated: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
state: "cold",
},
{
conversation_id: "3",
title: "Another Project",
selected_repository: "octocat/earth",
name: "Another Project",
repo: "octocat/earth",
// 5 days ago
last_updated_at: new Date(
Date.now() - 5 * 24 * 60 * 60 * 1000,
).toISOString(),
created_at: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
status: "STOPPED",
lastUpdated: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
state: "finished",
},
];
@@ -193,15 +182,9 @@ export const handlers = [
http.get("/api/options/config", () => HttpResponse.json({ APP_MODE: "oss" })),
http.get("/api/conversations", async () => {
const values = Array.from(CONVERSATIONS.values());
const results: ResultSet<Conversation> = {
results: values,
next_page_id: null,
};
return HttpResponse.json(results, { status: 200 });
}),
http.get("/api/conversations", async () =>
HttpResponse.json(Array.from(CONVERSATIONS.values())),
),
http.delete("/api/conversations/:conversationId", async ({ params }) => {
const { conversationId } = params;
@@ -214,7 +197,7 @@ export const handlers = [
return HttpResponse.json(null, { status: 404 });
}),
http.patch(
http.put(
"/api/conversations/:conversationId",
async ({ params, request }) => {
const { conversationId } = params;
@@ -224,10 +207,10 @@ export const handlers = [
if (conversation) {
const body = await request.json();
if (typeof body === "object" && body?.title) {
if (typeof body === "object" && body?.name) {
CONVERSATIONS.set(conversationId, {
...conversation,
title: body.title,
name: body.name,
});
return HttpResponse.json(null, { status: 200 });
}
@@ -241,11 +224,10 @@ export const handlers = [
http.post("/api/conversations", () => {
const conversation: Conversation = {
conversation_id: (Math.random() * 100).toString(),
title: "New Conversation",
selected_repository: null,
last_updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
status: "RUNNING",
name: "New Conversation",
repo: null,
lastUpdated: new Date().toISOString(),
state: "warm",
};
CONVERSATIONS.set(conversation.conversation_id, conversation);

View File

@@ -29,7 +29,10 @@ function Home() {
const latestConversation = localStorage.getItem("latest_conversation_id");
return (
<div className="bg-root-secondary h-full rounded-xl flex flex-col items-center justify-center relative overflow-y-auto px-2">
<div
data-testid="root-index"
className="bg-root-secondary h-full rounded-xl flex flex-col items-center justify-center relative overflow-y-auto px-2"
>
<HeroHeading />
<div className="flex flex-col gap-8 w-full md:w-[600px] items-center">
<div className="flex flex-col gap-2 w-full">

View File

@@ -1,64 +1,31 @@
import React from "react";
import toast from "react-hot-toast";
import { useDispatch, useSelector } from "react-redux";
import { useSelector } from "react-redux";
import { useAuth } from "#/context/auth-context";
import { useWsClient } from "#/context/ws-client-provider";
import { getGitHubTokenCommand } from "#/services/terminal-service";
import { setImportedProjectZip } from "#/state/initial-query-slice";
import { RootState } from "#/store";
import { base64ToBlob } from "#/utils/base64-to-blob";
import { useUploadFiles } from "../../../hooks/mutation/use-upload-files";
import { useGitHubUser } from "../../../hooks/query/use-github-user";
import { isGitHubErrorReponse } from "#/api/github";
import { isGitHubErrorReponse } from "#/api/github-axios-instance";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
export const useHandleRuntimeActive = () => {
const { gitHubToken } = useAuth();
const { send } = useWsClient();
const dispatch = useDispatch();
const { data: user } = useGitHubUser();
const { mutate: uploadFiles } = useUploadFiles();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const runtimeActive = !RUNTIME_INACTIVE_STATES.includes(curAgentState);
const { data: user } = useGitHubUser();
const { importedProjectZip } = useSelector(
(state: RootState) => state.initialQuery,
);
const runtimeActive = !RUNTIME_INACTIVE_STATES.includes(curAgentState);
const userId = React.useMemo(() => {
if (user && !isGitHubErrorReponse(user)) return user.id;
return null;
}, [user]);
const handleUploadFiles = (zip: string) => {
const blob = base64ToBlob(zip);
const file = new File([blob], "imported-project.zip", {
type: blob.type,
});
uploadFiles(
{ files: [file] },
{
onError: () => {
toast.error("Failed to upload project files.");
},
},
);
dispatch(setImportedProjectZip(null));
};
React.useEffect(() => {
if (runtimeActive && userId && gitHubToken) {
// Export if the user valid, this could happen mid-session so it is handled here
send(getGitHubTokenCommand(gitHubToken));
}
}, [userId, gitHubToken, runtimeActive]);
React.useEffect(() => {
if (runtimeActive && importedProjectZip) {
handleUploadFiles(importedProjectZip);
}
}, [runtimeActive, importedProjectZip]);
};

View File

@@ -34,7 +34,7 @@ import { useUserConversation } from "#/hooks/query/get-conversation-permissions"
import { CountBadge } from "#/components/layout/count-badge";
import { TerminalStatusLabel } from "#/components/features/terminal/terminal-status-label";
import { useSettings } from "#/hooks/query/use-settings";
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
function AppContent() {
const { gitHubToken } = useAuth();
@@ -73,7 +73,7 @@ function AppContent() {
);
React.useEffect(() => {
if (MULTI_CONVERSATION_UI && isFetched && !conversation) {
if (MULTI_CONVO_UI_IS_ENABLED && isFetched && !conversation) {
toast.error(
"This conversation does not exist, or you do not have permission to access it.",
);
@@ -175,7 +175,7 @@ function AppContent() {
}
return (
<WsClientProvider conversationId={conversationId}>
<WsClientProvider ghToken={gitHubToken} conversationId={conversationId}>
<EventHandler>
<div data-testid="app-route" className="flex flex-col h-full gap-3">
<div className="flex h-full overflow-auto">{renderMain()}</div>

View File

@@ -78,10 +78,7 @@ export default function MainApp() {
>
<Sidebar />
<div
id="root-outlet"
className="h-[calc(100%-50px)] md:h-full w-full relative"
>
<div className="h-[calc(100%-50px)] md:h-full w-full relative">
<Outlet />
</div>

View File

@@ -75,7 +75,6 @@ export function handleActionMessage(message: ActionMessage) {
if (message.args && message.args.thought) {
store.dispatch(addAssistantMessage(message.args.thought));
}
// Need to convert ActionMessage to RejectAction
// @ts-expect-error TODO: fix
store.dispatch(addAssistantAction(message));
}

View File

@@ -8,7 +8,6 @@ export type Settings = {
LLM_API_KEY: string | null;
CONFIRMATION_MODE: boolean;
SECURITY_ANALYZER: string;
REMOTE_RUNTIME_RESOURCE_FACTOR: number;
};
export type ApiSettings = {
@@ -19,7 +18,6 @@ export type ApiSettings = {
llm_api_key: string | null;
confirmation_mode: boolean;
security_analyzer: string;
remote_runtime_resource_factor: number;
};
export const DEFAULT_SETTINGS: Settings = {
@@ -30,7 +28,6 @@ export const DEFAULT_SETTINGS: Settings = {
LLM_API_KEY: null,
CONFIRMATION_MODE: false,
SECURITY_ANALYZER: "",
REMOTE_RUNTIME_RESOURCE_FACTOR: 1,
};
export const getCurrentSettingsVersion = () => {
@@ -69,8 +66,6 @@ export const getLocalStorageSettings = (): Settings => {
LLM_API_KEY: llmApiKey || DEFAULT_SETTINGS.LLM_API_KEY,
CONFIRMATION_MODE: confirmationMode || DEFAULT_SETTINGS.CONFIRMATION_MODE,
SECURITY_ANALYZER: securityAnalyzer || DEFAULT_SETTINGS.SECURITY_ANALYZER,
REMOTE_RUNTIME_RESOURCE_FACTOR:
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
};
};
@@ -78,8 +73,3 @@ export const getLocalStorageSettings = (): Settings => {
* Get the default settings
*/
export const getDefaultSettings = (): Settings => DEFAULT_SETTINGS;
/**
* Get the current settings, either from local storage or defaults
*/
export const getSettings = (): Settings => getLocalStorageSettings();

View File

@@ -73,7 +73,7 @@ export const chatSlice = createSlice({
state.messages.push(message);
},
addAssistantMessage(state: SliceState, action: PayloadAction<string>) {
addAssistantMessage(state, action: PayloadAction<string>) {
const message: Message = {
type: "thought",
sender: "assistant",
@@ -85,10 +85,7 @@ export const chatSlice = createSlice({
state.messages.push(message);
},
addAssistantAction(
state: SliceState,
action: PayloadAction<OpenHandsAction>,
) {
addAssistantAction(state, action: PayloadAction<OpenHandsAction>) {
const actionID = action.payload.action;
if (!HANDLED_ACTIONS.includes(actionID)) {
return;
@@ -128,7 +125,7 @@ export const chatSlice = createSlice({
},
addAssistantObservation(
state: SliceState,
state,
observation: PayloadAction<OpenHandsObservation>,
) {
const observationID = observation.payload.observation;
@@ -182,7 +179,7 @@ export const chatSlice = createSlice({
},
addErrorMessage(
state: SliceState,
state,
action: PayloadAction<{ id?: string; message: string }>,
) {
const { id, message } = action.payload;
@@ -195,7 +192,7 @@ export const chatSlice = createSlice({
});
},
clearMessages(state: SliceState) {
clearMessages(state) {
state.messages = [];
},
},

View File

@@ -43,6 +43,6 @@ export interface ObservationMessage {
export interface StatusMessage {
status_update: true;
type: string;
id?: string;
id: string;
message: string;
}

View File

@@ -1,25 +1,19 @@
export const base64ToBlob = (base64: string) => {
// Remove the prefix (e.g. data:image/png;base64,)
const base64WithoutPrefix = base64.split(",")[1] || base64;
const base64WithoutPrefix = base64.split(",")[1];
// Decode to bytes
const bytes = atob(base64WithoutPrefix);
// Process in chunks to avoid memory issues
const chunkSize = 8192; // Process 8KB at a time
const chunks = [];
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.slice(i, i + chunkSize);
const array = new Uint8Array(chunk.length);
for (let j = 0; j < chunk.length; j += 1) {
array[j] = chunk.charCodeAt(j);
}
chunks.push(array);
// Create an array of byte values
const byteNumbers = new Array(bytes.length);
for (let i = 0; i < bytes.length; i += 1) {
byteNumbers[i] = bytes.charCodeAt(i);
}
// Create a Blob from all chunks
return new Blob(chunks, { type: "application/zip" });
// Convert to Uint8Array
const array = new Uint8Array(byteNumbers);
// Create a Blob
return new Blob([array], { type: "application/zip" });
};

View File

@@ -0,0 +1 @@
export const MULTI_CONVO_UI_IS_ENABLED = false;

View File

@@ -1,15 +0,0 @@
function loadFeatureFlag(
flagName: string,
defaultValue: boolean = false,
): boolean {
try {
const stringValue =
localStorage.getItem(`FEATURE_${flagName}`) || defaultValue.toString();
const value = !!JSON.parse(stringValue);
return value;
} catch (e) {
return defaultValue;
}
}
export const MULTI_CONVERSATION_UI = loadFeatureFlag("MULTI_CONVERSATION_UI");

View File

@@ -24,7 +24,6 @@ from openhands.events.action import (
MessageAction,
)
from openhands.events.observation import (
AgentCondensationObservation,
AgentDelegateObservation,
BrowserOutputObservation,
CmdOutputObservation,
@@ -37,7 +36,6 @@ from openhands.events.observation.error import ErrorObservation
from openhands.events.observation.observation import Observation
from openhands.events.serialization.event import truncate_content
from openhands.llm.llm import LLM
from openhands.memory.condenser import Condenser
from openhands.runtime.plugins import (
AgentSkillsRequirement,
JupyterRequirement,
@@ -117,9 +115,6 @@ class CodeActAgent(Agent):
disabled_microagents=self.config.disabled_microagents,
)
self.condenser = Condenser.from_config(self.config.condenser)
logger.debug(f'Using condenser: {self.condenser}')
def get_action_message(
self,
action: Action,
@@ -327,9 +322,6 @@ class CodeActAgent(Agent):
text = 'OBSERVATION:\n' + truncate_content(obs.content, max_message_chars)
text += '\n[Last action has been rejected by the user]'
message = Message(role='user', content=[TextContent(text=text)])
elif isinstance(obs, AgentCondensationObservation):
text = truncate_content(obs.content, max_message_chars)
message = Message(role='user', content=[TextContent(text=text)])
else:
# If an observation message is not returned, it will cause an error
# when the LLM tries to return the next message
@@ -450,10 +442,7 @@ class CodeActAgent(Agent):
pending_tool_call_action_messages: dict[str, Message] = {}
tool_call_id_to_message: dict[str, Message] = {}
# Condense the events from the state.
events = self.condenser.condensed_history(state)
events = list(state.history)
for event in events:
# create a regular message from an event
if isinstance(event, Action):

View File

@@ -217,7 +217,7 @@ class AgentController:
reported = RuntimeError(
'There was an unexpected error while running the agent. Please '
f'report this error to the developers. Your session ID is {self.id}. '
f'Error type: {e.__class__.__name__}'
f'Exception: {e}.'
)
if isinstance(e, litellm.AuthenticationError) or isinstance(
e, litellm.BadRequestError
@@ -993,12 +993,10 @@ class AgentController:
def __repr__(self):
return (
f'AgentController(id={getattr(self, "id", "<uninitialized>")}, '
f'agent={getattr(self, "agent", "<uninitialized>")!r}, '
f'event_stream={getattr(self, "event_stream", "<uninitialized>")!r}, '
f'state={getattr(self, "state", "<uninitialized>")!r}, '
f'delegate={getattr(self, "delegate", "<uninitialized>")!r}, '
f'_pending_action={getattr(self, "_pending_action", "<uninitialized>")!r})'
f'AgentController(id={self.id}, agent={self.agent!r}, '
f'event_stream={self.event_stream!r}, '
f'state={self.state!r}, '
f'delegate={self.delegate!r}, _pending_action={self._pending_action!r})'
)
def _is_awaiting_observation(self):

View File

@@ -1,6 +1,5 @@
from dataclasses import dataclass, field, fields
from dataclasses import dataclass, fields
from openhands.core.config.condenser_config import CondenserConfig, NoOpCondenserConfig
from openhands.core.config.config_utils import get_field_info
@@ -19,7 +18,6 @@ class AgentConfig:
llm_config: The name of the llm config to use. If specified, this will override global llm config.
use_microagents: Whether to use microagents at all. Default is True.
disabled_microagents: A list of microagents to disable. Default is None.
condenser: Configuration for the memory condenser. Default is NoOpCondenserConfig.
"""
codeact_enable_browsing: bool = True
@@ -31,7 +29,6 @@ class AgentConfig:
llm_config: str | None = None
use_microagents: bool = True
disabled_microagents: list[str] | None = None
condenser: CondenserConfig = field(default_factory=NoOpCondenserConfig) # type: ignore
def defaults_to_dict(self) -> dict:
"""Serialize fields to a dict for the frontend, including type hints, defaults, and whether it's optional."""

View File

@@ -1,90 +0,0 @@
from typing import Literal
from pydantic import BaseModel, Field
from openhands.core.config.llm_config import LLMConfig
class NoOpCondenserConfig(BaseModel):
"""Configuration for NoOpCondenser."""
type: Literal['noop'] = Field('noop')
class ObservationMaskingCondenserConfig(BaseModel):
"""Configuration for ObservationMaskingCondenser."""
type: Literal['observation_masking'] = Field('observation_masking')
attention_window: int = Field(
default=10,
description='The number of most-recent events where observations will not be masked.',
ge=1,
)
class RecentEventsCondenserConfig(BaseModel):
"""Configuration for RecentEventsCondenser."""
type: Literal['recent'] = Field('recent')
keep_first: int = Field(
default=0,
description='The number of initial events to condense.',
ge=0,
)
max_events: int = Field(
default=10, description='Maximum number of events to keep.', ge=1
)
class LLMSummarizingCondenserConfig(BaseModel):
"""Configuration for LLMCondenser."""
type: Literal['llm'] = Field('llm')
llm_config: LLMConfig = Field(
..., description='Configuration for the LLM to use for condensing.'
)
class AmortizedForgettingCondenserConfig(BaseModel):
"""Configuration for AmortizedForgettingCondenser."""
type: Literal['amortized'] = Field('amortized')
max_size: int = Field(
default=100,
description='Maximum size of the condensed history before triggering forgetting.',
ge=2,
)
keep_first: int = Field(
default=0,
description='Number of initial events to always keep in history.',
ge=0,
)
class LLMAttentionCondenserConfig(BaseModel):
"""Configuration for LLMAttentionCondenser."""
type: Literal['llm_attention'] = Field('llm_attention')
llm_config: LLMConfig = Field(
..., description='Configuration for the LLM to use for attention.'
)
max_size: int = Field(
default=100,
description='Maximum size of the condensed history before triggering forgetting.',
ge=2,
)
keep_first: int = Field(
default=0,
description='Number of initial events to always keep in history.',
ge=0,
)
CondenserConfig = (
NoOpCondenserConfig
| ObservationMaskingCondenserConfig
| RecentEventsCondenserConfig
| LLMSummarizingCondenserConfig
| AmortizedForgettingCondenserConfig
| LLMAttentionCondenserConfig
)

View File

@@ -35,8 +35,6 @@ class SandboxConfig:
remote_runtime_resource_factor: Factor to scale the resource allocation for remote runtime.
Must be one of [1, 2, 4, 8]. Will only be used if the runtime is remote.
enable_gpu: Whether to enable GPU.
docker_runtime_kwargs: Additional keyword arguments to pass to the Docker runtime when running containers.
This should be a JSON string that will be parsed into a dictionary.
"""
remote_runtime_api_url: str = 'http://localhost:8000'
@@ -60,10 +58,9 @@ class SandboxConfig:
runtime_startup_env_vars: dict[str, str] = field(default_factory=dict)
browsergym_eval_env: str | None = None
platform: str | None = None
close_delay: int = 900
close_delay: int = 15
remote_runtime_resource_factor: int = 1
enable_gpu: bool = False
docker_runtime_kwargs: str | None = None
def defaults_to_dict(self) -> dict:
"""Serialize fields to a dict for the frontend, including type hints, defaults, and whether it's optional."""

View File

@@ -44,8 +44,5 @@ class ObservationTypeSchema(BaseModel):
USER_REJECTED: str = Field(default='user_rejected')
CONDENSE: str = Field(default='condense')
"""Result of a condensation operation."""
ObservationType = ObservationTypeSchema()

View File

@@ -1,7 +1,4 @@
from openhands.events.observation.agent import (
AgentCondensationObservation,
AgentStateChangedObservation,
)
from openhands.events.observation.agent import AgentStateChangedObservation
from openhands.events.observation.browse import BrowserOutputObservation
from openhands.events.observation.commands import (
CmdOutputMetadata,
@@ -35,5 +32,4 @@ __all__ = [
'AgentDelegateObservation',
'SuccessObservation',
'UserRejectObservation',
'AgentCondensationObservation',
]

View File

@@ -14,14 +14,3 @@ class AgentStateChangedObservation(Observation):
@property
def message(self) -> str:
return ''
@dataclass
class AgentCondensationObservation(Observation):
"""The output of a condensation action."""
observation: str = ObservationType.CONDENSE
@property
def message(self) -> str:
return self.content

View File

@@ -1,9 +1,6 @@
import copy
from openhands.events.observation.agent import (
AgentCondensationObservation,
AgentStateChangedObservation,
)
from openhands.events.observation.agent import AgentStateChangedObservation
from openhands.events.observation.browse import BrowserOutputObservation
from openhands.events.observation.commands import (
CmdOutputMetadata,
@@ -35,7 +32,6 @@ observations = (
ErrorObservation,
AgentStateChangedObservation,
UserRejectObservation,
AgentCondensationObservation,
)
OBSERVATION_TYPE_TO_CLASS = {

View File

@@ -1,10 +1,9 @@
import asyncio
import queue
import threading
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from enum import Enum
from functools import partial
from queue import Queue
from typing import Callable, Iterable
from openhands.core.logger import openhands_logger as logger
@@ -62,19 +61,12 @@ class EventStream:
_subscribers: dict[str, dict[str, Callable]]
_cur_id: int = 0
_lock: threading.Lock
_queue: queue.Queue[Event]
_queue_thread: threading.Thread
_queue_loop: asyncio.AbstractEventLoop | None
_thread_loops: dict[str, dict[str, asyncio.AbstractEventLoop]]
def __init__(self, sid: str, file_store: FileStore):
def __init__(self, sid: str, file_store: FileStore, num_workers: int = 1):
self.sid = sid
self.file_store = file_store
self._stop_flag = threading.Event()
self._queue: queue.Queue[Event] = queue.Queue()
self._queue: Queue[Event] = Queue()
self._thread_pools: dict[str, dict[str, ThreadPoolExecutor]] = {}
self._thread_loops: dict[str, dict[str, asyncio.AbstractEventLoop]] = {}
self._queue_loop = None
self._queue_thread = threading.Thread(target=self._run_queue_loop)
self._queue_thread.daemon = True
self._queue_thread.start()
@@ -99,54 +91,9 @@ class EventStream:
if id >= self._cur_id:
self._cur_id = id + 1
def _init_thread_loop(self, subscriber_id: str, callback_id: str):
def _init_thread_loop(self):
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
if subscriber_id not in self._thread_loops:
self._thread_loops[subscriber_id] = {}
self._thread_loops[subscriber_id][callback_id] = loop
def close(self):
self._stop_flag.set()
if self._queue_thread.is_alive():
self._queue_thread.join()
subscriber_ids = list(self._subscribers.keys())
for subscriber_id in subscriber_ids:
callback_ids = list(self._subscribers[subscriber_id].keys())
for callback_id in callback_ids:
self._clean_up_subscriber(subscriber_id, callback_id)
def _clean_up_subscriber(self, subscriber_id: str, callback_id: str):
if subscriber_id not in self._subscribers:
logger.warning(f'Subscriber not found during cleanup: {subscriber_id}')
return
if callback_id not in self._subscribers[subscriber_id]:
logger.warning(f'Callback not found during cleanup: {callback_id}')
return
if (
subscriber_id in self._thread_loops
and callback_id in self._thread_loops[subscriber_id]
):
loop = self._thread_loops[subscriber_id][callback_id]
try:
loop.stop()
loop.close()
except Exception as e:
logger.warning(
f'Error closing loop for {subscriber_id}/{callback_id}: {e}'
)
del self._thread_loops[subscriber_id][callback_id]
if (
subscriber_id in self._thread_pools
and callback_id in self._thread_pools[subscriber_id]
):
pool = self._thread_pools[subscriber_id][callback_id]
pool.shutdown()
del self._thread_pools[subscriber_id][callback_id]
del self._subscribers[subscriber_id][callback_id]
def _get_filename_for_id(self, id: int) -> str:
return get_conversation_event_filename(self.sid, id)
@@ -229,8 +176,7 @@ class EventStream:
def subscribe(
self, subscriber_id: EventStreamSubscriber, callback: Callable, callback_id: str
):
initializer = partial(self._init_thread_loop, subscriber_id, callback_id)
pool = ThreadPoolExecutor(max_workers=1, initializer=initializer)
pool = ThreadPoolExecutor(max_workers=1, initializer=self._init_thread_loop)
if subscriber_id not in self._subscribers:
self._subscribers[subscriber_id] = {}
self._thread_pools[subscriber_id] = {}
@@ -252,7 +198,7 @@ class EventStream:
logger.warning(f'Callback not found during unsubscribe: {callback_id}')
return
self._clean_up_subscriber(subscriber_id, callback_id)
del self._subscribers[subscriber_id][callback_id]
def add_event(self, event: Event, source: EventSource):
if hasattr(event, '_id') and event.id is not None:
@@ -271,20 +217,13 @@ class EventStream:
self._queue.put(event)
def _run_queue_loop(self):
self._queue_loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._queue_loop)
try:
self._queue_loop.run_until_complete(self._process_queue())
finally:
self._queue_loop.close()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(self._process_queue())
async def _process_queue(self):
while should_continue() and not self._stop_flag.is_set():
event = None
try:
event = self._queue.get(timeout=0.1)
except queue.Empty:
continue
while should_continue():
event = self._queue.get()
for key in sorted(self._subscribers.keys()):
callbacks = self._subscribers[key]
for callback_id in callbacks:

View File

@@ -1,4 +1,4 @@
from openhands.memory.condenser import Condenser
from openhands.memory.condenser import MemoryCondenser
from openhands.memory.memory import LongTermMemory
__all__ = ['LongTermMemory', 'Condenser']
__all__ = ['LongTermMemory', 'MemoryCondenser']

View File

@@ -1,409 +1,24 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from contextlib import contextmanager
from typing import Any
from litellm import supports_response_schema
from pydantic import BaseModel
from typing_extensions import override
from openhands.controller.state.state import State
from openhands.core.config.condenser_config import (
AmortizedForgettingCondenserConfig,
CondenserConfig,
LLMAttentionCondenserConfig,
LLMSummarizingCondenserConfig,
NoOpCondenserConfig,
ObservationMaskingCondenserConfig,
RecentEventsCondenserConfig,
)
from openhands.core.logger import openhands_logger as logger
from openhands.events.event import Event
from openhands.events.observation import AgentCondensationObservation, Observation
from openhands.llm.llm import LLM
CONDENSER_METADATA_KEY = 'condenser_meta'
"""Key identifying where metadata is stored in a `State` object's `extra_data` field."""
class MemoryCondenser:
def condense(self, summarize_prompt: str, llm: LLM):
"""Attempts to condense the memory by using the llm
def get_condensation_metadata(state: State) -> list[dict[str, Any]]:
"""Utility function to retrieve a list of metadata batches from a `State`.
Args:
state: The state to retrieve metadata from.
Returns:
list[dict[str, Any]]: A list of metadata batches, each representing a condensation.
"""
if CONDENSER_METADATA_KEY in state.extra_data:
return state.extra_data[CONDENSER_METADATA_KEY]
return []
class Condenser(ABC):
"""Abstract condenser interface.
Condensers take a list of `Event` objects and reduce them into a potentially smaller list.
Agents can use condensers to reduce the amount of events they need to consider when deciding which action to take. To use a condenser, agents can call the `condensed_history` method on the current `State` being considered and use the results instead of the full history.
Example usage::
condenser = Condenser.from_config(condenser_config)
events = condenser.condensed_history(state)
"""
def __init__(self):
self._metadata_batch: dict[str, Any] = {}
def add_metadata(self, key: str, value: Any) -> None:
"""Add information to the current metadata batch.
Any key/value pairs added to the metadata batch will be recorded in the `State` at the end of the current condensation.
Args:
key: The key to store the metadata under.
value: The metadata to store.
"""
self._metadata_batch[key] = value
def write_metadata(self, state: State) -> None:
"""Write the current batch of metadata to the `State`.
Resets the current metadata batch: any metadata added after this call will be stored in a new batch and written to the `State` at the end of the next condensation.
"""
if CONDENSER_METADATA_KEY not in state.extra_data:
state.extra_data[CONDENSER_METADATA_KEY] = []
if self._metadata_batch:
state.extra_data[CONDENSER_METADATA_KEY].append(self._metadata_batch)
# Since the batch has been written, clear it for the next condensation
self._metadata_batch = {}
@contextmanager
def metadata_batch(self, state: State):
"""Context manager to ensure batched metadata is always written to the `State`."""
try:
yield
finally:
self.write_metadata(state)
@abstractmethod
def condense(self, events: list[Event]) -> list[Event]:
"""Condense a sequence of events into a potentially smaller list.
New condenser strategies should override this method to implement their own condensation logic. Call `self.add_metadata` in the implementation to record any relevant per-condensation diagnostic information.
Args:
events: A list of events representing the entire history of the agent.
Returns:
list[Event]: An event sequence representing a condensed history of the agent.
"""
def condensed_history(self, state: State) -> list[Event]:
"""Condense the state's history."""
with self.metadata_batch(state):
return self.condense(state.history)
@classmethod
def from_config(cls, config: CondenserConfig) -> Condenser:
"""Create a condenser from a configuration object.
Args:
config: Configuration for the condenser.
Returns:
Condenser: A condenser instance.
Parameters:
- llm (LLM): llm to be used for summarization
Raises:
ValueError: If the condenser type is not recognized.
"""
match config:
case NoOpCondenserConfig():
return NoOpCondenser()
case ObservationMaskingCondenserConfig():
return ObservationMaskingCondenser(
**config.model_dump(exclude=['type'])
)
case RecentEventsCondenserConfig():
return RecentEventsCondenser(**config.model_dump(exclude=['type']))
case LLMSummarizingCondenserConfig(llm_config=llm_config):
return LLMSummarizingCondenser(llm=LLM(config=llm_config))
case AmortizedForgettingCondenserConfig():
return AmortizedForgettingCondenser(
**config.model_dump(exclude=['type'])
)
case LLMAttentionCondenserConfig(llm_config=llm_config):
return LLMAttentionCondenser(
llm=LLM(config=llm_config),
**config.model_dump(exclude=['type', 'llm_config']),
)
case _:
raise ValueError(f'Unknown condenser config: {config}')
class RollingCondenser(Condenser, ABC):
"""Base class for a specialized condenser strategy that applies condensation to a rolling history.
The rolling history is computed by appending new events to the most recent condensation. For example, the sequence of calls::
assert state.history == [event1, event2, event3]
condensation = condenser.condensed_history(state)
# ...new events are added to the state...
assert state.history == [event1, event2, event3, event4, event5]
condenser.condensed_history(state)
will result in second call to `condensed_history` passing `condensation + [event4, event5]` to the `condense` method.
"""
def __init__(self) -> None:
self._condensation: list[Event] = []
self._last_history_length: int = 0
super().__init__()
@override
def condensed_history(self, state: State) -> list[Event]:
new_events = state.history[self._last_history_length :]
with self.metadata_batch(state):
results = self.condense(self._condensation + new_events)
self._condensation = results
self._last_history_length = len(state.history)
return results
class NoOpCondenser(Condenser):
"""A condenser that does nothing to the event sequence."""
def condense(self, events: list[Event]) -> list[Event]:
"""Returns the list of events unchanged."""
return events
class ObservationMaskingCondenser(Condenser):
"""A condenser that masks the values of observations outside of a recent attention window."""
def __init__(self, attention_window: int = 5):
self.attention_window = attention_window
super().__init__()
def condense(self, events: list[Event]) -> list[Event]:
"""Replace the content of observations outside of the attention window with a placeholder."""
results: list[Event] = []
for i, event in enumerate(events):
if (
isinstance(event, Observation)
and i < len(events) - self.attention_window
):
results.append(AgentCondensationObservation('<MASKED>'))
else:
results.append(event)
return results
class RecentEventsCondenser(Condenser):
"""A condenser that only keeps a certain number of the most recent events."""
def __init__(self, keep_first: int = 0, max_events: int = 10):
self.keep_first = keep_first
self.max_events = max_events
super().__init__()
def condense(self, events: list[Event]) -> list[Event]:
"""Keep only the most recent events (up to `max_events`)."""
head = events[: self.keep_first]
tail_length = max(0, self.max_events - len(head))
tail = events[-tail_length:]
return head + tail
class LLMSummarizingCondenser(Condenser):
"""A condenser that relies on a language model to summarize the event sequence as a single event."""
def __init__(self, llm: LLM):
self.llm = llm
super().__init__()
def condense(self, events: list[Event]) -> list[Event]:
"""Applies an LLM to summarize the list of events.
Raises:
Exception: If the LLM is unable to summarize the event sequence.
- Exception: the same exception as it got from the llm or processing the response
"""
try:
# Convert events to a format suitable for summarization
events_text = '\n'.join(f'{e.timestamp}: {e.message}' for e in events)
summarize_prompt = f'Please summarize these events:\n{events_text}'
resp = self.llm.completion(
messages=[{'content': summarize_prompt, 'role': 'user'}]
)
summary_response = resp.choices[0].message.content
# Create a new summary event with the condensed content
summary_event = AgentCondensationObservation(summary_response)
# Add metrics to state
self.add_metadata('response', resp.model_dump())
self.add_metadata('metrics', self.llm.metrics.get())
return [summary_event]
messages = [{'content': summarize_prompt, 'role': 'user'}]
resp = llm.completion(messages=messages)
summary_response = resp['choices'][0]['message']['content']
return summary_response
except Exception as e:
logger.error('Error condensing events: %s', str(e), exc_info=False)
raise e
logger.error('Error condensing thoughts: %s', str(e), exc_info=False)
class AmortizedForgettingCondenser(RollingCondenser):
"""A condenser that maintains a condensed history and forgets old events when it grows too large."""
def __init__(self, max_size: int = 100, keep_first: int = 0):
"""Initialize the condenser.
Args:
max_size: Maximum size of history before forgetting.
keep_first: Number of initial events to always keep.
Raises:
ValueError: If keep_first is greater than max_size, keep_first is negative, or max_size is non-positive.
"""
if keep_first >= max_size // 2:
raise ValueError(
f'keep_first ({keep_first}) must be less than half of max_size ({max_size})'
)
if keep_first < 0:
raise ValueError(f'keep_first ({keep_first}) cannot be negative')
if max_size < 1:
raise ValueError(f'max_size ({keep_first}) cannot be non-positive')
self.max_size = max_size
self.keep_first = keep_first
super().__init__()
def condense(self, events: list[Event]) -> list[Event]:
"""Apply the amortized forgetting strategy to the given list of events."""
if len(events) <= self.max_size:
return events
target_size = self.max_size // 2
head = events[: self.keep_first]
events_from_tail = target_size - len(head)
tail = events[-events_from_tail:]
return head + tail
class ImportantEventSelection(BaseModel):
"""Utility class for the `LLMAttentionCondenser` that forces the LLM to return a list of integers."""
ids: list[int]
class LLMAttentionCondenser(RollingCondenser):
"""Rolling condenser strategy that uses an LLM to select the most important events when condensing the history."""
def __init__(self, llm: LLM, max_size: int = 100, keep_first: int = 0):
if keep_first >= max_size // 2:
raise ValueError(
f'keep_first ({keep_first}) must be less than half of max_size ({max_size})'
)
if keep_first < 0:
raise ValueError(f'keep_first ({keep_first}) cannot be negative')
if max_size < 1:
raise ValueError(f'max_size ({keep_first}) cannot be non-positive')
self.max_size = max_size
self.keep_first = keep_first
self.llm = llm
# This condenser relies on the `response_schema` feature, which is not supported by all LLMs
if not supports_response_schema(
model=self.llm.config.model,
custom_llm_provider=self.llm.config.custom_llm_provider,
):
raise ValueError(
"The LLM model must support the 'response_schema' parameter to use the LLMAttentionCondenser."
)
super().__init__()
def condense(self, events: list[Event]) -> list[Event]:
"""If the history is too long, use an LLM to select the most important events."""
if len(events) <= self.max_size:
return events
target_size = self.max_size // 2
head = events[: self.keep_first]
events_from_tail = target_size - len(head)
message: str = """You will be given a list of actions, observations, and thoughts from a coding agent.
Each item in the list has an identifier. Please sort the identifiers in order of how important the
contents of the item are for the next step of the coding agent's task, from most important to least
important."""
response = self.llm.completion(
messages=[
{'content': message, 'role': 'user'},
*[
{
'content': f'<ID>{e.id}</ID>\n<CONTENT>{e.message}</CONTENT>',
'role': 'user',
}
for e in events
],
],
response_format={
'type': 'json_schema',
'json_schema': {
'name': 'ImportantEventSelection',
'schema': ImportantEventSelection.model_json_schema(),
},
},
)
response_ids = ImportantEventSelection.model_validate_json(
response.choices[0].message.content
).ids
self.add_metadata('all_event_ids', [event.id for event in events])
self.add_metadata('response_ids', response_ids)
self.add_metadata('metrics', self.llm.metrics.get())
# Filter out any IDs from the head and trim the results down
head_ids = [event.id for event in head]
response_ids = [
response_id for response_id in response_ids if response_id not in head_ids
][:events_from_tail]
# If the response IDs aren't _long_ enough, iterate backwards through the events and add any unfound IDs to the list.
for event in reversed(events):
if len(response_ids) >= events_from_tail:
break
if event.id not in response_ids:
response_ids.append(event.id)
# Grab the events associated with the response IDs
tail = [event for event in events if event.id in response_ids]
return head + tail
# TODO If the llm fails with ContextWindowExceededError, we can try to condense the memory chunk by chunk
raise

View File

@@ -210,11 +210,9 @@ class Runtime(FileEditRuntimeMixin):
source = event.source if event.source else EventSource.AGENT
self.event_stream.add_event(observation, source) # type: ignore[arg-type]
def clone_repo(self, github_token: str, selected_repository: str):
def clone_repo(self, github_token: str | None, selected_repository: str | None):
if not github_token or not selected_repository:
raise ValueError(
'github_token and selected_repository must be provided to clone a repository'
)
return
url = f'https://{github_token}@github.com/{selected_repository}.git'
dir_name = selected_repository.split('/')[1]
# add random branch name to avoid conflicts

View File

@@ -26,17 +26,6 @@ class DockerRuntimeBuilder(RuntimeBuilder):
self.rolling_logger = RollingLogger(max_lines=10)
@staticmethod
def check_buildx():
"""Check if Docker Buildx is available"""
try:
result = subprocess.run(
['docker', 'buildx', 'version'], capture_output=True, text=True
)
return result.returncode == 0
except FileNotFoundError:
return False
def build(
self,
path: str,
@@ -73,38 +62,6 @@ class DockerRuntimeBuilder(RuntimeBuilder):
'Docker server version must be >= 18.09 to use BuildKit'
)
if not DockerRuntimeBuilder.check_buildx():
# when running openhands in a container, there might not be a "docker"
# binary available, in which case we need to download docker binary.
# since the official openhands app image is built from debian, we use
# debian way to install docker binary
logger.info(
'No docker binary available inside openhands-app container, trying to download online...'
)
commands = [
'apt-get update',
'apt-get install -y ca-certificates curl gnupg',
'install -m 0755 -d /etc/apt/keyrings',
'curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc',
'chmod a+r /etc/apt/keyrings/docker.asc',
'echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
tee /etc/apt/sources.list.d/docker.list > /dev/null',
'apt-get update',
'apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin',
]
for cmd in commands:
try:
subprocess.run(
cmd, shell=True, check=True, stdout=subprocess.DEVNULL
)
except subprocess.CalledProcessError as e:
logger.error(f'Image build failed:\n{e}')
logger.error(f'Command output:\n{e.output}')
raise
logger.info('Downloaded and installed docker binary')
target_image_hash_name = tags[0]
target_image_repo, target_image_source_tag = target_image_hash_name.split(':')
target_image_tag = tags[1].split(':')[1] if len(tags) > 1 else None

View File

@@ -267,11 +267,13 @@ class DockerRuntime(ActionExecutionClient):
environment=environment,
volumes=volumes,
device_requests=(
[docker.types.DeviceRequest(capabilities=[['gpu']], count=-1)]
[docker.types.DeviceRequest(
capabilities=[['gpu']],
count=-1
)]
if self.config.sandbox.enable_gpu
else None
),
**(self.config.sandbox.docker_runtime_kwargs or {}),
)
self.log('debug', f'Container started. Server url: {self.api_url}')
self.send_status_message('STATUS$CONTAINER_STARTED')

View File

@@ -1,7 +1,6 @@
import os
import re
import time
import traceback
import uuid
from enum import Enum
@@ -24,11 +23,11 @@ def split_bash_commands(commands):
return ['']
try:
parsed = bashlex.parse(commands)
except (bashlex.errors.ParsingError, NotImplementedError):
except bashlex.errors.ParsingError as e:
logger.debug(
f'Failed to parse bash commands\n'
f'[input]: {commands}\n'
f'[warning]: {traceback.format_exc()}\n'
f'[warning]: {e}\n'
f'The original command will be returned as is.'
)
# If parsing fails, return the original commands
@@ -144,13 +143,9 @@ def escape_bash_special_chars(command: str) -> str:
remaining = command[last_pos:]
parts.append(remaining)
return ''.join(parts)
except (bashlex.errors.ParsingError, NotImplementedError):
logger.debug(
f'Failed to parse bash commands for special characters escape\n'
f'[input]: {command}\n'
f'[warning]: {traceback.format_exc()}\n'
f'The original command will be returned as is.'
)
except bashlex.errors.ParsingError:
# Fallback if parsing fails
logger.warning(f'Failed to parse command: {command}')
return command

View File

@@ -1,14 +1,9 @@
import jwt
from fastapi import Request
from jwt.exceptions import InvalidTokenError
from openhands.core.logger import openhands_logger as logger
def get_user_id(request: Request) -> int:
return getattr(request.state, 'github_user_id', 0)
def get_sid_from_token(token: str, jwt_secret: str) -> str:
"""Retrieves the session id from a JWT token.

View File

@@ -1,6 +1,6 @@
from urllib.parse import parse_qs
import jwt
from github import Github, GithubException
from socketio.exceptions import ConnectionRefusedError
from openhands.core.logger import openhands_logger as logger
@@ -18,6 +18,7 @@ from openhands.server.routes.settings import ConversationStoreImpl, SettingsStor
from openhands.server.session.manager import ConversationDoesNotExistError
from openhands.server.shared import config, openhands_config, session_manager, sio
from openhands.server.types import AppMode
from openhands.utils.async_utils import call_sync_from_async
@sio.event
@@ -30,20 +31,27 @@ async def connect(connection_id: str, environ, auth):
logger.error('No conversation_id in query params')
raise ConnectionRefusedError('No conversation_id in query params')
user_id = -1
github_token = ''
if openhands_config.app_mode != AppMode.OSS:
cookies_str = environ.get('HTTP_COOKIE', '')
cookies = dict(cookie.split('=', 1) for cookie in cookies_str.split('; '))
signed_token = cookies.get('github_auth', '')
if not signed_token:
logger.error('No github_auth cookie')
raise ConnectionRefusedError('No github_auth cookie')
decoded = jwt.decode(signed_token, config.jwt_secret, algorithms=['HS256'])
user_id = decoded['github_user_id']
user_id = ''
if auth and 'github_token' in auth:
github_token = auth['github_token']
try:
with Github(github_token) as g:
gh_user = await call_sync_from_async(g.get_user)
user_id = gh_user.id
except GithubException as e:
logger.error(f'Error connecting to github: {e}')
raise ConnectionRefusedError(f'Error connecting to github: {e}')
else:
logger.error('No github_token in auth')
raise ConnectionRefusedError('No github_token sent')
logger.info(f'User {user_id} is connecting to conversation {conversation_id}')
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
conversation_store = await ConversationStoreImpl.get_instance(
config, github_token
)
metadata = await conversation_store.get_metadata(conversation_id)
if metadata.github_user_id != user_id:
logger.error(
@@ -53,7 +61,7 @@ async def connect(connection_id: str, environ, auth):
f'User {user_id} is not allowed to join conversation {conversation_id}'
)
settings_store = await SettingsStoreImpl.get_instance(config, user_id)
settings_store = await SettingsStoreImpl.get_instance(config, github_token)
settings = await settings_store.load()
if not settings:

View File

@@ -1,7 +1,5 @@
from typing import Literal
import requests
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from openhands.server.shared import openhands_config
@@ -9,9 +7,6 @@ from openhands.utils.async_utils import call_sync_from_async
app = APIRouter(prefix='/api')
GITHUB_API_BASE = 'https://api.github.com'
GITHUB_API_VERSION = '2022-11-28'
@app.get('/github/repositories')
async def get_github_repositories(
@@ -69,143 +64,3 @@ async def get_github_repositories(
json_response.headers['Link'] = response.headers['Link']
return json_response
@app.get('/github/installations')
async def get_github_installations(request: Request):
"""Get GitHub App installations for the authenticated user"""
github_token = request.headers.get('X-GitHub-Token')
if not github_token:
raise HTTPException(status_code=400, detail='Missing X-GitHub-Token header')
headers = {
'Authorization': f'Bearer {github_token}',
'Accept': 'application/vnd.github.v3+json',
'X-GitHub-Api-Version': GITHUB_API_VERSION,
}
try:
response = await call_sync_from_async(
requests.get, f'{GITHUB_API_BASE}/user/installations', headers=headers
)
response.raise_for_status()
except requests.exceptions.RequestException as e:
raise HTTPException(
status_code=response.status_code if response else 500,
detail=f'Error fetching installations: {str(e)}',
)
return JSONResponse(content=response.json())
@app.get('/github/user')
async def get_github_user(request: Request):
"""Get authenticated GitHub user information"""
github_token = request.headers.get('X-GitHub-Token')
if not github_token:
raise HTTPException(status_code=400, detail='Missing X-GitHub-Token header')
headers = {
'Authorization': f'Bearer {github_token}',
'Accept': 'application/vnd.github.v3+json',
'X-GitHub-Api-Version': GITHUB_API_VERSION,
}
try:
response = await call_sync_from_async(
requests.get, f'{GITHUB_API_BASE}/user', headers=headers
)
response.raise_for_status()
except requests.exceptions.RequestException as e:
raise HTTPException(
status_code=response.status_code if response else 500,
detail=f'Error fetching user: {str(e)}',
)
data = response.json()
return JSONResponse(
content={
'id': data['id'],
'login': data['login'],
'avatar_url': data['avatar_url'],
'company': data.get('company'),
'name': data.get('name'),
'email': data.get('email'),
}
)
@app.get('/github/search/repositories')
async def search_github_repositories(
request: Request,
query: str,
per_page: int = Query(default=5, le=100),
sort: Literal['', 'updated', 'stars', 'forks'] = 'stars',
order: Literal['desc', 'asc'] = 'desc',
):
"""Search public GitHub repositories"""
github_token = request.headers.get('X-GitHub-Token')
if not github_token:
raise HTTPException(status_code=400, detail='Missing X-GitHub-Token header')
headers = {
'Authorization': f'Bearer {github_token}',
'Accept': 'application/vnd.github.v3+json',
'X-GitHub-Api-Version': GITHUB_API_VERSION,
}
params = {'q': query, 'per_page': str(per_page), 'sort': sort, 'order': order}
try:
response = await call_sync_from_async(
requests.get,
f'{GITHUB_API_BASE}/search/repositories',
headers=headers,
params=params,
)
response.raise_for_status()
except requests.exceptions.RequestException as e:
raise HTTPException(
status_code=response.status_code if response else 500,
detail=f'Error searching repositories: {str(e)}',
)
return JSONResponse(content=response.json())
@app.get('/github/repos/{owner}/{repo}/commits')
async def get_github_commits(
request: Request, owner: str, repo: str, per_page: int = Query(default=1, le=100)
):
"""Get latest commits for a GitHub repository"""
github_token = request.headers.get('X-GitHub-Token')
if not github_token:
raise HTTPException(status_code=400, detail='Missing X-GitHub-Token header')
headers = {
'Authorization': f'Bearer {github_token}',
'Accept': 'application/vnd.github.v3+json',
'X-GitHub-Api-Version': GITHUB_API_VERSION,
}
params = {'per_page': str(per_page)}
try:
response = await call_sync_from_async(
requests.get,
f'{GITHUB_API_BASE}/repos/{owner}/{repo}/commits',
headers=headers,
params=params,
)
response.raise_for_status()
except requests.exceptions.RequestException as e:
response = getattr(e, 'response', None)
if response and response.status_code == 409:
# Repository is empty, no commits yet
return JSONResponse(content=[])
raise HTTPException(
status_code=getattr(getattr(e, 'response', None), 'status_code', 500),
detail=f'Error fetching commits: {str(e)}',
)
return JSONResponse(content=response.json())

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