Compare commits

...

31 Commits

Author SHA1 Message Date
Engel Nyst
b1b875d4aa Merge branch 'main' into openhands-fix-issue-8199 2025-05-21 22:11:01 +02:00
Engel Nyst
637cb0726a specify condenser config for evals (#8177)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-21 22:08:57 +02:00
tofarr
2bd10de636 Revert "Fix for issue where initial env vars are not passed to runtime" (#8624) 2025-05-21 20:00:56 +00:00
dependabot[bot]
70322c8418 chore(deps): bump the version-all group across 1 directory with 8 updates (#8617)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-05-21 19:34:59 +00:00
Rohit Malhotra
8b08958efe [Fix]: make mcp config optional in settings (#8622) 2025-05-21 19:17:43 +00:00
dependabot[bot]
5b021ad1bb chore(deps): bump the version-all group across 1 directory with 2 updates (#8618)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-21 16:39:07 +00:00
Rohit Malhotra
890796cc9d [Feat]: Git mcp server to open PRs (#8348)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
Co-authored-by: Robert Brennan <accounts@rbren.io>
2025-05-21 11:48:02 -04:00
sp.wack
7305c8fb31 hotfix(frontend): Prevent merging conversation events when switching between conversations (#8614) 2025-05-21 15:12:04 +00:00
Xingyao Wang
f1897b8095 docs: add Devstral MLX link for local models documentation (#8615) 2025-05-21 15:04:50 +00:00
Engel Nyst
c26ef180f2 Fix unsupported MCP tools param (#8610) 2025-05-21 14:41:01 +00:00
Robert Brennan
37e9933092 Revert "Fix passing environment" (#8612) 2025-05-21 14:32:47 +00:00
Xingyao Wang
c353fb6e7e docs: update local llm documentation (#8609)
Co-authored-by: mamoodi <mamoodiha@gmail.com>
2025-05-21 14:05:21 +00:00
Engel Nyst
b75bad16e4 Merge branch 'main' into openhands-fix-issue-8199 2025-05-09 20:36:59 +02:00
Engel Nyst
226d1ecd9f Merge branch 'main' into openhands-fix-issue-8199 2025-05-09 20:06:55 +02:00
Engel Nyst
cf9e17e85a fix raise on git config 2025-05-02 18:56:37 +02:00
Engel Nyst
ec22a15b6b Merge branch 'main' into openhands-fix-issue-8199 2025-05-02 18:39:42 +02:00
Engel Nyst
d2466d2570 Merge branch 'main' into openhands-fix-issue-8199 2025-05-02 13:33:25 +02:00
Engel Nyst
6c66e18388 Merge branch 'main' into openhands-fix-issue-8199 2025-05-02 08:58:56 +02:00
Engel Nyst
74b60c4930 Update openhands/resolver/send_pull_request.py 2025-05-02 02:38:53 +02:00
openhands
560901262b Fix pr #8212: Fix the resolver to comment / finish without code changes 2025-05-02 00:34:24 +00:00
openhands
eec1fa9abf Fix pr #8212: Fix the resolver to comment / finish without code changes 2025-05-02 00:01:30 +00:00
Engel Nyst
11bd0289e0 Update openhands/resolver/interfaces/issue_definitions.py 2025-05-02 00:55:52 +02:00
Engel Nyst
23edbd56b8 Update openhands/resolver/interfaces/issue_definitions.py 2025-05-02 00:55:22 +02:00
Engel Nyst
2141473907 Update openhands/resolver/interfaces/issue_definitions.py 2025-05-02 00:54:54 +02:00
Engel Nyst
a6194ea990 Update tests/unit/resolver/github/test_send_pull_request.py 2025-05-02 00:53:47 +02:00
Engel Nyst
8c0dfdfe0a Update openhands/resolver/interfaces/issue_definitions.py 2025-05-02 00:50:23 +02:00
OpenHands Bot
2496b8592e 🤖 Auto-fix Python linting issues 2025-05-01 22:44:38 +00:00
openhands
8bf1db8cce Fix pr #8212: Fix issue #8199: [Bug]: Fix the resolver to comment / finish without code changes 2025-05-01 22:41:02 +00:00
openhands
ce2dc26b47 Fix pr #8212: Fix issue #8199: [Bug]: Fix the resolver to comment / finish without code changes 2025-05-01 22:28:46 +00:00
openhands
8c204936ee Fix pr #8212: Fix issue #8199: [Bug]: Fix the resolver to comment / finish without code changes 2025-05-01 21:28:07 +00:00
openhands
aeba03b0e7 Fix issue #8199: [Bug]: Fix the resolver to comment / finish without code changes 2025-05-01 20:07:03 +00:00
45 changed files with 4296 additions and 2457 deletions

View File

@@ -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:
![image](./screenshots/1_select_power_user.png)
4. Then click `Select a model to load` on top of the application:
![image](./screenshots/2_select_model.png)
5. And choose the model you want to use, holding `option` on mac to enable advanced loading options:
![image](./screenshots/3_select_devstral.png)
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.
![image](./screenshots/4_set_context_window.png)
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):
![image](./screenshots/5_copy_url.png)
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):
![image](./screenshots/6_copy_to_get_model_name.png)
### 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

View File

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

View File

@@ -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,

View File

@@ -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')

View File

@@ -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"

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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");
}

View File

@@ -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
```

View File

@@ -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
```

View File

@@ -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}')

View File

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

View File

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

View File

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

View File

@@ -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',

View File

@@ -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',

View File

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

View 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

View File

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

View File

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

View File

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

View File

@@ -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(

View File

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

View File

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

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,

View File

@@ -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())],
)

View 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

View File

@@ -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,
)

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -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]

View File

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

View File

@@ -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')

View 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]

View File

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