Compare commits

...

6 Commits

Author SHA1 Message Date
openhands
9367e3d7d1 Simplify workspace mounting with RUNTIME_MOUNT environment variable 2025-05-03 16:17:00 +00:00
Engel Nyst
985e20d529 [chore] Run full agent pre-commit (#8235) 2025-05-03 11:24:03 -04:00
Boxuan Li
98cb2e24ee Make tool call json decode error recoverable (#8233) 2025-05-03 15:01:32 +00:00
Chase
de175dcc87 bugfix for #8187 (infinite loop when delegating) (#8189) 2025-05-02 22:49:42 +02:00
Robert Brennan
976019ce11 Fix websocket error message handling (#8227)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-02 12:56:36 -04:00
dependabot[bot]
709b6ff39a chore(deps): bump the version-all group with 5 updates (#8226)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-02 18:14:29 +02:00
42 changed files with 495 additions and 228 deletions

View File

@@ -8,19 +8,47 @@ nikolaik の `SANDBOX_RUNTIME_CONTAINER_IMAGE` は、ランタイムサーバー
## ファイルシステムへの接続
ここでの便利な機能の1つは、ローカルファイルシステムに接続する機能です。ファイルシステムをランタイムにマウントするには
### RUNTIME_MOUNT の使用(推奨)
ローカルファイルシステムをマウントする最も簡単な方法は、`RUNTIME_MOUNT` 環境変数を使用することです:
```bash
docker run # ...
-e SANDBOX_USER_ID=$(id -u) \
-e RUNTIME_MOUNT=/path/to/your/code:/workspace:rw \
# ...
```
`RUNTIME_MOUNT` の形式は:`ホストパス:コンテナパス[:モード]`
- `ホストパス`:マウントしたいホストマシン上のパス
- `コンテナパス`:ホストパスがマウントされるコンテナ内のパス(通常は `/workspace`
- `モード`:オプションのマウントモード、`rw`(読み書き可能、デフォルト)または `ro`(読み取り専用)
例:
```bash
# Linux と Mac の例
export RUNTIME_MOUNT=$HOME/OpenHands:/workspace:rw
# Windows の WSL の例
export RUNTIME_MOUNT=/mnt/c/dev/OpenHands:/workspace:rw
# 読み取り専用マウントの例
export RUNTIME_MOUNT=/path/to/reference/code:/workspace:ro
```
### WORKSPACE_* 変数の使用(非推奨)
> **注意:** この方法は非推奨であり、将来のバージョンで削除される予定です。代わりに `RUNTIME_MOUNT` を使用してください。
1. `WORKSPACE_BASE` を設定します:
```bash
export WORKSPACE_BASE=/path/to/your/code
# Linux と Mac の例
# export WORKSPACE_BASE=$HOME/OpenHands
# $WORKSPACE_BASE を /home/<username>/OpenHands に設定します
#
# Windows の WSL の例
# export WORKSPACE_BASE=/mnt/c/dev/OpenHands
# $WORKSPACE_BASE を C:\dev\OpenHands に設定します
```
2. 以下のオプションを `docker run` コマンドに追加します:
```bash

View File

@@ -20,7 +20,7 @@ MCP configuration is defined in the `[mcp]` section of your `config.toml` file.
sse_servers = [
# Basic SSE server with just a URL
"http://example.com:8080/mcp",
# SSE server with API key authentication
{url="https://secure-example.com/mcp", api_key="your-api-key"}
]
@@ -29,7 +29,7 @@ sse_servers = [
stdio_servers = [
# Basic stdio server
{name="fetch", command="uvx", args=["mcp-server-fetch"]},
# Stdio server with environment variables
{
name="data-processor",

View File

@@ -9,19 +9,47 @@ You can also [build your own runtime image](../how-to/custom-sandbox-guide).
## Connecting to Your filesystem
A useful feature is the ability to connect to your local filesystem. To mount your filesystem into the runtime:
### Using RUNTIME_MOUNT (Recommended)
The simplest way to mount your local filesystem is to use the `RUNTIME_MOUNT` environment variable:
```bash
docker run # ...
-e SANDBOX_USER_ID=$(id -u) \
-e RUNTIME_MOUNT=/path/to/your/code:/workspace:rw \
# ...
```
The `RUNTIME_MOUNT` format is: `host_path:container_path[:mode]`
- `host_path`: The path on your host machine that you want to mount
- `container_path`: The path inside the container where the host path will be mounted (typically `/workspace`)
- `mode`: Optional mount mode, either `rw` (read-write, default) or `ro` (read-only)
Examples:
```bash
# Linux and Mac Example
export RUNTIME_MOUNT=$HOME/OpenHands:/workspace:rw
# WSL on Windows Example
export RUNTIME_MOUNT=/mnt/c/dev/OpenHands:/workspace:rw
# Read-only mount example
export RUNTIME_MOUNT=/path/to/reference/code:/workspace:ro
```
### Using WORKSPACE_* variables (Deprecated)
> **Note:** This method is deprecated and will be removed in a future version. Please use `RUNTIME_MOUNT` instead.
1. Set `WORKSPACE_BASE`:
```bash
export WORKSPACE_BASE=/path/to/your/code
# Linux and Mac Example
# export WORKSPACE_BASE=$HOME/OpenHands
# Will set $WORKSPACE_BASE to /home/<username>/OpenHands
#
# WSL on Windows Example
# export WORKSPACE_BASE=/mnt/c/dev/OpenHands
# Will set $WORKSPACE_BASE to C:\dev\OpenHands
```
2. Add the following options to the `docker run` command:
```bash

View File

@@ -55,4 +55,4 @@
"node": ">=18.0"
},
"packageManager": "npm@10.5.0"
}
}

View File

@@ -36,13 +36,12 @@ from openhands.core.config import (
)
from openhands.core.logger import openhands_logger as logger
from openhands.core.main import create_runtime, run_controller
from openhands.events.action import CmdRunAction, MessageAction, FileReadAction
from openhands.events.action import CmdRunAction, FileReadAction, MessageAction
from openhands.events.observation import CmdOutputObservation, ErrorObservation
from openhands.events.serialization.event import event_to_dict
from openhands.runtime.base import Runtime
from openhands.utils.async_utils import call_async_from_sync
from openhands.utils.shutdown_listener import sleep_if_should_continue
import pdb
USE_HINT_TEXT = os.environ.get('USE_HINT_TEXT', 'false').lower() == 'true'
USE_INSTANCE_IMAGE = os.environ.get('USE_INSTANCE_IMAGE', 'true').lower() == 'true'
@@ -51,7 +50,7 @@ RUN_WITH_BROWSING = os.environ.get('RUN_WITH_BROWSING', 'false').lower() == 'tru
# TODO: migrate all swe-bench docker to ghcr.io/openhands
# TODO: 适应所有的语言
DOCKER_IMAGE_PREFIX = os.environ.get('EVAL_DOCKER_IMAGE_PREFIX', '')
LANGUAGE =os.environ.get('LANGUAGE', 'python')
LANGUAGE = os.environ.get('LANGUAGE', 'python')
logger.info(f'Using docker image prefix: {DOCKER_IMAGE_PREFIX}')
@@ -71,7 +70,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
# Instruction based on Anthropic's official trajectory
# https://github.com/eschluntz/swe-bench-experiments/tree/main/evaluation/verified/20241022_tools_claude-3-5-sonnet-updated/trajs
instructions = {
"python":(
'python': (
'<uploaded_files>\n'
f'/workspace/{workspace_dir_name}\n'
'</uploaded_files>\n'
@@ -96,7 +95,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
' Make sure all these tests pass with your changes.\n'
"Your thinking should be thorough and so it's fine if it's very long.\n"
),
"java": (
'java': (
'<uploaded_files>\n'
f'/workspace/{workspace_dir_name}\n'
'</uploaded_files>\n'
@@ -121,7 +120,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
" Make sure all these tests pass with your changes.\n"
"Your thinking should be thorough and so it's fine if it's very long.\n"
),
"go": (
'go': (
'<uploaded_files>\n'
f'/workspace/{workspace_dir_name}\n'
'</uploaded_files>\n'
@@ -146,7 +145,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
' Make sure all these tests pass with your changes.\n'
"Your thinking should be thorough and so it's fine if it's very long.\n"
),
"c": (
'c': (
'<uploaded_files>\n'
f'/workspace/{workspace_dir_name}\n'
'</uploaded_files>\n'
@@ -171,7 +170,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
' Make sure all these tests pass with your changes.\n'
"Your thinking should be thorough and so it's fine if it's very long.\n"
),
"cpp": (
'cpp': (
'<uploaded_files>\n'
f'/workspace/{workspace_dir_name}\n'
'</uploaded_files>\n'
@@ -196,7 +195,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
' Make sure all these tests pass with your changes.\n'
"Your thinking should be thorough and so it's fine if it's very long.\n"
),
"javascript": (
'javascript': (
'<uploaded_files>\n'
f'/workspace/{workspace_dir_name}\n'
'</uploaded_files>\n'
@@ -221,7 +220,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
' Make sure all these tests pass with your changes.\n'
"Your thinking should be thorough and so it's fine if it's very long.\n"
),
"typescript":(
'typescript': (
'<uploaded_files>\n'
f'/workspace/{workspace_dir_name}\n'
'</uploaded_files>\n'
@@ -246,7 +245,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
' Make sure all these tests pass with your changes.\n'
"Your thinking should be thorough and so it's fine if it's very long.\n"
),
"rust":(
'rust': (
'<uploaded_files>\n'
f'/workspace/{workspace_dir_name}\n'
'</uploaded_files>\n'
@@ -270,11 +269,10 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
' - The functions you changed\n'
' Make sure all these tests pass with your changes.\n'
"Your thinking should be thorough and so it's fine if it's very long.\n"
)
),
}
instruction = instructions.get(LANGUAGE.lower())
if instruction and RUN_WITH_BROWSING:
instruction += (
'<IMPORTANT!>\n'
@@ -284,7 +282,6 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
return instruction
# TODO: 适应所有的语言
# def get_instance_docker_image(instance_id: str) -> str:
# image_name = 'sweb.eval.x86_64.' + instance_id
@@ -307,16 +304,15 @@ def get_instance_docker_image(instance: pd.Series):
container_name = container_name.replace('/', '_m_')
instance_id = instance.get('instance_id', '')
tag_suffix = instance_id.split('-')[-1] if instance_id else ''
container_tag = f"pr-{tag_suffix}"
container_tag = f'pr-{tag_suffix}'
# pdb.set_trace()
return f"mswebench/{container_name}:{container_tag}"
return f'mswebench/{container_name}:{container_tag}'
# return "kong/insomnia:pr-8284"
# return "'sweb.eval.x86_64.local_insomnia"
# return "local_insomnia_why"
# return "local/kong-insomnia:pr-8117"
def get_config(
instance: pd.Series,
metadata: EvalMetadata,
@@ -569,7 +565,6 @@ def complete_runtime(
f'Failed to git config --global core.pager "": {str(obs)}',
)
action = CmdRunAction(command='git add -A')
action.set_hard_timeout(600)
logger.info(action, extra={'msg_type': 'ACTION'})
@@ -582,14 +577,14 @@ def complete_runtime(
##删除二进制文件
action = CmdRunAction(
command=f'''
command="""
for file in $(git status --porcelain | grep -E "^(M| M|\\?\\?|A| A)" | cut -c4-); do
if [ -f "$file" ] && (file "$file" | grep -q "executable" || git check-attr binary "$file" | grep -q "binary: set"); then
git rm -f "$file" 2>/dev/null || rm -f "$file"
echo "Removed: $file"
fi
done
'''
"""
)
action.set_hard_timeout(600)
logger.info(action, extra={'msg_type': 'ACTION'})
@@ -626,14 +621,12 @@ def complete_runtime(
else:
assert_and_raise(False, f'Unexpected observation type: {str(obs)}')
action = FileReadAction(
path='patch.diff'
)
action = FileReadAction(path='patch.diff')
action.set_hard_timeout(max(300 + 100 * n_retries, 600))
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
git_patch = obs.content
# pdb.set_trace()
# pdb.set_trace()
assert_and_raise(git_patch is not None, 'Failed to get git diff (None)')
@@ -714,12 +707,12 @@ def process_instance(
is_binary_block = False
for line in lines:
if line.startswith("diff --git "):
if line.startswith('diff --git '):
if block and not is_binary_block:
cleaned_lines.extend(block)
block = [line]
is_binary_block = False
elif "Binary files" in line:
elif 'Binary files' in line:
is_binary_block = True
block.append(line)
else:
@@ -727,7 +720,8 @@ def process_instance(
if block and not is_binary_block:
cleaned_lines.extend(block)
return "\n".join(cleaned_lines)
return '\n'.join(cleaned_lines)
git_patch = remove_binary_diffs(git_patch)
test_result = {
'git_patch': git_patch,
@@ -797,7 +791,7 @@ if __name__ == '__main__':
# so we don't need to manage file uploading to OpenHands's repo
# dataset = load_dataset(args.dataset, split=args.split)
# dataset = load_dataset(args.dataset)
dataset = load_dataset("json", data_files = args.dataset)
dataset = load_dataset('json', data_files=args.dataset)
dataset = dataset[args.split]
swe_bench_tests = filter_dataset(dataset.to_pandas(), 'instance_id')
logger.info(

View File

@@ -3,7 +3,9 @@ import json
input_file = 'XXX.jsonl'
output_file = 'YYY.jsonl'
with open(input_file, 'r', encoding='utf-8') as fin, open(output_file, 'w', encoding='utf-8') as fout:
with open(input_file, 'r', encoding='utf-8') as fin, open(
output_file, 'w', encoding='utf-8'
) as fout:
for line in fin:
line = line.strip()
if not line:
@@ -13,18 +15,22 @@ with open(input_file, 'r', encoding='utf-8') as fin, open(output_file, 'w', enco
item = data
# 提取原始数据
org = item.get("org", "")
repo = item.get("repo", "")
number = str(item.get("number", ""))
org = item.get('org', '')
repo = item.get('repo', '')
number = str(item.get('number', ''))
new_item = {}
new_item["repo"] = f"{org}/{repo}"
new_item["instance_id"] = f"{org}__{repo}-{number}"
new_item["problem_statement"] = item["resolved_issues"][0].get("title", "") + "\n" + item["resolved_issues"][0].get("body", "")
new_item["FAIL_TO_PASS"] = []
new_item["PASS_TO_PASS"] = []
new_item["base_commit"] = item['base'].get("sha","")
new_item["version"] = "0.1" # depends
new_item['repo'] = f'{org}/{repo}'
new_item['instance_id'] = f'{org}__{repo}-{number}'
new_item['problem_statement'] = (
item['resolved_issues'][0].get('title', '')
+ '\n'
+ item['resolved_issues'][0].get('body', '')
)
new_item['FAIL_TO_PASS'] = []
new_item['PASS_TO_PASS'] = []
new_item['base_commit'] = item['base'].get('sha', '')
new_item['version'] = '0.1' # depends
output_data = new_item
fout.write(json.dumps(output_data, ensure_ascii=False) + "\n")
fout.write(json.dumps(output_data, ensure_ascii=False) + '\n')

View File

@@ -15,7 +15,7 @@ def main():
'org': groups.group(1),
'repo': groups.group(2),
'number': groups.group(3),
'fix_patch': data['test_result']['git_patch']
'fix_patch': data['test_result']['git_patch'],
}
fout.write(json.dumps(patch) + '\n')

View File

@@ -27,7 +27,7 @@ describe("AuthModal", () => {
it("should render the GitHub and GitLab buttons", () => {
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
const githubButton = screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" });
const gitlabButton = screen.getByRole("button", { name: "GITLAB$CONNECT_TO_GITLAB" });

View File

@@ -43,7 +43,7 @@ const createWrapper = () => {
},
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
@@ -61,7 +61,7 @@ describe("AcceptTOS", () => {
it("should render a TOS checkbox that is unchecked by default", () => {
render(<AcceptTOS />, { wrapper: createWrapper() });
const checkbox = screen.getByRole("checkbox");
const continueButton = screen.getByRole("button", { name: "TOS$CONTINUE" });
@@ -72,7 +72,7 @@ describe("AcceptTOS", () => {
it("should enable the continue button when the TOS checkbox is checked", async () => {
const user = userEvent.setup();
render(<AcceptTOS />, { wrapper: createWrapper() });
const checkbox = screen.getByRole("checkbox");
const continueButton = screen.getByRole("button", { name: "TOS$CONTINUE" });
@@ -96,7 +96,7 @@ describe("AcceptTOS", () => {
const user = userEvent.setup();
render(<AcceptTOS />, { wrapper: createWrapper() });
const checkbox = screen.getByRole("checkbox");
await user.click(checkbox);
@@ -121,7 +121,7 @@ describe("AcceptTOS", () => {
const user = userEvent.setup();
render(<AcceptTOS />, { wrapper: createWrapper() });
const checkbox = screen.getByRole("checkbox");
await user.click(checkbox);
@@ -133,4 +133,4 @@ describe("AcceptTOS", () => {
expect(window.location.href).toBe(externalUrl);
});
});
});

View File

@@ -152,6 +152,22 @@ export function handleAssistantMessage(message: Record<string, unknown>) {
handleObservationMessage(message as unknown as ObservationMessage);
} else if (message.status_update) {
handleStatusMessage(message as unknown as StatusMessage);
} else if (message.error) {
// Handle error messages from the server
const errorMessage =
typeof message.message === "string"
? message.message
: String(message.message || "Unknown error");
trackError({
message: errorMessage,
source: "websocket",
metadata: { raw_message: message },
});
store.dispatch(
addErrorMessage({
message: errorMessage,
}),
);
} else {
const errorMsg = "Unknown message type received";
trackError({

View File

@@ -76,7 +76,7 @@ def response_to_actions(
try:
arguments = json.loads(tool_call.function.arguments)
except json.decoder.JSONDecodeError as e:
raise RuntimeError(
raise FunctionCallValidationError(
f'Failed to parse tool call arguments: {tool_call.function.arguments}'
) from e

View File

@@ -127,7 +127,7 @@ def response_to_actions(
try:
arguments = json.loads(tool_call.function.arguments)
except json.decoder.JSONDecodeError as e:
raise RuntimeError(
raise FunctionCallValidationError(
f'Failed to parse tool call arguments: {tool_call.function.arguments}'
) from e

View File

@@ -741,10 +741,6 @@ class AgentController:
content = (
f'{self.delegate.agent.name} finishes task with {formatted_output}'
)
# emit the delegate result observation
obs = AgentDelegateObservation(outputs=delegate_outputs, content=content)
self.event_stream.add_event(obs, EventSource.AGENT)
else:
# delegate state is ERROR
# emit AgentDelegateObservation with error content
@@ -755,13 +751,22 @@ class AgentController:
f'{self.delegate.agent.name} encountered an error during execution.'
)
# emit the delegate result observation
obs = AgentDelegateObservation(outputs=delegate_outputs, content=content)
self.event_stream.add_event(obs, EventSource.AGENT)
content = f'Delegated agent finished with result:\n\n{content}'
# emit the delegate result observation
obs = AgentDelegateObservation(outputs=delegate_outputs, content=content)
# associate the delegate action with the initiating tool call
for event in reversed(self.state.history):
if isinstance(event, AgentDelegateAction):
delegate_action = event
obs.tool_call_metadata = delegate_action.tool_call_metadata
break
self.event_stream.add_event(obs, EventSource.AGENT)
# unset delegate so parent can resume normal handling
self.delegate = None
self.delegateAction = None
async def _step(self) -> None:
"""Executes a single step of the parent or delegate agent. Detects stuck agents and limits on the number of iterations and the task budget."""

View File

@@ -63,10 +63,14 @@ class AppConfig(BaseModel):
save_trajectory_path: str | None = Field(default=None)
save_screenshots_in_trajectory: bool = Field(default=False)
replay_trajectory_path: str | None = Field(default=None)
workspace_base: str | None = Field(default=None)
workspace_mount_path: str | None = Field(default=None)
workspace_mount_path_in_sandbox: str = Field(default='/workspace')
workspace_mount_rewrite: str | None = Field(default=None)
# New mount parameter that replaces the workspace_* parameters
runtime_mount: str | None = Field(default=None, description="Mount specification in the format 'host_path:container_path:mode', e.g. '/my/host/dir:/workspace:rw'")
# Deprecated parameters - will be removed in a future version
workspace_base: str | None = Field(default=None, deprecated=True)
workspace_mount_path: str | None = Field(default=None, deprecated=True)
workspace_mount_path_in_sandbox: str = Field(default='/workspace', deprecated=True)
workspace_mount_rewrite: str | None = Field(default=None, deprecated=True)
cache_dir: str = Field(default='/tmp/cache')
run_as_openhands: bool = Field(default=True)
max_iterations: int = Field(default=OH_MAX_ITERATIONS)

View File

@@ -294,10 +294,39 @@ def get_or_create_jwt_secret(file_store: FileStore) -> str:
def finalize_config(cfg: AppConfig) -> None:
"""More tweaks to the config after it's been loaded."""
if cfg.workspace_base is not None:
cfg.workspace_base = os.path.abspath(cfg.workspace_base)
if cfg.workspace_mount_path is None:
cfg.workspace_mount_path = cfg.workspace_base
# Handle the new runtime_mount parameter
if cfg.runtime_mount is not None:
# Parse the runtime_mount parameter
parts = cfg.runtime_mount.split(':')
if len(parts) < 2 or len(parts) > 3:
raise ValueError(
f"Invalid runtime_mount format: {cfg.runtime_mount}. "
f"Expected format: 'host_path:container_path[:mode]', e.g. '/my/host/dir:/workspace:rw'"
)
host_path = os.path.abspath(parts[0])
container_path = parts[1]
# Default mode is 'rw' if not specified
mode = parts[2] if len(parts) > 2 else 'rw'
# Set the workspace_mount_path and workspace_mount_path_in_sandbox for backward compatibility
cfg.workspace_mount_path = host_path
cfg.workspace_mount_path_in_sandbox = container_path
# Also set workspace_base for backward compatibility
cfg.workspace_base = host_path
# Handle the deprecated workspace_* parameters
elif cfg.workspace_base is not None or cfg.workspace_mount_path is not None:
logger.openhands_logger.warning(
"DEPRECATED: The WORKSPACE_BASE and WORKSPACE_MOUNT_PATH environment variables are deprecated. "
"Please use RUNTIME_MOUNT instead, e.g. 'RUNTIME_MOUNT=/my/host/dir:/workspace:rw'"
)
if cfg.workspace_base is not None:
cfg.workspace_base = os.path.abspath(cfg.workspace_base)
if cfg.workspace_mount_path is None:
cfg.workspace_mount_path = cfg.workspace_base
if cfg.workspace_mount_rewrite:
base = cfg.workspace_base or os.getcwd()

View File

@@ -390,7 +390,9 @@ class GitHubService(BaseGitService, GitService):
except Exception:
return []
async def get_repository_details_from_repo_name(self, repository: str) -> Repository:
async def get_repository_details_from_repo_name(
self, repository: str
) -> Repository:
url = f'{self.BASE_URL}/repos/{repository}'
repo, _ = await self._make_request(url)

View File

@@ -382,9 +382,10 @@ class GitLabService(BaseGitService, GitService):
except Exception:
return []
async def get_repository_details_from_repo_name(self, repository: str) -> Repository:
encoded_name = repository.replace("/", "%2F")
async def get_repository_details_from_repo_name(
self, repository: str
) -> Repository:
encoded_name = repository.replace('/', '%2F')
url = f'{self.BASE_URL}/projects/{encoded_name}'
repo, _ = await self._make_request(url)
@@ -396,8 +397,6 @@ class GitLabService(BaseGitService, GitService):
git_provider=ProviderType.GITLAB,
is_public=repo.get('visibility') == 'public',
)
gitlab_service_cls = os.environ.get(

View File

@@ -3,4 +3,4 @@ Use the {{ apiName }} with the {{ tokenEnvVar }} environment variable to retriev
Check out the branch from that {{ requestVerb }} and look at the diff versus the base branch of the {{ requestTypeShort }} to understand the {{ requestTypeShort }}'s intention.
Then use the {{ apiName }} to look at the {{ ciSystem }} that are failing on the most recent commit. Try and reproduce the failure locally.
Get things working locally, then push your changes. Sleep for 30 seconds at a time until the {{ ciProvider }} {{ ciSystem.lower() }} have run again.
If they are still failing, repeat the process.
If they are still failing, repeat the process.

View File

@@ -1,4 +1,4 @@
You are working on {{ requestType }} #{{ issue_number }} in repository {{ repo }}. You need to fix the merge conflicts.
Use the {{ apiName }} with the {{ tokenEnvVar }} environment variable to retrieve the {{ requestTypeShort }} details.
Check out the branch from that {{ requestVerb }} and look at the diff versus the base branch of the {{ requestTypeShort }} to understand the {{ requestTypeShort }}'s intention.
Then resolve the merge conflicts. If you aren't sure what the right solution is, look back through the commit history at the commits that introduced the conflict and resolve them accordingly.
Then resolve the merge conflicts. If you aren't sure what the right solution is, look back through the commit history at the commits that introduced the conflict and resolve them accordingly.

View File

@@ -1,4 +1,4 @@
You are working on Issue #{{ issue_number }} in repository {{ repo }}. Your goal is to fix the issue.
Use the {{ apiName }} with the {{ tokenEnvVar }} environment variable to retrieve the issue details and any comments on the issue.
Then check out a new branch and investigate what changes will need to be made.
Finally, make the required changes and open up a {{ requestVerb }}. Be sure to reference the issue in the {{ requestTypeShort }} description.
Finally, make the required changes and open up a {{ requestVerb }}. Be sure to reference the issue in the {{ requestTypeShort }} description.

View File

@@ -2,4 +2,4 @@ You are working on {{ requestType }} #{{ issue_number }} in repository {{ repo }
Use the {{ apiName }} with the {{ tokenEnvVar }} environment variable to retrieve the {{ requestTypeShort }} details.
Check out the branch from that {{ requestVerb }} and look at the diff versus the base branch of the {{ requestTypeShort }} to understand the {{ requestTypeShort }}'s intention.
Then use the {{ apiName }} to retrieve all the feedback on the {{ requestTypeShort }} so far.
If anything hasn't been addressed, address it and commit your changes back to the same branch.
If anything hasn't been addressed, address it and commit your changes back to the same branch.

View File

@@ -412,7 +412,7 @@ class ConversationMemory:
logger.debug('Vision disabled for browsing, showing text')
elif isinstance(obs, AgentDelegateObservation):
text = truncate_content(
obs.outputs['content'] if 'content' in obs.outputs else '',
obs.outputs.get('content', obs.content),
max_message_chars,
)
message = Message(role='user', content=[TextContent(text=text)])

View File

@@ -1,4 +1,3 @@
import asyncio
import os
import tempfile
import threading
@@ -46,6 +45,7 @@ from openhands.runtime.utils.request import send_request
from openhands.utils.http_session import HttpSession
from openhands.utils.tenacity_stop import stop_if_should_exit
def _is_retryable_error(exception):
return isinstance(
exception, (httpx.RemoteProtocolError, httpcore.RemoteProtocolError)
@@ -358,26 +358,27 @@ class ActionExecutionClient(Runtime):
async def call_tool_mcp(self, action: MCPAction) -> Observation:
# Import here to avoid circular imports
from openhands.mcp.utils import create_mcp_clients, call_tool_mcp as call_tool_mcp_handler
from openhands.mcp.utils import call_tool_mcp as call_tool_mcp_handler
from openhands.mcp.utils import create_mcp_clients
# Get the updated MCP config
updated_mcp_config = self.get_updated_mcp_config()
self.log(
'debug',
f'Creating MCP clients with servers: {updated_mcp_config.sse_servers}',
)
# Create clients for this specific operation
mcp_clients = await create_mcp_clients(updated_mcp_config.sse_servers)
# Call the tool and return the result
# No need for try/finally since disconnect() is now just resetting state
result = await call_tool_mcp_handler(mcp_clients, action)
# Reset client state (no active connections to worry about)
for client in mcp_clients:
await client.disconnect()
return result
def close(self) -> None:

View File

@@ -272,14 +272,23 @@ class DockerRuntime(ActionExecutionClient):
self.config.workspace_mount_path is not None
and self.config.workspace_mount_path_in_sandbox is not None
):
# Determine the mount mode
mount_mode = 'rw' # Default mode
# If runtime_mount is set, extract the mode from it
if self.config.runtime_mount is not None:
parts = self.config.runtime_mount.split(':')
if len(parts) > 2:
mount_mode = parts[2]
# e.g. result would be: {"/home/user/openhands/workspace": {'bind': "/workspace", 'mode': 'rw'}}
volumes = {
self.config.workspace_mount_path: {
'bind': self.config.workspace_mount_path_in_sandbox,
'mode': 'rw',
'mode': mount_mode,
}
}
logger.debug(f'Mount dir: {self.config.workspace_mount_path}')
logger.debug(f'Mount dir: {self.config.workspace_mount_path} with mode: {mount_mode}')
else:
logger.debug(
'Mount dir is not set, will not mount the workspace directory to the container'

View File

@@ -10,8 +10,8 @@ from openhands.events.event_store import EventStore
from openhands.server.config.server_config import ServerConfig
from openhands.server.monitoring import MonitoringListener
from openhands.server.session.conversation import Conversation
from openhands.storage.data_models.settings import Settings
from openhands.storage.conversation.conversation_store import ConversationStore
from openhands.storage.data_models.settings import Settings
from openhands.storage.files import FileStore

View File

@@ -18,9 +18,9 @@ from openhands.server.monitoring import MonitoringListener
from openhands.server.session.agent_session import WAIT_TIME_BEFORE_CLOSE
from openhands.server.session.conversation import Conversation
from openhands.server.session.session import ROOM_KEY, Session
from openhands.storage.data_models.settings import Settings
from openhands.storage.conversation.conversation_store import ConversationStore
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
from openhands.storage.data_models.settings import Settings
from openhands.storage.files import FileStore
from openhands.utils.async_utils import GENERAL_TIMEOUT, call_async_from_sync, wait_all
from openhands.utils.import_utils import get_impl

View File

@@ -14,7 +14,11 @@ from openhands.integrations.provider import (
PROVIDER_TOKEN_TYPE,
ProviderHandler,
)
from openhands.integrations.service_types import AuthenticationError, ProviderType, Repository, SuggestedTask
from openhands.integrations.service_types import (
AuthenticationError,
ProviderType,
SuggestedTask,
)
from openhands.runtime import get_runtime_cls
from openhands.server.data_models.conversation_info import ConversationInfo
from openhands.server.data_models.conversation_info_result_set import (
@@ -45,7 +49,6 @@ from openhands.storage.data_models.conversation_status import ConversationStatus
from openhands.utils.async_utils import wait_all
from openhands.utils.conversation_summary import generate_conversation_title
app = APIRouter(prefix='/api')
@@ -58,7 +61,7 @@ class InitSessionRequest(BaseModel):
image_urls: list[str] | None = None
replay_json: str | None = None
suggested_task: SuggestedTask | None = None
async def _create_new_conversation(
user_id: str | None,
@@ -71,10 +74,13 @@ async def _create_new_conversation(
conversation_trigger: ConversationTrigger = ConversationTrigger.GUI,
attach_convo_id: bool = False,
):
logger.info(
'Creating conversation',
extra={'signal': 'create_conversation', 'user_id': user_id, 'trigger': conversation_trigger.value},
extra={
'signal': 'create_conversation',
'user_id': user_id,
'trigger': conversation_trigger.value,
},
)
logger.info('Loading settings')
settings_store = await SettingsStoreImpl.get_instance(config, user_id)
@@ -163,7 +169,7 @@ async def new_conversation(
data: InitSessionRequest,
user_id: str = Depends(get_user_id),
provider_tokens: PROVIDER_TOKEN_TYPE = Depends(get_provider_tokens),
auth_type: AuthType | None = Depends(get_auth_type)
auth_type: AuthType | None = Depends(get_auth_type),
):
"""Initialize a new session or join an existing one.
@@ -202,7 +208,7 @@ async def new_conversation(
initial_user_msg=initial_user_msg,
image_urls=image_urls,
replay_json=replay_json,
conversation_trigger=conversation_trigger
conversation_trigger=conversation_trigger,
)
return JSONResponse(
@@ -227,13 +233,13 @@ async def new_conversation(
},
status_code=status.HTTP_400_BAD_REQUEST,
)
except AuthenticationError as e:
return JSONResponse(
content={
'status': 'error',
'message': str(e),
'msg_id': 'STATUS$GIT_PROVIDER_AUTHENTICATION_ERROR'
'msg_id': 'STATUS$GIT_PROVIDER_AUTHENTICATION_ERROR',
},
status_code=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -2,9 +2,8 @@ from typing import Any
from fastapi import APIRouter
from openhands.security.options import SecurityAnalyzers
from openhands.controller.agent import Agent
from openhands.security.options import SecurityAnalyzers
from openhands.server.shared import config, server_config
from openhands.utils.llm import get_supported_llm_models

View File

@@ -15,12 +15,12 @@ from openhands.server.settings import (
POSTSettingsModel,
)
from openhands.server.shared import config
from openhands.storage.data_models.settings import Settings
from openhands.server.user_auth import (
get_provider_tokens,
get_user_settings,
get_user_settings_store,
)
from openhands.storage.data_models.settings import Settings
from openhands.storage.settings.settings_store import SettingsStore
app = APIRouter(prefix='/api')
@@ -38,7 +38,7 @@ async def load_settings(
content={'error': 'Settings not found'},
)
provider_tokens_set: dict[ProviderType, str | None] = {}
provider_tokens_set: dict[ProviderType, str | None] = {}
if provider_tokens:
for provider_type, provider_token in provider_tokens.items():
if provider_token.token or provider_token.user_id:
@@ -227,8 +227,7 @@ async def store_provider_tokens(
if existing_settings:
if existing_settings.secrets_store:
existing_providers = [
provider
for provider in existing_settings.secrets_store.provider_tokens
provider for provider in existing_settings.secrets_store.provider_tokens
]
# Merge incoming settings store with the existing one
@@ -245,7 +244,7 @@ async def store_provider_tokens(
else: # nothing passed in means keep current settings
provider_tokens = dict(existing_settings.secrets_store.provider_tokens)
settings.provider_tokens = provider_tokens
return settings
@@ -334,7 +333,11 @@ def convert_to_settings(settings_with_token_data: POSTSettingsModel) -> Settings
# Create new provider tokens immutably
if settings_with_token_data.provider_tokens:
settings = settings.model_copy(
update={'secrets_store': SecretStore(provider_tokens=settings_with_token_data.provider_tokens)}
update={
'secrets_store': SecretStore(
provider_tokens=settings_with_token_data.provider_tokens
)
}
)
return settings

View File

@@ -17,7 +17,6 @@ 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 PROVIDER_TOKEN_TYPE, ProviderHandler
from openhands.integrations.service_types import Repository
from openhands.mcp import add_mcp_tools_to_agent
from openhands.memory.memory import Memory
from openhands.microagent.microagent import BaseMicroagent
@@ -420,9 +419,7 @@ class AgentSession:
memory.load_user_workspace_microagents(microagents)
if selected_repository and repo_directory:
memory.set_repository_info(
selected_repository, repo_directory
)
memory.set_repository_info(selected_repository, repo_directory)
return memory
def _maybe_restore_state(self) -> State | None:

View File

@@ -21,8 +21,8 @@ from openhands.events.observation import (
CmdOutputObservation,
NullObservation,
)
from openhands.events.observation.error import ErrorObservation
from openhands.events.observation.agent import RecallObservation
from openhands.events.observation.error import ErrorObservation
from openhands.events.serialization import event_from_dict, event_to_dict
from openhands.events.stream import EventStreamSubscriber
from openhands.llm.llm import LLM
@@ -214,7 +214,8 @@ class Session:
await self.send(event_to_dict(event))
# NOTE: ipython observations are not sent here currently
elif event.source == EventSource.ENVIRONMENT and isinstance(
event, (CmdOutputObservation, AgentStateChangedObservation, RecallObservation)
event,
(CmdOutputObservation, AgentStateChangedObservation, RecallObservation),
):
# feedback from the environment to agent actions is understood as agent events by the UI
event_dict = event_to_dict(event)

View File

@@ -50,4 +50,4 @@ async def get_user_settings_store(request: Request) -> SettingsStore | None:
async def get_auth_type(request: Request) -> AuthType | None:
user_auth = await get_user_auth(request)
return user_auth.get_auth_type()
return user_auth.get_auth_type()

View File

@@ -51,7 +51,6 @@ class DefaultUserAuth(UserAuth):
provider_tokens = getattr(secrets_store, 'provider_tokens', None)
return provider_tokens
@classmethod
async def get_instance(cls, request: Request) -> UserAuth:
user_auth = DefaultUserAuth()

View File

@@ -14,8 +14,8 @@ from openhands.utils.import_utils import get_impl
class AuthType(Enum):
COOKIE = "cookie"
BEARER = "bearer"
COOKIE = 'cookie'
BEARER = 'bearer'
class UserAuth(ABC):

View File

@@ -4,8 +4,8 @@ import json
from dataclasses import dataclass
from openhands.core.config.app_config import AppConfig
from openhands.storage.data_models.settings import Settings
from openhands.storage import get_file_store
from openhands.storage.data_models.settings import Settings
from openhands.storage.files import FileStore
from openhands.storage.settings.settings_store import SettingsStore
from openhands.utils.async_utils import call_sync_from_async

83
poetry.lock generated
View File

@@ -496,18 +496,18 @@ files = [
[[package]]
name = "boto3"
version = "1.38.6"
version = "1.38.7"
description = "The AWS SDK for Python"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "boto3-1.38.6-py3-none-any.whl", hash = "sha256:fe5bbd349310ef560b247e61453983ee6078ad4c2672620ca66bc0d29d64e728"},
{file = "boto3-1.38.6.tar.gz", hash = "sha256:9d764c402cadd112020812b9621a567058aa29d41a491d2d04b070be19ad5173"},
{file = "boto3-1.38.7-py3-none-any.whl", hash = "sha256:c548983189b0a88f09cd4c572519b1923695b25cd877def58b61e03f41a6fd96"},
{file = "boto3-1.38.7.tar.gz", hash = "sha256:0269f793f0affc646b95c2cd12d42a4db49d5f30ef1073f616a112a384933f8e"},
]
[package.dependencies]
botocore = ">=1.38.6,<1.39.0"
botocore = ">=1.38.7,<1.39.0"
jmespath = ">=0.7.1,<2.0.0"
s3transfer = ">=0.12.0,<0.13.0"
@@ -516,14 +516,14 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
[[package]]
name = "boto3-stubs"
version = "1.38.6"
description = "Type annotations for boto3 1.38.6 generated with mypy-boto3-builder 8.10.1"
version = "1.38.7"
description = "Type annotations for boto3 1.38.7 generated with mypy-boto3-builder 8.10.1"
optional = false
python-versions = ">=3.8"
groups = ["evaluation"]
files = [
{file = "boto3_stubs-1.38.6-py3-none-any.whl", hash = "sha256:501117865f52445654c9192885abd5995cfd31fec46aac02c7f5ea0b78492252"},
{file = "boto3_stubs-1.38.6.tar.gz", hash = "sha256:3459d1203da5ca3400a1606b5dac53b10c31877ac11bbf95a82b85784ff32771"},
{file = "boto3_stubs-1.38.7-py3-none-any.whl", hash = "sha256:1d3fbc6a3a79d1bf91da081c22a83797297d261190b4a36052aac4953425fb17"},
{file = "boto3_stubs-1.38.7.tar.gz", hash = "sha256:fda87e0ef0dbada1179b6bf563567ddf45d6d014c5d682d2b5db101263c2eb44"},
]
[package.dependencies]
@@ -579,7 +579,7 @@ bedrock-data-automation-runtime = ["mypy-boto3-bedrock-data-automation-runtime (
bedrock-runtime = ["mypy-boto3-bedrock-runtime (>=1.38.0,<1.39.0)"]
billing = ["mypy-boto3-billing (>=1.38.0,<1.39.0)"]
billingconductor = ["mypy-boto3-billingconductor (>=1.38.0,<1.39.0)"]
boto3 = ["boto3 (==1.38.6)"]
boto3 = ["boto3 (==1.38.7)"]
braket = ["mypy-boto3-braket (>=1.38.0,<1.39.0)"]
budgets = ["mypy-boto3-budgets (>=1.38.0,<1.39.0)"]
ce = ["mypy-boto3-ce (>=1.38.0,<1.39.0)"]
@@ -944,14 +944,14 @@ xray = ["mypy-boto3-xray (>=1.38.0,<1.39.0)"]
[[package]]
name = "botocore"
version = "1.38.6"
version = "1.38.7"
description = "Low-level, data-driven core of boto 3."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "botocore-1.38.6-py3-none-any.whl", hash = "sha256:ccac197e444b7fcdc2ddbdafddddf9f82454a7e1f9d2a55ef9dcc0258b3b27e3"},
{file = "botocore-1.38.6.tar.gz", hash = "sha256:07e0721f6b1758183ed425f481a7af79e4897a3c02c2c486e101c576aee7377c"},
{file = "botocore-1.38.7-py3-none-any.whl", hash = "sha256:a002ec18cc02c4b039d20c39ca88ecf2fdb9533c0a5f3670e8c0fcdd3ee4a045"},
{file = "botocore-1.38.7.tar.gz", hash = "sha256:5c6df7171390437683072aadc0d2dfbcbfa72df52a134a5d4bed811ed214c3df"},
]
[package.dependencies]
@@ -2857,7 +2857,7 @@ grpcio = {version = ">=1.49.1,<2.0dev", optional = true, markers = "python_versi
grpcio-status = {version = ">=1.49.1,<2.0.dev0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
proto-plus = [
{version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0dev"},
{version = ">=1.22.3,<2.0.0dev", markers = "python_version < \"3.13\""},
]
protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0"
requests = ">=2.18.0,<3.0.0.dev0"
@@ -3072,7 +3072,7 @@ google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev"
grpc-google-iam-v1 = ">=0.14.0,<1.0.0dev"
proto-plus = [
{version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0dev", markers = "python_version < \"3.13\""},
{version = ">=1.22.3,<2.0.0dev"},
]
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev"
@@ -5108,14 +5108,14 @@ files = [
[[package]]
name = "mcp"
version = "1.6.0"
version = "1.7.0"
description = "Model Context Protocol SDK"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "mcp-1.6.0-py3-none-any.whl", hash = "sha256:7bd24c6ea042dbec44c754f100984d186620d8b841ec30f1b19eda9b93a634d0"},
{file = "mcp-1.6.0.tar.gz", hash = "sha256:d9324876de2c5637369f43161cd71eebfd803df5a95e46225cab8d280e366723"},
{file = "mcp-1.7.0-py3-none-any.whl", hash = "sha256:0e874a587f542211f36f87b02c9acd6ad2534976fb2c36ff794d8b2bc0a91915"},
{file = "mcp-1.7.0.tar.gz", hash = "sha256:8b955eddf5fbb09613c4d517956745d932db9f757673200112b336bd8092509f"},
]
[package.dependencies]
@@ -5124,9 +5124,10 @@ httpx = ">=0.27"
httpx-sse = ">=0.4"
pydantic = ">=2.7.2,<3.0.0"
pydantic-settings = ">=2.5.2"
python-multipart = ">=0.0.9"
sse-starlette = ">=1.6.1"
starlette = ">=0.27"
uvicorn = ">=0.23.1"
uvicorn = {version = ">=0.23.1", markers = "sys_platform != \"emscripten\""}
[package.extras]
cli = ["python-dotenv (>=1.0.0)", "typer (>=0.12.4)"]
@@ -5217,14 +5218,14 @@ files = [
[[package]]
name = "modal"
version = "0.74.39"
version = "0.74.40"
description = "Python client library for Modal"
optional = false
python-versions = ">=3.9"
groups = ["main", "evaluation"]
files = [
{file = "modal-0.74.39-py3-none-any.whl", hash = "sha256:6ac666ef159eff8e0d33c279894cd8cc8b924510a871f207955cbd8531f42db6"},
{file = "modal-0.74.39.tar.gz", hash = "sha256:06a2ec55d1591918ce6b51b620148330006b2e81ed6e0f1b8cf4a18f124e5e67"},
{file = "modal-0.74.40-py3-none-any.whl", hash = "sha256:2cb1f2c44f8b4965f7d12713074d0761ae43cd00dd19d22689ec3d431ef2a65a"},
{file = "modal-0.74.40.tar.gz", hash = "sha256:b36f16a52aaa1d22fa8e2d98543c72e0758ca9b0c6967cfd7e9e98395b8fb135"},
]
[package.dependencies]
@@ -8570,30 +8571,30 @@ files = [
[[package]]
name = "ruff"
version = "0.11.7"
version = "0.11.8"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
groups = ["dev", "evaluation"]
files = [
{file = "ruff-0.11.7-py3-none-linux_armv6l.whl", hash = "sha256:d29e909d9a8d02f928d72ab7837b5cbc450a5bdf578ab9ebee3263d0a525091c"},
{file = "ruff-0.11.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dd1fb86b168ae349fb01dd497d83537b2c5541fe0626e70c786427dd8363aaee"},
{file = "ruff-0.11.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d3d7d2e140a6fbbc09033bce65bd7ea29d6a0adeb90b8430262fbacd58c38ada"},
{file = "ruff-0.11.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4809df77de390a1c2077d9b7945d82f44b95d19ceccf0c287c56e4dc9b91ca64"},
{file = "ruff-0.11.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3a0c2e169e6b545f8e2dba185eabbd9db4f08880032e75aa0e285a6d3f48201"},
{file = "ruff-0.11.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49b888200a320dd96a68e86736cf531d6afba03e4f6cf098401406a257fcf3d6"},
{file = "ruff-0.11.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2b19cdb9cf7dae00d5ee2e7c013540cdc3b31c4f281f1dacb5a799d610e90db4"},
{file = "ruff-0.11.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64e0ee994c9e326b43539d133a36a455dbaab477bc84fe7bfbd528abe2f05c1e"},
{file = "ruff-0.11.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bad82052311479a5865f52c76ecee5d468a58ba44fb23ee15079f17dd4c8fd63"},
{file = "ruff-0.11.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7940665e74e7b65d427b82bffc1e46710ec7f30d58b4b2d5016e3f0321436502"},
{file = "ruff-0.11.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:169027e31c52c0e36c44ae9a9c7db35e505fee0b39f8d9fca7274a6305295a92"},
{file = "ruff-0.11.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:305b93f9798aee582e91e34437810439acb28b5fc1fee6b8205c78c806845a94"},
{file = "ruff-0.11.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a681db041ef55550c371f9cd52a3cf17a0da4c75d6bd691092dfc38170ebc4b6"},
{file = "ruff-0.11.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:07f1496ad00a4a139f4de220b0c97da6d4c85e0e4aa9b2624167b7d4d44fd6b6"},
{file = "ruff-0.11.7-py3-none-win32.whl", hash = "sha256:f25dfb853ad217e6e5f1924ae8a5b3f6709051a13e9dad18690de6c8ff299e26"},
{file = "ruff-0.11.7-py3-none-win_amd64.whl", hash = "sha256:0a931d85959ceb77e92aea4bbedfded0a31534ce191252721128f77e5ae1f98a"},
{file = "ruff-0.11.7-py3-none-win_arm64.whl", hash = "sha256:778c1e5d6f9e91034142dfd06110534ca13220bfaad5c3735f6cb844654f6177"},
{file = "ruff-0.11.7.tar.gz", hash = "sha256:655089ad3224070736dc32844fde783454f8558e71f501cb207485fe4eee23d4"},
{file = "ruff-0.11.8-py3-none-linux_armv6l.whl", hash = "sha256:896a37516c594805e34020c4a7546c8f8a234b679a7716a3f08197f38913e1a3"},
{file = "ruff-0.11.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ab86d22d3d721a40dd3ecbb5e86ab03b2e053bc93c700dc68d1c3346b36ce835"},
{file = "ruff-0.11.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:258f3585057508d317610e8a412788cf726efeefa2fec4dba4001d9e6f90d46c"},
{file = "ruff-0.11.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:727d01702f7c30baed3fc3a34901a640001a2828c793525043c29f7614994a8c"},
{file = "ruff-0.11.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3dca977cc4fc8f66e89900fa415ffe4dbc2e969da9d7a54bfca81a128c5ac219"},
{file = "ruff-0.11.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c657fa987d60b104d2be8b052d66da0a2a88f9bd1d66b2254333e84ea2720c7f"},
{file = "ruff-0.11.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f2e74b021d0de5eceb8bd32919f6ff8a9b40ee62ed97becd44993ae5b9949474"},
{file = "ruff-0.11.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f9b5ef39820abc0f2c62111f7045009e46b275f5b99d5e59dda113c39b7f4f38"},
{file = "ruff-0.11.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c1dba3135ca503727aa4648152c0fa67c3b1385d3dc81c75cd8a229c4b2a1458"},
{file = "ruff-0.11.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f024d32e62faad0f76b2d6afd141b8c171515e4fb91ce9fd6464335c81244e5"},
{file = "ruff-0.11.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d365618d3ad747432e1ae50d61775b78c055fee5936d77fb4d92c6f559741948"},
{file = "ruff-0.11.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4d9aaa91035bdf612c8ee7266153bcf16005c7c7e2f5878406911c92a31633cb"},
{file = "ruff-0.11.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0eba551324733efc76116d9f3a0d52946bc2751f0cd30661564117d6fd60897c"},
{file = "ruff-0.11.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:161eb4cff5cfefdb6c9b8b3671d09f7def2f960cee33481dd898caf2bcd02304"},
{file = "ruff-0.11.8-py3-none-win32.whl", hash = "sha256:5b18caa297a786465cc511d7f8be19226acf9c0a1127e06e736cd4e1878c3ea2"},
{file = "ruff-0.11.8-py3-none-win_amd64.whl", hash = "sha256:6e70d11043bef637c5617297bdedec9632af15d53ac1e1ba29c448da9341b0c4"},
{file = "ruff-0.11.8-py3-none-win_arm64.whl", hash = "sha256:304432e4c4a792e3da85b7699feb3426a0908ab98bf29df22a31b0cdd098fac2"},
{file = "ruff-0.11.8.tar.gz", hash = "sha256:6d742d10626f9004b781f4558154bb226620a7242080e11caeffab1a40e99df8"},
]
[[package]]
@@ -11118,4 +11119,4 @@ cffi = ["cffi (>=1.11)"]
[metadata]
lock-version = "2.1"
python-versions = "^3.12"
content-hash = "f1ce7e74efef9d2c06783bb4167b7ab558459ed63bcd99c8549f984bbb4d0034"
content-hash = "fc267a36d8218a751e29c461ea1a769c8cc76b11e1eb55e860f85d8df349bb95"

View File

@@ -72,7 +72,7 @@ ipywidgets = "^8.1.5"
qtconsole = "^5.6.1"
memory-profiler = "^0.61.0"
daytona-sdk = "0.15.0"
mcp = "1.6.0"
mcp = "1.7.0"
python-json-logger = "^3.2.1"
playwright = "^1.51.0"
prompt-toolkit = "^3.0.50"
@@ -81,7 +81,7 @@ poetry = "^2.1.2"
anyio = "4.9.0"
[tool.poetry.group.dev.dependencies]
ruff = "0.11.7"
ruff = "0.11.8"
mypy = "1.15.0"
pre-commit = "4.2.0"
build = "*"

View File

@@ -26,7 +26,7 @@ from openhands.resolver.resolver_output import ResolverOutput
@pytest.fixture
def default_mock_args():
"""Fixture that provides a default mock args object with common values.
Tests can override specific attributes as needed.
"""
mock_args = MagicMock()
@@ -53,10 +53,13 @@ def default_mock_args():
@pytest.fixture
def mock_github_token():
"""Fixture that patches the identify_token function to return GitHub provider type.
This eliminates the need for repeated patching in each test function.
"""
with patch('openhands.resolver.resolve_issue.identify_token', return_value=ProviderType.GITHUB) as patched:
with patch(
'openhands.resolver.resolve_issue.identify_token',
return_value=ProviderType.GITHUB,
) as patched:
yield patched
@@ -152,7 +155,9 @@ async def test_resolve_issue_no_issues_found(default_mock_args, mock_github_toke
# Verify that the handler was correctly configured and called
resolver.issue_handler_factory.assert_called_once()
mock_handler.get_converted_issues.assert_called_once_with(issue_numbers=[5432], comment_id=None)
mock_handler.get_converted_issues.assert_called_once_with(
issue_numbers=[5432], comment_id=None
)
def test_download_issues_from_github():
@@ -348,9 +353,7 @@ async def test_complete_runtime(default_mock_args, mock_github_token):
# Create resolver with mocked token identification
resolver = IssueResolver(default_mock_args)
result = await resolver.complete_runtime(
mock_runtime, 'base_commit_hash'
)
result = await resolver.complete_runtime(mock_runtime, 'base_commit_hash')
assert result == {'git_patch': 'git diff content'}
assert mock_runtime.run_action.call_count == 5
@@ -358,7 +361,7 @@ async def test_complete_runtime(default_mock_args, mock_github_token):
@pytest.mark.asyncio
@pytest.mark.parametrize(
"test_case",
'test_case',
[
{
'name': 'successful_run',
@@ -410,11 +413,20 @@ async def test_complete_runtime(default_mock_args, mock_github_token):
'expected_error': None,
'expected_explanation': 'Non-JSON explanation',
'is_pr': True,
'comment_success': [True, False], # To trigger the PR success logging code path
'comment_success': [
True,
False,
], # To trigger the PR success logging code path
},
],
)
async def test_process_issue(default_mock_args, mock_github_token, mock_output_dir, mock_prompt_template, test_case):
async def test_process_issue(
default_mock_args,
mock_github_token,
mock_output_dir,
mock_prompt_template,
test_case,
):
"""Test the process_issue method with different scenarios."""
# Set up test data
@@ -426,7 +438,7 @@ async def test_process_issue(default_mock_args, mock_github_token, mock_output_d
body='This is a test issue',
)
base_commit = 'abcdef1234567890'
# Customize the mock args for this test
default_mock_args.output_dir = mock_output_dir
default_mock_args.issue_type = 'pr' if test_case.get('is_pr', False) else 'issue'
@@ -457,7 +469,7 @@ async def test_process_issue(default_mock_args, mock_github_token, mock_output_d
# Mock the create_runtime function
mock_create_runtime = MagicMock(return_value=mock_runtime)
# Mock the run_controller function
mock_run_controller = AsyncMock()
if test_case['run_controller_raises']:
@@ -466,14 +478,15 @@ async def test_process_issue(default_mock_args, mock_github_token, mock_output_d
mock_run_controller.return_value = test_case['run_controller_return']
# Patch the necessary functions and methods
with patch('openhands.resolver.resolve_issue.create_runtime', mock_create_runtime), \
patch('openhands.resolver.resolve_issue.run_controller', mock_run_controller), \
patch.object(resolver, 'complete_runtime', return_value={'git_patch': 'test patch'}), \
patch.object(resolver, 'initialize_runtime') as mock_initialize_runtime:
with patch(
'openhands.resolver.resolve_issue.create_runtime', mock_create_runtime
), patch(
'openhands.resolver.resolve_issue.run_controller', mock_run_controller
), patch.object(
resolver, 'complete_runtime', return_value={'git_patch': 'test patch'}
), patch.object(resolver, 'initialize_runtime') as mock_initialize_runtime:
# Call the process_issue method
result = await resolver.process_issue(issue, base_commit, handler_instance)
# Assert the result matches our expectations
assert isinstance(result, ResolverOutput)
@@ -490,16 +503,17 @@ async def test_process_issue(default_mock_args, mock_github_token, mock_output_d
mock_initialize_runtime.assert_called_once()
mock_run_controller.assert_called_once()
resolver.complete_runtime.assert_awaited_once_with(mock_runtime, base_commit)
# Assert run_controller was called with the right parameters
if not test_case['run_controller_raises']:
# Check that the first positional argument is a config
assert 'config' in mock_run_controller.call_args[1]
# Check that initial_user_action is a MessageAction with the right content
assert isinstance(mock_run_controller.call_args[1]['initial_user_action'], MessageAction)
assert isinstance(
mock_run_controller.call_args[1]['initial_user_action'], MessageAction
)
assert mock_run_controller.call_args[1]['runtime'] == mock_runtime
# Assert that guess_success was called only for successful runs
if test_case['expected_success']:
handler_instance.guess_success.assert_called_once()

View File

@@ -19,14 +19,16 @@ from openhands.resolver.interfaces.issue_definitions import (
ServiceContextIssue,
ServiceContextPR,
)
from openhands.resolver.resolve_issue import IssueResolver, SandboxConfig, AppConfig, AgentConfig
from openhands.resolver.resolve_issue import (
IssueResolver,
)
from openhands.resolver.resolver_output import ResolverOutput
@pytest.fixture
def default_mock_args():
"""Fixture that provides a default mock args object with common values.
Tests can override specific attributes as needed.
"""
mock_args = MagicMock()
@@ -52,10 +54,13 @@ def default_mock_args():
@pytest.fixture
def mock_gitlab_token():
"""Fixture that patches the identify_token function to return GitLab provider type.
This eliminates the need for repeated patching in each test function.
"""
with patch('openhands.resolver.resolve_issue.identify_token', return_value=ProviderType.GITLAB) as patched:
with patch(
'openhands.resolver.resolve_issue.identify_token',
return_value=ProviderType.GITLAB,
) as patched:
yield patched
@@ -124,10 +129,10 @@ def test_initialize_runtime(default_mock_args, mock_gitlab_token):
exit_code=0, content='', command='git config --global core.pager ""'
),
]
# Create resolver with mocked token identification
resolver = IssueResolver(default_mock_args)
resolver.initialize_runtime(mock_runtime)
if os.getenv('GITLAB_CI') == 'true':
@@ -154,24 +159,26 @@ async def test_resolve_issue_no_issues_found(default_mock_args, mock_gitlab_toke
# Customize the mock args for this test
default_mock_args.issue_number = 5432
# Create a resolver instance with mocked token identification
resolver = IssueResolver(default_mock_args)
# Mock the issue_handler_factory method
resolver.issue_handler_factory = MagicMock(return_value=mock_handler)
# Test that the correct exception is raised
with pytest.raises(ValueError) as exc_info:
await resolver.resolve_issue()
# Verify the error message
assert 'No issues found for issue number 5432' in str(exc_info.value)
assert 'test-owner/test-repo' in str(exc_info.value)
# Verify that the handler was correctly configured and called
resolver.issue_handler_factory.assert_called_once()
mock_handler.get_converted_issues.assert_called_once_with(issue_numbers=[5432], comment_id=None)
mock_handler.get_converted_issues.assert_called_once_with(
issue_numbers=[5432], comment_id=None
)
def test_download_issues_from_gitlab():
@@ -377,12 +384,14 @@ async def test_complete_runtime(default_mock_args, mock_gitlab_token):
content='',
command='git config --global --add safe.directory /workspace',
),
create_cmd_output(exit_code=0, content='', command='git add -A'),
create_cmd_output(
exit_code=0, content='', command='git add -A'
exit_code=0,
content='git diff content',
command='git diff --no-color --cached base_commit_hash',
),
create_cmd_output(exit_code=0, content='git diff content', command='git diff --no-color --cached base_commit_hash'),
]
# Create a resolver instance with mocked token identification
resolver = IssueResolver(default_mock_args)
@@ -394,7 +403,7 @@ async def test_complete_runtime(default_mock_args, mock_gitlab_token):
@pytest.mark.asyncio
@pytest.mark.parametrize(
"test_case",
'test_case',
[
{
'name': 'successful_run',
@@ -448,7 +457,13 @@ async def test_complete_runtime(default_mock_args, mock_gitlab_token):
},
],
)
async def test_process_issue(default_mock_args, mock_gitlab_token, mock_output_dir, mock_prompt_template, test_case):
async def test_process_issue(
default_mock_args,
mock_gitlab_token,
mock_output_dir,
mock_prompt_template,
test_case,
):
"""Test the process_issue method with different scenarios."""
# Set up test data
issue = Issue(
@@ -482,7 +497,7 @@ async def test_process_issue(default_mock_args, mock_gitlab_token, mock_output_d
mock_runtime = MagicMock()
mock_runtime.connect = AsyncMock()
mock_create_runtime = MagicMock(return_value=mock_runtime)
# Configure run_controller mock based on test case
mock_run_controller = AsyncMock()
if test_case.get('run_controller_raises'):
@@ -491,16 +506,18 @@ async def test_process_issue(default_mock_args, mock_gitlab_token, mock_output_d
mock_run_controller.return_value = test_case['run_controller_return']
# Patch the necessary functions and methods
with patch('openhands.resolver.resolve_issue.create_runtime', mock_create_runtime), \
patch('openhands.resolver.resolve_issue.run_controller', mock_run_controller), \
patch.object(resolver, 'complete_runtime', return_value={'git_patch': 'test patch'}), \
patch.object(resolver, 'initialize_runtime') as mock_initialize_runtime, \
patch('openhands.resolver.resolve_issue.SandboxConfig', return_value=MagicMock()), \
patch('openhands.resolver.resolve_issue.AppConfig', return_value=MagicMock()):
with patch(
'openhands.resolver.resolve_issue.create_runtime', mock_create_runtime
), patch(
'openhands.resolver.resolve_issue.run_controller', mock_run_controller
), patch.object(
resolver, 'complete_runtime', return_value={'git_patch': 'test patch'}
), patch.object(resolver, 'initialize_runtime') as mock_initialize_runtime, patch(
'openhands.resolver.resolve_issue.SandboxConfig', return_value=MagicMock()
), patch('openhands.resolver.resolve_issue.AppConfig', return_value=MagicMock()):
# Call the process_issue method
result = await resolver.process_issue(issue, base_commit, handler_instance)
mock_create_runtime.assert_called_once()
mock_runtime.connect.assert_called_once()
mock_initialize_runtime.assert_called_once()
@@ -521,6 +538,7 @@ async def test_process_issue(default_mock_args, mock_gitlab_token, mock_output_d
else:
handler_instance.guess_success.assert_not_called()
def test_get_instruction(mock_prompt_template, mock_followup_prompt_template):
issue = Issue(
owner='test_owner',
@@ -923,4 +941,4 @@ def test_download_issue_with_specific_comment():
if __name__ == '__main__':
pytest.main()
pytest.main()

View File

@@ -389,6 +389,52 @@ def test_defaults_dict_after_updates(default_config):
assert defaults_after_updates == initial_defaults
def test_runtime_mount_config(monkeypatch, default_config):
# Test the new RUNTIME_MOUNT parameter
monkeypatch.setenv('RUNTIME_MOUNT', '/host/path:/container/path:ro')
load_from_env(default_config, os.environ)
finalize_config(default_config)
# Check that runtime_mount is set correctly
assert default_config.runtime_mount == '/host/path:/container/path:ro'
# Check that the old parameters are set for backward compatibility
assert default_config.workspace_base == os.path.abspath('/host/path')
assert default_config.workspace_mount_path == os.path.abspath('/host/path')
assert default_config.workspace_mount_path_in_sandbox == '/container/path'
def test_runtime_mount_with_default_mode(monkeypatch, default_config):
# Test RUNTIME_MOUNT without specifying mode (should default to 'rw')
monkeypatch.setenv('RUNTIME_MOUNT', '/host/path:/container/path')
load_from_env(default_config, os.environ)
finalize_config(default_config)
# Check that runtime_mount is set correctly
assert default_config.runtime_mount == '/host/path:/container/path'
# Check that the old parameters are set for backward compatibility
assert default_config.workspace_base == os.path.abspath('/host/path')
assert default_config.workspace_mount_path == os.path.abspath('/host/path')
assert default_config.workspace_mount_path_in_sandbox == '/container/path'
def test_runtime_mount_invalid_format(monkeypatch, default_config):
# Test RUNTIME_MOUNT with invalid format
monkeypatch.setenv('RUNTIME_MOUNT', '/single/path')
load_from_env(default_config, os.environ)
# Check that runtime_mount is set
assert default_config.runtime_mount == '/single/path'
# Finalize config should raise a ValueError for invalid format
with pytest.raises(ValueError, match="Invalid runtime_mount format"):
finalize_config(default_config)
def test_invalid_toml_format(monkeypatch, temp_toml_file, default_config):
# Invalid TOML format doesn't break the configuration
monkeypatch.setenv('LLM_MODEL', 'gpt-5-turbo-1106')

View File

@@ -69,3 +69,65 @@ def test_container_not_stopped_when_keep_runtime_alive_true(
# Assert
mock_stop_containers.assert_not_called()
def test_runtime_mount_mode_extraction():
"""Test that the mount mode is correctly extracted from runtime_mount."""
from openhands.runtime.impl.docker.docker_runtime import DockerRuntime
# Create a mock DockerRuntime instance
runtime = MagicMock(spec=DockerRuntime)
# Test with read-only mode
runtime.config = MagicMock()
runtime.config.runtime_mount = '/host/path:/container/path:ro'
runtime.config.workspace_mount_path = '/host/path'
runtime.config.workspace_mount_path_in_sandbox = '/container/path'
# Call the _create_container method directly
volumes = {
runtime.config.workspace_mount_path: {
'bind': runtime.config.workspace_mount_path_in_sandbox,
'mode': 'rw', # Default mode
}
}
# Simulate the code in DockerRuntime that extracts the mode
if runtime.config.runtime_mount is not None:
parts = runtime.config.runtime_mount.split(':')
if len(parts) > 2:
volumes[runtime.config.workspace_mount_path]['mode'] = parts[2]
# Assert that the mode was correctly set to 'ro'
assert volumes['/host/path']['mode'] == 'ro'
def test_runtime_mount_default_mode():
"""Test that the default mount mode (rw) is used when not specified in runtime_mount."""
from openhands.runtime.impl.docker.docker_runtime import DockerRuntime
# Create a mock DockerRuntime instance
runtime = MagicMock(spec=DockerRuntime)
# Test with no mode specified (should default to 'rw')
runtime.config = MagicMock()
runtime.config.runtime_mount = '/host/path:/container/path'
runtime.config.workspace_mount_path = '/host/path'
runtime.config.workspace_mount_path_in_sandbox = '/container/path'
# Call the _create_container method directly
volumes = {
runtime.config.workspace_mount_path: {
'bind': runtime.config.workspace_mount_path_in_sandbox,
'mode': 'rw', # Default mode
}
}
# Simulate the code in DockerRuntime that extracts the mode
if runtime.config.runtime_mount is not None:
parts = runtime.config.runtime_mount.split(':')
if len(parts) > 2:
volumes[runtime.config.workspace_mount_path]['mode'] = parts[2]
# Assert that the mode remains 'rw' (default)
assert volumes['/host/path']['mode'] == 'rw'

View File

@@ -215,6 +215,6 @@ def test_invalid_json_arguments():
}
],
)
with pytest.raises(RuntimeError) as exc_info:
with pytest.raises(FunctionCallValidationError) as exc_info:
response_to_actions(response)
assert 'Failed to parse tool call arguments' in str(exc_info.value)