"""This file contains the function calling implementation for different actions. This is similar to the functionality of `CodeActResponseParser`. """ import json import shlex from litellm import ( ChatCompletionToolParam, ModelResponse, ) from openhands.agenthub.codeact_agent.function_calling import ( combine_thought, ) from openhands.agenthub.codeact_agent.tools import ( FinishTool, ThinkTool, ) from openhands.agenthub.readonly_agent.tools import ( GlobTool, GrepTool, ViewTool, ) from openhands.core.exceptions import ( FunctionCallNotExistsError, FunctionCallValidationError, ) from openhands.core.logger import openhands_logger as logger from openhands.events.action import ( Action, AgentFinishAction, AgentThinkAction, CmdRunAction, FileReadAction, MCPAction, MessageAction, ) from openhands.events.event import FileReadSource from openhands.events.tool import ToolCallMetadata def grep_to_cmdrun( pattern: str, path: str | None = None, include: str | None = None ) -> str: # NOTE: This function currently relies on `rg` (ripgrep). # `rg` may not be installed when using CLIRuntime or LocalRuntime. # TODO: Implement a fallback to `grep` if `rg` is not available. """Convert grep tool arguments to a shell command string. Args: pattern: The regex pattern to search for in file contents path: The directory to search in (optional) include: Optional file pattern to filter which files to search (e.g., "*.js") Returns: A properly escaped shell command string for ripgrep """ # Use shlex.quote to properly escape all shell special characters quoted_pattern = shlex.quote(pattern) path_arg = shlex.quote(path) if path else '.' # Build ripgrep command rg_cmd = f'rg -li {quoted_pattern} --sortr=modified' if include: quoted_include = shlex.quote(include) rg_cmd += f' --glob {quoted_include}' # Build the complete command complete_cmd = f'{rg_cmd} {path_arg} | head -n 100' # Add a header to the output echo_cmd = f'echo "Below are the execution results of the search command: {complete_cmd}\n"; ' return echo_cmd + complete_cmd def glob_to_cmdrun(pattern: str, path: str = '.') -> str: # NOTE: This function currently relies on `rg` (ripgrep). # `rg` may not be installed when using CLIRuntime or LocalRuntime # TODO: Implement a fallback to `find` if `rg` is not available. """Convert glob tool arguments to a shell command string. Args: pattern: The glob pattern to match files (e.g., "**/*.js") path: The directory to search in (defaults to current directory) Returns: A properly escaped shell command string for ripgrep implementing glob """ # Use shlex.quote to properly escape all shell special characters quoted_path = shlex.quote(path) quoted_pattern = shlex.quote(pattern) # Use ripgrep in a glob-only mode with -g flag and --files to list files # This most closely matches the behavior of the NodeJS glob implementation rg_cmd = f'rg --files {quoted_path} -g {quoted_pattern} --sortr=modified' # Sort results and limit to 100 entries (matching the Node.js implementation) sort_and_limit_cmd = ' | head -n 100' complete_cmd = f'{rg_cmd}{sort_and_limit_cmd}' # Add a header to the output echo_cmd = f'echo "Below are the execution results of the glob command: {complete_cmd}\n"; ' return echo_cmd + complete_cmd def response_to_actions( response: ModelResponse, mcp_tool_names: list[str] | None = None ) -> list[Action]: actions: list[Action] = [] assert len(response.choices) == 1, 'Only one choice is supported for now' choice = response.choices[0] assistant_msg = choice.message if hasattr(assistant_msg, 'tool_calls') and assistant_msg.tool_calls: # Check if there's assistant_msg.content. If so, add it to the thought thought = '' if isinstance(assistant_msg.content, str): thought = assistant_msg.content elif isinstance(assistant_msg.content, list): for msg in assistant_msg.content: if msg['type'] == 'text': thought += msg['text'] # Process each tool call to OpenHands action for i, tool_call in enumerate(assistant_msg.tool_calls): action: Action logger.debug(f'Tool call in function_calling.py: {tool_call}') try: arguments = json.loads(tool_call.function.arguments) except json.decoder.JSONDecodeError as e: raise FunctionCallValidationError( f'Failed to parse tool call arguments: {tool_call.function.arguments}' ) from e # ================================================ # AgentFinishAction # ================================================ if tool_call.function.name == FinishTool['function']['name']: action = AgentFinishAction( final_thought=arguments.get('message', ''), task_completed=arguments.get('task_completed', None), ) # ================================================ # ViewTool (ACI-based file viewer, READ-ONLY) # ================================================ elif tool_call.function.name == ViewTool['function']['name']: if 'path' not in arguments: raise FunctionCallValidationError( f'Missing required argument "path" in tool call {tool_call.function.name}' ) action = FileReadAction( path=arguments['path'], impl_source=FileReadSource.OH_ACI, view_range=arguments.get('view_range', None), ) # ================================================ # AgentThinkAction # ================================================ elif tool_call.function.name == ThinkTool['function']['name']: action = AgentThinkAction(thought=arguments.get('thought', '')) # ================================================ # GrepTool (file content search) # ================================================ elif tool_call.function.name == GrepTool['function']['name']: if 'pattern' not in arguments: raise FunctionCallValidationError( f'Missing required argument "pattern" in tool call {tool_call.function.name}' ) pattern = arguments['pattern'] path = arguments.get('path') include = arguments.get('include') grep_cmd = grep_to_cmdrun(pattern, path, include) action = CmdRunAction(command=grep_cmd, is_input=False) # ================================================ # GlobTool (file pattern matching) # ================================================ elif tool_call.function.name == GlobTool['function']['name']: if 'pattern' not in arguments: raise FunctionCallValidationError( f'Missing required argument "pattern" in tool call {tool_call.function.name}' ) pattern = arguments['pattern'] path = arguments.get('path', '.') glob_cmd = glob_to_cmdrun(pattern, path) action = CmdRunAction(command=glob_cmd, is_input=False) # ================================================ # MCPAction (MCP) # ================================================ elif mcp_tool_names and tool_call.function.name in mcp_tool_names: action = MCPAction( name=tool_call.function.name, arguments=arguments, ) else: raise FunctionCallNotExistsError( f'Tool {tool_call.function.name} is not registered. (arguments: {arguments}). Please check the tool name and retry with an existing tool.' ) # We only add thought to the first action if i == 0: action = combine_thought(action, thought) # Add metadata for tool calling action.tool_call_metadata = ToolCallMetadata( tool_call_id=tool_call.id, function_name=tool_call.function.name, model_response=response, total_calls_in_response=len(assistant_msg.tool_calls), ) actions.append(action) else: actions.append( MessageAction( content=str(assistant_msg.content) if assistant_msg.content else '', wait_for_response=True, ) ) # Add response id to actions # This will ensure we can match both actions without tool calls (e.g. MessageAction) # and actions with tool calls (e.g. CmdRunAction, IPythonRunCellAction, etc.) # with the token usage data for action in actions: action.response_id = response.id assert len(actions) >= 1 return actions def get_tools() -> list[ChatCompletionToolParam]: return [ ThinkTool, FinishTool, GrepTool, GlobTool, ViewTool, ]