Compare commits

...

3 Commits

Author SHA1 Message Date
Engel Nyst
aea92f3869 runtime(bash): use dedicated tmux socket to avoid inherited permission issues; initialize _closed safely
Create a fresh, uniquely named tmux socket for each BashSession via libtmux.Server(socket_name=...). This prevents connecting to a root-owned TMUX socket (e.g., /tmp/tmux-0/default) on CI, which caused interactive tests to misbehave.

Also set _closed early and reset on initialize to avoid __del__ AttributeError if initialization fails early.

Co-authored-by: OpenHands-GPT-5 openhands@all-hands.dev
2025-08-18 10:52:29 +00:00
enyst
cee88aff48 codeact: expose execute_bash.name for clearer comparisons
- Export a lightweight execute_bash object with a .name attribute
- Switch comparisons and handler map in CodeAct function_calling to use execute_bash.name

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-17 18:42:56 +00:00
enyst
0c2283abcc feat(codeact tools): encapsulate Bash tool as class; add Tool base; dispatch via handler (#10441)
- Add Tool base class with to_param(), parse_arguments(), to_action()
- Implement CmdRunTool with schema and validation
- Use CmdRunTool in agent tool list and response parser
- Keep create_cmd_run_tool for backward compat

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-17 17:52:05 +00:00
6 changed files with 142 additions and 24 deletions

View File

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

View File

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

View File

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

View 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

View File

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

View File

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