Compare commits

..

1 Commits

Author SHA1 Message Date
Robert Brennan
155b806bff update nikolaik 2025-03-31 13:24:09 -04:00
137 changed files with 1822 additions and 2724 deletions

View File

@@ -145,15 +145,13 @@ jobs:
fi
- name: Set environment variables
env:
REVIEW_BODY: ${{ github.event.review.body || '' }}
run: |
# Handle pull request events first
if [ -n "${{ github.event.pull_request.number }}" ]; then
echo "ISSUE_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_ENV
echo "ISSUE_TYPE=pr" >> $GITHUB_ENV
# Handle pull request review events
elif [ -n "$REVIEW_BODY" ]; then
elif [ -n "${{ github.event.review.body }}" ]; then
echo "ISSUE_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_ENV
echo "ISSUE_TYPE=pr" >> $GITHUB_ENV
# Handle issue comment events that reference a PR
@@ -166,7 +164,7 @@ jobs:
echo "ISSUE_TYPE=issue" >> $GITHUB_ENV
fi
if [ -n "$REVIEW_BODY" ]; then
if [ -n "${{ github.event.review.body }}" ]; then
echo "COMMENT_ID=${{ github.event.review.id || 'None' }}" >> $GITHUB_ENV
else
echo "COMMENT_ID=${{ github.event.comment.id || 'None' }}" >> $GITHUB_ENV

View File

@@ -19,10 +19,9 @@ jobs:
ref: ${{ github.head_ref }}
- name: Trigger remote job
env:
PR_BRANCH: ${{ github.head_ref }}
run: |
REPO_URL="https://github.com/${{ github.repository }}"
PR_BRANCH="${{ github.head_ref }}"
echo "Repository URL: $REPO_URL"
echo "PR Branch: $PR_BRANCH"

View File

@@ -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.31-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.30-nikolaik`
## Develop inside Docker container

View File

@@ -43,17 +43,17 @@ See the [Running OpenHands](https://docs.all-hands.dev/modules/usage/installatio
system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-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.31
docker.all-hands.dev/all-hands-ai/openhands:0.30
```
> [!WARNING]

View File

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

View File

@@ -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.31-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.30-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:

View File

@@ -60,7 +60,25 @@ Félicitations !
## Explication technique
Veuillez consulter le [chapitre sur les images Docker personnalisées dans la documentation d'exécution](https://docs.all-hands.dev/fr/modules/usage/architecture/runtime) pour obtenir des explications plus détaillées.
Lorsqu'une image personnalisée est utilisée pour la première fois, elle ne sera pas trouvée et donc elle sera construite (à l'exécution ultérieure, l'image construite sera trouvée et renvoyée).
L'image personnalisée est construite avec [_build_sandbox_image()](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/runtime/docker/image_agnostic_util.py#L29), qui crée un fichier docker en utilisant votre image personnalisée comme base et configure ensuite l'environnement pour OpenHands, comme ceci:
```python
dockerfile_content = (
f'FROM {base_image}\n'
'RUN apt update && apt install -y openssh-server wget sudo\n'
'RUN mkdir -p -m0755 /var/run/sshd\n'
'RUN mkdir -p /openhands && mkdir -p /openhands/logs && chmod 777 /openhands/logs\n'
'RUN wget "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh"\n'
'RUN bash Miniforge3-$(uname)-$(uname -m).sh -b -p /openhands/miniforge3\n'
'RUN bash -c ". /openhands/miniforge3/etc/profile.d/conda.sh && conda config --set changeps1 False && conda config --append channels conda-forge"\n'
'RUN echo "export PATH=/openhands/miniforge3/bin:$PATH" >> ~/.bashrc\n'
'RUN echo "export PATH=/openhands/miniforge3/bin:$PATH" >> /openhands/bash.bashrc\n'
).strip()
```
> Remarque: Le nom de l'image est modifié via [_get_new_image_name()](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/runtime/docker/image_agnostic_util.py#L63) et c'est ce nom modifié qui sera recherché lors des exécutions ultérieures.
## Dépannage / Erreurs

View File

@@ -52,7 +52,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-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.31 \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
python -m openhands.core.cli
```

View File

@@ -46,7 +46,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-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.31 \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
```

View File

@@ -13,16 +13,16 @@
La façon la plus simple d'exécuter OpenHands est avec Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-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.31
docker.all-hands.dev/all-hands-ai/openhands:0.30
```
Vous pouvez également exécuter OpenHands en mode [headless scriptable](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), en tant que [CLI interactive](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), ou en utilisant l'[Action GitHub OpenHands](https://docs.all-hands.dev/modules/usage/how-to/github-action).

View File

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

View File

@@ -60,7 +60,25 @@ base_container_image="custom_image"
## 技術的な説明
詳細な説明については、[実行時ドキュメントのカスタムDockerイメージの章](https://docs.all-hands.dev/ja/modules/usage/architecture/runtime)を参照してください
カスタムイメージが初めて使用される場合、イメージが見つからないため、ビルドされます (その後の実行では、ビルドされたイメージが見つかり、返されます)
カスタムイメージは [_build_sandbox_image()](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/runtime/docker/image_agnostic_util.py#L29) でビルドされます。これは、カスタムイメージをベースとして使用して docker ファイルを作成し、次のように OpenHands の環境を設定します:
```python
dockerfile_content = (
f'FROM {base_image}\n'
'RUN apt update && apt install -y openssh-server wget sudo\n'
'RUN mkdir -p -m0755 /var/run/sshd\n'
'RUN mkdir -p /openhands && mkdir -p /openhands/logs && chmod 777 /openhands/logs\n'
'RUN wget "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh"\n'
'RUN bash Miniforge3-$(uname)-$(uname -m).sh -b -p /openhands/miniforge3\n'
'RUN bash -c ". /openhands/miniforge3/etc/profile.d/conda.sh && conda config --set changeps1 False && conda config --append channels conda-forge"\n'
'RUN echo "export PATH=/openhands/miniforge3/bin:$PATH" >> ~/.bashrc\n'
'RUN echo "export PATH=/openhands/miniforge3/bin:$PATH" >> /openhands/bash.bashrc\n'
).strip()
```
> 注: イメージ名は [_get_new_image_name()](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/runtime/docker/image_agnostic_util.py#L63) で変更され、この変更された名前が後続の実行時に検索されます。
## トラブルシューティング / エラー

View File

@@ -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.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-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.31 \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
python -m openhands.core.cli
```

View File

@@ -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.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-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.31 \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
python -m openhands.core.main -t "write a bash script that prints hi"
```

View File

@@ -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.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-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.31
docker.all-hands.dev/all-hands-ai/openhands:0.30
```

View File

@@ -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.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-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.31 \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
python -m openhands.core.cli
```

View File

@@ -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.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-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.31 \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
python -m openhands.core.main -t "escreva um script bash que imprima oi"
```

View File

@@ -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.31-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-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.31
docker.all-hands.dev/all-hands-ai/openhands:0.30
```
Você encontrará o OpenHands em execução em http://localhost:3000!

View File

@@ -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.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```

View File

@@ -1,6 +1,6 @@
# 💿 如何创建自定义 Docker 沙箱
默认的 OpenHands 沙箱包含一个[最小化 ubuntu 配置](https://github.com/All-Hands-AI/OpenHands/blob/main/containers/e2b-sandbox/Dockerfile)。您的应用场景可能需要在默认状态下安装额外的软件。本指南将教您如何通过使用自定义 Docker 映像来实现这一目标。
默认的 OpenHands 沙箱包含一个[最小化 ubuntu 配置](https://github.com/All-Hands-AI/OpenHands/blob/main/containers/sandbox/Dockerfile)。您的应用场景可能需要在默认状态下安装额外的软件。本指南将教您如何通过使用自定义 Docker 映像来实现这一目标。
目前提供两种实现方案:
1. 从 Docker Hub 拉取已有镜像。例如,如果您想安装 `nodejs` ,您可以通过使用 `node:20` 镜像来实现。

View File

@@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-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.31 \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
python -m openhands.core.cli
```

View File

@@ -47,7 +47,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-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.31 \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
```

View File

@@ -11,16 +11,16 @@
在 Docker 中运行 OpenHands 是最简单的方式。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-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.31
docker.all-hands.dev/all-hands-ai/openhands:0.30
```
你也可以在可脚本化的[无头模式](https://docs.all-hands.dev/modules/usage/how-to/headless-mode)下运行 OpenHands作为[交互式 CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),或使用 [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action)。

View File

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

View File

@@ -35,7 +35,7 @@ To run OpenHands in CLI mode with Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-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.31 \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
python -m openhands.core.cli
```

View File

@@ -32,7 +32,7 @@ To run OpenHands in Headless mode with Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-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.31 \
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
python -m openhands.core.main -t "write a bash script that prints hi"
```

View File

@@ -58,31 +58,30 @@ 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.31-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.31-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-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.31
docker.all-hands.dev/all-hands-ai/openhands:0.30
```
You'll find OpenHands running at http://localhost:3000!
You can also [connect OpenHands to your local filesystem](https://docs.all-hands.dev/modules/usage/runtimes/docker#connecting-to-your-filesystem),
You can also [connect OpenHands to your local filesystem](https://docs.all-hands.dev/modules/usage/runtimes#connecting-to-your-filesystem),
run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode),
interact with it via a [friendly CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),
or run it on tagged issues with [a GitHub action](https://docs.all-hands.dev/modules/usage/how-to/github-action).
## Setup
After launching OpenHands, you **must** select an `LLM Provider` and `LLM Model` and enter a corresponding `API Key`.
This can be done during the initial settings popup or by selecting the `Settings`
button (gear icon) in the UI.
Upon launching OpenHands, you'll see a Settings page. You **must** select an `LLM Provider` and `LLM Model` and enter a corresponding `API Key`.
These can be changed at any time by selecting the `Settings` button (gear icon) in the UI.
If the required model does not exist in the list, you can toggle `Advanced` options and manually enter it with the correct prefix
in the `Custom Model` text box.
@@ -94,17 +93,17 @@ OpenHands requires an API key to access most language models. Here's how to get
#### Anthropic (Claude)
1. [Create an Anthropic account](https://console.anthropic.com/).
2. [Generate an API key](https://console.anthropic.com/settings/keys).
3. [Set up billing](https://console.anthropic.com/settings/billing).
1. [Create an Anthropic account](https://console.anthropic.com/)
2. [Generate an API key](https://console.anthropic.com/settings/keys)
3. [Set up billing](https://console.anthropic.com/settings/billing)
Consider setting usage limits to control costs.
#### OpenAI
1. [Create an OpenAI account](https://platform.openai.com/).
2. [Generate an API key](https://platform.openai.com/api-keys).
3. [Set up billing](https://platform.openai.com/account/billing/overview).
1. [Create an OpenAI account](https://platform.openai.com/)
2. [Generate an API key](https://platform.openai.com/api-keys)
3. [Set up billing](https://platform.openai.com/account/billing/overview)
Now you're ready to [get started with OpenHands](./getting-started).

View File

@@ -25,7 +25,7 @@ You will need your ChatGPT deployment name which can be found on the deployments
<deployment-name> below.
:::
1. Enable `Advanced` options.
1. Enable `Advanced` options
2. Set the following:
- `Custom Model` to azure/<deployment-name>
- `Base URL` to your Azure API Base URL (e.g. `https://example-endpoint.openai.azure.com`)

View File

@@ -9,9 +9,9 @@ recommendations for model selection. Our latest benchmarking results can be foun
Based on these findings and community feedback, the following models have been verified to work reasonably well with OpenHands:
- anthropic/claude-3-7-sonnet-20250219 (recommended)
- anthropic/claude-3-5-sonnet-20241022 (recommended)
- anthropic/claude-3-5-haiku-20241022
- deepseek/deepseek-chat
- OpenHands LM
- gpt-4o
:::warning
@@ -56,7 +56,6 @@ We have a few guides for running OpenHands with specific model providers:
- [Azure](llms/azure-llms)
- [Google](llms/google-llms)
- [Groq](llms/groq)
- [Local LLMs with SGLang or vLLM](llms/../local-llms.md)
- [LiteLLM Proxy](llms/litellm-proxy)
- [OpenAI](llms/openai-llms)
- [OpenRouter](llms/openrouter)

View File

@@ -1,83 +1,192 @@
# Local LLM with SGLang or vLLM
# Local LLM with Ollama
:::warning
When using a Local LLM, OpenHands may have limited functionality.
It is highly recommended that you use GPUs to serve local models for optimal experience.
:::
## News
Ensure that you have the Ollama server up and running.
For detailed startup instructions, refer to [here](https://github.com/ollama/ollama).
- 2025/03/31: We released an open model OpenHands LM v0.1 32B that achieves 37.1% on SWE-Bench Verified
([blog](https://www.all-hands.dev/blog/introducing-openhands-lm-32b----a-strong-open-coding-agent-model), [model](https://huggingface.co/all-hands/openhands-lm-32b-v0.1)).
This guide assumes you've started ollama with `ollama serve`. If you're running ollama differently (e.g. inside docker), the instructions might need to be modified. Please note that if you're running WSL the default ollama configuration blocks requests from docker containers. See [here](#configuring-ollama-service-wsl-en).
## Download the Model from Huggingface
## Pull Models
For example, to download [OpenHands LM 32B v0.1](https://huggingface.co/all-hands/openhands-lm-32b-v0.1):
Ollama model names can be found [here](https://ollama.com/library). For a small example, you can use
the `codellama:7b` model. Bigger models will generally perform better.
```bash
huggingface-cli download all-hands/openhands-lm-32b-v0.1 --local-dir my_folder/openhands-lm-32b-v0.1
ollama pull codellama:7b
```
## Create an OpenAI-Compatible Endpoint With a Model Serving Framework
### Serving with SGLang
- Install SGLang following [the official documentation](https://docs.sglang.ai/start/install.html).
- Example launch command for OpenHands LM 32B (with at least 2 GPUs):
you can check which models you have downloaded like this:
```bash
SGLANG_ALLOW_OVERWRITE_LONGER_CONTEXT_LEN=1 python3 -m sglang.launch_server \
--model my_folder/openhands-lm-32b-v0.1 \
--served-model-name openhands-lm-32b-v0.1 \
--port 8000 \
--tp 2 --dp 1 \
--host 0.0.0.0 \
--api-key mykey --context-length 131072
~$ ollama list
NAME ID SIZE MODIFIED
codellama:7b 8fdf8f752f6e 3.8 GB 6 weeks ago
mistral:7b-instruct-v0.2-q4_K_M eb14864c7427 4.4 GB 2 weeks ago
starcoder2:latest f67ae0f64584 1.7 GB 19 hours ago
```
### Serving with vLLM
## Run OpenHands with Docker
- Install vLLM following [the official documentation](https://docs.vllm.ai/en/latest/getting_started/installation.html).
- Example launch command for OpenHands LM 32B (with at least 2 GPUs):
### Start OpenHands
Use the instructions [here](../getting-started) to start OpenHands using Docker.
But when running `docker run`, you'll need to add a few more arguments:
```bash
vllm serve my_folder/openhands-lm-32b-v0.1 \
--host 0.0.0.0 --port 8000 \
--api-key mykey \
--tensor-parallel-size 2 \
--served-model-name openhands-lm-32b-v0.1
--enable-prefix-caching
docker run # ...
--add-host host.docker.internal:host-gateway \
-e LLM_OLLAMA_BASE_URL="http://host.docker.internal:11434" \
# ...
```
## Run and Configure OpenHands
LLM_OLLAMA_BASE_URL is optional. If you set it, it will be used to show
the available installed models in the UI.
### Run OpenHands
#### Using Docker
### Configure the Web Application
Run OpenHands using [the official docker run command](../installation#start-the-app).
When running `openhands`, you'll need to set the following in the OpenHands UI through the Settings:
- the model to "ollama/<model-name>"
- the base url to `http://host.docker.internal:11434`
- the API key is optional, you can use any string, such as `ollama`.
#### Using Development Mode
## Run OpenHands in Development Mode
### Build from Source
Use the instructions in [Development.md](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md) to build OpenHands.
Ensure `config.toml` exists by running `make setup-config` which will create one for you. In the `config.toml`, enter the following:
Make sure `config.toml` is there by running `make setup-config` which will create one for you. In `config.toml`, enter the followings:
```
[core]
workspace_base="/path/to/your/workspace"
workspace_base="./workspace"
[llm]
embedding_model="local"
ollama_base_url="http://localhost:8000"
ollama_base_url="http://localhost:11434"
```
Start OpenHands using `make run`.
Done! Now you can start OpenHands by: `make run`. You now should be able to connect to `http://localhost:3000/`
### Configure OpenHands
### Configure the Web Application
Once OpenHands is running, you'll need to set the following in the OpenHands UI through the Settings:
1. Enable `Advanced` options.
2. Set the following:
- `Custom Model` to `openai/<served-model-name>` (e.g. `openai/openhands-lm-32b-v0.1`)
- `Base URL` to `http://host.docker.internal:8000`
- `API key` to the same string you set when serving the model (e.g. `mykey`)
In the OpenHands UI, click on the Settings wheel in the bottom-left corner.
Then in the `Model` input, enter `ollama/codellama:7b`, or the name of the model you pulled earlier.
If it doesnt show up in the dropdown, enable `Advanced Settings` and type it in. Please note: you need the model name as listed by `ollama list`, with the prefix `ollama/`.
In the API Key field, enter `ollama` or any value, since you don't need a particular key.
In the Base URL field, enter `http://localhost:11434`.
And now you're ready to go!
## Configuring the ollama service (WSL) {#configuring-ollama-service-wsl-en}
The default configuration for ollama in WSL only serves localhost. This means you can't reach it from a docker container. eg. it wont work with OpenHands. First let's test that ollama is running correctly.
```bash
ollama list # get list of installed models
curl http://localhost:11434/api/generate -d '{"model":"[NAME]","prompt":"hi"}'
#ex. curl http://localhost:11434/api/generate -d '{"model":"codellama:7b","prompt":"hi"}'
#ex. curl http://localhost:11434/api/generate -d '{"model":"codellama","prompt":"hi"}' #the tag is optional if there is only one
```
Once that is done, test that it allows "outside" requests, like those from inside a docker container.
```bash
docker ps # get list of running docker containers, for most accurate test choose the OpenHands sandbox container.
docker exec [CONTAINER ID] curl http://host.docker.internal:11434/api/generate -d '{"model":"[NAME]","prompt":"hi"}'
#ex. docker exec cd9cc82f7a11 curl http://host.docker.internal:11434/api/generate -d '{"model":"codellama","prompt":"hi"}'
```
## Fixing it
Now let's make it work. Edit /etc/systemd/system/ollama.service with sudo privileges. (Path may vary depending on linux flavor)
```bash
sudo vi /etc/systemd/system/ollama.service
```
or
```bash
sudo nano /etc/systemd/system/ollama.service
```
In the [Service] bracket add these lines
```
Environment="OLLAMA_HOST=0.0.0.0:11434"
Environment="OLLAMA_ORIGINS=*"
```
Then save, reload the configuration and restart the service.
```bash
sudo systemctl daemon-reload
sudo systemctl restart ollama
```
Finally test that ollama is accessible from within the container
```bash
ollama list # get list of installed models
docker ps # get list of running docker containers, for most accurate test choose the OpenHands sandbox container.
docker exec [CONTAINER ID] curl http://host.docker.internal:11434/api/generate -d '{"model":"[NAME]","prompt":"hi"}'
```
# Local LLM with LM Studio
Steps to set up LM Studio:
1. Open LM Studio
2. Go to the Local Server tab.
3. Click the "Start Server" button.
4. Select the model you want to use from the dropdown.
Set the following configs:
```bash
LLM_MODEL="openai/lmstudio"
LLM_BASE_URL="http://localhost:1234/v1"
CUSTOM_LLM_PROVIDER="openai"
```
### Docker
```bash
docker run # ...
-e LLM_MODEL="openai/lmstudio" \
-e LLM_BASE_URL="http://host.docker.internal:1234/v1" \
-e CUSTOM_LLM_PROVIDER="openai" \
# ...
```
You should now be able to connect to `http://localhost:3000/`
In the development environment, you can set the following configs in the `config.toml` file:
```
[core]
workspace_base="./workspace"
[llm]
model="openai/lmstudio"
base_url="http://localhost:1234/v1"
custom_llm_provider="openai"
```
Done! Now you can start OpenHands by: `make run` without Docker. You now should be able to connect to `http://localhost:3000/`
# Note
For WSL, run the following commands in cmd to set up the networking mode to mirrored:
```
python -c "print('[wsl2]\nnetworkingMode=mirrored',file=open(r'%UserProfile%\.wslconfig','w'))"
wsl --shutdown
```

View File

@@ -3,20 +3,22 @@
A Runtime is an environment where the OpenHands agent can edit files and run
commands.
By default, OpenHands uses a [Docker-based runtime](./runtimes/docker), running on your local computer.
By default, OpenHands uses a Docker-based runtime, running on your local computer.
This means you only have to pay for the LLM you're using, and your code is only ever sent to the LLM.
We also support other runtimes, which are typically managed by third-parties.
We also support "remote" runtimes, which are typically managed by third-parties.
They can make setup a bit simpler and more scalable, especially
if you're running many OpenHands conversations in parallel (e.g. to do evaluation).
Additionally, we provide a [Local Runtime](./runtimes/local) that runs directly on your machine without Docker,
Additionally, we provide a "local" runtime that runs directly on your machine without Docker,
which can be useful in controlled environments like CI pipelines.
## Available Runtimes
OpenHands supports several different runtime environments:
- [Docker Runtime](./runtimes/docker.md) - The default runtime that uses Docker containers for isolation (recommended for most users).
- [OpenHands Remote Runtime](./runtimes/remote.md) - Cloud-based runtime for parallel execution (beta).
- [Modal Runtime](./runtimes/modal.md) - Runtime provided by our partners at Modal.
- [Daytona Runtime](./runtimes/daytona.md) - Runtime provided by Daytona.
- [Local Runtime](./runtimes/local.md) - Direct execution on your local machine without Docker.
- [Docker Runtime](./runtimes/docker.md) - The default runtime that uses Docker containers for isolation (recommended for most users)
- [OpenHands Remote Runtime](./runtimes/remote.md) - Cloud-based runtime for parallel execution (beta)
- [Modal Runtime](./runtimes/modal.md) - Runtime provided by our partners at Modal
- [Daytona Runtime](./runtimes/daytona.md) - Runtime provided by Daytona
- [Local Runtime](./runtimes/local.md) - Direct execution on your local machine without Docker

View File

@@ -8,7 +8,7 @@ that contains our Runtime server, as well as some basic utilities for Python and
You can also [build your own runtime image](../how-to/custom-sandbox-guide).
## Connecting to Your filesystem
A useful feature is the ability to connect to your local filesystem. To mount your filesystem into the runtime:
One useful feature here is the ability to connect to your local filesystem. To mount your filesystem into the runtime:
1. Set `WORKSPACE_BASE`:
```bash
@@ -40,20 +40,20 @@ but seems to work well on most systems.
## Hardened Docker Installation
When deploying OpenHands in environments where security is a priority, you should consider implementing a hardened
Docker configuration. This section provides recommendations for securing your OpenHands Docker deployment beyond the default configuration.
When deploying OpenHands in environments where security is a priority, you should consider implementing a hardened Docker configuration. This section provides recommendations for securing your OpenHands Docker deployment beyond the default configuration.
### Security Considerations
The default Docker configuration in the README is designed for ease of use on a local development machine. If you're
running on a public network (e.g. airport WiFi), you should implement additional security measures.
The default Docker configuration in the README is designed for ease of use on a local development machine. If you're running on a public network (e.g. airport WiFi),
you should implement additional security measures.
### Network Binding Security
By default, OpenHands binds to all network interfaces (`0.0.0.0`), which can expose your instance to all networks the
host is connected to. For a more secure setup:
By default, OpenHands binds to all network interfaces (`0.0.0.0`), which can expose your instance to all networks the host is connected to. For a more secure setup:
1. **Restrict Network Binding**: Use the `runtime_binding_address` configuration to restrict which network interfaces OpenHands listens on:
1. **Restrict Network Binding**:
Use the `runtime_binding_address` configuration to restrict which network interfaces OpenHands listens on:
```bash
docker run # ...
@@ -63,7 +63,9 @@ host is connected to. For a more secure setup:
This configuration ensures OpenHands only listens on the loopback interface (`127.0.0.1`), making it accessible only from the local machine.
2. **Secure Port Binding**: Modify the `-p` flag to bind only to localhost instead of all interfaces:
2. **Secure Port Binding**:
Modify the `-p` flag to bind only to localhost instead of all interfaces:
```bash
docker run # ... \

View File

@@ -1,26 +1,23 @@
# Local Runtime
The Local Runtime allows the OpenHands agent to execute actions directly on your local machine without using Docker.
This runtime is primarily intended for controlled environments like CI pipelines or testing scenarios where Docker is not available.
The Local Runtime allows the OpenHands agent to execute actions directly on your local machine without using Docker. This runtime is primarily intended for controlled environments like CI pipelines or testing scenarios where Docker is not available.
:::caution
**Security Warning**: The Local Runtime runs without any sandbox isolation. The agent can directly access and modify
files on your machine. Only use this runtime in controlled environments or when you fully understand the security implications.
**Security Warning**: The Local Runtime runs without any sandbox isolation. The agent can directly access and modify files on your machine. Only use this runtime in controlled environments or when you fully understand the security implications.
:::
## Prerequisites
Before using the Local Runtime, ensure that:
1. You can run OpenHands using the [Development workflow](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md).
1. You have followed the [Development setup instructions](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md).
2. tmux is available on your system.
## Configuration
To use the Local Runtime, besides required configurations like the LLM provider, model and API key, you'll need to set
the following options via environment variables or the [config.toml file](https://github.com/All-Hands-AI/OpenHands/blob/main/config.template.toml) when starting OpenHands:
To use the Local Runtime, besides required configurations like the model, API key, you'll need to set the following options via environment variables or the [config.toml file](https://github.com/All-Hands-AI/OpenHands/blob/main/config.template.toml) when starting OpenHands:
Via environment variables:
- Via environment variables:
```bash
# Required
@@ -30,7 +27,7 @@ export RUNTIME=local
export WORKSPACE_BASE=/path/to/your/workspace
```
Via `config.toml`:
- Via `config.toml`:
```toml
[core]
@@ -62,3 +59,4 @@ The Local Runtime is particularly useful for:
- CI/CD pipelines where Docker is not available.
- Testing and development of OpenHands itself.
- Environments where container usage is restricted.
- Scenarios where direct file system access is required.

View File

@@ -9,5 +9,5 @@ You'll then need to set the following environment variables when starting OpenHa
docker run # ...
-e RUNTIME=modal \
-e MODAL_API_TOKEN_ID="your-id" \
-e MODAL_API_TOKEN_SECRET="modal-api-key" \
-e MODAL_API_TOKEN_SECRET="your-secret" \
```

View File

@@ -1,9 +1,6 @@
# OpenHands Remote Runtime
:::note
This runtime is specifically designed for agent evaluation purposes only through the
[OpenHands evaluation harness](https://github.com/All-Hands-AI/OpenHands/tree/main/evaluation). It should not be used to launch production OpenHands applications.
:::
OpenHands Remote Runtime is currently in beta (read [here](https://runtime.all-hands.dev/) for more details), it allows you to launch runtimes in parallel in the cloud.
Fill out [this form](https://docs.google.com/forms/d/e/1FAIpQLSckVz_JFwg2_mOxNZjCtr7aoBFI2Mwdan3f75J_TrdMS1JV2g/viewform) to apply if you want to try this out!
OpenHands Remote Runtime is currently in beta (read [here](https://runtime.all-hands.dev/) for more details), it allows you to launch runtimes
in parallel in the cloud. Fill out [this form](https://docs.google.com/forms/d/e/1FAIpQLSckVz_JFwg2_mOxNZjCtr7aoBFI2Mwdan3f75J_TrdMS1JV2g/viewform) to apply if you want to try this out!
NOTE: This runtime is specifically designed for agent evaluation purposes only through [OpenHands evaluation harness](https://github.com/All-Hands-AI/OpenHands/tree/main/evaluation). It should not be used to launch production OpenHands applications.

View File

@@ -141,11 +141,6 @@ const sidebars: SidebarsConfig = {
label: 'Groq',
id: 'usage/llms/groq',
},
{
type: 'doc',
label: 'Local LLMs with SGLang or vLLM',
id: 'usage/llms/local-llms',
},
{
type: 'doc',
label: 'LiteLLM Proxy',

View File

@@ -156,7 +156,7 @@ For example, to evaluate a specific instance with a custom dataset and split:
./evaluation/benchmarks/swe_bench/scripts/eval_infer.sh $YOUR_OUTPUT_JSONL instance_123 princeton-nlp/SWE-bench test
```
> You can also pass in a JSONL with [SWE-Bench format](https://github.com/SWE-bench/SWE-bench/blob/main/assets/evaluation.md#-creating-predictions) to `./evaluation/benchmarks/swe_bench/scripts/eval_infer.sh`, where each line is a JSON of `{"model_patch": "XXX", "model_name_or_path": "YYY", "instance_id": "ZZZ"}`.
> You can also pass in a JSONL with [SWE-Bench format](https://github.com/princeton-nlp/SWE-bench/blob/main/tutorials/evaluation.md#-creating-predictions) to `./evaluation/benchmarks/swe_bench/scripts/eval_infer.sh`, where each line is a JSON of `{"model_patch": "XXX", "model_name_or_path": "YYY", "instance_id": "ZZZ"}`.
The final results will be saved to `evaluation/evaluation_outputs/outputs/swe_bench/CodeActAgent/gpt-4-1106-preview_maxiter_50_N_v1.0/` with the following files/directory:

View File

@@ -386,21 +386,6 @@ def complete_runtime(
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
if obs.exit_code == -1:
# The previous command is still running
# We need to kill previous command
logger.info('The previous command is still running, trying to ctrl+z it...')
action = CmdRunAction(command='C-z')
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
# Then run the command again
action = CmdRunAction(command=f'cd /workspace/{workspace_dir_name}')
action.set_hard_timeout(600)
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert_and_raise(
isinstance(obs, CmdOutputObservation) and obs.exit_code == 0,
f'Failed to cd to /workspace/{workspace_dir_name}: {str(obs)}',

View File

@@ -521,11 +521,6 @@ def compatibility_for_eval_history_pairs(
def is_fatal_evaluation_error(error: str | None) -> bool:
"""
The AgentController class overrides last error for certain exceptions
We want to ensure those exeption do not overlap with fatal exceptions defined here
This is because we do a comparisino against the stringified error
"""
if not error:
return False

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { ActionSuggestions } from "#/components/features/chat/action-suggestions";
import { useAuth } from "#/context/auth-context";
@@ -21,34 +21,21 @@ vi.mock("#/context/auth-context", () => ({
describe("ActionSuggestions", () => {
// Setup mocks for each test
beforeEach(() => {
vi.clearAllMocks();
vi.clearAllMocks();
(useAuth as any).mockReturnValue({
providersAreSet: true,
});
(useAuth as any).mockReturnValue({
githubTokenIsSet: true,
});
(useSelector as any).mockReturnValue({
selectedRepository: "test-repo",
});
(useSelector as any).mockReturnValue({
selectedRepository: "test-repo",
});
it("should render both GitHub buttons when GitHub token is set and repository is selected", () => {
render(<ActionSuggestions onSuggestionsClick={() => {}} />);
// Find all buttons with data-testid="suggestion"
const buttons = screen.getAllByTestId("suggestion");
// Check if we have at least 2 buttons
expect(buttons.length).toBeGreaterThanOrEqual(2);
// Check if the buttons contain the expected text
const pushButton = buttons.find((button) =>
button.textContent?.includes("Push to Branch"),
);
const prButton = buttons.find((button) =>
button.textContent?.includes("Push & Create PR"),
);
const pushButton = screen.getByRole("button", { name: "Push to Branch" });
const prButton = screen.getByRole("button", { name: "Push & Create PR" });
expect(pushButton).toBeInTheDocument();
expect(prButton).toBeInTheDocument();
@@ -56,12 +43,13 @@ describe("ActionSuggestions", () => {
it("should not render buttons when GitHub token is not set", () => {
(useAuth as any).mockReturnValue({
providersAreSet: false,
githubTokenIsSet: false,
});
render(<ActionSuggestions onSuggestionsClick={() => {}} />);
expect(screen.queryByTestId("suggestion")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Push to Branch" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Push & Create PR" })).not.toBeInTheDocument();
});
it("should not render buttons when no repository is selected", () => {
@@ -71,20 +59,17 @@ describe("ActionSuggestions", () => {
render(<ActionSuggestions onSuggestionsClick={() => {}} />);
expect(screen.queryByTestId("suggestion")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Push to Branch" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Push & Create PR" })).not.toBeInTheDocument();
});
it("should have different prompts for 'Push to Branch' and 'Push & Create PR' buttons", () => {
// This test verifies that the prompts are different in the component
const component = render(
<ActionSuggestions onSuggestionsClick={() => {}} />,
);
const component = render(<ActionSuggestions onSuggestionsClick={() => {}} />);
// Get the component instance to access the internal values
const pushBranchPrompt =
"Please push the changes to a remote branch on GitHub, but do NOT create a pull request. Please use the exact SAME branch name as the one you are currently on.";
const createPRPrompt =
"Please push the changes to GitHub and open a pull request. Please create a meaningful branch name that describes the changes.";
const pushBranchPrompt = "Please push the changes to a remote branch on GitHub, but do NOT create a pull request. Please use the exact SAME branch name as the one you are currently on.";
const createPRPrompt = "Please push the changes to GitHub and open a pull request. Please create a meaningful branch name that describes the changes.";
// Verify the prompts are different
expect(pushBranchPrompt).not.toEqual(createPRPrompt);

View File

@@ -1,17 +1,16 @@
import { screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { GitRepositorySelector } from "#/components/features/git/git-repo-selector";
import { GitHubRepositorySelector } from "#/components/features/github/github-repo-selector";
import OpenHands from "#/api/open-hands";
import { Provider } from "#/types/settings";
describe("GitRepositorySelector", () => {
describe("GitHubRepositorySelector", () => {
const onInputChangeMock = vi.fn();
const onSelectMock = vi.fn();
it("should render the search input", () => {
renderWithProviders(
<GitRepositorySelector
<GitHubRepositorySelector
onInputChange={onInputChangeMock}
onSelect={onSelectMock}
publicRepositories={[]}
@@ -20,7 +19,7 @@ describe("GitRepositorySelector", () => {
);
expect(
screen.getByPlaceholderText("LANDING$SELECT_GIT_REPO"),
screen.getByPlaceholderText("LANDING$SELECT_REPO"),
).toBeInTheDocument();
});
@@ -38,7 +37,7 @@ describe("GitRepositorySelector", () => {
});
renderWithProviders(
<GitRepositorySelector
<GitHubRepositorySelector
onInputChange={onInputChangeMock}
onSelect={onSelectMock}
publicRepositories={[]}
@@ -54,25 +53,23 @@ describe("GitRepositorySelector", () => {
{
id: 1,
full_name: "test/repo1",
git_provider: "github" as Provider,
stargazers_count: 100,
},
{
id: 2,
full_name: "test/repo2",
git_provider: "github" as Provider,
stargazers_count: 200,
},
];
const searchPublicRepositoriesSpy = vi.spyOn(
OpenHands,
"searchGitRepositories",
"searchGitHubRepositories",
);
searchPublicRepositoriesSpy.mockResolvedValue(mockSearchedRepos);
renderWithProviders(
<GitRepositorySelector
<GitHubRepositorySelector
onInputChange={onInputChangeMock}
onSelect={onSelectMock}
publicRepositories={[]}

View File

@@ -18,7 +18,7 @@ describe("useSaveSettings", () => {
),
});
result.current.mutate({ llm_api_key: "" });
result.current.mutate({ LLM_API_KEY: "" });
await waitFor(() => {
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
@@ -27,7 +27,7 @@ describe("useSaveSettings", () => {
);
});
result.current.mutate({ llm_api_key: null });
result.current.mutate({ LLM_API_KEY: null });
await waitFor(() => {
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({

View File

@@ -10,18 +10,12 @@ import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import * as ConsentHandlers from "#/utils/handle-capture-consent";
import AccountSettings from "#/routes/account-settings";
import { Provider } from "#/types/settings";
const toggleAdvancedSettings = async (user: UserEvent) => {
const advancedSwitch = await screen.findByTestId("advanced-settings-switch");
await user.click(advancedSwitch);
};
const mock_provider_tokens_are_set: Record<Provider, boolean> = {
github: true,
gitlab: false,
};
describe("Settings Screen", () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
@@ -65,7 +59,7 @@ describe("Settings Screen", () => {
await waitFor(() => {
screen.getByText("LLM Settings");
screen.getByText("Git Provider Settings");
screen.getByText("GitHub Settings");
screen.getByText("Additional Settings");
screen.getByText("Reset to defaults");
screen.getByText("Save Changes");
@@ -100,6 +94,7 @@ describe("Settings Screen", () => {
it.skip("should render an indicator if the GitHub token is not set", async () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: false,
});
renderSettingsScreen();
@@ -120,7 +115,7 @@ describe("Settings Screen", () => {
it("should set '<hidden>' placeholder if the GitHub token is set", async () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: mock_provider_tokens_are_set,
github_token_is_set: true,
});
renderSettingsScreen();
@@ -134,7 +129,7 @@ describe("Settings Screen", () => {
it("should render an indicator if the GitHub token is set", async () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: mock_provider_tokens_are_set,
github_token_is_set: true,
});
renderSettingsScreen();
@@ -150,26 +145,27 @@ describe("Settings Screen", () => {
}
});
it("should render a disabled 'Disconnect Tokens' button if the GitHub token is not set", async () => {
it("should render a disabled 'Disconnect from GitHub' button if the GitHub token is not set", async () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: false,
});
renderSettingsScreen();
const button = await screen.findByText("Disconnect Tokens");
const button = await screen.findByText("Disconnect from GitHub");
expect(button).toBeInTheDocument();
expect(button).toBeDisabled();
});
it("should render an enabled 'Disconnect Tokens' button if any Git tokens are set", async () => {
it("should render an enabled 'Disconnect from GitHub' button if the GitHub token is set", async () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: mock_provider_tokens_are_set,
github_token_is_set: true,
});
renderSettingsScreen();
const button = await screen.findByText("Disconnect Tokens");
const button = await screen.findByText("Disconnect from GitHub");
expect(button).toBeInTheDocument();
expect(button).toBeEnabled();
@@ -178,17 +174,17 @@ describe("Settings Screen", () => {
expect(input).toBeInTheDocument();
});
it("should logout the user when the 'Disconnect Tokens' button is clicked", async () => {
it("should logout the user when the 'Disconnect from GitHub' button is clicked", async () => {
const user = userEvent.setup();
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: mock_provider_tokens_are_set,
github_token_is_set: true,
});
renderSettingsScreen();
const button = await screen.findByText("Disconnect Tokens");
const button = await screen.findByText("Disconnect from GitHub");
await user.click(button);
expect(handleLogoutMock).toHaveBeenCalled();
@@ -253,6 +249,7 @@ describe("Settings Screen", () => {
const user = userEvent.setup();
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: false,
llm_model: "anthropic/claude-3-5-sonnet-20241022",
});
saveSettingsSpy.mockRejectedValueOnce(new Error("Invalid GitHub token"));
@@ -395,7 +392,7 @@ describe("Settings Screen", () => {
it("should render an indicator if the LLM API key is set", async () => {
getSettingsSpy.mockResolvedValueOnce({
...MOCK_DEFAULT_USER_SETTINGS,
llm_api_key_set: true,
llm_api_key: "**********",
});
renderSettingsScreen();
@@ -416,7 +413,7 @@ describe("Settings Screen", () => {
it("should set '<hidden>' placeholder if the LLM API key is set", async () => {
getSettingsSpy.mockResolvedValueOnce({
...MOCK_DEFAULT_USER_SETTINGS,
llm_api_key_set: true,
llm_api_key: "**********",
});
renderSettingsScreen();
@@ -710,6 +707,7 @@ describe("Settings Screen", () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
language: "no",
github_token_is_set: true,
user_consents_to_analytics: true,
llm_base_url: "https://test.com",
llm_model: "anthropic/claude-3-5-sonnet-20241022",
@@ -721,7 +719,7 @@ describe("Settings Screen", () => {
await waitFor(() => {
expect(screen.getByTestId("language-input")).toHaveValue("Norsk");
expect(screen.getByText("Disconnect Tokens")).toBeInTheDocument();
expect(screen.getByText("Disconnect from GitHub")).toBeInTheDocument();
expect(screen.getByTestId("enable-analytics-switch")).toBeChecked();
expect(screen.getByTestId("advanced-settings-switch")).toBeChecked();
expect(screen.getByTestId("base-url-input")).toHaveValue(
@@ -762,6 +760,7 @@ describe("Settings Screen", () => {
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
llm_api_key: "", // empty because it's not set previously
provider_tokens: undefined,
language: "no",
}),
);
@@ -798,6 +797,7 @@ describe("Settings Screen", () => {
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
provider_tokens: undefined,
llm_api_key: "", // empty because it's not set previously
llm_model: "openai/gpt-4o",
}),
@@ -846,17 +846,11 @@ describe("Settings Screen", () => {
// Wait for the mutation to complete and the modal to be removed
await waitFor(() => {
expect(screen.queryByTestId("reset-modal")).not.toBeInTheDocument();
expect(
screen.queryByTestId("llm-custom-model-input"),
).not.toBeInTheDocument();
expect(screen.queryByTestId("llm-custom-model-input")).not.toBeInTheDocument();
expect(screen.queryByTestId("base-url-input")).not.toBeInTheDocument();
expect(screen.queryByTestId("agent-input")).not.toBeInTheDocument();
expect(
screen.queryByTestId("security-analyzer-input"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("enable-confirmation-mode-switch"),
).not.toBeInTheDocument();
expect(screen.queryByTestId("security-analyzer-input")).not.toBeInTheDocument();
expect(screen.queryByTestId("enable-confirmation-mode-switch")).not.toBeInTheDocument();
});
});
@@ -971,7 +965,7 @@ describe("Settings Screen", () => {
const user = userEvent.setup();
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_api_key_set: true,
llm_api_key: "**********",
});
renderSettingsScreen();

View File

@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.31.0",
"version": "0.30.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.31.0",
"version": "0.30.1",
"dependencies": {
"@heroui/react": "2.7.5",
"@monaco-editor/react": "^4.7.0-rc.0",
@@ -17140,9 +17140,9 @@
}
},
"node_modules/vite": {
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.4.tgz",
"integrity": "sha512-veHMSew8CcRzhL5o8ONjy8gkfmFJAd5Ac16oxBUjlwgX3Gq2Wqr+qNC3TjPIpy7TPV/KporLga5GT9HqdrCizw==",
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.3.tgz",
"integrity": "sha512-IzwM54g4y9JA/xAeBPNaDXiBF8Jsgl3VBQ2YQ/wOY6fyW3xMdSoltIV3Bo59DErdqdE6RxUfv8W69DvUorE4Eg==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",

View File

@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.31.0",
"version": "0.30.1",
"private": true,
"type": "module",
"engines": {

View File

@@ -1,4 +1,3 @@
import { GitRepository } from "#/types/git";
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
import { openHands } from "./open-hands-axios";
@@ -15,8 +14,8 @@ export const retrieveGitHubAppRepositories = async (
per_page = 30,
) => {
const installationId = installations[installationIndex];
const response = await openHands.get<GitRepository[]>(
"/api/user/repositories",
const response = await openHands.get<GitHubRepository[]>(
"/api/github/repositories",
{
params: {
sort: "pushed",
@@ -54,9 +53,12 @@ export const retrieveGitHubAppRepositories = async (
* Given a PAT, retrieves the repositories of the user
* @returns A list of repositories
*/
export const retrieveUserGitRepositories = async (page = 1, per_page = 30) => {
const response = await openHands.get<GitRepository[]>(
"/api/user/repositories",
export const retrieveGitHubUserRepositories = async (
page = 1,
per_page = 30,
) => {
const response = await openHands.get<GitHubRepository[]>(
"/api/github/repositories",
{
params: {
sort: "pushed",
@@ -66,7 +68,6 @@ export const retrieveUserGitRepositories = async (page = 1, per_page = 30) => {
},
);
// Check if any provider has more results
const link =
response.data.length > 0 && response.data[0].link_header
? response.data[0].link_header

View File

@@ -3,3 +3,22 @@ import axios from "axios";
export const openHands = axios.create({
baseURL: `${window.location.protocol}//${import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host}`,
});
export const setAuthTokenHeader = (token: string) => {
openHands.defaults.headers.common.Authorization = `Bearer ${token}`;
};
export const setGitHubTokenHeader = (token: string) => {
openHands.defaults.headers.common["X-GitHub-Token"] = token;
};
export const removeAuthTokenHeader = () => {
if (openHands.defaults.headers.common.Authorization) {
delete openHands.defaults.headers.common.Authorization;
}
};
export const removeGitHubTokenHeader = () => {
if (openHands.defaults.headers.common["X-GitHub-Token"]) {
delete openHands.defaults.headers.common["X-GitHub-Token"];
}
};

View File

@@ -14,7 +14,6 @@ import {
} from "./open-hands.types";
import { openHands } from "./open-hands-axios";
import { ApiSettings, PostApiSettings } from "#/types/settings";
import { GitUser, GitRepository } from "#/types/git";
class OpenHands {
/**
@@ -306,12 +305,12 @@ class OpenHands {
return data.credits;
}
static async getGitUser(): Promise<GitUser> {
const response = await openHands.get<GitUser>("/api/user/info");
static async getGitHubUser(): Promise<GitHubUser> {
const response = await openHands.get<GitHubUser>("/api/github/user");
const { data } = response;
const user: GitUser = {
const user: GitHubUser = {
id: data.id,
login: data.login,
avatar_url: data.avatar_url,
@@ -324,16 +323,16 @@ class OpenHands {
}
static async getGitHubUserInstallationIds(): Promise<number[]> {
const response = await openHands.get<number[]>("/api/user/installations");
const response = await openHands.get<number[]>("/api/github/installations");
return response.data;
}
static async searchGitRepositories(
static async searchGitHubRepositories(
query: string,
per_page = 5,
): Promise<GitRepository[]> {
const response = await openHands.get<GitRepository[]>(
"/api/user/search/repositories",
): Promise<GitHubRepository[]> {
const response = await openHands.get<GitHubRepository[]>(
"/api/github/search/repositories",
{
params: {
query,

View File

@@ -0,0 +1,6 @@
import { ErrorResponse, FileUploadSuccessResponse } from "./open-hands.types";
export const isOpenHandsErrorResponse = (
data: ErrorResponse | FileUploadSuccessResponse,
): data is ErrorResponse =>
typeof data === "object" && data !== null && "error" in data;

View File

@@ -12,43 +12,24 @@ interface ActionSuggestionsProps {
export function ActionSuggestions({
onSuggestionsClick,
}: ActionSuggestionsProps) {
const { providersAreSet } = useAuth();
const { githubTokenIsSet } = useAuth();
const { selectedRepository } = useSelector(
(state: RootState) => state.initialQuery,
);
const [hasPullRequest, setHasPullRequest] = React.useState(false);
const isGitLab =
selectedRepository !== null &&
selectedRepository.git_provider &&
selectedRepository.git_provider.toLowerCase() === "gitlab";
const pr = isGitLab ? "merge request" : "pull request";
const prShort = isGitLab ? "MR" : "PR";
const terms = {
pr,
prShort,
pushToBranch: `Please push the changes to a remote branch on ${
isGitLab ? "GitLab" : "GitHub"
}, but do NOT create a ${pr}. Please use the exact SAME branch name as the one you are currently on.`,
createPR: `Please push the changes to ${
isGitLab ? "GitLab" : "GitHub"
} and open a ${pr}. Please create a meaningful branch name that describes the changes.`,
pushToPR: `Please push the latest changes to the existing ${pr}.`,
};
return (
<div className="flex flex-col gap-2 mb-2">
{providersAreSet && selectedRepository && (
{githubTokenIsSet && selectedRepository && (
<div className="flex flex-row gap-2 justify-center w-full">
{!hasPullRequest ? (
<>
<SuggestionItem
suggestion={{
label: "Push to Branch",
value: terms.pushToBranch,
value:
"Please push the changes to a remote branch on GitHub, but do NOT create a pull request. Please use the exact SAME branch name as the one you are currently on.",
}}
onClick={(value) => {
posthog.capture("push_to_branch_button_clicked");
@@ -57,8 +38,9 @@ export function ActionSuggestions({
/>
<SuggestionItem
suggestion={{
label: `Push & Create ${terms.prShort}`,
value: terms.createPR,
label: "Push & Create PR",
value:
"Please push the changes to GitHub and open a pull request. Please create a meaningful branch name that describes the changes.",
}}
onClick={(value) => {
posthog.capture("create_pr_button_clicked");
@@ -70,8 +52,9 @@ export function ActionSuggestions({
) : (
<SuggestionItem
suggestion={{
label: `Push changes to ${terms.prShort}`,
value: terms.pushToPR,
label: "Push changes to PR",
value:
"Please push the latest changes to the existing pull request.",
}}
onClick={(value) => {
posthog.capture("push_to_pr_button_clicked");

View File

@@ -1,203 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import {
Autocomplete,
AutocompleteItem,
AutocompleteSection,
Spinner,
} from "@heroui/react";
import { useDispatch } from "react-redux";
import posthog from "posthog-js";
import { I18nKey } from "#/i18n/declaration";
import { setSelectedRepository } from "#/state/initial-query-slice";
import { useConfig } from "#/hooks/query/use-config";
import { sanitizeQuery } from "#/utils/sanitize-query";
import { GitRepository } from "#/types/git";
import { Provider, ProviderOptions } from "#/types/settings";
interface GitRepositorySelectorProps {
onInputChange: (value: string) => void;
onSelect: () => void;
userRepositories: GitRepository[];
publicRepositories: GitRepository[];
isLoading?: boolean;
}
export function GitRepositorySelector({
onInputChange,
onSelect,
userRepositories,
publicRepositories,
isLoading = false,
}: GitRepositorySelectorProps) {
const { t } = useTranslation();
const { data: config } = useConfig();
const [selectedKey, setSelectedKey] = React.useState<string | null>(null);
const allRepositories: GitRepository[] = [
...publicRepositories.filter(
(repo) => !userRepositories.find((r) => r.id === repo.id),
),
...userRepositories,
];
// Group repositories by provider
const groupedUserRepos = userRepositories.reduce<
Record<Provider, GitRepository[]>
>(
(acc, repo) => {
if (!acc[repo.git_provider]) {
acc[repo.git_provider] = [];
}
acc[repo.git_provider].push(repo);
return acc;
},
{} as Record<Provider, GitRepository[]>,
);
const groupedPublicRepos = publicRepositories.reduce<
Record<Provider, GitRepository[]>
>(
(acc, repo) => {
if (!acc[repo.git_provider]) {
acc[repo.git_provider] = [];
}
acc[repo.git_provider].push(repo);
return acc;
},
{} as Record<Provider, GitRepository[]>,
);
const dispatch = useDispatch();
const handleRepoSelection = (id: string | null) => {
const repo = allRepositories.find((r) => r.id.toString() === id);
if (repo) {
dispatch(setSelectedRepository(repo));
posthog.capture("repository_selected");
onSelect();
setSelectedKey(id);
}
};
const handleClearSelection = () => {
dispatch(setSelectedRepository(null));
};
const emptyContent = isLoading ? (
<div className="flex items-center justify-center py-2">
<Spinner size="sm" className="mr-2" />
<span>{t(I18nKey.GITHUB$LOADING_REPOSITORIES)}</span>
</div>
) : (
t(I18nKey.GITHUB$NO_RESULTS)
);
return (
<Autocomplete
data-testid="github-repo-selector"
name="repo"
aria-label="Git Repository"
placeholder={t(I18nKey.LANDING$SELECT_GIT_REPO)}
isVirtualized={false}
selectedKey={selectedKey}
inputProps={{
classNames: {
inputWrapper:
"text-sm w-full rounded-[4px] px-3 py-[10px] bg-[#525252] text-[#A3A3A3]",
},
endContent: isLoading ? <Spinner size="sm" /> : undefined,
}}
onSelectionChange={(id) => handleRepoSelection(id?.toString() ?? null)}
onInputChange={onInputChange}
clearButtonProps={{ onPress: handleClearSelection }}
listboxProps={{
emptyContent,
}}
defaultFilter={(textValue, inputValue) => {
if (!inputValue) return true;
const sanitizedInput = sanitizeQuery(inputValue);
const repo = allRepositories.find((r) => r.full_name === textValue);
if (!repo) return false;
const provider = repo.git_provider?.toLowerCase() as Provider;
const providerKeys = Object.keys(ProviderOptions) as Provider[];
// If input is exactly "git", show repos from any git-based provider
if (sanitizedInput === "git") {
return providerKeys.includes(provider);
}
// Provider based typeahead
for (const p of providerKeys) {
if (p.startsWith(sanitizedInput)) {
return provider === p;
}
}
// Default case: check if the repository name matches the input
return sanitizeQuery(textValue).includes(sanitizedInput);
}}
>
{config?.APP_MODE === "saas" &&
config?.APP_SLUG &&
((
<AutocompleteItem key="install">
<a
href={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
target="_blank"
rel="noreferrer noopener"
onClick={(e) => e.stopPropagation()}
>
{t(I18nKey.GITHUB$ADD_MORE_REPOS)}
</a>
</AutocompleteItem> // eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any)}
{Object.entries(groupedUserRepos).map(([provider, repos]) =>
repos.length > 0 ? (
<AutocompleteSection
key={`user-${provider}`}
showDivider
title={`${t(I18nKey.GITHUB$YOUR_REPOS)} - ${provider}`}
>
{repos.map((repo) => (
<AutocompleteItem
data-testid="github-repo-item"
key={repo.id}
className="data-[selected=true]:bg-default-100"
textValue={repo.full_name}
>
{repo.full_name}
</AutocompleteItem>
))}
</AutocompleteSection>
) : null,
)}
{Object.entries(groupedPublicRepos).map(([provider, repos]) =>
repos.length > 0 ? (
<AutocompleteSection
key={`public-${provider}`}
showDivider
title={`${t(I18nKey.GITHUB$PUBLIC_REPOS)} - ${provider}`}
>
{repos.map((repo) => (
<AutocompleteItem
data-testid="github-repo-item"
key={repo.id}
className="data-[selected=true]:bg-default-100"
textValue={repo.full_name}
>
{repo.full_name}
<span className="ml-1 text-gray-400">
({repo.stargazers_count || 0})
</span>
</AutocompleteItem>
))}
</AutocompleteSection>
) : null,
)}
</Autocomplete>
);
}

View File

@@ -5,7 +5,7 @@ import { setInitialPrompt } from "#/state/initial-query-slice";
const INITIAL_PROMPT = "";
export function CodeNotInGitLink() {
export function CodeNotInGitHubLink() {
const dispatch = useDispatch();
const { mutate: createConversation } = useCreateConversation();
@@ -17,7 +17,7 @@ export function CodeNotInGitLink() {
return (
<div className="text-xs text-neutral-400">
Code not in Git?{" "}
Code not in GitHub?{" "}
<span
onClick={handleStartFromScratch}
className="underline cursor-pointer"

View File

@@ -0,0 +1,129 @@
import React from "react";
import { useTranslation } from "react-i18next";
import {
Autocomplete,
AutocompleteItem,
AutocompleteSection,
} from "@heroui/react";
import { useDispatch } from "react-redux";
import posthog from "posthog-js";
import { I18nKey } from "#/i18n/declaration";
import { setSelectedRepository } from "#/state/initial-query-slice";
import { useConfig } from "#/hooks/query/use-config";
import { sanitizeQuery } from "#/utils/sanitize-query";
interface GitHubRepositorySelectorProps {
onInputChange: (value: string) => void;
onSelect: () => void;
userRepositories: GitHubRepository[];
publicRepositories: GitHubRepository[];
}
export function GitHubRepositorySelector({
onInputChange,
onSelect,
userRepositories,
publicRepositories,
}: GitHubRepositorySelectorProps) {
const { t } = useTranslation();
const { data: config } = useConfig();
const [selectedKey, setSelectedKey] = React.useState<string | null>(null);
const allRepositories: GitHubRepository[] = [
...publicRepositories.filter(
(repo) => !userRepositories.find((r) => r.id === repo.id),
),
...userRepositories,
];
const dispatch = useDispatch();
const handleRepoSelection = (id: string | null) => {
const repo = allRepositories.find((r) => r.id.toString() === id);
if (repo) {
dispatch(setSelectedRepository(repo.full_name));
posthog.capture("repository_selected");
onSelect();
setSelectedKey(id);
}
};
const handleClearSelection = () => {
dispatch(setSelectedRepository(null));
};
const emptyContent = t(I18nKey.GITHUB$NO_RESULTS);
return (
<Autocomplete
data-testid="github-repo-selector"
name="repo"
aria-label="GitHub Repository"
placeholder={t(I18nKey.LANDING$SELECT_REPO)}
isVirtualized={false}
selectedKey={selectedKey}
inputProps={{
classNames: {
inputWrapper:
"text-sm w-full rounded-[4px] px-3 py-[10px] bg-[#525252] text-[#A3A3A3]",
},
}}
onSelectionChange={(id) => handleRepoSelection(id?.toString() ?? null)}
onInputChange={onInputChange}
clearButtonProps={{ onPress: handleClearSelection }}
listboxProps={{
emptyContent,
}}
defaultFilter={(textValue, inputValue) =>
!inputValue ||
sanitizeQuery(textValue).includes(sanitizeQuery(inputValue))
}
>
{config?.APP_MODE === "saas" &&
config?.APP_SLUG &&
((
<AutocompleteItem key="install">
<a
href={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
target="_blank"
rel="noreferrer noopener"
onClick={(e) => e.stopPropagation()}
>
{t(I18nKey.GITHUB$ADD_MORE_REPOS)}
</a>
</AutocompleteItem> // eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any)}
{userRepositories.length > 0 && (
<AutocompleteSection showDivider title={t(I18nKey.GITHUB$YOUR_REPOS)}>
{userRepositories.map((repo) => (
<AutocompleteItem
data-testid="github-repo-item"
key={repo.id}
className="data-[selected=true]:bg-default-100"
textValue={repo.full_name}
>
{repo.full_name}
</AutocompleteItem>
))}
</AutocompleteSection>
)}
{publicRepositories.length > 0 && (
<AutocompleteSection showDivider title={t(I18nKey.GITHUB$PUBLIC_REPOS)}>
{publicRepositories.map((repo) => (
<AutocompleteItem
data-testid="github-repo-item"
key={repo.id}
className="data-[selected=true]:bg-default-100"
textValue={repo.full_name}
>
{repo.full_name}
<span className="ml-1 text-gray-400">
({repo.stargazers_count || 0})
</span>
</AutocompleteItem>
))}
</AutocompleteSection>
)}
</Autocomplete>
);
}

View File

@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import { I18nKey } from "#/i18n/declaration";
import { SuggestionBox } from "#/components/features/suggestions/suggestion-box";
import { GitRepositorySelector } from "./git-repo-selector";
import { GitHubRepositorySelector } from "./github-repo-selector";
import { useAppRepositories } from "#/hooks/query/use-app-repositories";
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
@@ -11,35 +11,29 @@ import { sanitizeQuery } from "#/utils/sanitize-query";
import { useDebounce } from "#/hooks/use-debounce";
import { BrandButton } from "../settings/brand-button";
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
import { GitHubErrorReponse, GitUser } from "#/types/git";
interface GitRepositoriesSuggestionBoxProps {
interface GitHubRepositoriesSuggestionBoxProps {
handleSubmit: () => void;
gitHubAuthUrl: string | null;
user: GitHubErrorReponse | GitUser | null;
user: GitHubErrorReponse | GitHubUser | null;
}
export function GitRepositoriesSuggestionBox({
export function GitHubRepositoriesSuggestionBox({
handleSubmit,
gitHubAuthUrl,
user,
}: GitRepositoriesSuggestionBoxProps) {
}: GitHubRepositoriesSuggestionBoxProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = React.useState<string>("");
const debouncedSearchQuery = useDebounce(searchQuery, 300);
// TODO: Use `useQueries` to fetch all repositories in parallel
const { data: appRepositories, isLoading: isAppReposLoading } =
useAppRepositories();
const { data: userRepositories, isLoading: isUserReposLoading } =
useUserRepositories();
const { data: searchedRepos, isLoading: isSearchReposLoading } =
useSearchRepositories(sanitizeQuery(debouncedSearchQuery));
const isLoading =
isAppReposLoading || isUserReposLoading || isSearchReposLoading;
const { data: appRepositories } = useAppRepositories();
const { data: userRepositories } = useUserRepositories();
const { data: searchedRepos } = useSearchRepositories(
sanitizeQuery(debouncedSearchQuery),
);
const repositories =
userRepositories?.pages.flatMap((page) => page.data) ||
@@ -61,12 +55,11 @@ export function GitRepositoriesSuggestionBox({
title={t(I18nKey.LANDING$OPEN_REPO)}
content={
isLoggedIn ? (
<GitRepositorySelector
<GitHubRepositorySelector
onInputChange={setSearchQuery}
onSelect={handleSubmit}
publicRepositories={searchedRepos || []}
userRepositories={repositories}
isLoading={isLoading}
/>
) : (
<BrandButton

View File

@@ -3,7 +3,7 @@ import { FaListUl } from "react-icons/fa";
import { useDispatch } from "react-redux";
import posthog from "posthog-js";
import { NavLink, useLocation } from "react-router";
import { useGitUser } from "#/hooks/query/use-git-user";
import { useGitHubUser } from "#/hooks/query/use-github-user";
import { UserActions } from "./user-actions";
import { AllHandsLogoButton } from "#/components/shared/buttons/all-hands-logo-button";
import { DocsButton } from "#/components/shared/buttons/docs-button";
@@ -26,7 +26,7 @@ export function Sidebar() {
const location = useLocation();
const dispatch = useDispatch();
const endSession = useEndSession();
const user = useGitUser();
const user = useGitHubUser();
const { data: config } = useConfig();
const {
data: settings,

View File

@@ -50,7 +50,7 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
posthog.capture("settings_saved", {
LLM_MODEL: newSettings.LLM_MODEL,
LLM_API_KEY_SET: newSettings.LLM_API_KEY_SET ? "SET" : "UNSET",
LLM_API_KEY: newSettings.LLM_API_KEY ? "SET" : "UNSET",
REMOTE_RUNTIME_RESOURCE_FACTOR:
newSettings.REMOTE_RUNTIME_RESOURCE_FACTOR,
});
@@ -74,7 +74,7 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
}
};
const isLLMKeySet = settings.LLM_API_KEY_SET;
const isLLMKeySet = settings.LLM_API_KEY === "**********";
return (
<div>

View File

@@ -1,37 +1,27 @@
import React from "react";
import { Provider } from "#/types/settings";
interface AuthContextType {
providerTokensSet: Provider[];
setProviderTokensSet: (tokens: Provider[]) => void;
providersAreSet: boolean;
setProvidersAreSet: (status: boolean) => void;
githubTokenIsSet: boolean;
setGitHubTokenIsSet: (value: boolean) => void;
}
interface AuthContextProps extends React.PropsWithChildren {
initialProviderTokens?: Provider[];
initialGithubTokenIsSet?: boolean;
}
const AuthContext = React.createContext<AuthContextType | undefined>(undefined);
function AuthProvider({
children,
initialProviderTokens = [],
}: AuthContextProps) {
const [providerTokensSet, setProviderTokensSet] = React.useState<Provider[]>(
initialProviderTokens,
function AuthProvider({ children, initialGithubTokenIsSet }: AuthContextProps) {
const [githubTokenIsSet, setGitHubTokenIsSet] = React.useState(
!!initialGithubTokenIsSet,
);
const [providersAreSet, setProvidersAreSet] = React.useState<boolean>(false);
const value = React.useMemo(
() => ({
providerTokensSet,
setProviderTokensSet,
providersAreSet,
setProvidersAreSet,
githubTokenIsSet,
setGitHubTokenIsSet,
}),
[providerTokensSet],
[githubTokenIsSet, setGitHubTokenIsSet],
);
return <AuthContext value={value}>{children}</AuthContext>;

View File

@@ -17,10 +17,19 @@ export const useCreateConversation = () => {
return useMutation({
mutationFn: async (variables: { q?: string }) => {
if (
!variables.q?.trim() &&
!selectedRepository &&
files.length === 0 &&
!replayJson
) {
throw new Error("No query provided");
}
if (variables.q) dispatch(setInitialPrompt(variables.q));
return OpenHands.createConversation(
selectedRepository?.full_name || undefined,
selectedRepository || undefined,
variables.q,
files,
replayJson || undefined,

View File

@@ -4,7 +4,7 @@ import { useAuth } from "#/context/auth-context";
import { useConfig } from "../query/use-config";
export const useLogout = () => {
const { setProviderTokensSet, setProvidersAreSet } = useAuth();
const { setGitHubTokenIsSet } = useAuth();
const queryClient = useQueryClient();
const { data: config } = useConfig();
@@ -20,8 +20,7 @@ export const useLogout = () => {
queryClient.removeQueries({ queryKey: ["settings"] });
// Update token state - this will trigger a settings refetch since it's part of the query key
setProviderTokensSet([]);
setProvidersAreSet(false);
setGitHubTokenIsSet(false);
},
});
};

View File

@@ -21,14 +21,14 @@ const saveSettingsMutationFn = async (
confirmation_mode: settings.CONFIRMATION_MODE,
security_analyzer: settings.SECURITY_ANALYZER,
llm_api_key:
settings.llm_api_key === ""
settings.LLM_API_KEY === ""
? ""
: settings.llm_api_key?.trim() || undefined,
: settings.LLM_API_KEY?.trim() || undefined,
remote_runtime_resource_factor: settings.REMOTE_RUNTIME_RESOURCE_FACTOR,
provider_tokens: settings.provider_tokens,
enable_default_condenser: settings.ENABLE_DEFAULT_CONDENSER,
enable_sound_notifications: settings.ENABLE_SOUND_NOTIFICATIONS,
user_consents_to_analytics: settings.user_consents_to_analytics,
provider_tokens: settings.provider_tokens,
};
await OpenHands.saveSettings(apiSettings);

View File

@@ -5,13 +5,13 @@ import { useAuth } from "#/context/auth-context";
export const useAppInstallations = () => {
const { data: config } = useConfig();
const { providersAreSet } = useAuth();
const { githubTokenIsSet } = useAuth();
return useQuery({
queryKey: ["installations", providersAreSet, config?.GITHUB_CLIENT_ID],
queryKey: ["installations", githubTokenIsSet, config?.GITHUB_CLIENT_ID],
queryFn: OpenHands.getGitHubUserInstallationIds,
enabled:
providersAreSet &&
githubTokenIsSet &&
!!config?.GITHUB_CLIENT_ID &&
config?.APP_MODE === "saas",
staleTime: 1000 * 60 * 5, // 5 minutes

View File

@@ -1,17 +1,17 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import React from "react";
import { retrieveGitHubAppRepositories } from "#/api/git";
import { retrieveGitHubAppRepositories } from "#/api/github";
import { useAppInstallations } from "./use-app-installations";
import { useConfig } from "./use-config";
import { useAuth } from "#/context/auth-context";
export const useAppRepositories = () => {
const { providersAreSet } = useAuth();
const { githubTokenIsSet } = useAuth();
const { data: config } = useConfig();
const { data: installations } = useAppInstallations();
const repos = useInfiniteQuery({
queryKey: ["repositories", providersAreSet, installations],
queryKey: ["repositories", githubTokenIsSet, installations],
queryFn: async ({
pageParam,
}: {
@@ -46,7 +46,7 @@ export const useAppRepositories = () => {
return null;
},
enabled:
providersAreSet &&
githubTokenIsSet &&
Array.isArray(installations) &&
installations.length > 0 &&
config?.APP_MODE === "saas",

View File

@@ -6,16 +6,16 @@ import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
import { useLogout } from "../mutation/use-logout";
export const useGitUser = () => {
const { providersAreSet, providerTokensSet } = useAuth();
export const useGitHubUser = () => {
const { githubTokenIsSet } = useAuth();
const { mutateAsync: logout } = useLogout();
const { data: config } = useConfig();
const user = useQuery({
queryKey: ["user", providerTokensSet],
queryFn: OpenHands.getGitUser,
enabled: providersAreSet && !!config?.APP_MODE,
queryKey: ["user", githubTokenIsSet],
queryFn: OpenHands.getGitHubUser,
enabled: githubTokenIsSet && !!config?.APP_MODE,
retry: false,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes

View File

@@ -5,13 +5,13 @@ import { useConfig } from "./use-config";
import { useAuth } from "#/context/auth-context";
export const useIsAuthed = () => {
const { providersAreSet } = useAuth();
const { githubTokenIsSet } = useAuth();
const { data: config } = useConfig();
const appMode = React.useMemo(() => config?.APP_MODE, [config]);
return useQuery({
queryKey: ["user", "authenticated", providersAreSet, appMode],
queryKey: ["user", "authenticated", githubTokenIsSet, appMode],
queryFn: () => OpenHands.authenticate(appMode!),
enabled: !!appMode,
staleTime: 1000 * 60 * 5, // 5 minutes

View File

@@ -4,7 +4,7 @@ import OpenHands from "#/api/open-hands";
export function useSearchRepositories(query: string) {
return useQuery({
queryKey: ["repositories", query],
queryFn: () => OpenHands.searchGitRepositories(query, 3),
queryFn: () => OpenHands.searchGitHubRepositories(query, 3),
enabled: !!query,
select: (data) => data.map((repo) => ({ ...repo, is_public: true })),
staleTime: 1000 * 60 * 5, // 5 minutes

View File

@@ -15,9 +15,9 @@ const getSettingsQueryFn = async () => {
LANGUAGE: apiSettings.language,
CONFIRMATION_MODE: apiSettings.confirmation_mode,
SECURITY_ANALYZER: apiSettings.security_analyzer,
LLM_API_KEY_SET: apiSettings.llm_api_key_set,
LLM_API_KEY: apiSettings.llm_api_key,
REMOTE_RUNTIME_RESOURCE_FACTOR: apiSettings.remote_runtime_resource_factor,
PROVIDER_TOKENS_SET: apiSettings.provider_tokens_set,
GITHUB_TOKEN_IS_SET: apiSettings.github_token_is_set,
ENABLE_DEFAULT_CONDENSER: apiSettings.enable_default_condenser,
ENABLE_SOUND_NOTIFICATIONS: apiSettings.enable_sound_notifications,
USER_CONSENTS_TO_ANALYTICS: apiSettings.user_consents_to_analytics,
@@ -27,11 +27,10 @@ const getSettingsQueryFn = async () => {
};
export const useSettings = () => {
const { setProviderTokensSet, providerTokensSet, setProvidersAreSet } =
useAuth();
const { setGitHubTokenIsSet, githubTokenIsSet } = useAuth();
const query = useQuery({
queryKey: ["settings", providerTokensSet],
queryKey: ["settings", githubTokenIsSet],
queryFn: getSettingsQueryFn,
// Only retry if the error is not a 404 because we
// would want to show the modal immediately if the
@@ -45,24 +44,14 @@ export const useSettings = () => {
});
React.useEffect(() => {
if (query.isFetched && query.data?.LLM_API_KEY_SET) {
if (query.isFetched && query.data?.LLM_API_KEY) {
posthog.capture("user_activated");
}
}, [query.data?.LLM_API_KEY_SET, query.isFetched]);
}, [query.data?.LLM_API_KEY, query.isFetched]);
React.useEffect(() => {
if (query.data?.PROVIDER_TOKENS_SET) {
const providers = query.data.PROVIDER_TOKENS_SET;
const setProviders = (
Object.keys(providers) as Array<keyof typeof providers>
).filter((key) => providers[key]);
setProviderTokensSet(setProviders);
const atLeastOneSet = Object.values(query.data.PROVIDER_TOKENS_SET).some(
(value) => value,
);
setProvidersAreSet(atLeastOneSet);
}
}, [query.data?.PROVIDER_TOKENS_SET, query.isFetched]);
if (query.isFetched) setGitHubTokenIsSet(!!query.data?.GITHUB_TOKEN_IS_SET);
}, [query.data?.GITHUB_TOKEN_IS_SET, query.isFetched]);
// We want to return the defaults if the settings aren't found so the user can still see the
// options to make their initial save. We don't set the defaults in `initialData` above because

View File

@@ -1,20 +1,20 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import React from "react";
import { retrieveUserGitRepositories } from "#/api/git";
import { retrieveGitHubUserRepositories } from "#/api/github";
import { useConfig } from "./use-config";
import { useAuth } from "#/context/auth-context";
export const useUserRepositories = () => {
const { providerTokensSet, providersAreSet } = useAuth();
const { githubTokenIsSet } = useAuth();
const { data: config } = useConfig();
const repos = useInfiniteQuery({
queryKey: ["repositories", providerTokensSet],
queryKey: ["repositories", githubTokenIsSet],
queryFn: async ({ pageParam }) =>
retrieveUserGitRepositories(pageParam, 100),
retrieveGitHubUserRepositories(pageParam, 100),
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage,
enabled: providersAreSet && config?.APP_MODE === "oss",
enabled: githubTokenIsSet && config?.APP_MODE === "oss",
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});

View File

@@ -9,15 +9,15 @@ interface UseGitHubAuthUrlConfig {
}
export const useGitHubAuthUrl = (config: UseGitHubAuthUrlConfig) => {
const { providersAreSet } = useAuth();
const { githubTokenIsSet } = useAuth();
return React.useMemo(() => {
if (config.appMode === "saas" && !providersAreSet)
if (config.appMode === "saas" && !githubTokenIsSet)
return generateGitHubAuthUrl(
config.gitHubClientId || "",
new URL(window.location.href),
);
return null;
}, [providersAreSet, config.appMode, config.gitHubClientId]);
}, [githubTokenIsSet, config.appMode, config.gitHubClientId]);
};

View File

@@ -151,7 +151,6 @@ export enum I18nKey {
LANDING$CHANGE_PROMPT = "LANDING$CHANGE_PROMPT",
GITHUB$CONNECT = "GITHUB$CONNECT",
GITHUB$NO_RESULTS = "GITHUB$NO_RESULTS",
GITHUB$LOADING_REPOSITORIES = "GITHUB$LOADING_REPOSITORIES",
GITHUB$ADD_MORE_REPOS = "GITHUB$ADD_MORE_REPOS",
GITHUB$YOUR_REPOS = "GITHUB$YOUR_REPOS",
GITHUB$PUBLIC_REPOS = "GITHUB$PUBLIC_REPOS",
@@ -306,7 +305,7 @@ export enum I18nKey {
ACCOUNT_SETTINGS$LOGOUT = "ACCOUNT_SETTINGS$LOGOUT",
SETTINGS_FORM$ADVANCED_OPTIONS_LABEL = "SETTINGS_FORM$ADVANCED_OPTIONS_LABEL",
CONVERSATION$NO_CONVERSATIONS = "CONVERSATION$NO_CONVERSATIONS",
LANDING$SELECT_GIT_REPO = "LANDING$SELECT_GIT_REPO",
LANDING$SELECT_REPO = "LANDING$SELECT_REPO",
BUTTON$SEND = "BUTTON$SEND",
STATUS$WAITING_FOR_CLIENT = "STATUS$WAITING_FOR_CLIENT",
SUGGESTIONS$WHAT_TO_BUILD = "SUGGESTIONS$WHAT_TO_BUILD",

View File

@@ -2078,7 +2078,6 @@
"tr": "Ajan hız sınırına ulaştı",
"ja": "エージェントがレート制限中"
},
"CHAT_INTERFACE$AGENT_PAUSED_MESSAGE": {
"en": "Agent has paused.",
"de": "Agent pausiert.",
@@ -2239,19 +2238,7 @@
"es": "No se encontraron resultados.",
"de": "Keine Ergebnisse gefunden.",
"it": "Nessun risultato trovato.",
"pt": "Nenhum resultado encontrado."
},
"GITHUB$LOADING_REPOSITORIES": {
"en": "Loading repositories...",
"ja": "リポジトリを読み込み中...",
"zh-CN": "正在加载仓库...",
"zh-TW": "正在加載倉庫...",
"ko-KR": "저장소 로딩 중...",
"fr": "Chargement des dépôts...",
"es": "Cargando repositorios...",
"de": "Lade Repositories...",
"it": "Caricamento dei repository...",
"pt": "Carregando repositórios...",
"pt": "Nenhum resultado encontrado.",
"ar": "لم يتم العثور على نتائج.",
"no": "Ingen resultater funnet.",
"tr": "Sonuç bulunamadı"
@@ -4566,19 +4553,19 @@
"no": "Ingen samtaler funnet",
"tr": "Konuşma yok"
},
"LANDING$SELECT_GIT_REPO": {
"en": "Select a Git project",
"ja": "Gitプロジェクトを選択",
"zh-CN": "选择Git项目",
"zh-TW": "選擇 Git 專案",
"ko-KR": "Git 프로젝트 선택",
"fr": "Sélectionner un projet Git",
"es": "Seleccionar un proyecto de Git",
"de": "Ein Git-Projekt auswählen",
"it": "Seleziona un progetto Git",
"pt": "Selecionar um projeto do Git",
"ar": "اختر مشروع Git",
"no": "Velg et Git-prosjekt",
"LANDING$SELECT_REPO": {
"en": "Select a GitHub project",
"ja": "GitHubプロジェクトを選択",
"zh-CN": "选择GitHub项目",
"zh-TW": "選擇 GitHub 專案",
"ko-KR": "GitHub 프로젝트 선택",
"fr": "Sélectionner un projet GitHub",
"es": "Seleccionar un proyecto de GitHub",
"de": "Ein GitHub-Projekt auswählen",
"it": "Seleziona un progetto GitHub",
"pt": "Selecionar um projeto do GitHub",
"ar": "اختر مشروع GitHub",
"no": "Velg et GitHub-prosjekt",
"tr": "Depo seç"
},
"BUTTON$SEND": {

View File

@@ -7,20 +7,18 @@ import {
import { DEFAULT_SETTINGS } from "#/services/settings";
import { STRIPE_BILLING_HANDLERS } from "./billing-handlers";
import { ApiSettings, PostApiSettings } from "#/types/settings";
import { GitUser } from "#/types/git";
export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
llm_base_url: DEFAULT_SETTINGS.LLM_BASE_URL,
llm_api_key: null,
llm_api_key_set: DEFAULT_SETTINGS.LLM_API_KEY_SET,
llm_api_key: DEFAULT_SETTINGS.LLM_API_KEY,
agent: DEFAULT_SETTINGS.AGENT,
language: DEFAULT_SETTINGS.LANGUAGE,
confirmation_mode: DEFAULT_SETTINGS.CONFIRMATION_MODE,
security_analyzer: DEFAULT_SETTINGS.SECURITY_ANALYZER,
remote_runtime_resource_factor:
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
provider_tokens_set: DEFAULT_SETTINGS.PROVIDER_TOKENS_SET,
github_token_is_set: DEFAULT_SETTINGS.GITHUB_TOKEN_IS_SET,
enable_default_condenser: DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER,
enable_sound_notifications: DEFAULT_SETTINGS.ENABLE_SOUND_NOTIFICATIONS,
user_consents_to_analytics: DEFAULT_SETTINGS.USER_CONSENTS_TO_ANALYTICS,
@@ -150,14 +148,14 @@ const openHandsHandlers = [
export const handlers = [
...STRIPE_BILLING_HANDLERS,
...openHandsHandlers,
http.get("/api/user/repositories", () =>
http.get("/api/github/repositories", () =>
HttpResponse.json([
{ id: 1, full_name: "octocat/hello-world" },
{ id: 2, full_name: "octocat/earth" },
]),
),
http.get("/api/user/info", () => {
const user: GitUser = {
http.get("/api/github/user", () => {
const user: GitHubUser = {
id: 1,
login: "octocat",
avatar_url: "https://avatars.githubusercontent.com/u/583231?v=4",
@@ -192,13 +190,12 @@ export const handlers = [
}),
http.get("/api/settings", async () => {
await delay();
const { settings } = MOCK_USER_PREFERENCES;
if (!settings) return HttpResponse.json(null, { status: 404 });
if (Object.keys(settings.provider_tokens_set).length > 0)
settings.provider_tokens_set = { github: false, gitlab: false };
if (Object.keys(settings.provider_tokens).length > 0)
settings.github_token_is_set = true;
return HttpResponse.json(settings);
}),

View File

@@ -2,12 +2,12 @@ import React from "react";
import { useDispatch } from "react-redux";
import posthog from "posthog-js";
import { setReplayJson } from "#/state/initial-query-slice";
import { useGitUser } from "#/hooks/query/use-git-user";
import { useGitHubUser } from "#/hooks/query/use-github-user";
import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
import { useConfig } from "#/hooks/query/use-config";
import { GitRepositoriesSuggestionBox } from "#/components/features/git/git-repositories-suggestion-box";
import { ReplaySuggestionBox } from "../../components/features/suggestions/replay-suggestion-box";
import { CodeNotInGitLink } from "#/components/features/git/code-not-in-github-link";
import { GitHubRepositoriesSuggestionBox } from "#/components/features/github/github-repositories-suggestion-box";
import { CodeNotInGitHubLink } from "#/components/features/github/code-not-in-github-link";
import { HeroHeading } from "#/components/shared/hero-heading";
import { TaskForm } from "#/components/shared/task-form";
import { convertFileToText } from "#/utils/convert-file-to-text";
@@ -18,7 +18,7 @@ function Home() {
const formRef = React.useRef<HTMLFormElement>(null);
const { data: config } = useConfig();
const { data: user } = useGitUser();
const { data: user } = useGitHubUser();
const gitHubAuthUrl = useGitHubAuthUrl({
appMode: config?.APP_MODE || null,
@@ -37,7 +37,7 @@ function Home() {
</div>
<div className="flex gap-4 w-full flex-col md:flex-row mt-8">
<GitRepositoriesSuggestionBox
<GitHubRepositoriesSuggestionBox
handleSubmit={() => formRef.current?.requestSubmit()}
gitHubAuthUrl={gitHubAuthUrl}
user={user || null}
@@ -58,7 +58,7 @@ function Home() {
)}
</div>
<div className="w-full flex justify-start mt-2 ml-2">
<CodeNotInGitLink />
<CodeNotInGitHubLink />
</div>
</div>
</div>

View File

@@ -58,7 +58,7 @@ export default function MainApp() {
const navigate = useNavigate();
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const { providersAreSet } = useAuth();
const { githubTokenIsSet } = useAuth();
const { data: settings } = useSettings();
const { error, isFetching } = useBalance();
const { migrateUserConsent } = useMigrateUserConsent();
@@ -131,7 +131,7 @@ export default function MainApp() {
{renderWaitlistModal && (
<WaitlistModal
ghTokenIsSet={providersAreSet}
ghTokenIsSet={githubTokenIsSet}
githubAuthUrl={gitHubAuthUrl}
/>
)}

View File

@@ -25,8 +25,6 @@ import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { ProviderOptions } from "#/types/settings";
import { useAuth } from "#/context/auth-context";
const REMOTE_RUNTIME_OPTIONS = [
{ key: 1, label: "1x (2 core, 8G)" },
@@ -48,7 +46,6 @@ function AccountSettings() {
} = useAIConfigOptions();
const { mutate: saveSettings } = useSaveSettings();
const { handleLogout } = useAppLogout();
const { providerTokensSet, providersAreSet } = useAuth();
const isFetching = isFetchingSettings || isFetchingResources;
const isSuccess = isSuccessfulSettings && isSuccessfulResources;
@@ -65,6 +62,7 @@ function AccountSettings() {
isCustomModel(resources.models, settings.LLM_MODEL) ||
hasAdvancedSettingsSet({
...settings,
PROVIDER_TOKENS: settings.PROVIDER_TOKENS || {},
})
);
}
@@ -73,11 +71,8 @@ function AccountSettings() {
};
const hasAppSlug = !!config?.APP_SLUG;
const isGitHubTokenSet =
providerTokensSet.includes(ProviderOptions.github) || false;
const isGitLabTokenSet =
providerTokensSet.includes(ProviderOptions.gitlab) || false;
const isLLMKeySet = settings?.LLM_API_KEY_SET;
const isGitHubTokenSet = settings?.GITHUB_TOKEN_IS_SET;
const isLLMKeySet = settings?.LLM_API_KEY === "**********";
const isAnalyticsEnabled = settings?.USER_CONSENTS_TO_ANALYTICS;
const isAdvancedSettingsSet = determineWhetherToToggleAdvancedSettings();
@@ -120,14 +115,12 @@ function AccountSettings() {
const enableSoundNotifications =
formData.get("enable-sound-notifications-switch")?.toString() === "on";
const llmBaseUrl = formData.get("base-url-input")?.toString() || "";
const inputApiKey = formData.get("llm-api-key-input")?.toString() || "";
const llmApiKey =
inputApiKey === "" && isLLMKeySet
? undefined // don't update if it's already set and input is empty
: inputApiKey; // otherwise use the input value
formData.get("llm-api-key-input")?.toString() ||
(isLLMKeySet
? undefined // don't update if it's already set
: ""); // reset if it's first time save to avoid 500 error
const githubToken = formData.get("github-token-input")?.toString();
const gitlabToken = formData.get("gitlab-token-input")?.toString();
// we don't want the user to be able to modify these settings in SaaS
const finalLlmModel = shouldHandleSpecialSaasCase
? undefined
@@ -137,21 +130,22 @@ function AccountSettings() {
: llmBaseUrl;
const finalLlmApiKey = shouldHandleSpecialSaasCase ? undefined : llmApiKey;
const githubToken = formData.get("github-token-input")?.toString();
const newSettings = {
provider_tokens:
githubToken || gitlabToken
? {
github: githubToken || "",
gitlab: gitlabToken || "",
}
: undefined,
github_token: githubToken,
provider_tokens: githubToken
? {
github: githubToken,
gitlab: "",
}
: undefined,
LANGUAGE: languageValue,
user_consents_to_analytics: userConsentsToAnalytics,
ENABLE_DEFAULT_CONDENSER: enableMemoryCondenser,
ENABLE_SOUND_NOTIFICATIONS: enableSoundNotifications,
LLM_MODEL: finalLlmModel,
LLM_BASE_URL: finalLlmBaseUrl,
llm_api_key: finalLlmApiKey,
LLM_API_KEY: finalLlmApiKey,
AGENT: formData.get("agent-input")?.toString(),
SECURITY_ANALYZER:
formData.get("security-analyzer-input")?.toString() || "",
@@ -277,10 +271,10 @@ function AccountSettings() {
label="API Key"
type="password"
className="w-[680px]"
placeholder={isLLMKeySet ? "<hidden>" : ""}
startContent={
isLLMKeySet && <KeyStatusIcon isSet={isLLMKeySet} />
}
placeholder={isLLMKeySet ? "<hidden>" : ""}
/>
)}
@@ -373,7 +367,7 @@ function AccountSettings() {
<section className="flex flex-col gap-6">
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
Git Provider Settings
GitHub Settings
</h2>
{isSaas && hasAppSlug && (
<Link
@@ -428,58 +422,17 @@ function AccountSettings() {
</b>
.
</p>
<SettingsInput
testId="gitlab-token-input"
name="gitlab-token-input"
label="GitLab Token"
type="password"
className="w-[680px]"
startContent={
isGitLabTokenSet && (
<KeyStatusIcon isSet={!!isGitLabTokenSet} />
)
}
placeholder={isGitHubTokenSet ? "<hidden>" : ""}
/>
<p data-testId="gitlab-token-help-anchor" className="text-xs">
{" "}
Generate a token on{" "}
<b>
{" "}
<a
href="https://gitlab.com/-/user_settings/personal_access_tokens?name=openhands-app&scopes=api,read_user,read_repository,write_repository"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
>
GitLab
</a>{" "}
</b>
or see the{" "}
<b>
<a
href="https://docs.gitlab.com/user/profile/personal_access_tokens/"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
>
documentation
</a>
</b>
.
</p>
<BrandButton
type="button"
variant="secondary"
onClick={handleLogout}
isDisabled={!providersAreSet}
>
Disconnect Tokens
</BrandButton>
</>
)}
<BrandButton
type="button"
variant="secondary"
onClick={handleLogout}
isDisabled={!isGitHubTokenSet}
>
Disconnect from GitHub
</BrandButton>
</section>
<section className="flex flex-col gap-6">

View File

@@ -7,11 +7,11 @@ export const DEFAULT_SETTINGS: Settings = {
LLM_BASE_URL: "",
AGENT: "CodeActAgent",
LANGUAGE: "en",
LLM_API_KEY_SET: false,
LLM_API_KEY: null,
CONFIRMATION_MODE: false,
SECURITY_ANALYZER: "",
REMOTE_RUNTIME_RESOURCE_FACTOR: 1,
PROVIDER_TOKENS_SET: { github: false, gitlab: false },
GITHUB_TOKEN_IS_SET: false,
ENABLE_DEFAULT_CONDENSER: true,
ENABLE_SOUND_NOTIFICATIONS: false,
USER_CONSENTS_TO_ANALYTICS: false,

View File

@@ -1,12 +1,9 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { Provider } from "#/types/settings";
import { GitRepository } from "#/types/git";
type SliceState = {
files: string[]; // base64 encoded images
initialPrompt: string | null;
selectedRepository: GitRepository | null;
selectedRepositoryProvider: Provider | null;
selectedRepository: string | null;
replayJson: string | null;
};
@@ -14,7 +11,6 @@ const initialState: SliceState = {
files: [],
initialPrompt: null,
selectedRepository: null,
selectedRepositoryProvider: null,
replayJson: null,
};
@@ -37,7 +33,7 @@ export const selectedFilesSlice = createSlice({
clearInitialPrompt(state) {
state.initialPrompt = null;
},
setSelectedRepository(state, action: PayloadAction<GitRepository | null>) {
setSelectedRepository(state, action: PayloadAction<string | null>) {
state.selectedRepository = action.payload;
},
clearSelectedRepository(state) {

View File

@@ -2,14 +2,14 @@ enum ArgConfigType {
LLM_MODEL = "LLM_MODEL",
AGENT = "AGENT",
LANGUAGE = "LANGUAGE",
LLM_API_KEY_SET = "LLM_API_KEY_SET",
LLM_API_KEY = "LLM_API_KEY",
}
const SupportedSettings: string[] = [
ArgConfigType.LLM_MODEL,
ArgConfigType.AGENT,
ArgConfigType.LANGUAGE,
ArgConfigType.LLM_API_KEY_SET,
ArgConfigType.LLM_API_KEY,
];
export { ArgConfigType, SupportedSettings };

View File

@@ -17,7 +17,7 @@ export interface InitConfig {
AGENT: string;
CONFIRMATION_MODE: boolean;
LANGUAGE: string;
LLM_API_KEY_SET: boolean;
LLM_API_KEY: string;
LLM_MODEL: string;
};
token?: string;

View File

@@ -1,12 +1,10 @@
import { Provider } from "#/types/settings";
interface GitHubErrorReponse {
message: string;
documentation_url: string;
status: number;
}
interface GitUser {
interface GitHubUser {
id: number;
login: string;
avatar_url: string;
@@ -15,10 +13,9 @@ interface GitUser {
email: string | null;
}
interface GitRepository {
interface GitHubRepository {
id: number;
full_name: string;
git_provider: Provider;
stargazers_count?: number;
link_header?: string;
}

View File

@@ -1,20 +1,15 @@
export const ProviderOptions = {
github: "github",
gitlab: "gitlab",
} as const;
export type Provider = keyof typeof ProviderOptions;
export type Provider = "github" | "gitlab";
export type Settings = {
LLM_MODEL: string;
LLM_BASE_URL: string;
AGENT: string;
LANGUAGE: string;
LLM_API_KEY_SET: boolean;
LLM_API_KEY: string | null;
CONFIRMATION_MODE: boolean;
SECURITY_ANALYZER: string;
REMOTE_RUNTIME_RESOURCE_FACTOR: number | null;
PROVIDER_TOKENS_SET: Record<Provider, boolean>;
GITHUB_TOKEN_IS_SET: boolean;
ENABLE_DEFAULT_CONDENSER: boolean;
ENABLE_SOUND_NOTIFICATIONS: boolean;
USER_CONSENTS_TO_ANALYTICS: boolean | null;
@@ -28,21 +23,19 @@ export type ApiSettings = {
agent: string;
language: string;
llm_api_key: string | null;
llm_api_key_set: boolean;
confirmation_mode: boolean;
security_analyzer: string;
remote_runtime_resource_factor: number | null;
github_token_is_set: boolean;
enable_default_condenser: boolean;
enable_sound_notifications: boolean;
user_consents_to_analytics: boolean | null;
provider_tokens: Record<Provider, string>;
provider_tokens_set: Record<Provider, boolean>;
};
export type PostSettings = Settings & {
provider_tokens: Record<Provider, string>;
user_consents_to_analytics: boolean | null;
llm_api_key?: string | null;
};
export type PostApiSettings = ApiSettings & {

View File

@@ -1,7 +1,7 @@
import { DEFAULT_SETTINGS } from "#/services/settings";
import { Settings } from "#/types/settings";
export const hasAdvancedSettingsSet = (settings: Partial<Settings>): boolean =>
export const hasAdvancedSettingsSet = (settings: Settings): boolean =>
!!settings.LLM_BASE_URL ||
settings.AGENT !== DEFAULT_SETTINGS.AGENT ||
settings.REMOTE_RUNTIME_RESOURCE_FACTOR !==

View File

@@ -47,9 +47,7 @@ const extractAdvancedFormData = (formData: FormData) => {
};
};
export const extractSettings = (
formData: FormData,
): Partial<Settings> & { llm_api_key?: string | null } => {
export const extractSettings = (formData: FormData): Partial<Settings> => {
const { LLM_MODEL, LLM_API_KEY, AGENT, LANGUAGE } =
extractBasicFormData(formData);
@@ -75,7 +73,7 @@ export const extractSettings = (
return {
LLM_MODEL: CUSTOM_LLM_MODEL || LLM_MODEL,
LLM_API_KEY_SET: !!LLM_API_KEY,
LLM_API_KEY,
AGENT,
LANGUAGE,
LLM_BASE_URL,
@@ -83,6 +81,5 @@ export const extractSettings = (
SECURITY_ANALYZER,
ENABLE_DEFAULT_CONDENSER,
PROVIDER_TOKENS: providerTokens,
llm_api_key: LLM_API_KEY,
};
};

View File

@@ -65,7 +65,7 @@ export function renderWithProviders(
function Wrapper({ children }: PropsWithChildren) {
return (
<Provider store={store}>
<AuthProvider initialProviderTokens={[]}>
<AuthProvider initialGithubTokenIsSet>
<QueryClientProvider
client={
new QueryClient({

View File

@@ -1,35 +0,0 @@
---
name: gitlab
type: knowledge
version: 1.0.0
agent: CodeActAgent
triggers:
- gitlab
- git
---
You have access to an environment variable, `GITLAB_TOKEN`, which allows you to interact with
the GitLab API.
You can use `curl` with the `GITLAB_TOKEN` to interact with GitLab's API.
ALWAYS use the GitLab API for operations instead of a web browser.
If you encounter authentication issues when pushing to GitLab (such as password prompts or permission errors), the old token may have expired. In such case, update the remote URL to include the current token: `git remote set-url origin https://${GITLAB_TOKEN}@gitlab.com/username/repo.git`
Here are some instructions for pushing, but ONLY do this if the user asks you to:
* NEVER push directly to the `main` or `master` branch
* Git config (username and email) is pre-set. Do not modify.
* You may already be on a branch starting with `openhands-workspace`. Create a new branch with a better name before pushing.
* Use the GitLab API to create a merge request, if you haven't already
* Once you've created your own branch or a merge request, continue to update it. Do NOT create a new one unless you are explicitly asked to. Update the PR title and description as necessary, but don't change the branch name.
* Use the main branch as the base branch, unless the user requests otherwise
* After opening or updating a merge request, send the user a short message with a link to the merge request.
* Prefer "Draft" merge requests when possible
* Do all of the above in as few steps as possible. E.g. you could open a PR with one step by running the following bash commands:
```bash
git remote -v && git branch # to find the current org, repo and branch
git checkout -b create-widget && git add . && git commit -m "Create widget" && git push -u origin create-widget
curl -X POST "https://gitlab.com/api/v4/projects/$PROJECT_ID/merge_requests" \
-H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
-d '{"source_branch": "create-widget", "target_branch": "openhands-workspace", "title": "Create widget"}'
```

View File

@@ -150,13 +150,13 @@ class BrowsingAgent(Agent):
last_obs = None
last_action = None
if EVAL_MODE and len(state.view) == 1:
if EVAL_MODE and len(state.history) == 1:
# for webarena and miniwob++ eval, we need to retrieve the initial observation already in browser env
# initialize and retrieve the first observation by issuing an noop OP
# For non-benchmark browsing, the browser env starts with a blank page, and the agent is expected to first navigate to desired websites
return BrowseInteractiveAction(browser_actions='noop()')
for event in state.view:
for event in state.history:
if isinstance(event, BrowseInteractiveAction):
prev_actions.append(event.browser_actions)
last_action = event

View File

@@ -130,7 +130,7 @@ class DummyAgent(Agent):
if 'observations' in prev_step and prev_step['observations']:
expected_observations = prev_step['observations']
hist_events = state.view[-len(expected_observations) :]
hist_events = state.history[-len(expected_observations) :]
if len(hist_events) < len(expected_observations):
print(

View File

@@ -204,13 +204,13 @@ Note:
last_action = None
set_of_marks = None # Initialize set_of_marks to None
if len(state.view) == 1:
if len(state.history) == 1:
# for visualwebarena, webarena and miniwob++ eval, we need to retrieve the initial observation already in browser env
# initialize and retrieve the first observation by issuing an noop OP
# For non-benchmark browsing, the browser env starts with a blank page, and the agent is expected to first navigate to desired websites
return BrowseInteractiveAction(browser_actions='noop(1000)')
for event in state.view:
for event in state.history:
if isinstance(event, BrowseInteractiveAction):
prev_actions.append(event)
last_action = event

View File

@@ -57,6 +57,7 @@ from openhands.events.action import (
from openhands.events.action.agent import CondensationAction, RecallAction
from openhands.events.event import Event
from openhands.events.observation import (
AgentCondensationObservation,
AgentDelegateObservation,
AgentStateChangedObservation,
ErrorObservation,
@@ -227,14 +228,11 @@ class AgentController:
e: Exception,
):
"""React to an exception by setting the agent state to error and sending a status message."""
# Store the error reason before setting the agent state
self.state.last_error = f'{type(e).__name__}: {str(e)}'
await self.set_agent_state_to(AgentState.ERROR)
if self.status_callback is not None:
err_id = ''
if isinstance(e, AuthenticationError):
err_id = 'STATUS$ERROR_LLM_AUTHENTICATION'
self.state.last_error = err_id
elif isinstance(
e,
(
@@ -244,21 +242,14 @@ class AgentController:
),
):
err_id = 'STATUS$ERROR_LLM_SERVICE_UNAVAILABLE'
self.state.last_error = err_id
elif isinstance(e, InternalServerError):
err_id = 'STATUS$ERROR_LLM_INTERNAL_SERVER_ERROR'
self.state.last_error = err_id
elif isinstance(e, BadRequestError) and 'ExceededBudget' in str(e):
err_id = 'STATUS$ERROR_LLM_OUT_OF_CREDITS'
# Set error reason for budget exceeded
self.state.last_error = err_id
elif isinstance(e, RateLimitError):
await self.set_agent_state_to(AgentState.RATE_LIMITED)
return
self.status_callback('error', err_id, self.state.last_error)
# Set the agent state to ERROR after storing the reason
await self.set_agent_state_to(AgentState.ERROR)
self.status_callback('error', err_id, type(e).__name__ + ': ' + str(e))
def step(self):
asyncio.create_task(self._step_with_exception_handling())
@@ -591,14 +582,8 @@ class AgentController:
self.event_stream.add_event(self._pending_action, EventSource.AGENT)
self.state.agent_state = new_state
# Create observation with reason field if it's an error state
reason = ''
if new_state == AgentState.ERROR:
reason = self.state.last_error
self.event_stream.add_event(
AgentStateChangedObservation('', self.state.agent_state, reason),
AgentStateChangedObservation('', self.state.agent_state),
EventSource.ENVIRONMENT,
)
@@ -943,6 +928,12 @@ class AgentController:
- For delegate events (between AgentDelegateAction and AgentDelegateObservation):
- Excludes all events between the action and observation
- Includes the delegate action and observation themselves
The history is loaded in two parts if truncation_id is set:
1. First user message from start_id onwards
2. Rest of history from truncation_id to the end
Otherwise loads normally from start_id.
"""
# define range of events to fetch
# delegates start with a start_id and initially won't find any events
@@ -965,6 +956,29 @@ class AgentController:
events: list[Event] = []
# If we have a truncation point, get first user message and then rest of history
if hasattr(self.state, 'truncation_id') and self.state.truncation_id > 0:
# Find first user message from stream
first_user_msg = next(
(
e
for e in self.event_stream.get_events(
start_id=start_id,
end_id=end_id,
reverse=False,
filter_out_type=self.filter_out,
filter_hidden=True,
)
if isinstance(e, MessageAction) and e.source == EventSource.USER
),
None,
)
if first_user_msg:
events.append(first_user_msg)
# the rest of the events are from the truncation point
start_id = self.state.truncation_id
# Get rest of history
events_to_add = list(
self.event_stream.get_events(
@@ -1032,10 +1046,7 @@ class AgentController:
def _handle_long_context_error(self) -> None:
# When context window is exceeded, keep roughly half of agent interactions
kept_event_ids = {
e.id for e in self._apply_conversation_window(self.state.history)
}
forgotten_event_ids = {e.id for e in self.state.history} - kept_event_ids
self.state.history = self._apply_conversation_window(self.state.history)
# Save the ID of the first event in our truncated history for future reloading
if self.state.history:
@@ -1043,9 +1054,8 @@ class AgentController:
# Add an error event to trigger another step by the agent
self.event_stream.add_event(
CondensationAction(
forgotten_events_start_id=min(forgotten_event_ids),
forgotten_events_end_id=max(forgotten_event_ids),
AgentCondensationObservation(
content='Trimming prompt to meet context window limitations'
),
EventSource.AGENT,
)
@@ -1123,6 +1133,10 @@ class AgentController:
# if it's an action with source == EventSource.AGENT, we're good
break
# Save where to continue from in next reload
if kept_events:
self.state.truncation_id = kept_events[0].id
# Ensure first user message is included
if first_user_msg and first_user_msg not in kept_events:
kept_events = [first_user_msg] + kept_events

View File

@@ -15,7 +15,6 @@ from openhands.events.action import (
from openhands.events.action.agent import AgentFinishAction
from openhands.events.event import Event, EventSource
from openhands.llm.metrics import Metrics
from openhands.memory.view import View
from openhands.storage.files import FileStore
from openhands.storage.locations import get_conversation_agent_state_filename
@@ -97,6 +96,8 @@ class State:
# start_id and end_id track the range of events in history
start_id: int = -1
end_id: int = -1
# truncation_id tracks where to load history after context window truncation
truncation_id: int = -1
delegates: dict[tuple[int, int], tuple[str, str]] = field(default_factory=dict)
# NOTE: This will never be used by the controller, but it can be used by different
@@ -169,12 +170,6 @@ class State:
# don't pickle history, it will be restored from the event stream
state = self.__dict__.copy()
state['history'] = []
# Remove any view caching attributes. They'll be rebuilt frmo the
# history after that gets reloaded.
state.pop('_history_checksum', None)
state.pop('_view', None)
return state
def __setstate__(self, state):
@@ -188,7 +183,7 @@ class State:
"""Returns the latest user message and image(if provided) that appears after a FinishAction, or the first (the task) if nothing was finished yet."""
last_user_message = None
last_user_message_image_urls: list[str] | None = []
for event in reversed(self.view):
for event in reversed(self.history):
if isinstance(event, MessageAction) and event.source == 'user':
last_user_message = event.content
last_user_message_image_urls = event.image_urls
@@ -199,13 +194,13 @@ class State:
return last_user_message, last_user_message_image_urls
def get_last_agent_message(self) -> MessageAction | None:
for event in reversed(self.view):
for event in reversed(self.history):
if isinstance(event, MessageAction) and event.source == EventSource.AGENT:
return event
return None
def get_last_user_message(self) -> MessageAction | None:
for event in reversed(self.view):
for event in reversed(self.history):
if isinstance(event, MessageAction) and event.source == EventSource.USER:
return event
return None
@@ -216,22 +211,7 @@ class State:
'trace_version': openhands.__version__,
'tags': [
f'agent:{agent_name}',
f"web_host:{os.environ.get('WEB_HOST', 'unspecified')}",
f'web_host:{os.environ.get("WEB_HOST", "unspecified")}',
f'openhands_version:{openhands.__version__}',
],
}
@property
def view(self) -> View:
# Compute a simple checksum from the history to see if we can re-use any
# cached view.
history_checksum = len(self.history)
old_history_checksum = getattr(self, '_history_checksum', -1)
# If the history has changed, we need to re-create the view and update
# the caching.
if history_checksum != old_history_checksum:
self._history_checksum = history_checksum
self._view = View.from_events(self.history)
return self._view

View File

@@ -47,7 +47,7 @@ class SandboxConfig(BaseModel):
rm_all_containers: bool = Field(default=False)
api_key: str | None = Field(default=None)
base_container_image: str = Field(
default='nikolaik/python-nodejs:python3.12-nodejs22'
default='nikolaik/python-nodejs:python3.13-nodejs23-bullseye'
)
runtime_container_image: str | None = Field(default=None)
user_id: int = Field(default=os.getuid() if hasattr(os, 'getuid') else 1000)

View File

@@ -148,7 +148,7 @@ async def run_controller(
)
# start event is a MessageAction with the task, either resumed or new
if initial_state is not None and initial_state.last_error:
if initial_state is not None:
# we're resuming the previous session
event_stream.add_event(
MessageAction(

View File

@@ -23,7 +23,6 @@ from openhands.runtime import get_runtime_cls
from openhands.runtime.base import Runtime
from openhands.security import SecurityAnalyzer, options
from openhands.storage import get_file_store
from openhands.utils.async_utils import GENERAL_TIMEOUT, call_async_from_sync
def create_runtime(
@@ -118,8 +117,10 @@ def initialize_repository_for_runtime(
repo_directory = None
if selected_repository and provider_tokens:
logger.debug(f'Selected repository {selected_repository}.')
repo_directory = call_async_from_sync(
runtime.clone_repo, GENERAL_TIMEOUT, github_token, selected_repository, None
repo_directory = runtime.clone_repo(
provider_tokens,
selected_repository,
None,
)
# Run setup script if it exists
runtime.maybe_run_setup_script()

View File

@@ -1,23 +0,0 @@
import asyncio
from typing import Any, AsyncIterator
from openhands.events.event import Event
from openhands.events.event_store import EventStore
class AsyncEventStoreWrapper:
def __init__(self, event_store: EventStore, *args: Any, **kwargs: Any) -> None:
self.event_store = event_store
self.args = args
self.kwargs = kwargs
async def __aiter__(self) -> AsyncIterator[Event]:
loop = asyncio.get_running_loop()
# Create an async generator that yields events
for event in self.event_store.get_events(*self.args, **self.kwargs):
# Run the blocking get_events() in a thread pool
def get_event(e: Event = event) -> Event:
return e
yield await loop.run_in_executor(None, get_event)

View File

@@ -1,239 +0,0 @@
import json
from dataclasses import dataclass
from typing import Iterable
from openhands.core.logger import openhands_logger as logger
from openhands.events.event import Event, EventSource
from openhands.events.serialization.event import event_from_dict, event_to_dict
from openhands.storage.files import FileStore
from openhands.storage.locations import (
get_conversation_event_filename,
get_conversation_events_dir,
)
from openhands.utils.shutdown_listener import should_continue
@dataclass
class EventStore:
"""
A stored list of events backing a conversation
"""
sid: str
file_store: FileStore
user_id: str | None
cur_id: int = -1 # We fix this in post init if it is not specified
def __post_init__(self) -> None:
if self.cur_id >= 0:
return
events = []
try:
events_dir = get_conversation_events_dir(self.sid, self.user_id)
events = self.file_store.list(events_dir)
except FileNotFoundError:
logger.debug(f'No events found for session {self.sid} at {events_dir}')
if self.user_id:
# During transition to new location, try old location if user_id is set
# TODO: remove this code after 5/1/2025
try:
events_dir = get_conversation_events_dir(self.sid)
events += self.file_store.list(events_dir)
except FileNotFoundError:
logger.debug(f'No events found for session {self.sid} at {events_dir}')
if not events:
self.cur_id = 0
return
# if we have events, we need to find the highest id to prepare for new events
for event_str in events:
id = self._get_id_from_filename(event_str)
if id >= self.cur_id:
self.cur_id = id + 1
def get_events(
self,
start_id: int = 0,
end_id: int | None = None,
reverse: bool = False,
filter_out_type: tuple[type[Event], ...] | None = None,
filter_hidden: bool = False,
) -> Iterable[Event]:
"""
Retrieve events from the event stream, optionally filtering out events of a given type
and events marked as hidden.
Args:
start_id: The ID of the first event to retrieve. Defaults to 0.
end_id: The ID of the last event to retrieve. Defaults to the last event in the stream.
reverse: Whether to retrieve events in reverse order. Defaults to False.
filter_out_type: A tuple of event types to filter out. Typically used to filter out backend events from the agent.
filter_hidden: If True, filters out events with the 'hidden' attribute set to True.
Yields:
Events from the stream that match the criteria.
"""
def should_filter(event: Event) -> bool:
if filter_hidden and hasattr(event, 'hidden') and event.hidden:
return True
if filter_out_type is not None and isinstance(event, filter_out_type):
return True
return False
if reverse:
if end_id is None:
end_id = self.cur_id - 1
event_id = end_id
while event_id >= start_id:
try:
event = self.get_event(event_id)
if not should_filter(event):
yield event
except FileNotFoundError:
logger.debug(f'No event found for ID {event_id}')
event_id -= 1
else:
event_id = start_id
while should_continue():
if end_id is not None and event_id > end_id:
break
try:
event = self.get_event(event_id)
if not should_filter(event):
yield event
except FileNotFoundError:
break
event_id += 1
def get_event(self, id: int) -> Event:
filename = self._get_filename_for_id(id, self.user_id)
try:
content = self.file_store.read(filename)
data = json.loads(content)
return event_from_dict(data)
except FileNotFoundError:
logger.debug(f'File {filename} not found')
# TODO remove this block after 5/1/2025
if self.user_id:
filename = self._get_filename_for_id(id, None)
content = self.file_store.read(filename)
data = json.loads(content)
return event_from_dict(data)
raise
def get_latest_event(self) -> Event:
return self.get_event(self.cur_id - 1)
def get_latest_event_id(self) -> int:
return self.cur_id - 1
def filtered_events_by_source(self, source: EventSource) -> Iterable[Event]:
for event in self.get_events():
if event.source == source:
yield event
def _should_filter_event(
self,
event: Event,
query: str | None = None,
event_types: tuple[type[Event], ...] | None = None,
source: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
) -> bool:
"""Check if an event should be filtered out based on the given criteria.
Args:
event: The event to check
query: Text to search for in event content
event_type: Filter by event type classes (e.g., (FileReadAction, ) ).
source: Filter by event source
start_date: Filter events after this date (ISO format)
end_date: Filter events before this date (ISO format)
Returns:
bool: True if the event should be filtered out, False if it matches all criteria
"""
if event_types and not isinstance(event, event_types):
return True
if source:
if event.source is None or event.source.value != source:
return True
if start_date and event.timestamp is not None and event.timestamp < start_date:
return True
if end_date and event.timestamp is not None and event.timestamp > end_date:
return True
# Text search in event content if query provided
if query:
event_dict = event_to_dict(event)
event_str = json.dumps(event_dict).lower()
if query.lower() not in event_str:
return True
return False
def get_matching_events(
self,
query: str | None = None,
event_types: tuple[type[Event], ...] | None = None,
source: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
start_id: int = 0,
limit: int = 100,
reverse: bool = False,
) -> list[Event]:
"""Get matching events from the event stream based on filters.
Args:
query: Text to search for in event content
event_types: Filter by event type classes (e.g., (FileReadAction, ) ).
source: Filter by event source
start_date: Filter events after this date (ISO format)
end_date: Filter events before this date (ISO format)
start_id: Starting ID in the event stream. Defaults to 0
limit: Maximum number of events to return. Must be between 1 and 100. Defaults to 100
reverse: Whether to retrieve events in reverse order. Defaults to False.
Returns:
list: List of matching events (as dicts)
Raises:
ValueError: If limit is less than 1 or greater than 100
"""
if limit < 1 or limit > 100:
raise ValueError('Limit must be between 1 and 100')
matching_events: list = []
for event in self.get_events(start_id=start_id, reverse=reverse):
if self._should_filter_event(
event, query, event_types, source, start_date, end_date
):
continue
matching_events.append(event)
# Stop if we have enough events
if len(matching_events) >= limit:
break
return matching_events
def _get_filename_for_id(self, id: int, user_id: str | None) -> str:
return get_conversation_event_filename(self.sid, id, user_id)
@staticmethod
def _get_id_from_filename(filename: str) -> int:
try:
return int(filename.split('/')[-1].split('.')[0])
except ValueError:
logger.warning(f'get id from filename ({filename}) failed.')
return -1

View File

@@ -10,7 +10,6 @@ class AgentStateChangedObservation(Observation):
"""This data class represents the result from delegating to another agent"""
agent_state: str
reason: str = ''
observation: str = ObservationType.AGENT_STATE_CHANGED
@property

View File

@@ -5,16 +5,17 @@ from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from enum import Enum
from functools import partial
from typing import Any, Callable
from typing import Any, AsyncIterator, Callable, Iterable
from openhands.core.logger import openhands_logger as logger
from openhands.events.event import Event, EventSource
from openhands.events.event_store import EventStore
from openhands.events.serialization.event import event_from_dict, event_to_dict
from openhands.io import json
from openhands.storage import FileStore
from openhands.storage.locations import (
get_conversation_dir,
get_conversation_event_filename,
get_conversation_events_dir,
)
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.shutdown_listener import should_continue
@@ -41,11 +42,33 @@ async def session_exists(
return False
class EventStream(EventStore):
class AsyncEventStreamWrapper:
def __init__(self, event_stream: 'EventStream', *args: Any, **kwargs: Any) -> None:
self.event_stream = event_stream
self.args = args
self.kwargs = kwargs
async def __aiter__(self) -> AsyncIterator[Event]:
loop = asyncio.get_running_loop()
# Create an async generator that yields events
for event in self.event_stream.get_events(*self.args, **self.kwargs):
# Run the blocking get_events() in a thread pool
def get_event(e: Event = event) -> Event:
return e
yield await loop.run_in_executor(None, get_event)
class EventStream:
sid: str
user_id: str | None
file_store: FileStore
secrets: dict[str, str]
# For each subscriber ID, there is a map of callback functions - useful
# when there are multiple listeners
_subscribers: dict[str, dict[str, Callable]]
_cur_id: int = 0
_lock: threading.Lock
_queue: queue.Queue[Event]
_queue_thread: threading.Thread
@@ -54,7 +77,9 @@ class EventStream(EventStore):
_thread_loops: dict[str, dict[str, asyncio.AbstractEventLoop]]
def __init__(self, sid: str, file_store: FileStore, user_id: str | None = None):
super().__init__(sid, file_store, user_id)
self.sid = sid
self.file_store = file_store
self.user_id = user_id
self._stop_flag = threading.Event()
self._queue: queue.Queue[Event] = queue.Queue()
self._thread_pools = {}
@@ -65,8 +90,40 @@ class EventStream(EventStore):
self._queue_thread.start()
self._subscribers = {}
self._lock = threading.Lock()
self._cur_id = 0
self.secrets = {}
# load the stream
self.__post_init__()
def __post_init__(self) -> None:
events = []
try:
events_dir = get_conversation_events_dir(self.sid, self.user_id)
events += self.file_store.list(events_dir)
except FileNotFoundError:
logger.debug(f'No events found for session {self.sid} at {events_dir}')
if self.user_id:
# During transition to new location, try old location if user_id is set
# TODO: remove this code after 5/1/2025
try:
events_dir = get_conversation_events_dir(self.sid)
events += self.file_store.list(events_dir)
except FileNotFoundError:
logger.debug(f'No events found for session {self.sid} at {events_dir}')
if not events:
self._cur_id = 0
return
# if we have events, we need to find the highest id to prepare for new events
for event_str in events:
id = self._get_id_from_filename(event_str)
if id >= self._cur_id:
self._cur_id = id + 1
def _init_thread_loop(self, subscriber_id: str, callback_id: str) -> None:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
@@ -120,6 +177,94 @@ class EventStream(EventStore):
del self._subscribers[subscriber_id][callback_id]
def _get_filename_for_id(self, id: int, user_id: str | None) -> str:
return get_conversation_event_filename(self.sid, id, user_id)
@staticmethod
def _get_id_from_filename(filename: str) -> int:
try:
return int(filename.split('/')[-1].split('.')[0])
except ValueError:
logger.warning(f'get id from filename ({filename}) failed.')
return -1
def get_events(
self,
start_id: int = 0,
end_id: int | None = None,
reverse: bool = False,
filter_out_type: tuple[type[Event], ...] | None = None,
filter_hidden: bool = False,
) -> Iterable[Event]:
"""
Retrieve events from the event stream, optionally filtering out events of a given type
and events marked as hidden.
Args:
start_id: The ID of the first event to retrieve. Defaults to 0.
end_id: The ID of the last event to retrieve. Defaults to the last event in the stream.
reverse: Whether to retrieve events in reverse order. Defaults to False.
filter_out_type: A tuple of event types to filter out. Typically used to filter out backend events from the agent.
filter_hidden: If True, filters out events with the 'hidden' attribute set to True.
Yields:
Events from the stream that match the criteria.
"""
def should_filter(event: Event) -> bool:
if filter_hidden and hasattr(event, 'hidden') and event.hidden:
return True
if filter_out_type is not None and isinstance(event, filter_out_type):
return True
return False
if reverse:
if end_id is None:
end_id = self._cur_id - 1
event_id = end_id
while event_id >= start_id:
try:
event = self.get_event(event_id)
if not should_filter(event):
yield event
except FileNotFoundError:
logger.debug(f'No event found for ID {event_id}')
event_id -= 1
else:
event_id = start_id
while should_continue():
if end_id is not None and event_id > end_id:
break
try:
event = self.get_event(event_id)
if not should_filter(event):
yield event
except FileNotFoundError:
break
event_id += 1
def get_event(self, id: int) -> Event:
filename = self._get_filename_for_id(id, self.user_id)
try:
content = self.file_store.read(filename)
data = json.loads(content)
return event_from_dict(data)
except FileNotFoundError:
logger.debug(f'File {filename} not found')
# TODO remove this block after 5/1/2025
if self.user_id:
filename = self._get_filename_for_id(id, None)
content = self.file_store.read(filename)
data = json.loads(content)
return event_from_dict(data)
raise
def get_latest_event(self) -> Event:
return self.get_event(self._cur_id - 1)
def get_latest_event_id(self) -> int:
return self._cur_id - 1
def subscribe(
self,
subscriber_id: EventStreamSubscriber,
@@ -159,8 +304,8 @@ class EventStream(EventStore):
f'Event already has an ID:{event.id}. It was probably added back to the EventStream from inside a handler, triggering a loop.'
)
with self._lock:
event._id = self.cur_id # type: ignore [attr-defined]
self.cur_id += 1
event._id = self._cur_id # type: ignore [attr-defined]
self._cur_id += 1
logger.debug(f'Adding {type(event).__name__} id={event.id} from {source.name}')
event._timestamp = datetime.now().isoformat()
event._source = source # type: ignore [attr-defined]
@@ -228,3 +373,100 @@ class EventStream(EventStore):
raise e
return _handle_callback_error
def filtered_events_by_source(self, source: EventSource) -> Iterable[Event]:
for event in self.get_events():
if event.source == source:
yield event
def _should_filter_event(
self,
event: Event,
query: str | None = None,
event_types: tuple[type[Event], ...] | None = None,
source: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
) -> bool:
"""Check if an event should be filtered out based on the given criteria.
Args:
event: The event to check
query: Text to search for in event content
event_type: Filter by event type classes (e.g., (FileReadAction, ) ).
source: Filter by event source
start_date: Filter events after this date (ISO format)
end_date: Filter events before this date (ISO format)
Returns:
bool: True if the event should be filtered out, False if it matches all criteria
"""
if event_types and not isinstance(event, event_types):
return True
if source:
if event.source is None or event.source.value != source:
return True
if start_date and event.timestamp is not None and event.timestamp < start_date:
return True
if end_date and event.timestamp is not None and event.timestamp > end_date:
return True
# Text search in event content if query provided
if query:
event_dict = event_to_dict(event)
event_str = json.dumps(event_dict).lower()
if query.lower() not in event_str:
return True
return False
def get_matching_events(
self,
query: str | None = None,
event_types: tuple[type[Event], ...] | None = None,
source: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
start_id: int = 0,
limit: int = 100,
reverse: bool = False,
) -> list[Event]:
"""Get matching events from the event stream based on filters.
Args:
query: Text to search for in event content
event_types: Filter by event type classes (e.g., (FileReadAction, ) ).
source: Filter by event source
start_date: Filter events after this date (ISO format)
end_date: Filter events before this date (ISO format)
start_id: Starting ID in the event stream. Defaults to 0
limit: Maximum number of events to return. Must be between 1 and 100. Defaults to 100
reverse: Whether to retrieve events in reverse order. Defaults to False.
Returns:
list: List of matching events (as dicts)
Raises:
ValueError: If limit is less than 1 or greater than 100
"""
if limit < 1 or limit > 100:
raise ValueError('Limit must be between 1 and 100')
matching_events: list = []
for event in self.get_events(start_id=start_id, reverse=reverse):
if self._should_filter_event(
event, query, event_types, source, start_date, end_date
):
continue
matching_events.append(event)
# Stop if we have enough events
if len(matching_events) >= limit:
break
return matching_events

View File

@@ -9,7 +9,6 @@ from openhands.core.logger import openhands_logger as logger
from openhands.integrations.service_types import (
AuthenticationError,
GitService,
ProviderType,
Repository,
SuggestedTask,
TaskType,
@@ -100,48 +99,29 @@ class GitHubService(GitService):
)
async def get_repositories(
self, sort: str, installation_id: int | None
self, page: int, per_page: int, sort: str, installation_id: int | None
) -> list[Repository]:
MAX_REPOS = 1000
PER_PAGE = 100 # Maximum allowed by GitHub API
all_repos: list[dict] = []
page = 1
params = {'page': str(page), 'per_page': str(per_page)}
if installation_id:
url = f'{self.BASE_URL}/user/installations/{installation_id}/repositories'
response, headers = await self._fetch_data(url, params)
response = response.get('repositories', [])
else:
url = f'{self.BASE_URL}/user/repos'
params['sort'] = sort
response, headers = await self._fetch_data(url, params)
while len(all_repos) < MAX_REPOS:
params = {'page': str(page), 'per_page': str(PER_PAGE)}
if installation_id:
url = (
f'{self.BASE_URL}/user/installations/{installation_id}/repositories'
)
response, headers = await self._fetch_data(url, params)
response = response.get('repositories', [])
else:
url = f'{self.BASE_URL}/user/repos'
params['sort'] = sort
response, headers = await self._fetch_data(url, params)
if not response: # No more repositories
break
all_repos.extend(response)
page += 1
# Check if we've reached the last page
link_header = headers.get('Link', '')
if 'rel="next"' not in link_header:
break
# Trim to MAX_REPOS if needed and convert to Repository objects
all_repos = all_repos[:MAX_REPOS]
return [
next_link: str = headers.get('Link', '')
repos = [
Repository(
id=repo.get('id'),
full_name=repo.get('full_name'),
stargazers_count=repo.get('stargazers_count'),
git_provider=ProviderType.GITHUB,
link_header=next_link,
)
for repo in all_repos
for repo in response
]
return repos
async def get_installation_ids(self) -> list[int]:
url = f'{self.BASE_URL}/user/installations'
@@ -153,14 +133,7 @@ class GitHubService(GitService):
self, query: str, per_page: int, sort: str, order: str
) -> list[Repository]:
url = f'{self.BASE_URL}/search/repositories'
# Add is:public to the query to ensure we only search for public repositories
query_with_visibility = f'{query} is:public'
params = {
'q': query_with_visibility,
'per_page': per_page,
'sort': sort,
'order': order,
}
params = {'q': query, 'per_page': per_page, 'sort': sort, 'order': order}
response, _ = await self._fetch_data(url, params)
repos = response.get('items', [])
@@ -170,7 +143,6 @@ class GitHubService(GitService):
id=repo.get('id'),
full_name=repo.get('full_name'),
stargazers_count=repo.get('stargazers_count'),
git_provider=ProviderType.GITHUB,
)
for repo in repos
]
@@ -318,14 +290,6 @@ class GitHubService(GitService):
except Exception:
return []
async def does_repo_exist(self, repository: str) -> bool:
url = f'{self.BASE_URL}/repos/{repository}'
try:
await self._fetch_data(url)
return True
except Exception:
return False
github_service_cls = os.environ.get(
'OPENHANDS_GITHUB_SERVICE_CLS',

View File

@@ -1,6 +1,5 @@
import os
from typing import Any
from urllib.parse import quote_plus
import httpx
from pydantic import SecretStr
@@ -8,7 +7,6 @@ from pydantic import SecretStr
from openhands.integrations.service_types import (
AuthenticationError,
GitService,
ProviderType,
Repository,
UnknownException,
User,
@@ -97,92 +95,22 @@ class GitLabService(GitService):
async def search_repositories(
self, query: str, per_page: int = 30, sort: str = 'updated', order: str = 'desc'
) -> list[Repository]:
url = f'{self.BASE_URL}/projects'
):
url = f'{self.BASE_URL}/search'
params = {
'scope': 'projects',
'search': query,
'per_page': per_page,
'order_by': 'last_activity_at',
'order_by': sort,
'sort': order,
'visibility': 'public',
}
response, _ = await self._fetch_data(url, params)
repos = [
Repository(
id=repo.get('id'),
full_name=repo.get('path_with_namespace'),
stargazers_count=repo.get('star_count'),
git_provider=ProviderType.GITLAB,
)
for repo in response
]
return repos
response, headers = await self._fetch_data(url, params)
return response, headers
async def get_repositories(
self, sort: str, installation_id: int | None
self, page: int, per_page: int, sort: str, installation_id: int | None
) -> list[Repository]:
# if installation_id:
# return [] # Not implementing installation_token case yet
MAX_REPOS = 1000
PER_PAGE = 100 # Maximum allowed by GitLab API
all_repos: list[dict] = []
page = 1
url = f'{self.BASE_URL}/projects'
# Map GitHub's sort values to GitLab's order_by values
order_by = {
'pushed': 'last_activity_at',
'updated': 'last_activity_at',
'created': 'created_at',
'full_name': 'name',
}.get(sort, 'last_activity_at')
while len(all_repos) < MAX_REPOS:
params = {
'page': str(page),
'per_page': str(PER_PAGE),
'order_by': order_by,
'sort': 'desc', # GitLab uses sort for direction (asc/desc)
'owned': 1, # Use 1 instead of True
'membership': 1, # Use 1 instead of True
}
response, headers = await self._fetch_data(url, params)
if not response: # No more repositories
break
all_repos.extend(response)
page += 1
# Check if we've reached the last page
link_header = headers.get('Link', '')
if 'rel="next"' not in link_header:
break
# Trim to MAX_REPOS if needed and convert to Repository objects
all_repos = all_repos[:MAX_REPOS]
return [
Repository(
id=repo.get('id'),
full_name=repo.get('path_with_namespace'),
stargazers_count=repo.get('star_count'),
git_provider=ProviderType.GITLAB,
)
for repo in all_repos
]
async def does_repo_exist(self, repository: str) -> bool:
encoded_repo = quote_plus(repository)
url = f'{self.BASE_URL}/projects/{encoded_repo}'
try:
await self._fetch_data(url)
return True
except Exception as e:
print(e)
return False
return []
gitlab_service_cls = os.environ.get(

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from enum import Enum
from types import MappingProxyType
from typing import Annotated, Any, Coroutine, Literal, overload
@@ -23,12 +24,16 @@ from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
from openhands.integrations.service_types import (
AuthenticationError,
GitService,
ProviderType,
Repository,
User,
)
class ProviderType(Enum):
GITHUB = 'github'
GITLAB = 'gitlab'
class ProviderToken(BaseModel):
token: SecretStr | None = Field(default=None)
user_id: str | None = Field(default=None)
@@ -189,71 +194,21 @@ class ProviderHandler:
return await service.get_latest_token()
async def get_repositories(
self,
sort: str,
installation_id: int | None,
self, page: int, per_page: int, sort: str, installation_id: int | None
) -> list[Repository]:
"""
Get repositories from a selected providers with pagination support
"""
all_repos: list[Repository] = []
"""Get repositories from all available providers"""
all_repos = []
for provider in self.provider_tokens:
try:
service = self._get_service(provider)
service_repos = await service.get_repositories(sort, installation_id)
all_repos.extend(service_repos)
except Exception:
continue
return all_repos
async def search_repositories(
self,
query: str,
per_page: int,
sort: str,
order: str,
):
all_repos: list[Repository] = []
for provider in self.provider_tokens:
try:
service = self._get_service(provider)
service_repos = await service.search_repositories(
query, per_page, sort, order
repos = await service.get_repositories(
page, per_page, sort, installation_id
)
all_repos.extend(service_repos)
all_repos.extend(repos)
except Exception:
continue
return all_repos
async def get_remote_repository_url(self, repository: str) -> str | None:
if not repository:
return None
provider_domains = {
ProviderType.GITHUB: 'github.com',
ProviderType.GITLAB: 'gitlab.com',
}
for provider in self.provider_tokens:
try:
service = self._get_service(provider)
repo_exists = await service.does_repo_exist(repository)
if repo_exists:
git_token = self.provider_tokens[provider].token
if git_token and provider in provider_domains:
domain = provider_domains[provider]
if provider == ProviderType.GITLAB:
return f'https://oauth2:{git_token.get_secret_value()}@{domain}/{repository}.git'
return f'https://{git_token.get_secret_value()}@{domain}/{repository}.git'
except Exception:
continue
return None
async def set_event_stream_secrets(
self,
event_stream: EventStream,

View File

@@ -4,11 +4,6 @@ from typing import Protocol
from pydantic import BaseModel, SecretStr
class ProviderType(Enum):
GITHUB = 'github'
GITLAB = 'gitlab'
class TaskType(str, Enum):
MERGE_CONFLICTS = 'MERGE_CONFLICTS'
FAILING_CHECKS = 'FAILING_CHECKS'
@@ -36,10 +31,8 @@ class User(BaseModel):
class Repository(BaseModel):
id: int
full_name: str
git_provider: ProviderType
stargazers_count: int | None = None
link_header: str | None = None
pushed_at: str | None = None # ISO 8601 format date string
class AuthenticationError(ValueError):
@@ -88,11 +81,10 @@ class GitService(Protocol):
async def get_repositories(
self,
page: int,
per_page: int,
sort: str,
installation_id: int | None,
) -> list[Repository]:
"""Get repositories for the authenticated user"""
...
async def does_repo_exist(self, repository: str) -> bool:
"""Check if a repository exists for the user"""

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