Compare commits

..

21 Commits

Author SHA1 Message Date
Engel Nyst
d08886f30e Fix non-function calls messages (#5026)
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2024-11-21 18:18:49 +00:00
Cheng Yang
68e52a9c62 feat: add return type hints to LLM class methods (#5173) 2024-11-21 14:00:46 +01:00
Cheng Yang
7e38297732 fix: correct relative links in agenthub README.md (#5170) 2024-11-21 06:39:32 -05:00
Graham Neubig
12ed523c01 docs: Add note about organizational token policies (#5161)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-11-21 04:07:21 +00:00
OpenHands
ebce77ab56 Fix issue #5155: [Resolver] Could we get a .md of tips for the .openhands_instructions file? (#5163)
Co-authored-by: Graham Neubig <neubig@gmail.com>
2024-11-20 23:03:22 -05:00
Rohit Malhotra
f4a2df859f [Bug][Resolver] Enable caching for reusable workflow (#5165) 2024-11-21 03:46:08 +00:00
Robert Brennan
94a8f58ece fix up logging in listen.py (#5145) 2024-11-20 22:42:13 -05:00
young010101
746722e1b5 style: remove extra newline in LLM wrapper function (#5149) 2024-11-20 22:41:51 -05:00
Robert Brennan
27f136b802 mitigate memory leak (#5152) 2024-11-20 22:40:30 -05:00
OpenHands
e211152f93 Fix issue #5159: [Bug]: lint-fix workflow terminates prematurely due to exit code 1 (#5160) 2024-11-21 02:36:47 +00:00
Graham Neubig
07b96cc8c9 docs: Add documentation on how to add new tools to codeact_agent (#5150)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-11-20 20:19:51 +00:00
young010101
3a65b7b07d docs: add missing toml_file parameter description in get_llm_config_a… (#5147) 2024-11-20 21:06:02 +01:00
young010101
5c83698524 Docs/fix logging param name (#5146) 2024-11-20 20:07:06 +01:00
Robert Brennan
cde7ce49be fix up lockup when long actions are run (#5144) 2024-11-20 15:42:02 +00:00
dependabot[bot]
24a83eb52d Bump the docusaurus group in /docs with 7 updates (#5140)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-20 14:48:40 +00:00
Rohit Malhotra
2a78b3323b Adding experimental option for resolver macro (#5131) 2024-11-19 17:42:49 -05:00
Robert Brennan
a3977621ed Add /health endpoint to server (#5136)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-11-19 17:40:20 -05:00
Robert Brennan
018080aae0 fix rate limiting (#5135) 2024-11-19 22:01:07 +00:00
mamoodi
302e41d7bb Release 0.14.1 (#5133) 2024-11-19 14:53:24 -05:00
Robert Brennan
3c61a9521b Simple initial rate limiting implementation (#4976) 2024-11-19 13:46:14 -05:00
Robert Brennan
c9ed9b166b handle exceptions more explicitly (#4971) 2024-11-19 13:46:03 -05:00
27 changed files with 3570 additions and 660 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
},

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

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

View File

@@ -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.

View File

@@ -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.
![image](https://github.com/All-Hands-AI/OpenHands/assets/38853559/92b622e3-72ad-4a61-8f41-8c040b6d5fb3)
## 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)

View File

@@ -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):

View File

@@ -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()

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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]

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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.')

View File

@@ -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:

View File

@@ -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',

View File

@@ -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)

View File

@@ -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(

View File

@@ -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'

View File

@@ -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:

View File

@@ -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"