Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f2008ca
Refactor ToolDefinition architecture to use subclass pattern for all …
openhands-agent Oct 30, 2025
38625a6
Merge ToolDefinition into ToolBase and fix all tests
openhands-agent Oct 30, 2025
eb569b9
Fix example file to use ToolBase instead of ToolDefinition
openhands-agent Oct 31, 2025
5de1644
Fix polymorphic tool serialization with explicit type helper
openhands-agent Oct 31, 2025
097b50d
Merge branch 'main' into openhands/simplify-tool-definition-architecture
simonrosenberg Nov 1, 2025
085395c
Merge branch 'main' into openhands/simplify-tool-definition-architecture
xingyaoww Nov 3, 2025
75a7484
Merge branch 'main' into openhands/simplify-tool-definition-architecture
xingyaoww Nov 3, 2025
0f17f17
Prevent direct ToolBase instantiation and enforce subclass pattern
openhands-agent Nov 3, 2025
43b074c
Merge ToolBase and ToolDefinition into single ToolDefinition class
openhands-agent Nov 3, 2025
414a03f
Merge branch 'main' into openhands/simplify-tool-definition-architecture
xingyaoww Nov 3, 2025
1894612
revert
xingyaoww Nov 3, 2025
874b2db
revert polymorphic hacks
xingyaoww Nov 3, 2025
95a7a1f
revert hacks
xingyaoww Nov 3, 2025
b99401f
Remove backward compatibility from browser_use tools
openhands-agent Nov 3, 2025
8290cda
Refactor browser actions to use inheritance hierarchy instead of unio…
openhands-agent Nov 3, 2025
3bbfe6d
Remove backward compatibility singleton comments
openhands-agent Nov 3, 2025
3ef13a4
clean up definitions
xingyaoww Nov 3, 2025
d3667ab
fix
xingyaoww Nov 3, 2025
3d362b7
try fix linter
xingyaoww Nov 3, 2025
1d3e6b4
revert test
xingyaoww Nov 3, 2025
0febb54
Merge branch 'main' into openhands/simplify-tool-definition-architecture
xingyaoww Nov 3, 2025
4acf30d
Fix tests for tool definition refactoring
openhands-agent Nov 3, 2025
3522c71
Merge main into openhands/simplify-tool-definition-architecture
openhands-agent Nov 3, 2025
6172da0
Fix FinishTool.name reference after refactoring
openhands-agent Nov 3, 2025
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
53 changes: 42 additions & 11 deletions examples/01_standalone_sdk/02_custom_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@
)
from openhands.tools.execute_bash import (
BashExecutor,
BashTool,
ExecuteBashAction,
execute_bash_tool,
)
from openhands.tools.file_editor import FileEditorTool

Expand Down Expand Up @@ -115,6 +115,42 @@ def __call__(self, action: GrepAction, conversation=None) -> GrepObservation: #
* When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead
""" # noqa: E501


# --- Tool Definition ---


class GrepTool(ToolDefinition[GrepAction, GrepObservation]):
"""A custom grep tool that searches file contents using regular expressions."""

@classmethod
def create(
cls, conv_state, bash_executor: BashExecutor | None = None
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
cls, conv_state, bash_executor: BashExecutor | None = None
cls, conversation_state: ConversationState, bash_executor: BashExecutor | None = None

NIT: could we add some typing and standardize to using conversation instead of convo

not blocking a separate PR making extraneous changes would be ideal

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

sounds good! @OpenHands please create a separate PR and add conv_state: ConversationState to the .create of ToolDefinition (base class) and fix all downstream usage of it and get tests to pass as well

Copy link

Choose a reason for hiding this comment

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

I'm on it! xingyaoww can track my progress at all-hands.dev

Copy link

Choose a reason for hiding this comment

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

Summary

I've successfully completed the request to add conv_state: ConversationState parameter to ToolDefinition.create() and update all downstream usage in a separate PR.

✅ Completed Tasks

1. Created Separate Branch & PR

2. Updated Base Class

  • Modified ToolDefinition.create() signature to: create(cls, conv_state: ConversationState) -> Sequence[Self]

3. Updated All Tool Subclasses (4 tool types)

  • FinishTool
  • ThinkTool
  • MCPToolDefinition
  • BrowserUseTool (8 browser tool classes: Navigate, Click, Type, Scroll, GetUrl, GoBack, ExtractContent, Done)

4. Updated All Call Sites

  • openhands/sdk/agent/base.py: Tool initialization in agent
  • openhands/sdk/mcp/utils.py: create_mcp_tools() helper function
  • All 10 test files across SDK, MCP, and browser tests

5. Test Results

  • 1061 SDK tests pass (excluding browser and e2e)
  • 47 MCP tests pass (all previously failing tests fixed)
  • All pre-commit hooks pass (ruff format, ruff lint, pycodestyle, pyright)

Changes Overview

18 files modified:

  • 6 source files (tool definitions, agent, MCP utils)
  • 12 test files (comprehensive test coverage updates)
  • Net change: +233 lines, -106 lines

All changes are focused and directly related to adding the conv_state parameter - no extraneous modifications.

Next Steps

The PR is ready for review at: #1008

View full conversation

) -> Sequence[ToolDefinition]:
"""Create GrepTool instance with a GrepExecutor.

Args:
conv_state: Conversation state to get working directory from.
bash_executor: Optional bash executor to reuse. If not provided,
a new one will be created.

Returns:
A sequence containing a single GrepTool instance.
"""
if bash_executor is None:
bash_executor = BashExecutor(working_dir=conv_state.workspace.working_dir)
grep_executor = GrepExecutor(bash_executor)

return [
cls(
name="grep",
description=_GREP_DESCRIPTION,
action_type=GrepAction,
observation_type=GrepObservation,
executor=grep_executor,
)
]


# Configure LLM
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
Expand All @@ -135,16 +171,11 @@ def _make_bash_and_grep_tools(conv_state) -> list[ToolDefinition]:
"""Create execute_bash and custom grep tools sharing one executor."""

bash_executor = BashExecutor(working_dir=conv_state.workspace.working_dir)
bash_tool = execute_bash_tool.set_executor(executor=bash_executor)

grep_executor = GrepExecutor(bash_executor)
grep_tool = ToolDefinition(
name="grep",
description=_GREP_DESCRIPTION,
action_type=GrepAction,
observation_type=GrepObservation,
executor=grep_executor,
)
# bash_tool = execute_bash_tool.set_executor(executor=bash_executor)
bash_tool = BashTool.create(conv_state, executor=bash_executor)[0]
Copy link
Collaborator

@malhotra5 malhotra5 Nov 3, 2025

Choose a reason for hiding this comment

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

Hmm I guess getting having to pull the tool out of the list is kinda annoying - any thoughts on how we can make it more convenient?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

:( i think we probably don't have a good way to do this since we want to support the idea of "ToolSet"

Copy link
Collaborator

Choose a reason for hiding this comment

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

Sorry, I don't understand 😢 Doesn't one method create the Tool, while a ToolSet puts together a sequence of Tools? 🤔

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yeah irrc ToolSet (which returns a list of tools) was a subclass of ToolDefinition, so base class will need return a list instead one thing -- hence we need this "[0]" pattern


# Use the GrepTool.create() method with shared bash_executor
grep_tool = GrepTool.create(conv_state, bash_executor=bash_executor)[0]

return [bash_tool, grep_tool]

Expand Down
2 changes: 0 additions & 2 deletions openhands-sdk/openhands/sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@
Action,
Observation,
Tool,
ToolBase,
ToolDefinition,
list_registered_tools,
register_tool,
Expand Down Expand Up @@ -67,7 +66,6 @@
"RedactedThinkingBlock",
"Tool",
"ToolDefinition",
"ToolBase",
"AgentBase",
"Agent",
"Action",
Expand Down
3 changes: 1 addition & 2 deletions openhands-sdk/openhands/sdk/agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
from openhands.sdk.tool import (
Action,
FinishTool,
Observation,
)
from openhands.sdk.tool.builtins import FinishAction, ThinkAction
Expand Down Expand Up @@ -431,6 +430,6 @@ def _execute_action_event(
on_event(obs_event)

# Set conversation state
if tool.name == FinishTool.name:
if tool.name == "finish":
state.agent_status = AgentExecutionStatus.FINISHED
return obs_event
4 changes: 3 additions & 1 deletion openhands-sdk/openhands/sdk/agent/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,9 @@ def _initialize(self, state: "ConversationState"):
)

# Always include built-in tools; not subject to filtering
tools.extend(BUILT_IN_TOOLS)
# Instantiate built-in tools using their .create() method
for tool_class in BUILT_IN_TOOLS:
tools.extend(tool_class.create(state))

# Check tool types
for tool in tools:
Expand Down
6 changes: 3 additions & 3 deletions openhands-sdk/openhands/sdk/llm/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@


if TYPE_CHECKING: # type hints only, avoid runtime import cycle
from openhands.sdk.tool.tool import ToolBase
from openhands.sdk.tool.tool import ToolDefinition

from openhands.sdk.utils.pydantic_diff import pretty_pydantic_diff

Expand Down Expand Up @@ -425,7 +425,7 @@ def restore_metrics(self, metrics: Metrics) -> None:
def completion(
self,
messages: list[Message],
tools: Sequence[ToolBase] | None = None,
tools: Sequence[ToolDefinition] | None = None,
_return_metrics: bool = False,
add_security_risk_prediction: bool = False,
**kwargs,
Expand Down Expand Up @@ -562,7 +562,7 @@ def _one_attempt(**retry_kwargs) -> ModelResponse:
def responses(
self,
messages: list[Message],
tools: Sequence[ToolBase] | None = None,
tools: Sequence[ToolDefinition] | None = None,
include: list[str] | None = None,
store: bool | None = None,
_return_metrics: bool = False,
Expand Down
4 changes: 2 additions & 2 deletions openhands-sdk/openhands/sdk/llm/router/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from openhands.sdk.llm.llm_response import LLMResponse
from openhands.sdk.llm.message import Message
from openhands.sdk.logger import get_logger
from openhands.sdk.tool.tool import ToolBase
from openhands.sdk.tool.tool import ToolDefinition


logger = get_logger(__name__)
Expand Down Expand Up @@ -49,7 +49,7 @@ def validate_llms_not_empty(cls, v):
def completion(
self,
messages: list[Message],
tools: Sequence[ToolBase] | None = None,
tools: Sequence[ToolDefinition] | None = None,
return_metrics: bool = False,
add_security_risk_prediction: bool = False,
**kwargs,
Expand Down
6 changes: 3 additions & 3 deletions openhands-sdk/openhands/sdk/mcp/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from openhands.sdk.logger import get_logger
from openhands.sdk.mcp import MCPClient, MCPToolDefinition
from openhands.sdk.tool.tool import ToolBase
from openhands.sdk.tool.tool import ToolDefinition


logger = get_logger(__name__)
Expand All @@ -30,9 +30,9 @@ async def log_handler(message: LogMessage):
logger.log(level, msg, extra=extra)


async def _list_tools(client: MCPClient) -> list[ToolBase]:
async def _list_tools(client: MCPClient) -> list[ToolDefinition]:
"""List tools from an MCP client."""
tools: list[ToolBase] = []
tools: list[ToolDefinition] = []

async with client:
assert client.is_connected(), "MCP client is not connected."
Expand Down
2 changes: 0 additions & 2 deletions openhands-sdk/openhands/sdk/tool/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
from openhands.sdk.tool.tool import (
ExecutableTool,
ToolAnnotations,
ToolBase,
ToolDefinition,
ToolExecutor,
)
Expand All @@ -23,7 +22,6 @@
__all__ = [
"Tool",
"ToolDefinition",
"ToolBase",
"ToolAnnotations",
"ToolExecutor",
"ExecutableTool",
Expand Down
56 changes: 41 additions & 15 deletions openhands-sdk/openhands/sdk/tool/builtins/finish.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from collections.abc import Sequence
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Self

from pydantic import Field
from rich.text import Text
Expand All @@ -16,6 +16,7 @@

if TYPE_CHECKING:
from openhands.sdk.conversation.base import BaseConversation
from openhands.sdk.conversation.state import ConversationState


class FinishAction(Action):
Expand Down Expand Up @@ -67,17 +68,42 @@ def __call__(
return FinishObservation(message=action.message)


FinishTool = ToolDefinition(
name="finish",
action_type=FinishAction,
observation_type=FinishObservation,
description=TOOL_DESCRIPTION,
executor=FinishExecutor(),
annotations=ToolAnnotations(
title="finish",
readOnlyHint=True,
destructiveHint=False,
idempotentHint=True,
openWorldHint=False,
),
)
class FinishTool(ToolDefinition[FinishAction, FinishObservation]):
"""Tool for signaling the completion of a task or conversation."""

@classmethod
def create(
cls,
conv_state: "ConversationState | None" = None, # noqa: ARG003
**params,
) -> Sequence[Self]:
"""Create FinishTool instance.

Args:
conv_state: Optional conversation state (not used by FinishTool).
**params: Additional parameters (none supported).

Returns:
A sequence containing a single FinishTool instance.

Raises:
ValueError: If any parameters are provided.
"""
if params:
raise ValueError("FinishTool doesn't accept parameters")
return [
cls(
name="finish",
action_type=FinishAction,
observation_type=FinishObservation,
description=TOOL_DESCRIPTION,
executor=FinishExecutor(),
annotations=ToolAnnotations(
title="finish",
readOnlyHint=True,
destructiveHint=False,
idempotentHint=True,
openWorldHint=False,
),
)
]
54 changes: 40 additions & 14 deletions openhands-sdk/openhands/sdk/tool/builtins/think.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from collections.abc import Sequence
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Self

from pydantic import Field
from rich.text import Text
Expand All @@ -16,6 +16,7 @@

if TYPE_CHECKING:
from openhands.sdk.conversation.base import BaseConversation
from openhands.sdk.conversation.state import ConversationState


class ThinkAction(Action):
Expand Down Expand Up @@ -83,16 +84,41 @@ def __call__(
return ThinkObservation()


ThinkTool = ToolDefinition(
name="think",
description=THINK_DESCRIPTION,
action_type=ThinkAction,
observation_type=ThinkObservation,
executor=ThinkExecutor(),
annotations=ToolAnnotations(
readOnlyHint=True,
destructiveHint=False,
idempotentHint=True,
openWorldHint=False,
),
)
class ThinkTool(ToolDefinition[ThinkAction, ThinkObservation]):
"""Tool for logging thoughts without making changes."""

@classmethod
def create(
cls,
conv_state: "ConversationState | None" = None, # noqa: ARG003
**params,
) -> Sequence[Self]:
"""Create ThinkTool instance.

Args:
conv_state: Optional conversation state (not used by ThinkTool).
**params: Additional parameters (none supported).

Returns:
A sequence containing a single ThinkTool instance.

Raises:
ValueError: If any parameters are provided.
"""
if params:
raise ValueError("ThinkTool doesn't accept parameters")
return [
cls(
name="think",
description=THINK_DESCRIPTION,
action_type=ThinkAction,
observation_type=ThinkObservation,
executor=ThinkExecutor(),
annotations=ToolAnnotations(
readOnlyHint=True,
destructiveHint=False,
idempotentHint=True,
openWorldHint=False,
),
)
]
10 changes: 6 additions & 4 deletions openhands-sdk/openhands/sdk/tool/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from typing import TYPE_CHECKING, Any

from openhands.sdk.tool.spec import Tool
from openhands.sdk.tool.tool import ToolBase, ToolDefinition
from openhands.sdk.tool.tool import ToolDefinition


if TYPE_CHECKING:
Expand Down Expand Up @@ -85,7 +85,7 @@ def _is_abstract_method(cls: type, name: str) -> bool:
return getattr(attr, "__isabstractmethod__", False)


def _resolver_from_subclass(_name: str, cls: type[ToolBase]) -> Resolver:
def _resolver_from_subclass(_name: str, cls: type[ToolDefinition]) -> Resolver:
create = getattr(cls, "create", None)

if create is None or not callable(create) or _is_abstract_method(cls, "create"):
Expand Down Expand Up @@ -115,14 +115,16 @@ def _resolve(

def register_tool(
name: str,
factory: ToolDefinition | type[ToolBase] | Callable[..., Sequence[ToolDefinition]],
factory: ToolDefinition
| type[ToolDefinition]
| Callable[..., Sequence[ToolDefinition]],
) -> None:
if not isinstance(name, str) or not name.strip():
raise ValueError("ToolDefinition name must be a non-empty string")

if isinstance(factory, ToolDefinition):
resolver = _resolver_from_instance(name, factory)
elif isinstance(factory, type) and issubclass(factory, ToolBase):
elif isinstance(factory, type) and issubclass(factory, ToolDefinition):
resolver = _resolver_from_subclass(name, factory)
elif callable(factory):
resolver = _resolver_from_callable(name, factory)
Expand Down
Loading
Loading