mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
41 Commits
enyst/cli-
...
optional-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da4b96da71 | ||
|
|
d9937ce566 | ||
|
|
2cfdebfbe2 | ||
|
|
9d5b39da98 | ||
|
|
caeeb0356e | ||
|
|
9b4ad4e6e3 | ||
|
|
1e33624951 | ||
|
|
8b90d610c6 | ||
|
|
834abc0eee | ||
|
|
c9bb0fc168 | ||
|
|
5d69e606eb | ||
|
|
081880248c | ||
|
|
4ee269c3f7 | ||
|
|
711315c3b9 | ||
|
|
c2e6244b86 | ||
|
|
a1479adfd3 | ||
|
|
99fd3f7bb2 | ||
|
|
c617881b3c | ||
|
|
7ca3607dcd | ||
|
|
89999a8e09 | ||
|
|
3d9761df7e | ||
|
|
ea3c4f9366 | ||
|
|
bda0a64a3d | ||
|
|
8badcb7b35 | ||
|
|
078534c2ab | ||
|
|
1e237d0bd9 | ||
|
|
ba885cd04c | ||
|
|
ee64a6662a | ||
|
|
5fda84f8cc | ||
|
|
9589e6655c | ||
|
|
075ef4db9f | ||
|
|
b319bea288 | ||
|
|
a3e02662d3 | ||
|
|
a526f73ea6 | ||
|
|
516f9fa635 | ||
|
|
8c5995a5d8 | ||
|
|
afe130f6db | ||
|
|
cc2f96c6c4 | ||
|
|
033dceb8ee | ||
|
|
09bf498cd5 | ||
|
|
1087173bdf |
@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
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.44-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.45-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
23
README.md
23
README.md
@@ -48,7 +48,7 @@ Learn more at [docs.all-hands.dev](https://docs.all-hands.dev), or [sign up for
|
||||
|
||||
## ☁️ OpenHands Cloud
|
||||
The easiest way to get started with OpenHands is on [OpenHands Cloud](https://app.all-hands.dev),
|
||||
which comes with $50 in free credits for new users.
|
||||
which comes with $20 in free credits for new users.
|
||||
|
||||
## 💻 Running OpenHands Locally
|
||||
|
||||
@@ -62,17 +62,17 @@ system requirements and more information.
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.44-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.45-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.44-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.45-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.44
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.45
|
||||
```
|
||||
|
||||
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
|
||||
@@ -147,13 +147,12 @@ For a list of open source projects and licenses used in OpenHands, please see ou
|
||||
## 📚 Cite
|
||||
|
||||
```
|
||||
@misc{openhands,
|
||||
title={{OpenHands: An Open Platform for AI Software Developers as Generalist Agents}},
|
||||
author={Xingyao Wang and Boxuan Li and Yufan Song and Frank F. Xu and Xiangru Tang and Mingchen Zhuge and Jiayi Pan and Yueqi Song and Bowen Li and Jaskirat Singh and Hoang H. Tran and Fuqiang Li and Ren Ma and Mingzhang Zheng and Bill Qian and Yanjun Shao and Niklas Muennighoff and Yizhe Zhang and Binyuan Hui and Junyang Lin and Robert Brennan and Hao Peng and Heng Ji and Graham Neubig},
|
||||
year={2024},
|
||||
eprint={2407.16741},
|
||||
archivePrefix={arXiv},
|
||||
primaryClass={cs.SE},
|
||||
url={https://arxiv.org/abs/2407.16741},
|
||||
@inproceedings{
|
||||
wang2025openhands,
|
||||
title={OpenHands: An Open Platform for {AI} Software Developers as Generalist Agents},
|
||||
author={Xingyao Wang and Boxuan Li and Yufan Song and Frank F. Xu and Xiangru Tang and Mingchen Zhuge and Jiayi Pan and Yueqi Song and Bowen Li and Jaskirat Singh and Hoang H. Tran and Fuqiang Li and Ren Ma and Mingzhang Zheng and Bill Qian and Yanjun Shao and Niklas Muennighoff and Yizhe Zhang and Binyuan Hui and Junyang Lin and Robert Brennan and Hao Peng and Heng Ji and Graham Neubig},
|
||||
booktitle={The Thirteenth International Conference on Learning Representations},
|
||||
year={2025},
|
||||
url={https://openreview.net/forum?id=OJd3ayDDoF}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.44-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.45-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.44-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.45-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.44
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.45
|
||||
```
|
||||
|
||||
> **注意**: 如果您在0.44版本之前使用过OpenHands,您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。
|
||||
|
||||
60
README_JA.md
Normal file
60
README_JA.md
Normal file
@@ -0,0 +1,60 @@
|
||||
<a name="readme-top"></a>
|
||||
|
||||
<div align="center">
|
||||
<img src="./docs/static/img/logo.png" alt="Logo" width="200">
|
||||
<h1 align="center">OpenHands: コードを減らして、もっと作ろう</h1>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/graphs/contributors"><img src="https://img.shields.io/github/contributors/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="Contributors"></a>
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/stargazers"><img src="https://img.shields.io/github/stars/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="Stargazers"></a>
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/LICENSE"><img src="https://img.shields.io/github/license/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="MIT License"></a>
|
||||
<br/>
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-34zm4j0gj-Qz5kRHoca8DFCbqXPS~f_A"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Slackコミュニティに参加"></a>
|
||||
<a href="https://discord.gg/ESHStjSjD4"><img src="https://img.shields.io/badge/Discord-Join%20Us-purple?logo=discord&logoColor=white&style=for-the-badge" alt="Discordコミュニティに参加"></a>
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/CREDITS.md"><img src="https://img.shields.io/badge/Project-Credits-blue?style=for-the-badge&color=FFE165&logo=github&logoColor=white" alt="クレジット"></a>
|
||||
<br/>
|
||||
<a href="https://docs.all-hands.dev/usage/getting-started"><img src="https://img.shields.io/badge/Documentation-000?logo=googledocs&logoColor=FFE165&style=for-the-badge" alt="ドキュメントを見る"></a>
|
||||
<a href="https://arxiv.org/abs/2407.16741"><img src="https://img.shields.io/badge/Paper%20on%20Arxiv-000?logoColor=FFE165&logo=arxiv&style=for-the-badge" alt="Arxiv論文"></a>
|
||||
<a href="https://docs.google.com/spreadsheets/d/1wOUdFCMyY6Nt0AIqF705KN4JKOWgeI4wUGUP60krXXs/edit?gid=0#gid=0"><img src="https://img.shields.io/badge/Benchmark%20score-000?logoColor=FFE165&logo=huggingface&style=for-the-badge" alt="評価ベンチマークスコア"></a>
|
||||
<hr>
|
||||
</div>
|
||||
|
||||
OpenHands(旧OpenDevin)へようこそ。これはAIが駆動するソフトウェア開発エージェントのプラットフォームです。
|
||||
|
||||
OpenHandsのエージェントは人間の開発者ができることは何でもこなします。コードを修正し、コマンドを実行し、ウェブを閲覧し、APIを呼び出し、StackOverflowからコードスニペットをコピーすることさえできます。
|
||||
|
||||
詳細は[docs.all-hands.dev](https://docs.all-hands.dev)をご覧いただくか、[OpenHands Cloud](https://app.all-hands.dev)に登録して始めましょう。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 仕事でOpenHandsを使っていますか?ぜひお話を聞かせてください。[こちらの短いフォーム](https://docs.google.com/forms/d/e/1FAIpQLSet3VbGaz8z32gW9Wm-Grl4jpt5WgMXPgJ4EDPVmCETCBpJtQ/viewform)にご記入いただき、Design Partnerプログラムにご参加ください。商用機能の早期アクセスや製品ロードマップへのフィードバックの機会を提供します。
|
||||
|
||||

|
||||
|
||||
## ☁️ OpenHands Cloud
|
||||
OpenHandsを始める最も簡単な方法は[OpenHands Cloud](https://app.all-hands.dev)を利用することです。新規ユーザーには50ドル分の無料クレジットが付与されます。
|
||||
|
||||
## 💻 OpenHandsをローカルで実行する
|
||||
|
||||
OpenHandsはDockerを利用してローカル環境でも実行できます。システム要件や詳細については[Running OpenHands](https://docs.all-hands.dev/usage/installation)ガイドをご覧ください。
|
||||
|
||||
> [!WARNING]
|
||||
> 公共ネットワークで実行していますか?[Hardened Docker Installation Guide](https://docs.all-hands.dev/usage/runtimes/docker#hardened-docker-installation)を参照して、ネットワークバインディングの制限や追加のセキュリティ対策を実施してください。
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.45-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.45-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.45
|
||||
```
|
||||
|
||||
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。
|
||||
|
||||
OpenHandsは[http://localhost:3000](http://localhost:3000)で起動します!
|
||||
@@ -26,7 +26,7 @@ RUN apt-get update -y \
|
||||
|
||||
COPY pyproject.toml poetry.lock ./
|
||||
RUN touch README.md
|
||||
RUN export POETRY_CACHE_DIR && poetry install --no-root && rm -rf $POETRY_CACHE_DIR
|
||||
RUN export POETRY_CACHE_DIR && poetry install --no-root --extras all-runtimes && rm -rf $POETRY_CACHE_DIR
|
||||
|
||||
FROM base AS openhands-app
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
- DOCKER_HOST_ADDR=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.44-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.45-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
FROM ubuntu:22.04
|
||||
|
||||
# install basic packages
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
wget \
|
||||
git \
|
||||
vim \
|
||||
nano \
|
||||
unzip \
|
||||
zip \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
python3-dev \
|
||||
build-essential \
|
||||
openssh-server \
|
||||
sudo \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -1,15 +0,0 @@
|
||||
# How to build custom E2B sandbox for OpenHands
|
||||
|
||||
[E2B](https://e2b.dev) is an [open-source](https://github.com/e2b-dev/e2b) secure cloud environment (sandbox) made for running AI-generated code and agents. E2B offers [Python](https://pypi.org/project/e2b/) and [JS/TS](https://www.npmjs.com/package/e2b) SDK to spawn and control these sandboxes.
|
||||
|
||||
|
||||
1. Install the CLI with NPM.
|
||||
```sh
|
||||
npm install -g @e2b/cli@latest
|
||||
```
|
||||
Full CLI API is [here](https://e2b.dev/docs/cli/installation).
|
||||
|
||||
1. Build the sandbox
|
||||
```sh
|
||||
e2b template build --dockerfile ./Dockerfile --name "openhands"
|
||||
```
|
||||
@@ -1,14 +0,0 @@
|
||||
# This is a config for E2B sandbox template.
|
||||
# You can use 'template_id' (785n69crgahmz0lkdw9h) or 'template_name (openhands) from this config to spawn a sandbox:
|
||||
|
||||
# Python SDK
|
||||
# from e2b import Sandbox
|
||||
# sandbox = Sandbox(template='openhands')
|
||||
|
||||
# JS SDK
|
||||
# import { Sandbox } from 'e2b'
|
||||
# const sandbox = await Sandbox.create({ template: 'openhands' })
|
||||
|
||||
dockerfile = "Dockerfile"
|
||||
template_name = "openhands"
|
||||
template_id = "785n69crgahmz0lkdw9h"
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.44-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.45-nikolaik}
|
||||
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
17
docs/README_JA.md
Normal file
17
docs/README_JA.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# セットアップ
|
||||
|
||||
```
|
||||
npm install -g mint
|
||||
```
|
||||
|
||||
または
|
||||
|
||||
```
|
||||
yarn global add mint
|
||||
```
|
||||
|
||||
# プレビュー
|
||||
|
||||
```
|
||||
mint dev
|
||||
```
|
||||
@@ -26,6 +26,7 @@
|
||||
"usage/installation",
|
||||
"usage/getting-started",
|
||||
"usage/key-features",
|
||||
"usage/faqs",
|
||||
{
|
||||
"group": "OpenHands Cloud",
|
||||
"pages": [
|
||||
@@ -43,7 +44,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Running OpenHands on Your Own",
|
||||
"group": "Run OpenHands on Your Own",
|
||||
"pages": [
|
||||
"usage/local-setup",
|
||||
"usage/how-to/gui-mode",
|
||||
@@ -103,8 +104,9 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Customization",
|
||||
"group": "Customizations & Settings",
|
||||
"pages": [
|
||||
"usage/common-settings",
|
||||
"usage/prompting/repository",
|
||||
{
|
||||
"group": "Microagents",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Cloud UI
|
||||
description: The Cloud UI provides a web interface for interacting with OpenHands. This page explains how to use the
|
||||
OpenHands Cloud UI.
|
||||
description: The Cloud UI provides a web interface for interacting with OpenHands. This page provides references on
|
||||
how to use the OpenHands Cloud UI.
|
||||
---
|
||||
|
||||
## Landing Page
|
||||
@@ -19,10 +19,12 @@ The landing page is where you can:
|
||||
The Settings page allows you to:
|
||||
|
||||
- [Configure GitHub repository access](/usage/cloud/github-installation#modifying-repository-access) for OpenHands.
|
||||
- [Install the OpenHands Slack app](/usage/cloud/slack-installation).
|
||||
- Set application settings like your preferred language, notifications and other preferences.
|
||||
- Add credits to your account.
|
||||
- Generate custom secrets.
|
||||
- Create API keys to work with OpenHands programmatically.
|
||||
- [Generate custom secrets](/usage/common-settings#secrets-management).
|
||||
- [Create API keys to work with OpenHands programmatically](/usage/cloud/cloud-api).
|
||||
- Change your email address.
|
||||
|
||||
## Key Features
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description: This guide walks you through installing the OpenHands Slack app.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Access to OpenHands Cloud
|
||||
- Access to OpenHands Cloud.
|
||||
|
||||
## Installation Steps
|
||||
|
||||
@@ -23,11 +23,11 @@ description: This guide walks you through installing the OpenHands Slack app.
|
||||
|
||||
<Accordion title="Authorize Slack App (for all Slack workspace members)">
|
||||
|
||||
**Make sure your Slack workspace admin/owner has installed OpenHands Slack App first**
|
||||
**Make sure your Slack workspace admin/owner has installed OpenHands Slack App first.**
|
||||
|
||||
Every user in the Slack workspace (including admins/owners) must link their Cloud OpenHands account to the OpenHands Slack App. To do this:
|
||||
Every user in the Slack workspace (including admins/owners) must link their OpenHands Cloud account to the OpenHands Slack App. To do this:
|
||||
1. Visit [integrations settings](https://app.all-hands.dev/settings/integrations) in OpenHands Cloud.
|
||||
2. Click the button "Install Slack App".
|
||||
2. Click `Install OpenHands Slack App`.
|
||||
3. In the top right corner, select the workspace to install the OpenHands Slack app.
|
||||
4. Review permissions and click allow.
|
||||
|
||||
|
||||
52
docs/usage/common-settings.mdx
Normal file
52
docs/usage/common-settings.mdx
Normal file
@@ -0,0 +1,52 @@
|
||||
---
|
||||
title: OpenHands Settings
|
||||
description: Overview of some of the settings available in OpenHands.
|
||||
---
|
||||
|
||||
## Openhands Cloud vs Running on Your Own
|
||||
|
||||
There are some differences between the settings available in OpenHands Cloud and those available when running OpenHands
|
||||
on your own:
|
||||
* [OpenHands Cloud settings](/usage/cloud/cloud-ui#settings)
|
||||
* [Settings available when running on your own](/usage/how-to/gui-mode#settings)
|
||||
|
||||
Refer to these pages for more detailed information.
|
||||
|
||||
## Secrets Management
|
||||
|
||||
OpenHands provides a secrets manager that allows you to securely store and manage sensitive information that can be
|
||||
accessed by the agent during runtime, such as API keys. These secrets are automatically exported as environment
|
||||
variables in the agent's runtime environment.
|
||||
|
||||
### Accessing the Secrets Manager
|
||||
|
||||
In the Settings page, navigate to the `Secrets` tab. Here, you'll see a list of all your existing custom secrets.
|
||||
|
||||
### Adding a New Secret
|
||||
1. Click `Add a new secret`.
|
||||
2. Fill in the following fields:
|
||||
- **Name**: A unique identifier for your secret (e.g., `AWS_ACCESS_KEY`). This will be the environment variable name.
|
||||
- **Value**: The sensitive information you want to store.
|
||||
- **Description** (optional): A brief description of what the secret is used for, which is also provided to the agent.
|
||||
3. Click `Add secret` to save.
|
||||
|
||||
### Editing a Secret
|
||||
|
||||
1. Click the `Edit` button next to the secret you want to modify.
|
||||
2. You can update the name and description of the secret.
|
||||
<Note>
|
||||
For security reasons, you cannot view or edit the value of an existing secret. If you need to change the
|
||||
value, delete the secret and create a new one.
|
||||
</Note>
|
||||
|
||||
### Deleting a Secret
|
||||
|
||||
1. Click the `Delete` button next to the secret you want to remove.
|
||||
2. Select `Confirm` to delete the secret.
|
||||
|
||||
### Using Secrets in the Agent
|
||||
- All custom secrets are automatically exported as environment variables in the agent's runtime environment.
|
||||
- You can access them in your code using standard environment variable access methods
|
||||
(e.g., `os.environ['SECRET_NAME']` in Python).
|
||||
- Example: If you create a secret named `OPENAI_API_KEY`, you can access it in your code as
|
||||
`process.env.OPENAI_API_KEY` in JavaScript or `os.environ['OPENAI_API_KEY']` in Python.
|
||||
@@ -7,22 +7,6 @@ description: This page outlines all available configuration options for OpenHand
|
||||
|
||||
The core configuration options are defined in the `[core]` section of the `config.toml` file.
|
||||
|
||||
### API Keys
|
||||
- `e2b_api_key`
|
||||
- Type: `str`
|
||||
- Default: `""`
|
||||
- Description: API key for E2B
|
||||
|
||||
- `modal_api_token_id`
|
||||
- Type: `str`
|
||||
- Default: `""`
|
||||
- Description: API token ID for Modal
|
||||
|
||||
- `modal_api_token_secret`
|
||||
- Type: `str`
|
||||
- Default: `""`
|
||||
- Description: API token secret for Modal
|
||||
|
||||
### Workspace
|
||||
- `workspace_base` **(Deprecated)**
|
||||
- Type: `str`
|
||||
|
||||
96
docs/usage/faqs.mdx
Normal file
96
docs/usage/faqs.mdx
Normal file
@@ -0,0 +1,96 @@
|
||||
---
|
||||
title: FAQs
|
||||
description: Frequently asked questions about OpenHands
|
||||
icon: question
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### I'm new to OpenHands. Where should I start?
|
||||
|
||||
1. **Quick start**: Use [OpenHands Cloud](/usage/cloud/openhands-cloud) to get started quickly with
|
||||
[GitHub](/usage/cloud/github-installation), [GitLab](/usage/cloud/gitlab-installation),
|
||||
and [Slack](/usage/cloud/slack-installation) integrations.
|
||||
2. **Run on your own**: If you prefer to run it on your own hardware, follow our [Getting Started guide](/usage/local-setup).
|
||||
3. **First steps**: Complete the [start building tutorial](/usage/getting-started) to learn the basics.
|
||||
|
||||
### Can I use OpenHands for production workloads?
|
||||
|
||||
OpenHands is meant to be run by a single user on their local workstation. It is not appropriate for multi-tenant
|
||||
deployments where multiple users share the same instance. There is no built-in authentication, isolation, or scalability.
|
||||
|
||||
If you're interested in running OpenHands in a multi-tenant environment, check out the source-available,
|
||||
commercially-licensed [OpenHands Cloud Helm Chart](https://github.com/all-Hands-AI/OpenHands-cloud).
|
||||
|
||||
<Info>
|
||||
Using OpenHands for work? We'd love to chat! Fill out
|
||||
[this short form](https://docs.google.com/forms/d/e/1FAIpQLSet3VbGaz8z32gW9Wm-Grl4jpt5WgMXPgJ4EDPVmCETCBpJtQ/viewform)
|
||||
to join our Design Partner program, where you'll get early access to commercial features and the opportunity to provide
|
||||
input on our product roadmap.
|
||||
</Info>
|
||||
|
||||
## Safety and Security
|
||||
|
||||
### It's doing stuff without asking, is that safe?
|
||||
|
||||
**Generally yes, but with important considerations.** OpenHands runs all code in a secure, isolated Docker container
|
||||
(called a "sandbox") that is separate from your host system. However, the safety depends on your configuration:
|
||||
|
||||
**What's protected:**
|
||||
- Your host system files and programs (unless you mount them using [this feature](/usage/runtimes/docker#connecting-to-your-filesystem))
|
||||
- Host system resources
|
||||
- Other containers and processes
|
||||
|
||||
**Potential risks to consider:**
|
||||
- The agent can access the internet from within the container.
|
||||
- If you provide credentials (API keys, tokens), the agent can use them.
|
||||
- Mounted files and directories can be modified or deleted.
|
||||
- Network requests can be made to external services.
|
||||
|
||||
For detailed security information, see our [Runtime Architecture](/usage/architecture/runtime),
|
||||
[Security Configuration](/usage/configuration-options#security-configuration),
|
||||
and [Hardened Docker Installation](/usage/runtimes/docker#hardened-docker-installation) documentation.
|
||||
|
||||
## File Storage and Access
|
||||
|
||||
### Where are my files stored?
|
||||
|
||||
Your files are stored in different locations depending on how you've configured OpenHands:
|
||||
|
||||
**Default behavior (no file mounting):**
|
||||
- Files created by the agent are stored inside the runtime Docker container.
|
||||
- These files are temporary and will be lost when the container is removed.
|
||||
- The agent works in the `/workspace` directory inside the runtime container.
|
||||
|
||||
**When you mount your local filesystem (following [this](/usage/runtimes/docker#connecting-to-your-filesystem)):**
|
||||
- Your local files are mounted into the container's `/workspace` directory.
|
||||
- Changes made by the agent are reflected in your local filesystem.
|
||||
- Files persist after the container is stopped.
|
||||
|
||||
<Warning>
|
||||
Be careful when mounting your filesystem - the agent can modify or delete any files in the mounted directory.
|
||||
</Warning>
|
||||
|
||||
## Development Tools and Environment
|
||||
|
||||
### How do I get the dev tools I need?
|
||||
|
||||
OpenHands comes with a basic runtime environment that includes Python and Node.js.
|
||||
It also has the ability to install any tools it needs, so usually it's sufficient to ask it to set up its environment.
|
||||
|
||||
If you would like to set things up more systematically, you can:
|
||||
- **Use setup.sh**: Add a [setup.sh file](/usage/prompting/repository#setup-script) file to
|
||||
your repository, which will be run every time the agent starts.
|
||||
- **Use a custom sandbox**: Use a [custom docker image](/usage/how-to/custom-sandbox-guide) to initialize the sandbox.
|
||||
|
||||
### Something's not working. Where can I get help?
|
||||
|
||||
1. **Search existing issues**: Check our [GitHub issues](https://github.com/All-Hands-AI/OpenHands/issues) to see if
|
||||
others have encountered the same problem.
|
||||
2. **Join our community**: Get help from other users and developers:
|
||||
- [Slack community](https://join.slack.com/t/openhands-ai/shared_invite/zt-34zm4j0gj-Qz5kRHoca8DFCbqXPS~f_A)
|
||||
- [Discord server](https://discord.gg/ESHStjSjD4)
|
||||
3. **Check our troubleshooting guide**: Common issues and solutions are documented in
|
||||
[Troubleshooting](/usage/troubleshooting/troubleshooting).
|
||||
4. **Report bugs**: If you've found a bug, please [create an issue](https://github.com/All-Hands-AI/OpenHands/issues/new)
|
||||
and fill in as much detail as possible.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Start Building
|
||||
description: So you've [run OpenHands](./installation) and have [set up your LLM](./installation#setup). Now what?
|
||||
description: So you've [run OpenHands](/usage/installation). Now what?
|
||||
icon: code
|
||||
---
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ poetry run python -m openhands.cli.main
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.44-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.45-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -64,7 +64,7 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--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.44 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.45 \
|
||||
python -m openhands.cli.main --override-cli-mode true
|
||||
```
|
||||
|
||||
|
||||
@@ -25,9 +25,9 @@ You can use the Settings page at any time to:
|
||||
- Setup the LLM provider and model for OpenHands.
|
||||
- [Setup the search engine](/usage/search-engine-setup).
|
||||
- [Configure MCP servers](/usage/mcp).
|
||||
- [Connect to GitHub](/usage/how-to/gui-mode#github-setup) and [connect to GitLab](/usage/how-to/gui-mode#gitlab-setup)
|
||||
- [Connect to GitHub](/usage/how-to/gui-mode#github-setup) and [connect to GitLab](/usage/how-to/gui-mode#gitlab-setup).
|
||||
- Set application settings like your preferred language, notifications and other preferences.
|
||||
- [Manage custom secrets](/usage/how-to/gui-mode#secrets-management).
|
||||
- [Manage custom secrets](/usage/common-settings#secrets-management).
|
||||
|
||||
#### GitHub Setup
|
||||
|
||||
@@ -157,37 +157,6 @@ OpenHands automatically exports a `GITLAB_TOKEN` to the shell environment if pro
|
||||
|
||||
</AccordionGroup>
|
||||
|
||||
|
||||
#### Secrets Management
|
||||
|
||||
OpenHands provides a secrets manager that allows you to securely store and manage sensitive information that can be accessed by the agent during runtime, such as API keys. These secrets are automatically exported as environment variables in the agent's runtime environment.
|
||||
|
||||
1. **Accessing the Secrets Manager**:
|
||||
- In the Settings page, navigate to the `Secrets` tab.
|
||||
- You'll see a list of all your existing custom secrets (if any).
|
||||
|
||||
2. **Adding a New Secret**:
|
||||
- Click the `Add New Secret` button.
|
||||
- Fill in the following fields:
|
||||
- **Name**: A unique identifier for your secret (e.g., `AWS_ACCESS_KEY`). This will be the environment variable name.
|
||||
- **Value**: The sensitive information you want to store.
|
||||
- **Description** (optional): A brief description of what the secret is used for, which is also provided to the agent.
|
||||
- Click `Add Secret` to save.
|
||||
|
||||
3. **Editing a Secret**:
|
||||
- Click the `Edit` button next to the secret you want to modify.
|
||||
- You can update the name and description of the secret.
|
||||
- Note: For security reasons, you cannot view or edit the value of an existing secret. If you need to change the value, delete the secret and create a new one.
|
||||
|
||||
4. **Deleting a Secret**:
|
||||
- Click the `Delete` button next to the secret you want to remove.
|
||||
- Confirm the deletion when prompted.
|
||||
|
||||
5. **Using Secrets in the Agent**:
|
||||
- All custom secrets are automatically exported as environment variables in the agent's runtime environment.
|
||||
- You can access them in your code using standard environment variable access methods (e.g., `os.environ['SECRET_NAME']` in Python).
|
||||
- Example: If you create a secret named `OPENAI_API_KEY`, you can access it in your code as `process.env.OPENAI_API_KEY` in JavaScript or `os.environ['OPENAI_API_KEY']` in Python.
|
||||
|
||||
#### Advanced Settings
|
||||
|
||||
The `Advanced` settings allows configuration of additional LLM settings. Inside the Settings page, under the `LLM` tab,
|
||||
@@ -208,11 +177,11 @@ section of the documentation.
|
||||
The status indicator located in the bottom left of the screen will cycle through a number of states as a new conversation
|
||||
is loaded. Typically these include:
|
||||
|
||||
* `Disconnected` : The frontend is not connected to any conversation
|
||||
* `Disconnected` : The frontend is not connected to any conversation.
|
||||
* `Connecting` : The frontend is connecting a websocket to a conversation.
|
||||
* `Building Runtime...` : The server is building a runtime. This is typically in development mode only while building a docker image.
|
||||
* `Starting Runtime...` : The server is starting a new runtime instance - probably a new docker container or remote runtime.
|
||||
* `Initializing Agent...` : The server is starting the agent loop. (This step does not appear at present with Nested runtimes)
|
||||
* `Initializing Agent...` : The server is starting the agent loop (This step does not appear at present with Nested runtimes).
|
||||
* `Setting up workspace...` : Usually this means a `git clone ...` operation.
|
||||
* `Setting up git hooks` : Setting up the git pre commit hooks for the workspace.
|
||||
* `Agent is awaiting user input...` : Ready to go!
|
||||
|
||||
@@ -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.44-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.45-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -42,7 +42,7 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--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.44 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.45 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
title: Quick Start
|
||||
description: Running OpenHands Cloud or running on your local system.
|
||||
description: Running OpenHands Cloud or running on your own.
|
||||
icon: rocket
|
||||
---
|
||||
|
||||
## OpenHands Cloud
|
||||
|
||||
The easiest way to get started with OpenHands is on OpenHands Cloud, which comes with $50 in free credits for new users.
|
||||
The easiest way to get started with OpenHands is on OpenHands Cloud, which comes with $20 in free credits for new users.
|
||||
|
||||
To get started with OpenHands Cloud, visit [app.all-hands.dev](https://app.all-hands.dev).
|
||||
|
||||
|
||||
@@ -54,19 +54,19 @@ Check [the installation guide](/usage/local-setup) to make sure you have all the
|
||||
export LMSTUDIO_MODEL_NAME="imported-models/uncategorized/devstralq4_k_m.gguf" # <- Replace this with the model name you copied from LMStudio
|
||||
export LMSTUDIO_URL="http://host.docker.internal:1234" # <- Replace this with the port from LMStudio
|
||||
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.44-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.45-nikolaik
|
||||
|
||||
mkdir -p ~/.openhands && echo '{"language":"en","agent":"CodeActAgent","max_iterations":null,"security_analyzer":null,"confirmation_mode":false,"llm_model":"lm_studio/'$LMSTUDIO_MODEL_NAME'","llm_api_key":"dummy","llm_base_url":"'$LMSTUDIO_URL/v1'","remote_runtime_resource_factor":null,"github_token":null,"enable_default_condenser":true,"user_consents_to_analytics":true}' > ~/.openhands/settings.json
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.44-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.45-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.44
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.45
|
||||
```
|
||||
|
||||
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
|
||||
@@ -74,7 +74,7 @@ docker run -it --rm --pull=always \
|
||||
Once your server is running -- you can visit `http://localhost:3000` in your browser to use OpenHands with local Devstral model:
|
||||
```
|
||||
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.44
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.45
|
||||
Starting OpenHands...
|
||||
Running OpenHands as root
|
||||
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
|
||||
@@ -172,3 +172,7 @@ Once OpenHands is running, you'll need to set the following in the OpenHands UI
|
||||
- `Custom Model` to `openai/<served-model-name>` (e.g. `openai/openhands-lm-32b-v0.1`)
|
||||
- `Base URL` to `http://host.docker.internal:8000`
|
||||
- `API key` to the same string you set when serving the model (e.g. `mykey`)
|
||||
|
||||
<Note>
|
||||
**API Key for Local LLMs**: When using local LLM servers (including Ollama, LM Studio, vLLM, etc.), you can enter any value as the API key if your server doesn't require authentication. The OpenHands UI requires an API key to be entered, but for local servers without authentication, you can use any placeholder value like `local-key`, `test123`, or `dummy`.
|
||||
</Note>
|
||||
|
||||
@@ -67,17 +67,17 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
|
||||
### Start the App
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.44-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.45-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.44-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.45-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.44
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.45
|
||||
```
|
||||
|
||||
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
|
||||
@@ -153,8 +153,6 @@ To enable search functionality in OpenHands:
|
||||
|
||||
For more details, see the [Search Engine Setup](/usage/search-engine-setup) guide.
|
||||
|
||||
Now you're ready to [get started with OpenHands](/usage/getting-started).
|
||||
|
||||
### Versions
|
||||
|
||||
The [docker command above](/usage/local-setup#start-the-app) pulls the most recent stable release of OpenHands. You have other options as well:
|
||||
|
||||
@@ -3,6 +3,19 @@ title: Daytona Runtime
|
||||
description: You can use [Daytona](https://www.daytona.io/) as a runtime provider.
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
The Daytona runtime is available as an optional runtime. To use it, install OpenHands with the Daytona extra:
|
||||
|
||||
```bash
|
||||
pip install openhands-ai[daytona]
|
||||
```
|
||||
|
||||
Or to install all available runtimes:
|
||||
|
||||
```bash
|
||||
pip install openhands-ai[all-runtimes]
|
||||
```
|
||||
|
||||
## Step 1: Retrieve Your Daytona API Key
|
||||
1. Visit the [Daytona Dashboard](https://app.daytona.io/dashboard/keys).
|
||||
|
||||
@@ -128,3 +128,7 @@ docker network create openhands-network
|
||||
docker run # ... \
|
||||
--network openhands-network \
|
||||
```
|
||||
|
||||
<Note>
|
||||
**Docker Desktop Required**: Network isolation features, including custom networks and `host.docker.internal` routing, require Docker Desktop. Docker Engine alone does not support these features on localhost across custom networks. If you're using Docker Engine without Docker Desktop, network isolation may not work as expected.
|
||||
</Note>
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
title: E2B Runtime
|
||||
description: E2B is an open-source secure cloud environment (sandbox) made for running AI-generated code and agents.
|
||||
---
|
||||
|
||||
[E2B](https://e2b.dev) offers [Python](https://pypi.org/project/e2b/) and [JS/TS](https://www.npmjs.com/package/e2b) SDK to spawn and control these sandboxes.
|
||||
|
||||
## Getting started
|
||||
|
||||
1. [Get your API key](https://e2b.dev/docs/getting-started/api-key)
|
||||
|
||||
1. Set your E2B API key to the `E2B_API_KEY` env var when starting the Docker container
|
||||
|
||||
1. **Optional** - Install the CLI with NPM.
|
||||
```sh
|
||||
npm install -g @e2b/cli@latest
|
||||
```
|
||||
Full CLI API is [here](https://e2b.dev/docs/cli/installation).
|
||||
|
||||
## OpenHands sandbox
|
||||
You can use the E2B CLI to create a custom sandbox with a Dockerfile. Read the full guide [here](https://e2b.dev/docs/guide/custom-sandbox). The premade OpenHands sandbox for E2B is set up in the `containers` directory. and it's called `openhands`.
|
||||
|
||||
## Debugging
|
||||
You can connect to a running E2B sandbox with E2B CLI in your terminal.
|
||||
|
||||
- List all running sandboxes (based on your API key)
|
||||
```sh
|
||||
e2b sandbox list
|
||||
```
|
||||
|
||||
- Connect to a running sandbox
|
||||
```sh
|
||||
e2b sandbox connect <sandbox-id>
|
||||
```
|
||||
|
||||
## Links
|
||||
- [E2B Docs](https://e2b.dev/docs)
|
||||
- [E2B GitHub](https://github.com/e2b-dev/e2b)
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
title: Modal Runtime
|
||||
---
|
||||
|
||||
Our partners at [Modal](https://modal.com/) have provided a runtime for OpenHands.
|
||||
To use the Modal Runtime, create an account, and then [create an API key.](https://modal.com/settings)
|
||||
|
||||
You'll then need to set the following environment variables when starting OpenHands:
|
||||
```bash
|
||||
docker run # ...
|
||||
-e RUNTIME=modal \
|
||||
-e MODAL_API_TOKEN_ID="your-id" \
|
||||
-e MODAL_API_TOKEN_SECRET="modal-api-key" \
|
||||
```
|
||||
@@ -9,8 +9,6 @@ commands.
|
||||
By default, OpenHands uses a [Docker-based runtime](/usage/runtimes/docker), running on your local computer.
|
||||
This means you only have to pay for the LLM you're using, and your code is only ever sent to the LLM.
|
||||
|
||||
We also support other runtimes, which are typically managed by third-parties.
|
||||
|
||||
Additionally, we provide a [Local Runtime](/usage/runtimes/local) that runs directly on your machine without Docker,
|
||||
which can be useful in controlled environments like CI pipelines.
|
||||
|
||||
@@ -21,6 +19,5 @@ OpenHands supports several different runtime environments:
|
||||
- [Docker Runtime](/usage/runtimes/docker) - The default runtime that uses Docker containers for isolation (recommended for most users).
|
||||
- [OpenHands Remote Runtime](/usage/runtimes/remote) - Cloud-based runtime for parallel execution (beta).
|
||||
- [Local Runtime](/usage/runtimes/local) - Direct execution on your local machine without Docker.
|
||||
- And more third-party runtimes:
|
||||
- [Modal Runtime](/usage/runtimes/modal) - Runtime provided by our partners at Modal.
|
||||
- [Daytona Runtime](/usage/runtimes/daytona) - Runtime provided by Daytona.
|
||||
- [Daytona Runtime](/usage/runtimes/daytona) - Runtime provided by Daytona.
|
||||
- [Third-party Runtimes](https://github.com/All-Hands-AI/third-party-runtimes) - These runtimes are supported by their developers, not by OpenHands. Please find them in the repository linked here if you would like to run on third-party infrastructure providers.
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
title: Runloop Runtime
|
||||
description: Runloop provides a fast, secure and scalable AI sandbox (Devbox). Check out the [runloop docs](https://docs.runloop.ai/overview/what-is-runloop) for more detail.
|
||||
---
|
||||
|
||||
## Access
|
||||
Runloop is currently available in a closed beta. For early access, or
|
||||
just to say hello, sign up at https://www.runloop.ai/hello
|
||||
|
||||
## Set up
|
||||
With your runloop API,
|
||||
```bash
|
||||
export RUNLOOP_API_KEY=<your-api-key>
|
||||
```
|
||||
|
||||
Configure the runtime
|
||||
```bash
|
||||
export RUNTIME="runloop"
|
||||
```
|
||||
|
||||
## Interact with your devbox
|
||||
Runloop provides additional tools to interact with your Devbox based
|
||||
runtime environment. See the [docs](https://docs.runloop.ai/tools) for an up
|
||||
to date list of tools.
|
||||
|
||||
### Dashboard
|
||||
View logs, ssh into, or view your Devbox status from the [dashboard](https://platform.runloop.ai)
|
||||
|
||||
### CLI
|
||||
Use the Runloop CLI to view logs, execute commands, and more.
|
||||
See the setup instructions [here](https://docs.runloop.ai/tools/cli)
|
||||
@@ -133,13 +133,66 @@ This guide provides step-by-step instructions for running OpenHands on a Windows
|
||||
|
||||
> **Note**: If you're running the frontend in development mode (using `npm run dev`), use port 3001 instead: `http://localhost:3001`
|
||||
|
||||
## Installing and Running the CLI
|
||||
|
||||
To install and run the OpenHands CLI on Windows without WSL, follow these steps:
|
||||
|
||||
### 1. Install uv (Python Package Manager)
|
||||
|
||||
Open PowerShell as Administrator and run:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
|
||||
```
|
||||
|
||||
### 2. Install .NET SDK (Required)
|
||||
|
||||
The OpenHands CLI **requires** the .NET Core runtime for PowerShell integration. Without it, the CLI will fail to start with a `coreclr` error. Install the .NET SDK which includes the runtime:
|
||||
|
||||
```powershell
|
||||
winget install Microsoft.DotNet.SDK.8
|
||||
```
|
||||
|
||||
Alternatively, you can download and install the .NET SDK from the [official Microsoft website](https://dotnet.microsoft.com/download).
|
||||
|
||||
After installation, restart your PowerShell session to ensure the environment variables are updated.
|
||||
|
||||
### 3. Install and Run OpenHands
|
||||
|
||||
After installing the prerequisites, you can install and run OpenHands with:
|
||||
|
||||
```powershell
|
||||
uvx --python 3.12 --from openhands-ai openhands
|
||||
```
|
||||
|
||||
### Troubleshooting CLI Issues
|
||||
|
||||
#### CoreCLR Error
|
||||
|
||||
If you encounter an error like `Failed to load CoreCLR` or `pythonnet.load('coreclr')` when running OpenHands CLI, this indicates that the .NET Core runtime is missing or not properly configured. To fix this:
|
||||
|
||||
1. Install the .NET SDK as described in step 2 above
|
||||
2. Verify that your system PATH includes the .NET SDK directories
|
||||
3. Restart your PowerShell session completely after installing the .NET SDK
|
||||
4. Make sure you're using PowerShell 7 (pwsh) rather than Windows PowerShell
|
||||
|
||||
To verify your .NET installation, run:
|
||||
|
||||
```powershell
|
||||
dotnet --info
|
||||
```
|
||||
|
||||
This should display information about your installed .NET SDKs and runtimes. If this command fails, the .NET SDK is not properly installed or not in your PATH.
|
||||
|
||||
If the issue persists after installing the .NET SDK, try installing the specific .NET Runtime version 6.0 or later from the [.NET download page](https://dotnet.microsoft.com/download).
|
||||
|
||||
## Limitations on Windows
|
||||
|
||||
When running OpenHands on Windows without WSL or Docker, be aware of the following limitations:
|
||||
|
||||
1. **Browser Tool Not Supported**: The browser tool is not currently supported on Windows.
|
||||
|
||||
2. **.NET Core Requirement**: The PowerShell integration requires .NET Core Runtime to be installed. If .NET Core is not available, OpenHands will automatically fall back to a more limited PowerShell implementation with reduced functionality.
|
||||
2. **.NET Core Requirement**: The PowerShell integration requires .NET Core Runtime to be installed. The CLI implementation attempts to load the CoreCLR at startup with `pythonnet.load('coreclr')` and will fail with an error if .NET Core is not properly installed.
|
||||
|
||||
3. **Interactive Shell Commands**: Some interactive shell commands may not work as expected. The PowerShell session implementation has limitations compared to the bash session used on Linux/macOS.
|
||||
|
||||
|
||||
95
frontend/__tests__/components/likert-scale.test.tsx
Normal file
95
frontend/__tests__/components/likert-scale.test.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { LikertScale } from "#/components/features/feedback/likert-scale";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
// Mock the mutation hook
|
||||
vi.mock("#/hooks/mutation/use-submit-conversation-feedback", () => ({
|
||||
useSubmitConversationFeedback: () => ({
|
||||
mutate: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("LikertScale", () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render with proper localized text for rating prompt", () => {
|
||||
renderWithProviders(<LikertScale eventId={1} />);
|
||||
|
||||
// Check that the rating prompt is displayed with proper translation key
|
||||
expect(screen.getByText(I18nKey.FEEDBACK$RATE_AGENT_PERFORMANCE)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show localized feedback reasons when rating is 3 or below", async () => {
|
||||
renderWithProviders(<LikertScale eventId={1} />);
|
||||
|
||||
// Click on a rating of 3 (which should show reasons)
|
||||
const threeStarButton = screen.getAllByRole("button")[2]; // 3rd button (rating 3)
|
||||
await user.click(threeStarButton);
|
||||
|
||||
// Wait for reasons to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(I18nKey.FEEDBACK$SELECT_REASON)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check that all feedback reasons are properly localized
|
||||
expect(screen.getByText(I18nKey.FEEDBACK$REASON_MISUNDERSTOOD_INSTRUCTION)).toBeInTheDocument();
|
||||
expect(screen.getByText(I18nKey.FEEDBACK$REASON_FORGOT_CONTEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(I18nKey.FEEDBACK$REASON_UNNECESSARY_CHANGES)).toBeInTheDocument();
|
||||
expect(screen.getByText(I18nKey.FEEDBACK$REASON_OTHER)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show countdown message with proper localization", async () => {
|
||||
renderWithProviders(<LikertScale eventId={1} />);
|
||||
|
||||
// Click on a rating of 2 (which should show reasons and countdown)
|
||||
const twoStarButton = screen.getAllByRole("button")[1]; // 2nd button (rating 2)
|
||||
await user.click(twoStarButton);
|
||||
|
||||
// Wait for countdown to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(I18nKey.FEEDBACK$SELECT_REASON_COUNTDOWN)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show thank you message after submission", () => {
|
||||
renderWithProviders(
|
||||
<LikertScale eventId={1} initiallySubmitted={true} initialRating={4} />
|
||||
);
|
||||
|
||||
// Check that thank you message is displayed with proper translation key
|
||||
expect(screen.getByText(I18nKey.FEEDBACK$THANK_YOU_FOR_FEEDBACK)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render all 5 star rating buttons", () => {
|
||||
renderWithProviders(<LikertScale eventId={1} />);
|
||||
|
||||
// Check that all 5 star buttons are rendered
|
||||
const starButtons = screen.getAllByRole("button");
|
||||
expect(starButtons).toHaveLength(5);
|
||||
|
||||
// Check that each button has proper aria-label
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
expect(screen.getByLabelText(`Rate ${i} stars`)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("should not show reasons for ratings above 3", async () => {
|
||||
renderWithProviders(<LikertScale eventId={1} />);
|
||||
|
||||
// Click on a rating of 5 (which should NOT show reasons)
|
||||
const fiveStarButton = screen.getAllByRole("button")[4]; // 5th button (rating 5)
|
||||
await user.click(fiveStarButton);
|
||||
|
||||
// Wait a bit to ensure reasons don't appear
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(I18nKey.FEEDBACK$SELECT_REASON)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
1064
frontend/package-lock.json
generated
1064
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.44.0",
|
||||
"version": "0.45.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroui/react": "^2.8.0-beta.7",
|
||||
"@heroui/react": "^2.8.0-beta.9",
|
||||
"@microlink/react-json-view": "^1.26.2",
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@react-router/node": "^7.6.2",
|
||||
@@ -18,7 +18,7 @@
|
||||
"@stripe/stripe-js": "^7.3.1",
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"@tailwindcss/vite": "^4.1.10",
|
||||
"@tanstack/react-query": "^5.80.7",
|
||||
"@tanstack/react-query": "^5.80.10",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
@@ -31,7 +31,7 @@
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.28",
|
||||
"jose": "^6.0.11",
|
||||
"lucide-react": "^0.517.0",
|
||||
"lucide-react": "^0.519.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.255.0",
|
||||
"react": "^19.1.0",
|
||||
@@ -84,7 +84,7 @@
|
||||
"@babel/traverse": "^7.27.1",
|
||||
"@babel/types": "^7.27.0",
|
||||
"@mswjs/socket.io-binding": "^0.2.0",
|
||||
"@playwright/test": "^1.53.0",
|
||||
"@playwright/test": "^1.53.1",
|
||||
"@react-router/dev": "^7.6.2",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/eslint-plugin-query": "^5.78.0",
|
||||
|
||||
@@ -293,9 +293,11 @@ class OpenHands {
|
||||
|
||||
static async startConversation(
|
||||
conversationId: string,
|
||||
providers?: Provider[],
|
||||
): Promise<Conversation | null> {
|
||||
const { data } = await openHands.post<Conversation | null>(
|
||||
`/api/conversations/${conversationId}/start`,
|
||||
providers ? { providers_set: providers } : {},
|
||||
);
|
||||
|
||||
return data;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useContext } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "#/utils/utils";
|
||||
import i18n from "#/i18n";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useSubmitConversationFeedback } from "#/hooks/mutation/use-submit-conversation-feedback";
|
||||
import { ScrollContext } from "#/context/scroll-context";
|
||||
|
||||
@@ -14,19 +15,14 @@ interface LikertScaleProps {
|
||||
initialReason?: string;
|
||||
}
|
||||
|
||||
const FEEDBACK_REASONS = [
|
||||
i18n.t("FEEDBACK$REASON_MISUNDERSTOOD_INSTRUCTION"),
|
||||
i18n.t("FEEDBACK$REASON_FORGOT_CONTEXT"),
|
||||
i18n.t("FEEDBACK$REASON_UNNECESSARY_CHANGES"),
|
||||
i18n.t("FEEDBACK$REASON_OTHER"),
|
||||
];
|
||||
|
||||
export function LikertScale({
|
||||
eventId,
|
||||
initiallySubmitted = false,
|
||||
initialRating,
|
||||
initialReason,
|
||||
}: LikertScaleProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [selectedRating, setSelectedRating] = useState<number | null>(
|
||||
initialRating || null,
|
||||
);
|
||||
@@ -43,6 +39,14 @@ export function LikertScale({
|
||||
// Get scroll context
|
||||
const scrollContext = useContext(ScrollContext);
|
||||
|
||||
// Define feedback reasons using the translation hook
|
||||
const FEEDBACK_REASONS = [
|
||||
t(I18nKey.FEEDBACK$REASON_MISUNDERSTOOD_INSTRUCTION),
|
||||
t(I18nKey.FEEDBACK$REASON_FORGOT_CONTEXT),
|
||||
t(I18nKey.FEEDBACK$REASON_UNNECESSARY_CHANGES),
|
||||
t(I18nKey.FEEDBACK$REASON_OTHER),
|
||||
];
|
||||
|
||||
// If scrollContext is undefined, we're not inside a ScrollProvider
|
||||
const scrollToBottom = scrollContext?.scrollDomToBottom;
|
||||
const autoScroll = scrollContext?.autoScroll;
|
||||
@@ -188,8 +192,8 @@ export function LikertScale({
|
||||
<div className="mt-3 flex flex-col gap-1">
|
||||
<div className="text-sm text-gray-500 mb-1">
|
||||
{isSubmitted
|
||||
? i18n.t("FEEDBACK$THANK_YOU_FOR_FEEDBACK")
|
||||
: i18n.t("FEEDBACK$RATE_AGENT_PERFORMANCE")}
|
||||
? t(I18nKey.FEEDBACK$THANK_YOU_FOR_FEEDBACK)
|
||||
: t(I18nKey.FEEDBACK$RATE_AGENT_PERFORMANCE)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="flex gap-2 items-center flex-wrap">
|
||||
@@ -220,11 +224,11 @@ export function LikertScale({
|
||||
{showReasons && !isSubmitted && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<div className="text-xs text-gray-500 mb-1">
|
||||
{i18n.t("FEEDBACK$SELECT_REASON")}
|
||||
{t(I18nKey.FEEDBACK$SELECT_REASON)}
|
||||
</div>
|
||||
{countdown > 0 && (
|
||||
<div className="text-xs text-gray-400 mb-1 italic">
|
||||
{i18n.t("FEEDBACK$SELECT_REASON_COUNTDOWN", {
|
||||
{t(I18nKey.FEEDBACK$SELECT_REASON_COUNTDOWN, {
|
||||
countdown,
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
|
||||
import GitLabLogo from "#/assets/branding/gitlab-logo.svg?react";
|
||||
import BitbucketLogo from "#/assets/branding/bitbucket-logo.svg?react";
|
||||
import { useAuthUrl } from "#/hooks/use-auth-url";
|
||||
import { GetConfigResponse } from "#/api/open-hands.types";
|
||||
|
||||
@@ -24,11 +23,6 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
|
||||
identityProvider: "gitlab",
|
||||
});
|
||||
|
||||
const bitbucketAuthUrl = useAuthUrl({
|
||||
appMode: appMode || null,
|
||||
identityProvider: "bitbucket",
|
||||
});
|
||||
|
||||
const handleGitHubAuth = () => {
|
||||
if (githubAuthUrl) {
|
||||
// Always start the OIDC flow, let the backend handle TOS check
|
||||
@@ -43,13 +37,6 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBitbucketAuth = () => {
|
||||
if (bitbucketAuthUrl) {
|
||||
// Always start the OIDC flow, let the backend handle TOS check
|
||||
window.location.href = bitbucketAuthUrl;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalBackdrop>
|
||||
<ModalBody className="border border-tertiary">
|
||||
@@ -80,16 +67,6 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
|
||||
>
|
||||
{t(I18nKey.GITLAB$CONNECT_TO_GITLAB)}
|
||||
</BrandButton>
|
||||
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={handleBitbucketAuth}
|
||||
className="w-full"
|
||||
startContent={<BitbucketLogo width={20} height={20} />}
|
||||
>
|
||||
{t(I18nKey.BITBUCKET$CONNECT_TO_BITBUCKET)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</ModalBody>
|
||||
</ModalBackdrop>
|
||||
|
||||
@@ -348,6 +348,7 @@ export function WsClientProvider({
|
||||
conversation?.url,
|
||||
conversation?.status,
|
||||
conversation?.runtime_status,
|
||||
providers,
|
||||
]);
|
||||
|
||||
React.useEffect(
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import React from "react";
|
||||
import { convertRawProvidersToList } from "#/utils/convert-raw-providers-to-list";
|
||||
import { useSettings } from "./query/use-settings";
|
||||
|
||||
export const useUserProviders = () => {
|
||||
const { data: settings } = useSettings();
|
||||
|
||||
const providers = React.useMemo(
|
||||
() => convertRawProvidersToList(settings?.PROVIDER_TOKENS_SET),
|
||||
[settings?.PROVIDER_TOKENS_SET],
|
||||
);
|
||||
|
||||
return {
|
||||
providers: convertRawProvidersToList(settings?.PROVIDER_TOKENS_SET),
|
||||
providers,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -37,6 +37,7 @@ import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { TabContent } from "#/components/layout/tab-content";
|
||||
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
|
||||
function AppContent() {
|
||||
useConversationConfig();
|
||||
@@ -45,6 +46,7 @@ function AppContent() {
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation, isFetched, refetch } = useActiveConversation();
|
||||
const { data: isAuthed } = useIsAuthed();
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
const dispatch = useDispatch();
|
||||
@@ -63,11 +65,11 @@ function AppContent() {
|
||||
navigate("/");
|
||||
} else if (conversation?.status === "STOPPED") {
|
||||
// start the conversation if the state is stopped on initial load
|
||||
OpenHands.startConversation(conversation.conversation_id).then(() =>
|
||||
refetch(),
|
||||
OpenHands.startConversation(conversation.conversation_id, providers).then(
|
||||
() => refetch(),
|
||||
);
|
||||
}
|
||||
}, [conversation?.conversation_id, isFetched, isAuthed]);
|
||||
}, [conversation?.conversation_id, isFetched, isAuthed, providers]);
|
||||
|
||||
React.useEffect(() => {
|
||||
dispatch(clearTerminal());
|
||||
|
||||
@@ -95,6 +95,7 @@ class CodeActAgent(Agent):
|
||||
if self._prompt_manager is None:
|
||||
self._prompt_manager = PromptManager(
|
||||
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts'),
|
||||
system_prompt_filename=self.config.system_prompt_filename,
|
||||
)
|
||||
|
||||
return self._prompt_manager
|
||||
|
||||
@@ -13,6 +13,7 @@ from openhands.cli.tui import (
|
||||
)
|
||||
from openhands.cli.utils import (
|
||||
VERIFIED_ANTHROPIC_MODELS,
|
||||
VERIFIED_MISTRAL_MODELS,
|
||||
VERIFIED_OPENAI_MODELS,
|
||||
VERIFIED_PROVIDERS,
|
||||
organize_models_and_providers,
|
||||
@@ -158,7 +159,7 @@ async def modify_llm_settings_basic(
|
||||
provider_completer = FuzzyWordCompleter(provider_list)
|
||||
session = PromptSession(key_bindings=kb_cancel())
|
||||
|
||||
# Set default provider - prefer 'anthropic' if available, otherwise use the first provider
|
||||
# Set default provider - prefer 'anthropic' if available, otherwise use first
|
||||
provider = 'anthropic' if 'anthropic' in provider_list else provider_list[0]
|
||||
model = None
|
||||
api_key = None
|
||||
@@ -168,15 +169,26 @@ async def modify_llm_settings_basic(
|
||||
print_formatted_text(
|
||||
HTML(f'\n<grey>Default provider: </grey><green>{provider}</green>')
|
||||
)
|
||||
change_provider = (
|
||||
cli_confirm(
|
||||
'Do you want to use a different provider?',
|
||||
[f'Use {provider}', 'Select another provider'],
|
||||
)
|
||||
== 1
|
||||
|
||||
# Show verified providers plus "Select another provider" option
|
||||
provider_choices = verified_providers + ['Select another provider']
|
||||
provider_choice = cli_confirm(
|
||||
'(Step 1/3) Select LLM Provider:',
|
||||
provider_choices,
|
||||
)
|
||||
|
||||
if change_provider:
|
||||
# Ensure provider_choice is an integer (for test compatibility)
|
||||
try:
|
||||
choice_index = int(provider_choice)
|
||||
except (TypeError, ValueError):
|
||||
# If conversion fails (e.g., in tests with mocks), default to 0
|
||||
choice_index = 0
|
||||
|
||||
if choice_index < len(verified_providers):
|
||||
# User selected one of the verified providers
|
||||
provider = verified_providers[choice_index]
|
||||
else:
|
||||
# User selected "Select another provider" - use manual selection
|
||||
# Define a validator function that prints an error message
|
||||
def provider_validator(x):
|
||||
is_valid = x in organized_models
|
||||
@@ -196,7 +208,8 @@ async def modify_llm_settings_basic(
|
||||
|
||||
# Make sure the provider exists in organized_models
|
||||
if provider not in organized_models:
|
||||
# If the provider doesn't exist, prefer 'anthropic' if available, otherwise use the first provider
|
||||
# If the provider doesn't exist, prefer 'anthropic' if available,
|
||||
# otherwise use the first provider
|
||||
provider = (
|
||||
'anthropic'
|
||||
if 'anthropic' in organized_models
|
||||
@@ -214,6 +227,11 @@ async def modify_llm_settings_basic(
|
||||
m for m in provider_models if m not in VERIFIED_ANTHROPIC_MODELS
|
||||
]
|
||||
provider_models = VERIFIED_ANTHROPIC_MODELS + provider_models
|
||||
if provider == 'mistral':
|
||||
provider_models = [
|
||||
m for m in provider_models if m not in VERIFIED_MISTRAL_MODELS
|
||||
]
|
||||
provider_models = VERIFIED_MISTRAL_MODELS + provider_models
|
||||
|
||||
# Set default model to the best verified model for the provider
|
||||
if provider == 'anthropic' and VERIFIED_ANTHROPIC_MODELS:
|
||||
@@ -222,6 +240,9 @@ async def modify_llm_settings_basic(
|
||||
elif provider == 'openai' and VERIFIED_OPENAI_MODELS:
|
||||
# Use the first model in the VERIFIED_OPENAI_MODELS list as it's the best/newest
|
||||
default_model = VERIFIED_OPENAI_MODELS[0]
|
||||
elif provider == 'mistral' and VERIFIED_MISTRAL_MODELS:
|
||||
# Use the first model in the VERIFIED_MISTRAL_MODELS list as it's the best/newest
|
||||
default_model = VERIFIED_MISTRAL_MODELS[0]
|
||||
else:
|
||||
# For other providers, use the first model in the list
|
||||
default_model = (
|
||||
@@ -243,23 +264,28 @@ async def modify_llm_settings_basic(
|
||||
if change_model:
|
||||
model_completer = FuzzyWordCompleter(provider_models)
|
||||
|
||||
# Define a validator function that prints an error message
|
||||
# Define a validator function that allows custom models but shows a warning
|
||||
def model_validator(x):
|
||||
is_valid = x in provider_models
|
||||
if not is_valid:
|
||||
# Allow any non-empty model name
|
||||
if not x.strip():
|
||||
return False
|
||||
|
||||
# Show a warning for models not in the predefined list, but still allow them
|
||||
if x not in provider_models:
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
f'<grey>Invalid model selected for provider {provider}: {x}</grey>'
|
||||
f'<yellow>Warning: {x} is not in the predefined list for provider {provider}. '
|
||||
f'Make sure this model name is correct.</yellow>'
|
||||
)
|
||||
)
|
||||
return is_valid
|
||||
return True
|
||||
|
||||
model = await get_validated_input(
|
||||
session,
|
||||
'(Step 2/3) Select LLM Model (TAB for options, CTRL-c to cancel): ',
|
||||
completer=model_completer,
|
||||
validator=model_validator,
|
||||
error_message=f'Invalid model selected for provider {provider}',
|
||||
error_message='Model name cannot be empty',
|
||||
)
|
||||
else:
|
||||
# Use the default model
|
||||
|
||||
@@ -27,13 +27,6 @@ def suppress_cli_warnings():
|
||||
category=UserWarning,
|
||||
)
|
||||
|
||||
# Suppress httpx deprecation warnings about content parameter
|
||||
warnings.filterwarnings(
|
||||
'ignore',
|
||||
message=".*Use 'content=<...>' to upload raw bytes/text content.*",
|
||||
category=DeprecationWarning,
|
||||
)
|
||||
|
||||
# Suppress general deprecation warnings from dependencies during CLI usage
|
||||
# This catches the "Call to deprecated method get_events" warning
|
||||
warnings.filterwarnings(
|
||||
|
||||
@@ -191,19 +191,24 @@ def display_event(event: Event, config: OpenHandsConfig) -> None:
|
||||
if isinstance(event, MessageAction):
|
||||
if event.source == EventSource.AGENT:
|
||||
display_message(event.content)
|
||||
|
||||
if isinstance(event, CmdRunAction):
|
||||
display_command(event)
|
||||
# Only display the command if it's not already confirmed
|
||||
# Commands are always shown when AWAITING_CONFIRMATION, so we don't need to show them again when CONFIRMED
|
||||
if event.confirmation_state != ActionConfirmationStatus.CONFIRMED:
|
||||
display_command(event)
|
||||
|
||||
if event.confirmation_state == ActionConfirmationStatus.CONFIRMED:
|
||||
initialize_streaming_output()
|
||||
if isinstance(event, CmdOutputObservation):
|
||||
elif isinstance(event, CmdOutputObservation):
|
||||
display_command_output(event.content)
|
||||
if isinstance(event, FileEditObservation):
|
||||
elif isinstance(event, FileEditObservation):
|
||||
display_file_edit(event)
|
||||
if isinstance(event, FileReadObservation):
|
||||
elif isinstance(event, FileReadObservation):
|
||||
display_file_read(event)
|
||||
if isinstance(event, AgentStateChangedObservation):
|
||||
elif isinstance(event, AgentStateChangedObservation):
|
||||
display_agent_state_change_message(event.agent_state)
|
||||
if isinstance(event, ErrorObservation):
|
||||
elif isinstance(event, ErrorObservation):
|
||||
display_error(event.content)
|
||||
|
||||
|
||||
|
||||
@@ -102,6 +102,8 @@ def extract_model_and_provider(model: str) -> ModelInfo:
|
||||
return ModelInfo(provider='openai', model=split[0], separator='/')
|
||||
if split[0] in VERIFIED_ANTHROPIC_MODELS:
|
||||
return ModelInfo(provider='anthropic', model=split[0], separator='/')
|
||||
if split[0] in VERIFIED_MISTRAL_MODELS:
|
||||
return ModelInfo(provider='mistral', model=split[0], separator='/')
|
||||
# return as model only
|
||||
return ModelInfo(provider='', model=model, separator='')
|
||||
|
||||
@@ -143,9 +145,10 @@ def organize_models_and_providers(
|
||||
return result_dict
|
||||
|
||||
|
||||
VERIFIED_PROVIDERS = ['openai', 'azure', 'anthropic', 'deepseek']
|
||||
VERIFIED_PROVIDERS = ['anthropic', 'openai', 'mistral']
|
||||
|
||||
VERIFIED_OPENAI_MODELS = [
|
||||
'o4-mini',
|
||||
'gpt-4o',
|
||||
'gpt-4o-mini',
|
||||
'gpt-4-turbo',
|
||||
@@ -171,6 +174,10 @@ VERIFIED_ANTHROPIC_MODELS = [
|
||||
'claude-2',
|
||||
]
|
||||
|
||||
VERIFIED_MISTRAL_MODELS = [
|
||||
'devstral-small-2505',
|
||||
]
|
||||
|
||||
|
||||
class ProviderInfo(BaseModel):
|
||||
"""Information about a provider and its models."""
|
||||
|
||||
@@ -5,12 +5,12 @@ from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import AgentConfig
|
||||
from openhands.events.action import Action
|
||||
from openhands.events.action.message import SystemMessageAction
|
||||
from openhands.utils.prompt import PromptManager
|
||||
from litellm import ChatCompletionToolParam
|
||||
|
||||
from openhands.core.config import AgentConfig
|
||||
from openhands.core.exceptions import (
|
||||
AgentAlreadyRegisteredError,
|
||||
AgentNotRegisteredError,
|
||||
@@ -33,10 +33,13 @@ class Agent(ABC):
|
||||
_registry: dict[str, type['Agent']] = {}
|
||||
sandbox_plugins: list[PluginRequirement] = []
|
||||
|
||||
config_model: type[AgentConfig] = AgentConfig
|
||||
"""Class field that specifies the config model to use for the agent. Subclasses may override with a derived config model if needed."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
llm: LLM,
|
||||
config: 'AgentConfig',
|
||||
config: AgentConfig,
|
||||
):
|
||||
self.llm = llm
|
||||
self.config = config
|
||||
|
||||
@@ -821,6 +821,11 @@ class AgentController:
|
||||
or 'input length and `max_tokens` exceed context limit' in error_str
|
||||
or 'please reduce the length of either one'
|
||||
in error_str # For OpenRouter context window errors
|
||||
or (
|
||||
'sambanovaexception' in error_str
|
||||
and 'maximum context length' in error_str
|
||||
)
|
||||
# For SambaNova context window errors - only match when both patterns are present
|
||||
or isinstance(e, ContextWindowExceededError)
|
||||
):
|
||||
if self.agent.config.enable_history_truncation:
|
||||
|
||||
@@ -5,6 +5,7 @@ from pydantic import BaseModel, Field, ValidationError
|
||||
from openhands.core.config.condenser_config import CondenserConfig, NoOpCondenserConfig
|
||||
from openhands.core.config.extended_config import ExtendedConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
|
||||
class AgentConfig(BaseModel):
|
||||
@@ -12,6 +13,8 @@ class AgentConfig(BaseModel):
|
||||
"""The name of the llm config to use. If specified, this will override global llm config."""
|
||||
classpath: str | None = Field(default=None)
|
||||
"""The classpath of the agent to use. To be used for custom agents that are not defined in the openhands.agenthub package."""
|
||||
system_prompt_filename: str = Field(default='system_prompt.j2')
|
||||
"""Filename of the system prompt template file within the agent's prompt directory. Defaults to 'system_prompt.j2'."""
|
||||
enable_browsing: bool = Field(default=True)
|
||||
"""Whether to enable browsing tool.
|
||||
Note: If using CLIRuntime, browsing is not implemented and should be disabled."""
|
||||
@@ -96,7 +99,27 @@ class AgentConfig(BaseModel):
|
||||
try:
|
||||
# Merge base config with overrides
|
||||
merged = {**base_config.model_dump(), **overrides}
|
||||
custom_config = cls.model_validate(merged)
|
||||
if merged.get('classpath'):
|
||||
# if an explicit classpath is given, try to load it and look up its config model class
|
||||
from openhands.controller.agent import Agent
|
||||
|
||||
try:
|
||||
agent_cls = get_impl(Agent, merged.get('classpath'))
|
||||
custom_config = agent_cls.config_model.model_validate(merged)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Failed to load custom agent class [{merged.get("classpath")}]: {e}. Using default config model.'
|
||||
)
|
||||
custom_config = cls.model_validate(merged)
|
||||
else:
|
||||
# otherwise, try to look up the agent class by name (i.e. if it's a built-in)
|
||||
# if that fails, just use the default AgentConfig class.
|
||||
try:
|
||||
agent_cls = Agent.get_cls(name)
|
||||
custom_config = agent_cls.config_model.model_validate(merged)
|
||||
except Exception:
|
||||
# otherwise, just fall back to the default config model
|
||||
custom_config = cls.model_validate(merged)
|
||||
agent_mapping[name] = custom_config
|
||||
except ValidationError as e:
|
||||
logger.warning(
|
||||
|
||||
@@ -45,7 +45,7 @@ class OpenHandsConfig(BaseModel):
|
||||
run_as_openhands: Whether to run as openhands.
|
||||
max_iterations: Maximum number of iterations allowed.
|
||||
max_budget_per_task: Maximum budget per task, agent stops if exceeded.
|
||||
e2b_api_key: E2B API key.
|
||||
|
||||
disable_color: Whether to disable terminal colors. For terminals that don't support color.
|
||||
debug: Whether to enable debugging mode.
|
||||
file_uploads_max_file_size_mb: Maximum file upload size in MB. `0` means unlimited.
|
||||
@@ -87,19 +87,17 @@ class OpenHandsConfig(BaseModel):
|
||||
run_as_openhands: bool = Field(default=True)
|
||||
max_iterations: int = Field(default=OH_MAX_ITERATIONS)
|
||||
max_budget_per_task: float | None = Field(default=None)
|
||||
e2b_api_key: SecretStr | None = Field(default=None)
|
||||
modal_api_token_id: SecretStr | None = Field(default=None)
|
||||
modal_api_token_secret: SecretStr | None = Field(default=None)
|
||||
|
||||
disable_color: bool = Field(default=False)
|
||||
jwt_secret: SecretStr | None = Field(default=None)
|
||||
debug: bool = Field(default=False)
|
||||
file_uploads_max_file_size_mb: int = Field(default=0)
|
||||
file_uploads_restrict_file_types: bool = Field(default=False)
|
||||
file_uploads_allowed_extensions: list[str] = Field(default_factory=lambda: ['.*'])
|
||||
runloop_api_key: SecretStr | None = Field(default=None)
|
||||
daytona_api_key: SecretStr | None = Field(default=None)
|
||||
daytona_api_url: str = Field(default='https://app.daytona.io/api')
|
||||
daytona_target: str = Field(default='eu')
|
||||
|
||||
cli_multiline_input: bool = Field(default=False)
|
||||
conversation_max_age_seconds: int = Field(default=864000) # 10 days in seconds
|
||||
enable_default_condenser: bool = Field(default=True)
|
||||
|
||||
@@ -254,11 +254,8 @@ class SensitiveDataFilter(logging.Filter):
|
||||
'api_key',
|
||||
'aws_access_key_id',
|
||||
'aws_secret_access_key',
|
||||
'e2b_api_key',
|
||||
'github_token',
|
||||
'jwt_secret',
|
||||
'modal_api_token_id',
|
||||
'modal_api_token_secret',
|
||||
'llm_api_key',
|
||||
'sandbox_env_github_token',
|
||||
'daytona_api_key',
|
||||
|
||||
@@ -163,6 +163,7 @@ class LLM(RetryMixin, DebugMixin):
|
||||
'temperature': self.config.temperature,
|
||||
'max_completion_tokens': self.config.max_output_tokens,
|
||||
}
|
||||
|
||||
if self.config.top_k is not None:
|
||||
# openai doesn't expose top_k
|
||||
# litellm will handle it a bit differently than the openai-compatible params
|
||||
@@ -288,7 +289,20 @@ class LLM(RetryMixin, DebugMixin):
|
||||
# Record start time for latency measurement
|
||||
start_time = time.time()
|
||||
# we don't support streaming here, thus we get a ModelResponse
|
||||
resp: ModelResponse = self._completion_unwrapped(*args, **kwargs)
|
||||
|
||||
# Suppress httpx deprecation warnings during LiteLLM calls
|
||||
# This prevents the "Use 'content=<...>' to upload raw bytes/text content" warning
|
||||
# that appears when LiteLLM makes HTTP requests to LLM providers
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings(
|
||||
'ignore', category=DeprecationWarning, module='httpx.*'
|
||||
)
|
||||
warnings.filterwarnings(
|
||||
'ignore',
|
||||
message=r'.*content=.*upload.*',
|
||||
category=DeprecationWarning,
|
||||
)
|
||||
resp: ModelResponse = self._completion_unwrapped(*args, **kwargs)
|
||||
|
||||
# Calculate and record latency
|
||||
latency = time.time() - start_time
|
||||
@@ -473,26 +487,6 @@ class LLM(RetryMixin, DebugMixin):
|
||||
# Safe fallback for any potentially viable model
|
||||
self.config.max_input_tokens = 4096
|
||||
|
||||
if self.config.max_output_tokens is None:
|
||||
# Safe default for any potentially viable model
|
||||
self.config.max_output_tokens = 4096
|
||||
if self.model_info is not None:
|
||||
# max_output_tokens has precedence over max_tokens, if either exists.
|
||||
# litellm has models with both, one or none of these 2 parameters!
|
||||
if 'max_output_tokens' in self.model_info and isinstance(
|
||||
self.model_info['max_output_tokens'], int
|
||||
):
|
||||
self.config.max_output_tokens = self.model_info['max_output_tokens']
|
||||
elif 'max_tokens' in self.model_info and isinstance(
|
||||
self.model_info['max_tokens'], int
|
||||
):
|
||||
self.config.max_output_tokens = self.model_info['max_tokens']
|
||||
if any(
|
||||
model in self.config.model
|
||||
for model in ['claude-3-7-sonnet', 'claude-3.7-sonnet']
|
||||
):
|
||||
self.config.max_output_tokens = 64000 # litellm set max to 128k, but that requires a header to be set
|
||||
|
||||
# Initialize function calling capability
|
||||
# Check if model name is in our supported list
|
||||
model_name_supported = (
|
||||
|
||||
@@ -56,6 +56,18 @@ class ConversationMemory:
|
||||
self.agent_config = config
|
||||
self.prompt_manager = prompt_manager
|
||||
|
||||
@staticmethod
|
||||
def _is_valid_image_url(url: str | None) -> bool:
|
||||
"""Check if an image URL is valid and non-empty.
|
||||
|
||||
Args:
|
||||
url: The image URL to validate
|
||||
|
||||
Returns:
|
||||
True if the URL is valid, False otherwise
|
||||
"""
|
||||
return bool(url and url.strip())
|
||||
|
||||
def process_events(
|
||||
self,
|
||||
condensed_history: list[Event],
|
||||
@@ -380,7 +392,27 @@ class ConversationMemory:
|
||||
|
||||
# Add image URLs if available and vision is active
|
||||
if vision_is_active and obs.image_urls:
|
||||
content.append(ImageContent(image_urls=obs.image_urls))
|
||||
# Filter out empty or invalid image URLs
|
||||
valid_image_urls = [
|
||||
url for url in obs.image_urls if self._is_valid_image_url(url)
|
||||
]
|
||||
invalid_count = len(obs.image_urls) - len(valid_image_urls)
|
||||
|
||||
if valid_image_urls:
|
||||
content.append(ImageContent(image_urls=valid_image_urls))
|
||||
if invalid_count > 0:
|
||||
# Add text indicating some images were filtered
|
||||
content[
|
||||
0
|
||||
].text += f'\n\nNote: {invalid_count} invalid or empty image(s) were filtered from this output. The agent may need to use alternative methods to access visual information.'
|
||||
else:
|
||||
logger.debug(
|
||||
'IPython observation has image URLs but none are valid'
|
||||
)
|
||||
# Add text indicating all images were filtered
|
||||
content[
|
||||
0
|
||||
].text += f'\n\nNote: All {len(obs.image_urls)} image(s) in this output were invalid or empty and have been filtered. The agent should use alternative methods to access visual information.'
|
||||
|
||||
message = Message(role='user', content=content)
|
||||
elif isinstance(obs, FileEditObservation):
|
||||
@@ -398,25 +430,42 @@ class ConversationMemory:
|
||||
and vision_is_active
|
||||
):
|
||||
text += 'Image: Current webpage screenshot (Note that only visible portion of webpage is present in the screenshot. You may need to scroll to view the remaining portion of the web-page.)\n'
|
||||
message = Message(
|
||||
role='user',
|
||||
content=[
|
||||
TextContent(text=text),
|
||||
ImageContent(
|
||||
image_urls=[
|
||||
# show set of marks if it exists
|
||||
# otherwise, show raw screenshot when using vision-supported model
|
||||
obs.set_of_marks
|
||||
if obs.set_of_marks is not None
|
||||
and len(obs.set_of_marks) > 0
|
||||
else obs.screenshot
|
||||
]
|
||||
),
|
||||
],
|
||||
)
|
||||
logger.debug(
|
||||
f'Vision enabled for browsing, showing {"set of marks" if obs.set_of_marks and len(obs.set_of_marks) > 0 else "screenshot"}'
|
||||
)
|
||||
|
||||
# Determine which image to use and validate it
|
||||
image_url = None
|
||||
if obs.set_of_marks is not None and len(obs.set_of_marks) > 0:
|
||||
image_url = obs.set_of_marks
|
||||
image_type = 'set of marks'
|
||||
elif obs.screenshot is not None and len(obs.screenshot) > 0:
|
||||
image_url = obs.screenshot
|
||||
image_type = 'screenshot'
|
||||
|
||||
# Create message content with text
|
||||
content = [TextContent(text=text)]
|
||||
|
||||
# Only add ImageContent if we have a valid image URL
|
||||
if self._is_valid_image_url(image_url):
|
||||
content.append(ImageContent(image_urls=[image_url]))
|
||||
logger.debug(f'Vision enabled for browsing, showing {image_type}')
|
||||
else:
|
||||
if image_url:
|
||||
logger.warning(
|
||||
f'Invalid image URL format for {image_type}: {image_url[:50]}...'
|
||||
)
|
||||
# Add text indicating the image was filtered
|
||||
content[
|
||||
0
|
||||
].text += f'\n\nNote: The {image_type} for this webpage was invalid or empty and has been filtered. The agent should use alternative methods to access visual information about the webpage.'
|
||||
else:
|
||||
logger.debug(
|
||||
'Vision enabled for browsing, but no valid image available'
|
||||
)
|
||||
# Add text indicating no image was available
|
||||
content[
|
||||
0
|
||||
].text += '\n\nNote: No visual information (screenshot or set of marks) is available for this webpage. The agent should rely on the text content above.'
|
||||
|
||||
message = Message(role='user', content=content)
|
||||
else:
|
||||
message = Message(
|
||||
role='user',
|
||||
|
||||
@@ -1,31 +1,35 @@
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.runtime.impl.cli.cli_runtime import CLIRuntime
|
||||
from openhands.runtime.impl.daytona.daytona_runtime import DaytonaRuntime
|
||||
from openhands.runtime.impl.docker.docker_runtime import (
|
||||
DockerRuntime,
|
||||
)
|
||||
from openhands.runtime.impl.e2b.e2b_runtime import E2BRuntime
|
||||
from openhands.runtime.impl.kubernetes.kubernetes_runtime import KubernetesRuntime
|
||||
from openhands.runtime.impl.local.local_runtime import LocalRuntime
|
||||
from openhands.runtime.impl.modal.modal_runtime import ModalRuntime
|
||||
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
|
||||
from openhands.runtime.impl.runloop.runloop_runtime import RunloopRuntime
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
# Conditionally import Daytona runtime if available
|
||||
try:
|
||||
from openhands.runtime.impl.daytona.daytona_runtime import DaytonaRuntime
|
||||
|
||||
_DAYTONA_AVAILABLE = True
|
||||
except ImportError:
|
||||
_DAYTONA_AVAILABLE = False
|
||||
|
||||
# mypy: disable-error-code="type-abstract"
|
||||
_DEFAULT_RUNTIME_CLASSES: dict[str, type[Runtime]] = {
|
||||
'eventstream': DockerRuntime,
|
||||
'docker': DockerRuntime,
|
||||
'e2b': E2BRuntime,
|
||||
'remote': RemoteRuntime,
|
||||
'modal': ModalRuntime,
|
||||
'runloop': RunloopRuntime,
|
||||
'local': LocalRuntime,
|
||||
'daytona': DaytonaRuntime,
|
||||
'kubernetes': KubernetesRuntime,
|
||||
'cli': CLIRuntime,
|
||||
}
|
||||
|
||||
# Add Daytona runtime if available
|
||||
if _DAYTONA_AVAILABLE:
|
||||
_DEFAULT_RUNTIME_CLASSES['daytona'] = DaytonaRuntime
|
||||
|
||||
|
||||
def get_runtime_cls(name: str) -> type[Runtime]:
|
||||
"""
|
||||
@@ -46,13 +50,13 @@ def get_runtime_cls(name: str) -> type[Runtime]:
|
||||
|
||||
__all__ = [
|
||||
'Runtime',
|
||||
'E2BRuntime',
|
||||
'RemoteRuntime',
|
||||
'ModalRuntime',
|
||||
'RunloopRuntime',
|
||||
'DockerRuntime',
|
||||
'DaytonaRuntime',
|
||||
'KubernetesRuntime',
|
||||
'CLIRuntime',
|
||||
'get_runtime_cls',
|
||||
]
|
||||
|
||||
# Add DaytonaRuntime to exports if available
|
||||
if _DAYTONA_AVAILABLE:
|
||||
__all__.append('DaytonaRuntime')
|
||||
|
||||
@@ -100,11 +100,10 @@ class Runtime(FileEditRuntimeMixin):
|
||||
|
||||
Built-in implementations include:
|
||||
- DockerRuntime: Containerized environment using Docker
|
||||
- E2BRuntime: Secure sandbox using E2B
|
||||
- RemoteRuntime: Remote execution environment
|
||||
- ModalRuntime: Scalable cloud environment using Modal
|
||||
- LocalRuntime: Local execution for development
|
||||
- DaytonaRuntime: Cloud development environment using Daytona
|
||||
- KubernetesRuntime: Kubernetes-based execution environment
|
||||
- CLIRuntime: Command-line interface runtime
|
||||
|
||||
Args:
|
||||
sid: Session ID that uniquely identifies the current user session
|
||||
|
||||
@@ -6,22 +6,14 @@ from openhands.runtime.impl.action_execution.action_execution_client import (
|
||||
ActionExecutionClient,
|
||||
)
|
||||
from openhands.runtime.impl.cli import CLIRuntime
|
||||
from openhands.runtime.impl.daytona.daytona_runtime import DaytonaRuntime
|
||||
from openhands.runtime.impl.docker.docker_runtime import DockerRuntime
|
||||
from openhands.runtime.impl.e2b.e2b_runtime import E2BRuntime
|
||||
from openhands.runtime.impl.local.local_runtime import LocalRuntime
|
||||
from openhands.runtime.impl.modal.modal_runtime import ModalRuntime
|
||||
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
|
||||
from openhands.runtime.impl.runloop.runloop_runtime import RunloopRuntime
|
||||
|
||||
__all__ = [
|
||||
'ActionExecutionClient',
|
||||
'CLIRuntime',
|
||||
'DaytonaRuntime',
|
||||
'DockerRuntime',
|
||||
'E2BRuntime',
|
||||
'LocalRuntime',
|
||||
'ModalRuntime',
|
||||
'RemoteRuntime',
|
||||
'RunloopRuntime',
|
||||
]
|
||||
|
||||
@@ -9,11 +9,12 @@ import select
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
from binaryornot.check import is_binary
|
||||
from openhands_aci.editor.editor import OHEditor
|
||||
@@ -51,6 +52,41 @@ from openhands.runtime.base import Runtime
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
from openhands.runtime.runtime_status import RuntimeStatus
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openhands.runtime.utils.windows_bash import WindowsPowershellSession
|
||||
|
||||
# Import Windows PowerShell support if on Windows
|
||||
if sys.platform == 'win32':
|
||||
try:
|
||||
from openhands.runtime.utils.windows_bash import WindowsPowershellSession
|
||||
from openhands.runtime.utils.windows_exceptions import DotNetMissingError
|
||||
except (ImportError, DotNetMissingError) as err:
|
||||
# Print a user-friendly error message without stack trace
|
||||
friendly_message = """
|
||||
ERROR: PowerShell and .NET SDK are required but not properly configured
|
||||
|
||||
The .NET SDK and PowerShell are required for OpenHands CLI on Windows.
|
||||
PowerShell integration cannot function without .NET Core.
|
||||
|
||||
Please install the .NET SDK by following the instructions at:
|
||||
https://docs.all-hands.dev/usage/windows-without-wsl
|
||||
|
||||
After installing .NET SDK, restart your terminal and try again.
|
||||
"""
|
||||
print(friendly_message, file=sys.stderr)
|
||||
logger.error(
|
||||
f'Windows runtime initialization failed: {type(err).__name__}: {str(err)}'
|
||||
)
|
||||
if (
|
||||
isinstance(err, DotNetMissingError)
|
||||
and hasattr(err, 'details')
|
||||
and err.details
|
||||
):
|
||||
logger.debug(f'Details: {err.details}')
|
||||
|
||||
# Exit the program with an error code
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
class CLIRuntime(Runtime):
|
||||
"""
|
||||
@@ -119,6 +155,10 @@ class CLIRuntime(Runtime):
|
||||
self.file_editor = OHEditor(workspace_root=self._workspace_path)
|
||||
self._shell_stream_callback: Callable[[str], None] | None = None
|
||||
|
||||
# Initialize PowerShell session on Windows
|
||||
self._is_windows = sys.platform == 'win32'
|
||||
self._powershell_session: WindowsPowershellSession | None = None
|
||||
|
||||
logger.warning(
|
||||
'Initializing CLIRuntime. WARNING: NO SANDBOX IS USED. '
|
||||
'This runtime executes commands directly on the local system. '
|
||||
@@ -135,6 +175,15 @@ class CLIRuntime(Runtime):
|
||||
# Change to the workspace directory
|
||||
os.chdir(self._workspace_path)
|
||||
|
||||
# Initialize PowerShell session if on Windows
|
||||
if self._is_windows:
|
||||
self._powershell_session = WindowsPowershellSession(
|
||||
work_dir=self._workspace_path,
|
||||
username=None, # Use current user
|
||||
no_change_timeout_seconds=30,
|
||||
max_memory_mb=None,
|
||||
)
|
||||
|
||||
if not self.attach_to_existing:
|
||||
await asyncio.to_thread(self.setup_initial_env)
|
||||
|
||||
@@ -241,6 +290,40 @@ class CLIRuntime(Runtime):
|
||||
except Exception as e:
|
||||
logger.error(f'Error: {e}')
|
||||
|
||||
def _execute_powershell_command(
|
||||
self, command: str, timeout: float
|
||||
) -> CmdOutputObservation | ErrorObservation:
|
||||
"""
|
||||
Execute a command using PowerShell session on Windows.
|
||||
Args:
|
||||
command: The command to execute
|
||||
timeout: Timeout in seconds for the command
|
||||
Returns:
|
||||
CmdOutputObservation containing the complete output and exit code
|
||||
"""
|
||||
if self._powershell_session is None:
|
||||
return ErrorObservation(
|
||||
content='PowerShell session is not available.',
|
||||
error_id='POWERSHELL_SESSION_ERROR',
|
||||
)
|
||||
|
||||
try:
|
||||
# Create a CmdRunAction for the PowerShell session
|
||||
from openhands.events.action import CmdRunAction
|
||||
|
||||
ps_action = CmdRunAction(command=command)
|
||||
ps_action.set_hard_timeout(timeout)
|
||||
|
||||
# Execute the command using the PowerShell session
|
||||
return self._powershell_session.execute(ps_action)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error executing PowerShell command "{command}": {e}')
|
||||
return ErrorObservation(
|
||||
content=f'Error executing PowerShell command "{command}": {str(e)}',
|
||||
error_id='POWERSHELL_EXECUTION_ERROR',
|
||||
)
|
||||
|
||||
def _execute_shell_command(
|
||||
self, command: str, timeout: float
|
||||
) -> CmdOutputObservation:
|
||||
@@ -378,9 +461,16 @@ class CLIRuntime(Runtime):
|
||||
logger.debug(
|
||||
f'Running command in CLIRuntime: "{action.command}" with effective timeout: {effective_timeout}s'
|
||||
)
|
||||
return self._execute_shell_command(
|
||||
action.command, timeout=effective_timeout
|
||||
)
|
||||
|
||||
# Use PowerShell on Windows if available, otherwise use subprocess
|
||||
if self._is_windows and self._powershell_session is not None:
|
||||
return self._execute_powershell_command(
|
||||
action.command, timeout=effective_timeout
|
||||
)
|
||||
else:
|
||||
return self._execute_shell_command(
|
||||
action.command, timeout=effective_timeout
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'Error in CLIRuntime.run for command "{action.command}": {str(e)}'
|
||||
@@ -737,6 +827,16 @@ class CLIRuntime(Runtime):
|
||||
raise RuntimeError(f'Error creating zip file: {str(e)}')
|
||||
|
||||
def close(self) -> None:
|
||||
# Clean up PowerShell session if it exists
|
||||
if self._powershell_session is not None:
|
||||
try:
|
||||
self._powershell_session.close()
|
||||
logger.debug('PowerShell session closed successfully.')
|
||||
except Exception as e:
|
||||
logger.warning(f'Error closing PowerShell session: {e}')
|
||||
finally:
|
||||
self._powershell_session = None
|
||||
|
||||
self._runtime_initialized = False
|
||||
super().close()
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# How to use E2B
|
||||
|
||||
[E2B](https://e2b.dev) is an [open-source](https://github.com/e2b-dev/e2b) secure cloud environment (sandbox) made for running AI-generated code and agents. E2B offers [Python](https://pypi.org/project/e2b/) and [JS/TS](https://www.npmjs.com/package/e2b) SDK to spawn and control these sandboxes.
|
||||
|
||||
## Getting started
|
||||
|
||||
1. [Get your API key](https://e2b.dev/docs/getting-started/api-key)
|
||||
|
||||
1. Set your E2B API key to the `E2B_API_KEY` env var when starting the Docker container
|
||||
|
||||
1. **Optional** - Install the CLI with NPM.
|
||||
```sh
|
||||
npm install -g @e2b/cli@latest
|
||||
```
|
||||
Full CLI API is [here](https://e2b.dev/docs/cli/installation).
|
||||
|
||||
## OpenHands sandbox
|
||||
You can use the E2B CLI to create a custom sandbox with a Dockerfile. Read the full guide [here](https://e2b.dev/docs/guide/custom-sandbox). The premade OpenHands sandbox for E2B is set up in the [`containers` directory](/containers/e2b-sandbox). and it's called `openhands`.
|
||||
|
||||
## Debugging
|
||||
You can connect to a running E2B sandbox with E2B CLI in your terminal.
|
||||
|
||||
- List all running sandboxes (based on your API key)
|
||||
```sh
|
||||
e2b sandbox list
|
||||
```
|
||||
|
||||
- Connect to a running sandbox
|
||||
```sh
|
||||
e2b sandbox connect <sandbox-id>
|
||||
```
|
||||
|
||||
## Links
|
||||
- [E2B Docs](https://e2b.dev/docs)
|
||||
- [E2B GitHub](https://github.com/e2b-dev/e2b)
|
||||
@@ -1,78 +0,0 @@
|
||||
from typing import Callable
|
||||
|
||||
from openhands.core.config import OpenHandsConfig
|
||||
from openhands.events.action import (
|
||||
FileReadAction,
|
||||
FileWriteAction,
|
||||
)
|
||||
from openhands.events.observation import (
|
||||
ErrorObservation,
|
||||
FileReadObservation,
|
||||
FileWriteObservation,
|
||||
Observation,
|
||||
)
|
||||
from openhands.events.stream import EventStream
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
|
||||
from openhands.runtime.impl.action_execution.action_execution_client import (
|
||||
ActionExecutionClient,
|
||||
)
|
||||
from openhands.runtime.impl.e2b.filestore import E2BFileStore
|
||||
from openhands.runtime.impl.e2b.sandbox import E2BSandbox
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
from openhands.runtime.utils.files import insert_lines, read_lines
|
||||
|
||||
|
||||
class E2BRuntime(ActionExecutionClient):
|
||||
def __init__(
|
||||
self,
|
||||
config: OpenHandsConfig,
|
||||
event_stream: EventStream,
|
||||
sid: str = 'default',
|
||||
plugins: list[PluginRequirement] | None = None,
|
||||
env_vars: dict[str, str] | None = None,
|
||||
status_callback: Callable | None = None,
|
||||
attach_to_existing: bool = False,
|
||||
headless_mode: bool = True,
|
||||
user_id: str | None = None,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
|
||||
sandbox: E2BSandbox | None = None,
|
||||
):
|
||||
super().__init__(
|
||||
config,
|
||||
event_stream,
|
||||
sid,
|
||||
plugins,
|
||||
env_vars,
|
||||
status_callback,
|
||||
attach_to_existing,
|
||||
headless_mode,
|
||||
user_id,
|
||||
git_provider_tokens,
|
||||
)
|
||||
if sandbox is None:
|
||||
self.sandbox = E2BSandbox()
|
||||
if not isinstance(self.sandbox, E2BSandbox):
|
||||
raise ValueError('E2BRuntime requires an E2BSandbox')
|
||||
self.file_store = E2BFileStore(self.sandbox.filesystem)
|
||||
|
||||
def read(self, action: FileReadAction) -> Observation:
|
||||
content = self.file_store.read(action.path)
|
||||
lines = read_lines(content.split('\n'), action.start, action.end)
|
||||
code_view = ''.join(lines)
|
||||
return FileReadObservation(code_view, path=action.path)
|
||||
|
||||
def write(self, action: FileWriteAction) -> Observation:
|
||||
if action.start == 0 and action.end == -1:
|
||||
self.file_store.write(action.path, action.content)
|
||||
return FileWriteObservation(content='', path=action.path)
|
||||
files = self.file_store.list(action.path)
|
||||
if action.path in files:
|
||||
all_lines = self.file_store.read(action.path).split('\n')
|
||||
new_file = insert_lines(
|
||||
action.content.split('\n'), all_lines, action.start, action.end
|
||||
)
|
||||
self.file_store.write(action.path, ''.join(new_file))
|
||||
return FileWriteObservation('', path=action.path)
|
||||
else:
|
||||
# FIXME: we should create a new file here
|
||||
return ErrorObservation(f'File not found: {action.path}')
|
||||
@@ -1,27 +0,0 @@
|
||||
from typing import Protocol
|
||||
|
||||
from openhands.storage.files import FileStore
|
||||
|
||||
|
||||
class SupportsFilesystemOperations(Protocol):
|
||||
def write(self, path: str, contents: str | bytes) -> None: ...
|
||||
def read(self, path: str) -> str: ...
|
||||
def list(self, path: str) -> list[str]: ...
|
||||
def delete(self, path: str) -> None: ...
|
||||
|
||||
|
||||
class E2BFileStore(FileStore):
|
||||
def __init__(self, filesystem: SupportsFilesystemOperations) -> None:
|
||||
self.filesystem = filesystem
|
||||
|
||||
def write(self, path: str, contents: str | bytes) -> None:
|
||||
self.filesystem.write(path, contents)
|
||||
|
||||
def read(self, path: str) -> str:
|
||||
return self.filesystem.read(path)
|
||||
|
||||
def list(self, path: str) -> list[str]:
|
||||
return self.filesystem.list(path)
|
||||
|
||||
def delete(self, path: str) -> None:
|
||||
self.filesystem.delete(path)
|
||||
@@ -1,114 +0,0 @@
|
||||
import copy
|
||||
import os
|
||||
import tarfile
|
||||
from glob import glob
|
||||
|
||||
from e2b import Sandbox as E2BSandbox
|
||||
from e2b.exceptions import TimeoutException
|
||||
|
||||
from openhands.core.config import SandboxConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
class E2BBox:
|
||||
closed = False
|
||||
_cwd: str = '/home/user'
|
||||
_env: dict[str, str] = {}
|
||||
is_initial_session: bool = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: SandboxConfig,
|
||||
e2b_api_key: str,
|
||||
template: str = 'openhands',
|
||||
):
|
||||
self.config = copy.deepcopy(config)
|
||||
self.initialize_plugins: bool = config.initialize_plugins
|
||||
self.sandbox = E2BSandbox(
|
||||
api_key=e2b_api_key,
|
||||
template=template,
|
||||
# It's possible to stream stdout and stderr from sandbox and from each process
|
||||
on_stderr=lambda x: logger.debug(f'E2B sandbox stderr: {x}'),
|
||||
on_stdout=lambda x: logger.debug(f'E2B sandbox stdout: {x}'),
|
||||
cwd=self._cwd, # Default workdir inside sandbox
|
||||
)
|
||||
logger.debug(f'Started E2B sandbox with ID "{self.sandbox.id}"')
|
||||
|
||||
@property
|
||||
def filesystem(self):
|
||||
return self.sandbox.filesystem
|
||||
|
||||
def _archive(self, host_src: str, recursive: bool = False):
|
||||
if recursive:
|
||||
assert os.path.isdir(host_src), (
|
||||
'Source must be a directory when recursive is True'
|
||||
)
|
||||
files = glob(host_src + '/**/*', recursive=True)
|
||||
srcname = os.path.basename(host_src)
|
||||
tar_filename = os.path.join(os.path.dirname(host_src), srcname + '.tar')
|
||||
with tarfile.open(tar_filename, mode='w') as tar:
|
||||
for file in files:
|
||||
tar.add(
|
||||
file, arcname=os.path.relpath(file, os.path.dirname(host_src))
|
||||
)
|
||||
else:
|
||||
assert os.path.isfile(host_src), (
|
||||
'Source must be a file when recursive is False'
|
||||
)
|
||||
srcname = os.path.basename(host_src)
|
||||
tar_filename = os.path.join(os.path.dirname(host_src), srcname + '.tar')
|
||||
with tarfile.open(tar_filename, mode='w') as tar:
|
||||
tar.add(host_src, arcname=srcname)
|
||||
return tar_filename
|
||||
|
||||
def execute(self, cmd: str, timeout: int | None = None) -> tuple[int, str]:
|
||||
timeout = timeout if timeout is not None else self.config.timeout
|
||||
process = self.sandbox.process.start(cmd, env_vars=self._env)
|
||||
try:
|
||||
process_output = process.wait(timeout=timeout)
|
||||
except TimeoutException:
|
||||
logger.debug('Command timed out, killing process...')
|
||||
process.kill()
|
||||
return -1, f'Command: "{cmd}" timed out'
|
||||
|
||||
logs = [m.line for m in process_output.messages]
|
||||
logs_str = '\n'.join(logs)
|
||||
if process.exit_code is None:
|
||||
return -1, logs_str
|
||||
|
||||
assert process_output.exit_code is not None
|
||||
return process_output.exit_code, logs_str
|
||||
|
||||
def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False):
|
||||
"""Copies a local file or directory to the sandbox."""
|
||||
tar_filename = self._archive(host_src, recursive)
|
||||
|
||||
# Prepend the sandbox destination with our sandbox cwd
|
||||
sandbox_dest = os.path.join(self._cwd, sandbox_dest.removeprefix('/'))
|
||||
|
||||
with open(tar_filename, 'rb') as tar_file:
|
||||
# Upload the archive to /home/user (default destination that always exists)
|
||||
uploaded_path = self.sandbox.upload_file(tar_file)
|
||||
|
||||
# Check if sandbox_dest exists. If not, create it.
|
||||
process = self.sandbox.process.start_and_wait(f'test -d {sandbox_dest}')
|
||||
if process.exit_code != 0:
|
||||
self.sandbox.filesystem.make_dir(sandbox_dest)
|
||||
|
||||
# Extract the archive into the destination and delete the archive
|
||||
process = self.sandbox.process.start_and_wait(
|
||||
f'sudo tar -xf {uploaded_path} -C {sandbox_dest} && sudo rm {uploaded_path}'
|
||||
)
|
||||
if process.exit_code != 0:
|
||||
raise Exception(
|
||||
f'Failed to extract {uploaded_path} to {sandbox_dest}: {process.stderr}'
|
||||
)
|
||||
|
||||
# Delete the local archive
|
||||
os.remove(tar_filename)
|
||||
|
||||
def close(self):
|
||||
self.sandbox.close()
|
||||
|
||||
def get_working_directory(self):
|
||||
return self.sandbox.cwd
|
||||
@@ -40,7 +40,7 @@ Two configuration options are required to use the Kubernetes runtime:
|
||||
2. **Runtime Container Image**: Specify the container image to use for the runtime environment
|
||||
```toml
|
||||
[sandbox]
|
||||
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.44-nikolaik"
|
||||
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.45-nikolaik"
|
||||
```
|
||||
|
||||
#### Additional Kubernetes Options
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import httpx
|
||||
import modal
|
||||
import tenacity
|
||||
|
||||
from openhands.core.config import OpenHandsConfig
|
||||
from openhands.events import EventStream
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
|
||||
from openhands.runtime.impl.action_execution.action_execution_client import (
|
||||
ActionExecutionClient,
|
||||
)
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
from openhands.runtime.runtime_status import RuntimeStatus
|
||||
from openhands.runtime.utils.command import get_action_execution_server_startup_command
|
||||
from openhands.runtime.utils.runtime_build import (
|
||||
BuildFromImageType,
|
||||
prep_build_folder,
|
||||
)
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.tenacity_stop import stop_if_should_exit
|
||||
|
||||
# FIXME: this will not work in HA mode. We need a better way to track IDs
|
||||
MODAL_RUNTIME_IDS: dict[str, str] = {}
|
||||
|
||||
|
||||
class ModalRuntime(ActionExecutionClient):
|
||||
"""This runtime will subscribe the event stream.
|
||||
|
||||
When receive an event, it will send the event to runtime-client which run inside the Modal sandbox environment.
|
||||
|
||||
Args:
|
||||
config (OpenHandsConfig): The application configuration.
|
||||
event_stream (EventStream): The event stream to subscribe to.
|
||||
sid (str, optional): The session ID. Defaults to 'default'.
|
||||
plugins (list[PluginRequirement] | None, optional): List of plugin requirements. Defaults to None.
|
||||
env_vars (dict[str, str] | None, optional): Environment variables to set. Defaults to None.
|
||||
"""
|
||||
|
||||
container_name_prefix = 'openhands-sandbox-'
|
||||
sandbox: modal.Sandbox | None
|
||||
sid: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: OpenHandsConfig,
|
||||
event_stream: EventStream,
|
||||
sid: str = 'default',
|
||||
plugins: list[PluginRequirement] | None = None,
|
||||
env_vars: dict[str, str] | None = None,
|
||||
status_callback: Callable | None = None,
|
||||
attach_to_existing: bool = False,
|
||||
headless_mode: bool = True,
|
||||
user_id: str | None = None,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
|
||||
):
|
||||
assert config.modal_api_token_id, 'Modal API token id is required'
|
||||
assert config.modal_api_token_secret, 'Modal API token secret is required'
|
||||
|
||||
self.config = config
|
||||
self.sandbox = None
|
||||
self.sid = sid
|
||||
|
||||
self.modal_client = modal.Client.from_credentials(
|
||||
config.modal_api_token_id.get_secret_value(),
|
||||
config.modal_api_token_secret.get_secret_value(),
|
||||
)
|
||||
self.app = modal.App.lookup(
|
||||
'openhands', create_if_missing=True, client=self.modal_client
|
||||
)
|
||||
|
||||
# workspace_base cannot be used because we can't bind mount into a sandbox.
|
||||
if self.config.workspace_base is not None:
|
||||
self.log(
|
||||
'warning',
|
||||
'Setting workspace_base is not supported in the modal runtime.',
|
||||
)
|
||||
|
||||
# This value is arbitrary as it's private to the container
|
||||
self.container_port = 3000
|
||||
self._vscode_port = 4445
|
||||
self._vscode_url: str | None = None
|
||||
|
||||
self.status_callback = status_callback
|
||||
self.base_container_image_id = self.config.sandbox.base_container_image
|
||||
self.runtime_container_image_id = self.config.sandbox.runtime_container_image
|
||||
|
||||
if self.config.sandbox.runtime_extra_deps:
|
||||
self.log(
|
||||
'debug',
|
||||
f'Installing extra user-provided dependencies in the runtime image: {self.config.sandbox.runtime_extra_deps}',
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
config,
|
||||
event_stream,
|
||||
sid,
|
||||
plugins,
|
||||
env_vars,
|
||||
status_callback,
|
||||
attach_to_existing,
|
||||
headless_mode,
|
||||
user_id,
|
||||
git_provider_tokens,
|
||||
)
|
||||
|
||||
async def connect(self):
|
||||
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
|
||||
|
||||
self.log('debug', f'ModalRuntime `{self.sid}`')
|
||||
|
||||
self.image = self._get_image_definition(
|
||||
self.base_container_image_id,
|
||||
self.runtime_container_image_id,
|
||||
self.config.sandbox.runtime_extra_deps,
|
||||
)
|
||||
|
||||
if self.attach_to_existing:
|
||||
if self.sid in MODAL_RUNTIME_IDS:
|
||||
sandbox_id = MODAL_RUNTIME_IDS[self.sid]
|
||||
self.log('debug', f'Attaching to existing Modal sandbox: {sandbox_id}')
|
||||
self.sandbox = modal.Sandbox.from_id(
|
||||
sandbox_id, client=self.modal_client
|
||||
)
|
||||
else:
|
||||
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
|
||||
await call_sync_from_async(
|
||||
self._init_sandbox,
|
||||
sandbox_workspace_dir=self.config.workspace_mount_path_in_sandbox,
|
||||
plugins=self.plugins,
|
||||
)
|
||||
|
||||
self.set_runtime_status(RuntimeStatus.RUNTIME_STARTED)
|
||||
|
||||
if self.sandbox is None:
|
||||
raise Exception('Sandbox not initialized')
|
||||
tunnel = self.sandbox.tunnels()[self.container_port]
|
||||
self.api_url = tunnel.url
|
||||
self.log('debug', f'Container started. Server url: {self.api_url}')
|
||||
|
||||
if not self.attach_to_existing:
|
||||
self.log('debug', 'Waiting for client to become ready...')
|
||||
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
|
||||
|
||||
self._wait_until_alive()
|
||||
self.setup_initial_env()
|
||||
|
||||
if not self.attach_to_existing:
|
||||
self.set_runtime_status(RuntimeStatus.READY)
|
||||
self._runtime_initialized = True
|
||||
|
||||
@property
|
||||
def action_execution_server_url(self):
|
||||
return self.api_url
|
||||
|
||||
@tenacity.retry(
|
||||
stop=tenacity.stop_after_delay(120) | stop_if_should_exit(),
|
||||
retry=tenacity.retry_if_exception_type((ConnectionError, httpx.NetworkError)),
|
||||
reraise=True,
|
||||
wait=tenacity.wait_fixed(2),
|
||||
)
|
||||
def _wait_until_alive(self):
|
||||
self.check_if_alive()
|
||||
|
||||
def _get_image_definition(
|
||||
self,
|
||||
base_container_image_id: str | None,
|
||||
runtime_container_image_id: str | None,
|
||||
runtime_extra_deps: str | None,
|
||||
) -> modal.Image:
|
||||
if runtime_container_image_id:
|
||||
base_runtime_image = modal.Image.from_registry(runtime_container_image_id)
|
||||
elif base_container_image_id:
|
||||
build_folder = tempfile.mkdtemp()
|
||||
prep_build_folder(
|
||||
build_folder=Path(build_folder),
|
||||
base_image=base_container_image_id,
|
||||
build_from=BuildFromImageType.SCRATCH,
|
||||
extra_deps=runtime_extra_deps,
|
||||
)
|
||||
|
||||
base_runtime_image = modal.Image.from_dockerfile(
|
||||
path=os.path.join(build_folder, 'Dockerfile'),
|
||||
context_mount=modal.Mount.from_local_dir(
|
||||
local_path=build_folder,
|
||||
remote_path='.', # to current WORKDIR
|
||||
),
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
'Neither runtime container image nor base container image is set'
|
||||
)
|
||||
|
||||
return base_runtime_image.run_commands(
|
||||
"""
|
||||
# Disable bracketed paste
|
||||
# https://github.com/pexpect/pexpect/issues/669
|
||||
echo "set enable-bracketed-paste off" >> /etc/inputrc && \\
|
||||
echo 'export INPUTRC=/etc/inputrc' >> /etc/bash.bashrc
|
||||
""".strip()
|
||||
)
|
||||
|
||||
@tenacity.retry(
|
||||
stop=tenacity.stop_after_attempt(5),
|
||||
wait=tenacity.wait_exponential(multiplier=1, min=4, max=60),
|
||||
)
|
||||
def _init_sandbox(
|
||||
self,
|
||||
sandbox_workspace_dir: str,
|
||||
plugins: list[PluginRequirement] | None = None,
|
||||
):
|
||||
try:
|
||||
self.log('debug', 'Preparing to start container...')
|
||||
# Combine environment variables
|
||||
environment: dict[str, str | None] = {
|
||||
'port': str(self.container_port),
|
||||
'PYTHONUNBUFFERED': '1',
|
||||
'VSCODE_PORT': str(self._vscode_port),
|
||||
}
|
||||
if self.config.debug:
|
||||
environment['DEBUG'] = 'true'
|
||||
|
||||
env_secret = modal.Secret.from_dict(environment)
|
||||
|
||||
self.log('debug', f'Sandbox workspace: {sandbox_workspace_dir}')
|
||||
sandbox_start_cmd = get_action_execution_server_startup_command(
|
||||
server_port=self.container_port,
|
||||
plugins=self.plugins,
|
||||
app_config=self.config,
|
||||
)
|
||||
self.log('debug', f'Starting container with command: {sandbox_start_cmd}')
|
||||
self.sandbox = modal.Sandbox.create(
|
||||
*sandbox_start_cmd,
|
||||
secrets=[env_secret],
|
||||
workdir='/openhands/code',
|
||||
encrypted_ports=[self.container_port, self._vscode_port],
|
||||
image=self.image,
|
||||
app=self.app,
|
||||
client=self.modal_client,
|
||||
timeout=60 * 60,
|
||||
)
|
||||
MODAL_RUNTIME_IDS[self.sid] = self.sandbox.object_id
|
||||
self.log('debug', 'Container started')
|
||||
|
||||
except Exception as e:
|
||||
self.log(
|
||||
'error', f'Error: Instance {self.sid} FAILED to start container!\n'
|
||||
)
|
||||
self.log('error', str(e))
|
||||
self.close()
|
||||
raise e
|
||||
|
||||
def close(self):
|
||||
"""Closes the ModalRuntime and associated objects."""
|
||||
super().close()
|
||||
|
||||
if not self.attach_to_existing and self.sandbox:
|
||||
self.sandbox.terminate()
|
||||
|
||||
@property
|
||||
def vscode_url(self) -> str | None:
|
||||
if self._vscode_url is not None: # cached value
|
||||
self.log('debug', f'VSCode URL: {self._vscode_url}')
|
||||
return self._vscode_url
|
||||
token = super().get_vscode_token()
|
||||
if not token:
|
||||
self.log('error', 'VSCode token not found')
|
||||
return None
|
||||
if not self.sandbox:
|
||||
self.log('error', 'Sandbox not initialized')
|
||||
return None
|
||||
|
||||
tunnel = self.sandbox.tunnels()[self._vscode_port]
|
||||
tunnel_url = tunnel.url
|
||||
self._vscode_url = (
|
||||
tunnel_url
|
||||
+ f'/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
|
||||
)
|
||||
|
||||
self.log(
|
||||
'debug',
|
||||
f'VSCode URL: {self._vscode_url}',
|
||||
)
|
||||
|
||||
return self._vscode_url
|
||||
@@ -1,31 +0,0 @@
|
||||
# Runloop Runtime
|
||||
Runloop provides a fast, secure and scalable AI sandbox (Devbox).
|
||||
Check out the [runloop docs](https://docs.runloop.ai/overview/what-is-runloop)
|
||||
for more detail
|
||||
|
||||
## Access
|
||||
Runloop is currently available in a closed beta. For early access, or
|
||||
just to say hello, sign up at https://www.runloop.ai/hello
|
||||
|
||||
## Set up
|
||||
With your runloop API,
|
||||
```bash
|
||||
export RUNLOOP_API_KEY=<your-api-key>
|
||||
```
|
||||
|
||||
Configure the runtime
|
||||
```bash
|
||||
export RUNTIME="runloop"
|
||||
```
|
||||
|
||||
## Interact with your devbox
|
||||
Runloop provides additional tools to interact with your Devbox based
|
||||
runtime environment. See the [docs](https://docs.runloop.ai/tools) for an up
|
||||
to date list of tools.
|
||||
|
||||
### Dashboard
|
||||
View logs, ssh into, or view your Devbox status from the [dashboard](https://platform.runloop.ai)
|
||||
|
||||
### CLI
|
||||
Use the Runloop CLI to view logs, execute commands, and more.
|
||||
See the setup instructions [here](https://docs.runloop.ai/tools/cli)
|
||||
@@ -1,198 +0,0 @@
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
import tenacity
|
||||
from runloop_api_client import Runloop
|
||||
from runloop_api_client.types import DevboxView
|
||||
from runloop_api_client.types.shared_params import LaunchParameters
|
||||
|
||||
from openhands.core.config import OpenHandsConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events import EventStream
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
|
||||
from openhands.runtime.impl.action_execution.action_execution_client import (
|
||||
ActionExecutionClient,
|
||||
)
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
from openhands.runtime.runtime_status import RuntimeStatus
|
||||
from openhands.runtime.utils.command import get_action_execution_server_startup_command
|
||||
from openhands.utils.tenacity_stop import stop_if_should_exit
|
||||
|
||||
CONTAINER_NAME_PREFIX = 'openhands-runtime-'
|
||||
|
||||
|
||||
class RunloopRuntime(ActionExecutionClient):
|
||||
"""The RunloopRuntime class is an DockerRuntime that utilizes Runloop Devbox as a runtime environment."""
|
||||
|
||||
_sandbox_port: int = 4444
|
||||
_vscode_port: int = 4445
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: OpenHandsConfig,
|
||||
event_stream: EventStream,
|
||||
sid: str = 'default',
|
||||
plugins: list[PluginRequirement] | None = None,
|
||||
env_vars: dict[str, str] | None = None,
|
||||
status_callback: Callable | None = None,
|
||||
attach_to_existing: bool = False,
|
||||
headless_mode: bool = True,
|
||||
user_id: str | None = None,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
|
||||
):
|
||||
assert config.runloop_api_key is not None, 'Runloop API key is required'
|
||||
self.devbox: DevboxView | None = None
|
||||
self.config = config
|
||||
self.runloop_api_client = Runloop(
|
||||
bearer_token=config.runloop_api_key.get_secret_value(),
|
||||
)
|
||||
self.container_name = CONTAINER_NAME_PREFIX + sid
|
||||
super().__init__(
|
||||
config,
|
||||
event_stream,
|
||||
sid,
|
||||
plugins,
|
||||
env_vars,
|
||||
status_callback,
|
||||
attach_to_existing,
|
||||
headless_mode,
|
||||
user_id,
|
||||
git_provider_tokens,
|
||||
)
|
||||
# Buffer for container logs
|
||||
self._vscode_url: str | None = None
|
||||
|
||||
@property
|
||||
def action_execution_server_url(self):
|
||||
return self.api_url
|
||||
|
||||
@tenacity.retry(
|
||||
stop=tenacity.stop_after_attempt(120),
|
||||
wait=tenacity.wait_fixed(1),
|
||||
)
|
||||
def _wait_for_devbox(self, devbox: DevboxView) -> DevboxView:
|
||||
"""Pull devbox status until it is running"""
|
||||
if devbox == 'running':
|
||||
return devbox
|
||||
|
||||
devbox = self.runloop_api_client.devboxes.retrieve(id=devbox.id)
|
||||
if devbox.status != 'running':
|
||||
raise ConnectionRefusedError('Devbox is not running')
|
||||
|
||||
# Devbox is connected and running
|
||||
logging.debug(f'devbox.id={devbox.id} is running')
|
||||
return devbox
|
||||
|
||||
def _create_new_devbox(self) -> DevboxView:
|
||||
# Note: Runloop connect
|
||||
start_command = get_action_execution_server_startup_command(
|
||||
server_port=self._sandbox_port,
|
||||
plugins=self.plugins,
|
||||
app_config=self.config,
|
||||
)
|
||||
|
||||
# Add some additional commands based on our image
|
||||
# NB: start off as root, action_execution_server will ultimately choose user but expects all context
|
||||
# (ie browser) to be installed as root
|
||||
# Convert start_command list to a single command string with additional setup
|
||||
start_command_str = (
|
||||
'export MAMBA_ROOT_PREFIX=/openhands/micromamba && '
|
||||
'cd /openhands/code && '
|
||||
'/openhands/micromamba/bin/micromamba run -n openhands poetry config virtualenvs.path /openhands/poetry && '
|
||||
+ ' '.join(start_command)
|
||||
)
|
||||
entrypoint = f"sudo bash -c '{start_command_str}'"
|
||||
|
||||
devbox = self.runloop_api_client.devboxes.create(
|
||||
entrypoint=entrypoint,
|
||||
name=self.sid,
|
||||
environment_variables={'DEBUG': 'true'} if self.config.debug else {},
|
||||
prebuilt='openhands',
|
||||
launch_parameters=LaunchParameters(
|
||||
available_ports=[self._sandbox_port, self._vscode_port],
|
||||
resource_size_request='LARGE',
|
||||
launch_commands=[
|
||||
f'mkdir -p {self.config.workspace_mount_path_in_sandbox}'
|
||||
],
|
||||
),
|
||||
metadata={'container-name': self.container_name},
|
||||
)
|
||||
return self._wait_for_devbox(devbox)
|
||||
|
||||
async def connect(self):
|
||||
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
|
||||
|
||||
if self.attach_to_existing:
|
||||
active_devboxes = self.runloop_api_client.devboxes.list(
|
||||
status='running'
|
||||
).devboxes
|
||||
self.devbox = next(
|
||||
(devbox for devbox in active_devboxes if devbox.name == self.sid), None
|
||||
)
|
||||
|
||||
if self.devbox is None:
|
||||
self.devbox = self._create_new_devbox()
|
||||
|
||||
# Create tunnel - this will return a stable url, so is safe to call if we are attaching to existing
|
||||
tunnel = self.runloop_api_client.devboxes.create_tunnel(
|
||||
id=self.devbox.id,
|
||||
port=self._sandbox_port,
|
||||
)
|
||||
|
||||
self.api_url = tunnel.url
|
||||
logger.info(f'Container started. Server url: {self.api_url}')
|
||||
|
||||
# End Runloop connect
|
||||
# NOTE: Copied from DockerRuntime
|
||||
logger.info('Waiting for client to become ready...')
|
||||
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
|
||||
self._wait_until_alive()
|
||||
|
||||
if not self.attach_to_existing:
|
||||
self.setup_initial_env()
|
||||
|
||||
logger.info(
|
||||
f'Container initialized with plugins: {[plugin.name for plugin in self.plugins]}'
|
||||
)
|
||||
self.set_runtime_status(RuntimeStatus.READY)
|
||||
|
||||
@tenacity.retry(
|
||||
stop=tenacity.stop_after_delay(120) | stop_if_should_exit(),
|
||||
wait=tenacity.wait_fixed(1),
|
||||
reraise=(ConnectionRefusedError,),
|
||||
)
|
||||
def _wait_until_alive(self):
|
||||
super().check_if_alive()
|
||||
|
||||
def close(self, rm_all_containers: bool | None = True):
|
||||
super().close()
|
||||
|
||||
if self.attach_to_existing:
|
||||
return
|
||||
|
||||
if self.devbox:
|
||||
self.runloop_api_client.devboxes.shutdown(self.devbox.id)
|
||||
|
||||
@property
|
||||
def vscode_url(self) -> str | None:
|
||||
if self._vscode_url is not None: # cached value
|
||||
return self._vscode_url
|
||||
token = super().get_vscode_token()
|
||||
if not token:
|
||||
return None
|
||||
if not self.devbox:
|
||||
return None
|
||||
self._vscode_url = (
|
||||
self.runloop_api_client.devboxes.create_tunnel(
|
||||
id=self.devbox.id,
|
||||
port=self._vscode_port,
|
||||
).url
|
||||
+ f'/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
|
||||
)
|
||||
|
||||
self.log(
|
||||
'debug',
|
||||
f'VSCode URL: {self._vscode_url}',
|
||||
)
|
||||
|
||||
return self._vscode_url
|
||||
@@ -21,21 +21,31 @@ from openhands.events.observation.commands import (
|
||||
CmdOutputObservation,
|
||||
)
|
||||
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
|
||||
from openhands.runtime.utils.windows_exceptions import DotNetMissingError
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
|
||||
pythonnet.load('coreclr')
|
||||
logger.info("Successfully called pythonnet.load('coreclr')")
|
||||
|
||||
# Now that pythonnet is initialized, import clr and System
|
||||
try:
|
||||
import clr
|
||||
pythonnet.load('coreclr')
|
||||
logger.info("Successfully called pythonnet.load('coreclr')")
|
||||
|
||||
logger.debug(f'Imported clr module from: {clr.__file__}')
|
||||
# Load System assembly *after* pythonnet is initialized
|
||||
clr.AddReference('System')
|
||||
import System
|
||||
except Exception as clr_sys_ex:
|
||||
raise RuntimeError(f'FATAL: Failed to import clr or System. Error: {clr_sys_ex}')
|
||||
# Now that pythonnet is initialized, import clr and System
|
||||
try:
|
||||
import clr
|
||||
|
||||
logger.debug(f'Imported clr module from: {clr.__file__}')
|
||||
# Load System assembly *after* pythonnet is initialized
|
||||
clr.AddReference('System')
|
||||
import System
|
||||
except Exception as clr_sys_ex:
|
||||
error_msg = 'Failed to import .NET components.'
|
||||
details = str(clr_sys_ex)
|
||||
logger.error(f'{error_msg} Details: {details}')
|
||||
raise DotNetMissingError(error_msg, details)
|
||||
except Exception as coreclr_ex:
|
||||
error_msg = 'Failed to load CoreCLR.'
|
||||
details = str(coreclr_ex)
|
||||
logger.error(f'{error_msg} Details: {details}')
|
||||
raise DotNetMissingError(error_msg, details)
|
||||
|
||||
# Attempt to load the PowerShell SDK assembly only if clr and System loaded
|
||||
ps_sdk_path = None
|
||||
@@ -78,9 +88,10 @@ try:
|
||||
RunspaceState,
|
||||
)
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
f'FATAL: Failed to load PowerShell SDK components. Error: {e}. Check pythonnet installation and .NET Runtime compatibility. Path searched: {ps_sdk_path}'
|
||||
)
|
||||
error_msg = 'Failed to load PowerShell SDK components.'
|
||||
details = f'{str(e)} (Path searched: {ps_sdk_path})'
|
||||
logger.error(f'{error_msg} Details: {details}')
|
||||
raise DotNetMissingError(error_msg, details)
|
||||
|
||||
|
||||
class WindowsPowershellSession:
|
||||
@@ -115,9 +126,11 @@ class WindowsPowershellSession:
|
||||
|
||||
if PowerShell is None: # Check if SDK loading failed during module import
|
||||
# Logged critical error during import, just raise here to prevent instantiation
|
||||
raise RuntimeError(
|
||||
'PowerShell SDK (System.Management.Automation.dll) could not be loaded. Cannot initialize WindowsPowershellSession.'
|
||||
error_msg = (
|
||||
'PowerShell SDK (System.Management.Automation.dll) could not be loaded.'
|
||||
)
|
||||
logger.error(error_msg)
|
||||
raise DotNetMissingError(error_msg)
|
||||
|
||||
self.work_dir = os.path.abspath(work_dir)
|
||||
self.username = username
|
||||
|
||||
15
openhands/runtime/utils/windows_exceptions.py
Normal file
15
openhands/runtime/utils/windows_exceptions.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
Custom exceptions for Windows-specific runtime issues.
|
||||
"""
|
||||
|
||||
|
||||
class DotNetMissingError(Exception):
|
||||
"""
|
||||
Exception raised when .NET SDK or CoreCLR is missing or cannot be loaded.
|
||||
This is used to provide a cleaner error message to users without a full stack trace.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, details: str | None = None):
|
||||
self.message = message
|
||||
self.details = details
|
||||
super().__init__(message)
|
||||
@@ -107,6 +107,10 @@ class ConversationManager(ABC):
|
||||
async def send_to_event_stream(self, connection_id: str, data: dict):
|
||||
"""Send data to an event stream."""
|
||||
|
||||
@abstractmethod
|
||||
async def send_event_to_conversation(self, sid: str, data: dict):
|
||||
"""Send an event to a conversation."""
|
||||
|
||||
@abstractmethod
|
||||
async def disconnect_from_session(self, connection_id: str):
|
||||
"""Disconnect from a session."""
|
||||
|
||||
@@ -275,6 +275,18 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
# Not supported - clients should connect directly to the nested server!
|
||||
raise ValueError('unsupported_operation')
|
||||
|
||||
async def send_event_to_conversation(self, sid, data):
|
||||
async with httpx.AsyncClient(
|
||||
headers={
|
||||
'X-Session-API-Key': self._get_session_api_key_for_conversation(sid)
|
||||
}
|
||||
) as client:
|
||||
nested_url = self._get_nested_url(sid)
|
||||
response = await client.post(
|
||||
f'{nested_url}/api/conversations/{sid}/events', json=data
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
async def disconnect_from_session(self, connection_id: str):
|
||||
# Not supported - clients should connect directly to the nested server!
|
||||
raise ValueError('unsupported_operation')
|
||||
|
||||
@@ -331,13 +331,13 @@ class StandaloneConversationManager(ConversationManager):
|
||||
sid = self._local_connection_id_to_session_id.get(connection_id)
|
||||
if not sid:
|
||||
raise RuntimeError(f'no_connected_session:{connection_id}')
|
||||
await self.send_event_to_conversation(sid, data)
|
||||
|
||||
async def send_event_to_conversation(self, sid: str, data: dict):
|
||||
session = self._local_agent_loops_by_sid.get(sid)
|
||||
if session:
|
||||
await session.dispatch(data)
|
||||
return
|
||||
|
||||
raise RuntimeError(f'no_connected_session:{connection_id}:{sid}')
|
||||
if not session:
|
||||
raise RuntimeError(f'no_conversation:{sid}')
|
||||
await session.dispatch(data)
|
||||
|
||||
async def disconnect_from_session(self, connection_id: str):
|
||||
sid = self._local_connection_id_to_session_id.pop(connection_id, None)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import asyncio
|
||||
import os
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
@@ -20,66 +19,17 @@ from openhands.events.observation.agent import (
|
||||
AgentStateChangedObservation,
|
||||
)
|
||||
from openhands.events.serialization import event_to_dict
|
||||
from openhands.experiments.experiment_manager import ExperimentManagerImpl
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderToken
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.server.session.conversation_init_data import ConversationInitData
|
||||
from openhands.server.services.conversation_service import (
|
||||
setup_init_convo_settings,
|
||||
)
|
||||
from openhands.server.shared import (
|
||||
SecretsStoreImpl,
|
||||
SettingsStoreImpl,
|
||||
config,
|
||||
conversation_manager,
|
||||
server_config,
|
||||
sio,
|
||||
)
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.storage.conversation.conversation_validator import (
|
||||
create_conversation_validator,
|
||||
)
|
||||
from openhands.storage.data_models.user_secrets import UserSecrets
|
||||
|
||||
|
||||
def create_provider_tokens_object(
|
||||
providers_set: list[ProviderType],
|
||||
) -> PROVIDER_TOKEN_TYPE:
|
||||
provider_information: dict[ProviderType, ProviderToken] = {}
|
||||
|
||||
for provider in providers_set:
|
||||
provider_information[provider] = ProviderToken(token=None, user_id=None)
|
||||
|
||||
return MappingProxyType(provider_information)
|
||||
|
||||
|
||||
async def setup_init_convo_settings(
|
||||
user_id: str | None, conversation_id: str, providers_set: list[ProviderType]
|
||||
) -> ConversationInitData:
|
||||
settings_store = await SettingsStoreImpl.get_instance(config, user_id)
|
||||
settings = await settings_store.load()
|
||||
|
||||
secrets_store = await SecretsStoreImpl.get_instance(config, user_id)
|
||||
user_secrets: UserSecrets | None = await secrets_store.load()
|
||||
|
||||
if not settings:
|
||||
raise ConnectionRefusedError(
|
||||
'Settings not found', {'msg_id': 'CONFIGURATION$SETTINGS_NOT_FOUND'}
|
||||
)
|
||||
|
||||
session_init_args: dict = {}
|
||||
session_init_args = {**settings.__dict__, **session_init_args}
|
||||
|
||||
git_provider_tokens = create_provider_tokens_object(providers_set)
|
||||
if server_config.app_mode != AppMode.SAAS and user_secrets:
|
||||
git_provider_tokens = user_secrets.provider_tokens
|
||||
|
||||
session_init_args['git_provider_tokens'] = git_provider_tokens
|
||||
if user_secrets:
|
||||
session_init_args['custom_secrets'] = user_secrets.custom_secrets
|
||||
|
||||
convo_init_data = ConversationInitData(**session_init_args)
|
||||
# We should recreate the same experiment conditions when restarting a conversation
|
||||
return ExperimentManagerImpl.run_conversation_variant_test(
|
||||
user_id, conversation_id, convo_init_data
|
||||
)
|
||||
|
||||
|
||||
@sio.event
|
||||
@@ -170,6 +120,7 @@ async def connect(connection_id: str, environ: dict) -> None:
|
||||
conversation_init_data = await setup_init_convo_settings(
|
||||
user_id, conversation_id, providers_set
|
||||
)
|
||||
|
||||
agent_loop_info = await conversation_manager.join_conversation(
|
||||
conversation_id,
|
||||
connection_id,
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi.responses import JSONResponse
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.async_event_store_wrapper import AsyncEventStoreWrapper
|
||||
from openhands.events.event_filter import EventFilter
|
||||
from openhands.events.serialization import event_to_dict
|
||||
from openhands.server.data_models.feedback import FeedbackDataModel, store_feedback
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
@@ -41,7 +42,9 @@ async def submit_feedback(
|
||||
# Assuming the storage service is already configured in the backend
|
||||
# and there is a function to handle the storage.
|
||||
body = await request.json()
|
||||
async_store = AsyncEventStoreWrapper(conversation.event_stream, filter_hidden=True)
|
||||
async_store = AsyncEventStoreWrapper(
|
||||
conversation.event_stream, filter=EventFilter(exclude_hidden=True)
|
||||
)
|
||||
trajectory = []
|
||||
async for event in async_store:
|
||||
trajectory.append(event_to_dict(event))
|
||||
|
||||
@@ -38,7 +38,10 @@ from openhands.server.data_models.conversation_info_result_set import (
|
||||
ConversationInfoResultSet,
|
||||
)
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
from openhands.server.services.conversation_service import create_new_conversation
|
||||
from openhands.server.services.conversation_service import (
|
||||
create_new_conversation,
|
||||
setup_init_convo_settings,
|
||||
)
|
||||
from openhands.server.session.conversation import ServerConversation
|
||||
from openhands.server.shared import (
|
||||
ConversationStoreImpl,
|
||||
@@ -95,6 +98,10 @@ class ConversationResponse(BaseModel):
|
||||
conversation_status: ConversationStatus | None = None
|
||||
|
||||
|
||||
class ProvidersSetModel(BaseModel):
|
||||
providers_set: list[ProviderType] | None = None
|
||||
|
||||
|
||||
@app.post('/conversations')
|
||||
async def new_conversation(
|
||||
data: InitSessionRequest,
|
||||
@@ -395,6 +402,7 @@ async def _get_conversation_info(
|
||||
@app.post('/conversations/{conversation_id}/start')
|
||||
async def start_conversation(
|
||||
conversation_id: str,
|
||||
providers_set: ProvidersSetModel,
|
||||
user_id: str = Depends(get_user_id),
|
||||
settings: Settings = Depends(get_user_settings),
|
||||
conversation_store: ConversationStore = Depends(get_conversation_store),
|
||||
@@ -420,10 +428,15 @@ async def start_conversation(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
# Set up conversation init data with provider information
|
||||
conversation_init_data = await setup_init_convo_settings(
|
||||
user_id, conversation_id, providers_set.providers_set or []
|
||||
)
|
||||
|
||||
# Start the agent loop
|
||||
agent_loop_info = await conversation_manager.maybe_start_agent_loop(
|
||||
sid=conversation_id,
|
||||
settings=settings,
|
||||
settings=conversation_init_data,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi.responses import JSONResponse
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.async_event_store_wrapper import AsyncEventStoreWrapper
|
||||
from openhands.events.event_filter import EventFilter
|
||||
from openhands.events.serialization import event_to_trajectory
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
from openhands.server.session.conversation import ServerConversation
|
||||
@@ -30,7 +31,7 @@ async def get_trajectory(
|
||||
"""
|
||||
try:
|
||||
async_store = AsyncEventStoreWrapper(
|
||||
conversation.event_stream, filter_hidden=True
|
||||
conversation.event_stream, filter=EventFilter(exclude_hidden=True)
|
||||
)
|
||||
trajectory = []
|
||||
async for event in async_store:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import uuid
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -7,21 +8,25 @@ from openhands.experiments.experiment_manager import ExperimentManagerImpl
|
||||
from openhands.integrations.provider import (
|
||||
CUSTOM_SECRETS_TYPE_WITH_JSON_SCHEMA,
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
ProviderToken,
|
||||
)
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.server.data_models.agent_loop_info import AgentLoopInfo
|
||||
from openhands.server.session.conversation_init_data import ConversationInitData
|
||||
from openhands.server.shared import (
|
||||
ConversationStoreImpl,
|
||||
SecretsStoreImpl,
|
||||
SettingsStoreImpl,
|
||||
config,
|
||||
conversation_manager,
|
||||
server_config,
|
||||
)
|
||||
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
||||
from openhands.server.types import AppMode, LLMAuthenticationError, MissingSettingsError
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.storage.data_models.user_secrets import UserSecrets
|
||||
from openhands.utils.conversation_summary import get_default_conversation_title
|
||||
|
||||
|
||||
@@ -135,3 +140,62 @@ async def create_new_conversation(
|
||||
)
|
||||
logger.info(f'Finished initializing conversation {agent_loop_info.conversation_id}')
|
||||
return agent_loop_info
|
||||
|
||||
|
||||
def create_provider_tokens_object(
|
||||
providers_set: list[ProviderType],
|
||||
) -> PROVIDER_TOKEN_TYPE:
|
||||
"""Create provider tokens object for the given providers."""
|
||||
provider_information: dict[ProviderType, ProviderToken] = {}
|
||||
|
||||
for provider in providers_set:
|
||||
provider_information[provider] = ProviderToken(token=None, user_id=None)
|
||||
|
||||
return MappingProxyType(provider_information)
|
||||
|
||||
|
||||
async def setup_init_convo_settings(
|
||||
user_id: str | None, conversation_id: str, providers_set: list[ProviderType]
|
||||
) -> ConversationInitData:
|
||||
"""Set up conversation initialization data with provider tokens.
|
||||
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
conversation_id: The conversation ID
|
||||
providers_set: List of provider types to set up tokens for
|
||||
|
||||
Returns:
|
||||
ConversationInitData with provider tokens configured
|
||||
"""
|
||||
settings_store = await SettingsStoreImpl.get_instance(config, user_id)
|
||||
settings = await settings_store.load()
|
||||
|
||||
secrets_store = await SecretsStoreImpl.get_instance(config, user_id)
|
||||
user_secrets: UserSecrets | None = await secrets_store.load()
|
||||
|
||||
if not settings:
|
||||
from socketio.exceptions import ConnectionRefusedError
|
||||
|
||||
raise ConnectionRefusedError(
|
||||
'Settings not found', {'msg_id': 'CONFIGURATION$SETTINGS_NOT_FOUND'}
|
||||
)
|
||||
|
||||
session_init_args: dict = {}
|
||||
session_init_args = {**settings.__dict__, **session_init_args}
|
||||
|
||||
git_provider_tokens = create_provider_tokens_object(providers_set)
|
||||
logger.info(f'Git provider scaffold: {git_provider_tokens}')
|
||||
|
||||
if server_config.app_mode != AppMode.SAAS and user_secrets:
|
||||
git_provider_tokens = user_secrets.provider_tokens
|
||||
|
||||
session_init_args['git_provider_tokens'] = git_provider_tokens
|
||||
if user_secrets:
|
||||
session_init_args['custom_secrets'] = user_secrets.custom_secrets
|
||||
|
||||
convo_init_data = ConversationInitData(**session_init_args)
|
||||
# We should recreate the same experiment conditions when restarting a conversation
|
||||
return ExperimentManagerImpl.run_conversation_variant_test(
|
||||
user_id, conversation_id, convo_init_data
|
||||
)
|
||||
|
||||
@@ -52,13 +52,33 @@ class PromptManager:
|
||||
def __init__(
|
||||
self,
|
||||
prompt_dir: str,
|
||||
system_prompt_filename: str = 'system_prompt.j2',
|
||||
):
|
||||
self.prompt_dir: str = prompt_dir
|
||||
self.system_template: Template = self._load_template('system_prompt')
|
||||
self.system_template: Template = self._load_system_template(
|
||||
system_prompt_filename
|
||||
)
|
||||
self.user_template: Template = self._load_template('user_prompt')
|
||||
self.additional_info_template: Template = self._load_template('additional_info')
|
||||
self.microagent_info_template: Template = self._load_template('microagent_info')
|
||||
|
||||
def _load_system_template(self, system_prompt_filename: str) -> Template:
|
||||
"""Load the system prompt template using the specified filename."""
|
||||
# Remove .j2 extension if present to use with _load_template
|
||||
template_name = system_prompt_filename
|
||||
if template_name.endswith('.j2'):
|
||||
template_name = template_name[:-3]
|
||||
|
||||
try:
|
||||
return self._load_template(template_name)
|
||||
except FileNotFoundError:
|
||||
# Provide a more specific error message for system prompt files
|
||||
template_path = os.path.join(self.prompt_dir, f'{template_name}.j2')
|
||||
raise FileNotFoundError(
|
||||
f'System prompt file "{system_prompt_filename}" not found at {template_path}. '
|
||||
f'Please ensure the file exists in the prompt directory: {self.prompt_dir}'
|
||||
)
|
||||
|
||||
def _load_template(self, template_name: str) -> Template:
|
||||
if self.prompt_dir is None:
|
||||
raise ValueError('Prompt directory is not set')
|
||||
|
||||
487
poetry.lock
generated
487
poetry.lock
generated
File diff suppressed because one or more lines are too long
@@ -6,7 +6,7 @@ requires = [
|
||||
|
||||
[tool.poetry]
|
||||
name = "openhands-ai"
|
||||
version = "0.44.0"
|
||||
version = "0.45.0"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
authors = [ "OpenHands" ]
|
||||
license = "MIT"
|
||||
@@ -20,12 +20,12 @@ packages = [
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.12,<3.14"
|
||||
litellm = "^1.60.0, !=1.64.4, !=1.67.*" # avoid 1.64.4 (known bug) & 1.67.* (known bug #10272)
|
||||
aiohttp = ">=3.9.0,!=3.11.13" # Pin to avoid yanked version 3.11.13
|
||||
google-generativeai = "*" # To use litellm with Gemini Pro API
|
||||
google-api-python-client = "^2.164.0" # For Google Sheets API
|
||||
google-auth-httplib2 = "*" # For Google Sheets authentication
|
||||
google-auth-oauthlib = "*" # For Google Sheets OAuth
|
||||
litellm = "^1.60.0, !=1.64.4, !=1.67.*" # avoid 1.64.4 (known bug) & 1.67.* (known bug #10272)
|
||||
aiohttp = ">=3.9.0,!=3.11.13" # Pin to avoid yanked version 3.11.13
|
||||
google-generativeai = "*" # To use litellm with Gemini Pro API
|
||||
google-api-python-client = "^2.164.0" # For Google Sheets API
|
||||
google-auth-httplib2 = "*" # For Google Sheets authentication
|
||||
google-auth-oauthlib = "*" # For Google Sheets OAuth
|
||||
termcolor = "*"
|
||||
docker = "*"
|
||||
fastapi = "*"
|
||||
@@ -34,9 +34,9 @@ types-toml = "*"
|
||||
uvicorn = "*"
|
||||
numpy = "*"
|
||||
json-repair = "*"
|
||||
browsergym-core = "0.13.3" # integrate browsergym-core as the browsing interface
|
||||
browsergym-core = "0.13.3" # integrate browsergym-core as the browsing interface
|
||||
html2text = "*"
|
||||
e2b = ">=1.0.5,<1.6.0"
|
||||
|
||||
pexpect = "*"
|
||||
jinja2 = "^3.1.3"
|
||||
python-multipart = "*"
|
||||
@@ -52,8 +52,7 @@ whatthepatch = "^1.0.6"
|
||||
protobuf = "^5.0.0,<6.0.0" # Updated to support newer opentelemetry
|
||||
opentelemetry-api = "^1.33.1"
|
||||
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
|
||||
modal = ">=0.66.26,<1.1.0"
|
||||
runloop-api-client = "0.42.0"
|
||||
|
||||
libtmux = ">=0.37,<0.40"
|
||||
pygithub = "^2.5.0"
|
||||
joblib = "*"
|
||||
@@ -80,7 +79,8 @@ bashlex = "^0.18"
|
||||
# TODO: These are integrations that should probably be optional
|
||||
redis = ">=5.2,<7.0"
|
||||
minio = "^7.2.8"
|
||||
daytona = "0.21.1"
|
||||
daytona = { version = "0.21.1", optional = true }
|
||||
|
||||
stripe = ">=11.5,<13.0"
|
||||
google-cloud-aiplatform = "*"
|
||||
anthropic = { extras = [ "vertex" ], version = "*" }
|
||||
@@ -92,8 +92,8 @@ pyyaml = "^6.0.2"
|
||||
optional = true
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ruff = "0.11.13"
|
||||
mypy = "1.16.0"
|
||||
ruff = "0.12.0"
|
||||
mypy = "1.16.1"
|
||||
pre-commit = "4.2.0"
|
||||
build = "*"
|
||||
types-setuptools = "*"
|
||||
@@ -150,6 +150,10 @@ pyarrow = "20.0.0" #
|
||||
datasets = "*"
|
||||
joblib = "*"
|
||||
|
||||
[tool.poetry.extras]
|
||||
daytona = [ "daytona" ]
|
||||
all-runtimes = [ "daytona" ]
|
||||
|
||||
[tool.poetry.scripts]
|
||||
openhands = "openhands.cli.main:main"
|
||||
|
||||
|
||||
@@ -12,11 +12,17 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events import EventStream
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.runtime.impl.cli.cli_runtime import CLIRuntime
|
||||
from openhands.runtime.impl.daytona.daytona_runtime import DaytonaRuntime
|
||||
from openhands.runtime.impl.docker.docker_runtime import DockerRuntime
|
||||
from openhands.runtime.impl.local.local_runtime import LocalRuntime
|
||||
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
|
||||
from openhands.runtime.impl.runloop.runloop_runtime import RunloopRuntime
|
||||
|
||||
# Conditionally import Daytona runtime if available
|
||||
try:
|
||||
from openhands.runtime.impl.daytona.daytona_runtime import DaytonaRuntime
|
||||
|
||||
_DAYTONA_AVAILABLE = True
|
||||
except ImportError:
|
||||
_DAYTONA_AVAILABLE = False
|
||||
from openhands.runtime.plugins import AgentSkillsRequirement, JupyterRequirement
|
||||
from openhands.storage import get_file_store
|
||||
from openhands.utils.async_utils import call_async_from_sync
|
||||
@@ -130,10 +136,13 @@ def get_runtime_classes() -> list[type[Runtime]]:
|
||||
return [LocalRuntime]
|
||||
elif runtime.lower() == 'remote':
|
||||
return [RemoteRuntime]
|
||||
elif runtime.lower() == 'runloop':
|
||||
return [RunloopRuntime]
|
||||
elif runtime.lower() == 'daytona':
|
||||
return [DaytonaRuntime]
|
||||
if _DAYTONA_AVAILABLE:
|
||||
return [DaytonaRuntime]
|
||||
else:
|
||||
raise ValueError(
|
||||
'Daytona runtime not available. Install with: pip install openhands-ai[daytona]'
|
||||
)
|
||||
elif runtime.lower() == 'cli':
|
||||
return [CLIRuntime]
|
||||
else:
|
||||
|
||||
@@ -1682,3 +1682,138 @@ async def test_openrouter_context_window_exceeded_error(
|
||||
)
|
||||
|
||||
await controller.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sambanova_context_window_exceeded_error(
|
||||
mock_agent, test_event_stream, mock_status_callback
|
||||
):
|
||||
"""Test that SambaNova context window exceeded errors are properly detected and handled."""
|
||||
max_iterations = 5
|
||||
error_after = 2
|
||||
|
||||
class StepState:
|
||||
def __init__(self):
|
||||
self.has_errored = False
|
||||
self.index = 0
|
||||
self.views = []
|
||||
|
||||
def step(self, state: State):
|
||||
# Store the view for later inspection
|
||||
self.views.append(state.view)
|
||||
# only throw it once.
|
||||
if self.index < error_after or self.has_errored:
|
||||
self.index += 1
|
||||
return MessageAction(content=f'Test message {self.index}')
|
||||
|
||||
# Create a BadRequestError with the SambaNova context window exceeded message pattern
|
||||
error = BadRequestError(
|
||||
message='litellm.BadRequestError: SambanovaException - The maximum context length of DeepSeek-V3-0324 is 32768. However, answering your request will take 39732 tokens. Please reduce the length of the messages or the specified max_completion_tokens value.',
|
||||
model='sambanova/deepseek-v3-0324',
|
||||
llm_provider='sambanova',
|
||||
)
|
||||
self.has_errored = True
|
||||
raise error
|
||||
|
||||
step_state = StepState()
|
||||
mock_agent.step = step_state.step
|
||||
mock_agent.config = AgentConfig(enable_history_truncation=True)
|
||||
|
||||
controller = AgentController(
|
||||
agent=mock_agent,
|
||||
event_stream=test_event_stream,
|
||||
iteration_delta=max_iterations,
|
||||
sid='test',
|
||||
confirmation_mode=False,
|
||||
headless_mode=True,
|
||||
status_callback=mock_status_callback,
|
||||
)
|
||||
|
||||
# Set the agent state to RUNNING
|
||||
controller.state.agent_state = AgentState.RUNNING
|
||||
|
||||
# Run the controller until it hits the error
|
||||
for _ in range(error_after + 2): # +2 to ensure we go past the error
|
||||
await controller._step()
|
||||
if step_state.has_errored:
|
||||
break
|
||||
|
||||
# Verify that the error was handled as a context window exceeded error
|
||||
# by checking that _handle_long_context_error was called (which adds a CondensationAction)
|
||||
events = list(test_event_stream.get_events())
|
||||
condensation_actions = [e for e in events if isinstance(e, CondensationAction)]
|
||||
|
||||
# There should be at least one CondensationAction if the error was handled correctly
|
||||
assert len(condensation_actions) > 0, (
|
||||
'SambaNova context window exceeded error was not handled correctly'
|
||||
)
|
||||
|
||||
await controller.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sambanova_generic_exception_not_handled_as_context_error(
|
||||
mock_agent, test_event_stream, mock_status_callback
|
||||
):
|
||||
"""Test that generic SambaNova exceptions (without context length pattern) are NOT handled as context window errors."""
|
||||
max_iterations = 5
|
||||
error_after = 2
|
||||
|
||||
class StepState:
|
||||
def __init__(self):
|
||||
self.has_errored = False
|
||||
self.index = 0
|
||||
self.views = []
|
||||
|
||||
def step(self, state: State):
|
||||
# Store the view for later inspection
|
||||
self.views.append(state.view)
|
||||
# only throw it once.
|
||||
if self.index < error_after or self.has_errored:
|
||||
self.index += 1
|
||||
return MessageAction(content=f'Test message {self.index}')
|
||||
|
||||
# Create a BadRequestError with a generic SambaNova error (no context length pattern)
|
||||
error = BadRequestError(
|
||||
message='litellm.BadRequestError: SambanovaException - Some other error occurred',
|
||||
model='sambanova/deepseek-v3-0324',
|
||||
llm_provider='sambanova',
|
||||
)
|
||||
self.has_errored = True
|
||||
raise error
|
||||
|
||||
step_state = StepState()
|
||||
mock_agent.step = step_state.step
|
||||
mock_agent.config = AgentConfig(enable_history_truncation=True)
|
||||
|
||||
controller = AgentController(
|
||||
agent=mock_agent,
|
||||
event_stream=test_event_stream,
|
||||
iteration_delta=max_iterations,
|
||||
sid='test',
|
||||
confirmation_mode=False,
|
||||
headless_mode=True,
|
||||
status_callback=mock_status_callback,
|
||||
)
|
||||
|
||||
# Set the agent state to RUNNING
|
||||
controller.state.agent_state = AgentState.RUNNING
|
||||
|
||||
# Run the controller until it hits the error
|
||||
with pytest.raises(BadRequestError):
|
||||
for _ in range(error_after + 2): # +2 to ensure we go past the error
|
||||
await controller._step()
|
||||
if step_state.has_errored:
|
||||
break
|
||||
|
||||
# Verify that the error was NOT handled as a context window exceeded error
|
||||
# by checking that _handle_long_context_error was NOT called (no CondensationAction should be added)
|
||||
events = list(test_event_stream.get_events())
|
||||
condensation_actions = [e for e in events if isinstance(e, CondensationAction)]
|
||||
|
||||
# There should be NO CondensationAction if the error was correctly NOT handled as context window error
|
||||
assert len(condensation_actions) == 0, (
|
||||
'Generic SambaNova exception was incorrectly handled as context window error'
|
||||
)
|
||||
|
||||
await controller.close()
|
||||
|
||||
@@ -171,16 +171,17 @@ class TestModifyLLMSettingsBasic:
|
||||
session_instance = MagicMock()
|
||||
session_instance.prompt_async = AsyncMock(
|
||||
side_effect=[
|
||||
'openai', # Provider
|
||||
'gpt-4', # Model
|
||||
'new-api-key', # API Key
|
||||
]
|
||||
)
|
||||
mock_session.return_value = session_instance
|
||||
|
||||
# Mock cli_confirm to select the second option (change provider/model) for the first two calls
|
||||
# and then select the first option (save settings) for the last call
|
||||
mock_confirm.side_effect = [1, 1, 0]
|
||||
# Mock cli_confirm to:
|
||||
# 1. Select the first provider (openai) from the list
|
||||
# 2. Select "Select another model" option
|
||||
# 3. Select "Yes, save" option
|
||||
mock_confirm.side_effect = [0, 1, 0]
|
||||
|
||||
# Call the function
|
||||
await modify_llm_settings_basic(app_config, settings_store)
|
||||
@@ -189,8 +190,8 @@ class TestModifyLLMSettingsBasic:
|
||||
app_config.set_llm_config.assert_called_once()
|
||||
args, kwargs = app_config.set_llm_config.call_args
|
||||
# The model name might be different based on the default model in the list
|
||||
# Just check that it starts with 'openai/'
|
||||
assert args[0].model.startswith('openai/')
|
||||
# Just check that it contains 'gpt-4' instead of checking for prefix
|
||||
assert 'gpt-4' in args[0].model
|
||||
assert args[0].api_key.get_secret_value() == 'new-api-key'
|
||||
assert args[0].base_url is None
|
||||
|
||||
@@ -199,8 +200,8 @@ class TestModifyLLMSettingsBasic:
|
||||
args, kwargs = settings_store.store.call_args
|
||||
settings = args[0]
|
||||
# The model name might be different based on the default model in the list
|
||||
# Just check that it starts with openai/
|
||||
assert settings.llm_model.startswith('openai/')
|
||||
# Just check that it contains 'gpt-4' instead of checking for prefix
|
||||
assert 'gpt-4' in settings.llm_model
|
||||
assert settings.llm_api_key.get_secret_value() == 'new-api-key'
|
||||
assert settings.llm_base_url is None
|
||||
|
||||
@@ -249,7 +250,7 @@ class TestModifyLLMSettingsBasic:
|
||||
'openhands.cli.settings.LLMSummarizingCondenserConfig',
|
||||
MockLLMSummarizingCondenserConfig,
|
||||
)
|
||||
async def test_modify_llm_settings_basic_invalid_input(
|
||||
async def test_modify_llm_settings_basic_invalid_provider_input(
|
||||
self,
|
||||
mock_print,
|
||||
mock_confirm,
|
||||
@@ -270,8 +271,7 @@ class TestModifyLLMSettingsBasic:
|
||||
side_effect=[
|
||||
'invalid-provider', # First invalid provider
|
||||
'openai', # Valid provider
|
||||
'invalid-model', # Invalid model
|
||||
'gpt-4', # Valid model
|
||||
'custom-model', # Custom model (now allowed with warning)
|
||||
'new-api-key', # API key
|
||||
]
|
||||
)
|
||||
@@ -284,34 +284,32 @@ class TestModifyLLMSettingsBasic:
|
||||
# Call the function
|
||||
await modify_llm_settings_basic(app_config, settings_store)
|
||||
|
||||
# Verify error messages were shown for invalid inputs
|
||||
assert (
|
||||
mock_print.call_count >= 2
|
||||
) # At least two error messages should be printed
|
||||
# Verify error message was shown for invalid provider and warning for custom model
|
||||
assert mock_print.call_count >= 2 # At least two messages should be printed
|
||||
|
||||
# Check for invalid provider error
|
||||
# Check for invalid provider error and custom model warning
|
||||
provider_error_found = False
|
||||
model_error_found = False
|
||||
model_warning_found = False
|
||||
|
||||
for call in mock_print.call_args_list:
|
||||
args, _ = call
|
||||
if args and isinstance(args[0], HTML):
|
||||
if 'Invalid provider selected' in args[0].value:
|
||||
provider_error_found = True
|
||||
if 'Invalid model selected' in args[0].value:
|
||||
model_error_found = True
|
||||
if 'Warning:' in args[0].value and 'custom-model' in args[0].value:
|
||||
model_warning_found = True
|
||||
|
||||
assert provider_error_found, 'No error message for invalid provider'
|
||||
assert model_error_found, 'No error message for invalid model'
|
||||
assert model_warning_found, 'No warning message for custom model'
|
||||
|
||||
# Verify LLM config was updated with correct values
|
||||
# Verify LLM config was updated with the custom model
|
||||
app_config.set_llm_config.assert_called_once()
|
||||
|
||||
# Verify settings were saved
|
||||
# Verify settings were saved with the custom model
|
||||
settings_store.store.assert_called_once()
|
||||
args, kwargs = settings_store.store.call_args
|
||||
settings = args[0]
|
||||
assert settings.llm_model == 'openai/gpt-4'
|
||||
assert 'custom-model' in settings.llm_model
|
||||
assert settings.llm_api_key.get_secret_value() == 'new-api-key'
|
||||
assert settings.llm_base_url is None
|
||||
|
||||
|
||||
@@ -29,25 +29,6 @@ class TestWarningSuppressionCLI:
|
||||
output = captured_output.getvalue()
|
||||
assert 'Pydantic serializer warnings' not in output
|
||||
|
||||
def test_suppress_httpx_warnings(self):
|
||||
"""Test that httpx deprecation warnings are suppressed."""
|
||||
# Apply suppression
|
||||
suppress_cli_warnings()
|
||||
|
||||
# Capture stderr to check if warnings are printed
|
||||
captured_output = StringIO()
|
||||
with patch('sys.stderr', captured_output):
|
||||
# Trigger httpx deprecation warning
|
||||
warnings.warn(
|
||||
"Use 'content=<...>' to upload raw bytes/text content.\n headers, stream = encode_request(",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# Should be suppressed (no output to stderr)
|
||||
output = captured_output.getvalue()
|
||||
assert 'content=' not in output
|
||||
|
||||
def test_suppress_deprecated_method_warnings(self):
|
||||
"""Test that deprecated method warnings are suppressed."""
|
||||
# Apply suppression
|
||||
@@ -146,5 +127,4 @@ class TestWarningSuppressionCLI:
|
||||
assert any(
|
||||
'Pydantic serializer warnings' in str(msg) for msg in filter_messages
|
||||
)
|
||||
assert any('content=' in str(msg) for msg in filter_messages)
|
||||
assert any('deprecated method' in str(msg) for msg in filter_messages)
|
||||
|
||||
@@ -85,12 +85,31 @@ class TestDisplayFunctions:
|
||||
@patch('openhands.cli.tui.display_command')
|
||||
def test_display_event_cmd_action(self, mock_display_command):
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
# Test that commands awaiting confirmation are displayed
|
||||
cmd_action = CmdRunAction(command='echo test')
|
||||
cmd_action.confirmation_state = ActionConfirmationStatus.AWAITING_CONFIRMATION
|
||||
|
||||
display_event(cmd_action, config)
|
||||
|
||||
mock_display_command.assert_called_once_with(cmd_action)
|
||||
|
||||
@patch('openhands.cli.tui.display_command')
|
||||
@patch('openhands.cli.tui.initialize_streaming_output')
|
||||
def test_display_event_cmd_action_confirmed(
|
||||
self, mock_init_streaming, mock_display_command
|
||||
):
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
# Test that confirmed commands don't display the command but do initialize streaming
|
||||
cmd_action = CmdRunAction(command='echo test')
|
||||
cmd_action.confirmation_state = ActionConfirmationStatus.CONFIRMED
|
||||
|
||||
display_event(cmd_action, config)
|
||||
|
||||
# Command should not be displayed (since it was already shown when awaiting confirmation)
|
||||
mock_display_command.assert_not_called()
|
||||
# But streaming should be initialized
|
||||
mock_init_streaming.assert_called_once()
|
||||
|
||||
@patch('openhands.cli.tui.display_command_output')
|
||||
def test_display_event_cmd_output(self, mock_display_output):
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
|
||||
@@ -361,6 +361,22 @@ class TestModelAndProviderFunctions:
|
||||
assert result['model'] == 'claude-sonnet-4-20250514'
|
||||
assert result['separator'] == '/'
|
||||
|
||||
def test_extract_model_and_provider_mistral_implicit(self):
|
||||
model = 'devstral-small-2505'
|
||||
result = extract_model_and_provider(model)
|
||||
|
||||
assert result['provider'] == 'mistral'
|
||||
assert result['model'] == 'devstral-small-2505'
|
||||
assert result['separator'] == '/'
|
||||
|
||||
def test_extract_model_and_provider_o4_mini(self):
|
||||
model = 'o4-mini'
|
||||
result = extract_model_and_provider(model)
|
||||
|
||||
assert result['provider'] == 'openai'
|
||||
assert result['model'] == 'o4-mini'
|
||||
assert result['separator'] == '/'
|
||||
|
||||
def test_extract_model_and_provider_versioned(self):
|
||||
model = 'deepseek.deepseek-coder-1.3b'
|
||||
result = extract_model_and_provider(model)
|
||||
@@ -382,6 +398,9 @@ class TestModelAndProviderFunctions:
|
||||
'openai/gpt-4o',
|
||||
'anthropic/claude-sonnet-4-20250514',
|
||||
'o3-mini',
|
||||
'o4-mini',
|
||||
'devstral-small-2505',
|
||||
'mistral/devstral-small-2505',
|
||||
'anthropic.claude-3-5', # Should be ignored as it uses dot separator for anthropic
|
||||
'unknown-model',
|
||||
]
|
||||
@@ -390,15 +409,20 @@ class TestModelAndProviderFunctions:
|
||||
|
||||
assert 'openai' in result
|
||||
assert 'anthropic' in result
|
||||
assert 'mistral' in result
|
||||
assert 'other' in result
|
||||
|
||||
assert len(result['openai']['models']) == 2
|
||||
assert len(result['openai']['models']) == 3
|
||||
assert 'gpt-4o' in result['openai']['models']
|
||||
assert 'o3-mini' in result['openai']['models']
|
||||
assert 'o4-mini' in result['openai']['models']
|
||||
|
||||
assert len(result['anthropic']['models']) == 1
|
||||
assert 'claude-sonnet-4-20250514' in result['anthropic']['models']
|
||||
|
||||
assert len(result['mistral']['models']) == 2
|
||||
assert 'devstral-small-2505' in result['mistral']['models']
|
||||
|
||||
assert len(result['other']['models']) == 1
|
||||
assert 'unknown-model' in result['other']['models']
|
||||
|
||||
|
||||
@@ -990,35 +990,19 @@ def test_api_keys_repr_str():
|
||||
app_config = OpenHandsConfig(
|
||||
llms={'llm': llm_config},
|
||||
agents={'agent': agent_config},
|
||||
e2b_api_key='my_e2b_api_key',
|
||||
jwt_secret='my_jwt_secret',
|
||||
modal_api_token_id='my_modal_api_token_id',
|
||||
modal_api_token_secret='my_modal_api_token_secret',
|
||||
runloop_api_key='my_runloop_api_key',
|
||||
search_api_key='my_search_api_key',
|
||||
daytona_api_key='my_daytona_api_key',
|
||||
)
|
||||
assert 'my_e2b_api_key' not in repr(app_config)
|
||||
assert 'my_e2b_api_key' not in str(app_config)
|
||||
assert 'my_jwt_secret' not in repr(app_config)
|
||||
assert 'my_jwt_secret' not in str(app_config)
|
||||
assert 'my_modal_api_token_id' not in repr(app_config)
|
||||
assert 'my_modal_api_token_id' not in str(app_config)
|
||||
assert 'my_modal_api_token_secret' not in repr(app_config)
|
||||
assert 'my_modal_api_token_secret' not in str(app_config)
|
||||
assert 'my_runloop_api_key' not in repr(app_config)
|
||||
assert 'my_runloop_api_key' not in str(app_config)
|
||||
assert 'my_search_api_key' not in repr(app_config)
|
||||
assert 'my_search_api_key' not in str(app_config)
|
||||
assert 'my_daytona_api_key' not in repr(app_config)
|
||||
assert 'my_daytona_api_key' not in str(app_config)
|
||||
|
||||
# Check that no other attrs in OpenHandsConfig have 'key' or 'token' in their name
|
||||
# This will fail when new attrs are added, and attract attention
|
||||
known_key_token_attrs_app = [
|
||||
'e2b_api_key',
|
||||
'modal_api_token_id',
|
||||
'modal_api_token_secret',
|
||||
'runloop_api_key',
|
||||
'daytona_api_key',
|
||||
'search_api_key',
|
||||
'daytona_api_key',
|
||||
]
|
||||
for attr_name in OpenHandsConfig.model_fields.keys():
|
||||
if (
|
||||
@@ -1211,3 +1195,39 @@ def test_agent_config_from_toml_section_with_invalid_base():
|
||||
assert 'CustomAgent' in result
|
||||
assert result['CustomAgent'].enable_browsing is False
|
||||
assert result['CustomAgent'].enable_jupyter is True
|
||||
|
||||
|
||||
def test_agent_config_system_prompt_filename_default():
|
||||
"""Test that AgentConfig defaults to 'system_prompt.j2' for system_prompt_filename."""
|
||||
config = AgentConfig()
|
||||
assert config.system_prompt_filename == 'system_prompt.j2'
|
||||
|
||||
|
||||
def test_agent_config_system_prompt_filename_toml_integration(
|
||||
default_config, temp_toml_file
|
||||
):
|
||||
"""Test that system_prompt_filename is correctly loaded from TOML configuration."""
|
||||
with open(temp_toml_file, 'w', encoding='utf-8') as toml_file:
|
||||
toml_file.write(
|
||||
"""
|
||||
[agent]
|
||||
enable_browsing = true
|
||||
system_prompt_filename = "custom_prompt.j2"
|
||||
|
||||
[agent.CodeReviewAgent]
|
||||
system_prompt_filename = "code_review_prompt.j2"
|
||||
enable_browsing = false
|
||||
"""
|
||||
)
|
||||
|
||||
load_from_toml(default_config, temp_toml_file)
|
||||
|
||||
# Check default agent config
|
||||
default_agent_config = default_config.get_agent_config()
|
||||
assert default_agent_config.system_prompt_filename == 'custom_prompt.j2'
|
||||
assert default_agent_config.enable_browsing is True
|
||||
|
||||
# Check custom agent config
|
||||
custom_agent_config = default_config.get_agent_config('CodeReviewAgent')
|
||||
assert custom_agent_config.system_prompt_filename == 'code_review_prompt.j2'
|
||||
assert custom_agent_config.enable_browsing is False
|
||||
|
||||
267
tests/unit/test_empty_image_url_fix_v2.py
Normal file
267
tests/unit/test_empty_image_url_fix_v2.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""Test for fixing empty image URL issue in multimodal browsing."""
|
||||
|
||||
from openhands.core.config.agent_config import AgentConfig
|
||||
from openhands.core.message import ImageContent
|
||||
from openhands.events.observation.browse import BrowserOutputObservation
|
||||
from openhands.memory.conversation_memory import ConversationMemory
|
||||
from openhands.utils.prompt import PromptManager
|
||||
|
||||
|
||||
def test_empty_image_url_handling():
|
||||
"""Test that empty image URLs are properly filtered out and notification text is added."""
|
||||
|
||||
# Create a browser observation with empty screenshot and set_of_marks
|
||||
browser_obs = BrowserOutputObservation(
|
||||
url='https://example.com',
|
||||
trigger_by_action='browse_interactive',
|
||||
screenshot='', # Empty screenshot
|
||||
set_of_marks='', # Empty set_of_marks
|
||||
content='Some webpage content',
|
||||
)
|
||||
|
||||
# Create conversation memory with vision enabled
|
||||
agent_config = AgentConfig(enable_som_visual_browsing=True)
|
||||
prompt_manager = PromptManager(
|
||||
prompt_dir='openhands/agenthub/codeact_agent/prompts'
|
||||
)
|
||||
conv_memory = ConversationMemory(agent_config, prompt_manager)
|
||||
|
||||
# Process the observation with vision enabled
|
||||
messages = conv_memory._process_observation(
|
||||
obs=browser_obs,
|
||||
tool_call_id_to_message={},
|
||||
max_message_chars=None,
|
||||
vision_is_active=True,
|
||||
enable_som_visual_browsing=True,
|
||||
current_index=0,
|
||||
events=[],
|
||||
)
|
||||
|
||||
# Check that no empty image URLs are included
|
||||
has_image_content = False
|
||||
has_notification_text = False
|
||||
for message in messages:
|
||||
for content in message.content:
|
||||
if isinstance(content, ImageContent):
|
||||
has_image_content = True
|
||||
# All image URLs should be non-empty and valid
|
||||
for url in content.image_urls:
|
||||
assert url != '', 'Empty image URL should be filtered out'
|
||||
assert url is not None, 'None image URL should be filtered out'
|
||||
# Should start with data: prefix for base64 images
|
||||
if url: # Only check if URL is not empty
|
||||
assert url.startswith('data:'), (
|
||||
f'Invalid image URL format: {url}'
|
||||
)
|
||||
elif hasattr(content, 'text'):
|
||||
# Check for notification text about missing visual information
|
||||
if (
|
||||
'No visual information' in content.text
|
||||
or 'has been filtered' in content.text
|
||||
):
|
||||
has_notification_text = True
|
||||
|
||||
# Should not have image content but should have notification text
|
||||
assert not has_image_content, 'Should not have ImageContent for empty images'
|
||||
assert has_notification_text, (
|
||||
'Should have notification text about missing visual information'
|
||||
)
|
||||
|
||||
|
||||
def test_valid_image_url_handling():
|
||||
"""Test that valid image URLs are properly handled."""
|
||||
|
||||
# Create a browser observation with valid base64 image data
|
||||
valid_base64_image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
|
||||
|
||||
browser_obs = BrowserOutputObservation(
|
||||
url='https://example.com',
|
||||
trigger_by_action='browse_interactive',
|
||||
screenshot=valid_base64_image,
|
||||
set_of_marks=valid_base64_image,
|
||||
content='Some webpage content',
|
||||
)
|
||||
|
||||
# Create conversation memory with vision enabled
|
||||
agent_config = AgentConfig(enable_som_visual_browsing=True)
|
||||
prompt_manager = PromptManager(
|
||||
prompt_dir='openhands/agenthub/codeact_agent/prompts'
|
||||
)
|
||||
conv_memory = ConversationMemory(agent_config, prompt_manager)
|
||||
|
||||
# Process the observation with vision enabled
|
||||
messages = conv_memory._process_observation(
|
||||
obs=browser_obs,
|
||||
tool_call_id_to_message={},
|
||||
max_message_chars=None,
|
||||
vision_is_active=True,
|
||||
enable_som_visual_browsing=True,
|
||||
current_index=0,
|
||||
events=[],
|
||||
)
|
||||
|
||||
# Check that valid image URLs are included
|
||||
found_image_content = False
|
||||
for message in messages:
|
||||
for content in message.content:
|
||||
if isinstance(content, ImageContent):
|
||||
found_image_content = True
|
||||
# Should have at least one valid image URL
|
||||
assert len(content.image_urls) > 0, 'Should have at least one image URL'
|
||||
for url in content.image_urls:
|
||||
assert url != '', 'Image URL should not be empty'
|
||||
assert url.startswith('data:image/'), (
|
||||
f'Invalid image URL format: {url}'
|
||||
)
|
||||
|
||||
assert found_image_content, 'Should have found ImageContent with valid URLs'
|
||||
|
||||
|
||||
def test_mixed_image_url_handling():
|
||||
"""Test handling of mixed valid and invalid image URLs."""
|
||||
|
||||
# Create a browser observation with one empty and one valid image
|
||||
valid_base64_image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
|
||||
|
||||
browser_obs = BrowserOutputObservation(
|
||||
url='https://example.com',
|
||||
trigger_by_action='browse_interactive',
|
||||
screenshot='', # Empty screenshot
|
||||
set_of_marks=valid_base64_image, # Valid set_of_marks
|
||||
content='Some webpage content',
|
||||
)
|
||||
|
||||
# Create conversation memory with vision enabled
|
||||
agent_config = AgentConfig(enable_som_visual_browsing=True)
|
||||
prompt_manager = PromptManager(
|
||||
prompt_dir='openhands/agenthub/codeact_agent/prompts'
|
||||
)
|
||||
conv_memory = ConversationMemory(agent_config, prompt_manager)
|
||||
|
||||
# Process the observation with vision enabled
|
||||
messages = conv_memory._process_observation(
|
||||
obs=browser_obs,
|
||||
tool_call_id_to_message={},
|
||||
max_message_chars=None,
|
||||
vision_is_active=True,
|
||||
enable_som_visual_browsing=True,
|
||||
current_index=0,
|
||||
events=[],
|
||||
)
|
||||
|
||||
# Check that only valid image URLs are included
|
||||
found_image_content = False
|
||||
for message in messages:
|
||||
for content in message.content:
|
||||
if isinstance(content, ImageContent):
|
||||
found_image_content = True
|
||||
# Should have exactly one valid image URL (set_of_marks)
|
||||
assert len(content.image_urls) == 1, (
|
||||
f'Should have exactly one image URL, got {len(content.image_urls)}'
|
||||
)
|
||||
url = content.image_urls[0]
|
||||
assert url == valid_base64_image, (
|
||||
f'Should use the valid image URL: {url}'
|
||||
)
|
||||
|
||||
assert found_image_content, 'Should have found ImageContent with valid URL'
|
||||
|
||||
|
||||
def test_ipython_empty_image_url_handling():
|
||||
"""Test that empty image URLs in IPython observations are properly filtered with notification text."""
|
||||
from openhands.events.observation.commands import IPythonRunCellObservation
|
||||
|
||||
# Create an IPython observation with empty image URLs
|
||||
ipython_obs = IPythonRunCellObservation(
|
||||
content='Some output',
|
||||
code='print("hello")',
|
||||
image_urls=['', None, ''], # Empty and None image URLs
|
||||
)
|
||||
|
||||
# Create conversation memory with vision enabled
|
||||
agent_config = AgentConfig(enable_som_visual_browsing=True)
|
||||
prompt_manager = PromptManager(
|
||||
prompt_dir='openhands/agenthub/codeact_agent/prompts'
|
||||
)
|
||||
conv_memory = ConversationMemory(agent_config, prompt_manager)
|
||||
|
||||
# Process the observation with vision enabled
|
||||
messages = conv_memory._process_observation(
|
||||
obs=ipython_obs,
|
||||
tool_call_id_to_message={},
|
||||
max_message_chars=None,
|
||||
vision_is_active=True,
|
||||
enable_som_visual_browsing=True,
|
||||
current_index=0,
|
||||
events=[],
|
||||
)
|
||||
|
||||
# Check that no empty image URLs are included and notification text is added
|
||||
has_image_content = False
|
||||
has_notification_text = False
|
||||
for message in messages:
|
||||
for content in message.content:
|
||||
if isinstance(content, ImageContent):
|
||||
has_image_content = True
|
||||
elif hasattr(content, 'text'):
|
||||
# Check for notification text about filtered images
|
||||
if 'invalid or empty and have been filtered' in content.text:
|
||||
has_notification_text = True
|
||||
|
||||
# Should not have image content but should have notification text
|
||||
assert not has_image_content, 'Should not have ImageContent for empty images'
|
||||
assert has_notification_text, 'Should have notification text about filtered images'
|
||||
|
||||
|
||||
def test_ipython_mixed_image_url_handling():
|
||||
"""Test handling of mixed valid and invalid image URLs in IPython observations."""
|
||||
from openhands.events.observation.commands import IPythonRunCellObservation
|
||||
|
||||
# Create an IPython observation with mixed image URLs
|
||||
valid_base64_image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
|
||||
ipython_obs = IPythonRunCellObservation(
|
||||
content='Some output',
|
||||
code='print("hello")',
|
||||
image_urls=['', valid_base64_image, None], # Mix of empty, valid, and None
|
||||
)
|
||||
|
||||
# Create conversation memory with vision enabled
|
||||
agent_config = AgentConfig(enable_som_visual_browsing=True)
|
||||
prompt_manager = PromptManager(
|
||||
prompt_dir='openhands/agenthub/codeact_agent/prompts'
|
||||
)
|
||||
conv_memory = ConversationMemory(agent_config, prompt_manager)
|
||||
|
||||
# Process the observation with vision enabled
|
||||
messages = conv_memory._process_observation(
|
||||
obs=ipython_obs,
|
||||
tool_call_id_to_message={},
|
||||
max_message_chars=None,
|
||||
vision_is_active=True,
|
||||
enable_som_visual_browsing=True,
|
||||
current_index=0,
|
||||
events=[],
|
||||
)
|
||||
|
||||
# Check that only valid image URLs are included and notification text is added
|
||||
found_image_content = False
|
||||
has_notification_text = False
|
||||
for message in messages:
|
||||
for content in message.content:
|
||||
if isinstance(content, ImageContent):
|
||||
found_image_content = True
|
||||
# Should have exactly one valid image URL
|
||||
assert len(content.image_urls) == 1, (
|
||||
f'Should have exactly one image URL, got {len(content.image_urls)}'
|
||||
)
|
||||
url = content.image_urls[0]
|
||||
assert url == valid_base64_image, (
|
||||
f'Should use the valid image URL: {url}'
|
||||
)
|
||||
elif hasattr(content, 'text'):
|
||||
# Check for notification text about filtered images
|
||||
if 'invalid or empty image(s) were filtered' in content.text:
|
||||
has_notification_text = True
|
||||
|
||||
assert found_image_content, 'Should have found ImageContent with valid URL'
|
||||
assert has_notification_text, 'Should have notification text about filtered images'
|
||||
82
tests/unit/test_image_content_validation.py
Normal file
82
tests/unit/test_image_content_validation.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Test for ImageContent serialization behavior.
|
||||
|
||||
Note: Image URL filtering now happens at the conversation memory level,
|
||||
not at the ImageContent serialization level. These tests verify that
|
||||
ImageContent correctly serializes whatever URLs it's given.
|
||||
"""
|
||||
|
||||
from openhands.core.message import ImageContent
|
||||
|
||||
|
||||
def test_image_content_serializes_all_urls():
|
||||
"""Test that ImageContent serializes all URLs it's given, including empty ones."""
|
||||
|
||||
# Create ImageContent with mixed valid and invalid URLs
|
||||
image_content = ImageContent(
|
||||
image_urls=[
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||
'', # Empty string
|
||||
' ', # Whitespace only
|
||||
'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/wA==',
|
||||
]
|
||||
)
|
||||
|
||||
# Serialize the content
|
||||
serialized = image_content.model_dump()
|
||||
|
||||
# Should serialize all URLs, including empty ones (filtering happens upstream)
|
||||
assert len(serialized) == 4, (
|
||||
f'Expected 4 URLs (including empty), got {len(serialized)}'
|
||||
)
|
||||
|
||||
expected_urls = [
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||
'',
|
||||
' ',
|
||||
'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/wA==',
|
||||
]
|
||||
|
||||
for i, item in enumerate(serialized):
|
||||
assert item['type'] == 'image_url'
|
||||
assert 'image_url' in item
|
||||
assert 'url' in item['image_url']
|
||||
assert item['image_url']['url'] == expected_urls[i]
|
||||
|
||||
|
||||
def test_image_content_serializes_empty_urls():
|
||||
"""Test that ImageContent serializes empty URLs (filtering happens upstream)."""
|
||||
|
||||
# Create ImageContent with only empty URLs
|
||||
image_content = ImageContent(image_urls=['', ' '])
|
||||
|
||||
# Serialize the content
|
||||
serialized = image_content.model_dump()
|
||||
|
||||
# Should serialize all URLs, even empty ones
|
||||
assert len(serialized) == 2, f'Expected 2 URLs, got {serialized}'
|
||||
assert serialized[0]['image_url']['url'] == ''
|
||||
assert serialized[1]['image_url']['url'] == ' '
|
||||
|
||||
|
||||
def test_image_content_all_valid_urls():
|
||||
"""Test that ImageContent preserves all valid URLs."""
|
||||
|
||||
valid_urls = [
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||
'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/wA==',
|
||||
]
|
||||
|
||||
# Create ImageContent with valid URLs
|
||||
image_content = ImageContent(image_urls=valid_urls)
|
||||
|
||||
# Serialize the content
|
||||
serialized = image_content.model_dump()
|
||||
|
||||
# Should preserve all valid URLs
|
||||
assert len(serialized) == len(valid_urls), (
|
||||
f'Expected {len(valid_urls)} URLs, got {len(serialized)}'
|
||||
)
|
||||
|
||||
for i, item in enumerate(serialized):
|
||||
assert item['type'] == 'image_url'
|
||||
assert item['image_url']['url'] == valid_urls[i]
|
||||
@@ -132,7 +132,7 @@ def test_llm_init_with_model_info(mock_get_model_info, default_config):
|
||||
llm = LLM(default_config)
|
||||
llm.init_model_info()
|
||||
assert llm.config.max_input_tokens == 8000
|
||||
assert llm.config.max_output_tokens == 2000
|
||||
assert llm.config.max_output_tokens is None
|
||||
|
||||
|
||||
@patch('openhands.llm.llm.litellm.get_model_info')
|
||||
@@ -141,7 +141,7 @@ def test_llm_init_without_model_info(mock_get_model_info, default_config):
|
||||
llm = LLM(default_config)
|
||||
llm.init_model_info()
|
||||
assert llm.config.max_input_tokens == 4096
|
||||
assert llm.config.max_output_tokens == 4096
|
||||
assert llm.config.max_output_tokens is None
|
||||
|
||||
|
||||
def test_llm_init_with_custom_config():
|
||||
@@ -260,7 +260,7 @@ def test_llm_init_with_openrouter_model(mock_get_model_info, default_config):
|
||||
llm = LLM(default_config)
|
||||
llm.init_model_info()
|
||||
assert llm.config.max_input_tokens == 7000
|
||||
assert llm.config.max_output_tokens == 1500
|
||||
assert llm.config.max_output_tokens is None
|
||||
mock_get_model_info.assert_called_once_with('openrouter:gpt-4o-mini')
|
||||
|
||||
|
||||
|
||||
@@ -84,11 +84,11 @@ def test_llm_config_attributes_masking(test_handler):
|
||||
|
||||
def test_app_config_attributes_masking(test_handler):
|
||||
logger, stream = test_handler
|
||||
app_config = OpenHandsConfig(e2b_api_key='e2b-xyz789')
|
||||
app_config = OpenHandsConfig(search_api_key='search-xyz789')
|
||||
logger.info(f'App Config: {app_config}')
|
||||
log_output = stream.getvalue()
|
||||
assert 'github_token' not in log_output
|
||||
assert 'e2b-xyz789' not in log_output
|
||||
assert 'search-xyz789' not in log_output
|
||||
assert 'ghp_abcdefghijklmnopqrstuvwxyz' not in log_output
|
||||
|
||||
|
||||
|
||||
@@ -269,3 +269,39 @@ def test_prompt_manager_initialization_error():
|
||||
"""Test that PromptManager raises an error if the prompt directory is not set."""
|
||||
with pytest.raises(ValueError, match='Prompt directory is not set'):
|
||||
PromptManager(None)
|
||||
|
||||
|
||||
def test_prompt_manager_custom_system_prompt_filename(prompt_dir):
|
||||
"""Test that PromptManager can use a custom system prompt filename."""
|
||||
# Create a custom system prompt file
|
||||
with open(os.path.join(prompt_dir, 'custom_system.j2'), 'w') as f:
|
||||
f.write('Custom system prompt: {{ custom_var }}')
|
||||
|
||||
# Create default system prompt
|
||||
with open(os.path.join(prompt_dir, 'system_prompt.j2'), 'w') as f:
|
||||
f.write('Default system prompt')
|
||||
|
||||
# Test with custom system prompt filename
|
||||
manager = PromptManager(
|
||||
prompt_dir=prompt_dir, system_prompt_filename='custom_system.j2'
|
||||
)
|
||||
system_msg = manager.get_system_message()
|
||||
assert 'Custom system prompt:' in system_msg
|
||||
|
||||
# Test without custom system prompt filename (should use default)
|
||||
manager_default = PromptManager(prompt_dir=prompt_dir)
|
||||
default_msg = manager_default.get_system_message()
|
||||
assert 'Default system prompt' in default_msg
|
||||
|
||||
# Clean up
|
||||
os.remove(os.path.join(prompt_dir, 'custom_system.j2'))
|
||||
os.remove(os.path.join(prompt_dir, 'system_prompt.j2'))
|
||||
|
||||
|
||||
def test_prompt_manager_custom_system_prompt_filename_not_found(prompt_dir):
|
||||
"""Test that PromptManager raises an error if custom system prompt file is not found."""
|
||||
with pytest.raises(
|
||||
FileNotFoundError,
|
||||
match=r'System prompt file "non_existent\.j2" not found at .*/non_existent\.j2\. Please ensure the file exists in the prompt directory:',
|
||||
):
|
||||
PromptManager(prompt_dir=prompt_dir, system_prompt_filename='non_existent.j2')
|
||||
|
||||
Reference in New Issue
Block a user