Compare commits

..

14 Commits

Author SHA1 Message Date
openhands 14bec04b4b Simplify setup script handling and remove session.py changes 2025-01-02 21:48:16 +00:00
openhands 3bdaf0abaa Use runtime file operations for setup script 2025-01-02 21:43:23 +00:00
openhands d0ba5882a0 Simplify setup script handling 2025-01-02 21:42:36 +00:00
openhands a1b40f1550 Add support for .openhands/setup.sh script
- Add maybe_run_setup_script method to Runtime class
- Run setup script after cloning repository
- Support both workspace and repository setup scripts
2025-01-02 21:13:21 +00:00
openhands 8f14520a87 Add support for .openhands/setup.sh script
- Add _run_setup_script method to Session class to run setup.sh after runtime initialization
- Add get_full_path method to FileStore interface and implementations
- Run setup script with proper permissions and error handling
2025-01-02 20:59:42 +00:00
mamoodi a1b59b6185 Minor README update, Headless and CLI doc changes (#5977) 2025-01-02 13:18:01 -05:00
mamoodi b73bac62f2 Fix CLI and Headless docs for after release (#5941) 2025-01-02 16:26:47 +00:00
mamoodi ee88af8563 Release 0.18.0 (#5974) 2025-01-02 11:01:11 -05:00
Robert Brennan f846b31eb8 Remove TaskAction functionality (#5959)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-01-02 15:11:45 +00:00
Robert Brennan 50a0b1d91d fix llm err handling (#5958) 2025-01-01 17:00:18 -05:00
dependabot[bot] 3d4d66a8c2 chore(deps-dev): bump llama-index from 0.12.8 to 0.12.9 in the llama group (#5955)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-01 16:15:31 +01:00
Robert Brennan f3885cadc1 Fix CLI and headless after changes to eventstream (#5949)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-01-01 06:05:35 +01:00
Engel Nyst 2ec2f2538f Load the event stream fix after it's no longer a dataclass (#5948) 2024-12-31 22:03:57 +00:00
Engel Nyst 40d8245089 Fix history loading when state was corrupt/non-existent (#5946)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-12-31 21:46:35 +00:00
56 changed files with 249 additions and 604 deletions
+1 -1
View File
@@ -100,7 +100,7 @@ poetry run pytest ./tests/unit/test_*.py
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image by
setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.17-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.18-nikolaik`
## Develop inside Docker container
+7 -7
View File
@@ -43,17 +43,17 @@ See the [Installation](https://docs.all-hands.dev/modules/usage/installation) gu
system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-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.17
docker.all-hands.dev/all-hands-ai/openhands:0.18
```
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
@@ -64,16 +64,16 @@ works best, but you have [many options](https://docs.all-hands.dev/modules/usage
---
You can also [connect OpenHands to your local filesystem](https://docs.all-hands.dev/modules/usage/runtimes),
You can also [connect OpenHands to your local filesystem](https://docs.all-hands.dev/modules/usage/runtimes#connecting-to-your-filesystem),
run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode),
interact with it via a [friendly CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),
or run it on tagged issues with [a github action](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/resolver/README.md).
or run it on tagged issues with [a github action](https://docs.all-hands.dev/modules/usage/how-to/github-action).
Visit [Installation](https://docs.all-hands.dev/modules/usage/installation) for more information and setup instructions.
> [!CAUTION]
> OpenHands is meant to be run by a single user on their local workstation.
> It is not appropriate for multi-tenant deployments, where multiple users share the same instance--there is no built-in isolation or scalability.
> It is not appropriate for multi-tenant deployments where multiple users share the same instance. There is no built-in isolation or scalability.
>
> If you're interested in running OpenHands in a multi-tenant environment, please
> [get in touch with us](https://docs.google.com/forms/d/e/1FAIpQLSet3VbGaz8z32gW9Wm-Grl4jpt5WgMXPgJ4EDPVmCETCBpJtQ/viewform)
@@ -86,7 +86,7 @@ Having issues? The [Troubleshooting Guide](https://docs.all-hands.dev/modules/us
## 📖 Documentation
To learn more about the project, and for tips on using OpenHands,
**check out our [documentation](https://docs.all-hands.dev/modules/usage/getting-started)**.
check out our [documentation](https://docs.all-hands.dev/modules/usage/getting-started).
There you'll find resources on how to use different LLM providers,
troubleshooting resources, and advanced configuration options.
+1 -1
View File
@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.17-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.18-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+1 -1
View File
@@ -11,7 +11,7 @@ services:
- BACKEND_HOST=${BACKEND_HOST:-"0.0.0.0"}
- SANDBOX_API_HOSTNAME=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.17-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.18-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
@@ -52,7 +52,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-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.17 \
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
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.17-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-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.17 \
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
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.17-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-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.17
docker.all-hands.dev/all-hands-ai/openhands:0.18
```
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.17-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-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.17-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-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.17 \
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
python -m openhands.core.cli
```
@@ -47,7 +47,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-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.17 \
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
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.17-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-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.17
docker.all-hands.dev/all-hands-ai/openhands:0.18
```
你也可以在可脚本化的[无头模式](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.17-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```
+10 -24
View File
@@ -6,10 +6,9 @@ This mode is different from the [headless mode](headless-mode), which is non-int
## With Python
To start an interactive OpenHands session via the command line, follow these steps:
To start an interactive OpenHands session via the command line:
1. Ensure you have followed the [Development setup instructions](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md).
2. Run the following command:
```bash
@@ -21,45 +20,32 @@ This command will start an interactive session where you can input tasks and rec
You'll need to be sure to set your model, API key, and other settings via environment variables
[or the `config.toml` file](https://github.com/All-Hands-AI/OpenHands/blob/main/config.template.toml).
## With Docker
To run OpenHands in CLI mode with Docker, follow these steps:
To run OpenHands in CLI mode with Docker:
1. Set `WORKSPACE_BASE` to the directory you want OpenHands to edit:
1. Set the following environmental variables in your terminal:
```bash
WORKSPACE_BASE=$(pwd)/workspace
```
* `WORKSPACE_BASE` to the directory you want OpenHands to edit (Ex: `export WORKSPACE_BASE=$(pwd)/workspace`).
* `LLM_MODEL` to the model to use (Ex: `export LLM_MODEL="anthropic/claude-3-5-sonnet-20241022"`).
* `LLM_API_KEY` to the API key (Ex: `export LLM_API_KEY="sk_test_12345"`).
2. Set `LLM_MODEL` to the model you want to use:
```bash
LLM_MODEL="anthropic/claude-3-5-sonnet-20241022"
```
3. Set `LLM_API_KEY` to your API key:
```bash
LLM_API_KEY="sk_test_12345"
```
4. Run the following Docker command:
2. Run the following Docker command:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
-e LLM_MODEL=$LLM_MODEL \
-v $WORKSPACE_BASE:/opt/workspace_base \
-v /var/run/docker.sock:/var/run/docker.sock \
-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.17 \
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
python -m openhands.core.cli
```
+22 -25
View File
@@ -7,12 +7,11 @@ This is different from [CLI Mode](cli-mode), which is interactive, and better fo
## With Python
To run OpenHands in headless mode with Python,
[follow the Development setup instructions](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md),
and then run:
To run OpenHands in headless mode with Python:
1. Ensure you have followed the [Development setup instructions](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md).
2. Run the following command:
```bash
poetry run python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
poetry run python -m openhands.core.main -t "write a bash script that prints hi"
```
You'll need to be sure to set your model, API key, and other settings via environment variables
@@ -20,31 +19,20 @@ You'll need to be sure to set your model, API key, and other settings via enviro
## With Docker
1. Set `WORKSPACE_BASE` to the directory you want OpenHands to edit:
To run OpenHands in Headless mode with Docker:
```bash
WORKSPACE_BASE=$(pwd)/workspace
```
1. Set the following environmental variables in your terminal:
2. Set `LLM_MODEL` to the model you want to use:
* `WORKSPACE_BASE` to the directory you want OpenHands to edit (Ex: `export WORKSPACE_BASE=$(pwd)/workspace`).
* `LLM_MODEL` to the model to use (Ex: `export LLM_MODEL="anthropic/claude-3-5-sonnet-20241022"`).
* `LLM_API_KEY` to the API key (Ex: `export LLM_API_KEY="sk_test_12345"`).
```bash
LLM_MODEL="anthropic/claude-3-5-sonnet-20241022"
```
3. Set `LLM_API_KEY` to your API key:
```bash
LLM_API_KEY="sk_test_12345"
```
4. Run the following Docker command:
2. Run the following Docker command:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -52,8 +40,17 @@ docker run -it \
-e LOG_ALL_EVENTS=true \
-v $WORKSPACE_BASE:/opt/workspace_base \
-v /var/run/docker.sock:/var/run/docker.sock \
-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.17 \
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
python -m openhands.core.main -t "write a bash script that prints hi"
```
## Advanced Headless Configurations
To view all available configuration options for headless mode, run the Python command with the `--help` flag.
### Additional Logs
For the headless mode to log all the agent actions, in your terminal run: `export LOG_ALL_EVENTS=true`
+9 -4
View File
@@ -11,20 +11,25 @@
The easiest way to run OpenHands is in Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-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.17
docker.all-hands.dev/all-hands-ai/openhands:0.18
```
You can also run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), as an [interactive CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), or using the [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action).
You'll find OpenHands running at http://localhost:3000!
You can also [connect OpenHands to your local filesystem](https://docs.all-hands.dev/modules/usage/runtimes#connecting-to-your-filesystem),
run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode),
interact with it via a [friendly CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),
or run it on tagged issues with [a github action](https://docs.all-hands.dev/modules/usage/how-to/github-action).
## Setup
+1 -1
View File
@@ -16,7 +16,7 @@ some flags being passed to `docker run` that make this possible:
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.17.0",
"version": "0.18.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.17.0",
"version": "0.18.0",
"dependencies": {
"@monaco-editor/react": "^4.7.0-rc.0",
"@nextui-org/react": "^2.6.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.17.0",
"version": "0.18.0",
"private": true,
"type": "module",
"engines": {
-6
View File
@@ -33,12 +33,6 @@ enum ActionType {
// Reject a request from user or another agent.
REJECT = "reject",
// Adds a task to the plan.
ADD_TASK = "add_task",
// Updates a task in the plan.
MODIFY_TASK = "modify_task",
// Changes the state of the agent, e.g. to paused or running
CHANGE_AGENT_STATE = "change_agent_state",
}
-23
View File
@@ -78,27 +78,6 @@ export interface BrowseInteractiveAction
};
}
export interface AddTaskAction extends OpenHandsActionEvent<"add_task"> {
source: "agent";
timeout: number;
args: {
parent: string;
goal: string;
subtasks: unknown[];
thought: string;
};
}
export interface ModifyTaskAction extends OpenHandsActionEvent<"modify_task"> {
source: "agent";
timeout: number;
args: {
task_id: string;
state: string;
thought: string;
};
}
export interface FileReadAction extends OpenHandsActionEvent<"read"> {
source: "agent";
args: {
@@ -144,6 +123,4 @@ export type OpenHandsAction =
| FileReadAction
| FileEditAction
| FileWriteAction
| AddTaskAction
| ModifyTaskAction
| RejectAction;
-2
View File
@@ -10,8 +10,6 @@ export type OpenHandsEventType =
| "browse"
| "browse_interactive"
| "reject"
| "add_task"
| "modify_task"
| "finish"
| "error";
-2
View File
@@ -12,12 +12,10 @@ from openhands.agenthub import ( # noqa: E402
codeact_agent,
delegator_agent,
dummy_agent,
planner_agent,
)
__all__ = [
'codeact_agent',
'planner_agent',
'delegator_agent',
'dummy_agent',
'browsing_agent',
+14 -62
View File
@@ -1,4 +1,4 @@
from typing import TypedDict, Union
from typing import TypedDict
from openhands.controller.agent import Agent
from openhands.controller.state.state import State
@@ -6,7 +6,6 @@ from openhands.core.config import AgentConfig
from openhands.core.schema import AgentState
from openhands.events.action import (
Action,
AddTaskAction,
AgentFinishAction,
AgentRejectAction,
BrowseInteractiveAction,
@@ -15,10 +14,10 @@ from openhands.events.action import (
FileReadAction,
FileWriteAction,
MessageAction,
ModifyTaskAction,
)
from openhands.events.observation import (
AgentStateChangedObservation,
BrowserOutputObservation,
CmdOutputObservation,
FileReadObservation,
FileWriteObservation,
@@ -49,20 +48,6 @@ class DummyAgent(Agent):
def __init__(self, llm: LLM, config: AgentConfig):
super().__init__(llm, config)
self.steps: list[ActionObs] = [
{
'action': AddTaskAction(
parent='None', goal='check the current directory'
),
'observations': [],
},
{
'action': AddTaskAction(parent='0', goal='run ls'),
'observations': [],
},
{
'action': ModifyTaskAction(task_id='0', state='in_progress'),
'observations': [],
},
{
'action': MessageAction('Time to get started!'),
'observations': [],
@@ -105,7 +90,12 @@ class DummyAgent(Agent):
{
'action': BrowseURLAction(url='https://google.com'),
'observations': [
# BrowserOutputObservation('<html><body>Simulated Google page</body></html>',url='https://google.com',screenshot=''),
BrowserOutputObservation(
'<html><body>Simulated Google page</body></html>',
url='https://google.com',
screenshot='',
trigger_by_action='',
),
],
},
{
@@ -113,7 +103,12 @@ class DummyAgent(Agent):
browser_actions='goto("https://google.com")'
),
'observations': [
# BrowserOutputObservation('<html><body>Simulated Google page after interaction</body></html>',url='https://google.com',screenshot=''),
BrowserOutputObservation(
'<html><body>Simulated Google page after interaction</body></html>',
url='https://google.com',
screenshot='',
trigger_by_action='',
),
],
},
{
@@ -135,30 +130,6 @@ class DummyAgent(Agent):
current_step = self.steps[state.iteration]
action = current_step['action']
# If the action is AddTaskAction or ModifyTaskAction, update the parent ID or task_id
if isinstance(action, AddTaskAction):
if action.parent == 'None':
action.parent = '' # Root task has no parent
elif action.parent == '0':
action.parent = state.root_task.id
elif action.parent.startswith('0.'):
action.parent = f'{state.root_task.id}{action.parent[1:]}'
elif isinstance(action, ModifyTaskAction):
if action.task_id == '0':
action.task_id = state.root_task.id
elif action.task_id.startswith('0.'):
action.task_id = f'{state.root_task.id}{action.task_id[1:]}'
# Ensure the task_id doesn't start with a dot
if action.task_id.startswith('.'):
action.task_id = action.task_id[1:]
elif isinstance(action, (BrowseURLAction, BrowseInteractiveAction)):
try:
return self.simulate_browser_action(action)
except (
Exception
): # This could be a specific exception for browser unavailability
return self.handle_browser_unavailable(action)
if state.iteration > 0:
prev_step = self.steps[state.iteration - 1]
@@ -190,22 +161,3 @@ class DummyAgent(Agent):
)
return action
def simulate_browser_action(
self, action: Union[BrowseURLAction, BrowseInteractiveAction]
) -> Action:
# Instead of simulating, we'll reject the browser action
return self.handle_browser_unavailable(action)
def handle_browser_unavailable(
self, action: Union[BrowseURLAction, BrowseInteractiveAction]
) -> Action:
# Create a message action to inform that browsing is not available
message = 'Browser actions are not available in the DummyAgent environment.'
if isinstance(action, BrowseURLAction):
message += f' Unable to browse URL: {action.url}'
elif isinstance(action, BrowseInteractiveAction):
message += (
f' Unable to perform interactive browsing: {action.browser_actions}'
)
return MessageAction(content=message)
@@ -1,4 +0,0 @@
from openhands.agenthub.planner_agent.agent import PlannerAgent
from openhands.controller.agent import Agent
Agent.register('PlannerAgent', PlannerAgent)
-53
View File
@@ -1,53 +0,0 @@
from openhands.agenthub.planner_agent.prompt import get_prompt_and_images
from openhands.agenthub.planner_agent.response_parser import PlannerResponseParser
from openhands.controller.agent import Agent
from openhands.controller.state.state import State
from openhands.core.config import AgentConfig
from openhands.core.message import ImageContent, Message, TextContent
from openhands.events.action import Action, AgentFinishAction
from openhands.llm.llm import LLM
class PlannerAgent(Agent):
VERSION = '1.0'
"""
The planner agent utilizes a special prompting strategy to create long term plans for solving problems.
The agent is given its previous action-observation pairs, current task, and hint based on last action taken at every step.
"""
response_parser = PlannerResponseParser()
def __init__(self, llm: LLM, config: AgentConfig):
"""Initialize the Planner Agent with an LLM
Parameters:
- llm (LLM): The llm to be used by this agent
"""
super().__init__(llm, config)
def step(self, state: State) -> Action:
"""Checks to see if current step is completed, returns AgentFinishAction if True.
Otherwise, creates a plan prompt and sends to model for inference, returning the result as the next action.
Parameters:
- state (State): The current state given the previous actions and observations
Returns:
- AgentFinishAction: If the last state was 'completed', 'verified', or 'abandoned'
- Action: The next action to take based on llm response
"""
if state.root_task.state in [
'completed',
'verified',
'abandoned',
]:
return AgentFinishAction()
prompt, image_urls = get_prompt_and_images(
state, self.llm.config.max_message_chars
)
content = [TextContent(text=prompt)]
if self.llm.vision_is_active() and image_urls:
content.append(ImageContent(image_urls=image_urls))
message = Message(role='user', content=content)
resp = self.llm.completion(messages=self.llm.format_messages_for_llm(message))
return self.response_parser.parse(resp)
-191
View File
@@ -1,191 +0,0 @@
from openhands.controller.state.state import State
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema import ActionType
from openhands.core.utils import json
from openhands.events.action import (
Action,
NullAction,
)
from openhands.events.serialization.action import action_from_dict
from openhands.events.serialization.event import event_to_memory
HISTORY_SIZE = 20
prompt = """
# Task
You're a diligent software engineer AI. You can't see, draw, or interact with a
browser, but you can read and write files, and you can run commands, and you can think.
You've been given the following task:
%(task)s
## Plan
As you complete this task, you're building a plan and keeping
track of your progress. Here's a JSON representation of your plan:
%(plan)s
%(plan_status)s
You're responsible for managing this plan and the status of tasks in
it, by using the `add_task` and `modify_task` actions described below.
If the History below contradicts the state of any of these tasks, you
MUST modify the task using the `modify_task` action described below.
Be sure NOT to duplicate any tasks. Do NOT use the `add_task` action for
a task that's already represented. Every task must be represented only once.
Tasks that are sequential MUST be siblings. They must be added in order
to their parent task.
If you mark a task as 'completed', 'verified', or 'abandoned',
all non-abandoned subtasks will be marked the same way.
So before closing a task this way, you MUST not only be sure that it has
been completed successfully--you must ALSO be sure that all its subtasks
are ready to be marked the same way.
If, and only if, ALL tasks have already been marked verified,
you MUST respond with the `finish` action.
## History
Here is a recent history of actions you've taken in service of this plan,
as well as observations you've made. This only includes the MOST RECENT
ten actions--more happened before that.
%(history)s
Your most recent action is at the bottom of that history.
## Action
What is your next thought or action? Your response must be in JSON format.
It must be an object, and it must contain two fields:
* `action`, which is one of the actions below
* `args`, which is a map of key-value pairs, specifying the arguments for that action
* `read` - reads the content of a file. Arguments:
* `path` - the path of the file to read
* `write` - writes the content to a file. Arguments:
* `path` - the path of the file to write
* `content` - the content to write to the file
* `run` - runs a command on the command line in a Linux shell. Arguments:
* `command` - the command to run
* `browse` - opens a web page. Arguments:
* `url` - the URL to open
* `message` - make a plan, set a goal, record your thoughts, or ask for more input from the user. Arguments:
* `content` - the message to record
* `wait_for_response` - set to `true` to wait for the user to respond before proceeding
* `add_task` - add a task to your plan. Arguments:
* `parent` - the ID of the parent task (leave empty if it should go at the top level)
* `goal` - the goal of the task
* `subtasks` - a list of subtasks, each of which is a map with a `goal` key.
* `modify_task` - close a task. Arguments:
* `task_id` - the ID of the task to close
* `state` - set to 'in_progress' to start the task, 'completed' to finish it, 'verified' to assert that it was successful, 'abandoned' to give up on it permanently, or `open` to stop working on it for now.
* `finish` - if ALL of your tasks and subtasks have been verified or abandoned, and you're absolutely certain that you've completed your task and have tested your work, use the finish action to stop working.
You MUST take time to think in between read, write, run, and browse actions--do this with the `message` action.
You should never act twice in a row without thinking. But if your last several
actions are all `message` actions, you should consider taking a different action.
What is your next thought or action? Again, you must reply with JSON, and only with JSON.
%(hint)s
"""
def get_hint(latest_action_id: str) -> str:
"""Returns action type hint based on given action_id"""
hints = {
'': "You haven't taken any actions yet. Start by using `ls` to check out what files you're working with.",
ActionType.RUN: 'You should think about the command you just ran, what output it gave, and how that affects your plan.',
ActionType.READ: 'You should think about the file you just read, what you learned from it, and how that affects your plan.',
ActionType.WRITE: 'You just changed a file. You should think about how it affects your plan.',
ActionType.BROWSE: 'You should think about the page you just visited, and what you learned from it.',
ActionType.MESSAGE: "Look at your last thought in the history above. What does it suggest? Don't think anymore--take action.",
ActionType.ADD_TASK: 'You should think about the next action to take.',
ActionType.MODIFY_TASK: 'You should think about the next action to take.',
ActionType.SUMMARIZE: '',
ActionType.FINISH: '',
}
return hints.get(latest_action_id, '')
def get_prompt_and_images(
state: State, max_message_chars: int
) -> tuple[str, list[str] | None]:
"""Gets the prompt for the planner agent.
Formatted with the most recent action-observation pairs, current task, and hint based on last action
Parameters:
- state (State): The state of the current agent
Returns:
- str: The formatted string prompt with historical values
"""
# the plan
plan_str = json.dumps(state.root_task.to_dict(), indent=2)
# the history
history_dicts = []
latest_action: Action = NullAction()
# retrieve the latest HISTORY_SIZE events
for event_count, event in enumerate(reversed(state.history)):
if event_count >= HISTORY_SIZE:
break
if latest_action == NullAction() and isinstance(event, Action):
latest_action = event
history_dicts.append(event_to_memory(event, max_message_chars))
# history_dicts is in reverse order, lets fix it
history_dicts.reverse()
# and get it as a JSON string
history_str = json.dumps(history_dicts, indent=2)
# the plan status
current_task = state.root_task.get_current_task()
if current_task is not None:
plan_status = f"You're currently working on this task:\n{current_task.goal}."
if len(current_task.subtasks) == 0:
plan_status += "\nIf it's not achievable AND verifiable with a SINGLE action, you MUST break it down into subtasks NOW."
else:
plan_status = "You're not currently working on any tasks. Your next action MUST be to mark a task as in_progress."
# the hint, based on the last action
hint = get_hint(event_to_memory(latest_action, max_message_chars).get('action', ''))
logger.debug('HINT:\n' + hint, extra={'msg_type': 'DETAIL'})
# the last relevant user message (the task)
message, image_urls = state.get_current_user_intent()
# finally, fill in the prompt
return prompt % {
'task': message,
'plan': plan_str,
'history': history_str,
'hint': hint,
'plan_status': plan_status,
}, image_urls
def parse_response(response: str) -> Action:
"""Parses the model output to find a valid action to take
Parameters:
- response (str): A response from the model that potentially contains an Action.
Returns:
- Action: A valid next action to perform from model output
"""
action_dict = json.loads(response)
if 'contents' in action_dict:
# The LLM gets confused here. Might as well be robust
action_dict['content'] = action_dict.pop('contents')
action = action_from_dict(action_dict)
return action
@@ -1,37 +0,0 @@
from openhands.controller.action_parser import ResponseParser
from openhands.core.utils import json
from openhands.events.action import (
Action,
)
from openhands.events.serialization.action import action_from_dict
class PlannerResponseParser(ResponseParser):
def __init__(self):
super().__init__()
def parse(self, response: str) -> Action:
action_str = self.parse_response(response)
return self.parse_action(action_str)
def parse_response(self, response) -> str:
# get the next action from the response
return response['choices'][0]['message']['content']
def parse_action(self, action_str: str) -> Action:
"""Parses a string to find an action within it
Parameters:
- response (str): The string to be parsed
Returns:
- Action: The action that was found in the response string
"""
# attempt to load the JSON dict from the response
action_dict = json.loads(action_str)
if 'content' in action_dict:
# The LLM gets confused here. Might as well be robust
action_dict['contents'] = action_dict.pop('content')
return action_from_dict(action_dict)
+15 -10
View File
@@ -26,7 +26,6 @@ from openhands.events import EventSource, EventStream, EventStreamSubscriber
from openhands.events.action import (
Action,
ActionConfirmationStatus,
AddTaskAction,
AgentDelegateAction,
AgentFinishAction,
AgentRejectAction,
@@ -34,7 +33,6 @@ from openhands.events.action import (
CmdRunAction,
IPythonRunCellAction,
MessageAction,
ModifyTaskAction,
NullAction,
)
from openhands.events.event import Event
@@ -208,11 +206,12 @@ class AgentController:
reported = RuntimeError(
'There was an unexpected error while running the agent.'
)
if isinstance(e, litellm.LLMError):
if isinstance(e, litellm.AuthenticationError):
reported = e
await self._react_to_exception(reported)
def should_step(self, event: Event) -> bool:
print('should step?', event)
if isinstance(event, Action):
if isinstance(event, MessageAction) and event.source == EventSource.USER:
return True
@@ -261,12 +260,7 @@ class AgentController:
await self._handle_message_action(action)
elif isinstance(action, AgentDelegateAction):
await self.start_delegate(action)
elif isinstance(action, AddTaskAction):
self.state.root_task.add_subtask(
action.parent, action.goal, action.subtasks
)
elif isinstance(action, ModifyTaskAction):
self.state.root_task.set_subtask_state(action.task_id, action.state)
elif isinstance(action, AgentFinishAction):
self.state.outputs = action.outputs
self.state.metrics.merge(self.state.local_metrics)
@@ -542,7 +536,9 @@ class AgentController:
self.update_state_before_step()
action: Action = NullAction()
try:
print('STEP AGENT')
action = self.agent.step(self.state)
print('GOT ACTION', action)
if action is None:
raise LLMNoActionError('No action was returned')
except (
@@ -730,12 +726,20 @@ class AgentController:
# - the previous session, in which case it has history
# - from a parent agent, in which case it has no history
# - None / a new state
# If state is None, we create a brand new state and still load the event stream so we can restore the history
if state is None:
self.state = State(
inputs={},
max_iterations=max_iterations,
confirmation_mode=confirmation_mode,
)
self.state.start_id = 0
self.log(
'debug',
f'AgentController {self.id} - created new state. start_id: {self.state.start_id}',
)
else:
self.state = state
@@ -747,7 +751,8 @@ class AgentController:
f'AgentController {self.id} initializing history from event {self.state.start_id}',
)
self._init_history()
# Always load from the event stream to avoid losing history
self._init_history()
def _init_history(self) -> None:
"""Initializes the agent's history from the event stream.
+7 -6
View File
@@ -91,7 +91,7 @@ def display_event(event: Event, config: AppConfig):
display_confirmation(event.confirmation_state)
async def main():
async def main(loop):
"""Runs the agent in CLI mode"""
parser = get_parser()
@@ -112,7 +112,7 @@ async def main():
logger.setLevel(logging.WARNING)
config = load_app_config(config_file=args.config_file)
sid = 'cli'
sid = str(uuid4())
agent_cls: Type[Agent] = Agent.get_cls(config.default_agent)
agent_config = config.get_agent_config(config.default_agent)
@@ -150,7 +150,6 @@ async def main():
async def prompt_for_next_task():
# Run input() in a thread pool to avoid blocking the event loop
loop = asyncio.get_event_loop()
next_message = await loop.run_in_executor(
None, lambda: input('How can I help? >> ')
)
@@ -165,13 +164,12 @@ async def main():
event_stream.add_event(action, EventSource.USER)
async def prompt_for_user_confirmation():
loop = asyncio.get_event_loop()
user_confirmation = await loop.run_in_executor(
None, lambda: input('Confirm action (possible security risk)? (y/n) >> ')
)
return user_confirmation.lower() == 'y'
async def on_event(event: Event):
async def on_event_async(event: Event):
display_event(event, config)
if isinstance(event, AgentStateChangedObservation):
if event.agent_state in [
@@ -193,6 +191,9 @@ async def main():
ChangeAgentStateAction(AgentState.USER_REJECTED), EventSource.USER
)
def on_event(event: Event) -> None:
loop.create_task(on_event_async(event))
event_stream.subscribe(EventStreamSubscriber.MAIN, on_event, str(uuid4()))
await runtime.connect()
@@ -208,7 +209,7 @@ if __name__ == '__main__':
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(main())
loop.run_until_complete(main(loop))
except KeyboardInterrupt:
print('Received keyboard interrupt, shutting down...')
except ConnectionRefusedError as e:
+1 -1
View File
@@ -385,7 +385,7 @@ def get_parser() -> argparse.ArgumentParser:
parser.add_argument(
'-n',
'--name',
default='default',
default='',
type=str,
help='Name for the session',
)
+1 -1
View File
@@ -182,7 +182,7 @@ async def run_controller(
# init with the provided actions
event_stream.add_event(initial_user_action, EventSource.USER)
async def on_event(event: Event):
def on_event(event: Event):
if isinstance(event, AgentStateChangedObservation):
if event.agent_state == AgentState.AWAITING_USER_INPUT:
if exit_on_message:
-4
View File
@@ -62,10 +62,6 @@ class ActionTypeSchema(BaseModel):
SUMMARIZE: str = Field(default='summarize')
ADD_TASK: str = Field(default='add_task')
MODIFY_TASK: str = Field(default='modify_task')
PAUSE: str = Field(default='pause')
"""Pauses the task.
"""
-3
View File
@@ -15,7 +15,6 @@ from openhands.events.action.files import (
FileWriteAction,
)
from openhands.events.action.message import MessageAction
from openhands.events.action.tasks import AddTaskAction, ModifyTaskAction
__all__ = [
'Action',
@@ -30,8 +29,6 @@ __all__ = [
'AgentRejectAction',
'AgentDelegateAction',
'AgentSummarizeAction',
'AddTaskAction',
'ModifyTaskAction',
'ChangeAgentStateAction',
'IPythonRunCellAction',
'MessageAction',
-29
View File
@@ -1,29 +0,0 @@
from dataclasses import dataclass, field
from openhands.core.schema import ActionType
from openhands.events.action.action import Action
@dataclass
class AddTaskAction(Action):
parent: str
goal: str
subtasks: list = field(default_factory=list)
thought: str = ''
action: str = ActionType.ADD_TASK
@property
def message(self) -> str:
return f'Added task: {self.goal}'
@dataclass
class ModifyTaskAction(Action):
task_id: str
state: str
thought: str = ''
action: str = ActionType.MODIFY_TASK
@property
def message(self) -> str:
return f'Set task {self.task_id} to {self.state}'
-3
View File
@@ -18,7 +18,6 @@ from openhands.events.action.files import (
FileWriteAction,
)
from openhands.events.action.message import MessageAction
from openhands.events.action.tasks import AddTaskAction, ModifyTaskAction
actions = (
NullAction,
@@ -32,8 +31,6 @@ actions = (
AgentFinishAction,
AgentRejectAction,
AgentDelegateAction,
AddTaskAction,
ModifyTaskAction,
ChangeAgentStateAction,
MessageAction,
)
+20 -3
View File
@@ -74,6 +74,9 @@ class EventStream:
self._lock = threading.Lock()
self._cur_id = 0
# load the stream
self.__post_init__()
def __post_init__(self) -> None:
try:
events = self.file_store.list(get_conversation_events_dir(self.sid))
@@ -226,10 +229,24 @@ class EventStream:
for callback_id in callbacks:
callback = callbacks[callback_id]
pool = self._thread_pools[key][callback_id]
pool.submit(callback, event)
future = pool.submit(callback, event)
future.add_done_callback(self._make_error_handler(callback_id, key))
def _callback(self, callback: Callable, event: Event):
asyncio.run(callback(event))
def _make_error_handler(self, callback_id: str, subscriber_id: str):
def _handle_callback_error(fut):
try:
# This will raise any exception that occurred during callback execution
fut.result()
except Exception as e:
logger.error(
f'Error in event callback {callback_id} for subscriber {subscriber_id}: {str(e)}',
exc_info=True,
stack_info=True,
)
# Re-raise in the main thread so the error is not swallowed
raise e
return _handle_callback_error
def filtered_events_by_source(self, source: EventSource):
for event in self.get_events():
+1 -1
View File
@@ -202,7 +202,7 @@ async def process_issue(
runtime = create_runtime(config)
await runtime.connect()
async def on_event(evt):
def on_event(evt):
logger.info(evt)
runtime.event_stream.subscribe(EventStreamSubscriber.MAIN, on_event, str(uuid4()))
+18
View File
@@ -219,6 +219,24 @@ class Runtime(FileEditRuntimeMixin):
self.log('info', f'Cloning repo: {selected_repository}')
self.run_action(action)
def maybe_run_setup_script(self, selected_repository: str | None):
"""Run .openhands/setup.sh if it exists in the workspace or repository."""
setup_script = '.openhands/setup.sh'
if selected_repository:
repo_name = selected_repository.split('/')[1]
setup_script = f'{repo_name}/.openhands/setup.sh'
# Try to read the setup script
read_obs = self.read(FileReadAction(path=setup_script))
if isinstance(read_obs, ErrorObservation):
return
# Execute the script
action = CmdRunAction(f'chmod +x {setup_script} && {setup_script}')
obs = self.run_action(action)
if isinstance(obs, CmdOutputObservation) and obs.exit_code != 0:
self.log('error', f'Setup script failed: {obs.content}')
def get_custom_microagents(self, selected_repository: str | None) -> list[str]:
custom_microagents_content = []
custom_microagents_dir = Path('.openhands') / 'microagents'
+5
View File
@@ -5,6 +5,11 @@ class E2BFileStore(FileStore):
def __init__(self, filesystem):
self.filesystem = filesystem
def get_full_path(self, path: str) -> str:
if path.startswith('/'):
path = path[1:]
return path
def write(self, path: str, contents: str) -> None:
self.filesystem.write(path, contents)
-1
View File
@@ -49,7 +49,6 @@ def read_llm_models():
def read_llm_agents():
return [
'CodeActAgent',
'PlannerAgent',
]
+20 -4
View File
@@ -202,6 +202,7 @@ class AgentSession:
return
self.runtime.clone_repo(github_token, selected_repository)
self.runtime.maybe_run_setup_script(selected_repository)
if agent.prompt_manager:
microagents = await call_sync_from_async(
self.runtime.get_custom_microagents, selected_repository
@@ -269,11 +270,26 @@ class AgentSession:
headless_mode=False,
status_callback=self._status_callback,
)
# Note: We now attempt to restore the state from session here,
# but if it fails, we fall back to None and still initialize the controller
# with a fresh state. That way, the controller will always load events from the event stream
# even if the state file was corrupt.
restored_state = None
try:
agent_state = State.restore_from_session(self.sid, self.file_store)
controller.set_initial_state(agent_state, max_iterations, confirmation_mode)
logger.debug(f'Restored agent state from session, sid: {self.sid}')
restored_state = State.restore_from_session(self.sid, self.file_store)
except Exception as e:
logger.debug(f'State could not be restored: {e}')
if self.event_stream.get_latest_event_id() > 0:
# if we have events, we should have a state
logger.warning(f'State could not be restored: {e}')
# Set the initial state through the controller.
controller.set_initial_state(restored_state, max_iterations, confirmation_mode)
if restored_state:
logger.debug(f'Restored agent state from session, sid: {self.sid}')
else:
logger.debug('New session state created.')
logger.debug('Agent controller initialized.')
return controller
+20 -27
View File
@@ -1,6 +1,5 @@
import asyncio
import json
import threading
import time
from dataclasses import dataclass, field
from uuid import uuid4
@@ -45,8 +44,7 @@ class SessionManager:
_local_agent_loops_by_sid: dict[str, Session] = field(default_factory=dict)
local_connection_id_to_session_id: dict[str, str] = field(default_factory=dict)
_last_alive_timestamps: dict[str, float] = field(default_factory=dict)
_redis_listen_thread: threading.Thread | None = None
_redis_listen_stop_event: threading.Event = field(default_factory=threading.Event)
_redis_listen_task: asyncio.Task | None = None
_session_is_running_checks: dict[str, _SessionIsRunningCheck] = field(
default_factory=dict
)
@@ -65,18 +63,14 @@ class SessionManager:
async def __aenter__(self):
redis_client = self._get_redis_client()
if redis_client:
self._redis_listen_stop_event.clear()
self._redis_listen_thread = threading.Thread(target=self._run_redis_subscribe)
self._redis_listen_thread.daemon = True
self._redis_listen_thread.start()
self._redis_listen_task = asyncio.create_task(self._redis_subscribe())
self._cleanup_task = asyncio.create_task(self._cleanup_detached_conversations())
return self
async def __aexit__(self, exc_type, exc_value, traceback):
if self._redis_listen_thread:
self._redis_listen_stop_event.set()
self._redis_listen_thread.join()
self._redis_listen_thread = None
if self._redis_listen_task:
self._redis_listen_task.cancel()
self._redis_listen_task = None
if self._cleanup_task:
self._cleanup_task.cancel()
self._cleanup_task = None
@@ -85,32 +79,31 @@ class SessionManager:
redis_client = getattr(self.sio.manager, 'redis', None)
return redis_client
def _run_redis_subscribe(self):
async def _redis_subscribe(self):
"""
We use a redis backchannel to send actions between server nodes.
This method runs in a separate thread.
We use a redis backchannel to send actions between server nodes
"""
logger.debug('_redis_subscribe')
redis_client = self._get_redis_client()
pubsub = redis_client.pubsub()
pubsub.subscribe('oh_event')
while not self._redis_listen_stop_event.is_set() and should_continue():
await pubsub.subscribe('oh_event')
while should_continue():
try:
message = pubsub.get_message(
message = await pubsub.get_message(
ignore_subscribe_messages=True, timeout=5
)
if message:
# Schedule the message processing in the event loop
asyncio.run_coroutine_threadsafe(
self._process_message(message),
asyncio.get_event_loop()
)
await self._process_message(message)
except asyncio.CancelledError:
return
except Exception:
logger.warning(
'error_reading_from_redis', exc_info=True, stack_info=True
)
time.sleep(1) # Avoid tight loop on error
try:
asyncio.get_running_loop()
logger.warning(
'error_reading_from_redis', exc_info=True, stack_info=True
)
except RuntimeError:
return # Loop has been shut down
async def _process_message(self, message: dict):
data = json.loads(message['data'])
+4 -1
View File
@@ -112,6 +112,9 @@ class Session:
github_token=github_token,
selected_repository=selected_repository,
)
# Run setup script if it exists
await self._run_setup_script()
except Exception as e:
logger.exception(f'Error creating controller: {e}')
await self.send_error(
@@ -206,4 +209,4 @@ class Session:
"""Queues a status message to be sent asynchronously."""
asyncio.run_coroutine_threadsafe(
self._send_status_message(msg_type, id, message), self.loop
)
)
@@ -4,9 +4,9 @@ import json
from dataclasses import dataclass
from openhands.core.config.app_config import AppConfig
from openhands.server.data_models.conversation_metadata import ConversationMetadata
from openhands.storage import get_file_store
from openhands.storage.conversation.conversation_store import ConversationStore
from openhands.server.data_models.conversation_metadata import ConversationMetadata
from openhands.storage.files import FileStore
from openhands.storage.locations import get_conversation_metadata_filename
from openhands.utils.async_utils import call_sync_from_async
+12
View File
@@ -2,6 +2,18 @@ from abc import abstractmethod
class FileStore:
@abstractmethod
def get_full_path(self, path: str) -> str:
"""Get the full path for a given relative path.
Args:
path: The relative path.
Returns:
The full path.
"""
pass
@abstractmethod
def write(self, path: str, contents: str) -> None:
pass
+5
View File
@@ -19,6 +19,11 @@ class GoogleCloudFileStore(FileStore):
self.storage_client = storage.Client()
self.bucket = self.storage_client.bucket(bucket_name)
def get_full_path(self, path: str) -> str:
if path.startswith('/'):
path = path[1:]
return path
def write(self, path: str, contents: str | bytes) -> None:
blob = self.bucket.blob(path)
with blob.open('w') as f:
+5
View File
@@ -12,6 +12,11 @@ class InMemoryFileStore(FileStore):
def __init__(self, files: dict[str, str] = IN_MEMORY_FILES):
self.files = files
def get_full_path(self, path: str) -> str:
if path.startswith('/'):
path = path[1:]
return path
def write(self, path: str, contents: str) -> None:
self.files[path] = contents
+5
View File
@@ -15,6 +15,11 @@ class S3FileStore(FileStore):
self.bucket = os.getenv('AWS_S3_BUCKET')
self.client = Minio(endpoint, access_key, secret_key, secure=secure)
def get_full_path(self, path: str) -> str:
if path.startswith('/'):
path = path[1:]
return path
def write(self, path: str, contents: str) -> None:
as_bytes = contents.encode('utf-8')
stream = io.BytesIO(as_bytes)
Generated
+7 -7
View File
@@ -3782,19 +3782,19 @@ pydantic = ">=1.10"
[[package]]
name = "llama-index"
version = "0.12.8"
version = "0.12.9"
description = "Interface between LLMs and your data"
optional = false
python-versions = "<4.0,>=3.9"
files = [
{file = "llama_index-0.12.8-py3-none-any.whl", hash = "sha256:6b98ea44c225c7d230fd7f552dfcc2911ef327e3be352dc239011118242e4a28"},
{file = "llama_index-0.12.8.tar.gz", hash = "sha256:f1578bb6873fa4f90a8645a80f4f997d184770e63bd7a2b45a98ab6e5c70fb59"},
{file = "llama_index-0.12.9-py3-none-any.whl", hash = "sha256:95c39d8055c7d19bd5f099560b53c0971ae9997ebe46f7438766189ed48e4456"},
{file = "llama_index-0.12.9.tar.gz", hash = "sha256:2f8d671e6ca7e5b33b0f5cbddef8c0a11eb1e39781f1be65e9bd0c4a7a0deb5b"},
]
[package.dependencies]
llama-index-agent-openai = ">=0.4.0,<0.5.0"
llama-index-cli = ">=0.4.0,<0.5.0"
llama-index-core = ">=0.12.8,<0.13.0"
llama-index-core = ">=0.12.9,<0.13.0"
llama-index-embeddings-openai = ">=0.3.0,<0.4.0"
llama-index-indices-managed-llama-cloud = ">=0.4.0"
llama-index-llms-openai = ">=0.3.0,<0.4.0"
@@ -3839,13 +3839,13 @@ llama-index-llms-openai = ">=0.3.0,<0.4.0"
[[package]]
name = "llama-index-core"
version = "0.12.8"
version = "0.12.9"
description = "Interface between LLMs and your data"
optional = false
python-versions = "<4.0,>=3.9"
files = [
{file = "llama_index_core-0.12.8-py3-none-any.whl", hash = "sha256:7ebecbdaa1d5b6a320c050bf90525605ac03b242d26ad55f0e00a0e1df69e070"},
{file = "llama_index_core-0.12.8.tar.gz", hash = "sha256:3b360437b4ae47b7bd1733f6492a95126e6739c7a2fd2b649ebe8bb3afea7143"},
{file = "llama_index_core-0.12.9-py3-none-any.whl", hash = "sha256:75bfdece8e1eb37faba43345cfbd9a8004859c177c1b5b358fc77620908c0f3f"},
{file = "llama_index_core-0.12.9.tar.gz", hash = "sha256:a6a702af13f8a840ff2a459024d21280e5b04d37f22c73efdc52def60e047af6"},
]
[package.dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "openhands-ai"
version = "0.17.0"
version = "0.18.0"
description = "OpenHands: Code Less, Make More"
authors = ["OpenHands"]
license = "MIT"
-23
View File
@@ -1,6 +1,5 @@
from openhands.events.action import (
Action,
AddTaskAction,
AgentFinishAction,
AgentRejectAction,
BrowseInteractiveAction,
@@ -9,7 +8,6 @@ from openhands.events.action import (
FileReadAction,
FileWriteAction,
MessageAction,
ModifyTaskAction,
)
from openhands.events.action.action import ActionConfirmationStatus
from openhands.events.serialization import (
@@ -156,24 +154,3 @@ def test_file_write_action_serialization_deserialization():
},
}
serialization_deserialization(original_action_dict, FileWriteAction)
def test_add_task_action_serialization_deserialization():
original_action_dict = {
'action': 'add_task',
'args': {
'parent': 'Test parent',
'goal': 'Test goal',
'subtasks': [],
'thought': '',
},
}
serialization_deserialization(original_action_dict, AddTaskAction)
def test_modify_task_action_serialization_deserialization():
original_action_dict = {
'action': 'modify_task',
'args': {'task_id': 1, 'state': 'Test state.', 'thought': ''},
}
serialization_deserialization(original_action_dict, ModifyTaskAction)
+1 -1
View File
@@ -18,7 +18,7 @@ def test_parser_default_values():
assert args.eval_num_workers == 4
assert args.eval_note is None
assert args.llm_config is None
assert args.name == 'default'
assert args.name == ''
assert not args.no_auto_continue
+9 -7
View File
@@ -71,7 +71,7 @@ def test_load_from_old_style_env(monkeypatch, default_config):
# Test loading configuration from old-style environment variables using monkeypatch
monkeypatch.setenv('LLM_API_KEY', 'test-api-key')
monkeypatch.setenv('AGENT_MEMORY_ENABLED', 'True')
monkeypatch.setenv('DEFAULT_AGENT', 'PlannerAgent')
monkeypatch.setenv('DEFAULT_AGENT', 'BrowsingAgent')
monkeypatch.setenv('WORKSPACE_BASE', '/opt/files/workspace')
monkeypatch.setenv('SANDBOX_BASE_CONTAINER_IMAGE', 'custom_image')
@@ -79,7 +79,7 @@ def test_load_from_old_style_env(monkeypatch, default_config):
assert default_config.get_llm_config().api_key == 'test-api-key'
assert default_config.get_agent_config().memory_enabled is True
assert default_config.default_agent == 'PlannerAgent'
assert default_config.default_agent == 'BrowsingAgent'
assert default_config.workspace_base == '/opt/files/workspace'
assert default_config.workspace_mount_path is None # before finalize_config
assert default_config.workspace_mount_path_in_sandbox is not None
@@ -333,8 +333,10 @@ def test_defaults_dict_after_updates(default_config):
updated_config.get_llm_config().api_key = 'updated-api-key'
updated_config.get_llm_config('llm').api_key = 'updated-api-key'
updated_config.get_llm_config_from_agent('agent').api_key = 'updated-api-key'
updated_config.get_llm_config_from_agent('PlannerAgent').api_key = 'updated-api-key'
updated_config.default_agent = 'PlannerAgent'
updated_config.get_llm_config_from_agent(
'BrowsingAgent'
).api_key = 'updated-api-key'
updated_config.default_agent = 'BrowsingAgent'
defaults_after_updates = updated_config.defaults_dict
assert defaults_after_updates['default_agent']['default'] == 'CodeActAgent'
@@ -547,7 +549,7 @@ max_budget_per_task = 4.0
[agent.CodeActAgent]
memory_enabled = true
[agent.PlannerAgent]
[agent.BrowsingAgent]
memory_max_threads = 10
"""
@@ -558,5 +560,5 @@ memory_max_threads = 10
codeact_config = default_config.get_agent_configs().get('CodeActAgent')
assert codeact_config.memory_enabled is True
planner_config = default_config.get_agent_configs().get('PlannerAgent')
assert planner_config.memory_max_threads == 10
browsing_config = default_config.get_agent_configs().get('BrowsingAgent')
assert browsing_config.memory_max_threads == 10
+1 -1
View File
@@ -31,7 +31,7 @@ def event_stream(temp_dir):
def agent_configs():
return {
'CoderAgent': AgentConfig(memory_enabled=True),
'PlannerAgent': AgentConfig(memory_enabled=True),
'BrowsingAgent': AgentConfig(memory_enabled=True),
}
+3 -6
View File
@@ -1,9 +1,6 @@
import pytest
from openhands.agenthub.micro.agent import parse_response as parse_response_micro
from openhands.agenthub.planner_agent.prompt import (
parse_response as parse_response_planner,
)
from openhands.core.exceptions import LLMResponseError
from openhands.core.utils.json import loads as custom_loads
from openhands.events.action import (
@@ -14,7 +11,7 @@ from openhands.events.action import (
@pytest.mark.parametrize(
'parse_response_module',
[parse_response_micro, parse_response_planner],
[parse_response_micro],
)
def test_parse_single_complete_json(parse_response_module):
input_response = """
@@ -34,7 +31,7 @@ def test_parse_single_complete_json(parse_response_module):
@pytest.mark.parametrize(
'parse_response_module',
[parse_response_micro, parse_response_planner],
[parse_response_micro],
)
def test_parse_json_with_surrounding_text(parse_response_module):
input_response = """
@@ -57,7 +54,7 @@ def test_parse_json_with_surrounding_text(parse_response_module):
@pytest.mark.parametrize(
'parse_response_module',
[parse_response_micro, parse_response_planner],
[parse_response_micro],
)
def test_parse_first_of_multiple_jsons(parse_response_module):
input_response = """
+2
View File
@@ -13,6 +13,8 @@ def mock_event_stream():
stream = MagicMock()
# Mock get_events to return an empty list by default
stream.get_events.return_value = []
# Mock get_latest_event_id to return a valid integer
stream.get_latest_event_id.return_value = 0
return stream