mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
18 Commits
remove-dep
...
add-remote
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e136651e46 | ||
|
|
760a14482e | ||
|
|
b5338c69d6 | ||
|
|
c99f031cdb | ||
|
|
bcc28a12fe | ||
|
|
c82b3378a6 | ||
|
|
a6d3db3ce7 | ||
|
|
4cbbfd799c | ||
|
|
0b728c0c79 | ||
|
|
e35c8ee173 | ||
|
|
9a9b143620 | ||
|
|
38578bd5f5 | ||
|
|
7b2c88ae6b | ||
|
|
0cbf3987f8 | ||
|
|
d18edc8b30 | ||
|
|
42eb355a68 | ||
|
|
5fa01ed278 | ||
|
|
1f747232cf |
@@ -118,7 +118,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.34-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.35-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -52,17 +52,17 @@ system requirements and more information.
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.34
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35
|
||||
```
|
||||
|
||||
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
|
||||
|
||||
@@ -391,7 +391,7 @@ type = "noop"
|
||||
#[llm.condenser]
|
||||
#model = "gpt-4o"
|
||||
#temperature = 0.1
|
||||
#max_tokens = 1024
|
||||
#max_input_tokens = 1024
|
||||
|
||||
#################################### Eval ####################################
|
||||
# Configuration for the evaluation, please refer to the specific evaluation
|
||||
|
||||
@@ -11,7 +11,7 @@ services:
|
||||
- BACKEND_HOST=${BACKEND_HOST:-"0.0.0.0"}
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.34-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.35-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -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.34-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.35-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-state for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -52,7 +52,7 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -61,7 +61,7 @@ docker run -it \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -56,6 +56,6 @@ docker run -it \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
|
||||
```
|
||||
|
||||
@@ -13,16 +13,16 @@
|
||||
La façon la plus simple d'exécuter OpenHands est avec Docker.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.34
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35
|
||||
```
|
||||
|
||||
Vous pouvez également exécuter OpenHands en mode [headless scriptable](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), en tant que [CLI interactive](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), ou en utilisant l'[Action GitHub OpenHands](https://docs.all-hands.dev/modules/usage/how-to/github-action).
|
||||
|
||||
@@ -13,7 +13,7 @@ C'est le Runtime par défaut qui est utilisé lorsque vous démarrez OpenHands.
|
||||
|
||||
```
|
||||
docker run # ...
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -34,7 +34,7 @@ Docker で OpenHands を CLI モードで実行するには:
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -44,7 +44,7 @@ docker run -it \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ DockerでOpenHandsをヘッドレスモードで実行するには:
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -42,7 +42,7 @@ docker run -it \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ OpenHandsがリポジトリで動作する際:
|
||||
|
||||
1. リポジトリに`.openhands/microagents/`が存在する場合、そこからリポジトリ固有の指示を読み込みます。
|
||||
2. 会話のキーワードによってトリガーされる一般的なガイドラインを読み込みます。
|
||||
現在の[パブリックMicroagents](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge)を参照してください。
|
||||
現在の[パブリックMicroagents](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents)を参照してください。
|
||||
|
||||
## Microagentのフォーマット
|
||||
|
||||
|
||||
@@ -88,4 +88,4 @@ triggers:
|
||||
- ビルド時間とイメージサイズを最適化
|
||||
```
|
||||
|
||||
より多くの例については、[現在のパブリックマイクロエージェント](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge)をご覧ください。
|
||||
より多くの例については、[現在のパブリックマイクロエージェント](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents)をご覧ください。
|
||||
|
||||
@@ -25,7 +25,7 @@ nikolaik の `SANDBOX_RUNTIME_CONTAINER_IMAGE` は、ランタイムサーバー
|
||||
|
||||
```bash
|
||||
docker run # ...
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-v $WORKSPACE_BASE:/opt/workspace_base \
|
||||
@@ -82,5 +82,5 @@ docker network create openhands-network
|
||||
# 分離されたネットワークで OpenHands を実行
|
||||
docker run # ... \
|
||||
--network openhands-network \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.34
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35
|
||||
```
|
||||
|
||||
@@ -35,7 +35,7 @@ Para executar o OpenHands no modo CLI com Docker:
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -45,7 +45,7 @@ docker run -it \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ Para executar o OpenHands no modo Headless com Docker:
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -43,7 +43,7 @@ docker run -it \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
python -m openhands.core.main -t "escreva um script bash que imprima oi"
|
||||
```
|
||||
|
||||
|
||||
@@ -58,17 +58,17 @@
|
||||
A maneira mais fácil de executar o OpenHands é no Docker.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.34
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35
|
||||
```
|
||||
|
||||
Você encontrará o OpenHands em execução em http://localhost:3000!
|
||||
|
||||
@@ -13,7 +13,7 @@ Quando o OpenHands trabalha com um repositório, ele:
|
||||
|
||||
1. Carrega instruções específicas do repositório de `.openhands/microagents/`, se presentes no repositório.
|
||||
2. Carrega diretrizes gerais acionadas por palavras-chave nas conversas.
|
||||
Veja os [Microagentes Públicos](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge) atuais.
|
||||
Veja os [Microagentes Públicos](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents) atuais.
|
||||
|
||||
## Formato do Microagente
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Microagentes públicos são diretrizes especializadas acionadas por palavras-chave para todos os usuários do OpenHands.
|
||||
Eles são definidos em arquivos markdown no diretório
|
||||
[`microagents/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge).
|
||||
[`microagents/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents).
|
||||
|
||||
Microagentes públicos:
|
||||
- Monitoram comandos recebidos em busca de suas palavras-chave de acionamento.
|
||||
@@ -149,5 +149,5 @@ Lembre-se de:
|
||||
- Otimizar para tempo de build e tamanho da imagem
|
||||
```
|
||||
|
||||
Veja os [microagentes públicos atuais](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge) para
|
||||
Veja os [microagentes públicos atuais](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents) para
|
||||
mais exemplos.
|
||||
|
||||
@@ -13,7 +13,7 @@ Este é o Runtime padrão que é usado quando você inicia o OpenHands. Você po
|
||||
|
||||
```
|
||||
docker run # ...
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -59,7 +59,7 @@ docker run -it \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -57,6 +57,6 @@ docker run -it \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
|
||||
```
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
在 Docker 中运行 OpenHands 是最简单的方式。
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.34
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35
|
||||
```
|
||||
|
||||
你也可以在可脚本化的[无头模式](https://docs.all-hands.dev/modules/usage/how-to/headless-mode)下运行 OpenHands,作为[交互式 CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),或使用 [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action)。
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
```
|
||||
docker run # ...
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -35,7 +35,7 @@ To run OpenHands in CLI mode with Docker:
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -45,7 +45,7 @@ docker run -it \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -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.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -43,7 +43,7 @@ docker run -it \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
|
||||
@@ -58,17 +58,17 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
|
||||
The easiest way to run OpenHands is in Docker.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.34
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35
|
||||
```
|
||||
|
||||
You'll find OpenHands running at http://localhost:3000!
|
||||
|
||||
@@ -46,4 +46,4 @@ Keyword-triggered microagents:
|
||||
- Apply their specialized knowledge and capabilities.
|
||||
- Follow defined guidelines and restrictions.
|
||||
|
||||
[See examples of microagents triggered by keywords in the official OpenHands repository](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge)
|
||||
[See examples of microagents triggered by keywords in the official OpenHands repository](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents)
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { it, describe, expect, vi, beforeAll, afterAll } from "vitest";
|
||||
import { it, describe, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AuthModal } from "#/components/features/waitlist/auth-modal";
|
||||
import * as CaptureConsent from "#/utils/handle-capture-consent";
|
||||
import * as AuthHook from "#/context/auth-context";
|
||||
|
||||
// Mock the useAuthUrl hook
|
||||
vi.mock("#/hooks/use-auth-url", () => ({
|
||||
useAuthUrl: () => "https://gitlab.com/oauth/authorize"
|
||||
}));
|
||||
|
||||
describe("AuthModal", () => {
|
||||
beforeAll(() => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("location", { href: "" });
|
||||
vi.spyOn(AuthHook, "useAuth").mockReturnValue({
|
||||
providersAreSet: false,
|
||||
@@ -16,50 +20,29 @@ describe("AuthModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should render a tos checkbox that is unchecked by default", () => {
|
||||
render(<AuthModal githubAuthUrl={null} appMode="saas" />);
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("should only enable the identity provider buttons if the tos checkbox is checked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AuthModal githubAuthUrl={null} appMode="saas" />);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
it("should render the GitHub and GitLab buttons", () => {
|
||||
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
|
||||
|
||||
const githubButton = screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" });
|
||||
const gitlabButton = screen.getByRole("button", { name: "GITLAB$CONNECT_TO_GITLAB" });
|
||||
|
||||
expect(githubButton).toBeDisabled();
|
||||
expect(gitlabButton).toBeDisabled();
|
||||
|
||||
await user.click(checkbox);
|
||||
|
||||
expect(githubButton).not.toBeDisabled();
|
||||
expect(gitlabButton).not.toBeDisabled();
|
||||
expect(githubButton).toBeInTheDocument();
|
||||
expect(gitlabButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should set user analytics consent to true when the user checks the tos checkbox", async () => {
|
||||
const handleCaptureConsentSpy = vi.spyOn(
|
||||
CaptureConsent,
|
||||
"handleCaptureConsent",
|
||||
);
|
||||
|
||||
it("should redirect to GitHub auth URL when GitHub button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
|
||||
const mockUrl = "https://github.com/login/oauth/authorize";
|
||||
render(<AuthModal githubAuthUrl={mockUrl} appMode="saas" />);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
await user.click(checkbox);
|
||||
const githubButton = screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" });
|
||||
await user.click(githubButton);
|
||||
|
||||
const button = screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" });
|
||||
await user.click(button);
|
||||
|
||||
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(true);
|
||||
expect(window.location.href).toBe(mockUrl);
|
||||
});
|
||||
});
|
||||
|
||||
136
frontend/__tests__/routes/accept-tos.test.tsx
Normal file
136
frontend/__tests__/routes/accept-tos.test.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { it, describe, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import AcceptTOS from "#/routes/accept-tos";
|
||||
import * as CaptureConsent from "#/utils/handle-capture-consent";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { openHands } from "#/api/open-hands-axios";
|
||||
|
||||
// Mock the react-router hooks
|
||||
vi.mock("react-router", () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
useSearchParams: () => [
|
||||
{
|
||||
get: (param: string) => {
|
||||
if (param === "redirect_url") {
|
||||
return "/dashboard";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
// Mock the axios instance
|
||||
vi.mock("#/api/open-hands-axios", () => ({
|
||||
openHands: {
|
||||
post: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the toast handlers
|
||||
vi.mock("#/utils/custom-toast-handlers", () => ({
|
||||
displayErrorToast: vi.fn(),
|
||||
}));
|
||||
|
||||
// Create a wrapper with QueryClientProvider
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe("AcceptTOS", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("location", { href: "" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should render a TOS checkbox that is unchecked by default", () => {
|
||||
render(<AcceptTOS />, { wrapper: createWrapper() });
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
const continueButton = screen.getByRole("button", { name: "TOS$CONTINUE" });
|
||||
|
||||
expect(checkbox).not.toBeChecked();
|
||||
expect(continueButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable the continue button when the TOS checkbox is checked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AcceptTOS />, { wrapper: createWrapper() });
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
const continueButton = screen.getByRole("button", { name: "TOS$CONTINUE" });
|
||||
|
||||
expect(continueButton).toBeDisabled();
|
||||
|
||||
await user.click(checkbox);
|
||||
|
||||
expect(continueButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should set user analytics consent to true when the user accepts TOS", async () => {
|
||||
const handleCaptureConsentSpy = vi.spyOn(
|
||||
CaptureConsent,
|
||||
"handleCaptureConsent",
|
||||
);
|
||||
|
||||
// Mock the API response
|
||||
vi.mocked(openHands.post).mockResolvedValue({
|
||||
data: { redirect_url: "/dashboard" },
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<AcceptTOS />, { wrapper: createWrapper() });
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
await user.click(checkbox);
|
||||
|
||||
const continueButton = screen.getByRole("button", { name: "TOS$CONTINUE" });
|
||||
await user.click(continueButton);
|
||||
|
||||
// Wait for the mutation to complete
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(true);
|
||||
expect(openHands.post).toHaveBeenCalledWith("/api/accept_tos", {
|
||||
redirect_url: "/dashboard",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle external redirect URLs", async () => {
|
||||
// Mock the API response with an external URL
|
||||
const externalUrl = "https://example.com/callback";
|
||||
vi.mocked(openHands.post).mockResolvedValue({
|
||||
data: { redirect_url: externalUrl },
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<AcceptTOS />, { wrapper: createWrapper() });
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
await user.click(checkbox);
|
||||
|
||||
const continueButton = screen.getByRole("button", { name: "TOS$CONTINUE" });
|
||||
await user.click(continueButton);
|
||||
|
||||
// Wait for the mutation to complete
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
expect(window.location.href).toBe(externalUrl);
|
||||
});
|
||||
});
|
||||
@@ -91,6 +91,13 @@ describe("HomeScreen", () => {
|
||||
screen.getByTestId("task-suggestions");
|
||||
});
|
||||
|
||||
it("should have responsive layout for mobile and desktop screens", async () => {
|
||||
renderHomeScreen();
|
||||
|
||||
const mainContainer = screen.getByTestId("home-screen").querySelector("main");
|
||||
expect(mainContainer).toHaveClass("flex", "flex-col", "md:flex-row");
|
||||
});
|
||||
|
||||
it("should filter the suggested tasks based on the selected repository", async () => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
GitService,
|
||||
|
||||
3623
frontend/package-lock.json
generated
3623
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.34.0",
|
||||
"version": "0.35.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroui/react": "2.7.6",
|
||||
"@heroui/react": "2.7.8",
|
||||
"@microlink/react-json-view": "^1.26.1",
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@react-router/node": "^7.5.2",
|
||||
"@react-router/serve": "^7.5.2",
|
||||
"@react-router/node": "^7.5.3",
|
||||
"@react-router/serve": "^7.5.3",
|
||||
"@react-types/shared": "^3.29.0",
|
||||
"@reduxjs/toolkit": "^2.7.0",
|
||||
"@stripe/react-stripe-js": "^3.6.0",
|
||||
"@stripe/stripe-js": "^7.2.0",
|
||||
"@tanstack/react-query": "^5.74.7",
|
||||
"@tanstack/react-query": "^5.74.9",
|
||||
"@vitejs/plugin-react": "^4.4.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
@@ -24,14 +24,14 @@
|
||||
"clsx": "^2.1.1",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.9.2",
|
||||
"i18next": "^25.0.1",
|
||||
"i18next": "^25.0.2",
|
||||
"i18next-browser-languagedetector": "^8.0.5",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.27",
|
||||
"jose": "^6.0.10",
|
||||
"lucide-react": "^0.503.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.236.7",
|
||||
"posthog-js": "^1.237.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -40,7 +40,7 @@
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.5.2",
|
||||
"react-router": "^7.5.3",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"remark-gfm": "^4.0.1",
|
||||
@@ -82,7 +82,7 @@
|
||||
"@babel/types": "^7.27.0",
|
||||
"@mswjs/socket.io-binding": "^0.1.1",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@react-router/dev": "^7.5.2",
|
||||
"@react-router/dev": "^7.5.3",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/eslint-plugin-query": "^5.74.7",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
|
||||
@@ -4,8 +4,6 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import { TOSCheckbox } from "./tos-checkbox";
|
||||
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
|
||||
import GitLabLogo from "#/assets/branding/gitlab-logo.svg?react";
|
||||
@@ -19,7 +17,6 @@ interface AuthModalProps {
|
||||
|
||||
export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isTosAccepted, setIsTosAccepted] = React.useState(false);
|
||||
|
||||
const gitlabAuthUrl = useAuthUrl({
|
||||
appMode: appMode || null,
|
||||
@@ -28,14 +25,14 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
|
||||
|
||||
const handleGitHubAuth = () => {
|
||||
if (githubAuthUrl) {
|
||||
handleCaptureConsent(true);
|
||||
// Always start the OIDC flow, let the backend handle TOS check
|
||||
window.location.href = githubAuthUrl;
|
||||
}
|
||||
};
|
||||
|
||||
const handleGitLabAuth = () => {
|
||||
if (gitlabAuthUrl) {
|
||||
handleCaptureConsent(true);
|
||||
// Always start the OIDC flow, let the backend handle TOS check
|
||||
window.location.href = gitlabAuthUrl;
|
||||
}
|
||||
};
|
||||
@@ -50,11 +47,8 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<TOSCheckbox onChange={() => setIsTosAccepted((prev) => !prev)} />
|
||||
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<BrandButton
|
||||
isDisabled={!isTosAccepted}
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={handleGitHubAuth}
|
||||
@@ -65,7 +59,6 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
|
||||
</BrandButton>
|
||||
|
||||
<BrandButton
|
||||
isDisabled={!isTosAccepted}
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={handleGitLabAuth}
|
||||
|
||||
@@ -13,21 +13,35 @@ import posthog from "posthog-js";
|
||||
import "./i18n";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import store from "./store";
|
||||
import { useConfig } from "./hooks/query/use-config";
|
||||
import { AuthProvider } from "./context/auth-context";
|
||||
import { queryClientConfig } from "./query-client-config";
|
||||
import OpenHands from "./api/open-hands";
|
||||
import { displayErrorToast } from "./utils/custom-toast-handlers";
|
||||
|
||||
function PosthogInit() {
|
||||
const { data: config } = useConfig();
|
||||
const [posthogClientKey, setPosthogClientKey] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (config?.POSTHOG_CLIENT_KEY) {
|
||||
posthog.init(config.POSTHOG_CLIENT_KEY, {
|
||||
(async () => {
|
||||
try {
|
||||
const config = await OpenHands.getConfig();
|
||||
setPosthogClientKey(config.POSTHOG_CLIENT_KEY);
|
||||
} catch (error) {
|
||||
displayErrorToast("Error fetching PostHog client key");
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (posthogClientKey) {
|
||||
posthog.init(posthogClientKey, {
|
||||
api_host: "https://us.i.posthog.com",
|
||||
person_profiles: "identified_only",
|
||||
});
|
||||
}
|
||||
}, [config]);
|
||||
}, [posthogClientKey]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useConfig } from "./use-config";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
|
||||
|
||||
export const useBalance = () => {
|
||||
const { data: config } = useConfig();
|
||||
const isOnTosPage = useIsOnTosPage();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["user", "balance"],
|
||||
queryFn: OpenHands.getBalance,
|
||||
enabled:
|
||||
config?.APP_MODE === "saas" && config?.FEATURE_FLAGS.ENABLE_BILLING,
|
||||
!isOnTosPage &&
|
||||
config?.APP_MODE === "saas" &&
|
||||
config?.FEATURE_FLAGS.ENABLE_BILLING,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
|
||||
|
||||
export const useConfig = () =>
|
||||
useQuery({
|
||||
export const useConfig = () => {
|
||||
const isOnTosPage = useIsOnTosPage();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["config"],
|
||||
queryFn: OpenHands.getConfig,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes,
|
||||
enabled: !isOnTosPage,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3,17 +3,19 @@ import React from "react";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useConfig } from "./use-config";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
|
||||
|
||||
export const useIsAuthed = () => {
|
||||
const { providersAreSet } = useAuth();
|
||||
const { data: config } = useConfig();
|
||||
const isOnTosPage = useIsOnTosPage();
|
||||
|
||||
const appMode = React.useMemo(() => config?.APP_MODE, [config]);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["user", "authenticated", providersAreSet, appMode],
|
||||
queryFn: () => OpenHands.authenticate(appMode!),
|
||||
enabled: !!appMode,
|
||||
enabled: !!appMode && !isOnTosPage,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
retry: false,
|
||||
|
||||
@@ -4,6 +4,7 @@ import posthog from "posthog-js";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
|
||||
import { Settings } from "#/types/settings";
|
||||
|
||||
const getSettingsQueryFn = async (): Promise<Settings> => {
|
||||
@@ -31,6 +32,8 @@ export const useSettings = () => {
|
||||
const { setProviderTokensSet, providerTokensSet, setProvidersAreSet } =
|
||||
useAuth();
|
||||
|
||||
const isOnTosPage = useIsOnTosPage();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["settings", providerTokensSet],
|
||||
queryFn: getSettingsQueryFn,
|
||||
@@ -40,6 +43,7 @@ export const useSettings = () => {
|
||||
retry: (_, error) => error.status !== 404,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
enabled: !isOnTosPage,
|
||||
meta: {
|
||||
disableToast: true,
|
||||
},
|
||||
|
||||
11
frontend/src/hooks/use-is-on-tos-page.ts
Normal file
11
frontend/src/hooks/use-is-on-tos-page.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useLocation } from "react-router";
|
||||
|
||||
/**
|
||||
* Hook to check if the current page is the Terms of Service acceptance page.
|
||||
*
|
||||
* @returns {boolean} True if the current page is the TOS acceptance page, false otherwise.
|
||||
*/
|
||||
export const useIsOnTosPage = (): boolean => {
|
||||
const { pathname } = useLocation();
|
||||
return pathname === "/accept-tos";
|
||||
};
|
||||
@@ -469,4 +469,8 @@ export enum I18nKey {
|
||||
SYSTEM_MESSAGE_MODAL$TOOLS_TAB = "SYSTEM_MESSAGE_MODAL$TOOLS_TAB",
|
||||
SYSTEM_MESSAGE_MODAL$PARAMETERS = "SYSTEM_MESSAGE_MODAL$PARAMETERS",
|
||||
SYSTEM_MESSAGE_MODAL$NO_TOOLS = "SYSTEM_MESSAGE_MODAL$NO_TOOLS",
|
||||
TOS$ACCEPT_TERMS_OF_SERVICE = "TOS$ACCEPT_TERMS_OF_SERVICE",
|
||||
TOS$ACCEPT_TERMS_DESCRIPTION = "TOS$ACCEPT_TERMS_DESCRIPTION",
|
||||
TOS$CONTINUE = "TOS$CONTINUE",
|
||||
TOS$ERROR_ACCEPTING = "TOS$ERROR_ACCEPTING",
|
||||
}
|
||||
|
||||
@@ -6559,6 +6559,21 @@
|
||||
"tr": "belgelendirme",
|
||||
"de": "Dokumentation"
|
||||
},
|
||||
"AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED": {
|
||||
"en": "The action has not been executed. This may have occurred because the user pressed the stop button, or because the runtime system crashed and restarted due to resource constraints. Any previously established system state, dependencies, or environment variables may have been lost.",
|
||||
"ja": "アクションは実行されていません。これはユーザーが停止ボタンを押したか、リソース制約によりランタイムシステムがクラッシュして再起動したことが原因かもしれません。以前に確立されたシステム状態、依存関係、または環境変数は失われている可能性があります。",
|
||||
"zh-CN": "该操作尚未执行。这可能是因为用户按下了停止按钮,或者因为运行时系统由于资源限制而崩溃并重新启动。任何先前建立的系统状态、依赖项或环境变量可能已丢失。",
|
||||
"zh-TW": "該操作尚未執行。這可能是因為用戶按下了停止按鈕,或者因為運行時系統由於資源限制而崩潰並重新啟動。任何先前建立的系統狀態、依賴項或環境變數可能已丟失。",
|
||||
"ko-KR": "작업이 실행되지 않았습니다. 이는 사용자가 중지 버튼을 눌렀거나 리소스 제약으로 인해 런타임 시스템이 충돌하고 재시작되었기 때문일 수 있습니다. 이전에 설정된 시스템 상태, 종속성 또는 환경 변수가 손실되었을 수 있습니다.",
|
||||
"no": "Handlingen har ikke blitt utført. Dette kan ha skjedd fordi brukeren trykket på stoppknappen, eller fordi kjøretidssystemet krasjet og startet på nytt på grunn av ressursbegrensninger. Enhver tidligere etablert systemtilstand, avhengigheter eller miljøvariabler kan ha gått tapt.",
|
||||
"it": "L'azione non è stata eseguita. Ciò potrebbe essere accaduto perché l'utente ha premuto il pulsante di arresto, o perché il sistema di runtime si è arrestato in modo anomalo e riavviato a causa di vincoli di risorse. Qualsiasi stato di sistema, dipendenza o variabile d'ambiente precedentemente stabilito potrebbe essere andato perso.",
|
||||
"pt": "A ação não foi executada. Isso pode ter ocorrido porque o usuário pressionou o botão de parar, ou porque o sistema de tempo de execução travou e reiniciou devido a restrições de recursos. Qualquer estado do sistema, dependências ou variáveis de ambiente estabelecidos anteriormente podem ter sido perdidos.",
|
||||
"es": "La acción no se ha ejecutado. Esto puede haber ocurrido porque el usuario presionó el botón de detener, o porque el sistema de tiempo de ejecución se bloqueó y reinició debido a restricciones de recursos. Cualquier estado del sistema, dependencias o variables de entorno establecidos previamente pueden haberse perdido.",
|
||||
"ar": "لم يتم تنفيذ الإجراء. قد يكون هذا حدث لأن المستخدم ضغط على زر التوقف، أو لأن نظام التشغيل تعطل وأعيد تشغيله بسبب قيود الموارد. قد تكون أي حالة نظام أو تبعيات أو متغيرات بيئية تم إنشاؤها مسبقًا قد فُقدت.",
|
||||
"fr": "L'action n'a pas été exécutée. Cela peut s'être produit parce que l'utilisateur a appuyé sur le bouton d'arrêt, ou parce que le système d'exécution s'est planté et a redémarré en raison de contraintes de ressources. Tout état du système, dépendances ou variables d'environnement précédemment établis peuvent avoir été perdus.",
|
||||
"tr": "Eylem yürütülmedi. Bu, kullanıcının durdurma düğmesine basması veya çalışma zamanı sisteminin kaynak kısıtlamaları nedeniyle çökmesi ve yeniden başlaması nedeniyle olmuş olabilir. Daha önce kurulmuş olan herhangi bir sistem durumu, bağımlılıklar veya ortam değişkenleri kaybolmuş olabilir.",
|
||||
"de": "Die Aktion wurde nicht ausgeführt. Dies kann passiert sein, weil der Benutzer die Stopp-Taste gedrückt hat oder weil das Laufzeitsystem aufgrund von Ressourcenbeschränkungen abgestürzt und neu gestartet wurde. Alle zuvor eingerichteten Systemzustände, Abhängigkeiten oder Umgebungsvariablen sind möglicherweise verloren gegangen."
|
||||
},
|
||||
"DIFF_VIEWER$LOADING": {
|
||||
"en": "Loading...",
|
||||
"ja": "読み込み中...",
|
||||
@@ -6754,4 +6769,53 @@
|
||||
"es": "No hay herramientas disponibles para este agente",
|
||||
"tr": "Bu ajan için kullanılabilir araç yok"
|
||||
}
|
||||
,
|
||||
"TOS$ACCEPT_TERMS_OF_SERVICE": {
|
||||
"en": "Accept Terms of Service",
|
||||
"ja": "利用規約に同意する",
|
||||
"zh-CN": "接受服务条款",
|
||||
"zh-TW": "接受服務條款",
|
||||
"ko-KR": "서비스 약관 동의",
|
||||
"fr": "Accepter les conditions d'utilisation",
|
||||
"es": "Aceptar términos de servicio",
|
||||
"de": "Nutzungsbedingungen akzeptieren",
|
||||
"it": "Accetta i termini di servizio",
|
||||
"pt": "Aceitar termos de serviço"
|
||||
},
|
||||
"TOS$ACCEPT_TERMS_DESCRIPTION": {
|
||||
"en": "Please review and accept our terms of service before continuing",
|
||||
"ja": "続行する前に利用規約を確認して同意してください",
|
||||
"zh-CN": "请在继续之前查看并接受我们的服务条款",
|
||||
"zh-TW": "請在繼續之前查看並接受我們的服務條款",
|
||||
"ko-KR": "계속하기 전에 서비스 약관을 검토하고 동의해 주세요",
|
||||
"fr": "Veuillez examiner et accepter nos conditions d'utilisation avant de continuer",
|
||||
"es": "Por favor, revise y acepte nuestros términos de servicio antes de continuar",
|
||||
"de": "Bitte überprüfen und akzeptieren Sie unsere Nutzungsbedingungen, bevor Sie fortfahren",
|
||||
"it": "Si prega di rivedere e accettare i nostri termini di servizio prima di continuare",
|
||||
"pt": "Por favor, revise e aceite nossos termos de serviço antes de continuar"
|
||||
},
|
||||
"TOS$CONTINUE": {
|
||||
"en": "Continue",
|
||||
"ja": "続行",
|
||||
"zh-CN": "继续",
|
||||
"zh-TW": "繼續",
|
||||
"ko-KR": "계속",
|
||||
"fr": "Continuer",
|
||||
"es": "Continuar",
|
||||
"de": "Fortfahren",
|
||||
"it": "Continua",
|
||||
"pt": "Continuar"
|
||||
},
|
||||
"TOS$ERROR_ACCEPTING": {
|
||||
"en": "Error accepting Terms of Service",
|
||||
"ja": "利用規約の承諾中にエラーが発生しました",
|
||||
"zh-CN": "接受服务条款时出错",
|
||||
"zh-TW": "接受服務條款時出錯",
|
||||
"ko-KR": "서비스 약관 수락 중 오류 발생",
|
||||
"fr": "Erreur lors de l'acceptation des conditions d'utilisation",
|
||||
"es": "Error al aceptar los Términos de Servicio",
|
||||
"de": "Fehler beim Akzeptieren der Nutzungsbedingungen",
|
||||
"it": "Errore nell'accettazione dei Termini di Servizio",
|
||||
"pt": "Erro ao aceitar os Termos de Serviço"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
export default [
|
||||
layout("routes/root-layout.tsx", [
|
||||
index("routes/home.tsx"),
|
||||
route("accept-tos", "routes/accept-tos.tsx"),
|
||||
route("settings", "routes/settings.tsx", [
|
||||
index("routes/llm-settings.tsx"),
|
||||
route("git", "routes/git-settings.tsx"),
|
||||
|
||||
84
frontend/src/routes/accept-tos.tsx
Normal file
84
frontend/src/routes/accept-tos.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
|
||||
import { TOSCheckbox } from "#/components/features/waitlist/tos-checkbox";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
|
||||
import { openHands } from "#/api/open-hands-axios";
|
||||
|
||||
export default function AcceptTOS() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [isTosAccepted, setIsTosAccepted] = React.useState(false);
|
||||
|
||||
// Get the redirect URL from the query parameters
|
||||
const redirectUrl = searchParams.get("redirect_url") || "/";
|
||||
|
||||
// Use mutation for accepting TOS
|
||||
const { mutate: acceptTOS, isPending: isSubmitting } = useMutation({
|
||||
mutationFn: async () => {
|
||||
// Set consent for analytics
|
||||
handleCaptureConsent(true);
|
||||
|
||||
// Call the API to record TOS acceptance in the database
|
||||
return openHands.post("/api/accept_tos", {
|
||||
redirect_url: redirectUrl,
|
||||
});
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
// Get the redirect URL from the response
|
||||
const finalRedirectUrl = response.data.redirect_url || redirectUrl;
|
||||
|
||||
// Check if the redirect URL is an external URL (starts with http or https)
|
||||
if (
|
||||
finalRedirectUrl.startsWith("http://") ||
|
||||
finalRedirectUrl.startsWith("https://")
|
||||
) {
|
||||
// For external URLs, redirect using window.location
|
||||
window.location.href = finalRedirectUrl;
|
||||
} else {
|
||||
// For internal routes, use navigate
|
||||
navigate(finalRedirectUrl);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleAcceptTOS = () => {
|
||||
if (isTosAccepted && !isSubmitting) {
|
||||
acceptTOS();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<div className="border border-tertiary p-8 rounded-lg max-w-md w-full flex flex-col gap-6 items-center bg-base-secondary">
|
||||
<AllHandsLogo width={68} height={46} />
|
||||
|
||||
<div className="flex flex-col gap-2 w-full items-center text-center">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{t(I18nKey.TOS$ACCEPT_TERMS_OF_SERVICE)}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t(I18nKey.TOS$ACCEPT_TERMS_DESCRIPTION)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TOSCheckbox onChange={() => setIsTosAccepted((prev) => !prev)} />
|
||||
|
||||
<BrandButton
|
||||
isDisabled={!isTosAccepted || isSubmitting}
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={handleAcceptTOS}
|
||||
className="w-full"
|
||||
>
|
||||
{isSubmitting ? t(I18nKey.HOME$LOADING) : t(I18nKey.TOS$CONTINUE)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ function HomeScreen() {
|
||||
|
||||
<hr className="border-[#717888]" />
|
||||
|
||||
<main className="flex justify-between gap-4">
|
||||
<main className="flex flex-col md:flex-row justify-between gap-4">
|
||||
<RepoConnector
|
||||
onRepoSelection={(title) => setSelectedRepoTitle(title)}
|
||||
/>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useMigrateUserConsent } from "#/hooks/use-migrate-user-consent";
|
||||
import { useBalance } from "#/hooks/query/use-balance";
|
||||
import { SetupPaymentModal } from "#/components/features/payment/setup-payment-modal";
|
||||
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
|
||||
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
@@ -58,6 +59,7 @@ export function ErrorBoundary() {
|
||||
export default function MainApp() {
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const tosPageStatus = useIsOnTosPage();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { data: settings } = useSettings();
|
||||
const { error, isFetching } = useBalance();
|
||||
@@ -71,49 +73,75 @@ export default function MainApp() {
|
||||
isError: authError,
|
||||
} = useIsAuthed();
|
||||
|
||||
// Always call the hook, but we'll only use the result when not on TOS page
|
||||
const gitHubAuthUrl = useGitHubAuthUrl({
|
||||
appMode: config.data?.APP_MODE || null,
|
||||
gitHubClientId: config.data?.GITHUB_CLIENT_ID || null,
|
||||
});
|
||||
|
||||
// When on TOS page, we don't use the GitHub auth URL
|
||||
const effectiveGitHubAuthUrl = tosPageStatus ? null : gitHubAuthUrl;
|
||||
|
||||
const [consentFormIsOpen, setConsentFormIsOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (settings?.LANGUAGE) {
|
||||
// Don't change language when on TOS page
|
||||
if (!tosPageStatus && settings?.LANGUAGE) {
|
||||
i18n.changeLanguage(settings.LANGUAGE);
|
||||
}
|
||||
}, [settings?.LANGUAGE]);
|
||||
}, [settings?.LANGUAGE, tosPageStatus]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const consentFormModalIsOpen =
|
||||
settings?.USER_CONSENTS_TO_ANALYTICS === null;
|
||||
// Don't show consent form when on TOS page
|
||||
if (!tosPageStatus) {
|
||||
const consentFormModalIsOpen =
|
||||
settings?.USER_CONSENTS_TO_ANALYTICS === null;
|
||||
|
||||
setConsentFormIsOpen(consentFormModalIsOpen);
|
||||
}, [settings]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Migrate user consent to the server if it was previously stored in localStorage
|
||||
migrateUserConsent({
|
||||
handleAnalyticsWasPresentInLocalStorage: () => {
|
||||
setConsentFormIsOpen(false);
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Don't allow users to use the app if it 402s
|
||||
if (error?.status === 402 && pathname !== "/") {
|
||||
navigate("/");
|
||||
} else if (!isFetching && searchParams.get("free_credits") === "success") {
|
||||
displaySuccessToast(t(I18nKey.BILLING$YOURE_IN));
|
||||
searchParams.delete("free_credits");
|
||||
navigate("/");
|
||||
setConsentFormIsOpen(consentFormModalIsOpen);
|
||||
}
|
||||
}, [error?.status, pathname, isFetching]);
|
||||
}, [settings, tosPageStatus]);
|
||||
|
||||
const userIsAuthed = !!isAuthed && !authError;
|
||||
React.useEffect(() => {
|
||||
// Don't migrate user consent when on TOS page
|
||||
if (!tosPageStatus) {
|
||||
// Migrate user consent to the server if it was previously stored in localStorage
|
||||
migrateUserConsent({
|
||||
handleAnalyticsWasPresentInLocalStorage: () => {
|
||||
setConsentFormIsOpen(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [tosPageStatus]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Don't do any redirects when on TOS page
|
||||
if (!tosPageStatus) {
|
||||
// Don't allow users to use the app if it 402s
|
||||
if (error?.status === 402 && pathname !== "/") {
|
||||
navigate("/");
|
||||
} else if (
|
||||
!isFetching &&
|
||||
searchParams.get("free_credits") === "success"
|
||||
) {
|
||||
displaySuccessToast(t(I18nKey.BILLING$YOURE_IN));
|
||||
searchParams.delete("free_credits");
|
||||
navigate("/");
|
||||
}
|
||||
}
|
||||
}, [error?.status, pathname, isFetching, tosPageStatus]);
|
||||
|
||||
// When on TOS page, we don't make any API calls, so we need to handle this case
|
||||
const userIsAuthed = tosPageStatus ? false : !!isAuthed && !authError;
|
||||
|
||||
// Only show the auth modal if:
|
||||
// 1. User is not authenticated
|
||||
// 2. We're not currently on the TOS page
|
||||
// 3. We're in SaaS mode
|
||||
const renderAuthModal =
|
||||
!isFetchingAuth && !userIsAuthed && config.data?.APP_MODE === "saas";
|
||||
!isFetchingAuth &&
|
||||
!userIsAuthed &&
|
||||
!tosPageStatus &&
|
||||
config.data?.APP_MODE === "saas";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -131,7 +159,7 @@ export default function MainApp() {
|
||||
|
||||
{renderAuthModal && (
|
||||
<AuthModal
|
||||
githubAuthUrl={gitHubAuthUrl}
|
||||
githubAuthUrl={effectiveGitHubAuthUrl}
|
||||
appMode={config.data?.APP_MODE}
|
||||
/>
|
||||
)}
|
||||
|
||||
20
frontend/src/utils/is-on-tos-page.ts
Normal file
20
frontend/src/utils/is-on-tos-page.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Checks if the current page is the Terms of Service acceptance page.
|
||||
* This function works outside of React Router context by checking window.location directly.
|
||||
*
|
||||
* @param {string} [pathname] - Optional pathname from React Router's useLocation hook
|
||||
* @returns {boolean} True if the current page is the TOS acceptance page, false otherwise.
|
||||
*/
|
||||
export const isOnTosPage = (pathname?: string): boolean => {
|
||||
// If pathname is provided (from React Router), use it
|
||||
if (pathname !== undefined) {
|
||||
return pathname === "/accept-tos";
|
||||
}
|
||||
|
||||
// Otherwise check window.location (works outside React Router context)
|
||||
if (typeof window !== "undefined") {
|
||||
return window.location.pathname === "/accept-tos";
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
@@ -18,6 +18,10 @@ vi.mock("react-i18next", async (importOriginal) => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-is-on-tos-page", () => ({
|
||||
useIsOnTosPage: () => false,
|
||||
}));
|
||||
|
||||
// Mock requests during tests
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "bypass" }));
|
||||
afterEach(() => {
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
---
|
||||
name: add_openhands_repo_instruction
|
||||
version: 1.0.0
|
||||
author: openhands
|
||||
agent: CodeActAgent
|
||||
inputs:
|
||||
- name: REPO_FOLDER_NAME
|
||||
description: "Branch for the agent to work on"
|
||||
required: false
|
||||
---
|
||||
|
||||
Please browse the current repository under /workspace/{{ REPO_FOLDER_NAME }}, look at the documentation and relevant code, and understand the purpose of this repository.
|
||||
|
||||
Specifically, I want you to create a `.openhands/microagents/repo.md` file. This file should contain succinct information that summarizes (1) the purpose of this repository, (2) the general setup of this repo, and (3) a brief description of the structure of this repo.
|
||||
|
||||
Here's an example:
|
||||
```markdown
|
||||
---
|
||||
name: repo
|
||||
type: repo
|
||||
agent: CodeActAgent
|
||||
---
|
||||
|
||||
This repository contains the code for OpenHands, an automated AI software engineer. It has a Python backend
|
||||
(in the `openhands` directory) and React frontend (in the `frontend` directory).
|
||||
|
||||
## General Setup:
|
||||
To set up the entire repo, including frontend and backend, run `make build`.
|
||||
You don't need to do this unless the user asks you to, or if you're trying to run the entire application.
|
||||
|
||||
Before pushing any changes, you should ensure that any lint errors or simple test errors have been fixed.
|
||||
|
||||
* If you've made changes to the backend, you should run `pre-commit run --all-files --config ./dev_config/python/.pre-commit-config.yaml`
|
||||
* If you've made changes to the frontend, you should run `cd frontend && npm run lint:fix && npm run build ; cd ..`
|
||||
|
||||
If either command fails, it may have automatically fixed some issues. You should fix any issues that weren't automatically fixed,
|
||||
then re-run the command to ensure it passes.
|
||||
|
||||
## Repository Structure
|
||||
Backend:
|
||||
- Located in the `openhands` directory
|
||||
- Testing:
|
||||
- All tests are in `tests/unit/test_*.py`
|
||||
- To test new code, run `poetry run pytest tests/unit/test_xxx.py` where `xxx` is the appropriate file for the current functionality
|
||||
- Write all tests with pytest
|
||||
|
||||
Frontend:
|
||||
- Located in the `frontend` directory
|
||||
- Prerequisites: A recent version of NodeJS / NPM
|
||||
- Setup: Run `npm install` in the frontend directory
|
||||
- Testing:
|
||||
- Run tests: `npm run test`
|
||||
- To run specific tests: `npm run test -- -t "TestName"`
|
||||
- Building:
|
||||
- Build for production: `npm run build`
|
||||
- Environment Variables:
|
||||
- Set in `frontend/.env` or as environment variables
|
||||
- Available variables: VITE_BACKEND_HOST, VITE_USE_TLS, VITE_INSECURE_SKIP_VERIFY, VITE_FRONTEND_PORT
|
||||
- Internationalization:
|
||||
- Generate i18n declaration file: `npm run make-i18n`
|
||||
```
|
||||
|
||||
Now, please write a similar markdown for the current repository.
|
||||
Read all the GitHub workflows under .github/ of the repository (if this folder exists) to understand the CI checks (e.g., linter, pre-commit), and include those in the repo.md file.
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
name: address_pr_comments
|
||||
version: 1.0.0
|
||||
author: openhands
|
||||
agent: CodeActAgent
|
||||
inputs:
|
||||
- name: PR_URL
|
||||
description: "URL of the pull request"
|
||||
required: true
|
||||
- name: BRANCH_NAME
|
||||
description: "Branch name corresponds to the pull request"
|
||||
required: true
|
||||
---
|
||||
|
||||
First, check the branch {{ BRANCH_NAME }} and read the diff against the main branch to understand the purpose.
|
||||
|
||||
This branch corresponds to this PR {{ PR_URL }}
|
||||
|
||||
Next, you should use the GitHub API to read the reviews and comments on this PR and address them.
|
||||
32
microagents/agent_memory.md
Normal file
32
microagents/agent_memory.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: agent_memory
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- /remember
|
||||
---
|
||||
|
||||
* Repository memory: Use .openhands/microagents/repo.md under each repository root to store and access important information.
|
||||
- If this file exists, it will be added to your context automatically.
|
||||
- If missing, you should create it unless the user has explicitly asked you to not do so.
|
||||
|
||||
* Store and maintain **general knowledge** that will be helpful for most future tasks:
|
||||
1. Repository structure
|
||||
2. Common commands (build, lint, test, pre-commit, etc.)
|
||||
3. Code style preferences
|
||||
4. Workflows and best practices
|
||||
5. Any other repository-specific knowledge you learn
|
||||
|
||||
* IMPORTANT: ONLY LOG the information that would be helpful for different future tasks, for example, how to configure the settings, how to setup the repository. Do NOT add issue-specific information (e.g., what specific error you have ran into and how you fix it).
|
||||
|
||||
* When adding new information:
|
||||
- ALWAYS ask for user confirmation first by listing the exact items (numbered 1, 2, 3, etc.) you plan to save to repo.md
|
||||
- Only save the items the user approves (they may ask you to save a subset)
|
||||
- Ensure it integrates nicely with existing knowledge in repo.md
|
||||
- Reorganize the content if needed to maintain clarity and organization
|
||||
- Group related information together under appropriate sections or headings
|
||||
- If you've only explored a portion of the codebase, clearly note this limitation in the repository structure documentation
|
||||
- If you don't know the essential commands for working with the repository, such as lint or typecheck, ask the user and suggest adding them to repo.md for future reference (with permission)
|
||||
|
||||
When you receive this message, please review and summarize your recent actions and observations, then present a list of valuable information that should be saved in repo.md to the user.
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
name: get_test_to_pass
|
||||
version: 1.0.0
|
||||
author: openhands
|
||||
agent: CodeActAgent
|
||||
inputs:
|
||||
- name: BRANCH_NAME
|
||||
description: "Branch for the agent to work on"
|
||||
required: true
|
||||
- name: TEST_COMMAND_TO_RUN
|
||||
description: "The test command you want the agent to work on. For example, `pytest tests/unit/test_bash_parsing.py`"
|
||||
required: true
|
||||
- name: FUNCTION_TO_FIX
|
||||
description: "The name of function to fix"
|
||||
required: false
|
||||
- name: FILE_FOR_FUNCTION
|
||||
description: "The path of the file that contains the function"
|
||||
required: false
|
||||
---
|
||||
|
||||
Can you check out branch "{{ BRANCH_NAME }}", and run {{ TEST_COMMAND_TO_RUN }}.
|
||||
|
||||
{%- if FUNCTION_TO_FIX and FILE_FOR_FUNCTION %}
|
||||
Help me fix these tests to pass by fixing the {{ FUNCTION_TO_FIX }} function in file {{ FILE_FOR_FUNCTION }}.
|
||||
{%- endif %}
|
||||
|
||||
PLEASE DO NOT modify the tests by yourselves -- Let me know if you think some of the tests are incorrect.
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
name: update_pr_description
|
||||
version: 1.0.0
|
||||
author: openhands
|
||||
agent: CodeActAgent
|
||||
inputs:
|
||||
- name: PR_URL
|
||||
description: "URL of the pull request"
|
||||
type: string
|
||||
required: true
|
||||
validation:
|
||||
pattern: "^https://github.com/.+/.+/pull/[0-9]+$"
|
||||
- name: BRANCH_NAME
|
||||
description: "Branch name corresponds to the pull request"
|
||||
type: string
|
||||
required: true
|
||||
---
|
||||
|
||||
Please check the branch "{{ BRANCH_NAME }}" and look at the diff against the main branch. This branch belongs to this PR "{{ PR_URL }}".
|
||||
|
||||
Once you understand the purpose of the diff, please use Github API to read the existing PR description, and update it to be more reflective of the changes we've made when necessary.
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
name: update_test_for_new_implementation
|
||||
version: 1.0.0
|
||||
author: openhands
|
||||
agent: CodeActAgent
|
||||
inputs:
|
||||
- name: BRANCH_NAME
|
||||
description: "Branch for the agent to work on"
|
||||
required: true
|
||||
- name: TEST_COMMAND_TO_RUN
|
||||
description: "The test command you want the agent to work on. For example, `pytest tests/unit/test_bash_parsing.py`"
|
||||
required: true
|
||||
---
|
||||
|
||||
Can you check out branch "{{ BRANCH_NAME }}", and run {{ TEST_COMMAND_TO_RUN }}.
|
||||
|
||||
{%- if FUNCTION_TO_FIX and FILE_FOR_FUNCTION %}
|
||||
Help me fix these tests to pass by fixing the {{ FUNCTION_TO_FIX }} function in file {{ FILE_FOR_FUNCTION }}.
|
||||
{%- endif %}
|
||||
|
||||
PLEASE DO NOT modify the tests by yourselves -- Let me know if you think some of the tests are incorrect.
|
||||
@@ -44,8 +44,9 @@ Your primary role is to assist users by executing commands, modifying code, and
|
||||
* For bug fixes: Create tests to verify issues before implementing fixes
|
||||
* For new features: Consider test-driven development when appropriate
|
||||
* If the repository lacks testing infrastructure and implementing tests would require extensive setup, consult with the user before investing time in building testing infrastructure
|
||||
* If the environment is not set up to run tests, consult with the user first before investing time to install all dependencies
|
||||
4. IMPLEMENTATION: Make focused, minimal changes to address the problem
|
||||
5. VERIFICATION: Test your implementation thoroughly, including edge cases
|
||||
5. VERIFICATION: If the environment is set up to run tests, test your implementation thoroughly, including edge cases. If the environment is not set up to run tests, consult with the user first before investing time to run tests.
|
||||
</PROBLEM_SOLVING_WORKFLOW>
|
||||
|
||||
<SECURITY>
|
||||
|
||||
@@ -76,6 +76,8 @@ from openhands.llm.metrics import Metrics, TokenUsage
|
||||
TRAFFIC_CONTROL_REMINDER = (
|
||||
"Please click on resume button if you'd like to continue, or start a new task."
|
||||
)
|
||||
ERROR_ACTION_NOT_EXECUTED_ID = 'AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED'
|
||||
ERROR_ACTION_NOT_EXECUTED = 'The action has not been executed. This may have occurred because the user pressed the stop button, or because the runtime system crashed and restarted due to resource constraints. Any previously established system state, dependencies, or environment variables may have been lost.'
|
||||
|
||||
|
||||
class AgentController:
|
||||
@@ -566,7 +568,10 @@ class AgentController:
|
||||
|
||||
# make a new ErrorObservation with the tool call metadata
|
||||
if not found_observation:
|
||||
obs = ErrorObservation(content='The action has not been executed.')
|
||||
obs = ErrorObservation(
|
||||
content=ERROR_ACTION_NOT_EXECUTED,
|
||||
error_id=ERROR_ACTION_NOT_EXECUTED_ID,
|
||||
)
|
||||
obs.tool_call_metadata = self._pending_action.tool_call_metadata
|
||||
obs._cause = self._pending_action.id # type: ignore[attr-defined]
|
||||
self.event_stream.add_event(obs, EventSource.AGENT)
|
||||
@@ -842,6 +847,8 @@ class AgentController:
|
||||
'contextwindowexceedederror' in error_str
|
||||
or 'prompt is too long' in error_str
|
||||
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 isinstance(e, ContextWindowExceededError)
|
||||
):
|
||||
if self.agent.config.enable_history_truncation:
|
||||
|
||||
@@ -60,6 +60,10 @@ PROVIDER_TOKEN_TYPE_WITH_JSON_SCHEMA = Annotated[
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
WithJsonSchema({'type': 'object', 'additionalProperties': {'type': 'string'}}),
|
||||
]
|
||||
CUSTOM_SECRETS_TYPE_WITH_JSON_SCHEMA = Annotated[
|
||||
CUSTOM_SECRETS_TYPE,
|
||||
WithJsonSchema({'type': 'object', 'additionalProperties': {'type': 'string'}}),
|
||||
]
|
||||
|
||||
|
||||
class SecretStore(BaseModel):
|
||||
@@ -67,8 +71,8 @@ class SecretStore(BaseModel):
|
||||
default_factory=lambda: MappingProxyType({})
|
||||
)
|
||||
|
||||
custom_secrets: CUSTOM_SECRETS_TYPE = Field(
|
||||
default_factory=lambda: MappingProxyType({})
|
||||
custom_secrets: CUSTOM_SECRETS_TYPE_WITH_JSON_SCHEMA = Field(
|
||||
default_factory=lambda: MappingProxyType({}),
|
||||
)
|
||||
|
||||
model_config = {
|
||||
|
||||
@@ -230,8 +230,8 @@ class ConversationMemory:
|
||||
pending_tool_call_action_messages[llm_response.id] = Message(
|
||||
role=getattr(assistant_msg, 'role', 'assistant'),
|
||||
# tool call content SHOULD BE a string
|
||||
content=[TextContent(text=assistant_msg.content or '')]
|
||||
if assistant_msg.content is not None
|
||||
content=[TextContent(text=assistant_msg.content)]
|
||||
if assistant_msg.content and assistant_msg.content.strip()
|
||||
else [],
|
||||
tool_calls=assistant_msg.tool_calls,
|
||||
)
|
||||
|
||||
@@ -98,14 +98,21 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
return self.runtime_url
|
||||
|
||||
async def connect(self):
|
||||
self.log('debug', f'Connecting to remote runtime with session ID: {self.sid}')
|
||||
try:
|
||||
self.log('debug', 'Starting or attaching to runtime...')
|
||||
await call_sync_from_async(self._start_or_attach_to_runtime)
|
||||
except Exception:
|
||||
self.log('debug', 'Runtime started/attached successfully')
|
||||
except Exception as e:
|
||||
self.log('error', f'Runtime failed to start: {str(e)}')
|
||||
self.close()
|
||||
self.log('error', 'Runtime failed to start')
|
||||
raise
|
||||
|
||||
self.log('debug', 'Setting up initial environment...')
|
||||
await call_sync_from_async(self.setup_initial_env)
|
||||
self.log('debug', 'Initial environment setup complete')
|
||||
self._runtime_initialized = True
|
||||
self.log('info', f'Remote runtime connection established. Runtime ID: {self.runtime_id}, URL: {self.runtime_url}')
|
||||
|
||||
def _start_or_attach_to_runtime(self):
|
||||
existing_runtime = self._check_existing_runtime()
|
||||
@@ -145,17 +152,21 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
self.send_status_message(' ')
|
||||
|
||||
def _check_existing_runtime(self) -> bool:
|
||||
self.log('debug', f'Checking for existing runtime with session ID: {self.sid}')
|
||||
try:
|
||||
response = self._send_runtime_api_request(
|
||||
'GET',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/sessions/{self.sid}',
|
||||
)
|
||||
data = response.json()
|
||||
self.log('debug', f'Existing runtime check response: {data}')
|
||||
status = data.get('status')
|
||||
if status == 'running' or status == 'paused':
|
||||
self.log('debug', f'Found existing runtime with status: {status}')
|
||||
self._parse_runtime_response(response)
|
||||
except httpx.HTTPError as e:
|
||||
if e.response.status_code == 404:
|
||||
self.log('debug', f'No existing runtime found for session ID: {self.sid}')
|
||||
return False
|
||||
self.log('debug', f'Error while looking for remote runtime: {e}')
|
||||
raise
|
||||
@@ -167,6 +178,7 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
raise
|
||||
|
||||
if status == 'running':
|
||||
self.log('info', f'Using existing running runtime for session ID: {self.sid}')
|
||||
return True
|
||||
elif status == 'stopped':
|
||||
self.log('debug', 'Found existing remote runtime, but it is stopped')
|
||||
@@ -224,16 +236,20 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
)
|
||||
|
||||
def _start_runtime(self):
|
||||
self.log('debug', 'Starting remote runtime...')
|
||||
# Prepare the request body for the /start endpoint
|
||||
command = get_action_execution_server_startup_command(
|
||||
server_port=self.port,
|
||||
plugins=self.plugins,
|
||||
app_config=self.config,
|
||||
)
|
||||
self.log('debug', f'Action execution server command: {command}')
|
||||
|
||||
environment = {}
|
||||
if self.config.debug or os.environ.get('DEBUG', 'false').lower() == 'true':
|
||||
environment['DEBUG'] = 'true'
|
||||
environment.update(self.config.sandbox.runtime_startup_env_vars)
|
||||
|
||||
start_request = {
|
||||
'image': self.container_image,
|
||||
'command': command,
|
||||
@@ -242,10 +258,16 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
'session_id': self.sid,
|
||||
'resource_factor': self.config.sandbox.remote_runtime_resource_factor,
|
||||
}
|
||||
|
||||
if self.config.sandbox.remote_runtime_class == 'sysbox':
|
||||
start_request['runtime_class'] = 'sysbox-runc'
|
||||
self.log('debug', 'Using sysbox runtime class')
|
||||
else:
|
||||
self.log('debug', 'Using default (gvisor) runtime class')
|
||||
# We ignore other runtime classes for now, because both None and 'gvisor' map to 'gvisor'
|
||||
|
||||
self.log('debug', f'Sending start request with parameters: {start_request}')
|
||||
|
||||
# Start the sandbox using the /start endpoint
|
||||
try:
|
||||
response = self._send_runtime_api_request(
|
||||
@@ -255,8 +277,8 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
)
|
||||
self._parse_runtime_response(response)
|
||||
self.log(
|
||||
'debug',
|
||||
f'Runtime started. URL: {self.runtime_url}',
|
||||
'info',
|
||||
f'Runtime started successfully. Runtime ID: {self.runtime_id}, URL: {self.runtime_url}',
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
self.log('error', f'Unable to start runtime: {str(e)}')
|
||||
@@ -269,15 +291,27 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
3. Poll for the runtime to be ready
|
||||
4. Update env vars
|
||||
"""
|
||||
self.log('debug', f'Resuming paused runtime with ID: {self.runtime_id}')
|
||||
self.send_status_message('STATUS$STARTING_RUNTIME')
|
||||
self._send_runtime_api_request(
|
||||
'POST',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/resume',
|
||||
json={'runtime_id': self.runtime_id},
|
||||
)
|
||||
self._wait_until_alive()
|
||||
self.setup_initial_env()
|
||||
self.log('debug', 'Runtime resumed.')
|
||||
|
||||
try:
|
||||
self.log('debug', 'Sending resume request to runtime API')
|
||||
self._send_runtime_api_request(
|
||||
'POST',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/resume',
|
||||
json={'runtime_id': self.runtime_id},
|
||||
)
|
||||
|
||||
self.log('debug', 'Waiting for resumed runtime to become alive')
|
||||
self._wait_until_alive()
|
||||
|
||||
self.log('debug', 'Setting up initial environment for resumed runtime')
|
||||
self.setup_initial_env()
|
||||
|
||||
self.log('info', f'Runtime with ID {self.runtime_id} resumed successfully')
|
||||
except Exception as e:
|
||||
self.log('error', f'Failed to resume runtime: {str(e)}')
|
||||
raise
|
||||
|
||||
def _parse_runtime_response(self, response: httpx.Response):
|
||||
start_response = response.json()
|
||||
@@ -324,53 +358,62 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
return retry_decorator(self._wait_until_alive_impl)()
|
||||
|
||||
def _wait_until_alive_impl(self):
|
||||
self.log('debug', f'Waiting for runtime to be alive at url: {self.runtime_url}')
|
||||
self.log('debug', f'Checking if runtime is alive at url: {self.runtime_url}')
|
||||
runtime_info_response = self._send_runtime_api_request(
|
||||
'GET',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/runtime/{self.runtime_id}',
|
||||
)
|
||||
runtime_data = runtime_info_response.json()
|
||||
assert 'runtime_id' in runtime_data
|
||||
assert runtime_data['runtime_id'] == self.runtime_id
|
||||
assert 'pod_status' in runtime_data
|
||||
self.log('debug', f'Runtime info response: {runtime_data}')
|
||||
|
||||
assert 'runtime_id' in runtime_data, "Missing runtime_id in response"
|
||||
assert runtime_data['runtime_id'] == self.runtime_id, f"Runtime ID mismatch: {runtime_data['runtime_id']} != {self.runtime_id}"
|
||||
assert 'pod_status' in runtime_data, "Missing pod_status in response"
|
||||
|
||||
pod_status = runtime_data['pod_status'].lower()
|
||||
self.log('debug', f'Pod status: {pod_status}')
|
||||
|
||||
restart_count = runtime_data.get('restart_count', 0)
|
||||
if restart_count != 0:
|
||||
restart_reasons = runtime_data.get('restart_reasons')
|
||||
self.log(
|
||||
'debug', f'Pod restarts: {restart_count}, reasons: {restart_reasons}'
|
||||
'warning', f'Pod has restarted {restart_count} times, reasons: {restart_reasons}'
|
||||
)
|
||||
|
||||
# FIXME: We should fix it at the backend of /start endpoint, make sure
|
||||
# the pod is created before returning the response.
|
||||
# Retry a period of time to give the cluster time to start the pod
|
||||
if pod_status == 'ready':
|
||||
self.log('debug', 'Pod status is ready, checking if action execution server is alive')
|
||||
try:
|
||||
self.check_if_alive()
|
||||
self.log('info', 'Runtime is fully alive and ready to accept commands')
|
||||
return
|
||||
except httpx.HTTPError as e:
|
||||
self.log(
|
||||
'warning',
|
||||
f"Runtime /alive failed, but pod says it's ready: {str(e)}",
|
||||
f"Runtime /alive check failed, but pod says it's ready: {str(e)}",
|
||||
)
|
||||
raise AgentRuntimeNotReadyError(
|
||||
f'Runtime /alive failed to respond with 200: {str(e)}'
|
||||
)
|
||||
return
|
||||
elif (
|
||||
pod_status == 'not found'
|
||||
or pod_status == 'pending'
|
||||
or pod_status == 'running'
|
||||
): # nb: Running is not yet Ready
|
||||
self.log('debug', f'Pod is in transitional state: {pod_status}')
|
||||
raise AgentRuntimeNotReadyError(
|
||||
f'Runtime (ID={self.runtime_id}) is not yet ready. Status: {pod_status}'
|
||||
)
|
||||
elif pod_status in ('failed', 'unknown', 'crashloopbackoff'):
|
||||
if pod_status == 'crashloopbackoff':
|
||||
self.log('error', f'Pod is in CrashLoopBackOff state. Restart count: {restart_count}')
|
||||
raise AgentRuntimeUnavailableError(
|
||||
'Runtime crashed and is being restarted, potentially due to memory usage. Please try again.'
|
||||
)
|
||||
else:
|
||||
self.log('error', f'Pod is in failed state: {pod_status}')
|
||||
raise AgentRuntimeUnavailableError(
|
||||
f'Runtime is unavailable (status: {pod_status}). Please try again.'
|
||||
)
|
||||
@@ -385,11 +428,18 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
raise AgentRuntimeNotReadyError()
|
||||
|
||||
def close(self):
|
||||
self.log('debug', f'Closing remote runtime with ID: {self.runtime_id}')
|
||||
|
||||
if self.attach_to_existing:
|
||||
self.log('debug', 'Runtime was attached to existing instance, not stopping it')
|
||||
super().close()
|
||||
return
|
||||
|
||||
if self.config.sandbox.keep_runtime_alive:
|
||||
self.log('debug', 'keep_runtime_alive is set, not stopping the runtime')
|
||||
|
||||
if self.config.sandbox.pause_closed_runtimes:
|
||||
self.log('debug', 'pause_closed_runtimes is set, pausing the runtime')
|
||||
try:
|
||||
if not self._runtime_closed:
|
||||
self._send_runtime_api_request(
|
||||
@@ -397,12 +447,14 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/pause',
|
||||
json={'runtime_id': self.runtime_id},
|
||||
)
|
||||
self.log('debug', 'Runtime paused.')
|
||||
self.log('info', f'Runtime with ID {self.runtime_id} paused successfully')
|
||||
except Exception as e:
|
||||
self.log('error', f'Unable to pause runtime: {str(e)}')
|
||||
raise e
|
||||
super().close()
|
||||
return
|
||||
|
||||
self.log('debug', 'Stopping the runtime')
|
||||
try:
|
||||
if not self._runtime_closed:
|
||||
self._send_runtime_api_request(
|
||||
@@ -410,7 +462,7 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/stop',
|
||||
json={'runtime_id': self.runtime_id},
|
||||
)
|
||||
self.log('debug', 'Runtime stopped.')
|
||||
self.log('info', f'Runtime with ID {self.runtime_id} stopped successfully')
|
||||
except Exception as e:
|
||||
self.log('error', f'Unable to stop runtime: {str(e)}')
|
||||
raise e
|
||||
@@ -418,15 +470,21 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
super().close()
|
||||
|
||||
def _send_runtime_api_request(self, method, url, **kwargs):
|
||||
self.log('debug', f'Sending {method} request to {url} with kwargs: {kwargs}')
|
||||
try:
|
||||
kwargs['timeout'] = self.config.sandbox.remote_runtime_api_timeout
|
||||
return send_request(self.session, method, url, **kwargs)
|
||||
response = send_request(self.session, method, url, **kwargs)
|
||||
self.log('debug', f'Received response from {url}: status={response.status_code}')
|
||||
return response
|
||||
except httpx.TimeoutException:
|
||||
self.log(
|
||||
'error',
|
||||
f'No response received within the timeout period for url: {url}',
|
||||
)
|
||||
raise
|
||||
except httpx.HTTPError as e:
|
||||
self.log('error', f'HTTP error when calling {url}: {str(e)}')
|
||||
raise
|
||||
|
||||
def _send_action_server_request(self, method, url, **kwargs):
|
||||
if not self.config.sandbox.remote_runtime_enable_retries:
|
||||
|
||||
@@ -45,7 +45,14 @@ def create_provider_tokens_object(
|
||||
async def connect(connection_id: str, environ):
|
||||
logger.info(f'sio:connect: {connection_id}')
|
||||
query_params = parse_qs(environ.get('QUERY_STRING', ''))
|
||||
latest_event_id = int(query_params.get('latest_event_id', [-1])[0])
|
||||
latest_event_id_str = query_params.get('latest_event_id', [-1])[0]
|
||||
try:
|
||||
latest_event_id = int(latest_event_id_str)
|
||||
except ValueError:
|
||||
logger.debug(
|
||||
f'Invalid latest_event_id value: {latest_event_id_str}, defaulting to -1'
|
||||
)
|
||||
latest_event_id = -1
|
||||
conversation_id = query_params.get('conversation_id', [None])[0]
|
||||
raw_list = query_params.get('providers_set', [])
|
||||
providers_list = []
|
||||
|
||||
58
poetry.lock
generated
58
poetry.lock
generated
@@ -207,14 +207,14 @@ vertex = ["google-auth[requests] (>=2,<3)"]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.8.0"
|
||||
version = "4.9.0"
|
||||
description = "High level compatibility layer for multiple asynchronous event loop implementations"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "evaluation", "runtime", "test"]
|
||||
files = [
|
||||
{file = "anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a"},
|
||||
{file = "anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a"},
|
||||
{file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"},
|
||||
{file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -223,8 +223,8 @@ sniffio = ">=1.1"
|
||||
typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
|
||||
|
||||
[package.extras]
|
||||
doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"]
|
||||
test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""]
|
||||
doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"]
|
||||
test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""]
|
||||
trio = ["trio (>=0.26.1)"]
|
||||
|
||||
[[package]]
|
||||
@@ -496,18 +496,18 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "boto3"
|
||||
version = "1.38.3"
|
||||
version = "1.38.4"
|
||||
description = "The AWS SDK for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "boto3-1.38.3-py3-none-any.whl", hash = "sha256:9218f86e2164e1bddb75d435bbde4fa651aa58687213d7e3e1b50f7eb8868f66"},
|
||||
{file = "boto3-1.38.3.tar.gz", hash = "sha256:655d51abcd68a40a33c52dbaa2ca73fc63c746b894e2ae22ed8ddc1912ddd93f"},
|
||||
{file = "boto3-1.38.4-py3-none-any.whl", hash = "sha256:ab315d38409f5b3262b653a10b0fac786bcff7e51e03dcb99ff38ba16bf85630"},
|
||||
{file = "boto3-1.38.4.tar.gz", hash = "sha256:4990df0087fe7be944ba06c2d1e6512b5a24f821af5a4881f24309e13ae29e68"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
botocore = ">=1.38.3,<1.39.0"
|
||||
botocore = ">=1.38.4,<1.39.0"
|
||||
jmespath = ">=0.7.1,<2.0.0"
|
||||
s3transfer = ">=0.12.0,<0.13.0"
|
||||
|
||||
@@ -516,14 +516,14 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
|
||||
|
||||
[[package]]
|
||||
name = "boto3-stubs"
|
||||
version = "1.38.3"
|
||||
description = "Type annotations for boto3 1.38.3 generated with mypy-boto3-builder 8.10.1"
|
||||
version = "1.38.4"
|
||||
description = "Type annotations for boto3 1.38.4 generated with mypy-boto3-builder 8.10.1"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["evaluation"]
|
||||
files = [
|
||||
{file = "boto3_stubs-1.38.3-py3-none-any.whl", hash = "sha256:93a2c38987dd0ee19a661e8fd9a77fb4b4a30e56f63115701c307bfc55e2695c"},
|
||||
{file = "boto3_stubs-1.38.3.tar.gz", hash = "sha256:e406626de8daf537984678355ad0e32d838865c4ea3d223268964d4e6fb44534"},
|
||||
{file = "boto3_stubs-1.38.4-py3-none-any.whl", hash = "sha256:ae931b5f40ebf70cd7ddc36065d058d406c6aaecc766bd843be855487fc6345e"},
|
||||
{file = "boto3_stubs-1.38.4.tar.gz", hash = "sha256:eff7f741a9b9df5b0af1e7018878a56028f05bb3d9c57cd50cc4f60bdae7960b"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -579,7 +579,7 @@ bedrock-data-automation-runtime = ["mypy-boto3-bedrock-data-automation-runtime (
|
||||
bedrock-runtime = ["mypy-boto3-bedrock-runtime (>=1.38.0,<1.39.0)"]
|
||||
billing = ["mypy-boto3-billing (>=1.38.0,<1.39.0)"]
|
||||
billingconductor = ["mypy-boto3-billingconductor (>=1.38.0,<1.39.0)"]
|
||||
boto3 = ["boto3 (==1.38.3)"]
|
||||
boto3 = ["boto3 (==1.38.4)"]
|
||||
braket = ["mypy-boto3-braket (>=1.38.0,<1.39.0)"]
|
||||
budgets = ["mypy-boto3-budgets (>=1.38.0,<1.39.0)"]
|
||||
ce = ["mypy-boto3-ce (>=1.38.0,<1.39.0)"]
|
||||
@@ -943,14 +943,14 @@ xray = ["mypy-boto3-xray (>=1.38.0,<1.39.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "botocore"
|
||||
version = "1.38.3"
|
||||
version = "1.38.4"
|
||||
description = "Low-level, data-driven core of boto 3."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "botocore-1.38.3-py3-none-any.whl", hash = "sha256:96f823240fe3704b99c17d1d1b2fd2d1679cf56d2a55b095f00255b76087cbf0"},
|
||||
{file = "botocore-1.38.3.tar.gz", hash = "sha256:790f8f966201781f5fcf486d48b4492e9f734446bbf9d19ef8159d08be854243"},
|
||||
{file = "botocore-1.38.4-py3-none-any.whl", hash = "sha256:6206cf07be1069efaead2ddc858eb752dafef276ebbe88ac32b5c427b1d90570"},
|
||||
{file = "botocore-1.38.4.tar.gz", hash = "sha256:6143546bb56f1da4dff8d285cb6a3b8b0b6442451fe5937cb48a62bf7275386f"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -2663,7 +2663,7 @@ grpcio = {version = ">=1.49.1,<2.0dev", optional = true, markers = "python_versi
|
||||
grpcio-status = {version = ">=1.49.1,<2.0.dev0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
|
||||
proto-plus = [
|
||||
{version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""},
|
||||
{version = ">=1.22.3,<2.0.0dev", markers = "python_version < \"3.13\""},
|
||||
{version = ">=1.22.3,<2.0.0dev"},
|
||||
]
|
||||
protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0"
|
||||
requests = ">=2.18.0,<3.0.0.dev0"
|
||||
@@ -2878,7 +2878,7 @@ google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev"
|
||||
grpc-google-iam-v1 = ">=0.14.0,<1.0.0dev"
|
||||
proto-plus = [
|
||||
{version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""},
|
||||
{version = ">=1.22.3,<2.0.0dev"},
|
||||
{version = ">=1.22.3,<2.0.0dev", markers = "python_version < \"3.13\""},
|
||||
]
|
||||
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev"
|
||||
|
||||
@@ -3794,14 +3794,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "json-repair"
|
||||
version = "0.43.0"
|
||||
version = "0.44.0"
|
||||
description = "A package to repair broken json strings"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "json_repair-0.43.0-py3-none-any.whl", hash = "sha256:3f2b66819c9f5e29edd5dd4851223b72d10ed816b6423b3c92e424090c3ffc1d"},
|
||||
{file = "json_repair-0.43.0.tar.gz", hash = "sha256:77cc6eda6f407ff5fe9544f962e42b332cca1e8c9f3f9f9dc660327028e0d651"},
|
||||
{file = "json_repair-0.44.0-py3-none-any.whl", hash = "sha256:4646b59decf8763c20cd218079a12e334202d658d760ddd999f23812f07bb9cd"},
|
||||
{file = "json_repair-0.44.0.tar.gz", hash = "sha256:732469762471e535de8aadcb4ec5a4b47e96c677d036cbc1c78ab396925c6a71"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4882,14 +4882,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "modal"
|
||||
version = "0.74.30"
|
||||
version = "0.74.35"
|
||||
description = "Python client library for Modal"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "evaluation"]
|
||||
files = [
|
||||
{file = "modal-0.74.30-py3-none-any.whl", hash = "sha256:46006cb57309171fe36ee41528a7cc8c0e67c88afd9bf04a9900313c18925aa4"},
|
||||
{file = "modal-0.74.30.tar.gz", hash = "sha256:14bd2ea0ebc9ab1ebce29ea76ddf12047f23599983725c5f82990ae97bea05c7"},
|
||||
{file = "modal-0.74.35-py3-none-any.whl", hash = "sha256:845c3176e5fc2d0856ff1d88a5fc7699552e6542135aedae6cd52598faa55fc0"},
|
||||
{file = "modal-0.74.35.tar.gz", hash = "sha256:bef2a40f18a40514e7502dbe543fc026b0a2542597669cd0bc0fa0db1149600a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -5633,14 +5633,14 @@ voice-helpers = ["numpy (>=2.0.2)", "sounddevice (>=0.5.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "openhands-aci"
|
||||
version = "0.2.10"
|
||||
version = "0.2.11"
|
||||
description = "An Agent-Computer Interface (ACI) designed for software development agents OpenHands."
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_aci-0.2.10-py3-none-any.whl", hash = "sha256:0703eb117e24326d80d990b81a0c71e28a364f56999095b95c146c934e40fc55"},
|
||||
{file = "openhands_aci-0.2.10.tar.gz", hash = "sha256:a5e6bf46cbd9a99c5c592548419a9a3b3091c4e82f0227e8aaf470b18a261cc9"},
|
||||
{file = "openhands_aci-0.2.11-py3-none-any.whl", hash = "sha256:bb6cdd30da8cf57292f7d1d87b2396a31af3f3abc2f5d6f02c22cb648956b41e"},
|
||||
{file = "openhands_aci-0.2.11.tar.gz", hash = "sha256:38ae6abbeedfc0f6018dc5db58ad0ccc38295d649fe27218a0acab689df0e6c7"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -10269,4 +10269,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12"
|
||||
content-hash = "d3f933e9abf6be481ec137e14f8f7ac502afd591a9ba74b315737fd894ca5cfe"
|
||||
content-hash = "e8326a1441d5ce74c017755566e1e0d865551712290c00202d257c931b7dc5bd"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "openhands-ai"
|
||||
version = "0.34.0"
|
||||
version = "0.35.0"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
authors = ["OpenHands"]
|
||||
license = "MIT"
|
||||
@@ -62,7 +62,7 @@ runloop-api-client = "0.32.0"
|
||||
libtmux = ">=0.37,<0.40"
|
||||
pygithub = "^2.5.0"
|
||||
joblib = "*"
|
||||
openhands-aci = "^0.2.10"
|
||||
openhands-aci = "0.2.11"
|
||||
python-socketio = "^5.11.4"
|
||||
redis = "^5.2.0"
|
||||
sse-starlette = "^2.1.3"
|
||||
@@ -76,6 +76,7 @@ mcp = "1.6.0"
|
||||
python-json-logger = "^3.2.1"
|
||||
playwright = "^1.51.0"
|
||||
prompt-toolkit = "^3.0.50"
|
||||
anyio = "4.9.0"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ruff = "0.11.7"
|
||||
|
||||
@@ -220,7 +220,7 @@ def test_str_replace_multi_line_with_tabs(temp_dir, runtime_cls, run_as_openhand
|
||||
obs.content
|
||||
== f"""The file {test_file} has been edited. Here's the result of running `cat -n` on a snippet of {test_file}:
|
||||
1\tdef test():
|
||||
2\t{'\t'.expandtabs()}print("Hello, Universe!")
|
||||
2\t\tprint("Hello, Universe!")
|
||||
Review the changes and make sure they are as expected. Edit the file again if necessary."""
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@ from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from litellm import ContentPolicyViolationError, ContextWindowExceededError
|
||||
from litellm import (
|
||||
BadRequestError,
|
||||
ContentPolicyViolationError,
|
||||
ContextWindowExceededError,
|
||||
)
|
||||
|
||||
from openhands.controller.agent import Agent
|
||||
from openhands.controller.agent_controller import AgentController
|
||||
@@ -499,7 +503,10 @@ async def test_reset_with_pending_action_no_observation(mock_agent, mock_event_s
|
||||
args, kwargs = mock_event_stream.add_event.call_args
|
||||
error_obs, source = args
|
||||
assert isinstance(error_obs, ErrorObservation)
|
||||
assert error_obs.content == 'The action has not been executed.'
|
||||
assert (
|
||||
error_obs.content
|
||||
== 'The action has not been executed. This may have occurred because the user pressed the stop button, or because the runtime system crashed and restarted due to resource constraints. Any previously established system state, dependencies, or environment variables may have been lost.'
|
||||
)
|
||||
assert error_obs.tool_call_metadata == pending_action.tool_call_metadata
|
||||
assert error_obs._cause == pending_action.id
|
||||
assert source == EventSource.AGENT
|
||||
@@ -1486,3 +1493,71 @@ def test_system_message_in_event_stream(mock_agent, test_event_stream):
|
||||
assert isinstance(events[0], SystemMessageAction)
|
||||
assert events[0].content == 'Test system message'
|
||||
assert events[0].tools == ['test_tool']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openrouter_context_window_exceeded_error(
|
||||
mock_agent, test_event_stream, mock_status_callback
|
||||
):
|
||||
"""Test that OpenRouter 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):
|
||||
self.views.append(state.view)
|
||||
|
||||
# Wait until the right step to throw the error, and make sure we
|
||||
# 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 OpenRouter context window exceeded message pattern
|
||||
error = BadRequestError(
|
||||
message='litellm.BadRequestError: OpenrouterException - This endpoint\'s maximum context length is 40960 tokens. However, you requested about 42988 tokens (38892 of text input, 4096 in the output). Please reduce the length of either one, or use the "middle-out" transform to compress your prompt automatically.',
|
||||
model='openrouter/qwen/qwen3-30b-a3b',
|
||||
llm_provider='openrouter',
|
||||
)
|
||||
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,
|
||||
max_iterations=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
|
||||
), 'OpenRouter context window exceeded error was not handled correctly'
|
||||
|
||||
await controller.close()
|
||||
|
||||
Reference in New Issue
Block a user