mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
3 Commits
openhands/
...
pr-10443
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aea92f3869 | ||
|
|
cee88aff48 | ||
|
|
0c2283abcc |
@@ -10,7 +10,7 @@ if TYPE_CHECKING:
|
||||
from openhands.llm.llm import ModelResponse
|
||||
|
||||
import openhands.agenthub.codeact_agent.function_calling as codeact_function_calling
|
||||
from openhands.agenthub.codeact_agent.tools.bash import create_cmd_run_tool
|
||||
from openhands.agenthub.codeact_agent.tools.bash import CmdRunTool
|
||||
from openhands.agenthub.codeact_agent.tools.browser import BrowserTool
|
||||
from openhands.agenthub.codeact_agent.tools.condensation_request import (
|
||||
CondensationRequestTool,
|
||||
@@ -125,7 +125,9 @@ class CodeActAgent(Agent):
|
||||
|
||||
tools = []
|
||||
if self.config.enable_cmd:
|
||||
tools.append(create_cmd_run_tool(use_short_description=use_short_tool_desc))
|
||||
tools.append(
|
||||
CmdRunTool(use_short_description=use_short_tool_desc).to_param()
|
||||
)
|
||||
if self.config.enable_think:
|
||||
tools.append(ThinkTool)
|
||||
if self.config.enable_finish:
|
||||
|
||||
@@ -16,9 +16,10 @@ from openhands.agenthub.codeact_agent.tools import (
|
||||
IPythonTool,
|
||||
LLMBasedFileEditTool,
|
||||
ThinkTool,
|
||||
create_cmd_run_tool,
|
||||
create_str_replace_editor_tool,
|
||||
execute_bash,
|
||||
)
|
||||
from openhands.agenthub.codeact_agent.tools.bash import CmdRunTool
|
||||
from openhands.core.exceptions import (
|
||||
FunctionCallNotExistsError,
|
||||
FunctionCallValidationError,
|
||||
@@ -30,7 +31,6 @@ from openhands.events.action import (
|
||||
AgentFinishAction,
|
||||
AgentThinkAction,
|
||||
BrowseInteractiveAction,
|
||||
CmdRunAction,
|
||||
FileEditAction,
|
||||
FileReadAction,
|
||||
IPythonRunCellAction,
|
||||
@@ -43,6 +43,11 @@ from openhands.events.event import FileEditSource, FileReadSource
|
||||
from openhands.events.tool import ToolCallMetadata
|
||||
from openhands.llm.tool_names import TASK_TRACKER_TOOL_NAME
|
||||
|
||||
# Tool handlers registry for class-based tools
|
||||
_TOOL_HANDLERS = {
|
||||
execute_bash.name: CmdRunTool(),
|
||||
}
|
||||
|
||||
|
||||
def combine_thought(action: Action, thought: str) -> Action:
|
||||
if not hasattr(action, 'thought'):
|
||||
@@ -86,23 +91,8 @@ def response_to_actions(
|
||||
# CmdRunTool (Bash)
|
||||
# ================================================
|
||||
|
||||
if tool_call.function.name == create_cmd_run_tool()['function']['name']:
|
||||
if 'command' not in arguments:
|
||||
raise FunctionCallValidationError(
|
||||
f'Missing required argument "command" in tool call {tool_call.function.name}'
|
||||
)
|
||||
# convert is_input to boolean
|
||||
is_input = arguments.get('is_input', 'false') == 'true'
|
||||
action = CmdRunAction(command=arguments['command'], is_input=is_input)
|
||||
|
||||
# Set hard timeout if provided
|
||||
if 'timeout' in arguments:
|
||||
try:
|
||||
action.set_hard_timeout(float(arguments['timeout']))
|
||||
except ValueError as e:
|
||||
raise FunctionCallValidationError(
|
||||
f"Invalid float passed to 'timeout' argument: {arguments['timeout']}"
|
||||
) from e
|
||||
if tool_call.function.name == execute_bash.name:
|
||||
action = _TOOL_HANDLERS[execute_bash.name].to_action(arguments)
|
||||
|
||||
# ================================================
|
||||
# IPythonTool (Jupyter)
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
from .bash import create_cmd_run_tool
|
||||
from .bash import create_cmd_run_tool, execute_bash
|
||||
|
||||
# NOTE: This module currently exposes schema-only tools. As part of #10441 we are
|
||||
# gradually encapsulating tools as classes that own schema and validation. See
|
||||
# bash.CmdRunTool for the first example. Existing code remains backward
|
||||
# compatible by exporting ChatCompletionToolParam for now.
|
||||
from .browser import BrowserTool
|
||||
from .condensation_request import CondensationRequestTool
|
||||
from .finish import FinishTool
|
||||
@@ -11,6 +16,7 @@ __all__ = [
|
||||
'BrowserTool',
|
||||
'CondensationRequestTool',
|
||||
'create_cmd_run_tool',
|
||||
'execute_bash',
|
||||
'FinishTool',
|
||||
'IPythonTool',
|
||||
'LLMBasedFileEditTool',
|
||||
|
||||
43
openhands/agenthub/codeact_agent/tools/base.py
Normal file
43
openhands/agenthub/codeact_agent/tools/base.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from litellm import ChatCompletionToolParam
|
||||
|
||||
from openhands.core.exceptions import FunctionCallValidationError
|
||||
|
||||
|
||||
class Tool(ABC):
|
||||
"""Base class for CodeAct tools.
|
||||
|
||||
Subclasses should encapsulate schema, descriptions and validation.
|
||||
They must implement to_param() and to_action().
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def to_param(self) -> ChatCompletionToolParam:
|
||||
"""Return the ChatCompletionToolParam schema for this tool."""
|
||||
raise NotImplementedError
|
||||
|
||||
def parse_arguments(self, raw_arguments: str) -> dict[str, Any]:
|
||||
"""Parse the raw JSON string from the model into a dict.
|
||||
|
||||
Raises FunctionCallValidationError on failure.
|
||||
"""
|
||||
try:
|
||||
return json.loads(raw_arguments) if raw_arguments else {}
|
||||
except json.decoder.JSONDecodeError as e:
|
||||
raise FunctionCallValidationError(
|
||||
f'Failed to parse tool call arguments: {raw_arguments}'
|
||||
) from e
|
||||
|
||||
@abstractmethod
|
||||
def to_action(self, arguments: dict[str, Any]): # -> Action
|
||||
"""Convert validated arguments to an Action.
|
||||
|
||||
Implementations should raise FunctionCallValidationError for
|
||||
missing/invalid parameters.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
@@ -1,8 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from litellm import ChatCompletionToolParam, ChatCompletionToolParamFunctionChunk
|
||||
|
||||
from openhands.agenthub.codeact_agent.tools.base import Tool
|
||||
from openhands.agenthub.codeact_agent.tools.prompt import refine_prompt
|
||||
from openhands.core.exceptions import FunctionCallValidationError
|
||||
from openhands.events.action import CmdRunAction
|
||||
from openhands.llm.tool_names import EXECUTE_BASH_TOOL_NAME
|
||||
|
||||
|
||||
class _ToolRef:
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
|
||||
|
||||
# Lightweight reference so callers can compare against execute_bash.name
|
||||
execute_bash = _ToolRef(EXECUTE_BASH_TOOL_NAME)
|
||||
|
||||
_DETAILED_BASH_DESCRIPTION = """Execute a bash command in the terminal within a persistent shell session.
|
||||
|
||||
|
||||
@@ -28,6 +42,65 @@ _DETAILED_BASH_DESCRIPTION = """Execute a bash command in the terminal within a
|
||||
* Output truncation: If the output exceeds a maximum length, it will be truncated before being returned.
|
||||
"""
|
||||
|
||||
|
||||
class CmdRunTool(Tool):
|
||||
def __init__(self, use_short_description: bool = False) -> None:
|
||||
self.use_short_description = use_short_description
|
||||
|
||||
def to_param(self) -> ChatCompletionToolParam:
|
||||
description = (
|
||||
_SHORT_BASH_DESCRIPTION
|
||||
if self.use_short_description
|
||||
else _DETAILED_BASH_DESCRIPTION
|
||||
)
|
||||
return ChatCompletionToolParam(
|
||||
type='function',
|
||||
function=ChatCompletionToolParamFunctionChunk(
|
||||
name=EXECUTE_BASH_TOOL_NAME,
|
||||
description=refine_prompt(description),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'command': {
|
||||
'type': 'string',
|
||||
'description': refine_prompt(
|
||||
'The bash command to execute. Can be empty string to view additional logs when previous exit code is `-1`. Can be `C-c` (Ctrl+C) to interrupt the currently running process. Note: You can only execute one bash command at a time. If you need to run multiple commands sequentially, you can use `&&` or `;` to chain them together.'
|
||||
),
|
||||
},
|
||||
'is_input': {
|
||||
'type': 'string',
|
||||
'description': refine_prompt(
|
||||
'If True, the command is an input to the running process. If False, the command is a bash command to be executed in the terminal. Default is False.'
|
||||
),
|
||||
'enum': ['true', 'false'],
|
||||
},
|
||||
'timeout': {
|
||||
'type': 'number',
|
||||
'description': 'Optional. Sets a hard timeout in seconds for the command execution. If not provided, the command will use the default soft timeout behavior.',
|
||||
},
|
||||
},
|
||||
'required': ['command'],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
def to_action(self, arguments: dict[str, str]) -> CmdRunAction:
|
||||
if 'command' not in arguments:
|
||||
raise FunctionCallValidationError(
|
||||
'Missing required argument "command" in tool call execute_bash'
|
||||
)
|
||||
is_input = arguments.get('is_input', 'false') == 'true'
|
||||
action = CmdRunAction(command=arguments['command'], is_input=is_input)
|
||||
if 'timeout' in arguments:
|
||||
try:
|
||||
action.set_hard_timeout(float(arguments['timeout']))
|
||||
except ValueError as e:
|
||||
raise FunctionCallValidationError(
|
||||
f"Invalid float passed to 'timeout' argument: {arguments['timeout']}"
|
||||
) from e
|
||||
return action
|
||||
|
||||
|
||||
_SHORT_BASH_DESCRIPTION = """Execute a bash command in the terminal.
|
||||
* Long running commands: For commands that may run indefinitely, it should be run in the background and the output should be redirected to a file, e.g. command = `python3 app.py > server.log 2>&1 &`. For commands that need to run for a specific duration, you can set the "timeout" argument to specify a hard timeout in seconds.
|
||||
* Interact with running process: If a bash command returns exit code `-1`, this means the process is not yet finished. By setting `is_input` to `true`, the assistant can interact with the running process and send empty `command` to retrieve any additional logs, or send additional text (set `command` to the text) to STDIN of the running process, or send command like `C-c` (Ctrl+C), `C-d` (Ctrl+D), `C-z` (Ctrl+Z) to interrupt the process.
|
||||
|
||||
@@ -189,9 +189,13 @@ class BashSession:
|
||||
self.username = username
|
||||
self._initialized = False
|
||||
self.max_memory_mb = max_memory_mb
|
||||
# Ensure a safe default for cleanup even if initialization fails early
|
||||
self._closed: bool = True
|
||||
|
||||
def initialize(self) -> None:
|
||||
self.server = libtmux.Server()
|
||||
# Use a dedicated tmux socket name to avoid inheriting an existing TMUX session
|
||||
# which may be owned by a different user and cause permission issues.
|
||||
self.server = libtmux.Server(socket_name=f'openhands-{uuid.uuid4()}')
|
||||
_shell_command = '/bin/bash'
|
||||
if self.username in ['root', 'openhands']:
|
||||
# This starts a non-login (new) shell for the given user
|
||||
@@ -241,7 +245,7 @@ class BashSession:
|
||||
# Store the last command for interactive input handling
|
||||
self.prev_status: BashCommandStatus | None = None
|
||||
self.prev_output: str = ''
|
||||
self._closed: bool = False
|
||||
self._closed = False
|
||||
logger.debug(f'Bash session initialized with work dir: {self.work_dir}')
|
||||
|
||||
# Maintain the current working directory
|
||||
|
||||
Reference in New Issue
Block a user