Compare commits

...

50 Commits

Author SHA1 Message Date
rohitvinodmalhotra@gmail.com
532a284d5c migrate to use OH version 2025-01-26 15:24:35 -05:00
rohitvinodmalhotra@gmail.com
43f6104967 Merge branch 'main' into eval/visualcodebench 2025-01-26 15:14:28 -05:00
Ray Myers
e619929909 Log restart reason if runtime reports it (#6455) 2025-01-25 07:20:18 +01:00
Ryan H. Tran
93753ac2e0 Upgrade openhands-aci to 0.1.9 (#6450) 2025-01-24 19:03:00 +00:00
Robert Brennan
38e19d214d Fix up conversation initialization (#6430)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-01-24 18:43:02 +00:00
dependabot[bot]
19a4f1c3ec chore(deps-dev): bump llama-index from 0.12.12 to 0.12.13 in the llama group (#6448)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-24 16:16:53 +00:00
Rohit Malhotra
45a048f9e3 NIT: Remove unused param (#6446) 2025-01-24 14:51:09 +00:00
sp.wack
358d9cb3f4 hotfix(frontend): Logout and clear token if retrieving user fails (#6436) 2025-01-24 09:49:50 -05:00
Xingyao Wang
e6a2fd3fd4 feat: add prompt to prevent agent execute multiple bash command at the same time (#6428) 2025-01-24 22:43:34 +08:00
OpenHands
c2f308f397 Fix issue #5620: [Bug]: Resolver fails when the existing requirements.txt does not end in a newline character (#6327) 2025-01-24 09:36:59 -05:00
Rohit Malhotra
a1f1c802d9 [Fix]: Fix bugs for target_branch param on resolver (#5745)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-01-23 21:36:20 -05:00
Xiaohua Zhang
ad2237d7dd feat: vscode support for modal runtime (#6442)
Co-authored-by: Xiaohua Zhang <xiaohua.dev@gmail.com>
2025-01-24 01:39:07 +00:00
Xiaohua Zhang
aa0cd51967 fix(frontend): display confirmation buttons for explandable messages (#6426)
Co-authored-by: Xiaohua Zhang <xiaohua.dev@gmail.com>
2025-01-23 20:14:52 -05:00
Graham Neubig
081a1305f0 Fix resolver linting issues (#6401)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-01-23 18:21:11 -05:00
Xiaohua Zhang
9912e28576 chore: update config template to use docker runtime by default (#6435)
Co-authored-by: Xiaohua Zhang <xiaohua.dev@gmail.com>
2025-01-23 22:24:00 +00:00
tofarr
b19a33ccad Fix: Filtering conversations with no created at (#6414) 2025-01-23 15:09:57 -07:00
tofarr
21e912d6fb Feat remove redis (#6278)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-01-23 14:33:16 -07:00
Robert Brennan
0dd9b95dbe change message to connecting (#6433) 2025-01-23 20:42:41 +00:00
Aditya Bharat Soni
aebb583779 Support for VisualWebArena evaluation in OpenHands (#4773)
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Graham Neubig <neubig@gmail.com>
2025-01-23 20:18:30 +00:00
chuckbutkus
2ff9ba1229 AWS necessary changes only (#6375)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-01-23 13:10:11 -05:00
Michael Jewell
a7e6068ba8 build: add required dependencies to package.json (#6423) 2025-01-23 10:07:12 -05:00
dependabot[bot]
24adcee9e3 chore(deps-dev): bump the llama group with 2 updates (#6411)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-23 14:54:27 +00:00
tofarr
21d4ba0bbd Feat: Stop runtimes rather than delete them (#6403)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-01-23 07:43:02 -07:00
tofarr
5ba9a6d321 Feat: Better mechanism for attaching middleware (#6365) 2025-01-23 07:31:43 -07:00
tofarr
aa223734d4 One more SecretStr fix (#6419) 2025-01-22 18:21:14 -07:00
sp.wack
053723a4d4 fix(frontend): Refetch conversations when toggling the conversation panel (#6190) 2025-01-22 18:19:01 +00:00
mamoodi
5a6dbac5a3 Release 0.21.0 (#6392)
Co-authored-by: Calvin Smith <email@cjsmith.io>
Co-authored-by: Calvin Smith <calvin@all-hands.dev>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2025-01-22 11:26:12 -05:00
Robert Brennan
93d74e9b41 make export button more stylistically consistent (#6412) 2025-01-22 11:18:43 -05:00
tofarr
1337d03816 Example usage of httpx (#6325) 2025-01-22 16:06:43 +00:00
Robert Brennan
04e36df4d7 remove dead code (#6386) 2025-01-22 10:26:59 -05:00
Boxuan Li
f9ba16b648 Edit tool prompt tweaking: only plain-text format is supported (#6067)
Co-authored-by: Graham Neubig <neubig@gmail.com>
Co-authored-by: mamoodi <mamoodiha@gmail.com>
2025-01-21 18:22:01 -08:00
Engel Nyst
f0dbb02ee1 Adjust prompt to use view command (#5506)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-01-21 23:50:39 +01:00
tofarr
318c811817 Added check to shutdown hook (#6402) 2025-01-21 22:32:46 +00:00
Xingyao Wang
b468150f2a fix(codeact): make sure agent sees the prefix/suffix as part of observation (#6400) 2025-01-21 21:54:57 +00:00
Engel Nyst
b9a3f1c753 Fix eval on remote runtime (#6398) 2025-01-21 20:49:30 +00:00
tofarr
09e8a1eeba Fix: Keeping runtimes alive again (For now) (#6395) 2025-01-21 19:20:35 +00:00
Xingyao Wang
ff3880c76d fix(remote_runtime): define runtime_id first to fix attrbute error (#6393) 2025-01-21 18:13:43 +00:00
Calvin Smith
8bd7613724 fix: Settings modal properly tracks if an API key is set (#6394)
Co-authored-by: Calvin Smith <calvin@all-hands.dev>
2025-01-21 11:04:30 -07:00
Engel Nyst
5b7fcfbe1a Disable prompt extensions in SWE-bench (#6391) 2025-01-21 17:18:30 +00:00
Robert Brennan
8ae36481df Fix API key again (#6390) 2025-01-21 17:00:59 +00:00
Robert Brennan
25fdb0c3bf fix api key value (#6388) 2025-01-21 16:15:28 +00:00
louria
7f57dbebda Update MiniWoB README (#6385) 2025-01-21 16:26:47 +01:00
dependabot[bot]
54589d7e83 chore(deps-dev): bump pre-commit from 4.0.1 to 4.1.0 in the pre-commit group (#6384)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-21 15:10:20 +00:00
Boxuan Li
b7f34c3f8d (feat) Add button to export trajectory on chat panel (#6378) 2025-01-21 22:10:00 +08:00
dependabot[bot]
210eeee94a chore(deps-dev): bump the eslint group in /frontend with 2 updates (#6358)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-21 13:46:56 +04:00
openhands
e249b920ff feat: adapt Design2Code block detection for in-memory evaluation 2024-11-30 19:28:22 +00:00
rohitvinodmalhotra@gmail.com
d920a69f69 adding back server code 2024-11-30 14:00:25 -05:00
openhands
a8ce888981 refactor: adapt Design2Code evaluation metrics 2024-11-30 17:17:05 +00:00
rohitvinodmalhotra@gmail.com
e22ddc0dd6 uncomment agent run 2024-11-26 17:00:07 -05:00
rohitvinodmalhotra@gmail.com
c370912f12 adding eval scripts 2024-11-26 16:57:19 -05:00
122 changed files with 3943 additions and 834 deletions

View File

@@ -160,7 +160,6 @@ jobs:
echo "api_key = \"$LLM_API_KEY\"" >> config.toml
echo "base_url = \"$LLM_BASE_URL\"" >> config.toml
echo "temperature = 0.0" >> config.toml
- name: Run integration test evaluation for DelegatorAgent (DeepSeek)
env:
SANDBOX_FORCE_REBUILD_RUNTIME: True
@@ -174,12 +173,42 @@ jobs:
cat $REPORT_FILE_DELEGATOR_DEEPSEEK >> $GITHUB_ENV
echo >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
# -------------------------------------------------------------
# Run VisualBrowsingAgent tests for DeepSeek, limited to t05 and t06
- name: Wait a little bit (again)
run: sleep 5
- name: Configure config.toml for testing VisualBrowsingAgent (DeepSeek)
env:
LLM_MODEL: "litellm_proxy/deepseek-chat"
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
MAX_ITERATIONS: 15
run: |
echo "[llm.eval]" > config.toml
echo "model = \"$LLM_MODEL\"" >> config.toml
echo "api_key = \"$LLM_API_KEY\"" >> config.toml
echo "base_url = \"$LLM_BASE_URL\"" >> config.toml
echo "temperature = 0.0" >> config.toml
- name: Run integration test evaluation for VisualBrowsingAgent (DeepSeek)
env:
SANDBOX_FORCE_REBUILD_RUNTIME: True
run: |
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD VisualBrowsingAgent '' 15 $N_PROCESSES "t05_simple_browsing,t06_github_pr_browsing.py" 'visualbrowsing_deepseek_run'
# Find and export the visual browsing agent test results
REPORT_FILE_VISUALBROWSING_DEEPSEEK=$(find evaluation/evaluation_outputs/outputs/integration_tests/VisualBrowsingAgent/deepseek*_maxiter_15_N* -name "report.md" -type f | head -n 1)
echo "REPORT_FILE_VISUALBROWSING_DEEPSEEK: $REPORT_FILE_VISUALBROWSING_DEEPSEEK"
echo "INTEGRATION_TEST_REPORT_VISUALBROWSING_DEEPSEEK<<EOF" >> $GITHUB_ENV
cat $REPORT_FILE_VISUALBROWSING_DEEPSEEK >> $GITHUB_ENV
echo >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Create archive of evaluation outputs
run: |
TIMESTAMP=$(date +'%y-%m-%d-%H-%M')
cd evaluation/evaluation_outputs/outputs # Change to the outputs directory
tar -czvf ../../../integration_tests_${TIMESTAMP}.tar.gz integration_tests/CodeActAgent/* integration_tests/DelegatorAgent/* # Only include the actual result directories
tar -czvf ../../../integration_tests_${TIMESTAMP}.tar.gz integration_tests/CodeActAgent/* integration_tests/DelegatorAgent/* integration_tests/VisualBrowsingAgent/* # Only include the actual result directories
- name: Upload evaluation results as artifact
uses: actions/upload-artifact@v4
@@ -227,4 +256,7 @@ jobs:
**Integration Tests Report Delegator (DeepSeek)**
${{ env.INTEGRATION_TEST_REPORT_DELEGATOR_DEEPSEEK }}
---
**Integration Tests Report VisualBrowsing (DeepSeek)**
${{ env.INTEGRATION_TEST_REPORT_VISUALBROWSING_DEEPSEEK }}
---
Download testing outputs (includes both Haiku and DeepSeek results): [Download](${{ steps.upload_results_artifact.outputs.artifact-url }})

View File

@@ -84,6 +84,10 @@ jobs:
run: |
python -m pip index versions openhands-ai > openhands_versions.txt
OPENHANDS_VERSION=$(head -n 1 openhands_versions.txt | awk '{print $2}' | tr -d '()')
# Ensure requirements.txt ends with newline before appending
if [ -f requirements.txt ] && [ -s requirements.txt ]; then
sed -i -e '$a\' requirements.txt
fi
echo "openhands-ai==${OPENHANDS_VERSION}" >> requirements.txt
cat requirements.txt

1
.gitignore vendored
View File

@@ -176,6 +176,7 @@ evaluation/gorilla/data
evaluation/toolqa/data
evaluation/scienceagentbench/benchmark
evaluation/commit0_bench/repos
evaluation/visualcodebench/
# openhands resolver
output/

View File

@@ -100,7 +100,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.20-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.21-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.20-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-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.20
docker.all-hands.dev/all-hands-ai/openhands:0.21
```
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!

View File

@@ -75,7 +75,7 @@ workspace_base = "./workspace"
#run_as_openhands = true
# Runtime environment
#runtime = "eventstream"
#runtime = "docker"
# Name of the default agent
#default_agent = "CodeActAgent"

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.20-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.21-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.20-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.21-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

@@ -373,7 +373,7 @@ Les options de configuration de l'agent sont définies dans les sections `[agent
- Description : Si l'éditeur LLM est activé dans l'espace d'action (fonctionne uniquement avec l'appel de fonction)
**Utilisation du micro-agent**
- `use_microagents`
- `enable_prompt_extensions`
- Type : `bool`
- Valeur par défaut : `true`
- Description : Indique si l'utilisation des micro-agents est activée ou non

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.20-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-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.20 \
docker.all-hands.dev/all-hands-ai/openhands:0.21 \
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.20-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-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.20 \
docker.all-hands.dev/all-hands-ai/openhands:0.21 \
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.20-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-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.20
docker.all-hands.dev/all-hands-ai/openhands:0.21
```
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.20-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```

View File

@@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-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.20 \
docker.all-hands.dev/all-hands-ai/openhands:0.21 \
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.20-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-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.20 \
docker.all-hands.dev/all-hands-ai/openhands:0.21 \
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.20-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-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.20
docker.all-hands.dev/all-hands-ai/openhands:0.21
```
你也可以在可脚本化的[无头模式](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.20-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-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.20-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-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.20 \
docker.all-hands.dev/all-hands-ai/openhands:0.21 \
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.20-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-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.20 \
docker.all-hands.dev/all-hands-ai/openhands:0.21 \
python -m openhands.core.main -t "write a bash script that prints hi"
```

View File

@@ -50,17 +50,17 @@
The easiest way to run OpenHands is in Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.21-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.21-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.20
docker.all-hands.dev/all-hands-ai/openhands:0.21
```
You'll find OpenHands running at http://localhost:3000!

View File

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

View File

@@ -8,6 +8,9 @@ Please follow instruction [here](../../README.md#setup) to setup your local deve
## Test if your environment works
Follow the instructions here https://miniwob.farama.org/content/getting_started/ & https://miniwob.farama.org/content/viewing/
to set up MiniWoB server in your local environment at http://localhost:8080/miniwob/
Access with browser the above MiniWoB URLs and see if they load correctly.
## Run Evaluation

View File

@@ -71,7 +71,7 @@ def process_git_patch(patch):
return patch
def get_config(instance: pd.Series) -> AppConfig:
def get_config(metadata: EvalMetadata, instance: pd.Series) -> AppConfig:
# We use a different instance image for the each instance of swe-bench eval
base_container_image = get_instance_docker_image(instance['instance_id'])
logger.info(
@@ -132,7 +132,7 @@ def process_instance(
else:
logger.info(f'Starting evaluation for instance {instance.instance_id}.')
config = get_config(instance)
config = get_config(metadata, instance)
instance_id = instance.instance_id
model_patch = instance['model_patch']
test_spec: TestSpec = instance['test_spec']

View File

@@ -158,6 +158,7 @@ def get_config(
codeact_enable_browsing=RUN_WITH_BROWSING,
codeact_enable_llm_editor=False,
condenser=metadata.condenser_config,
enable_prompt_extensions=False,
)
config.set_agent_config(agent_config)
return config

View File

@@ -0,0 +1,674 @@
from collections import Counter
from copy import deepcopy
from difflib import SequenceMatcher
from io import BytesIO
from bs4 import BeautifulSoup, Comment, NavigableString, Tag
import cv2
import numpy as np
import torch
from colormath.color_conversions import convert_color
from colormath.color_diff import delta_e_cie2000
from colormath.color_objects import LabColor, sRGBColor
from PIL import Image, ImageChops, ImageColor
from scipy.optimize import linear_sum_assignment
from transformers import CLIPModel, CLIPProcessor
from openhands.core.logger import openhands_logger as logger
def calculate_similarity(block1, block2):
"""Calculate text similarity between two blocks using SequenceMatcher."""
text_similarity = SequenceMatcher(None, block1['text'], block2['text']).ratio()
return text_similarity
def adjust_cost_for_context(cost_matrix, consecutive_bonus=1.0, window_size=20):
"""Adjust cost matrix by considering context similarity."""
if window_size <= 0:
return cost_matrix
n, m = cost_matrix.shape
adjusted_cost_matrix = np.copy(cost_matrix)
for i in range(n):
for j in range(m):
if adjusted_cost_matrix[i][j] >= -0.5:
continue
nearby_matrix = cost_matrix[
max(0, i - window_size) : min(n, i + window_size + 1),
max(0, j - window_size) : min(m, j + window_size + 1),
]
flattened_array = nearby_matrix.flatten()
sorted_array = np.sort(flattened_array)[::-1]
sorted_array = np.delete(
sorted_array, np.where(sorted_array == cost_matrix[i, j])[0][0]
)
top_k_elements = sorted_array[-window_size * 2 :]
bonus = consecutive_bonus * np.sum(top_k_elements)
adjusted_cost_matrix[i][j] += bonus
return adjusted_cost_matrix
def create_cost_matrix(A, B):
"""Create cost matrix for block matching."""
n = len(A)
m = len(B)
cost_matrix = np.zeros((n, m))
for i in range(n):
for j in range(m):
cost_matrix[i, j] = -calculate_similarity(A[i], B[j])
return cost_matrix
def calculate_distance_max_1d(x1, y1, x2, y2):
"""Calculate maximum 1D distance between points."""
return max(abs(x2 - x1), abs(y2 - y1))
def calculate_ratio(h1, h2):
"""Calculate ratio between two heights."""
return max(h1, h2) / min(h1, h2)
def rgb_to_lab(rgb):
"""Convert RGB color to Lab color space."""
rgb_color = sRGBColor(rgb[0], rgb[1], rgb[2], is_upscaled=True)
lab_color = convert_color(rgb_color, LabColor)
return lab_color
def color_similarity_ciede2000(rgb1, rgb2):
"""Calculate color similarity using CIEDE2000 formula."""
lab1 = rgb_to_lab(rgb1)
lab2 = rgb_to_lab(rgb2)
delta_e = delta_e_cie2000(lab1, lab2)
similarity = max(0, 1 - (delta_e / 100))
return similarity
def merge_blocks_wo_check(block1, block2):
"""Merge two blocks without additional checks."""
merged_text = block1['text'] + ' ' + block2['text']
x_min = min(block1['bbox'][0], block2['bbox'][0])
y_min = min(block1['bbox'][1], block2['bbox'][1])
x_max = max(
block1['bbox'][0] + block1['bbox'][2], block2['bbox'][0] + block2['bbox'][2]
)
y_max = max(
block1['bbox'][1] + block1['bbox'][3], block2['bbox'][1] + block2['bbox'][3]
)
merged_bbox = (x_min, y_min, x_max - x_min, y_max - y_min)
merged_color = tuple(
(color1 + color2) // 2
for color1, color2 in zip(block1['color'], block2['color'])
)
return {'text': merged_text, 'bbox': merged_bbox, 'color': merged_color}
def find_maximum_matching(A, B, consecutive_bonus, window_size):
"""Find maximum matching between two sets of blocks."""
cost_matrix = create_cost_matrix(A, B)
cost_matrix = adjust_cost_for_context(cost_matrix, consecutive_bonus, window_size)
row_ind, col_ind = linear_sum_assignment(cost_matrix)
current_cost = cost_matrix[row_ind, col_ind].tolist()
return list(zip(row_ind, col_ind)), current_cost, cost_matrix
def remove_indices(lst, indices):
"""Remove indices from list in reverse order."""
for index in sorted(indices, reverse=True):
if index < len(lst):
lst.pop(index)
return lst
def merge_blocks_by_list(blocks, merge_list):
"""Merge blocks according to merge list."""
pop_list = []
while merge_list:
i = merge_list[0][0]
j = merge_list[0][1]
blocks[i] = merge_blocks_wo_check(blocks[i], blocks[j])
pop_list.append(j)
merge_list.pop(0)
if merge_list:
new_merge_list = []
for k in range(len(merge_list)):
if (
merge_list[k][0] != i
and merge_list[k][1] != i
and merge_list[k][0] != j
and merge_list[k][1] != j
):
new_merge_list.append(merge_list[k])
merge_list = new_merge_list
remove_indices(blocks, pop_list)
return blocks
def difference_of_means(list1, list2):
"""Calculate difference of means between two lists."""
counter1 = Counter(list1)
counter2 = Counter(list2)
for element in set(list1) & set(list2):
common_count = min(counter1[element], counter2[element])
counter1[element] -= common_count
counter2[element] -= common_count
unique_list1 = [item for item in counter1.elements()]
unique_list2 = [item for item in counter2.elements()]
mean_list1 = sum(unique_list1) / len(unique_list1) if unique_list1 else 0
mean_list2 = sum(unique_list2) / len(unique_list2) if unique_list2 else 0
if mean_list1 - mean_list2 > 0:
if min(unique_list1) > min(unique_list2):
return mean_list1 - mean_list2
return 0.0
return mean_list1 - mean_list2
def find_possible_merge(A, B, consecutive_bonus, window_size, debug=False):
"""Find possible merges between blocks."""
merge_bonus = 0.0
merge_windows = 1
def sortFn(value):
return value[2]
while True:
A_changed = False
B_changed = False
matching, current_cost, cost_matrix = find_maximum_matching(
A, B, merge_bonus, merge_windows
)
if len(A) >= 2:
merge_list = []
for i in range(len(A) - 1):
new_A = deepcopy(A)
new_A[i] = merge_blocks_wo_check(new_A[i], new_A[i + 1])
new_A.pop(i + 1)
updated_matching, updated_cost, _ = find_maximum_matching(
new_A, B, merge_bonus, merge_windows
)
diff = difference_of_means(current_cost, updated_cost)
if diff > 0.05:
merge_list.append([i, i + 1, diff])
merge_list.sort(key=sortFn, reverse=True)
if merge_list:
A_changed = True
A = merge_blocks_by_list(A, merge_list)
matching, current_cost, cost_matrix = find_maximum_matching(
A, B, merge_bonus, merge_windows
)
if len(B) >= 2:
merge_list = []
for i in range(len(B) - 1):
new_B = deepcopy(B)
new_B[i] = merge_blocks_wo_check(new_B[i], new_B[i + 1])
new_B.pop(i + 1)
updated_matching, updated_cost, _ = find_maximum_matching(
A, new_B, merge_bonus, merge_windows
)
diff = difference_of_means(current_cost, updated_cost)
if diff > 0.05:
merge_list.append([i, i + 1, diff])
merge_list.sort(key=sortFn, reverse=True)
if merge_list:
B_changed = True
B = merge_blocks_by_list(B, merge_list)
matching, current_cost, cost_matrix = find_maximum_matching(
A, B, merge_bonus, merge_windows
)
if not A_changed and not B_changed:
break
matching, _, _ = find_maximum_matching(A, B, consecutive_bonus, window_size)
return A, B, matching
def merge_blocks_by_bbox(blocks):
"""Merge blocks with same bounding box."""
merged_blocks = {}
for block in blocks:
bbox = tuple(block['bbox'])
if bbox in merged_blocks:
existing_block = merged_blocks[bbox]
existing_block['text'] += ' ' + block['text']
existing_block['color'] = [
(ec + c) / 2 for ec, c in zip(existing_block['color'], block['color'])
]
else:
merged_blocks[bbox] = block
return list(merged_blocks.values())
def mask_bounding_boxes_with_inpainting(image, bounding_boxes):
"""Mask bounding boxes in image using inpainting."""
image_cv = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR)
mask = np.zeros(image_cv.shape[:2], dtype=np.uint8)
height, width = image_cv.shape[:2]
for bbox in bounding_boxes:
x_ratio, y_ratio, w_ratio, h_ratio = bbox
x = int(x_ratio * width)
y = int(y_ratio * height)
w = int(w_ratio * width)
h = int(h_ratio * height)
mask[y : y + h, x : x + w] = 255
inpainted_image = cv2.inpaint(image_cv, mask, 3, cv2.INPAINT_TELEA)
return Image.fromarray(cv2.cvtColor(inpainted_image, cv2.COLOR_BGR2RGB))
def rescale_and_mask(image, blocks):
"""Rescale image and mask blocks."""
if blocks:
image = mask_bounding_boxes_with_inpainting(image, blocks)
width, height = image.size
if width < height:
new_size = (width, width)
else:
new_size = (height, height)
return image.resize(new_size, Image.LANCZOS)
def calculate_clip_similarity(image1, image2, blocks1, blocks2):
"""Calculate CLIP similarity between two images."""
model = CLIPModel.from_pretrained('openai/clip-vit-base-patch32')
processor = CLIPProcessor.from_pretrained('openai/clip-vit-base-patch32')
device = 'cuda' if torch.cuda.is_available() else 'cpu'
model = model.to(device)
# Mask and preprocess images
image1_masked = rescale_and_mask(image1, [block['bbox'] for block in blocks1])
image2_masked = rescale_and_mask(image2, [block['bbox'] for block in blocks2])
inputs = processor(
images=[image1_masked, image2_masked], return_tensors='pt', padding=True
)
inputs = {k: v.to(device) for k, v in inputs.items()}
# Calculate features and similarity
with torch.no_grad():
image_features = model.get_image_features(**inputs)
image_features1 = image_features[0].unsqueeze(0)
image_features2 = image_features[1].unsqueeze(0)
image_features1 /= image_features1.norm(dim=-1, keepdim=True)
image_features2 /= image_features2.norm(dim=-1, keepdim=True)
similarity = (image_features1 @ image_features2.T).item()
return similarity
def rgb_to_hex(rgb):
"""Convert an RGB tuple to hexadecimal format."""
return '{:02X}{:02X}{:02X}'.format(*rgb)
class ColorPool:
def __init__(self, offset=0):
color_values = list(range(10, 251, 16))
color_list = [((r + offset) % 256, (g + offset) % 256, (b + offset) % 256)
for r in color_values for g in color_values for b in color_values]
self.color_pool = [rgb_to_hex(color) for color in color_list]
def pop_color(self):
if self.color_pool:
return self.color_pool.pop()
else:
raise NotImplementedError
def process_html_str(html_str, offset=0):
"""Process HTML string to assign unique colors to text elements."""
soup = BeautifulSoup(html_str, 'html.parser')
def update_style(element, property_name, value):
important_value = f"{value} !important"
styles = element.attrs.get('style', '').split(';')
updated_styles = [s for s in styles if not s.strip().startswith(property_name) and len(s.strip()) > 0]
updated_styles.append(f"{property_name}: {important_value}")
element['style'] = '; '.join(updated_styles).strip()
# Set background color of all elements to transparent white
for element in soup.find_all(True):
update_style(element, 'background-color', 'rgba(255, 255, 255, 0.0)')
color_pool = ColorPool(offset)
text_tags = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'div', 'span', 'a', 'b', 'li',
'table', 'td', 'th', 'button', 'footer', 'header', 'figcaption']
for tag in soup.find_all(text_tags):
color = f"#{color_pool.pop_color()}"
update_style(tag, 'color', color)
update_style(tag, 'opacity', '1.0')
return str(soup)
def similar(n1, n2):
"""Check if two numbers are similar within a threshold."""
return abs(n1 - n2) <= 8
def find_different_pixels(image1, image2):
"""Find pixels that differ between two images."""
if image1.size != image2.size:
logger.warning("Images are not the same size")
return None
image1 = image1.convert('RGB')
image2 = image2.convert('RGB')
pixels1 = image1.load()
pixels2 = image2.load()
different_pixels = []
for x in range(image1.size[0]):
for y in range(image1.size[1]):
r1, g1, b1 = pixels1[x, y]
r2, g2, b2 = pixels2[x, y]
if similar((r1 + 50) % 256, r2) and similar((g1 + 50) % 256, g2) and similar((b1 + 50) % 256, b2):
different_pixels.append((y, x))
return np.stack(different_pixels) if different_pixels else None
def extract_text_with_color(html_str):
"""Extract text and color information from HTML string."""
def get_color(tag):
if 'style' in tag.attrs:
styles = tag['style'].split(';')
color_style = [s for s in styles if 'color' in s and 'background-color' not in s]
if color_style:
color = color_style[-1].split(':')[1].strip().replace(" !important", "")
if color[0] == "#":
return color
else:
try:
if color.startswith('rgb'):
color = tuple(map(int, color[4:-1].split(',')))
else:
color = ImageColor.getrgb(color)
return '#{:02x}{:02x}{:02x}'.format(*color)
except ValueError:
logger.warning(f"Unable to identify or convert color: {color}")
return None
return None
def extract_text_recursive(element, parent_color='#000000'):
if isinstance(element, Comment):
return None
elif isinstance(element, NavigableString):
text = element.strip()
return (text, parent_color) if text else None
elif isinstance(element, Tag):
current_color = get_color(element) or parent_color
children_texts = filter(None, [extract_text_recursive(child, current_color)
for child in element.children])
return list(children_texts)
soup = BeautifulSoup(html_str, 'html.parser')
body = soup.body
return extract_text_recursive(body) if body else []
def flatten_tree(tree):
"""Flatten a nested tree structure into a list."""
flat_list = []
def flatten(node):
if isinstance(node, list):
for item in node:
flatten(item)
else:
flat_list.append(node)
flatten(tree)
return flat_list
def get_blocks_from_image_diff_pixels(image, html_text_color_tree, different_pixels):
"""Extract text blocks from image using color differences."""
image_cv = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR)
x_w = image_cv.shape[0]
y_w = image_cv.shape[1]
def hex_to_bgr(hex_color):
hex_color = hex_color.lstrip('#')
rgb = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
return rgb[::-1]
def get_intersect(arr1, arr2):
arr1_reshaped = arr1.view([('', arr1.dtype)] * arr1.shape[1])
arr2_reshaped = arr2.view([('', arr2.dtype)] * arr2.shape[1])
common_rows = np.intersect1d(arr1_reshaped, arr2_reshaped)
return common_rows.view(arr1.dtype).reshape(-1, arr1.shape[1])
blocks = []
for item in html_text_color_tree:
try:
color = np.array(hex_to_bgr(item[1]), dtype="uint8")
except:
continue
lower = color - 4
upper = color + 4
mask = cv2.inRange(image_cv, lower, upper)
coords = np.column_stack(np.where(mask > 0))
coords = get_intersect(coords, different_pixels)
if coords.size == 0:
continue
x_min, y_min = np.min(coords, axis=0)
x_max, y_max = np.max(coords, axis=0)
# Get average color from original image
color_coords = coords.copy()
color_coords = color_coords[color_coords[:, 0] <= x_max]
color_coords = color_coords[color_coords[:, 1] <= y_max]
colors = [image_cv[x, y] for x, y in color_coords]
avg_color = tuple(map(int, np.mean(colors, axis=0)))[::-1] # Convert BGR to RGB
blocks.append({
'text': item[0].lower(),
'bbox': (y_min / y_w, x_min / x_w, (y_max - y_min + 1) / y_w, (x_max - x_min + 1) / x_w),
'color': avg_color
})
return blocks
def get_blocks_from_html(html_str, image1):
"""Extract text blocks from HTML and image."""
# Process HTML with two different color offsets
html_str_1 = process_html_str(html_str, offset=0)
html_str_2 = process_html_str(html_str, offset=50)
# Render both HTML versions to images
# TODO: Screenshot html_str_2
filter_color = (255, 0, 0)
image2 = Image.new("RGB", image1.size, filter_color)
# Find pixels that differ between the two rendered images
different_pixels = find_different_pixels(image1, image2)
if different_pixels is None:
logger.warning("Unable to get pixels with different colors")
return []
# Extract text and color information from HTML
html_text_color_tree = flatten_tree(extract_text_with_color(html_str_1))
try:
blocks = get_blocks_from_image_diff_pixels(image1, html_text_color_tree, different_pixels)
except Exception as e:
logger.warning(f"Unable to get blocks: {e}")
return []
return blocks
def evaluate(task, generated_img):
"""Evaluate generated image against reference image using multiple metrics."""
# Load reference image
post_image = task['post_image']
# Extract blocks from HTML and images
post_blocks = get_blocks_from_html(task['post_html'], post_image)
gen_blocks = get_blocks_from_html(task['gen_html'], generated_img)
print("block details", post_blocks, gen_blocks)
if not post_blocks or not gen_blocks:
# Fallback to basic CLIP and pixel comparison if no blocks available
clip_score = calculate_clip_similarity(post_image, generated_img, [], [])
logger.info(f'CLIP similarity score: {clip_score}')
# Pixel comparison
diff = ImageChops.difference(generated_img, post_image)
pixel_match = not diff.getbbox()
logger.info(
f"Pixel difference analysis: {'No difference' if pixel_match else 'Differences found'}"
)
return clip_score > 0.95 or pixel_match
# Merge blocks with same bounding boxes
post_blocks = merge_blocks_by_bbox(post_blocks)
gen_blocks = merge_blocks_by_bbox(gen_blocks)
# Find optimal block matching
consecutive_bonus, window_size = 0.1, 1
gen_blocks_m, post_blocks_m, matching = find_possible_merge(
gen_blocks, deepcopy(post_blocks), consecutive_bonus, window_size
)
# Filter matches with low similarity
filtered_matching = []
for i, j in matching:
text_similarity = calculate_similarity(gen_blocks_m[i], post_blocks_m[j])
if text_similarity >= 0.5:
filtered_matching.append([i, j, text_similarity])
matching = filtered_matching
if not matching:
logger.warning('No matching blocks found')
clip_score = calculate_clip_similarity(
post_image, generated_img, gen_blocks, post_blocks
)
return clip_score > 0.95
# Calculate metrics for matched blocks
indices1 = [item[0] for item in matching]
indices2 = [item[1] for item in matching]
# Calculate unmatched areas
unmatched_area_1 = sum(
block['bbox'][2] * block['bbox'][3]
for i, block in enumerate(gen_blocks_m)
if i not in indices1
)
unmatched_area_2 = sum(
block['bbox'][2] * block['bbox'][3]
for j, block in enumerate(post_blocks_m)
if j not in indices2
)
total_unmatched_area = unmatched_area_1 + unmatched_area_2
# Calculate metrics for matched blocks
matched_areas = []
text_scores = []
position_scores = []
color_scores = []
for i, j, text_similarity in matching:
# Area
block_area = (
gen_blocks_m[i]['bbox'][2] * gen_blocks_m[i]['bbox'][3]
+ post_blocks_m[j]['bbox'][2] * post_blocks_m[j]['bbox'][3]
)
matched_areas.append(block_area)
# Position similarity
position_similarity = 1 - calculate_distance_max_1d(
gen_blocks_m[i]['bbox'][0] + gen_blocks_m[i]['bbox'][2] / 2,
gen_blocks_m[i]['bbox'][1] + gen_blocks_m[i]['bbox'][3] / 2,
post_blocks_m[j]['bbox'][0] + post_blocks_m[j]['bbox'][2] / 2,
post_blocks_m[j]['bbox'][1] + post_blocks_m[j]['bbox'][3] / 2,
)
# Color similarity
color_similarity = color_similarity_ciede2000(
gen_blocks_m[i]['color'], post_blocks_m[j]['color']
)
text_scores.append(text_similarity)
position_scores.append(position_similarity)
color_scores.append(color_similarity)
# Calculate final scores
total_area = sum(matched_areas) + total_unmatched_area
size_score = sum(matched_areas) / total_area if total_area > 0 else 0
text_score = np.mean(text_scores) if text_scores else 0
position_score = np.mean(position_scores) if position_scores else 0
color_score = np.mean(color_scores) if color_scores else 0
clip_score = calculate_clip_similarity(
post_image, generated_img, gen_blocks, post_blocks
)
# Combine scores with equal weights
final_score = 0.2 * (
size_score + text_score + position_score + color_score + clip_score
)
logger.info('Evaluation scores:')
logger.info(f'- Size score: {size_score:.3f}')
logger.info(f'- Text score: {text_score:.3f}')
logger.info(f'- Position score: {position_score:.3f}')
logger.info(f'- Color score: {color_score:.3f}')
logger.info(f'- CLIP score: {clip_score:.3f}')
logger.info(f'- Final score: {final_score:.3f}')
return final_score > 0.8 # Consider it a match if final score > 80%
def png_to_bytes(png):
buffer = BytesIO()
png.save(buffer, format='PNG')
image_bytes = buffer.getvalue()
return image_bytes
def bytes_to_image(image_bytes):
"""Convert bytes to a Pillow Image object."""
return Image.open(BytesIO(image_bytes))
if __name__ == '__main__':
first_image = Image.open('./evaluation/visualcodebench/data/1/post.png')
image = Image.open('./evaluation/visualcodebench/data/1/prev.png')
html_file = open('./evaluation/visualcodebench/data/1/post/index.html', 'r')
first_html = html_file.read()
html_file.close()
html_file = open('./evaluation/visualcodebench/data/1/prev/index.html', 'r')
gen_html = html_file.read()
html_file.close()
sample = {'post_image': first_image, "post_html": first_html, "gen_html": gen_html}
evaluate(sample, image)

View File

@@ -0,0 +1,97 @@
import base64
import os
from io import BytesIO
import pandas as pd
from huggingface_hub import snapshot_download
from PIL import PngImagePlugin
from tqdm import tqdm
from openhands.core.logger import openhands_logger as logger
REPO_DOWNLOAD_DIR = (
'./evaluation/visualcodebench/' # Directory to store the downloaded repository
)
def download_repository():
"""
Download the entire repository from Hugging Face Hub.
This function clones the repository into REPO_DOWNLOAD_DIR.
"""
repo_id = 'rvmalhot/VisualCodeBench'
try:
logger.info(f"Downloading repository '{repo_id}'...")
snapshot_download(
repo_id=repo_id,
local_dir=REPO_DOWNLOAD_DIR,
repo_type='dataset',
ignore_patterns=None, # Download all files
)
logger.info(f"Repository downloaded to '{REPO_DOWNLOAD_DIR}'.")
except Exception as e:
logger.error(f"Error downloading repository '{repo_id}': {e}")
raise e
def format_task_dict(example):
instance_id = example['id']
prev_remote_path = os.path.join(REPO_DOWNLOAD_DIR, f'data/{instance_id}/prev')
post_remote_path = os.path.join(REPO_DOWNLOAD_DIR, f'data/{instance_id}/post')
# Check if 'prev' and 'post' directories exist
prev_exists = os.path.exists(prev_remote_path)
post_exists = os.path.exists(post_remote_path)
if prev_exists and post_exists:
skip = False
else:
skip = True
task = {
'instance_id': instance_id,
'prev_image': example['prev_image'],
'post_image': example['post_image'],
'changes': example['changes'],
'prev_code_files': example['prev_code_files'],
'post_code_files': example['post_code_files'],
'skip': skip,
}
return task
def prepare_visualcodebench(dataset):
logger.info('Processing dataset')
dataset_processed = []
for example in tqdm(dataset['train']):
formatted_example = format_task_dict(example)
if formatted_example['skip']:
continue
del formatted_example['skip']
dataset_processed.append(formatted_example)
return pd.DataFrame(dataset_processed)
def pil_image_to_base64(image: PngImagePlugin.PngImageFile) -> str:
"""
Converts a PIL image to a Base64-encoded string.
Parameters:
- image (PngImagePlugin.PngImageFile): The PIL image to convert.
Returns:
- str: The Base64-encoded string of the image.
"""
if not isinstance(image, PngImagePlugin.PngImageFile):
raise ValueError(
'The provided image is not a PIL.PngImagePlugin.PngImageFile instance.'
)
buffered = BytesIO()
image.save(buffered, format='PNG')
img_bytes = buffered.getvalue()
img_base64 = base64.b64encode(img_bytes).decode('utf-8')
base64_with_prefix = f'data:image/png;base64,{img_base64}'
return [base64_with_prefix]

View File

@@ -0,0 +1,247 @@
# FILE: run_infer.py
import asyncio
import os
import shutil
import tempfile
from functools import partial
import pandas as pd
from datasets import load_dataset
# from evaluation.benchmarks.visualcodebench.eval import capture_screenshot
from evaluation.benchmarks.visualcodebench.prepare import (
REPO_DOWNLOAD_DIR,
download_repository,
pil_image_to_base64,
prepare_visualcodebench,
)
from evaluation.utils.shared import (
EvalMetadata,
assert_and_raise,
codeact_user_response,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
run_evaluation,
)
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
)
from openhands.core.config.utils import parse_arguments
from openhands.core.logger import openhands_logger as logger # Import OpenHands logger
from openhands.core.main import create_runtime, run_controller
from openhands.events.action.commands import CmdRunAction
from openhands.events.action.message import MessageAction
from openhands.events.observation.commands import CmdOutputObservation
from openhands.runtime.base import Runtime
from openhands.utils.async_utils import call_async_from_sync
# Define workspace and output directories
WORKSPACE_DIR = './workspace'
FAKE_RESPONSES = {
'CodeActAgent': partial(codeact_user_response, encapsulate_solution=True),
}
def get_config(
metadata: EvalMetadata,
) -> AppConfig:
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='eventstream',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
enable_auto_lint=True,
use_host_network=False,
),
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(metadata.llm_config)
return config
def initialize_runtime(
runtime: Runtime,
instance: pd.Series, # this argument is not required
):
"""Initialize the runtime for the agent.
This function is called before the runtime is used to run the agent.
"""
logger.info('-' * 30)
logger.info('BEGIN Runtime Initialization Fn')
logger.info('-' * 30)
workspace_dir_name = instance['instance_id']
obs: CmdOutputObservation
action = CmdRunAction(command='mkdir -p /workspace/{workspace_dir_name}')
action.timeout = 600
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert_and_raise(
obs.exit_code == 0,
f'Failed to create /workspace/{workspace_dir_name}: {str(obs)}',
)
file_path = REPO_DOWNLOAD_DIR + f'data/{workspace_dir_name}/prev/index.html'
runtime.copy_to(file_path, f'/workspace/{workspace_dir_name}')
logger.info(f'Copied code file for instance {workspace_dir_name}')
action = CmdRunAction(command=f'cd /workspace/{workspace_dir_name}')
action.timeout = 600
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert_and_raise(
obs.exit_code == 0,
f'Failed to cd to /workspace/{workspace_dir_name}: {str(obs)}',
)
logger.info('-' * 30)
logger.info('END Runtime Initialization Fn')
logger.info('-' * 30)
def complete_runtime(
runtime: Runtime,
instance: pd.Series, # this argument is not required, but it is used to get the workspace_dir_name
) -> str:
# TODO: extract edited HTML file from agent workspace
# temp_zip = runtime.copy_from(f'/workspace/{instance.instance_id}')
# file_name = f'/workspace/{instance.instance_id}/index.html'
# with zipfile.ZipFile(temp_zip, 'r') as zip_ref:
# if file_name in zip_ref.namelist():
# with zip_ref.open(file_name) as file:
# file_content = file.read().decode('utf-8') # Decode bytes to string
# else:
# raise FileNotFoundError(f"'{file_name}' not found in the ZIP archive.")
with tempfile.TemporaryDirectory() as tmpdir:
src_folder = REPO_DOWNLOAD_DIR + f'data/{instance.instance_id}/post/'
shutil.copytree(src_folder, tmpdir, dirs_exist_ok=True)
# image = capture_screenshot(tmpdir)
# if image is not None:
# shutil.copy(os.path.join(tmpdir, 'final_screenshot.png'), REPO_DOWNLOAD_DIR)
def process_instance(
instance: pd.Series, metadata: EvalMetadata, reset_logger: bool = True
):
config = get_config(metadata)
# Setup the logger properly, so you can run multi-processing to parallelize the evaluation
if reset_logger:
log_dir = os.path.join(metadata.eval_output_dir, 'infer_logs')
reset_logger_for_multiprocessing(logger, instance.instance_id, log_dir)
else:
logger.info(f'Starting evaluation for instance {instance.instance_id}.')
# =============================================
# build instruction
# =============================================
# Prepare instruction
instruction = (
f"Modify the HTML/CSS according to the following instruction:\n\n"
f"{instance['changes']}\n\n"
)
instruction += (
'IMPORTANT: You should ONLY interact with the environment provided '
'to you AND NEVER ASK FOR HUMAN HELP.\n'
)
# =============================================
# create sandbox and run the agent
# =============================================
runtime: Runtime = create_runtime(config)
call_async_from_sync(runtime.connect)
try:
initialize_runtime(runtime, instance=instance)
image_urls = pil_image_to_base64(instance['prev_image'])
action = MessageAction(content=instruction, image_urls=image_urls)
state: State | None = asyncio.run(
run_controller(
config=config,
initial_user_action=action,
runtime=runtime,
fake_user_response_fn=FAKE_RESPONSES[metadata.agent_class],
)
)
if state is None:
raise ValueError('State should not be None.')
# =============================================
# result evaluation
# =============================================
return_val = complete_runtime(runtime, instance)
logger.info(f'Return value {return_val}')
finally:
runtime.close()
# TODO: return EVAL output
def main():
"""Main function to run the evaluation."""
# args = parse_args()
args = parse_arguments()
logger.info(f"\n{'='*80}\nStarting VisualCodeBench Evaluation\n{'='*80}")
logger.info(f'Agent: {args.agent_cls}')
logger.info(f'Model: {args.llm_config}')
logger.info(f'Max iterations: {args.max_iterations}')
logger.info(f'Eval limit: {args.eval_n_limit}')
logger.info(f'Num workers: {args.eval_num_workers}\n')
logger.info(f'Eval output: {args.eval_output_dir}\n')
# Step 1: Download the entire repository once
logger.info('Downloading repository...')
download_repository()
# Step 2: Load Dataset
logger.info('Loading dataset...')
dataset = load_dataset(REPO_DOWNLOAD_DIR)
# Step 3: Prepare dataset
llm_config = get_llm_config_arg(args.llm_config)
if llm_config is None:
logger.error(f'Could not find LLM config: {args.llm_config}')
raise ValueError(f'Could not find LLM config: {args.llm_config}')
metadata = make_metadata(
llm_config,
'VisualCodeBench',
args.agent_cls,
args.max_iterations,
args.eval_note,
'evaluation/output/',
)
output_file = os.path.join(metadata.eval_output_dir, 'output.jsonl')
dataset = prepare_visualcodebench(dataset)
instances = prepare_dataset(dataset, output_file, eval_n_limit=args.eval_n_limit)
# Step 4: Run eval
run_evaluation(
instances, metadata, output_file, args.eval_num_workers, process_instance
)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,46 @@
#!/bin/bash
set -eo pipefail
source "evaluation/utils/version_control.sh"
# Check if required arguments are provided
if [ "$#" -lt 4 ]; then
echo "Usage: $0 [model_config] [commit_hash] [agent_cls] [eval_limit] [num_workers]"
echo "Example: $0 llm.eval_gpt_4o_mini HEAD CodeActAgent 5 1"
exit 1
fi
MODEL_CONFIG=$1
COMMIT_HASH=$2
AGENT_CLS=$3
EVAL_LIMIT=$4
NUM_WORKERS=${5:-1} # Default to 1 worker if not specified
# Checkout the specified commit
checkout_eval_branch
if [ -z "$AGENT" ]; then
echo "Agent not specified, use default CodeActAgent"
AGENT="CodeActAgent"
fi
get_openhands_version
echo "AGENT: $AGENT"
echo "OPENHANDS_VERSION: $OPENHANDS_VERSION"
echo "MODEL_CONFIG: $MODEL_CONFIG"
COMMAND="export PYTHONPATH=evaluation/benchmarks/visualcodebench:\$PYTHONPATH && poetry run python evaluation/benchmarks/visualcodebench/run_infer.py \
--agent-cls $AGENT \
--llm-config $MODEL_CONFIG \
--max-iterations 5 \
--eval-num-workers $NUM_WORKERS \
--eval-note $OPENHANDS_VERSION" \
if [ -n "$EVAL_LIMIT" ]; then
echo "EVAL_LIMIT: $EVAL_LIMIT"
COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT"
fi
# Run the command
eval $COMMAND

View File

@@ -0,0 +1,167 @@
import http
import os
import socket
import socketserver
import threading
import time
from io import BytesIO
import requests
from PIL import Image, ImageChops
from playwright.sync_api import sync_playwright
from openhands.core.logger import openhands_logger as logger
def get_free_port():
"""Find a free port to run the HTTP server."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('', 0))
return s.getsockname()[1]
def start_http_server(tmpdir):
port = get_free_port()
class CustomHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
def translate_path(self, path):
# Serve files from the specified directory instead of the current working directory
path = super().translate_path(path)
relative_path = os.path.relpath(path, os.getcwd())
return os.path.join(tmpdir, relative_path)
handler = CustomHTTPRequestHandler
server = socketserver.TCPServer(('', port), handler)
return server, port
def capture_screenshot(tmpdir):
server, port = start_http_server(tmpdir)
server_thread = threading.Thread(target=server.serve_forever)
server_thread.daemon = True
server_thread.start()
time.sleep(10)
image = None
try:
server_url = f'http://localhost:{port}/'
if not is_server_reachable(server_url):
raise RuntimeError(f'Server not reachable at {server_url}')
screenshot_path = os.path.join(tmpdir, 'final_screenshot.png')
capture_screenshot_playwright(server_url, screenshot_path)
image = Image.open(screenshot_path)
image.load()
finally:
# Shut down the server and clean up
server.shutdown()
server.server_close()
return image
def is_server_reachable(url):
"""
Check if the local server is reachable.
"""
try:
response = requests.get(url, timeout=5) # Set a 5-second timeout
if response.status_code == 200:
logger.info(f'Server is reachable at {url}')
return True
else:
logger.warning(
f'Server responded with status code {response.status_code} at {url}'
)
return False
except requests.ConnectionError as e:
logger.error(f'Failed to connect to server at {url}: {e}')
return False
def capture_screenshot_playwright(url, screenshot_path):
"""Capture a screenshot of the given URL using Playwright."""
try:
with sync_playwright() as p:
logger.info('Launching browser...')
browser = p.chromium.launch(timeout=10000) # 10 seconds for browser launch
logger.info('Creating a new page...')
page = browser.new_page()
logger.info(f'Navigating to URL: {url}')
try:
page.goto(url, timeout=60 * 1000) # Set timeout to 5 seconds
logger.info('Page navigation completed.')
except Exception as e:
logger.warning(f'Page navigation timed out. {e}. Continuing...')
logger.info('Waiting for network to be idle...')
try:
page.wait_for_load_state(
'networkidle', timeout=60 * 1000
) # Set timeout to 5 seconds
logger.info('Page load state reached.')
except Exception as e:
logger.warning(f'Page load state timed out. {e}. Continuing...')
logger.info('Capturing screenshot...')
page.screenshot(
path=screenshot_path, full_page=True
) # Capture full page screenshot
logger.info(f'Screenshot saved to {screenshot_path}')
browser.close()
return True
except Exception as e:
logger.error(f'Error capturing screenshot with Playwright: {e}')
return False
def evaluate(task, screenshot_path):
"""Compare generated screenshot with post_image using CLIP score."""
try:
import torch
from transformers import CLIPModel, CLIPProcessor
# Load CLIP model and processor
model = CLIPModel.from_pretrained('openai/clip-vit-base-patch32')
processor = CLIPProcessor.from_pretrained('openai/clip-vit-base-patch32')
# Load images
post_image = Image.open(BytesIO(task['post_image']))
generated_img = Image.open(screenshot_path)
# Process images
inputs = processor(
images=[post_image, generated_img], return_tensors='pt', padding=True
)
# Get image features
image_features = model.get_image_features(**inputs)
# Calculate cosine similarity
similarity = torch.nn.functional.cosine_similarity(
image_features[0].unsqueeze(0), image_features[1].unsqueeze(0)
).item()
logger.info(f'CLIP similarity score: {similarity}')
return similarity > 0.95 # Consider it a match if similarity > 95%
except Exception as e:
logger.error(f'Error in CLIP evaluation: {e}')
# Fallback to pixel comparison if CLIP fails
try:
post_image = Image.open(BytesIO(task['post_image']))
generated_img = Image.open(screenshot_path)
# Compare images directly without converting to bytes
diff = ImageChops.difference(generated_img, post_image)
logger.info(
f"Pixel difference analysis: {'No difference' if not diff.getbbox() else 'Differences found'}"
)
return not diff.getbbox()
except Exception as ex:
logger.error(f'Error in fallback evaluation: {ex}')
return False

View File

@@ -0,0 +1,50 @@
# VisualWebArena Evaluation with OpenHands Browsing Agents
This folder contains evaluation for [VisualWebArena](https://github.com/web-arena-x/visualwebarena) benchmark, powered by [BrowserGym](https://github.com/ServiceNow/BrowserGym) for easy evaluation of how well an agent capable of browsing can perform on realistic web browsing tasks.
## Setup Environment and LLM Configuration
Please follow instruction [here](../../README.md#setup) to setup your local development environment and LLM.
## Setup VisualWebArena Environment
VisualWebArena requires you to set up websites containing pre-populated content that is accessible via URL to the machine running the OpenHands agents.
Follow [this document](https://github.com/web-arena-x/visualwebarena/blob/main/environment_docker/README.md) to set up your own VisualWebArena environment through local servers or AWS EC2 instances.
Take note of the base URL (`$VISUALWEBARENA_BASE_URL`) of the machine where the environment is installed.
## Test if your environment works
Access with browser the above VisualWebArena website URLs and see if they load correctly.
If you cannot access the website, make sure the firewall allows public access of the aforementioned ports on your server
Check the network security policy if you are using an AWS machine.
Follow the VisualWebArena environment setup guide carefully, and make sure the URL fields are populated with the correct base URL of your server.
## Run Evaluation
```bash
export VISUALWEBARENA_BASE_URL=<YOUR_SERVER_URL_HERE>
export OPENAI_API_KEY="yourkey" # this OpenAI API key is required for some visualWebArena validators that utilize LLMs
export OPENAI_BASE_URL="https://api.openai.com/v1/" # base URL for OpenAI model used for VisualWebArena evaluation
bash evaluation/benchmarks/visualwebarena/scripts/run_infer.sh llm.claude HEAD VisualBrowsingAgent
```
Results will be in `evaluation/evaluation_outputs/outputs/visualwebarena/`
To calculate the success rate, run:
```sh
poetry run python evaluation/benchmarks/visualwebarena/get_success_rate.py evaluation/evaluation_outputs/outputs/visualwebarena/SOME_AGENT/EXP_NAME/output.jsonl
```
## Submit your evaluation results
You can start your own fork of [our huggingface evaluation outputs](https://huggingface.co/spaces/OpenHands/evaluation) and submit a PR of your evaluation results following the guide [here](https://huggingface.co/docs/hub/en/repositories-pull-requests-discussions#pull-requests-and-discussions).
## VisualBrowsingAgent V1.0 result
Tested on VisualBrowsingAgent V1.0
VisualWebArena, 910 tasks (high cost, single run due to fixed task), max step 15. Resolve rates are:
- GPT4o: 26.15%
- Claude-3.5 Sonnet: 25.27%

View File

@@ -0,0 +1,40 @@
import argparse
import json
import browsergym.visualwebarena # noqa F401 register visualwebarena tasks as gym environments
import gymnasium as gym
parser = argparse.ArgumentParser(description='Calculate average reward.')
parser.add_argument('output_path', type=str, help='path to output.jsonl')
args = parser.parse_args()
if __name__ == '__main__':
env_ids = [
id
for id in gym.envs.registry.keys()
if id.startswith('browsergym/visualwebarena')
]
total_num = len(env_ids)
print('Total number of tasks: ', total_num)
total_reward = 0
total_cost = 0
actual_num = 0
with open(args.output_path, 'r') as f:
for line in f:
data = json.loads(line)
actual_num += 1
total_cost += data['metrics']['accumulated_cost']
reward = data['test_result']['reward']
if reward >= 0:
total_reward += data['test_result']['reward']
else:
actual_num -= 1
avg_reward = total_reward / total_num
print('Total reward: ', total_reward)
print('Success Rate: ', avg_reward)
avg_cost = total_cost / actual_num
print('Avg Cost: ', avg_cost)
print('Total Cost: ', total_cost)
print('Actual number of tasks finished: ', actual_num)

View File

@@ -0,0 +1,254 @@
import asyncio
import json
import os
from typing import Any
import browsergym.visualwebarena # noqa F401 register visualwebarena tasks as gym environments
import gymnasium as gym
import pandas as pd
from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
compatibility_for_eval_history_pairs,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
run_evaluation,
update_llm_config_for_completions_logging,
)
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
parse_arguments,
)
from openhands.core.logger import openhands_logger as logger
from openhands.core.main import create_runtime, run_controller
from openhands.events.action import (
BrowseInteractiveAction,
CmdRunAction,
MessageAction,
)
from openhands.events.observation import CmdOutputObservation
from openhands.runtime.base import Runtime
from openhands.runtime.browser.browser_env import (
BROWSER_EVAL_GET_GOAL_ACTION,
BROWSER_EVAL_GET_REWARDS_ACTION,
)
from openhands.utils.async_utils import call_async_from_sync
SUPPORTED_AGENT_CLS = {'VisualBrowsingAgent'}
AGENT_CLS_TO_FAKE_USER_RESPONSE_FN = {
'VisualBrowsingAgent': 'Continue the task. IMPORTANT: do not talk to the user until you have finished the task',
}
def get_config(
metadata: EvalMetadata,
env_id: str,
) -> AppConfig:
base_url = os.environ.get('VISUALWEBARENA_BASE_URL', None)
openai_api_key = os.environ.get('OPENAI_API_KEY', None)
openai_base_url = os.environ.get('OPENAI_BASE_URL', None)
assert base_url is not None, 'VISUALWEBARENA_BASE_URL must be set'
assert openai_api_key is not None, 'OPENAI_API_KEY must be set'
assert openai_base_url is not None, 'OPENAI_BASE_URL must be set'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
enable_auto_lint=True,
use_host_network=False,
browsergym_eval_env=env_id,
runtime_startup_env_vars={
'BASE_URL': base_url,
'OPENAI_API_KEY': openai_api_key,
'OPENAI_BASE_URL': openai_base_url,
'VWA_CLASSIFIEDS': f'{base_url}:9980',
'VWA_CLASSIFIEDS_RESET_TOKEN': '4b61655535e7ed388f0d40a93600254c',
'VWA_SHOPPING': f'{base_url}:7770',
'VWA_SHOPPING_ADMIN': f'{base_url}:7780/admin',
'VWA_REDDIT': f'{base_url}:9999',
'VWA_GITLAB': f'{base_url}:8023',
'VWA_WIKIPEDIA': f'{base_url}:8888',
'VWA_HOMEPAGE': f'{base_url}:4399',
},
timeout=300,
),
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
attach_to_existing=True,
)
config.set_llm_config(
update_llm_config_for_completions_logging(
metadata.llm_config,
metadata.eval_output_dir,
env_id,
)
)
return config
def initialize_runtime(
runtime: Runtime,
) -> tuple[str, list]:
"""Initialize the runtime for the agent.
This function is called before the runtime is used to run the agent.
"""
logger.info(f"{'-' * 50} BEGIN Runtime Initialization Fn {'-' * 50}")
obs: CmdOutputObservation
# Set instance id
action = CmdRunAction(command='mkdir -p /workspace')
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
assert obs.exit_code == 0
action = BrowseInteractiveAction(browser_actions=BROWSER_EVAL_GET_GOAL_ACTION)
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
goal = obs.content
goal_image_urls = []
if hasattr(obs, 'goal_image_urls'):
goal_image_urls = obs.goal_image_urls
logger.info(f"{'-' * 50} END Runtime Initialization Fn {'-' * 50}")
return goal, goal_image_urls
def complete_runtime(
runtime: Runtime,
) -> dict[str, Any]:
"""Complete the runtime for the agent.
This function is called before the runtime is used to run the agent.
If you need to do something in the sandbox to get the correctness metric after
the agent has run, modify this function.
"""
logger.info(f"{'-' * 50} BEGIN Runtime Completion Fn {'-' * 50}")
obs: CmdOutputObservation
action = BrowseInteractiveAction(browser_actions=BROWSER_EVAL_GET_REWARDS_ACTION)
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
logger.info(f"{'-' * 50} END Runtime Completion Fn {'-' * 50}")
return {
'rewards': json.loads(obs.content),
}
def process_instance(
instance: pd.Series,
metadata: EvalMetadata,
reset_logger: bool = True,
):
env_id = instance.instance_id
config = get_config(metadata, env_id)
# Setup the logger properly, so you can run multi-processing to parallelize the evaluation
if reset_logger:
log_dir = os.path.join(metadata.eval_output_dir, 'infer_logs')
reset_logger_for_multiprocessing(logger, env_id, log_dir)
else:
logger.info(f'Starting evaluation for instance {env_id}.')
runtime = create_runtime(config)
call_async_from_sync(runtime.connect)
task_str, goal_image_urls = initialize_runtime(runtime)
initial_user_action = MessageAction(content=task_str, image_urls=goal_image_urls)
state: State | None = asyncio.run(
run_controller(
config=config,
initial_user_action=initial_user_action,
runtime=runtime,
)
)
# ======= Attempt to evaluate the agent's environment impact =======
# If you are working on some simpler benchmark that only evaluates the final model output (e.g., in a MessageAction)
# You can simply get the LAST `MessageAction` from the returned `state.history` and parse it for evaluation.
if state is None:
raise ValueError('State should not be None.')
metrics = state.metrics.get() if state.metrics else None
# Instruction obtained from the first message from the USER
instruction = ''
for event in state.history:
if isinstance(event, MessageAction):
instruction = event.content
break
try:
return_val = complete_runtime(runtime)
logger.info(f'Return value from complete_runtime: {return_val}')
reward = max(return_val['rewards'])
except Exception:
reward = -1.0 # kept -1 to identify instances for which evaluation failed.
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
# for compatibility with the existing output format, we can remake the pairs here
# remove when it becomes unnecessary
histories = compatibility_for_eval_history_pairs(state.history)
# Save the output
output = EvalOutput(
instance_id=env_id,
instruction=instruction,
metadata=metadata,
history=histories,
metrics=metrics,
error=state.last_error if state and state.last_error else None,
test_result={
'reward': reward,
},
)
runtime.close()
return output
if __name__ == '__main__':
args = parse_arguments()
dataset = pd.DataFrame(
{
'instance_id': [
id
for id in gym.envs.registry.keys()
if id.startswith('browsergym/visualwebarena')
]
}
)
llm_config = None
if args.llm_config:
llm_config = get_llm_config_arg(args.llm_config)
if llm_config is None:
raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
metadata = make_metadata(
llm_config,
'visualwebarena',
args.agent_cls,
args.max_iterations,
args.eval_note,
args.eval_output_dir,
)
output_file = os.path.join(metadata.eval_output_dir, 'output.jsonl')
instances = prepare_dataset(dataset, output_file, args.eval_n_limit)
run_evaluation(
instances,
metadata,
output_file,
args.eval_num_workers,
process_instance,
)

View File

@@ -0,0 +1,48 @@
#!/bin/bash
set -eo pipefail
source "evaluation/utils/version_control.sh"
# configure browsing agent
export USE_NAV="true"
export USE_CONCISE_ANSWER="true"
MODEL_CONFIG=$1
COMMIT_HASH=$2
AGENT=$3
EVAL_LIMIT=$4
NUM_WORKERS=$5
if [ -z "$NUM_WORKERS" ]; then
NUM_WORKERS=1
echo "Number of workers not specified, use default $NUM_WORKERS"
fi
checkout_eval_branch
if [ -z "$AGENT" ]; then
echo "Agent not specified, use default VisualBrowsingAgent"
AGENT="VisualBrowsingAgent"
fi
get_openhands_version
echo "AGENT: $AGENT"
echo "AGENT_VERSION: $OPENHANDS_VERSION"
echo "MODEL_CONFIG: $MODEL_CONFIG"
EVAL_NOTE="${OPENHANDS_VERSION}"
COMMAND="poetry run python evaluation/benchmarks/visualwebarena/run_infer.py \
--agent-cls $AGENT \
--llm-config $MODEL_CONFIG \
--max-iterations 15 \
--eval-num-workers $NUM_WORKERS \
--eval-note $EVAL_NOTE"
if [ -n "$EVAL_LIMIT" ]; then
echo "EVAL_LIMIT: $EVAL_LIMIT"
COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT"
fi
# Run the command
eval $COMMAND

View File

@@ -35,6 +35,7 @@ from openhands.utils.async_utils import call_async_from_sync
FAKE_RESPONSES = {
'CodeActAgent': fake_user_response,
'DelegatorAgent': fake_user_response,
'VisualBrowsingAgent': fake_user_response,
}

View File

@@ -1,4 +1,4 @@
import { render, screen, within } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
QueryClientProvider,
@@ -7,10 +7,12 @@ import {
} from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { createRoutesStub } from "react-router";
import React from "react";
import { ConversationPanel } from "#/components/features/conversation-panel/conversation-panel";
import OpenHands from "#/api/open-hands";
import { AuthProvider } from "#/context/auth-context";
import { clickOnEditButton } from "./utils";
import { queryClientConfig } from "#/query-client-config";
describe("ConversationPanel", () => {
const onCloseMock = vi.fn();
@@ -231,4 +233,47 @@ describe("ConversationPanel", () => {
expect(onCloseMock).toHaveBeenCalledOnce();
});
it("should refetch data on rerenders", async () => {
// We need to simulate the toggling of the component to test the refetching
function PanelWithToggle() {
const [isOpen, setIsOpen] = React.useState(true);
return (
<>
<button type="button" onClick={() => setIsOpen((prev) => !prev)}>
Toggle
</button>
{isOpen && <ConversationPanel onClose={onCloseMock} />}
</>
);
}
const MyRouterStub = createRoutesStub([
{
Component: PanelWithToggle,
path: "/",
},
]);
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
render(<MyRouterStub />, {
wrapper: ({ children }) => (
<AuthProvider>
<QueryClientProvider client={new QueryClient(queryClientConfig)}>
{children}
</QueryClientProvider>
</AuthProvider>
),
});
await waitFor(() => expect(getUserConversationsSpy).toHaveBeenCalledOnce());
const button = screen.getByText("Toggle");
await userEvent.click(button);
await userEvent.click(button);
await waitFor(() =>
expect(getUserConversationsSpy).toHaveBeenCalledTimes(2),
);
});
});

View File

@@ -8,6 +8,9 @@ import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
import OpenHands from "#/api/open-hands";
import { MOCK_USER_PREFERENCES } from "#/mocks/handlers";
// These tests will now fail because the conversation panel is rendered through a portal
// and technically not a child of the Sidebar component.
const renderSidebar = () => {
const RouterStub = createRoutesStub([
{
@@ -152,11 +155,13 @@ describe("Sidebar", () => {
const settingsModal = screen.getByTestId("ai-config-modal");
// Click the advanced options switch to show the API key input
const advancedOptionsSwitch = within(settingsModal).getByTestId("advanced-option-switch");
const advancedOptionsSwitch = within(settingsModal).getByTestId(
"advanced-option-switch",
);
await user.click(advancedOptionsSwitch);
const apiKeyInput = within(settingsModal).getByLabelText(/API\$KEY/i);
await user.type(apiKeyInput, "SET");
await user.type(apiKeyInput, "**********");
const saveButton = within(settingsModal).getByTestId(
"save-settings-button",

View File

@@ -1,12 +1,13 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FeedbackActions } from "#/components/features/feedback/feedback-actions";
import { TrajectoryActions } from "#/components/features/trajectory/trajectory-actions";
describe("FeedbackActions", () => {
describe("TrajectoryActions", () => {
const user = userEvent.setup();
const onPositiveFeedback = vi.fn();
const onNegativeFeedback = vi.fn();
const onExportTrajectory = vi.fn();
afterEach(() => {
vi.clearAllMocks();
@@ -14,9 +15,10 @@ describe("FeedbackActions", () => {
it("should render correctly", () => {
render(
<FeedbackActions
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
@@ -27,9 +29,10 @@ describe("FeedbackActions", () => {
it("should call onPositiveFeedback when positive feedback is clicked", async () => {
render(
<FeedbackActions
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
@@ -41,9 +44,10 @@ describe("FeedbackActions", () => {
it("should call onNegativeFeedback when negative feedback is clicked", async () => {
render(
<FeedbackActions
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
@@ -52,4 +56,19 @@ describe("FeedbackActions", () => {
expect(onNegativeFeedback).toHaveBeenCalled();
});
it("should call onExportTrajectory when negative feedback is clicked", async () => {
render(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
const exportButton = screen.getByTestId("export-trajectory");
await user.click(exportButton);
expect(onExportTrajectory).toHaveBeenCalled();
});
});

View File

@@ -1,20 +1,20 @@
import { describe, it, expect } from "vitest";
import store from "../src/store";
import {
setInitialQuery,
clearInitialQuery,
setInitialPrompt,
clearInitialPrompt,
} from "../src/state/initial-query-slice";
describe("Initial Query Behavior", () => {
it("should clear initial query when clearInitialQuery is dispatched", () => {
it("should clear initial query when clearInitialPrompt is dispatched", () => {
// Set up initial query in the store
store.dispatch(setInitialQuery("test query"));
expect(store.getState().initialQuery.initialQuery).toBe("test query");
store.dispatch(setInitialPrompt("test query"));
expect(store.getState().initialQuery.initialPrompt).toBe("test query");
// Clear the initial query
store.dispatch(clearInitialQuery());
store.dispatch(clearInitialPrompt());
// Verify initial query is cleared
expect(store.getState().initialQuery.initialQuery).toBeNull();
expect(store.getState().initialQuery.initialPrompt).toBeNull();
});
});

View File

@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.20.0",
"version": "0.21.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.20.0",
"version": "0.21.0",
"dependencies": {
"@monaco-editor/react": "^4.7.0-rc.0",
"@nextui-org/react": "^2.6.11",
@@ -21,6 +21,7 @@
"axios": "^1.7.9",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.0.1",
"i18next": "^24.2.1",
"i18next-browser-languagedetector": "^8.0.2",
"i18next-http-backend": "^3.0.1",
@@ -52,7 +53,8 @@
"@playwright/test": "^1.49.1",
"@react-router/dev": "^7.1.2",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.62.16",
"@tanstack/eslint-plugin-query": "^5.64.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.0",
@@ -73,7 +75,7 @@
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.2.2",
"eslint-plugin-prettier": "^5.2.3",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^4.6.2",
"husky": "^9.1.6",
@@ -103,6 +105,7 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -1591,6 +1594,7 @@
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^5.1.2",
@@ -1608,6 +1612,7 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -1620,6 +1625,7 @@
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
@@ -3402,6 +3408,7 @@
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
@@ -3415,6 +3422,7 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -3424,6 +3432,7 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
@@ -3524,6 +3533,7 @@
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
@@ -5461,10 +5471,11 @@
}
},
"node_modules/@tanstack/eslint-plugin-query": {
"version": "5.62.16",
"resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.62.16.tgz",
"integrity": "sha512-VhnHSQ/hc62olLzGhlLJ4BJGWynwjs3cDMsByasKJ3zjW1YZ+6raxOv0gHHISm+VEnAY42pkMowmSWrXfL4NTw==",
"version": "5.64.2",
"resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.64.2.tgz",
"integrity": "sha512-Xq7jRYvNtGMHjQEGUZLHgEMNB59hgTlqdmKor6cdJ6CMZ/nwmBGpnlr/dcHden7W7BPCdBVN4PWMZBICWvCNQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/utils": "^8.18.1"
},
@@ -5531,7 +5542,6 @@
"integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -5633,8 +5643,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -5755,7 +5764,7 @@
"version": "22.10.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz",
"integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==",
"devOptional": true,
"dev": true,
"dependencies": {
"undici-types": "~6.20.0"
}
@@ -5764,6 +5773,7 @@
"version": "19.0.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.7.tgz",
"integrity": "sha512-MoFsEJKkAtZCrC1r6CM8U22GzhG7u2Wir8ons/aCKH6MBdD1ibV24zOSSkdZVUKqN5i396zG5VKLYZ3yaUZdLA==",
"dev": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -6452,6 +6462,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -6461,6 +6472,7 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -6476,12 +6488,14 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
"dev": true,
"license": "MIT"
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
@@ -6495,6 +6509,7 @@
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
@@ -6820,6 +6835,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/basic-auth": {
@@ -6844,6 +6860,7 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -6892,6 +6909,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -6901,6 +6919,7 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
@@ -7051,6 +7070,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -7626,6 +7646,7 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -7640,6 +7661,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
@@ -7662,6 +7684,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true,
"license": "MIT",
"bin": {
"cssesc": "bin/cssesc"
@@ -7928,6 +7951,7 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dir-glob": {
@@ -7947,6 +7971,7 @@
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"dev": true,
"license": "MIT"
},
"node_modules/doctrine": {
@@ -7967,8 +7992,7 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/dot-case": {
"version": "3.0.4",
@@ -8012,6 +8036,7 @@
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true,
"license": "MIT"
},
"node_modules/ee-first": {
@@ -8030,6 +8055,7 @@
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true,
"license": "MIT"
},
"node_modules/encodeurl": {
@@ -8739,10 +8765,11 @@
}
},
"node_modules/eslint-plugin-prettier": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.2.tgz",
"integrity": "sha512-1yI3/hf35wmlq66C8yOyrujQnel+v5l1Vop5Cl2I6ylyNTT1JbuUUnV3/41PzwTzcyDp/oF0jWE3HXvcH5AQOQ==",
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.3.tgz",
"integrity": "sha512-qJ+y0FfCp/mQYQ/vWQ3s7eUlFEL4PyKfAJxsnYTJ4YT73nsJBWqmEpFryxV9OeUiqmsTsYJ5Y+KDNaeP31wrRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"prettier-linter-helpers": "^1.0.0",
"synckit": "^0.9.1"
@@ -9185,6 +9212,7 @@
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
"integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
@@ -9201,6 +9229,7 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@@ -9227,6 +9256,7 @@
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz",
"integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
@@ -9268,6 +9298,7 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -9387,6 +9418,7 @@
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
"integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==",
"dev": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.0",
@@ -9444,13 +9476,13 @@
}
},
"node_modules/framer-motion": {
"version": "11.16.1",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.16.1.tgz",
"integrity": "sha512-xsjhEUSWHn39g334PpBTH+QissgEJVJkpRGS/4QUyMSmoJSNxA+7FTuq61s+OXPMS4muu5k9Y6r7GpcNKhd1xA==",
"peer": true,
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.0.1.tgz",
"integrity": "sha512-u6p0Qc4cY/AEQAtrC7qiYlXla39qnWoI4JXY7OCNBDXwJ5yRBD8HU+RhaOqqziw2m/b0BDh32f44W94+wXonMQ==",
"license": "MIT",
"dependencies": {
"motion-dom": "^11.16.1",
"motion-utils": "^11.16.0",
"motion-dom": "^12.0.0",
"motion-utils": "^12.0.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
@@ -9657,6 +9689,7 @@
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"dev": true,
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
@@ -9677,6 +9710,7 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.3"
@@ -10428,6 +10462,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
@@ -10470,6 +10505,7 @@
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
"dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
@@ -10537,6 +10573,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -10591,6 +10628,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -10643,6 +10681,7 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
@@ -10871,6 +10910,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
@@ -10949,6 +10989,7 @@
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
@@ -10964,6 +11005,7 @@
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
@@ -11178,6 +11220,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
@@ -11190,6 +11233,7 @@
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true,
"license": "MIT"
},
"node_modules/lint-staged": {
@@ -11594,7 +11638,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -11965,6 +12008,7 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -12545,6 +12589,7 @@
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
@@ -12635,6 +12680,7 @@
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
@@ -12660,6 +12706,7 @@
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -12715,19 +12762,19 @@
}
},
"node_modules/motion-dom": {
"version": "11.16.1",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.16.1.tgz",
"integrity": "sha512-XVNf3iCfZn9OHPZYJQy5YXXLn0NuPNvtT3YCat89oAnr4D88Cr52KqFgKa8dWElBK8uIoQhpJMJEG+dyniYycQ==",
"peer": true,
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.0.0.tgz",
"integrity": "sha512-CvYd15OeIR6kHgMdonCc1ihsaUG4MYh/wrkz8gZ3hBX/uamyZCXN9S9qJoYF03GqfTt7thTV/dxnHYX4+55vDg==",
"license": "MIT",
"dependencies": {
"motion-utils": "^11.16.0"
"motion-utils": "^12.0.0"
}
},
"node_modules/motion-utils": {
"version": "11.16.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.16.0.tgz",
"integrity": "sha512-ngdWPjg31rD4WGXFi0eZ00DQQqKKu04QExyv/ymlC+3k+WIgYVFbt6gS5JsFPbJODTF/r8XiE/X+SsoT9c0ocw==",
"peer": true
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.0.0.tgz",
"integrity": "sha512-MNFiBKbbqnmvOjkPyOKgHUp3Q6oiokLkI1bEwm5QA28cxMZrv0CbbBGDNmhF6DIXsi1pCQBSs0dX8xjeER1tmA==",
"license": "MIT"
},
"node_modules/mri": {
"version": "1.2.0",
@@ -12819,6 +12866,7 @@
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0",
@@ -12939,6 +12987,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -13049,6 +13098,7 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -13058,6 +13108,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -13299,6 +13350,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/pako": {
@@ -13417,6 +13469,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -13426,12 +13479,14 @@
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true,
"license": "MIT"
},
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
@@ -13448,6 +13503,7 @@
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/path-to-regexp": {
@@ -13503,6 +13559,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -13528,6 +13585,7 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -13537,6 +13595,7 @@
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",
"integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -13615,6 +13674,7 @@
"version": "15.1.0",
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.0.0",
@@ -13632,6 +13692,7 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz",
"integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"camelcase-css": "^2.0.1"
@@ -13651,6 +13712,7 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz",
"integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -13686,6 +13748,7 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -13711,6 +13774,7 @@
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
@@ -13738,6 +13802,7 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true,
"license": "MIT"
},
"node_modules/posthog-js": {
@@ -13812,7 +13877,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -13828,7 +13892,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -14001,6 +14064,7 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
@@ -14121,8 +14185,7 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/react-markdown": {
"version": "9.0.3",
@@ -14250,6 +14313,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pify": "^2.3.0"
@@ -14578,6 +14642,7 @@
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
"integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-core-module": "^2.16.0",
@@ -14651,6 +14716,7 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
"integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
@@ -14775,6 +14841,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
@@ -15057,6 +15124,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -15069,6 +15137,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -15157,6 +15226,7 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
@@ -15476,6 +15546,7 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"eastasianwidth": "^0.2.0",
@@ -15494,6 +15565,7 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -15508,12 +15580,14 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -15523,6 +15597,7 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -15535,6 +15610,7 @@
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
@@ -15677,6 +15753,7 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -15690,6 +15767,7 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -15760,6 +15838,7 @@
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
"integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.2",
@@ -15782,6 +15861,7 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -15804,6 +15884,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -15881,6 +15962,7 @@
"version": "3.4.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
@@ -15918,6 +16000,7 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
@@ -15942,6 +16025,7 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@@ -15954,6 +16038,7 @@
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
@@ -15967,6 +16052,7 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
@@ -16000,6 +16086,7 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0"
@@ -16009,6 +16096,7 @@
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"thenify": ">= 3.1.0 < 4"
@@ -16101,6 +16189,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
@@ -16189,6 +16278,7 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/tsconfck": {
@@ -16370,7 +16460,7 @@
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"devOptional": true,
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16411,7 +16501,7 @@
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/unified": {
@@ -16628,6 +16718,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT"
},
"node_modules/utils-merge": {
@@ -17165,6 +17256,7 @@
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.1.0",
@@ -17183,6 +17275,7 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
@@ -17200,12 +17293,14 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -17215,6 +17310,7 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -17229,6 +17325,7 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -17241,6 +17338,7 @@
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -17253,6 +17351,7 @@
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
@@ -17346,6 +17445,7 @@
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.1.tgz",
"integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"

View File

@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.20.0",
"version": "0.21.0",
"private": true,
"type": "module",
"engines": {
@@ -20,6 +20,7 @@
"axios": "^1.7.9",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.0.1",
"i18next": "^24.2.1",
"i18next-browser-languagedetector": "^8.0.2",
"i18next-http-backend": "^3.0.1",
@@ -79,7 +80,8 @@
"@playwright/test": "^1.49.1",
"@react-router/dev": "^7.1.2",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.62.16",
"@tanstack/eslint-plugin-query": "^5.64.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.0",
@@ -100,7 +102,7 @@
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.2.2",
"eslint-plugin-prettier": "^5.2.3",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^4.6.2",
"husky": "^9.1.6",

View File

@@ -10,6 +10,7 @@ import {
AuthenticateResponse,
Conversation,
ResultSet,
GetTrajectoryResponse,
} from "./open-hands.types";
import { openHands } from "./open-hands-axios";
import { ApiSettings } from "#/services/settings";
@@ -243,10 +244,14 @@ class OpenHands {
static async createConversation(
githubToken?: string,
selectedRepository?: string,
initialUserMsg?: string,
imageUrls?: string[],
): Promise<Conversation> {
const body = {
github_token: githubToken,
selected_repository: selectedRepository,
initial_user_msg: initialUserMsg,
image_urls: imageUrls,
};
const { data } = await openHands.post<Conversation>(
@@ -354,6 +359,15 @@ class OpenHands {
return response.data.items;
}
static async getTrajectory(
conversationId: string,
): Promise<GetTrajectoryResponse> {
const { data } = await openHands.get<GetTrajectoryResponse>(
`/api/conversations/${conversationId}/trajectory`,
);
return data;
}
}
export default OpenHands;

View File

@@ -55,6 +55,11 @@ export interface GetVSCodeUrlResponse {
error?: string;
}
export interface GetTrajectoryResponse {
trajectory: unknown[] | null;
error?: string;
}
export interface AuthenticateResponse {
message?: string;
error?: string;

View File

@@ -23,7 +23,7 @@ export const AGENT_STATUS_MAP: {
},
[AgentState.AWAITING_USER_INPUT]: {
message: I18nKey.CHAT_INTERFACE$AGENT_AWAITING_USER_INPUT_MESSAGE,
indicator: IndicatorColor.ORANGE,
indicator: IndicatorColor.BLUE,
},
[AgentState.PAUSED]: {
message: I18nKey.CHAT_INTERFACE$AGENT_PAUSED_MESSAGE,

View File

@@ -1,8 +1,10 @@
import { useDispatch, useSelector } from "react-redux";
import toast from "react-hot-toast";
import React from "react";
import posthog from "posthog-js";
import { useParams } from "react-router";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
import { FeedbackActions } from "../feedback/feedback-actions";
import { TrajectoryActions } from "../trajectory/trajectory-actions";
import { createChatMessage } from "#/services/chat-service";
import { InteractiveChatBox } from "./interactive-chat-box";
import { addUserMessage } from "#/state/chat-slice";
@@ -19,6 +21,8 @@ import { ActionSuggestions } from "./action-suggestions";
import { ContinueButton } from "#/components/shared/buttons/continue-button";
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory";
import { downloadTrajectory } from "#/utils/download-files";
function getEntryPoint(
hasRepository: boolean | null,
@@ -47,6 +51,8 @@ export function ChatInterface() {
const { selectedRepository, importedProjectZip } = useSelector(
(state: RootState) => state.initialQuery,
);
const params = useParams();
const { mutate: getTrajectory } = useGetTrajectory();
const handleSendMessage = async (content: string, files: File[]) => {
if (messages.length === 0) {
@@ -90,6 +96,25 @@ export function ChatInterface() {
setFeedbackPolarity(polarity);
};
const onClickExportTrajectoryButton = () => {
if (!params.conversationId) {
toast.error("ConversationId unknown, cannot download trajectory");
return;
}
getTrajectory(params.conversationId, {
onSuccess: async (data) => {
await downloadTrajectory(
params.conversationId ?? "unknown",
data.trajectory,
);
},
onError: (error) => {
toast.error(error.message);
},
});
};
const isWaitingForUserInput =
curAgentState === AgentState.AWAITING_USER_INPUT ||
curAgentState === AgentState.FINISHED;
@@ -129,13 +154,14 @@ export function ChatInterface() {
<div className="flex flex-col gap-[6px] px-4 pb-4">
<div className="flex justify-between relative">
<FeedbackActions
<TrajectoryActions
onPositiveFeedback={() =>
onClickShareFeedbackActionButton("positive")
}
onNegativeFeedback={() =>
onClickShareFeedbackActionButton("negative")
}
onExportTrajectory={() => onClickExportTrajectoryButton()}
/>
<div className="absolute left-1/2 transform -translate-x-1/2 bottom-0">

View File

@@ -12,15 +12,22 @@ interface MessagesProps {
export const Messages: React.FC<MessagesProps> = React.memo(
({ messages, isAwaitingUserConfirmation }) =>
messages.map((message, index) => {
const shouldShowConfirmationButtons =
messages.length - 1 === index &&
message.sender === "assistant" &&
isAwaitingUserConfirmation;
if (message.type === "error" || message.type === "action") {
return (
<ExpandableMessage
key={index}
type={message.type}
id={message.translationID}
message={message.content}
success={message.success}
/>
<div key={index}>
<ExpandableMessage
type={message.type}
id={message.translationID}
message={message.content}
success={message.success}
/>
{shouldShowConfirmationButtons && <ConfirmationButtons />}
</div>
);
}
@@ -33,9 +40,7 @@ export const Messages: React.FC<MessagesProps> = React.memo(
{message.imageUrls && message.imageUrls.length > 0 && (
<ImageCarousel size="small" images={message.imageUrls} />
)}
{messages.length - 1 === index &&
message.sender === "assistant" &&
isAwaitingUserConfirmation && <ConfirmationButtons />}
{shouldShowConfirmationButtons && <ConfirmationButtons />}
</ChatMessage>
);
}),

View File

@@ -43,7 +43,7 @@ export function AgentStatusBar() {
React.useEffect(() => {
if (status === WsClientProviderStatus.DISCONNECTED) {
setStatusMessage("Trying to reconnect...");
setStatusMessage("Connecting...");
} else {
setStatusMessage(AGENT_STATUS_MAP[curAgentState].message);
}

View File

@@ -1,28 +1,36 @@
import ThumbsUpIcon from "#/icons/thumbs-up.svg?react";
import ThumbDownIcon from "#/icons/thumbs-down.svg?react";
import { FeedbackActionButton } from "#/components/shared/buttons/feedback-action-button";
import ExportIcon from "#/icons/export.svg?react";
import { TrajectoryActionButton } from "#/components/shared/buttons/trajectory-action-button";
interface FeedbackActionsProps {
interface TrajectoryActionsProps {
onPositiveFeedback: () => void;
onNegativeFeedback: () => void;
onExportTrajectory: () => void;
}
export function FeedbackActions({
export function TrajectoryActions({
onPositiveFeedback,
onNegativeFeedback,
}: FeedbackActionsProps) {
onExportTrajectory,
}: TrajectoryActionsProps) {
return (
<div data-testid="feedback-actions" className="flex gap-1">
<FeedbackActionButton
<TrajectoryActionButton
testId="positive-feedback"
onClick={onPositiveFeedback}
icon={<ThumbsUpIcon width={15} height={15} />}
/>
<FeedbackActionButton
<TrajectoryActionButton
testId="negative-feedback"
onClick={onNegativeFeedback}
icon={<ThumbDownIcon width={15} height={15} />}
/>
<TrajectoryActionButton
testId="export-trajectory"
onClick={onExportTrajectory}
icon={<ExportIcon width={15} height={15} />}
/>
</div>
);
}

View File

@@ -1,14 +1,14 @@
interface FeedbackActionButtonProps {
interface TrajectoryActionButtonProps {
testId?: string;
onClick: () => void;
icon: React.ReactNode;
}
export function FeedbackActionButton({
export function TrajectoryActionButton({
testId,
onClick,
icon,
}: FeedbackActionButtonProps) {
}: TrajectoryActionButtonProps) {
return (
<button
type="button"

View File

@@ -171,7 +171,7 @@ export function SettingsForm({
<APIKeyInput
isDisabled={!!disabled}
isSet={settings.LLM_API_KEY === "SET"}
isSet={settings.LLM_API_KEY === "**********"}
/>
{showAdvancedOptions && (

View File

@@ -34,7 +34,7 @@ export function SettingsProvider({ children }: SettingsProviderProps) {
...newSettings,
};
if (updatedSettings.LLM_API_KEY === "SET") {
if (updatedSettings.LLM_API_KEY === "**********") {
delete updatedSettings.LLM_API_KEY;
}

View File

@@ -11,15 +11,11 @@ import { hydrateRoot } from "react-dom/client";
import { Provider } from "react-redux";
import posthog from "posthog-js";
import "./i18n";
import {
QueryCache,
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
import toast from "react-hot-toast";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import store from "./store";
import { useConfig } from "./hooks/query/use-config";
import { AuthProvider } from "./context/auth-context";
import { queryClientConfig } from "./query-client-config";
import { SettingsProvider } from "./context/settings-context";
function PosthogInit() {
@@ -50,27 +46,7 @@ async function prepareApp() {
}
}
const QUERY_KEYS_TO_IGNORE = ["authenticated", "hosts"];
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
if (!QUERY_KEYS_TO_IGNORE.some((key) => query.queryKey.includes(key))) {
toast.error(error.message);
}
},
}),
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
},
mutations: {
onError: (error) => {
toast.error(error.message);
},
},
},
});
const queryClient = new QueryClient(queryClientConfig);
prepareApp().then(() =>
startTransition(() => {

View File

@@ -3,7 +3,7 @@ import { useNavigate } from "react-router";
import posthog from "posthog-js";
import { useDispatch, useSelector } from "react-redux";
import OpenHands from "#/api/open-hands";
import { setInitialQuery } from "#/state/initial-query-slice";
import { setInitialPrompt } from "#/state/initial-query-slice";
import { RootState } from "#/store";
import { useAuth } from "#/context/auth-context";
@@ -18,7 +18,7 @@ export const useCreateConversation = () => {
);
return useMutation({
mutationFn: (variables: { q?: string }) => {
mutationFn: async (variables: { q?: string }) => {
if (
!variables.q?.trim() &&
!selectedRepository &&
@@ -28,10 +28,13 @@ export const useCreateConversation = () => {
throw new Error("No query provided");
}
if (variables.q) dispatch(setInitialQuery(variables.q));
if (variables.q) dispatch(setInitialPrompt(variables.q));
return OpenHands.createConversation(
gitHubToken || undefined,
selectedRepository || undefined,
variables.q,
files,
);
},
onSuccess: async ({ conversation_id: conversationId }, { q }) => {

View File

@@ -0,0 +1,7 @@
import { useMutation } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
export const useGetTrajectory = () =>
useMutation({
mutationFn: (cid: string) => OpenHands.getTrajectory(cid),
});

View File

@@ -6,7 +6,7 @@ import { useConfig } from "./use-config";
import OpenHands from "#/api/open-hands";
export const useGitHubUser = () => {
const { gitHubToken, setUserId } = useAuth();
const { gitHubToken, setUserId, logout } = useAuth();
const { data: config } = useConfig();
const user = useQuery({
@@ -29,5 +29,11 @@ export const useGitHubUser = () => {
}
}, [user.data]);
React.useEffect(() => {
if (user.isError) {
logout();
}
}, [user.isError]);
return user;
};

View File

@@ -9,5 +9,6 @@ export const useUserConversations = () => {
queryKey: ["user", "conversations"],
queryFn: OpenHands.getUserConversations,
enabled: !!userIsAuthenticated,
staleTime: 0,
});
};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-download"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>

After

Width:  |  Height:  |  Size: 347 B

View File

@@ -141,7 +141,7 @@ export const handlers = [
{ id: 2, full_name: "octocat/earth" },
]),
),
http.get("https://api.github.com/user", () => {
http.get("/api/github/user", () => {
const user: GitHubUser = {
id: 1,
login: "octocat",

View File

@@ -0,0 +1,25 @@
import { QueryClientConfig, QueryCache } from "@tanstack/react-query";
import toast from "react-hot-toast";
const QUERY_KEYS_TO_IGNORE = ["authenticated", "hosts"];
export const queryClientConfig: QueryClientConfig = {
queryCache: new QueryCache({
onError: (error, query) => {
if (!QUERY_KEYS_TO_IGNORE.some((key) => query.queryKey.includes(key))) {
toast.error(error.message);
}
},
}),
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
},
mutations: {
onError: (error) => {
toast.error(error.message);
},
},
},
};

View File

@@ -1,10 +1,8 @@
import React from "react";
import { useWSStatusChange } from "./hooks/use-ws-status-change";
import { useHandleWSEvents } from "./hooks/use-handle-ws-events";
import { useHandleRuntimeActive } from "./hooks/use-handle-runtime-active";
export function EventHandler({ children }: React.PropsWithChildren) {
useWSStatusChange();
useHandleWSEvents();
useHandleRuntimeActive();

View File

@@ -1,68 +0,0 @@
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import {
useWsClient,
WsClientProviderStatus,
} from "#/context/ws-client-provider";
import { createChatMessage } from "#/services/chat-service";
import { setCurrentAgentState } from "#/state/agent-slice";
import { addUserMessage } from "#/state/chat-slice";
import { clearFiles, clearInitialQuery } from "#/state/initial-query-slice";
import { RootState } from "#/store";
import { AgentState } from "#/types/agent-state";
export const useWSStatusChange = () => {
const { send, status } = useWsClient();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const dispatch = useDispatch();
const statusRef = React.useRef<WsClientProviderStatus | null>(null);
const { files, initialQuery } = useSelector(
(state: RootState) => state.initialQuery,
);
const sendInitialQuery = (query: string, base64Files: string[]) => {
const timestamp = new Date().toISOString();
send(createChatMessage(query, base64Files, timestamp));
};
const dispatchInitialQuery = (query: string) => {
sendInitialQuery(query, files);
dispatch(clearFiles()); // reset selected files
dispatch(clearInitialQuery()); // reset initial query
};
const handleAgentInit = () => {
if (initialQuery) {
dispatchInitialQuery(initialQuery);
}
};
React.useEffect(() => {
if (curAgentState === AgentState.INIT) {
handleAgentInit();
}
}, [curAgentState]);
React.useEffect(() => {
if (statusRef.current === status) {
return; // This is a check because of strict mode - if the status did not change, don't do anything
}
statusRef.current = status;
if (status !== WsClientProviderStatus.DISCONNECTED && initialQuery) {
dispatch(
addUserMessage({
content: initialQuery,
imageUrls: files,
timestamp: new Date().toISOString(),
pending: true,
}),
);
}
if (status === WsClientProviderStatus.DISCONNECTED) {
dispatch(setCurrentAgentState(AgentState.STOPPED));
}
}, [status]);
};

View File

@@ -1,7 +1,7 @@
import { useDisclosure } from "@nextui-org/react";
import React from "react";
import { Outlet } from "react-router";
import { useDispatch } from "react-redux";
import { useDispatch, useSelector } from "react-redux";
import { FaServer } from "react-icons/fa";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -11,7 +11,7 @@ import {
useConversation,
} from "#/context/conversation-context";
import { Controls } from "#/components/features/controls/controls";
import { clearMessages } from "#/state/chat-slice";
import { clearMessages, addUserMessage } from "#/state/chat-slice";
import { clearTerminal } from "#/state/command-slice";
import { useEffectOnce } from "#/hooks/use-effect-once";
import CodeIcon from "#/icons/code.svg?react";
@@ -36,6 +36,8 @@ import { ServedAppLabel } from "#/components/layout/served-app-label";
import { TerminalStatusLabel } from "#/components/features/terminal/terminal-status-label";
import { useSettings } from "#/hooks/query/use-settings";
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
import { clearFiles, clearInitialPrompt } from "#/state/initial-query-slice";
import { RootState } from "#/store";
function AppContent() {
useConversationConfig();
@@ -46,6 +48,9 @@ function AppContent() {
const { data: conversation, isFetched } = useUserConversation(
conversationId || null,
);
const { initialPrompt, files } = useSelector(
(state: RootState) => state.initialQuery,
);
const dispatch = useDispatch();
const endSession = useEndSession();
@@ -74,6 +79,18 @@ function AppContent() {
dispatch(clearMessages());
dispatch(clearTerminal());
dispatch(clearJupyter());
if (conversationId && (initialPrompt || files.length > 0)) {
dispatch(
addUserMessage({
content: initialPrompt || "",
imageUrls: files || [],
timestamp: new Date().toISOString(),
pending: true,
}),
);
dispatch(clearInitialPrompt());
dispatch(clearFiles());
}
}, [conversationId]);
useEffectOnce(() => {

View File

@@ -2,14 +2,14 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit";
type SliceState = {
files: string[]; // base64 encoded images
initialQuery: string | null;
initialPrompt: string | null;
selectedRepository: string | null;
importedProjectZip: string | null; // base64 encoded zip
};
const initialState: SliceState = {
files: [],
initialQuery: null,
initialPrompt: null,
selectedRepository: null,
importedProjectZip: null,
};
@@ -27,11 +27,11 @@ export const selectedFilesSlice = createSlice({
clearFiles(state) {
state.files = [];
},
setInitialQuery(state, action: PayloadAction<string>) {
state.initialQuery = action.payload;
setInitialPrompt(state, action: PayloadAction<string>) {
state.initialPrompt = action.payload;
},
clearInitialQuery(state) {
state.initialQuery = null;
clearInitialPrompt(state) {
state.initialPrompt = null;
},
setSelectedRepository(state, action: PayloadAction<string | null>) {
state.selectedRepository = action.payload;
@@ -49,8 +49,8 @@ export const {
addFile,
removeFile,
clearFiles,
setInitialQuery,
clearInitialQuery,
setInitialPrompt,
clearInitialPrompt,
setSelectedRepository,
clearSelectedRepository,
setImportedProjectZip,

View File

@@ -26,6 +26,18 @@ interface FileSystemDirectoryHandle {
): Promise<FileSystemFileHandle>;
}
interface SaveFilePickerOptions {
suggestedName?: string;
types?: Array<{
description?: string;
accept: Record<string, string[]>;
}>;
excludeAcceptAllOption?: boolean;
}
interface Window {
showDirectoryPicker(): Promise<FileSystemDirectoryHandle>;
showSaveFilePicker(
options?: SaveFilePickerOptions,
): Promise<FileSystemFileHandle>;
}

View File

@@ -22,6 +22,13 @@ function isFileSystemAccessSupported(): boolean {
return "showDirectoryPicker" in window;
}
/**
* Checks if the Save File Picker API is supported
*/
function isSaveFilePickerSupported(): boolean {
return "showSaveFilePicker" in window;
}
/**
* Creates subdirectories and returns the final directory handle
*/
@@ -162,6 +169,39 @@ async function processBatch(
};
}
export async function downloadTrajectory(
conversationId: string,
data: unknown[] | null,
): Promise<void> {
try {
if (!isSaveFilePickerSupported()) {
throw new Error(
"Your browser doesn't support downloading folders. Please use Chrome, Edge, or another browser that supports the File System Access API.",
);
}
const options = {
suggestedName: `trajectory-${conversationId}.json`,
types: [
{
description: "JSON File",
accept: {
"application/json": [".json"],
},
},
],
};
const handle = await window.showSaveFilePicker(options);
const writable = await handle.createWritable();
await writable.write(JSON.stringify(data, null, 2));
await writable.close();
} catch (error) {
throw new Error(
`Failed to download file: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Downloads files from the workspace one by one
* @param initialPath Initial path to start downloading from. If not provided, downloads from root

View File

@@ -20,7 +20,7 @@ The key classes in OpenHands are:
* Sandbox: the part of the runtime responsible for running commands, e.g. inside of Docker
* Server: brokers OpenHands sessions over HTTP, e.g. to drive the frontend
* Session: holds a single EventStream, a single AgentController, and a single Runtime. Generally represents a single task (but potentially including several user prompts)
* SessionManager: keeps a list of active sessions, and ensures requests are routed to the correct Session
* ConversationManager: keeps a list of active sessions, and ensures requests are routed to the correct Session
## Control Flow
Here's the basic loop (in pseudocode) that drives agents.

View File

@@ -12,6 +12,7 @@ from openhands.agenthub import ( # noqa: E402
codeact_agent,
delegator_agent,
dummy_agent,
visualbrowsing_agent,
)
__all__ = [
@@ -19,6 +20,7 @@ __all__ = [
'delegator_agent',
'dummy_agent',
'browsing_agent',
'visualbrowsing_agent',
]
for agent in all_microagents.values():

View File

@@ -277,16 +277,11 @@ class CodeActAgent(Agent):
# if it doesn't have tool call metadata, it was triggered by a user action
if obs.tool_call_metadata is None:
text = truncate_content(
f'\nObserved result of command executed by user:\n{obs.content}',
f'\nObserved result of command executed by user:\n{obs.to_agent_observation()}',
max_message_chars,
)
else:
text = truncate_content(
obs.content
+ f'\n[Python Interpreter: {obs.metadata.py_interpreter_path}]',
max_message_chars,
)
text += f'\n[Command finished with exit code {obs.exit_code}]'
text = truncate_content(obs.to_agent_observation(), max_message_chars)
message = Message(role='user', content=[TextContent(text=text)])
elif isinstance(obs, IPythonRunCellObservation):
text = obs.content

View File

@@ -32,6 +32,7 @@ from openhands.events.tool import ToolCallMetadata
_BASH_DESCRIPTION = """Execute a bash command in the terminal.
* Long running commands: For commands that may run indefinitely, it should be run in the background and the output should be redirected to a file, e.g. command = `python3 app.py > server.log 2>&1 &`.
* Interact with running process: If a bash command returns exit code `-1`, this means the process is not yet finished. By setting `is_input` to `true`, the assistant can interact with the running process and send empty `command` to retrieve any additional logs, or send additional text (set `command` to the text) to STDIN of the running process, or send command like `C-c` (Ctrl+C), `C-d` (Ctrl+D), `C-z` (Ctrl+Z) to interrupt the process.
* One command at a time: You can only execute one bash command at a time. If you need to run multiple commands sequentially, you can use `&&` or `;` to chain them together.
"""
CmdRunTool = ChatCompletionToolParam(
@@ -44,7 +45,7 @@ CmdRunTool = ChatCompletionToolParam(
'properties': {
'command': {
'type': 'string',
'description': 'The bash command to execute. Can be empty string to view additional logs when previous exit code is `-1`. Can be `C-c` (Ctrl+C) to interrupt the currently running process.',
'description': 'The bash command to execute. Can be empty string to view additional logs when previous exit code is `-1`. Can be `C-c` (Ctrl+C) to interrupt the currently running process. Note: You can only execute one bash command at a time. If you need to run multiple commands sequentially, you can use `&&` or `;` to chain them together.',
},
'is_input': {
'type': 'string',
@@ -80,7 +81,7 @@ IPythonTool = ChatCompletionToolParam(
),
)
_FILE_EDIT_DESCRIPTION = """Edit a file.
_FILE_EDIT_DESCRIPTION = """Edit a file in plain-text format.
* The assistant can edit files by specifying the file path and providing a draft of the new file content.
* The draft content doesn't need to be exactly the same as the existing file; the assistant may skip unchanged lines using comments like `# unchanged` to indicate unchanged sections.
* IMPORTANT: For large files (e.g., > 300 lines), specify the range of lines to edit using `start` and `end` (1-indexed, inclusive). The range should be smaller than 300 lines.
@@ -216,7 +217,7 @@ LLMBasedFileEditTool = ChatCompletionToolParam(
),
)
_STR_REPLACE_EDITOR_DESCRIPTION = """Custom editing tool for viewing, creating and editing files
_STR_REPLACE_EDITOR_DESCRIPTION = """Custom editing tool for viewing, creating and editing files in plain-text format
* State is persistent across command calls and discussions with the user
* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep
* The `create` command cannot be used if the specified `path` already exists as a file

View File

@@ -1,6 +1,7 @@
You are OpenHands agent, a helpful AI assistant that can interact with a computer to solve tasks.
<IMPORTANT>
* If user provides a path, you should NOT assume it's relative to the current working directory. Instead, you should explore the file system to find the file before working on it.
* You should start exploring the file system with your view command, unless you need to explore more deeply.
* When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
* The assistant MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior.
* You MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior.
</IMPORTANT>

View File

@@ -0,0 +1,7 @@
# Browsing Agent Framework
This folder implements the AgentLab [generic agent](https://github.com/ServiceNow/AgentLab/tree/main/src/agentlab/agents/generic_agent) that enables full-featured web browsing. The observations given to the agent include set-of-marks annotated web-page screenshot, accessibility tree of the web-page and all the thoughts and actions from previous steps.
## Test run
Note that for browsing tasks, GPT-4/Claude is usually a requirement to get reasonable results, due to the complexity of the web page structures. This agent has been evaluated on the VisualWebArena benchmark and the CodeAct agent does not call this VisualBrowsingAgent. CodeAct agent uses has in-built support for browsing (e.g., via browse_url and browser tool).

View File

@@ -0,0 +1,6 @@
from openhands.agenthub.visualbrowsing_agent.visualbrowsing_agent import (
VisualBrowsingAgent,
)
from openhands.controller.agent import Agent
Agent.register('VisualBrowsingAgent', VisualBrowsingAgent)

View File

@@ -0,0 +1,306 @@
from browsergym.core.action.highlevel import HighLevelActionSet
from browsergym.utils.obs import flatten_axtree_to_str
from openhands.agenthub.browsing_agent.response_parser import BrowsingResponseParser
from openhands.controller.agent import Agent
from openhands.controller.state.state import State
from openhands.core.config import AgentConfig
from openhands.core.logger import openhands_logger as logger
from openhands.core.message import ImageContent, Message, TextContent
from openhands.events.action import (
Action,
AgentFinishAction,
BrowseInteractiveAction,
MessageAction,
)
from openhands.events.event import EventSource
from openhands.events.observation import BrowserOutputObservation
from openhands.events.observation.observation import Observation
from openhands.llm.llm import LLM
from openhands.runtime.plugins import (
PluginRequirement,
)
def get_error_prefix(obs: BrowserOutputObservation) -> str:
# temporary fix for OneStopMarket to ignore timeout errors
if 'timeout' in obs.last_browser_action_error:
return ''
return f'## Error from previous action:\n{obs.last_browser_action_error}\n'
def create_goal_prompt(goal: str, image_urls: list[str] | None):
goal_txt: str = f"""\
# Instructions
Review the current state of the page and all other information to find the best possible next action to accomplish your goal. Your answer will be interpreted and executed by a program, make sure to follow the formatting instructions.
## Goal:
{goal}
"""
goal_image_urls = []
if image_urls is not None:
for idx, url in enumerate(image_urls):
goal_txt = goal_txt + f'Images: Goal input image ({idx+1})\n'
goal_image_urls.append(url)
goal_txt += '\n'
return goal_txt, goal_image_urls
def create_observation_prompt(
axtree_txt: str,
tabs: str,
focused_element: str,
error_prefix: str,
som_screenshot: str | None,
):
txt_observation = f"""
# Observation of current step:
{tabs}{axtree_txt}{focused_element}{error_prefix}
"""
# screenshot + som: will be a non-empty string if present in observation
screenshot_url = None
if (som_screenshot is not None) and (len(som_screenshot) > 0):
txt_observation += 'Image: Current page screenshot (Note that only visible portion of webpage is present in the screenshot. You may need to scroll to view the remaining portion of the web-page.\n'
screenshot_url = som_screenshot
else:
logger.info('SOM Screenshot not present in observation!')
txt_observation += '\n'
return txt_observation, screenshot_url
def get_tabs(obs: BrowserOutputObservation) -> str:
prompt_pieces = ['\n## Currently open tabs:']
for page_index, page_url in enumerate(obs.open_pages_urls):
active_or_not = ' (active tab)' if page_index == obs.active_page_index else ''
prompt_piece = f"""\
Tab {page_index}{active_or_not}:
URL: {page_url}
"""
prompt_pieces.append(prompt_piece)
return '\n'.join(prompt_pieces) + '\n'
def get_axtree(axtree_txt: str) -> str:
bid_info = """\
Note: [bid] is the unique alpha-numeric identifier at the beginning of lines for each element in the AXTree. Always use bid to refer to elements in your actions.
"""
visible_tag_info = """\
Note: You can only interact with visible elements. If the "visible" tag is not present, the element is not visible on the page.
"""
return f'\n## AXTree:\n{bid_info}{visible_tag_info}{axtree_txt}\n'
def get_action_prompt(action_set: HighLevelActionSet) -> str:
action_set_generic_info = """\
Note: This action set allows you to interact with your environment. Most of them are python function executing playwright code. The primary way of referring to elements in the page is through bid which are specified in your observations.
"""
action_description = action_set.describe(
with_long_description=False,
with_examples=False,
)
action_prompt = f'# Action space:\n{action_set_generic_info}{action_description}\n'
return action_prompt
def get_history_prompt(prev_actions: list[BrowseInteractiveAction]) -> str:
history_prompt = ['# History of all previous interactions with the task:\n']
for i in range(len(prev_actions)):
history_prompt.append(f'## step {i+1}')
history_prompt.append(
f'\nOuput thought and action: {prev_actions[i].thought} ```{prev_actions[i].browser_actions}```\n'
)
return '\n'.join(history_prompt) + '\n'
class VisualBrowsingAgent(Agent):
VERSION = '1.0'
"""
VisualBrowsing Agent that can uses webpage screenshots during browsing.
"""
sandbox_plugins: list[PluginRequirement] = []
response_parser = BrowsingResponseParser()
def __init__(
self,
llm: LLM,
config: AgentConfig,
) -> None:
"""Initializes a new instance of the VisualBrowsingAgent class.
Parameters:
- llm (LLM): The llm to be used by this agent
"""
super().__init__(llm, config)
# define a configurable action space, with chat functionality, web navigation, and webpage grounding using accessibility tree and HTML.
# see https://github.com/ServiceNow/BrowserGym/blob/main/core/src/browsergym/core/action/highlevel.py for more details
action_subsets = [
'chat',
'bid',
'nav',
'tab',
'infeas',
]
self.action_space = HighLevelActionSet(
subsets=action_subsets,
strict=False, # less strict on the parsing of the actions
multiaction=False,
)
self.action_prompt = get_action_prompt(self.action_space)
self.abstract_example = f"""
# Abstract Example
Here is an abstract version of the answer with description of the content of each tag. Make sure you follow this structure, but replace the content with your answer:
You must mandatorily think step by step. If you need to make calculations such as coordinates, write them here. Describe the effect that your previous action had on the current content of the page. In summary the next action I will perform is ```{self.action_space.example_action(abstract=True)}```
"""
self.concrete_example = """
# Concrete Example
Here is a concrete example of how to format your answer. Make sure to generate the action in the correct format ensuring that the action is present inside ``````:
Let's think step-by-step. From previous action I tried to set the value of year to "2022", using select_option, but it doesn't appear to be in the form. It may be a dynamic dropdown, I will try using click with the bid "324" and look at the response from the page. In summary the next action I will perform is ```click('324')```
"""
self.hints = """
Note:
* Make sure to use bid to identify elements when using commands.
* Interacting with combobox, dropdowns and auto-complete fields can be tricky, sometimes you need to use select_option, while other times you need to use fill or click and wait for the reaction of the page.
"""
self.reset()
def reset(self) -> None:
"""Resets the VisualBrowsingAgent."""
super().reset()
self.cost_accumulator = 0
self.error_accumulator = 0
def step(self, state: State) -> Action:
"""Performs one step using the VisualBrowsingAgent.
This includes gathering information on previous steps and prompting the model to make a browsing command to execute.
Parameters:
- state (State): used to get updated info
Returns:
- BrowseInteractiveAction(browsergym_command) - BrowserGym commands to run
- MessageAction(content) - Message action to run (e.g. ask for clarification)
- AgentFinishAction() - end the interaction
"""
messages: list[Message] = []
prev_actions = []
cur_axtree_txt = ''
error_prefix = ''
focused_element = ''
tabs = ''
last_obs = None
last_action = None
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.history:
if isinstance(event, BrowseInteractiveAction):
prev_actions.append(event)
last_action = event
elif isinstance(event, MessageAction) and event.source == EventSource.AGENT:
# agent has responded, task finished.
return AgentFinishAction(outputs={'content': event.content})
elif isinstance(event, Observation):
last_obs = event
if len(prev_actions) >= 1: # ignore noop()
prev_actions = prev_actions[1:] # remove the first noop action
# if the final BrowserInteractiveAction exec BrowserGym's send_msg_to_user,
# we should also send a message back to the user in OpenHands and call it a day
if (
isinstance(last_action, BrowseInteractiveAction)
and last_action.browsergym_send_msg_to_user
):
return MessageAction(last_action.browsergym_send_msg_to_user)
history_prompt = get_history_prompt(prev_actions)
if isinstance(last_obs, BrowserOutputObservation):
if last_obs.error:
# add error recovery prompt prefix
error_prefix = get_error_prefix(last_obs)
if len(error_prefix) > 0:
self.error_accumulator += 1
if self.error_accumulator > 5:
return MessageAction(
'Too many errors encountered. Task failed.'
)
focused_element = '## Focused element:\nNone\n'
if last_obs.focused_element_bid is not None:
focused_element = (
f"## Focused element:\nbid='{last_obs.focused_element_bid}'\n"
)
tabs = get_tabs(last_obs)
try:
# IMPORTANT: keep AX Tree of full webpage, add visible and clickable tags
cur_axtree_txt = flatten_axtree_to_str(
last_obs.axtree_object,
extra_properties=last_obs.extra_element_properties,
with_visible=True,
with_clickable=True,
with_center_coords=False,
with_bounding_box_coords=False,
filter_visible_only=False,
filter_with_bid_only=False,
filter_som_only=False,
)
cur_axtree_txt = get_axtree(axtree_txt=cur_axtree_txt)
except Exception as e:
logger.error(
'Error when trying to process the accessibility tree: %s', e
)
return MessageAction('Error encountered when browsing.')
set_of_marks = last_obs.set_of_marks
goal, image_urls = state.get_current_user_intent()
if goal is None:
goal = state.inputs['task']
goal_txt, goal_images = create_goal_prompt(goal, image_urls)
observation_txt, som_screenshot = create_observation_prompt(
cur_axtree_txt, tabs, focused_element, error_prefix, set_of_marks
)
human_prompt = [TextContent(type='text', text=goal_txt)]
if len(goal_images) > 0:
human_prompt.append(ImageContent(image_urls=goal_images))
human_prompt.append(TextContent(type='text', text=observation_txt))
if som_screenshot is not None:
human_prompt.append(ImageContent(image_urls=[som_screenshot]))
remaining_content = f"""
{history_prompt}\
{self.action_prompt}\
{self.hints}\
{self.abstract_example}\
{self.concrete_example}\
"""
human_prompt.append(TextContent(type='text', text=remaining_content))
system_msg = """\
You are an agent trying to solve a web task based on the content of the page and user instructions. You can interact with the page and explore, and send messages to the user when you finish the task. Each time you submit an action it will be sent to the browser and you will receive a new page.
""".strip()
messages.append(Message(role='system', content=[TextContent(text=system_msg)]))
messages.append(Message(role='user', content=human_prompt))
flat_messages = self.llm.format_messages_for_llm(messages)
response = self.llm.completion(
messages=flat_messages,
temperature=0.0,
stop=[')```', ')\n```'],
)
return self.response_parser.parse(response)

View File

@@ -501,10 +501,6 @@ class AgentController:
EventSource.ENVIRONMENT,
)
if new_state == AgentState.INIT and self.state.resume_state:
await self.set_agent_state_to(self.state.resume_state)
self.state.resume_state = None
def get_agent_state(self) -> AgentState:
"""Returns the current state of the agent.

View File

@@ -9,7 +9,7 @@ from uuid import uuid4
import toml
from dotenv import load_dotenv
from pydantic import BaseModel, ValidationError
from pydantic import BaseModel, SecretStr, ValidationError
from openhands.core import logger
from openhands.core.config.agent_config import AgentConfig
@@ -192,7 +192,7 @@ def load_from_toml(cfg: AppConfig, toml_file: str = 'config.toml'):
custom_fields[k] = v
merged_llm_dict = generic_llm_fields.copy()
merged_llm_dict.update(custom_fields)
custom_llm_config = LLMConfig(**merged_llm_dict)
cfg.set_llm_config(custom_llm_config, nested_key)
@@ -287,8 +287,10 @@ def finalize_config(cfg: AppConfig):
pathlib.Path(cfg.cache_dir).mkdir(parents=True, exist_ok=True)
if not cfg.jwt_secret:
cfg.jwt_secret = get_or_create_jwt_secret(
get_file_store(cfg.file_store, cfg.file_store_path)
cfg.jwt_secret = SecretStr(
get_or_create_jwt_secret(
get_file_store(cfg.file_store, cfg.file_store_path)
)
)

View File

@@ -4,10 +4,6 @@ __all__ = ['ActionType']
class ActionTypeSchema(BaseModel):
INIT: str = Field(default='initialize')
"""Initializes the agent. Only sent by client.
"""
MESSAGE: str = Field(default='message')
"""Represents a message.
"""

View File

@@ -6,10 +6,6 @@ class AgentState(str, Enum):
"""The agent is loading.
"""
INIT = 'init'
"""The agent is initialized.
"""
RUNNING = 'running'
"""The agent is running.
"""

View File

@@ -13,8 +13,10 @@ class BrowserOutputObservation(Observation):
url: str
trigger_by_action: str
screenshot: str = field(repr=False, default='') # don't show in repr
set_of_marks: str = field(default='', repr=False) # don't show in repr
error: bool = False
observation: str = ObservationType.BROWSE
goal_image_urls: list = field(default_factory=list)
# do not include in the memory
open_pages_urls: list = field(default_factory=list)
active_page_index: int = -1

View File

@@ -149,16 +149,18 @@ class CmdOutputObservation(Observation):
f'**CmdOutputObservation (source={self.source}, exit code={self.exit_code}, '
f'metadata={json.dumps(self.metadata.model_dump(), indent=2)})**\n'
'--BEGIN AGENT OBSERVATION--\n'
f'{self._to_agent_observation()}\n'
f'{self.to_agent_observation()}\n'
'--END AGENT OBSERVATION--'
)
def _to_agent_observation(self) -> str:
def to_agent_observation(self) -> str:
ret = f'{self.metadata.prefix}{self.content}{self.metadata.suffix}'
if self.metadata.working_dir:
ret += f'\n[Current working directory: {self.metadata.working_dir}]'
if self.metadata.py_interpreter_path:
ret += f'\n[Python interpreter: {self.metadata.py_interpreter_path}]'
if self.metadata.exit_code != -1:
ret += f'\n[Command finished with exit code {self.metadata.exit_code}]'
return ret

View File

@@ -18,3 +18,4 @@ class GithubIssue(BaseModel):
review_threads: list[ReviewThread] | None = None
thread_ids: list[str] | None = None
head_branch: str | None = None
base_branch: str | None = None

View File

@@ -331,9 +331,10 @@ def main():
if not token:
raise ValueError('Github token is required.')
api_key = my_args.llm_api_key or os.environ['LLM_API_KEY']
llm_config = LLMConfig(
model=my_args.llm_model or os.environ['LLM_MODEL'],
api_key=my_args.llm_api_key or os.environ['LLM_API_KEY'],
api_key=str(api_key) if api_key else None,
base_url=my_args.llm_base_url or os.environ.get('LLM_BASE_URL', None),
)

View File

@@ -307,7 +307,6 @@ async def resolve_issue(
repo_instruction: str | None,
issue_number: int,
comment_id: int | None,
target_branch: str | None = None,
reset_logger: bool = False,
) -> None:
"""Resolve a single github issue.
@@ -326,7 +325,7 @@ async def resolve_issue(
repo_instruction: Repository instruction to use.
issue_number: Issue number to resolve.
comment_id: Optional ID of a specific comment to focus on.
target_branch: Optional target branch to create PR against (for PRs).
reset_logger: Whether to reset the logger for multiprocessing.
"""
issue_handler = issue_handler_factory(issue_type, owner, repo, token, llm_config)
@@ -424,9 +423,9 @@ async def resolve_issue(
try:
# checkout to pr branch if needed
if issue_type == 'pr':
branch_to_use = target_branch if target_branch else issue.head_branch
branch_to_use = issue.head_branch
logger.info(
f'Checking out to PR branch {target_branch} for issue {issue.number}'
f'Checking out to PR branch {branch_to_use} for issue {issue.number}'
)
if not branch_to_use:
@@ -446,10 +445,6 @@ async def resolve_issue(
cwd=repo_dir,
)
# Update issue's base_branch if using custom target branch
if target_branch:
issue.base_branch = target_branch
base_commit = (
subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repo_dir)
.decode('utf-8')
@@ -572,12 +567,6 @@ def main():
choices=['issue', 'pr'],
help='Type of issue to resolve, either open issue or pr comments.',
)
parser.add_argument(
'--target-branch',
type=str,
default=None,
help="Target branch to pull and create PR against (for PRs). If not specified, uses the PR's base branch.",
)
parser.add_argument(
'--is-experimental',
type=lambda x: x.lower() == 'true',
@@ -601,9 +590,10 @@ def main():
if not token:
raise ValueError('Github token is required.')
api_key = my_args.llm_api_key or os.environ['LLM_API_KEY']
llm_config = LLMConfig(
model=my_args.llm_model or os.environ['LLM_MODEL'],
api_key=my_args.llm_api_key or os.environ['LLM_API_KEY'],
api_key=str(api_key) if api_key else None,
base_url=my_args.llm_base_url or os.environ.get('LLM_BASE_URL', None),
)
@@ -643,7 +633,6 @@ def main():
repo_instruction=repo_instruction,
issue_number=my_args.issue_number,
comment_id=my_args.comment_id,
target_branch=my_args.target_branch,
)
)

View File

@@ -719,9 +719,10 @@ def main():
else os.getenv('GITHUB_USERNAME')
)
api_key = my_args.llm_api_key or os.environ['LLM_API_KEY']
llm_config = LLMConfig(
model=my_args.llm_model or os.environ['LLM_MODEL'],
api_key=my_args.llm_api_key or os.environ['LLM_API_KEY'],
api_key=str(api_key) if api_key else None,
base_url=my_args.llm_base_url or os.environ.get('LLM_BASE_URL', None),
)

View File

@@ -136,6 +136,10 @@ class Runtime(FileEditRuntimeMixin):
def close(self) -> None:
pass
@classmethod
async def delete(cls, conversation_id: str) -> None:
pass
def log(self, level: str, message: str) -> None:
message = f'[runtime {self.sid}] {message}'
getattr(logger, level)(message, stacklevel=2)

View File

@@ -11,7 +11,7 @@ import gymnasium as gym
import html2text
import numpy as np
import tenacity
from browsergym.utils.obs import flatten_dom_to_str
from browsergym.utils.obs import flatten_dom_to_str, overlay_som
from PIL import Image
from openhands.core.exceptions import BrowserInitException
@@ -65,15 +65,22 @@ class BrowserEnv:
logger.error(f'Failed to start browser process: {e}')
raise
if not self.check_alive():
if not self.check_alive(timeout=200):
self.close()
raise BrowserInitException('Failed to start browser environment.')
def browser_process(self):
if self.eval_mode:
assert self.browsergym_eval_env is not None
logger.debug('Initializing browser env for web browsing evaluation.')
if 'webarena' in self.browsergym_eval_env:
logger.info('Initializing browser env for web browsing evaluation.')
if not self.browsergym_eval_env.startswith('browsergym/'):
self.browsergym_eval_env = 'browsergym/' + self.browsergym_eval_env
if 'visualwebarena' in self.browsergym_eval_env:
import browsergym.visualwebarena # noqa F401 register visualwebarena tasks as gym environments
import nltk
nltk.download('punkt_tab')
elif 'webarena' in self.browsergym_eval_env:
import browsergym.webarena # noqa F401 register webarena tasks as gym environments
elif 'miniwob' in self.browsergym_eval_env:
import browsergym.miniwob # noqa F401 register miniwob tasks as gym environments
@@ -81,10 +88,7 @@ class BrowserEnv:
raise ValueError(
f'Unsupported browsergym eval env: {self.browsergym_eval_env}'
)
env = gym.make(
self.browsergym_eval_env,
tags_to_mark='all',
)
env = gym.make(self.browsergym_eval_env, tags_to_mark='all', timeout=100000)
else:
env = gym.make(
'browsergym/openended',
@@ -94,17 +98,27 @@ class BrowserEnv:
disable_env_checker=True,
tags_to_mark='all',
)
obs, info = env.reset()
logger.info('Successfully called env.reset')
# EVAL ONLY: save the goal into file for evaluation
self.eval_goal = None
self.goal_image_urls = []
self.eval_rewards: list[float] = []
if self.eval_mode:
logger.debug(f"Browsing goal: {obs['goal']}")
self.eval_goal = obs['goal']
if 'goal_object' in obs:
if len(obs['goal_object']) > 0:
self.eval_goal = obs['goal_object'][0]['text']
for message in obs['goal_object']:
if message['type'] == 'image_url':
image_src = message['image_url']
if isinstance(image_src, dict):
image_src = image_src['url']
self.goal_image_urls.append(image_src)
logger.debug(f'Browsing goal: {self.eval_goal}')
logger.info('Browser env started.')
logger.debug('Browser env started.')
while should_continue():
try:
if self.browser_side.poll(timeout=0.01):
@@ -122,7 +136,13 @@ class BrowserEnv:
# EVAL ONLY: Get evaluation info
if action_data['action'] == BROWSER_EVAL_GET_GOAL_ACTION:
self.browser_side.send(
(unique_request_id, {'text_content': self.eval_goal})
(
unique_request_id,
{
'text_content': self.eval_goal,
'image_content': self.goal_image_urls,
},
)
)
continue
elif action_data['action'] == BROWSER_EVAL_GET_REWARDS_ACTION:
@@ -145,7 +165,15 @@ class BrowserEnv:
html_str = flatten_dom_to_str(obs['dom_object'])
obs['text_content'] = self.html_text_converter.handle(html_str)
# make observation serializable
obs['screenshot'] = self.image_to_png_base64_url(obs['screenshot'])
obs['set_of_marks'] = self.image_to_png_base64_url(
overlay_som(
obs['screenshot'], obs.get('extra_element_properties', {})
),
add_data_prefix=True,
)
obs['screenshot'] = self.image_to_png_base64_url(
obs['screenshot'], add_data_prefix=True
)
obs['active_page_index'] = obs['active_page_index'].item()
obs['elapsed_time'] = obs['elapsed_time'].item()
self.browser_side.send((unique_request_id, obs))
@@ -157,7 +185,7 @@ class BrowserEnv:
pass
return
def step(self, action_str: str, timeout: float = 30) -> dict:
def step(self, action_str: str, timeout: float = 100) -> dict:
"""Execute an action in the browser environment and return the observation."""
unique_request_id = str(uuid.uuid4())
self.agent_side.send((unique_request_id, {'action': action_str}))

View File

@@ -35,6 +35,10 @@ async def browse(
content=obs['text_content'], # text content of the page
url=obs.get('url', ''), # URL of the page
screenshot=obs.get('screenshot', None), # base64-encoded screenshot, png
set_of_marks=obs.get(
'set_of_marks', None
), # base64-encoded Set-of-Marks annotated screenshot, png,
goal_image_urls=obs.get('image_content', []),
open_pages_urls=obs.get('open_pages_urls', []), # list of open pages
active_page_index=obs.get(
'active_page_index', -1

View File

@@ -1,18 +1,19 @@
import docker
def remove_all_containers(prefix: str):
def stop_all_containers(prefix: str):
docker_client = docker.from_env()
try:
containers = docker_client.containers.list(all=True)
for container in containers:
try:
if container.name.startswith(prefix):
container.remove(force=True)
container.stop()
except docker.errors.APIError:
pass
except docker.errors.NotFound:
pass
except docker.errors.NotFound: # yes, this can happen!
pass
finally:
docker_client.close()

View File

@@ -5,6 +5,7 @@ from typing import Callable
import docker
import requests
import tenacity
from docker.models.containers import Container
from openhands.core.config import AppConfig
from openhands.core.exceptions import (
@@ -18,7 +19,7 @@ from openhands.runtime.builder import DockerRuntimeBuilder
from openhands.runtime.impl.action_execution.action_execution_client import (
ActionExecutionClient,
)
from openhands.runtime.impl.docker.containers import remove_all_containers
from openhands.runtime.impl.docker.containers import stop_all_containers
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.utils import find_available_tcp_port
from openhands.runtime.utils.command import get_action_execution_server_startup_command
@@ -35,8 +36,8 @@ APP_PORT_RANGE_1 = (50000, 54999)
APP_PORT_RANGE_2 = (55000, 59999)
def remove_all_runtime_containers():
remove_all_containers(CONTAINER_NAME_PREFIX)
def stop_all_runtime_containers():
stop_all_containers(CONTAINER_NAME_PREFIX)
_atexit_registered = False
@@ -68,7 +69,7 @@ class DockerRuntime(ActionExecutionClient):
global _atexit_registered
if not _atexit_registered:
_atexit_registered = True
atexit.register(remove_all_runtime_containers)
atexit.register(stop_all_runtime_containers)
self.config = config
self._runtime_initialized: bool = False
@@ -85,7 +86,7 @@ class DockerRuntime(ActionExecutionClient):
self.base_container_image = self.config.sandbox.base_container_image
self.runtime_container_image = self.config.sandbox.runtime_container_image
self.container_name = CONTAINER_NAME_PREFIX + sid
self.container = None
self.container: Container | None = None
self.runtime_builder = DockerRuntimeBuilder(self.docker_client)
@@ -187,7 +188,6 @@ class DockerRuntime(ActionExecutionClient):
def _init_container(self):
self.log('debug', 'Preparing to start container...')
self.send_status_message('STATUS$PREPARING_CONTAINER')
self._host_port = self._find_available_port(EXECUTION_SERVER_PORT_RANGE)
self._container_port = self._host_port
self._vscode_port = self._find_available_port(VSCODE_PORT_RANGE)
@@ -287,7 +287,7 @@ class DockerRuntime(ActionExecutionClient):
'warning',
f'Container {self.container_name} already exists. Removing...',
)
remove_all_containers(self.container_name)
stop_all_containers(self.container_name)
return self._init_container()
else:
@@ -308,20 +308,20 @@ class DockerRuntime(ActionExecutionClient):
def _attach_to_container(self):
self.container = self.docker_client.containers.get(self.container_name)
for port in self.container.attrs['NetworkSettings']['Ports']: # type: ignore
port = int(port.split('/')[0])
if (
port >= EXECUTION_SERVER_PORT_RANGE[0]
and port <= EXECUTION_SERVER_PORT_RANGE[1]
):
self._container_port = port
if port >= VSCODE_PORT_RANGE[0] and port <= VSCODE_PORT_RANGE[1]:
self._vscode_port = port
elif port >= APP_PORT_RANGE_1[0] and port <= APP_PORT_RANGE_1[1]:
self._app_ports.append(port)
elif port >= APP_PORT_RANGE_2[0] and port <= APP_PORT_RANGE_2[1]:
self._app_ports.append(port)
self._host_port = self._container_port
if self.container.status == 'exited':
self.container.start()
config = self.container.attrs['Config']
for env_var in config['Env']:
if env_var.startswith('port='):
self._host_port = int(env_var.split('port=')[1])
self._container_port = self._host_port
elif env_var.startswith('VSCODE_PORT='):
self._vscode_port = int(env_var.split('VSCODE_PORT=')[1])
self._app_ports = []
for exposed_port in config['ExposedPorts'].keys():
exposed_port = int(exposed_port.split('/tcp')[0])
if exposed_port != self._host_port and exposed_port != self._vscode_port:
self._app_ports.append(exposed_port)
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
self.log(
'debug',
@@ -368,7 +368,7 @@ class DockerRuntime(ActionExecutionClient):
close_prefix = (
CONTAINER_NAME_PREFIX if rm_all_containers else self.container_name
)
remove_all_containers(close_prefix)
stop_all_containers(close_prefix)
def _is_port_in_use_docker(self, port):
containers = self.docker_client.containers.list()
@@ -404,3 +404,17 @@ class DockerRuntime(ActionExecutionClient):
hosts[f'http://localhost:{port}'] = port
return hosts
@classmethod
async def delete(cls, conversation_id: str):
docker_client = cls._init_docker_client()
try:
container_name = CONTAINER_NAME_PREFIX + conversation_id
container = docker_client.containers.get(container_name)
container.remove(force=True)
except docker.errors.APIError:
pass
except docker.errors.NotFound:
pass
finally:
docker_client.close()

View File

@@ -40,6 +40,7 @@ class ModalRuntime(ActionExecutionClient):
container_name_prefix = 'openhands-sandbox-'
sandbox: modal.Sandbox | None
sid: str
def __init__(
self,
@@ -57,6 +58,7 @@ class ModalRuntime(ActionExecutionClient):
self.config = config
self.sandbox = None
self.sid = sid
self.modal_client = modal.Client.from_credentials(
config.modal_api_token_id.get_secret_value(),
@@ -75,6 +77,8 @@ class ModalRuntime(ActionExecutionClient):
# This value is arbitrary as it's private to the container
self.container_port = 3000
self._vscode_port = 4445
self._vscode_url: str | None = None
self.status_callback = status_callback
self.base_container_image_id = self.config.sandbox.base_container_image
@@ -140,6 +144,7 @@ class ModalRuntime(ActionExecutionClient):
if not self.attach_to_existing:
self.send_status_message(' ')
self._runtime_initialized = True
def _get_action_execution_server_host(self):
return self.api_url
@@ -208,6 +213,7 @@ echo 'export INPUTRC=/etc/inputrc' >> /etc/bash.bashrc
environment: dict[str, str | None] = {
'port': str(self.container_port),
'PYTHONUNBUFFERED': '1',
'VSCODE_PORT': str(self._vscode_port),
}
if self.config.debug:
environment['DEBUG'] = 'true'
@@ -225,7 +231,7 @@ echo 'export INPUTRC=/etc/inputrc' >> /etc/bash.bashrc
*sandbox_start_cmd,
secrets=[env_secret],
workdir='/openhands/code',
encrypted_ports=[self.container_port],
encrypted_ports=[self.container_port, self._vscode_port],
image=self.image,
app=self.app,
client=self.modal_client,
@@ -248,3 +254,27 @@ echo 'export INPUTRC=/etc/inputrc' >> /etc/bash.bashrc
if not self.attach_to_existing and self.sandbox:
self.sandbox.terminate()
@property
def vscode_url(self) -> str | None:
if self._vscode_url is not None: # cached value
self.log('debug', f'VSCode URL: {self._vscode_url}')
return self._vscode_url
token = super().get_vscode_token()
if not token:
self.log('error', 'VSCode token not found')
return None
if not self.sandbox:
self.log('error', 'Sandbox not initialized')
return None
tunnel = self.sandbox.tunnels()[self._vscode_port]
tunnel_url = tunnel.url
self._vscode_url = tunnel_url + f'/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
self.log(
'debug',
f'VSCode URL: {self._vscode_url}',
)
return self._vscode_url

View File

@@ -31,6 +31,9 @@ class RemoteRuntime(ActionExecutionClient):
"""This runtime will connect to a remote oh-runtime-client."""
port: int = 60000 # default port for the remote runtime client
runtime_id: str | None = None
runtime_url: str | None = None
_runtime_initialized: bool = False
def __init__(
self,
@@ -71,10 +74,7 @@ class RemoteRuntime(ActionExecutionClient):
self.config.sandbox.api_key,
self.session,
)
self.runtime_id: str | None = None
self.runtime_url: str | None = None
self.available_hosts: dict[str, int] = {}
self._runtime_initialized: bool = False
def log(self, level: str, message: str) -> None:
message = f'[runtime session_id={self.sid} runtime_id={self.runtime_id or "unknown"}] {message}'
@@ -306,6 +306,12 @@ class RemoteRuntime(ActionExecutionClient):
assert 'pod_status' in runtime_data
pod_status = runtime_data['pod_status'].lower()
self.log('debug', f'Pod status: {pod_status}')
restart_count = runtime_data.get('restart_count', 0)
if restart_count != 0:
restart_reasons = runtime_data.get('restart_reasons')
self.log(
'debug', f'Pod restarts: {restart_count}, reasons: {restart_reasons}'
)
# FIXME: We should fix it at the backend of /start endpoint, make sure
# the pod is created before returning the response.

View File

@@ -125,13 +125,13 @@ The `agent_session.py` file contains the `AgentSession` class, which manages the
- Handling security analysis
- Managing the event stream
### 3. session/manager.py
### 3. session/conversation_manager/conversation_manager.py
The `manager.py` file defines the `SessionManager` class, which is responsible for managing multiple client sessions. Key features include:
The `conversation_manager.py` file defines the `ConversationManager` class, which is responsible for managing multiple client conversations. Key features include:
- Adding and restarting sessions
- Sending messages to specific sessions
- Cleaning up inactive sessions
- Adding and restarting conversations
- Sending messages to specific conversations
- Cleaning up inactive conversations
### 4. listen.py
@@ -148,7 +148,7 @@ The `listen.py` file is the main server file that sets up the FastAPI applicatio
1. **Server Initialization**:
- The FastAPI application is created and configured in `listen.py`.
- CORS middleware and static file serving are set up.
- The `SessionManager` is initialized.
- The `ConversationManager` is initialized.
2. **Client Connection**:
- When a client connects via WebSocket, a new `Session` is created or an existing one is restarted.
@@ -173,7 +173,7 @@ The `listen.py` file is the main server file that sets up the FastAPI applicatio
- Security-related API requests are forwarded to the security analyzer.
7. **Session Management**:
- The `SessionManager` periodically cleans up inactive sessions.
- The `ConversationManager` periodically cleans up inactive sessions.
- It also handles sending messages to specific sessions when needed.
8. **API Endpoints**:

View File

@@ -10,13 +10,6 @@ from fastapi import (
import openhands.agenthub # noqa F401 (we import this to get the agents registered)
from openhands import __version__
from openhands.server.middleware import (
AttachConversationMiddleware,
CacheControlMiddleware,
InMemoryRateLimiter,
LocalhostCORSMiddleware,
RateLimitMiddleware,
)
from openhands.server.routes.conversation import app as conversation_api_router
from openhands.server.routes.feedback import app as feedback_api_router
from openhands.server.routes.files import app as files_api_router
@@ -27,13 +20,13 @@ from openhands.server.routes.manage_conversations import (
from openhands.server.routes.public import app as public_api_router
from openhands.server.routes.security import app as security_api_router
from openhands.server.routes.settings import app as settings_router
from openhands.server.shared import openhands_config, session_manager
from openhands.utils.import_utils import get_impl
from openhands.server.routes.trajectory import app as trajectory_router
from openhands.server.shared import conversation_manager, openhands_config
@asynccontextmanager
async def _lifespan(app: FastAPI):
async with session_manager:
async with conversation_manager:
yield
@@ -43,17 +36,7 @@ app = FastAPI(
version=__version__,
lifespan=_lifespan,
)
app.add_middleware(
LocalhostCORSMiddleware,
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
)
app.add_middleware(CacheControlMiddleware)
app.add_middleware(
RateLimitMiddleware, rate_limiter=InMemoryRateLimiter(requests=10, seconds=1)
)
openhands_config.attach_middleware(app)
@app.get('/health')
@@ -69,8 +52,4 @@ app.include_router(conversation_api_router)
app.include_router(manage_conversation_api_router)
app.include_router(settings_router)
app.include_router(github_api_router)
AttachConversationMiddlewareImpl = get_impl(
AttachConversationMiddleware, openhands_config.attach_conversation_middleware_path
)
app.middleware('http')(AttachConversationMiddlewareImpl(app))
app.include_router(trajectory_router)

View File

@@ -1,44 +1,5 @@
import jwt
from fastapi import Request
from jwt.exceptions import InvalidTokenError
from openhands.core.logger import openhands_logger as logger
def get_user_id(request: Request) -> str | None:
return getattr(request.state, 'github_user_id', None)
def get_sid_from_token(token: str, jwt_secret: str) -> str:
"""Retrieves the session id from a JWT token.
Parameters:
token (str): The JWT token from which the session id is to be extracted.
Returns:
str: The session id if found and valid, otherwise an empty string.
"""
try:
# Decode the JWT using the specified secret and algorithm
payload = jwt.decode(token, jwt_secret, algorithms=['HS256'])
# Ensure the payload contains 'sid'
if 'sid' in payload:
return payload['sid']
else:
logger.error('SID not found in token')
return ''
except InvalidTokenError:
logger.error('Invalid token')
except Exception as e:
logger.exception('Unexpected error decoding token: %s', e)
return ''
def sign_token(payload: dict[str, object], jwt_secret: str, algorithm='HS256') -> str:
"""Signs a JWT token."""
# payload = {
# "sid": sid,
# # "exp": datetime.now(timezone.utc) + timedelta(minutes=15),
# }
return jwt.encode(payload, jwt_secret, algorithm=algorithm)

View File

@@ -1,8 +1,15 @@
import os
from fastapi import HTTPException
from fastapi import FastAPI, HTTPException
from openhands.core.logger import openhands_logger as logger
from openhands.server.middleware import (
AttachConversationMiddleware,
CacheControlMiddleware,
InMemoryRateLimiter,
LocalhostCORSMiddleware,
RateLimitMiddleware,
)
from openhands.server.types import AppMode, OpenhandsConfigInterface
from openhands.utils.import_utils import get_impl
@@ -12,15 +19,13 @@ class OpenhandsConfig(OpenhandsConfigInterface):
app_mode = AppMode.OSS
posthog_client_key = 'phc_3ESMmY9SgqEAGBB6sMGK5ayYHkeUuknH2vP6FmWH9RA'
github_client_id = os.environ.get('GITHUB_APP_CLIENT_ID', '')
attach_conversation_middleware_path = (
'openhands.server.middleware.AttachConversationMiddleware'
)
settings_store_class: str = (
'openhands.storage.settings.file_settings_store.FileSettingsStore'
)
conversation_store_class: str = (
'openhands.storage.conversation.file_conversation_store.FileConversationStore'
)
conversation_manager_class: str = 'openhands.server.conversation_manager.standalone_conversation_manager.StandaloneConversationManager'
def verify_config(self):
if self.config_cls:
@@ -42,6 +47,21 @@ class OpenhandsConfig(OpenhandsConfigInterface):
return config
def attach_middleware(self, api: FastAPI) -> None:
api.add_middleware(
LocalhostCORSMiddleware,
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
)
api.add_middleware(CacheControlMiddleware)
api.add_middleware(
RateLimitMiddleware,
rate_limiter=InMemoryRateLimiter(requests=10, seconds=1),
)
api.middleware('http')(AttachConversationMiddleware(api))
def load_openhands_config():
config_cls = os.environ.get('OPENHANDS_CONFIG_CLS', None)

View File

@@ -0,0 +1,96 @@
from __future__ import annotations
from abc import ABC, abstractmethod
import socketio
from openhands.core.config import AppConfig
from openhands.events.action import MessageAction
from openhands.events.stream import EventStream
from openhands.server.session.conversation import Conversation
from openhands.server.settings import Settings
from openhands.storage.files import FileStore
class ConversationManager(ABC):
"""Abstract base class for managing conversations in OpenHands.
This class defines the interface for managing conversations, whether in standalone
or clustered mode. It handles the lifecycle of conversations, including creation,
attachment, detachment, and cleanup.
"""
sio: socketio.AsyncServer
config: AppConfig
file_store: FileStore
@abstractmethod
async def __aenter__(self):
"""Initialize the conversation manager."""
@abstractmethod
async def __aexit__(self, exc_type, exc_value, traceback):
"""Clean up the conversation manager."""
@abstractmethod
async def attach_to_conversation(self, sid: str) -> Conversation | None:
"""Attach to an existing conversation or create a new one."""
@abstractmethod
async def detach_from_conversation(self, conversation: Conversation):
"""Detach from a conversation."""
@abstractmethod
async def join_conversation(
self, sid: str, connection_id: str, settings: Settings, user_id: str | None
) -> EventStream | None:
"""Join a conversation and return its event stream."""
async def is_agent_loop_running(self, sid: str) -> bool:
"""Check if an agent loop is running for the given session ID."""
sids = await self.get_running_agent_loops(filter_to_sids={sid})
return bool(sids)
@abstractmethod
async def get_running_agent_loops(
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
) -> set[str]:
"""Get all running agent loops, optionally filtered by user ID and session IDs."""
@abstractmethod
async def get_connections(
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
) -> dict[str, str]:
"""Get all connections, optionally filtered by user ID and session IDs."""
@abstractmethod
async def maybe_start_agent_loop(
self,
sid: str,
settings: Settings,
user_id: str | None,
initial_user_msg: MessageAction | None = None,
) -> EventStream:
"""Start an event loop if one is not already running"""
@abstractmethod
async def send_to_event_stream(self, connection_id: str, data: dict):
"""Send data to an event stream."""
@abstractmethod
async def disconnect_from_session(self, connection_id: str):
"""Disconnect from a session."""
@abstractmethod
async def close_session(self, sid: str):
"""Close a session."""
@classmethod
@abstractmethod
def get_instance(
cls,
sio: socketio.AsyncServer,
config: AppConfig,
file_store: FileStore,
) -> ConversationManager:
"""Get a store for the user represented by the token given"""

View File

@@ -0,0 +1,284 @@
import asyncio
import time
from dataclasses import dataclass, field
from typing import Iterable
import socketio
from openhands.core.config.app_config import AppConfig
from openhands.core.exceptions import AgentRuntimeUnavailableError
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events.action import MessageAction
from openhands.events.stream import EventStream, session_exists
from openhands.server.session.conversation import Conversation
from openhands.server.session.session import ROOM_KEY, Session
from openhands.server.settings import Settings
from openhands.storage.files import FileStore
from openhands.utils.async_utils import wait_all
from openhands.utils.shutdown_listener import should_continue
from .conversation_manager import ConversationManager
_CLEANUP_INTERVAL = 15
MAX_RUNNING_CONVERSATIONS = 3
@dataclass
class StandaloneConversationManager(ConversationManager):
"""Manages conversations in standalone mode (single server instance)."""
sio: socketio.AsyncServer
config: AppConfig
file_store: FileStore
_local_agent_loops_by_sid: dict[str, Session] = field(default_factory=dict)
_local_connection_id_to_session_id: dict[str, str] = field(default_factory=dict)
_active_conversations: dict[str, tuple[Conversation, int]] = field(
default_factory=dict
)
_detached_conversations: dict[str, tuple[Conversation, float]] = field(
default_factory=dict
)
_conversations_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
_cleanup_task: asyncio.Task | None = None
async def __aenter__(self):
self._cleanup_task = asyncio.create_task(self._cleanup_stale())
return self
async def __aexit__(self, exc_type, exc_value, traceback):
if self._cleanup_task:
self._cleanup_task.cancel()
self._cleanup_task = None
async def attach_to_conversation(self, sid: str) -> Conversation | None:
start_time = time.time()
if not await session_exists(sid, self.file_store):
return None
async with self._conversations_lock:
# Check if we have an active conversation we can reuse
if sid in self._active_conversations:
conversation, count = self._active_conversations[sid]
self._active_conversations[sid] = (conversation, count + 1)
logger.info(f'Reusing active conversation {sid}')
return conversation
# Check if we have a detached conversation we can reuse
if sid in self._detached_conversations:
conversation, _ = self._detached_conversations.pop(sid)
self._active_conversations[sid] = (conversation, 1)
logger.info(f'Reusing detached conversation {sid}')
return conversation
# Create new conversation if none exists
c = Conversation(sid, file_store=self.file_store, config=self.config)
try:
await c.connect()
except AgentRuntimeUnavailableError as e:
logger.error(f'Error connecting to conversation {c.sid}: {e}')
await c.disconnect()
return None
end_time = time.time()
logger.info(
f'Conversation {c.sid} connected in {end_time - start_time} seconds'
)
self._active_conversations[sid] = (c, 1)
return c
async def join_conversation(
self, sid: str, connection_id: str, settings: Settings, user_id: str | None
):
logger.info(f'join_conversation:{sid}:{connection_id}')
await self.sio.enter_room(connection_id, ROOM_KEY.format(sid=sid))
self._local_connection_id_to_session_id[connection_id] = sid
event_stream = await self._get_event_stream(sid)
if not event_stream:
return await self.maybe_start_agent_loop(sid, settings, user_id)
return event_stream
async def detach_from_conversation(self, conversation: Conversation):
sid = conversation.sid
async with self._conversations_lock:
if sid in self._active_conversations:
conv, count = self._active_conversations[sid]
if count > 1:
self._active_conversations[sid] = (conv, count - 1)
return
else:
self._active_conversations.pop(sid)
self._detached_conversations[sid] = (conversation, time.time())
async def _cleanup_stale(self):
while should_continue():
try:
async with self._conversations_lock:
# Create a list of items to process to avoid modifying dict during iteration
items = list(self._detached_conversations.items())
for sid, (conversation, detach_time) in items:
await conversation.disconnect()
self._detached_conversations.pop(sid, None)
close_threshold = time.time() - self.config.sandbox.close_delay
running_loops = list(self._local_agent_loops_by_sid.items())
running_loops.sort(key=lambda item: item[1].last_active_ts)
sid_to_close: list[str] = []
for sid, session in running_loops:
state = session.agent_session.get_state()
if session.last_active_ts < close_threshold and state not in [
AgentState.RUNNING,
None,
]:
sid_to_close.append(sid)
connections = await self.get_connections(
filter_to_sids=set(sid_to_close)
)
connected_sids = {sid for _, sid in connections.items()}
sid_to_close = [
sid for sid in sid_to_close if sid not in connected_sids
]
await wait_all(self._close_session(sid) for sid in sid_to_close)
await asyncio.sleep(_CLEANUP_INTERVAL)
except asyncio.CancelledError:
async with self._conversations_lock:
for conversation, _ in self._detached_conversations.values():
await conversation.disconnect()
self._detached_conversations.clear()
await wait_all(
self._close_session(sid) for sid in self._local_agent_loops_by_sid
)
return
except Exception as e:
logger.warning(f'error_cleaning_stale: {str(e)}')
await asyncio.sleep(_CLEANUP_INTERVAL)
async def get_running_agent_loops(
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
) -> set[str]:
"""Get the running session ids. If a user is supplied, then the results are limited to session ids for that user. If a set of filter_to_sids is supplied, then results are limited to these ids of interest."""
items: Iterable[tuple[str, Session]] = self._local_agent_loops_by_sid.items()
if filter_to_sids is not None:
items = (item for item in items if item[0] in filter_to_sids)
if user_id:
items = (item for item in items if item[1].user_id == user_id)
sids = {sid for sid, _ in items}
return sids
async def get_connections(
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
) -> dict[str, str]:
connections = dict(**self._local_connection_id_to_session_id)
if filter_to_sids is not None:
connections = {
connection_id: sid
for connection_id, sid in connections.items()
if sid in filter_to_sids
}
if user_id:
for connection_id, sid in list(connections.items()):
session = self._local_agent_loops_by_sid.get(sid)
if not session or session.user_id != user_id:
connections.pop(connection_id)
return connections
async def maybe_start_agent_loop(
self,
sid: str,
settings: Settings,
user_id: str | None,
initial_user_msg: MessageAction | None = None,
) -> EventStream:
logger.info(f'maybe_start_agent_loop:{sid}')
session: Session | None = None
if not await self.is_agent_loop_running(sid):
logger.info(f'start_agent_loop:{sid}')
response_ids = await self.get_running_agent_loops(user_id)
if len(response_ids) >= MAX_RUNNING_CONVERSATIONS:
logger.info('too_many_sessions_for:{user_id}')
# Order is not guaranteed, but response_ids tend to be in descending chronological order
# By reversing, we are likely to pick the oldest (or at least an older) conversation
session_id = next(iter(reversed(list(response_ids))))
await self.close_session(session_id)
session = Session(
sid=sid,
file_store=self.file_store,
config=self.config,
sio=self.sio,
user_id=user_id,
)
self._local_agent_loops_by_sid[sid] = session
asyncio.create_task(session.initialize_agent(settings, initial_user_msg))
event_stream = await self._get_event_stream(sid)
if not event_stream:
logger.error(f'No event stream after starting agent loop: {sid}')
raise RuntimeError(f'no_event_stream:{sid}')
return event_stream
async def _get_event_stream(self, sid: str) -> EventStream | None:
logger.info(f'_get_event_stream:{sid}')
session = self._local_agent_loops_by_sid.get(sid)
if session:
logger.info(f'found_local_agent_loop:{sid}')
return session.agent_session.event_stream
return None
async def send_to_event_stream(self, connection_id: str, data: dict):
# If there is a local session running, send to that
sid = self._local_connection_id_to_session_id.get(connection_id)
if not sid:
raise RuntimeError(f'no_connected_session:{connection_id}')
session = self._local_agent_loops_by_sid.get(sid)
if session:
await session.dispatch(data)
return
raise RuntimeError(f'no_connected_session:{connection_id}:{sid}')
async def disconnect_from_session(self, connection_id: str):
sid = self._local_connection_id_to_session_id.pop(connection_id, None)
logger.info(f'disconnect_from_session:{connection_id}:{sid}')
if not sid:
# This can occur if the init action was never run.
logger.warning(f'disconnect_from_uninitialized_session:{connection_id}')
return
async def close_session(self, sid: str):
session = self._local_agent_loops_by_sid.get(sid)
if session:
await self._close_session(sid)
async def _close_session(self, sid: str):
logger.info(f'_close_session:{sid}')
# Clear up local variables
connection_ids_to_remove = list(
connection_id
for connection_id, conn_sid in self._local_connection_id_to_session_id.items()
if sid == conn_sid
)
logger.info(f'removing connections: {connection_ids_to_remove}')
for connnnection_id in connection_ids_to_remove:
self._local_connection_id_to_session_id.pop(connnnection_id, None)
session = self._local_agent_loops_by_sid.pop(sid, None)
if not session:
logger.warning(f'no_session_to_close:{sid}')
return
logger.info(f'closing_session:{session.sid}')
await session.close()
logger.info(f'closed_session:{session.sid}')
@classmethod
def get_instance(
cls,
sio: socketio.AsyncServer,
config: AppConfig,
file_store: FileStore,
) -> ConversationManager:
return StandaloneConversationManager(sio, config, file_store)

View File

@@ -1,10 +1,10 @@
from urllib.parse import parse_qs
import jwt
from pydantic import SecretStr
from socketio.exceptions import ConnectionRefusedError
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events.action import (
NullAction,
)
@@ -15,7 +15,7 @@ from openhands.events.observation.agent import AgentStateChangedObservation
from openhands.events.serialization import event_to_dict
from openhands.events.stream import AsyncEventStreamWrapper
from openhands.server.routes.settings import ConversationStoreImpl, SettingsStoreImpl
from openhands.server.shared import config, openhands_config, session_manager, sio
from openhands.server.shared import config, conversation_manager, openhands_config, sio
from openhands.server.types import AppMode
@@ -37,7 +37,15 @@ async def connect(connection_id: str, environ, auth):
if not signed_token:
logger.error('No github_auth cookie')
raise ConnectionRefusedError('No github_auth cookie')
decoded = jwt.decode(signed_token, config.jwt_secret, algorithms=['HS256'])
if not config.jwt_secret:
raise RuntimeError('JWT secret not found')
jwt_secret = (
config.jwt_secret.get_secret_value()
if isinstance(config.jwt_secret, SecretStr)
else config.jwt_secret
)
decoded = jwt.decode(signed_token, jwt_secret, algorithms=['HS256'])
user_id = decoded['github_user_id']
logger.info(f'User {user_id} is connecting to conversation {conversation_id}')
@@ -61,7 +69,7 @@ async def connect(connection_id: str, environ, auth):
'Settings not found', {'msg_id': 'CONFIGURATION$SETTINGS_NOT_FOUND'}
)
event_stream = await session_manager.join_conversation(
event_stream = await conversation_manager.join_conversation(
conversation_id, connection_id, settings, user_id
)
@@ -77,8 +85,6 @@ async def connect(connection_id: str, environ, auth):
):
continue
elif isinstance(event, AgentStateChangedObservation):
if event.agent_state == AgentState.INIT:
await sio.emit('oh_event', event_to_dict(event), to=connection_id)
agent_state_changed = event
else:
await sio.emit('oh_event', event_to_dict(event), to=connection_id)
@@ -88,10 +94,10 @@ async def connect(connection_id: str, environ, auth):
@sio.event
async def oh_action(connection_id: str, data: dict):
await session_manager.send_to_event_stream(connection_id, data)
await conversation_manager.send_to_event_stream(connection_id, data)
@sio.event
async def disconnect(connection_id: str):
logger.info(f'sio:disconnect:{connection_id}')
await session_manager.disconnect_from_session(connection_id)
await conversation_manager.disconnect_from_session(connection_id)

View File

@@ -11,7 +11,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request as StarletteRequest
from starlette.types import ASGIApp
from openhands.server.shared import session_manager
from openhands.server import shared
from openhands.server.types import SessionMiddlewareInterface
@@ -146,8 +146,8 @@ class AttachConversationMiddleware(SessionMiddlewareInterface):
"""
Attach the user's session based on the provided authentication token.
"""
request.state.conversation = await session_manager.attach_to_conversation(
request.state.sid
request.state.conversation = (
await shared.conversation_manager.attach_to_conversation(request.state.sid)
)
if not request.state.conversation:
return JSONResponse(
@@ -160,7 +160,9 @@ class AttachConversationMiddleware(SessionMiddlewareInterface):
"""
Detach the user's session.
"""
await session_manager.detach_from_conversation(request.state.conversation)
await shared.conversation_manager.detach_from_conversation(
request.state.conversation
)
async def __call__(self, request: Request, call_next: Callable):
if not self._should_attach(request):

View File

@@ -2,7 +2,6 @@ import uvicorn
from fastapi import FastAPI, WebSocket
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema import ActionType
from openhands.utils.shutdown_listener import should_continue
app = FastAPI()
@@ -11,10 +10,6 @@ app = FastAPI()
@app.websocket('/ws')
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
# send message to mock connection
await websocket.send_json(
{'action': ActionType.INIT, 'message': 'Control loop started.'}
)
try:
while should_continue():

View File

@@ -1,3 +1,4 @@
import httpx
import requests
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
@@ -47,46 +48,41 @@ async def get_github_repositories(
# Fetch repositories from GitHub
try:
response = await call_sync_from_async(
requests.get, github_api_url, headers=headers, params=params
)
response.raise_for_status() # Raise an error for HTTP codes >= 400
async with httpx.AsyncClient() as client:
response = await client.get(github_api_url, headers=headers, params=params)
response.raise_for_status() # Raise an error for HTTP codes >= 400
json_response = JSONResponse(content=response.json())
# Forward the Link header if it exists
if 'Link' in response.headers:
json_response.headers['Link'] = response.headers['Link']
return json_response
except requests.exceptions.RequestException as e:
raise HTTPException(
status_code=response.status_code if response else 500,
detail=f'Error fetching repositories: {str(e)}',
)
# Create response with the JSON content
json_response = JSONResponse(content=response.json())
response.close()
# Forward the Link header if it exists
if 'Link' in response.headers:
json_response.headers['Link'] = response.headers['Link']
return json_response
@app.get('/user')
async def get_github_user(github_token: str = Depends(require_github_token)):
headers = generate_github_headers(github_token)
try:
response = await call_sync_from_async(
requests.get, 'https://api.github.com/user', headers=headers
)
response.raise_for_status()
async with httpx.AsyncClient() as client:
response = await client.get('https://api.github.com/user', headers=headers)
response.raise_for_status() # Raise an error for HTTP codes >= 400
json_response = JSONResponse(content=response.json())
return json_response
except requests.exceptions.RequestException as e:
raise HTTPException(
status_code=response.status_code if response else 500,
detail=f'Error fetching user: {str(e)}',
)
json_response = JSONResponse(content=response.json())
response.close()
return json_response
@app.get('/installations')
async def get_github_installation_ids(

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