mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 715420ed3c | |||
| f7c806c119 | |||
| 36d93a8ba4 | |||
| ff25e794ef | |||
| cbe186b3fb | |||
| a371562d94 | |||
| 285ba07bfd | |||
| 425ccc9b1f | |||
| 1afe7f1058 | |||
| 3188646195 | |||
| 6772227c9d | |||
| 6a6dc93e03 | |||
| 1a715d2ec4 | |||
| 4615548477 | |||
| b12b426e3d | |||
| a1107a2c30 | |||
| af0becd65b | |||
| 13839b4273 | |||
| 7860055f8c | |||
| 2b40a92943 | |||
| 6c88b10c59 | |||
| 8688634950 | |||
| 6e35ac49c1 | |||
| 9bdc8dda6c | |||
| 75f3f282af | |||
| 4a5891cbea | |||
| 707cb07f4f | |||
| 61c709b7c7 | |||
| 1c72676483 | |||
| 52ac2729f7 | |||
| 5fa2634d60 | |||
| 478b225d11 | |||
| ce82545437 | |||
| 93d2e4a338 | |||
| ff48f8beba | |||
| e930cd0aef | |||
| 6655ec0731 | |||
| 669e284dc5 | |||
| 8140d2e05a |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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,5 +1,4 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
cp pyproject.toml poetry.lock openhands
|
||||
poetry build -v
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
|
||||
+2
-2
@@ -47,7 +47,7 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.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 \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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"` |
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Generated
+2150
-2160
File diff suppressed because it is too large
Load Diff
+17
-17
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
() => ({
|
||||
|
||||
@@ -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,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,
|
||||
},
|
||||
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
type Message = {
|
||||
export type Message = {
|
||||
sender: "user" | "assistant";
|
||||
content: string;
|
||||
timestamp: string;
|
||||
|
||||
@@ -15,6 +15,4 @@ export default [
|
||||
route("served", "routes/app.tsx"),
|
||||
]),
|
||||
]),
|
||||
|
||||
route("oauth/github/callback", "routes/oauth.github.callback.tsx"),
|
||||
] satisfies RouteConfig;
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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}')
|
||||
|
||||
+32
-36
@@ -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)
|
||||
+12
@@ -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
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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')
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}')
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user