Compare commits

...

12 Commits

Author SHA1 Message Date
Engel Nyst
fac6af6889 fix stuck 2025-03-05 00:00:14 +01:00
Engel Nyst
1490e43aea tweak log 2025-03-05 00:00:01 +01:00
Aditya Bharat Soni
c76a659cde Condenser for Browser Output Observations (#6578)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Rick van Hattem <wolph@wol.ph>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
Co-authored-by: Boxuan Li <liboxuan@connect.hku.hk>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
Co-authored-by: tofarr <tofarr@gmail.com>
Co-authored-by: Xingyao Wang <xingyao6@illinois.edu>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Akim Tsvigun <36672861+Aktsvigun@users.noreply.github.com>
Co-authored-by: Akim Tsvigun <aktsvigun@nebius.com>
Co-authored-by: mamoodi <mamoodiha@gmail.com>
Co-authored-by: OpenHands <opendevin@all-hands.dev>
Co-authored-by: Calvin Smith <email@cjsmith.io>
Co-authored-by: Calvin Smith <calvin@all-hands.dev>
Co-authored-by: Graham Neubig <neubig@gmail.com>
Co-authored-by: Peter Dave Hello <hsu@peterdavehello.org>
Co-authored-by: Ray Myers <ray.myers@gmail.com>
2025-03-04 16:28:33 -05:00
dependabot[bot]
0f68a18cbb chore(deps): bump docker/setup-qemu-action from 3.4.0 to 3.6.0 (#7075)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-04 20:57:14 +00:00
Robert Brennan
c9ebabd82d Add contact link to runtime settings label (#6880)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-03-05 00:49:53 +04:00
mamoodi
ad932e45e8 Checkout HEAD instead of Merge Commit for builds (#7085) 2025-03-04 15:32:59 -05:00
sp.wack
3278caf3c2 Always enable GET /settings (#7101) 2025-03-04 14:54:26 -05:00
He Du
896d7b8b96 Openhands fix issue 7091 (#7092)
Co-authored-by: 杜贺 <duhe@duhedeMacBook-Pro-2.local>
2025-03-04 18:39:28 +01:00
Ryan H. Tran
cb61282c39 Improve error detection for read and edit observations (#7090) 2025-03-04 15:05:15 +01:00
Graham Neubig
7a235ce6ff Fix/mypy routes (#6900)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-03-04 03:43:09 +00:00
Rohit Malhotra
5ffb1ef704 Fix typing (#7083)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-03-03 20:41:11 +00:00
chuckbutkus
4e4f4d64f8 Fix runtime to call new token refresh (#7084) 2025-03-03 20:36:27 +00:00
37 changed files with 240 additions and 101 deletions

View File

@@ -41,8 +41,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.4.0
uses: docker/setup-qemu-action@v3.6.0
with:
image: tonistiigi/binfmt:latest
- name: Login to GHCR
@@ -90,8 +92,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.4.0
uses: docker/setup-qemu-action@v3.6.0
with:
image: tonistiigi/binfmt:latest
- name: Login to GHCR
@@ -154,6 +158,8 @@ jobs:
base_image: ['nikolaik']
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Cache Poetry dependencies
uses: actions/cache@v4
with:

View File

@@ -13,6 +13,7 @@
# limitations under the License.
# This file is modified from https://github.com/ShishirPatil/gorilla/blob/main/eval/eval-scripts/ast_eval_hf.py
import tree_sitter_python as tspython
from tree_sitter import Language, Parser
@@ -39,10 +40,9 @@ def get_all_sub_trees(root_node):
# Parse the program into AST trees
def ast_parse(candidate, lang='python'):
LANGUAGE = Language('evaluation/gorilla/my-languages.so', lang)
parser = Parser()
parser.set_language(LANGUAGE)
def ast_parse(candidate):
LANGUAGE = Language(tspython.language())
parser = Parser(LANGUAGE)
candidate_tree = parser.parse(bytes(candidate, 'utf8')).root_node
return candidate_tree

View File

@@ -13,6 +13,7 @@
# limitations under the License.
# This file is modified from https://github.com/ShishirPatil/gorilla/blob/main/eval/eval-scripts/ast_eval_tf.py
import tree_sitter_python as tspython
from tree_sitter import Language, Parser
@@ -39,10 +40,9 @@ def get_all_sub_trees(root_node):
# Parse the program into AST trees
def ast_parse(candidate, lang='python'):
LANGUAGE = Language('evaluation/gorilla/my-languages.so', lang)
parser = Parser()
parser.set_language(LANGUAGE)
def ast_parse(candidate):
LANGUAGE = Language(tspython.language())
parser = Parser(LANGUAGE)
candidate_tree = parser.parse(bytes(candidate, 'utf8')).root_node
return candidate_tree

View File

@@ -13,6 +13,7 @@
# limitations under the License.
# This file is modified from https://github.com/ShishirPatil/gorilla/blob/main/eval/eval-scripts/ast_eval_th.py
import tree_sitter_python as tspython
from tree_sitter import Language, Parser
@@ -39,10 +40,9 @@ def get_all_sub_trees(root_node):
# Parse the program into AST trees
def ast_parse(candidate, lang='python'):
LANGUAGE = Language('evaluation/gorilla/my-languages.so', lang)
parser = Parser()
parser.set_language(LANGUAGE)
def ast_parse(candidate):
LANGUAGE = Language(tspython.language())
parser = Parser(LANGUAGE)
candidate_tree = parser.parse(bytes(candidate, 'utf8')).root_node
return candidate_tree

View File

@@ -71,19 +71,19 @@ def fetch_data(url, filename):
def get_data_for_hub(hub: str):
if hub == 'hf':
question_data = 'https://raw.githubusercontent.com/ShishirPatil/gorilla/main/eval/eval-data/questions/huggingface/questions_huggingface_0_shot.jsonl'
api_dataset = 'https://raw.githubusercontent.com/ShishirPatil/gorilla/main/data/api/huggingface_api.jsonl'
apibench = 'https://raw.githubusercontent.com/ShishirPatil/gorilla/main/data/apibench/huggingface_eval.json'
question_data = 'https://raw.githubusercontent.com/ShishirPatil/gorilla/refs/tags/v1.2/eval/eval-data/questions/huggingface/questions_huggingface_0_shot.jsonl'
api_dataset = 'https://raw.githubusercontent.com/ShishirPatil/gorilla/refs/tags/v1.2/data/api/huggingface_api.jsonl'
apibench = 'https://raw.githubusercontent.com/ShishirPatil/gorilla/refs/tags/v1.2/data/apibench/huggingface_eval.json'
ast_eval = ast_eval_hf
elif hub == 'torch':
question_data = 'https://raw.githubusercontent.com/ShishirPatil/gorilla/main/eval/eval-data/questions/torchhub/questions_torchhub_0_shot.jsonl'
api_dataset = 'https://raw.githubusercontent.com/ShishirPatil/gorilla/main/data/api/torchhub_api.jsonl'
apibench = 'https://raw.githubusercontent.com/ShishirPatil/gorilla/main/data/apibench/torchhub_eval.json'
question_data = 'https://raw.githubusercontent.com/ShishirPatil/gorilla/refs/tags/v1.2/eval/eval-data/questions/torchhub/questions_torchhub_0_shot.jsonl'
api_dataset = 'https://raw.githubusercontent.com/ShishirPatil/gorilla/refs/tags/v1.2/data/api/torchhub_api.jsonl'
apibench = 'https://raw.githubusercontent.com/ShishirPatil/gorilla/refs/tags/v1.2/data/apibench/torchhub_eval.json'
ast_eval = ast_eval_th
elif hub == 'tf':
question_data = 'https://raw.githubusercontent.com/ShishirPatil/gorilla/main/eval/eval-data/questions/tensorflowhub/questions_tensorflowhub_0_shot.jsonl'
api_dataset = 'https://raw.githubusercontent.com/ShishirPatil/gorilla/main/data/api/tensorflowhub_api.jsonl'
apibench = 'https://raw.githubusercontent.com/ShishirPatil/gorilla/main/data/apibench/tensorflow_eval.json'
question_data = 'https://raw.githubusercontent.com/ShishirPatil/gorilla/refs/tags/v1.2/eval/eval-data/questions/tensorflowhub/questions_tensorflowhub_0_shot.jsonl'
api_dataset = 'https://raw.githubusercontent.com/ShishirPatil/gorilla/refs/tags/v1.2/data/api/tensorflowhub_api.jsonl'
apibench = 'https://raw.githubusercontent.com/ShishirPatil/gorilla/refs/tags/v1.2/data/apibench/tensorflow_eval.json'
ast_eval = ast_eval_tf
question_data = fetch_data(question_data, 'question_data.jsonl')

View File

@@ -7,7 +7,7 @@ import os
import re
from dataclasses import dataclass
from enum import Enum, auto
from typing import Dict, List, Optional, Union
from typing import Dict, List, Union
from openhands.core.logger import openhands_logger as logger
from openhands.events.action import BrowseInteractiveAction
@@ -133,7 +133,7 @@ def parse_content_to_elements(content: str) -> Dict[str, str]:
return elements
def find_matching_anchor(content: str, selector: str) -> Optional[str]:
def find_matching_anchor(content: str, selector: str) -> str | None:
"""Find the anchor ID that matches the given selector description"""
elements = parse_content_to_elements(content)

View File

@@ -1,9 +1,10 @@
import { Autocomplete, AutocompleteItem } from "@heroui/react";
import { ReactNode } from "react";
import { OptionalTag } from "./optional-tag";
interface SettingsDropdownInputProps {
testId: string;
label: string;
label: ReactNode;
name: string;
items: { key: React.Key; label: string }[];
showOptionalTag?: boolean;
@@ -29,7 +30,7 @@ export function SettingsDropdownInput({
{showOptionalTag && <OptionalTag />}
</div>
<Autocomplete
aria-label={label}
aria-label={typeof label === "string" ? label : name}
data-testid={testId}
name={name}
defaultItems={items}

View File

@@ -43,15 +43,18 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
const handleFormSubmission = async (formData: FormData) => {
const newSettings = extractSettings(formData);
await saveUserSettings(newSettings);
onClose();
resetOngoingSession();
await saveUserSettings(newSettings, {
onSuccess: () => {
onClose();
resetOngoingSession();
posthog.capture("settings_saved", {
LLM_MODEL: newSettings.LLM_MODEL,
LLM_API_KEY: newSettings.LLM_API_KEY ? "SET" : "UNSET",
REMOTE_RUNTIME_RESOURCE_FACTOR:
newSettings.REMOTE_RUNTIME_RESOURCE_FACTOR,
posthog.capture("settings_saved", {
LLM_MODEL: newSettings.LLM_MODEL,
LLM_API_KEY: newSettings.LLM_API_KEY ? "SET" : "UNSET",
REMOTE_RUNTIME_RESOURCE_FACTOR:
newSettings.REMOTE_RUNTIME_RESOURCE_FACTOR,
});
},
});
};

View File

@@ -3,7 +3,6 @@ import React from "react";
import posthog from "posthog-js";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
import { useConfig } from "#/hooks/query/use-config";
import { DEFAULT_SETTINGS } from "#/services/settings";
const getSettingsQueryFn = async () => {
@@ -27,12 +26,10 @@ const getSettingsQueryFn = async () => {
export const useSettings = () => {
const { setGitHubTokenIsSet, githubTokenIsSet } = useAuth();
const { data: config } = useConfig();
const query = useQuery({
queryKey: ["settings", githubTokenIsSet],
queryFn: getSettingsQueryFn,
enabled: config?.APP_MODE !== "saas" || githubTokenIsSet,
// Only retry if the error is not a 404 because we
// would want to show the modal immediately if the
// settings are not found

View File

@@ -278,7 +278,15 @@ function AccountSettings() {
<SettingsDropdownInput
testId="runtime-settings-input"
name="runtime-settings-input"
label="Runtime Settings"
label={
<>
Runtime Settings (
<a href="mailto:contact@all-hands.dev">
get in touch for access
</a>
)
</>
}
items={REMOTE_RUNTIME_OPTIONS}
defaultSelectedKey={settings.REMOTE_RUNTIME_RESOURCE_FACTOR?.toString()}
isDisabled

View File

@@ -95,6 +95,7 @@ export function handleObservationMessage(message: ObservationMessage) {
observation,
extras: {
path: String(message.extras.path || ""),
impl_source: String(message.extras.impl_source || ""),
},
}),
);
@@ -107,6 +108,7 @@ export function handleObservationMessage(message: ObservationMessage) {
extras: {
path: String(message.extras.path || ""),
diff: String(message.extras.diff || ""),
impl_source: String(message.extras.impl_source || ""),
},
}),
);

View File

@@ -159,9 +159,16 @@ export const chatSlice = createSlice({
.includes("error:");
} else if (observationID === "read" || observationID === "edit") {
// For read/edit operations, we consider it successful if there's content and no error
causeMessage.success =
observation.payload.content.length > 0 &&
!observation.payload.content.toLowerCase().includes("error:");
if (observation.payload.extras.impl_source === "oh_aci") {
causeMessage.success =
observation.payload.content.length > 0 &&
!observation.payload.content.startsWith("ERROR:\n");
} else {
causeMessage.success =
observation.payload.content.length > 0 &&
!observation.payload.content.toLowerCase().includes("error:");
}
}
if (observationID === "run" || observationID === "run_ipython") {

View File

@@ -63,6 +63,7 @@ export interface ReadObservation extends OpenHandsObservationEvent<"read"> {
source: "agent";
extras: {
path: string;
impl_source: string;
};
}
@@ -71,6 +72,7 @@ export interface EditObservation extends OpenHandsObservationEvent<"edit"> {
extras: {
path: string;
diff: string;
impl_source: string;
};
}

View File

@@ -53,4 +53,3 @@ To verify Docker is working correctly, run the hello-world container:
```bash
sudo docker run hello-world
```

View File

@@ -1,4 +1,3 @@
import json
import os
from collections import deque
@@ -74,7 +73,7 @@ class CodeActAgent(Agent):
codeact_enable_llm_editor=self.config.codeact_enable_llm_editor,
)
logger.debug(
f'TOOLS loaded for CodeActAgent: {json.dumps(self.tools, indent=2, ensure_ascii=False).replace("\\n", "\n")}'
f'TOOLS loaded for CodeActAgent: {', '.join([tool.get('function').get('name') for tool in self.tools])}'
)
self.prompt_manager = PromptManager(
microagent_dir=os.path.join(

View File

@@ -130,7 +130,7 @@ class StuckDetector:
# it takes 3 actions and 3 observations to detect a loop
# check if the last three actions are the same and result in errors
if len(last_actions) < 4 or len(last_observations) < 4:
if len(last_actions) < 3 or len(last_observations) < 3:
return False
# are the last three actions the "same"?

View File

@@ -27,6 +27,17 @@ class ObservationMaskingCondenserConfig(BaseModel):
model_config = {'extra': 'forbid'}
class BrowserOutputCondenserConfig(BaseModel):
"""Configuration for the BrowserOutputCondenser."""
type: Literal['browser_output_masking'] = Field('browser_output_masking')
attention_window: int = Field(
default=1,
description='The number of most recent browser output observations that will not be masked.',
ge=1,
)
class RecentEventsCondenserConfig(BaseModel):
"""Configuration for RecentEventsCondenser."""
@@ -115,6 +126,7 @@ class LLMAttentionCondenserConfig(BaseModel):
CondenserConfig = (
NoOpCondenserConfig
| ObservationMaskingCondenserConfig
| BrowserOutputCondenserConfig
| RecentEventsCondenserConfig
| LLMSummarizingCondenserConfig
| AmortizedForgettingCondenserConfig

View File

@@ -7,6 +7,7 @@ OpenHands uses its own `Message` class (`openhands/core/message.py`) which provi
## Class Structure
Our `Message` class (`openhands/core/message.py`):
```python
class Message(BaseModel):
role: Literal['user', 'system', 'assistant', 'tool']
@@ -22,13 +23,14 @@ class Message(BaseModel):
```
litellm's `Message` class (`litellm/types/utils.py`):
```python
class Message(OpenAIObject):
content: Optional[str]
content: str | None
role: Literal["assistant", "user", "system", "tool", "function"]
tool_calls: Optional[List[ChatCompletionMessageToolCall]]
function_call: Optional[FunctionCall]
audio: Optional[ChatCompletionAudioResponse] = None
tool_calls: List[ChatCompletionMessageToolCall] | None
function_call: FunctionCall | None
audio: ChatCompletionAudioResponse | None = None
```
## How It Works
@@ -36,6 +38,7 @@ class Message(OpenAIObject):
1. **Message Creation**: Our `Message` class is a Pydantic model that supports rich content (text and images) through its `content` field.
2. **Serialization**: The class uses Pydantic's `@model_serializer` to convert messages into dictionaries that litellm can understand. We have two serialization methods:
```python
def _string_serializer(self) -> dict:
# convert content to a single string
@@ -55,6 +58,7 @@ class Message(OpenAIObject):
```
The appropriate serializer is chosen based on the message's capabilities:
```python
@model_serializer
def serialize_model(self) -> dict:
@@ -64,11 +68,13 @@ class Message(OpenAIObject):
```
3. **Tool Call Handling**: Tool calls require special attention in serialization because:
- They need to work with litellm's API calls (which accept both dicts and objects)
- They need to be properly serialized for token counting
- They need to maintain compatibility with different LLM providers' formats
4. **litellm Integration**: When we pass our messages to `litellm.completion()`, litellm doesn't care about the message class type - it works with the dictionary representation. This works because:
- litellm's transformation code (e.g., `litellm/llms/anthropic/chat/transformation.py`) processes messages based on their structure, not their type
- our serialization produces dictionaries that match litellm's expected format
- litellm handles rich content by looking at the message structure, supporting both simple string content and lists of content items
@@ -78,6 +84,7 @@ class Message(OpenAIObject):
### Token Counting
To use litellm's token counter, we need to make sure that all message components (including tool calls) are properly serialized to dictionaries. This is because:
- litellm's token counter expects dictionary structures
- Tool calls need to be included in the token count
- Different providers may count tokens differently for structured content

View File

@@ -51,6 +51,9 @@ class GitHubService:
async def get_latest_token(self) -> SecretStr:
return self.token
async def get_latest_provider_token(self) -> SecretStr:
return self.token
async def _fetch_data(
self, url: str, params: dict | None = None
) -> tuple[Any, dict]:

View File

@@ -1,6 +1,9 @@
from openhands.memory.condenser.impl.amortized_forgetting_condenser import (
AmortizedForgettingCondenser,
)
from openhands.memory.condenser.impl.browser_output_condenser import (
BrowserOutputCondenser,
)
from openhands.memory.condenser.impl.llm_attention_condenser import (
ImportantEventSelection,
LLMAttentionCondenser,
@@ -23,5 +26,6 @@ __all__ = [
'LLMSummarizingCondenser',
'NoOpCondenser',
'ObservationMaskingCondenser',
'BrowserOutputCondenser',
'RecentEventsCondenser',
]

View File

@@ -0,0 +1,48 @@
from __future__ import annotations
from openhands.core.config.condenser_config import BrowserOutputCondenserConfig
from openhands.events.event import Event
from openhands.events.observation import BrowserOutputObservation
from openhands.events.observation.agent import AgentCondensationObservation
from openhands.memory.condenser.condenser import Condenser
class BrowserOutputCondenser(Condenser):
"""A condenser that masks the observations from browser outputs outside of a recent attention window.
The intent here is to mask just the browser outputs and leave everything else untouched. This is important because currently we provide screenshots and accessibility trees as input to the model for browser observations. These are really large and consume a lot of tokens without any benefits in performance. So we want to mask all such observations from all previous timesteps, and leave only the most recent one in context.
"""
def __init__(self, attention_window: int = 1):
self.attention_window = attention_window
super().__init__()
def condense(self, events: list[Event]) -> list[Event]:
"""Replace the content of browser observations outside of the attention window with a placeholder."""
results: list[Event] = []
cnt: int = 0
for event in reversed(events):
if (
isinstance(event, BrowserOutputObservation)
and cnt >= self.attention_window
):
results.append(
AgentCondensationObservation(
f'Current URL: {event.url}\nContent Omitted'
)
)
else:
results.append(event)
if isinstance(event, BrowserOutputObservation):
cnt += 1
return list(reversed(results))
@classmethod
def from_config(
cls, config: BrowserOutputCondenserConfig
) -> BrowserOutputCondenser:
return BrowserOutputCondenser(**config.model_dump(exclude=['type']))
BrowserOutputCondenser.register_config(BrowserOutputCondenserConfig)

View File

@@ -4,7 +4,7 @@ import multiprocessing as mp
import os
import re
from enum import Enum
from typing import Callable, Optional
from typing import Callable
import pandas as pd
import requests
@@ -22,7 +22,7 @@ class Platform(Enum):
GITLAB = 2
def identify_token(token: str, repo: Optional[str] = None) -> Platform:
def identify_token(token: str, repo: str | None = None) -> Platform:
"""
Identifies whether a token belongs to GitHub or GitLab.

View File

@@ -222,7 +222,7 @@ class Runtime(FileEditRuntimeMixin):
if isinstance(event, CmdRunAction):
if self.github_user_id and '$GITHUB_TOKEN' in event.command:
gh_client = GithubServiceImpl(user_id=self.github_user_id)
token = await gh_client.get_latest_token()
token = await gh_client.get_latest_provider_token()
if token:
export_cmd = CmdRunAction(
f"export GITHUB_TOKEN='{token.get_secret_value()}'"

View File

@@ -1,4 +1,4 @@
from typing import Callable, Optional
from typing import Callable
from openhands.core.config import AppConfig
from openhands.events.action import (
@@ -27,7 +27,7 @@ class E2BRuntime(Runtime):
sid: str = 'default',
plugins: list[PluginRequirement] | None = None,
sandbox: E2BSandbox | None = None,
status_callback: Optional[Callable] = None,
status_callback: Callable | None = None,
):
super().__init__(
config,

View File

@@ -7,7 +7,7 @@ import shutil
import subprocess
import tempfile
import threading
from typing import Callable, Optional
from typing import Callable
import requests
import tenacity
@@ -155,7 +155,7 @@ class LocalRuntime(ActionExecutionClient):
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._host_port}'
self.status_callback = status_callback
self.server_process: Optional[subprocess.Popen[str]] = None
self.server_process: subprocess.Popen[str] | None = None
self.action_semaphore = threading.Semaphore(1) # Ensure one action at a time
# Update env vars

View File

@@ -1,5 +1,5 @@
import os
from typing import Callable, Optional
from typing import Callable
from urllib.parse import urlparse
import requests
@@ -42,7 +42,7 @@ class RemoteRuntime(ActionExecutionClient):
sid: str = 'default',
plugins: list[PluginRequirement] | None = None,
env_vars: dict[str, str] | None = None,
status_callback: Optional[Callable] = None,
status_callback: Callable | None = None,
attach_to_existing: bool = False,
headless_mode: bool = True,
github_user_id: str | None = None,

View File

@@ -1,5 +1,5 @@
import json
from typing import Any, Literal, Optional
from typing import Any, Literal
import requests
from pydantic import BaseModel
@@ -15,7 +15,7 @@ class FeedbackDataModel(BaseModel):
'positive', 'negative'
] # TODO: remove this, its here for backward compatibility
permissions: Literal['public', 'private']
trajectory: Optional[list[dict[str, Any]]]
trajectory: list[dict[str, Any]] | None
FEEDBACK_URL = 'https://share-od-trajectory-3u9bw9tx.uc.gateway.dev/share_od_trajectory'

View File

@@ -11,7 +11,7 @@ app = APIRouter(prefix='/api/conversations/{conversation_id}')
@app.post('/submit-feedback')
async def submit_feedback(request: Request, conversation_id: str):
async def submit_feedback(request: Request, conversation_id: str) -> JSONResponse:
"""Submit user feedback.
This function stores the provided feedback data.

View File

@@ -15,7 +15,7 @@ from openhands.server.auth import get_github_token, get_idp_token, get_user_id
app = APIRouter(prefix='/api/github')
@app.get('/repositories')
@app.get('/repositories', response_model=list[GitHubRepository])
async def get_github_repositories(
page: int = 1,
per_page: int = 10,
@@ -47,7 +47,7 @@ async def get_github_repositories(
)
@app.get('/user')
@app.get('/user', response_model=GitHubUser)
async def get_github_user(
github_user_id: str | None = Depends(get_user_id),
github_user_token: SecretStr | None = Depends(get_github_token),
@@ -73,7 +73,7 @@ async def get_github_user(
)
@app.get('/installations')
@app.get('/installations', response_model=list[int])
async def get_github_installation_ids(
github_user_id: str | None = Depends(get_user_id),
github_user_token: SecretStr | None = Depends(get_github_token),
@@ -99,7 +99,7 @@ async def get_github_installation_ids(
)
@app.get('/search/repositories')
@app.get('/search/repositories', response_model=list[GitHubRepository])
async def search_github_repositories(
query: str,
per_page: int = 5,
@@ -131,7 +131,7 @@ async def search_github_repositories(
)
@app.get('/suggested-tasks')
@app.get('/suggested-tasks', response_model=list[SuggestedTask])
async def get_suggested_tasks(
github_user_id: str | None = Depends(get_user_id),
github_user_token: SecretStr | None = Depends(get_github_token),

View File

@@ -1,6 +1,8 @@
import warnings
from typing import Any
import requests
from fastapi import APIRouter
from openhands.security.options import SecurityAnalyzers
@@ -8,10 +10,6 @@ with warnings.catch_warnings():
warnings.simplefilter('ignore')
import litellm
from fastapi import (
APIRouter,
)
from openhands.controller.agent import Agent
from openhands.core.config import LLMConfig
from openhands.core.logger import openhands_logger as logger
@@ -21,7 +19,7 @@ from openhands.server.shared import config, server_config
app = APIRouter(prefix='/api/options')
@app.get('/models')
@app.get('/models', response_model=list[str])
async def get_litellm_models() -> list[str]:
"""Get all models supported by LiteLLM.
@@ -34,7 +32,7 @@ async def get_litellm_models() -> list[str]:
```
Returns:
list: A sorted list of unique model names.
list[str]: A sorted list of unique model names.
"""
litellm_model_list = litellm.model_list + list(litellm.model_cost.keys())
litellm_model_list_without_bedrock = bedrock.remove_error_modelId(
@@ -74,8 +72,8 @@ async def get_litellm_models() -> list[str]:
return list(sorted(set(model_list)))
@app.get('/agents')
async def get_agents():
@app.get('/agents', response_model=list[str])
async def get_agents() -> list[str]:
"""Get all agents supported by LiteLLM.
To get the agents:
@@ -84,14 +82,13 @@ async def get_agents():
```
Returns:
list: A sorted list of agent names.
list[str]: A sorted list of agent names.
"""
agents = sorted(Agent.list_agents())
return agents
return sorted(Agent.list_agents())
@app.get('/security-analyzers')
async def get_security_analyzers():
@app.get('/security-analyzers', response_model=list[str])
async def get_security_analyzers() -> list[str]:
"""Get all supported security analyzers.
To get the security analyzers:
@@ -100,15 +97,16 @@ async def get_security_analyzers():
```
Returns:
list: A sorted list of security analyzer names.
list[str]: A sorted list of security analyzer names.
"""
return sorted(SecurityAnalyzers.keys())
@app.get('/config')
async def get_config():
"""
Get current config
"""
@app.get('/config', response_model=dict[str, Any])
async def get_config() -> dict[str, Any]:
"""Get current config.
Returns:
dict[str, Any]: The current server configuration.
"""
return server_config.get_config()

View File

@@ -2,6 +2,7 @@ from fastapi import (
APIRouter,
HTTPException,
Request,
Response,
status,
)
@@ -9,7 +10,7 @@ app = APIRouter(prefix='/api/conversations/{conversation_id}')
@app.route('/security/{path:path}', methods=['GET', 'POST', 'PUT', 'DELETE'])
async def security_api(request: Request):
async def security_api(request: Request) -> Response:
"""Catch-all route for security analyzer API requests.
Each request is handled directly to the security analyzer.
@@ -18,7 +19,7 @@ async def security_api(request: Request):
request (Request): The incoming FastAPI request object.
Returns:
Any: The response from the security analyzer.
Response: The response from the security analyzer.
Raises:
HTTPException: If the security analyzer is not initialized.

View File

@@ -11,8 +11,8 @@ from openhands.server.shared import SettingsStoreImpl, config
app = APIRouter(prefix='/api')
@app.get('/settings')
async def load_settings(request: Request) -> GETSettingsModel | None:
@app.get('/settings', response_model=GETSettingsModel)
async def load_settings(request: Request) -> GETSettingsModel | JSONResponse:
try:
user_id = get_user_id(request)
settings_store = await SettingsStoreImpl.get_instance(config, user_id)
@@ -40,13 +40,12 @@ async def load_settings(request: Request) -> GETSettingsModel | None:
)
@app.post('/settings')
@app.post('/settings', response_model=dict[str, str])
async def store_settings(
request: Request,
settings: POSTSettingsModel,
) -> JSONResponse:
# Check if token is valid
if settings.github_token:
try:
# We check if the token is valid by getting the user

View File

@@ -9,7 +9,7 @@ app = APIRouter(prefix='/api/conversations/{conversation_id}')
@app.get('/trajectory')
async def get_trajectory(request: Request):
async def get_trajectory(request: Request) -> JSONResponse:
"""Get trajectory.
This function retrieves the current trajectory and returns it.

View File

@@ -1,6 +1,6 @@
import asyncio
import time
from typing import Callable, Optional
from typing import Callable
from pydantic import SecretStr
@@ -52,7 +52,7 @@ class AgentSession:
sid: str,
file_store: FileStore,
monitoring_listener: MonitoringListener,
status_callback: Optional[Callable] = None,
status_callback: Callable | None = None,
github_user_id: str | None = None,
):
"""Initializes a new instance of the Session class

View File

@@ -1,5 +1,5 @@
import os
from typing import List, Optional
from typing import List
from google.api_core.exceptions import NotFound
from google.cloud import storage
@@ -8,7 +8,7 @@ from openhands.storage.files import FileStore
class GoogleCloudFileStore(FileStore):
def __init__(self, bucket_name: Optional[str] = None) -> None:
def __init__(self, bucket_name: str | None = None) -> None:
"""
Create a new FileStore. If GOOGLE_APPLICATION_CREDENTIALS is defined in the
environment it will be used for authentication. Otherwise access will be

View File

@@ -7,6 +7,7 @@ import pytest
from openhands.controller.state.state import State
from openhands.core.config.condenser_config import (
AmortizedForgettingCondenserConfig,
BrowserOutputCondenserConfig,
LLMAttentionCondenserConfig,
LLMSummarizingCondenserConfig,
NoOpCondenserConfig,
@@ -15,6 +16,7 @@ from openhands.core.config.condenser_config import (
)
from openhands.core.config.llm_config import LLMConfig
from openhands.events.event import Event, EventSource
from openhands.events.observation import BrowserOutputObservation
from openhands.events.observation.agent import AgentCondensationObservation
from openhands.events.observation.observation import Observation
from openhands.llm import LLM
@@ -22,6 +24,7 @@ from openhands.memory.condenser import Condenser
from openhands.memory.condenser.condenser import RollingCondenser
from openhands.memory.condenser.impl import (
AmortizedForgettingCondenser,
BrowserOutputCondenser,
ImportantEventSelection,
LLMAttentionCondenser,
LLMSummarizingCondenser,
@@ -154,6 +157,46 @@ def test_observation_masking_condenser_respects_attention_window(mock_state):
assert event == condensed_event
def test_browser_output_condenser_from_config():
"""Test that BrowserOutputCondenser objects can be made from config."""
attention_window = 5
config = BrowserOutputCondenserConfig(attention_window=attention_window)
condenser = Condenser.from_config(config)
assert isinstance(condenser, BrowserOutputCondenser)
assert condenser.attention_window == attention_window
def test_browser_output_condenser_respects_attention_window(mock_state):
"""Test that BrowserOutputCondenser only masks events outside the attention window."""
attention_window = 3
condenser = BrowserOutputCondenser(attention_window=attention_window)
events = [
BrowserOutputObservation('Observation 1', url='', trigger_by_action=''),
BrowserOutputObservation('Observation 2', url='', trigger_by_action=''),
create_test_event('Event 3'),
create_test_event('Event 4'),
BrowserOutputObservation('Observation 3', url='', trigger_by_action=''),
BrowserOutputObservation('Observation 4', url='', trigger_by_action=''),
]
mock_state.history = events
result = condenser.condensed_history(mock_state)
assert len(result) == len(events)
cnt = 4
for event, condensed_event in zip(events, result):
if isinstance(event, BrowserOutputObservation):
if cnt > attention_window:
assert 'Content Omitted' in str(condensed_event)
else:
assert event == condensed_event
cnt -= 1
else:
assert event == condensed_event
def test_recent_events_condenser_from_config():
"""Test that RecentEventsCondenser objects can be made from config."""
max_events = 5

View File

@@ -5,7 +5,7 @@ import shutil
from abc import ABC
from dataclasses import dataclass, field
from io import BytesIO, StringIO
from typing import Dict, List, Optional
from typing import Dict, List
from unittest import TestCase
from unittest.mock import patch
@@ -145,10 +145,10 @@ class _MockGoogleCloudClient:
class _MockGoogleCloudBucket:
blobs_by_path: Dict[str, _MockGoogleCloudBlob] = field(default_factory=dict)
def blob(self, path: Optional[str] = None) -> _MockGoogleCloudBlob:
def blob(self, path: str | None = None) -> _MockGoogleCloudBlob:
return self.blobs_by_path.get(path) or _MockGoogleCloudBlob(self, path)
def list_blobs(self, prefix: Optional[str] = None) -> List[_MockGoogleCloudBlob]:
def list_blobs(self, prefix: str | None = None) -> List[_MockGoogleCloudBlob]:
blobs = list(self.blobs_by_path.values())
if prefix and prefix != '/':
blobs = [blob for blob in blobs if blob.name.startswith(prefix)]
@@ -159,7 +159,7 @@ class _MockGoogleCloudBucket:
class _MockGoogleCloudBlob:
bucket: _MockGoogleCloudBucket
name: str
content: Optional[str | bytes] = None
content: str | bytes | None = None
def open(self, op: str):
if op == 'r':