Compare commits

...

20 Commits

Author SHA1 Message Date
openhands
b735fb8b9f Fix issue #5614: [Bug]: BrowserOutputObservation.__init__() missing 1 required positional argument: 'trigger_by_action' 2024-12-20 14:41:00 +00:00
dependabot[bot]
0dd919bacf Bump prism-react-renderer from 2.4.0 to 2.4.1 in /docs in the version-all group (#5668)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-20 16:43:12 +04:00
d-walsh
5ad361623d feat: add support for custom PR titles (#5706)
Co-authored-by: David Walsh <walsha@gmail.com>
2024-12-20 04:00:00 +00:00
Xingyao Wang
c333938384 feat(eval): add standard error to swebench summarize outputs (#5700)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-12-20 08:39:43 +08:00
tofarr
ebf3bf606a Settings store type is defined in openhands_config rather than main config (#5701) 2024-12-19 12:44:35 -07:00
dependabot[bot]
c2293ad1dd Bump the version-all group across 1 directory with 13 updates (#5699)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-19 20:08:22 +01:00
mamoodi
6f7d054385 Add examples for filesystem use (#5697) 2024-12-19 13:13:09 -05:00
Xingyao Wang
e9cafb0372 chore: Cleanup runtime exception handling (#5696) 2024-12-19 17:28:29 +00:00
mamoodi
13097f9d1d Release 0.16.1 (#5693) 2024-12-19 11:13:26 -05:00
OpenHands
2a66439ca6 Fix issue #5676: [Bug]: Frontend Hyperlink in Chat window should open link in a new tab (#5677)
Co-authored-by: Xingyao Wang <xingyao6@illinois.edu>
2024-12-19 14:39:00 +00:00
Rohit Malhotra
3876f4a59c [Bug]: Prevent selection of "add more repo" option in dropdown (#5688) 2024-12-19 16:00:10 +04:00
Rohit Malhotra
3db118f3d9 [Bug]: Fixing next page param extraction for app installation repos reponse (#5687) 2024-12-19 03:29:22 +00:00
tofarr
fe1bb1c233 Feat config server side store (#5594)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-12-18 15:18:56 -07:00
mamoodi
154ef7391a Release 0.16.0 (#5678) 2024-12-18 16:31:38 -05:00
tofarr
5498ca1f8b Fix: Agent gets stuck in closing and server won't die (#5675) 2024-12-18 18:47:27 +00:00
Xingyao Wang
2cc6a51fe8 chore: cleanup log - make "cannot restore state" a debug message (#5674) 2024-12-18 18:43:28 +00:00
dependabot[bot]
409d132747 Bump llama-index from 0.12.5 to 0.12.6 in the llama group (#5669)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-18 19:06:31 +01:00
Rohit Malhotra
2c47a1b33f [Bug]: Settings modal opens on every refresh (#5670)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-12-18 12:32:21 -05:00
Xingyao Wang
8983eb4cc1 fix(eval): Increase RemoteRuntime request timeouts to cope with busy cluster (#5671) 2024-12-18 17:10:38 +00:00
Robert Brennan
bd3e38fe67 Implement file-by-file download with progress (#5008)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-12-18 08:37:43 -05:00
62 changed files with 1364 additions and 348 deletions

View File

@@ -100,7 +100,7 @@ poetry run pytest ./tests/unit/test_*.py
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image by
setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.15-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.16-nikolaik`
## Develop inside Docker container

View File

@@ -43,17 +43,17 @@ See the [Installation](https://docs.all-hands.dev/modules/usage/installation) gu
system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.15-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.15-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/home/openhands/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.15
docker.all-hands.dev/all-hands-ai/openhands:0.16
```
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!

View File

@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.15-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.16-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -11,7 +11,7 @@ services:
- BACKEND_HOST=${BACKEND_HOST:-"0.0.0.0"}
- SANDBOX_API_HOSTNAME=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.15-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.16-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.15-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -59,7 +59,7 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.15 \
docker.all-hands.dev/all-hands-ai/openhands:0.16 \
python -m openhands.core.cli
```

View File

@@ -44,7 +44,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.15-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -54,6 +54,6 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.15 \
docker.all-hands.dev/all-hands-ai/openhands:0.16 \
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
```

View File

@@ -11,16 +11,16 @@
The easiest way to run OpenHands is in Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.15-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.15-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.15
docker.all-hands.dev/all-hands-ai/openhands:0.16
```
You can also run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), as an [interactive CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), or using the [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action).

View File

@@ -16,7 +16,7 @@ some flags being passed to `docker run` that make this possible:
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.15-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```
@@ -28,12 +28,22 @@ You can also [build your own runtime image](how-to/custom-sandbox-guide).
### Connecting to Your filesystem
One useful feature here is the ability to connect to your local filesystem.
To mount your filesystem into the runtime, add the following options to
the `docker run` command:
To mount your filesystem into the runtime, first 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
```
then add the following options to the `docker run` command:
```bash
docker run # ...
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \

View File

@@ -14,7 +14,7 @@
"@docusaurus/theme-mermaid": "^3.6.3",
"@mdx-js/react": "^3.1.0",
"clsx": "^2.0.0",
"prism-react-renderer": "^2.4.0",
"prism-react-renderer": "^2.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.4.0",
@@ -14781,9 +14781,9 @@
}
},
"node_modules/prism-react-renderer": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.0.tgz",
"integrity": "sha512-327BsVCD/unU4CNLZTWVHyUHKnsqcvj2qbPlQ8MiBE2eq2rgctjigPA1Gp9HLF83kZ20zNN6jgizHJeEsyFYOw==",
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz",
"integrity": "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==",
"dependencies": {
"@types/prismjs": "^1.26.0",
"clsx": "^2.0.0"

View File

@@ -21,7 +21,7 @@
"@docusaurus/theme-mermaid": "^3.6.3",
"@mdx-js/react": "^3.1.0",
"clsx": "^2.0.0",
"prism-react-renderer": "^2.4.0",
"prism-react-renderer": "^2.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.4.0",

View File

@@ -15,6 +15,7 @@ from evaluation.utils.shared import (
EvalOutput,
assert_and_raise,
codeact_user_response,
is_fatal_evaluation_error,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -400,11 +401,7 @@ def process_instance(
)
# if fatal error, throw EvalError to trigger re-run
if (
state.last_error
and 'fatal error during agent execution' in state.last_error
and 'stuck in a loop' not in state.last_error
):
if is_fatal_evaluation_error(state.last_error):
raise EvalException('Fatal error detected: ' + state.last_error)
# ======= THIS IS SWE-Bench specific =======

View File

@@ -6,6 +6,8 @@ import os
from collections import Counter
import pandas as pd
import random
import numpy as np
from openhands.events.serialization import event_from_dict
from openhands.events.utils import get_pairs_from_events
@@ -18,6 +20,18 @@ ERROR_KEYWORDS = [
]
def get_bootstrap_accuracy_error_bars(values: float | int | bool, num_samples: int = 1000, p_value=0.05) -> tuple[float, float]:
sorted_vals = np.sort(
[
np.mean(random.sample(values, len(values) // 2))
for _ in range(num_samples)
]
)
bottom_idx = int(num_samples * p_value / 2)
top_idx = int(num_samples * (1.0 - p_value / 2))
return (sorted_vals[bottom_idx], sorted_vals[top_idx])
def process_file(file_path):
with open(file_path, 'r') as file:
lines = file.readlines()
@@ -26,6 +40,7 @@ def process_file(file_path):
num_error_lines = 0
num_agent_stuck_in_loop = 0
num_resolved = 0
resolved_arr = []
num_empty_patch = 0
num_unfinished_runs = 0
error_counter = Counter()
@@ -74,6 +89,9 @@ def process_file(file_path):
resolved = report.get('resolved', False)
if resolved:
num_resolved += 1
resolved_arr.append(1)
else:
resolved_arr.append(0)
# Error
error = _d.get('error', None)
@@ -100,6 +118,7 @@ def process_file(file_path):
'resolved': {
'count': num_resolved,
'percentage': (num_resolved / num_lines * 100) if num_lines > 0 else 0,
'ci': tuple(x * 100 for x in get_bootstrap_accuracy_error_bars(resolved_arr)),
},
'empty_patches': {
'count': num_empty_patch,
@@ -174,6 +193,7 @@ def aggregate_directory(input_path) -> pd.DataFrame:
)
df['resolve_rate'] = df['resolved'].apply(lambda x: x['percentage'])
df['resolve_rate_ci'] = df['resolved'].apply(lambda x: x['ci'])
df['empty_patch_rate'] = df['empty_patches'].apply(lambda x: x['percentage'])
df['unfinished_rate'] = df['unfinished_runs'].apply(lambda x: x['percentage'])
df['avg_turns'] = df['statistics'].apply(lambda x: x['avg_turns'])
@@ -242,7 +262,7 @@ if __name__ == '__main__':
# Print detailed results for single file
print(f'\nResults for {args.input_path}:')
print(
f"Number of resolved: {result['resolved']['count']} / {result['total_instances']} ({result['resolved']['percentage']:.2f}%)"
f"Number of resolved: {result['resolved']['count']} / {result['total_instances']} ({result['resolved']['percentage']:.2f}% [{result['resolved']['ci'][0]:.2f}%, {result['resolved']['ci'][1]:.2f}%])"
)
print(
f"Number of empty patch: {result['empty_patches']['count']} / {result['total_instances']} ({result['empty_patches']['percentage']:.2f}%)"

View File

@@ -16,6 +16,16 @@ from tqdm import tqdm
from openhands.controller.state.state import State
from openhands.core.config import LLMConfig
from openhands.core.exceptions import (
AgentRuntimeBuildError,
AgentRuntimeDisconnectedError,
AgentRuntimeError,
AgentRuntimeNotFoundError,
AgentRuntimeNotReadyError,
AgentRuntimeTimeoutError,
AgentRuntimeUnavailableError,
AgentStuckInLoopError,
)
from openhands.core.logger import get_console_handler
from openhands.core.logger import openhands_logger as logger
from openhands.events.action import Action
@@ -503,3 +513,25 @@ def compatibility_for_eval_history_pairs(
history_pairs.append((event_to_dict(action), event_to_dict(observation)))
return history_pairs
def is_fatal_evaluation_error(error: str | None) -> bool:
if not error:
return False
FATAL_EXCEPTIONS = [
AgentRuntimeError,
AgentRuntimeBuildError,
AgentRuntimeTimeoutError,
AgentRuntimeUnavailableError,
AgentRuntimeNotReadyError,
AgentRuntimeDisconnectedError,
AgentRuntimeNotFoundError,
AgentStuckInLoopError,
]
if any(exception.__name__ in error for exception in FATAL_EXCEPTIONS):
logger.error(f'Fatal evaluation error detected: {error}')
return True
return False

View File

@@ -39,12 +39,6 @@ describe("frontend/routes/_oh", () => {
await screen.findByTestId("root-layout");
});
it("should render the AI config modal if the user is authed", async () => {
// Our mock return value is true by default
renderWithProviders(<RouteStub />);
await screen.findByTestId("ai-config-modal");
});
it("should render the AI config modal if settings are not up-to-date", async () => {
settingsAreUpToDateMock.mockReturnValue(false);
renderWithProviders(<RouteStub />);

View File

@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.15.3",
"version": "0.16.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.15.3",
"version": "0.16.1",
"dependencies": {
"@monaco-editor/react": "^4.6.0",
"@nextui-org/react": "^2.4.8",

View File

@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.15.3",
"version": "0.16.1",
"private": true,
"type": "module",
"engines": {

View File

@@ -2,7 +2,7 @@ import posthog from "posthog-js";
import React from "react";
import { SuggestionItem } from "#/components/features/suggestions/suggestion-item";
import { useAuth } from "#/context/auth-context";
import { downloadWorkspace } from "#/utils/download-workspace";
import { DownloadModal } from "#/components/shared/download-modal";
interface ActionSuggestionsProps {
onSuggestionsClick: (value: string) => void;
@@ -16,19 +16,17 @@ export function ActionSuggestions({
const [isDownloading, setIsDownloading] = React.useState(false);
const [hasPullRequest, setHasPullRequest] = React.useState(false);
const handleDownloadWorkspace = async () => {
setIsDownloading(true);
try {
await downloadWorkspace();
} catch (error) {
// TODO: Handle error
} finally {
setIsDownloading(false);
}
const handleDownloadClose = () => {
setIsDownloading(false);
};
return (
<div className="flex flex-col gap-2 mb-2">
<DownloadModal
initialPath=""
onClose={handleDownloadClose}
isOpen={isDownloading}
/>
{gitHubToken ? (
<div className="flex flex-row gap-2 justify-center w-full">
{!hasPullRequest ? (
@@ -75,13 +73,15 @@ export function ActionSuggestions({
<SuggestionItem
suggestion={{
label: !isDownloading
? "Download .zip"
? "Download files"
: "Downloading, please wait...",
value: "Download .zip",
value: "Download files",
}}
onClick={() => {
posthog.capture("download_workspace_button_clicked");
handleDownloadWorkspace();
if (!isDownloading) {
setIsDownloading(true);
}
}}
/>
)}

View File

@@ -5,6 +5,7 @@ import { code } from "../markdown/code";
import { cn } from "#/utils/utils";
import { ul, ol } from "../markdown/list";
import { CopyToClipboardButton } from "#/components/shared/buttons/copy-to-clipboard-button";
import { anchor } from "../markdown/anchor";
interface ChatMessageProps {
type: "user" | "assistant";
@@ -62,6 +63,7 @@ export function ChatMessage({
code,
ul,
ol,
a: anchor,
}}
remarkPlugins={[remarkGfm]}
>

View File

@@ -1,3 +1,4 @@
import React from "react";
import { Autocomplete, AutocompleteItem } from "@nextui-org/react";
import { useDispatch } from "react-redux";
import posthog from "posthog-js";
@@ -14,6 +15,7 @@ export function GitHubRepositorySelector({
repositories,
}: GitHubRepositorySelectorProps) {
const { data: config } = useConfig();
const [selectedKey, setSelectedKey] = React.useState<string | null>(null);
// Add option to install app onto more repos
const finalRepositories =
@@ -36,6 +38,7 @@ export function GitHubRepositorySelector({
dispatch(setSelectedRepository(repo.full_name));
posthog.capture("repository_selected");
onSelect();
setSelectedKey(id);
}
};
@@ -63,6 +66,7 @@ export function GitHubRepositorySelector({
name="repo"
aria-label="GitHub Repository"
placeholder="Select a GitHub project"
selectedKey={selectedKey}
inputProps={{
classNames: {
inputWrapper:

View File

@@ -0,0 +1,20 @@
import React from "react";
import { ExtraProps } from "react-markdown";
export function anchor({
href,
children,
}: React.ClassAttributes<HTMLAnchorElement> &
React.AnchorHTMLAttributes<HTMLAnchorElement> &
ExtraProps) {
return (
<a
className="text-blue-500 hover:underline"
href={href}
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
);
}

View File

@@ -1,16 +1,14 @@
import React from "react";
import toast from "react-hot-toast";
import posthog from "posthog-js";
import EllipsisH from "#/icons/ellipsis-h.svg?react";
import { createChatMessage } from "#/services/chat-service";
import { ProjectMenuCardContextMenu } from "./project.menu-card-context-menu";
import { ProjectMenuDetailsPlaceholder } from "./project-menu-details-placeholder";
import { ProjectMenuDetails } from "./project-menu-details";
import { downloadWorkspace } from "#/utils/download-workspace";
import { useWsClient } from "#/context/ws-client-provider";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { ConnectToGitHubModal } from "#/components/shared/modals/connect-to-github-modal";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { DownloadModal } from "#/components/shared/download-modal";
interface ProjectMenuCardProps {
isConnectedToGitHub: boolean;
@@ -30,7 +28,7 @@ export function ProjectMenuCard({
const [contextMenuIsOpen, setContextMenuIsOpen] = React.useState(false);
const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] =
React.useState(false);
const [working, setWorking] = React.useState(false);
const [downloading, setDownloading] = React.useState(false);
const toggleMenuVisibility = () => {
setContextMenuIsOpen((prev) => !prev);
@@ -58,20 +56,16 @@ Please push the changes to GitHub and open a pull request.
const handleDownloadWorkspace = () => {
posthog.capture("download_workspace_button_clicked");
try {
setWorking(true);
downloadWorkspace().then(
() => setWorking(false),
() => setWorking(false),
);
} catch (error) {
toast.error("Failed to download workspace");
}
setDownloading(true);
};
const handleDownloadClose = () => {
setDownloading(false);
};
return (
<div className="px-4 py-[10px] w-[337px] rounded-xl border border-[#525252] flex justify-between items-center relative">
{!working && contextMenuIsOpen && (
{!downloading && contextMenuIsOpen && (
<ProjectMenuCardContextMenu
isConnectedToGitHub={isConnectedToGitHub}
onConnectToGitHub={() => setConnectToGitHubModalOpen(true)}
@@ -93,17 +87,20 @@ Please push the changes to GitHub and open a pull request.
onConnectToGitHub={() => setConnectToGitHubModalOpen(true)}
/>
)}
<button
type="button"
onClick={toggleMenuVisibility}
aria-label="Open project menu"
>
{working ? (
<LoadingSpinner size="small" />
) : (
<DownloadModal
initialPath=""
onClose={handleDownloadClose}
isOpen={downloading}
/>
{!downloading && (
<button
type="button"
onClick={toggleMenuVisibility}
aria-label="Open project menu"
>
<EllipsisH width={36} height={36} />
)}
</button>
</button>
)}
{connectToGitHubModalOpen && (
<ModalBackdrop onClose={() => setConnectToGitHubModalOpen(false)}>
<ConnectToGitHubModal

View File

@@ -37,7 +37,7 @@ export function ProjectMenuCardContextMenu({
</ContextMenuListItem>
)}
<ContextMenuListItem onClick={onDownloadWorkspace}>
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_AS_ZIP_LABEL)}
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_FILES_LABEL)}
</ContextMenuListItem>
</ContextMenu>
);

View File

@@ -0,0 +1,33 @@
import { useDownloadProgress } from "#/hooks/use-download-progress";
import { DownloadProgress } from "./download-progress";
interface DownloadModalProps {
initialPath: string;
onClose: () => void;
isOpen: boolean;
}
function ActiveDownload({
initialPath,
onClose,
}: {
initialPath: string;
onClose: () => void;
}) {
const { progress, cancelDownload } = useDownloadProgress(
initialPath,
onClose,
);
return <DownloadProgress progress={progress} onCancel={cancelDownload} />;
}
export function DownloadModal({
initialPath,
onClose,
isOpen,
}: DownloadModalProps) {
if (!isOpen) return null;
return <ActiveDownload initialPath={initialPath} onClose={onClose} />;
}

View File

@@ -0,0 +1,87 @@
export interface DownloadProgressState {
filesTotal: number;
filesDownloaded: number;
currentFile: string;
totalBytesDownloaded: number;
bytesDownloadedPerSecond: number;
isDiscoveringFiles: boolean;
}
interface DownloadProgressProps {
progress: DownloadProgressState;
onCancel: () => void;
}
export function DownloadProgress({
progress,
onCancel,
}: DownloadProgressProps) {
const formatBytes = (bytes: number) => {
const units = ["B", "KB", "MB", "GB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-20">
<div className="bg-[#1C1C1C] rounded-lg p-6 max-w-md w-full mx-4 border border-[#525252]">
<div className="mb-4">
<h3 className="text-lg font-semibold mb-2 text-white">
{progress.isDiscoveringFiles
? "Preparing Download..."
: "Downloading Files"}
</h3>
<p className="text-sm text-gray-400 truncate">
{progress.isDiscoveringFiles
? `Found ${progress.filesTotal} files...`
: progress.currentFile}
</p>
</div>
<div className="mb-4">
<div className="h-2 bg-[#2C2C2C] rounded-full overflow-hidden">
{progress.isDiscoveringFiles ? (
<div
className="h-full bg-blue-500 animate-pulse"
style={{ width: "100%" }}
/>
) : (
<div
className="h-full bg-blue-500 transition-all duration-300"
style={{
width: `${(progress.filesDownloaded / progress.filesTotal) * 100}%`,
}}
/>
)}
</div>
</div>
<div className="flex justify-between text-sm text-gray-400">
<span>
{progress.isDiscoveringFiles
? `Scanning workspace...`
: `${progress.filesDownloaded} of ${progress.filesTotal} files`}
</span>
{!progress.isDiscoveringFiles && (
<span>{formatBytes(progress.bytesDownloadedPerSecond)}/s</span>
)}
</div>
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm text-gray-400 hover:text-white transition-colors"
>
Cancel
</button>
</div>
</div>
</div>
);
}

View File

@@ -20,7 +20,7 @@ export function ModalBackdrop({ children, onClose }: ModalBackdropProps) {
};
return (
<div className="fixed inset-0 flex items-center justify-center z-10">
<div className="fixed inset-0 flex items-center justify-center z-20">
<div
onClick={handleClick}
className="fixed inset-0 bg-black bg-opacity-80"

View File

@@ -0,0 +1,78 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { downloadFiles } from "#/utils/download-files";
import { DownloadProgressState } from "#/components/shared/download-progress";
export const INITIAL_PROGRESS: DownloadProgressState = {
filesTotal: 0,
filesDownloaded: 0,
currentFile: "",
totalBytesDownloaded: 0,
bytesDownloadedPerSecond: 0,
isDiscoveringFiles: true,
};
export function useDownloadProgress(
initialPath: string | undefined,
onClose: () => void,
) {
const [isStarted, setIsStarted] = useState(false);
const [progress, setProgress] =
useState<DownloadProgressState>(INITIAL_PROGRESS);
const progressRef = useRef<DownloadProgressState>(INITIAL_PROGRESS);
const abortController = useRef<AbortController>();
// Create AbortController on mount
useEffect(() => {
const controller = new AbortController();
abortController.current = controller;
// Initialize progress ref with initial state
progressRef.current = INITIAL_PROGRESS;
return () => {
controller.abort();
abortController.current = undefined;
};
}, []); // Empty deps array - only run on mount/unmount
// Start download when isStarted becomes true
useEffect(() => {
if (!isStarted) {
setIsStarted(true);
return;
}
if (!abortController.current) return;
// Start download
const download = async () => {
try {
await downloadFiles(initialPath, {
onProgress: (p) => {
// Update both the ref and state
progressRef.current = { ...p };
setProgress((prev: DownloadProgressState) => ({ ...prev, ...p }));
},
signal: abortController.current!.signal,
});
onClose();
} catch (error) {
if (error instanceof Error && error.message === "Download cancelled") {
onClose();
} else {
throw error;
}
}
};
download();
}, [initialPath, onClose, isStarted]);
// No longer need startDownload as it's handled in useEffect
const cancelDownload = useCallback(() => {
abortController.current?.abort();
}, []);
return {
progress,
cancelDownload,
};
}

View File

@@ -2001,9 +2001,9 @@
"en": "Push to GitHub",
"es": "Subir a GitHub"
},
"PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_AS_ZIP_LABEL": {
"en": "Download as .zip",
"es": "Descargar como .zip"
"PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_FILES_LABEL": {
"en": "Download files",
"es": "Descargar archivos"
},
"ACTION_MESSAGE$RUN": {
"en": "Running a bash command"

View File

@@ -45,12 +45,15 @@ export function ErrorBoundary() {
export default function MainApp() {
const { gitHubToken, clearToken } = useAuth();
const { settings } = useUserPrefs();
const { settings, settingsAreUpToDate } = useUserPrefs();
const [consentFormIsOpen, setConsentFormIsOpen] = React.useState(
!localStorage.getItem("analytics-consent"),
);
const [aiConfigModalIsOpen, setAiConfigModalIsOpen] =
React.useState(!settingsAreUpToDate);
const config = useConfig();
const {
data: isAuthed,
@@ -77,9 +80,6 @@ export default function MainApp() {
const isInWaitlist =
!isFetchingAuth && !isAuthed && config.data?.APP_MODE === "saas";
const { settingsAreUpToDate } = useUserPrefs();
const [showAIConfig, setShowAIConfig] = React.useState(true);
return (
<div
data-testid="root-layout"
@@ -101,8 +101,11 @@ export default function MainApp() {
/>
)}
{(isAuthed || !settingsAreUpToDate) && showAIConfig && (
<SettingsModal onClose={() => setShowAIConfig(false)} />
{aiConfigModalIsOpen && (
<SettingsModal
onClose={() => setAiConfigModalIsOpen(false)}
data-testid="ai-config-modal"
/>
)}
</div>
);

31
frontend/src/types/file-system.d.ts vendored Normal file
View File

@@ -0,0 +1,31 @@
interface FileSystemWritableFileStream extends WritableStream {
write(data: BufferSource | Blob | string): Promise<void>;
seek(position: number): Promise<void>;
truncate(size: number): Promise<void>;
}
interface FileSystemFileHandle {
kind: "file";
name: string;
getFile(): Promise<File>;
createWritable(options?: {
keepExistingData?: boolean;
}): Promise<FileSystemWritableFileStream>;
}
interface FileSystemDirectoryHandle {
kind: "directory";
name: string;
getDirectoryHandle(
name: string,
options?: { create?: boolean },
): Promise<FileSystemDirectoryHandle>;
getFileHandle(
name: string,
options?: { create?: boolean },
): Promise<FileSystemFileHandle>;
}
interface Window {
showDirectoryPicker(): Promise<FileSystemDirectoryHandle>;
}

View File

@@ -0,0 +1,305 @@
import OpenHands from "#/api/open-hands";
interface DownloadProgress {
filesTotal: number;
filesDownloaded: number;
currentFile: string;
totalBytesDownloaded: number;
bytesDownloadedPerSecond: number;
isDiscoveringFiles: boolean;
}
interface DownloadOptions {
onProgress?: (progress: DownloadProgress) => void;
signal?: AbortSignal;
}
/**
* Checks if the File System Access API is supported
*/
function isFileSystemAccessSupported(): boolean {
return "showDirectoryPicker" in window;
}
/**
* Creates subdirectories and returns the final directory handle
*/
async function createSubdirectories(
baseHandle: FileSystemDirectoryHandle,
pathParts: string[],
): Promise<FileSystemDirectoryHandle> {
return pathParts.reduce(async (promise, part) => {
const handle = await promise;
return handle.getDirectoryHandle(part, { create: true });
}, Promise.resolve(baseHandle));
}
/**
* Recursively gets all files in a directory
*/
async function getAllFiles(
path: string,
progress: DownloadProgress,
options?: DownloadOptions,
): Promise<string[]> {
const entries = await OpenHands.getFiles(path);
const processEntry = async (entry: string): Promise<string[]> => {
if (options?.signal?.aborted) {
throw new Error("Download cancelled");
}
const fullPath = path + entry;
if (entry.endsWith("/")) {
const subEntries = await OpenHands.getFiles(fullPath);
const subFilesPromises = subEntries.map((subEntry) =>
processEntry(subEntry),
);
const subFilesArrays = await Promise.all(subFilesPromises);
return subFilesArrays.flat();
}
const updatedProgress = {
...progress,
filesTotal: progress.filesTotal + 1,
currentFile: fullPath,
};
options?.onProgress?.(updatedProgress);
return [fullPath];
};
const filePromises = entries.map((entry) => processEntry(entry));
const fileArrays = await Promise.all(filePromises);
const updatedProgress = {
...progress,
isDiscoveringFiles: false,
};
options?.onProgress?.(updatedProgress);
return fileArrays.flat();
}
/**
* Process a batch of files
*/
async function processBatch(
batch: string[],
directoryHandle: FileSystemDirectoryHandle,
progress: DownloadProgress,
startTime: number,
completedFiles: number,
totalBytes: number,
options?: DownloadOptions,
): Promise<{ newCompleted: number; newBytes: number }> {
if (options?.signal?.aborted) {
throw new Error("Download cancelled");
}
// Process files in the batch in parallel
const results = await Promise.all(
batch.map(async (path) => {
try {
const newProgress = {
...progress,
currentFile: path,
isDiscoveringFiles: false,
filesDownloaded: completedFiles,
totalBytesDownloaded: totalBytes,
bytesDownloadedPerSecond:
totalBytes / ((Date.now() - startTime) / 1000),
};
options?.onProgress?.(newProgress);
const content = await OpenHands.getFile(path);
// Save to the selected directory preserving structure
const pathParts = path.split("/").filter(Boolean);
const fileName = pathParts.pop() || "file";
const dirHandle =
pathParts.length > 0
? await createSubdirectories(directoryHandle, pathParts)
: directoryHandle;
// Create and write the file
const fileHandle = await dirHandle.getFileHandle(fileName, {
create: true,
});
const writable = await fileHandle.createWritable();
await writable.write(content);
await writable.close();
// Return the size of this file
return new Blob([content]).size;
} catch (error) {
// Silently handle file processing errors and return 0 bytes
return 0;
}
}),
);
// Calculate batch totals
const batchBytes = results.reduce((sum, size) => sum + size, 0);
const newTotalBytes = totalBytes + batchBytes;
const newCompleted =
completedFiles + results.filter((size) => size > 0).length;
// Update progress with batch results
const updatedProgress = {
...progress,
filesDownloaded: newCompleted,
totalBytesDownloaded: newTotalBytes,
bytesDownloadedPerSecond: newTotalBytes / ((Date.now() - startTime) / 1000),
isDiscoveringFiles: false,
};
options?.onProgress?.(updatedProgress);
return {
newCompleted,
newBytes: newTotalBytes,
};
}
/**
* Downloads files from the workspace one by one
* @param initialPath Initial path to start downloading from. If not provided, downloads from root
* @param options Download options including progress callback and abort signal
*/
export async function downloadFiles(
initialPath?: string,
options?: DownloadOptions,
): Promise<void> {
const startTime = Date.now();
const progress: DownloadProgress = {
filesTotal: 0, // Will be updated during file discovery
filesDownloaded: 0,
currentFile: "",
totalBytesDownloaded: 0,
bytesDownloadedPerSecond: 0,
isDiscoveringFiles: true,
};
try {
// Check if File System Access API is supported
if (!isFileSystemAccessSupported()) {
throw new Error(
"Your browser doesn't support downloading folders. Please use Chrome, Edge, or another browser that supports the File System Access API.",
);
}
// Show directory picker first
let directoryHandle: FileSystemDirectoryHandle;
try {
directoryHandle = await window.showDirectoryPicker();
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw new Error("Download cancelled");
}
if (error instanceof Error && error.name === "SecurityError") {
throw new Error(
"Permission denied. Please allow access to the download location when prompted.",
);
}
throw new Error("Failed to select download location. Please try again.");
}
// Then recursively get all files
const files = await getAllFiles(initialPath || "", progress, options);
// Set isDiscoveringFiles to false now that we have the full list and preserve filesTotal
const finalTotal = progress.filesTotal;
options?.onProgress?.({
...progress,
filesTotal: finalTotal,
isDiscoveringFiles: false,
});
// Verify we still have permission after the potentially long file scan
try {
// Try to create and write to a test file to verify permissions
const testHandle = await directoryHandle.getFileHandle(
".openhands-test",
{ create: true },
);
const writable = await testHandle.createWritable();
await writable.close();
} catch (error) {
if (
error instanceof Error &&
error.message.includes("User activation is required")
) {
// Ask for permission again
try {
directoryHandle = await window.showDirectoryPicker();
} catch (permissionError) {
if (
permissionError instanceof Error &&
permissionError.name === "AbortError"
) {
throw new Error("Download cancelled");
}
if (
permissionError instanceof Error &&
permissionError.name === "SecurityError"
) {
throw new Error(
"Permission denied. Please allow access to the download location when prompted.",
);
}
throw new Error(
"Failed to select download location. Please try again.",
);
}
} else {
throw error;
}
}
// Process files in parallel batches to avoid overwhelming the browser
const BATCH_SIZE = 5;
const batches = Array.from(
{ length: Math.ceil(files.length / BATCH_SIZE) },
(_, i) => files.slice(i * BATCH_SIZE, (i + 1) * BATCH_SIZE),
);
// Keep track of completed files across all batches
let completedFiles = 0;
let totalBytesDownloaded = 0;
// Process batches sequentially to maintain order and avoid overwhelming the browser
await batches.reduce(
(promise, batch) =>
promise.then(async () => {
const { newCompleted, newBytes } = await processBatch(
batch,
directoryHandle,
progress,
startTime,
completedFiles,
totalBytesDownloaded,
options,
);
completedFiles = newCompleted;
totalBytesDownloaded = newBytes;
}),
Promise.resolve(),
);
} catch (error) {
if (error instanceof Error && error.message === "Download cancelled") {
throw error;
}
// Re-throw the error as is if it's already a user-friendly message
if (
error instanceof Error &&
(error.message.includes("browser doesn't support") ||
error.message.includes("Failed to select") ||
error.message === "Download cancelled")
) {
throw error;
}
// Otherwise, wrap it with a generic message
throw new Error(
`Failed to download files: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

View File

@@ -1,7 +1,6 @@
/** @type {import('tailwindcss').Config} */
import { nextui } from "@nextui-org/react";
import typography from '@tailwindcss/typography';
export default {
content: [
"./src/**/*.{js,ts,jsx,tsx}",

View File

@@ -12,6 +12,7 @@ from openhands.controller.state.state import State, TrafficControlState
from openhands.controller.stuck import StuckDetector
from openhands.core.config import AgentConfig, LLMConfig
from openhands.core.exceptions import (
AgentStuckInLoopError,
FunctionCallNotExistsError,
FunctionCallValidationError,
LLMMalformedActionError,
@@ -196,7 +197,7 @@ class AgentController:
err_id = ''
if isinstance(e, litellm.AuthenticationError):
err_id = 'STATUS$ERROR_LLM_AUTHENTICATION'
self.status_callback('error', err_id, str(e))
self.status_callback('error', err_id, type(e).__name__ + ': ' + str(e))
async def start_step_loop(self):
"""The main loop for the agent's step-by-step execution."""
@@ -502,7 +503,9 @@ class AgentController:
return
if self._is_stuck():
await self._react_to_exception(RuntimeError('Agent got stuck in a loop'))
await self._react_to_exception(
AgentStuckInLoopError('Agent got stuck in a loop')
)
return
self.update_state_before_step()

View File

@@ -118,7 +118,7 @@ class State:
pickled = base64.b64decode(encoded)
state = pickle.loads(pickled)
except Exception as e:
logger.warning(f'Could not restore state from session: {e}')
logger.debug(f'Could not restore state from session: {e}')
raise e
# update state

View File

@@ -1,14 +1,25 @@
class AgentNoInstructionError(Exception):
# ============================================
# Agent Exceptions
# ============================================
class AgentError(Exception):
"""Base class for all agent exceptions."""
pass
class AgentNoInstructionError(AgentError):
def __init__(self, message='Instruction must be provided'):
super().__init__(message)
class AgentEventTypeError(Exception):
class AgentEventTypeError(AgentError):
def __init__(self, message='Event must be a dictionary'):
super().__init__(message)
class AgentAlreadyRegisteredError(Exception):
class AgentAlreadyRegisteredError(AgentError):
def __init__(self, name=None):
if name is not None:
message = f"Agent class already registered under '{name}'"
@@ -17,7 +28,7 @@ class AgentAlreadyRegisteredError(Exception):
super().__init__(message)
class AgentNotRegisteredError(Exception):
class AgentNotRegisteredError(AgentError):
def __init__(self, name=None):
if name is not None:
message = f"No agent class registered under '{name}'"
@@ -26,6 +37,16 @@ class AgentNotRegisteredError(Exception):
super().__init__(message)
class AgentStuckInLoopError(AgentError):
def __init__(self, message='Agent got stuck in a loop'):
super().__init__(message)
# ============================================
# Agent Controller Exceptions
# ============================================
class TaskInvalidStateError(Exception):
def __init__(self, state=None):
if state is not None:
@@ -35,17 +56,9 @@ class TaskInvalidStateError(Exception):
super().__init__(message)
class BrowserInitException(Exception):
def __init__(self, message='Failed to initialize browser environment'):
super().__init__(message)
class BrowserUnavailableException(Exception):
def __init__(
self,
message='Browser environment is not available, please check if has been initialized',
):
super().__init__(message)
# ============================================
# LLM Exceptions
# ============================================
# This exception gets sent back to the LLM
@@ -96,6 +109,11 @@ class CloudFlareBlockageError(Exception):
pass
# ============================================
# LLM function calling Exceptions
# ============================================
class FunctionCallConversionError(Exception):
"""Exception raised when FunctionCallingConverter failed to convert a non-function call message to a function call message.
@@ -121,3 +139,68 @@ class FunctionCallNotExistsError(Exception):
def __init__(self, message):
super().__init__(message)
# ============================================
# Agent Runtime Exceptions
# ============================================
class AgentRuntimeError(Exception):
"""Base class for all agent runtime exceptions."""
pass
class AgentRuntimeBuildError(AgentRuntimeError):
"""Exception raised when an agent runtime build operation fails."""
pass
class AgentRuntimeTimeoutError(AgentRuntimeError):
"""Exception raised when an agent runtime operation times out."""
pass
class AgentRuntimeUnavailableError(AgentRuntimeError):
"""Exception raised when an agent runtime is unavailable."""
pass
class AgentRuntimeNotReadyError(AgentRuntimeUnavailableError):
"""Exception raised when an agent runtime is not ready."""
pass
class AgentRuntimeDisconnectedError(AgentRuntimeUnavailableError):
"""Exception raised when an agent runtime is disconnected."""
pass
class AgentRuntimeNotFoundError(AgentRuntimeUnavailableError):
"""Exception raised when an agent runtime is not found."""
pass
# ============================================
# Browser Exceptions
# ============================================
class BrowserInitException(Exception):
def __init__(self, message='Failed to initialize browser environment'):
super().__init__(message)
class BrowserUnavailableException(Exception):
def __init__(
self,
message='Browser environment is not available, please check if has been initialized',
):
super().__init__(message)

View File

@@ -239,6 +239,7 @@ def send_pull_request(
additional_message: str | None = None,
target_branch: str | None = None,
reviewer: str | None = None,
pr_title: str | None = None,
) -> str:
"""Send a pull request to a GitHub repository.
@@ -251,6 +252,8 @@ def send_pull_request(
fork_owner: The owner of the fork to push changes to (if different from the original repo owner)
additional_message: The additional messages to post as a comment on the PR in json list format
target_branch: The target branch to create the pull request against (defaults to repository default branch)
reviewer: The GitHub username of the reviewer to assign
pr_title: Custom title for the pull request (optional)
"""
if pr_type not in ['branch', 'draft', 'ready']:
raise ValueError(f'Invalid pr_type: {pr_type}')
@@ -321,7 +324,11 @@ def send_pull_request(
raise RuntimeError('Failed to push changes to the remote repository')
# Prepare the PR data: title and body
pr_title = f'Fix issue #{github_issue.number}: {github_issue.title}'
final_pr_title = (
pr_title
if pr_title
else f'Fix issue #{github_issue.number}: {github_issue.title}'
)
pr_body = f'This pull request fixes #{github_issue.number}.'
if additional_message:
pr_body += f'\n\n{additional_message}'
@@ -334,7 +341,7 @@ def send_pull_request(
else:
# Prepare the PR for the GitHub API
data = {
'title': pr_title, # No need to escape title for GitHub API
'title': final_pr_title, # No need to escape title for GitHub API
'body': pr_body,
'head': branch_name,
'base': base_branch,
@@ -366,7 +373,9 @@ def send_pull_request(
url = pr_data['html_url']
print(f'{pr_type} created: {url}\n\n--- Title: {pr_title}\n\n--- Body:\n{pr_body}')
print(
f'{pr_type} created: {url}\n\n--- Title: {final_pr_title}\n\n--- Body:\n{pr_body}'
)
return url
@@ -535,6 +544,7 @@ def process_single_issue(
send_on_failure: bool,
target_branch: str | None = None,
reviewer: str | None = None,
pr_title: str | None = None,
) -> None:
if not resolver_output.success and not send_on_failure:
print(
@@ -585,6 +595,7 @@ def process_single_issue(
additional_message=resolver_output.success_explanation,
target_branch=target_branch,
reviewer=reviewer,
pr_title=pr_title,
)
@@ -687,6 +698,12 @@ def main():
help='GitHub username of the person to request review from',
default=None,
)
parser.add_argument(
'--pr-title',
type=str,
help='Custom title for the pull request',
default=None,
)
my_args = parser.parse_args()
github_token = (
@@ -741,6 +758,7 @@ def main():
my_args.send_on_failure,
my_args.target_branch,
my_args.reviewer,
my_args.pr_title,
)

View File

@@ -9,6 +9,7 @@ from typing import Callable
from requests.exceptions import ConnectionError
from openhands.core.config import AppConfig, SandboxConfig
from openhands.core.exceptions import AgentRuntimeDisconnectedError
from openhands.core.logger import openhands_logger as logger
from openhands.events import EventSource, EventStream, EventStreamSubscriber
from openhands.events.action import (
@@ -47,22 +48,6 @@ STATUS_MESSAGES = {
}
class RuntimeUnavailableError(Exception):
pass
class RuntimeNotReadyError(RuntimeUnavailableError):
pass
class RuntimeDisconnectedError(RuntimeUnavailableError):
pass
class RuntimeNotFoundError(RuntimeUnavailableError):
pass
def _default_env_vars(sandbox_config: SandboxConfig) -> dict[str, str]:
ret = {}
for key in os.environ:
@@ -193,7 +178,7 @@ class Runtime(FileEditRuntimeMixin):
except Exception as e:
err_id = ''
if isinstance(e, ConnectionError) or isinstance(
e, RuntimeDisconnectedError
e, AgentRuntimeDisconnectedError
):
err_id = 'STATUS$ERROR_RUNTIME_DISCONNECTED'
logger.error(

View File

@@ -59,4 +59,11 @@ async def browse(
last_browser_action_error=str(e),
url=asked_url if action.action == ActionType.BROWSE else '',
trigger_by_action=action.action,
open_pages_urls=[],
active_page_index=-1,
dom_object={},
axtree_object={},
extra_element_properties={},
focused_element_bid='',
last_browser_action='',
)

View File

@@ -24,7 +24,7 @@ class RuntimeBuilder(abc.ABC):
registry prefix). This should be used for subsequent use (e.g., `docker run`).
Raises:
RuntimeError: If the build failed.
AgentRuntimeBuildError: If the build failed.
"""
pass

View File

@@ -6,6 +6,7 @@ import time
import docker
from openhands import __version__ as oh_version
from openhands.core.exceptions import AgentRuntimeBuildError
from openhands.core.logger import RollingLogger
from openhands.core.logger import openhands_logger as logger
from openhands.runtime.builder.base import RuntimeBuilder
@@ -19,7 +20,9 @@ class DockerRuntimeBuilder(RuntimeBuilder):
version_info = self.docker_client.version()
server_version = version_info.get('Version', '').replace('-', '.')
if tuple(map(int, server_version.split('.')[:2])) < (18, 9):
raise RuntimeError('Docker server version must be >= 18.09 to use BuildKit')
raise AgentRuntimeBuildError(
'Docker server version must be >= 18.09 to use BuildKit'
)
self.rolling_logger = RollingLogger(max_lines=10)
@@ -44,7 +47,7 @@ class DockerRuntimeBuilder(RuntimeBuilder):
str: The name of the built Docker image.
Raises:
RuntimeError: If the Docker server version is incompatible or if the build process fails.
AgentRuntimeBuildError: If the Docker server version is incompatible or if the build process fails.
Note:
This method uses Docker BuildKit for improved build performance and caching capabilities.
@@ -55,7 +58,9 @@ class DockerRuntimeBuilder(RuntimeBuilder):
version_info = self.docker_client.version()
server_version = version_info.get('Version', '').replace('-', '.')
if tuple(map(int, server_version.split('.'))) < (18, 9):
raise RuntimeError('Docker server version must be >= 18.09 to use BuildKit')
raise AgentRuntimeBuildError(
'Docker server version must be >= 18.09 to use BuildKit'
)
target_image_hash_name = tags[0]
target_image_repo, target_image_source_tag = target_image_hash_name.split(':')
@@ -154,7 +159,7 @@ class DockerRuntimeBuilder(RuntimeBuilder):
# Check if the image is built successfully
image = self.docker_client.images.get(target_image_hash_name)
if image is None:
raise RuntimeError(
raise AgentRuntimeBuildError(
f'Build failed: Image {target_image_hash_name} not found'
)

View File

@@ -5,6 +5,7 @@ import time
import requests
from openhands.core.exceptions import AgentRuntimeBuildError
from openhands.core.logger import openhands_logger as logger
from openhands.runtime.builder import RuntimeBuilder
from openhands.runtime.utils.request import send_request
@@ -77,7 +78,7 @@ class RemoteRuntimeBuilder(RuntimeBuilder):
while should_continue():
if time.time() - start_time > timeout:
logger.error('Build timed out after 30 minutes')
raise RuntimeError('Build timed out after 30 minutes')
raise AgentRuntimeBuildError('Build timed out after 30 minutes')
status_response = send_request(
self.session,
@@ -88,7 +89,7 @@ class RemoteRuntimeBuilder(RuntimeBuilder):
if status_response.status_code != 200:
logger.error(f'Failed to get build status: {status_response.text}')
raise RuntimeError(
raise AgentRuntimeBuildError(
f'Failed to get build status: {status_response.text}'
)
@@ -110,12 +111,14 @@ class RemoteRuntimeBuilder(RuntimeBuilder):
'error', f'Build failed with status: {status}. Build ID: {build_id}'
)
logger.error(error_message)
raise RuntimeError(error_message)
raise AgentRuntimeBuildError(error_message)
# Wait before polling again
sleep_if_should_continue(30)
raise RuntimeError('Build interrupted (likely received SIGTERM or SIGINT).')
raise AgentRuntimeBuildError(
'Build interrupted (likely received SIGTERM or SIGINT).'
)
def image_exists(self, image_name: str, pull_from_repo: bool = True) -> bool:
"""Checks if an image exists in the remote registry using the /image_exists endpoint."""
@@ -129,7 +132,9 @@ class RemoteRuntimeBuilder(RuntimeBuilder):
if response.status_code != 200:
logger.error(f'Failed to check image existence: {response.text}')
raise RuntimeError(f'Failed to check image existence: {response.text}')
raise AgentRuntimeBuildError(
f'Failed to check image existence: {response.text}'
)
result = response.json()

View File

@@ -12,6 +12,13 @@ import requests
import tenacity
from openhands.core.config import AppConfig
from openhands.core.exceptions import (
AgentRuntimeDisconnectedError,
AgentRuntimeError,
AgentRuntimeNotFoundError,
AgentRuntimeNotReadyError,
AgentRuntimeTimeoutError,
)
from openhands.core.logger import DEBUG
from openhands.core.logger import openhands_logger as logger
from openhands.events import EventStream
@@ -34,11 +41,7 @@ from openhands.events.observation import (
)
from openhands.events.serialization import event_to_dict, observation_from_dict
from openhands.events.serialization.action import ACTION_TYPE_TO_CLASS
from openhands.runtime.base import (
Runtime,
RuntimeDisconnectedError,
RuntimeNotFoundError,
)
from openhands.runtime.base import Runtime
from openhands.runtime.builder import DockerRuntimeBuilder
from openhands.runtime.impl.eventstream.containers import remove_all_containers
from openhands.runtime.plugins import PluginRequirement
@@ -358,14 +361,16 @@ class EventStreamRuntime(Runtime):
try:
container = self.docker_client.containers.get(self.container_name)
if container.status == 'exited':
raise RuntimeDisconnectedError(
raise AgentRuntimeDisconnectedError(
f'Container {self.container_name} has exited.'
)
except docker.errors.NotFound:
raise RuntimeNotFoundError(f'Container {self.container_name} not found.')
raise AgentRuntimeNotFoundError(
f'Container {self.container_name} not found.'
)
if not self.log_streamer:
raise RuntimeError('Runtime client is not ready.')
raise AgentRuntimeNotReadyError('Runtime client is not ready.')
with send_request(
self.session,
@@ -445,7 +450,7 @@ class EventStreamRuntime(Runtime):
obs = observation_from_dict(output)
obs._cause = action.id # type: ignore[attr-defined]
except requests.Timeout:
raise RuntimeError(
raise AgentRuntimeTimeoutError(
f'Runtime failed to return execute_action before the requested timeout of {action.timeout}s'
)
@@ -514,9 +519,9 @@ class EventStreamRuntime(Runtime):
pass
except requests.Timeout:
raise TimeoutError('Copy operation timed out')
raise AgentRuntimeTimeoutError('Copy operation timed out')
except Exception as e:
raise RuntimeError(f'Copy operation failed: {str(e)}')
raise AgentRuntimeError(f'Copy operation failed: {str(e)}')
finally:
if recursive:
os.unlink(temp_zip_path)

View File

@@ -10,6 +10,14 @@ import requests
import tenacity
from openhands.core.config import AppConfig
from openhands.core.exceptions import (
AgentRuntimeDisconnectedError,
AgentRuntimeError,
AgentRuntimeNotFoundError,
AgentRuntimeNotReadyError,
AgentRuntimeTimeoutError,
AgentRuntimeUnavailableError,
)
from openhands.events import EventStream
from openhands.events.action import (
BrowseInteractiveAction,
@@ -28,13 +36,7 @@ from openhands.events.observation import (
)
from openhands.events.serialization import event_to_dict, observation_from_dict
from openhands.events.serialization.action import ACTION_TYPE_TO_CLASS
from openhands.runtime.base import (
Runtime,
RuntimeDisconnectedError,
RuntimeNotFoundError,
RuntimeNotReadyError,
RuntimeUnavailableError,
)
from openhands.runtime.base import Runtime
from openhands.runtime.builder.remote import RemoteRuntimeBuilder
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.utils.command import get_remote_startup_command
@@ -100,7 +102,7 @@ class RemoteRuntime(Runtime):
async def connect(self):
try:
await call_sync_from_async(self._start_or_attach_to_runtime)
except RuntimeNotReadyError:
except AgentRuntimeNotReadyError:
self.log('error', 'Runtime failed to start, timed out before ready')
raise
await call_sync_from_async(self.setup_initial_env)
@@ -111,7 +113,7 @@ class RemoteRuntime(Runtime):
if existing_runtime:
self.log('debug', f'Using existing runtime with ID: {self.runtime_id}')
elif self.attach_to_existing:
raise RuntimeNotFoundError(
raise AgentRuntimeNotFoundError(
f'Could not find existing runtime for SID: {self.sid}'
)
else:
@@ -149,7 +151,7 @@ class RemoteRuntime(Runtime):
'GET',
f'{self.config.sandbox.remote_runtime_api_url}/sessions/{self.sid}',
is_retry=False,
timeout=30,
timeout=60,
) as response:
data = response.json()
status = data.get('status')
@@ -180,7 +182,7 @@ class RemoteRuntime(Runtime):
'GET',
f'{self.config.sandbox.remote_runtime_api_url}/registry_prefix',
is_retry=False,
timeout=10,
timeout=60,
) as response:
response_json = response.json()
registry_prefix = response_json['registry_prefix']
@@ -212,10 +214,10 @@ class RemoteRuntime(Runtime):
f'{self.config.sandbox.remote_runtime_api_url}/image_exists',
is_retry=False,
params={'image': self.container_image},
timeout=10,
timeout=60,
) as response:
if not response.json()['exists']:
raise RuntimeError(
raise AgentRuntimeError(
f'Container image {self.container_image} does not exist'
)
@@ -253,6 +255,7 @@ class RemoteRuntime(Runtime):
f'{self.config.sandbox.remote_runtime_api_url}/start',
is_retry=False,
json=start_request,
timeout=60,
) as response:
self._parse_runtime_response(response)
self.log(
@@ -261,7 +264,7 @@ class RemoteRuntime(Runtime):
)
except requests.HTTPError as e:
self.log('error', f'Unable to start runtime: {e}')
raise RuntimeUnavailableError() from e
raise AgentRuntimeUnavailableError() from e
def _resume_runtime(self):
with self._send_request(
@@ -269,7 +272,7 @@ class RemoteRuntime(Runtime):
f'{self.config.sandbox.remote_runtime_api_url}/resume',
is_retry=False,
json={'runtime_id': self.runtime_id},
timeout=30,
timeout=60,
):
pass
self.log('debug', 'Runtime resumed.')
@@ -294,7 +297,7 @@ class RemoteRuntime(Runtime):
with self._send_request(
'GET',
f'{self.runtime_url}/vscode/connection_token',
timeout=10,
timeout=60,
) as response:
response_json = response.json()
assert isinstance(response_json, dict)
@@ -321,7 +324,7 @@ class RemoteRuntime(Runtime):
)
| stop_if_should_exit(),
reraise=True,
retry=tenacity.retry_if_exception_type(RuntimeNotReadyError),
retry=tenacity.retry_if_exception_type(AgentRuntimeNotReadyError),
wait=tenacity.wait_fixed(2),
)
return retry_decorator(self._wait_until_alive_impl)()
@@ -331,6 +334,7 @@ class RemoteRuntime(Runtime):
with self._send_request(
'GET',
f'{self.config.sandbox.remote_runtime_api_url}/sessions/{self.sid}',
timeout=60,
) as runtime_info_response:
runtime_data = runtime_info_response.json()
assert 'runtime_id' in runtime_data
@@ -347,13 +351,14 @@ class RemoteRuntime(Runtime):
with self._send_request(
'GET',
f'{self.runtime_url}/alive',
timeout=60,
): # will raise exception if we don't get 200 back.
pass
except requests.HTTPError as e:
self.log(
'warning', f"Runtime /alive failed, but pod says it's ready: {e}"
)
raise RuntimeNotReadyError(
raise AgentRuntimeNotReadyError(
f'Runtime /alive failed to respond with 200: {e}'
)
return
@@ -362,14 +367,14 @@ class RemoteRuntime(Runtime):
or pod_status == 'pending'
or pod_status == 'running'
): # nb: Running is not yet Ready
raise RuntimeNotReadyError(
raise AgentRuntimeNotReadyError(
f'Runtime (ID={self.runtime_id}) is not yet ready. Status: {pod_status}'
)
elif pod_status in ('failed', 'unknown', 'crashloopbackoff'):
# clean up the runtime
self.close()
raise RuntimeError(
f'Runtime (ID={self.runtime_id}) failed to start. Current status: {pod_status}'
raise AgentRuntimeUnavailableError(
f'Runtime (ID={self.runtime_id}) failed to start. Current status: {pod_status}. Pod Logs:\n{runtime_data.get("pod_logs", "N/A")}'
)
else:
# Maybe this should be a hard failure, but passing through in case the API changes
@@ -379,7 +384,7 @@ class RemoteRuntime(Runtime):
'debug',
f'Waiting for runtime pod to be active. Current status: {pod_status}',
)
raise RuntimeNotReadyError()
raise AgentRuntimeNotReadyError()
def close(self, timeout: int = 10):
if self.config.sandbox.keep_runtime_alive or self.attach_to_existing:
@@ -434,7 +439,7 @@ class RemoteRuntime(Runtime):
obs = observation_from_dict(output)
obs._cause = action.id # type: ignore[attr-defined]
except requests.Timeout:
raise RuntimeError(
raise AgentRuntimeTimeoutError(
f'Runtime failed to return execute_action before the requested timeout of {action.timeout}s'
)
return obs
@@ -448,7 +453,7 @@ class RemoteRuntime(Runtime):
raise
except requests.HTTPError as e:
if is_runtime_request and e.response.status_code == 404:
raise RuntimeDisconnectedError(
raise AgentRuntimeDisconnectedError(
f'404 error while connecting to {self.runtime_url}'
)
elif is_runtime_request and e.response.status_code == 503:

View File

@@ -10,6 +10,10 @@ from runloop_api_client.types import DevboxView
from runloop_api_client.types.shared_params import LaunchParameters
from openhands.core.config import AppConfig
from openhands.core.exceptions import (
AgentRuntimeNotReadyError,
AgentRuntimeUnavailableError,
)
from openhands.core.logger import openhands_logger as logger
from openhands.events import EventStream
from openhands.runtime.impl.eventstream.eventstream_runtime import EventStreamRuntime
@@ -227,7 +231,7 @@ class RunloopRuntime(EventStreamRuntime):
)
def _wait_until_alive(self):
if not self.log_streamer:
raise RuntimeError('Runtime client is not ready.')
raise AgentRuntimeNotReadyError('Runtime client is not ready.')
response = send_request(
self.session,
'GET',
@@ -239,7 +243,7 @@ class RunloopRuntime(EventStreamRuntime):
else:
msg = f'Action execution API is not alive. Response: {response}'
logger.error(msg)
raise RuntimeError(msg)
raise AgentRuntimeUnavailableError(msg)
def close(self, rm_all_containers: bool | None = True):
if self.log_streamer:

View File

@@ -14,6 +14,7 @@ from jinja2 import Environment, FileSystemLoader
import openhands
from openhands import __version__ as oh_version
from openhands.core.exceptions import AgentRuntimeBuildError
from openhands.core.logger import openhands_logger as logger
from openhands.runtime.builder import DockerRuntimeBuilder, RuntimeBuilder
@@ -364,7 +365,7 @@ def _build_sandbox_image(
extra_build_args=extra_build_args,
)
if not image_name:
raise RuntimeError(f'Build failed for image {names}')
raise AgentRuntimeBuildError(f'Build failed for image {names}')
return image_name

View File

@@ -22,6 +22,7 @@ from openhands.server.routes.files import app as files_api_router
from openhands.server.routes.github import app as github_api_router
from openhands.server.routes.public import app as public_api_router
from openhands.server.routes.security import app as security_api_router
from openhands.server.routes.settings import app as settings_router
from openhands.server.shared import openhands_config, session_manager
from openhands.utils.import_utils import get_impl
@@ -56,6 +57,7 @@ app.include_router(files_api_router)
app.include_router(conversation_api_router)
app.include_router(security_api_router)
app.include_router(feedback_api_router)
app.include_router(settings_router)
app.include_router(github_api_router)

View File

@@ -15,6 +15,9 @@ class OpenhandsConfig(OpenhandsConfigInterface):
attach_session_middleware_path = (
'openhands.server.middleware.AttachSessionMiddleware'
)
settings_store_class: str = (
'openhands.storage.file_settings_store.FileSettingsStore'
)
def verify_config(self):
if self.config_cls:

View File

@@ -10,8 +10,9 @@ from openhands.events.observation.agent import AgentStateChangedObservation
from openhands.events.serialization import event_to_dict
from openhands.events.stream import AsyncEventStreamWrapper
from openhands.server.auth import get_sid_from_token, sign_token
from openhands.server.routes.settings import SettingsStoreImpl
from openhands.server.session.session_init_data import SessionInitData
from openhands.server.shared import config, openhands_config, session_manager, sio
from openhands.server.shared import config, session_manager, sio
@sio.event
@@ -24,15 +25,16 @@ async def oh_action(connection_id: str, data: dict):
# If it's an init, we do it here.
action = data.get('action', '')
if action == ActionType.INIT:
await openhands_config.github_auth(data)
github_token = data.pop('github_token', None)
token = data.pop('token', None)
latest_event_id = int(data.pop('latest_event_id', -1))
kwargs = {k.lower(): v for k, v in (data.get('args') or {}).items()}
session_init_data = SessionInitData(**kwargs)
session_init_data.github_token = github_token
session_init_data.selected_repository = data.get('selected_repository', None)
await init_connection(connection_id, token, session_init_data, latest_event_id)
await init_connection(
connection_id=connection_id,
token=data.get('token', None),
github_token=data.get('github_token', None),
session_init_args={
k.lower(): v for k, v in (data.get('args') or {}).items()
},
latest_event_id=int(data.get('latest_event_id', -1)),
selected_repository=data.get('selected_repository'),
)
return
logger.info(f'sio:oh_action:{connection_id}')
@@ -42,9 +44,19 @@ async def oh_action(connection_id: str, data: dict):
async def init_connection(
connection_id: str,
token: str | None,
session_init_data: SessionInitData,
github_token: str | None,
session_init_args: dict,
latest_event_id: int,
selected_repository: str | None,
):
settings_store = await SettingsStoreImpl.get_instance(config, github_token)
settings = await settings_store.load()
if settings:
session_init_args = {**settings.__dict__, **session_init_args}
session_init_args['github_token'] = github_token
session_init_args['selected_repository'] = selected_repository
session_init_data = SessionInitData(**session_init_args)
if token:
sid = get_sid_from_token(token, config.jwt_secret)
if sid == '':

View File

@@ -13,6 +13,7 @@ from fastapi.responses import FileResponse, JSONResponse
from pathspec import PathSpec
from pathspec.patterns import GitWildMatchPattern
from openhands.core.exceptions import AgentRuntimeUnavailableError
from openhands.core.logger import openhands_logger as logger
from openhands.events.action import (
FileReadAction,
@@ -23,7 +24,7 @@ from openhands.events.observation import (
FileReadObservation,
FileWriteObservation,
)
from openhands.runtime.base import Runtime, RuntimeUnavailableError
from openhands.runtime.base import Runtime
from openhands.server.file_config import (
FILES_TO_IGNORE,
MAX_FILE_SIZE_MB,
@@ -66,7 +67,7 @@ async def list_files(request: Request, path: str | None = None):
runtime: Runtime = request.state.conversation.runtime
try:
file_list = await call_sync_from_async(runtime.list_files, path)
except RuntimeUnavailableError as e:
except AgentRuntimeUnavailableError as e:
logger.error(f'Error listing files: {e}', exc_info=True)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -93,7 +94,7 @@ async def list_files(request: Request, path: str | None = None):
try:
file_list = await filter_for_gitignore(file_list, '')
except RuntimeUnavailableError as e:
except AgentRuntimeUnavailableError as e:
logger.error(f'Error filtering files: {e}', exc_info=True)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -129,7 +130,7 @@ async def select_file(file: str, request: Request):
read_action = FileReadAction(file)
try:
observation = await call_sync_from_async(runtime.run_action, read_action)
except RuntimeUnavailableError as e:
except AgentRuntimeUnavailableError as e:
logger.error(f'Error opening file {file}: {e}', exc_info=True)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -205,7 +206,7 @@ async def upload_file(request: Request, files: list[UploadFile]):
tmp_file_path,
runtime.config.workspace_mount_path_in_sandbox,
)
except RuntimeUnavailableError as e:
except AgentRuntimeUnavailableError as e:
logger.error(
f'Error saving file {safe_filename}: {e}', exc_info=True
)
@@ -282,7 +283,7 @@ async def save_file(request: Request):
write_action = FileWriteAction(file_path, content)
try:
observation = await call_sync_from_async(runtime.run_action, write_action)
except RuntimeUnavailableError as e:
except AgentRuntimeUnavailableError as e:
logger.error(f'Error saving file: {e}', exc_info=True)
return JSONResponse(
status_code=500,
@@ -317,7 +318,7 @@ async def zip_current_workspace(request: Request, background_tasks: BackgroundTa
path = runtime.config.workspace_mount_path_in_sandbox
try:
zip_file = await call_sync_from_async(runtime.copy_from, path)
except RuntimeUnavailableError as e:
except AgentRuntimeUnavailableError as e:
logger.error(f'Error zipping workspace: {e}', exc_info=True)
return JSONResponse(
status_code=500,

View File

@@ -52,5 +52,11 @@ def get_github_repositories(
detail=f'Error fetching repositories: {str(e)}',
)
# Return the JSON response
return JSONResponse(content=response.json())
# Create response with the JSON content
json_response = JSONResponse(content=response.json())
# Forward the Link header if it exists
if 'Link' in response.headers:
json_response.headers['Link'] = response.headers['Link']
return json_response

View File

@@ -0,0 +1,53 @@
from typing import Annotated
from fastapi import APIRouter, Header, status
from fastapi.responses import JSONResponse
from openhands.core.logger import openhands_logger as logger
from openhands.server.settings import Settings
from openhands.server.shared import config, openhands_config
from openhands.storage.settings_store import SettingsStore
from openhands.utils.import_utils import get_impl
app = APIRouter(prefix='/api')
SettingsStoreImpl = get_impl(SettingsStore, openhands_config.settings_store_class) # type: ignore
@app.get('/settings')
async def load_settings(
github_auth: Annotated[str | None, Header()] = None,
) -> Settings | None:
try:
settings_store = await SettingsStoreImpl.get_instance(config, github_auth)
settings = await settings_store.load()
if settings:
# For security reasons we don't ever send the api key to the client
settings.llm_api_key = None
return settings
except Exception as e:
logger.warning(f'Invalid token: {e}')
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'Invalid token'},
)
@app.post('/settings')
async def store_settings(
settings: Settings,
github_auth: Annotated[str | None, Header()] = None,
) -> bool:
try:
settings_store = await SettingsStoreImpl.get_instance(config, github_auth)
existing_settings = await settings_store.load()
if existing_settings:
if settings.llm_api_key is None:
settings.llm_api_key = existing_settings.llm_api_key
return await settings_store.store(settings)
except Exception as e:
logger.warning(f'Invalid token: {e}')
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'Invalid token'},
)

View File

@@ -5,16 +5,18 @@ from openhands.controller import AgentController
from openhands.controller.agent import Agent
from openhands.controller.state.state import State
from openhands.core.config import AgentConfig, AppConfig, LLMConfig
from openhands.core.exceptions import AgentRuntimeUnavailableError
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events.action import ChangeAgentStateAction
from openhands.events.event import EventSource
from openhands.events.stream import EventStream
from openhands.runtime import get_runtime_cls
from openhands.runtime.base import Runtime, RuntimeUnavailableError
from openhands.runtime.base import Runtime
from openhands.security import SecurityAnalyzer, options
from openhands.storage.files import FileStore
from openhands.utils.async_utils import call_async_from_sync
from openhands.utils.shutdown_listener import should_continue
WAIT_TIME_BEFORE_CLOSE = 300
WAIT_TIME_BEFORE_CLOSE_INTERVAL = 5
@@ -152,7 +154,7 @@ class AgentSession:
async def _close(self):
seconds_waited = 0
while self._initializing:
while self._initializing and should_continue():
logger.debug(
f'Waiting for initialization to finish before closing session {self.sid}'
)
@@ -221,7 +223,7 @@ class AgentSession:
try:
await self.runtime.connect()
except RuntimeUnavailableError as e:
except AgentRuntimeUnavailableError as e:
logger.error(f'Runtime initialization failed: {e}', exc_info=True)
if self._status_callback:
self._status_callback(

View File

@@ -6,9 +6,9 @@ from dataclasses import dataclass, field
import socketio
from openhands.core.config import AppConfig
from openhands.core.exceptions import AgentRuntimeUnavailableError
from openhands.core.logger import openhands_logger as logger
from openhands.events.stream import EventStream, session_exists
from openhands.runtime.base import RuntimeUnavailableError
from openhands.server.session.conversation import Conversation
from openhands.server.session.session import ROOM_KEY, Session
from openhands.server.session.session_init_data import SessionInitData
@@ -29,6 +29,14 @@ class SessionManager:
_last_alive_timestamps: dict[str, float] = field(default_factory=dict)
_redis_listen_task: asyncio.Task | None = None
_session_is_running_flags: dict[str, asyncio.Event] = field(default_factory=dict)
_active_conversations: dict[str, tuple[Conversation, int]] = field(
default_factory=dict
)
_detached_conversations: dict[str, tuple[Conversation, float]] = field(
default_factory=dict
)
_conversations_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
_cleanup_task: asyncio.Task | None = None
_has_remote_connections_flags: dict[str, asyncio.Event] = field(
default_factory=dict
)
@@ -37,12 +45,16 @@ class SessionManager:
redis_client = self._get_redis_client()
if redis_client:
self._redis_listen_task = asyncio.create_task(self._redis_subscribe())
self._cleanup_task = asyncio.create_task(self._cleanup_detached_conversations())
return self
async def __aexit__(self, exc_type, exc_value, traceback):
if self._redis_listen_task:
self._redis_listen_task.cancel()
self._redis_listen_task = None
if self._cleanup_task:
self._cleanup_task.cancel()
self._cleanup_task = None
def _get_redis_client(self):
redis_client = getattr(self.sio.manager, 'redis', None)
@@ -128,20 +140,68 @@ class SessionManager:
start_time = time.time()
if not await session_exists(sid, self.file_store):
return None
c = Conversation(sid, file_store=self.file_store, config=self.config)
try:
await c.connect()
except RuntimeUnavailableError as e:
logger.error(f'Error connecting to conversation {c.sid}: {e}')
return None
end_time = time.time()
logger.info(
f'Conversation {c.sid} connected in {end_time - start_time} seconds'
)
return c
async with self._conversations_lock:
# Check if we have an active conversation we can reuse
if sid in self._active_conversations:
conversation, count = self._active_conversations[sid]
self._active_conversations[sid] = (conversation, count + 1)
logger.info(f'Reusing active conversation {sid}')
return conversation
# Check if we have a detached conversation we can reuse
if sid in self._detached_conversations:
conversation, _ = self._detached_conversations.pop(sid)
self._active_conversations[sid] = (conversation, 1)
logger.info(f'Reusing detached conversation {sid}')
return conversation
# Create new conversation if none exists
c = Conversation(sid, file_store=self.file_store, config=self.config)
try:
await c.connect()
except AgentRuntimeUnavailableError as e:
logger.error(f'Error connecting to conversation {c.sid}: {e}')
return None
end_time = time.time()
logger.info(
f'Conversation {c.sid} connected in {end_time - start_time} seconds'
)
self._active_conversations[sid] = (c, 1)
return c
async def detach_from_conversation(self, conversation: Conversation):
await conversation.disconnect()
sid = conversation.sid
async with self._conversations_lock:
if sid in self._active_conversations:
conv, count = self._active_conversations[sid]
if count > 1:
self._active_conversations[sid] = (conv, count - 1)
return
else:
self._active_conversations.pop(sid)
self._detached_conversations[sid] = (conversation, time.time())
async def _cleanup_detached_conversations(self):
while should_continue():
try:
async with self._conversations_lock:
# Create a list of items to process to avoid modifying dict during iteration
items = list(self._detached_conversations.items())
for sid, (conversation, detach_time) in items:
await conversation.disconnect()
self._detached_conversations.pop(sid, None)
await asyncio.sleep(60)
except asyncio.CancelledError:
async with self._conversations_lock:
for conversation, _ in self._detached_conversations.values():
await conversation.disconnect()
self._detached_conversations.clear()
return
except Exception:
logger.warning('error_cleaning_detached_conversations', exc_info=True)
await asyncio.sleep(15)
async def init_or_join_session(
self, sid: str, connection_id: str, session_init_data: SessionInitData

View File

@@ -1,19 +1,13 @@
from dataclasses import dataclass
from openhands.server.settings import Settings
@dataclass
class SessionInitData:
class SessionInitData(Settings):
"""
Session initialization data for the web environment - a deep copy of the global config is made and then overridden with this data.
"""
language: str | None = None
agent: str | None = None
max_iterations: int | None = None
security_analyzer: str | None = None
confirmation_mode: bool | None = None
llm_model: str | None = None
llm_api_key: str | None = None
llm_base_url: str | None = None
github_token: str | None = None
selected_repository: str | None = None

View File

@@ -0,0 +1,17 @@
from dataclasses import dataclass
@dataclass
class Settings:
"""
Persisted settings for OpenHands sessions
"""
language: str | None = None
agent: str | None = None
max_iterations: int | None = None
security_analyzer: str | None = None
confirmation_mode: bool | None = None
llm_model: str | None = None
llm_api_key: str | None = None
llm_base_url: str | None = None

View File

@@ -0,0 +1,34 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from openhands.core.config.app_config import AppConfig
from openhands.server.settings import Settings
from openhands.storage import get_file_store
from openhands.storage.files import FileStore
from openhands.storage.settings_store import SettingsStore
@dataclass
class FileSettingsStore(SettingsStore):
file_store: FileStore
path: str = 'settings.json'
async def load(self) -> Settings | None:
try:
json_str = self.file_store.read(self.path)
kwargs = json.loads(json_str)
settings = Settings(**kwargs)
return settings
except FileNotFoundError:
return None
async def store(self, settings: Settings):
json_str = json.dumps(settings.__dict__)
self.file_store.write(self.path, json_str)
@classmethod
async def get_instance(cls, config: AppConfig, token: str | None):
file_store = get_file_store(config.file_store, config.file_store_path)
return FileSettingsStore(file_store)

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from openhands.core.config.app_config import AppConfig
from openhands.server.settings import Settings
class SettingsStore(ABC):
"""
Storage for SessionInitData. May or may not support multiple users depending on the environment
"""
@abstractmethod
async def load(self) -> Settings | None:
"""Load session init data"""
@abstractmethod
async def store(self, settings: Settings):
"""Store session init data"""
@classmethod
@abstractmethod
async def get_instance(cls, config: AppConfig, token: str | None) -> SettingsStore:
"""Get a store for the user represented by the token given"""

197
poetry.lock generated
View File

@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand.
[[package]]
name = "aiohappyeyeballs"
@@ -170,13 +170,13 @@ files = [
[[package]]
name = "anthropic"
version = "0.40.0"
version = "0.42.0"
description = "The official Python library for the anthropic API"
optional = false
python-versions = ">=3.8"
files = [
{file = "anthropic-0.40.0-py3-none-any.whl", hash = "sha256:442028ae8790ff9e3b6f8912043918755af1230d193904ae2ef78cc22995280c"},
{file = "anthropic-0.40.0.tar.gz", hash = "sha256:3efeca6d9e97813f93ed34322c6c7ea2279bf0824cd0aa71b59ce222665e2b87"},
{file = "anthropic-0.42.0-py3-none-any.whl", hash = "sha256:46775f65b723c078a2ac9e9de44a46db5c6a4fabeacfd165e5ea78e6817f4eff"},
{file = "anthropic-0.42.0.tar.gz", hash = "sha256:bf8b0ed8c8cb2c2118038f29c58099d2f99f7847296cafdaa853910bfff4edf4"},
]
[package.dependencies]
@@ -187,7 +187,7 @@ httpx = ">=0.23.0,<1"
jiter = ">=0.4.0,<1"
pydantic = ">=1.9.0,<3"
sniffio = "*"
typing-extensions = ">=4.7,<5"
typing-extensions = ">=4.10,<5"
[package.extras]
bedrock = ["boto3 (>=1.28.57)", "botocore (>=1.31.57)"]
@@ -553,17 +553,17 @@ files = [
[[package]]
name = "boto3"
version = "1.35.82"
version = "1.35.84"
description = "The AWS SDK for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "boto3-1.35.82-py3-none-any.whl", hash = "sha256:c422b68ae76959b9e23b77eb79e41c3483332f7e1de918d2b083c456d8cf234c"},
{file = "boto3-1.35.82.tar.gz", hash = "sha256:2bbaf1551b1ed55770cb437d7040f1abe6742601103695057b30ce6328eef286"},
{file = "boto3-1.35.84-py3-none-any.whl", hash = "sha256:c94fc8023caf952f8740a48fc400521bba167f883cfa547d985c05fda7223f7a"},
{file = "boto3-1.35.84.tar.gz", hash = "sha256:9f9bf72d92f7fdd546b974ffa45fa6715b9af7f5c00463e9d0f6ef9c95efe0c2"},
]
[package.dependencies]
botocore = ">=1.35.82,<1.36.0"
botocore = ">=1.35.84,<1.36.0"
jmespath = ">=0.7.1,<2.0.0"
s3transfer = ">=0.10.0,<0.11.0"
@@ -572,13 +572,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
[[package]]
name = "botocore"
version = "1.35.82"
version = "1.35.84"
description = "Low-level, data-driven core of boto 3."
optional = false
python-versions = ">=3.8"
files = [
{file = "botocore-1.35.82-py3-none-any.whl", hash = "sha256:e43b97d8cbf19d35ce3a177f144bd97cc370f0a67d0984c7d7cf105ac198748f"},
{file = "botocore-1.35.82.tar.gz", hash = "sha256:78dd7bf8f49616d00073698d7bbaf5a115208fe730b7b7afae4456adddb3552e"},
{file = "botocore-1.35.84-py3-none-any.whl", hash = "sha256:b4dc2ac7f54ba959429e1debbd6c7c2fb2349baa1cd63803f0682f0773dbd077"},
{file = "botocore-1.35.84.tar.gz", hash = "sha256:f86754882e04683e2e99a6a23377d0dd7f1fc2b2242844b2381dbe4dcd639301"},
]
[package.dependencies]
@@ -2175,13 +2175,13 @@ grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"]
[[package]]
name = "google-api-python-client"
version = "2.155.0"
version = "2.156.0"
description = "Google API Client Library for Python"
optional = false
python-versions = ">=3.7"
files = [
{file = "google_api_python_client-2.155.0-py2.py3-none-any.whl", hash = "sha256:83fe9b5aa4160899079d7c93a37be306546a17e6686e2549bcc9584f1a229747"},
{file = "google_api_python_client-2.155.0.tar.gz", hash = "sha256:25529f89f0d13abcf3c05c089c423fb2858ac16e0b3727543393468d0d7af67c"},
{file = "google_api_python_client-2.156.0-py2.py3-none-any.whl", hash = "sha256:6352185c505e1f311f11b0b96c1b636dcb0fec82cd04b80ac5a671ac4dcab339"},
{file = "google_api_python_client-2.156.0.tar.gz", hash = "sha256:b809c111ded61716a9c1c7936e6899053f13bae3defcdfda904bd2ca68065b9c"},
]
[package.dependencies]
@@ -2249,13 +2249,13 @@ tool = ["click (>=6.0.0)"]
[[package]]
name = "google-cloud-aiplatform"
version = "1.74.0"
version = "1.75.0"
description = "Vertex AI API client library"
optional = false
python-versions = ">=3.8"
files = [
{file = "google_cloud_aiplatform-1.74.0-py2.py3-none-any.whl", hash = "sha256:7f37a835e543a4cb4b62505928b983e307c5fee6d949f831cd3804f03c753d87"},
{file = "google_cloud_aiplatform-1.74.0.tar.gz", hash = "sha256:2202e4e0cbbd2db02835737a1ae9a51ad7bf75c8ed130a3fdbcfced33525e3f0"},
{file = "google_cloud_aiplatform-1.75.0-py2.py3-none-any.whl", hash = "sha256:eb5d79b5f7210d79a22b53c93a69b5bae5680dfc829387ea020765b97786b3d0"},
{file = "google_cloud_aiplatform-1.75.0.tar.gz", hash = "sha256:eb8404abf1134b3b368535fe429c4eec2fd12d444c2e9ffbc329ddcbc72b36c9"},
]
[package.dependencies]
@@ -2286,10 +2286,10 @@ pipelines = ["pyyaml (>=5.3.1,<7)"]
prediction = ["docker (>=5.0.3)", "fastapi (>=0.71.0,<=0.114.0)", "httpx (>=0.23.0,<0.25.0)", "starlette (>=0.17.1)", "uvicorn[standard] (>=0.16.0)"]
private-endpoints = ["requests (>=2.28.1)", "urllib3 (>=1.21.1,<1.27)"]
ray = ["google-cloud-bigquery", "google-cloud-bigquery-storage", "immutabledict", "pandas (>=1.0.0)", "pyarrow (>=6.0.1)", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<2.10.dev0 || >=2.33.dev0,<=2.33.0)", "ray[default] (>=2.5,<=2.33.0)", "setuptools (<70.0.0)"]
ray-testing = ["google-cloud-bigquery", "google-cloud-bigquery-storage", "immutabledict", "pandas (>=1.0.0)", "pyarrow (>=6.0.1)", "pytest-xdist", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<2.10.dev0 || >=2.33.dev0,<=2.33.0)", "ray[default] (>=2.5,<=2.33.0)", "ray[train]", "scikit-learn", "setuptools (<70.0.0)", "tensorflow", "torch (>=2.0.0,<2.1.0)", "xgboost", "xgboost-ray"]
ray-testing = ["google-cloud-bigquery", "google-cloud-bigquery-storage", "immutabledict", "pandas (>=1.0.0)", "pyarrow (>=6.0.1)", "pytest-xdist", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<2.10.dev0 || >=2.33.dev0,<=2.33.0)", "ray[default] (>=2.5,<=2.33.0)", "ray[train]", "scikit-learn (<1.6.0)", "setuptools (<70.0.0)", "tensorflow", "torch (>=2.0.0,<2.1.0)", "xgboost", "xgboost-ray"]
reasoningengine = ["cloudpickle (>=3.0,<4.0)", "google-cloud-trace (<2)", "opentelemetry-exporter-gcp-trace (<2)", "opentelemetry-sdk (<2)", "pydantic (>=2.6.3,<2.10)"]
tensorboard = ["tensorboard-plugin-profile (>=2.4.0,<2.18.0)", "tensorflow (>=2.3.0,<3.0.0dev)", "tensorflow (>=2.4.0,<3.0.0dev)", "werkzeug (>=2.0.0,<2.1.0dev)"]
testing = ["aiohttp", "bigframes", "docker (>=5.0.3)", "explainable-ai-sdk (>=1.0.0)", "fastapi (>=0.71.0,<=0.114.0)", "google-api-core (>=2.11,<3.0.0)", "google-cloud-bigquery", "google-cloud-bigquery-storage", "google-vizier (>=0.1.6)", "grpcio-testing", "httpx (>=0.23.0,<0.25.0)", "immutabledict", "ipython", "kfp (>=2.6.0,<3.0.0)", "lit-nlp (==0.4.0)", "mlflow (>=1.27.0,<=2.16.0)", "nltk", "numpy (>=1.15.0)", "pandas (>=1.0.0)", "pyarrow (>=10.0.1)", "pyarrow (>=14.0.0)", "pyarrow (>=3.0.0,<8.0dev)", "pyarrow (>=6.0.1)", "pytest-asyncio", "pytest-xdist", "pyyaml (>=5.3.1,<7)", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<2.10.dev0 || >=2.33.dev0,<=2.33.0)", "ray[default] (>=2.5,<=2.33.0)", "requests (>=2.28.1)", "requests-toolbelt (<1.0.0)", "scikit-learn", "sentencepiece (>=0.2.0)", "setuptools (<70.0.0)", "starlette (>=0.17.1)", "tensorboard-plugin-profile (>=2.4.0,<2.18.0)", "tensorflow (==2.13.0)", "tensorflow (==2.16.1)", "tensorflow (>=2.3.0,<3.0.0dev)", "tensorflow (>=2.3.0,<3.0.0dev)", "tensorflow (>=2.4.0,<3.0.0dev)", "torch (>=2.0.0,<2.1.0)", "torch (>=2.2.0)", "tqdm (>=4.23.0)", "urllib3 (>=1.21.1,<1.27)", "uvicorn[standard] (>=0.16.0)", "werkzeug (>=2.0.0,<2.1.0dev)", "xgboost"]
testing = ["aiohttp", "bigframes", "docker (>=5.0.3)", "explainable-ai-sdk (>=1.0.0)", "fastapi (>=0.71.0,<=0.114.0)", "google-api-core (>=2.11,<3.0.0)", "google-cloud-bigquery", "google-cloud-bigquery-storage", "google-vizier (>=0.1.6)", "grpcio-testing", "httpx (>=0.23.0,<0.25.0)", "immutabledict", "ipython", "kfp (>=2.6.0,<3.0.0)", "lit-nlp (==0.4.0)", "mlflow (>=1.27.0,<=2.16.0)", "nltk", "numpy (>=1.15.0)", "pandas (>=1.0.0)", "pyarrow (>=10.0.1)", "pyarrow (>=14.0.0)", "pyarrow (>=3.0.0,<8.0dev)", "pyarrow (>=6.0.1)", "pytest-asyncio", "pytest-xdist", "pyyaml (>=5.3.1,<7)", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<2.10.dev0 || >=2.33.dev0,<=2.33.0)", "ray[default] (>=2.5,<=2.33.0)", "requests (>=2.28.1)", "requests-toolbelt (<1.0.0)", "scikit-learn", "scikit-learn (<1.6.0)", "sentencepiece (>=0.2.0)", "setuptools (<70.0.0)", "starlette (>=0.17.1)", "tensorboard-plugin-profile (>=2.4.0,<2.18.0)", "tensorflow (==2.13.0)", "tensorflow (==2.16.1)", "tensorflow (>=2.3.0,<3.0.0dev)", "tensorflow (>=2.3.0,<3.0.0dev)", "tensorflow (>=2.4.0,<3.0.0dev)", "torch (>=2.0.0,<2.1.0)", "torch (>=2.2.0)", "tqdm (>=4.23.0)", "urllib3 (>=1.21.1,<1.27)", "uvicorn[standard] (>=0.16.0)", "werkzeug (>=2.0.0,<2.1.0dev)", "xgboost"]
tokenization = ["sentencepiece (>=0.2.0)"]
vizier = ["google-vizier (>=0.1.6)"]
xai = ["tensorflow (>=2.3.0,<3.0.0dev)"]
@@ -3256,13 +3256,13 @@ files = [
[[package]]
name = "json-repair"
version = "0.31.0"
version = "0.32.0"
description = "A package to repair broken json strings"
optional = false
python-versions = ">=3.9"
files = [
{file = "json_repair-0.31.0-py3-none-any.whl", hash = "sha256:82a4f60a9a836ed12b103367477af96f94fc6a14310d70a06e5354c3eb287d11"},
{file = "json_repair-0.31.0.tar.gz", hash = "sha256:3539dbb1857fa2c64404e1cf3e53b091738e74fcf3f12762d4199a0e2af657f5"},
{file = "json_repair-0.32.0-py3-none-any.whl", hash = "sha256:a06a83c62e75c69a58cda5902f5631adec567d6584413cf233b412b491cf8580"},
{file = "json_repair-0.32.0.tar.gz", hash = "sha256:eed776fb24dbcce5bcd200f3c254a7d70fda40405c31c97f52a5ca8cfb7cf3e4"},
]
[[package]]
@@ -3494,13 +3494,13 @@ test = ["jupyter-server (>=2.0.0)", "pytest (>=7.0)", "pytest-jupyter[server] (>
[[package]]
name = "jupyterlab"
version = "4.3.3"
version = "4.3.4"
description = "JupyterLab computational environment"
optional = false
python-versions = ">=3.8"
files = [
{file = "jupyterlab-4.3.3-py3-none-any.whl", hash = "sha256:32a8fd30677e734ffcc3916a4758b9dab21b02015b668c60eb36f84357b7d4b1"},
{file = "jupyterlab-4.3.3.tar.gz", hash = "sha256:76fa39e548fdac94dc1204af5956c556f54c785f70ee26aa47ea08eda4d5bbcd"},
{file = "jupyterlab-4.3.4-py3-none-any.whl", hash = "sha256:b754c2601c5be6adf87cb5a1d8495d653ffb945f021939f77776acaa94dae952"},
{file = "jupyterlab-4.3.4.tar.gz", hash = "sha256:f0bb9b09a04766e3423cccc2fc23169aa2ffedcdf8713e9e0fb33cac0b6859d0"},
]
[package.dependencies]
@@ -3739,13 +3739,13 @@ types-tqdm = "*"
[[package]]
name = "litellm"
version = "1.55.3"
version = "1.55.6"
description = "Library to easily interface with LLM API providers"
optional = false
python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8"
files = [
{file = "litellm-1.55.3-py3-none-any.whl", hash = "sha256:83e48a6104f2b30edb62f708ca47dfd42f22416ed2ab34702770b3a57d866aa5"},
{file = "litellm-1.55.3.tar.gz", hash = "sha256:83f7fd5c7b6eec1d431e786be9d73fbb72f1f1bc9aff333169a4a8eb61e72018"},
{file = "litellm-1.55.6-py3-none-any.whl", hash = "sha256:9987d275fe92096daed2a50cb7fcfb6a937bbab3934c03cf20c432dd1dff82f0"},
{file = "litellm-1.55.6.tar.gz", hash = "sha256:8a2dfee6b432ac51d2e824f21d4fe55cd60015639ff233d7018dfadc6f093cfa"},
]
[package.dependencies]
@@ -3783,22 +3783,21 @@ pydantic = ">=1.10"
[[package]]
name = "llama-index"
version = "0.12.5"
version = "0.12.6"
description = "Interface between LLMs and your data"
optional = false
python-versions = "<4.0,>=3.9"
files = [
{file = "llama_index-0.12.5-py3-none-any.whl", hash = "sha256:2bb6d234cf6d7fdb6a308e9aff1a607e83a24210cc7325be62c65bc43493680f"},
{file = "llama_index-0.12.5.tar.gz", hash = "sha256:a816f18079c88e17b53fab6efc27f7c3dfb0a7af559afaaeaeef0577654235a4"},
{file = "llama_index-0.12.6-py3-none-any.whl", hash = "sha256:30cb996992da67b1e32207784746a5779d0c7582d453cb8f7e0887dc7f241e76"},
{file = "llama_index-0.12.6.tar.gz", hash = "sha256:485c73b2d221e1509f7948da707944b1053466f8a1464ebaf989e64bd7db6efd"},
]
[package.dependencies]
llama-index-agent-openai = ">=0.4.0,<0.5.0"
llama-index-cli = ">=0.4.0,<0.5.0"
llama-index-core = ">=0.12.5,<0.13.0"
llama-index-core = ">=0.12.6,<0.13.0"
llama-index-embeddings-openai = ">=0.3.0,<0.4.0"
llama-index-indices-managed-llama-cloud = ">=0.4.0"
llama-index-legacy = ">=0.9.48,<0.10.0"
llama-index-llms-openai = ">=0.3.0,<0.4.0"
llama-index-multi-modal-llms-openai = ">=0.4.0,<0.5.0"
llama-index-program-openai = ">=0.3.0,<0.4.0"
@@ -3841,13 +3840,13 @@ llama-index-llms-openai = ">=0.3.0,<0.4.0"
[[package]]
name = "llama-index-core"
version = "0.12.5"
version = "0.12.6"
description = "Interface between LLMs and your data"
optional = false
python-versions = "<4.0,>=3.9"
files = [
{file = "llama_index_core-0.12.5-py3-none-any.whl", hash = "sha256:1fe6dd39b2dc5a945b4702d780a2f5962a553e187524a255429461dc92a664db"},
{file = "llama_index_core-0.12.5.tar.gz", hash = "sha256:1d967323891920579fad3d6497587c137894df3f76718a3ec134f9201f2f4fc0"},
{file = "llama_index_core-0.12.6-py3-none-any.whl", hash = "sha256:31d72b61c9f582bb879bf110e09a97b0106acdee155ce92cbbeed4df74ce0b8c"},
{file = "llama_index_core-0.12.6.tar.gz", hash = "sha256:fb380db6472d1d5e25297676b4e82a7644890fcc4d264ab10ac12490ebf359d4"},
]
[package.dependencies]
@@ -3966,45 +3965,6 @@ files = [
llama-cloud = ">=0.1.5"
llama-index-core = ">=0.12.0,<0.13.0"
[[package]]
name = "llama-index-legacy"
version = "0.9.48.post4"
description = "Interface between LLMs and your data"
optional = false
python-versions = "<4.0,>=3.8.1"
files = [
{file = "llama_index_legacy-0.9.48.post4-py3-none-any.whl", hash = "sha256:4b817d7c343fb5f7f00c4410eff519f320013b8d5f24c4fedcf270c471f92038"},
{file = "llama_index_legacy-0.9.48.post4.tar.gz", hash = "sha256:f8a9764e7e134a52bfef5e53d2d62561bfc01fc09874c51cc001df6f5302ae30"},
]
[package.dependencies]
aiohttp = ">=3.8.6,<4.0.0"
dataclasses-json = "*"
deprecated = ">=1.2.9.3"
dirtyjson = ">=1.0.8,<2.0.0"
fsspec = ">=2023.5.0"
httpx = "*"
nest-asyncio = ">=1.5.8,<2.0.0"
networkx = ">=3.0"
nltk = ">=3.8.1"
numpy = "*"
openai = ">=1.1.0"
pandas = "*"
requests = ">=2.31.0"
SQLAlchemy = {version = ">=1.4.49", extras = ["asyncio"]}
tenacity = ">=8.2.0,<9.0.0"
tiktoken = ">=0.3.3"
typing-extensions = ">=4.5.0"
typing-inspect = ">=0.8.0"
[package.extras]
gradientai = ["gradientai (>=1.4.0)"]
html = ["beautifulsoup4 (>=4.12.2,<5.0.0)"]
langchain = ["langchain (>=0.0.303)"]
local-models = ["optimum[onnxruntime] (>=1.13.2,<2.0.0)", "sentencepiece (>=0.1.99,<0.2.0)", "transformers[torch] (>=4.33.1,<5.0.0)"]
postgres = ["asyncpg (>=0.28.0,<0.29.0)", "pgvector (>=0.1.0,<0.2.0)", "psycopg2-binary (>=2.9.9,<3.0.0)"]
query-tools = ["guidance (>=0.0.64,<0.0.65)", "jsonpath-ng (>=1.6.0,<2.0.0)", "lm-format-enforcer (>=0.4.3,<0.5.0)", "rank-bm25 (>=0.2.2,<0.3.0)", "scikit-learn", "spacy (>=3.7.1,<4.0.0)"]
[[package]]
name = "llama-index-llms-azure-openai"
version = "0.3.0"
@@ -4513,13 +4473,13 @@ files = [
[[package]]
name = "minio"
version = "7.2.12"
version = "7.2.13"
description = "MinIO Python SDK for Amazon S3 Compatible Cloud Storage"
optional = false
python-versions = ">=3.9"
files = [
{file = "minio-7.2.12-py3-none-any.whl", hash = "sha256:4b63370ca83f82c23e6fb0a094a1e2b08b275884ae43f6a90c4388a45633e3f5"},
{file = "minio-7.2.12.tar.gz", hash = "sha256:2a3fcf4ab753824de8ae3ffeb14da33d6ad416f83a7e82363a27b34da8e91f27"},
{file = "minio-7.2.13-py3-none-any.whl", hash = "sha256:ad806be056e6b49510ad27f0782976c0b9d4c16baccd9d75518d97709bd5c105"},
{file = "minio-7.2.13.tar.gz", hash = "sha256:0fc878da4c5139138f66d3f00ae898ed74cead854b900420b02cd68cf4be7133"},
]
[package.dependencies]
@@ -4655,12 +4615,12 @@ type = ["mypy (==1.11.2)"]
[[package]]
name = "modal"
version = "0.68.26"
version = "0.68.42"
description = "Python client library for Modal"
optional = false
python-versions = ">=3.9"
files = [
{file = "modal-0.68.26-py3-none-any.whl", hash = "sha256:ddc433ec699493b69d30e05e2e7747548b452ed13eb5cba26622da3ae2a61ad1"},
{file = "modal-0.68.42-py3-none-any.whl", hash = "sha256:aaf4d508592016dc694d1cb802900e2a0790dc99926f83ade58d0b4966e88955"},
]
[package.dependencies]
@@ -4671,7 +4631,7 @@ fastapi = "*"
grpclib = "0.4.7"
protobuf = ">=3.19,<4.24.0 || >4.24.0,<6.0"
rich = ">=12.0.0"
synchronicity = ">=0.9.6,<0.10.0"
synchronicity = ">=0.9.7,<0.10.0"
toml = "*"
typer = ">=0.9"
types-certifi = "*"
@@ -5423,13 +5383,13 @@ sympy = "*"
[[package]]
name = "openai"
version = "1.57.4"
version = "1.58.1"
description = "The official Python library for the openai API"
optional = false
python-versions = ">=3.8"
files = [
{file = "openai-1.57.4-py3-none-any.whl", hash = "sha256:7def1ab2d52f196357ce31b9cfcf4181529ce00838286426bb35be81c035dafb"},
{file = "openai-1.57.4.tar.gz", hash = "sha256:a8f071a3e9198e2818f63aade68e759417b9f62c0971bdb83de82504b70b77f7"},
{file = "openai-1.58.1-py3-none-any.whl", hash = "sha256:e2910b1170a6b7f88ef491ac3a42c387f08bd3db533411f7ee391d166571d63c"},
{file = "openai-1.58.1.tar.gz", hash = "sha256:f5a035fd01e141fc743f4b0e02c41ca49be8fab0866d3b67f5f29b4f4d3c0973"},
]
[package.dependencies]
@@ -5444,6 +5404,7 @@ typing-extensions = ">=4.11,<5"
[package.extras]
datalib = ["numpy (>=1)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)"]
realtime = ["websockets (>=13,<15)"]
[[package]]
name = "opencv-python"
@@ -7000,13 +6961,13 @@ cli = ["click (>=5.0)"]
[[package]]
name = "python-engineio"
version = "4.10.1"
version = "4.11.1"
description = "Engine.IO server and client for Python"
optional = false
python-versions = ">=3.6"
files = [
{file = "python_engineio-4.10.1-py3-none-any.whl", hash = "sha256:445a94004ec8034960ab99e7ce4209ec619c6e6b6a12aedcb05abeab924025c0"},
{file = "python_engineio-4.10.1.tar.gz", hash = "sha256:166cea8dd7429638c5c4e3a4895beae95196e860bc6f29ed0b9fe753d1ef2072"},
{file = "python_engineio-4.11.1-py3-none-any.whl", hash = "sha256:8ff9ec366724cd9b0fd92acf7a61b15ae923d28f37f842304adbd7f71b3d6672"},
{file = "python_engineio-4.11.1.tar.gz", hash = "sha256:ff8a23a843c223ec793835f1bcf584ff89ce0f1c2bcce37dffa6436c6fa74133"},
]
[package.dependencies]
@@ -7076,18 +7037,18 @@ XlsxWriter = ">=0.5.7"
[[package]]
name = "python-socketio"
version = "5.11.4"
version = "5.12.0"
description = "Socket.IO server and client for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "python_socketio-5.11.4-py3-none-any.whl", hash = "sha256:42efaa3e3e0b166fc72a527488a13caaac2cefc76174252486503bd496284945"},
{file = "python_socketio-5.11.4.tar.gz", hash = "sha256:8b0b8ff2964b2957c865835e936310190639c00310a47d77321a594d1665355e"},
{file = "python_socketio-5.12.0-py3-none-any.whl", hash = "sha256:50fe22fd2b0aa634df3e74489e42217b09af2fb22eee45f2c006df36d1d08cb9"},
{file = "python_socketio-5.12.0.tar.gz", hash = "sha256:39b55bff4ef6ac5c39b8bbc38fa61962e22e15349b038c1ca7ee2e18824e06dc"},
]
[package.dependencies]
bidict = ">=0.21.0"
python-engineio = ">=4.8.0"
python-engineio = ">=4.11.0"
[package.extras]
asyncio-client = ["aiohttp (>=3.4)"]
@@ -7696,29 +7657,29 @@ pyasn1 = ">=0.1.3"
[[package]]
name = "ruff"
version = "0.8.3"
version = "0.8.4"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
files = [
{file = "ruff-0.8.3-py3-none-linux_armv6l.whl", hash = "sha256:8d5d273ffffff0acd3db5bf626d4b131aa5a5ada1276126231c4174543ce20d6"},
{file = "ruff-0.8.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e4d66a21de39f15c9757d00c50c8cdd20ac84f55684ca56def7891a025d7e939"},
{file = "ruff-0.8.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c356e770811858bd20832af696ff6c7e884701115094f427b64b25093d6d932d"},
{file = "ruff-0.8.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c0a60a825e3e177116c84009d5ebaa90cf40dfab56e1358d1df4e29a9a14b13"},
{file = "ruff-0.8.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75fb782f4db39501210ac093c79c3de581d306624575eddd7e4e13747e61ba18"},
{file = "ruff-0.8.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f26bc76a133ecb09a38b7868737eded6941b70a6d34ef53a4027e83913b6502"},
{file = "ruff-0.8.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:01b14b2f72a37390c1b13477c1c02d53184f728be2f3ffc3ace5b44e9e87b90d"},
{file = "ruff-0.8.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53babd6e63e31f4e96ec95ea0d962298f9f0d9cc5990a1bbb023a6baf2503a82"},
{file = "ruff-0.8.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ae441ce4cf925b7f363d33cd6570c51435972d697e3e58928973994e56e1452"},
{file = "ruff-0.8.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7c65bc0cadce32255e93c57d57ecc2cca23149edd52714c0c5d6fa11ec328cd"},
{file = "ruff-0.8.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5be450bb18f23f0edc5a4e5585c17a56ba88920d598f04a06bd9fd76d324cb20"},
{file = "ruff-0.8.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8faeae3827eaa77f5721f09b9472a18c749139c891dbc17f45e72d8f2ca1f8fc"},
{file = "ruff-0.8.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:db503486e1cf074b9808403991663e4277f5c664d3fe237ee0d994d1305bb060"},
{file = "ruff-0.8.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6567be9fb62fbd7a099209257fef4ad2c3153b60579818b31a23c886ed4147ea"},
{file = "ruff-0.8.3-py3-none-win32.whl", hash = "sha256:19048f2f878f3ee4583fc6cb23fb636e48c2635e30fb2022b3a1cd293402f964"},
{file = "ruff-0.8.3-py3-none-win_amd64.whl", hash = "sha256:f7df94f57d7418fa7c3ffb650757e0c2b96cf2501a0b192c18e4fb5571dfada9"},
{file = "ruff-0.8.3-py3-none-win_arm64.whl", hash = "sha256:fe2756edf68ea79707c8d68b78ca9a58ed9af22e430430491ee03e718b5e4936"},
{file = "ruff-0.8.3.tar.gz", hash = "sha256:5e7558304353b84279042fc584a4f4cb8a07ae79b2bf3da1a7551d960b5626d3"},
{file = "ruff-0.8.4-py3-none-linux_armv6l.whl", hash = "sha256:58072f0c06080276804c6a4e21a9045a706584a958e644353603d36ca1eb8a60"},
{file = "ruff-0.8.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ffb60904651c00a1e0b8df594591770018a0f04587f7deeb3838344fe3adabac"},
{file = "ruff-0.8.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ddf5d654ac0d44389f6bf05cee4caeefc3132a64b58ea46738111d687352296"},
{file = "ruff-0.8.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e248b1f0fa2749edd3350a2a342b67b43a2627434c059a063418e3d375cfe643"},
{file = "ruff-0.8.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf197b98ed86e417412ee3b6c893f44c8864f816451441483253d5ff22c0e81e"},
{file = "ruff-0.8.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c41319b85faa3aadd4d30cb1cffdd9ac6b89704ff79f7664b853785b48eccdf3"},
{file = "ruff-0.8.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:9f8402b7c4f96463f135e936d9ab77b65711fcd5d72e5d67597b543bbb43cf3f"},
{file = "ruff-0.8.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4e56b3baa9c23d324ead112a4fdf20db9a3f8f29eeabff1355114dd96014604"},
{file = "ruff-0.8.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:736272574e97157f7edbbb43b1d046125fce9e7d8d583d5d65d0c9bf2c15addf"},
{file = "ruff-0.8.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fe710ab6061592521f902fca7ebcb9fabd27bc7c57c764298b1c1f15fff720"},
{file = "ruff-0.8.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:13e9ec6d6b55f6da412d59953d65d66e760d583dd3c1c72bf1f26435b5bfdbae"},
{file = "ruff-0.8.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:97d9aefef725348ad77d6db98b726cfdb075a40b936c7984088804dfd38268a7"},
{file = "ruff-0.8.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ab78e33325a6f5374e04c2ab924a3367d69a0da36f8c9cb6b894a62017506111"},
{file = "ruff-0.8.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8ef06f66f4a05c3ddbc9121a8b0cecccd92c5bf3dd43b5472ffe40b8ca10f0f8"},
{file = "ruff-0.8.4-py3-none-win32.whl", hash = "sha256:552fb6d861320958ca5e15f28b20a3d071aa83b93caee33a87b471f99a6c0835"},
{file = "ruff-0.8.4-py3-none-win_amd64.whl", hash = "sha256:f21a1143776f8656d7f364bd264a9d60f01b7f52243fbe90e7670c0dfe0cf65d"},
{file = "ruff-0.8.4-py3-none-win_arm64.whl", hash = "sha256:9183dd615d8df50defa8b1d9a074053891ba39025cf5ae88e8bcb52edcc4bf08"},
{file = "ruff-0.8.4.tar.gz", hash = "sha256:0d5f89f254836799af1615798caa5f80b7f935d7a670fad66c5007928e57ace8"},
]
[[package]]
@@ -8481,13 +8442,13 @@ dev = ["hypothesis (>=6.70.0)", "pytest (>=7.1.0)"]
[[package]]
name = "synchronicity"
version = "0.9.6"
version = "0.9.7"
description = "Export blocking and async library versions from a single async implementation"
optional = false
python-versions = ">=3.8"
files = [
{file = "synchronicity-0.9.6-py3-none-any.whl", hash = "sha256:4bcb170500c044004198f2ebbd52bd5c7c318e3862b8be3f0ab7321482babb44"},
{file = "synchronicity-0.9.6.tar.gz", hash = "sha256:755c99881700256038939a39a1bbf1052b5da9bcd73524659ba686db0a340254"},
{file = "synchronicity-0.9.7-py3-none-any.whl", hash = "sha256:6e92758864f3396bcb424e0bc4b7778474078891c9c5d97410ffa207c570f85f"},
{file = "synchronicity-0.9.7.tar.gz", hash = "sha256:e96769d3c7abaaba83fc8da266b20b859c2034d9d607e5ccba72cba7782a6a87"},
]
[package.dependencies]
@@ -8510,13 +8471,13 @@ widechars = ["wcwidth"]
[[package]]
name = "tenacity"
version = "8.5.0"
version = "9.0.0"
description = "Retry code until it succeeds"
optional = false
python-versions = ">=3.8"
files = [
{file = "tenacity-8.5.0-py3-none-any.whl", hash = "sha256:b594c2a5945830c267ce6b79a166228323ed52718f30302c1359836112346687"},
{file = "tenacity-8.5.0.tar.gz", hash = "sha256:8bc6c0c8a09b31e6cad13c47afbed1a567518250a9a171418582ed8d9c20ca78"},
{file = "tenacity-9.0.0-py3-none-any.whl", hash = "sha256:93de0c98785b27fcf659856aa9f54bfbd399e29969b0621bc7f762bd441b4539"},
{file = "tenacity-9.0.0.tar.gz", hash = "sha256:807f37ca97d62aa361264d497b0e31e92b8027044942bfa756160d908320d73b"},
]
[package.extras]
@@ -10088,4 +10049,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"]
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "c770c2ef5342d40b2288168e983f40e9fc8d16ced074cf67586c7e923bb6f3f8"
content-hash = "3893da8994f1a0ad86331b468baa432c14023b33c0764243da412edfa4d683f6"

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "openhands-ai"
version = "0.15.3"
version = "0.16.1"
description = "OpenHands: Code Less, Make More"
authors = ["OpenHands"]
license = "MIT"
@@ -38,7 +38,7 @@ boto3 = "*"
minio = "^7.2.8"
gevent = "^24.2.1"
pyarrow = "18.1.0" # transitive dependency, pinned here to avoid conflicts
tenacity = "^8.5.0"
tenacity = ">=8.5,<10.0"
zope-interface = "7.2"
pathspec = "^0.12.1"
google-cloud-aiplatform = "*"
@@ -80,7 +80,7 @@ voyageai = "*"
llama-index-embeddings-voyageai = "*"
[tool.poetry.group.dev.dependencies]
ruff = "0.8.3"
ruff = "0.8.4"
mypy = "1.13.0"
pre-commit = "4.0.1"
build = "*"

View File

@@ -64,3 +64,27 @@ def test_simple_browse(temp_dir, runtime_cls, run_as_openhands):
assert obs.exit_code == 0
_close_test_runtime(runtime)
def test_browse_error_case(temp_dir, runtime_cls, run_as_openhands):
runtime = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
# Test browse with invalid URL to trigger error case
action_browse = BrowseURLAction(url='http://invalid.url.that.does.not.exist')
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert obs.url == 'http://invalid.url.that.does.not.exist'
assert obs.error
assert obs.open_pages_urls == []
assert obs.active_page_index == -1
assert obs.last_browser_action == ''
assert obs.last_browser_action_error != ''
assert obs.focused_element_bid == ''
assert obs.dom_object == {}
assert obs.axtree_object == {}
assert obs.extra_element_properties == {}
_close_test_runtime(runtime)

View File

@@ -332,14 +332,16 @@ def test_update_existing_pull_request(
@pytest.mark.parametrize(
'pr_type,target_branch',
'pr_type,target_branch,pr_title',
[
('branch', None),
('draft', None),
('ready', None),
('branch', 'feature'),
('draft', 'develop'),
('ready', 'staging'),
('branch', None, None),
('draft', None, None),
('ready', None, None),
('branch', 'feature', None),
('draft', 'develop', None),
('ready', 'staging', None),
('ready', None, 'Custom PR Title'),
('draft', 'develop', 'Another Custom Title'),
],
)
@patch('subprocess.run')
@@ -353,6 +355,7 @@ def test_send_pull_request(
mock_output_dir,
pr_type,
target_branch,
pr_title,
):
repo_path = os.path.join(mock_output_dir, 'repo')
@@ -386,6 +389,7 @@ def test_send_pull_request(
patch_dir=repo_path,
pr_type=pr_type,
target_branch=target_branch,
pr_title=pr_title,
)
# Assert API calls
@@ -425,7 +429,8 @@ def test_send_pull_request(
assert result == 'https://github.com/test-owner/test-repo/pull/1'
mock_post.assert_called_once()
post_data = mock_post.call_args[1]['json']
assert post_data['title'] == 'Fix issue #42: Test Issue'
expected_title = pr_title if pr_title else 'Fix issue #42: Test Issue'
assert post_data['title'] == expected_title
assert post_data['body'].startswith('This pull request fixes #42.')
assert post_data['head'] == 'openhands-fix-issue-42'
assert post_data['base'] == (target_branch if target_branch else 'main')
@@ -828,6 +833,7 @@ def test_process_single_issue(
additional_message=resolver_output.success_explanation,
target_branch=None,
reviewer=None,
pr_title=None,
)
@@ -1096,6 +1102,7 @@ def test_main(
mock_args.llm_api_key = 'mock_key'
mock_args.target_branch = None
mock_args.reviewer = None
mock_args.pr_title = None
mock_parser.return_value.parse_args.return_value = mock_args
# Setup environment variables
@@ -1131,6 +1138,7 @@ def test_main(
False,
mock_args.target_branch,
mock_args.reviewer,
mock_args.pr_title,
)
# Other assertions

View File

@@ -161,7 +161,7 @@ async def test_run_controller_with_fatal_error(mock_agent, mock_event_stream):
print(f'event_stream: {list(event_stream.get_events())}')
assert state.iteration == 4
assert state.agent_state == AgentState.ERROR
assert state.last_error == 'Agent got stuck in a loop'
assert state.last_error == 'AgentStuckInLoopError: Agent got stuck in a loop'
assert len(list(event_stream.get_events())) == 11
@@ -227,7 +227,7 @@ async def test_run_controller_stop_with_stuck():
assert last_event['observation'] == 'agent_state_changed'
assert state.agent_state == AgentState.ERROR
assert state.last_error == 'Agent got stuck in a loop'
assert state.last_error == 'AgentStuckInLoopError: Agent got stuck in a loop'
@pytest.mark.asyncio

View File

@@ -0,0 +1,81 @@
import json
from unittest.mock import MagicMock, patch
import pytest
from openhands.core.config.app_config import AppConfig
from openhands.server.settings import Settings
from openhands.storage.file_settings_store import FileSettingsStore
from openhands.storage.files import FileStore
@pytest.fixture
def mock_file_store():
return MagicMock(spec=FileStore)
@pytest.fixture
def session_init_store(mock_file_store):
return FileSettingsStore(mock_file_store)
@pytest.mark.asyncio
async def test_load_nonexistent_data(session_init_store):
session_init_store.file_store.read.side_effect = FileNotFoundError()
assert await session_init_store.load() is None
@pytest.mark.asyncio
async def test_store_and_load_data(session_init_store):
# Test data
init_data = Settings(
language='python',
agent='test-agent',
max_iterations=100,
security_analyzer='default',
confirmation_mode=True,
llm_model='test-model',
llm_api_key='test-key',
llm_base_url='https://test.com',
)
# Store data
await session_init_store.store(init_data)
# Verify store called with correct JSON
expected_json = json.dumps(init_data.__dict__)
session_init_store.file_store.write.assert_called_once_with(
'settings.json', expected_json
)
# Setup mock for load
session_init_store.file_store.read.return_value = expected_json
# Load and verify data
loaded_data = await session_init_store.load()
assert loaded_data is not None
assert loaded_data.language == init_data.language
assert loaded_data.agent == init_data.agent
assert loaded_data.max_iterations == init_data.max_iterations
assert loaded_data.security_analyzer == init_data.security_analyzer
assert loaded_data.confirmation_mode == init_data.confirmation_mode
assert loaded_data.llm_model == init_data.llm_model
assert loaded_data.llm_api_key == init_data.llm_api_key
assert loaded_data.llm_base_url == init_data.llm_base_url
@pytest.mark.asyncio
async def test_get_instance():
config = AppConfig(file_store='local', file_store_path='/test/path')
with patch(
'openhands.storage.file_settings_store.get_file_store'
) as mock_get_store:
mock_store = MagicMock(spec=FileStore)
mock_get_store.return_value = mock_store
store = await FileSettingsStore.get_instance(config, None)
assert isinstance(store, FileSettingsStore)
assert store.file_store == mock_store
mock_get_store.assert_called_once_with('local', '/test/path')