mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
20 Commits
openhands-
...
openhands-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b735fb8b9f | ||
|
|
0dd919bacf | ||
|
|
5ad361623d | ||
|
|
c333938384 | ||
|
|
ebf3bf606a | ||
|
|
c2293ad1dd | ||
|
|
6f7d054385 | ||
|
|
e9cafb0372 | ||
|
|
13097f9d1d | ||
|
|
2a66439ca6 | ||
|
|
3876f4a59c | ||
|
|
3db118f3d9 | ||
|
|
fe1bb1c233 | ||
|
|
154ef7391a | ||
|
|
5498ca1f8b | ||
|
|
2cc6a51fe8 | ||
|
|
409d132747 | ||
|
|
2c47a1b33f | ||
|
|
8983eb4cc1 | ||
|
|
bd3e38fe67 |
@@ -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
|
||||
|
||||
|
||||
@@ -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)!
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 \
|
||||
|
||||
8
docs/package-lock.json
generated
8
docs/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 =======
|
||||
|
||||
@@ -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}%)"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.15.3",
|
||||
"version": "0.16.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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]}
|
||||
>
|
||||
|
||||
@@ -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:
|
||||
|
||||
20
frontend/src/components/features/markdown/anchor.tsx
Normal file
20
frontend/src/components/features/markdown/anchor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
33
frontend/src/components/shared/download-modal.tsx
Normal file
33
frontend/src/components/shared/download-modal.tsx
Normal 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} />;
|
||||
}
|
||||
87
frontend/src/components/shared/download-progress.tsx
Normal file
87
frontend/src/components/shared/download-progress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
78
frontend/src/hooks/use-download-progress.ts
Normal file
78
frontend/src/hooks/use-download-progress.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
31
frontend/src/types/file-system.d.ts
vendored
Normal 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>;
|
||||
}
|
||||
305
frontend/src/utils/download-files.ts
Normal file
305
frontend/src/utils/download-files.ts
Normal 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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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='',
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 == '':
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
53
openhands/server/routes/settings.py
Normal file
53
openhands/server/routes/settings.py
Normal 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'},
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
17
openhands/server/settings.py
Normal file
17
openhands/server/settings.py
Normal 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
|
||||
34
openhands/storage/file_settings_store.py
Normal file
34
openhands/storage/file_settings_store.py
Normal 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)
|
||||
25
openhands/storage/settings_store.py
Normal file
25
openhands/storage/settings_store.py
Normal 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
197
poetry.lock
generated
@@ -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"
|
||||
|
||||
@@ -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 = "*"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
81
tests/unit/test_file_settings_store.py
Normal file
81
tests/unit/test_file_settings_store.py
Normal 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')
|
||||
Reference in New Issue
Block a user