Compare commits

..

39 Commits

Author SHA1 Message Date
mamoodi 715420ed3c Merge branch 'main' into mh/rel0240 2025-02-12 09:23:12 -05:00
Xingyao Wang f7c806c119 using all available system memory when RUNTIME_MAX_MEMORY_GB is not set (#6691) 2025-02-12 09:18:34 -05:00
mamoodi 36d93a8ba4 Merge branch 'main' into mh/rel0240 2025-02-12 09:15:08 -05:00
sp.wack ff25e794ef Revert "Only show start project button in conversations" (#6698) 2025-02-12 17:57:13 +04:00
mamoodi cbe186b3fb Merge branch 'main' into mh/rel0240 2025-02-12 08:51:03 -05:00
Xingyao Wang a371562d94 refactor: do not add DEBUG env var when it is not set (#6690) 2025-02-11 22:30:40 +00:00
mamoodi 285ba07bfd Release 0.24.0 2025-02-11 15:16:41 -05:00
dependabot[bot] 425ccc9b1f chore(deps-dev): bump @tanstack/eslint-plugin-query from 5.66.0 to 5.66.1 in /frontend in the eslint group (#6682)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-11 23:53:51 +04:00
Robert Brennan 1afe7f1058 Fix debug in remote runtime (#6688) 2025-02-11 17:43:46 +00:00
Xingyao Wang 3188646195 refactor(runtime): Use openhands-aci file editor directly in runtime instead of execute it through ipython (#6671)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Graham Neubig <neubig@gmail.com>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-02-12 01:37:44 +08:00
Eric Zhang 6772227c9d fix(frontend): fix public github repo cannot be selected (#6680) 2025-02-11 16:46:53 +00:00
Xingyao Wang 6a6dc93e03 feat(runtime): use prlimit to limit resource usage of command to avoid OOM Runtime Kill (#6338)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
Co-authored-by: Graham Neubig <neubig@gmail.com>
2025-02-11 11:21:11 +08:00
Engel Nyst 1a715d2ec4 Clean up global in llm.py (we figured it's not needed) (#6675) 2025-02-11 00:00:46 +01:00
Xingyao Wang 4615548477 Bump OpenHands ACI to 0.2.1 (#6678) 2025-02-10 21:54:23 +00:00
sp.wack b12b426e3d hotfix: Typecheck routes during frontend build (#6676) 2025-02-10 20:52:59 +00:00
dependabot[bot] a1107a2c30 chore(deps): bump the version-all group across 1 directory with 9 updates (#6667)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-02-10 20:58:00 +01:00
dependabot[bot] af0becd65b chore(deps): bump docker/setup-qemu-action from 3.3.0 to 3.4.0 (#6666)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-02-10 19:58:37 +01:00
Fredy Sierra 13839b4273 fix: adding support for environment variables type dict (#6672)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-02-10 18:56:58 +00:00
Graham Neubig 7860055f8c fix: Normalize whitespace when comparing patch context lines (#6541)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-02-10 18:53:39 +00:00
tofarr 2b40a92943 Fix for issue where temp file is empty (#6669) 2025-02-10 11:07:40 -07:00
Graham Neubig 6c88b10c59 Fix issue #6262: Add success/failure indicators for file read/edit operations (#6653)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-02-10 12:58:31 -05:00
Rohit Malhotra 8688634950 [Resolver]: Add target branch param (#6668) 2025-02-10 17:28:38 +00:00
dependabot[bot] 6e35ac49c1 chore(deps): bump the version-all group in /frontend with 4 updates (#6665)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-02-10 16:52:20 +00:00
Rohit Malhotra 9bdc8dda6c [Enhancement]: Handle GH token refresh inside runtime (#6632) 2025-02-10 11:12:12 -05:00
Robert Brennan 75f3f282af Add comprehensive OpenHands glossary (#6310)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2025-02-10 10:13:39 -05:00
Rohit Malhotra 4a5891cbea [Bug fix]: Standardize SecretStr use (#6660)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2025-02-10 08:03:56 -05:00
tofarr 707cb07f4f Removed in page callback (#6657) 2025-02-10 05:34:58 -07:00
dependabot[bot] 61c709b7c7 chore(deps): bump the version-all group across 1 directory with 3 updates (#6648)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-02-10 09:02:02 +00:00
zchn 1c72676483 fix(6223): More properly add 'pyproject.toml' and 'poetry.lock' to the pip package (#6658) 2025-02-08 01:23:42 +01:00
Xingyao Wang 52ac2729f7 fix: set tool_choice to none for non-fncall models (#6652) 2025-02-07 12:49:08 -05:00
sp.wack 5fa2634d60 chore(frontend): Take into account other error message types (#6647) 2025-02-07 20:52:02 +04:00
Xingyao Wang 478b225d11 feat: Add LocalRuntime (#5284)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-02-07 16:35:14 +00:00
tofarr ce82545437 fix: handle SAAS mode properly in useSettings hook (#6646)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-02-07 09:24:15 -07:00
Graham Neubig 93d2e4a338 Optimize memory usage in FileEditObservation (#6622)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Xingyao Wang <xingyao6@illinois.edu>
2025-02-07 08:19:32 -05:00
mamoodi ff48f8beba Add o1 to verfied models (#6642) 2025-02-06 16:38:08 -05:00
Graham Neubig e930cd0aef Better error logging in posthog (#6346)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Ray Myers <ray.myers@gmail.com>
2025-02-06 20:16:37 +00:00
sp.wack 6655ec0731 chore(frontend): Migrate from NextUI to HeroUI via codemod (#6635) 2025-02-06 19:24:54 +04:00
mamoodi 669e284dc5 Only show start project button in conversations (#6626)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2025-02-06 09:57:54 -05:00
dependabot[bot] 8140d2e05a chore(deps): bump the version-all group across 1 directory with 15 updates (#6617)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-02-06 12:29:12 +00:00
133 changed files with 5731 additions and 3161 deletions
+3 -3
View File
@@ -42,7 +42,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.3.0
uses: docker/setup-qemu-action@v3.4.0
with:
image: tonistiigi/binfmt:latest
- name: Login to GHCR
@@ -91,7 +91,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.3.0
uses: docker/setup-qemu-action@v3.4.0
with:
image: tonistiigi/binfmt:latest
- name: Login to GHCR
@@ -233,7 +233,7 @@ jobs:
run: pipx install poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies
- name: Run runtime tests
- name: Run docker runtime tests
run: |
# We install pytest-xdist in order to run tests across CPUs
poetry run pip install pytest-xdist
+2 -1
View File
@@ -177,7 +177,7 @@ jobs:
echo "SANDBOX_ENV_BASE_CONTAINER_IMAGE=${{ inputs.base_container_image }}" >> $GITHUB_ENV
# Set branch variables
echo "TARGET_BRANCH=${{ inputs.target_branch }}" >> $GITHUB_ENV
echo "TARGET_BRANCH=${{ inputs.target_branch || 'main' }}" >> $GITHUB_ENV
- name: Comment on issue with start message
uses: actions/github-script@v7
@@ -277,6 +277,7 @@ jobs:
if [ "${{ steps.check_result.outputs.RESOLUTION_SUCCESS }}" == "true" ]; then
cd /tmp && python -m openhands.resolver.send_pull_request \
--issue-number ${{ env.ISSUE_NUMBER }} \
--target-branch ${{ env.TARGET_BRANCH }} \
--pr-type draft \
--reviewer ${{ github.actor }} | tee pr_result.txt && \
grep "draft created" pr_result.txt | sed 's/.*\///g' > pr_number.txt
+172
View File
@@ -0,0 +1,172 @@
# OpenHands Glossary
### Agent
The core AI entity in OpenHands that can perform software development tasks by interacting with tools, browsing the web, and modifying code.
#### Agent Controller
A component that manages the agent's lifecycle, handles its state, and coordinates interactions between the agent and various tools.
#### Agent Delegation
The ability of an agent to hand off specific tasks to other specialized agents for better task completion.
#### Agent Hub
A central registry of different agent types and their capabilities, allowing for easy agent selection and instantiation.
#### Agent Skill
A specific capability or function that an agent can perform, such as file manipulation, web browsing, or code editing.
#### Agent State
The current context and status of an agent, including its memory, active tools, and ongoing tasks.
#### CodeAct Agent
[A generalist agent in OpenHands](https://arxiv.org/abs/2407.16741) designed to perform tasks by editing and executing code.
### Browser
A system for web-based interactions and tasks.
#### Browser Gym
A testing and evaluation environment for browser-based agent interactions and tasks.
#### Web Browser Tool
A tool that enables agents to interact with web pages and perform web-based tasks.
### Commands
Terminal and execution related functionality.
#### Bash Session
A persistent terminal session that maintains state and history for bash command execution.
This uses tmux under the hood.
### Configuration
System-wide settings and options.
#### Agent Configuration
Settings that define an agent's behavior, capabilities, and limitations, including available tools and runtime settings.
#### Configuration Options
Settings that control various aspects of OpenHands behavior, including runtime, security, and agent settings.
#### LLM Config
Configuration settings for language models used by agents, including model selection and parameters.
#### LLM Draft Config
Settings for draft mode operations with language models, typically used for faster, lower-quality responses.
#### Runtime Configuration
Settings that define how the runtime environment should be set up and operated.
#### Security Options
Configuration settings that control security features and restrictions.
### Conversation
A sequence of interactions between a user and an agent, including messages, actions, and their results.
#### Conversation Info
Metadata about a conversation, including its status, participants, and timeline.
#### Conversation Manager
A component that handles the creation, storage, and retrieval of conversations.
#### Conversation Metadata
Additional information about conversations, such as tags, timestamps, and related resources.
#### Conversation Status
The current state of a conversation, including whether it's active, completed, or failed.
#### Conversation Store
A storage system for maintaining conversation history and related data.
### Events
#### Event
Every Conversation comprises a series of Events. Each Event is either an Action or an Observation.
#### Event Stream
A continuous flow of events that represents the ongoing activities and interactions in the system.
#### Action
A specific operation or command that an agent executes through available tools, such as running a command or editing a file.
#### Observation
The response or result returned by a tool after an agent's action, providing feedback about the action's outcome.
### Interface
Different ways to interact with OpenHands.
#### CLI Mode
A command-line interface mode for interacting with OpenHands agents without a graphical interface.
#### GUI Mode
A graphical user interface mode for interacting with OpenHands agents through a web interface.
#### Headless Mode
A mode of operation where OpenHands runs without a user interface, suitable for automation and scripting.
### Agent Memory
The system that decides which parts of the Event Stream (i.e. the conversation history) should be passed into each LLM prompt.
#### Memory Store
A storage system for maintaining agent memory and context across sessions.
#### Condenser
A component that processes and summarizes conversation history to maintain context while staying within token limits.
#### Truncation
A very simple Condenser strategy. Reduces conversation history or content to stay within token limits.
### Microagent
A specialized prompt that enhances OpenHands with domain-specific knowledge, repository-specific context, and task-specific workflows.
#### Microagent Registry
A central repository of available microagents and their configurations.
#### Public Microagent
A general-purpose microagent available to all OpenHands users, triggered by specific keywords.
#### Repository Microagent
A type of microagent that provides repository-specific context and guidelines, stored in the `.openhands/microagents/` directory.
### Prompt
Components for managing and processing prompts.
#### Prompt Caching
A system for caching and reusing common prompts to improve performance.
#### Prompt Manager
A component that handles the loading, processing, and management of prompts used by agents, including microagents.
#### Response Parsing
The process of interpreting and structuring responses from language models and tools.
### Runtime
The execution environment where agents perform their tasks, which can be local, remote, or containerized.
#### Action Execution Server
A REST API that receives agent actions (e.g. bash commands, python code, browsing actions), executes them in the runtime environment, and returns the results.
#### Action Execution Client
A component that handles the execution of actions in the runtime environment, managing the communication between the agent and the runtime.
#### Docker Runtime
A containerized runtime environment that provides isolation and reproducibility for agent operations.
#### E2B Runtime
A specialized runtime environment built on E2B for secure and isolated code execution.
#### Local Runtime
A runtime environment that executes on the local machine, suitable for development and testing.
#### Modal Runtime
A runtime environment built on Modal for scalable and distributed agent operations.
#### Remote Runtime
A sandboxed environment that executes code and commands remotely, providing isolation and security for agent operations.
#### Runtime Builder
A component that builds a Docker image for the Action Execution Server based on a user-specified base image.
### Security
Security-related components and features.
#### Security Analyzer
A component that checks agent actions for potential security risks.
+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.23-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.24-nikolaik`
## Develop inside Docker container
+3 -3
View File
@@ -43,17 +43,17 @@ See the [Running OpenHands](https://docs.all-hands.dev/modules/usage/installatio
system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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.23
docker.all-hands.dev/all-hands-ai/openhands:0.24
```
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
-1
View File
@@ -1,5 +1,4 @@
#!/bin/bash
set -e
cp pyproject.toml poetry.lock openhands
poetry build -v
+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.23-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.24-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+3
View File
@@ -24,3 +24,6 @@ inline-quotes = "single"
[format]
quote-style = "single"
[lint.flake8-bugbear]
extend-immutable-calls = ["Depends", "fastapi.Depends", "fastapi.params.Depends"]
+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:-docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik}
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of openhands-state for this user
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
@@ -52,7 +52,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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.23 \
docker.all-hands.dev/all-hands-ai/openhands:0.24 \
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.23-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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.23 \
docker.all-hands.dev/all-hands-ai/openhands:0.24 \
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.23-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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.23
docker.all-hands.dev/all-hands-ai/openhands:0.24
```
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.23-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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.23-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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.23 \
docker.all-hands.dev/all-hands-ai/openhands:0.24 \
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.23-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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.23 \
docker.all-hands.dev/all-hands-ai/openhands:0.24 \
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.23-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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.23
docker.all-hands.dev/all-hands-ai/openhands:0.24
```
你也可以在可脚本化的[无头模式](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.23-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```
+2 -2
View File
@@ -35,7 +35,7 @@ To run OpenHands in CLI mode with Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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.23 \
docker.all-hands.dev/all-hands-ai/openhands:0.24 \
python -m openhands.core.cli
```
+7 -6
View File
@@ -42,9 +42,10 @@ You can provide custom directions for OpenHands by following the [README for the
Github resolver will automatically check for valid [repository secrets](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions?tool=webui#creating-secrets-for-a-repository) or [repository variables](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#creating-configuration-variables-for-a-repository) to customize its behavior.
The customization options you can set are:
| **Attribute name** | **Type** | **Purpose** | **Example** |
|----------------------------------| -------- |-------------------------------------------------------------------------------------------------------------|------------------------------------------------------|
| `LLM_MODEL` | Variable | Set the LLM to use with OpenHands | `LLM_MODEL="anthropic/claude-3-5-sonnet-20241022"` |
| `OPENHANDS_MAX_ITER` | Variable | Set max limit for agent iterations | `OPENHANDS_MAX_ITER=10` |
| `OPENHANDS_MACRO` | Variable | Customize default macro for invoking the resolver | `OPENHANDS_MACRO=@resolveit` |
| `OPENHANDS_BASE_CONTAINER_IMAGE` | Variable | Custom Sandbox ([learn more](https://docs.all-hands.dev/modules/usage/how-to/custom-sandbox-guide)) | `OPENHANDS_BASE_CONTAINER_IMAGE="custom_image"` |
| **Attribute name** | **Type** | **Purpose** | **Example** |
| -------------------------------- | -------- | --------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
| `LLM_MODEL` | Variable | Set the LLM to use with OpenHands | `LLM_MODEL="anthropic/claude-3-5-sonnet-20241022"` |
| `OPENHANDS_MAX_ITER` | Variable | Set max limit for agent iterations | `OPENHANDS_MAX_ITER=10` |
| `OPENHANDS_MACRO` | Variable | Customize default macro for invoking the resolver | `OPENHANDS_MACRO=@resolveit` |
| `OPENHANDS_BASE_CONTAINER_IMAGE` | Variable | Custom Sandbox ([learn more](https://docs.all-hands.dev/modules/usage/how-to/custom-sandbox-guide)) | `OPENHANDS_BASE_CONTAINER_IMAGE="custom_image"` |
| `TARGET_BRANCH` | Variable | Merge to branch other than `main` | `TARGET_BRANCH="dev"` |
+2 -2
View File
@@ -32,7 +32,7 @@ To run OpenHands in Headless mode with Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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.23 \
docker.all-hands.dev/all-hands-ai/openhands:0.24 \
python -m openhands.core.main -t "write a bash script that prints hi"
```
+3 -3
View File
@@ -54,17 +54,17 @@
The easiest way to run OpenHands is in Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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.23
docker.all-hands.dev/all-hands-ai/openhands:0.24
```
You'll find OpenHands running at http://localhost:3000!
+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.23-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```
@@ -7,6 +7,7 @@ This file tracks the resource requirements of different instances.
import json
import os
from openhands.core.logger import openhands_logger as logger
CUR_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -1,4 +1,5 @@
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import type { Message } from "#/message";
import { act, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
@@ -0,0 +1,82 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { Messages } from "#/components/features/chat/messages";
import type { Message } from "#/message";
describe("File Operations Messages", () => {
it("should show success indicator for successful file read operation", () => {
const messages: Message[] = [
{
type: "action",
translationID: "read_file_contents",
content: "Successfully read file contents",
success: true,
sender: "assistant",
timestamp: new Date().toISOString(),
},
];
render(<Messages messages={messages} isAwaitingUserConfirmation={false} />);
const statusIcon = screen.getByTestId("status-icon");
expect(statusIcon).toBeInTheDocument();
expect(statusIcon.closest("svg")).toHaveClass("fill-success");
});
it("should show failure indicator for failed file read operation", () => {
const messages: Message[] = [
{
type: "action",
translationID: "read_file_contents",
content: "Failed to read file contents",
success: false,
sender: "assistant",
timestamp: new Date().toISOString(),
},
];
render(<Messages messages={messages} isAwaitingUserConfirmation={false} />);
const statusIcon = screen.getByTestId("status-icon");
expect(statusIcon).toBeInTheDocument();
expect(statusIcon.closest("svg")).toHaveClass("fill-danger");
});
it("should show success indicator for successful file edit operation", () => {
const messages: Message[] = [
{
type: "action",
translationID: "edit_file_contents",
content: "Successfully edited file contents",
success: true,
sender: "assistant",
timestamp: new Date().toISOString(),
},
];
render(<Messages messages={messages} isAwaitingUserConfirmation={false} />);
const statusIcon = screen.getByTestId("status-icon");
expect(statusIcon).toBeInTheDocument();
expect(statusIcon.closest("svg")).toHaveClass("fill-success");
});
it("should show failure indicator for failed file edit operation", () => {
const messages: Message[] = [
{
type: "action",
translationID: "edit_file_contents",
content: "Failed to edit file contents",
success: false,
sender: "assistant",
timestamp: new Date().toISOString(),
},
];
render(<Messages messages={messages} isAwaitingUserConfirmation={false} />);
const statusIcon = screen.getByTestId("status-icon");
expect(statusIcon).toBeInTheDocument();
expect(statusIcon.closest("svg")).toHaveClass("fill-danger");
});
});
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import translations from "../../src/i18n/translation.json";
import { UserAvatar } from "../../src/components/features/sidebar/user-avatar";
vi.mock("@nextui-org/react", () => ({
vi.mock("@heroui/react", () => ({
Tooltip: ({ content, children }: { content: string; children: React.ReactNode }) => (
<div>
{children}
@@ -132,6 +132,7 @@ describe("AccountSettingsModal", () => {
agent: "CodeActAgent",
confirmation_mode: false,
enable_default_condenser: false,
github_token: undefined,
language: "en",
llm_base_url: "",
llm_model: "anthropic/claude-3-5-sonnet-20241022",
@@ -0,0 +1,59 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { handleStatusMessage } from "#/services/actions";
import store from "#/store";
import { trackError } from "#/utils/error-handler";
// Mock dependencies
vi.mock("#/utils/error-handler", () => ({
trackError: vi.fn(),
}));
vi.mock("#/store", () => ({
default: {
dispatch: vi.fn(),
},
}));
describe("Actions Service", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("handleStatusMessage", () => {
it("should dispatch info messages to status state", () => {
const message = {
type: "info",
message: "Runtime is not available",
id: "runtime.unavailable",
status_update: true as const,
};
handleStatusMessage(message);
expect(store.dispatch).toHaveBeenCalledWith(expect.objectContaining({
payload: message,
}));
});
it("should log error messages and display them in chat", () => {
const message = {
type: "error",
message: "Runtime connection failed",
id: "runtime.connection.failed",
status_update: true as const,
};
handleStatusMessage(message);
expect(trackError).toHaveBeenCalledWith({
message: "Runtime connection failed",
source: "chat",
metadata: { msgId: "runtime.connection.failed" },
});
expect(store.dispatch).toHaveBeenCalledWith(expect.objectContaining({
payload: message,
}));
});
});
});
@@ -0,0 +1,165 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { trackError, showErrorToast, showChatError } from "#/utils/error-handler";
import posthog from "posthog-js";
import toast from "react-hot-toast";
import * as Actions from "#/services/actions";
vi.mock("posthog-js", () => ({
default: {
captureException: vi.fn(),
},
}));
vi.mock("react-hot-toast", () => ({
default: {
error: vi.fn(),
},
}));
vi.mock("#/services/actions", () => ({
handleStatusMessage: vi.fn(),
}));
describe("Error Handler", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
describe("trackError", () => {
it("should send error to PostHog with basic info", () => {
const error = {
message: "Test error",
source: "test",
};
trackError(error);
expect(posthog.captureException).toHaveBeenCalledWith(new Error("Test error"), {
error_source: "test",
});
});
it("should include additional metadata in PostHog event", () => {
const error = {
message: "Test error",
source: "test",
metadata: {
extra: "info",
details: { foo: "bar" },
},
};
trackError(error);
expect(posthog.captureException).toHaveBeenCalledWith(new Error("Test error"), {
error_source: "test",
extra: "info",
details: { foo: "bar" },
});
});
});
describe("showErrorToast", () => {
it("should log error and show toast", () => {
const error = {
message: "Toast error",
source: "toast-test",
};
showErrorToast(error);
// Verify PostHog logging
expect(posthog.captureException).toHaveBeenCalledWith(new Error("Toast error"), {
error_source: "toast-test",
});
// Verify toast was shown
expect(toast.error).toHaveBeenCalled();
});
it("should include metadata in PostHog event when showing toast", () => {
const error = {
message: "Toast error",
source: "toast-test",
metadata: { context: "testing" },
};
showErrorToast(error);
expect(posthog.captureException).toHaveBeenCalledWith(new Error("Toast error"), {
error_source: "toast-test",
context: "testing",
});
});
it("should log errors from different sources with appropriate metadata", () => {
// Test agent status error
showErrorToast({
message: "Agent error",
source: "agent-status",
metadata: { id: "error.agent" },
});
expect(posthog.captureException).toHaveBeenCalledWith(new Error("Agent error"), {
error_source: "agent-status",
id: "error.agent",
});
showErrorToast({
message: "Server error",
source: "server",
metadata: { error_code: 500, details: "Internal error" },
});
expect(posthog.captureException).toHaveBeenCalledWith(new Error("Server error"), {
error_source: "server",
error_code: 500,
details: "Internal error",
});
});
it("should log feedback submission errors with conversation context", () => {
const error = new Error("Feedback submission failed");
showErrorToast({
message: error.message,
source: "feedback",
metadata: { conversationId: "123", error },
});
expect(posthog.captureException).toHaveBeenCalledWith(new Error("Feedback submission failed"), {
error_source: "feedback",
conversationId: "123",
error,
});
});
});
describe("showChatError", () => {
it("should log error and show chat error message", () => {
const error = {
message: "Chat error",
source: "chat-test",
msgId: "123",
};
showChatError(error);
// Verify PostHog logging
expect(posthog.captureException).toHaveBeenCalledWith(new Error("Chat error"), {
error_source: "chat-test",
});
// Verify error message was shown in chat
expect(Actions.handleStatusMessage).toHaveBeenCalledWith({
type: "error",
message: "Chat error",
id: "123",
status_update: true,
});
});
});
});
+2150 -2160
View File
File diff suppressed because it is too large Load Diff
+17 -17
View File
@@ -1,33 +1,33 @@
{
"name": "openhands-frontend",
"version": "0.23.0",
"version": "0.24.0",
"private": true,
"type": "module",
"engines": {
"node": ">=20.0.0"
},
"dependencies": {
"@heroui/react": "2.6.14",
"@monaco-editor/react": "^4.7.0-rc.0",
"@nextui-org/react": "^2.6.11",
"@react-router/node": "^7.1.3",
"@react-router/serve": "^7.1.3",
"@react-router/node": "^7.1.5",
"@react-router/serve": "^7.1.5",
"@react-types/shared": "^3.27.0",
"@reduxjs/toolkit": "^2.5.1",
"@tanstack/react-query": "^5.65.1",
"@tanstack/react-query": "^5.66.0",
"@vitejs/plugin-react": "^4.3.2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.7.9",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.0.6",
"framer-motion": "^12.4.2",
"i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.2",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.22",
"jose": "^5.9.4",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.211.3",
"posthog-js": "^1.216.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-highlight": "^0.15.0",
@@ -36,21 +36,21 @@
"react-icons": "^5.4.0",
"react-markdown": "^9.0.3",
"react-redux": "^9.2.0",
"react-router": "^7.1.3",
"react-router": "^7.1.5",
"react-syntax-highlighter": "^15.6.1",
"react-textarea-autosize": "^8.5.7",
"remark-gfm": "^4.0.0",
"remark-gfm": "^4.0.1",
"sirv-cli": "^3.0.0",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^2.6.0",
"vite": "^6.0.11",
"tailwind-merge": "^3.0.1",
"vite": "^6.1.0",
"web-vitals": "^3.5.2",
"ws": "^8.18.0"
},
"scripts": {
"dev": "npm run make-i18n && cross-env VITE_MOCK_API=false react-router dev",
"dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true react-router dev",
"build": "npm run make-i18n && tsc && react-router build",
"build": "npm run make-i18n && npm run typecheck && react-router build",
"start": "npx sirv-cli build/ --single",
"test": "vitest run",
"test:e2e": "playwright test",
@@ -77,15 +77,15 @@
},
"devDependencies": {
"@mswjs/socket.io-binding": "^0.1.1",
"@playwright/test": "^1.50.0",
"@react-router/dev": "^7.1.3",
"@playwright/test": "^1.50.1",
"@react-router/dev": "^7.1.5",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.65.0",
"@tanstack/eslint-plugin-query": "^5.66.1",
"@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.12.0",
"@types/node": "^22.13.1",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@types/react-highlight": "^0.12.8",
@@ -93,7 +93,7 @@
"@types/ws": "^8.5.14",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitest/coverage-v8": "^3.0.4",
"@vitest/coverage-v8": "^3.0.5",
"autoprefixer": "^10.4.20",
"cross-env": "^7.0.3",
"eslint": "^8.57.0",
@@ -46,55 +46,57 @@ export function ExpandableMessage({
)}
>
<div className="text-sm w-full">
{headline && (
<div className="flex flex-row justify-between items-center w-full">
<span
className={cn(
"font-bold",
type === "error" ? "text-danger" : "text-neutral-300",
)}
>
{headline}
<button
type="button"
onClick={() => setShowDetails(!showDetails)}
className="cursor-pointer text-left"
>
{showDetails ? (
<ArrowUp
className={cn(
"h-4 w-4 ml-2 inline",
type === "error" ? "fill-danger" : "fill-neutral-300",
)}
/>
) : (
<ArrowDown
className={cn(
"h-4 w-4 ml-2 inline",
type === "error" ? "fill-danger" : "fill-neutral-300",
)}
/>
)}
</button>
</span>
{type === "action" && success !== undefined && (
<span className="flex-shrink-0">
{success ? (
<CheckCircle
data-testid="status-icon"
className={cn(statusIconClasses, "fill-success")}
/>
) : (
<XCircle
data-testid="status-icon"
className={cn(statusIconClasses, "fill-danger")}
/>
)}
</span>
<div className="flex flex-row justify-between items-center w-full">
<span
className={cn(
headline ? "font-bold" : "",
type === "error" ? "text-danger" : "text-neutral-300",
)}
</div>
)}
{showDetails && (
>
{headline && (
<>
{headline}
<button
type="button"
onClick={() => setShowDetails(!showDetails)}
className="cursor-pointer text-left"
>
{showDetails ? (
<ArrowUp
className={cn(
"h-4 w-4 ml-2 inline",
type === "error" ? "fill-danger" : "fill-neutral-300",
)}
/>
) : (
<ArrowDown
className={cn(
"h-4 w-4 ml-2 inline",
type === "error" ? "fill-danger" : "fill-neutral-300",
)}
/>
)}
</button>
</>
)}
</span>
{type === "action" && success !== undefined && (
<span className="flex-shrink-0">
{success ? (
<CheckCircle
data-testid="status-icon"
className={cn(statusIconClasses, "fill-success")}
/>
) : (
<XCircle
data-testid="status-icon"
className={cn(statusIconClasses, "fill-danger")}
/>
)}
</span>
)}
</div>
{(!headline || showDetails) && (
<Markdown
className="text-sm overflow-auto"
components={{
@@ -1,4 +1,5 @@
import React from "react";
import type { Message } from "#/message";
import { ChatMessage } from "#/components/features/chat/chat-message";
import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons";
import { ImageCarousel } from "../images/image-carousel";
@@ -1,7 +1,7 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import toast from "react-hot-toast";
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";
@@ -27,7 +27,11 @@ export function AgentStatusBar() {
}
}
if (curStatusMessage?.type === "error") {
toast.error(message);
showErrorToast({
message,
source: "agent-status",
metadata: { ...curStatusMessage },
});
return;
}
if (curAgentState === AgentState.LOADING && message.trim()) {
@@ -4,7 +4,7 @@ import {
Autocomplete,
AutocompleteItem,
AutocompleteSection,
} from "@nextui-org/react";
} from "@heroui/react";
import { useDispatch } from "react-redux";
import posthog from "posthog-js";
import { I18nKey } from "#/i18n/declaration";
@@ -31,7 +31,7 @@ export function GitHubRepositorySelector({
const allRepositories: GitHubRepository[] = [
...publicRepositories.filter(
(repo) => !publicRepositories.find((r) => r.id === repo.id),
(repo) => !userRepositories.find((r) => r.id === repo.id),
),
...userRepositories,
];
@@ -1,4 +1,4 @@
import { Tooltip } from "@nextui-org/react";
import { Tooltip } from "@heroui/react";
import { useTranslation } from "react-i18next";
import ConfirmIcon from "#/assets/confirm";
import RejectIcon from "#/assets/reject";
@@ -1,4 +1,4 @@
import { Tooltip } from "@nextui-org/react";
import { Tooltip } from "@heroui/react";
import { AgentState } from "#/types/agent-state";
interface ActionButtonProps {
@@ -1,4 +1,4 @@
import { Button } from "@nextui-org/react";
import { Button } from "@heroui/react";
import React, { ReactElement } from "react";
export interface IconButtonProps {
@@ -1,4 +1,4 @@
import { Tooltip } from "@nextui-org/react";
import { Tooltip } from "@heroui/react";
import React, { ReactNode } from "react";
import { cn } from "#/utils/utils";
@@ -1,4 +1,4 @@
import { Autocomplete, AutocompleteItem } from "@nextui-org/react";
import { Autocomplete, AutocompleteItem } from "@heroui/react";
interface FormFieldsetProps {
id: string;
@@ -1,4 +1,4 @@
import { Switch } from "@nextui-org/react";
import { Switch } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { cn } from "#/utils/utils";
@@ -1,4 +1,4 @@
import { Autocomplete, AutocompleteItem } from "@nextui-org/react";
import { Autocomplete, AutocompleteItem } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
@@ -1,4 +1,4 @@
import { Input, Tooltip } from "@nextui-org/react";
import { Input, Tooltip } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { FaCheckCircle, FaExclamationCircle } from "react-icons/fa";
import { I18nKey } from "#/i18n/declaration";
@@ -1,4 +1,4 @@
import { Input } from "@nextui-org/react";
import { Input } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
@@ -1,4 +1,4 @@
import { Switch } from "@nextui-org/react";
import { Switch } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { cn } from "#/utils/utils";
@@ -1,4 +1,4 @@
import { Input } from "@nextui-org/react";
import { Input } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
@@ -1,4 +1,4 @@
import { Autocomplete, AutocompleteItem } from "@nextui-org/react";
import { Autocomplete, AutocompleteItem } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
@@ -4,7 +4,7 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
} from "@nextui-org/react";
} from "@heroui/react";
import React from "react";
import { Action, FooterContent } from "./footer-content";
import { HeaderContent } from "./header-content";
@@ -1,4 +1,4 @@
import { Button } from "@nextui-org/react";
import { Button } from "@heroui/react";
import React from "react";
export interface Action {
@@ -4,7 +4,7 @@ import { IoAlertCircle } from "react-icons/io5";
import { useTranslation } from "react-i18next";
import { Editor, Monaco } from "@monaco-editor/react";
import { editor } from "monaco-editor";
import { Button, Select, SelectItem } from "@nextui-org/react";
import { Button, Select, SelectItem } from "@heroui/react";
import { useMutation } from "@tanstack/react-query";
import { RootState } from "#/store";
import {
@@ -2,7 +2,7 @@ import {
Autocomplete,
AutocompleteItem,
AutocompleteSection,
} from "@nextui-org/react";
} from "@heroui/react";
import React from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import { Select, SelectItem } from "@nextui-org/react";
import { Select, SelectItem } from "@heroui/react";
import { I18nKey } from "#/i18n/declaration";
interface RuntimeSizeSelectorProps {
+8 -2
View File
@@ -5,10 +5,16 @@ interface AuthContextType {
setGitHubTokenIsSet: (value: boolean) => void;
}
interface AuthContextProps extends React.PropsWithChildren {
initialGithubTokenIsSet?: boolean;
}
const AuthContext = React.createContext<AuthContextType | undefined>(undefined);
function AuthProvider({ children }: React.PropsWithChildren) {
const [githubTokenIsSet, setGitHubTokenIsSet] = React.useState(false);
function AuthProvider({ children, initialGithubTokenIsSet }: AuthContextProps) {
const [githubTokenIsSet, setGitHubTokenIsSet] = React.useState(
!!initialGithubTokenIsSet,
);
const value = React.useMemo(
() => ({
+14 -17
View File
@@ -1,11 +1,8 @@
import posthog from "posthog-js";
import React from "react";
import { io, Socket } from "socket.io-client";
import EventLogger from "#/utils/event-logger";
import {
handleAssistantMessage,
handleStatusMessage,
} from "#/services/actions";
import { handleAssistantMessage } from "#/services/actions";
import { showChatError } from "#/utils/error-handler";
import { useRate } from "#/hooks/use-rate";
import { OpenHandsParsedEvent } from "#/types/core";
import {
@@ -85,19 +82,20 @@ export function updateStatusWhenErrorMessagePresent(data: ErrorArg | unknown) {
return;
}
let msgId: string | undefined;
if (
"data" in data &&
isObject(data.data) &&
"msg_id" in data.data &&
isString(data.data.msg_id)
) {
msgId = data.data.msg_id;
let metadata: Record<string, unknown> = {};
if ("data" in data && isObject(data.data)) {
if ("msg_id" in data.data && isString(data.data.msg_id)) {
msgId = data.data.msg_id;
}
metadata = data.data as Record<string, unknown>;
}
handleStatusMessage({
type: "error",
showChatError({
message: data.message,
id: msgId,
status_update: true,
source: "websocket",
metadata,
msgId,
});
}
}
@@ -153,7 +151,6 @@ export function WsClientProvider({
function handleError(data: unknown) {
setStatus(WsClientProviderStatus.DISCONNECTED);
updateStatusWhenErrorMessagePresent(data);
posthog.capture("socket_error");
}
React.useEffect(() => {
+4 -1
View File
@@ -4,6 +4,7 @@ import posthog from "posthog-js";
import { DEFAULT_SETTINGS } from "#/services/settings";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
import { useConfig } from "#/hooks/query/use-config";
const getSettingsQueryFn = async () => {
const apiSettings = await OpenHands.getSettings();
@@ -24,7 +25,8 @@ const getSettingsQueryFn = async () => {
};
export const useSettings = () => {
const { setGitHubTokenIsSet } = useAuth();
const { setGitHubTokenIsSet, githubTokenIsSet } = useAuth();
const { data: config } = useConfig();
const query = useQuery({
queryKey: ["settings"],
@@ -32,6 +34,7 @@ export const useSettings = () => {
initialData: DEFAULT_SETTINGS,
staleTime: 0,
retry: false,
enabled: config?.APP_MODE !== "saas" || githubTokenIsSet,
meta: {
disableToast: true,
},
+1 -1
View File
@@ -1,4 +1,4 @@
type Message = {
export type Message = {
sender: "user" | "assistant";
content: string;
timestamp: string;
-2
View File
@@ -15,6 +15,4 @@ export default [
route("served", "routes/app.tsx"),
]),
]),
route("oauth/github/callback", "routes/oauth.github.callback.tsx"),
] satisfies RouteConfig;
+1 -1
View File
@@ -1,4 +1,4 @@
import { useDisclosure } from "@nextui-org/react";
import { useDisclosure } from "@heroui/react";
import React from "react";
import { Outlet } from "react-router";
import { useDispatch, useSelector } from "react-redux";
@@ -1,39 +0,0 @@
import { useNavigate, useSearchParams } from "react-router";
import { useQuery } from "@tanstack/react-query";
import React from "react";
import OpenHands from "#/api/open-hands";
function OAuthGitHubCallback() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const code = searchParams.get("code");
const { isSuccess, error } = useQuery({
queryKey: ["access_token", code],
queryFn: () => OpenHands.getGitHubAccessToken(code!),
enabled: !!code,
});
React.useEffect(() => {
if (isSuccess) {
navigate("/");
}
}, [isSuccess]);
if (error) {
return (
<div>
<h1>Error</h1>
<p>{error.message}</p>
</div>
);
}
return (
<div>
<h1>Redirecting...</h1>
</div>
);
}
export default OAuthGitHubCallback;
+13 -1
View File
@@ -4,6 +4,7 @@ import {
addUserMessage,
addErrorMessage,
} from "#/state/chat-slice";
import { trackError } from "#/utils/error-handler";
import { appendSecurityAnalyzerInput } from "#/state/security-analyzer-slice";
import { setCode, setActiveFilepath } from "#/state/code-slice";
import { appendJupyterInput } from "#/state/jupyter-slice";
@@ -95,6 +96,11 @@ export function handleStatusMessage(message: StatusMessage) {
}),
);
} else if (message.type === "error") {
trackError({
message: message.message,
source: "chat",
metadata: { msgId: message.id },
});
store.dispatch(
addErrorMessage({
...message,
@@ -111,9 +117,15 @@ export function handleAssistantMessage(message: Record<string, unknown>) {
} else if (message.status_update) {
handleStatusMessage(message as unknown as StatusMessage);
} else {
const errorMsg = "Unknown message type received";
trackError({
message: errorMsg,
source: "chat",
metadata: { raw_message: message },
});
store.dispatch(
addErrorMessage({
message: "Unknown message type received",
message: errorMsg,
}),
);
}
+6
View File
@@ -1,4 +1,5 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import type { Message } from "#/message";
import { ActionSecurityRisk } from "#/state/security-analyzer-slice";
import {
@@ -154,6 +155,11 @@ export const chatSlice = createSlice({
causeMessage.success = !ipythonObs.content
.toLowerCase()
.includes("error:");
} else if (observationID === "read" || observationID === "edit") {
// For read/edit operations, we consider it successful if there's content and no error
causeMessage.success =
observation.payload.content.length > 0 &&
!observation.payload.content.toLowerCase().includes("error:");
}
if (observationID === "run" || observationID === "run_ipython") {
+15 -2
View File
@@ -83,7 +83,9 @@ export interface FileReadAction extends OpenHandsActionEvent<"read"> {
args: {
path: string;
thought: string;
translated_ipython_code: string | null;
security_risk: ActionSecurityRisk | null;
impl_source?: string;
view_range?: number[] | null;
};
}
@@ -100,7 +102,18 @@ export interface FileEditAction extends OpenHandsActionEvent<"edit"> {
source: "agent";
args: {
path: string;
translated_ipython_code: string;
command?: string;
file_text?: string | null;
view_range?: number[] | null;
old_str?: string | null;
new_str?: string | null;
insert_line?: number | null;
content?: string;
start?: number;
end?: number;
thought: string;
security_risk: ActionSecurityRisk | null;
impl_source?: string;
};
}
+42
View File
@@ -0,0 +1,42 @@
import posthog from "posthog-js";
import toast from "react-hot-toast";
import { handleStatusMessage } from "#/services/actions";
interface ErrorDetails {
message: string;
source?: string;
metadata?: Record<string, unknown>;
msgId?: string;
}
export function trackError({ message, source, metadata = {} }: ErrorDetails) {
const error = new Error(message);
posthog.captureException(error, {
error_source: source || "unknown",
...metadata,
});
}
export function showErrorToast({
message,
source,
metadata = {},
}: ErrorDetails) {
trackError({ message, source, metadata });
toast.error(message);
}
export function showChatError({
message,
source,
metadata = {},
msgId,
}: ErrorDetails) {
trackError({ message, source, metadata });
handleStatusMessage({
type: "error",
message,
id: msgId,
status_update: true,
});
}
@@ -1,5 +1,8 @@
import { AxiosError } from "axios";
import { isAxiosErrorWithResponse } from "./type-guards";
import {
isAxiosErrorWithErrorField,
isAxiosErrorWithMessageField,
} from "./type-guards";
/**
* Retrieve the error message from an Axios error
@@ -8,8 +11,13 @@ import { isAxiosErrorWithResponse } from "./type-guards";
export const retrieveAxiosErrorMessage = (error: AxiosError) => {
let errorMessage: string | null = null;
if (isAxiosErrorWithResponse(error) && error.response?.data.error) {
if (isAxiosErrorWithErrorField(error) && error.response?.data.error) {
errorMessage = error.response?.data.error;
} else if (
isAxiosErrorWithMessageField(error) &&
error.response?.data.message
) {
errorMessage = error.response?.data.message;
} else {
errorMessage = error.message;
}
+9 -1
View File
@@ -1,9 +1,17 @@
import { AxiosError } from "axios";
export const isAxiosErrorWithResponse = (
export const isAxiosErrorWithErrorField = (
error: AxiosError,
): error is AxiosError<{ error: string }> =>
typeof error.response?.data === "object" &&
error.response?.data !== null &&
"error" in error.response.data &&
typeof error.response?.data?.error === "string";
export const isAxiosErrorWithMessageField = (
error: AxiosError,
): error is AxiosError<{ message: string }> =>
typeof error.response?.data === "object" &&
error.response?.data !== null &&
"message" in error.response.data &&
typeof error.response?.data?.message === "string";
+1 -1
View File
@@ -15,7 +15,7 @@ export const VERIFIED_OPENAI_MODELS = [
"gpt-4",
"gpt-4-32k",
"o1-mini",
"o1-preview",
"o1",
"o3-mini",
"o3-mini-2025-01-31",
];
+3 -3
View File
@@ -1,10 +1,10 @@
/** @type {import('tailwindcss').Config} */
import { nextui } from "@nextui-org/react";
import { heroui } from "@heroui/react";
import typography from '@tailwindcss/typography';
export default {
content: [
"./src/**/*.{js,ts,jsx,tsx}",
"./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}",
"./node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
@@ -19,7 +19,7 @@ export default {
},
darkMode: "class",
plugins: [
nextui({
heroui({
defaultTheme: "dark",
layout: {
radius: {
+1 -1
View File
@@ -66,7 +66,7 @@ export function renderWithProviders(
function Wrapper({ children }: PropsWithChildren) {
return (
<Provider store={store}>
<AuthProvider>
<AuthProvider initialGithubTokenIsSet={true}>
<QueryClientProvider
client={
new QueryClient({
@@ -303,7 +303,6 @@ class CodeActAgent(Agent):
and len(obs.set_of_marks) > 0
and self.config.enable_som_visual_browsing
and self.llm.vision_is_active()
and self.llm.is_visual_browser_tool_supported()
):
text += 'Image: Current webpage screenshot (Note that only visible portion of webpage is present in the screenshot. You may need to scroll to view the remaining portion of the web-page.)\n'
message = Message(
@@ -16,7 +16,6 @@ from openhands.core.exceptions import (
FunctionCallNotExistsError,
FunctionCallValidationError,
)
from openhands.core.logger import openhands_logger as logger
from openhands.events.action import (
Action,
AgentDelegateAction,
@@ -541,26 +540,27 @@ def response_to_actions(response: ModelResponse) -> list[Action]:
raise FunctionCallValidationError(
f'Missing required argument "path" in tool call {tool_call.function.name}'
)
path = arguments['path']
command = arguments['command']
other_kwargs = {
k: v for k, v in arguments.items() if k not in ['command', 'path']
}
# We implement this in agent_skills, which can be used via Jupyter
# convert tool_call.function.arguments to kwargs that can be passed to file_editor
code = f'print(file_editor(**{arguments}))'
logger.debug(
f'TOOL CALL: str_replace_editor -> file_editor with code: {code}'
)
if arguments['command'] == 'view':
if command == 'view':
action = FileReadAction(
path=arguments['path'],
translated_ipython_code=code,
path=path,
impl_source=FileReadSource.OH_ACI,
view_range=other_kwargs.get('view_range', None),
)
else:
if 'view_range' in other_kwargs:
# Remove view_range from other_kwargs since it is not needed for FileEditAction
other_kwargs.pop('view_range')
action = FileEditAction(
path=arguments['path'],
content='', # dummy value -- we don't need it
translated_ipython_code=code,
path=path,
command=command,
impl_source=FileEditSource.OH_ACI,
**other_kwargs,
)
elif tool_call.function.name == 'browser':
if 'code' not in arguments:
+4
View File
@@ -3,6 +3,7 @@ import os
import pathlib
import platform
import sys
from ast import literal_eval
from types import UnionType
from typing import Any, MutableMapping, get_args, get_origin
from uuid import uuid4
@@ -72,6 +73,9 @@ def load_from_env(cfg: AppConfig, env_or_toml_dict: dict | MutableMapping[str, s
# Attempt to cast the env var to type hinted in the dataclass
if field_type is bool:
cast_value = str(value).lower() in ['true', '1']
# parse dicts like SANDBOX_RUNTIME_STARTUP_ENV_VARS
elif get_origin(field_type) is dict:
cast_value = literal_eval(value)
else:
cast_value = field_type(value)
setattr(sub_config, field_name, cast_value)
+61 -11
View File
@@ -21,7 +21,7 @@ class FileReadAction(Action):
runnable: ClassVar[bool] = True
security_risk: ActionSecurityRisk | None = None
impl_source: FileReadSource = FileReadSource.DEFAULT
translated_ipython_code: str = '' # translated openhands-aci IPython code
view_range: list[int] | None = None # ONLY used in OH_ACI mode
@property
def message(self) -> str:
@@ -60,29 +60,79 @@ class FileWriteAction(Action):
@dataclass
class FileEditAction(Action):
"""Edits a file by provided a draft at a given path.
"""Edits a file using various commands including view, create, str_replace, insert, and undo_edit.
Can be set to edit specific lines using start and end (1-index, inclusive) if the file is too long.
Default lines 1:-1 (whole file).
This class supports two main modes of operation:
1. LLM-based editing (impl_source = FileEditSource.LLM_BASED_EDIT)
2. ACI-based editing (impl_source = FileEditSource.OH_ACI)
If start is set to -1, the FileEditAction will simply append the content to the file.
Attributes:
path (str): The path to the file being edited. Works for both LLM-based and OH_ACI editing.
OH_ACI only arguments:
command (str): The editing command to be performed (view, create, str_replace, insert, undo_edit, write).
file_text (str): The content of the file to be created (used with 'create' command in OH_ACI mode).
old_str (str): The string to be replaced (used with 'str_replace' command in OH_ACI mode).
new_str (str): The string to replace old_str (used with 'str_replace' and 'insert' commands in OH_ACI mode).
insert_line (int): The line number after which to insert new_str (used with 'insert' command in OH_ACI mode).
LLM-based editing arguments:
content (str): The content to be written or edited in the file (used in LLM-based editing and 'write' command).
start (int): The starting line for editing (1-indexed, inclusive). Default is 1.
end (int): The ending line for editing (1-indexed, inclusive). Default is -1 (end of file).
thought (str): The reasoning behind the edit action.
action (str): The type of action being performed (always ActionType.EDIT).
runnable (bool): Indicates if the action can be executed (always True).
security_risk (ActionSecurityRisk | None): Indicates any security risks associated with the action.
impl_source (FileEditSource): The source of the implementation (LLM_BASED_EDIT or OH_ACI).
Usage:
- For LLM-based editing: Use path, content, start, and end attributes.
- For ACI-based editing: Use path, command, and the appropriate attributes for the specific command.
Note:
- If start is set to -1 in LLM-based editing, the content will be appended to the file.
- The 'write' command behaves similarly to LLM-based editing, using content, start, and end attributes.
"""
path: str
content: str
# OH_ACI arguments
command: str = ''
file_text: str | None = None
old_str: str | None = None
new_str: str | None = None
insert_line: int | None = None
# LLM-based editing arguments
content: str = ''
start: int = 1
end: int = -1
# Shared arguments
thought: str = ''
action: str = ActionType.EDIT
runnable: ClassVar[bool] = True
security_risk: ActionSecurityRisk | None = None
impl_source: FileEditSource = FileEditSource.LLM_BASED_EDIT
translated_ipython_code: str = ''
impl_source: FileEditSource = FileEditSource.OH_ACI
def __repr__(self) -> str:
ret = '**FileEditAction**\n'
ret += f'Thought: {self.thought}\n'
ret += f'Range: [L{self.start}:L{self.end}]\n'
ret += f'Path: [{self.path}]\n'
ret += f'Content:\n```\n{self.content}\n```\n'
ret += f'Thought: {self.thought}\n'
if self.impl_source == FileEditSource.LLM_BASED_EDIT:
ret += f'Range: [L{self.start}:L{self.end}]\n'
ret += f'Content:\n```\n{self.content}\n```\n'
else: # OH_ACI mode
ret += f'Command: {self.command}\n'
if self.command == 'create':
ret += f'Created File with Text:\n```\n{self.file_text}\n```\n'
elif self.command == 'str_replace':
ret += f'Old String: ```\n{self.old_str}\n```\n'
ret += f'New String: ```\n{self.new_str}\n```\n'
elif self.command == 'insert':
ret += f'Insert Line: {self.insert_line}\n'
ret += f'New String: ```\n{self.new_str}\n```\n'
elif self.command == 'undo_edit':
ret += 'Undo Edit\n'
# We ignore "view" command because it will be mapped to a FileReadAction
return ret
+81 -37
View File
@@ -1,3 +1,5 @@
"""File-related observation classes for tracking file operations."""
from dataclasses import dataclass
from difflib import SequenceMatcher
@@ -16,55 +18,77 @@ class FileReadObservation(Observation):
@property
def message(self) -> str:
"""Get a human-readable message describing the file read operation."""
return f'I read the file {self.path}.'
def __str__(self) -> str:
return f'[Read from {self.path} is successful.]\n' f'{self.content}'
"""Get a string representation of the file read observation."""
return f'[Read from {self.path} is successful.]\n{self.content}'
@dataclass
class FileWriteObservation(Observation):
"""This data class represents a file write operation"""
"""This data class represents a file write operation."""
path: str
observation: str = ObservationType.WRITE
@property
def message(self) -> str:
"""Get a human-readable message describing the file write operation."""
return f'I wrote to the file {self.path}.'
def __str__(self) -> str:
return f'[Write to {self.path} is successful.]\n' f'{self.content}'
"""Get a string representation of the file write observation."""
return f'[Write to {self.path} is successful.]\n{self.content}'
@dataclass
class FileEditObservation(Observation):
"""This data class represents a file edit operation"""
"""This data class represents a file edit operation.
# content: str will be a unified diff patch string include NO context lines
path: str
prev_exist: bool
old_content: str
new_content: str
The observation includes both the old and new content of the file, and can
generate a diff visualization showing the changes. The diff is computed lazily
and cached to improve performance.
The .content property can either be:
- Git diff in LLM-based editing mode
- the rendered message sent to the LLM in OH_ACI mode (e.g., "The file /path/to/file.txt is created with the provided content.")
"""
path: str = ''
prev_exist: bool = False
old_content: str | None = None
new_content: str | None = None
observation: str = ObservationType.EDIT
impl_source: FileEditSource = FileEditSource.LLM_BASED_EDIT
formatted_output_and_error: str = ''
_diff_cache: str | None = None # Cache for the diff visualization
@property
def message(self) -> str:
"""Get a human-readable message describing the file edit operation."""
return f'I edited the file {self.path}.'
def get_edit_groups(self, n_context_lines: int = 2) -> list[dict[str, list[str]]]:
"""Get the edit groups of the file edit."""
"""Get the edit groups showing changes between old and new content.
Args:
n_context_lines: Number of context lines to show around each change.
Returns:
A list of edit groups, where each group contains before/after edits.
"""
if self.old_content is None or self.new_content is None:
return []
old_lines = self.old_content.split('\n')
new_lines = self.new_content.split('\n')
# Borrowed from difflib.unified_diff to directly parse into structured format.
# Borrowed from difflib.unified_diff to directly parse into structured format
edit_groups: list[dict] = []
for group in SequenceMatcher(None, old_lines, new_lines).get_grouped_opcodes(
n_context_lines
):
# take the max line number in the group
_indent_pad_size = len(str(group[-1][3])) + 1 # +1 for the "*" prefix
# Take the max line number in the group
_indent_pad_size = len(str(group[-1][3])) + 1 # +1 for "*" prefix
cur_group: dict[str, list[str]] = {
'before_edits': [],
'after_edits': [],
@@ -72,23 +96,27 @@ class FileEditObservation(Observation):
for tag, i1, i2, j1, j2 in group:
if tag == 'equal':
for idx, line in enumerate(old_lines[i1:i2]):
line_num = i1 + idx + 1
cur_group['before_edits'].append(
f'{i1+idx+1:>{_indent_pad_size}}|{line}'
f'{line_num:>{_indent_pad_size}}|{line}'
)
for idx, line in enumerate(new_lines[j1:j2]):
line_num = j1 + idx + 1
cur_group['after_edits'].append(
f'{j1+idx+1:>{_indent_pad_size}}|{line}'
f'{line_num:>{_indent_pad_size}}|{line}'
)
continue
if tag in {'replace', 'delete'}:
for idx, line in enumerate(old_lines[i1:i2]):
line_num = i1 + idx + 1
cur_group['before_edits'].append(
f'-{i1+idx+1:>{_indent_pad_size-1}}|{line}'
f'-{line_num:>{_indent_pad_size-1}}|{line}'
)
if tag in {'replace', 'insert'}:
for idx, line in enumerate(new_lines[j1:j2]):
line_num = j1 + idx + 1
cur_group['after_edits'].append(
f'+{j1+idx+1:>{_indent_pad_size-1}}|{line}'
f'+{line_num:>{_indent_pad_size-1}}|{line}'
)
edit_groups.append(cur_group)
return edit_groups
@@ -100,24 +128,37 @@ class FileEditObservation(Observation):
) -> str:
"""Visualize the diff of the file edit.
Instead of showing the diff line by line, this function
shows each hunk of changes as a separate entity.
Instead of showing the diff line by line, this function shows each hunk
of changes as a separate entity.
Args:
n_context_lines: The number of lines of context to show before and after the changes.
change_applied: Whether the changes are applied to the file. If true, the file have been modified. If not, the file is not modified (due to linting errors).
n_context_lines: Number of context lines to show before/after changes.
change_applied: Whether changes are applied. If false, shows as
attempted edit.
Returns:
A string containing the formatted diff visualization.
"""
if change_applied and self.content.strip() == '':
# diff patch is empty
return '(no changes detected. Please make sure your edits changes the content of the existing file.)\n'
# Use cached diff if available
if self._diff_cache is not None:
return self._diff_cache
# Check if there are any changes
if change_applied and self.old_content == self.new_content:
msg = '(no changes detected. Please make sure your edits change '
msg += 'the content of the existing file.)\n'
self._diff_cache = msg
return self._diff_cache
edit_groups = self.get_edit_groups(n_context_lines=n_context_lines)
result = [
f'[Existing file {self.path} is edited with {len(edit_groups)} changes.]'
if change_applied
else f"[Changes are NOT applied to {self.path} - Here's how the file looks like if changes are applied.]"
]
if change_applied:
header = f'[Existing file {self.path} is edited with '
header += f'{len(edit_groups)} changes.]'
else:
header = f"[Changes are NOT applied to {self.path} - Here's how "
header += 'the file looks like if changes are applied.]'
result = [header]
op_type = 'edit' if change_applied else 'ATTEMPTED edit'
for i, cur_edit_group in enumerate(edit_groups):
@@ -129,18 +170,21 @@ class FileEditObservation(Observation):
result.append(f'(content after {op_type})')
result.extend(cur_edit_group['after_edits'])
result.append(f'[end of {op_type} {i+1} / {len(edit_groups)}]')
return '\n'.join(result)
# Cache the result
self._diff_cache = '\n'.join(result)
return self._diff_cache
def __str__(self) -> str:
"""Get a string representation of the file edit observation."""
if self.impl_source == FileEditSource.OH_ACI:
return self.formatted_output_and_error
return self.content
ret = ''
if not self.prev_exist:
assert (
self.old_content == ''
), 'old_content should be empty if the file is new (prev_exist=False).'
ret += f'[New file {self.path} is created with the provided content.]\n'
return ret.rstrip() + '\n'
ret += self.visualize_diff()
return ret.rstrip() + '\n'
return f'[New file {self.path} is created with the provided content.]\n'
# Use cached diff if available, otherwise compute it
return self.visualize_diff().rstrip() + '\n'
+36 -3
View File
@@ -1,3 +1,5 @@
import re
from openhands.core.exceptions import LLMMalformedActionError
from openhands.events.action.action import Action
from openhands.events.action.agent import (
@@ -38,6 +40,38 @@ actions = (
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:
# keep_prompt has been deprecated in https://github.com/All-Hands-AI/OpenHands/pull/4881
if 'keep_prompt' in args:
args.pop('keep_prompt')
# Handle translated_ipython_code deprecation
if 'translated_ipython_code' in args:
code = args.pop('translated_ipython_code')
# Check if it's a file_editor call
file_editor_pattern = r'print\(file_editor\(\*\*(.*?)\)\)'
if code is not None and (match := re.match(file_editor_pattern, code)):
try:
# Extract and evaluate the dictionary string
import ast
file_args = ast.literal_eval(match.group(1))
# Update args with the extracted file editor arguments
args.update(file_args)
except (ValueError, SyntaxError):
# If parsing fails, just remove the translated_ipython_code
pass
if args.get('command') == 'view':
args.pop(
'command'
) # "view" will be translated to FileReadAction which doesn't have a command argument
return args
def action_from_dict(action: dict) -> Action:
if not isinstance(action, dict):
raise LLMMalformedActionError('action must be a dictionary')
@@ -67,9 +101,8 @@ def action_from_dict(action: dict) -> Action:
if 'images_urls' in args:
args['image_urls'] = args.pop('images_urls')
# keep_prompt has been deprecated in https://github.com/All-Hands-AI/OpenHands/pull/4881
if 'keep_prompt' in args:
args.pop('keep_prompt')
# handle deprecated args
args = handle_action_deprecated_args(args)
try:
decoded_action = action_class(**args)
+19 -9
View File
@@ -64,6 +64,23 @@ def _update_cmd_output_metadata(
return metadata
def handle_observation_deprecated_extras(extras: dict) -> dict:
# These are deprecated in https://github.com/All-Hands-AI/OpenHands/pull/4881
if 'exit_code' in extras:
extras['metadata'] = _update_cmd_output_metadata(
extras.get('metadata', None), exit_code=extras.pop('exit_code')
)
if 'command_id' in extras:
extras['metadata'] = _update_cmd_output_metadata(
extras.get('metadata', None), pid=extras.pop('command_id')
)
# formatted_output_and_error has been deprecated in https://github.com/All-Hands-AI/OpenHands/pull/6671
if 'formatted_output_and_error' in extras:
extras.pop('formatted_output_and_error')
return extras
def observation_from_dict(observation: dict) -> Observation:
observation = observation.copy()
if 'observation' not in observation:
@@ -78,15 +95,8 @@ def observation_from_dict(observation: dict) -> Observation:
content = observation.pop('content', '')
extras = copy.deepcopy(observation.pop('extras', {}))
# Handle legacy attributes for CmdOutputObservation
if 'exit_code' in extras:
extras['metadata'] = _update_cmd_output_metadata(
extras.get('metadata', None), exit_code=extras.pop('exit_code')
)
if 'command_id' in extras:
extras['metadata'] = _update_cmd_output_metadata(
extras.get('metadata', None), pid=extras.pop('command_id')
)
extras = handle_observation_deprecated_extras(extras)
# convert metadata to CmdOutputMetadata if it is a dict
if observation_class is CmdOutputObservation:
if 'metadata' in extras and isinstance(extras['metadata'], dict):
+4
View File
@@ -120,6 +120,10 @@ class EventStream:
for callback_id in callback_ids:
self._clean_up_subscriber(subscriber_id, callback_id)
# Clear queue
while not self._queue.empty():
self._queue.get()
def _clean_up_subscriber(self, subscriber_id: str, callback_id: str):
if subscriber_id not in self._subscribers:
logger.warning(f'Subscriber not found during cleanup: {subscriber_id}')
@@ -1,50 +1,47 @@
import os
from typing import Any
import httpx
from fastapi import Request
from pydantic import SecretStr
from openhands.server.auth import get_github_token
from openhands.server.config_init import config, server_config
from openhands.server.data_models.gh_types import GitHubRepository, GitHubUser
from openhands.server.types import AppMode, GhAuthenticationError, GHUnknownException
from openhands.storage.settings.settings_store import SettingsStore
from openhands.integrations.github.github_types import (
GhAuthenticationError,
GHUnknownException,
GitHubRepository,
GitHubUser,
)
from openhands.utils.import_utils import get_impl
SettingsStoreImpl = get_impl(SettingsStore, server_config.settings_store_class) # type: ignore
class GitHubService:
BASE_URL = 'https://api.github.com'
token: str = ''
token: SecretStr = SecretStr('')
refresh = False
def __init__(self, user_id: str | None):
def __init__(self, user_id: str | None = None, token: SecretStr | None = None):
self.user_id = user_id
async def get_user_token(self) -> str:
settings_store = await SettingsStoreImpl.get_instance(config, self.user_id)
settings = await settings_store.load()
if settings and settings.github_token:
return settings.github_token.get_secret_value()
if token:
self.token = token
return ''
async def _get_github_headers(self):
async def _get_github_headers(self) -> dict:
"""
Retrieve the GH Token from settings store to construct the headers
"""
self.token = await self.get_user_token()
if self.user_id and not self.token:
self.token = await self.get_latest_token()
return {
'Authorization': f'Bearer {self.token}',
'Authorization': f'Bearer {self.token.get_secret_value()}',
'Accept': 'application/vnd.github.v3+json',
}
def _has_token_expired(self, status_code: int):
def _has_token_expired(self, status_code: int) -> bool:
return status_code == 401
async def _get_latest_token(self):
pass
async def get_latest_token(self) -> SecretStr:
return self.token
async def _fetch_data(
self, url: str, params: dict | None = None
@@ -53,10 +50,8 @@ class GitHubService:
async with httpx.AsyncClient() as client:
github_headers = await self._get_github_headers()
response = await client.get(url, headers=github_headers, params=params)
if server_config.app_mode == AppMode.SAAS and self._has_token_expired(
response.status_code
):
await self._get_latest_token()
if self.refresh and self._has_token_expired(response.status_code):
await self.get_latest_token()
github_headers = await self._get_github_headers()
response = await client.get(
url, headers=github_headers, params=params
@@ -69,8 +64,10 @@ class GitHubService:
return response.json(), headers
except httpx.HTTPStatusError:
raise GhAuthenticationError('Invalid Github token')
except httpx.HTTPStatusError as e:
if e.response.status_code == 401:
raise GhAuthenticationError('Invalid Github token')
raise GHUnknownException('Unknown error')
except httpx.HTTPError:
raise GHUnknownException('Unknown error')
@@ -88,10 +85,6 @@ class GitHubService:
email=response.get('email'),
)
async def validate_user(self, token) -> GitHubUser:
self.token = token
return await self.get_user()
async def get_repositories(
self, page: int, per_page: int, sort: str, installation_id: int | None
) -> list[GitHubRepository]:
@@ -143,6 +136,9 @@ class GitHubService:
return repos
@classmethod
def get_gh_token(cls, request: Request) -> str | None:
return get_github_token(request)
github_service_cls = os.environ.get(
'OPENHANDS_GITHUB_SERVICE_CLS',
'openhands.integrations.github.github_service.GitHubService',
)
GithubServiceImpl = get_impl(GitHubService, github_service_cls)
@@ -15,3 +15,15 @@ class GitHubRepository(BaseModel):
full_name: str
stargazers_count: int | None = None
link_header: str | None = None
class GhAuthenticationError(ValueError):
"""Raised when there is an issue with GitHub authentication."""
pass
class GHUnknownException(ValueError):
"""Raised when there is an issue with GitHub communcation."""
pass
+3 -19
View File
@@ -75,16 +75,6 @@ FUNCTION_CALLING_SUPPORTED_MODELS = [
'o3-mini',
]
# visual browsing tool supported models
# This flag is needed since gpt-4o and gpt-4o-mini do not allow passing image_urls with role='tool'
VISUAL_BROWSING_TOOL_SUPPORTED_MODELS = [
'claude-3-5-sonnet',
'claude-3-5-sonnet-20240620',
'claude-3-5-sonnet-20241022',
'o1-2024-12-17',
]
REASONING_EFFORT_SUPPORTED_MODELS = [
'o1-2024-12-17',
'o1',
@@ -232,6 +222,9 @@ class LLM(RetryMixin, DebugMixin):
kwargs['stop'] = STOP_WORDS
mock_fncall_tools = kwargs.pop('tools')
kwargs['tool_choice'] = (
'none' # force no tool calling because we're mocking it - without it, it will cause issue with sglang
)
# if we have no messages, something went very wrong
if not messages:
@@ -492,15 +485,6 @@ class LLM(RetryMixin, DebugMixin):
"""
return self._function_calling_active
def is_visual_browser_tool_supported(self) -> bool:
return (
self.config.model in VISUAL_BROWSING_TOOL_SUPPORTED_MODELS
or self.config.model.split('/')[-1] in VISUAL_BROWSING_TOOL_SUPPORTED_MODELS
or any(
m in self.config.model for m in VISUAL_BROWSING_TOOL_SUPPORTED_MODELS
)
)
def _post_completion(self, response: ModelResponse) -> float:
"""Post-process the completion response.
@@ -25,6 +25,7 @@ jobs:
max_iterations: ${{ fromJson(vars.OPENHANDS_MAX_ITER || 50) }}
base_container_image: ${{ vars.OPENHANDS_BASE_CONTAINER_IMAGE || '' }}
LLM_MODEL: ${{ vars.LLM_MODEL || 'anthropic/claude-3-5-sonnet-20241022' }}
target_branch: ${{ vars.TARGET_BRANCH || 'main' }}
secrets:
PAT_TOKEN: ${{ secrets.PAT_TOKEN }}
PAT_USERNAME: ${{ secrets.PAT_USERNAME }}
+11 -6
View File
@@ -94,12 +94,17 @@ def apply_diff(diff, text, reverse=False, use_patch=False):
hunk=hunk,
)
if lines[old - 1] != line:
raise HunkApplyException(
'context line {n}, "{line}" does not match "{sl}"'.format(
n=old, line=line, sl=lines[old - 1]
),
hunk=hunk,
)
# Try to normalize whitespace by replacing multiple spaces with a single space
# This helps with patches that have different indentation levels
normalized_line = ' '.join(line.split())
normalized_source = ' '.join(lines[old - 1].split())
if normalized_line != normalized_source:
raise HunkApplyException(
'context line {n}, "{line}" does not match "{sl}"'.format(
n=old, line=line, sl=lines[old - 1]
),
hunk=hunk,
)
# for calculating the old line
r = 0
+18
View File
@@ -109,9 +109,27 @@ Key features:
- Real-time logging and debugging capabilities
- Direct access to the local file system
- Faster execution due to local resources
- Container isolation for security
This is the default runtime used within OpenHands.
### Local Runtime
The Local Runtime is designed for direct execution on the local machine. Currently only supports running as the local user:
- Runs the action_execution_server directly on the host
- No Docker container overhead
- Direct access to local system resources
- Ideal for development and testing when Docker is not available or desired
Key features:
- Minimal setup required
- Direct access to local resources
- No container overhead
- Fastest execution speed
**Important: This runtime provides no isolation as it runs directly on the host machine. All actions are executed with the same permissions as the user running OpenHands. For secure execution with proper isolation, use the Docker Runtime instead.**
### Remote Runtime
The Remote Runtime is designed for execution in a remote environment:
+3
View File
@@ -3,6 +3,7 @@ from openhands.runtime.impl.docker.docker_runtime import (
DockerRuntime,
)
from openhands.runtime.impl.e2b.sandbox import E2BBox
from openhands.runtime.impl.local.local_runtime import LocalRuntime
from openhands.runtime.impl.modal.modal_runtime import ModalRuntime
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
from openhands.runtime.impl.runloop.runloop_runtime import RunloopRuntime
@@ -21,6 +22,8 @@ def get_runtime_cls(name: str):
return ModalRuntime
elif name == 'runloop':
return RunloopRuntime
elif name == 'local':
return LocalRuntime
else:
raise ValueError(f'Runtime {name} not supported')
+148 -116
View File
@@ -9,10 +9,8 @@ import argparse
import asyncio
import base64
import io
import json
import mimetypes
import os
import re
import shutil
import tempfile
import time
@@ -25,7 +23,9 @@ from fastapi import Depends, FastAPI, HTTPException, Request, UploadFile
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.security import APIKeyHeader
from openhands_aci.utils.diff import get_diff
from openhands_aci.editor.editor import OHEditor
from openhands_aci.editor.exceptions import ToolError
from openhands_aci.editor.results import ToolResult
from pydantic import BaseModel
from starlette.exceptions import HTTPException as StarletteHTTPException
from uvicorn import run
@@ -36,6 +36,7 @@ from openhands.events.action import (
BrowseInteractiveAction,
BrowseURLAction,
CmdRunAction,
FileEditAction,
FileReadAction,
FileWriteAction,
IPythonRunCellAction,
@@ -66,9 +67,6 @@ class ActionRequest(BaseModel):
ROOT_GID = 0
INIT_COMMANDS = [
'git config --global user.name "openhands" && git config --global user.email "openhands@all-hands.dev" && alias git="git --no-pager"',
]
SESSION_API_KEY = os.environ.get('SESSION_API_KEY')
api_key_header = APIKeyHeader(name='X-Session-API-Key', auto_error=False)
@@ -80,6 +78,58 @@ def verify_api_key(api_key: str = Depends(api_key_header)):
return api_key
def _execute_file_editor(
editor: OHEditor,
command: str,
path: str,
file_text: str | None = None,
view_range: list[int] | None = None,
old_str: str | None = None,
new_str: str | None = None,
insert_line: int | None = None,
enable_linting: bool = False,
) -> str:
"""Execute file editor command and handle exceptions.
Args:
editor: The OHEditor instance
command: Editor command to execute
path: File path
file_text: Optional file text content
view_range: Optional view range tuple (start, end)
old_str: Optional string to replace
new_str: Optional replacement string
insert_line: Optional line number for insertion
enable_linting: Whether to enable linting
Returns:
str: Result string from the editor operation
"""
result: ToolResult | None = None
try:
result = editor(
command=command,
path=path,
file_text=file_text,
view_range=view_range,
old_str=old_str,
new_str=new_str,
insert_line=insert_line,
enable_linting=enable_linting,
)
except ToolError as e:
result = ToolResult(error=e.message)
if result.error:
return f'ERROR:\n{result.error}'
if not result.output:
logger.warning(f'No output from file_editor for {path}')
return ''
return result.output
class ActionExecutor:
"""ActionExecutor is running inside docker sandbox.
It is responsible for executing actions received from OpenHands backend and producing observations.
@@ -106,11 +156,21 @@ class ActionExecutor:
self.bash_session: BashSession | None = None
self.lock = asyncio.Lock()
self.plugins: dict[str, Plugin] = {}
self.file_editor = OHEditor()
self.browser = BrowserEnv(browsergym_eval_env)
self.start_time = time.time()
self.last_execution_time = self.start_time
self._initialized = False
self.max_memory_gb: int | None = None
if _override_max_memory_gb := os.environ.get('RUNTIME_MAX_MEMORY_GB', None):
self.max_memory_gb = int(_override_max_memory_gb)
logger.info(
f'Setting max memory to {self.max_memory_gb}GB (according to the RUNTIME_MAX_MEMORY_GB environment variable)'
)
else:
logger.info('No max memory limit set, using all available system memory')
@property
def initial_cwd(self):
return self._initial_cwd
@@ -123,8 +183,10 @@ class ActionExecutor:
no_change_timeout_seconds=int(
os.environ.get('NO_CHANGE_TIMEOUT_SECONDS', 30)
),
max_memory_mb=self.max_memory_gb * 1024 if self.max_memory_gb else None,
)
self.bash_session.initialize()
await wait_all(
(self._init_plugin(plugin) for plugin in self.plugins_to_load),
timeout=30,
@@ -163,6 +225,11 @@ class ActionExecutor:
)
async def _init_bash_commands(self):
INIT_COMMANDS = [
'git config --file ./.git_config user.name "openhands" && git config --file ./.git_config user.email "openhands@all-hands.dev" && alias git="git --no-pager" && export GIT_CONFIG=$(pwd)/.git_config'
if os.environ.get('LOCAL_RUNTIME_MODE') == '1'
else 'git config --global user.name "openhands" && git config --global user.email "openhands@all-hands.dev" && alias git="git --no-pager"'
]
logger.debug(f'Initializing by running {len(INIT_COMMANDS)} bash commands...')
for command in INIT_COMMANDS:
action = CmdRunAction(command=command)
@@ -174,7 +241,6 @@ class ActionExecutor:
f'Init command outputs (exit code: {obs.exit_code}): {obs.content}'
)
assert obs.exit_code == 0
logger.debug('Bash init commands completed')
async def run_action(self, action) -> Observation:
@@ -217,65 +283,6 @@ class ActionExecutor:
obs: IPythonRunCellObservation = await _jupyter_plugin.run(action)
obs.content = obs.content.rstrip()
matches = re.findall(
r'<oh_aci_output_[0-9a-f]{32}>(.*?)</oh_aci_output_[0-9a-f]{32}>',
obs.content,
re.DOTALL,
)
if matches:
results: list[str] = []
if len(matches) == 1:
# Use specific actions/observations types
match = matches[0]
try:
result_dict = json.loads(match)
if result_dict.get('path'): # Successful output
if (
result_dict['new_content'] is not None
): # File edit commands
diff = get_diff(
old_contents=result_dict['old_content']
or '', # old_content is None when file is created
new_contents=result_dict['new_content'],
filepath=result_dict['path'],
)
return FileEditObservation(
content=diff,
path=result_dict['path'],
old_content=result_dict['old_content'],
new_content=result_dict['new_content'],
prev_exist=result_dict['prev_exist'],
impl_source=FileEditSource.OH_ACI,
formatted_output_and_error=result_dict[
'formatted_output_and_error'
],
)
else: # File view commands
return FileReadObservation(
content=result_dict['formatted_output_and_error'],
path=result_dict['path'],
impl_source=FileReadSource.OH_ACI,
)
else: # Error output
results.append(result_dict['formatted_output_and_error'])
except json.JSONDecodeError:
# Handle JSON decoding errors if necessary
results.append(
f"Invalid JSON in 'openhands-aci' output: {match}"
)
else:
for match in matches:
try:
result_dict = json.loads(match)
results.append(result_dict['formatted_output_and_error'])
except json.JSONDecodeError:
# Handle JSON decoding errors if necessary
results.append(
f"Invalid JSON in 'openhands-aci' output: {match}"
)
# Combine the results (e.g., join them) or handle them as required
obs.content = '\n'.join(str(result) for result in results)
if action.include_extra:
obs.content += (
@@ -297,11 +304,17 @@ class ActionExecutor:
async def read(self, action: FileReadAction) -> Observation:
assert self.bash_session is not None
if action.impl_source == FileReadSource.OH_ACI:
return await self.run_ipython(
IPythonRunCellAction(
code=action.translated_ipython_code,
include_extra=False,
)
result_str = _execute_file_editor(
self.file_editor,
command='view',
path=action.path,
view_range=action.view_range,
)
return FileReadObservation(
content=result_str,
path=action.path,
impl_source=FileReadSource.OH_ACI,
)
# NOTE: the client code is running inside the sandbox,
@@ -358,56 +371,75 @@ class ActionExecutor:
filepath = self._resolve_path(action.path, working_dir)
insert = action.content.split('\n')
if not os.path.exists(os.path.dirname(filepath)):
os.makedirs(os.path.dirname(filepath))
file_exists = os.path.exists(filepath)
if file_exists:
file_stat = os.stat(filepath)
else:
file_stat = None
mode = 'w' if not file_exists else 'r+'
try:
if not os.path.exists(os.path.dirname(filepath)):
os.makedirs(os.path.dirname(filepath))
file_exists = os.path.exists(filepath)
if file_exists:
file_stat = os.stat(filepath)
else:
file_stat = None
mode = 'w' if not file_exists else 'r+'
try:
with open(filepath, mode, encoding='utf-8') as file:
if mode != 'w':
all_lines = file.readlines()
new_file = insert_lines(
insert, all_lines, action.start, action.end
)
else:
new_file = [i + '\n' for i in insert]
file.seek(0)
file.writelines(new_file)
file.truncate()
# Handle file permissions
if file_exists:
assert file_stat is not None
# restore the original file permissions if the file already exists
os.chmod(filepath, file_stat.st_mode)
os.chown(filepath, file_stat.st_uid, file_stat.st_gid)
with open(filepath, mode, encoding='utf-8') as file:
if mode != 'w':
all_lines = file.readlines()
new_file = insert_lines(insert, all_lines, action.start, action.end)
else:
# set the new file permissions if the file is new
os.chmod(filepath, 0o664)
os.chown(filepath, self.user_id, self.user_id)
new_file = [i + '\n' for i in insert]
except FileNotFoundError:
return ErrorObservation(f'File not found: {filepath}')
except IsADirectoryError:
return ErrorObservation(
f'Path is a directory: {filepath}. You can only write to files'
)
except UnicodeDecodeError:
return ErrorObservation(
f'File could not be decoded as utf-8: {filepath}'
)
except PermissionError:
return ErrorObservation(f'Malformed paths not permitted: {filepath}')
file.seek(0)
file.writelines(new_file)
file.truncate()
except FileNotFoundError:
return ErrorObservation(f'File not found: {filepath}')
except IsADirectoryError:
return ErrorObservation(
f'Path is a directory: {filepath}. You can only write to files'
)
except UnicodeDecodeError:
return ErrorObservation(f'File could not be decoded as utf-8: {filepath}')
# Attempt to handle file permissions
try:
if file_exists:
assert file_stat is not None
# restore the original file permissions if the file already exists
os.chmod(filepath, file_stat.st_mode)
os.chown(filepath, file_stat.st_uid, file_stat.st_gid)
else:
# set the new file permissions if the file is new
os.chmod(filepath, 0o664)
os.chown(filepath, self.user_id, self.user_id)
except PermissionError as e:
return ErrorObservation(
f'File {filepath} written, but failed to change ownership and permissions: {e}'
)
return FileWriteObservation(content='', path=filepath)
async def edit(self, action: FileEditAction) -> Observation:
assert action.impl_source == FileEditSource.OH_ACI
result_str = _execute_file_editor(
self.file_editor,
command=action.command,
path=action.path,
file_text=action.file_text,
old_str=action.old_str,
new_str=action.new_str,
insert_line=action.insert_line,
enable_linting=False,
)
return FileEditObservation(
content=result_str,
path=action.path,
old_content=action.old_str,
new_content=action.new_str,
impl_source=FileEditSource.OH_ACI,
)
async def browse(self, action: BrowseURLAction) -> Observation:
return await browse(action, self.browser)
+15 -19
View File
@@ -12,6 +12,7 @@ from pathlib import Path
from typing import Callable
from zipfile import ZipFile
from pydantic import SecretStr
from requests.exceptions import ConnectionError
from openhands.core.config import AppConfig, SandboxConfig
@@ -38,6 +39,7 @@ from openhands.events.observation import (
UserRejectObservation,
)
from openhands.events.serialization.action import ACTION_TYPE_TO_CLASS
from openhands.integrations.github.github_service import GithubServiceImpl
from openhands.microagent import (
BaseMicroAgent,
load_microagents_from_dir,
@@ -93,6 +95,7 @@ class Runtime(FileEditRuntimeMixin):
status_callback: Callable | None = None,
attach_to_existing: bool = False,
headless_mode: bool = False,
github_user_id: str | None = None,
):
self.sid = sid
self.event_stream = event_stream
@@ -125,6 +128,8 @@ class Runtime(FileEditRuntimeMixin):
self, enable_llm_editor=config.get_agent_config().codeact_enable_llm_editor
)
self.github_user_id = github_user_id
def setup_initial_env(self) -> None:
if self.attach_to_existing:
return
@@ -133,14 +138,6 @@ class Runtime(FileEditRuntimeMixin):
if self.config.sandbox.runtime_startup_env_vars:
self.add_env_vars(self.config.sandbox.runtime_startup_env_vars)
def attach_github_token(self, token) -> None:
print('attaching token via runtime')
cmd = f'export GITHUB_TOKEN={json.dumps(token)};'
obs = self.run(CmdRunAction(cmd))
if not isinstance(obs, CmdOutputObservation) or obs.exit_code != 0:
raise RuntimeError(f'Failed to update gh token: {obs.content}')
def close(self) -> None:
"""
This should only be called by conversation manager or closing the session.
@@ -221,15 +218,14 @@ class Runtime(FileEditRuntimeMixin):
assert event.timeout is not None
try:
if isinstance(event, CmdRunAction):
print('found event action', event.command)
if '$GITHUB_TOKEN' in event.command:
print('token required by action', event.command)
await call_sync_from_async(
self.run,
CmdRunAction(
"export UNTESTED_GITHUB_TOKEN='this is a dummy token'"
),
)
if self.github_user_id and '$GITHUB_TOKEN' in event.command:
gh_client = GithubServiceImpl(user_id=self.github_user_id)
token = await gh_client.get_latest_token()
if token:
export_cmd = CmdRunAction(
f"export GITHUB_TOKEN='{token.get_secret_value()}'"
)
await call_sync_from_async(self.run, export_cmd)
observation: Observation = await call_sync_from_async(
self.run_action, event
@@ -253,12 +249,12 @@ class Runtime(FileEditRuntimeMixin):
source = event.source if event.source else EventSource.AGENT
self.event_stream.add_event(observation, source) # type: ignore[arg-type]
def clone_repo(self, github_token: str, selected_repository: str) -> str:
def clone_repo(self, github_token: SecretStr, selected_repository: str) -> str:
if not github_token or not selected_repository:
raise ValueError(
'github_token and selected_repository must be provided to clone a repository'
)
url = f'https://{github_token}@github.com/{selected_repository}.git'
url = f'https://{github_token.get_secret_value()}@github.com/{selected_repository}.git'
dir_name = selected_repository.split('/')[1]
# add random branch name to avoid conflicts
random_str = ''.join(
@@ -24,6 +24,7 @@ from openhands.events.action import (
IPythonRunCellAction,
)
from openhands.events.action.action import Action
from openhands.events.action.files import FileEditSource
from openhands.events.observation import (
ErrorObservation,
NullObservation,
@@ -55,6 +56,7 @@ class ActionExecutionClient(Runtime):
status_callback: Any | None = None,
attach_to_existing: bool = False,
headless_mode: bool = True,
github_user_id: str | None = None,
):
self.session = HttpSession()
self.action_semaphore = threading.Semaphore(1) # Ensure one action at a time
@@ -70,6 +72,7 @@ class ActionExecutionClient(Runtime):
status_callback,
attach_to_existing,
headless_mode,
github_user_id,
)
@abstractmethod
@@ -140,11 +143,13 @@ class ActionExecutionClient(Runtime):
stream=True,
timeout=30,
) as response:
temp_file = tempfile.NamedTemporaryFile(delete=False)
for chunk in response.iter_content(chunk_size=8192):
if chunk: # filter out keep-alive new chunks
temp_file.write(chunk)
return Path(temp_file.name)
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
total_length = 0
for chunk in response.iter_content(chunk_size=8192):
if chunk: # filter out keep-alive new chunks
total_length += len(chunk)
temp_file.write(chunk)
return Path(temp_file.name)
except requests.Timeout:
raise TimeoutError('Copy operation timed out')
@@ -213,8 +218,11 @@ class ActionExecutionClient(Runtime):
return ''
def send_action_for_execution(self, action: Action) -> Observation:
if isinstance(action, FileEditAction):
return self.edit(action)
if (
isinstance(action, FileEditAction)
and action.impl_source == FileEditSource.LLM_BASED_EDIT
):
return self.llm_based_edit(action)
# set timeout to default if not set
if action.timeout is None:
@@ -277,6 +285,9 @@ class ActionExecutionClient(Runtime):
def write(self, action: FileWriteAction) -> Observation:
return self.send_action_for_execution(action)
def edit(self, action: FileEditAction) -> Observation:
return self.send_action_for_execution(action)
def browse(self, action: BrowseURLAction) -> Observation:
return self.send_action_for_execution(action)
@@ -0,0 +1,342 @@
"""
This runtime runs the action_execution_server directly on the local machine without Docker.
"""
import os
import shutil
import subprocess
import tempfile
import threading
from typing import Callable, Optional
import requests
import tenacity
import openhands
from openhands.core.config import AppConfig
from openhands.core.exceptions import AgentRuntimeDisconnectedError
from openhands.core.logger import openhands_logger as logger
from openhands.events import EventStream
from openhands.events.action import (
Action,
)
from openhands.events.observation import (
Observation,
)
from openhands.events.serialization import event_to_dict, observation_from_dict
from openhands.runtime.impl.action_execution.action_execution_client import (
ActionExecutionClient,
)
from openhands.runtime.impl.docker.docker_runtime import (
APP_PORT_RANGE_1,
APP_PORT_RANGE_2,
EXECUTION_SERVER_PORT_RANGE,
VSCODE_PORT_RANGE,
)
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.utils import find_available_tcp_port
from openhands.runtime.utils.command import get_action_execution_server_startup_command
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.tenacity_stop import stop_if_should_exit
def check_dependencies(code_repo_path: str, poetry_venvs_path: str):
ERROR_MESSAGE = 'Please follow the instructions in https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md to install OpenHands.'
if not os.path.exists(code_repo_path):
raise ValueError(
f'Code repo path {code_repo_path} does not exist. ' + ERROR_MESSAGE
)
if not os.path.exists(poetry_venvs_path):
raise ValueError(
f'Poetry venvs path {poetry_venvs_path} does not exist. ' + ERROR_MESSAGE
)
# Check jupyter is installed
logger.debug('Checking dependencies: Jupyter')
output = subprocess.check_output(
'poetry run jupyter --version',
shell=True,
text=True,
cwd=code_repo_path,
)
logger.debug(f'Jupyter output: {output}')
if 'jupyter' not in output.lower():
raise ValueError('Jupyter is not properly installed. ' + ERROR_MESSAGE)
# Check libtmux is installed
logger.debug('Checking dependencies: libtmux')
import libtmux
server = libtmux.Server()
session = server.new_session(session_name='test-session')
pane = session.attached_pane
pane.send_keys('echo "test"')
pane_output = '\n'.join(pane.cmd('capture-pane', '-p').stdout)
session.kill_session()
if 'test' not in pane_output:
raise ValueError('libtmux is not properly installed. ' + ERROR_MESSAGE)
# Check browser works
logger.debug('Checking dependencies: browser')
from openhands.runtime.browser.browser_env import BrowserEnv
browser = BrowserEnv()
browser.close()
class LocalRuntime(ActionExecutionClient):
"""This runtime will run the action_execution_server directly on the local machine.
When receiving an event, it will send the event to the server via HTTP.
Args:
config (AppConfig): The application configuration.
event_stream (EventStream): The event stream to subscribe to.
sid (str, optional): The session ID. Defaults to 'default'.
plugins (list[PluginRequirement] | None, optional): List of plugin requirements. Defaults to None.
env_vars (dict[str, str] | None, optional): Environment variables to set. Defaults to None.
"""
def __init__(
self,
config: AppConfig,
event_stream: EventStream,
sid: str = 'default',
plugins: list[PluginRequirement] | None = None,
env_vars: dict[str, str] | None = None,
status_callback: Callable | None = None,
attach_to_existing: bool = False,
headless_mode: bool = True,
):
self.config = config
self._user_id = os.getuid()
self._username = os.getenv('USER')
if self.config.workspace_base is not None:
logger.warning(
f'Workspace base path is set to {self.config.workspace_base}. '
'It will be used as the path for the agent to run in. '
'Be careful, the agent can EDIT files in this directory!'
)
self.config.workspace_mount_path_in_sandbox = self.config.workspace_base
self._temp_workspace = None
else:
# A temporary directory is created for the agent to run in
# This is used for the local runtime only
self._temp_workspace = tempfile.mkdtemp(
prefix=f'openhands_workspace_{sid}',
)
self.config.workspace_mount_path_in_sandbox = self._temp_workspace
logger.warning(
'Initializing LocalRuntime. WARNING: NO SANDBOX IS USED. '
'This is an experimental feature, please report issues to https://github.com/All-Hands-AI/OpenHands/issues. '
'`run_as_openhands` will be ignored since the current user will be used to launch the server. '
'We highly recommend using a sandbox (eg. DockerRuntime) unless you '
'are running in a controlled environment.\n'
f'Temp workspace: {self._temp_workspace}. '
f'User ID: {self._user_id}. '
f'Username: {self._username}.'
)
if self.config.workspace_base is not None:
logger.warning(
f'Workspace base path is set to {self.config.workspace_base}. It will be used as the path for the agent to run in.'
)
self.config.workspace_mount_path_in_sandbox = self.config.workspace_base
else:
logger.warning(
'Workspace base path is NOT set. Agent will run in a temporary directory.'
)
self._temp_workspace = tempfile.mkdtemp()
self.config.workspace_mount_path_in_sandbox = self._temp_workspace
self._host_port = -1
self._vscode_port = -1
self._app_ports: list[int] = []
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._host_port}'
self.status_callback = status_callback
self.server_process: Optional[subprocess.Popen[str]] = None
self.action_semaphore = threading.Semaphore(1) # Ensure one action at a time
# Update env vars
if self.config.sandbox.runtime_startup_env_vars:
os.environ.update(self.config.sandbox.runtime_startup_env_vars)
# Initialize the action_execution_server
super().__init__(
config,
event_stream,
sid,
plugins,
env_vars,
status_callback,
attach_to_existing,
headless_mode,
)
def _get_action_execution_server_host(self):
return self.api_url
async def connect(self):
"""Start the action_execution_server on the local machine."""
self.send_status_message('STATUS$STARTING_RUNTIME')
self._host_port = self._find_available_port(EXECUTION_SERVER_PORT_RANGE)
self._vscode_port = self._find_available_port(VSCODE_PORT_RANGE)
self._app_ports = [
self._find_available_port(APP_PORT_RANGE_1),
self._find_available_port(APP_PORT_RANGE_2),
]
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._host_port}'
# Start the server process
cmd = get_action_execution_server_startup_command(
server_port=self._host_port,
plugins=self.plugins,
app_config=self.config,
python_prefix=[],
override_user_id=self._user_id,
override_username=self._username,
)
self.log('debug', f'Starting server with command: {cmd}')
env = os.environ.copy()
# Get the code repo path
code_repo_path = os.path.dirname(os.path.dirname(openhands.__file__))
env['PYTHONPATH'] = f'{code_repo_path}:$PYTHONPATH'
env['OPENHANDS_REPO_PATH'] = code_repo_path
env['LOCAL_RUNTIME_MODE'] = '1'
# run poetry show -v | head -n 1 | awk '{print $2}'
poetry_venvs_path = (
subprocess.check_output(
['poetry', 'show', '-v'],
env=env,
cwd=code_repo_path,
text=True,
shell=False,
)
.splitlines()[0]
.split(':')[1]
.strip()
)
env['POETRY_VIRTUALENVS_PATH'] = poetry_venvs_path
logger.debug(f'POETRY_VIRTUALENVS_PATH: {poetry_venvs_path}')
check_dependencies(code_repo_path, poetry_venvs_path)
self.server_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True,
bufsize=1,
env=env,
)
# Start a thread to read and log server output
def log_output():
while (
self.server_process
and self.server_process.poll()
and self.server_process.stdout
):
line = self.server_process.stdout.readline()
if not line:
break
self.log('debug', f'Server: {line.strip()}')
self._log_thread = threading.Thread(target=log_output, daemon=True)
self._log_thread.start()
self.log('info', f'Waiting for server to become ready at {self.api_url}...')
self.send_status_message('STATUS$WAITING_FOR_CLIENT')
await call_sync_from_async(self._wait_until_alive)
if not self.attach_to_existing:
await call_sync_from_async(self.setup_initial_env)
self.log(
'debug',
f'Server initialized with plugins: {[plugin.name for plugin in self.plugins]}',
)
if not self.attach_to_existing:
self.send_status_message(' ')
self._runtime_initialized = True
def _find_available_port(self, port_range, max_attempts=5):
port = port_range[1]
for _ in range(max_attempts):
port = find_available_tcp_port(port_range[0], port_range[1])
return port
return port
@tenacity.retry(
wait=tenacity.wait_exponential(min=1, max=10),
stop=tenacity.stop_after_attempt(10) | stop_if_should_exit(),
before_sleep=lambda retry_state: logger.debug(
f'Waiting for server to be ready... (attempt {retry_state.attempt_number})'
),
)
def _wait_until_alive(self):
"""Wait until the server is ready to accept requests."""
if self.server_process and self.server_process.poll() is not None:
raise RuntimeError('Server process died')
try:
response = self.session.get(f'{self.api_url}/alive')
response.raise_for_status()
return True
except Exception as e:
self.log('debug', f'Server not ready yet: {e}')
raise
async def execute_action(self, action: Action) -> Observation:
"""Execute an action by sending it to the server."""
if not self._runtime_initialized:
raise AgentRuntimeDisconnectedError('Runtime not initialized')
if self.server_process is None or self.server_process.poll() is not None:
raise AgentRuntimeDisconnectedError('Server process died')
with self.action_semaphore:
try:
response = await call_sync_from_async(
lambda: self.session.post(
f'{self.api_url}/execute_action',
json={'action': event_to_dict(action)},
)
)
return observation_from_dict(response.json())
except requests.exceptions.ConnectionError:
raise AgentRuntimeDisconnectedError('Server connection lost')
def close(self):
"""Stop the server process."""
if self.server_process:
self.server_process.terminate()
try:
self.server_process.wait(timeout=5)
except subprocess.TimeoutExpired:
self.server_process.kill()
self.server_process = None
self._log_thread.join()
if self._temp_workspace:
shutil.rmtree(self._temp_workspace)
super().close()
@property
def vscode_url(self) -> str | None:
token = super().get_vscode_token()
if not token:
return None
vscode_url = f'http://localhost:{self._vscode_port}/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
return vscode_url
@property
def web_hosts(self):
hosts: dict[str, int] = {}
for port in self._app_ports:
hosts[f'http://localhost:{port}'] = port
return hosts
@@ -45,6 +45,7 @@ class RemoteRuntime(ActionExecutionClient):
status_callback: Optional[Callable] = None,
attach_to_existing: bool = False,
headless_mode: bool = True,
github_user_id: str | None = None,
):
super().__init__(
config,
@@ -55,6 +56,7 @@ class RemoteRuntime(ActionExecutionClient):
status_callback,
attach_to_existing,
headless_mode,
github_user_id,
)
if self.config.sandbox.api_key is None:
raise ValueError(
@@ -210,13 +212,15 @@ class RemoteRuntime(ActionExecutionClient):
plugins=self.plugins,
app_config=self.config,
)
environment = {}
if self.config.debug or os.environ.get('DEBUG', 'false').lower() == 'true':
environment['DEBUG'] = 'true'
environment.update(self.config.sandbox.runtime_startup_env_vars)
start_request = {
'image': self.container_image,
'command': command,
'working_dir': '/openhands/code/',
'environment': {'DEBUG': 'true'}
if self.config.debug or os.environ.get('DEBUG', 'false').lower() == 'true'
else {},
'environment': environment,
'session_id': self.sid,
'resource_factor': self.config.sandbox.remote_runtime_resource_factor,
}
@@ -291,7 +295,8 @@ class RemoteRuntime(ActionExecutionClient):
stop=tenacity.stop_after_delay(
self.config.sandbox.remote_runtime_init_timeout
)
| stop_if_should_exit() | self._stop_if_closed,
| stop_if_should_exit()
| self._stop_if_closed,
reraise=True,
retry=tenacity.retry_if_exception_type(AgentRuntimeNotReadyError),
wait=tenacity.wait_fixed(2),
@@ -394,10 +399,14 @@ class RemoteRuntime(ActionExecutionClient):
retry_decorator = tenacity.retry(
retry=tenacity.retry_if_exception_type(ConnectionError),
stop=tenacity.stop_after_attempt(3) | stop_if_should_exit() | self._stop_if_closed,
stop=tenacity.stop_after_attempt(3)
| stop_if_should_exit()
| self._stop_if_closed,
wait=tenacity.wait_exponential(multiplier=1, min=4, max=60),
)
return retry_decorator(self._send_action_server_request_impl)(method, url, **kwargs)
return retry_decorator(self._send_action_server_request_impl)(
method, url, **kwargs
)
def _send_action_server_request_impl(self, method, url, **kwargs):
try:
@@ -430,6 +439,6 @@ class RemoteRuntime(ActionExecutionClient):
) from e
else:
raise e
def _stop_if_closed(self, retry_state: tenacity.RetryCallState) -> bool:
return self._runtime_closed
+36 -8
View File
@@ -1,3 +1,4 @@
import os
import subprocess
import time
from dataclasses import dataclass
@@ -22,19 +23,46 @@ class JupyterPlugin(Plugin):
async def initialize(self, username: str, kernel_id: str = 'openhands-default'):
self.kernel_gateway_port = find_available_tcp_port(40000, 49999)
self.kernel_id = kernel_id
self.gateway_process = subprocess.Popen(
(
f"su - {username} -s /bin/bash << 'EOF'\n"
if username in ['root', 'openhands']:
# Non-LocalRuntime
prefix = f'su - {username} -s '
# cd to code repo, setup all env vars and run micromamba
poetry_prefix = (
'cd /openhands/code\n'
'export POETRY_VIRTUALENVS_PATH=/openhands/poetry;\n'
'export PYTHONPATH=/openhands/code:$PYTHONPATH;\n'
'export MAMBA_ROOT_PREFIX=/openhands/micromamba;\n'
'/openhands/micromamba/bin/micromamba run -n openhands '
'poetry run jupyter kernelgateway '
'--KernelGatewayApp.ip=0.0.0.0 '
f'--KernelGatewayApp.port={self.kernel_gateway_port}\n'
'EOF'
),
)
else:
# LocalRuntime
prefix = ''
code_repo_path = os.environ.get('OPENHANDS_REPO_PATH')
if not code_repo_path:
raise ValueError(
'OPENHANDS_REPO_PATH environment variable is not set. '
'This is required for the jupyter plugin to work with LocalRuntime.'
)
# assert POETRY_VIRTUALENVS_PATH is set
poetry_venvs_path = os.environ.get('POETRY_VIRTUALENVS_PATH')
if not poetry_venvs_path:
raise ValueError(
'POETRY_VIRTUALENVS_PATH environment variable is not set. '
'This is required for the jupyter plugin to work with LocalRuntime.'
)
poetry_prefix = f'cd {code_repo_path}\n'
jupyter_launch_command = (
f"{prefix}/bin/bash << 'EOF'\n"
f'{poetry_prefix}'
'poetry run jupyter kernelgateway '
'--KernelGatewayApp.ip=0.0.0.0 '
f'--KernelGatewayApp.port={self.kernel_gateway_port}\n'
'EOF'
)
logger.debug(f'Jupyter launch command: {jupyter_launch_command}')
self.gateway_process = subprocess.Popen(
jupyter_launch_command,
stderr=subprocess.STDOUT,
shell=True,
)
@@ -19,6 +19,15 @@ class VSCodePlugin(Plugin):
name: str = 'vscode'
async def initialize(self, username: str):
if username not in ['root', 'openhands']:
self.vscode_port = None
self.vscode_connection_token = None
logger.warning(
'VSCodePlugin is only supported for root or openhands user. '
'It is not yet supported for other users (i.e., when running LocalRuntime).'
)
return
self.vscode_port = int(os.environ['VSCODE_PORT'])
self.vscode_connection_token = str(uuid.uuid4())
assert check_port_available(self.vscode_port)
+14 -5
View File
@@ -175,24 +175,32 @@ class BashSession:
work_dir: str,
username: str | None = None,
no_change_timeout_seconds: int = 30,
max_memory_mb: int | None = None,
):
self.NO_CHANGE_TIMEOUT_SECONDS = no_change_timeout_seconds
self.work_dir = work_dir
self.username = username
self._initialized = False
self.max_memory_mb = max_memory_mb
def initialize(self):
self.server = libtmux.Server()
window_command = '/bin/bash'
if self.username:
_shell_command = '/bin/bash'
if self.username in ['root', 'openhands']:
# This starts a non-login (new) shell for the given user
window_command = f'su {self.username} -'
_shell_command = f'su {self.username} -'
# otherwise, we are running as the CURRENT USER (e.g., when running LocalRuntime)
if self.max_memory_mb is not None:
window_command = (
f'prlimit --as={self.max_memory_mb * 1024 * 1024} {_shell_command}'
)
else:
window_command = _shell_command
logger.debug(f'Initializing bash session with command: {window_command}')
session_name = f'openhands-{self.username}-{uuid.uuid4()}'
self.session = self.server.new_session(
session_name=session_name,
window_name='bash',
window_command=window_command,
start_directory=self.work_dir,
kill_session=True,
x=1000,
@@ -206,6 +214,7 @@ class BashSession:
# We need to create a new pane because the initial pane's history limit is (default) 2000
_initial_window = self.session.attached_window
self.window = self.session.new_window(
window_name='bash',
window_shell=window_command,
start_directory=self.work_dir,
)
+11 -3
View File
@@ -17,6 +17,8 @@ def get_action_execution_server_startup_command(
app_config: AppConfig,
python_prefix: list[str] = DEFAULT_PYTHON_PREFIX,
use_nice_for_root: bool = True,
override_user_id: int | None = None,
override_username: str | None = None,
):
sandbox_config = app_config.sandbox
@@ -32,7 +34,13 @@ def get_action_execution_server_startup_command(
'--browsergym-eval-env'
] + sandbox_config.browsergym_eval_env.split(' ')
is_root = not app_config.run_as_openhands
username = override_username or (
'openhands' if app_config.run_as_openhands else 'root'
)
user_id = override_user_id or (
sandbox_config.user_id if app_config.run_as_openhands else 0
)
is_root = bool(username == 'root')
base_cmd = [
*python_prefix,
@@ -45,9 +53,9 @@ def get_action_execution_server_startup_command(
app_config.workspace_mount_path_in_sandbox,
*plugin_args,
'--username',
'openhands' if app_config.run_as_openhands else 'root',
username,
'--user-id',
str(sandbox_config.user_id),
str(user_id),
*browsergym_args,
]
+1 -11
View File
@@ -13,7 +13,6 @@ from openhands.events.action import (
FileWriteAction,
IPythonRunCellAction,
)
from openhands.events.event import FileEditSource
from openhands.events.observation import (
ErrorObservation,
FileEditObservation,
@@ -205,16 +204,7 @@ class FileEditRuntimeMixin(FileEditRuntimeInterface):
return ErrorObservation(error_message)
return None
def edit(self, action: FileEditAction) -> Observation:
if action.impl_source == FileEditSource.OH_ACI:
# Translate to ipython command to file_editor
return self.run_ipython(
IPythonRunCellAction(
code=action.translated_ipython_code,
include_extra=False,
)
)
def llm_based_edit(self, action: FileEditAction) -> Observation:
obs = self.read(FileReadAction(path=action.path))
if (
isinstance(obs, ErrorObservation)
+2 -2
View File
@@ -140,6 +140,6 @@ async def write_file(
)
except UnicodeDecodeError:
return ErrorObservation(f'File could not be decoded as utf-8: {path}')
except PermissionError:
return ErrorObservation(f'Malformed paths not permitted: {path}')
except PermissionError as e:
return ErrorObservation(f'Permission error on {path}: {e}')
return FileWriteObservation(content='', path=path)
+5
View File
@@ -1,3 +1,4 @@
import os
import subprocess
from openhands.core.logger import openhands_logger as logger
@@ -31,6 +32,10 @@ def init_user_and_working_directory(
Returns:
int | None: The user ID if it was updated, None otherwise.
"""
# if username is CURRENT_USER, then we don't need to do anything
# This is specific to the local runtime
if username == os.getenv('USER') and username not in ['root', 'openhands']:
return None
# First create the working directory, independent of the user
logger.debug(f'Client working directory: {initial_cwd}')
+2 -1
View File
@@ -1,7 +1,8 @@
from fastapi import Request
from pydantic import SecretStr
def get_github_token(request: Request) -> str | None:
def get_github_token(request: Request) -> SecretStr | None:
return getattr(request.state, 'github_token', None)
-2
View File
@@ -18,8 +18,6 @@ class ServerConfig(ServerConfigInterface):
)
conversation_manager_class: str = 'openhands.server.conversation_manager.standalone_conversation_manager.StandaloneConversationManager'
github_service_class: str = 'openhands.server.services.github_service.GitHubService'
def verify_config(self):
if self.config_cls:
raise ValueError('Unexpected config path provided')
-21
View File
@@ -1,21 +0,0 @@
from dotenv import load_dotenv
from openhands.core.config import load_app_config
from openhands.server.config.server_config import load_server_config
from openhands.storage import get_file_store
from openhands.storage.conversation.conversation_store import ConversationStore
from openhands.storage.settings.settings_store import SettingsStore
from openhands.utils.import_utils import get_impl
load_dotenv()
config = load_app_config()
server_config = load_server_config()
file_store = get_file_store(config.file_store, config.file_store_path)
ConversationStoreImpl = get_impl(
ConversationStore, # type: ignore
server_config.conversation_store_class,
)
SettingsStoreImpl = get_impl(SettingsStore, server_config.settings_store_class) # type: ignore
@@ -77,10 +77,6 @@ class ConversationManager(ABC):
async def send_to_event_stream(self, connection_id: str, data: dict):
"""Send data to an event stream."""
@abstractmethod
def update_token(self, connection_id: str):
"""Update/refresh the runtime gh token"""
@abstractmethod
async def disconnect_from_session(self, connection_id: str):
"""Disconnect from a session."""
@@ -10,8 +10,6 @@ from openhands.core.exceptions import AgentRuntimeUnavailableError
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events.action import MessageAction
from openhands.events.action.commands import CmdRunAction
from openhands.events.event import EventSource
from openhands.events.stream import EventStream, session_exists
from openhands.server.session.conversation import Conversation
from openhands.server.session.session import ROOM_KEY, Session
@@ -89,17 +87,13 @@ class StandaloneConversationManager(ConversationManager):
return c
async def join_conversation(
self,
sid: str,
connection_id: str,
settings: Settings | None,
user_id: str | None,
self, sid: str, connection_id: str, settings: Settings, user_id: str | None
):
logger.info(f'join_conversation:{sid}:{connection_id}')
await self.sio.enter_room(connection_id, ROOM_KEY.format(sid=sid))
self._local_connection_id_to_session_id[connection_id] = sid
event_stream = await self._get_event_stream(sid)
if not event_stream and settings:
if not event_stream:
return await self.maybe_start_agent_loop(sid, settings, user_id)
return event_stream
@@ -245,30 +239,6 @@ class StandaloneConversationManager(ConversationManager):
raise RuntimeError(f'no_connected_session:{connection_id}:{sid}')
async def update_token(self, connection_id: str):
await asyncio.sleep(1)
# print('updating token')
# session = self._local_agent_loops_by_sid.get(connection_id)
# print(f'found session for sid: {connection_id}')
# if session:
# try:
# await session.update_token()
# except Exception as e:
# print(f'error updating token: {str(e)}')
try:
event_stream = await self.join_conversation(
sid=connection_id,
connection_id=connection_id,
settings=None,
user_id=None,
)
cmd = 'export GITHUB_TOKEN="this is a dummy token";'
action = CmdRunAction(cmd, hidden=True)
event_stream.add_event(action, EventSource.ENVIRONMENT)
except Exception as e:
print(f'error updating token: {str(e)}')
async def disconnect_from_session(self, connection_id: str):
sid = self._local_connection_id_to_session_id.pop(connection_id, None)
logger.info(f'disconnect_from_session:{connection_id}:{sid}')

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