mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51bf321a01 | |||
| 2d66939b42 | |||
| a0c79f7388 | |||
| a44cdae36e | |||
| 7aa7eb2399 | |||
| 42712a44d8 | |||
| 76c992e2df | |||
| 26b420a01d | |||
| e707be429e | |||
| 24773e15c5 | |||
| 5dba964281 | |||
| 1596a6cc62 | |||
| 0df87bfacc | |||
| 8e9eb7d07d | |||
| 60196d2eca | |||
| b9af0188fe | |||
| 9850f1767a | |||
| c5491e87aa | |||
| 400afeb70e | |||
| c63d52d5e6 | |||
| 1230b229b5 | |||
| 72d5f1fe53 | |||
| e3a5df514e | |||
| aaf65ebf0f | |||
| 844d84d7bb | |||
| 5a3eca2a2a | |||
| efcf30a23d | |||
| 951cb1c880 | |||
| 78b67bc9d9 | |||
| 500e09f12b | |||
| 2a5e17d548 | |||
| 6541eab43b | |||
| 2e72ef151e | |||
| 032eb152bf | |||
| 63ebd9e338 | |||
| 1064939013 | |||
| ff6312ab02 | |||
| 036fa5dccf | |||
| 86c6feafcc | |||
| 0efe4feb2a | |||
| 8b473397d1 |
@@ -238,7 +238,7 @@ jobs:
|
||||
PYTHONPATH: ""
|
||||
run: |
|
||||
cd /tmp && python -m openhands.resolver.resolve_issue \
|
||||
--repo ${{ github.repository }} \
|
||||
--selected-repo ${{ github.repository }} \
|
||||
--issue-number ${{ env.ISSUE_NUMBER }} \
|
||||
--issue-type ${{ env.ISSUE_TYPE }} \
|
||||
--max-iterations ${{ env.MAX_ITERATIONS }} \
|
||||
|
||||
+1
-1
@@ -118,7 +118,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image by
|
||||
setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.29-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.30-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -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.29-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.29-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.29
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.30
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
#daytona_target = ""
|
||||
|
||||
# Base path for the workspace
|
||||
workspace_base = "./workspace"
|
||||
#workspace_base = "./workspace"
|
||||
|
||||
# Cache directory path
|
||||
#cache_dir = "/tmp/cache"
|
||||
@@ -64,7 +64,7 @@ workspace_base = "./workspace"
|
||||
#max_budget_per_task = 0.0
|
||||
|
||||
# Maximum number of iterations
|
||||
#max_iterations = 100
|
||||
#max_iterations = 250
|
||||
|
||||
# Path to mount the workspace in the sandbox
|
||||
#workspace_mount_path_in_sandbox = "/workspace"
|
||||
|
||||
@@ -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.29-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.30-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
+1
-1
@@ -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.29-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik}
|
||||
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of openhands-state for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -47,6 +47,7 @@ docker run -it \
|
||||
...
|
||||
```
|
||||
|
||||
|
||||
### Referring to UI Elements
|
||||
|
||||
When referencing UI elements, use ``.
|
||||
|
||||
+1
-1
@@ -57,4 +57,4 @@ $ poetry run python docs/translation_updater.py
|
||||
# ...
|
||||
```
|
||||
|
||||
This process uses `claude-3-opus-20240229` as base model and each language consumes at least ~30k input tokens and ~35k output tokens.
|
||||
This process uses `claude-3-7-sonnet-20250219` as base model and each language consumes at least ~30k input tokens and ~35k output tokens.
|
||||
|
||||
@@ -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.29-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -61,7 +61,7 @@ docker run -it \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.29 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.29-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -56,6 +56,6 @@ docker run -it \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.29 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
|
||||
```
|
||||
|
||||
@@ -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.29-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.29-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.29
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.30
|
||||
```
|
||||
|
||||
Vous pouvez également exécuter OpenHands en mode [headless scriptable](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), en tant que [CLI interactive](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), ou en utilisant l'[Action GitHub OpenHands](https://docs.all-hands.dev/modules/usage/how-to/github-action).
|
||||
|
||||
@@ -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.29-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -35,7 +35,7 @@ Para executar o OpenHands no modo CLI com Docker:
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.29-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -45,7 +45,7 @@ docker run -it \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.29 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
+2
-2
@@ -32,7 +32,7 @@ Para executar o OpenHands no modo Headless com Docker:
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.29-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -43,7 +43,7 @@ docker run -it \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.29 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
|
||||
python -m openhands.core.main -t "escreva um script bash que imprima oi"
|
||||
```
|
||||
|
||||
|
||||
@@ -58,17 +58,17 @@
|
||||
A maneira mais fácil de executar o OpenHands é no Docker.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.29-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.29-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.29
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.30
|
||||
```
|
||||
|
||||
Você encontrará o OpenHands em execução em http://localhost:3000!
|
||||
|
||||
@@ -13,7 +13,7 @@ Este é o Runtime padrão que é usado quando você inicia o OpenHands. Você po
|
||||
|
||||
```
|
||||
docker run # ...
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.29-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -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.29-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -59,7 +59,7 @@ docker run -it \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.29 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
+2
-2
@@ -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.29-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -57,6 +57,6 @@ docker run -it \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.29 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
|
||||
```
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
在 Docker 中运行 OpenHands 是最简单的方式。
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.29-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.29-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.29
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.30
|
||||
```
|
||||
|
||||
你也可以在可脚本化的[无头模式](https://docs.all-hands.dev/modules/usage/how-to/headless-mode)下运行 OpenHands,作为[交互式 CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),或使用 [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action)。
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
```
|
||||
docker run # ...
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.29-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -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.29-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -45,7 +45,7 @@ docker run -it \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.29 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ To run OpenHands in Headless mode with Docker:
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.29-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -43,7 +43,7 @@ docker run -it \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.29 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.30 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
|
||||
@@ -58,17 +58,17 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
|
||||
The easiest way to run OpenHands is in Docker.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.29-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.29-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.29
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.30
|
||||
```
|
||||
|
||||
You'll find OpenHands running at http://localhost:3000!
|
||||
@@ -87,6 +87,24 @@ If the required model does not exist in the list, you can toggle `Advanced` opti
|
||||
in the `Custom Model` text box.
|
||||
The `Advanced` options also allow you to specify a `Base URL` if required.
|
||||
|
||||
### Getting an API Key
|
||||
|
||||
OpenHands requires an API key to access most language models. Here's how to get an API key from the recommended providers:
|
||||
|
||||
#### Anthropic (Claude)
|
||||
|
||||
1. [Create an Anthropic account](https://console.anthropic.com/)
|
||||
2. [Generate an API key](https://console.anthropic.com/settings/keys)
|
||||
3. [Set up billing(https://console.anthropic.com/settings/billing)
|
||||
|
||||
Consider setting usage limits to control costs.
|
||||
|
||||
#### OpenAI
|
||||
|
||||
1. [Create an OpenAI account](https://platform.openai.com/)
|
||||
2. [Generate an API key](https://platform.openai.com/api-keys)
|
||||
3. [Set up billing](https://platform.openai.com/account/billing/overview)
|
||||
|
||||
Now you're ready to [get started with OpenHands](./getting-started).
|
||||
|
||||
## Versions
|
||||
|
||||
@@ -4,33 +4,28 @@ Microagents are specialized prompts that enhance OpenHands with domain-specific
|
||||
and task-specific workflows. They help by providing expert guidance, automating common tasks, and ensuring
|
||||
consistent practices across projects.
|
||||
|
||||
## Microagent Types
|
||||
## Microagent Categories
|
||||
|
||||
Currently OpenHands supports the following types of microagents:
|
||||
Currently OpenHands supports two categories of microagents:
|
||||
|
||||
* [Repository Microagents](./microagents-repo): Repository-specific context and guidelines for OpenHands.
|
||||
* [Public Microagents](./microagents-public): General guidelines triggered by keywords for all OpenHands users.
|
||||
- [Repository-specific Microagents](./microagents-repo): Repository-specific context and guidelines for OpenHands.
|
||||
- [Public Microagents](./microagents-public): General guidelines triggered by keywords for all OpenHands users.
|
||||
|
||||
A microagent is classified as repository-specific or public depending on its location:
|
||||
|
||||
- Repository-specific microagents are located in a repository's `.openhands/microagents/` directory
|
||||
- Public microagents are located in the official OpenHands repository inside the `/microagents` folder
|
||||
|
||||
When OpenHands works with a repository, it:
|
||||
|
||||
1. Loads repository-specific instructions from `.openhands/microagents/` if present in the repository.
|
||||
2. Loads general guidelines triggered by keywords in conversations.
|
||||
See current [Public Microagents](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge).
|
||||
1. Loads **repository-specific** microagents from `.openhands/microagents/` if present in the repository.
|
||||
2. Loads **public knowledge** microagents triggered by keywords in conversations
|
||||
3. Loads **public tasks** microagents when explicitly requested by the user
|
||||
|
||||
You can check out the existing public microagents at the [official OpenHands repository](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/).
|
||||
|
||||
## Microagent Format
|
||||
|
||||
All microagents use markdown files with YAML frontmatter that have special instructions to help OpenHands accomplish
|
||||
tasks:
|
||||
```
|
||||
---
|
||||
name: <Name of the microagent>
|
||||
type: <MicroAgent type>
|
||||
version: <MicroAgent version>
|
||||
agent: <The agent type (Typically CodeActAgent)>
|
||||
triggers:
|
||||
- <Optional keywords triggering the microagent. If triggers are removed, it will always be included>
|
||||
---
|
||||
All microagents use markdown files with YAML frontmatter that have special instructions to help OpenHands activate them.
|
||||
|
||||
<Markdown with any special guidelines, instructions, and prompts that OpenHands should follow.
|
||||
Check out the specific documentation for each microagent on best practices for more information.>
|
||||
```
|
||||
Check out the [syntax documentation](./microagents-syntax) for a comprehensive guide on how to configure your microagents.
|
||||
|
||||
@@ -2,64 +2,32 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Public microagents are specialized guidelines triggered by keywords for all OpenHands users.
|
||||
They are defined in markdown files under the
|
||||
[`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge) directory.
|
||||
Public microagents provide specialized context and capabilities for all OpenHands users, regardless of their repository configuration. Unlike repository-specific microagents, public microagents are globally available across all repositories.
|
||||
|
||||
Public microagents come in two types:
|
||||
|
||||
- **Knowledge microagents**: Automatically activated when keywords in conversations match their triggers
|
||||
- **Task microagents**: Explicitly invoked by users to guide through specific workflows
|
||||
|
||||
Both types follow the same syntax and structure as repository-specific microagents, using markdown files with YAML frontmatter that define their behavior and capabilities. They are located in the official OpenHands repository under:
|
||||
|
||||
- [`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge) for knowledge microagents
|
||||
- [`microagents/tasks/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/tasks) for task microagents
|
||||
|
||||
Public microagents:
|
||||
|
||||
- Monitor incoming commands for their trigger words.
|
||||
- Activate when relevant triggers are detected.
|
||||
- Apply their specialized knowledge and capabilities.
|
||||
- Follow their specific guidelines and restrictions.
|
||||
|
||||
## Current Public Microagents
|
||||
|
||||
For more information about specific microagents, refer to their individual documentation files in
|
||||
the [`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge/) directory.
|
||||
|
||||
### GitHub Agent
|
||||
**File**: `github.md`
|
||||
**Triggers**: `github`, `git`
|
||||
|
||||
The GitHub agent specializes in GitHub API interactions and repository management. It:
|
||||
- Has access to a `GITHUB_TOKEN` for API authentication.
|
||||
- Follows strict guidelines for repository interactions.
|
||||
- Handles branch management and pull requests.
|
||||
- Uses the GitHub API instead of web browser interactions.
|
||||
|
||||
Key features:
|
||||
- Branch protection (prevents direct pushes to main/master)
|
||||
- Automated PR creation
|
||||
- Git configuration management
|
||||
- API-first approach for GitHub operations
|
||||
|
||||
Usage Example:
|
||||
|
||||
```bash
|
||||
git checkout -b feature-branch
|
||||
git commit -m "Add new feature"
|
||||
git push origin feature-branch
|
||||
```
|
||||
|
||||
### NPM Agent
|
||||
**File**: `npm.md`
|
||||
**Triggers**: `npm`
|
||||
|
||||
Specializes in handling npm package management with specific focus on:
|
||||
- Non-interactive shell operations.
|
||||
- Automated confirmation handling using Unix 'yes' command.
|
||||
- Package installation automation.
|
||||
|
||||
Usage Example:
|
||||
|
||||
```bash
|
||||
yes | npm install package-name
|
||||
```
|
||||
When loading public microagents, OpenHands scans the official repository's microagents directories recursively, processing all markdown files except README.md. The system categorizes each microagent based on its `type` field in the YAML frontmatter, regardless of its exact file location within the knowledge or tasks directories.
|
||||
|
||||
## Contributing a Public Microagent
|
||||
|
||||
You can create your own public microagents by adding new markdown files to the
|
||||
[`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge/) directory.
|
||||
You can create public microagents and share with the community by opening a pull request to the official repository.
|
||||
|
||||
See the [CONTRIBUTING.md](https://github.com/All-Hands-AI/OpenHands/blob/main/CONTRIBUTING.md) for specific instructions on how to contribute to OpenHands.
|
||||
|
||||
### Public Microagents Best Practices
|
||||
|
||||
@@ -74,6 +42,7 @@ You can create your own public microagents by adding new markdown files to the
|
||||
#### 1. Plan the Public Microagent
|
||||
|
||||
Before creating a public microagent, consider:
|
||||
|
||||
- What specific problem or use case will it address?
|
||||
- What unique capabilities or knowledge should it have?
|
||||
- What trigger words make sense for activating it?
|
||||
@@ -81,73 +50,24 @@ Before creating a public microagent, consider:
|
||||
|
||||
#### 2. Create File
|
||||
|
||||
Create a new markdown file in [`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge/)
|
||||
with a descriptive name (e.g., `docker.md` for a Docker-focused agent).
|
||||
Create a new Markdown file with a descriptive name in the appropriate directory:
|
||||
|
||||
Update the file with the required frontmatter [according to the required format](./microagents-overview#microagent-format)
|
||||
and the required specialized guidelines while following the [best practices above](#public-microagents-best-practices).
|
||||
- [`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge) for knowledge microagents
|
||||
- [`microagents/tasks/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/tasks) for task microagents
|
||||
|
||||
Ensure it follows the correct [syntax](./microagents-syntax.md) and [best practices](./microagents-syntax.md#markdown-content-best-practices).
|
||||
|
||||
#### 3. Testing the Public Microagent
|
||||
|
||||
- Test the agent with various prompts.
|
||||
- Verify trigger words activate the agent correctly.
|
||||
- Ensure instructions are clear and comprehensive.
|
||||
- Check for potential conflicts with existing agents.
|
||||
- Test the agent with various prompts
|
||||
- Verify trigger words activate the agent correctly
|
||||
- Ensure instructions are clear and comprehensive
|
||||
- Check for potential conflicts and overlaps with existing agents
|
||||
|
||||
#### 4. Submission Process
|
||||
|
||||
Submit a pull request with:
|
||||
- The new microagent file.
|
||||
- Updated documentation if needed.
|
||||
- Description of the agent's purpose and capabilities.
|
||||
|
||||
### Example Public Microagent Implementation
|
||||
|
||||
Here's a template for a new microagent:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: docker
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- docker
|
||||
- container
|
||||
---
|
||||
|
||||
You are responsible for Docker container management and Dockerfile creation.
|
||||
|
||||
Key responsibilities:
|
||||
1. Create and modify Dockerfiles
|
||||
2. Manage container lifecycle
|
||||
3. Handle Docker Compose configurations
|
||||
|
||||
Guidelines:
|
||||
- Always use official base images when possible
|
||||
- Include necessary security considerations
|
||||
- Follow Docker best practices for layer optimization
|
||||
|
||||
Examples:
|
||||
1. Creating a Dockerfile:
|
||||
FROM node:18-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
CMD ["npm", "start"]
|
||||
|
||||
2. Docker Compose usage:
|
||||
version: '3'
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
Remember to:
|
||||
- Validate Dockerfile syntax
|
||||
- Check for security vulnerabilities
|
||||
- Optimize for build time and image size
|
||||
```
|
||||
|
||||
See the [current public micro-agents](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge) for
|
||||
more examples.
|
||||
- The new microagent file
|
||||
- Updated documentation if needed
|
||||
- Description of the agent's purpose and capabilities
|
||||
|
||||
@@ -1,68 +1,117 @@
|
||||
# Repository Microagents
|
||||
# Repository-specific Microagents
|
||||
|
||||
## Overview
|
||||
|
||||
OpenHands can be customized to work more effectively with specific repositories by providing repository-specific context
|
||||
and guidelines. This section explains how to optimize OpenHands for your project.
|
||||
OpenHands can be customized to work more effectively with specific repositories by providing repository-specific context and guidelines.
|
||||
|
||||
## Creating a Repository Micro-Agent
|
||||
This section explains how to optimize OpenHands for your project.
|
||||
|
||||
## Creating Repository Microagents
|
||||
|
||||
You can customize OpenHands' behavior for your repository by creating a `.openhands/microagents/` directory in your repository's root.
|
||||
At minimum it should contain the file
|
||||
`.openhands/microagents/repo.md`, which includes instructions that will
|
||||
be given to the agent every time it works with this repository.
|
||||
|
||||
### Repository Microagents Best Practices
|
||||
You can enhance OpenHands' performance by adding custom microagents to your repository:
|
||||
|
||||
- **Keep Instructions Updated**: Regularly update your `.openhands/microagents/` directory as your project evolves.
|
||||
- **Be Specific**: Include specific paths, patterns, and requirements unique to your project.
|
||||
- **Document Dependencies**: List all tools and dependencies required for development.
|
||||
- **Include Examples**: Provide examples of good code patterns from your project.
|
||||
- **Specify Conventions**: Document naming conventions, file organization, and code style preferences.
|
||||
1. For overall repository-specific instructions, create a `.openhands/microagents/repo.md` file
|
||||
2. For reusable domain knowledge triggered by keywords, add multiple `.md` files to `.openhands/microagents/knowledge/`
|
||||
3. For common workflows and tasks, create multiple `.md` files to `.openhands/microagents/tasks/`
|
||||
|
||||
### Steps to Create a Repository Microagent
|
||||
Check out the [best practices](./microagents-syntax.md#markdown-content-best-practices) for formatting the content of your custom microagent.
|
||||
|
||||
#### 1. Plan the Repository Microagent
|
||||
When creating a repository-specific micro-agent, we suggest including the following information:
|
||||
- **Repository Overview**: A brief description of your project's purpose and architecture.
|
||||
- **Directory Structure**: Key directories and their purposes.
|
||||
- **Development Guidelines**: Project-specific coding standards and practices.
|
||||
- **Testing Requirements**: How to run tests and what types of tests are required.
|
||||
- **Setup Instructions**: Steps needed to build and run the project.
|
||||
Keep in mind that loaded microagents take up space in the context window. It's crucial to strike a balance between the additional context provided by microagents and the instructions provided in the user's inputs.
|
||||
|
||||
#### 2. Create File
|
||||
Note that you can use OpenHands to create new microagents. The public microagent [`add_agent`](https://github.com/All-Hands-AI/OpenHands/blob/main/microagents/knowledge/add_agent.md) is loaded to all OpenHands instance and can support you on this.
|
||||
|
||||
Create a file in your repository under `.openhands/microagents/` (Example: `.openhands/microagents/repo.md`)
|
||||
## Types of Microagents
|
||||
|
||||
Update the file with the required frontmatter [according to the required format](./microagents-overview#microagent-format)
|
||||
and the required specialized guidelines for your repository.
|
||||
OpenHands supports three primary types of microagents, each with specific purposes and features to enhance agent performance:
|
||||
|
||||
### Example Repository Microagent
|
||||
- [repository](#repository-microagents)
|
||||
- [knowledge](#knowledge-microagents)
|
||||
- [tasks](#tasks-microagents)
|
||||
|
||||
The standard directory structure within a repository is:
|
||||
|
||||
- One main `repo.md` file containing repository-specific instructions
|
||||
- Additional `Knowledge` agents in `.openhands/microagents/knowledge/` directory
|
||||
- Additional `Task` agents in `.openhands/microagents/tasks/` directory
|
||||
|
||||
When processing the `.openhands/microagents/` directory, OpenHands will recursively scan all subfolders and process any `.md` files (except `README.md`) it finds. The system determines the microagent type based on the `type` field in the YAML frontmatter, not by the file's location. However, for organizational clarity, it's recommended to follow the standard directory structure.
|
||||
|
||||
### Repository Microagents
|
||||
|
||||
The `Repository` microagent is loaded specifically from `.openhands/microagents/repo.md` and serves as the main
|
||||
repository-specific instruction file. This single file is automatically loaded whenever OpenHands works with that repository
|
||||
without requiring any keyword matching or explicit call from the user.
|
||||
|
||||
OpenHands does not support multiple `repo.md` files in different locations or multiple microagents with type `repo`.
|
||||
|
||||
If you need to organize different types of repository information, the recommended approach is to use a single `repo.md` file with well-structured sections rather than trying to create multiple microagents with the type `repo`.
|
||||
|
||||
The best practice is to include project-specific instructions, team practices, coding standards, and architectural guidelines that are relevant for **all** prompts in that repository.
|
||||
|
||||
Example structure:
|
||||
|
||||
```
|
||||
---
|
||||
name: repo
|
||||
type: repo
|
||||
agent: CodeActAgent
|
||||
---
|
||||
|
||||
Repository: MyProject
|
||||
Description: A web application for task management
|
||||
|
||||
Directory Structure:
|
||||
- src/: Main application code
|
||||
- tests/: Test files
|
||||
- docs/: Documentation
|
||||
|
||||
Setup:
|
||||
- Run `npm install` to install dependencies
|
||||
- Use `npm run dev` for development
|
||||
- Run `npm test` for testing
|
||||
|
||||
Guidelines:
|
||||
- Follow ESLint configuration
|
||||
- Write tests for all new features
|
||||
- Use TypeScript for new code
|
||||
|
||||
If adding a new component in src/components, always add appropriate unit tests in tests/components/.
|
||||
your-repository/
|
||||
└── .openhands/
|
||||
└── microagents/
|
||||
└── repo.md # Repository-specific instructions
|
||||
```
|
||||
|
||||
[See the example in the official OpenHands repository](https://github.com/All-Hands-AI/OpenHands/blob/main/.openhands/microagents/repo.md?plain=1)
|
||||
|
||||
### Knowledge Microagents
|
||||
|
||||
Knowledge microagents provide specialized domain expertise:
|
||||
|
||||
- Recommended to be located in `.openhands/microagents/knowledge/`
|
||||
- Triggered by specific keywords in conversations
|
||||
- Contain expertise on tools, languages, frameworks, and common practices
|
||||
|
||||
Use knowledge microagents to trigger additional context relevant to specific technologies, tools, or workflows. For example, mentioning "git" in your conversation will automatically trigger git-related expertise to help with Git operations.
|
||||
|
||||
Examples structure:
|
||||
|
||||
```
|
||||
your-repository/
|
||||
└── .openhands/
|
||||
└── microagents/
|
||||
└── knowledge/
|
||||
└── git.md
|
||||
└── docker.md
|
||||
└── python.md
|
||||
└── ...
|
||||
└── repo.md
|
||||
```
|
||||
|
||||
You can find several real examples of `Knowledge` microagents in the [offical OpenHands repository](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge)
|
||||
|
||||
### Tasks Microagents
|
||||
|
||||
Task microagents guide users through interactive workflows:
|
||||
|
||||
- Recommended to be located in `.openhands/microagents/tasks/`
|
||||
- Provide step-by-step processes for common development tasks
|
||||
- Accept inputs and adapt to different scenarios
|
||||
- Ensure consistent outcomes for complex operations
|
||||
|
||||
Task microagents are a convenient way to store multi-step processes you perform regularly. For instance, you can create a `update_pr_description.md` microagent to automatically generate better pull request descriptions based on code changes.
|
||||
|
||||
Examples structure:
|
||||
|
||||
```
|
||||
your-repository/
|
||||
└── .openhands/
|
||||
└── microagents/
|
||||
└── tasks/
|
||||
└── update_pr_description.md
|
||||
└── address_pr_comments.md
|
||||
└── get_test_to_pass.md
|
||||
└── ...
|
||||
└── knowledge/
|
||||
└── ...
|
||||
└── repo.md
|
||||
```
|
||||
|
||||
You can find several real examples of `Tasks` microagents in the [offical OpenHands repository](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/tasks)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# Microagents Syntax
|
||||
|
||||
Microagents are defined using markdown files with YAML frontmatter that specify their behavior, triggers, and capabilities.
|
||||
|
||||
Find below a comprehensive description of the frontmatter syntax and other details about how to use each type of microagent available at OpenHands.
|
||||
|
||||
## Frontmatter Schema
|
||||
|
||||
Every microagent requires a YAML frontmatter section at the beginning of the file, enclosed by triple dashes (`---`). The fields are:
|
||||
|
||||
| Field | Description | Required | Used By |
|
||||
| ---------- | -------------------------------------------------- | ------------------------ | ---------------- |
|
||||
| `name` | Unique identifier for the microagent | Yes | All types |
|
||||
| `type` | Type of microagent: `repo`, `knowledge`, or `task` | Yes | All types |
|
||||
| `version` | Version number (Semantic versioning recommended) | Yes | All types |
|
||||
| `agent` | The agent type (typically `CodeActAgent`) | Yes | All types |
|
||||
| `author` | Creator of the microagent | No | All types |
|
||||
| `triggers` | List of keywords that activate the microagent | Yes for knowledge agents | Knowledge agents |
|
||||
| `inputs` | Defines required user inputs for task execution | Yes for task agents | Task agents |
|
||||
|
||||
## Core Fields
|
||||
|
||||
### `agent`
|
||||
|
||||
**Purpose**: Specifies which agent implementation processes the microagent (typically `CodeActAgent`).
|
||||
|
||||
- Defines a single agent responsible for processing the microagent
|
||||
- Must be available in the OpenHands system (see the [agent hub](https://github.com/All-Hands-AI/OpenHands/tree/main/openhands/agenthub))
|
||||
- If the specified agent is not active, the microagent will not be used
|
||||
|
||||
### `triggers`
|
||||
|
||||
**Purpose**: Defines keywords that activate the `knowledge` microagent.
|
||||
|
||||
**Example**:
|
||||
|
||||
```yaml
|
||||
triggers:
|
||||
- kubernetes
|
||||
- k8s
|
||||
- docker
|
||||
- security
|
||||
- containers cluster
|
||||
```
|
||||
|
||||
**Key points**:
|
||||
|
||||
- Can include both single words and multi-word phrases
|
||||
- Case-insensitive matching is typically used
|
||||
- More specific triggers (like "docker compose") prevent false activations
|
||||
- Multiple triggers increase the chance of activation in relevant contexts
|
||||
- Unique triggers like "flarglebargle" can be used for testing or special functionality
|
||||
- Triggers should be carefully chosen to avoid unwanted activations or conflicts with other microagents
|
||||
- Common terms used in many conversations may cause the microagent to be activated too frequently
|
||||
|
||||
When using multiple triggers, the microagent will be activated if any of the trigger words or phrases appear in the
|
||||
conversation.
|
||||
|
||||
### `inputs`
|
||||
|
||||
**Purpose**: Defines parameters required from the user when a `task` microagent is activated.
|
||||
|
||||
**Schema**:
|
||||
|
||||
```yaml
|
||||
inputs:
|
||||
- name: INPUT_NAME # Used with {{ INPUT_NAME }}
|
||||
description: 'Description of what this input is for'
|
||||
required: true # Optional, defaults to true
|
||||
```
|
||||
|
||||
**Key points**:
|
||||
|
||||
- The `name` and `description` properties are required for each input
|
||||
- The `required` property is optional and defaults to `true`
|
||||
- Input values are referenced in the microagent body using double curly braces (e.g., `{{ INPUT_NAME }}`)
|
||||
- All inputs defined will be collected from the user before the task microagent executes
|
||||
|
||||
**Variable Usage**: Reference input values using double curly braces `{{ INPUT_NAME }}`.
|
||||
|
||||
## Example Formats
|
||||
|
||||
### Repository Microagent
|
||||
|
||||
Repository microagents provide context and guidelines for a specific repository.
|
||||
|
||||
- Located at: `.openhands/microagents/repo.md`
|
||||
- Automatically loaded when working with the repository
|
||||
- Only one per repository
|
||||
|
||||
The `Repository` microagent is loaded specifically from `.openhands/microagents/repo.md` and serves as the main
|
||||
repository-specific instruction file. This single file is automatically loaded whenever OpenHands works with that repository
|
||||
without requiring any keyword matching or explicit call from the user.
|
||||
|
||||
[See the example in the official OpenHands repository](https://github.com/All-Hands-AI/OpenHands/blob/main/.openhands/microagents/repo.md?plain=1)
|
||||
|
||||
### Knowledge Microagent
|
||||
|
||||
Provides specialized domain expertise triggered by keywords.
|
||||
|
||||
You can find several real examples of `Knowledge` microagents in the [offical OpenHands repository](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge)
|
||||
|
||||
### Task Microagent
|
||||
|
||||
When explicitly asked by the user, will guide through interactive workflows with specific inputs.
|
||||
|
||||
You can find several real examples of `Tasks` microagents in the [offical OpenHands repository](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/tasks)
|
||||
|
||||
## Markdown Content Best Practices
|
||||
|
||||
After the frontmatter, compose the microagent body using Markdown syntax. Examples of elements you can include are:
|
||||
|
||||
- Clear, concise instructions outlining the microagent's purpose and responsibilities
|
||||
- Specific guidelines and constraints the microagent should adhere to
|
||||
- Relevant code snippets and practical examples to illustrate key points
|
||||
- Step-by-step procedures for task agents, guiding users through workflows
|
||||
|
||||
**Design Tips**:
|
||||
|
||||
- Keep microagents focused with a clear purpose
|
||||
- Provide specific guidelines rather than general advice
|
||||
- Use distinctive triggers for knowledge agents
|
||||
- Keep content concise to minimize context window usage
|
||||
- Break large microagents into smaller, focused ones
|
||||
|
||||
Aim for clarity, brevity, and practicality in your writing. Use formatting like bullet points, code blocks, and emphasis to enhance readability and comprehension.
|
||||
|
||||
Remember that balancing microagents details with user input space is important for maintaining effective interactions.
|
||||
+8
-3
@@ -1,7 +1,7 @@
|
||||
import type { SidebarsConfig } from "@docusaurus/plugin-content-docs";
|
||||
import type { SidebarsConfig } from '@docusaurus/plugin-content-docs';
|
||||
|
||||
const sidebars: SidebarsConfig = {
|
||||
apiSidebar: [require("./modules/python/sidebar.json")],
|
||||
apiSidebar: [require('./modules/python/sidebar.json')],
|
||||
docsSidebar: [
|
||||
{
|
||||
type: 'doc',
|
||||
@@ -38,7 +38,7 @@ const sidebars: SidebarsConfig = {
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
label: 'Repository',
|
||||
label: 'Repository-specific',
|
||||
id: 'usage/prompting/microagents-repo',
|
||||
},
|
||||
{
|
||||
@@ -46,6 +46,11 @@ const sidebars: SidebarsConfig = {
|
||||
label: 'Public',
|
||||
id: 'usage/prompting/microagents-public',
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
label: 'Syntax',
|
||||
id: 'usage/prompting/microagents-syntax',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -56,7 +56,7 @@ def translate_content(content, target_lang):
|
||||
system_prompt = f'You are a professional translator. Translate the following content into {target_lang}. Preserve all Markdown formatting, code blocks, and front matter. Keep any {{% jsx %}} tags and similar intact. Do not translate code examples, URLs, or technical terms.'
|
||||
|
||||
message = client.messages.create(
|
||||
model='claude-3-opus-20240229',
|
||||
model='claude-3-7-sonnet-20250219',
|
||||
max_tokens=4096,
|
||||
temperature=0,
|
||||
system=system_prompt,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
import httpx
|
||||
import openai
|
||||
import requests.exceptions
|
||||
from openai import OpenAI
|
||||
from retry import retry
|
||||
|
||||
@@ -101,7 +101,7 @@ class Q20Game:
|
||||
@retry(
|
||||
(
|
||||
openai.Timeout,
|
||||
requests.exceptions.ReadTimeout,
|
||||
httpx.TimeoutException,
|
||||
openai.RateLimitError,
|
||||
openai.APIError,
|
||||
openai.APIConnectionError,
|
||||
@@ -161,7 +161,7 @@ class Q20GameCelebrity(Q20Game):
|
||||
@retry(
|
||||
(
|
||||
openai.Timeout,
|
||||
requests.exceptions.ReadTimeout,
|
||||
httpx.TimeoutException,
|
||||
openai.RateLimitError,
|
||||
openai.APIError,
|
||||
openai.APIConnectionError,
|
||||
|
||||
@@ -2,8 +2,8 @@ import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
import httpx
|
||||
import pandas as pd
|
||||
import requests
|
||||
|
||||
from evaluation.benchmarks.gorilla.utils import encode_question, get_data_for_hub
|
||||
from evaluation.utils.shared import (
|
||||
@@ -182,7 +182,7 @@ if __name__ == '__main__':
|
||||
# Check if the file exists
|
||||
if not os.path.exists(file_path):
|
||||
url = 'https://raw.githubusercontent.com/ShishirPatil/gorilla/main/eval/eval-scripts/codebleu/parser/my-languages.so'
|
||||
response = requests.get(url)
|
||||
response = httpx.get(url)
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(response.content)
|
||||
else:
|
||||
|
||||
@@ -2,8 +2,8 @@ import json
|
||||
import os
|
||||
from functools import partial
|
||||
|
||||
import httpx
|
||||
import pandas as pd
|
||||
import requests
|
||||
from ast_eval_hf import ast_eval_hf, ast_parse
|
||||
from ast_eval_tf import ast_eval_tf
|
||||
from ast_eval_th import ast_eval_th
|
||||
@@ -60,7 +60,7 @@ def fetch_data(url, filename):
|
||||
with open(cache_path, 'r') as f:
|
||||
return f.read()
|
||||
else:
|
||||
response = requests.get(url)
|
||||
response = httpx.get(url)
|
||||
if response.status_code == 200:
|
||||
with open(cache_path, 'w') as f:
|
||||
f.write(response.text)
|
||||
|
||||
@@ -4,7 +4,7 @@ import re
|
||||
import string
|
||||
import zipfile
|
||||
|
||||
import requests
|
||||
import httpx
|
||||
|
||||
|
||||
def download_data(dir):
|
||||
@@ -40,7 +40,7 @@ def download_tools(dir, wolfram_alpha_appid='YOUR_WOLFRAMALPHA_APPID'):
|
||||
]
|
||||
for tool in tools:
|
||||
url = f'https://raw.githubusercontent.com/night-chen/ToolQA/main/benchmark/ReAct/code/tools/{tool}'
|
||||
response = requests.get(url)
|
||||
response = httpx.get(url)
|
||||
output_file = os.path.join(tool_path, tool.split('/')[1])
|
||||
with open(output_file, 'wb') as f:
|
||||
f.write(response.content)
|
||||
@@ -82,7 +82,7 @@ def get_data(dataset, hardness):
|
||||
)
|
||||
data = []
|
||||
url = f'https://raw.githubusercontent.com/night-chen/ToolQA/main/data/questions/{hardness}/{dataset}-{hardness}.jsonl'
|
||||
url = requests.get(url)
|
||||
url = httpx.get(url)
|
||||
if url.status_code == 200:
|
||||
lines = url.text.splitlines()
|
||||
for line in lines:
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
VITE_BACKEND_BASE_URL="localhost:3000" # Backend URL without protocol (e.g. localhost:3000)
|
||||
VITE_MOCK_API="false" # true or false
|
||||
VITE_BACKEND_HOST="127.0.0.1:3000" # Backend host with port for API connections
|
||||
VITE_MOCK_API="false" # Enable/disable API mocking with MSW (true or false)
|
||||
VITE_MOCK_SAAS="false" # Simulate SaaS mode in development (true or false)
|
||||
VITE_USE_TLS="false" # Use HTTPS/WSS for backend connections (true or false)
|
||||
VITE_FRONTEND_PORT="3001" # Port to run the frontend application
|
||||
VITE_INSECURE_SKIP_VERIFY="false" # Skip TLS certificate verification (true or false)
|
||||
# VITE_GITHUB_TOKEN="" # GitHub token for repository access (used in some tests)
|
||||
|
||||
+14
-1
@@ -79,7 +79,20 @@ npm run dev:mock or npm run dev:mock:saas
|
||||
|
||||
### Environment Variables
|
||||
|
||||
TODO
|
||||
The frontend application uses the following environment variables:
|
||||
|
||||
| Variable | Description | Default Value |
|
||||
| --------------------------- | ---------------------------------------------------------------------- | ---------------- |
|
||||
| `VITE_BACKEND_BASE_URL` | The backend hostname without protocol (used for WebSocket connections) | `localhost:3000` |
|
||||
| `VITE_BACKEND_HOST` | The backend host with port for API connections | `127.0.0.1:3000` |
|
||||
| `VITE_MOCK_API` | Enable/disable API mocking with MSW | `false` |
|
||||
| `VITE_MOCK_SAAS` | Simulate SaaS mode in development | `false` |
|
||||
| `VITE_USE_TLS` | Use HTTPS/WSS for backend connections | `false` |
|
||||
| `VITE_FRONTEND_PORT` | Port to run the frontend application | `3001` |
|
||||
| `VITE_INSECURE_SKIP_VERIFY` | Skip TLS certificate verification | `false` |
|
||||
| `VITE_GITHUB_TOKEN` | GitHub token for repository access (used in some tests) | - |
|
||||
|
||||
You can create a `.env` file in the frontend directory with these variables based on the `.env.sample` file.
|
||||
|
||||
### Project Structure
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ActionSuggestions } from "#/components/features/chat/action-suggestions";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("posthog-js", () => ({
|
||||
default: {
|
||||
capture: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("react-redux", () => ({
|
||||
useSelector: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("#/context/auth-context", () => ({
|
||||
useAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("ActionSuggestions", () => {
|
||||
// Setup mocks for each test
|
||||
vi.clearAllMocks();
|
||||
|
||||
(useAuth as any).mockReturnValue({
|
||||
githubTokenIsSet: true,
|
||||
});
|
||||
|
||||
(useSelector as any).mockReturnValue({
|
||||
selectedRepository: "test-repo",
|
||||
});
|
||||
|
||||
it("should render both GitHub buttons when GitHub token is set and repository is selected", () => {
|
||||
render(<ActionSuggestions onSuggestionsClick={() => {}} />);
|
||||
|
||||
const pushButton = screen.getByRole("button", { name: "Push to Branch" });
|
||||
const prButton = screen.getByRole("button", { name: "Push & Create PR" });
|
||||
|
||||
expect(pushButton).toBeInTheDocument();
|
||||
expect(prButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render buttons when GitHub token is not set", () => {
|
||||
(useAuth as any).mockReturnValue({
|
||||
githubTokenIsSet: false,
|
||||
});
|
||||
|
||||
render(<ActionSuggestions onSuggestionsClick={() => {}} />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Push to Branch" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Push & Create PR" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render buttons when no repository is selected", () => {
|
||||
(useSelector as any).mockReturnValue({
|
||||
selectedRepository: null,
|
||||
});
|
||||
|
||||
render(<ActionSuggestions onSuggestionsClick={() => {}} />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Push to Branch" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Push & Create PR" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have different prompts for 'Push to Branch' and 'Push & Create PR' buttons", () => {
|
||||
// This test verifies that the prompts are different in the component
|
||||
const component = render(<ActionSuggestions onSuggestionsClick={() => {}} />);
|
||||
|
||||
// Get the component instance to access the internal values
|
||||
const pushBranchPrompt = "Please push the changes to a remote branch on GitHub, but do NOT create a pull request. Please use the exact SAME branch name as the one you are currently on.";
|
||||
const createPRPrompt = "Please push the changes to GitHub and open a pull request. Please create a meaningful branch name that describes the changes.";
|
||||
|
||||
// Verify the prompts are different
|
||||
expect(pushBranchPrompt).not.toEqual(createPRPrompt);
|
||||
|
||||
// Verify the PR prompt mentions creating a meaningful branch name
|
||||
expect(createPRPrompt).toContain("meaningful branch name");
|
||||
expect(createPRPrompt).not.toContain("SAME branch name");
|
||||
});
|
||||
});
|
||||
@@ -1,47 +1,51 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as ChatSlice from "#/state/chat-slice";
|
||||
import {
|
||||
updateStatusWhenErrorMessagePresent,
|
||||
WsClientProvider,
|
||||
useWsClient,
|
||||
} from "#/context/ws-client-provider";
|
||||
import React from "react";
|
||||
|
||||
describe("Propagate error message", () => {
|
||||
it("should do nothing when no message was passed from server", () => {
|
||||
const addErrorMessageSpy = vi.spyOn(ChatSlice, "addErrorMessage");
|
||||
updateStatusWhenErrorMessagePresent(null)
|
||||
updateStatusWhenErrorMessagePresent(undefined)
|
||||
updateStatusWhenErrorMessagePresent({})
|
||||
updateStatusWhenErrorMessagePresent({message: null})
|
||||
updateStatusWhenErrorMessagePresent(null);
|
||||
updateStatusWhenErrorMessagePresent(undefined);
|
||||
updateStatusWhenErrorMessagePresent({});
|
||||
updateStatusWhenErrorMessagePresent({ message: null });
|
||||
|
||||
expect(addErrorMessageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should display error to user when present", () => {
|
||||
const message = "We have a problem!"
|
||||
const addErrorMessageSpy = vi.spyOn(ChatSlice, "addErrorMessage")
|
||||
updateStatusWhenErrorMessagePresent({message})
|
||||
const message = "We have a problem!";
|
||||
const addErrorMessageSpy = vi.spyOn(ChatSlice, "addErrorMessage");
|
||||
updateStatusWhenErrorMessagePresent({ message });
|
||||
|
||||
expect(addErrorMessageSpy).toHaveBeenCalledWith({
|
||||
message,
|
||||
status_update: true,
|
||||
type: 'error'
|
||||
});
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
|
||||
it("should display error including translation id when present", () => {
|
||||
const message = "We have a problem!"
|
||||
const addErrorMessageSpy = vi.spyOn(ChatSlice, "addErrorMessage")
|
||||
updateStatusWhenErrorMessagePresent({message, data: {msg_id: '..id..'}})
|
||||
const message = "We have a problem!";
|
||||
const addErrorMessageSpy = vi.spyOn(ChatSlice, "addErrorMessage");
|
||||
updateStatusWhenErrorMessagePresent({
|
||||
message,
|
||||
data: { msg_id: "..id.." },
|
||||
});
|
||||
|
||||
expect(addErrorMessageSpy).toHaveBeenCalledWith({
|
||||
message,
|
||||
id: '..id..',
|
||||
id: "..id..",
|
||||
status_update: true,
|
||||
type: 'error'
|
||||
});
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,24 +55,22 @@ const mockOn = vi.fn();
|
||||
const mockOff = vi.fn();
|
||||
const mockDisconnect = vi.fn();
|
||||
|
||||
vi.mock("socket.io-client", () => {
|
||||
return {
|
||||
io: vi.fn(() => ({
|
||||
emit: mockEmit,
|
||||
on: mockOn,
|
||||
off: mockOff,
|
||||
disconnect: mockDisconnect,
|
||||
io: {
|
||||
opts: {
|
||||
query: {},
|
||||
},
|
||||
vi.mock("socket.io-client", () => ({
|
||||
io: vi.fn(() => ({
|
||||
emit: mockEmit,
|
||||
on: mockOn,
|
||||
off: mockOff,
|
||||
disconnect: mockDisconnect,
|
||||
io: {
|
||||
opts: {
|
||||
query: {},
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock component to test the hook
|
||||
const TestComponent = () => {
|
||||
function TestComponent() {
|
||||
const { send } = useWsClient();
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -77,7 +79,7 @@ const TestComponent = () => {
|
||||
}, [send]);
|
||||
|
||||
return <div>Test Component</div>;
|
||||
};
|
||||
}
|
||||
|
||||
describe("WsClientProvider", () => {
|
||||
beforeEach(() => {
|
||||
@@ -85,18 +87,27 @@ describe("WsClientProvider", () => {
|
||||
});
|
||||
|
||||
it("should emit oh_user_action event when send is called", async () => {
|
||||
const { getByText } = render(
|
||||
<WsClientProvider conversationId="test-conversation-id">
|
||||
<TestComponent />
|
||||
</WsClientProvider>
|
||||
);
|
||||
const { getByText } = render(<TestComponent />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<WsClientProvider conversationId="test-conversation-id">
|
||||
{children}
|
||||
</WsClientProvider>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(getByText("Test Component")).toBeInTheDocument();
|
||||
|
||||
// Wait for the emit call to happen (useEffect needs time to run)
|
||||
await waitFor(() => {
|
||||
expect(mockEmit).toHaveBeenCalledWith("oh_user_action", { type: "test_event" });
|
||||
}, { timeout: 1000 });
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mockEmit).toHaveBeenCalledWith("oh_user_action", {
|
||||
type: "test_event",
|
||||
});
|
||||
},
|
||||
{ timeout: 1000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ import { AuthProvider } from "#/context/auth-context";
|
||||
import SettingsScreen from "#/routes/settings";
|
||||
import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import { PostApiSettings } from "#/types/settings";
|
||||
import * as ConsentHandlers from "#/utils/handle-capture-consent";
|
||||
import AccountSettings from "#/routes/account-settings";
|
||||
|
||||
@@ -20,6 +19,7 @@ const toggleAdvancedSettings = async (user: UserEvent) => {
|
||||
describe("Settings Screen", () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
const resetSettingsSpy = vi.spyOn(OpenHands, "resetSettings");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
|
||||
const { handleLogoutMock } = vi.hoisted(() => ({
|
||||
@@ -583,6 +583,11 @@ describe("Settings Screen", () => {
|
||||
|
||||
test("resetting settings with no changes but having advanced enabled should hide the advanced items", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
getSettingsSpy.mockResolvedValueOnce({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await toggleAdvancedSettings(user);
|
||||
@@ -594,6 +599,15 @@ describe("Settings Screen", () => {
|
||||
const modal = await screen.findByTestId("reset-modal");
|
||||
expect(modal).toBeInTheDocument();
|
||||
|
||||
// Mock the settings that will be returned after reset
|
||||
// This should be the default settings with no advanced settings enabled
|
||||
getSettingsSpy.mockResolvedValueOnce({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_base_url: "",
|
||||
confirmation_mode: false,
|
||||
security_analyzer: "",
|
||||
});
|
||||
|
||||
// confirm reset
|
||||
const confirmButton = within(modal).getByText("Reset");
|
||||
await user.click(confirmButton);
|
||||
@@ -817,19 +831,27 @@ describe("Settings Screen", () => {
|
||||
const confirmButton = within(modal).getByText("Reset");
|
||||
await user.click(confirmButton);
|
||||
|
||||
const mockCopy: Partial<PostApiSettings> = {
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
};
|
||||
delete mockCopy.github_token_is_set;
|
||||
delete mockCopy.unset_github_token;
|
||||
delete mockCopy.user_consents_to_analytics;
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
...mockCopy,
|
||||
provider_tokens: undefined, // not set
|
||||
llm_api_key: "", // reset as well
|
||||
await waitFor(() => {
|
||||
expect(resetSettingsSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Mock the settings response after reset
|
||||
getSettingsSpy.mockResolvedValueOnce({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_base_url: "",
|
||||
confirmation_mode: false,
|
||||
security_analyzer: "",
|
||||
});
|
||||
|
||||
// Wait for the mutation to complete and the modal to be removed
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("reset-modal")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("llm-custom-model-input")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("base-url-input")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("agent-input")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("security-analyzer-input")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("enable-confirmation-mode-switch")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId("reset-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should cancel the reset when the 'Cancel' button is clicked", async () => {
|
||||
@@ -887,32 +909,6 @@ describe("Settings Screen", () => {
|
||||
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("should not reset analytics consent when resetting to defaults", async () => {
|
||||
const user = userEvent.setup();
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
user_consents_to_analytics: true,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const analyticsConsentInput = await screen.findByTestId(
|
||||
"enable-analytics-switch",
|
||||
);
|
||||
expect(analyticsConsentInput).toBeChecked();
|
||||
|
||||
const resetButton = await screen.findByText("Reset to defaults");
|
||||
await user.click(resetButton);
|
||||
|
||||
const modal = await screen.findByTestId("reset-modal");
|
||||
const confirmButton = within(modal).getByText("Reset");
|
||||
await user.click(confirmButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ user_consents_to_analytics: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should render the security analyzer input if the confirmation mode is enabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsScreen();
|
||||
@@ -1094,14 +1090,8 @@ describe("Settings Screen", () => {
|
||||
const modal = await screen.findByTestId("reset-modal");
|
||||
const confirmButton = within(modal).getByText("Reset");
|
||||
await user.click(confirmButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
llm_api_key: undefined,
|
||||
llm_base_url: undefined,
|
||||
llm_model: undefined,
|
||||
}),
|
||||
);
|
||||
expect(saveSettingsSpy).not.toHaveBeenCalled();
|
||||
expect(resetSettingsSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+1856
-2282
File diff suppressed because it is too large
Load Diff
+26
-26
@@ -1,35 +1,35 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.29.1",
|
||||
"version": "0.30.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroui/react": "2.7.4",
|
||||
"@heroui/react": "2.7.5",
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@react-router/node": "^7.3.0",
|
||||
"@react-router/serve": "^7.3.0",
|
||||
"@react-router/node": "^7.4.0",
|
||||
"@react-router/serve": "^7.4.0",
|
||||
"@react-types/shared": "^3.28.0",
|
||||
"@reduxjs/toolkit": "^2.6.0",
|
||||
"@stripe/react-stripe-js": "^3.3.0",
|
||||
"@stripe/stripe-js": "^5.10.0",
|
||||
"@tanstack/react-query": "^5.67.2",
|
||||
"@reduxjs/toolkit": "^2.6.1",
|
||||
"@stripe/react-stripe-js": "^3.5.1",
|
||||
"@stripe/stripe-js": "^6.1.0",
|
||||
"@tanstack/react-query": "^5.69.0",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"axios": "^1.8.2",
|
||||
"axios": "^1.8.4",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.4.10",
|
||||
"i18next": "^24.2.2",
|
||||
"framer-motion": "^12.6.2",
|
||||
"i18next": "^24.2.3",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.23",
|
||||
"jose": "^6.0.8",
|
||||
"isbot": "^5.1.25",
|
||||
"jose": "^6.0.10",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.229.3",
|
||||
"posthog-js": "^1.233.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -38,14 +38,14 @@
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.3.0",
|
||||
"react-router": "^7.4.0",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-textarea-autosize": "^8.5.7",
|
||||
"react-textarea-autosize": "^8.5.8",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sirv-cli": "^3.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"vite": "^6.2.1",
|
||||
"vite": "^6.2.3",
|
||||
"web-vitals": "^3.5.2",
|
||||
"ws": "^8.18.1"
|
||||
},
|
||||
@@ -80,24 +80,24 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mswjs/socket.io-binding": "^0.1.1",
|
||||
"@playwright/test": "^1.51.0",
|
||||
"@react-router/dev": "^7.3.0",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@react-router/dev": "^7.4.0",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/eslint-plugin-query": "^5.67.2",
|
||||
"@tanstack/eslint-plugin-query": "^5.68.0",
|
||||
"@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.1",
|
||||
"@types/node": "^22.13.9",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/node": "^22.13.14",
|
||||
"@types/react": "^19.0.12",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/ws": "^8.18.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitest/coverage-v8": "^3.0.8",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"@vitest/coverage-v8": "^3.0.9",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
@@ -105,12 +105,12 @@
|
||||
"eslint-config-prettier": "^10.1.1",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.2.3",
|
||||
"eslint-plugin-prettier": "^5.2.5",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"husky": "^9.1.6",
|
||||
"jsdom": "^26.0.0",
|
||||
"lint-staged": "^15.4.3",
|
||||
"lint-staged": "^15.5.0",
|
||||
"msw": "^2.6.6",
|
||||
"postcss": "^8.5.2",
|
||||
"prettier": "^3.5.3",
|
||||
|
||||
@@ -273,6 +273,14 @@ class OpenHands {
|
||||
return data.status === 200;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset user settings in server
|
||||
*/
|
||||
static async resetSettings(): Promise<boolean> {
|
||||
const response = await openHands.post("/api/reset-settings");
|
||||
return response.status === 200;
|
||||
}
|
||||
|
||||
static async createCheckoutSession(amount: number): Promise<string> {
|
||||
const { data } = await openHands.post(
|
||||
"/api/billing/create-checkout-session",
|
||||
@@ -345,8 +353,10 @@ class OpenHands {
|
||||
return data;
|
||||
}
|
||||
|
||||
static async logout(): Promise<void> {
|
||||
await openHands.post("/api/logout");
|
||||
static async logout(appMode: GetConfigResponse["APP_MODE"]): Promise<void> {
|
||||
const endpoint =
|
||||
appMode === "saas" ? "/api/logout" : "/api/unset-settings-tokens";
|
||||
await openHands.post(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
|
||||
enum IndicatorColor {
|
||||
export enum IndicatorColor {
|
||||
BLUE = "bg-blue-500",
|
||||
GREEN = "bg-green-500",
|
||||
ORANGE = "bg-orange-500",
|
||||
|
||||
@@ -40,7 +40,7 @@ export function ActionSuggestions({
|
||||
suggestion={{
|
||||
label: "Push & Create PR",
|
||||
value:
|
||||
"Please push the changes to GitHub and open a pull request. Please use the exact SAME branch name as the one you are currently on.",
|
||||
"Please push the changes to GitHub and open a pull request. Please create a meaningful branch name that describes the changes.",
|
||||
}}
|
||||
onClick={(value) => {
|
||||
posthog.capture("create_pr_button_clicked");
|
||||
|
||||
@@ -10,7 +10,6 @@ import { addUserMessage } from "#/state/chat-slice";
|
||||
import { RootState } from "#/store";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
|
||||
import { getStopProcessesCommand } from "#/services/terminal-service";
|
||||
import { FeedbackModal } from "../feedback/feedback-modal";
|
||||
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
|
||||
import { TypingIndicator } from "./typing-indicator";
|
||||
@@ -83,8 +82,7 @@ export function ChatInterface() {
|
||||
|
||||
const handleStop = () => {
|
||||
posthog.capture("stop_button_clicked");
|
||||
send(getStopProcessesCommand()); // First kill all processes
|
||||
send(generateAgentStateChangeEvent(AgentState.STOPPED)); // Then change agent state
|
||||
send(generateAgentStateChangeEvent(AgentState.STOPPED));
|
||||
};
|
||||
|
||||
const onClickShareFeedbackActionButton = async (
|
||||
|
||||
@@ -22,7 +22,7 @@ export function AccountSettingsContextMenu({
|
||||
<ContextMenu
|
||||
testId="account-settings-context-menu"
|
||||
ref={ref}
|
||||
className="absolute right-full md:left-full -top-1 z-10"
|
||||
className="absolute right-full md:left-full -top-1 z-10 w-fit"
|
||||
>
|
||||
<ContextMenuListItem onClick={onLogout} isDisabled={!isLoggedIn}>
|
||||
{t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)}
|
||||
|
||||
@@ -4,7 +4,10 @@ import { useSelector } from "react-redux";
|
||||
import { showErrorToast } from "#/utils/error-handler";
|
||||
import { RootState } from "#/store";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { AGENT_STATUS_MAP } from "../../agent-status-map.constant";
|
||||
import {
|
||||
AGENT_STATUS_MAP,
|
||||
IndicatorColor,
|
||||
} from "../../agent-status-map.constant";
|
||||
import {
|
||||
useWsClient,
|
||||
WsClientProviderStatus,
|
||||
@@ -69,11 +72,17 @@ export function AgentStatusBar() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [indicatorColor, setIndicatorColor] = React.useState<string>(
|
||||
AGENT_STATUS_MAP[curAgentState].indicator,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (status === WsClientProviderStatus.DISCONNECTED) {
|
||||
setStatusMessage("Connecting...");
|
||||
setIndicatorColor(IndicatorColor.RED);
|
||||
} else {
|
||||
setStatusMessage(AGENT_STATUS_MAP[curAgentState].message);
|
||||
setIndicatorColor(AGENT_STATUS_MAP[curAgentState].indicator);
|
||||
if (notificationStates.includes(curAgentState)) {
|
||||
const message = t(AGENT_STATUS_MAP[curAgentState].message);
|
||||
notify(t(AGENT_STATUS_MAP[curAgentState].message), {
|
||||
@@ -87,13 +96,13 @@ export function AgentStatusBar() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [curAgentState, notify, t]);
|
||||
}, [curAgentState, status, notify, t]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center bg-base-secondary px-2 py-1 text-gray-400 rounded-[100px] text-sm gap-[6px]">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full animate-pulse ${AGENT_STATUS_MAP[curAgentState].indicator}`}
|
||||
className={`w-2 h-2 rounded-full animate-pulse ${indicatorColor}`}
|
||||
/>
|
||||
<span className="text-sm text-stone-400">{t(statusMessage)}</span>
|
||||
</div>
|
||||
|
||||
@@ -240,22 +240,67 @@ export function ConversationCard({
|
||||
title="Metrics Information"
|
||||
testID="metrics-modal"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{metrics?.cost !== null && (
|
||||
<p>Total Cost: ${metrics.cost.toFixed(4)}</p>
|
||||
)}
|
||||
{metrics?.usage !== null && (
|
||||
<>
|
||||
<p>Tokens Used:</p>
|
||||
<ul className="list-inside space-y-1 ml-2">
|
||||
<li>- Input: {metrics.usage.prompt_tokens}</li>
|
||||
<li>- Output: {metrics.usage.completion_tokens}</li>
|
||||
<li>- Total: {metrics.usage.total_tokens}</li>
|
||||
</ul>
|
||||
</>
|
||||
<div className="space-y-4">
|
||||
{(metrics?.cost !== null || metrics?.usage !== null) && (
|
||||
<div className="rounded-md p-3">
|
||||
<div className="grid gap-3">
|
||||
{metrics?.cost !== null && (
|
||||
<div className="flex justify-between items-center border-b border-neutral-700 pb-2">
|
||||
<span className="text-lg font-semibold">
|
||||
Total Cost (USD):
|
||||
</span>
|
||||
<span className="font-semibold">
|
||||
${metrics.cost.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metrics?.usage !== null && (
|
||||
<>
|
||||
<div className="flex justify-between items-center pb-2">
|
||||
<span>Total Input Tokens:</span>
|
||||
<span className="font-semibold">
|
||||
{metrics.usage.prompt_tokens.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 pl-4 text-sm">
|
||||
<span className="text-neutral-400">Cache Hit:</span>
|
||||
<span className="text-right">
|
||||
{metrics.usage.cache_read_tokens.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-neutral-400">Cache Write:</span>
|
||||
<span className="text-right">
|
||||
{metrics.usage.cache_write_tokens.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center border-b border-neutral-700 pb-2">
|
||||
<span>Total Output Tokens:</span>
|
||||
<span className="font-semibold">
|
||||
{metrics.usage.completion_tokens.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-1">
|
||||
<span className="font-semibold">Total Tokens:</span>
|
||||
<span className="font-bold">
|
||||
{(
|
||||
metrics.usage.prompt_tokens +
|
||||
metrics.usage.completion_tokens
|
||||
).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!metrics?.cost && !metrics?.usage && (
|
||||
<p className="text-neutral-400">No metrics data available</p>
|
||||
<div className="rounded-md p-4 text-center">
|
||||
<p className="text-neutral-400">No metrics data available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BaseModal>
|
||||
|
||||
@@ -64,7 +64,7 @@ export function PaymentForm() {
|
||||
onChange={handleTopUpInputChange}
|
||||
type="text"
|
||||
label="Add funds"
|
||||
placeholder="Specify an amount (USD) to add to your account"
|
||||
placeholder="Specify an amount in USD to add - min $10"
|
||||
className="w-[680px]"
|
||||
/>
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import { useLogout } from "#/hooks/mutation/use-logout";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
|
||||
export function Sidebar() {
|
||||
const location = useLocation();
|
||||
@@ -36,7 +35,6 @@ export function Sidebar() {
|
||||
isFetching: isFetchingSettings,
|
||||
} = useSettings();
|
||||
const { mutateAsync: logout } = useLogout();
|
||||
const { mutate: saveUserSettings } = useSaveSettings();
|
||||
|
||||
const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false);
|
||||
|
||||
@@ -78,8 +76,7 @@ export function Sidebar() {
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (config?.APP_MODE === "saas") await logout();
|
||||
else saveUserSettings({ unset_github_token: true });
|
||||
await logout();
|
||||
posthog.reset();
|
||||
};
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
|
||||
testId="llm-api-key-help-anchor"
|
||||
text="Don't know your API key?"
|
||||
linkText="Click here for instructions"
|
||||
href="https://docs.all-hands.dev/modules/usage/llms"
|
||||
href="https://docs.all-hands.dev/modules/usage/installation#getting-an-api-key"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ export function updateStatusWhenErrorMessagePresent(data: ErrorArg | unknown) {
|
||||
!!val && typeof val === "object";
|
||||
const isString = (val: unknown): val is string => typeof val === "string";
|
||||
if (isObject(data) && "message" in data && isString(data.message)) {
|
||||
if (data.message === "websocket error") {
|
||||
if (data.message === "websocket error" || data.message === "timeout") {
|
||||
return;
|
||||
}
|
||||
let msgId: string | undefined;
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useConfig } from "../query/use-config";
|
||||
|
||||
export const useLogout = () => {
|
||||
const { setGitHubTokenIsSet } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: OpenHands.logout,
|
||||
onSuccess: async () => {
|
||||
mutationFn: async () => {
|
||||
// Pause all queries that depend on githubTokenIsSet
|
||||
queryClient.setQueryData(["user"], null);
|
||||
|
||||
// Call logout endpoint
|
||||
await OpenHands.logout(config?.APP_MODE ?? "oss");
|
||||
|
||||
// Remove settings from cache so it will be refetched with new token state
|
||||
queryClient.removeQueries({ queryKey: ["settings"] });
|
||||
|
||||
// Update token state - this will trigger a settings refetch since it's part of the query key
|
||||
setGitHubTokenIsSet(false);
|
||||
await queryClient.invalidateQueries();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -4,8 +4,14 @@ import OpenHands from "#/api/open-hands";
|
||||
import { PostSettings, PostApiSettings } from "#/types/settings";
|
||||
import { useSettings } from "../query/use-settings";
|
||||
|
||||
const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
|
||||
const resetLlmApiKey = settings.LLM_API_KEY === "";
|
||||
const saveSettingsMutationFn = async (
|
||||
settings: Partial<PostSettings> | null,
|
||||
) => {
|
||||
// If settings is null, we're resetting
|
||||
if (settings === null) {
|
||||
await OpenHands.resetSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
const apiSettings: Partial<PostApiSettings> = {
|
||||
llm_model: settings.LLM_MODEL,
|
||||
@@ -14,12 +20,12 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
|
||||
language: settings.LANGUAGE || DEFAULT_SETTINGS.LANGUAGE,
|
||||
confirmation_mode: settings.CONFIRMATION_MODE,
|
||||
security_analyzer: settings.SECURITY_ANALYZER,
|
||||
llm_api_key: resetLlmApiKey
|
||||
? ""
|
||||
: settings.LLM_API_KEY?.trim() || undefined,
|
||||
llm_api_key:
|
||||
settings.LLM_API_KEY === ""
|
||||
? ""
|
||||
: settings.LLM_API_KEY?.trim() || undefined,
|
||||
remote_runtime_resource_factor: settings.REMOTE_RUNTIME_RESOURCE_FACTOR,
|
||||
provider_tokens: settings.provider_tokens,
|
||||
unset_github_token: settings.unset_github_token,
|
||||
enable_default_condenser: settings.ENABLE_DEFAULT_CONDENSER,
|
||||
enable_sound_notifications: settings.ENABLE_SOUND_NOTIFICATIONS,
|
||||
user_consents_to_analytics: settings.user_consents_to_analytics,
|
||||
@@ -33,20 +39,13 @@ export const useSaveSettings = () => {
|
||||
const { data: currentSettings } = useSettings();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (settings: Partial<PostSettings>) => {
|
||||
const newSettings = { ...currentSettings, ...settings };
|
||||
|
||||
// Temp hack for reset logic
|
||||
if (
|
||||
settings.LLM_API_KEY === undefined &&
|
||||
settings.LLM_BASE_URL === undefined &&
|
||||
settings.LLM_MODEL === undefined
|
||||
) {
|
||||
delete newSettings.LLM_API_KEY;
|
||||
delete newSettings.LLM_BASE_URL;
|
||||
delete newSettings.LLM_MODEL;
|
||||
mutationFn: async (settings: Partial<PostSettings> | null) => {
|
||||
if (settings === null) {
|
||||
await saveSettingsMutationFn(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const newSettings = { ...currentSettings, ...settings };
|
||||
await saveSettingsMutationFn(newSettings);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
|
||||
@@ -5,13 +5,11 @@ import { useConfig } from "./use-config";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useLogout } from "../mutation/use-logout";
|
||||
import { useSaveSettings } from "../mutation/use-save-settings";
|
||||
|
||||
export const useGitHubUser = () => {
|
||||
const { githubTokenIsSet } = useAuth();
|
||||
const { setGitHubTokenIsSet } = useAuth();
|
||||
const { mutateAsync: logout } = useLogout();
|
||||
const { mutate: saveUserSettings } = useSaveSettings();
|
||||
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const user = useQuery({
|
||||
@@ -36,11 +34,7 @@ export const useGitHubUser = () => {
|
||||
}, [user.data]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (config?.APP_MODE === "saas") await logout();
|
||||
else {
|
||||
saveUserSettings({ unset_github_token: true });
|
||||
setGitHubTokenIsSet(false);
|
||||
}
|
||||
await logout();
|
||||
posthog.reset();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { useLogout } from "./mutation/use-logout";
|
||||
import { useSaveSettings } from "./mutation/use-save-settings";
|
||||
import { useConfig } from "./query/use-config";
|
||||
|
||||
export const useAppLogout = () => {
|
||||
const { data: config } = useConfig();
|
||||
const { mutateAsync: logout } = useLogout();
|
||||
const { mutate: saveUserSettings } = useSaveSettings();
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (config?.APP_MODE === "saas") await logout();
|
||||
else saveUserSettings({ unset_github_token: true });
|
||||
await logout();
|
||||
};
|
||||
|
||||
return { handleLogout };
|
||||
|
||||
@@ -206,11 +206,6 @@ export const handlers = [
|
||||
let newSettings: Partial<PostApiSettings> = {};
|
||||
if (typeof body === "object") {
|
||||
newSettings = { ...body };
|
||||
if (newSettings.unset_github_token) {
|
||||
newSettings.provider_tokens = { github: "", gitlab: "" };
|
||||
newSettings.github_token_is_set = false;
|
||||
delete newSettings.unset_github_token;
|
||||
}
|
||||
}
|
||||
|
||||
const fullSettings = {
|
||||
@@ -306,4 +301,10 @@ export const handlers = [
|
||||
}),
|
||||
|
||||
http.post("/api/logout", () => HttpResponse.json(null, { status: 200 })),
|
||||
|
||||
http.post("/api/reset-settings", async () => {
|
||||
await delay();
|
||||
MOCK_USER_PREFERENCES.settings = { ...MOCK_DEFAULT_USER_SETTINGS };
|
||||
return HttpResponse.json(null, { status: 200 });
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
displayErrorToast,
|
||||
displaySuccessToast,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
import { PostSettings } from "#/types/settings";
|
||||
|
||||
const REMOTE_RUNTIME_OPTIONS = [
|
||||
{ key: 1, label: "1x (2 core, 8G)" },
|
||||
@@ -170,24 +169,11 @@ function AccountSettings() {
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const newSettings: Partial<PostSettings> = {
|
||||
...DEFAULT_SETTINGS,
|
||||
LLM_API_KEY: "", // reset LLM API key
|
||||
};
|
||||
|
||||
// we don't want the user to be able to modify these settings in SaaS
|
||||
// and we should make sure they aren't included in the reset
|
||||
if (shouldHandleSpecialSaasCase) {
|
||||
delete newSettings.LLM_API_KEY;
|
||||
delete newSettings.LLM_BASE_URL;
|
||||
delete newSettings.LLM_MODEL;
|
||||
}
|
||||
|
||||
saveSettings(newSettings, {
|
||||
saveSettings(null, {
|
||||
onSuccess: () => {
|
||||
displaySuccessToast("Settings reset");
|
||||
setResetSettingsModalIsOpen(false);
|
||||
setLlmConfigMode(isAdvancedSettingsSet ? "advanced" : "basic");
|
||||
setLlmConfigMode("basic");
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -297,7 +283,7 @@ function AccountSettings() {
|
||||
testId="llm-api-key-help-anchor"
|
||||
text="Don't know your API key?"
|
||||
linkText="Click here for instructions"
|
||||
href="https://docs.all-hands.dev/modules/usage/llms"
|
||||
href="https://docs.all-hands.dev/modules/usage/installation#getting-an-api-key"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -87,13 +87,10 @@ export function handleActionMessage(message: ActionMessage) {
|
||||
}
|
||||
|
||||
// Update metrics if available
|
||||
if (
|
||||
message.llm_metrics ||
|
||||
message.tool_call_metadata?.model_response?.usage
|
||||
) {
|
||||
if (message.llm_metrics) {
|
||||
const metrics = {
|
||||
cost: message.llm_metrics?.accumulated_cost ?? null,
|
||||
usage: message.tool_call_metadata?.model_response?.usage ?? null,
|
||||
usage: message.llm_metrics?.accumulated_token_usage ?? null,
|
||||
};
|
||||
store.dispatch(setMetrics(metrics));
|
||||
}
|
||||
|
||||
@@ -4,8 +4,3 @@ export function getTerminalCommand(command: string, hidden: boolean = false) {
|
||||
const event = { action: ActionType.RUN, args: { command, hidden } };
|
||||
return event;
|
||||
}
|
||||
|
||||
export function getStopProcessesCommand() {
|
||||
const event = { action: ActionType.RUN, args: { command: "pkill -P $$" } };
|
||||
return event;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ interface MetricsState {
|
||||
usage: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_write_tokens: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,12 @@ export interface ActionMessage {
|
||||
// LLM metrics information
|
||||
llm_metrics?: {
|
||||
accumulated_cost: number;
|
||||
accumulated_token_usage: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_write_tokens: number;
|
||||
};
|
||||
};
|
||||
|
||||
// Tool call metadata
|
||||
|
||||
@@ -35,12 +35,10 @@ export type ApiSettings = {
|
||||
|
||||
export type PostSettings = Settings & {
|
||||
provider_tokens: Record<Provider, string>;
|
||||
unset_github_token: boolean;
|
||||
user_consents_to_analytics: boolean | null;
|
||||
};
|
||||
|
||||
export type PostApiSettings = ApiSettings & {
|
||||
provider_tokens: Record<Provider, string>;
|
||||
unset_github_token: boolean;
|
||||
user_consents_to_analytics: boolean | null;
|
||||
};
|
||||
|
||||
@@ -4,17 +4,17 @@ type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- new agent
|
||||
- new microagent
|
||||
- create agent
|
||||
- create an agent
|
||||
- create microagent
|
||||
- create a microagent
|
||||
- add agent
|
||||
- add an agent
|
||||
- add microagent
|
||||
- add a microagent
|
||||
- microagent template
|
||||
- new agent
|
||||
- new microagent
|
||||
- create agent
|
||||
- create an agent
|
||||
- create microagent
|
||||
- create a microagent
|
||||
- add agent
|
||||
- add an agent
|
||||
- add microagent
|
||||
- add a microagent
|
||||
- microagent template
|
||||
---
|
||||
|
||||
This agent helps create new microagents in the `.openhands/microagents` directory by providing guidance and templates.
|
||||
@@ -22,17 +22,20 @@ This agent helps create new microagents in the `.openhands/microagents` director
|
||||
Microagents are specialized prompts that provide context and capabilities for specific domains or tasks. They are activated by trigger words in the conversation and help the AI assistant understand what capabilities are available, how to use specific APIs or tools, what limitations exist, and how to handle common scenarios.
|
||||
|
||||
When creating a new microagent:
|
||||
* Create a markdown file in `.openhands/microagents/` with an appropriate name (e.g., `github.md`, `google_workspace.md`)
|
||||
* Include YAML frontmatter with metadata (name, type, version, agent, triggers)
|
||||
* type is by DEFAULT knowledge
|
||||
* version is DEFAULT 1.0.0
|
||||
* agent is by DEFAULT CodeActAgent
|
||||
* Document any credentials, environment variables, or API access needed
|
||||
* Keep trigger words specific to avoid false activations
|
||||
* Include error handling guidance and limitations
|
||||
* Provide clear usage examples
|
||||
* Keep the prompt focused and concise
|
||||
|
||||
- Create a markdown file in `.openhands/microagents/` with an appropriate name (e.g., `github.md`, `google_workspace.md`)
|
||||
- Include YAML frontmatter with metadata (name, type, version, agent, triggers)
|
||||
- type is by DEFAULT knowledge
|
||||
- version is DEFAULT 1.0.0
|
||||
- agent is by DEFAULT CodeActAgent
|
||||
- Document any credentials, environment variables, or API access needed
|
||||
- Keep trigger words specific to avoid false activations
|
||||
- Include error handling guidance and limitations
|
||||
- Provide clear usage examples
|
||||
- Keep the prompt focused and concise
|
||||
|
||||
For detailed information, see:
|
||||
* [Microagents Overview](https://docs.all-hands.dev/modules/usage/prompting/microagents-overview)
|
||||
* [Example GitHub Microagent](https://github.com/All-Hands-AI/OpenHands/blob/main/microagents/knowledge/github.md)
|
||||
|
||||
- [Microagents Overview](https://docs.all-hands.dev/modules/usage/prompting/microagents-overview)
|
||||
- [Microagents Syntax](https://docs.all-hands.dev/modules/usage/prompting/microagents-syntax)
|
||||
- [Example GitHub Microagent](https://github.com/All-Hands-AI/OpenHands/blob/main/microagents/knowledge/github.md)
|
||||
|
||||
@@ -11,8 +11,10 @@ from openhands.events.action import (
|
||||
Action,
|
||||
AgentFinishAction,
|
||||
)
|
||||
from openhands.events.event import Event
|
||||
from openhands.llm.llm import LLM
|
||||
from openhands.memory.condenser import Condenser
|
||||
from openhands.memory.condenser.condenser import Condensation, View
|
||||
from openhands.memory.conversation_memory import ConversationMemory
|
||||
from openhands.runtime.plugins import (
|
||||
AgentSkillsRequirement,
|
||||
@@ -92,6 +94,7 @@ class CodeActAgent(Agent):
|
||||
|
||||
def step(self, state: State) -> Action:
|
||||
"""Performs one step using the CodeAct Agent.
|
||||
|
||||
This includes gathering info on previous steps and prompting the model to make a command to execute.
|
||||
|
||||
Parameters:
|
||||
@@ -113,8 +116,23 @@ class CodeActAgent(Agent):
|
||||
if latest_user_message and latest_user_message.content.strip() == '/exit':
|
||||
return AgentFinishAction()
|
||||
|
||||
# prepare what we want to send to the LLM
|
||||
messages = self._get_messages(state)
|
||||
# Condense the events from the state. If we get a view we'll pass those
|
||||
# to the conversation manager for processing, but if we get a condensation
|
||||
# event we'll just return that instead of an action. The controller will
|
||||
# immediately ask the agent to step again with the new view.
|
||||
condensed_history: list[Event] = []
|
||||
match self.condenser.condensed_history(state):
|
||||
case View(events=events):
|
||||
condensed_history = events
|
||||
|
||||
case Condensation(action=condensation_action):
|
||||
return condensation_action
|
||||
|
||||
logger.debug(
|
||||
f'Processing {len(condensed_history)} events from a total of {len(state.history)} events'
|
||||
)
|
||||
|
||||
messages = self._get_messages(condensed_history)
|
||||
params: dict = {
|
||||
'messages': self.llm.format_messages_for_llm(messages),
|
||||
}
|
||||
@@ -127,7 +145,7 @@ class CodeActAgent(Agent):
|
||||
self.pending_actions.append(action)
|
||||
return self.pending_actions.popleft()
|
||||
|
||||
def _get_messages(self, state: State) -> list[Message]:
|
||||
def _get_messages(self, events: list[Event]) -> list[Message]:
|
||||
"""Constructs the message history for the LLM conversation.
|
||||
|
||||
This method builds a structured conversation history by processing events from the state
|
||||
@@ -143,7 +161,7 @@ class CodeActAgent(Agent):
|
||||
6. Adds environment reminders for non-function-calling mode
|
||||
|
||||
Args:
|
||||
state (State): The current state object containing conversation history and other metadata
|
||||
events: The list of events to convert to messages
|
||||
|
||||
Returns:
|
||||
list[Message]: A list of formatted messages ready for LLM consumption, including:
|
||||
@@ -167,13 +185,6 @@ class CodeActAgent(Agent):
|
||||
with_caching=self.llm.is_caching_prompt_active()
|
||||
)
|
||||
|
||||
# Condense the events from the state.
|
||||
events = self.condenser.condensed_history(state)
|
||||
|
||||
logger.debug(
|
||||
f'Processing {len(events)} events from a total of {len(state.history)} events'
|
||||
)
|
||||
|
||||
# Use ConversationMemory to process events
|
||||
messages = self.conversation_memory.process_events(
|
||||
condensed_history=events,
|
||||
|
||||
@@ -215,6 +215,13 @@ def response_to_actions(response: ModelResponse) -> list[Action]:
|
||||
)
|
||||
)
|
||||
|
||||
# Add response id to actions
|
||||
# This will ensure we can match both actions without tool calls (e.g. MessageAction)
|
||||
# and actions with tool calls (e.g. CmdRunAction, IPythonRunCellAction, etc.)
|
||||
# with the token usage data
|
||||
for action in actions:
|
||||
action.response_id = response.id
|
||||
|
||||
assert len(actions) >= 1
|
||||
return actions
|
||||
|
||||
|
||||
@@ -24,5 +24,8 @@ For example, if you are using vite.config.js, you should set server.host and ser
|
||||
{% if runtime_info.additional_agent_instructions %}
|
||||
{{ runtime_info.additional_agent_instructions }}
|
||||
{% endif %}
|
||||
{% if runtime_info.date %}
|
||||
Today's date is {{ runtime_info.date }} (UTC).
|
||||
{% endif %}
|
||||
</RUNTIME_INFORMATION>
|
||||
{% endif %}
|
||||
|
||||
@@ -9,7 +9,7 @@ _FILE_EDIT_DESCRIPTION = """Edit a file in plain-text format.
|
||||
|
||||
**Example 1: general edit for short files**
|
||||
For example, given an existing file `/path/to/file.py` that looks like this:
|
||||
(this is the end of the file)
|
||||
(this is the beginning of the file)
|
||||
1|class MyClass:
|
||||
2| def __init__(self):
|
||||
3| self.x = 1
|
||||
@@ -21,7 +21,7 @@ For example, given an existing file `/path/to/file.py` that looks like this:
|
||||
(this is the end of the file)
|
||||
|
||||
The assistant wants to edit the file to look like this:
|
||||
(this is the end of the file)
|
||||
(this is the beginning of the file)
|
||||
1|class MyClass:
|
||||
2| def __init__(self):
|
||||
3| self.x = 1
|
||||
@@ -45,7 +45,7 @@ print(MyClass().y)
|
||||
|
||||
**Example 2: append to file for short files**
|
||||
For example, given an existing file `/path/to/file.py` that looks like this:
|
||||
(this is the end of the file)
|
||||
(this is the beginning of the file)
|
||||
1|class MyClass:
|
||||
2| def __init__(self):
|
||||
3| self.x = 1
|
||||
|
||||
@@ -54,7 +54,7 @@ from openhands.events.action import (
|
||||
MessageAction,
|
||||
NullAction,
|
||||
)
|
||||
from openhands.events.action.agent import RecallAction
|
||||
from openhands.events.action.agent import CondensationAction, RecallAction
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.observation import (
|
||||
AgentCondensationObservation,
|
||||
@@ -305,6 +305,8 @@ class AgentController:
|
||||
return True
|
||||
if isinstance(event, AgentDelegateAction):
|
||||
return True
|
||||
if isinstance(event, CondensationAction):
|
||||
return True
|
||||
return False
|
||||
if isinstance(event, Observation):
|
||||
if (
|
||||
@@ -479,8 +481,18 @@ class AgentController:
|
||||
|
||||
if self.get_agent_state() != AgentState.RUNNING:
|
||||
await self.set_agent_state_to(AgentState.RUNNING)
|
||||
elif action.source == EventSource.AGENT and action.wait_for_response:
|
||||
await self.set_agent_state_to(AgentState.AWAITING_USER_INPUT)
|
||||
elif action.source == EventSource.AGENT:
|
||||
# Check if we need to trigger microagents based on agent message content
|
||||
recall_action = RecallAction(
|
||||
query=action.content, recall_type=RecallType.KNOWLEDGE
|
||||
)
|
||||
self._pending_action = recall_action
|
||||
# This is source=AGENT because the agent message is the trigger for the microagent retrieval
|
||||
self.event_stream.add_event(recall_action, EventSource.AGENT)
|
||||
|
||||
# If the agent is waiting for a response, set the appropriate state
|
||||
if action.wait_for_response:
|
||||
await self.set_agent_state_to(AgentState.AWAITING_USER_INPUT)
|
||||
|
||||
def _reset(self) -> None:
|
||||
"""Resets the agent controller."""
|
||||
|
||||
@@ -30,7 +30,9 @@ class AgentConfig(BaseModel):
|
||||
disabled_microagents: list[str] = Field(default_factory=list)
|
||||
enable_history_truncation: bool = Field(default=True)
|
||||
enable_som_visual_browsing: bool = Field(default=False)
|
||||
condenser: CondenserConfig = Field(default_factory=NoOpCondenserConfig)
|
||||
condenser: CondenserConfig = Field(
|
||||
default_factory=lambda: NoOpCondenserConfig(type='noop')
|
||||
)
|
||||
|
||||
model_config = {'extra': 'forbid'}
|
||||
|
||||
|
||||
@@ -134,4 +134,5 @@ class AppConfig(BaseModel):
|
||||
def model_post_init(self, __context):
|
||||
"""Post-initialization hook, called when the instance is created with only default values."""
|
||||
super().model_post_init(__context)
|
||||
AppConfig.defaults_dict = model_defaults_to_dict(self)
|
||||
if not AppConfig.defaults_dict: # Only set defaults_dict if it's empty
|
||||
AppConfig.defaults_dict = model_defaults_to_dict(self)
|
||||
|
||||
@@ -200,7 +200,7 @@ def condenser_config_from_toml_section(
|
||||
f'Invalid condenser configuration: {e}. Using NoOpCondenserConfig.'
|
||||
)
|
||||
# Default to NoOpCondenserConfig if config fails
|
||||
config = NoOpCondenserConfig()
|
||||
config = NoOpCondenserConfig(type='noop')
|
||||
condenser_mapping['condenser'] = config
|
||||
|
||||
return condenser_mapping
|
||||
|
||||
@@ -5,7 +5,7 @@ from pydantic import BaseModel
|
||||
from pydantic.fields import FieldInfo
|
||||
|
||||
OH_DEFAULT_AGENT = 'CodeActAgent'
|
||||
OH_MAX_ITERATIONS = 500
|
||||
OH_MAX_ITERATIONS = 250
|
||||
|
||||
|
||||
def get_field_info(field: FieldInfo) -> dict[str, Any]:
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import RootModel
|
||||
|
||||
|
||||
class ExtendedConfig(RootModel[dict]):
|
||||
class ExtendedConfig(RootModel[dict[str, Any]]):
|
||||
"""Configuration for extended functionalities.
|
||||
|
||||
This is implemented as a root model so that the entire input is stored
|
||||
@@ -9,31 +11,30 @@ class ExtendedConfig(RootModel[dict]):
|
||||
accessed via attribute or dictionary-style access.
|
||||
"""
|
||||
|
||||
@property
|
||||
def root(self) -> dict: # type annotation to help mypy
|
||||
return super().root
|
||||
|
||||
def __str__(self) -> str:
|
||||
# Use the root dict to build a string representation.
|
||||
attr_str = [f'{k}={repr(v)}' for k, v in self.root.items()]
|
||||
return f"ExtendedConfig({', '.join(attr_str)})"
|
||||
root_dict: dict[str, Any] = self.model_dump()
|
||||
attr_str = [f'{k}={repr(v)}' for k, v in root_dict.items()]
|
||||
return f'ExtendedConfig({", ".join(attr_str)})'
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.__str__()
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> 'ExtendedConfig':
|
||||
def from_dict(cls, data: dict[str, Any]) -> 'ExtendedConfig':
|
||||
# Create an instance directly by wrapping the input dict.
|
||||
return cls(data)
|
||||
|
||||
def __getitem__(self, key: str) -> object:
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
# Provide dictionary-like access via the root dict.
|
||||
return self.root[key]
|
||||
root_dict: dict[str, Any] = self.model_dump()
|
||||
return root_dict[key]
|
||||
|
||||
def __getattr__(self, key: str) -> object:
|
||||
def __getattr__(self, key: str) -> Any:
|
||||
# Fallback for attribute access using the root dict.
|
||||
try:
|
||||
return self.root[key]
|
||||
root_dict: dict[str, Any] = self.model_dump()
|
||||
return root_dict[key]
|
||||
except KeyError as e:
|
||||
raise AttributeError(
|
||||
f"'ExtendedConfig' object has no attribute '{key}'"
|
||||
|
||||
@@ -5,7 +5,7 @@ import platform
|
||||
import sys
|
||||
from ast import literal_eval
|
||||
from types import UnionType
|
||||
from typing import Any, MutableMapping, get_args, get_origin
|
||||
from typing import MutableMapping, get_args, get_origin
|
||||
from uuid import uuid4
|
||||
|
||||
import toml
|
||||
@@ -46,10 +46,16 @@ def load_from_env(
|
||||
env_or_toml_dict: The environment variables or a config.toml dict.
|
||||
"""
|
||||
|
||||
def get_optional_type(union_type: UnionType) -> Any:
|
||||
def get_optional_type(union_type: UnionType | type | None) -> type | None:
|
||||
"""Returns the non-None type from a Union."""
|
||||
types = get_args(union_type)
|
||||
return next((t for t in types if t is not type(None)), None)
|
||||
if union_type is None:
|
||||
return None
|
||||
if get_origin(union_type) is UnionType:
|
||||
types = get_args(union_type)
|
||||
return next((t for t in types if t is not type(None)), None)
|
||||
if isinstance(union_type, type):
|
||||
return union_type
|
||||
return None
|
||||
|
||||
# helper function to set attributes based on env vars
|
||||
def set_attr_from_env(sub_config: BaseModel, prefix='') -> None:
|
||||
@@ -85,7 +91,8 @@ def load_from_env(
|
||||
elif get_origin(field_type) is dict:
|
||||
cast_value = literal_eval(value)
|
||||
else:
|
||||
cast_value = field_type(value)
|
||||
if field_type is not None:
|
||||
cast_value = field_type(value)
|
||||
setattr(sub_config, field_name, cast_value)
|
||||
except (ValueError, TypeError):
|
||||
logger.openhands_logger.error(
|
||||
@@ -225,6 +232,7 @@ def load_from_toml(cfg: AppConfig, toml_file: str = 'config.toml') -> None:
|
||||
# Create default LLM summarizing condenser config
|
||||
default_condenser = LLMSummarizingCondenserConfig(
|
||||
llm_config=cfg.get_llm_config(), # Use default LLM config
|
||||
type='llm',
|
||||
)
|
||||
|
||||
# Set as default condenser
|
||||
|
||||
@@ -4,22 +4,30 @@ from openhands.llm.metrics import Metrics, TokenUsage
|
||||
|
||||
def get_token_usage_for_event(event: Event, metrics: Metrics) -> TokenUsage | None:
|
||||
"""
|
||||
Returns at most one token usage record for the `model_response.id` in this event's
|
||||
`tool_call_metadata`.
|
||||
Returns at most one token usage record for either:
|
||||
- `tool_call_metadata.model_response.id`, if possible
|
||||
- otherwise event.response_id, if set
|
||||
|
||||
If no response_id is found, or none match in metrics.token_usages, returns None.
|
||||
If neither exist or none matches in metrics.token_usages, returns None.
|
||||
"""
|
||||
# 1) Use the tool_call_metadata's response.id if present
|
||||
if event.tool_call_metadata and event.tool_call_metadata.model_response:
|
||||
response_id = event.tool_call_metadata.model_response.get('id')
|
||||
if response_id:
|
||||
return next(
|
||||
(
|
||||
usage
|
||||
for usage in metrics.token_usages
|
||||
if usage.response_id == response_id
|
||||
),
|
||||
tool_response_id = event.tool_call_metadata.model_response.get('id')
|
||||
if tool_response_id:
|
||||
usage_rec = next(
|
||||
(u for u in metrics.token_usages if u.response_id == tool_response_id),
|
||||
None,
|
||||
)
|
||||
if usage_rec is not None:
|
||||
return usage_rec
|
||||
|
||||
# 2) Fallback to the top-level event.response_id if present
|
||||
if event.response_id:
|
||||
return next(
|
||||
(u for u in metrics.token_usages if u.response_id == event.response_id),
|
||||
None,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -28,17 +36,17 @@ def get_token_usage_for_event_id(
|
||||
) -> TokenUsage | None:
|
||||
"""
|
||||
Starting from the event with .id == event_id and moving backwards in `events`,
|
||||
find the first TokenUsage record (if any) associated with a response_id from
|
||||
tool_call_metadata.model_response.id.
|
||||
|
||||
find the first TokenUsage record (if any) associated either with:
|
||||
- tool_call_metadata.model_response.id, or
|
||||
- event.response_id
|
||||
Returns the first match found, or None if none is found.
|
||||
"""
|
||||
# find the index of the event with the given id
|
||||
# Find the index of the event with the given id
|
||||
idx = next((i for i, e in enumerate(events) if e.id == event_id), None)
|
||||
if idx is None:
|
||||
return None
|
||||
|
||||
# search backward from idx down to 0
|
||||
# Search backward from idx down to 0
|
||||
for i in range(idx, -1, -1):
|
||||
usage = get_token_usage_for_event(events[i], metrics)
|
||||
if usage is not None:
|
||||
|
||||
@@ -1,91 +1,85 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
__all__ = ['ActionType']
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ActionTypeSchema(BaseModel):
|
||||
MESSAGE: str = Field(default='message')
|
||||
class ActionType(str, Enum):
|
||||
MESSAGE = 'message'
|
||||
"""Represents a message.
|
||||
"""
|
||||
|
||||
START: str = Field(default='start')
|
||||
START = 'start'
|
||||
"""Starts a new development task OR send chat from the user. Only sent by the client.
|
||||
"""
|
||||
|
||||
READ: str = Field(default='read')
|
||||
READ = 'read'
|
||||
"""Reads the content of a file.
|
||||
"""
|
||||
|
||||
WRITE: str = Field(default='write')
|
||||
WRITE = 'write'
|
||||
"""Writes the content to a file.
|
||||
"""
|
||||
|
||||
EDIT: str = Field(default='edit')
|
||||
EDIT = 'edit'
|
||||
"""Edits a file by providing a draft.
|
||||
"""
|
||||
|
||||
RUN: str = Field(default='run')
|
||||
RUN = 'run'
|
||||
"""Runs a command.
|
||||
"""
|
||||
|
||||
RUN_IPYTHON: str = Field(default='run_ipython')
|
||||
RUN_IPYTHON = 'run_ipython'
|
||||
"""Runs a IPython cell.
|
||||
"""
|
||||
|
||||
BROWSE: str = Field(default='browse')
|
||||
BROWSE = 'browse'
|
||||
"""Opens a web page.
|
||||
"""
|
||||
|
||||
BROWSE_INTERACTIVE: str = Field(default='browse_interactive')
|
||||
BROWSE_INTERACTIVE = 'browse_interactive'
|
||||
"""Interact with the browser instance.
|
||||
"""
|
||||
|
||||
DELEGATE: str = Field(default='delegate')
|
||||
DELEGATE = 'delegate'
|
||||
"""Delegates a task to another agent.
|
||||
"""
|
||||
|
||||
THINK: str = Field(default='think')
|
||||
THINK = 'think'
|
||||
"""Logs a thought.
|
||||
"""
|
||||
|
||||
FINISH: str = Field(default='finish')
|
||||
FINISH = 'finish'
|
||||
"""If you're absolutely certain that you've completed your task and have tested your work,
|
||||
use the finish action to stop working.
|
||||
"""
|
||||
|
||||
REJECT: str = Field(default='reject')
|
||||
REJECT = 'reject'
|
||||
"""If you're absolutely certain that you cannot complete the task with given requirements,
|
||||
use the reject action to stop working.
|
||||
"""
|
||||
|
||||
NULL: str = Field(default='null')
|
||||
NULL = 'null'
|
||||
|
||||
SUMMARIZE: str = Field(default='summarize')
|
||||
|
||||
PAUSE: str = Field(default='pause')
|
||||
PAUSE = 'pause'
|
||||
"""Pauses the task.
|
||||
"""
|
||||
|
||||
RESUME: str = Field(default='resume')
|
||||
RESUME = 'resume'
|
||||
"""Resumes the task.
|
||||
"""
|
||||
|
||||
STOP: str = Field(default='stop')
|
||||
STOP = 'stop'
|
||||
"""Stops the task. Must send a start action to restart a new task.
|
||||
"""
|
||||
|
||||
CHANGE_AGENT_STATE: str = Field(default='change_agent_state')
|
||||
CHANGE_AGENT_STATE = 'change_agent_state'
|
||||
|
||||
PUSH: str = Field(default='push')
|
||||
PUSH = 'push'
|
||||
"""Push a branch to github."""
|
||||
|
||||
SEND_PR: str = Field(default='send_pr')
|
||||
SEND_PR = 'send_pr'
|
||||
"""Send a PR to github."""
|
||||
|
||||
RECALL: str = Field(default='recall')
|
||||
RECALL = 'recall'
|
||||
"""Retrieves content from a user workspace, microagent, or other source."""
|
||||
|
||||
|
||||
|
||||
|
||||
ActionType = ActionTypeSchema()
|
||||
CONDENSATION = 'condensation'
|
||||
"""Condenses a list of events into a summary."""
|
||||
|
||||
@@ -1,56 +1,51 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
__all__ = ['ObservationType']
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ObservationTypeSchema(BaseModel):
|
||||
READ: str = Field(default='read')
|
||||
class ObservationType(str, Enum):
|
||||
READ = 'read'
|
||||
"""The content of a file
|
||||
"""
|
||||
|
||||
WRITE: str = Field(default='write')
|
||||
WRITE = 'write'
|
||||
|
||||
EDIT: str = Field(default='edit')
|
||||
EDIT = 'edit'
|
||||
|
||||
BROWSE: str = Field(default='browse')
|
||||
BROWSE = 'browse'
|
||||
"""The HTML content of a URL
|
||||
"""
|
||||
|
||||
RUN: str = Field(default='run')
|
||||
RUN = 'run'
|
||||
"""The output of a command
|
||||
"""
|
||||
|
||||
RUN_IPYTHON: str = Field(default='run_ipython')
|
||||
RUN_IPYTHON = 'run_ipython'
|
||||
"""Runs a IPython cell.
|
||||
"""
|
||||
|
||||
CHAT: str = Field(default='chat')
|
||||
CHAT = 'chat'
|
||||
"""A message from the user
|
||||
"""
|
||||
|
||||
DELEGATE: str = Field(default='delegate')
|
||||
DELEGATE = 'delegate'
|
||||
"""The result of a task delegated to another agent
|
||||
"""
|
||||
|
||||
MESSAGE: str = Field(default='message')
|
||||
MESSAGE = 'message'
|
||||
|
||||
ERROR: str = Field(default='error')
|
||||
ERROR = 'error'
|
||||
|
||||
SUCCESS: str = Field(default='success')
|
||||
SUCCESS = 'success'
|
||||
|
||||
NULL: str = Field(default='null')
|
||||
NULL = 'null'
|
||||
|
||||
THINK: str = Field(default='think')
|
||||
THINK = 'think'
|
||||
|
||||
AGENT_STATE_CHANGED: str = Field(default='agent_state_changed')
|
||||
AGENT_STATE_CHANGED = 'agent_state_changed'
|
||||
|
||||
USER_REJECTED: str = Field(default='user_rejected')
|
||||
USER_REJECTED = 'user_rejected'
|
||||
|
||||
CONDENSE: str = Field(default='condense')
|
||||
CONDENSE = 'condense'
|
||||
"""Result of a condensation operation."""
|
||||
|
||||
RECALL: str = Field(default='recall')
|
||||
RECALL = 'recall'
|
||||
"""Result of a recall operation. This can be the workspace context, a microagent, or other types of information."""
|
||||
|
||||
|
||||
ObservationType = ObservationTypeSchema()
|
||||
|
||||
@@ -3,7 +3,6 @@ from openhands.events.action.agent import (
|
||||
AgentDelegateAction,
|
||||
AgentFinishAction,
|
||||
AgentRejectAction,
|
||||
AgentSummarizeAction,
|
||||
AgentThinkAction,
|
||||
ChangeAgentStateAction,
|
||||
RecallAction,
|
||||
@@ -30,11 +29,10 @@ __all__ = [
|
||||
'AgentFinishAction',
|
||||
'AgentRejectAction',
|
||||
'AgentDelegateAction',
|
||||
'AgentSummarizeAction',
|
||||
'ChangeAgentStateAction',
|
||||
'IPythonRunCellAction',
|
||||
'MessageAction',
|
||||
'ActionConfirmationStatus',
|
||||
'AgentThinkAction',
|
||||
|
||||
'RecallAction',
|
||||
]
|
||||
|
||||
@@ -20,21 +20,6 @@ class ChangeAgentStateAction(Action):
|
||||
return f'Agent state changed to {self.agent_state}'
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentSummarizeAction(Action):
|
||||
summary: str
|
||||
action: str = ActionType.SUMMARIZE
|
||||
|
||||
@property
|
||||
def message(self) -> str:
|
||||
return self.summary
|
||||
|
||||
def __str__(self) -> str:
|
||||
ret = '**AgentSummarizeAction**\n'
|
||||
ret += f'SUMMARY: {self.summary}'
|
||||
return ret
|
||||
|
||||
|
||||
class AgentFinishTaskCompleted(Enum):
|
||||
FALSE = 'false'
|
||||
PARTIAL = 'partial'
|
||||
@@ -126,3 +111,87 @@ class RecallAction(Action):
|
||||
ret = '**RecallAction**\n'
|
||||
ret += f'QUERY: {self.query[:50]}'
|
||||
return ret
|
||||
|
||||
|
||||
@dataclass
|
||||
class CondensationAction(Action):
|
||||
"""This action indicates a condensation of the conversation history is happening.
|
||||
|
||||
There are two ways to specify the events to be forgotten:
|
||||
1. By providing a list of event IDs.
|
||||
2. By providing the start and end IDs of a range of events.
|
||||
|
||||
In the second case, we assume that event IDs are monotonically increasing, and that _all_ events between the start and end IDs are to be forgotten.
|
||||
|
||||
Raises:
|
||||
ValueError: If the optional fields are not instantiated in a valid configuration.
|
||||
"""
|
||||
|
||||
action: str = ActionType.CONDENSATION
|
||||
|
||||
forgotten_event_ids: list[int] | None = None
|
||||
"""The IDs of the events that are being forgotten (removed from the `View` given to the LLM)."""
|
||||
|
||||
forgotten_events_start_id: int | None = None
|
||||
"""The ID of the first event to be forgotten in a range of events."""
|
||||
|
||||
forgotten_events_end_id: int | None = None
|
||||
"""The ID of the last event to be forgotten in a range of events."""
|
||||
|
||||
summary: str | None = None
|
||||
"""An optional summary of the events being forgotten."""
|
||||
|
||||
summary_offset: int | None = None
|
||||
"""An optional offset to the start of the resulting view indicating where the summary should be inserted."""
|
||||
|
||||
def _validate_field_polymorphism(self) -> bool:
|
||||
"""Check if the optional fields are instantiated in a valid configuration."""
|
||||
# For the forgotton events, there are only two valid configurations:
|
||||
# 1. We're forgetting events based on the list of provided IDs, or
|
||||
using_event_ids = self.forgotten_event_ids is not None
|
||||
# 2. We're forgetting events based on the range of IDs.
|
||||
using_event_range = (
|
||||
self.forgotten_events_start_id is not None
|
||||
and self.forgotten_events_end_id is not None
|
||||
)
|
||||
|
||||
# Either way, we can only have one of the two valid configurations.
|
||||
forgotten_event_configuration = using_event_ids ^ using_event_range
|
||||
|
||||
# We also need to check that if the summary is provided, so is the
|
||||
# offset (and vice versa).
|
||||
summary_configuration = (
|
||||
self.summary is None and self.summary_offset is None
|
||||
) or (self.summary is not None and self.summary_offset is not None)
|
||||
|
||||
return forgotten_event_configuration and summary_configuration
|
||||
|
||||
def __post_init__(self):
|
||||
if not self._validate_field_polymorphism():
|
||||
raise ValueError('Invalid configuration of the optional fields.')
|
||||
|
||||
@property
|
||||
def forgotten(self) -> list[int]:
|
||||
"""The list of event IDs that should be forgotten."""
|
||||
# Start by making sure the fields are instantiated in a valid
|
||||
# configuration. We check this whenever the event is initialized, but we
|
||||
# can't make the dataclass immutable so we need to check it again here
|
||||
# to make sure the configuration is still valid.
|
||||
if not self._validate_field_polymorphism():
|
||||
raise ValueError('Invalid configuration of the optional fields.')
|
||||
|
||||
if self.forgotten_event_ids is not None:
|
||||
return self.forgotten_event_ids
|
||||
|
||||
# If we've gotten this far, the start/end IDs are not None.
|
||||
assert self.forgotten_events_start_id is not None
|
||||
assert self.forgotten_events_end_id is not None
|
||||
return list(
|
||||
range(self.forgotten_events_start_id, self.forgotten_events_end_id + 1)
|
||||
)
|
||||
|
||||
@property
|
||||
def message(self) -> str:
|
||||
if self.summary:
|
||||
return f'Summary: {self.summary}'
|
||||
return f'Condenser is dropping the events: {self.forgotten}.'
|
||||
|
||||
@@ -60,6 +60,3 @@ class IPythonRunCellAction(Action):
|
||||
@property
|
||||
def message(self) -> str:
|
||||
return f'Running Python code interactively: {self.code}'
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -17,12 +17,12 @@ class MessageAction(Action):
|
||||
return self.content
|
||||
|
||||
@property
|
||||
def images_urls(self):
|
||||
def images_urls(self) -> list[str] | None:
|
||||
# Deprecated alias for backward compatibility
|
||||
return self.image_urls
|
||||
|
||||
@images_urls.setter
|
||||
def images_urls(self, value):
|
||||
def images_urls(self, value: list[str] | None) -> None:
|
||||
self.image_urls = value
|
||||
|
||||
def __str__(self) -> str:
|
||||
|
||||
+32
-12
@@ -39,19 +39,23 @@ class Event:
|
||||
@property
|
||||
def message(self) -> str | None:
|
||||
if hasattr(self, '_message'):
|
||||
return self._message # type: ignore[attr-defined]
|
||||
msg = getattr(self, '_message')
|
||||
return str(msg) if msg is not None else None
|
||||
return ''
|
||||
|
||||
@property
|
||||
def id(self) -> int:
|
||||
if hasattr(self, '_id'):
|
||||
return self._id # type: ignore[attr-defined]
|
||||
id_val = getattr(self, '_id')
|
||||
return int(id_val) if id_val is not None else Event.INVALID_ID
|
||||
return Event.INVALID_ID
|
||||
|
||||
@property
|
||||
def timestamp(self):
|
||||
def timestamp(self) -> str | None:
|
||||
if hasattr(self, '_timestamp') and isinstance(self._timestamp, str):
|
||||
return self._timestamp
|
||||
ts = getattr(self, '_timestamp')
|
||||
return str(ts) if ts is not None else None
|
||||
return None
|
||||
|
||||
@timestamp.setter
|
||||
def timestamp(self, value: datetime) -> None:
|
||||
@@ -61,22 +65,25 @@ class Event:
|
||||
@property
|
||||
def source(self) -> EventSource | None:
|
||||
if hasattr(self, '_source'):
|
||||
return self._source # type: ignore[attr-defined]
|
||||
src = getattr(self, '_source')
|
||||
return EventSource(src) if src is not None else None
|
||||
return None
|
||||
|
||||
@property
|
||||
def cause(self) -> int | None:
|
||||
if hasattr(self, '_cause'):
|
||||
return self._cause # type: ignore[attr-defined]
|
||||
cause_val = getattr(self, '_cause')
|
||||
return int(cause_val) if cause_val is not None else None
|
||||
return None
|
||||
|
||||
@property
|
||||
def timeout(self) -> int | None:
|
||||
def timeout(self) -> float | None:
|
||||
if hasattr(self, '_timeout'):
|
||||
return self._timeout # type: ignore[attr-defined]
|
||||
timeout_val = getattr(self, '_timeout')
|
||||
return float(timeout_val) if timeout_val is not None else None
|
||||
return None
|
||||
|
||||
def set_hard_timeout(self, value: int | None, blocking: bool = True) -> None:
|
||||
def set_hard_timeout(self, value: float | None, blocking: bool = True) -> None:
|
||||
"""Set the timeout for the event.
|
||||
|
||||
NOTE, this is a hard timeout, meaning that the event will be blocked
|
||||
@@ -100,20 +107,33 @@ class Event:
|
||||
@property
|
||||
def llm_metrics(self) -> Metrics | None:
|
||||
if hasattr(self, '_llm_metrics'):
|
||||
return self._llm_metrics # type: ignore[attr-defined]
|
||||
metrics = getattr(self, '_llm_metrics')
|
||||
return metrics if isinstance(metrics, Metrics) else None
|
||||
return None
|
||||
|
||||
@llm_metrics.setter
|
||||
def llm_metrics(self, value: Metrics) -> None:
|
||||
self._llm_metrics = value
|
||||
|
||||
# optional field
|
||||
# optional field, metadata about the tool call, if the event has a tool call
|
||||
@property
|
||||
def tool_call_metadata(self) -> ToolCallMetadata | None:
|
||||
if hasattr(self, '_tool_call_metadata'):
|
||||
return self._tool_call_metadata # type: ignore[attr-defined]
|
||||
metadata = getattr(self, '_tool_call_metadata')
|
||||
return metadata if isinstance(metadata, ToolCallMetadata) else None
|
||||
return None
|
||||
|
||||
@tool_call_metadata.setter
|
||||
def tool_call_metadata(self, value: ToolCallMetadata) -> None:
|
||||
self._tool_call_metadata = value
|
||||
|
||||
# optional field, the id of the response from the LLM
|
||||
@property
|
||||
def response_id(self) -> str | None:
|
||||
if hasattr(self, '_response_id'):
|
||||
return self._response_id # type: ignore[attr-defined]
|
||||
return None
|
||||
|
||||
@response_id.setter
|
||||
def response_id(self, value: str) -> None:
|
||||
self._response_id = value
|
||||
|
||||
@@ -72,6 +72,7 @@ class RecallObservation(Observation):
|
||||
repo_instructions: str = ''
|
||||
runtime_hosts: dict[str, int] = field(default_factory=dict)
|
||||
additional_agent_instructions: str = ''
|
||||
date: str = ''
|
||||
|
||||
# knowledge
|
||||
microagent_knowledge: list[MicroagentKnowledge] = field(default_factory=list)
|
||||
@@ -112,6 +113,7 @@ class RecallObservation(Observation):
|
||||
f'repo_instructions={self.repo_instructions[:20]}...',
|
||||
f'runtime_hosts={self.runtime_hosts}',
|
||||
f'additional_agent_instructions={self.additional_agent_instructions[:20]}...',
|
||||
f'date={self.date}'
|
||||
]
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from browsergym.utils.obs import flatten_axtree_to_str
|
||||
|
||||
@@ -16,13 +17,17 @@ class BrowserOutputObservation(Observation):
|
||||
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)
|
||||
goal_image_urls: list[str] = field(default_factory=list)
|
||||
# do not include in the memory
|
||||
open_pages_urls: list = field(default_factory=list)
|
||||
open_pages_urls: list[str] = field(default_factory=list)
|
||||
active_page_index: int = -1
|
||||
dom_object: dict = field(default_factory=dict, repr=False) # don't show in repr
|
||||
axtree_object: dict = field(default_factory=dict, repr=False) # don't show in repr
|
||||
extra_element_properties: dict = field(
|
||||
dom_object: dict[str, Any] = field(
|
||||
default_factory=dict, repr=False
|
||||
) # don't show in repr
|
||||
axtree_object: dict[str, Any] = field(
|
||||
default_factory=dict, repr=False
|
||||
) # don't show in repr
|
||||
extra_element_properties: dict[str, Any] = field(
|
||||
default_factory=dict, repr=False
|
||||
) # don't show in repr
|
||||
last_browser_action: str = ''
|
||||
@@ -102,4 +107,4 @@ class BrowserOutputObservation(Observation):
|
||||
skip_generic=False,
|
||||
filter_visible_only=filter_visible_only,
|
||||
)
|
||||
return cur_axtree_txt
|
||||
return str(cur_axtree_txt)
|
||||
|
||||
@@ -2,7 +2,7 @@ import json
|
||||
import re
|
||||
import traceback
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Self
|
||||
from typing import Any, Self
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -105,10 +105,10 @@ class CmdOutputObservation(Observation):
|
||||
content: str,
|
||||
command: str,
|
||||
observation: str = ObservationType.RUN,
|
||||
metadata: dict | CmdOutputMetadata | None = None,
|
||||
metadata: dict[str, Any] | CmdOutputMetadata | None = None,
|
||||
hidden: bool = False,
|
||||
**kwargs,
|
||||
):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(content)
|
||||
self.command = command
|
||||
self.observation = observation
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import Any
|
||||
|
||||
from openhands.core.exceptions import LLMMalformedActionError
|
||||
from openhands.events.action.action import Action
|
||||
from openhands.events.action.agent import (
|
||||
@@ -6,6 +8,7 @@ from openhands.events.action.agent import (
|
||||
AgentRejectAction,
|
||||
AgentThinkAction,
|
||||
ChangeAgentStateAction,
|
||||
CondensationAction,
|
||||
RecallAction,
|
||||
)
|
||||
from openhands.events.action.browse import BrowseInteractiveAction, BrowseURLAction
|
||||
@@ -37,12 +40,13 @@ actions = (
|
||||
RecallAction,
|
||||
ChangeAgentStateAction,
|
||||
MessageAction,
|
||||
CondensationAction,
|
||||
)
|
||||
|
||||
ACTION_TYPE_TO_CLASS = {action_class.action: action_class for action_class in actions} # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def handle_action_deprecated_args(args: dict) -> dict:
|
||||
def handle_action_deprecated_args(args: dict[str, Any]) -> dict[str, Any]:
|
||||
# keep_prompt has been deprecated in https://github.com/All-Hands-AI/OpenHands/pull/4881
|
||||
if 'keep_prompt' in args:
|
||||
args.pop('keep_prompt')
|
||||
@@ -126,4 +130,5 @@ def action_from_dict(action: dict) -> Action:
|
||||
raise LLMMalformedActionError(
|
||||
f'action={action} has the wrong arguments: {str(e)}'
|
||||
)
|
||||
assert isinstance(decoded_action, Action)
|
||||
return decoded_action
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -48,14 +49,14 @@ DELETE_FROM_TRAJECTORY_EXTRAS_AND_SCREENSHOTS = DELETE_FROM_TRAJECTORY_EXTRAS |
|
||||
}
|
||||
|
||||
|
||||
def event_from_dict(data) -> 'Event':
|
||||
def event_from_dict(data: dict[str, Any]) -> 'Event':
|
||||
evt: Event
|
||||
if 'action' in data:
|
||||
evt = action_from_dict(data)
|
||||
elif 'observation' in data:
|
||||
evt = observation_from_dict(data)
|
||||
else:
|
||||
raise ValueError('Unknown event type: ' + data)
|
||||
raise ValueError(f'Unknown event type: {data}')
|
||||
for key in UNDERSCORE_KEYS:
|
||||
if key in data:
|
||||
value = data[key]
|
||||
@@ -78,6 +79,11 @@ def event_from_dict(data) -> 'Event':
|
||||
metrics.token_usages = [
|
||||
TokenUsage(**usage) for usage in value.get('token_usages', [])
|
||||
]
|
||||
# Set accumulated token usage if available
|
||||
if 'accumulated_token_usage' in value:
|
||||
metrics._accumulated_token_usage = TokenUsage(
|
||||
**value.get('accumulated_token_usage', {})
|
||||
)
|
||||
value = metrics
|
||||
setattr(evt, '_' + key, value)
|
||||
return evt
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import copy
|
||||
from typing import Any
|
||||
|
||||
from openhands.events.event import RecallType
|
||||
from openhands.events.observation.agent import (
|
||||
@@ -53,8 +54,8 @@ OBSERVATION_TYPE_TO_CLASS = {
|
||||
|
||||
|
||||
def _update_cmd_output_metadata(
|
||||
metadata: dict | CmdOutputMetadata | None, **kwargs
|
||||
) -> dict | CmdOutputMetadata:
|
||||
metadata: dict[str, Any] | CmdOutputMetadata | None, **kwargs: Any
|
||||
) -> dict[str, Any] | CmdOutputMetadata:
|
||||
"""Update the metadata of a CmdOutputObservation.
|
||||
|
||||
If metadata is None, create a new CmdOutputMetadata instance.
|
||||
@@ -128,4 +129,6 @@ def observation_from_dict(observation: dict) -> Observation:
|
||||
for item in extras['microagent_knowledge']
|
||||
]
|
||||
|
||||
return observation_class(content=content, **extras)
|
||||
obs = observation_class(content=content, **extras)
|
||||
assert isinstance(obs, Observation)
|
||||
return obs
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
def remove_fields(obj, fields: set[str]):
|
||||
def remove_fields(obj: dict | list | tuple, fields: set[str]) -> None:
|
||||
"""Remove fields from an object.
|
||||
|
||||
Parameters:
|
||||
@@ -14,7 +14,7 @@ def remove_fields(obj, fields: set[str]):
|
||||
elif isinstance(obj, (list, tuple)):
|
||||
for item in obj:
|
||||
remove_fields(item, fields)
|
||||
elif hasattr(obj, '__dataclass_fields__'):
|
||||
if hasattr(obj, '__dataclass_fields__'):
|
||||
raise ValueError(
|
||||
'Object must not contain dataclass, consider converting to dict first'
|
||||
)
|
||||
|
||||
+40
-29
@@ -5,7 +5,7 @@ from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from functools import partial
|
||||
from typing import Callable, Iterable
|
||||
from typing import Any, AsyncIterator, Callable, Iterable
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.event import Event, EventSource
|
||||
@@ -43,18 +43,21 @@ async def session_exists(
|
||||
|
||||
|
||||
class AsyncEventStreamWrapper:
|
||||
def __init__(self, event_stream, *args, **kwargs):
|
||||
def __init__(self, event_stream: 'EventStream', *args: Any, **kwargs: Any) -> None:
|
||||
self.event_stream = event_stream
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
async def __aiter__(self):
|
||||
async def __aiter__(self) -> AsyncIterator[Event]:
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Create an async generator that yields events
|
||||
for event in self.event_stream.get_events(*self.args, **self.kwargs):
|
||||
# Run the blocking get_events() in a thread pool
|
||||
yield await loop.run_in_executor(None, lambda e=event: e) # type: ignore
|
||||
def get_event(e: Event = event) -> Event:
|
||||
return e
|
||||
|
||||
yield await loop.run_in_executor(None, get_event)
|
||||
|
||||
|
||||
class EventStream:
|
||||
@@ -121,14 +124,14 @@ class EventStream:
|
||||
if id >= self._cur_id:
|
||||
self._cur_id = id + 1
|
||||
|
||||
def _init_thread_loop(self, subscriber_id: str, callback_id: str):
|
||||
def _init_thread_loop(self, subscriber_id: str, callback_id: str) -> None:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
if subscriber_id not in self._thread_loops:
|
||||
self._thread_loops[subscriber_id] = {}
|
||||
self._thread_loops[subscriber_id][callback_id] = loop
|
||||
|
||||
def close(self):
|
||||
def close(self) -> None:
|
||||
self._stop_flag.set()
|
||||
if self._queue_thread.is_alive():
|
||||
self._queue_thread.join()
|
||||
@@ -143,7 +146,7 @@ class EventStream:
|
||||
while not self._queue.empty():
|
||||
self._queue.get()
|
||||
|
||||
def _clean_up_subscriber(self, subscriber_id: str, callback_id: str):
|
||||
def _clean_up_subscriber(self, subscriber_id: str, callback_id: str) -> None:
|
||||
if subscriber_id not in self._subscribers:
|
||||
logger.warning(f'Subscriber not found during cleanup: {subscriber_id}')
|
||||
return
|
||||
@@ -191,7 +194,7 @@ class EventStream:
|
||||
end_id: int | None = None,
|
||||
reverse: bool = False,
|
||||
filter_out_type: tuple[type[Event], ...] | None = None,
|
||||
filter_hidden=False,
|
||||
filter_hidden: bool = False,
|
||||
) -> Iterable[Event]:
|
||||
"""
|
||||
Retrieve events from the event stream, optionally filtering out events of a given type
|
||||
@@ -208,7 +211,7 @@ class EventStream:
|
||||
Events from the stream that match the criteria.
|
||||
"""
|
||||
|
||||
def should_filter(event: Event):
|
||||
def should_filter(event: Event) -> bool:
|
||||
if filter_hidden and hasattr(event, 'hidden') and event.hidden:
|
||||
return True
|
||||
if filter_out_type is not None and isinstance(event, filter_out_type):
|
||||
@@ -263,8 +266,11 @@ class EventStream:
|
||||
return self._cur_id - 1
|
||||
|
||||
def subscribe(
|
||||
self, subscriber_id: EventStreamSubscriber, callback: Callable, callback_id: str
|
||||
):
|
||||
self,
|
||||
subscriber_id: EventStreamSubscriber,
|
||||
callback: Callable[[Event], None],
|
||||
callback_id: str,
|
||||
) -> None:
|
||||
initializer = partial(self._init_thread_loop, subscriber_id, callback_id)
|
||||
pool = ThreadPoolExecutor(max_workers=1, initializer=initializer)
|
||||
if subscriber_id not in self._subscribers:
|
||||
@@ -279,7 +285,9 @@ class EventStream:
|
||||
self._subscribers[subscriber_id][callback_id] = callback
|
||||
self._thread_pools[subscriber_id][callback_id] = pool
|
||||
|
||||
def unsubscribe(self, subscriber_id: EventStreamSubscriber, callback_id: str):
|
||||
def unsubscribe(
|
||||
self, subscriber_id: EventStreamSubscriber, callback_id: str
|
||||
) -> None:
|
||||
if subscriber_id not in self._subscribers:
|
||||
logger.warning(f'Subscriber not found during unsubscribe: {subscriber_id}')
|
||||
return
|
||||
@@ -290,8 +298,8 @@ class EventStream:
|
||||
|
||||
self._clean_up_subscriber(subscriber_id, callback_id)
|
||||
|
||||
def add_event(self, event: Event, source: EventSource):
|
||||
if hasattr(event, '_id') and event.id is not None:
|
||||
def add_event(self, event: Event, source: EventSource) -> None:
|
||||
if event.id != Event.INVALID_ID:
|
||||
raise ValueError(
|
||||
f'Event already has an ID:{event.id}. It was probably added back to the EventStream from inside a handler, triggering a loop.'
|
||||
)
|
||||
@@ -310,13 +318,13 @@ class EventStream:
|
||||
)
|
||||
self._queue.put(event)
|
||||
|
||||
def set_secrets(self, secrets: dict[str, str]):
|
||||
def set_secrets(self, secrets: dict[str, str]) -> None:
|
||||
self.secrets = secrets.copy()
|
||||
|
||||
def update_secrets(self, secrets: dict[str, str]):
|
||||
def update_secrets(self, secrets: dict[str, str]) -> None:
|
||||
self.secrets.update(secrets)
|
||||
|
||||
def _replace_secrets(self, data: dict) -> dict:
|
||||
def _replace_secrets(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
for key in data:
|
||||
if isinstance(data[key], dict):
|
||||
data[key] = self._replace_secrets(data[key])
|
||||
@@ -325,7 +333,7 @@ class EventStream:
|
||||
data[key] = data[key].replace(secret, '<secret_hidden>')
|
||||
return data
|
||||
|
||||
def _run_queue_loop(self):
|
||||
def _run_queue_loop(self) -> None:
|
||||
self._queue_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self._queue_loop)
|
||||
try:
|
||||
@@ -333,7 +341,7 @@ class EventStream:
|
||||
finally:
|
||||
self._queue_loop.close()
|
||||
|
||||
async def _process_queue(self):
|
||||
async def _process_queue(self) -> None:
|
||||
while should_continue() and not self._stop_flag.is_set():
|
||||
event = None
|
||||
try:
|
||||
@@ -350,8 +358,10 @@ class EventStream:
|
||||
future = pool.submit(callback, event)
|
||||
future.add_done_callback(self._make_error_handler(callback_id, key))
|
||||
|
||||
def _make_error_handler(self, callback_id: str, subscriber_id: str):
|
||||
def _handle_callback_error(fut):
|
||||
def _make_error_handler(
|
||||
self, callback_id: str, subscriber_id: str
|
||||
) -> Callable[[Any], None]:
|
||||
def _handle_callback_error(fut: Any) -> None:
|
||||
try:
|
||||
# This will raise any exception that occurred during callback execution
|
||||
fut.result()
|
||||
@@ -364,14 +374,14 @@ class EventStream:
|
||||
|
||||
return _handle_callback_error
|
||||
|
||||
def filtered_events_by_source(self, source: EventSource):
|
||||
def filtered_events_by_source(self, source: EventSource) -> Iterable[Event]:
|
||||
for event in self.get_events():
|
||||
if event.source == source:
|
||||
yield event
|
||||
|
||||
def _should_filter_event(
|
||||
self,
|
||||
event,
|
||||
event: Event,
|
||||
query: str | None = None,
|
||||
event_types: tuple[type[Event], ...] | None = None,
|
||||
source: str | None = None,
|
||||
@@ -394,13 +404,14 @@ class EventStream:
|
||||
if event_types and not isinstance(event, event_types):
|
||||
return True
|
||||
|
||||
if source and not event.source.value == source:
|
||||
if source:
|
||||
if event.source is None or event.source.value != source:
|
||||
return True
|
||||
|
||||
if start_date and event.timestamp is not None and event.timestamp < start_date:
|
||||
return True
|
||||
|
||||
if start_date and event.timestamp < start_date:
|
||||
return True
|
||||
|
||||
if end_date and event.timestamp > end_date:
|
||||
if end_date and event.timestamp is not None and event.timestamp > end_date:
|
||||
return True
|
||||
|
||||
# Text search in event content if query provided
|
||||
@@ -422,7 +433,7 @@ class EventStream:
|
||||
start_id: int = 0,
|
||||
limit: int = 100,
|
||||
reverse: bool = False,
|
||||
) -> list[type[Event]]:
|
||||
) -> list[Event]:
|
||||
"""Get matching events from the event stream based on filters.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -2,18 +2,20 @@ from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Coroutine, Literal, overload
|
||||
from typing import Annotated, Any, Coroutine, Literal, overload
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
Field,
|
||||
SecretStr,
|
||||
SerializationInfo,
|
||||
WithJsonSchema,
|
||||
field_serializer,
|
||||
model_validator,
|
||||
)
|
||||
from pydantic.json import pydantic_encoder
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action.action import Action
|
||||
from openhands.events.action.commands import CmdRunAction
|
||||
from openhands.events.stream import EventStream
|
||||
@@ -57,10 +59,14 @@ class ProviderToken(BaseModel):
|
||||
|
||||
PROVIDER_TOKEN_TYPE = MappingProxyType[ProviderType, ProviderToken]
|
||||
CUSTOM_SECRETS_TYPE = MappingProxyType[str, SecretStr]
|
||||
PROVIDER_TOKEN_TYPE_WITH_JSON_SCHEMA = Annotated[
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
WithJsonSchema({'type': 'object', 'additionalProperties': {'type': 'string'}}),
|
||||
]
|
||||
|
||||
|
||||
class SecretStore(BaseModel):
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE = Field(
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE_WITH_JSON_SCHEMA = Field(
|
||||
default_factory=lambda: MappingProxyType({})
|
||||
)
|
||||
|
||||
@@ -268,7 +274,9 @@ class ProviderHandler:
|
||||
get_latest: Get the latest working token for the providers if True, otherwise get the existing ones
|
||||
"""
|
||||
|
||||
if not self.provider_tokens:
|
||||
# TODO: We should remove `not get_latest` in the future. More
|
||||
# details about the error this fixes is in the next comment below
|
||||
if not self.provider_tokens and not get_latest:
|
||||
return {}
|
||||
|
||||
env_vars: dict[ProviderType, SecretStr] = {}
|
||||
@@ -289,6 +297,20 @@ class ProviderHandler:
|
||||
if token:
|
||||
env_vars[provider] = token
|
||||
|
||||
# TODO: we have an error where reinitializing the runtime doesn't happen with
|
||||
# the provider tokens; thus the code above believes that github isn't a provider
|
||||
# when it really is. We need to share information about current providers set
|
||||
# for the user when the socket event for connect is sent
|
||||
if ProviderType.GITHUB not in env_vars and get_latest:
|
||||
logger.info(
|
||||
f'Force refresh runtime token for user: {self.external_auth_id}'
|
||||
)
|
||||
service = GithubServiceImpl(
|
||||
external_auth_id=self.external_auth_id,
|
||||
external_token_manager=self.external_token_manager,
|
||||
)
|
||||
env_vars[ProviderType.GITHUB] = await service.get_latest_token()
|
||||
|
||||
if not expose_secrets:
|
||||
return env_vars
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import warnings
|
||||
from functools import partial
|
||||
from typing import Any, Callable
|
||||
|
||||
import requests
|
||||
import httpx
|
||||
|
||||
from openhands.core.config import LLMConfig
|
||||
|
||||
@@ -347,7 +347,7 @@ class LLM(RetryMixin, DebugMixin):
|
||||
if self.config.model.startswith('litellm_proxy/'):
|
||||
# IF we are using LiteLLM proxy, get model info from LiteLLM proxy
|
||||
# GET {base_url}/v1/model/info with litellm_model_id as path param
|
||||
response = requests.get(
|
||||
response = httpx.get(
|
||||
f'{self.config.base_url}/v1/model/info',
|
||||
headers={
|
||||
'Authorization': f'Bearer {self.config.api_key.get_secret_value() if self.config.api_key else None}'
|
||||
|
||||
+72
-15
@@ -20,12 +20,23 @@ class ResponseLatency(BaseModel):
|
||||
class TokenUsage(BaseModel):
|
||||
"""Metric tracking detailed token usage per completion call."""
|
||||
|
||||
model: str
|
||||
prompt_tokens: int
|
||||
completion_tokens: int
|
||||
cache_read_tokens: int
|
||||
cache_write_tokens: int
|
||||
response_id: str
|
||||
model: str = Field(default='')
|
||||
prompt_tokens: int = Field(default=0)
|
||||
completion_tokens: int = Field(default=0)
|
||||
cache_read_tokens: int = Field(default=0)
|
||||
cache_write_tokens: int = Field(default=0)
|
||||
response_id: str = Field(default='')
|
||||
|
||||
def __add__(self, other: 'TokenUsage') -> 'TokenUsage':
|
||||
"""Add two TokenUsage instances together."""
|
||||
return TokenUsage(
|
||||
model=self.model,
|
||||
prompt_tokens=self.prompt_tokens + other.prompt_tokens,
|
||||
completion_tokens=self.completion_tokens + other.completion_tokens,
|
||||
cache_read_tokens=self.cache_read_tokens + other.cache_read_tokens,
|
||||
cache_write_tokens=self.cache_write_tokens + other.cache_write_tokens,
|
||||
response_id=self.response_id,
|
||||
)
|
||||
|
||||
|
||||
class Metrics:
|
||||
@@ -42,6 +53,14 @@ class Metrics:
|
||||
self._response_latencies: list[ResponseLatency] = []
|
||||
self.model_name = model_name
|
||||
self._token_usages: list[TokenUsage] = []
|
||||
self._accumulated_token_usage: TokenUsage = TokenUsage(
|
||||
model=model_name,
|
||||
prompt_tokens=0,
|
||||
completion_tokens=0,
|
||||
cache_read_tokens=0,
|
||||
cache_write_tokens=0,
|
||||
response_id='',
|
||||
)
|
||||
|
||||
@property
|
||||
def accumulated_cost(self) -> float:
|
||||
@@ -77,6 +96,20 @@ class Metrics:
|
||||
def token_usages(self, value: list[TokenUsage]) -> None:
|
||||
self._token_usages = value
|
||||
|
||||
@property
|
||||
def accumulated_token_usage(self) -> TokenUsage:
|
||||
"""Get the accumulated token usage, initializing it if it doesn't exist."""
|
||||
if not hasattr(self, '_accumulated_token_usage'):
|
||||
self._accumulated_token_usage = TokenUsage(
|
||||
model=self.model_name,
|
||||
prompt_tokens=0,
|
||||
completion_tokens=0,
|
||||
cache_read_tokens=0,
|
||||
cache_write_tokens=0,
|
||||
response_id='',
|
||||
)
|
||||
return self._accumulated_token_usage
|
||||
|
||||
def add_cost(self, value: float) -> None:
|
||||
if value < 0:
|
||||
raise ValueError('Added cost cannot be negative.')
|
||||
@@ -99,15 +132,24 @@ class Metrics:
|
||||
response_id: str,
|
||||
) -> None:
|
||||
"""Add a single usage record."""
|
||||
self._token_usages.append(
|
||||
TokenUsage(
|
||||
model=self.model_name,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
cache_read_tokens=cache_read_tokens,
|
||||
cache_write_tokens=cache_write_tokens,
|
||||
response_id=response_id,
|
||||
)
|
||||
usage = TokenUsage(
|
||||
model=self.model_name,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
cache_read_tokens=cache_read_tokens,
|
||||
cache_write_tokens=cache_write_tokens,
|
||||
response_id=response_id,
|
||||
)
|
||||
self._token_usages.append(usage)
|
||||
|
||||
# Update accumulated token usage using the __add__ operator
|
||||
self._accumulated_token_usage = self.accumulated_token_usage + TokenUsage(
|
||||
model=self.model_name,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
cache_read_tokens=cache_read_tokens,
|
||||
cache_write_tokens=cache_write_tokens,
|
||||
response_id='',
|
||||
)
|
||||
|
||||
def merge(self, other: 'Metrics') -> None:
|
||||
@@ -118,10 +160,16 @@ class Metrics:
|
||||
self.token_usages += other.token_usages
|
||||
self.response_latencies += other.response_latencies
|
||||
|
||||
# Merge accumulated token usage using the __add__ operator
|
||||
self._accumulated_token_usage = (
|
||||
self.accumulated_token_usage + other.accumulated_token_usage
|
||||
)
|
||||
|
||||
def get(self) -> dict:
|
||||
"""Return the metrics in a dictionary."""
|
||||
return {
|
||||
'accumulated_cost': self._accumulated_cost,
|
||||
'accumulated_token_usage': self.accumulated_token_usage.model_dump(),
|
||||
'costs': [cost.model_dump() for cost in self._costs],
|
||||
'response_latencies': [
|
||||
latency.model_dump() for latency in self._response_latencies
|
||||
@@ -134,6 +182,15 @@ class Metrics:
|
||||
self._costs = []
|
||||
self._response_latencies = []
|
||||
self._token_usages = []
|
||||
# Reset accumulated token usage with a new instance
|
||||
self._accumulated_token_usage = TokenUsage(
|
||||
model=self.model_name,
|
||||
prompt_tokens=0,
|
||||
completion_tokens=0,
|
||||
cache_read_tokens=0,
|
||||
cache_write_tokens=0,
|
||||
response_id='',
|
||||
)
|
||||
|
||||
def log(self):
|
||||
"""Log the metrics."""
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import openhands.memory.condenser.impl # noqa F401 (we import this to get the condensers registered)
|
||||
from openhands.memory.condenser.condenser import Condenser, get_condensation_metadata
|
||||
from openhands.memory.condenser.condenser import (
|
||||
Condenser,
|
||||
get_condensation_metadata,
|
||||
View,
|
||||
Condensation,
|
||||
)
|
||||
|
||||
__all__ = ['Condenser', 'get_condensation_metadata', 'CONDENSER_REGISTRY']
|
||||
__all__ = [
|
||||
'Condenser',
|
||||
'get_condensation_metadata',
|
||||
'CONDENSER_REGISTRY',
|
||||
'View',
|
||||
'Condensation',
|
||||
]
|
||||
|
||||
@@ -2,13 +2,15 @@ from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
from typing import Any, overload
|
||||
|
||||
from typing_extensions import override
|
||||
from pydantic import BaseModel
|
||||
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config.condenser_config import CondenserConfig
|
||||
from openhands.events.action.agent import CondensationAction
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.observation.agent import AgentCondensationObservation
|
||||
|
||||
CONDENSER_METADATA_KEY = 'condenser_meta'
|
||||
"""Key identifying where metadata is stored in a `State` object's `extra_data` field."""
|
||||
@@ -32,6 +34,75 @@ CONDENSER_REGISTRY: dict[type[CondenserConfig], type[Condenser]] = {}
|
||||
"""Registry of condenser configurations to their corresponding condenser classes."""
|
||||
|
||||
|
||||
class View(BaseModel):
|
||||
"""Linearly ordered view of events.
|
||||
|
||||
Produced by a condenser to indicate the included events are ready to process as LLM input.
|
||||
"""
|
||||
|
||||
events: list[Event]
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.events)
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.events)
|
||||
|
||||
# To preserve list-like indexing, we ideally support slicing and position-based indexing.
|
||||
# The only challenge with that is switching the return type based on the input type -- we
|
||||
# can mark the different signatures for MyPy with `@overload` decorators.
|
||||
|
||||
@overload
|
||||
def __getitem__(self, key: slice) -> list[Event]: ...
|
||||
|
||||
@overload
|
||||
def __getitem__(self, key: int) -> Event: ...
|
||||
|
||||
def __getitem__(self, key: int | slice) -> Event | list[Event]:
|
||||
if isinstance(key, slice):
|
||||
start, stop, step = key.indices(len(self))
|
||||
return [self[i] for i in range(start, stop, step)]
|
||||
elif isinstance(key, int):
|
||||
return self.events[key]
|
||||
else:
|
||||
raise ValueError(f'Invalid key type: {type(key)}')
|
||||
|
||||
@staticmethod
|
||||
def from_events(events: list[Event]) -> View:
|
||||
"""Create a view from a list of events, respecting the semantics of any condensation events."""
|
||||
forgotten_event_ids: set[int] = set()
|
||||
for event in events:
|
||||
if isinstance(event, CondensationAction):
|
||||
forgotten_event_ids.update(event.forgotten)
|
||||
|
||||
kept_events = [event for event in events if event.id not in forgotten_event_ids]
|
||||
|
||||
# If we have a summary, insert it at the specified offset.
|
||||
summary: str | None = None
|
||||
summary_offset: int | None = None
|
||||
|
||||
# The relevant summary is always in the last condensation event (i.e., the most recent one).
|
||||
for event in reversed(events):
|
||||
if isinstance(event, CondensationAction):
|
||||
if event.summary is not None and event.summary_offset is not None:
|
||||
summary = event.summary
|
||||
summary_offset = event.summary_offset
|
||||
break
|
||||
|
||||
if summary is not None and summary_offset is not None:
|
||||
kept_events.insert(
|
||||
summary_offset, AgentCondensationObservation(content=summary)
|
||||
)
|
||||
|
||||
return View(events=kept_events)
|
||||
|
||||
|
||||
class Condensation(BaseModel):
|
||||
"""Produced by a condenser to indicate the history has been condensed."""
|
||||
|
||||
action: CondensationAction
|
||||
|
||||
|
||||
class Condenser(ABC):
|
||||
"""Abstract condenser interface.
|
||||
|
||||
@@ -39,10 +110,7 @@ class Condenser(ABC):
|
||||
|
||||
Agents can use condensers to reduce the amount of events they need to consider when deciding which action to take. To use a condenser, agents can call the `condensed_history` method on the current `State` being considered and use the results instead of the full history.
|
||||
|
||||
Example usage::
|
||||
|
||||
condenser = Condenser.from_config(condenser_config)
|
||||
events = condenser.condensed_history(state)
|
||||
If the condenser returns a `Condensation` instead of a `View`, the agent should return `Condensation.action` instead of producing its own action. On the next agent step the condenser will use that condensation event to produce a new `View`.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
@@ -82,7 +150,7 @@ class Condenser(ABC):
|
||||
self.write_metadata(state)
|
||||
|
||||
@abstractmethod
|
||||
def condense(self, events: list[Event]) -> list[Event]:
|
||||
def condense(self, events: list[Event]) -> View | Condensation:
|
||||
"""Condense a sequence of events into a potentially smaller list.
|
||||
|
||||
New condenser strategies should override this method to implement their own condensation logic. Call `self.add_metadata` in the implementation to record any relevant per-condensation diagnostic information.
|
||||
@@ -91,10 +159,10 @@ class Condenser(ABC):
|
||||
events: A list of events representing the entire history of the agent.
|
||||
|
||||
Returns:
|
||||
list[Event]: An event sequence representing a condensed history of the agent.
|
||||
View | Condensation: A condensed view of the events or an event indicating the history has been condensed.
|
||||
"""
|
||||
|
||||
def condensed_history(self, state: State) -> list[Event]:
|
||||
def condensed_history(self, state: State) -> View | Condensation:
|
||||
"""Condense the state's history."""
|
||||
with self.metadata_batch(state):
|
||||
return self.condense(state.history)
|
||||
@@ -140,39 +208,28 @@ class Condenser(ABC):
|
||||
class RollingCondenser(Condenser, ABC):
|
||||
"""Base class for a specialized condenser strategy that applies condensation to a rolling history.
|
||||
|
||||
The rolling history is computed by appending new events to the most recent condensation. For example, the sequence of calls::
|
||||
The rolling history is generated by `View.from_events`, which analyzes all events in the history and produces a `View` object representing what will be sent to the LLM.
|
||||
|
||||
assert state.history == [event1, event2, event3]
|
||||
condensation = condenser.condensed_history(state)
|
||||
|
||||
# ...new events are added to the state...
|
||||
|
||||
assert state.history == [event1, event2, event3, event4, event5]
|
||||
condenser.condensed_history(state)
|
||||
|
||||
will result in second call to `condensed_history` passing `condensation + [event4, event5]` to the `condense` method.
|
||||
If `should_condense` says so, the condenser is then responsible for generating a `Condensation` object from the `View` object. This will be added to the event history which should -- when given to `get_view` -- produce the condensed `View` to be passed to the LLM.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._condensation: list[Event] = []
|
||||
self._last_history_length: int = 0
|
||||
@abstractmethod
|
||||
def should_condense(self, view: View) -> bool:
|
||||
"""Determine if a view should be condensed."""
|
||||
|
||||
super().__init__()
|
||||
@abstractmethod
|
||||
def get_condensation(self, view: View) -> Condensation:
|
||||
"""Get the condensation from a view."""
|
||||
|
||||
@override
|
||||
def condensed_history(self, state: State) -> list[Event]:
|
||||
# The history should grow monotonically -- if it doesn't, something has
|
||||
# truncated the history and we need to reset our tracking.
|
||||
if len(state.history) < self._last_history_length:
|
||||
self._condensation = []
|
||||
self._last_history_length = 0
|
||||
def condense(self, events: list[Event]) -> View | Condensation:
|
||||
# Convert the state to a view. This might require some condenser-specific logic.
|
||||
view = View.from_events(events)
|
||||
|
||||
new_events = state.history[self._last_history_length :]
|
||||
# If we trigger the condenser-specific condensation threshold, compute and return
|
||||
# the condensation.
|
||||
if self.should_condense(view):
|
||||
return self.get_condensation(view)
|
||||
|
||||
with self.metadata_batch(state):
|
||||
results = self.condense(self._condensation + new_events)
|
||||
|
||||
self._condensation = results
|
||||
self._last_history_length = len(state.history)
|
||||
|
||||
return results
|
||||
# Otherwise we're safe to just return the view.
|
||||
else:
|
||||
return view
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from openhands.core.config.condenser_config import AmortizedForgettingCondenserConfig
|
||||
from openhands.events.event import Event
|
||||
from openhands.memory.condenser.condenser import RollingCondenser
|
||||
from openhands.events.action.agent import CondensationAction
|
||||
from openhands.memory.condenser.condenser import (
|
||||
Condensation,
|
||||
RollingCondenser,
|
||||
View,
|
||||
)
|
||||
|
||||
|
||||
class AmortizedForgettingCondenser(RollingCondenser):
|
||||
@@ -32,18 +36,25 @@ class AmortizedForgettingCondenser(RollingCondenser):
|
||||
|
||||
super().__init__()
|
||||
|
||||
def condense(self, events: list[Event]) -> list[Event]:
|
||||
"""Apply the amortized forgetting strategy to the given list of events."""
|
||||
if len(events) <= self.max_size:
|
||||
return events
|
||||
|
||||
def get_condensation(self, view: View) -> Condensation:
|
||||
target_size = self.max_size // 2
|
||||
head = events[: self.keep_first]
|
||||
head = view[: self.keep_first]
|
||||
|
||||
events_from_tail = target_size - len(head)
|
||||
tail = events[-events_from_tail:]
|
||||
tail = view[-events_from_tail:]
|
||||
|
||||
return head + tail
|
||||
event_ids_to_keep = {event.id for event in head + tail}
|
||||
event_ids_to_forget = {event.id for event in view} - event_ids_to_keep
|
||||
|
||||
event = CondensationAction(
|
||||
forgotten_events_start_id=min(event_ids_to_forget),
|
||||
forgotten_events_end_id=max(event_ids_to_forget),
|
||||
)
|
||||
|
||||
return Condensation(action=event)
|
||||
|
||||
def should_condense(self, view: View) -> bool:
|
||||
return len(view) > self.max_size
|
||||
|
||||
@classmethod
|
||||
def from_config(
|
||||
|
||||
@@ -4,7 +4,7 @@ from openhands.core.config.condenser_config import BrowserOutputCondenserConfig
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.observation import BrowserOutputObservation
|
||||
from openhands.events.observation.agent import AgentCondensationObservation
|
||||
from openhands.memory.condenser.condenser import Condenser
|
||||
from openhands.memory.condenser.condenser import Condensation, Condenser, View
|
||||
|
||||
|
||||
class BrowserOutputCondenser(Condenser):
|
||||
@@ -17,7 +17,7 @@ class BrowserOutputCondenser(Condenser):
|
||||
self.attention_window = attention_window
|
||||
super().__init__()
|
||||
|
||||
def condense(self, events: list[Event]) -> list[Event]:
|
||||
def condense(self, events: list[Event]) -> View | Condensation:
|
||||
"""Replace the content of browser observations outside of the attention window with a placeholder."""
|
||||
results: list[Event] = []
|
||||
cnt: int = 0
|
||||
@@ -36,7 +36,7 @@ class BrowserOutputCondenser(Condenser):
|
||||
if isinstance(event, BrowserOutputObservation):
|
||||
cnt += 1
|
||||
|
||||
return list(reversed(results))
|
||||
return View(events=list(reversed(results)))
|
||||
|
||||
@classmethod
|
||||
def from_config(
|
||||
|
||||
@@ -4,9 +4,13 @@ from litellm import supports_response_schema
|
||||
from pydantic import BaseModel
|
||||
|
||||
from openhands.core.config.condenser_config import LLMAttentionCondenserConfig
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.action.agent import CondensationAction
|
||||
from openhands.llm.llm import LLM
|
||||
from openhands.memory.condenser.condenser import RollingCondenser
|
||||
from openhands.memory.condenser.condenser import (
|
||||
Condensation,
|
||||
RollingCondenser,
|
||||
View,
|
||||
)
|
||||
|
||||
|
||||
class ImportantEventSelection(BaseModel):
|
||||
@@ -43,15 +47,11 @@ class LLMAttentionCondenser(RollingCondenser):
|
||||
|
||||
super().__init__()
|
||||
|
||||
def condense(self, events: list[Event]) -> list[Event]:
|
||||
"""If the history is too long, use an LLM to select the most important events."""
|
||||
if len(events) <= self.max_size:
|
||||
return events
|
||||
|
||||
def get_condensation(self, view: View) -> Condensation:
|
||||
target_size = self.max_size // 2
|
||||
head = events[: self.keep_first]
|
||||
head_event_ids = [event.id for event in view.events[: self.keep_first]]
|
||||
|
||||
events_from_tail = target_size - len(head)
|
||||
events_from_tail = target_size - len(head_event_ids)
|
||||
|
||||
message: str = """You will be given a list of actions, observations, and thoughts from a coding agent.
|
||||
Each item in the list has an identifier. Please sort the identifiers in order of how important the
|
||||
@@ -66,7 +66,7 @@ class LLMAttentionCondenser(RollingCondenser):
|
||||
'content': f'<ID>{e.id}</ID>\n<CONTENT>{e.message}</CONTENT>',
|
||||
'role': 'user',
|
||||
}
|
||||
for e in events
|
||||
for e in view
|
||||
],
|
||||
],
|
||||
response_format={
|
||||
@@ -82,27 +82,35 @@ class LLMAttentionCondenser(RollingCondenser):
|
||||
response.choices[0].message.content
|
||||
).ids
|
||||
|
||||
self.add_metadata('all_event_ids', [event.id for event in events])
|
||||
self.add_metadata('response_ids', response_ids)
|
||||
self.add_metadata('metrics', self.llm.metrics.get())
|
||||
|
||||
# Filter out any IDs from the head and trim the results down
|
||||
head_ids = [event.id for event in head]
|
||||
response_ids = [
|
||||
response_id for response_id in response_ids if response_id not in head_ids
|
||||
response_id
|
||||
for response_id in response_ids
|
||||
if response_id not in head_event_ids
|
||||
][:events_from_tail]
|
||||
|
||||
# If the response IDs aren't _long_ enough, iterate backwards through the events and add any unfound IDs to the list.
|
||||
for event in reversed(events):
|
||||
for event in reversed(view):
|
||||
if len(response_ids) >= events_from_tail:
|
||||
break
|
||||
if event.id not in response_ids:
|
||||
response_ids.append(event.id)
|
||||
|
||||
# Grab the events associated with the response IDs
|
||||
tail = [event for event in events if event.id in response_ids]
|
||||
# Now that we've found the right number of events to keep, convert this into a list of events to forget.
|
||||
event = CondensationAction(
|
||||
forgotten_event_ids=[
|
||||
event.id
|
||||
for event in view
|
||||
if event.id not in response_ids and event.id not in head_event_ids
|
||||
],
|
||||
)
|
||||
|
||||
return head + tail
|
||||
return Condensation(action=event)
|
||||
|
||||
def should_condense(self, view: View) -> bool:
|
||||
return len(view) > self.max_size
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config: LLMAttentionCondenserConfig) -> LLMAttentionCondenser:
|
||||
|
||||
@@ -2,10 +2,14 @@ from __future__ import annotations
|
||||
|
||||
from openhands.core.config.condenser_config import LLMSummarizingCondenserConfig
|
||||
from openhands.core.message import Message, TextContent
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.action.agent import CondensationAction
|
||||
from openhands.events.observation.agent import AgentCondensationObservation
|
||||
from openhands.llm import LLM
|
||||
from openhands.memory.condenser.condenser import RollingCondenser
|
||||
from openhands.memory.condenser.condenser import (
|
||||
Condensation,
|
||||
RollingCondenser,
|
||||
View,
|
||||
)
|
||||
|
||||
|
||||
class LLMSummarizingCondenser(RollingCondenser):
|
||||
@@ -32,26 +36,22 @@ class LLMSummarizingCondenser(RollingCondenser):
|
||||
|
||||
super().__init__()
|
||||
|
||||
def condense(self, events: list[Event]) -> list[Event]:
|
||||
"""Apply the amortized forgetting strategy with LLM summarization to the given list of events."""
|
||||
if len(events) <= self.max_size:
|
||||
return events
|
||||
|
||||
head = events[: self.keep_first]
|
||||
|
||||
def get_condensation(self, view: View) -> Condensation:
|
||||
head = view[: self.keep_first]
|
||||
target_size = self.max_size // 2
|
||||
events_from_tail = target_size - len(head)
|
||||
tail = events[-events_from_tail:]
|
||||
# Number of events to keep from the tail -- target size, minus however many
|
||||
# prefix events from the head, minus one for the summarization event
|
||||
events_from_tail = target_size - len(head) - 1
|
||||
|
||||
summary_event = (
|
||||
events[self.keep_first]
|
||||
if isinstance(events[self.keep_first], AgentCondensationObservation)
|
||||
view[self.keep_first]
|
||||
if isinstance(view[self.keep_first], AgentCondensationObservation)
|
||||
else AgentCondensationObservation('No events summarized')
|
||||
)
|
||||
|
||||
# Identify events to be forgotten (those not in head or tail)
|
||||
forgotten_events = []
|
||||
for event in events[self.keep_first : -events_from_tail]:
|
||||
for event in view[self.keep_first : -events_from_tail]:
|
||||
if not isinstance(event, AgentCondensationObservation):
|
||||
forgotten_events.append(event)
|
||||
|
||||
@@ -82,11 +82,11 @@ CHANGES: str(val) replaces f"{val:.16G}"
|
||||
DEPS: None modified
|
||||
INTENT: Fix precision while maintaining FITS compliance"""
|
||||
|
||||
prompt + '\n\n'
|
||||
prompt += '\n\n'
|
||||
|
||||
prompt += ('\n' + summary_event.message + '\n') if summary_event.message else ''
|
||||
|
||||
prompt + '\n\n'
|
||||
prompt += '\n\n'
|
||||
|
||||
for forgotten_event in forgotten_events:
|
||||
prompt += str(forgotten_event) + '\n\n'
|
||||
@@ -101,7 +101,17 @@ INTENT: Fix precision while maintaining FITS compliance"""
|
||||
self.add_metadata('response', response.model_dump())
|
||||
self.add_metadata('metrics', self.llm.metrics.get())
|
||||
|
||||
return head + [AgentCondensationObservation(summary)] + tail
|
||||
return Condensation(
|
||||
action=CondensationAction(
|
||||
forgotten_events_start_id=min(event.id for event in forgotten_events),
|
||||
forgotten_events_end_id=max(event.id for event in forgotten_events),
|
||||
summary=summary,
|
||||
summary_offset=self.keep_first,
|
||||
)
|
||||
)
|
||||
|
||||
def should_condense(self, view: View) -> bool:
|
||||
return len(view) > self.max_size
|
||||
|
||||
@classmethod
|
||||
def from_config(
|
||||
|
||||
@@ -2,15 +2,15 @@ from __future__ import annotations
|
||||
|
||||
from openhands.core.config.condenser_config import NoOpCondenserConfig
|
||||
from openhands.events.event import Event
|
||||
from openhands.memory.condenser.condenser import Condenser
|
||||
from openhands.memory.condenser.condenser import Condensation, Condenser, View
|
||||
|
||||
|
||||
class NoOpCondenser(Condenser):
|
||||
"""A condenser that does nothing to the event sequence."""
|
||||
|
||||
def condense(self, events: list[Event]) -> list[Event]:
|
||||
def condense(self, events: list[Event]) -> View | Condensation:
|
||||
"""Returns the list of events unchanged."""
|
||||
return events
|
||||
return View(events=events)
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config: NoOpCondenserConfig) -> NoOpCondenser:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user