Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93287ef9ac | |||
| e70595f46f | |||
| 1d3ff66987 | |||
| 1a95f86802 | |||
| eee12bfd94 | |||
| 8c2d4dbe8b | |||
| f5ae1759b6 | |||
| 9ec94737ed | |||
| 63c7815823 | |||
| 95ae47307c | |||
| 035050252b | |||
| 5b48aee0c9 | |||
| 1a89dbb738 | |||
| bba62c26fd | |||
| 0ca3188afa | |||
| 283f503870 | |||
| 0691e5c0d0 | |||
| fc16da8fd2 | |||
| bd3ff43c67 | |||
| 0fe5b808af | |||
| 6c49686ff0 | |||
| 17212bb2f2 | |||
| 9d9f931e95 | |||
| 6fe9680474 | |||
| 53c80d1c92 | |||
| 401262f353 | |||
| 58845b01a3 | |||
| 469d184157 | |||
| 4837c4dc74 | |||
| 6763f21cc3 | |||
| 32e610ac1d | |||
| 85c65391ca | |||
| c444dbfbbf | |||
| dd988d0f14 | |||
| 6f1a74e286 | |||
| 7b956b6103 | |||
| 34b097115d | |||
| 3e4ab4f379 | |||
| 54cd9f7e44 | |||
| 802b765f98 | |||
| 18c88f99ff | |||
| f3934be07b | |||
| 6ce9f49d1e | |||
| fc07622b20 | |||
| da935f9d8f | |||
| 642cc52a1a | |||
| 4c361ab9e5 | |||
| 5dfa1bb6eb | |||
| a07cf972a5 | |||
| f2e3bc3254 | |||
| 3790ec7d60 | |||
| 3c0719309e | |||
| 0236e0943e | |||
| cd464c0022 | |||
| 4519a7f4f3 | |||
| fdc591330b | |||
| 98e454e82c | |||
| e088d2d24a | |||
| 58c574af1e | |||
| 405f0069f8 | |||
| f26d770d03 | |||
| bf2c3de219 | |||
| 7c35ce16e5 | |||
| f4024ccd94 | |||
| b55bfed831 | |||
| cb0994027f | |||
| bcc9bd0b9a | |||
| 6c144e6b5a | |||
| e90b841b0d | |||
| a1e6ed4dff | |||
| ad6311d3cd |
@@ -45,6 +45,13 @@ body:
|
||||
description: What version of OpenHands are you using?
|
||||
placeholder: ex. 0.9.8, main, etc.
|
||||
|
||||
- type: input
|
||||
id: model-name
|
||||
attributes:
|
||||
label: Model Name
|
||||
description: What model are you using?
|
||||
placeholder: ex. gpt-4o, claude-3-5-sonnet, openrouter/deepseek-r1, etc.
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
|
||||
@@ -26,7 +26,7 @@ RUN apt-get update -y \
|
||||
|
||||
COPY pyproject.toml poetry.lock ./
|
||||
RUN touch README.md
|
||||
RUN export POETRY_CACHE_DIR && poetry install --no-root --extras all-runtimes && rm -rf $POETRY_CACHE_DIR
|
||||
RUN export POETRY_CACHE_DIR && poetry install --no-root && rm -rf $POETRY_CACHE_DIR
|
||||
|
||||
FROM base AS openhands-app
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM ubuntu:24.04
|
||||
|
||||
# install basic packages
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
wget \
|
||||
git \
|
||||
vim \
|
||||
nano \
|
||||
unzip \
|
||||
zip \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
python3-dev \
|
||||
build-essential \
|
||||
openssh-server \
|
||||
sudo \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -0,0 +1,15 @@
|
||||
# How to build custom E2B sandbox for OpenHands
|
||||
|
||||
[E2B](https://e2b.dev) is an [open-source](https://github.com/e2b-dev/e2b) secure cloud environment (sandbox) made for running AI-generated code and agents. E2B offers [Python](https://pypi.org/project/e2b/) and [JS/TS](https://www.npmjs.com/package/e2b) SDK to spawn and control these sandboxes.
|
||||
|
||||
|
||||
1. Install the CLI with NPM.
|
||||
```sh
|
||||
npm install -g @e2b/cli@latest
|
||||
```
|
||||
Full CLI API is [here](https://e2b.dev/docs/cli/installation).
|
||||
|
||||
1. Build the sandbox
|
||||
```sh
|
||||
e2b template build --dockerfile ./Dockerfile --name "openhands"
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
# This is a config for E2B sandbox template.
|
||||
# You can use 'template_id' (785n69crgahmz0lkdw9h) or 'template_name (openhands) from this config to spawn a sandbox:
|
||||
|
||||
# Python SDK
|
||||
# from e2b import Sandbox
|
||||
# sandbox = Sandbox(template='openhands')
|
||||
|
||||
# JS SDK
|
||||
# import { Sandbox } from 'e2b'
|
||||
# const sandbox = await Sandbox.create({ template: 'openhands' })
|
||||
|
||||
dockerfile = "Dockerfile"
|
||||
template_name = "openhands"
|
||||
template_id = "785n69crgahmz0lkdw9h"
|
||||
@@ -151,6 +151,12 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "Success Stories",
|
||||
"pages": [
|
||||
"success-stories/index"
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "API Reference",
|
||||
"openapi": "/openapi.json"
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
---
|
||||
title: "Success Stories"
|
||||
description: "Real-world examples of what you can achieve with OpenHands"
|
||||
---
|
||||
|
||||
Discover how developers and teams are using OpenHands to automate their software development workflows. From quick fixes to complex projects, see what's possible with AI-powered development assistance.
|
||||
|
||||
Check out the [#success-stories](https://www.linen.dev/s/openhands/c/success-stories) channel on our Slack for more!
|
||||
|
||||
<Update label="2025-06-13 OpenHands helps frontline support" description="@Joe Pelletier">
|
||||
|
||||
## One of the cool things about OpenHands, and especially the Slack Integration, is the ability to empower folks who are on the ‘front lines’ with customers.
|
||||
|
||||
For example, often times Support and Customer Success teams will field bug reports, doc questions, and other ‘nits’ from customers. They tend to have few options to deal with this, other than file a feedback ticket with product teams and hope it gets prioritized in an upcoming sprint.
|
||||
|
||||
Instead, with tools like OpenHands and the Slack integration, they can request OpenHands to make fixes proactively and then have someone on the engineering team (like a lead engineer, a merge engineer, or even technical product manager) review the PR and approve it — thus reducing the cycle time for ‘quick wins’ from weeks to just a few hours.
|
||||
|
||||
Here's how we do that with the OpenHands project:
|
||||
|
||||
<iframe
|
||||
width="560"
|
||||
height="560"
|
||||
src="https://www.linen.dev/s/openhands/t/29118545/seems-mcp-config-from-config-toml-is-being-overwritten-hence#629f8e2b-cde8-427e-920c-390557a06cc9"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
|
||||
[Original Slack thread](https://www.linen.dev/s/openhands/t/29124350/one-of-the-cool-things-about-openhands-and-especially-the-sl#25029f37-7b0d-4535-9187-83b3e06a4011)
|
||||
|
||||
</Update>
|
||||
|
||||
|
||||
<Update label="2025-06-13 Ask OpenHands to show me some love" description="@Graham Neubig">
|
||||
|
||||
## Asked openhands to “show me some love” and...
|
||||
|
||||
Asked openhands to “show me some love” and it coded up this app for me, actually kinda genuinely feel loved
|
||||
|
||||
<video
|
||||
controls
|
||||
autoplay
|
||||
className="w-full aspect-video"
|
||||
src="/success-stories/stories/2025-06-13-show-love/v1.mp4"
|
||||
></video>
|
||||
|
||||
[Original Slack thread](https://www.linen.dev/s/openhands/t/29100731/asked-openhands-to-show-me-some-love-and-it-coded-up-this-ap#1e08af6b-b7d5-4167-8a53-17e6806555e0)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2025-06-11 OpenHands does 100% of my infra IAM research for me" description="@Xingyao Wang">
|
||||
|
||||
## Now, OpenHands does 100% of my infra IAM research for me
|
||||
|
||||
Got an IAM error on GCP? Send a screenshot to OH... and it just works!!!
|
||||
Can't imagine going back to the early days without OH: I'd spend an entire afternoon figuring how to get IAM right
|
||||
|
||||
[Original Slack thread](https://www.linen.dev/s/openhands/t/29100732/now-openhands-does-100-of-my-infra-iam-research-for-me-sweat#20482a73-4e2e-4edd-b6d1-c9e8442fccd1)
|
||||
|
||||

|
||||

|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2025-06-08 OpenHands builds an interactive map for me" description="@Rodrigo Argenton Freire (ODLab)">
|
||||
|
||||
## Very simple example, but baby steps....
|
||||
|
||||
I am a professor of architecture and urban design. We built, me and some students, an interactive map prototype to help visitors and new students to find important places in the campus. Considering that we lack a lot of knowledge in programming, that was really nice to build and a smooth process.
|
||||
We first created the main components with all-hands and then adjusted some details locally. Definitely, saved us a lot of time and money.
|
||||
That's a prototype but we will have all the info by tuesday.
|
||||
https://buriti-emau.github.io/Mapa-UFU/
|
||||
|
||||
[Original Slack thread](https://www.linen.dev/s/openhands/t/29100736/very-simple-example-but-baby-steps-i-am-a-professor-of-archi#8f2e3f3f-44e6-44ea-b9a8-d53487470179)
|
||||
|
||||

|
||||
|
||||
</Update>
|
||||
|
||||
|
||||
<Update label="2025-06-06 Web Search Saves the Day" description="@Ian Walker">
|
||||
|
||||
## Tavily adapter helps solve persistent debugging issue
|
||||
|
||||
Big congratulations to the new [Tavily adapter](https://www.all-hands.dev/blog/building-a-provably-versatile-agent)... OpenHands and I have been beavering away at a Lightstreamer client library for most of this week but were getting a persistent (and unhelpful) "unexpected error" from the server.
|
||||
|
||||
Coming back to the problem today, after trying several unsuccessful fixes prompted by me, OH decided all by itself to search the web, and found the cause of the problem (of course it was simply CRLF line endings...). I was on the verge of giving up - good thing OH has more stamina than me!
|
||||
|
||||
This demonstrates how OpenHands' web search capabilities can help solve debugging issues that would otherwise require extensive manual research.
|
||||
|
||||
<iframe
|
||||
width="560"
|
||||
height="560"
|
||||
src="https://www.linen.dev/s/openhands/t/29100737/big-congratulations-to-the-new-tavily-adapter-openhands-and-#87b027e5-188b-425e-8aa9-719dcb4929f4"
|
||||
title="YouTube video player"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
|
||||
|
||||
[Original Slack thread](https://www.linen.dev/s/openhands/t/29100737/big-congratulations-to-the-new-tavily-adapter-openhands-and-#76f1fb26-6ef7-4709-b9ea-fb99105e47e4)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2025-06-05 OpenHands updates my personal website for a new paper" description="@Xingyao Wang">
|
||||
|
||||
## I asked OpenHands to update my personal website for the "OpenHands Versa" paper.
|
||||
|
||||
It is an extremely trivial task: You just need to browse to arxiv, copy the author names, format them for BibTeX, and then modify the papers.bib file. But now I'm getting way too lazy to even open my IDE and actually do this one-file change!
|
||||
|
||||
[Original Tweet/X thread](https://x.com/xingyaow_/status/1930796287919542410)
|
||||
|
||||
[Original Slack thread](https://www.linen.dev/s/openhands/t/29100738/i-asked-openhands-to-update-my-personal-website-for-the-open#f0324022-b12b-4d34-b12b-bdbc43823f69)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2025-06-02 OpenHands makes an animated gif of swe-bench verified scores over time" description="@Graham Neubig">
|
||||
|
||||
## I asked OpenHands to make an animated gif of swe-bench verified scores over time.
|
||||
|
||||
It took a bit of prompting but ended up looking pretty nice I think
|
||||
|
||||
<video width="560" height="315" autoPlay loop muted src="/success-stories/stories/2025-06-02-swebench-score/s1.mp4"></video>
|
||||
|
||||
[Original Slack thread](https://www.linen.dev/s/openhands/t/29100744/i-asked-openhands-to-make-an-animated-gif-of-swe-bench-verif#fb3b82c9-6222-4311-b97b-b2ac1cfe6dff)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2025-05-30 AWS Troubleshooting" description="@Graham Neubig">
|
||||
|
||||
## Quick AWS security group fix
|
||||
|
||||
I really don't like trying to fix issues with AWS, especially security groups and other finicky things like this. But I started up an instance and wasn't able to ssh in. So I asked OpenHands:
|
||||
|
||||
> Currently, the following ssh command is timing out:
|
||||
>
|
||||
> $ ssh -i gneubig.pem ubuntu@XXX.us-east-2.compute.amazonaws.com
|
||||
> ssh: connect to host XXX.us-east-2.compute.amazonaws.com port 22: Operation timed out
|
||||
>
|
||||
> Use the provided AWS credentials to take a look at i-XXX and examine why
|
||||
|
||||
And 2 minutes later I was able to SSH in!
|
||||
|
||||
This shows how OpenHands can quickly diagnose and fix AWS infrastructure issues that would normally require manual investigation.
|
||||
|
||||
[Original Slack thread](https://www.linen.dev/s/openhands/t/29100747/i-really-don-t-like-trying-to-fix-issues-with-aws-especially#d92a66d2-3bc1-4467-9d09-dc983004d083)
|
||||
|
||||
</Update>
|
||||
|
||||
|
||||
<Update label="2025-05-04 Chrome Extension Development" description="@Xingyao Wang">
|
||||
|
||||
## OpenHands builds Chrome extension for GitHub integration
|
||||
|
||||
I asked OpenHands to write a Chrome extension based on our [OpenHands Cloud API](https://docs.all-hands.dev/modules/usage/cloud/cloud-api). Once installed, you can now easily launch an OpenHands cloud session from your GitHub webpage/PR!
|
||||
|
||||
This demonstrates OpenHands' ability to create browser extensions and integrate with external APIs, enabling seamless workflows between GitHub and OpenHands Cloud.
|
||||
|
||||

|
||||

|
||||
|
||||
[GitHub Repository](https://github.com/xingyaoww/openhands-chrome-extension)
|
||||
|
||||
[Original Slack thread](https://www.linen.dev/s/openhands/t/29100755/i-asked-openhands-to-write-a-chrome-extension-based-on-our-h#88f14b7f-f8ff-40a6-83c2-bd64e95924c5)
|
||||
|
||||
</Update>
|
||||
|
||||
|
||||
<Update label="2025-04-11 Visual UI Testing" description="@Xingyao Wang">
|
||||
|
||||
## OpenHands tests UI automatically with visual browsing
|
||||
|
||||
Thanks to visual browsing -- OpenHands can actually test some simple UI by serving the website, clicking the button in the browser and looking at screenshots now!
|
||||
|
||||
Prompt is just:
|
||||
```
|
||||
I want to create a Hello World app in Javascript that:
|
||||
* Displays Hello World in the middle.
|
||||
* Has a button that when clicked, changes the greeting with a bouncing animation to fun versions of Hello.
|
||||
* Has a counter for how many times the button has been clicked.
|
||||
* Has another button that changes the app's background color.
|
||||
```
|
||||
|
||||
Eager-to-work Sonnet 3.7 will test stuff for you without you asking!
|
||||
|
||||
This showcases OpenHands' visual browsing capabilities, enabling it to create, serve, and automatically test web applications through actual browser interactions and screenshot analysis.
|
||||
|
||||

|
||||
|
||||
[Original Slack thread](https://www.linen.dev/s/openhands/t/29100764/thanks-to-u07k0p3bdb9-s-visual-browsing-openhands-can-actual#21beb9bc-1a04-4272-87e9-4d3e3b9925e7)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2025-03-07 Proactive Error Handling" description="@Graham Neubig">
|
||||
|
||||
## OpenHands fixes crashes before you notice them
|
||||
|
||||
Interesting story, I asked OpenHands to start an app on port 12000, it showed up on the app pane. I started using the app, and then it crashed... But because it crashed in OpenHands, OpenHands immediately saw the error message and started fixing the problem without me having to do anything. It was already fixing the problem before I even realized what was going wrong.
|
||||
|
||||
This demonstrates OpenHands' proactive monitoring capabilities - it doesn't just execute commands, but actively watches for errors and begins remediation automatically, often faster than human reaction time.
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2024-12-03 Creative Design Acceleration" description="@Rohit Malhotra">
|
||||
|
||||
## Pair programming for interactive design projects
|
||||
|
||||
Used OpenHands as a pair programmer to do heavy lifting for a creative/interactive design project in p5js.
|
||||
|
||||
I usually take around 2 days for high fidelity interactions (planning strategy + writing code + circling back with designer), did this in around 5hrs instead with the designer watching curiously the entire time.
|
||||
|
||||
This showcases how OpenHands can accelerate creative and interactive design workflows, reducing development time by 75% while maintaining high quality output.
|
||||
|
||||
[Original Tweet](https://x.com/rohit_malh5/status/1863995531657425225)
|
||||
|
||||
</Update>
|
||||
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 236 KiB |
@@ -7,6 +7,22 @@ description: This page outlines all available configuration options for OpenHand
|
||||
|
||||
The core configuration options are defined in the `[core]` section of the `config.toml` file.
|
||||
|
||||
### API Keys
|
||||
- `e2b_api_key`
|
||||
- Type: `str`
|
||||
- Default: `""`
|
||||
- Description: API key for E2B
|
||||
|
||||
- `modal_api_token_id`
|
||||
- Type: `str`
|
||||
- Default: `""`
|
||||
- Description: API token ID for Modal
|
||||
|
||||
- `modal_api_token_secret`
|
||||
- Type: `str`
|
||||
- Default: `""`
|
||||
- Description: API token secret for Modal
|
||||
|
||||
### Workspace
|
||||
- `workspace_base` **(Deprecated)**
|
||||
- Type: `str`
|
||||
|
||||
@@ -6,58 +6,70 @@ description: When using a Local LLM, OpenHands may have limited functionality. I
|
||||
## News
|
||||
|
||||
- 2025/05/21: We collaborated with Mistral AI and released [Devstral Small](https://mistral.ai/news/devstral) that achieves [46.8% on SWE-Bench Verified](https://github.com/SWE-bench/experiments/pull/228)!
|
||||
- 2025/03/31: We released an open model OpenHands LM v0.1 32B that achieves 37.1% on SWE-Bench Verified
|
||||
- 2025/03/31: We released an open model OpenHands LM 32B v0.1 that achieves 37.1% on SWE-Bench Verified
|
||||
([blog](https://www.all-hands.dev/blog/introducing-openhands-lm-32b----a-strong-open-coding-agent-model), [model](https://huggingface.co/all-hands/openhands-lm-32b-v0.1)).
|
||||
|
||||
## Quickstart: Running OpenHands with a Local LLM using LM Studio
|
||||
|
||||
## Quickstart: Running OpenHands on Your Macbook
|
||||
This guide explains how to serve a local Devstral LLM using [LM Studio](https://lmstudio.ai/) and have OpenHands connect to it.
|
||||
|
||||
### Serve the model on your Macbook
|
||||
We recommend:
|
||||
- **LM Studio** as the local model server, which handles metadata downloads automatically and offers a simple, user-friendly interface for configuration.
|
||||
- **Devstral Small 2505** as the LLM for software development, trained on real GitHub issues and optimized for agent-style workflows like OpenHands.
|
||||
|
||||
We recommend using [LMStudio](https://lmstudio.ai/) for serving these models locally.
|
||||
### Hardware Requirements
|
||||
|
||||
1. Download [LM Studio](https://lmstudio.ai/) and install it
|
||||
Running Devstral requires a recent GPU with at least 16GB of VRAM, or a Mac with Apple Silicon (M1, M2, etc.) with at least 32GB of RAM.
|
||||
|
||||
2. Download the model:
|
||||
- Option 1: Directly download the LLM from [this link](https://lmstudio.ai/model/devstral-small-2505-mlx) or by searching for the name `Devstral-Small-2505` in LM Studio
|
||||
- Option 2: Download a LLM in GGUF format. For example, to download [Devstral Small 2505 GGUF](https://huggingface.co/mistralai/Devstral-Small-2505_gguf), using `huggingface-cli download mistralai/Devstral-Small-2505_gguf --local-dir mistralai/Devstral-Small-2505_gguf`. Then in bash terminal, run `lms import {model_name}` in the directory where you've downloaded the model checkpoint (e.g. run `lms import devstralQ4_K_M.gguf` in `mistralai/Devstral-Small-2505_gguf`)
|
||||
### 1. Install LM Studio
|
||||
|
||||
3. Open LM Studio application, you should first switch to `power user` mode, and then open the developer tab:
|
||||
Download and install the LM Studio desktop app from [lmstudio.ai](https://lmstudio.ai/).
|
||||
|
||||

|
||||
### 2. Download Devstral Small
|
||||
|
||||
4. Then click `Select a model to load` on top of the application:
|
||||
1. Make sure to set the User Interface Complexity Level to "Power User", by clicking on the appropriate label at the bottom of the window.
|
||||
2. Click the "Discover" button (Magnifying Glass icon) on the left navigation bar to open the Models download page.
|
||||
|
||||

|
||||

|
||||
|
||||
5. And choose the model you want to use, holding `option` on mac to enable advanced loading options:
|
||||
3. Search for the "Devstral Small 2505" model, confirm it's the official Mistral AI (mistralai) model, then proceed to download.
|
||||
|
||||

|
||||

|
||||
|
||||
6. You should then pick an appropriate context window for OpenHands based on your hardware configuration (larger than 32768 is recommended for using OpenHands, but too large may cause you to run out of memory); Flash attention is also recommended if it works on your machine.
|
||||
4. Wait for the download to finish.
|
||||
|
||||

|
||||
### 3. Load the Model
|
||||
|
||||
7. And you should start the server (if it is not already in `Running` status), un-toggle `Serve on Local Network` and remember the port number of the LMStudio URL (`1234` is the port number for `http://127.0.0.1:1234` in this example):
|
||||
1. Click the "Developer" button (Console icon) on the left navigation bar to open the Developer Console.
|
||||
2. Click the "Select a model to load" dropdown at the top of the application window.
|
||||
|
||||

|
||||

|
||||
|
||||
8. Finally, you can click the `copy` button near model name to copy the model name (`imported-models/uncategorized/devstralq4_k_m.gguf` in this example):
|
||||
3. Enable the "Manually choose model load parameters" switch.
|
||||
4. Select 'Devstral Small 2505' from the model list.
|
||||
|
||||

|
||||

|
||||
|
||||
### Start OpenHands with locally served model
|
||||
5. Enable the "Show advanced settings" switch at the bottom of the Model settings flyout to show all the available settings.
|
||||
6. Set "Context Length" to at least 32768 and enable Flash Attention.
|
||||
7. Click "Load Model" to start loading the model.
|
||||
|
||||
Check [the installation guide](/usage/local-setup) to make sure you have all the prerequisites for running OpenHands.
|
||||

|
||||
|
||||
### 4. Start the LLM server
|
||||
|
||||
1. Enable the switch next to "Status" at the top-left of the Window.
|
||||
2. Take note of the Model API Identifier shown on the sidebar on the right.
|
||||
|
||||

|
||||
|
||||
### 5. Start OpenHands
|
||||
|
||||
1. Check [the installation guide](/usage/local-setup) and ensure all prerequisites are met before running OpenHands, then run:
|
||||
|
||||
```bash
|
||||
export LMSTUDIO_MODEL_NAME="imported-models/uncategorized/devstralq4_k_m.gguf" # <- Replace this with the model name you copied from LMStudio
|
||||
export LMSTUDIO_URL="http://host.docker.internal:1234" # <- Replace this with the port from LMStudio
|
||||
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.45-nikolaik
|
||||
|
||||
mkdir -p ~/.openhands && echo '{"language":"en","agent":"CodeActAgent","max_iterations":null,"security_analyzer":null,"confirmation_mode":false,"llm_model":"lm_studio/'$LMSTUDIO_MODEL_NAME'","llm_api_key":"dummy","llm_base_url":"'$LMSTUDIO_URL/v1'","remote_runtime_resource_factor":null,"github_token":null,"enable_default_condenser":true,"user_consents_to_analytics":true}' > ~/.openhands/settings.json
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.45-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
@@ -69,9 +81,7 @@ docker run -it --rm --pull=always \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.45
|
||||
```
|
||||
|
||||
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
|
||||
|
||||
Once your server is running -- you can visit `http://localhost:3000` in your browser to use OpenHands with local Devstral model:
|
||||
2. Wait until the server is running (see log below):
|
||||
```
|
||||
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.45
|
||||
@@ -84,65 +94,88 @@ INFO: Application startup complete.
|
||||
INFO: Uvicorn running on http://0.0.0.0:3000 (Press CTRL+C to quit)
|
||||
```
|
||||
|
||||
3. Visit `http://localhost:3000` in your browser.
|
||||
|
||||
## Advanced: Serving LLM on GPUs
|
||||
### 6. Configure OpenHands to use the LLM server
|
||||
|
||||
### Download model checkpoints
|
||||
Once you open OpenHands in your browser, you'll need to configure it to use the local LLM server you just started.
|
||||
|
||||
<Note>
|
||||
The model checkpoints downloaded here should NOT be in GGUF format.
|
||||
</Note>
|
||||
When started for the first time, OpenHands will prompt you to set up the LLM provider.
|
||||
|
||||
For example, to download [OpenHands LM 32B v0.1](https://huggingface.co/all-hands/openhands-lm-32b-v0.1):
|
||||
1. Click "see advanced settings" to open the LLM Settings page.
|
||||
|
||||

|
||||
|
||||
2. Enable the "Advanced" switch at the top of the page to show all the available settings.
|
||||
|
||||
3. Set the following values:
|
||||
- **Custom Model**: `openai/mistralai/devstral-small-2505` (the Model API identifier from LM Studio, prefixed with "openai/")
|
||||
- **Base URL**: `http://host.docker.internal:1234/v1`
|
||||
- **API Key**: `local-llm`
|
||||
|
||||
4. Click "Save Settings" to save the configuration.
|
||||
|
||||

|
||||
|
||||
That's it! You can now start using OpenHands with the local LLM server.
|
||||
|
||||
If you encounter any issues, let us know on [Slack](https://join.slack.com/t/openhands-ai/shared_invite/zt-34zm4j0gj-Qz5kRHoca8DFCbqXPS~f_A) or [Discord](https://discord.gg/ESHStjSjD4).
|
||||
|
||||
## Advanced: Alternative LLM Backends
|
||||
|
||||
This section describes how to run local LLMs with OpenHands using alternative backends like Ollama, SGLang, or vLLM — without relying on LM Studio.
|
||||
|
||||
### Create an OpenAI-Compatible Endpoint with Ollama
|
||||
|
||||
- Install Ollama following [the official documentation](https://ollama.com/download).
|
||||
- Example launch command for Devstral Small 2505:
|
||||
|
||||
```bash
|
||||
huggingface-cli download all-hands/openhands-lm-32b-v0.1 --local-dir all-hands/openhands-lm-32b-v0.1
|
||||
# ⚠️ WARNING: OpenHands requires a large context size to work properly.
|
||||
# When using Ollama, set OLLAMA_CONTEXT_LENGTH to at least 32768.
|
||||
# The default (4096) is way too small — not even the system prompt will fit, and the agent will not behave correctly.
|
||||
OLLAMA_CONTEXT_LENGTH=32768 OLLAMA_HOST=0.0.0.0:11434 OLLAMA_KEEP_ALIVE=-1 nohup ollama serve &
|
||||
ollama pull devstral:latest
|
||||
```
|
||||
|
||||
### Create an OpenAI-Compatible Endpoint With SGLang
|
||||
### Create an OpenAI-Compatible Endpoint with vLLM or SGLang
|
||||
|
||||
First, download the model checkpoints. For [Devstral Small 2505](https://huggingface.co/mistralai/Devstral-Small-2505):
|
||||
|
||||
```bash
|
||||
huggingface-cli download mistralai/Devstral-Small-2505 --local-dir mistralai/Devstral-Small-2505
|
||||
```
|
||||
|
||||
#### Serving the model using SGLang
|
||||
|
||||
- Install SGLang following [the official documentation](https://docs.sglang.ai/start/install.html).
|
||||
- Example launch command for OpenHands LM 32B (with at least 2 GPUs):
|
||||
- Example launch command for Devstral Small 2505 (with at least 2 GPUs):
|
||||
|
||||
```bash
|
||||
SGLANG_ALLOW_OVERWRITE_LONGER_CONTEXT_LEN=1 python3 -m sglang.launch_server \
|
||||
--model all-hands/openhands-lm-32b-v0.1 \
|
||||
--served-model-name openhands-lm-32b-v0.1 \
|
||||
--model mistralai/Devstral-Small-2505 \
|
||||
--served-model-name Devstral-Small-2505 \
|
||||
--port 8000 \
|
||||
--tp 2 --dp 1 \
|
||||
--host 0.0.0.0 \
|
||||
--api-key mykey --context-length 131072
|
||||
```
|
||||
|
||||
### Create an OpenAI-Compatible Endpoint with vLLM
|
||||
#### Serving the model using vLLM
|
||||
|
||||
- Install vLLM following [the official documentation](https://docs.vllm.ai/en/latest/getting_started/installation.html).
|
||||
- Example launch command for OpenHands LM 32B (with at least 2 GPUs):
|
||||
- Example launch command for Devstral Small 2505 (with at least 2 GPUs):
|
||||
|
||||
```bash
|
||||
vllm serve all-hands/openhands-lm-32b-v0.1 \
|
||||
vllm serve mistralai/Devstral-Small-2505 \
|
||||
--host 0.0.0.0 --port 8000 \
|
||||
--api-key mykey \
|
||||
--tensor-parallel-size 2 \
|
||||
--served-model-name openhands-lm-32b-v0.1
|
||||
--served-model-name Devstral-Small-2505 \
|
||||
--enable-prefix-caching
|
||||
```
|
||||
|
||||
### Create an OpenAI-Compatible Endpoint with Ollama
|
||||
|
||||
- Install Ollama following [the official documentation](https://ollama.com/download).
|
||||
- For Ollama configuration, use `ollama/<modelname>` as custom model in web. Api key also can be set to `ollama`.
|
||||
- Example launch command for Devstral LM 24B:
|
||||
|
||||
```bash
|
||||
OLLAMA_CONTEXT_LENGTH=32768 OLLAMA_HOST=0.0.0.0:11434 OLLAMA_KEEP_ALIVE=-1 nohup ollama serve&
|
||||
#The minimum context size is ~8196, even the system prompt won't fit smaller
|
||||
ollama pull devstral:latest
|
||||
```
|
||||
|
||||
## Advanced: Run and Configure OpenHands
|
||||
|
||||
### Run OpenHands
|
||||
### Run OpenHands (Alternative Backends)
|
||||
|
||||
#### Using Docker
|
||||
|
||||
@@ -151,28 +184,20 @@ Run OpenHands using [the official docker run command](../installation#start-the-
|
||||
#### Using Development Mode
|
||||
|
||||
Use the instructions in [Development.md](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md) to build OpenHands.
|
||||
Ensure `config.toml` exists by running `make setup-config` which will create one for you. In the `config.toml`, enter the following:
|
||||
|
||||
```
|
||||
[core]
|
||||
workspace_base="/path/to/your/workspace"
|
||||
|
||||
[llm]
|
||||
model="openhands-lm-32b-v0.1"
|
||||
ollama_base_url="http://localhost:8000"
|
||||
```
|
||||
|
||||
Start OpenHands using `make run`.
|
||||
|
||||
### Configure OpenHands
|
||||
### Configure OpenHands (Alternative Backends)
|
||||
|
||||
Once OpenHands is running, you'll need to set the following in the OpenHands UI through the Settings under the `LLM` tab:
|
||||
1. Enable `Advanced` options.
|
||||
2. Set the following:
|
||||
- `Custom Model` to `openai/<served-model-name>` (e.g. `openai/openhands-lm-32b-v0.1`)
|
||||
- `Base URL` to `http://host.docker.internal:8000`
|
||||
- `API key` to the same string you set when serving the model (e.g. `mykey`)
|
||||
Once OpenHands is running, open the Settings page in the UI and go to the `LLM` tab.
|
||||
|
||||
<Note>
|
||||
**API Key for Local LLMs**: When using local LLM servers (including Ollama, LM Studio, vLLM, etc.), you can enter any value as the API key if your server doesn't require authentication. The OpenHands UI requires an API key to be entered, but for local servers without authentication, you can use any placeholder value like `local-key`, `test123`, or `dummy`.
|
||||
</Note>
|
||||
1. Click **"see advanced settings"** to access the full configuration panel.
|
||||
2. Enable the **Advanced** toggle at the top of the page.
|
||||
3. Set the following parameters, if you followed the examples above:
|
||||
- **Custom Model**: `openai/<served-model-name>`
|
||||
e.g. `openai/devstral` if you're using Ollama, or `openai/Devstral-Small-2505` for SGLang or vLLM.
|
||||
- **Base URL**: `http://host.docker.internal:<port>/v1`
|
||||
Use port `11434` for Ollama, or `8000` for SGLang and vLLM.
|
||||
- **API Key**:
|
||||
- For **Ollama**: any placeholder value (e.g. `dummy`, `local-llm`)
|
||||
- For **SGLang** or **vLLM**: use the same key provided when starting the server (e.g. `mykey`)
|
||||
|
||||
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 420 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 558 KiB |
|
Before Width: | Height: | Size: 646 KiB |
|
Before Width: | Height: | Size: 93 KiB |
@@ -5,26 +5,111 @@ description: Keyword-triggered microagents provide OpenHands with specific instr
|
||||
|
||||
## Usage
|
||||
|
||||
These microagents are only loaded when a prompt includes one of the trigger words.
|
||||
Keyword-triggered microagents are only loaded when a prompt includes one of the trigger words. There are two types of keyword-triggered microagents:
|
||||
|
||||
1. **Standard Keyword Microagents**: Triggered by keywords embedded in text
|
||||
2. **Command-Style Microagents**: Triggered by command-style inputs (e.g., `/fix_test`) that can prompt for user input
|
||||
|
||||
Additionally, there's a special type of microagent that's always active:
|
||||
|
||||
3. **Repository Microagents**: Always active for a specific repository, providing repository-specific context and tools
|
||||
|
||||
## Frontmatter Syntax
|
||||
|
||||
Frontmatter is required for keyword-triggered microagents. It must be placed at the top of the file,
|
||||
above the guidelines.
|
||||
above the guidelines. Enclose the frontmatter in triple dashes (---).
|
||||
|
||||
Enclose the frontmatter in triple dashes (---) and include the following fields:
|
||||
### Standard Keyword Microagents
|
||||
|
||||
For standard keyword microagents, include the following fields:
|
||||
|
||||
| Field | Description | Required | Default |
|
||||
|------------|--------------------------------------------------|----------|------------------|
|
||||
| `name` | The name of the microagent | No | Filename |
|
||||
| `type` | The type of microagent (`knowledge`) | No | Inferred |
|
||||
| `triggers` | A list of keywords that activate the microagent. | Yes | None |
|
||||
| `agent` | The agent this microagent applies to. | No | 'CodeActAgent' |
|
||||
|
||||
### Command-Style Microagents
|
||||
|
||||
## Example
|
||||
For command-style microagents that require user input, include the following fields:
|
||||
|
||||
Keyword-triggered microagent file example located at `.openhands/microagents/yummy.md`:
|
||||
```
|
||||
| Field | Description | Required | Default |
|
||||
|------------|------------------------------------------------------------|----------|------------------|
|
||||
| `name` | The name of the microagent | No | Filename |
|
||||
| `type` | The type of microagent (`task`) | No | Inferred |
|
||||
| `triggers` | A list of command triggers (e.g., `/fix_test`) | No | `/[name]` |
|
||||
| `inputs` | A list of input variables the microagent requires | Yes | None |
|
||||
|
||||
### Repository Microagents
|
||||
|
||||
Repository microagents are always active for a specific repository. They provide repository-specific context and tools.
|
||||
|
||||
| Field | Description | Required | Default |
|
||||
|------------|------------------------------------------------------------|----------|------------------|
|
||||
| `name` | The name of the microagent | No | Filename |
|
||||
| `type` | The type of microagent (`repo`) | No | Inferred |
|
||||
|
||||
#### Repository Microagent Example
|
||||
|
||||
Here's an example of a repository microagent:
|
||||
|
||||
```yaml
|
||||
---
|
||||
# The type field is optional and will be inferred as 'repo' when no triggers are present
|
||||
---
|
||||
|
||||
# Repository Guidelines
|
||||
|
||||
This repository follows these coding standards:
|
||||
1. Use PEP 8 for Python code
|
||||
2. Use ESLint for JavaScript code
|
||||
3. Write unit tests for all new features
|
||||
```
|
||||
|
||||
This microagent is always active when working with the repository and provides repository-specific guidelines.
|
||||
|
||||
### MCP Tools Support
|
||||
|
||||
Microagents can also provide additional MCP (Model-Code-Prompt) tools to the agent. This is useful for extending the agent's capabilities with custom tools.
|
||||
|
||||
| Field | Description | Required | Default |
|
||||
|--------------|-----------------------------------------------------------|----------|------------------|
|
||||
| `mcp_tools` | Configuration for additional MCP tools | No | None |
|
||||
|
||||
#### MCP Tools Example
|
||||
|
||||
Here's an example of a microagent that provides an additional MCP tool (the `fetch` tool for accessing web content):
|
||||
|
||||
```yaml
|
||||
---
|
||||
# The type field is optional and will be inferred as 'repo' when no triggers are present
|
||||
mcp_tools:
|
||||
stdio_servers:
|
||||
- name: "fetch"
|
||||
command: uvx
|
||||
args:
|
||||
- mcp-server-fetch
|
||||
---
|
||||
```
|
||||
|
||||
This microagent is a repository microagent (always active) that adds the `fetch` tool to the agent's capabilities.
|
||||
|
||||
Each input in the `inputs` list requires:
|
||||
|
||||
| Field | Description | Required |
|
||||
|---------------|--------------------------------------------------|----------|
|
||||
| `name` | The name of the input variable | Yes |
|
||||
| `description` | A description of what the input should contain | Yes |
|
||||
|
||||
|
||||
## Examples
|
||||
|
||||
### Standard Keyword Microagent Example
|
||||
|
||||
Standard keyword microagent file example located at `.openhands/microagents/yummy.md`:
|
||||
```yaml
|
||||
---
|
||||
# The type field is optional and will be inferred as 'knowledge' when triggers are present
|
||||
triggers:
|
||||
- yummyhappy
|
||||
- happyyummy
|
||||
@@ -33,4 +118,58 @@ triggers:
|
||||
The user has said the magic word. Respond with "That was delicious!"
|
||||
```
|
||||
|
||||
[See examples of microagents triggered by keywords in the official OpenHands repository](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents)
|
||||
### Command-Style Microagent Example
|
||||
|
||||
Command-style microagent file example located at `.openhands/microagents/fix_test.md`:
|
||||
```yaml
|
||||
---
|
||||
# The type field is optional and will be inferred as 'task' when inputs are present
|
||||
triggers:
|
||||
- /fix_test
|
||||
inputs:
|
||||
- name: BRANCH_NAME
|
||||
description: "Branch for the agent to work on"
|
||||
- name: TEST_COMMAND_TO_RUN
|
||||
description: "The test command you want the agent to work on. For example, `pytest tests/unit/test_bash_parsing.py`"
|
||||
- name: FUNCTION_TO_FIX
|
||||
description: "The name of function to fix"
|
||||
- name: FILE_FOR_FUNCTION
|
||||
description: "The path of the file that contains the function"
|
||||
---
|
||||
|
||||
Can you check out branch "{{ BRANCH_NAME }}", and run {{ TEST_COMMAND_TO_RUN }}.
|
||||
|
||||
Help me fix these tests to pass by fixing the {{ FUNCTION_TO_FIX }} function in file {{ FILE_FOR_FUNCTION }}.
|
||||
|
||||
PLEASE DO NOT modify the tests by yourself -- Let me know if you think some of the tests are incorrect.
|
||||
```
|
||||
|
||||
## Using Command-Style Microagents
|
||||
|
||||
Command-style microagents are designed to streamline common development tasks by providing structured templates for specific operations. They are triggered using a command-style format and will prompt the user for any required inputs.
|
||||
|
||||
### How to Use
|
||||
|
||||
1. Type `/` in the chat input to see available command-style microagents
|
||||
2. Select a microagent from the dropdown or type its name (e.g., `/fix_test`)
|
||||
3. The agent will prompt you for any required inputs
|
||||
4. Provide the requested information
|
||||
5. The agent will execute the task with your inputs
|
||||
|
||||
### Template Variables
|
||||
|
||||
In the body of a command-style microagent, you can reference input variables using the `{{ VARIABLE_NAME }}` syntax. These will be replaced with the user-provided values when the microagent is triggered.
|
||||
|
||||
### Available Command-Style Microagents
|
||||
|
||||
OpenHands includes several built-in command-style microagents:
|
||||
|
||||
| Command | Description |
|
||||
|----------------------|-------------------------------------------------------|
|
||||
| `/fix_test` | Fix failing tests by modifying a specific function |
|
||||
| `/update_test` | Update tests for a new implementation |
|
||||
| `/update_pr` | Update a pull request description |
|
||||
| `/address_pr_comments` | Address comments on a pull request |
|
||||
| `/add_repo_instruction` | Add instructions to the repository microagent |
|
||||
|
||||
[See examples of microagents in the official OpenHands repository](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents)
|
||||
|
||||
@@ -8,7 +8,7 @@ description: Microagents are specialized prompts that enhance OpenHands with dom
|
||||
Currently OpenHands supports the following types of microagents:
|
||||
|
||||
- [General Microagents](./microagents-repo): General guidelines for OpenHands about the repository.
|
||||
- [Keyword-Triggered Microagents](./microagents-keyword): Guidelines activated by specific keywords in prompts.
|
||||
- [Keyword-Triggered Microagents](./microagents-keyword): Guidelines activated by specific keywords in prompts, including command-style microagents that prompt for user inputs.
|
||||
|
||||
To customize OpenHands' behavior, create a .openhands/microagents/ directory in the root of your repository and
|
||||
add `<microagent_name>.md` files inside. For repository-specific guidelines, you can ask OpenHands to analyze your repository and create a comprehensive `repo.md` file (see [General Microagents](./microagents-repo) for details).
|
||||
@@ -34,7 +34,7 @@ some-repository/
|
||||
Each microagent file may include frontmatter that provides additional information. In some cases, this frontmatter
|
||||
is required:
|
||||
|
||||
| Microagent Type | Required |
|
||||
|---------------------------------|----------|
|
||||
| `General Microagents` | No |
|
||||
| `Keyword-Triggered Microagents` | Yes |
|
||||
| Microagent Type | Required |
|
||||
|------------------------------------------------|----------|
|
||||
| `General Microagents` | No |
|
||||
| `Keyword-Triggered Microagents (all types)` | Yes |
|
||||
|
||||
@@ -3,19 +3,6 @@ title: Daytona Runtime
|
||||
description: You can use [Daytona](https://www.daytona.io/) as a runtime provider.
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
The Daytona runtime is available as an optional runtime. To use it, install OpenHands with the Daytona extra:
|
||||
|
||||
```bash
|
||||
pip install openhands-ai[daytona]
|
||||
```
|
||||
|
||||
Or to install all available runtimes:
|
||||
|
||||
```bash
|
||||
pip install openhands-ai[all-runtimes]
|
||||
```
|
||||
|
||||
## Step 1: Retrieve Your Daytona API Key
|
||||
1. Visit the [Daytona Dashboard](https://app.daytona.io/dashboard/keys).
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: E2B Runtime
|
||||
description: E2B is an open-source secure cloud environment (sandbox) made for running AI-generated code and agents.
|
||||
---
|
||||
|
||||
[E2B](https://e2b.dev) offers [Python](https://pypi.org/project/e2b/) and [JS/TS](https://www.npmjs.com/package/e2b) SDK to spawn and control these sandboxes.
|
||||
|
||||
## Getting started
|
||||
|
||||
1. [Get your API key](https://e2b.dev/docs/getting-started/api-key)
|
||||
|
||||
1. Set your E2B API key to the `E2B_API_KEY` env var when starting the Docker container
|
||||
|
||||
1. **Optional** - Install the CLI with NPM.
|
||||
```sh
|
||||
npm install -g @e2b/cli@latest
|
||||
```
|
||||
Full CLI API is [here](https://e2b.dev/docs/cli/installation).
|
||||
|
||||
## OpenHands sandbox
|
||||
You can use the E2B CLI to create a custom sandbox with a Dockerfile. Read the full guide [here](https://e2b.dev/docs/guide/custom-sandbox). The premade OpenHands sandbox for E2B is set up in the `containers` directory. and it's called `openhands`.
|
||||
|
||||
## Debugging
|
||||
You can connect to a running E2B sandbox with E2B CLI in your terminal.
|
||||
|
||||
- List all running sandboxes (based on your API key)
|
||||
```sh
|
||||
e2b sandbox list
|
||||
```
|
||||
|
||||
- Connect to a running sandbox
|
||||
```sh
|
||||
e2b sandbox connect <sandbox-id>
|
||||
```
|
||||
|
||||
## Links
|
||||
- [E2B Docs](https://e2b.dev/docs)
|
||||
- [E2B GitHub](https://github.com/e2b-dev/e2b)
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
title: Modal Runtime
|
||||
---
|
||||
|
||||
Our partners at [Modal](https://modal.com/) have provided a runtime for OpenHands.
|
||||
To use the Modal Runtime, create an account, and then [create an API key.](https://modal.com/settings)
|
||||
|
||||
You'll then need to set the following environment variables when starting OpenHands:
|
||||
```bash
|
||||
docker run # ...
|
||||
-e RUNTIME=modal \
|
||||
-e MODAL_API_TOKEN_ID="your-id" \
|
||||
-e MODAL_API_TOKEN_SECRET="modal-api-key" \
|
||||
```
|
||||
@@ -9,6 +9,8 @@ commands.
|
||||
By default, OpenHands uses a [Docker-based runtime](/usage/runtimes/docker), running on your local computer.
|
||||
This means you only have to pay for the LLM you're using, and your code is only ever sent to the LLM.
|
||||
|
||||
We also support other runtimes, which are typically managed by third-parties.
|
||||
|
||||
Additionally, we provide a [Local Runtime](/usage/runtimes/local) that runs directly on your machine without Docker,
|
||||
which can be useful in controlled environments like CI pipelines.
|
||||
|
||||
@@ -19,5 +21,6 @@ OpenHands supports several different runtime environments:
|
||||
- [Docker Runtime](/usage/runtimes/docker) - The default runtime that uses Docker containers for isolation (recommended for most users).
|
||||
- [OpenHands Remote Runtime](/usage/runtimes/remote) - Cloud-based runtime for parallel execution (beta).
|
||||
- [Local Runtime](/usage/runtimes/local) - Direct execution on your local machine without Docker.
|
||||
- [Daytona Runtime](/usage/runtimes/daytona) - Runtime provided by Daytona.
|
||||
- [Third-party Runtimes](https://github.com/All-Hands-AI/third-party-runtimes) - These runtimes are supported by their developers, not by OpenHands. Please find them in the repository linked here if you would like to run on third-party infrastructure providers.
|
||||
- And more third-party runtimes:
|
||||
- [Modal Runtime](/usage/runtimes/modal) - Runtime provided by our partners at Modal.
|
||||
- [Daytona Runtime](/usage/runtimes/daytona) - Runtime provided by Daytona.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
title: Runloop Runtime
|
||||
description: Runloop provides a fast, secure and scalable AI sandbox (Devbox). Check out the [runloop docs](https://docs.runloop.ai/overview/what-is-runloop) for more detail.
|
||||
---
|
||||
|
||||
## Access
|
||||
Runloop is currently available in a closed beta. For early access, or
|
||||
just to say hello, sign up at https://www.runloop.ai/hello
|
||||
|
||||
## Set up
|
||||
With your runloop API,
|
||||
```bash
|
||||
export RUNLOOP_API_KEY=<your-api-key>
|
||||
```
|
||||
|
||||
Configure the runtime
|
||||
```bash
|
||||
export RUNTIME="runloop"
|
||||
```
|
||||
|
||||
## Interact with your devbox
|
||||
Runloop provides additional tools to interact with your Devbox based
|
||||
runtime environment. See the [docs](https://docs.runloop.ai/tools) for an up
|
||||
to date list of tools.
|
||||
|
||||
### Dashboard
|
||||
View logs, ssh into, or view your Devbox status from the [dashboard](https://platform.runloop.ai)
|
||||
|
||||
### CLI
|
||||
Use the Runloop CLI to view logs, execute commands, and more.
|
||||
See the setup instructions [here](https://docs.runloop.ai/tools/cli)
|
||||
@@ -1,20 +1,16 @@
|
||||
---
|
||||
name: add_agent
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- new agent
|
||||
- new microagent
|
||||
- create agent
|
||||
- create an agent
|
||||
- create microagent
|
||||
- create a microagent
|
||||
- add agent
|
||||
- add an agent
|
||||
- add microagent
|
||||
- add a microagent
|
||||
- microagent template
|
||||
- new agent
|
||||
- new microagent
|
||||
- create agent
|
||||
- create an agent
|
||||
- create microagent
|
||||
- create a microagent
|
||||
- add agent
|
||||
- add an agent
|
||||
- add microagent
|
||||
- add a microagent
|
||||
- microagent template
|
||||
---
|
||||
|
||||
This agent helps create new microagents in the `.openhands/microagents` directory by providing guidance and templates.
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
---
|
||||
name: add_repo_inst
|
||||
version: 1.0.0
|
||||
author: openhands
|
||||
agent: CodeActAgent
|
||||
inputs:
|
||||
- description: Branch for the agent to work on
|
||||
name: REPO_FOLDER_NAME
|
||||
triggers:
|
||||
- /add_repo_inst
|
||||
inputs:
|
||||
- name: REPO_FOLDER_NAME
|
||||
description: "Branch for the agent to work on"
|
||||
---
|
||||
|
||||
Please browse the current repository under /workspace/{{ REPO_FOLDER_NAME }}, look at the documentation and relevant code, and understand the purpose of this repository.
|
||||
@@ -18,7 +14,6 @@ Here's an example:
|
||||
```markdown
|
||||
---
|
||||
name: repo
|
||||
type: repo
|
||||
agent: CodeActAgent
|
||||
---
|
||||
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
---
|
||||
name: address_pr_comments
|
||||
version: 1.0.0
|
||||
author: openhands
|
||||
agent: CodeActAgent
|
||||
inputs:
|
||||
- description: URL of the pull request
|
||||
name: PR_URL
|
||||
- description: Branch name corresponds to the pull request
|
||||
name: BRANCH_NAME
|
||||
triggers:
|
||||
- /address_pr_comments
|
||||
inputs:
|
||||
- name: PR_URL
|
||||
description: "URL of the pull request"
|
||||
- name: BRANCH_NAME
|
||||
description: "Branch name corresponds to the pull request"
|
||||
---
|
||||
|
||||
First, check the branch {{ BRANCH_NAME }} and read the diff against the main branch to understand the purpose.
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
---
|
||||
name: agent_memory
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- /remember
|
||||
---
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
---
|
||||
# This is a repo microagent that is always activated
|
||||
# to include necessary default tools implemented with MCP
|
||||
name: default-tools
|
||||
type: repo
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
mcp_tools:
|
||||
stdio_servers:
|
||||
- name: "fetch"
|
||||
command: "uvx"
|
||||
args: ["mcp-server-fetch"]
|
||||
# We leave the body empty because MCP tools will automatically add the
|
||||
# tool description for LLMs in tool calls, so there's no need to add extra descriptions.
|
||||
- args:
|
||||
- mcp-server-fetch
|
||||
command: uvx
|
||||
name: fetch
|
||||
---
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
---
|
||||
name: docker
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- docker
|
||||
- container
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
---
|
||||
name: fix_test
|
||||
version: 1.0.0
|
||||
author: openhands
|
||||
agent: CodeActAgent
|
||||
inputs:
|
||||
- description: Branch for the agent to work on
|
||||
name: BRANCH_NAME
|
||||
- description: The test command you want the agent to work on. For example, `pytest
|
||||
tests/unit/test_bash_parsing.py`
|
||||
name: TEST_COMMAND_TO_RUN
|
||||
- description: The name of function to fix
|
||||
name: FUNCTION_TO_FIX
|
||||
- description: The path of the file that contains the function
|
||||
name: FILE_FOR_FUNCTION
|
||||
triggers:
|
||||
- /fix_test
|
||||
inputs:
|
||||
- name: BRANCH_NAME
|
||||
description: "Branch for the agent to work on"
|
||||
- name: TEST_COMMAND_TO_RUN
|
||||
description: "The test command you want the agent to work on. For example, `pytest tests/unit/test_bash_parsing.py`"
|
||||
- name: FUNCTION_TO_FIX
|
||||
description: "The name of function to fix"
|
||||
- name: FILE_FOR_FUNCTION
|
||||
description: "The path of the file that contains the function"
|
||||
---
|
||||
|
||||
Can you check out branch "{{ BRANCH_NAME }}", and run {{ TEST_COMMAND_TO_RUN }}.
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
---
|
||||
name: flarglebargle
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- flarglebargle
|
||||
---
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
---
|
||||
name: github
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- github
|
||||
- git
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
---
|
||||
name: gitlab
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- gitlab
|
||||
- git
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
---
|
||||
name: kubernetes
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- kubernetes
|
||||
- k8s
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
---
|
||||
name: npm
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- npm
|
||||
---
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
---
|
||||
name: pdflatex
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- pdflatex
|
||||
---
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
---
|
||||
name: security
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- security
|
||||
- vulnerability
|
||||
- authentication
|
||||
- authorization
|
||||
- permissions
|
||||
- security
|
||||
- vulnerability
|
||||
- authentication
|
||||
- authorization
|
||||
- permissions
|
||||
---
|
||||
|
||||
This document provides guidance on security best practices
|
||||
|
||||
You should always be considering security implications when developing.
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
---
|
||||
name: SSH Microagent
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- ssh
|
||||
- remote server
|
||||
- remote machine
|
||||
- remote host
|
||||
- remote connection
|
||||
- secure shell
|
||||
- ssh keys
|
||||
- ssh
|
||||
- remote server
|
||||
- remote machine
|
||||
- remote host
|
||||
- remote connection
|
||||
- secure shell
|
||||
- ssh keys
|
||||
---
|
||||
|
||||
# SSH Microagent
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
---
|
||||
name: swift-linux
|
||||
type: knowledge
|
||||
agent: CodeActAgent
|
||||
version: 1.0.0
|
||||
triggers:
|
||||
- swift-linux
|
||||
- swift-debian
|
||||
- swift-installation
|
||||
triggers:
|
||||
- swift-linux
|
||||
- swift-debian
|
||||
- swift-installation
|
||||
---
|
||||
|
||||
# Swift Installation Guide for Debian Linux
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
---
|
||||
name: update_pr_description
|
||||
version: 1.0.0
|
||||
author: openhands
|
||||
agent: CodeActAgent
|
||||
inputs:
|
||||
- description: URL of the pull request
|
||||
name: PR_URL
|
||||
type: string
|
||||
validation:
|
||||
pattern: ^https://github.com/.+/.+/pull/[0-9]+$
|
||||
- description: Branch name corresponds to the pull request
|
||||
name: BRANCH_NAME
|
||||
type: string
|
||||
triggers:
|
||||
- /update_pr_description
|
||||
inputs:
|
||||
- name: PR_URL
|
||||
description: "URL of the pull request"
|
||||
type: string
|
||||
validation:
|
||||
pattern: "^https://github.com/.+/.+/pull/[0-9]+$"
|
||||
- name: BRANCH_NAME
|
||||
description: "Branch name corresponds to the pull request"
|
||||
type: string
|
||||
---
|
||||
|
||||
Please check the branch "{{ BRANCH_NAME }}" and look at the diff against the main branch. This branch belongs to this PR "{{ PR_URL }}".
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
---
|
||||
name: update_test
|
||||
version: 1.0.0
|
||||
author: openhands
|
||||
agent: CodeActAgent
|
||||
inputs:
|
||||
- description: Branch for the agent to work on
|
||||
name: BRANCH_NAME
|
||||
- description: The test command you want the agent to work on. For example, `pytest
|
||||
tests/unit/test_bash_parsing.py`
|
||||
name: TEST_COMMAND_TO_RUN
|
||||
triggers:
|
||||
- /update_test
|
||||
inputs:
|
||||
- name: BRANCH_NAME
|
||||
description: "Branch for the agent to work on"
|
||||
- name: TEST_COMMAND_TO_RUN
|
||||
description: "The test command you want the agent to work on. For example, `pytest tests/unit/test_bash_parsing.py`"
|
||||
---
|
||||
|
||||
Can you check out branch "{{ BRANCH_NAME }}", and run {{ TEST_COMMAND_TO_RUN }}.
|
||||
|
||||
@@ -6,17 +6,20 @@ from openhands.llm.tool_names import EXECUTE_BASH_TOOL_NAME
|
||||
|
||||
_DETAILED_BASH_DESCRIPTION = """Execute a bash command in the terminal within a persistent shell session.
|
||||
|
||||
|
||||
### Command Execution
|
||||
* One command at a time: You can only execute one bash command at a time. If you need to run multiple commands sequentially, use `&&` or `;` to chain them together.
|
||||
* Persistent session: Commands execute in a persistent shell session where environment variables, virtual environments, and working directory persist between commands.
|
||||
* Timeout: Commands have a soft timeout of 10 seconds, once that's reached, you have the option to continue or interrupt the command (see section below for details)
|
||||
* Soft timeout: Commands have a soft timeout of 10 seconds, once that's reached, you have the option to continue or interrupt the command (see section below for details)
|
||||
|
||||
### Running and Interacting with Processes
|
||||
* Long running commands: For commands that may run indefinitely, run them in the background and redirect output to a file, e.g. `python3 app.py > server.log 2>&1 &`. For commands that need to run for a specific duration, like "sleep", you can set the "timeout" argument to specify a hard timeout in seconds.
|
||||
* Interact with running process: If a bash command returns exit code `-1`, this means the process is not yet finished. By setting `is_input` to `true`, you can:
|
||||
### Long-running Commands
|
||||
* For commands that may run indefinitely, run them in the background and redirect output to a file, e.g. `python3 app.py > server.log 2>&1 &`.
|
||||
* For commands that may run for a long time (e.g. installation or testing commands), or commands that run for a fixed amount of time (e.g. sleep), you should set the "timeout" parameter of your function call to an appropriate value.
|
||||
* If a bash command returns exit code `-1`, this means the process hit the soft timeout and is not yet finished. By setting `is_input` to `true`, you can:
|
||||
- Send empty `command` to retrieve additional logs
|
||||
- Send text (set `command` to the text) to STDIN of the running process
|
||||
- Send control commands like `C-c` (Ctrl+C), `C-d` (Ctrl+D), or `C-z` (Ctrl+Z) to interrupt the process
|
||||
- If you do C-c, you can re-start the process with a longer "timeout" parameter to let it run to completion
|
||||
|
||||
### Best Practices
|
||||
* Directory verification: Before creating new directories or files, first verify the parent directory exists and is the correct location.
|
||||
|
||||
@@ -52,6 +52,7 @@ async def handle_commands(
|
||||
|
||||
if command == '/exit':
|
||||
close_repl = handle_exit_command(
|
||||
config,
|
||||
event_stream,
|
||||
usage_metrics,
|
||||
sid,
|
||||
@@ -66,7 +67,7 @@ async def handle_commands(
|
||||
handle_status_command(usage_metrics, sid)
|
||||
elif command == '/new':
|
||||
close_repl, new_session_requested = handle_new_command(
|
||||
event_stream, usage_metrics, sid
|
||||
config, event_stream, usage_metrics, sid
|
||||
)
|
||||
elif command == '/settings':
|
||||
await handle_settings_command(config, settings_store)
|
||||
@@ -81,12 +82,16 @@ async def handle_commands(
|
||||
|
||||
|
||||
def handle_exit_command(
|
||||
event_stream: EventStream, usage_metrics: UsageMetrics, sid: str
|
||||
config: OpenHandsConfig,
|
||||
event_stream: EventStream,
|
||||
usage_metrics: UsageMetrics,
|
||||
sid: str,
|
||||
) -> bool:
|
||||
close_repl = False
|
||||
|
||||
confirm_exit = (
|
||||
cli_confirm('\nTerminate session?', ['Yes, proceed', 'No, dismiss']) == 0
|
||||
cli_confirm(config, '\nTerminate session?', ['Yes, proceed', 'No, dismiss'])
|
||||
== 0
|
||||
)
|
||||
|
||||
if confirm_exit:
|
||||
@@ -119,7 +124,7 @@ async def handle_init_command(
|
||||
reload_microagents = False
|
||||
|
||||
if config.runtime == 'local':
|
||||
init_repo = await init_repository(current_dir)
|
||||
init_repo = await init_repository(config, current_dir)
|
||||
if init_repo:
|
||||
event_stream.add_event(
|
||||
MessageAction(content=REPO_MD_CREATE_PROMPT),
|
||||
@@ -140,13 +145,17 @@ def handle_status_command(usage_metrics: UsageMetrics, sid: str) -> None:
|
||||
|
||||
|
||||
def handle_new_command(
|
||||
event_stream: EventStream, usage_metrics: UsageMetrics, sid: str
|
||||
config: OpenHandsConfig,
|
||||
event_stream: EventStream,
|
||||
usage_metrics: UsageMetrics,
|
||||
sid: str,
|
||||
) -> tuple[bool, bool]:
|
||||
close_repl = False
|
||||
new_session_requested = False
|
||||
|
||||
new_session_requested = (
|
||||
cli_confirm(
|
||||
config,
|
||||
'\nCurrent session will be terminated and you will lose the conversation history.\n\nContinue?',
|
||||
['Yes, proceed', 'No, dismiss'],
|
||||
)
|
||||
@@ -171,6 +180,7 @@ async def handle_settings_command(
|
||||
) -> None:
|
||||
display_settings(config)
|
||||
modify_settings = cli_confirm(
|
||||
config,
|
||||
'\nWhich settings would you like to modify?',
|
||||
[
|
||||
'Basic',
|
||||
@@ -207,7 +217,7 @@ async def handle_resume_command(
|
||||
return close_repl, new_session_requested
|
||||
|
||||
|
||||
async def init_repository(current_dir: str) -> bool:
|
||||
async def init_repository(config: OpenHandsConfig, current_dir: str) -> bool:
|
||||
repo_file_path = Path(current_dir) / '.openhands' / 'microagents' / 'repo.md'
|
||||
init_repo = False
|
||||
|
||||
@@ -237,6 +247,7 @@ async def init_repository(current_dir: str) -> bool:
|
||||
|
||||
init_repo = (
|
||||
cli_confirm(
|
||||
config,
|
||||
'Do you want to re-initialize?',
|
||||
['Yes, re-initialize', 'No, dismiss'],
|
||||
)
|
||||
@@ -255,6 +266,7 @@ async def init_repository(current_dir: str) -> bool:
|
||||
|
||||
init_repo = (
|
||||
cli_confirm(
|
||||
config,
|
||||
'Do you want to proceed?',
|
||||
['Yes, create', 'No, dismiss'],
|
||||
)
|
||||
@@ -297,7 +309,10 @@ def check_folder_security_agreement(config: OpenHandsConfig, current_dir: str) -
|
||||
print_formatted_text('')
|
||||
|
||||
confirm = (
|
||||
cli_confirm('Do you wish to continue?', ['Yes, proceed', 'No, exit']) == 0
|
||||
cli_confirm(
|
||||
config, 'Do you wish to continue?', ['Yes, proceed', 'No, exit']
|
||||
)
|
||||
== 0
|
||||
)
|
||||
|
||||
if confirm:
|
||||
|
||||
@@ -155,7 +155,7 @@ async def run_session(
|
||||
nonlocal reload_microagents, new_session_requested
|
||||
while True:
|
||||
next_message = await read_prompt_input(
|
||||
agent_state, multiline=config.cli_multiline_input
|
||||
config, agent_state, multiline=config.cli_multiline_input
|
||||
)
|
||||
|
||||
if not next_message.strip():
|
||||
@@ -214,7 +214,7 @@ async def run_session(
|
||||
)
|
||||
return
|
||||
|
||||
confirmation_status = await read_confirmation_input()
|
||||
confirmation_status = await read_confirmation_input(config)
|
||||
if confirmation_status == 'yes' or confirmation_status == 'always':
|
||||
event_stream.add_event(
|
||||
ChangeAgentStateAction(AgentState.USER_CONFIRMED),
|
||||
|
||||
@@ -135,9 +135,10 @@ async def get_validated_input(
|
||||
return value
|
||||
|
||||
|
||||
def save_settings_confirmation() -> bool:
|
||||
def save_settings_confirmation(config: OpenHandsConfig) -> bool:
|
||||
return (
|
||||
cli_confirm(
|
||||
config,
|
||||
'\nSave new settings? (They will take effect after restart)',
|
||||
['Yes, save', 'No, discard'],
|
||||
)
|
||||
@@ -173,6 +174,7 @@ async def modify_llm_settings_basic(
|
||||
# Show verified providers plus "Select another provider" option
|
||||
provider_choices = verified_providers + ['Select another provider']
|
||||
provider_choice = cli_confirm(
|
||||
config,
|
||||
'(Step 1/3) Select LLM Provider:',
|
||||
provider_choices,
|
||||
)
|
||||
@@ -255,6 +257,7 @@ async def modify_llm_settings_basic(
|
||||
)
|
||||
change_model = (
|
||||
cli_confirm(
|
||||
config,
|
||||
'Do you want to use a different model?',
|
||||
[f'Use {default_model}', 'Select another model'],
|
||||
)
|
||||
@@ -307,7 +310,7 @@ async def modify_llm_settings_basic(
|
||||
# The try-except block above ensures we either have valid inputs or we've already returned
|
||||
# No need to check for None values here
|
||||
|
||||
save_settings = save_settings_confirmation()
|
||||
save_settings = save_settings_confirmation(config)
|
||||
|
||||
if not save_settings:
|
||||
return
|
||||
@@ -382,6 +385,7 @@ async def modify_llm_settings_advanced(
|
||||
|
||||
enable_confirmation_mode = (
|
||||
cli_confirm(
|
||||
config,
|
||||
question='(Step 5/6) Confirmation Mode (CTRL-c to cancel):',
|
||||
choices=['Enable', 'Disable'],
|
||||
)
|
||||
@@ -390,6 +394,7 @@ async def modify_llm_settings_advanced(
|
||||
|
||||
enable_memory_condensation = (
|
||||
cli_confirm(
|
||||
config,
|
||||
question='(Step 6/6) Memory Condensation (CTRL-c to cancel):',
|
||||
choices=['Enable', 'Disable'],
|
||||
)
|
||||
@@ -406,7 +411,7 @@ async def modify_llm_settings_advanced(
|
||||
# The try-except block above ensures we either have valid inputs or we've already returned
|
||||
# No need to check for None values here
|
||||
|
||||
save_settings = save_settings_confirmation()
|
||||
save_settings = save_settings_confirmation(config)
|
||||
|
||||
if not save_settings:
|
||||
return
|
||||
|
||||
@@ -520,13 +520,16 @@ class CommandCompleter(Completer):
|
||||
)
|
||||
|
||||
|
||||
def create_prompt_session() -> PromptSession[str]:
|
||||
return PromptSession(style=DEFAULT_STYLE)
|
||||
def create_prompt_session(config: OpenHandsConfig) -> PromptSession[str]:
|
||||
"""Creates a prompt session with VI mode enabled if specified in the config."""
|
||||
return PromptSession(style=DEFAULT_STYLE, vi_mode=config.cli.vi_mode)
|
||||
|
||||
|
||||
async def read_prompt_input(agent_state: str, multiline: bool = False) -> str:
|
||||
async def read_prompt_input(
|
||||
config: OpenHandsConfig, agent_state: str, multiline: bool = False
|
||||
) -> str:
|
||||
try:
|
||||
prompt_session = create_prompt_session()
|
||||
prompt_session = create_prompt_session(config)
|
||||
prompt_session.completer = (
|
||||
CommandCompleter(agent_state) if not multiline else None
|
||||
)
|
||||
@@ -558,9 +561,9 @@ async def read_prompt_input(agent_state: str, multiline: bool = False) -> str:
|
||||
return '/exit'
|
||||
|
||||
|
||||
async def read_confirmation_input() -> str:
|
||||
async def read_confirmation_input(config: OpenHandsConfig) -> str:
|
||||
try:
|
||||
prompt_session = create_prompt_session()
|
||||
prompt_session = create_prompt_session(config)
|
||||
|
||||
with patch_stdout():
|
||||
print_formatted_text('')
|
||||
@@ -606,7 +609,9 @@ async def process_agent_pause(done: asyncio.Event, event_stream: EventStream) ->
|
||||
|
||||
|
||||
def cli_confirm(
|
||||
question: str = 'Are you sure?', choices: list[str] | None = None
|
||||
config: OpenHandsConfig,
|
||||
question: str = 'Are you sure?',
|
||||
choices: list[str] | None = None,
|
||||
) -> int:
|
||||
"""Display a confirmation prompt with the given question and choices.
|
||||
|
||||
@@ -630,15 +635,27 @@ def cli_confirm(
|
||||
kb = KeyBindings()
|
||||
|
||||
@kb.add('up')
|
||||
def _(event: KeyPressEvent) -> None:
|
||||
def _handle_up(event: KeyPressEvent) -> None:
|
||||
selected[0] = (selected[0] - 1) % len(choices)
|
||||
|
||||
if config.cli.vi_mode:
|
||||
|
||||
@kb.add('k')
|
||||
def _handle_k(event: KeyPressEvent) -> None:
|
||||
selected[0] = (selected[0] - 1) % len(choices)
|
||||
|
||||
@kb.add('down')
|
||||
def _(event: KeyPressEvent) -> None:
|
||||
def _handle_down(event: KeyPressEvent) -> None:
|
||||
selected[0] = (selected[0] + 1) % len(choices)
|
||||
|
||||
if config.cli.vi_mode:
|
||||
|
||||
@kb.add('j')
|
||||
def _handle_j(event: KeyPressEvent) -> None:
|
||||
selected[0] = (selected[0] + 1) % len(choices)
|
||||
|
||||
@kb.add('enter')
|
||||
def _(event: KeyPressEvent) -> None:
|
||||
def _handle_enter(event: KeyPressEvent) -> None:
|
||||
event.app.exit(result=selected[0])
|
||||
|
||||
style = Style.from_dict({'selected': COLOR_GOLD, 'unselected': ''})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from openhands.core.config.agent_config import AgentConfig
|
||||
from openhands.core.config.cli_config import CLIConfig
|
||||
from openhands.core.config.config_utils import (
|
||||
OH_DEFAULT_AGENT,
|
||||
OH_MAX_ITERATIONS,
|
||||
@@ -26,6 +27,7 @@ __all__ = [
|
||||
'OH_DEFAULT_AGENT',
|
||||
'OH_MAX_ITERATIONS',
|
||||
'AgentConfig',
|
||||
'CLIConfig',
|
||||
'OpenHandsConfig',
|
||||
'MCPConfig',
|
||||
'LLMConfig',
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CLIConfig(BaseModel):
|
||||
"""Configuration for CLI-specific settings."""
|
||||
|
||||
vi_mode: bool = Field(default=False)
|
||||
|
||||
model_config = {'extra': 'forbid'}
|
||||
@@ -158,6 +158,7 @@ class MCPConfig(BaseModel):
|
||||
mcp_mapping['mcp'] = cls(
|
||||
sse_servers=mcp_config.sse_servers,
|
||||
stdio_servers=mcp_config.stdio_servers,
|
||||
shttp_servers=mcp_config.shttp_servers,
|
||||
)
|
||||
except ValidationError as e:
|
||||
raise ValueError(f'Invalid MCP configuration: {e}')
|
||||
|
||||
@@ -5,6 +5,7 @@ from pydantic import BaseModel, Field, SecretStr
|
||||
|
||||
from openhands.core import logger
|
||||
from openhands.core.config.agent_config import AgentConfig
|
||||
from openhands.core.config.cli_config import CLIConfig
|
||||
from openhands.core.config.config_utils import (
|
||||
OH_DEFAULT_AGENT,
|
||||
OH_MAX_ITERATIONS,
|
||||
@@ -45,7 +46,7 @@ class OpenHandsConfig(BaseModel):
|
||||
run_as_openhands: Whether to run as openhands.
|
||||
max_iterations: Maximum number of iterations allowed.
|
||||
max_budget_per_task: Maximum budget per task, agent stops if exceeded.
|
||||
|
||||
e2b_api_key: E2B API key.
|
||||
disable_color: Whether to disable terminal colors. For terminals that don't support color.
|
||||
debug: Whether to enable debugging mode.
|
||||
file_uploads_max_file_size_mb: Maximum file upload size in MB. `0` means unlimited.
|
||||
@@ -87,17 +88,19 @@ class OpenHandsConfig(BaseModel):
|
||||
run_as_openhands: bool = Field(default=True)
|
||||
max_iterations: int = Field(default=OH_MAX_ITERATIONS)
|
||||
max_budget_per_task: float | None = Field(default=None)
|
||||
|
||||
e2b_api_key: SecretStr | None = Field(default=None)
|
||||
modal_api_token_id: SecretStr | None = Field(default=None)
|
||||
modal_api_token_secret: SecretStr | None = Field(default=None)
|
||||
disable_color: bool = Field(default=False)
|
||||
jwt_secret: SecretStr | None = Field(default=None)
|
||||
debug: bool = Field(default=False)
|
||||
file_uploads_max_file_size_mb: int = Field(default=0)
|
||||
file_uploads_restrict_file_types: bool = Field(default=False)
|
||||
file_uploads_allowed_extensions: list[str] = Field(default_factory=lambda: ['.*'])
|
||||
runloop_api_key: SecretStr | None = Field(default=None)
|
||||
daytona_api_key: SecretStr | None = Field(default=None)
|
||||
daytona_api_url: str = Field(default='https://app.daytona.io/api')
|
||||
daytona_target: str = Field(default='eu')
|
||||
|
||||
cli_multiline_input: bool = Field(default=False)
|
||||
conversation_max_age_seconds: int = Field(default=864000) # 10 days in seconds
|
||||
enable_default_condenser: bool = Field(default=True)
|
||||
@@ -107,6 +110,7 @@ class OpenHandsConfig(BaseModel):
|
||||
mcp_host: str = Field(default=f'localhost:{os.getenv("port", 3000)}')
|
||||
mcp: MCPConfig = Field(default_factory=MCPConfig)
|
||||
kubernetes: KubernetesConfig = Field(default_factory=KubernetesConfig)
|
||||
cli: CLIConfig = Field(default_factory=CLIConfig)
|
||||
|
||||
defaults_dict: ClassVar[dict] = {}
|
||||
|
||||
|
||||
@@ -254,8 +254,11 @@ class SensitiveDataFilter(logging.Filter):
|
||||
'api_key',
|
||||
'aws_access_key_id',
|
||||
'aws_secret_access_key',
|
||||
'e2b_api_key',
|
||||
'github_token',
|
||||
'jwt_secret',
|
||||
'modal_api_token_id',
|
||||
'modal_api_token_secret',
|
||||
'llm_api_key',
|
||||
'sandbox_env_github_token',
|
||||
'daytona_api_key',
|
||||
|
||||
@@ -794,17 +794,19 @@ def convert_non_fncall_messages_to_fncall_messages(
|
||||
)
|
||||
|
||||
if tool_result_match:
|
||||
if not (
|
||||
isinstance(content, str)
|
||||
or (
|
||||
isinstance(content, list)
|
||||
and len(content) == 1
|
||||
and content[0].get('type') == 'text'
|
||||
)
|
||||
):
|
||||
if isinstance(content, list):
|
||||
text_content_items = [
|
||||
item for item in content if item.get('type') == 'text'
|
||||
]
|
||||
if not text_content_items:
|
||||
raise FunctionCallConversionError(
|
||||
f'Could not find text content in message with tool result. Content: {content}'
|
||||
)
|
||||
elif not isinstance(content, str):
|
||||
raise FunctionCallConversionError(
|
||||
f'Expected str or list with one text item when tool result is present in the message. Content: {content}'
|
||||
f'Unexpected content type {type(content)}. Expected str or list. Content: {content}'
|
||||
)
|
||||
|
||||
tool_name = tool_result_match.group(1)
|
||||
tool_result = tool_result_match.group(2).strip()
|
||||
|
||||
|
||||
@@ -40,6 +40,11 @@ class BaseMicroagent(BaseModel):
|
||||
derived_name = None
|
||||
if microagent_dir is not None:
|
||||
derived_name = str(path.relative_to(microagent_dir).with_suffix(''))
|
||||
else:
|
||||
derived_name = path.with_suffix('').name
|
||||
logger.warning(
|
||||
f'No microagent_dir provided. Microagent name will be the file name: {derived_name}'
|
||||
)
|
||||
|
||||
# Only load directly from path if file_content is not provided
|
||||
if file_content is None:
|
||||
@@ -95,6 +100,16 @@ class BaseMicroagent(BaseModel):
|
||||
MicroagentType.TASK: TaskMicroagent,
|
||||
}
|
||||
|
||||
# We will always use derived_name if available
|
||||
assert derived_name is not None
|
||||
agent_name = derived_name
|
||||
if metadata.name is not None:
|
||||
logger.warning(
|
||||
f'Detected `name:` field in frontmatter for microagent {metadata.name}. '
|
||||
"This is deprecated. Microagent's name will use the file name "
|
||||
f'({derived_name}) instead.'
|
||||
)
|
||||
|
||||
# Infer the agent type:
|
||||
# 1. If inputs exist -> TASK
|
||||
# 2. If triggers exist -> KNOWLEDGE
|
||||
@@ -102,8 +117,7 @@ class BaseMicroagent(BaseModel):
|
||||
inferred_type: MicroagentType
|
||||
if metadata.inputs:
|
||||
inferred_type = MicroagentType.TASK
|
||||
# Add a trigger for the agent name if not already present
|
||||
trigger = f'/{metadata.name}'
|
||||
trigger = f'/{agent_name}'
|
||||
if not metadata.triggers or trigger not in metadata.triggers:
|
||||
if not metadata.triggers:
|
||||
metadata.triggers = [trigger]
|
||||
@@ -120,9 +134,6 @@ class BaseMicroagent(BaseModel):
|
||||
# This should theoretically not happen with the logic above
|
||||
raise ValueError(f'Could not determine microagent type for: {path}')
|
||||
|
||||
# Use derived_name if available (from relative path), otherwise fallback to metadata.name
|
||||
agent_name = derived_name if derived_name is not None else metadata.name
|
||||
|
||||
agent_class = subclass_map[inferred_type]
|
||||
return agent_class(
|
||||
name=agent_name,
|
||||
|
||||
@@ -25,10 +25,12 @@ class InputMetadata(BaseModel):
|
||||
class MicroagentMetadata(BaseModel):
|
||||
"""Metadata for all microagents."""
|
||||
|
||||
name: str = 'default'
|
||||
name: str = Field(default='default', exclude=True)
|
||||
type: MicroagentType = Field(default=MicroagentType.REPO_KNOWLEDGE)
|
||||
version: str = Field(default='1.0.0')
|
||||
agent: str = Field(default='CodeActAgent')
|
||||
# Keep these fields for backward compatibility but they're not used
|
||||
version: str = Field(default='1.0.0', exclude=True)
|
||||
agent: str = Field(default='CodeActAgent', exclude=True)
|
||||
author: str = Field(default='', exclude=True)
|
||||
triggers: list[str] = [] # optional, only exists for knowledge microagents
|
||||
inputs: list[InputMetadata] = [] # optional, only exists for task microagents
|
||||
mcp_tools: MCPConfig | None = (
|
||||
|
||||
@@ -1,35 +1,31 @@
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.runtime.impl.cli.cli_runtime import CLIRuntime
|
||||
from openhands.runtime.impl.daytona.daytona_runtime import DaytonaRuntime
|
||||
from openhands.runtime.impl.docker.docker_runtime import (
|
||||
DockerRuntime,
|
||||
)
|
||||
from openhands.runtime.impl.e2b.e2b_runtime import E2BRuntime
|
||||
from openhands.runtime.impl.kubernetes.kubernetes_runtime import KubernetesRuntime
|
||||
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
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
# Conditionally import Daytona runtime if available
|
||||
try:
|
||||
from openhands.runtime.impl.daytona.daytona_runtime import DaytonaRuntime
|
||||
|
||||
_DAYTONA_AVAILABLE = True
|
||||
except ImportError:
|
||||
_DAYTONA_AVAILABLE = False
|
||||
|
||||
# mypy: disable-error-code="type-abstract"
|
||||
_DEFAULT_RUNTIME_CLASSES: dict[str, type[Runtime]] = {
|
||||
'eventstream': DockerRuntime,
|
||||
'docker': DockerRuntime,
|
||||
'e2b': E2BRuntime,
|
||||
'remote': RemoteRuntime,
|
||||
'modal': ModalRuntime,
|
||||
'runloop': RunloopRuntime,
|
||||
'local': LocalRuntime,
|
||||
'daytona': DaytonaRuntime,
|
||||
'kubernetes': KubernetesRuntime,
|
||||
'cli': CLIRuntime,
|
||||
}
|
||||
|
||||
# Add Daytona runtime if available
|
||||
if _DAYTONA_AVAILABLE:
|
||||
_DEFAULT_RUNTIME_CLASSES['daytona'] = DaytonaRuntime
|
||||
|
||||
|
||||
def get_runtime_cls(name: str) -> type[Runtime]:
|
||||
"""
|
||||
@@ -50,13 +46,13 @@ def get_runtime_cls(name: str) -> type[Runtime]:
|
||||
|
||||
__all__ = [
|
||||
'Runtime',
|
||||
'E2BRuntime',
|
||||
'RemoteRuntime',
|
||||
'ModalRuntime',
|
||||
'RunloopRuntime',
|
||||
'DockerRuntime',
|
||||
'DaytonaRuntime',
|
||||
'KubernetesRuntime',
|
||||
'CLIRuntime',
|
||||
'get_runtime_cls',
|
||||
]
|
||||
|
||||
# Add DaytonaRuntime to exports if available
|
||||
if _DAYTONA_AVAILABLE:
|
||||
__all__.append('DaytonaRuntime')
|
||||
|
||||
@@ -100,10 +100,11 @@ class Runtime(FileEditRuntimeMixin):
|
||||
|
||||
Built-in implementations include:
|
||||
- DockerRuntime: Containerized environment using Docker
|
||||
- E2BRuntime: Secure sandbox using E2B
|
||||
- RemoteRuntime: Remote execution environment
|
||||
- ModalRuntime: Scalable cloud environment using Modal
|
||||
- LocalRuntime: Local execution for development
|
||||
- KubernetesRuntime: Kubernetes-based execution environment
|
||||
- CLIRuntime: Command-line interface runtime
|
||||
- DaytonaRuntime: Cloud development environment using Daytona
|
||||
|
||||
Args:
|
||||
sid: Session ID that uniquely identifies the current user session
|
||||
|
||||
@@ -6,14 +6,22 @@ from openhands.runtime.impl.action_execution.action_execution_client import (
|
||||
ActionExecutionClient,
|
||||
)
|
||||
from openhands.runtime.impl.cli import CLIRuntime
|
||||
from openhands.runtime.impl.daytona.daytona_runtime import DaytonaRuntime
|
||||
from openhands.runtime.impl.docker.docker_runtime import DockerRuntime
|
||||
from openhands.runtime.impl.e2b.e2b_runtime import E2BRuntime
|
||||
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
|
||||
|
||||
__all__ = [
|
||||
'ActionExecutionClient',
|
||||
'CLIRuntime',
|
||||
'DaytonaRuntime',
|
||||
'DockerRuntime',
|
||||
'E2BRuntime',
|
||||
'LocalRuntime',
|
||||
'ModalRuntime',
|
||||
'RemoteRuntime',
|
||||
'RunloopRuntime',
|
||||
]
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# How to use E2B
|
||||
|
||||
[E2B](https://e2b.dev) is an [open-source](https://github.com/e2b-dev/e2b) secure cloud environment (sandbox) made for running AI-generated code and agents. E2B offers [Python](https://pypi.org/project/e2b/) and [JS/TS](https://www.npmjs.com/package/e2b) SDK to spawn and control these sandboxes.
|
||||
|
||||
## Getting started
|
||||
|
||||
1. [Get your API key](https://e2b.dev/docs/getting-started/api-key)
|
||||
|
||||
1. Set your E2B API key to the `E2B_API_KEY` env var when starting the Docker container
|
||||
|
||||
1. **Optional** - Install the CLI with NPM.
|
||||
```sh
|
||||
npm install -g @e2b/cli@latest
|
||||
```
|
||||
Full CLI API is [here](https://e2b.dev/docs/cli/installation).
|
||||
|
||||
## OpenHands sandbox
|
||||
You can use the E2B CLI to create a custom sandbox with a Dockerfile. Read the full guide [here](https://e2b.dev/docs/guide/custom-sandbox). The premade OpenHands sandbox for E2B is set up in the [`containers` directory](/containers/e2b-sandbox). and it's called `openhands`.
|
||||
|
||||
## Debugging
|
||||
You can connect to a running E2B sandbox with E2B CLI in your terminal.
|
||||
|
||||
- List all running sandboxes (based on your API key)
|
||||
```sh
|
||||
e2b sandbox list
|
||||
```
|
||||
|
||||
- Connect to a running sandbox
|
||||
```sh
|
||||
e2b sandbox connect <sandbox-id>
|
||||
```
|
||||
|
||||
## Links
|
||||
- [E2B Docs](https://e2b.dev/docs)
|
||||
- [E2B GitHub](https://github.com/e2b-dev/e2b)
|
||||
@@ -0,0 +1,78 @@
|
||||
from typing import Callable
|
||||
|
||||
from openhands.core.config import OpenHandsConfig
|
||||
from openhands.events.action import (
|
||||
FileReadAction,
|
||||
FileWriteAction,
|
||||
)
|
||||
from openhands.events.observation import (
|
||||
ErrorObservation,
|
||||
FileReadObservation,
|
||||
FileWriteObservation,
|
||||
Observation,
|
||||
)
|
||||
from openhands.events.stream import EventStream
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
|
||||
from openhands.runtime.impl.action_execution.action_execution_client import (
|
||||
ActionExecutionClient,
|
||||
)
|
||||
from openhands.runtime.impl.e2b.filestore import E2BFileStore
|
||||
from openhands.runtime.impl.e2b.sandbox import E2BSandbox
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
from openhands.runtime.utils.files import insert_lines, read_lines
|
||||
|
||||
|
||||
class E2BRuntime(ActionExecutionClient):
|
||||
def __init__(
|
||||
self,
|
||||
config: OpenHandsConfig,
|
||||
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,
|
||||
user_id: str | None = None,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
|
||||
sandbox: E2BSandbox | None = None,
|
||||
):
|
||||
super().__init__(
|
||||
config,
|
||||
event_stream,
|
||||
sid,
|
||||
plugins,
|
||||
env_vars,
|
||||
status_callback,
|
||||
attach_to_existing,
|
||||
headless_mode,
|
||||
user_id,
|
||||
git_provider_tokens,
|
||||
)
|
||||
if sandbox is None:
|
||||
self.sandbox = E2BSandbox()
|
||||
if not isinstance(self.sandbox, E2BSandbox):
|
||||
raise ValueError('E2BRuntime requires an E2BSandbox')
|
||||
self.file_store = E2BFileStore(self.sandbox.filesystem)
|
||||
|
||||
def read(self, action: FileReadAction) -> Observation:
|
||||
content = self.file_store.read(action.path)
|
||||
lines = read_lines(content.split('\n'), action.start, action.end)
|
||||
code_view = ''.join(lines)
|
||||
return FileReadObservation(code_view, path=action.path)
|
||||
|
||||
def write(self, action: FileWriteAction) -> Observation:
|
||||
if action.start == 0 and action.end == -1:
|
||||
self.file_store.write(action.path, action.content)
|
||||
return FileWriteObservation(content='', path=action.path)
|
||||
files = self.file_store.list(action.path)
|
||||
if action.path in files:
|
||||
all_lines = self.file_store.read(action.path).split('\n')
|
||||
new_file = insert_lines(
|
||||
action.content.split('\n'), all_lines, action.start, action.end
|
||||
)
|
||||
self.file_store.write(action.path, ''.join(new_file))
|
||||
return FileWriteObservation('', path=action.path)
|
||||
else:
|
||||
# FIXME: we should create a new file here
|
||||
return ErrorObservation(f'File not found: {action.path}')
|
||||
@@ -0,0 +1,27 @@
|
||||
from typing import Protocol
|
||||
|
||||
from openhands.storage.files import FileStore
|
||||
|
||||
|
||||
class SupportsFilesystemOperations(Protocol):
|
||||
def write(self, path: str, contents: str | bytes) -> None: ...
|
||||
def read(self, path: str) -> str: ...
|
||||
def list(self, path: str) -> list[str]: ...
|
||||
def delete(self, path: str) -> None: ...
|
||||
|
||||
|
||||
class E2BFileStore(FileStore):
|
||||
def __init__(self, filesystem: SupportsFilesystemOperations) -> None:
|
||||
self.filesystem = filesystem
|
||||
|
||||
def write(self, path: str, contents: str | bytes) -> None:
|
||||
self.filesystem.write(path, contents)
|
||||
|
||||
def read(self, path: str) -> str:
|
||||
return self.filesystem.read(path)
|
||||
|
||||
def list(self, path: str) -> list[str]:
|
||||
return self.filesystem.list(path)
|
||||
|
||||
def delete(self, path: str) -> None:
|
||||
self.filesystem.delete(path)
|
||||
@@ -0,0 +1,114 @@
|
||||
import copy
|
||||
import os
|
||||
import tarfile
|
||||
from glob import glob
|
||||
|
||||
from e2b import Sandbox as E2BSandbox
|
||||
from e2b.exceptions import TimeoutException
|
||||
|
||||
from openhands.core.config import SandboxConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
class E2BBox:
|
||||
closed = False
|
||||
_cwd: str = '/home/user'
|
||||
_env: dict[str, str] = {}
|
||||
is_initial_session: bool = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: SandboxConfig,
|
||||
e2b_api_key: str,
|
||||
template: str = 'openhands',
|
||||
):
|
||||
self.config = copy.deepcopy(config)
|
||||
self.initialize_plugins: bool = config.initialize_plugins
|
||||
self.sandbox = E2BSandbox(
|
||||
api_key=e2b_api_key,
|
||||
template=template,
|
||||
# It's possible to stream stdout and stderr from sandbox and from each process
|
||||
on_stderr=lambda x: logger.debug(f'E2B sandbox stderr: {x}'),
|
||||
on_stdout=lambda x: logger.debug(f'E2B sandbox stdout: {x}'),
|
||||
cwd=self._cwd, # Default workdir inside sandbox
|
||||
)
|
||||
logger.debug(f'Started E2B sandbox with ID "{self.sandbox.id}"')
|
||||
|
||||
@property
|
||||
def filesystem(self):
|
||||
return self.sandbox.filesystem
|
||||
|
||||
def _archive(self, host_src: str, recursive: bool = False):
|
||||
if recursive:
|
||||
assert os.path.isdir(host_src), (
|
||||
'Source must be a directory when recursive is True'
|
||||
)
|
||||
files = glob(host_src + '/**/*', recursive=True)
|
||||
srcname = os.path.basename(host_src)
|
||||
tar_filename = os.path.join(os.path.dirname(host_src), srcname + '.tar')
|
||||
with tarfile.open(tar_filename, mode='w') as tar:
|
||||
for file in files:
|
||||
tar.add(
|
||||
file, arcname=os.path.relpath(file, os.path.dirname(host_src))
|
||||
)
|
||||
else:
|
||||
assert os.path.isfile(host_src), (
|
||||
'Source must be a file when recursive is False'
|
||||
)
|
||||
srcname = os.path.basename(host_src)
|
||||
tar_filename = os.path.join(os.path.dirname(host_src), srcname + '.tar')
|
||||
with tarfile.open(tar_filename, mode='w') as tar:
|
||||
tar.add(host_src, arcname=srcname)
|
||||
return tar_filename
|
||||
|
||||
def execute(self, cmd: str, timeout: int | None = None) -> tuple[int, str]:
|
||||
timeout = timeout if timeout is not None else self.config.timeout
|
||||
process = self.sandbox.process.start(cmd, env_vars=self._env)
|
||||
try:
|
||||
process_output = process.wait(timeout=timeout)
|
||||
except TimeoutException:
|
||||
logger.debug('Command timed out, killing process...')
|
||||
process.kill()
|
||||
return -1, f'Command: "{cmd}" timed out'
|
||||
|
||||
logs = [m.line for m in process_output.messages]
|
||||
logs_str = '\n'.join(logs)
|
||||
if process.exit_code is None:
|
||||
return -1, logs_str
|
||||
|
||||
assert process_output.exit_code is not None
|
||||
return process_output.exit_code, logs_str
|
||||
|
||||
def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False):
|
||||
"""Copies a local file or directory to the sandbox."""
|
||||
tar_filename = self._archive(host_src, recursive)
|
||||
|
||||
# Prepend the sandbox destination with our sandbox cwd
|
||||
sandbox_dest = os.path.join(self._cwd, sandbox_dest.removeprefix('/'))
|
||||
|
||||
with open(tar_filename, 'rb') as tar_file:
|
||||
# Upload the archive to /home/user (default destination that always exists)
|
||||
uploaded_path = self.sandbox.upload_file(tar_file)
|
||||
|
||||
# Check if sandbox_dest exists. If not, create it.
|
||||
process = self.sandbox.process.start_and_wait(f'test -d {sandbox_dest}')
|
||||
if process.exit_code != 0:
|
||||
self.sandbox.filesystem.make_dir(sandbox_dest)
|
||||
|
||||
# Extract the archive into the destination and delete the archive
|
||||
process = self.sandbox.process.start_and_wait(
|
||||
f'sudo tar -xf {uploaded_path} -C {sandbox_dest} && sudo rm {uploaded_path}'
|
||||
)
|
||||
if process.exit_code != 0:
|
||||
raise Exception(
|
||||
f'Failed to extract {uploaded_path} to {sandbox_dest}: {process.stderr}'
|
||||
)
|
||||
|
||||
# Delete the local archive
|
||||
os.remove(tar_filename)
|
||||
|
||||
def close(self):
|
||||
self.sandbox.close()
|
||||
|
||||
def get_working_directory(self):
|
||||
return self.sandbox.cwd
|
||||
@@ -0,0 +1,288 @@
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import httpx
|
||||
import modal
|
||||
import tenacity
|
||||
|
||||
from openhands.core.config import OpenHandsConfig
|
||||
from openhands.events import EventStream
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
|
||||
from openhands.runtime.impl.action_execution.action_execution_client import (
|
||||
ActionExecutionClient,
|
||||
)
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
from openhands.runtime.runtime_status import RuntimeStatus
|
||||
from openhands.runtime.utils.command import get_action_execution_server_startup_command
|
||||
from openhands.runtime.utils.runtime_build import (
|
||||
BuildFromImageType,
|
||||
prep_build_folder,
|
||||
)
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.tenacity_stop import stop_if_should_exit
|
||||
|
||||
# FIXME: this will not work in HA mode. We need a better way to track IDs
|
||||
MODAL_RUNTIME_IDS: dict[str, str] = {}
|
||||
|
||||
|
||||
class ModalRuntime(ActionExecutionClient):
|
||||
"""This runtime will subscribe the event stream.
|
||||
|
||||
When receive an event, it will send the event to runtime-client which run inside the Modal sandbox environment.
|
||||
|
||||
Args:
|
||||
config (OpenHandsConfig): 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.
|
||||
"""
|
||||
|
||||
container_name_prefix = 'openhands-sandbox-'
|
||||
sandbox: modal.Sandbox | None
|
||||
sid: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: OpenHandsConfig,
|
||||
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,
|
||||
user_id: str | None = None,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
|
||||
):
|
||||
assert config.modal_api_token_id, 'Modal API token id is required'
|
||||
assert config.modal_api_token_secret, 'Modal API token secret is required'
|
||||
|
||||
self.config = config
|
||||
self.sandbox = None
|
||||
self.sid = sid
|
||||
|
||||
self.modal_client = modal.Client.from_credentials(
|
||||
config.modal_api_token_id.get_secret_value(),
|
||||
config.modal_api_token_secret.get_secret_value(),
|
||||
)
|
||||
self.app = modal.App.lookup(
|
||||
'openhands', create_if_missing=True, client=self.modal_client
|
||||
)
|
||||
|
||||
# workspace_base cannot be used because we can't bind mount into a sandbox.
|
||||
if self.config.workspace_base is not None:
|
||||
self.log(
|
||||
'warning',
|
||||
'Setting workspace_base is not supported in the modal runtime.',
|
||||
)
|
||||
|
||||
# This value is arbitrary as it's private to the container
|
||||
self.container_port = 3000
|
||||
self._vscode_port = 4445
|
||||
self._vscode_url: str | None = None
|
||||
|
||||
self.status_callback = status_callback
|
||||
self.base_container_image_id = self.config.sandbox.base_container_image
|
||||
self.runtime_container_image_id = self.config.sandbox.runtime_container_image
|
||||
|
||||
if self.config.sandbox.runtime_extra_deps:
|
||||
self.log(
|
||||
'debug',
|
||||
f'Installing extra user-provided dependencies in the runtime image: {self.config.sandbox.runtime_extra_deps}',
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
config,
|
||||
event_stream,
|
||||
sid,
|
||||
plugins,
|
||||
env_vars,
|
||||
status_callback,
|
||||
attach_to_existing,
|
||||
headless_mode,
|
||||
user_id,
|
||||
git_provider_tokens,
|
||||
)
|
||||
|
||||
async def connect(self):
|
||||
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
|
||||
|
||||
self.log('debug', f'ModalRuntime `{self.sid}`')
|
||||
|
||||
self.image = self._get_image_definition(
|
||||
self.base_container_image_id,
|
||||
self.runtime_container_image_id,
|
||||
self.config.sandbox.runtime_extra_deps,
|
||||
)
|
||||
|
||||
if self.attach_to_existing:
|
||||
if self.sid in MODAL_RUNTIME_IDS:
|
||||
sandbox_id = MODAL_RUNTIME_IDS[self.sid]
|
||||
self.log('debug', f'Attaching to existing Modal sandbox: {sandbox_id}')
|
||||
self.sandbox = modal.Sandbox.from_id(
|
||||
sandbox_id, client=self.modal_client
|
||||
)
|
||||
else:
|
||||
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
|
||||
await call_sync_from_async(
|
||||
self._init_sandbox,
|
||||
sandbox_workspace_dir=self.config.workspace_mount_path_in_sandbox,
|
||||
plugins=self.plugins,
|
||||
)
|
||||
|
||||
self.set_runtime_status(RuntimeStatus.RUNTIME_STARTED)
|
||||
|
||||
if self.sandbox is None:
|
||||
raise Exception('Sandbox not initialized')
|
||||
tunnel = self.sandbox.tunnels()[self.container_port]
|
||||
self.api_url = tunnel.url
|
||||
self.log('debug', f'Container started. Server url: {self.api_url}')
|
||||
|
||||
if not self.attach_to_existing:
|
||||
self.log('debug', 'Waiting for client to become ready...')
|
||||
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
|
||||
|
||||
self._wait_until_alive()
|
||||
self.setup_initial_env()
|
||||
|
||||
if not self.attach_to_existing:
|
||||
self.set_runtime_status(RuntimeStatus.READY)
|
||||
self._runtime_initialized = True
|
||||
|
||||
@property
|
||||
def action_execution_server_url(self):
|
||||
return self.api_url
|
||||
|
||||
@tenacity.retry(
|
||||
stop=tenacity.stop_after_delay(120) | stop_if_should_exit(),
|
||||
retry=tenacity.retry_if_exception_type((ConnectionError, httpx.NetworkError)),
|
||||
reraise=True,
|
||||
wait=tenacity.wait_fixed(2),
|
||||
)
|
||||
def _wait_until_alive(self):
|
||||
self.check_if_alive()
|
||||
|
||||
def _get_image_definition(
|
||||
self,
|
||||
base_container_image_id: str | None,
|
||||
runtime_container_image_id: str | None,
|
||||
runtime_extra_deps: str | None,
|
||||
) -> modal.Image:
|
||||
if runtime_container_image_id:
|
||||
base_runtime_image = modal.Image.from_registry(runtime_container_image_id)
|
||||
elif base_container_image_id:
|
||||
build_folder = tempfile.mkdtemp()
|
||||
prep_build_folder(
|
||||
build_folder=Path(build_folder),
|
||||
base_image=base_container_image_id,
|
||||
build_from=BuildFromImageType.SCRATCH,
|
||||
extra_deps=runtime_extra_deps,
|
||||
)
|
||||
|
||||
base_runtime_image = modal.Image.from_dockerfile(
|
||||
path=os.path.join(build_folder, 'Dockerfile'),
|
||||
context_mount=modal.Mount.from_local_dir(
|
||||
local_path=build_folder,
|
||||
remote_path='.', # to current WORKDIR
|
||||
),
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
'Neither runtime container image nor base container image is set'
|
||||
)
|
||||
|
||||
return base_runtime_image.run_commands(
|
||||
"""
|
||||
# Disable bracketed paste
|
||||
# https://github.com/pexpect/pexpect/issues/669
|
||||
echo "set enable-bracketed-paste off" >> /etc/inputrc && \\
|
||||
echo 'export INPUTRC=/etc/inputrc' >> /etc/bash.bashrc
|
||||
""".strip()
|
||||
)
|
||||
|
||||
@tenacity.retry(
|
||||
stop=tenacity.stop_after_attempt(5),
|
||||
wait=tenacity.wait_exponential(multiplier=1, min=4, max=60),
|
||||
)
|
||||
def _init_sandbox(
|
||||
self,
|
||||
sandbox_workspace_dir: str,
|
||||
plugins: list[PluginRequirement] | None = None,
|
||||
):
|
||||
try:
|
||||
self.log('debug', 'Preparing to start container...')
|
||||
# Combine environment variables
|
||||
environment: dict[str, str | None] = {
|
||||
'port': str(self.container_port),
|
||||
'PYTHONUNBUFFERED': '1',
|
||||
'VSCODE_PORT': str(self._vscode_port),
|
||||
}
|
||||
if self.config.debug:
|
||||
environment['DEBUG'] = 'true'
|
||||
|
||||
env_secret = modal.Secret.from_dict(environment)
|
||||
|
||||
self.log('debug', f'Sandbox workspace: {sandbox_workspace_dir}')
|
||||
sandbox_start_cmd = get_action_execution_server_startup_command(
|
||||
server_port=self.container_port,
|
||||
plugins=self.plugins,
|
||||
app_config=self.config,
|
||||
)
|
||||
self.log('debug', f'Starting container with command: {sandbox_start_cmd}')
|
||||
self.sandbox = modal.Sandbox.create(
|
||||
*sandbox_start_cmd,
|
||||
secrets=[env_secret],
|
||||
workdir='/openhands/code',
|
||||
encrypted_ports=[self.container_port, self._vscode_port],
|
||||
image=self.image,
|
||||
app=self.app,
|
||||
client=self.modal_client,
|
||||
timeout=60 * 60,
|
||||
)
|
||||
MODAL_RUNTIME_IDS[self.sid] = self.sandbox.object_id
|
||||
self.log('debug', 'Container started')
|
||||
|
||||
except Exception as e:
|
||||
self.log(
|
||||
'error', f'Error: Instance {self.sid} FAILED to start container!\n'
|
||||
)
|
||||
self.log('error', str(e))
|
||||
self.close()
|
||||
raise e
|
||||
|
||||
def close(self):
|
||||
"""Closes the ModalRuntime and associated objects."""
|
||||
super().close()
|
||||
|
||||
if not self.attach_to_existing and self.sandbox:
|
||||
self.sandbox.terminate()
|
||||
|
||||
@property
|
||||
def vscode_url(self) -> str | None:
|
||||
if self._vscode_url is not None: # cached value
|
||||
self.log('debug', f'VSCode URL: {self._vscode_url}')
|
||||
return self._vscode_url
|
||||
token = super().get_vscode_token()
|
||||
if not token:
|
||||
self.log('error', 'VSCode token not found')
|
||||
return None
|
||||
if not self.sandbox:
|
||||
self.log('error', 'Sandbox not initialized')
|
||||
return None
|
||||
|
||||
tunnel = self.sandbox.tunnels()[self._vscode_port]
|
||||
tunnel_url = tunnel.url
|
||||
self._vscode_url = (
|
||||
tunnel_url
|
||||
+ f'/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
|
||||
)
|
||||
|
||||
self.log(
|
||||
'debug',
|
||||
f'VSCode URL: {self._vscode_url}',
|
||||
)
|
||||
|
||||
return self._vscode_url
|
||||
@@ -0,0 +1,31 @@
|
||||
# Runloop Runtime
|
||||
Runloop provides a fast, secure and scalable AI sandbox (Devbox).
|
||||
Check out the [runloop docs](https://docs.runloop.ai/overview/what-is-runloop)
|
||||
for more detail
|
||||
|
||||
## Access
|
||||
Runloop is currently available in a closed beta. For early access, or
|
||||
just to say hello, sign up at https://www.runloop.ai/hello
|
||||
|
||||
## Set up
|
||||
With your runloop API,
|
||||
```bash
|
||||
export RUNLOOP_API_KEY=<your-api-key>
|
||||
```
|
||||
|
||||
Configure the runtime
|
||||
```bash
|
||||
export RUNTIME="runloop"
|
||||
```
|
||||
|
||||
## Interact with your devbox
|
||||
Runloop provides additional tools to interact with your Devbox based
|
||||
runtime environment. See the [docs](https://docs.runloop.ai/tools) for an up
|
||||
to date list of tools.
|
||||
|
||||
### Dashboard
|
||||
View logs, ssh into, or view your Devbox status from the [dashboard](https://platform.runloop.ai)
|
||||
|
||||
### CLI
|
||||
Use the Runloop CLI to view logs, execute commands, and more.
|
||||
See the setup instructions [here](https://docs.runloop.ai/tools/cli)
|
||||
@@ -0,0 +1,198 @@
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
import tenacity
|
||||
from runloop_api_client import Runloop
|
||||
from runloop_api_client.types import DevboxView
|
||||
from runloop_api_client.types.shared_params import LaunchParameters
|
||||
|
||||
from openhands.core.config import OpenHandsConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events import EventStream
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
|
||||
from openhands.runtime.impl.action_execution.action_execution_client import (
|
||||
ActionExecutionClient,
|
||||
)
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
from openhands.runtime.runtime_status import RuntimeStatus
|
||||
from openhands.runtime.utils.command import get_action_execution_server_startup_command
|
||||
from openhands.utils.tenacity_stop import stop_if_should_exit
|
||||
|
||||
CONTAINER_NAME_PREFIX = 'openhands-runtime-'
|
||||
|
||||
|
||||
class RunloopRuntime(ActionExecutionClient):
|
||||
"""The RunloopRuntime class is an DockerRuntime that utilizes Runloop Devbox as a runtime environment."""
|
||||
|
||||
_sandbox_port: int = 4444
|
||||
_vscode_port: int = 4445
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: OpenHandsConfig,
|
||||
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,
|
||||
user_id: str | None = None,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
|
||||
):
|
||||
assert config.runloop_api_key is not None, 'Runloop API key is required'
|
||||
self.devbox: DevboxView | None = None
|
||||
self.config = config
|
||||
self.runloop_api_client = Runloop(
|
||||
bearer_token=config.runloop_api_key.get_secret_value(),
|
||||
)
|
||||
self.container_name = CONTAINER_NAME_PREFIX + sid
|
||||
super().__init__(
|
||||
config,
|
||||
event_stream,
|
||||
sid,
|
||||
plugins,
|
||||
env_vars,
|
||||
status_callback,
|
||||
attach_to_existing,
|
||||
headless_mode,
|
||||
user_id,
|
||||
git_provider_tokens,
|
||||
)
|
||||
# Buffer for container logs
|
||||
self._vscode_url: str | None = None
|
||||
|
||||
@property
|
||||
def action_execution_server_url(self):
|
||||
return self.api_url
|
||||
|
||||
@tenacity.retry(
|
||||
stop=tenacity.stop_after_attempt(120),
|
||||
wait=tenacity.wait_fixed(1),
|
||||
)
|
||||
def _wait_for_devbox(self, devbox: DevboxView) -> DevboxView:
|
||||
"""Pull devbox status until it is running"""
|
||||
if devbox == 'running':
|
||||
return devbox
|
||||
|
||||
devbox = self.runloop_api_client.devboxes.retrieve(id=devbox.id)
|
||||
if devbox.status != 'running':
|
||||
raise ConnectionRefusedError('Devbox is not running')
|
||||
|
||||
# Devbox is connected and running
|
||||
logging.debug(f'devbox.id={devbox.id} is running')
|
||||
return devbox
|
||||
|
||||
def _create_new_devbox(self) -> DevboxView:
|
||||
# Note: Runloop connect
|
||||
start_command = get_action_execution_server_startup_command(
|
||||
server_port=self._sandbox_port,
|
||||
plugins=self.plugins,
|
||||
app_config=self.config,
|
||||
)
|
||||
|
||||
# Add some additional commands based on our image
|
||||
# NB: start off as root, action_execution_server will ultimately choose user but expects all context
|
||||
# (ie browser) to be installed as root
|
||||
# Convert start_command list to a single command string with additional setup
|
||||
start_command_str = (
|
||||
'export MAMBA_ROOT_PREFIX=/openhands/micromamba && '
|
||||
'cd /openhands/code && '
|
||||
'/openhands/micromamba/bin/micromamba run -n openhands poetry config virtualenvs.path /openhands/poetry && '
|
||||
+ ' '.join(start_command)
|
||||
)
|
||||
entrypoint = f"sudo bash -c '{start_command_str}'"
|
||||
|
||||
devbox = self.runloop_api_client.devboxes.create(
|
||||
entrypoint=entrypoint,
|
||||
name=self.sid,
|
||||
environment_variables={'DEBUG': 'true'} if self.config.debug else {},
|
||||
prebuilt='openhands',
|
||||
launch_parameters=LaunchParameters(
|
||||
available_ports=[self._sandbox_port, self._vscode_port],
|
||||
resource_size_request='LARGE',
|
||||
launch_commands=[
|
||||
f'mkdir -p {self.config.workspace_mount_path_in_sandbox}'
|
||||
],
|
||||
),
|
||||
metadata={'container-name': self.container_name},
|
||||
)
|
||||
return self._wait_for_devbox(devbox)
|
||||
|
||||
async def connect(self):
|
||||
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
|
||||
|
||||
if self.attach_to_existing:
|
||||
active_devboxes = self.runloop_api_client.devboxes.list(
|
||||
status='running'
|
||||
).devboxes
|
||||
self.devbox = next(
|
||||
(devbox for devbox in active_devboxes if devbox.name == self.sid), None
|
||||
)
|
||||
|
||||
if self.devbox is None:
|
||||
self.devbox = self._create_new_devbox()
|
||||
|
||||
# Create tunnel - this will return a stable url, so is safe to call if we are attaching to existing
|
||||
tunnel = self.runloop_api_client.devboxes.create_tunnel(
|
||||
id=self.devbox.id,
|
||||
port=self._sandbox_port,
|
||||
)
|
||||
|
||||
self.api_url = tunnel.url
|
||||
logger.info(f'Container started. Server url: {self.api_url}')
|
||||
|
||||
# End Runloop connect
|
||||
# NOTE: Copied from DockerRuntime
|
||||
logger.info('Waiting for client to become ready...')
|
||||
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
|
||||
self._wait_until_alive()
|
||||
|
||||
if not self.attach_to_existing:
|
||||
self.setup_initial_env()
|
||||
|
||||
logger.info(
|
||||
f'Container initialized with plugins: {[plugin.name for plugin in self.plugins]}'
|
||||
)
|
||||
self.set_runtime_status(RuntimeStatus.READY)
|
||||
|
||||
@tenacity.retry(
|
||||
stop=tenacity.stop_after_delay(120) | stop_if_should_exit(),
|
||||
wait=tenacity.wait_fixed(1),
|
||||
reraise=(ConnectionRefusedError,),
|
||||
)
|
||||
def _wait_until_alive(self):
|
||||
super().check_if_alive()
|
||||
|
||||
def close(self, rm_all_containers: bool | None = True):
|
||||
super().close()
|
||||
|
||||
if self.attach_to_existing:
|
||||
return
|
||||
|
||||
if self.devbox:
|
||||
self.runloop_api_client.devboxes.shutdown(self.devbox.id)
|
||||
|
||||
@property
|
||||
def vscode_url(self) -> str | None:
|
||||
if self._vscode_url is not None: # cached value
|
||||
return self._vscode_url
|
||||
token = super().get_vscode_token()
|
||||
if not token:
|
||||
return None
|
||||
if not self.devbox:
|
||||
return None
|
||||
self._vscode_url = (
|
||||
self.runloop_api_client.devboxes.create_tunnel(
|
||||
id=self.devbox.id,
|
||||
port=self._vscode_port,
|
||||
).url
|
||||
+ f'/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
|
||||
)
|
||||
|
||||
self.log(
|
||||
'debug',
|
||||
f'VSCode URL: {self._vscode_url}',
|
||||
)
|
||||
|
||||
return self._vscode_url
|
||||
@@ -72,6 +72,7 @@ async def load_settings(
|
||||
)
|
||||
settings_with_token_data.llm_api_key = None
|
||||
settings_with_token_data.search_api_key = None
|
||||
settings_with_token_data.sandbox_api_key = None
|
||||
return settings_with_token_data
|
||||
except Exception as e:
|
||||
logger.warning(f'Invalid token: {e}')
|
||||
|
||||
@@ -133,6 +133,8 @@ class Session:
|
||||
default_llm_config.api_key = settings.llm_api_key
|
||||
default_llm_config.base_url = settings.llm_base_url
|
||||
self.config.search_api_key = settings.search_api_key
|
||||
if settings.sandbox_api_key:
|
||||
self.config.sandbox.api_key = settings.sandbox_api_key.get_secret_value()
|
||||
|
||||
# NOTE: this need to happen AFTER the config is updated with the search_api_key
|
||||
self.config.mcp = settings.mcp_config or MCPConfig(
|
||||
|
||||
@@ -40,6 +40,7 @@ class Settings(BaseModel):
|
||||
sandbox_runtime_container_image: str | None = None
|
||||
mcp_config: MCPConfig | None = None
|
||||
search_api_key: SecretStr | None = None
|
||||
sandbox_api_key: SecretStr | None = None
|
||||
max_budget_per_task: float | None = None
|
||||
email: str | None = None
|
||||
email_verified: bool | None = None
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
name = "aioboto3"
|
||||
version = "14.3.0"
|
||||
description = "Async boto3 wrapper"
|
||||
optional = true
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.8"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"daytona\" or extra == \"all-runtimes\""
|
||||
files = [
|
||||
{file = "aioboto3-14.3.0-py3-none-any.whl", hash = "sha256:aec5de94e9edc1ffbdd58eead38a37f00ddac59a519db749a910c20b7b81bca7"},
|
||||
{file = "aioboto3-14.3.0.tar.gz", hash = "sha256:1d18f88bb56835c607b62bb6cb907754d717bedde3ddfff6935727cb48a80135"},
|
||||
@@ -25,10 +24,9 @@ s3cse = ["cryptography (>=44.0.1)"]
|
||||
name = "aiobotocore"
|
||||
version = "2.22.0"
|
||||
description = "Async client for aws services using botocore and aiohttp"
|
||||
optional = true
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"daytona\" or extra == \"all-runtimes\""
|
||||
files = [
|
||||
{file = "aiobotocore-2.22.0-py3-none-any.whl", hash = "sha256:b4e6306f79df9d81daff1f9d63189a2dbee4b77ce3ab937304834e35eaaeeccf"},
|
||||
{file = "aiobotocore-2.22.0.tar.gz", hash = "sha256:11091477266b75c2b5d28421c1f2bc9a87d175d0b8619cb830805e7a113a170b"},
|
||||
@@ -52,10 +50,9 @@ boto3 = ["boto3 (>=1.37.2,<1.37.4)"]
|
||||
name = "aiofiles"
|
||||
version = "24.1.0"
|
||||
description = "File support for asyncio."
|
||||
optional = true
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"daytona\" or extra == \"all-runtimes\""
|
||||
files = [
|
||||
{file = "aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5"},
|
||||
{file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"},
|
||||
@@ -185,10 +182,9 @@ speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (>
|
||||
name = "aiohttp-retry"
|
||||
version = "2.9.1"
|
||||
description = "Simple retry client for aiohttp"
|
||||
optional = true
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"daytona\" or extra == \"all-runtimes\""
|
||||
files = [
|
||||
{file = "aiohttp_retry-2.9.1-py3-none-any.whl", hash = "sha256:66d2759d1921838256a05a3f80ad7e724936f083e35be5abb5e16eed6be6dc54"},
|
||||
{file = "aiohttp_retry-2.9.1.tar.gz", hash = "sha256:8eb75e904ed4ee5c2ec242fefe85bf04240f685391c4879d8f541d6028ff01f1"},
|
||||
@@ -201,10 +197,9 @@ aiohttp = "*"
|
||||
name = "aioitertools"
|
||||
version = "0.12.0"
|
||||
description = "itertools and builtins for AsyncIO and mixed iterables"
|
||||
optional = true
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"daytona\" or extra == \"all-runtimes\""
|
||||
files = [
|
||||
{file = "aioitertools-0.12.0-py3-none-any.whl", hash = "sha256:fc1f5fac3d737354de8831cbba3eb04f79dd649d8f3afb4c5b114925e662a796"},
|
||||
{file = "aioitertools-0.12.0.tar.gz", hash = "sha256:c2a9055b4fbb7705f561b9d86053e8af5d10cc845d22c32008c43490b2d8dd6b"},
|
||||
@@ -2005,10 +2000,9 @@ vision = ["Pillow (>=9.4.0)"]
|
||||
name = "daytona"
|
||||
version = "0.21.1"
|
||||
description = "Python SDK for Daytona"
|
||||
optional = true
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"daytona\" or extra == \"all-runtimes\""
|
||||
files = [
|
||||
{file = "daytona-0.21.1-py3-none-any.whl", hash = "sha256:1ce6b352f52ef92e667098b7bdaa60c22ffbfb8e686a8cbd12418bf7698ac834"},
|
||||
{file = "daytona-0.21.1.tar.gz", hash = "sha256:01d83dd2b627f87e82491fb97f41845768d75c33f0767eaa44f6e8378bd58e60"},
|
||||
@@ -2038,10 +2032,9 @@ dev = ["black[jupyter] (>=23.1.0,<24.0.0)", "build (>=1.0.3)", "isort (>=5.10.0,
|
||||
name = "daytona-api-client"
|
||||
version = "0.21.0"
|
||||
description = "Daytona"
|
||||
optional = true
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"daytona\" or extra == \"all-runtimes\""
|
||||
files = [
|
||||
{file = "daytona_api_client-0.21.0-py3-none-any.whl", hash = "sha256:a8ff1f0fb397368dbd6ddb224c28d679e599c657eab2ec5821cf0c972a60229a"},
|
||||
{file = "daytona_api_client-0.21.0.tar.gz", hash = "sha256:92d591c5a1750a827b5850425ce483441609b72b05d35a618d5353fbbba50bca"},
|
||||
@@ -2057,10 +2050,9 @@ urllib3 = ">=1.25.3,<3.0.0"
|
||||
name = "daytona-api-client-async"
|
||||
version = "0.21.0"
|
||||
description = "Daytona"
|
||||
optional = true
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"daytona\" or extra == \"all-runtimes\""
|
||||
files = [
|
||||
{file = "daytona_api_client_async-0.21.0-py3-none-any.whl", hash = "sha256:f5731963d0dd6c1e207b92bdc7f5b59952d3365444bc9dc8b013d77a4dddf377"},
|
||||
{file = "daytona_api_client_async-0.21.0.tar.gz", hash = "sha256:08a22c0d1616f82efa8d157d7be6c432554fd43d75560725c4e0cef0228607d6"},
|
||||
@@ -2321,6 +2313,27 @@ files = [
|
||||
{file = "durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "e2b"
|
||||
version = "1.5.2"
|
||||
description = "E2B SDK that give agents cloud environments"
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "e2b-1.5.2-py3-none-any.whl", hash = "sha256:8cf755f2ff04098daa7ac778f768eee1df730a6181637fe124210345999890b3"},
|
||||
{file = "e2b-1.5.2.tar.gz", hash = "sha256:29ed891ae04ffafff1744c57eff55901200f15030d34ac3fe76d6672e2bf7845"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
attrs = ">=23.2.0"
|
||||
httpcore = ">=1.0.5,<2.0.0"
|
||||
httpx = ">=0.27.0,<1.0.0"
|
||||
packaging = ">=24.1"
|
||||
protobuf = ">=5.29.4,<6.0.0"
|
||||
python-dateutil = ">=2.8.2"
|
||||
typing-extensions = ">=4.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "english-words"
|
||||
version = "2.0.1"
|
||||
@@ -2336,10 +2349,9 @@ files = [
|
||||
name = "environs"
|
||||
version = "9.5.0"
|
||||
description = "simplified environment variable parsing"
|
||||
optional = true
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"daytona\" or extra == \"all-runtimes\""
|
||||
files = [
|
||||
{file = "environs-9.5.0-py2.py3-none-any.whl", hash = "sha256:1e549569a3de49c05f856f40bce86979e7d5ffbbc4398e7f338574c220189124"},
|
||||
{file = "environs-9.5.0.tar.gz", hash = "sha256:a76307b36fbe856bdca7ee9161e6c466fd7fcffc297109a118c59b54e27e30c9"},
|
||||
@@ -3632,7 +3644,7 @@ version = "0.4.7"
|
||||
description = "Pure-Python gRPC implementation for asyncio"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["evaluation"]
|
||||
groups = ["main", "evaluation"]
|
||||
files = [
|
||||
{file = "grpclib-0.4.7.tar.gz", hash = "sha256:2988ef57c02b22b7a2e8e961792c41ccf97efc2ace91ae7a5b0de03c363823c3"},
|
||||
]
|
||||
@@ -3693,7 +3705,7 @@ version = "4.2.0"
|
||||
description = "Pure-Python HTTP/2 protocol implementation"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["evaluation"]
|
||||
groups = ["main", "evaluation"]
|
||||
files = [
|
||||
{file = "h2-4.2.0-py3-none-any.whl", hash = "sha256:479a53ad425bb29af087f3458a61d30780bc818e4ebcf01f0b536ba916462ed0"},
|
||||
{file = "h2-4.2.0.tar.gz", hash = "sha256:c8a52129695e88b1a0578d8d2cc6842bbd79128ac685463b887ee278126ad01f"},
|
||||
@@ -3731,7 +3743,7 @@ version = "4.1.0"
|
||||
description = "Pure-Python HPACK header encoding"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["evaluation"]
|
||||
groups = ["main", "evaluation"]
|
||||
files = [
|
||||
{file = "hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496"},
|
||||
{file = "hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca"},
|
||||
@@ -3868,7 +3880,7 @@ version = "6.1.0"
|
||||
description = "Pure-Python HTTP/2 framing"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["evaluation"]
|
||||
groups = ["main", "evaluation"]
|
||||
files = [
|
||||
{file = "hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5"},
|
||||
{file = "hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08"},
|
||||
@@ -5377,7 +5389,6 @@ files = [
|
||||
{file = "marshmallow-3.26.1-py3-none-any.whl", hash = "sha256:3350409f20a70a7e4e11a27661187b77cdcaeb20abca41c1454fe33636bea09c"},
|
||||
{file = "marshmallow-3.26.1.tar.gz", hash = "sha256:e6d8affb6cb61d39d26402096dc0aee12d5a26d490a121f118d2e81dc0719dc6"},
|
||||
]
|
||||
markers = {main = "extra == \"daytona\" or extra == \"all-runtimes\""}
|
||||
|
||||
[package.dependencies]
|
||||
packaging = ">=17.0"
|
||||
@@ -5564,7 +5575,7 @@ version = "1.0.4"
|
||||
description = "Python client library for Modal"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["evaluation"]
|
||||
groups = ["main", "evaluation"]
|
||||
files = [
|
||||
{file = "modal-1.0.4-py3-none-any.whl", hash = "sha256:6c0d96bb49b09fa47e407a13e49545e32fe0803803b4330fbeb38de5e71209cc"},
|
||||
{file = "modal-1.0.4.tar.gz", hash = "sha256:09a575ff5fcae1e690b10187bea6da7ff01430c38ec1785090bf7a7ccee7f408"},
|
||||
@@ -9041,6 +9052,26 @@ files = [
|
||||
{file = "ruff-0.12.0.tar.gz", hash = "sha256:4d047db3662418d4a848a3fdbfaf17488b34b62f527ed6f10cb8afd78135bc5c"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "runloop-api-client"
|
||||
version = "0.43.0"
|
||||
description = "The official Python library for the runloop API"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "runloop_api_client-0.43.0-py3-none-any.whl", hash = "sha256:20b6098b8e0714bb48812a97d5f420f547a98748d52d90789d60a38fa37a2526"},
|
||||
{file = "runloop_api_client-0.43.0.tar.gz", hash = "sha256:879ee6a3baaabd7fd9930fe0c187de8458d138afea4f50c1e428cbf73f2ef08a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
anyio = ">=3.5.0,<5"
|
||||
distro = ">=1.7.0,<2"
|
||||
httpx = ">=0.23.0,<1"
|
||||
pydantic = ">=1.9.0,<3"
|
||||
sniffio = "*"
|
||||
typing-extensions = ">=4.10,<5"
|
||||
|
||||
[[package]]
|
||||
name = "s3transfer"
|
||||
version = "0.11.3"
|
||||
@@ -9405,7 +9436,7 @@ version = "4.0.1"
|
||||
description = "Utilities for working with inspect.Signature objects."
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
groups = ["evaluation"]
|
||||
groups = ["main", "evaluation"]
|
||||
files = [
|
||||
{file = "sigtools-4.0.1-py2.py3-none-any.whl", hash = "sha256:d216b4cf920bbab0fce636ddc429ed8463a5b533d9e1492acb45a2a1bc36ac6c"},
|
||||
{file = "sigtools-4.0.1.tar.gz", hash = "sha256:4b8e135a9cd4d2ea00da670c093372d74e672ba3abb87f4c98d8e73dea54445c"},
|
||||
@@ -9761,7 +9792,7 @@ version = "0.9.15"
|
||||
description = "Export blocking and async library versions from a single async implementation"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["evaluation"]
|
||||
groups = ["main", "evaluation"]
|
||||
files = [
|
||||
{file = "synchronicity-0.9.15-py3-none-any.whl", hash = "sha256:6e3008f54795d73d59fbd133c812734e7c83f4a6f44257cc2a3251237ee8921b"},
|
||||
{file = "synchronicity-0.9.15.tar.gz", hash = "sha256:9451d0caef3509e9f980ba62885a3b8ba7ab247845618e9d9c9c8d11da7ee84b"},
|
||||
@@ -10418,7 +10449,7 @@ version = "2021.10.8.3"
|
||||
description = "Typing stubs for certifi"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["evaluation"]
|
||||
groups = ["main", "evaluation"]
|
||||
files = [
|
||||
{file = "types-certifi-2021.10.8.3.tar.gz", hash = "sha256:72cf7798d165bc0b76e1c10dd1ea3097c7063c42c21d664523b928e88b554a4f"},
|
||||
{file = "types_certifi-2021.10.8.3-py3-none-any.whl", hash = "sha256:b2d1e325e69f71f7c78e5943d410e650b4707bb0ef32e4ddf3da37f54176e88a"},
|
||||
@@ -10737,7 +10768,7 @@ version = "1.0.5"
|
||||
description = "Simple, modern and high performance file watching and code reload in python."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["evaluation"]
|
||||
groups = ["main", "evaluation"]
|
||||
files = [
|
||||
{file = "watchfiles-1.0.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:5c40fe7dd9e5f81e0847b1ea64e1f5dd79dd61afbedb57759df06767ac719b40"},
|
||||
{file = "watchfiles-1.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8c0db396e6003d99bb2d7232c957b5f0b5634bbd1b24e381a5afcc880f7373fb"},
|
||||
@@ -11737,11 +11768,7 @@ cffi = {version = ">=1.11", markers = "platform_python_implementation == \"PyPy\
|
||||
[package.extras]
|
||||
cffi = ["cffi (>=1.11)"]
|
||||
|
||||
[extras]
|
||||
all-runtimes = ["daytona"]
|
||||
daytona = ["daytona"]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "10b6f924cad187d42cd854a8d067e61e6286cda25fbdf1058f780ae4f5e9d078"
|
||||
content-hash = "a2cf9e6529f7ed81f96c6183607aa61f99293027bbd3b4a635733f7c3c8e52cb"
|
||||
|
||||
@@ -20,12 +20,12 @@ packages = [
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.12,<3.14"
|
||||
litellm = "^1.60.0, !=1.64.4, !=1.67.*" # avoid 1.64.4 (known bug) & 1.67.* (known bug #10272)
|
||||
aiohttp = ">=3.9.0,!=3.11.13" # Pin to avoid yanked version 3.11.13
|
||||
google-generativeai = "*" # To use litellm with Gemini Pro API
|
||||
google-api-python-client = "^2.164.0" # For Google Sheets API
|
||||
google-auth-httplib2 = "*" # For Google Sheets authentication
|
||||
google-auth-oauthlib = "*" # For Google Sheets OAuth
|
||||
litellm = "^1.60.0, !=1.64.4, !=1.67.*" # avoid 1.64.4 (known bug) & 1.67.* (known bug #10272)
|
||||
aiohttp = ">=3.9.0,!=3.11.13" # Pin to avoid yanked version 3.11.13
|
||||
google-generativeai = "*" # To use litellm with Gemini Pro API
|
||||
google-api-python-client = "^2.164.0" # For Google Sheets API
|
||||
google-auth-httplib2 = "*" # For Google Sheets authentication
|
||||
google-auth-oauthlib = "*" # For Google Sheets OAuth
|
||||
termcolor = "*"
|
||||
docker = "*"
|
||||
fastapi = "*"
|
||||
@@ -34,9 +34,9 @@ types-toml = "*"
|
||||
uvicorn = "*"
|
||||
numpy = "*"
|
||||
json-repair = "*"
|
||||
browsergym-core = "0.13.3" # integrate browsergym-core as the browsing interface
|
||||
browsergym-core = "0.13.3" # integrate browsergym-core as the browsing interface
|
||||
html2text = "*"
|
||||
|
||||
e2b = ">=1.0.5,<1.6.0"
|
||||
pexpect = "*"
|
||||
jinja2 = "^3.1.3"
|
||||
python-multipart = "*"
|
||||
@@ -52,7 +52,8 @@ whatthepatch = "^1.0.6"
|
||||
protobuf = "^5.0.0,<6.0.0" # Updated to support newer opentelemetry
|
||||
opentelemetry-api = "^1.33.1"
|
||||
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
|
||||
|
||||
modal = ">=0.66.26,<1.1.0"
|
||||
runloop-api-client = "0.43.0"
|
||||
libtmux = ">=0.37,<0.40"
|
||||
pygithub = "^2.5.0"
|
||||
joblib = "*"
|
||||
@@ -79,8 +80,7 @@ bashlex = "^0.18"
|
||||
# TODO: These are integrations that should probably be optional
|
||||
redis = ">=5.2,<7.0"
|
||||
minio = "^7.2.8"
|
||||
daytona = { version = "0.21.1", optional = true }
|
||||
|
||||
daytona = "0.21.1"
|
||||
stripe = ">=11.5,<13.0"
|
||||
google-cloud-aiplatform = "*"
|
||||
anthropic = { extras = [ "vertex" ], version = "*" }
|
||||
@@ -150,10 +150,6 @@ pyarrow = "20.0.0" #
|
||||
datasets = "*"
|
||||
joblib = "*"
|
||||
|
||||
[tool.poetry.extras]
|
||||
daytona = [ "daytona" ]
|
||||
all-runtimes = [ "daytona" ]
|
||||
|
||||
[tool.poetry.scripts]
|
||||
openhands = "openhands.cli.main:main"
|
||||
|
||||
|
||||
@@ -12,17 +12,11 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events import EventStream
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.runtime.impl.cli.cli_runtime import CLIRuntime
|
||||
from openhands.runtime.impl.daytona.daytona_runtime import DaytonaRuntime
|
||||
from openhands.runtime.impl.docker.docker_runtime import DockerRuntime
|
||||
from openhands.runtime.impl.local.local_runtime import LocalRuntime
|
||||
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
|
||||
|
||||
# Conditionally import Daytona runtime if available
|
||||
try:
|
||||
from openhands.runtime.impl.daytona.daytona_runtime import DaytonaRuntime
|
||||
|
||||
_DAYTONA_AVAILABLE = True
|
||||
except ImportError:
|
||||
_DAYTONA_AVAILABLE = False
|
||||
from openhands.runtime.impl.runloop.runloop_runtime import RunloopRuntime
|
||||
from openhands.runtime.plugins import AgentSkillsRequirement, JupyterRequirement
|
||||
from openhands.storage import get_file_store
|
||||
from openhands.utils.async_utils import call_async_from_sync
|
||||
@@ -136,13 +130,10 @@ def get_runtime_classes() -> list[type[Runtime]]:
|
||||
return [LocalRuntime]
|
||||
elif runtime.lower() == 'remote':
|
||||
return [RemoteRuntime]
|
||||
elif runtime.lower() == 'runloop':
|
||||
return [RunloopRuntime]
|
||||
elif runtime.lower() == 'daytona':
|
||||
if _DAYTONA_AVAILABLE:
|
||||
return [DaytonaRuntime]
|
||||
else:
|
||||
raise ValueError(
|
||||
'Daytona runtime not available. Install with: pip install openhands-ai[daytona]'
|
||||
)
|
||||
return [DaytonaRuntime]
|
||||
elif runtime.lower() == 'cli':
|
||||
return [CLIRuntime]
|
||||
else:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Tests for microagent loading in runtime."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -20,7 +19,7 @@ from openhands.microagent.microagent import (
|
||||
RepoMicroagent,
|
||||
TaskMicroagent,
|
||||
)
|
||||
from openhands.microagent.types import MicroagentType
|
||||
from openhands.microagent.types import InputMetadata, MicroagentType
|
||||
|
||||
|
||||
def _create_test_microagents(test_dir: str):
|
||||
@@ -32,10 +31,6 @@ def _create_test_microagents(test_dir: str):
|
||||
knowledge_dir = microagents_dir / 'knowledge'
|
||||
knowledge_dir.mkdir(exist_ok=True)
|
||||
knowledge_agent = """---
|
||||
name: test_knowledge_agent
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- test
|
||||
- pytest
|
||||
@@ -45,17 +40,10 @@ triggers:
|
||||
|
||||
Testing best practices and guidelines.
|
||||
"""
|
||||
(knowledge_dir / 'knowledge.md').write_text(knowledge_agent)
|
||||
(knowledge_dir / 'test_knowledge_agent.md').write_text(knowledge_agent)
|
||||
|
||||
# Create test repo agent
|
||||
repo_agent = """---
|
||||
name: test_repo_agent
|
||||
type: repo
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
---
|
||||
|
||||
# Test Repository Agent
|
||||
repo_agent = """# Test Repository Agent
|
||||
|
||||
Repository-specific test instructions.
|
||||
"""
|
||||
@@ -89,7 +77,7 @@ def test_load_microagents_with_trailing_slashes(
|
||||
# Check knowledge agents
|
||||
assert len(knowledge_agents) == 1
|
||||
agent = knowledge_agents[0]
|
||||
assert agent.name == 'knowledge/knowledge'
|
||||
assert agent.name == 'knowledge/test_knowledge_agent'
|
||||
assert 'test' in agent.triggers
|
||||
assert 'pytest' in agent.triggers
|
||||
|
||||
@@ -126,7 +114,7 @@ def test_load_microagents_with_selected_repo(temp_dir, runtime_cls, run_as_openh
|
||||
# Check knowledge agents
|
||||
assert len(knowledge_agents) == 1
|
||||
agent = knowledge_agents[0]
|
||||
assert agent.name == 'knowledge/knowledge'
|
||||
assert agent.name == 'knowledge/test_knowledge_agent'
|
||||
assert 'test' in agent.triggers
|
||||
assert 'pytest' in agent.triggers
|
||||
|
||||
@@ -180,7 +168,7 @@ Repository-specific test instructions.
|
||||
_close_test_runtime(runtime)
|
||||
|
||||
|
||||
def test_task_microagent_creation():
|
||||
def test_task_microagent_creation(temp_dir):
|
||||
"""Test that a TaskMicroagent is created correctly."""
|
||||
content = """---
|
||||
name: test_task
|
||||
@@ -196,21 +184,43 @@ inputs:
|
||||
|
||||
This is a test task microagent with a variable: ${test_var}.
|
||||
"""
|
||||
with open(os.path.join(temp_dir, 'test_task.md'), 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix='.md') as f:
|
||||
f.write(content.encode())
|
||||
f.flush()
|
||||
agent = BaseMicroagent.load(os.path.join(temp_dir, 'test_task.md'))
|
||||
|
||||
agent = BaseMicroagent.load(f.name)
|
||||
assert isinstance(agent, TaskMicroagent)
|
||||
assert agent.type == MicroagentType.TASK
|
||||
assert agent.name == 'test_task'
|
||||
assert '/test_task' in agent.triggers
|
||||
assert "If the user didn't provide any of these variables" in agent.content
|
||||
assert agent.inputs == [InputMetadata(name='TEST_VAR', description='Test variable')]
|
||||
simplified_content = """---
|
||||
triggers:
|
||||
- /test_task
|
||||
inputs:
|
||||
- name: TEST_VAR
|
||||
description: "Test variable"
|
||||
---
|
||||
|
||||
assert isinstance(agent, TaskMicroagent)
|
||||
assert agent.type == MicroagentType.TASK
|
||||
assert agent.name == 'test_task'
|
||||
assert '/test_task' in agent.triggers
|
||||
assert "If the user didn't provide any of these variables" in agent.content
|
||||
This is a test task microagent with a variable: ${test_var}.
|
||||
"""
|
||||
|
||||
with open(os.path.join(temp_dir, 'test_task.md'), 'w') as f:
|
||||
f.write(simplified_content)
|
||||
|
||||
simplified_agent = BaseMicroagent.load(os.path.join(temp_dir, 'test_task.md'))
|
||||
|
||||
assert isinstance(simplified_agent, TaskMicroagent)
|
||||
assert simplified_agent.type == MicroagentType.TASK
|
||||
assert simplified_agent.name == 'test_task'
|
||||
assert '/test_task' in simplified_agent.triggers
|
||||
assert (
|
||||
"If the user didn't provide any of these variables" in simplified_agent.content
|
||||
)
|
||||
|
||||
|
||||
def test_task_microagent_variable_extraction():
|
||||
def test_task_microagent_variable_extraction(temp_dir):
|
||||
"""Test that variables are correctly extracted from the content."""
|
||||
content = """---
|
||||
name: test_task
|
||||
@@ -227,19 +237,18 @@ inputs:
|
||||
This is a test with variables: ${var1}, ${var2}, and ${var3}.
|
||||
"""
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix='.md') as f:
|
||||
f.write(content.encode())
|
||||
f.flush()
|
||||
with open(os.path.join(temp_dir, 'test_task.md'), 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
agent = BaseMicroagent.load(f.name)
|
||||
agent = BaseMicroagent.load(os.path.join(temp_dir, 'test_task.md'))
|
||||
|
||||
assert isinstance(agent, TaskMicroagent)
|
||||
variables = agent.extract_variables(agent.content)
|
||||
assert set(variables) == {'var1', 'var2', 'var3'}
|
||||
assert agent.requires_user_input()
|
||||
assert isinstance(agent, TaskMicroagent)
|
||||
variables = agent.extract_variables(agent.content)
|
||||
assert set(variables) == {'var1', 'var2', 'var3'}
|
||||
assert agent.requires_user_input()
|
||||
|
||||
|
||||
def test_knowledge_microagent_no_prompt():
|
||||
def test_knowledge_microagent_no_prompt(temp_dir):
|
||||
"""Test that a regular KnowledgeMicroagent doesn't get the prompt."""
|
||||
content = """---
|
||||
name: test_knowledge
|
||||
@@ -252,19 +261,17 @@ triggers:
|
||||
|
||||
This is a test knowledge microagent.
|
||||
"""
|
||||
with open(os.path.join(temp_dir, 'test_knowledge.md'), 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix='.md') as f:
|
||||
f.write(content.encode())
|
||||
f.flush()
|
||||
agent = BaseMicroagent.load(os.path.join(temp_dir, 'test_knowledge.md'))
|
||||
|
||||
agent = BaseMicroagent.load(f.name)
|
||||
|
||||
assert isinstance(agent, KnowledgeMicroagent)
|
||||
assert agent.type == MicroagentType.KNOWLEDGE
|
||||
assert "If the user didn't provide any of these variables" not in agent.content
|
||||
assert isinstance(agent, KnowledgeMicroagent)
|
||||
assert agent.type == MicroagentType.KNOWLEDGE
|
||||
assert "If the user didn't provide any of these variables" not in agent.content
|
||||
|
||||
|
||||
def test_task_microagent_trigger_addition():
|
||||
def test_task_microagent_trigger_addition(temp_dir):
|
||||
"""Test that a trigger is added if not present."""
|
||||
content = """---
|
||||
name: test_task
|
||||
@@ -278,18 +285,16 @@ inputs:
|
||||
|
||||
This is a test task microagent.
|
||||
"""
|
||||
with open(os.path.join(temp_dir, 'test_task.md'), 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix='.md') as f:
|
||||
f.write(content.encode())
|
||||
f.flush()
|
||||
agent = BaseMicroagent.load(os.path.join(temp_dir, 'test_task.md'))
|
||||
|
||||
agent = BaseMicroagent.load(f.name)
|
||||
|
||||
assert isinstance(agent, TaskMicroagent)
|
||||
assert '/test_task' in agent.triggers
|
||||
assert isinstance(agent, TaskMicroagent)
|
||||
assert '/test_task' in agent.triggers
|
||||
|
||||
|
||||
def test_task_microagent_no_duplicate_trigger():
|
||||
def test_task_microagent_no_duplicate_trigger(temp_dir):
|
||||
"""Test that a trigger is not duplicated if already present."""
|
||||
content = """---
|
||||
name: test_task
|
||||
@@ -306,21 +311,19 @@ inputs:
|
||||
|
||||
This is a test task microagent.
|
||||
"""
|
||||
with open(os.path.join(temp_dir, 'test_task.md'), 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix='.md') as f:
|
||||
f.write(content.encode())
|
||||
f.flush()
|
||||
agent = BaseMicroagent.load(os.path.join(temp_dir, 'test_task.md'))
|
||||
|
||||
agent = BaseMicroagent.load(f.name)
|
||||
|
||||
assert isinstance(agent, TaskMicroagent)
|
||||
assert agent.triggers.count('/test_task') == 1 # No duplicates
|
||||
assert len(agent.triggers) == 2
|
||||
assert 'another_trigger' in agent.triggers
|
||||
assert '/test_task' in agent.triggers
|
||||
assert isinstance(agent, TaskMicroagent)
|
||||
assert agent.triggers.count('/test_task') == 1 # No duplicates
|
||||
assert len(agent.triggers) == 2
|
||||
assert 'another_trigger' in agent.triggers
|
||||
assert '/test_task' in agent.triggers
|
||||
|
||||
|
||||
def test_task_microagent_match_trigger():
|
||||
def test_task_microagent_match_trigger(temp_dir):
|
||||
"""Test that a task microagent matches its trigger correctly."""
|
||||
content = """---
|
||||
name: test_task
|
||||
@@ -337,17 +340,16 @@ inputs:
|
||||
This is a test task microagent.
|
||||
"""
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix='.md') as f:
|
||||
f.write(content.encode())
|
||||
f.flush()
|
||||
with open(os.path.join(temp_dir, 'test_task.md'), 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
agent = BaseMicroagent.load(f.name)
|
||||
agent = BaseMicroagent.load(os.path.join(temp_dir, 'test_task.md'))
|
||||
|
||||
assert isinstance(agent, TaskMicroagent)
|
||||
assert agent.match_trigger('/test_task') == '/test_task'
|
||||
assert agent.match_trigger(' /test_task ') == '/test_task'
|
||||
assert agent.match_trigger('This contains /test_task') == '/test_task'
|
||||
assert agent.match_trigger('/other_task') is None
|
||||
assert isinstance(agent, TaskMicroagent)
|
||||
assert agent.match_trigger('/test_task') == '/test_task'
|
||||
assert agent.match_trigger(' /test_task ') == '/test_task'
|
||||
assert agent.match_trigger('This contains /test_task') == '/test_task'
|
||||
assert agent.match_trigger('/other_task') is None
|
||||
|
||||
|
||||
def test_default_tools_microagent_exists():
|
||||
@@ -369,15 +371,12 @@ def test_default_tools_microagent_exists():
|
||||
with open(default_tools_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Verify it's a repo microagent (always activated)
|
||||
assert 'type: repo' in content, 'default-tools.md should be a repo microagent'
|
||||
assert 'command: uvx' in content, 'default-tools.md should use uvx command'
|
||||
assert 'mcp-server-fetch' in content, 'default-tools.md should use mcp-server-fetch'
|
||||
|
||||
# Verify it has the fetch tool configured
|
||||
assert 'name: "fetch"' in content, 'default-tools.md should have a fetch tool'
|
||||
assert 'command: "uvx"' in content, 'default-tools.md should use uvx command'
|
||||
assert 'args: ["mcp-server-fetch"]' in content, (
|
||||
'default-tools.md should use mcp-server-fetch'
|
||||
)
|
||||
agent = BaseMicroagent.load(default_tools_path)
|
||||
|
||||
assert isinstance(agent, RepoMicroagent)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -50,6 +50,7 @@ class TestHandleCommands:
|
||||
)
|
||||
|
||||
mock_handle_exit.assert_called_once_with(
|
||||
mock_dependencies['config'],
|
||||
mock_dependencies['event_stream'],
|
||||
mock_dependencies['usage_metrics'],
|
||||
mock_dependencies['sid'],
|
||||
@@ -116,6 +117,7 @@ class TestHandleCommands:
|
||||
)
|
||||
|
||||
mock_handle_new.assert_called_once_with(
|
||||
mock_dependencies['config'],
|
||||
mock_dependencies['event_stream'],
|
||||
mock_dependencies['usage_metrics'],
|
||||
mock_dependencies['sid'],
|
||||
@@ -166,6 +168,7 @@ class TestHandleExitCommand:
|
||||
@patch('openhands.cli.commands.cli_confirm')
|
||||
@patch('openhands.cli.commands.display_shutdown_message')
|
||||
def test_exit_with_confirmation(self, mock_display_shutdown, mock_cli_confirm):
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
event_stream = MagicMock(spec=EventStream)
|
||||
usage_metrics = MagicMock(spec=UsageMetrics)
|
||||
sid = 'test-session-id'
|
||||
@@ -174,7 +177,7 @@ class TestHandleExitCommand:
|
||||
mock_cli_confirm.return_value = 0 # First option, which is "Yes, proceed"
|
||||
|
||||
# Call the function under test
|
||||
result = handle_exit_command(event_stream, usage_metrics, sid)
|
||||
result = handle_exit_command(config, event_stream, usage_metrics, sid)
|
||||
|
||||
# Verify correct behavior
|
||||
mock_cli_confirm.assert_called_once()
|
||||
@@ -191,6 +194,7 @@ class TestHandleExitCommand:
|
||||
@patch('openhands.cli.commands.cli_confirm')
|
||||
@patch('openhands.cli.commands.display_shutdown_message')
|
||||
def test_exit_without_confirmation(self, mock_display_shutdown, mock_cli_confirm):
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
event_stream = MagicMock(spec=EventStream)
|
||||
usage_metrics = MagicMock(spec=UsageMetrics)
|
||||
sid = 'test-session-id'
|
||||
@@ -199,7 +203,7 @@ class TestHandleExitCommand:
|
||||
mock_cli_confirm.return_value = 1 # Second option, which is "No, dismiss"
|
||||
|
||||
# Call the function under test
|
||||
result = handle_exit_command(event_stream, usage_metrics, sid)
|
||||
result = handle_exit_command(config, event_stream, usage_metrics, sid)
|
||||
|
||||
# Verify correct behavior
|
||||
mock_cli_confirm.assert_called_once()
|
||||
@@ -230,6 +234,7 @@ class TestHandleNewCommand:
|
||||
@patch('openhands.cli.commands.cli_confirm')
|
||||
@patch('openhands.cli.commands.display_shutdown_message')
|
||||
def test_new_with_confirmation(self, mock_display_shutdown, mock_cli_confirm):
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
event_stream = MagicMock(spec=EventStream)
|
||||
usage_metrics = MagicMock(spec=UsageMetrics)
|
||||
sid = 'test-session-id'
|
||||
@@ -238,7 +243,9 @@ class TestHandleNewCommand:
|
||||
mock_cli_confirm.return_value = 0 # First option, which is "Yes, proceed"
|
||||
|
||||
# Call the function under test
|
||||
close_repl, new_session = handle_new_command(event_stream, usage_metrics, sid)
|
||||
close_repl, new_session = handle_new_command(
|
||||
config, event_stream, usage_metrics, sid
|
||||
)
|
||||
|
||||
# Verify correct behavior
|
||||
mock_cli_confirm.assert_called_once()
|
||||
@@ -256,6 +263,7 @@ class TestHandleNewCommand:
|
||||
@patch('openhands.cli.commands.cli_confirm')
|
||||
@patch('openhands.cli.commands.display_shutdown_message')
|
||||
def test_new_without_confirmation(self, mock_display_shutdown, mock_cli_confirm):
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
event_stream = MagicMock(spec=EventStream)
|
||||
usage_metrics = MagicMock(spec=UsageMetrics)
|
||||
sid = 'test-session-id'
|
||||
@@ -264,7 +272,9 @@ class TestHandleNewCommand:
|
||||
mock_cli_confirm.return_value = 1 # Second option, which is "No, dismiss"
|
||||
|
||||
# Call the function under test
|
||||
close_repl, new_session = handle_new_command(event_stream, usage_metrics, sid)
|
||||
close_repl, new_session = handle_new_command(
|
||||
config, event_stream, usage_metrics, sid
|
||||
)
|
||||
|
||||
# Verify correct behavior
|
||||
mock_cli_confirm.assert_called_once()
|
||||
@@ -292,7 +302,7 @@ class TestHandleInitCommand:
|
||||
)
|
||||
|
||||
# Verify correct behavior
|
||||
mock_init_repository.assert_called_once_with(current_dir)
|
||||
mock_init_repository.assert_called_once_with(config, current_dir)
|
||||
event_stream.add_event.assert_called_once()
|
||||
# Check event is the right type
|
||||
args, kwargs = event_stream.add_event.call_args
|
||||
@@ -320,7 +330,7 @@ class TestHandleInitCommand:
|
||||
)
|
||||
|
||||
# Verify correct behavior
|
||||
mock_init_repository.assert_called_once_with(current_dir)
|
||||
mock_init_repository.assert_called_once_with(config, current_dir)
|
||||
event_stream.add_event.assert_not_called()
|
||||
|
||||
assert close_repl is False
|
||||
|
||||
@@ -281,7 +281,7 @@ class TestReadConfirmationInput:
|
||||
mock_session.prompt_async.return_value = 'y'
|
||||
mock_create_session.return_value = mock_session
|
||||
|
||||
result = await read_confirmation_input()
|
||||
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
|
||||
assert result == 'yes'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -291,7 +291,7 @@ class TestReadConfirmationInput:
|
||||
mock_session.prompt_async.return_value = 'yes'
|
||||
mock_create_session.return_value = mock_session
|
||||
|
||||
result = await read_confirmation_input()
|
||||
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
|
||||
assert result == 'yes'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -301,7 +301,7 @@ class TestReadConfirmationInput:
|
||||
mock_session.prompt_async.return_value = 'n'
|
||||
mock_create_session.return_value = mock_session
|
||||
|
||||
result = await read_confirmation_input()
|
||||
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
|
||||
assert result == 'no'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -311,7 +311,7 @@ class TestReadConfirmationInput:
|
||||
mock_session.prompt_async.return_value = 'no'
|
||||
mock_create_session.return_value = mock_session
|
||||
|
||||
result = await read_confirmation_input()
|
||||
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
|
||||
assert result == 'no'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -321,7 +321,7 @@ class TestReadConfirmationInput:
|
||||
mock_session.prompt_async.return_value = 'a'
|
||||
mock_create_session.return_value = mock_session
|
||||
|
||||
result = await read_confirmation_input()
|
||||
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
|
||||
assert result == 'always'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -331,7 +331,7 @@ class TestReadConfirmationInput:
|
||||
mock_session.prompt_async.return_value = 'always'
|
||||
mock_create_session.return_value = mock_session
|
||||
|
||||
result = await read_confirmation_input()
|
||||
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
|
||||
assert result == 'always'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -341,7 +341,7 @@ class TestReadConfirmationInput:
|
||||
mock_session.prompt_async.return_value = 'invalid'
|
||||
mock_create_session.return_value = mock_session
|
||||
|
||||
result = await read_confirmation_input()
|
||||
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
|
||||
assert result == 'no'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -351,7 +351,7 @@ class TestReadConfirmationInput:
|
||||
mock_session.prompt_async.return_value = ''
|
||||
mock_create_session.return_value = mock_session
|
||||
|
||||
result = await read_confirmation_input()
|
||||
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
|
||||
assert result == 'no'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -361,7 +361,7 @@ class TestReadConfirmationInput:
|
||||
mock_session.prompt_async.return_value = None
|
||||
mock_create_session.return_value = mock_session
|
||||
|
||||
result = await read_confirmation_input()
|
||||
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
|
||||
assert result == 'no'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -373,7 +373,7 @@ class TestReadConfirmationInput:
|
||||
mock_session.prompt_async.side_effect = KeyboardInterrupt
|
||||
mock_create_session.return_value = mock_session
|
||||
|
||||
result = await read_confirmation_input()
|
||||
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
|
||||
assert result == 'no'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -383,5 +383,5 @@ class TestReadConfirmationInput:
|
||||
mock_session.prompt_async.side_effect = EOFError
|
||||
mock_create_session.return_value = mock_session
|
||||
|
||||
result = await read_confirmation_input()
|
||||
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
|
||||
assert result == 'no'
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import os
|
||||
from unittest.mock import ANY, MagicMock, patch
|
||||
|
||||
from openhands.core.config import CLIConfig, OpenHandsConfig
|
||||
|
||||
|
||||
class TestCliViMode:
|
||||
"""Test the VI mode feature."""
|
||||
|
||||
@patch('openhands.cli.tui.PromptSession')
|
||||
def test_create_prompt_session_vi_mode_enabled(self, mock_prompt_session):
|
||||
"""Test that vi_mode can be enabled."""
|
||||
from openhands.cli.tui import create_prompt_session
|
||||
|
||||
config = OpenHandsConfig(cli=CLIConfig(vi_mode=True))
|
||||
create_prompt_session(config)
|
||||
mock_prompt_session.assert_called_with(
|
||||
style=ANY,
|
||||
vi_mode=True,
|
||||
)
|
||||
|
||||
@patch('openhands.cli.tui.PromptSession')
|
||||
def test_create_prompt_session_vi_mode_disabled(self, mock_prompt_session):
|
||||
"""Test that vi_mode is disabled by default."""
|
||||
from openhands.cli.tui import create_prompt_session
|
||||
|
||||
config = OpenHandsConfig(cli=CLIConfig(vi_mode=False))
|
||||
create_prompt_session(config)
|
||||
mock_prompt_session.assert_called_with(
|
||||
style=ANY,
|
||||
vi_mode=False,
|
||||
)
|
||||
|
||||
@patch('openhands.cli.tui.Application')
|
||||
def test_cli_confirm_vi_keybindings_are_added(self, mock_app_class):
|
||||
"""Test that vi keybindings are added to the KeyBindings object."""
|
||||
from openhands.cli.tui import cli_confirm
|
||||
|
||||
config = OpenHandsConfig(cli=CLIConfig(vi_mode=True))
|
||||
with patch('openhands.cli.tui.KeyBindings', MagicMock()) as mock_key_bindings:
|
||||
cli_confirm(
|
||||
config, 'Test question', choices=['Choice 1', 'Choice 2', 'Choice 3']
|
||||
)
|
||||
# here we are checking if the key bindings are being created
|
||||
assert mock_key_bindings.call_count == 1
|
||||
|
||||
# then we check that the key bindings are being added
|
||||
mock_kb_instance = mock_key_bindings.return_value
|
||||
assert mock_kb_instance.add.call_count > 0
|
||||
|
||||
@patch('openhands.cli.tui.Application')
|
||||
def test_cli_confirm_vi_keybindings_are_not_added(self, mock_app_class):
|
||||
"""Test that vi keybindings are not added when vi_mode is False."""
|
||||
from openhands.cli.tui import cli_confirm
|
||||
|
||||
config = OpenHandsConfig(cli=CLIConfig(vi_mode=False))
|
||||
with patch('openhands.cli.tui.KeyBindings', MagicMock()) as mock_key_bindings:
|
||||
cli_confirm(
|
||||
config, 'Test question', choices=['Choice 1', 'Choice 2', 'Choice 3']
|
||||
)
|
||||
# here we are checking if the key bindings are being created
|
||||
assert mock_key_bindings.call_count == 1
|
||||
|
||||
# then we check that the key bindings are being added
|
||||
mock_kb_instance = mock_key_bindings.return_value
|
||||
|
||||
# and here we check that the vi key bindings are not being added
|
||||
for call in mock_kb_instance.add.call_args_list:
|
||||
assert call[0][0] not in ('j', 'k')
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_vi_mode_disabled_by_default(self):
|
||||
"""Test that vi_mode is disabled by default when no env var is set."""
|
||||
from openhands.core.config.utils import load_from_env
|
||||
|
||||
config = OpenHandsConfig()
|
||||
load_from_env(config, os.environ)
|
||||
assert config.cli.vi_mode is False, 'vi_mode should be False by default'
|
||||
|
||||
@patch.dict(os.environ, {'CLI_VI_MODE': 'True'})
|
||||
def test_vi_mode_enabled_from_env(self):
|
||||
"""Test that vi_mode can be enabled from an environment variable."""
|
||||
from openhands.core.config.utils import load_from_env
|
||||
|
||||
config = OpenHandsConfig()
|
||||
load_from_env(config, os.environ)
|
||||
assert config.cli.vi_mode is True, (
|
||||
'vi_mode should be True when CLI_VI_MODE is set'
|
||||
)
|
||||
@@ -990,19 +990,35 @@ def test_api_keys_repr_str():
|
||||
app_config = OpenHandsConfig(
|
||||
llms={'llm': llm_config},
|
||||
agents={'agent': agent_config},
|
||||
search_api_key='my_search_api_key',
|
||||
e2b_api_key='my_e2b_api_key',
|
||||
jwt_secret='my_jwt_secret',
|
||||
modal_api_token_id='my_modal_api_token_id',
|
||||
modal_api_token_secret='my_modal_api_token_secret',
|
||||
runloop_api_key='my_runloop_api_key',
|
||||
daytona_api_key='my_daytona_api_key',
|
||||
)
|
||||
assert 'my_search_api_key' not in repr(app_config)
|
||||
assert 'my_search_api_key' not in str(app_config)
|
||||
assert 'my_e2b_api_key' not in repr(app_config)
|
||||
assert 'my_e2b_api_key' not in str(app_config)
|
||||
assert 'my_jwt_secret' not in repr(app_config)
|
||||
assert 'my_jwt_secret' not in str(app_config)
|
||||
assert 'my_modal_api_token_id' not in repr(app_config)
|
||||
assert 'my_modal_api_token_id' not in str(app_config)
|
||||
assert 'my_modal_api_token_secret' not in repr(app_config)
|
||||
assert 'my_modal_api_token_secret' not in str(app_config)
|
||||
assert 'my_runloop_api_key' not in repr(app_config)
|
||||
assert 'my_runloop_api_key' not in str(app_config)
|
||||
assert 'my_daytona_api_key' not in repr(app_config)
|
||||
assert 'my_daytona_api_key' not in str(app_config)
|
||||
|
||||
# Check that no other attrs in OpenHandsConfig have 'key' or 'token' in their name
|
||||
# This will fail when new attrs are added, and attract attention
|
||||
known_key_token_attrs_app = [
|
||||
'search_api_key',
|
||||
'e2b_api_key',
|
||||
'modal_api_token_id',
|
||||
'modal_api_token_secret',
|
||||
'runloop_api_key',
|
||||
'daytona_api_key',
|
||||
'search_api_key',
|
||||
]
|
||||
for attr_name in OpenHandsConfig.model_fields.keys():
|
||||
if (
|
||||
|
||||
@@ -84,11 +84,11 @@ def test_llm_config_attributes_masking(test_handler):
|
||||
|
||||
def test_app_config_attributes_masking(test_handler):
|
||||
logger, stream = test_handler
|
||||
app_config = OpenHandsConfig(search_api_key='search-xyz789')
|
||||
app_config = OpenHandsConfig(e2b_api_key='e2b-xyz789')
|
||||
logger.info(f'App Config: {app_config}')
|
||||
log_output = stream.getvalue()
|
||||
assert 'github_token' not in log_output
|
||||
assert 'search-xyz789' not in log_output
|
||||
assert 'e2b-xyz789' not in log_output
|
||||
assert 'ghp_abcdefghijklmnopqrstuvwxyz' not in log_output
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from openhands.microagent.types import MicroagentType
|
||||
def test_load_markdown_without_frontmatter():
|
||||
"""Test loading a markdown file without frontmatter."""
|
||||
content = '# Test Content\nThis is a test markdown file without frontmatter.'
|
||||
path = Path('test.md')
|
||||
path = Path('default.md')
|
||||
|
||||
# Load the agent from content using keyword argument
|
||||
agent = BaseMicroagent.load(path=path, file_content=content)
|
||||
@@ -26,7 +26,7 @@ def test_load_markdown_with_empty_frontmatter():
|
||||
content = (
|
||||
'---\n---\n# Test Content\nThis is a test markdown file with empty frontmatter.'
|
||||
)
|
||||
path = Path('test.md')
|
||||
path = Path('default.md')
|
||||
|
||||
# Load the agent from content using keyword argument
|
||||
agent = BaseMicroagent.load(path=path, file_content=content)
|
||||
@@ -50,12 +50,12 @@ name: custom_name
|
||||
---
|
||||
# Test Content
|
||||
This is a test markdown file with partial frontmatter."""
|
||||
path = Path('test.md')
|
||||
path = Path('custom_name.md')
|
||||
|
||||
# Load the agent from content using keyword argument
|
||||
agent = BaseMicroagent.load(path=path, file_content=content)
|
||||
|
||||
# Verify it uses provided name but default values for other fields
|
||||
# Verify it uses filename instead of provided name (filename takes precedence)
|
||||
assert isinstance(agent, RepoMicroagent)
|
||||
assert agent.name == 'custom_name'
|
||||
assert (
|
||||
@@ -77,12 +77,12 @@ version: 2.0.0
|
||||
---
|
||||
# Test Content
|
||||
This is a test markdown file with full frontmatter."""
|
||||
path = Path('test.md')
|
||||
path = Path('test_agent.md')
|
||||
|
||||
# Load the agent from content using keyword argument
|
||||
agent = BaseMicroagent.load(path=path, file_content=content)
|
||||
|
||||
# Verify all provided values are used
|
||||
# Verify filename is used for name but other metadata values are preserved
|
||||
assert isinstance(agent, RepoMicroagent)
|
||||
assert agent.name == 'test_agent'
|
||||
assert (
|
||||
|
||||