Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion openhands-sdk/openhands/sdk/agent/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,13 +175,21 @@ def fix_malformed_tool_arguments(
"command": "terminal",
"execute": "terminal",
"execute_bash": "terminal",
"git": "terminal",
"reset": "terminal",
"str_replace": "file_editor",
"str_replace_editor": "file_editor",
}

# Terminal aliases that prepend the tool name to the command argument.
# Unlike 'bash' which passes through the command directly, these tools
# (e.g., 'git', 'reset') are themselves commands that should be combined
# with their arguments (e.g., 'git status', 'reset clear').
_TERMINAL_COMMAND_PREFIX_ALIASES = frozenset({"git", "reset"})

# This fallback is intentionally tiny: it only accepts exact, bare command names
# that are useful as read-only defaults when some models emit them as tool names.
_SHELL_TOOL_FALLBACK_COMMANDS = frozenset({"find", "ls", "pwd"})
_SHELL_TOOL_FALLBACK_COMMANDS = frozenset({"find", "git", "ls", "pwd"})

# Typo normalization for common mistakes in security_risk field
_SECURITY_RISK_TYPOS = {"security_rort", "securtiy_risk", "security_riks"}
Expand Down Expand Up @@ -306,6 +314,21 @@ def normalize_tool_call(
alias_target = TOOL_NAME_ALIASES.get(tool_name)
if alias_target and alias_target in available_tools:
normalized_tool_name = alias_target
# For terminal alias with prefix, combine tool name with command
if (
alias_target == "terminal"
and tool_name in _TERMINAL_COMMAND_PREFIX_ALIASES
):
original_command = arguments.get("command")
normalized_arguments = {
key: value
for key, value in arguments.items()
if key in {"security_risk", "summary"}
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: The argument filtering to only {security_risk, summary} is intentional and correct - prevents weird combinations when handling hallucinated tool names. However, verify this works correctly with trinity-large-thinking in practice.

The logic constructs git status from tool_name='git' + command='status', which is the right approach for these prefix aliases.

if original_command:
normalized_arguments["command"] = f"{tool_name} {original_command}"
else:
normalized_arguments["command"] = tool_name
elif "terminal" in available_tools:
terminal_command = _maybe_rewrite_as_terminal_command(
tool_name,
Expand Down
56 changes: 56 additions & 0 deletions tests/sdk/agent/test_tool_call_compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -470,3 +470,59 @@ def test_explicitly_registered_tool_not_hijacked_by_alias():
"str_replace", {"old_str": "x", "new_str": "y"}, available_tools
)
assert tool_name == "file_editor", "str_replace alias should map to file_editor"


def test_git_alias_executes_terminal_tool(tmp_path):
"""Test that 'git' tool name is aliased to 'terminal'."""
events = _run_tool_call(
tmp_path,
tool_name="git",
arguments={"command": "status"},
tool_names=(TERMINAL_TOOL_SPEC,),
)

action_event = next(e for e in events if isinstance(e, ActionEvent))
errors = [e for e in events if isinstance(e, AgentErrorEvent)]

assert not errors
assert action_event.tool_name == TERMINAL_TOOL_NAME
assert action_event.tool_call.name == TERMINAL_TOOL_NAME
assert action_event.action is not None
assert getattr(action_event.action, "command") == "git status"


def test_reset_alias_executes_terminal_tool(tmp_path):
"""Test that 'reset' tool name is aliased to 'terminal'."""
events = _run_tool_call(
tmp_path,
tool_name="reset",
arguments={"command": "clear"},
tool_names=(TERMINAL_TOOL_SPEC,),
)

action_event = next(e for e in events if isinstance(e, ActionEvent))
errors = [e for e in events if isinstance(e, AgentErrorEvent)]

assert not errors
assert action_event.tool_name == TERMINAL_TOOL_NAME
assert action_event.tool_call.name == TERMINAL_TOOL_NAME
assert action_event.action is not None
assert getattr(action_event.action, "command") == "reset clear"


def test_shell_tool_name_git_falls_back_to_terminal(tmp_path):
"""Test that 'git' without arguments falls back to terminal."""
events = _run_tool_call(
tmp_path,
tool_name="git",
arguments={},
tool_names=(TERMINAL_TOOL_SPEC,),
)

action_event = next(e for e in events if isinstance(e, ActionEvent))
errors = [e for e in events if isinstance(e, AgentErrorEvent)]

assert not errors
assert action_event.tool_name == TERMINAL_TOOL_NAME
assert action_event.action is not None
assert getattr(action_event.action, "command") == "git"
Loading