mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d08886f30e | ||
|
|
68e52a9c62 | ||
|
|
7e38297732 | ||
|
|
12ed523c01 | ||
|
|
ebce77ab56 | ||
|
|
f4a2df859f | ||
|
|
94a8f58ece | ||
|
|
746722e1b5 | ||
|
|
27f136b802 | ||
|
|
e211152f93 | ||
|
|
07b96cc8c9 | ||
|
|
3a65b7b07d | ||
|
|
5c83698524 | ||
|
|
cde7ce49be | ||
|
|
24a83eb52d | ||
|
|
2a78b3323b | ||
|
|
a3977621ed | ||
|
|
018080aae0 | ||
|
|
302e41d7bb | ||
|
|
3c61a9521b | ||
|
|
c9ed9b166b |
7
.github/workflows/lint-fix.yml
vendored
7
.github/workflows/lint-fix.yml
vendored
@@ -44,11 +44,8 @@ jobs:
|
||||
run: pip install pre-commit==3.7.0
|
||||
- name: Fix python lint issues
|
||||
run: |
|
||||
pre-commit run trailing-whitespace --files openhands/**/* evaluation/**/* tests/**/* --config ./dev_config/python/.pre-commit-config.yaml
|
||||
pre-commit run end-of-file-fixer --files openhands/**/* evaluation/**/* tests/**/* --config ./dev_config/python/.pre-commit-config.yaml
|
||||
pre-commit run pyproject-fmt --files openhands/**/* evaluation/**/* tests/**/* --config ./dev_config/python/.pre-commit-config.yaml
|
||||
pre-commit run ruff --files openhands/**/* evaluation/**/* tests/**/* --config ./dev_config/python/.pre-commit-config.yaml
|
||||
pre-commit run ruff-format --files openhands/**/* evaluation/**/* tests/**/* --config ./dev_config/python/.pre-commit-config.yaml
|
||||
# Run all pre-commit hooks and continue even if they modify files (exit code 1)
|
||||
pre-commit run --config ./dev_config/python/.pre-commit-config.yaml --files openhands/**/* evaluation/**/* tests/**/* || true
|
||||
|
||||
# Commit and push changes if any
|
||||
- name: Check for changes
|
||||
|
||||
20
.github/workflows/openhands-resolver.yml
vendored
20
.github/workflows/openhands-resolver.yml
vendored
@@ -40,7 +40,6 @@ permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
|
||||
auto-fix:
|
||||
if: |
|
||||
github.event_name == 'workflow_call' ||
|
||||
@@ -76,7 +75,18 @@ jobs:
|
||||
cat requirements.txt
|
||||
|
||||
- name: Cache pip dependencies
|
||||
if: github.event.label.name != 'fix-me-experimental'
|
||||
if: |
|
||||
!(
|
||||
github.event.label.name == 'fix-me-experimental' ||
|
||||
(
|
||||
(github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
|
||||
startsWith(github.event.comment.body, '@openhands-agent-exp')
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'pull_request_review' &&
|
||||
startsWith(github.event.review.body, '@openhands-agent-exp')
|
||||
)
|
||||
)
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ env.pythonLocation }}/lib/python3.12/site-packages/*
|
||||
@@ -140,7 +150,11 @@ jobs:
|
||||
|
||||
- name: Install OpenHands
|
||||
run: |
|
||||
if [ "${{ github.event.label.name }}" == "fix-me-experimental" ]; then
|
||||
if [[ "${{ github.event.label.name }}" == "fix-me-experimental" ]] ||
|
||||
([[ "${{ github.event_name }}" == "issue_comment" || "${{ github.event_name }}" == "pull_request_review_comment" ]] &&
|
||||
[[ "${{ github.event.comment.body }}" == "@openhands-agent-exp"* ]]) ||
|
||||
([[ "${{ github.event_name }}" == "pull_request_review" ]] &&
|
||||
[[ "${{ github.event.review.body }}" == "@openhands-agent-exp"* ]]); then
|
||||
python -m pip install --upgrade pip
|
||||
pip install git+https://github.com/all-hands-ai/openhands.git
|
||||
else
|
||||
|
||||
@@ -43,3 +43,53 @@ To customize the default macro (`@openhands-agent`):
|
||||
|
||||
1. [Create a repository variable](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#creating-configuration-variables-for-a-repository) named `OPENHANDS_MACRO`
|
||||
2. Assign the variable a custom value
|
||||
|
||||
## Writing Effective .openhands_instructions Files
|
||||
|
||||
The `.openhands_instructions` file is a file that you can put in the root directory of your repository to guide OpenHands in understanding and working with your repository effectively. Here are key tips for writing high-quality instructions:
|
||||
|
||||
### Core Principles
|
||||
|
||||
1. **Concise but Informative**: Provide a clear, focused overview of the repository that emphasizes the most common actions OpenHands will need to perform.
|
||||
|
||||
2. **Repository Structure**: Explain the key directories and their purposes, especially highlighting where different types of code (e.g., frontend, backend) are located.
|
||||
|
||||
3. **Development Workflows**: Document the essential commands for:
|
||||
- Building and setting up the project
|
||||
- Running tests
|
||||
- Linting and code quality checks
|
||||
- Any environment-specific requirements
|
||||
|
||||
4. **Testing Guidelines**: Specify:
|
||||
- Where tests are located
|
||||
- How to run specific test suites
|
||||
- Any testing conventions or requirements
|
||||
|
||||
### Example Structure
|
||||
|
||||
```markdown
|
||||
# Repository Overview
|
||||
[Brief description of the project]
|
||||
|
||||
## General Setup
|
||||
- Main build command
|
||||
- Development environment setup
|
||||
- Pre-commit checks
|
||||
|
||||
## Backend
|
||||
- Location and structure
|
||||
- Testing instructions
|
||||
- Environment requirements
|
||||
|
||||
## Frontend
|
||||
- Setup prerequisites
|
||||
- Build and test commands
|
||||
- Environment variables
|
||||
|
||||
## Additional Guidelines
|
||||
- Code style requirements
|
||||
- Special considerations
|
||||
- Common workflows
|
||||
```
|
||||
|
||||
For a real-world example, refer to the [OpenHands repository's .openhands_instructions](https://github.com/All-Hands-AI/OpenHands/blob/main/.openhands_instructions).
|
||||
|
||||
2668
docs/package-lock.json
generated
2668
docs/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,10 +15,10 @@
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "^3.6.0",
|
||||
"@docusaurus/plugin-content-pages": "^3.6.0",
|
||||
"@docusaurus/preset-classic": "^3.6.0",
|
||||
"@docusaurus/theme-mermaid": "^3.6.0",
|
||||
"@docusaurus/core": "^3.6.2",
|
||||
"@docusaurus/plugin-content-pages": "^3.6.2",
|
||||
"@docusaurus/preset-classic": "^3.6.2",
|
||||
"@docusaurus/theme-mermaid": "^3.6.2",
|
||||
"@mdx-js/react": "^3.1.0",
|
||||
"clsx": "^2.0.0",
|
||||
"prism-react-renderer": "^2.4.0",
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.5.1",
|
||||
"@docusaurus/tsconfig": "^3.6.0",
|
||||
"@docusaurus/tsconfig": "^3.6.2",
|
||||
"@docusaurus/types": "^3.5.1",
|
||||
"typescript": "~5.6.3"
|
||||
},
|
||||
|
||||
1041
docs/yarn.lock
1041
docs/yarn.lock
File diff suppressed because it is too large
Load Diff
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.1",
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@nextui-org/react": "^2.4.8",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -7,10 +7,10 @@ Contributors from different backgrounds and interests can choose to contribute t
|
||||
|
||||
## Constructing an Agent
|
||||
|
||||
The abstraction for an agent can be found [here](../openhands/controller/agent.py).
|
||||
The abstraction for an agent can be found [here](../controller/agent.py).
|
||||
|
||||
Agents are run inside of a loop. At each iteration, `agent.step()` is called with a
|
||||
[State](../openhands/controller/state/state.py) input, and the agent must output an [Action](../openhands/events/action).
|
||||
[State](../controller/state/state.py) input, and the agent must output an [Action](../events/action).
|
||||
|
||||
Every agent also has a `self.llm` which it can use to interact with the LLM configured by the user.
|
||||
See the [LiteLLM docs for `self.llm.completion`](https://docs.litellm.ai/docs/completion).
|
||||
@@ -46,17 +46,17 @@ The agent can add and modify subtasks through the `AddTaskAction` and `ModifyTas
|
||||
|
||||
Here is a list of available Actions, which can be returned by `agent.step()`:
|
||||
|
||||
- [`CmdRunAction`](../openhands/events/action/commands.py) - Runs a command inside a sandboxed terminal
|
||||
- [`IPythonRunCellAction`](../openhands/events/action/commands.py) - Execute a block of Python code interactively (in Jupyter notebook) and receives `CmdOutputObservation`. Requires setting up `jupyter` [plugin](../openhands/runtime/plugins) as a requirement.
|
||||
- [`FileReadAction`](../openhands/events/action/files.py) - Reads the content of a file
|
||||
- [`FileWriteAction`](../openhands/events/action/files.py) - Writes new content to a file
|
||||
- [`BrowseURLAction`](../openhands/events/action/browse.py) - Gets the content of a URL
|
||||
- [`AddTaskAction`](../openhands/events/action/tasks.py) - Adds a subtask to the plan
|
||||
- [`ModifyTaskAction`](../openhands/events/action/tasks.py) - Changes the state of a subtask.
|
||||
- [`AgentFinishAction`](../openhands/events/action/agent.py) - Stops the control loop, allowing the user/delegator agent to enter a new task
|
||||
- [`AgentRejectAction`](../openhands/events/action/agent.py) - Stops the control loop, allowing the user/delegator agent to enter a new task
|
||||
- [`AgentFinishAction`](../openhands/events/action/agent.py) - Stops the control loop, allowing the user to enter a new task
|
||||
- [`MessageAction`](../openhands/events/action/message.py) - Represents a message from an agent or the user
|
||||
- [`CmdRunAction`](../events/action/commands.py) - Runs a command inside a sandboxed terminal
|
||||
- [`IPythonRunCellAction`](../events/action/commands.py) - Execute a block of Python code interactively (in Jupyter notebook) and receives `CmdOutputObservation`. Requires setting up `jupyter` [plugin](../runtime/plugins) as a requirement.
|
||||
- [`FileReadAction`](../events/action/files.py) - Reads the content of a file
|
||||
- [`FileWriteAction`](../events/action/files.py) - Writes new content to a file
|
||||
- [`BrowseURLAction`](../events/action/browse.py) - Gets the content of a URL
|
||||
- [`AddTaskAction`](../events/action/tasks.py) - Adds a subtask to the plan
|
||||
- [`ModifyTaskAction`](../events/action/tasks.py) - Changes the state of a subtask.
|
||||
- [`AgentFinishAction`](../events/action/agent.py) - Stops the control loop, allowing the user/delegator agent to enter a new task
|
||||
- [`AgentRejectAction`](../events/action/agent.py) - Stops the control loop, allowing the user/delegator agent to enter a new task
|
||||
- [`AgentFinishAction`](../events/action/agent.py) - Stops the control loop, allowing the user to enter a new task
|
||||
- [`MessageAction`](../events/action/message.py) - Represents a message from an agent or the user
|
||||
|
||||
To serialize and deserialize an action, you can use:
|
||||
- `action.to_dict()` to serialize the action to a dictionary to be sent to the UI, including a user-friendly string representation of the message
|
||||
@@ -70,12 +70,12 @@ But they may also appear as a result of asynchronous events (e.g. a message from
|
||||
|
||||
Here is a list of available Observations:
|
||||
|
||||
- [`CmdOutputObservation`](../openhands/events/observation/commands.py)
|
||||
- [`BrowserOutputObservation`](../openhands/events/observation/browse.py)
|
||||
- [`FileReadObservation`](../openhands/events/observation/files.py)
|
||||
- [`FileWriteObservation`](../openhands/events/observation/files.py)
|
||||
- [`ErrorObservation`](../openhands/events/observation/error.py)
|
||||
- [`SuccessObservation`](../openhands/events/observation/success.py)
|
||||
- [`CmdOutputObservation`](../events/observation/commands.py)
|
||||
- [`BrowserOutputObservation`](../events/observation/browse.py)
|
||||
- [`FileReadObservation`](../events/observation/files.py)
|
||||
- [`FileWriteObservation`](../events/observation/files.py)
|
||||
- [`ErrorObservation`](../events/observation/error.py)
|
||||
- [`SuccessObservation`](../events/observation/success.py)
|
||||
|
||||
You can use `observation.to_dict()` and `observation_from_dict` to serialize and deserialize observations.
|
||||
|
||||
|
||||
@@ -10,3 +10,57 @@ The conceptual idea is illustrated below. At each turn, the agent can:
|
||||
- Execute any valid `Python` code with [an interactive Python interpreter](https://ipython.org/). This is simulated through `bash` command, see plugin system below for more details.
|
||||
|
||||

|
||||
|
||||
## Adding New Tools
|
||||
|
||||
The CodeAct agent uses a function calling interface to define tools that the agent can use. Tools are defined in `function_calling.py` using the `ChatCompletionToolParam` class from `litellm`. Each tool consists of:
|
||||
|
||||
1. A description string that explains what the tool does and how to use it
|
||||
2. A tool definition using `ChatCompletionToolParam` that specifies:
|
||||
- The tool's name
|
||||
- The tool's parameters and their types
|
||||
- Required vs optional parameters
|
||||
|
||||
Here's an example of how a tool is defined:
|
||||
|
||||
```python
|
||||
MyTool = ChatCompletionToolParam(
|
||||
type='function',
|
||||
function=ChatCompletionToolParamFunctionChunk(
|
||||
name='my_tool',
|
||||
description='Description of what the tool does and how to use it',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'param1': {
|
||||
'type': 'string',
|
||||
'description': 'Description of parameter 1',
|
||||
},
|
||||
'param2': {
|
||||
'type': 'integer',
|
||||
'description': 'Description of parameter 2',
|
||||
},
|
||||
},
|
||||
'required': ['param1'], # List required parameters here
|
||||
},
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
To add a new tool:
|
||||
|
||||
1. Define your tool in `function_calling.py` following the pattern above
|
||||
2. Add your tool to the `get_tools()` function in `function_calling.py`
|
||||
3. Implement the corresponding action handler in the agent to process the tool's invocation
|
||||
|
||||
The agent currently supports several built-in tools:
|
||||
- `execute_bash`: Execute bash commands
|
||||
- `execute_ipython_cell`: Run Python code in IPython
|
||||
- `browser`: Interact with a web browser
|
||||
- `str_replace_editor`: Edit files using string replacement
|
||||
- `edit_file`: Edit files using LLM-based editing
|
||||
|
||||
Tools can be enabled/disabled through configuration parameters:
|
||||
- `codeact_enable_browsing`: Enable browser interaction
|
||||
- `codeact_enable_jupyter`: Enable IPython code execution
|
||||
- `codeact_enable_llm_editor`: Enable LLM-based file editing (if disabled, uses string replacement editor instead)
|
||||
|
||||
@@ -18,6 +18,7 @@ from openhands.core.exceptions import (
|
||||
LLMNoActionError,
|
||||
LLMResponseError,
|
||||
)
|
||||
from openhands.core.logger import LOG_ALL_EVENTS
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.schema import AgentState
|
||||
from openhands.events import EventSource, EventStream, EventStreamSubscriber
|
||||
@@ -528,8 +529,7 @@ class AgentController:
|
||||
|
||||
await self.update_state_after_step()
|
||||
|
||||
# Use info level if LOG_ALL_EVENTS is set
|
||||
log_level = 'info' if os.getenv('LOG_ALL_EVENTS') in ('true', '1') else 'debug'
|
||||
log_level = 'info' if LOG_ALL_EVENTS else 'debug'
|
||||
self.log(log_level, str(action), extra={'msg_type': 'ACTION'})
|
||||
|
||||
async def _delegate_step(self):
|
||||
|
||||
@@ -241,6 +241,7 @@ def get_llm_config_arg(
|
||||
|
||||
Args:
|
||||
llm_config_arg: The group of llm settings to get from the config.toml file.
|
||||
toml_file: Path to the configuration file to read from. Defaults to 'config.toml'.
|
||||
|
||||
Returns:
|
||||
LLMConfig: The LLMConfig object with the settings from the config file.
|
||||
@@ -384,7 +385,7 @@ def load_app_config(
|
||||
"""Load the configuration from the specified config file and environment variables.
|
||||
|
||||
Args:
|
||||
set_logger_levels: Whether to set the global variables for logging levels.
|
||||
set_logging_levels: Whether to set the global variables for logging levels.
|
||||
config_file: Path to the config file. Defaults to 'config.toml' in the current directory.
|
||||
"""
|
||||
config = AppConfig()
|
||||
|
||||
@@ -17,6 +17,8 @@ if DEBUG:
|
||||
LOG_TO_FILE = os.getenv('LOG_TO_FILE', 'False').lower() in ['true', '1', 'yes']
|
||||
DISABLE_COLOR_PRINTING = False
|
||||
|
||||
LOG_ALL_EVENTS = os.getenv('LOG_ALL_EVENTS', 'False').lower() in ['true', '1', 'yes']
|
||||
|
||||
ColorType = Literal[
|
||||
'red',
|
||||
'green',
|
||||
@@ -89,8 +91,11 @@ class ColoredFormatter(logging.Formatter):
|
||||
return f'{time_str} - {name_str}:{level_str}: {record.filename}:{record.lineno}\n{msg_type_color}\n{msg}'
|
||||
return f'{time_str} - {msg_type_color}\n{msg}'
|
||||
elif msg_type == 'STEP':
|
||||
msg = '\n\n==============\n' + record.msg + '\n'
|
||||
return f'{msg}'
|
||||
if LOG_ALL_EVENTS:
|
||||
msg = '\n\n==============\n' + record.msg + '\n'
|
||||
return f'{msg}'
|
||||
else:
|
||||
return record.msg
|
||||
return super().format(record)
|
||||
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ class Message(BaseModel):
|
||||
cache_enabled: bool = False
|
||||
vision_enabled: bool = False
|
||||
# function calling
|
||||
function_calling_enabled: bool = False
|
||||
# - tool calls (from LLM)
|
||||
tool_calls: list[ChatCompletionMessageToolCall] | None = None
|
||||
# - tool execution result (to LLM)
|
||||
@@ -72,22 +73,22 @@ class Message(BaseModel):
|
||||
# - into a single string: for providers that don't support list of content items (e.g. no vision, no tool calls)
|
||||
# - into a list of content items: the new APIs of providers with vision/prompt caching/tool calls
|
||||
# NOTE: remove this when litellm or providers support the new API
|
||||
if (
|
||||
self.cache_enabled
|
||||
or self.vision_enabled
|
||||
or self.tool_call_id is not None
|
||||
or self.tool_calls is not None
|
||||
):
|
||||
if self.cache_enabled or self.vision_enabled or self.function_calling_enabled:
|
||||
return self._list_serializer()
|
||||
# some providers, like HF and Groq/llama, don't support a list here, but a single string
|
||||
return self._string_serializer()
|
||||
|
||||
def _string_serializer(self):
|
||||
def _string_serializer(self) -> dict:
|
||||
# convert content to a single string
|
||||
content = '\n'.join(
|
||||
item.text for item in self.content if isinstance(item, TextContent)
|
||||
)
|
||||
return {'content': content, 'role': self.role}
|
||||
message_dict: dict = {'content': content, 'role': self.role}
|
||||
|
||||
def _list_serializer(self):
|
||||
# add tool call keys if we have a tool call or response
|
||||
return self._add_tool_call_keys(message_dict)
|
||||
|
||||
def _list_serializer(self) -> dict:
|
||||
content: list[dict] = []
|
||||
role_tool_with_prompt_caching = False
|
||||
for item in self.content:
|
||||
@@ -102,24 +103,37 @@ class Message(BaseModel):
|
||||
elif isinstance(item, ImageContent) and self.vision_enabled:
|
||||
content.extend(d)
|
||||
|
||||
ret: dict = {'content': content, 'role': self.role}
|
||||
message_dict: dict = {'content': content, 'role': self.role}
|
||||
|
||||
# pop content if it's empty
|
||||
if not content or (
|
||||
len(content) == 1
|
||||
and content[0]['type'] == 'text'
|
||||
and content[0]['text'] == ''
|
||||
):
|
||||
ret.pop('content')
|
||||
message_dict.pop('content')
|
||||
|
||||
if role_tool_with_prompt_caching:
|
||||
ret['cache_control'] = {'type': 'ephemeral'}
|
||||
message_dict['cache_control'] = {'type': 'ephemeral'}
|
||||
|
||||
# add tool call keys if we have a tool call or response
|
||||
return self._add_tool_call_keys(message_dict)
|
||||
|
||||
def _add_tool_call_keys(self, message_dict: dict) -> dict:
|
||||
"""Add tool call keys if we have a tool call or response.
|
||||
|
||||
NOTE: this is necessary for both native and non-native tool calling"""
|
||||
|
||||
# an assistant message calling a tool
|
||||
if self.tool_calls is not None:
|
||||
message_dict['tool_calls'] = self.tool_calls
|
||||
|
||||
# an observation message with tool response
|
||||
if self.tool_call_id is not None:
|
||||
assert (
|
||||
self.name is not None
|
||||
), 'name is required when tool_call_id is not None'
|
||||
ret['tool_call_id'] = self.tool_call_id
|
||||
ret['name'] = self.name
|
||||
if self.tool_calls:
|
||||
ret['tool_calls'] = self.tool_calls
|
||||
return ret
|
||||
message_dict['tool_call_id'] = self.tool_call_id
|
||||
message_dict['name'] = self.name
|
||||
|
||||
return message_dict
|
||||
|
||||
@@ -320,9 +320,8 @@ def convert_fncall_messages_to_non_fncall_messages(
|
||||
converted_messages = []
|
||||
first_user_message_encountered = False
|
||||
for message in messages:
|
||||
role, content = message['role'], message['content']
|
||||
if content is None:
|
||||
content = ''
|
||||
role = message['role']
|
||||
content = message.get('content', '')
|
||||
|
||||
# 1. SYSTEM MESSAGES
|
||||
# append system prompt suffix to content
|
||||
@@ -339,6 +338,7 @@ def convert_fncall_messages_to_non_fncall_messages(
|
||||
f'Unexpected content type {type(content)}. Expected str or list. Content: {content}'
|
||||
)
|
||||
converted_messages.append({'role': 'system', 'content': content})
|
||||
|
||||
# 2. USER MESSAGES (no change)
|
||||
elif role == 'user':
|
||||
# Add in-context learning example for the first user message
|
||||
@@ -447,10 +447,12 @@ def convert_fncall_messages_to_non_fncall_messages(
|
||||
f'Unexpected content type {type(content)}. Expected str or list. Content: {content}'
|
||||
)
|
||||
converted_messages.append({'role': 'assistant', 'content': content})
|
||||
|
||||
# 4. TOOL MESSAGES (tool outputs)
|
||||
elif role == 'tool':
|
||||
# Convert tool result as assistant message
|
||||
prefix = f'EXECUTION RESULT of [{message["name"]}]:\n'
|
||||
# Convert tool result as user message
|
||||
tool_name = message.get('name', 'function')
|
||||
prefix = f'EXECUTION RESULT of [{tool_name}]:\n'
|
||||
# and omit "tool_call_id" AND "name"
|
||||
if isinstance(content, str):
|
||||
content = prefix + content
|
||||
|
||||
@@ -122,6 +122,9 @@ class LLM(RetryMixin, DebugMixin):
|
||||
drop_params=self.config.drop_params,
|
||||
)
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('ignore')
|
||||
self.init_model_info()
|
||||
if self.vision_is_active():
|
||||
logger.debug('LLM: model has vision enabled')
|
||||
if self.is_caching_prompt_active():
|
||||
@@ -143,16 +146,6 @@ class LLM(RetryMixin, DebugMixin):
|
||||
drop_params=self.config.drop_params,
|
||||
)
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('ignore')
|
||||
self.init_model_info()
|
||||
if self.vision_is_active():
|
||||
logger.debug('LLM: model has vision enabled')
|
||||
if self.is_caching_prompt_active():
|
||||
logger.debug('LLM: caching prompt enabled')
|
||||
if self.is_function_calling_active():
|
||||
logger.debug('LLM: model supports function calling')
|
||||
|
||||
self._completion_unwrapped = self._completion
|
||||
|
||||
@self.retry_decorator(
|
||||
@@ -164,7 +157,6 @@ class LLM(RetryMixin, DebugMixin):
|
||||
)
|
||||
def wrapper(*args, **kwargs):
|
||||
"""Wrapper for the litellm completion function. Logs the input and output of the completion function."""
|
||||
|
||||
from openhands.core.utils import json
|
||||
|
||||
messages: list[dict[str, Any]] | dict[str, Any] = []
|
||||
@@ -343,6 +335,13 @@ class LLM(RetryMixin, DebugMixin):
|
||||
pass
|
||||
logger.debug(f'Model info: {self.model_info}')
|
||||
|
||||
if self.config.model.startswith('huggingface'):
|
||||
# HF doesn't support the OpenAI default value for top_p (1)
|
||||
logger.debug(
|
||||
f'Setting top_p to 0.9 for Hugging Face model: {self.config.model}'
|
||||
)
|
||||
self.config.top_p = 0.9 if self.config.top_p == 1 else self.config.top_p
|
||||
|
||||
# Set the max tokens in an LM-specific way if not set
|
||||
if self.config.max_input_tokens is None:
|
||||
if (
|
||||
@@ -370,16 +369,16 @@ class LLM(RetryMixin, DebugMixin):
|
||||
):
|
||||
self.config.max_output_tokens = self.model_info['max_tokens']
|
||||
|
||||
def vision_is_active(self):
|
||||
def vision_is_active(self) -> bool:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('ignore')
|
||||
return not self.config.disable_vision and self._supports_vision()
|
||||
|
||||
def _supports_vision(self):
|
||||
def _supports_vision(self) -> bool:
|
||||
"""Acquire from litellm if model is vision capable.
|
||||
|
||||
Returns:
|
||||
bool: True if model is vision capable. If model is not supported by litellm, it will return False.
|
||||
bool: True if model is vision capable. Return False if model not supported by litellm.
|
||||
"""
|
||||
# litellm.supports_vision currently returns False for 'openai/gpt-...' or 'anthropic/claude-...' (with prefixes)
|
||||
# but model_info will have the correct value for some reason.
|
||||
@@ -477,7 +476,7 @@ class LLM(RetryMixin, DebugMixin):
|
||||
if stats:
|
||||
logger.debug(stats)
|
||||
|
||||
def get_token_count(self, messages):
|
||||
def get_token_count(self, messages) -> int:
|
||||
"""Get the number of tokens in a list of messages.
|
||||
|
||||
Args:
|
||||
@@ -492,7 +491,7 @@ class LLM(RetryMixin, DebugMixin):
|
||||
# TODO: this is to limit logspam in case token count is not supported
|
||||
return 0
|
||||
|
||||
def _is_local(self):
|
||||
def _is_local(self) -> bool:
|
||||
"""Determines if the system is using a locally running LLM.
|
||||
|
||||
Returns:
|
||||
@@ -507,7 +506,7 @@ class LLM(RetryMixin, DebugMixin):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _completion_cost(self, response):
|
||||
def _completion_cost(self, response) -> float:
|
||||
"""Calculate the cost of a completion response based on the model. Local models are treated as free.
|
||||
Add the current cost into total cost in metrics.
|
||||
|
||||
@@ -556,7 +555,7 @@ class LLM(RetryMixin, DebugMixin):
|
||||
def __repr__(self):
|
||||
return str(self)
|
||||
|
||||
def reset(self):
|
||||
def reset(self) -> None:
|
||||
self.metrics.reset()
|
||||
|
||||
def format_messages_for_llm(self, messages: Message | list[Message]) -> list[dict]:
|
||||
@@ -567,6 +566,7 @@ class LLM(RetryMixin, DebugMixin):
|
||||
for message in messages:
|
||||
message.cache_enabled = self.is_caching_prompt_active()
|
||||
message.vision_enabled = self.vision_is_active()
|
||||
message.function_calling_enabled = self.is_function_calling_active()
|
||||
|
||||
# let pydantic handle the serialization
|
||||
return [message.model_dump() for message in messages]
|
||||
|
||||
@@ -15,6 +15,8 @@ Follow these steps to use this workflow in your own repository:
|
||||
|
||||
1. [Create a personal access token](https://github.com/settings/tokens?type=beta) with read/write scope for "contents", "issues", "pull requests", and "workflows"
|
||||
|
||||
Note: If you're working with an organizational repository, you may need to configure the organization's personal access token policy first. See [Setting a personal access token policy for your organization](https://docs.github.com/en/organizations/managing-programmatic-access-to-your-organization/setting-a-personal-access-token-policy-for-your-organization) for details.
|
||||
|
||||
2. Create an API key for the [Claude API](https://www.anthropic.com/api) (recommended) or another supported LLM service
|
||||
|
||||
3. Copy `examples/openhands-resolver.yml` to your repository's `.github/workflows/` directory
|
||||
@@ -83,11 +85,14 @@ pip install openhands-ai
|
||||
3. Set up environment variables:
|
||||
|
||||
```bash
|
||||
|
||||
# GitHub credentials
|
||||
|
||||
export GITHUB_TOKEN="your-github-token"
|
||||
export GITHUB_USERNAME="your-github-username" # Optional, defaults to token owner
|
||||
|
||||
# LLM configuration
|
||||
|
||||
export LLM_MODEL="anthropic/claude-3-5-sonnet-20241022" # Recommended
|
||||
export LLM_API_KEY="your-llm-api-key"
|
||||
export LLM_BASE_URL="your-api-url" # Optional, for API proxies
|
||||
|
||||
@@ -52,7 +52,7 @@ from openhands.runtime.utils.bash import BashSession
|
||||
from openhands.runtime.utils.files import insert_lines, read_lines
|
||||
from openhands.runtime.utils.runtime_init import init_user_and_working_directory
|
||||
from openhands.runtime.utils.system import check_port_available
|
||||
from openhands.utils.async_utils import wait_all
|
||||
from openhands.utils.async_utils import call_sync_from_async, wait_all
|
||||
|
||||
|
||||
class ActionRequest(BaseModel):
|
||||
@@ -170,7 +170,8 @@ class ActionExecutor:
|
||||
async def run(
|
||||
self, action: CmdRunAction
|
||||
) -> CmdOutputObservation | ErrorObservation:
|
||||
return self.bash_session.run(action)
|
||||
obs = await call_sync_from_async(self.bash_session.run, action)
|
||||
return obs
|
||||
|
||||
async def run_ipython(self, action: IPythonRunCellAction) -> Observation:
|
||||
if 'jupyter' in self.plugins:
|
||||
|
||||
@@ -47,11 +47,19 @@ STATUS_MESSAGES = {
|
||||
}
|
||||
|
||||
|
||||
class RuntimeNotReadyError(Exception):
|
||||
class RuntimeUnavailableError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class RuntimeDisconnectedError(Exception):
|
||||
class RuntimeNotReadyError(RuntimeUnavailableError):
|
||||
pass
|
||||
|
||||
|
||||
class RuntimeDisconnectedError(RuntimeUnavailableError):
|
||||
pass
|
||||
|
||||
|
||||
class RuntimeNotFoundError(RuntimeUnavailableError):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -34,7 +34,11 @@ 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
|
||||
from openhands.runtime.base import (
|
||||
Runtime,
|
||||
RuntimeDisconnectedError,
|
||||
RuntimeNotFoundError,
|
||||
)
|
||||
from openhands.runtime.builder import DockerRuntimeBuilder
|
||||
from openhands.runtime.impl.eventstream.containers import remove_all_containers
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
@@ -424,10 +428,22 @@ class EventStreamRuntime(Runtime):
|
||||
|
||||
@tenacity.retry(
|
||||
stop=tenacity.stop_after_delay(120) | stop_if_should_exit(),
|
||||
reraise=(ConnectionRefusedError,),
|
||||
retry=tenacity.retry_if_exception_type(
|
||||
(ConnectionError, requests.exceptions.ConnectionError)
|
||||
),
|
||||
reraise=True,
|
||||
wait=tenacity.wait_fixed(2),
|
||||
)
|
||||
def _wait_until_alive(self):
|
||||
try:
|
||||
container = self.docker_client.containers.get(self.container_name)
|
||||
if container.status == 'exited':
|
||||
raise RuntimeDisconnectedError(
|
||||
f'Container {self.container_name} has exited.'
|
||||
)
|
||||
except docker.errors.NotFound:
|
||||
raise RuntimeNotFoundError(f'Container {self.container_name} not found.')
|
||||
|
||||
self._refresh_logs()
|
||||
if not self.log_buffer:
|
||||
raise RuntimeError('Runtime client is not ready.')
|
||||
|
||||
@@ -31,6 +31,7 @@ from openhands.events.serialization.action import ACTION_TYPE_TO_CLASS
|
||||
from openhands.runtime.base import (
|
||||
Runtime,
|
||||
RuntimeDisconnectedError,
|
||||
RuntimeNotFoundError,
|
||||
RuntimeNotReadyError,
|
||||
)
|
||||
from openhands.runtime.builder.remote import RemoteRuntimeBuilder
|
||||
@@ -109,7 +110,9 @@ class RemoteRuntime(Runtime):
|
||||
if existing_runtime:
|
||||
self.log('debug', f'Using existing runtime with ID: {self.runtime_id}')
|
||||
elif self.attach_to_existing:
|
||||
raise RuntimeError('Could not find existing runtime to attach to.')
|
||||
raise RuntimeNotFoundError(
|
||||
f'Could not find existing runtime for SID: {self.sid}'
|
||||
)
|
||||
else:
|
||||
self.send_status_message('STATUS$STARTING_CONTAINER')
|
||||
if self.config.sandbox.runtime_container_image is None:
|
||||
|
||||
@@ -34,6 +34,7 @@ from fastapi import (
|
||||
Request,
|
||||
UploadFile,
|
||||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
status,
|
||||
)
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
@@ -61,9 +62,14 @@ from openhands.events.observation import (
|
||||
from openhands.events.serialization import event_to_dict
|
||||
from openhands.events.stream import AsyncEventStreamWrapper
|
||||
from openhands.llm import bedrock
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.runtime.base import Runtime, RuntimeUnavailableError
|
||||
from openhands.server.auth.auth import get_sid_from_token, sign_token
|
||||
from openhands.server.middleware import LocalhostCORSMiddleware, NoCacheMiddleware
|
||||
from openhands.server.middleware import (
|
||||
InMemoryRateLimiter,
|
||||
LocalhostCORSMiddleware,
|
||||
NoCacheMiddleware,
|
||||
RateLimitMiddleware,
|
||||
)
|
||||
from openhands.server.session import SessionManager
|
||||
|
||||
load_dotenv()
|
||||
@@ -83,6 +89,15 @@ app.add_middleware(
|
||||
|
||||
|
||||
app.add_middleware(NoCacheMiddleware)
|
||||
app.add_middleware(
|
||||
RateLimitMiddleware, rate_limiter=InMemoryRateLimiter(requests=10, seconds=1)
|
||||
)
|
||||
|
||||
|
||||
@app.get('/health')
|
||||
async def health():
|
||||
return 'OK'
|
||||
|
||||
|
||||
security_scheme = HTTPBearer()
|
||||
|
||||
@@ -238,7 +253,8 @@ async def attach_session(request: Request, call_next):
|
||||
request.state.conversation = await session_manager.attach_to_conversation(
|
||||
request.state.sid
|
||||
)
|
||||
if request.state.conversation is None:
|
||||
if not request.state.conversation:
|
||||
logger.error(f'Runtime not found for session: {request.state.sid}')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': 'Session not found'},
|
||||
@@ -344,7 +360,13 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
|
||||
latest_event_id = -1
|
||||
if websocket.query_params.get('latest_event_id'):
|
||||
latest_event_id = int(websocket.query_params.get('latest_event_id'))
|
||||
try:
|
||||
latest_event_id = int(websocket.query_params.get('latest_event_id'))
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
f'Invalid latest_event_id: {websocket.query_params.get("latest_event_id")}'
|
||||
)
|
||||
pass
|
||||
|
||||
async_stream = AsyncEventStreamWrapper(
|
||||
session.agent_session.event_stream, latest_event_id + 1
|
||||
@@ -361,7 +383,14 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
),
|
||||
):
|
||||
continue
|
||||
await websocket.send_json(event_to_dict(event))
|
||||
try:
|
||||
await websocket.send_json(event_to_dict(event))
|
||||
except WebSocketDisconnect:
|
||||
logger.warning(
|
||||
'Websocket disconnected while sending event history, before loop started'
|
||||
)
|
||||
session.close()
|
||||
return
|
||||
|
||||
await session.loop_recv()
|
||||
|
||||
@@ -488,7 +517,14 @@ async def list_files(request: Request, path: str | None = None):
|
||||
)
|
||||
|
||||
runtime: Runtime = request.state.conversation.runtime
|
||||
file_list = await call_sync_from_async(runtime.list_files, path)
|
||||
try:
|
||||
file_list = await call_sync_from_async(runtime.list_files, path)
|
||||
except RuntimeUnavailableError as e:
|
||||
logger.error(f'Error listing files: {e}', exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={'error': f'Error listing files: {e}'},
|
||||
)
|
||||
if path:
|
||||
file_list = [os.path.join(path, f) for f in file_list]
|
||||
|
||||
@@ -508,7 +544,14 @@ async def list_files(request: Request, path: str | None = None):
|
||||
file_list = [entry for entry in file_list if not spec.match_file(entry)]
|
||||
return file_list
|
||||
|
||||
file_list = await filter_for_gitignore(file_list, '')
|
||||
try:
|
||||
file_list = await filter_for_gitignore(file_list, '')
|
||||
except RuntimeUnavailableError as e:
|
||||
logger.error(f'Error filtering files: {e}', exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={'error': f'Error filtering files: {e}'},
|
||||
)
|
||||
|
||||
return file_list
|
||||
|
||||
@@ -537,7 +580,14 @@ async def select_file(file: str, request: Request):
|
||||
|
||||
file = os.path.join(runtime.config.workspace_mount_path_in_sandbox, file)
|
||||
read_action = FileReadAction(file)
|
||||
observation = await call_sync_from_async(runtime.run_action, read_action)
|
||||
try:
|
||||
observation = await call_sync_from_async(runtime.run_action, read_action)
|
||||
except RuntimeUnavailableError as e:
|
||||
logger.error(f'Error opening file {file}: {e}', exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={'error': f'Error opening file: {e}'},
|
||||
)
|
||||
|
||||
if isinstance(observation, FileReadObservation):
|
||||
content = observation.content
|
||||
@@ -633,9 +683,20 @@ async def upload_file(request: Request, files: list[UploadFile]):
|
||||
tmp_file.flush()
|
||||
|
||||
runtime: Runtime = request.state.conversation.runtime
|
||||
runtime.copy_to(
|
||||
tmp_file_path, runtime.config.workspace_mount_path_in_sandbox
|
||||
)
|
||||
try:
|
||||
await call_sync_from_async(
|
||||
runtime.copy_to,
|
||||
tmp_file_path,
|
||||
runtime.config.workspace_mount_path_in_sandbox,
|
||||
)
|
||||
except RuntimeUnavailableError as e:
|
||||
logger.error(
|
||||
f'Error saving file {safe_filename}: {e}', exc_info=True
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={'error': f'Error saving file: {e}'},
|
||||
)
|
||||
uploaded_files.append(safe_filename)
|
||||
|
||||
response_content = {
|
||||
@@ -766,7 +827,14 @@ async def save_file(request: Request):
|
||||
runtime.config.workspace_mount_path_in_sandbox, file_path
|
||||
)
|
||||
write_action = FileWriteAction(file_path, content)
|
||||
observation = await call_sync_from_async(runtime.run_action, write_action)
|
||||
try:
|
||||
observation = await call_sync_from_async(runtime.run_action, write_action)
|
||||
except RuntimeUnavailableError as e:
|
||||
logger.error(f'Error saving file: {e}', exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={'error': f'Error saving file: {e}'},
|
||||
)
|
||||
|
||||
if isinstance(observation, FileWriteObservation):
|
||||
return JSONResponse(
|
||||
@@ -817,7 +885,14 @@ async def zip_current_workspace(request: Request, background_tasks: BackgroundTa
|
||||
logger.debug('Zipping workspace')
|
||||
runtime: Runtime = request.state.conversation.runtime
|
||||
path = runtime.config.workspace_mount_path_in_sandbox
|
||||
zip_file = await call_sync_from_async(runtime.copy_from, path)
|
||||
try:
|
||||
zip_file = await call_sync_from_async(runtime.copy_from, path)
|
||||
except RuntimeUnavailableError as e:
|
||||
logger.error(f'Error zipping workspace: {e}', exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={'error': f'Error zipping workspace: {e}'},
|
||||
)
|
||||
response = FileResponse(
|
||||
path=zip_file,
|
||||
filename='workspace.zip',
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
@@ -41,3 +46,56 @@ class NoCacheMiddleware(BaseHTTPMiddleware):
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
return response
|
||||
|
||||
|
||||
class InMemoryRateLimiter:
|
||||
history: dict
|
||||
requests: int
|
||||
seconds: int
|
||||
sleep_seconds: int
|
||||
|
||||
def __init__(self, requests: int = 2, seconds: int = 1, sleep_seconds: int = 1):
|
||||
self.requests = requests
|
||||
self.seconds = seconds
|
||||
self.sleep_seconds = sleep_seconds
|
||||
self.history = defaultdict(list)
|
||||
|
||||
def _clean_old_requests(self, key: str) -> None:
|
||||
now = datetime.now()
|
||||
cutoff = now - timedelta(seconds=self.seconds)
|
||||
self.history[key] = [ts for ts in self.history[key] if ts > cutoff]
|
||||
|
||||
async def __call__(self, request: Request) -> bool:
|
||||
key = request.client.host
|
||||
now = datetime.now()
|
||||
|
||||
self._clean_old_requests(key)
|
||||
|
||||
self.history[key].append(now)
|
||||
|
||||
if len(self.history[key]) > self.requests * 2:
|
||||
return False
|
||||
elif len(self.history[key]) > self.requests:
|
||||
if self.sleep_seconds > 0:
|
||||
await asyncio.sleep(self.sleep_seconds)
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
def __init__(self, app: ASGIApp, rate_limiter: InMemoryRateLimiter):
|
||||
super().__init__(app)
|
||||
self.rate_limiter = rate_limiter
|
||||
|
||||
async def dispatch(self, request, call_next):
|
||||
ok = await self.rate_limiter(request)
|
||||
if not ok:
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={'message': 'Too many requests'},
|
||||
headers={'Retry-After': '1'},
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
@@ -11,7 +11,7 @@ from openhands.events.action.agent 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
|
||||
from openhands.runtime.base import Runtime, RuntimeUnavailableError
|
||||
from openhands.security import SecurityAnalyzer, options
|
||||
from openhands.storage.files import FileStore
|
||||
|
||||
@@ -194,13 +194,13 @@ class AgentSession:
|
||||
|
||||
try:
|
||||
await self.runtime.connect()
|
||||
except Exception as e:
|
||||
except RuntimeUnavailableError as e:
|
||||
logger.error(f'Runtime initialization failed: {e}', exc_info=True)
|
||||
if self._status_callback:
|
||||
self._status_callback(
|
||||
'error', 'STATUS$ERROR_RUNTIME_DISCONNECTED', str(e)
|
||||
)
|
||||
raise
|
||||
return
|
||||
|
||||
if self.runtime is not None:
|
||||
logger.debug(
|
||||
|
||||
@@ -6,6 +6,7 @@ from fastapi import WebSocket
|
||||
from openhands.core.config import AppConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.stream import session_exists
|
||||
from openhands.runtime.base import RuntimeUnavailableError
|
||||
from openhands.server.session.conversation import Conversation
|
||||
from openhands.server.session.session import Session
|
||||
from openhands.storage.files import FileStore
|
||||
@@ -26,7 +27,11 @@ class SessionManager:
|
||||
if not await session_exists(sid, self.file_store):
|
||||
return None
|
||||
c = Conversation(sid, file_store=self.file_store, config=self.config)
|
||||
await c.connect()
|
||||
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'
|
||||
|
||||
@@ -57,6 +57,9 @@ class Session:
|
||||
self.websocket = None
|
||||
finally:
|
||||
self.agent_session.close()
|
||||
del (
|
||||
self.agent_session
|
||||
) # FIXME: this should not be necessary but it mitigates a memory leak
|
||||
|
||||
async def loop_recv(self):
|
||||
try:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "openhands-ai"
|
||||
version = "0.14.0"
|
||||
version = "0.14.1"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
authors = ["OpenHands"]
|
||||
license = "MIT"
|
||||
|
||||
Reference in New Issue
Block a user