mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
31 Commits
feature/ad
...
openhands-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1b875d4aa | ||
|
|
637cb0726a | ||
|
|
2bd10de636 | ||
|
|
70322c8418 | ||
|
|
8b08958efe | ||
|
|
5b021ad1bb | ||
|
|
890796cc9d | ||
|
|
7305c8fb31 | ||
|
|
f1897b8095 | ||
|
|
c26ef180f2 | ||
|
|
37e9933092 | ||
|
|
c353fb6e7e | ||
|
|
b75bad16e4 | ||
|
|
226d1ecd9f | ||
|
|
cf9e17e85a | ||
|
|
ec22a15b6b | ||
|
|
d2466d2570 | ||
|
|
6c66e18388 | ||
|
|
74b60c4930 | ||
|
|
560901262b | ||
|
|
eec1fa9abf | ||
|
|
11bd0289e0 | ||
|
|
23edbd56b8 | ||
|
|
2141473907 | ||
|
|
a6194ea990 | ||
|
|
8c0dfdfe0a | ||
|
|
2496b8592e | ||
|
|
8bf1db8cce | ||
|
|
ce2dc26b47 | ||
|
|
8c204936ee | ||
|
|
aeba03b0e7 |
@@ -1,4 +1,4 @@
|
||||
# Local LLM with SGLang or vLLM
|
||||
# Local LLMs
|
||||
|
||||
:::warning
|
||||
When using a Local LLM, OpenHands may have limited functionality.
|
||||
@@ -7,10 +7,91 @@ It is highly recommended that you use GPUs to serve local models for optimal exp
|
||||
|
||||
## 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
|
||||
([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)).
|
||||
|
||||
## Download the Model from Huggingface
|
||||
|
||||
## Quickstart: Running OpenHands on Your Macbook
|
||||
|
||||
### Serve the model on your Macbook
|
||||
|
||||
We recommend using [LMStudio](https://lmstudio.ai/) for serving these models locally.
|
||||
|
||||
1. Download [LM Studio](https://lmstudio.ai/) and install it
|
||||
|
||||
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`)
|
||||
|
||||
3. Open LM Studio application, you should first switch to `power user` mode, and then open the developer tab:
|
||||
|
||||

|
||||
|
||||
4. Then click `Select a model to load` on top of the application:
|
||||
|
||||

|
||||
|
||||
5. And choose the model you want to use, holding `option` on mac to enable advanced loading options:
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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):
|
||||
|
||||

|
||||
|
||||
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):
|
||||
|
||||

|
||||
|
||||
### Start OpenHands with locally served model
|
||||
|
||||
Check [the installation guide](https://docs.all-hands.dev/modules/usage/installation) to make sure you have all the prerequisites for running OpenHands.
|
||||
|
||||
```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.39-nikolaik
|
||||
|
||||
mkdir -p ~/.openhands-state && 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-state/settings.json
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.39-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.39
|
||||
```
|
||||
|
||||
Once your server is running -- you can visit `http://localhost:3000` in your browser to use OpenHands with local Devstral model:
|
||||
```
|
||||
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.39
|
||||
Starting OpenHands...
|
||||
Running OpenHands as root
|
||||
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
|
||||
INFO: Started server process [8]
|
||||
INFO: Waiting for application startup.
|
||||
INFO: Application startup complete.
|
||||
INFO: Uvicorn running on http://0.0.0.0:3000 (Press CTRL+C to quit)
|
||||
```
|
||||
|
||||
|
||||
## Advanced: Serving LLM on GPUs
|
||||
|
||||
### Download model checkpoints
|
||||
|
||||
:::note
|
||||
The model checkpoints downloaded here should NOT be in GGUF format.
|
||||
:::
|
||||
|
||||
For example, to download [OpenHands LM 32B v0.1](https://huggingface.co/all-hands/openhands-lm-32b-v0.1):
|
||||
|
||||
@@ -18,9 +99,7 @@ For example, to download [OpenHands LM 32B v0.1](https://huggingface.co/all-hand
|
||||
huggingface-cli download all-hands/openhands-lm-32b-v0.1 --local-dir all-hands/openhands-lm-32b-v0.1
|
||||
```
|
||||
|
||||
## Create an OpenAI-Compatible Endpoint With a Model Serving Framework
|
||||
|
||||
### Serving with SGLang
|
||||
### Create an OpenAI-Compatible Endpoint With 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):
|
||||
@@ -35,7 +114,7 @@ SGLANG_ALLOW_OVERWRITE_LONGER_CONTEXT_LEN=1 python3 -m sglang.launch_server \
|
||||
--api-key mykey --context-length 131072
|
||||
```
|
||||
|
||||
### Serving with vLLM
|
||||
### Create an OpenAI-Compatible Endpoint with 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):
|
||||
@@ -49,7 +128,7 @@ vllm serve all-hands/openhands-lm-32b-v0.1 \
|
||||
--enable-prefix-caching
|
||||
```
|
||||
|
||||
## Run and Configure OpenHands
|
||||
## Advanced: Run and Configure OpenHands
|
||||
|
||||
### Run OpenHands
|
||||
|
||||
|
||||
BIN
docs/modules/usage/llms/screenshots/1_select_power_user.png
Normal file
BIN
docs/modules/usage/llms/screenshots/1_select_power_user.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
BIN
docs/modules/usage/llms/screenshots/2_select_model.png
Normal file
BIN
docs/modules/usage/llms/screenshots/2_select_model.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 420 KiB |
BIN
docs/modules/usage/llms/screenshots/3_select_devstral.png
Normal file
BIN
docs/modules/usage/llms/screenshots/3_select_devstral.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
BIN
docs/modules/usage/llms/screenshots/4_set_context_window.png
Normal file
BIN
docs/modules/usage/llms/screenshots/4_set_context_window.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 558 KiB |
BIN
docs/modules/usage/llms/screenshots/5_copy_url.png
Normal file
BIN
docs/modules/usage/llms/screenshots/5_copy_url.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 646 KiB |
BIN
docs/modules/usage/llms/screenshots/6_copy_to_get_model_name.png
Normal file
BIN
docs/modules/usage/llms/screenshots/6_copy_to_get_model_name.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
@@ -42,6 +42,37 @@ api_key = "XXX"
|
||||
temperature = 0.0
|
||||
```
|
||||
|
||||
### Configuring Condensers for Evaluation
|
||||
|
||||
For benchmarks that support condenser configuration (like SWE-Bench), you can define multiple condenser configurations in your `config.toml` file. A condenser is responsible for managing conversation history to maintain context while staying within token limits - you can learn more about how it works [here](https://www.all-hands.dev/blog/openhands-context-condensensation-for-more-efficient-ai-agents):
|
||||
|
||||
```toml
|
||||
# LLM-based summarizing condenser for evaluation
|
||||
[condenser.summarizer_for_eval]
|
||||
type = "llm"
|
||||
llm_config = "haiku" # Reference to an LLM config to use for summarization
|
||||
keep_first = 2 # Number of initial events to always keep
|
||||
max_size = 100 # Maximum size of history before triggering summarization
|
||||
|
||||
# Recent events condenser for evaluation
|
||||
[condenser.recent_for_eval]
|
||||
type = "recent"
|
||||
keep_first = 2 # Number of initial events to always keep
|
||||
max_events = 50 # Maximum number of events to keep in history
|
||||
```
|
||||
|
||||
You can then specify which condenser configuration to use when running evaluation scripts, for example:
|
||||
|
||||
```bash
|
||||
EVAL_CONDENSER=summarizer_for_eval \
|
||||
./evaluation/benchmarks/swe_bench/scripts/run_infer.sh llm.eval_gpt4_1106_preview HEAD CodeActAgent 500 100 1 princeton-nlp/SWE-bench_Verified test
|
||||
```
|
||||
|
||||
The name is up to you, but should match a name defined in your `config.toml` file. The last argument in the command specifies the condenser configuration to use. In this case, `summarizer_for_eval` is used, which refers to the LLM-based summarizing condenser as defined above.
|
||||
|
||||
If no condenser configuration is specified, the 'noop' condenser will be used by default, which keeps the full conversation history.
|
||||
```
|
||||
|
||||
For other configurations specific to evaluation, such as `save_trajectory_path`, these are typically set in the `get_config` function of the respective `run_infer.py` file for each benchmark.
|
||||
|
||||
## Supported Benchmarks
|
||||
|
||||
@@ -45,7 +45,7 @@ For example, for instance ID `django_django-11011`, it will try to pull our pre-
|
||||
This image will be used create an OpenHands runtime image where the agent will operate on.
|
||||
|
||||
```bash
|
||||
./evaluation/benchmarks/swe_bench/scripts/run_infer.sh [model_config] [git-version] [agent] [eval_limit] [max_iter] [num_workers] [dataset] [dataset_split]
|
||||
./evaluation/benchmarks/swe_bench/scripts/run_infer.sh [model_config] [git-version] [agent] [eval_limit] [max_iter] [num_workers] [dataset] [dataset_split] [n_runs] [mode]
|
||||
|
||||
# Example
|
||||
./evaluation/benchmarks/swe_bench/scripts/run_infer.sh llm.eval_gpt4_1106_preview HEAD CodeActAgent 500 100 1 princeton-nlp/SWE-bench_Verified test
|
||||
@@ -69,13 +69,20 @@ default, it is set to 1.
|
||||
- `dataset`, a huggingface dataset name. e.g. `princeton-nlp/SWE-bench`, `princeton-nlp/SWE-bench_Lite`, `princeton-nlp/SWE-bench_Verified`, or `princeton-nlp/SWE-bench_Multimodal`, specifies which dataset to evaluate on.
|
||||
- `dataset_split`, split for the huggingface dataset. e.g., `test`, `dev`. Default to `test`.
|
||||
|
||||
- `n_runs`, e.g. `3`, is the number of times to run the evaluation. Default is 1.
|
||||
- `mode`, e.g. `swt`, `swt-ci`, or `swe`, specifies the evaluation mode. Default is `swe`.
|
||||
|
||||
> [!CAUTION]
|
||||
> Setting `num_workers` larger than 1 is not officially tested, YMMV.
|
||||
|
||||
There is also one optional environment variable you can set.
|
||||
There are also optional environment variables you can set:
|
||||
|
||||
```bash
|
||||
export USE_HINT_TEXT=true # if you want to use hint text in the evaluation. Default to false. Ignore this if you are not sure.
|
||||
# Use hint text in the evaluation (default: false)
|
||||
export USE_HINT_TEXT=true # Ignore this if you are not sure.
|
||||
|
||||
# Specify a condenser configuration for memory management (default: NoOpCondenser)
|
||||
export EVAL_CONDENSER=summarizer_for_eval # Name of the condenser config group in config.toml
|
||||
```
|
||||
|
||||
Let's say you'd like to run 10 instances using `llm.eval_gpt4_1106_preview` and CodeActAgent,
|
||||
|
||||
@@ -44,6 +44,8 @@ from openhands.core.config import (
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.config.utils import get_condenser_config_arg
|
||||
from openhands.core.config.condenser_config import NoOpCondenserConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
from openhands.critic import AgentFinishedCritic
|
||||
@@ -756,6 +758,7 @@ if __name__ == '__main__':
|
||||
choices=['swe', 'swt', 'swt-ci'],
|
||||
help="mode to run the evaluation, either 'swe', 'swt', or 'swt-ci'",
|
||||
)
|
||||
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
# NOTE: It is preferable to load datasets from huggingface datasets and perform post-processing
|
||||
@@ -792,6 +795,19 @@ if __name__ == '__main__':
|
||||
if llm_config is None:
|
||||
raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
|
||||
|
||||
# Get condenser config from environment variable
|
||||
condenser_name = os.environ.get('EVAL_CONDENSER')
|
||||
if condenser_name:
|
||||
condenser_config = get_condenser_config_arg(condenser_name)
|
||||
if condenser_config is None:
|
||||
raise ValueError(
|
||||
f'Could not find Condenser config: EVAL_CONDENSER={condenser_name}'
|
||||
)
|
||||
else:
|
||||
# If no specific condenser config is provided via env var, default to NoOpCondenser
|
||||
condenser_config = NoOpCondenserConfig()
|
||||
logger.debug('No Condenser config provided via EVAL_CONDENSER, using NoOpCondenser.')
|
||||
|
||||
details = {'mode': args.mode}
|
||||
_agent_cls = openhands.agenthub.Agent.get_cls(args.agent_cls)
|
||||
|
||||
@@ -806,6 +822,7 @@ if __name__ == '__main__':
|
||||
args.eval_note,
|
||||
args.eval_output_dir,
|
||||
details=details,
|
||||
condenser_config=condenser_config,
|
||||
)
|
||||
|
||||
output_file = os.path.join(metadata.eval_output_dir, 'output.jsonl')
|
||||
|
||||
@@ -14,6 +14,7 @@ SPLIT=$8
|
||||
N_RUNS=$9
|
||||
MODE=${10}
|
||||
|
||||
|
||||
if [ -z "$NUM_WORKERS" ]; then
|
||||
NUM_WORKERS=1
|
||||
echo "Number of workers not specified, use default $NUM_WORKERS"
|
||||
@@ -51,6 +52,12 @@ if [ -z "$MODE" ]; then
|
||||
echo "MODE not specified, use default $MODE"
|
||||
fi
|
||||
|
||||
if [ -n "$EVAL_CONDENSER" ]; then
|
||||
echo "Using Condenser Config: $EVAL_CONDENSER"
|
||||
else
|
||||
echo "No Condenser Config provided via EVAL_CONDENSER, use default (NoOpCondenser)."
|
||||
fi
|
||||
|
||||
export RUN_WITH_BROWSING=$RUN_WITH_BROWSING
|
||||
echo "RUN_WITH_BROWSING: $RUN_WITH_BROWSING"
|
||||
|
||||
@@ -65,6 +72,7 @@ echo "MAX_ITER: $MAX_ITER"
|
||||
echo "NUM_WORKERS: $NUM_WORKERS"
|
||||
echo "COMMIT_HASH: $COMMIT_HASH"
|
||||
echo "MODE: $MODE"
|
||||
echo "EVAL_CONDENSER: $EVAL_CONDENSER"
|
||||
|
||||
# Default to NOT use Hint
|
||||
if [ -z "$USE_HINT_TEXT" ]; then
|
||||
@@ -88,6 +96,10 @@ fi
|
||||
if [ "$MODE" != "swe" ]; then
|
||||
EVAL_NOTE="${EVAL_NOTE}-${MODE}"
|
||||
fi
|
||||
# Add condenser config to eval note if provided
|
||||
if [ -n "$EVAL_CONDENSER" ]; then
|
||||
EVAL_NOTE="${EVAL_NOTE}-${EVAL_CONDENSER}"
|
||||
fi
|
||||
|
||||
function run_eval() {
|
||||
local eval_note="${1}"
|
||||
@@ -101,6 +113,8 @@ function run_eval() {
|
||||
--split $SPLIT \
|
||||
--mode $MODE"
|
||||
|
||||
|
||||
|
||||
if [ -n "$EVAL_LIMIT" ]; then
|
||||
echo "EVAL_LIMIT: $EVAL_LIMIT"
|
||||
COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT"
|
||||
|
||||
969
frontend/package-lock.json
generated
969
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@react-router/node": "^7.6.0",
|
||||
"@react-router/serve": "^7.6.0",
|
||||
"@react-types/shared": "^3.29.0",
|
||||
"@react-types/shared": "^3.29.1",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@stripe/react-stripe-js": "^3.7.0",
|
||||
"@stripe/stripe-js": "^7.3.0",
|
||||
@@ -31,7 +31,7 @@
|
||||
"jose": "^6.0.11",
|
||||
"lucide-react": "^0.511.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.242.2",
|
||||
"posthog-js": "^1.245.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -90,15 +90,15 @@
|
||||
"@testing-library/jest-dom": "^6.6.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^22.15.18",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/node": "^22.15.21",
|
||||
"@types/react": "^19.1.5",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitest/coverage-v8": "^3.1.3",
|
||||
"@vitest/coverage-v8": "^3.1.4",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.57.0",
|
||||
@@ -117,7 +117,7 @@
|
||||
"msw": "^2.6.6",
|
||||
"postcss": "^8.5.2",
|
||||
"prettier": "^3.5.3",
|
||||
"stripe": "^18.1.0",
|
||||
"stripe": "^18.1.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.8.3",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
|
||||
@@ -261,6 +261,11 @@ export function WsClientProvider({
|
||||
}, [conversationId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// reset events when conversationId changes
|
||||
setEvents([]);
|
||||
setParsedEvents([]);
|
||||
setStatus(WsClientProviderStatus.DISCONNECTED);
|
||||
|
||||
if (!conversationId) {
|
||||
throw new Error("No conversation ID provided");
|
||||
}
|
||||
|
||||
@@ -14,13 +14,15 @@ the GitHub API.
|
||||
You can use `curl` with the `GITHUB_TOKEN` to interact with GitHub's API.
|
||||
ALWAYS use the GitHub API for operations instead of a web browser.
|
||||
|
||||
To open a pull request, always use the `create_pr` tool
|
||||
|
||||
If you encounter authentication issues when pushing to GitHub (such as password prompts or permission errors), the old token may have expired. In such case, update the remote URL to include the current token: `git remote set-url origin https://${GITHUB_TOKEN}@github.com/username/repo.git`
|
||||
|
||||
Here are some instructions for pushing, but ONLY do this if the user asks you to:
|
||||
* NEVER push directly to the `main` or `master` branch
|
||||
* Git config (username and email) is pre-set. Do not modify.
|
||||
* You may already be on a branch starting with `openhands-workspace`. Create a new branch with a better name before pushing.
|
||||
* Use the GitHub API to create a pull request, if you haven't already
|
||||
* Use the `create_pr` tool to create a pull request, if you haven't already
|
||||
* Once you've created your own branch or a pull request, continue to update it. Do NOT create a new one unless you are explicitly asked to. Update the PR title and description as necessary, but don't change the branch name.
|
||||
* Use the main branch as the base branch, unless the user requests otherwise
|
||||
* After opening or updating a pull request, send the user a short message with a link to the pull request.
|
||||
@@ -30,7 +32,5 @@ Here are some instructions for pushing, but ONLY do this if the user asks you to
|
||||
```bash
|
||||
git remote -v && git branch # to find the current org, repo and branch
|
||||
git checkout -b create-widget && git add . && git commit -m "Create widget" && git push -u origin create-widget
|
||||
curl -X POST "https://api.github.com/repos/$ORG_NAME/$REPO_NAME/pulls" \
|
||||
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
-d '{"title":"Create widget","head":"create-widget","base":"openhands-workspace"}'
|
||||
# Then use the MCP tool to create the PR instead of directly using the GitHub API
|
||||
```
|
||||
|
||||
@@ -14,13 +14,15 @@ the GitLab API.
|
||||
You can use `curl` with the `GITLAB_TOKEN` to interact with GitLab's API.
|
||||
ALWAYS use the GitLab API for operations instead of a web browser.
|
||||
|
||||
To open a merge request, always use the `create_mr` tool
|
||||
|
||||
If you encounter authentication issues when pushing to GitLab (such as password prompts or permission errors), the old token may have expired. In such case, update the remote URL to include the current token: `git remote set-url origin https://oauth2:${GITLAB_TOKEN}@gitlab.com/username/repo.git`
|
||||
|
||||
Here are some instructions for pushing, but ONLY do this if the user asks you to:
|
||||
* NEVER push directly to the `main` or `master` branch
|
||||
* Git config (username and email) is pre-set. Do not modify.
|
||||
* You may already be on a branch starting with `openhands-workspace`. Create a new branch with a better name before pushing.
|
||||
* Use the GitLab API to create a merge request, if you haven't already
|
||||
* Use the `create_mr` tool to create a merge request, if you haven't already
|
||||
* Once you've created your own branch or a merge request, continue to update it. Do NOT create a new one unless you are explicitly asked to. Update the PR title and description as necessary, but don't change the branch name.
|
||||
* Use the main branch as the base branch, unless the user requests otherwise
|
||||
* After opening or updating a merge request, send the user a short message with a link to the merge request.
|
||||
@@ -29,7 +31,5 @@ Here are some instructions for pushing, but ONLY do this if the user asks you to
|
||||
```bash
|
||||
git remote -v && git branch # to find the current org, repo and branch
|
||||
git checkout -b create-widget && git add . && git commit -m "Create widget" && git push -u origin create-widget
|
||||
curl -X POST "https://gitlab.com/api/v4/projects/$PROJECT_ID/merge_requests" \
|
||||
-H "Authorization: Bearer $GITLAB_TOKEN" \
|
||||
-d '{"source_branch": "create-widget", "target_branch": "openhands-workspace", "title": "Create widget"}'
|
||||
# Then use the MCP tool to create the MR instead of directly using the GitLab API
|
||||
```
|
||||
|
||||
@@ -10,6 +10,7 @@ if TYPE_CHECKING:
|
||||
from openhands.events.action import Action
|
||||
from openhands.llm.llm import ModelResponse
|
||||
|
||||
from openhands.llm.llm_utils import check_tools
|
||||
import openhands.agenthub.codeact_agent.function_calling as codeact_function_calling
|
||||
from openhands.agenthub.codeact_agent.tools.bash import create_cmd_run_tool
|
||||
from openhands.agenthub.codeact_agent.tools.browser import BrowserTool
|
||||
@@ -185,26 +186,7 @@ class CodeActAgent(Agent):
|
||||
params: dict = {
|
||||
'messages': self.llm.format_messages_for_llm(messages),
|
||||
}
|
||||
params['tools'] = self.tools
|
||||
|
||||
# Special handling for Gemini model which doesn't support default fields
|
||||
if self.llm.config.model == 'gemini-2.5-pro-preview-03-25':
|
||||
logger.info(
|
||||
f'Removing the default fields from tools for {self.llm.config.model} '
|
||||
"since it doesn't support them and the request would crash."
|
||||
)
|
||||
# prevent mutation of input tools
|
||||
params['tools'] = copy.deepcopy(params['tools'])
|
||||
# Strip off default fields that cause errors with gemini-preview
|
||||
for tool in params['tools']:
|
||||
if 'function' in tool and 'parameters' in tool['function']:
|
||||
if 'properties' in tool['function']['parameters']:
|
||||
for prop_name, prop in tool['function']['parameters'][
|
||||
'properties'
|
||||
].items():
|
||||
if 'default' in prop:
|
||||
del prop['default']
|
||||
# log to litellm proxy if possible
|
||||
params['tools'] = check_tools(self.tools, self.llm.config)
|
||||
params['extra_body'] = {'metadata': state.to_llm_metadata(agent_name=self.name)}
|
||||
response = self.llm.completion(**params)
|
||||
logger.debug(f'Response from LLM: {response}')
|
||||
|
||||
@@ -48,6 +48,7 @@ class AppConfig(BaseModel):
|
||||
file_uploads_allowed_extensions: Allowed file extensions. `['.*']` allows all.
|
||||
cli_multiline_input: Whether to enable multiline input in CLI. When disabled,
|
||||
input is read line by line. When enabled, input continues until /exit command.
|
||||
mcp_host: Host for OpenHands' default MCP server
|
||||
mcp: MCP configuration settings.
|
||||
"""
|
||||
|
||||
@@ -92,6 +93,7 @@ class AppConfig(BaseModel):
|
||||
max_concurrent_conversations: int = Field(
|
||||
default=3
|
||||
) # Maximum number of concurrent agent loops allowed per user
|
||||
mcp_host: str = Field(default='localhost:3000')
|
||||
mcp: MCPConfig = Field(default_factory=MCPConfig)
|
||||
|
||||
defaults_dict: ClassVar[dict] = {}
|
||||
@@ -141,5 +143,6 @@ class AppConfig(BaseModel):
|
||||
def model_post_init(self, __context: Any) -> None:
|
||||
"""Post-initialization hook, called when the instance is created with only default values."""
|
||||
super().model_post_init(__context)
|
||||
|
||||
if not AppConfig.defaults_dict: # Only set defaults_dict if it's empty
|
||||
AppConfig.defaults_dict = model_defaults_to_dict(self)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError, model_validator
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
|
||||
class MCPSSEServerConfig(BaseModel):
|
||||
@@ -120,3 +122,31 @@ class MCPConfig(BaseModel):
|
||||
except ValidationError as e:
|
||||
raise ValueError(f'Invalid MCP configuration: {e}')
|
||||
return mcp_mapping
|
||||
|
||||
|
||||
|
||||
class OpenHandsMCPConfig:
|
||||
@staticmethod
|
||||
def create_default_mcp_server_config(host: str, user_id: str | None = None) -> MCPSSEServerConfig | None:
|
||||
"""
|
||||
Create a default MCP server configuration.
|
||||
|
||||
Args:
|
||||
host: Host string
|
||||
|
||||
Returns:
|
||||
MCPSSEServerConfig: A default SSE server configuration
|
||||
"""
|
||||
|
||||
return MCPSSEServerConfig(url=f'http://{host}/mcp/sse', api_key=None)
|
||||
|
||||
|
||||
|
||||
openhands_mcp_config_cls = os.environ.get(
|
||||
'OPENHANDS_MCP_CONFIG_CLS',
|
||||
'openhands.core.config.mcp_config.OpenHandsMCPConfig',
|
||||
)
|
||||
|
||||
OpenHandsMCPConfigImpl = get_impl(
|
||||
OpenHandsMCPConfig, openhands_mcp_config_cls
|
||||
)
|
||||
@@ -16,7 +16,11 @@ from openhands import __version__
|
||||
from openhands.core import logger
|
||||
from openhands.core.config.agent_config import AgentConfig
|
||||
from openhands.core.config.app_config import AppConfig
|
||||
from openhands.core.config.condenser_config import condenser_config_from_toml_section
|
||||
from openhands.core.config.condenser_config import (
|
||||
CondenserConfig,
|
||||
condenser_config_from_toml_section,
|
||||
create_condenser_config,
|
||||
)
|
||||
from openhands.core.config.config_utils import (
|
||||
OH_DEFAULT_AGENT,
|
||||
OH_MAX_ITERATIONS,
|
||||
@@ -491,6 +495,118 @@ def get_llm_config_arg(
|
||||
return None
|
||||
|
||||
|
||||
def get_condenser_config_arg(
|
||||
condenser_config_arg: str, toml_file: str = 'config.toml'
|
||||
) -> CondenserConfig | None:
|
||||
"""Get a group of condenser settings from the config file by name.
|
||||
|
||||
A group in config.toml can look like this:
|
||||
|
||||
```
|
||||
[condenser.my_summarizer]
|
||||
type = 'llm'
|
||||
llm_config = 'gpt-4o' # References [llm.gpt-4o]
|
||||
max_size = 50
|
||||
...
|
||||
```
|
||||
|
||||
The user-defined group name, like "my_summarizer", is the argument to this function.
|
||||
The function will load the CondenserConfig object with the settings of this group,
|
||||
from the config file.
|
||||
|
||||
Note that the group must be under the "condenser" group, or in other words,
|
||||
the group name must start with "condenser.".
|
||||
|
||||
Args:
|
||||
condenser_config_arg: The group of condenser settings to get from the config.toml file.
|
||||
toml_file: Path to the configuration file to read from. Defaults to 'config.toml'.
|
||||
|
||||
Returns:
|
||||
CondenserConfig: The CondenserConfig object with the settings from the config file, or None if not found/error.
|
||||
"""
|
||||
# keep only the name, just in case
|
||||
condenser_config_arg = condenser_config_arg.strip('[]')
|
||||
|
||||
# truncate the prefix, just in case
|
||||
if condenser_config_arg.startswith('condenser.'):
|
||||
condenser_config_arg = condenser_config_arg[10:]
|
||||
|
||||
logger.openhands_logger.debug(
|
||||
f'Loading condenser config [{condenser_config_arg}] from {toml_file}'
|
||||
)
|
||||
|
||||
# load the toml file
|
||||
try:
|
||||
with open(toml_file, 'r', encoding='utf-8') as toml_contents:
|
||||
toml_config = toml.load(toml_contents)
|
||||
except FileNotFoundError as e:
|
||||
logger.openhands_logger.error(f'Config file not found: {toml_file}. Error: {e}')
|
||||
return None
|
||||
except toml.TomlDecodeError as e:
|
||||
logger.openhands_logger.error(
|
||||
f'Cannot parse condenser group [{condenser_config_arg}] from {toml_file}. Exception: {e}'
|
||||
)
|
||||
return None
|
||||
|
||||
# Check if the condenser section and the specific config exist
|
||||
if (
|
||||
'condenser' not in toml_config
|
||||
or condenser_config_arg not in toml_config['condenser']
|
||||
):
|
||||
logger.openhands_logger.error(
|
||||
f'Condenser config section [condenser.{condenser_config_arg}] not found in {toml_file}'
|
||||
)
|
||||
return None
|
||||
|
||||
condenser_data = toml_config['condenser'][
|
||||
condenser_config_arg
|
||||
].copy() # Use copy to modify
|
||||
|
||||
# Determine the type and handle potential LLM dependency
|
||||
condenser_type = condenser_data.get('type')
|
||||
if not condenser_type:
|
||||
logger.openhands_logger.error(
|
||||
f'Missing "type" field in [condenser.{condenser_config_arg}] section of {toml_file}'
|
||||
)
|
||||
return None
|
||||
|
||||
# Handle LLM config reference if needed, using get_llm_config_arg
|
||||
if (
|
||||
condenser_type in ('llm', 'llm_attention', 'structured')
|
||||
and 'llm_config' in condenser_data
|
||||
and isinstance(condenser_data['llm_config'], str)
|
||||
):
|
||||
llm_config_name = condenser_data['llm_config']
|
||||
logger.openhands_logger.debug(
|
||||
f'Condenser [{condenser_config_arg}] requires LLM config [{llm_config_name}]. Loading it...'
|
||||
)
|
||||
# Use the existing function to load the specific LLM config
|
||||
referenced_llm_config = get_llm_config_arg(llm_config_name, toml_file=toml_file)
|
||||
|
||||
if referenced_llm_config:
|
||||
# Replace the string reference with the actual LLMConfig object
|
||||
condenser_data['llm_config'] = referenced_llm_config
|
||||
else:
|
||||
# get_llm_config_arg already logs the error if not found
|
||||
logger.openhands_logger.error(
|
||||
f"Failed to load required LLM config '{llm_config_name}' for condenser '{condenser_config_arg}'."
|
||||
)
|
||||
return None
|
||||
|
||||
# Create the condenser config instance
|
||||
try:
|
||||
config = create_condenser_config(condenser_type, condenser_data)
|
||||
logger.openhands_logger.info(
|
||||
f'Successfully loaded condenser config [{condenser_config_arg}] from {toml_file}'
|
||||
)
|
||||
return config
|
||||
except (ValidationError, ValueError) as e:
|
||||
logger.openhands_logger.error(
|
||||
f'Invalid condenser configuration for [{condenser_config_arg}]: {e}.'
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# Command line arguments
|
||||
def get_parser() -> argparse.ArgumentParser:
|
||||
"""Get the argument parser."""
|
||||
|
||||
@@ -448,6 +448,60 @@ class GitHubService(BaseGitService, GitService):
|
||||
|
||||
return all_branches
|
||||
|
||||
async def create_pr(
|
||||
self,
|
||||
repo_name: str,
|
||||
source_branch: str,
|
||||
target_branch: str,
|
||||
title: str,
|
||||
body: str | None = None,
|
||||
draft: bool = True,
|
||||
) -> str:
|
||||
"""
|
||||
Creates a PR using user credentials
|
||||
|
||||
Args:
|
||||
repo_name: The full name of the repository (owner/repo)
|
||||
source_branch: The name of the branch where your changes are implemented
|
||||
target_branch: The name of the branch you want the changes pulled into
|
||||
title: The title of the pull request (optional, defaults to a generic title)
|
||||
body: The body/description of the pull request (optional)
|
||||
draft: Whether to create the PR as a draft (optional, defaults to False)
|
||||
|
||||
Returns:
|
||||
- PR URL when successful
|
||||
- Error message when unsuccessful
|
||||
"""
|
||||
try:
|
||||
url = f'{self.BASE_URL}/repos/{repo_name}/pulls'
|
||||
|
||||
# Set default body if none provided
|
||||
if not body:
|
||||
body = f'Merging changes from {source_branch} into {target_branch}'
|
||||
|
||||
# Prepare the request payload
|
||||
payload = {
|
||||
'title': title,
|
||||
'head': source_branch,
|
||||
'base': target_branch,
|
||||
'body': body,
|
||||
'draft': draft,
|
||||
}
|
||||
|
||||
# Make the POST request to create the PR
|
||||
response, _ = await self._make_request(
|
||||
url=url, params=payload, method=RequestMethod.POST
|
||||
)
|
||||
|
||||
# Return the HTML URL of the created PR
|
||||
if 'html_url' in response:
|
||||
return response['html_url']
|
||||
else:
|
||||
return f'PR created but URL not found in response: {response}'
|
||||
|
||||
except Exception as e:
|
||||
return f'Error creating pull request: {str(e)}'
|
||||
|
||||
|
||||
github_service_cls = os.environ.get(
|
||||
'OPENHANDS_GITHUB_SERVICE_CLS',
|
||||
|
||||
@@ -438,6 +438,61 @@ class GitLabService(BaseGitService, GitService):
|
||||
|
||||
return all_branches
|
||||
|
||||
async def create_mr(
|
||||
self,
|
||||
id: int | str,
|
||||
source_branch: str,
|
||||
target_branch: str,
|
||||
title: str,
|
||||
description: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Creates a merge request in GitLab
|
||||
|
||||
Args:
|
||||
id: The ID or URL-encoded path of the project
|
||||
source_branch: The name of the branch where your changes are implemented
|
||||
target_branch: The name of the branch you want the changes merged into
|
||||
title: The title of the merge request (optional, defaults to a generic title)
|
||||
description: The description of the merge request (optional)
|
||||
draft: Whether to create the MR as a draft (optional, defaults to False)
|
||||
|
||||
Returns:
|
||||
- MR URL when successful
|
||||
- Error message when unsuccessful
|
||||
"""
|
||||
try:
|
||||
# Convert string ID to URL-encoded path if needed
|
||||
project_id = str(id).replace('/', '%2F') if isinstance(id, str) else id
|
||||
url = f'{self.BASE_URL}/projects/{project_id}/merge_requests'
|
||||
|
||||
# Set default description if none provided
|
||||
if not description:
|
||||
description = f'Merging changes from {source_branch} into {target_branch}'
|
||||
|
||||
# Prepare the request payload
|
||||
payload = {
|
||||
'source_branch': source_branch,
|
||||
'target_branch': target_branch,
|
||||
'title': title,
|
||||
'description': description,
|
||||
}
|
||||
|
||||
|
||||
# Make the POST request to create the MR
|
||||
response, _ = await self._make_request(
|
||||
url=url, params=payload, method=RequestMethod.POST
|
||||
)
|
||||
|
||||
# Return the web URL of the created MR
|
||||
if 'web_url' in response:
|
||||
return response['web_url']
|
||||
else:
|
||||
return f'MR created but URL not found in response: {response}'
|
||||
|
||||
except Exception as e:
|
||||
return f'Error creating merge request: {str(e)}'
|
||||
|
||||
|
||||
gitlab_service_cls = os.environ.get(
|
||||
'OPENHANDS_GITLAB_SERVICE_CLS',
|
||||
|
||||
@@ -279,9 +279,6 @@ class LLM(RetryMixin, DebugMixin):
|
||||
# Record start time for latency measurement
|
||||
start_time = time.time()
|
||||
# we don't support streaming here, thus we get a ModelResponse
|
||||
logger.debug(
|
||||
f'LLM: calling litellm completion with model: {self.config.model}, base_url: {self.config.base_url}, args: {args}, kwargs: {kwargs}'
|
||||
)
|
||||
resp: ModelResponse = self._completion_unwrapped(*args, **kwargs)
|
||||
|
||||
# Calculate and record latency
|
||||
|
||||
44
openhands/llm/llm_utils.py
Normal file
44
openhands/llm/llm_utils.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import copy
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from openhands.core.config import LLMConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm import ChatCompletionToolParam
|
||||
|
||||
|
||||
def check_tools(
|
||||
tools: list['ChatCompletionToolParam'], llm_config: LLMConfig
|
||||
) -> list['ChatCompletionToolParam']:
|
||||
"""Checks and modifies tools for compatibility with the current LLM."""
|
||||
# Special handling for Gemini models which don't support default fields and have limited format support
|
||||
if 'gemini' in llm_config.model.lower():
|
||||
logger.info(
|
||||
f'Removing default fields and unsupported formats from tools for Gemini model {llm_config.model} '
|
||||
"since Gemini models have limited format support (only 'enum' and 'date-time' for STRING types)."
|
||||
)
|
||||
# prevent mutation of input tools
|
||||
checked_tools = copy.deepcopy(tools)
|
||||
# Strip off default fields and unsupported formats that cause errors with gemini-preview
|
||||
for tool in checked_tools:
|
||||
if 'function' in tool and 'parameters' in tool['function']:
|
||||
if 'properties' in tool['function']['parameters']:
|
||||
for prop_name, prop in tool['function']['parameters'][
|
||||
'properties'
|
||||
].items():
|
||||
# Remove default fields
|
||||
if 'default' in prop:
|
||||
del prop['default']
|
||||
|
||||
# Remove format fields for STRING type parameters if the format is unsupported
|
||||
# Gemini only supports 'enum' and 'date-time' formats for STRING type
|
||||
if prop.get('type') == 'string' and 'format' in prop:
|
||||
supported_formats = ['enum', 'date-time']
|
||||
if prop['format'] not in supported_formats:
|
||||
logger.info(
|
||||
f'Removing unsupported format "{prop["format"]}" for STRING parameter "{prop_name}"'
|
||||
)
|
||||
del prop['format']
|
||||
return checked_tools
|
||||
return tools
|
||||
@@ -25,7 +25,11 @@ class MCPClient(BaseModel):
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
async def connect_sse(
|
||||
self, server_url: str, api_key: str | None = None, timeout: float = 30.0
|
||||
self,
|
||||
server_url: str,
|
||||
api_key: str | None = None,
|
||||
conversation_id: str | None = None,
|
||||
timeout: float = 30.0,
|
||||
) -> None:
|
||||
"""Connect to an MCP server using SSE transport.
|
||||
|
||||
@@ -41,9 +45,14 @@ class MCPClient(BaseModel):
|
||||
try:
|
||||
# Use asyncio.wait_for to enforce the timeout
|
||||
async def connect_with_timeout():
|
||||
headers = {'Authorization': f'Bearer {api_key}'} if api_key else {}
|
||||
|
||||
if conversation_id:
|
||||
headers['X-OpenHands-Conversation-ID'] = conversation_id
|
||||
|
||||
streams_context = sse_client(
|
||||
url=server_url,
|
||||
headers={'Authorization': f'Bearer {api_key}'} if api_key else None,
|
||||
headers=headers if headers else None,
|
||||
timeout=timeout,
|
||||
)
|
||||
streams = await self.exit_stack.enter_async_context(streams_context)
|
||||
|
||||
@@ -44,7 +44,7 @@ def convert_mcp_clients_to_tools(mcp_clients: list[MCPClient] | None) -> list[di
|
||||
|
||||
|
||||
async def create_mcp_clients(
|
||||
sse_servers: list[MCPSSEServerConfig],
|
||||
sse_servers: list[MCPSSEServerConfig], conversation_id: str | None = None
|
||||
) -> list[MCPClient]:
|
||||
mcp_clients: list[MCPClient] = []
|
||||
# Initialize SSE connections
|
||||
@@ -56,7 +56,11 @@ async def create_mcp_clients(
|
||||
|
||||
client = MCPClient()
|
||||
try:
|
||||
await client.connect_sse(server_url.url, api_key=server_url.api_key)
|
||||
await client.connect_sse(
|
||||
server_url.url,
|
||||
api_key=server_url.api_key,
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
# Only add the client to the list after a successful connection
|
||||
mcp_clients.append(client)
|
||||
logger.info(f'Connected to MCP server {server_url} via SSE')
|
||||
@@ -155,6 +159,7 @@ async def add_mcp_tools_to_agent(
|
||||
"""
|
||||
Add MCP tools to an agent.
|
||||
"""
|
||||
|
||||
from openhands.runtime.impl.action_execution.action_execution_client import (
|
||||
ActionExecutionClient, # inline import to avoid circular import
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ from openhands.resolver.utils import extract_image_urls
|
||||
|
||||
class ServiceContext:
|
||||
issue_type: ClassVar[str]
|
||||
default_git_patch: ClassVar[str] = 'No changes made yet'
|
||||
default_git_patch: ClassVar[str] = 'No code changes were made or needed'
|
||||
|
||||
def __init__(self, strategy: IssueHandlerInterface, llm_config: LLMConfig | None):
|
||||
self._strategy = strategy
|
||||
@@ -362,7 +362,7 @@ class ServiceContextIssue(ServiceContext):
|
||||
Args:
|
||||
issue: The issue to check
|
||||
history: The agent's history
|
||||
git_patch: Optional git patch showing the changes made
|
||||
git_patch: Optional git patch showing the changes made. If None, indicates no code changes were needed.
|
||||
"""
|
||||
last_message = history[-1].message
|
||||
# Include thread comments in the prompt if they exist
|
||||
|
||||
@@ -158,67 +158,79 @@ def initialize_repo(
|
||||
return dest_dir
|
||||
|
||||
|
||||
def make_commit(repo_dir: str, issue: Issue, issue_type: str) -> None:
|
||||
def make_commit(repo_dir: str, issue: Issue, issue_type: str) -> bool:
|
||||
"""Make a commit with the changes to the repository.
|
||||
|
||||
Args:
|
||||
repo_dir: The directory containing the repository
|
||||
issue: The issue to fix
|
||||
issue_type: The type of the issue
|
||||
|
||||
Returns:
|
||||
bool: True if changes were committed, False if there were no changes to commit
|
||||
"""
|
||||
# Check if git username is set
|
||||
result = subprocess.run(
|
||||
f'git -C {repo_dir} config user.name',
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if not result.stdout.strip():
|
||||
# If username is not set, configure git
|
||||
subprocess.run(
|
||||
f'git -C {repo_dir} config user.name "openhands" && '
|
||||
f'git -C {repo_dir} config user.email "openhands@all-hands.dev" && '
|
||||
f'git -C {repo_dir} config alias.git "git --no-pager"',
|
||||
try:
|
||||
# Check if git username is set
|
||||
result = subprocess.run(
|
||||
f'git -C {repo_dir} config user.name',
|
||||
shell=True,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
logger.info('Git user configured as openhands')
|
||||
|
||||
# Add all changes to the git index
|
||||
result = subprocess.run(
|
||||
f'git -C {repo_dir} add .', shell=True, capture_output=True, text=True
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error(f'Error adding files: {result.stderr}')
|
||||
raise RuntimeError('Failed to add files to git')
|
||||
if not result.stdout.strip():
|
||||
# If username is not set, configure git
|
||||
subprocess.run(
|
||||
f'git -C {repo_dir} config user.name "openhands" && '
|
||||
f'git -C {repo_dir} config user.email "openhands@all-hands.dev" && '
|
||||
f'git -C {repo_dir} config alias.git "git --no-pager"',
|
||||
shell=True,
|
||||
)
|
||||
logger.info('Git user configured as openhands')
|
||||
|
||||
# Check the status of the git index
|
||||
status_result = subprocess.run(
|
||||
f'git -C {repo_dir} status --porcelain',
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# If there are no changes, raise an error
|
||||
if not status_result.stdout.strip():
|
||||
logger.error(
|
||||
f'No changes to commit for issue #{issue.number}. Skipping commit.'
|
||||
# Add all changes to the git index
|
||||
result = subprocess.run(
|
||||
f'git -C {repo_dir} add .', shell=True, capture_output=True, text=True
|
||||
)
|
||||
raise RuntimeError('ERROR: Openhands failed to make code changes.')
|
||||
if result.returncode != 0:
|
||||
logger.error(f'Error adding files: {result.stderr}')
|
||||
return False
|
||||
|
||||
# Prepare the commit message
|
||||
commit_message = f'Fix {issue_type} #{issue.number}: {issue.title}'
|
||||
# Check the status of the git index
|
||||
status_result = subprocess.run(
|
||||
f'git -C {repo_dir} status --porcelain',
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if status_result.returncode != 0:
|
||||
logger.error(f'Error checking git status: {status_result.stderr}')
|
||||
return False
|
||||
|
||||
# Commit the changes
|
||||
result = subprocess.run(
|
||||
['git', '-C', repo_dir, 'commit', '-m', commit_message],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f'Failed to commit changes: {result}')
|
||||
# If there are no changes, log it and return False
|
||||
if not status_result.stdout.strip():
|
||||
logger.info(
|
||||
f'No changes to commit for issue #{issue.number}. Skipping commit.'
|
||||
)
|
||||
return False
|
||||
|
||||
# Prepare the commit message
|
||||
commit_message = f'Fix {issue_type} #{issue.number}: {issue.title}'
|
||||
|
||||
# Commit the changes
|
||||
result = subprocess.run(
|
||||
['git', '-C', repo_dir, 'commit', '-m', commit_message],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error(f'Failed to commit changes: {result}')
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f'Error during git operations: {e}')
|
||||
return False
|
||||
|
||||
|
||||
def send_pull_request(
|
||||
@@ -518,7 +530,39 @@ def process_single_issue(
|
||||
|
||||
apply_patch(patched_repo_dir, resolver_output.git_patch)
|
||||
|
||||
make_commit(patched_repo_dir, resolver_output.issue, issue_type)
|
||||
has_changes = make_commit(patched_repo_dir, resolver_output.issue, issue_type)
|
||||
|
||||
# If there are no changes, we still want to post a comment with the result explanation
|
||||
if not has_changes:
|
||||
# Create a handler to post comments for both issues and PRs
|
||||
handler = (
|
||||
ServiceContextIssue(
|
||||
GithubIssueHandler(
|
||||
resolver_output.issue.owner,
|
||||
resolver_output.issue.repo,
|
||||
token,
|
||||
username,
|
||||
base_domain,
|
||||
),
|
||||
llm_config,
|
||||
)
|
||||
if platform == ProviderType.GITHUB
|
||||
else ServiceContextIssue(
|
||||
GitlabIssueHandler(
|
||||
resolver_output.issue.owner,
|
||||
resolver_output.issue.repo,
|
||||
token,
|
||||
username,
|
||||
base_domain,
|
||||
),
|
||||
llm_config,
|
||||
)
|
||||
)
|
||||
if resolver_output.result_explanation:
|
||||
handler.send_comment_msg(
|
||||
resolver_output.issue.number, resolver_output.result_explanation
|
||||
)
|
||||
return
|
||||
|
||||
if issue_type == 'pr':
|
||||
update_existing_pull_request(
|
||||
|
||||
@@ -135,10 +135,6 @@ class Runtime(FileEditRuntimeMixin):
|
||||
atexit.register(self.close)
|
||||
|
||||
self.initial_env_vars = _default_env_vars(config.sandbox)
|
||||
|
||||
# also update with runtime_startup_env_vars
|
||||
self.initial_env_vars.update(self.config.sandbox.runtime_startup_env_vars)
|
||||
|
||||
if env_vars is not None:
|
||||
self.initial_env_vars.update(env_vars)
|
||||
|
||||
|
||||
@@ -356,6 +356,7 @@ class ActionExecutionClient(Runtime):
|
||||
) -> MCPConfig:
|
||||
# Add the runtime as another MCP server
|
||||
updated_mcp_config = self.config.mcp.model_copy()
|
||||
|
||||
# Send a request to the action execution server to updated MCP config
|
||||
stdio_tools = [
|
||||
server.model_dump(mode='json')
|
||||
@@ -408,7 +409,7 @@ class ActionExecutionClient(Runtime):
|
||||
)
|
||||
|
||||
# Create clients for this specific operation
|
||||
mcp_clients = await create_mcp_clients(updated_mcp_config.sse_servers)
|
||||
mcp_clients = await create_mcp_clients(updated_mcp_config.sse_servers, self.sid)
|
||||
|
||||
# Call the tool and return the result
|
||||
# No need for try/finally since disconnect() is now just resetting state
|
||||
|
||||
@@ -23,7 +23,7 @@ from openhands.runtime.impl.action_execution.action_execution_client import (
|
||||
from openhands.runtime.impl.docker.containers import stop_all_containers
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
from openhands.runtime.utils import find_available_tcp_port
|
||||
from openhands.runtime.utils.command import DEFAULT_MAIN_MODULE, get_action_execution_server_startup_command
|
||||
from openhands.runtime.utils.command import get_action_execution_server_startup_command
|
||||
from openhands.runtime.utils.log_streamer import LogStreamer
|
||||
from openhands.runtime.utils.runtime_build import build_runtime_image
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
@@ -80,7 +80,6 @@ class DockerRuntime(ActionExecutionClient):
|
||||
status_callback: Callable | None = None,
|
||||
attach_to_existing: bool = False,
|
||||
headless_mode: bool = True,
|
||||
main_module: str = DEFAULT_MAIN_MODULE,
|
||||
):
|
||||
if not DockerRuntime._shutdown_listener_id:
|
||||
DockerRuntime._shutdown_listener_id = add_shutdown_listener(
|
||||
@@ -110,7 +109,6 @@ class DockerRuntime(ActionExecutionClient):
|
||||
self.runtime_container_image = self.config.sandbox.runtime_container_image
|
||||
self.container_name = CONTAINER_NAME_PREFIX + sid
|
||||
self.container: Container | None = None
|
||||
self.main_module = main_module
|
||||
|
||||
self.runtime_builder = DockerRuntimeBuilder(self.docker_client)
|
||||
|
||||
@@ -311,15 +309,16 @@ class DockerRuntime(ActionExecutionClient):
|
||||
)
|
||||
|
||||
# Combine environment variables
|
||||
environment = dict(**self.initial_env_vars)
|
||||
environment.update({
|
||||
environment = {
|
||||
'port': str(self._container_port),
|
||||
'PYTHONUNBUFFERED': '1',
|
||||
'VSCODE_PORT': str(self._vscode_port),
|
||||
'PIP_BREAK_SYSTEM_PACKAGES': '1',
|
||||
})
|
||||
}
|
||||
if self.config.debug or DEBUG:
|
||||
environment['DEBUG'] = 'true'
|
||||
# also update with runtime_startup_env_vars
|
||||
environment.update(self.config.sandbox.runtime_startup_env_vars)
|
||||
|
||||
self.log('debug', f'Workspace Base: {self.config.workspace_base}')
|
||||
|
||||
@@ -337,7 +336,11 @@ class DockerRuntime(ActionExecutionClient):
|
||||
f'Sandbox workspace: {self.config.workspace_mount_path_in_sandbox}',
|
||||
)
|
||||
|
||||
command = self.get_action_execution_server_startup_command()
|
||||
command = get_action_execution_server_startup_command(
|
||||
server_port=self._container_port,
|
||||
plugins=self.plugins,
|
||||
app_config=self.config,
|
||||
)
|
||||
|
||||
try:
|
||||
self.container = self.docker_client.containers.run(
|
||||
@@ -531,11 +534,3 @@ class DockerRuntime(ActionExecutionClient):
|
||||
pass
|
||||
finally:
|
||||
docker_client.close()
|
||||
|
||||
def get_action_execution_server_startup_command(self):
|
||||
return get_action_execution_server_startup_command(
|
||||
server_port=self._container_port,
|
||||
plugins=self.plugins,
|
||||
app_config=self.config,
|
||||
main_module=self.main_module,
|
||||
)
|
||||
|
||||
@@ -24,7 +24,7 @@ from openhands.runtime.impl.action_execution.action_execution_client import (
|
||||
ActionExecutionClient,
|
||||
)
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
from openhands.runtime.utils.command import DEFAULT_MAIN_MODULE, get_action_execution_server_startup_command
|
||||
from openhands.runtime.utils.command import get_action_execution_server_startup_command
|
||||
from openhands.runtime.utils.request import send_request
|
||||
from openhands.runtime.utils.runtime_build import build_runtime_image
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
@@ -54,7 +54,6 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
headless_mode: bool = True,
|
||||
user_id: str | None = None,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
|
||||
main_module: str = DEFAULT_MAIN_MODULE,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
config,
|
||||
@@ -86,7 +85,6 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
)
|
||||
|
||||
assert self.config.sandbox.remote_runtime_class in (None, 'sysbox', 'gvisor')
|
||||
self.main_module = main_module
|
||||
|
||||
self.runtime_builder = RemoteRuntimeBuilder(
|
||||
self.config.sandbox.remote_runtime_api_url,
|
||||
@@ -233,11 +231,15 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
|
||||
def _start_runtime(self) -> None:
|
||||
# Prepare the request body for the /start endpoint
|
||||
command = self.get_action_execution_server_startup_command()
|
||||
command = get_action_execution_server_startup_command(
|
||||
server_port=self.port,
|
||||
plugins=self.plugins,
|
||||
app_config=self.config,
|
||||
)
|
||||
environment: dict[str, str] = {}
|
||||
if self.config.debug or os.environ.get('DEBUG', 'false').lower() == 'true':
|
||||
environment['DEBUG'] = 'true'
|
||||
environment.update(self.initial_env_vars)
|
||||
environment.update(self.config.sandbox.runtime_startup_env_vars)
|
||||
start_request: dict[str, Any] = {
|
||||
'image': self.container_image,
|
||||
'command': command,
|
||||
@@ -490,11 +492,3 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
|
||||
def _stop_if_closed(self, retry_state: RetryCallState) -> bool:
|
||||
return self._runtime_closed
|
||||
|
||||
def get_action_execution_server_startup_command(self):
|
||||
return get_action_execution_server_startup_command(
|
||||
server_port=self.port,
|
||||
plugins=self.plugins,
|
||||
app_config=self.config,
|
||||
main_module=self.main_module,
|
||||
)
|
||||
|
||||
@@ -9,7 +9,6 @@ DEFAULT_PYTHON_PREFIX = [
|
||||
'poetry',
|
||||
'run',
|
||||
]
|
||||
DEFAULT_MAIN_MODULE = 'openhands.runtime.action_execution_server'
|
||||
|
||||
|
||||
def get_action_execution_server_startup_command(
|
||||
@@ -19,7 +18,6 @@ def get_action_execution_server_startup_command(
|
||||
python_prefix: list[str] = DEFAULT_PYTHON_PREFIX,
|
||||
override_user_id: int | None = None,
|
||||
override_username: str | None = None,
|
||||
main_module: str = DEFAULT_MAIN_MODULE,
|
||||
) -> list[str]:
|
||||
sandbox_config = app_config.sandbox
|
||||
|
||||
@@ -47,7 +45,7 @@ def get_action_execution_server_startup_command(
|
||||
'python',
|
||||
'-u',
|
||||
'-m',
|
||||
main_module,
|
||||
'openhands.runtime.action_execution_server',
|
||||
str(server_port),
|
||||
'--working-dir',
|
||||
app_config.workspace_mount_path_in_sandbox,
|
||||
|
||||
@@ -2,6 +2,8 @@ import warnings
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncIterator
|
||||
|
||||
from fastapi.routing import Mount
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('ignore')
|
||||
|
||||
@@ -18,6 +20,7 @@ from openhands.server.routes.git import app as git_api_router
|
||||
from openhands.server.routes.manage_conversations import (
|
||||
app as manage_conversation_api_router,
|
||||
)
|
||||
from openhands.server.routes.mcp import mcp_server
|
||||
from openhands.server.routes.public import app as public_api_router
|
||||
from openhands.server.routes.secrets import app as secrets_router
|
||||
from openhands.server.routes.security import app as security_api_router
|
||||
@@ -37,6 +40,7 @@ app = FastAPI(
|
||||
description='OpenHands: Code Less, Make More',
|
||||
version=__version__,
|
||||
lifespan=_lifespan,
|
||||
routes=[Mount(path='/mcp', app=mcp_server.sse_app())],
|
||||
)
|
||||
|
||||
|
||||
|
||||
141
openhands/server/routes/mcp.py
Normal file
141
openhands/server/routes/mcp.py
Normal file
@@ -0,0 +1,141 @@
|
||||
import re
|
||||
from typing import Annotated
|
||||
from pydantic import Field
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.dependencies import get_http_request
|
||||
from openhands.integrations.github.github_service import GithubServiceImpl
|
||||
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.integrations.provider import ProviderToken
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.server.shared import ConversationStoreImpl, config
|
||||
from openhands.server.user_auth import get_access_token, get_provider_tokens, get_user_id
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
|
||||
|
||||
mcp_server = FastMCP('mcp')
|
||||
|
||||
|
||||
async def save_pr_metadata(user_id: str, conversation_id: str, tool_result: str) -> None:
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
|
||||
conversation: ConversationMetadata = await conversation_store.get_metadata(conversation_id)
|
||||
|
||||
|
||||
pull_pattern = r"pull/(\d+)"
|
||||
merge_request_pattern = r"merge_requests/(\d+)"
|
||||
|
||||
# Check if the tool_result contains the PR number
|
||||
pr_number = None
|
||||
match_pull = re.search(pull_pattern, tool_result)
|
||||
match_merge_request = re.search(merge_request_pattern, tool_result)
|
||||
|
||||
if match_pull:
|
||||
pr_number = int(match_pull.group(1))
|
||||
elif match_merge_request:
|
||||
pr_number = int(match_merge_request.group(1))
|
||||
|
||||
|
||||
if pr_number:
|
||||
conversation.pr_number.append(pr_number)
|
||||
await conversation_store.save_metadata(conversation)
|
||||
|
||||
@mcp_server.tool()
|
||||
async def create_pr(
|
||||
repo_name: Annotated[
|
||||
str, Field(description='GitHub repository ({{owner}}/{{repo}})')
|
||||
],
|
||||
source_branch: Annotated[str, Field(description='Source branch on repo')],
|
||||
target_branch: Annotated[str, Field(description='Target branch on repo')],
|
||||
title: Annotated[str, Field(description='PR Title')],
|
||||
body: Annotated[str | None, Field(description='PR body')]
|
||||
) -> str:
|
||||
"""Open a draft PR in GitHub"""
|
||||
|
||||
logger.info('Calling OpenHands MCP create_pr')
|
||||
|
||||
request = get_http_request()
|
||||
headers = request.headers
|
||||
conversation_id = headers.get('X-OpenHands-Conversation-ID', None)
|
||||
|
||||
provider_tokens = await get_provider_tokens(request)
|
||||
access_token = await get_access_token(request)
|
||||
user_id = await get_user_id(request)
|
||||
|
||||
github_token = provider_tokens.get(ProviderType.GITHUB, ProviderToken()) if provider_tokens else ProviderToken()
|
||||
|
||||
github_service = GithubServiceImpl(
|
||||
user_id=github_token.user_id,
|
||||
external_auth_id=user_id,
|
||||
external_auth_token=access_token,
|
||||
token=github_token.token,
|
||||
base_domain=github_token.host
|
||||
)
|
||||
|
||||
try:
|
||||
response = await github_service.create_pr(
|
||||
repo_name=repo_name,
|
||||
source_branch=source_branch,
|
||||
target_branch=target_branch,
|
||||
title=title,
|
||||
body=body
|
||||
)
|
||||
|
||||
if conversation_id and user_id:
|
||||
await save_pr_metadata(user_id, conversation_id, response)
|
||||
|
||||
except Exception as e:
|
||||
response = str(e)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@mcp_server.tool()
|
||||
async def create_mr(
|
||||
id: Annotated[
|
||||
int | str, Field(description='GitLab repository (ID or URL-encoded path of the project)')
|
||||
],
|
||||
source_branch: Annotated[str, Field(description='Source branch on repo')],
|
||||
target_branch: Annotated[str, Field(description='Target branch on repo')],
|
||||
title: Annotated[str, Field(description='MR Title')],
|
||||
description: Annotated[str | None, Field(description='MR description')]
|
||||
) -> str:
|
||||
"""Open a draft MR in GitLab"""
|
||||
|
||||
logger.info('Calling OpenHands MCP create_mr')
|
||||
|
||||
request = get_http_request()
|
||||
headers = request.headers
|
||||
conversation_id = headers.get('X-OpenHands-Conversation-ID', None)
|
||||
|
||||
provider_tokens = await get_provider_tokens(request)
|
||||
access_token = await get_access_token(request)
|
||||
user_id = await get_user_id(request)
|
||||
|
||||
github_token = provider_tokens.get(ProviderType.GITLAB, ProviderToken()) if provider_tokens else ProviderToken()
|
||||
|
||||
github_service = GitLabServiceImpl(
|
||||
user_id=github_token.user_id,
|
||||
external_auth_id=user_id,
|
||||
external_auth_token=access_token,
|
||||
token=github_token.token,
|
||||
base_domain=github_token.host
|
||||
)
|
||||
|
||||
try:
|
||||
response = await github_service.create_mr(
|
||||
id=id,
|
||||
source_branch=source_branch,
|
||||
target_branch=target_branch,
|
||||
title=title,
|
||||
description=description,
|
||||
)
|
||||
|
||||
if conversation_id and user_id:
|
||||
await save_pr_metadata(user_id, conversation_id, response)
|
||||
|
||||
except Exception as e:
|
||||
response = str(e)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from openhands.events.action import ChangeAgentStateAction, MessageAction
|
||||
from openhands.events.event import Event, EventSource
|
||||
from openhands.events.stream import EventStream
|
||||
from openhands.integrations.provider import CUSTOM_SECRETS_TYPE, PROVIDER_TOKEN_TYPE, ProviderHandler
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.mcp import add_mcp_tools_to_agent
|
||||
from openhands.memory.memory import Memory
|
||||
from openhands.microagent.microagent import BaseMicroagent
|
||||
@@ -270,6 +271,23 @@ class AgentSession:
|
||||
security_analyzer, SecurityAnalyzer
|
||||
)(self.event_stream)
|
||||
|
||||
|
||||
def override_provider_tokens_with_custom_secret(
|
||||
self,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE | None,
|
||||
custom_secrets: CUSTOM_SECRETS_TYPE | None
|
||||
):
|
||||
if git_provider_tokens and custom_secrets:
|
||||
tokens = dict(git_provider_tokens)
|
||||
for provider, _ in tokens.items():
|
||||
token_name = ProviderHandler.get_provider_env_key(provider)
|
||||
if token_name in custom_secrets or token_name.upper() in custom_secrets:
|
||||
del tokens[provider]
|
||||
|
||||
return MappingProxyType(tokens)
|
||||
return git_provider_tokens
|
||||
|
||||
|
||||
async def _create_runtime(
|
||||
self,
|
||||
runtime_name: str,
|
||||
@@ -299,7 +317,11 @@ class AgentSession:
|
||||
|
||||
self.logger.debug(f'Initializing runtime `{runtime_name}` now...')
|
||||
runtime_cls = get_runtime_cls(runtime_name)
|
||||
if runtime_cls == RemoteRuntime:
|
||||
if runtime_cls == RemoteRuntime:
|
||||
# If provider tokens is passed in custom secrets, then remove provider from provider tokens
|
||||
# We prioritize provider tokens set in custom secrets
|
||||
provider_tokens_without_gitlab = self.override_provider_tokens_with_custom_secret(git_provider_tokens, custom_secrets)
|
||||
|
||||
self.runtime = runtime_cls(
|
||||
config=config,
|
||||
event_stream=self.event_stream,
|
||||
@@ -308,7 +330,7 @@ class AgentSession:
|
||||
status_callback=self._status_callback,
|
||||
headless_mode=False,
|
||||
attach_to_existing=False,
|
||||
git_provider_tokens=git_provider_tokens,
|
||||
git_provider_tokens=provider_tokens_without_gitlab,
|
||||
env_vars=env_vars,
|
||||
user_id=self.user_id,
|
||||
)
|
||||
|
||||
@@ -6,12 +6,13 @@ from logging import LoggerAdapter
|
||||
import socketio
|
||||
|
||||
from openhands.controller.agent import Agent
|
||||
from openhands.core.config import AppConfig, MCPConfig
|
||||
from openhands.core.config import AppConfig
|
||||
from openhands.core.config.condenser_config import (
|
||||
BrowserOutputCondenserConfig,
|
||||
CondenserPipelineConfig,
|
||||
LLMSummarizingCondenserConfig,
|
||||
)
|
||||
from openhands.core.config.mcp_config import MCPConfig, OpenHandsMCPConfigImpl
|
||||
from openhands.core.exceptions import MicroagentValidationError
|
||||
from openhands.core.logger import OpenHandsLoggerAdapter
|
||||
from openhands.core.schema import AgentState
|
||||
@@ -115,7 +116,11 @@ class Session:
|
||||
or settings.sandbox_runtime_container_image
|
||||
else self.config.sandbox.runtime_container_image
|
||||
)
|
||||
self.config.mcp = settings.mcp_config or MCPConfig()
|
||||
self.config.mcp = settings.mcp_config or MCPConfig(sse_servers=[], stdio_servers=[])
|
||||
# Add OpenHands' MCP server by default
|
||||
openhands_mcp_server = OpenHandsMCPConfigImpl.create_default_mcp_server_config(self.config.mcp_host, self.user_id)
|
||||
if openhands_mcp_server:
|
||||
self.config.mcp.sse_servers.append(openhands_mcp_server)
|
||||
max_iterations = settings.max_iterations or self.config.max_iterations
|
||||
|
||||
# This is a shallow copy of the default LLM config, so changes here will
|
||||
|
||||
@@ -20,6 +20,7 @@ class ConversationMetadata:
|
||||
title: str | None = None
|
||||
last_updated_at: datetime | None = None
|
||||
trigger: ConversationTrigger | None = None
|
||||
pr_number: list[int] = field(default_factory=list)
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
# Cost and token metrics
|
||||
accumulated_cost: float = 0.0
|
||||
|
||||
@@ -40,6 +40,7 @@ class Settings(BaseModel):
|
||||
sandbox_runtime_container_image: str | None = None
|
||||
mcp_config: MCPConfig | None = None
|
||||
|
||||
|
||||
model_config = {
|
||||
'validate_assignment': True,
|
||||
}
|
||||
|
||||
4436
poetry.lock
generated
4436
poetry.lock
generated
File diff suppressed because one or more lines are too long
@@ -83,7 +83,7 @@ prompt-toolkit = "^3.0.50"
|
||||
poetry = "^2.1.2"
|
||||
anyio = "4.9.0"
|
||||
pythonnet = "*"
|
||||
mcp = "1.9.0"
|
||||
fastmcp = "^2.3.3"
|
||||
mcpm = "1.12.0"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from unittest.mock import ANY, MagicMock, call, patch
|
||||
|
||||
@@ -440,6 +441,46 @@ def test_send_pull_request(
|
||||
assert post_data['draft'] == (pr_type == 'draft')
|
||||
|
||||
|
||||
def test_make_commit_failed_add(mock_output_dir, mock_issue):
|
||||
"""Test that make_commit returns False when git add fails."""
|
||||
# Create a test file
|
||||
test_file = os.path.join(mock_output_dir, 'test.txt')
|
||||
with open(test_file, 'w') as f:
|
||||
f.write('test content')
|
||||
|
||||
# Initialize git repo
|
||||
os.system(f'git init {mock_output_dir}')
|
||||
|
||||
# Mock a failed add by making the directory not a git repo
|
||||
shutil.rmtree(os.path.join(mock_output_dir, '.git'))
|
||||
|
||||
# Try to make a commit
|
||||
result = make_commit(mock_output_dir, mock_issue, 'issue')
|
||||
|
||||
# Assert that the function returned False
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_make_commit_failed_commit(mock_output_dir, mock_issue):
|
||||
"""Test that make_commit returns False when git commit fails."""
|
||||
# Create a test file
|
||||
test_file = os.path.join(mock_output_dir, 'test.txt')
|
||||
with open(test_file, 'w') as f:
|
||||
f.write('test content')
|
||||
|
||||
# Initialize git repo
|
||||
os.system(f'git init {mock_output_dir}')
|
||||
|
||||
# Mock a failed commit by making the directory not a git repo
|
||||
shutil.rmtree(os.path.join(mock_output_dir, '.git'))
|
||||
|
||||
# Try to make a commit
|
||||
result = make_commit(mock_output_dir, mock_issue, 'issue')
|
||||
|
||||
# Assert that the function returned False
|
||||
assert result is False
|
||||
|
||||
|
||||
@patch('subprocess.run')
|
||||
@patch('httpx.post')
|
||||
@patch('httpx.get')
|
||||
@@ -1025,6 +1066,137 @@ def test_process_single_issue_unsuccessful(
|
||||
mock_initialize_repo.assert_not_called()
|
||||
mock_apply_patch.assert_not_called()
|
||||
mock_make_commit.assert_not_called()
|
||||
|
||||
|
||||
@patch('openhands.resolver.send_pull_request.initialize_repo')
|
||||
@patch('openhands.resolver.send_pull_request.apply_patch')
|
||||
@patch('openhands.resolver.send_pull_request.update_existing_pull_request')
|
||||
@patch('openhands.resolver.send_pull_request.make_commit')
|
||||
@patch('openhands.resolver.interfaces.github.GithubIssueHandler.send_comment_msg')
|
||||
def test_process_single_pr_no_changes(
|
||||
mock_send_comment_msg,
|
||||
mock_make_commit,
|
||||
mock_update_existing_pull_request,
|
||||
mock_apply_patch,
|
||||
mock_initialize_repo,
|
||||
mock_output_dir,
|
||||
mock_llm_config,
|
||||
):
|
||||
"""Test that process_single_issue handles PR with no changes correctly."""
|
||||
# Setup
|
||||
resolver_output = ResolverOutput(
|
||||
issue=Issue(
|
||||
owner='test-owner',
|
||||
repo='test-repo',
|
||||
number=1,
|
||||
title='Test PR',
|
||||
body='Test body',
|
||||
head_branch='branch 1',
|
||||
),
|
||||
success=True,
|
||||
git_patch='',
|
||||
issue_type='pr',
|
||||
base_commit='def456',
|
||||
result_explanation='No changes needed',
|
||||
history=[],
|
||||
metrics={},
|
||||
instruction='Test instruction',
|
||||
comment_success=None,
|
||||
error=None,
|
||||
)
|
||||
token = 'test-token'
|
||||
username = 'test-user'
|
||||
platform = ProviderType.GITHUB
|
||||
|
||||
# Mock make_commit to return False (no changes)
|
||||
mock_make_commit.return_value = False
|
||||
|
||||
# Mock initialize_repo to return the expected path
|
||||
expected_path = f'{mock_output_dir}/patches/pr_1'
|
||||
mock_initialize_repo.return_value = expected_path
|
||||
|
||||
# Call the function
|
||||
process_single_issue(
|
||||
mock_output_dir,
|
||||
resolver_output,
|
||||
token,
|
||||
username,
|
||||
platform,
|
||||
'ready',
|
||||
mock_llm_config,
|
||||
None,
|
||||
False,
|
||||
)
|
||||
|
||||
# Assert that the mocked functions were called correctly
|
||||
mock_initialize_repo.assert_called_once_with(mock_output_dir, 1, 'pr', 'branch 1')
|
||||
mock_apply_patch.assert_called_once_with(expected_path, resolver_output.git_patch)
|
||||
mock_make_commit.assert_called_once_with(expected_path, resolver_output.issue, 'pr')
|
||||
mock_update_existing_pull_request.assert_not_called()
|
||||
mock_send_comment_msg.assert_called_once_with(1, 'No changes needed')
|
||||
|
||||
|
||||
@patch('openhands.resolver.send_pull_request.initialize_repo')
|
||||
@patch('openhands.resolver.send_pull_request.apply_patch')
|
||||
@patch('openhands.resolver.send_pull_request.send_pull_request')
|
||||
@patch('openhands.resolver.send_pull_request.make_commit')
|
||||
def test_process_single_issue_no_changes(
|
||||
mock_make_commit,
|
||||
mock_send_pull_request,
|
||||
mock_apply_patch,
|
||||
mock_initialize_repo,
|
||||
mock_output_dir,
|
||||
mock_llm_config,
|
||||
):
|
||||
"""Test that process_single_issue handles issue with no changes correctly."""
|
||||
# Setup
|
||||
resolver_output = ResolverOutput(
|
||||
issue=Issue(
|
||||
owner='test-owner',
|
||||
repo='test-repo',
|
||||
number=1,
|
||||
title='Test Issue',
|
||||
body='Test body',
|
||||
),
|
||||
success=True,
|
||||
git_patch='',
|
||||
issue_type='issue',
|
||||
base_commit='def456',
|
||||
result_explanation='No changes needed',
|
||||
history=[],
|
||||
metrics={},
|
||||
instruction='Test instruction',
|
||||
comment_success=None,
|
||||
error=None,
|
||||
)
|
||||
token = 'test-token'
|
||||
username = 'test-user'
|
||||
platform = ProviderType.GITHUB
|
||||
|
||||
# Mock make_commit to return False (no changes)
|
||||
mock_make_commit.return_value = False
|
||||
|
||||
# Mock initialize_repo to return the expected path
|
||||
expected_path = f'{mock_output_dir}/patches/issue_1'
|
||||
mock_initialize_repo.return_value = expected_path
|
||||
|
||||
# Call the function
|
||||
process_single_issue(
|
||||
mock_output_dir,
|
||||
resolver_output,
|
||||
token,
|
||||
username,
|
||||
platform,
|
||||
'ready',
|
||||
mock_llm_config,
|
||||
None,
|
||||
False,
|
||||
)
|
||||
|
||||
# Assert that the mocked functions were called correctly
|
||||
mock_initialize_repo.assert_called_once_with(mock_output_dir, 1, 'issue', 'def456')
|
||||
mock_apply_patch.assert_called_once_with(expected_path, resolver_output.git_patch)
|
||||
mock_make_commit.assert_called_once_with(expected_path, resolver_output.issue, 'issue')
|
||||
mock_send_pull_request.assert_not_called()
|
||||
|
||||
|
||||
@@ -1197,18 +1369,22 @@ def test_make_commit_escapes_issue_title(mock_subprocess_run):
|
||||
body='Test body',
|
||||
)
|
||||
|
||||
# Mock subprocess.run to return success for all calls
|
||||
mock_subprocess_run.return_value = MagicMock(
|
||||
returncode=0, stdout='sample output', stderr=''
|
||||
)
|
||||
# Mock subprocess.run to simulate changes in the repo
|
||||
mock_subprocess_run.side_effect = [
|
||||
MagicMock(returncode=0, stdout='openhands'), # git config check
|
||||
MagicMock(returncode=0), # git add
|
||||
MagicMock(returncode=0, stdout='M file1.txt\nA file2.txt'), # git status --porcelain (has changes)
|
||||
MagicMock(returncode=0), # git commit
|
||||
]
|
||||
|
||||
# Call the function
|
||||
issue_type = 'issue'
|
||||
make_commit(repo_dir, issue, issue_type)
|
||||
result = make_commit(repo_dir, issue, issue_type)
|
||||
assert result is True
|
||||
|
||||
# Assert that subprocess.run was called with the correct arguments
|
||||
calls = mock_subprocess_run.call_args_list
|
||||
assert len(calls) == 4 # git config check, git add, git commit
|
||||
assert len(calls) == 4 # git config check, git add, git status, git commit
|
||||
|
||||
# Check the git commit call
|
||||
git_commit_call = calls[3][0][0]
|
||||
@@ -1239,15 +1415,23 @@ def test_make_commit_no_changes(mock_subprocess_run):
|
||||
|
||||
# Mock subprocess.run to simulate no changes in the repo
|
||||
mock_subprocess_run.side_effect = [
|
||||
MagicMock(returncode=0),
|
||||
MagicMock(returncode=0),
|
||||
MagicMock(returncode=1, stdout=''), # git status --porcelain (no changes)
|
||||
MagicMock(returncode=0, stdout='openhands'), # git config check
|
||||
MagicMock(returncode=0), # git add
|
||||
MagicMock(returncode=0, stdout=''), # git status --porcelain (no changes)
|
||||
]
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError, match='ERROR: Openhands failed to make code changes.'
|
||||
):
|
||||
make_commit(repo_dir, issue, 'issue')
|
||||
# Should not raise an error and return False
|
||||
result = make_commit(repo_dir, issue, 'issue')
|
||||
assert result is False
|
||||
|
||||
# Verify that git commit was not called
|
||||
assert len(mock_subprocess_run.call_args_list) == 3 # git config check, git add, git status
|
||||
|
||||
# Check the specific calls
|
||||
calls = mock_subprocess_run.call_args_list
|
||||
assert calls[0][0][0] == f'git -C {repo_dir} config user.name' # git config check
|
||||
assert calls[1][0][0] == f'git -C {repo_dir} add .' # git add
|
||||
assert calls[2][0][0] == f'git -C {repo_dir} status --porcelain' # git status
|
||||
|
||||
# Check that subprocess.run was called for checking git status and add, but not commit
|
||||
assert mock_subprocess_run.call_count == 3
|
||||
|
||||
@@ -1141,22 +1141,89 @@ def test_make_commit_no_changes(mock_subprocess_run):
|
||||
|
||||
# Mock subprocess.run to simulate no changes in the repo
|
||||
mock_subprocess_run.side_effect = [
|
||||
MagicMock(returncode=0),
|
||||
MagicMock(returncode=0),
|
||||
MagicMock(returncode=1, stdout=''), # git status --porcelain (no changes)
|
||||
MagicMock(returncode=0), # git config user.name
|
||||
MagicMock(returncode=0), # git add .
|
||||
MagicMock(returncode=0, stdout=''), # git status --porcelain (no changes)
|
||||
]
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError, match='ERROR: Openhands failed to make code changes.'
|
||||
):
|
||||
make_commit(repo_dir, issue, 'issue')
|
||||
|
||||
# Check that subprocess.run was called for checking git status and add, but not commit
|
||||
# Call make_commit and verify it returns False when there are no changes
|
||||
result = make_commit(repo_dir, issue, 'issue')
|
||||
assert result is False
|
||||
assert mock_subprocess_run.call_count == 3
|
||||
git_status_call = mock_subprocess_run.call_args_list[2][0][0]
|
||||
assert f'git -C {repo_dir} status --porcelain' in git_status_call
|
||||
|
||||
|
||||
@patch('openhands.resolver.send_pull_request.initialize_repo')
|
||||
@patch('openhands.resolver.send_pull_request.apply_patch')
|
||||
@patch('openhands.resolver.send_pull_request.send_pull_request')
|
||||
@patch('openhands.resolver.send_pull_request.make_commit')
|
||||
@patch('openhands.resolver.interfaces.gitlab.GitlabIssueHandler.send_comment_msg')
|
||||
def test_process_single_issue_no_changes(
|
||||
mock_send_comment_msg,
|
||||
mock_make_commit,
|
||||
mock_send_pull_request,
|
||||
mock_apply_patch,
|
||||
mock_initialize_repo,
|
||||
mock_output_dir,
|
||||
mock_llm_config,
|
||||
):
|
||||
"""Test that process_single_issue handles issue with no changes correctly."""
|
||||
# Setup
|
||||
resolver_output = ResolverOutput(
|
||||
issue=Issue(
|
||||
owner='test-owner',
|
||||
repo='test-repo',
|
||||
number=1,
|
||||
title='Test Issue',
|
||||
body='Test body',
|
||||
),
|
||||
success=True,
|
||||
git_patch='',
|
||||
issue_type='issue',
|
||||
base_commit='def456',
|
||||
result_explanation='No changes needed',
|
||||
history=[],
|
||||
metrics={},
|
||||
instruction='Test instruction',
|
||||
comment_success=None,
|
||||
error=None,
|
||||
)
|
||||
token = 'test-token'
|
||||
username = 'test-user'
|
||||
platform = ProviderType.GITLAB
|
||||
|
||||
# Mock make_commit to return False (no changes)
|
||||
mock_make_commit.return_value = False
|
||||
|
||||
# Mock initialize_repo to return a path
|
||||
mock_initialize_repo.return_value = f'{mock_output_dir}/patches/issue_1'
|
||||
|
||||
# Call the function
|
||||
process_single_issue(
|
||||
mock_output_dir,
|
||||
resolver_output,
|
||||
token,
|
||||
username,
|
||||
platform,
|
||||
'ready',
|
||||
mock_llm_config,
|
||||
None,
|
||||
False,
|
||||
)
|
||||
|
||||
# Assert that the mocked functions were called correctly
|
||||
mock_initialize_repo.assert_called_once_with(mock_output_dir, 1, 'issue', 'def456')
|
||||
mock_apply_patch.assert_called_once_with(
|
||||
f'{mock_output_dir}/patches/issue_1', resolver_output.git_patch
|
||||
)
|
||||
mock_make_commit.assert_called_once_with(
|
||||
f'{mock_output_dir}/patches/issue_1', resolver_output.issue, 'issue'
|
||||
)
|
||||
mock_send_pull_request.assert_not_called()
|
||||
mock_send_comment_msg.assert_called_once_with(1, 'No changes needed')
|
||||
|
||||
|
||||
def test_apply_patch_rename_directory(mock_output_dir):
|
||||
# Create a sample directory structure
|
||||
old_dir = os.path.join(mock_output_dir, 'prompts', 'resolve')
|
||||
|
||||
113
tests/unit/resolver/test_no_code_changes.py
Normal file
113
tests/unit/resolver/test_no_code_changes.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from openhands.core.config import LLMConfig
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.llm import LLM
|
||||
from openhands.resolver.interfaces.github import GithubIssueHandler, GithubPRHandler
|
||||
from openhands.resolver.interfaces.issue import Issue
|
||||
from openhands.resolver.interfaces.issue_definitions import (
|
||||
ServiceContextIssue,
|
||||
ServiceContextPR,
|
||||
)
|
||||
|
||||
|
||||
def test_issue_success_without_code_changes():
|
||||
"""Test that an issue can be marked as successful without code changes."""
|
||||
# Mock data
|
||||
issue = Issue(
|
||||
owner='test',
|
||||
repo='test',
|
||||
number=1,
|
||||
title='Test Issue',
|
||||
body='Test body',
|
||||
thread_comments=['Please review this solution'],
|
||||
review_comments=None,
|
||||
)
|
||||
history = [MessageAction(content='After reviewing the solution, it looks good and no changes are needed.')]
|
||||
llm_config = LLMConfig(model='test', api_key='test')
|
||||
|
||||
# Create a mock response indicating success without code changes
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [
|
||||
MagicMock(
|
||||
message=MagicMock(
|
||||
content="""--- success
|
||||
true
|
||||
|
||||
--- explanation
|
||||
The solution has been reviewed and no code changes are needed because:
|
||||
- The current implementation already handles the case correctly
|
||||
- The reported issue was a misunderstanding
|
||||
- No actual bug was found in the code"""
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
# Use patch to mock the LLM completion call
|
||||
with patch.object(LLM, 'completion', return_value=mock_response) as mock_completion:
|
||||
# Create a handler instance
|
||||
handler = ServiceContextIssue(
|
||||
GithubIssueHandler('test', 'test', 'test'), llm_config
|
||||
)
|
||||
|
||||
# Call guess_success with no git patch
|
||||
success, _, explanation = handler.guess_success(issue, history, git_patch=None)
|
||||
|
||||
# Verify the results
|
||||
assert success is True
|
||||
assert 'The solution has been reviewed' in explanation
|
||||
assert 'no code changes are needed' in explanation
|
||||
|
||||
# Verify that LLM completion was called exactly once
|
||||
mock_completion.assert_called_once()
|
||||
|
||||
|
||||
def test_pr_success_without_code_changes():
|
||||
"""Test that a PR can be marked as successful without code changes."""
|
||||
# Create a PR handler instance
|
||||
llm_config = LLMConfig(model='test', api_key='test')
|
||||
handler = ServiceContextPR(GithubPRHandler('test', 'test', 'test'), llm_config)
|
||||
|
||||
# Create a mock issue with thread comments
|
||||
issue = Issue(
|
||||
owner='test-owner',
|
||||
repo='test-repo',
|
||||
number=1,
|
||||
title='Test PR',
|
||||
body='Test Body',
|
||||
thread_comments=['Please review this approach'],
|
||||
closing_issues=['Issue description'],
|
||||
review_comments=None,
|
||||
thread_ids=None,
|
||||
head_branch='test-branch',
|
||||
)
|
||||
|
||||
# Create mock history with a message indicating no changes needed
|
||||
history = [MessageAction(content='After reviewing the approach, no code changes are needed because the current implementation is correct.')]
|
||||
|
||||
# Mock the LLM response
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [
|
||||
MagicMock(
|
||||
message=MagicMock(
|
||||
content="""--- success
|
||||
true
|
||||
|
||||
--- explanation
|
||||
The review has been completed and no code changes are needed because:
|
||||
- The current implementation is correct
|
||||
- The proposed approach would not improve the solution
|
||||
- The existing code already handles all edge cases"""
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
# Test the guess_success method
|
||||
with patch.object(LLM, 'completion', return_value=mock_response):
|
||||
success, success_list, explanation = handler.guess_success(issue, history, git_patch=None)
|
||||
|
||||
# Verify the results
|
||||
assert success is True
|
||||
assert success_list == [True]
|
||||
assert 'no code changes are needed' in json.loads(explanation)[0]
|
||||
@@ -40,10 +40,10 @@ async def test_create_mcp_clients_success(mock_mcp_client):
|
||||
|
||||
# Check that connect_sse was called with correct parameters
|
||||
mock_client_instance.connect_sse.assert_any_call(
|
||||
'http://server1:8080', api_key=None
|
||||
'http://server1:8080', api_key=None, conversation_id=None
|
||||
)
|
||||
mock_client_instance.connect_sse.assert_any_call(
|
||||
'http://server2:8080', api_key='test-key'
|
||||
'http://server2:8080', api_key='test-key', conversation_id=None
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user