mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
12 Commits
fix-events
...
enyst/smal
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fac6af6889 | ||
|
|
1490e43aea | ||
|
|
c76a659cde | ||
|
|
0f68a18cbb | ||
|
|
c9ebabd82d | ||
|
|
ad932e45e8 | ||
|
|
3278caf3c2 | ||
|
|
896d7b8b96 | ||
|
|
cb61282c39 | ||
|
|
7a235ce6ff | ||
|
|
5ffb1ef704 | ||
|
|
4e4f4d64f8 |
10
.github/workflows/ghcr-build.yml
vendored
10
.github/workflows/ghcr-build.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || ""),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -53,4 +53,3 @@ To verify Docker is working correctly, run the hello-world container:
|
||||
```bash
|
||||
sudo docker run hello-world
|
||||
```
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
48
openhands/memory/condenser/impl/browser_output_condenser.py
Normal file
48
openhands/memory/condenser/impl/browser_output_condenser.py
Normal 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)
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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()}'"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user