-
Notifications
You must be signed in to change notification settings - Fork 38
Description
Problem Statement
The current tool system has multiple overlapping concepts (Tool, ToolDefinition, ToolBase) that make the architecture confusing and inconsistent:
-
Inconsistent tool definition patterns: Tools are defined in two different ways:
- Direct instances:
FinishTool = ToolDefinition(...)with executor already initialized - Subclasses:
class BashTool(ToolDefinition): ...with custom.create()method
- Direct instances:
-
Ambiguous
.create()contract:- Direct instance tools (like
FinishTool,ThinkTool) have a.create()method that raisesNotImplementedError - Subclass tools (like
BashTool,FileEditorTool) implement.create()to initialize the executor - This makes the contract unclear and forces defensive programming
- Direct instance tools (like
-
Confusion about naming:
Tool(spec.py): Serializable spec withnameandparamsToolDefinition: Concrete tool that can be executedToolBase: Abstract base class for tools- The relationship and purpose of each is not immediately clear
Current Architecture
# spec.py - Serializable spec passed to Agent
class Tool(BaseModel):
name: str
params: dict[str, Any]
# tool.py - Abstract base
class ToolBase[ActionT, ObservationT](ABC):
name: str
description: str
action_type: type[Action]
observation_type: type[Observation] | None
executor: ToolExecutor | None # Optional!
@abstractmethod
def create(cls, *args, **kwargs) -> Sequence[Self]:
...
# tool.py - Concrete implementation
class ToolDefinition[ActionT, ObservationT](ToolBase):
@classmethod
def create(cls, *args, **kwargs) -> Sequence[Self]:
raise NotImplementedError(...) # Hacky!Usage Pattern 1 (Direct Instance):
# builtins/finish.py
FinishTool = ToolDefinition(
name="finish",
action_type=FinishAction,
observation_type=FinishObservation,
description=TOOL_DESCRIPTION,
executor=FinishExecutor(), # Already initialized
)Usage Pattern 2 (Subclass):
# tools/execute_bash/definition.py
class BashTool(ToolDefinition[ExecuteBashAction, ExecuteBashObservation]):
@classmethod
def create(cls, conv_state, **params) -> Sequence["BashTool"]:
executor = BashExecutor(working_dir=conv_state.workspace.working_dir, ...)
return [cls(
name="execute_bash",
action_type=ExecuteBashAction,
observation_type=ExecuteBashObservation,
description=TOOL_DESCRIPTION,
executor=executor, # Initialized in create()
)]Constraints to Satisfy
-
Serializable and lazy: Tools passed to
Agentclass must be serializable (for remote conversation) and lazy (should not materialize until agent is initialized)- Currently satisfied by
Tool(spec) withnameandparams
- Currently satisfied by
-
Two-level architecture: We need at least two things:
- A "spec" version of the tool that's serializable (
Tool) - Actual tool instance that can be executed (
ToolDefinition)
- A "spec" version of the tool that's serializable (
-
Flexible executor initialization: Ability to decide whether to provide tool executor during tool initialization (e.g., for sharing executors among multiple tool instances)
Proposed Solution
Make all tools follow the subclass pattern with a clear contract:
- All tools should be subclasses of
ToolDefinition, not direct instances - Make
.create()an@abstractmethodinToolBaseso the contract is explicit - Each tool class implements
.create()which returns instances with executor populated - For simple built-in tools, create minimal subclasses instead of direct instances
Example Refactoring
Before (Direct Instance):
FinishTool = ToolDefinition(
name="finish",
action_type=FinishAction,
observation_type=FinishObservation,
description=TOOL_DESCRIPTION,
executor=FinishExecutor(),
)After (Subclass):
class FinishTool(ToolDefinition[FinishAction, FinishObservation]):
@classmethod
def create(cls, conv_state=None, **params) -> Sequence["FinishTool"]:
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(...),
)]Benefits
- Predictable pattern: All tools follow the same subclass pattern
- Clear contract:
.create()is@abstractmethod, making the interface explicit - Consistent naming: Tool classes have reliable names (e.g.,
BashTool.name) - Better for registration: Registry can reference tools by class name without instantiating
- Type safety: Eliminates the
NotImplementedErrorhack - Maintains flexibility: Executor can still be initialized in
.create()with custom parameters
Alternative Considered
Option B: Lazy initialization with futures/proxies
Figure out some hacks for ToolDefinition.create() to return a serializable "future" that materializes on demand:
# Hypothetical proxy pattern
class ToolProxy(BaseModel):
_tool_class: type[ToolDefinition]
_params: dict[str, Any]
def materialize(self, conv_state) -> ToolDefinition:
return self._tool_class.create(conv_state, **self._params)[0]Why we prefer Option A (subclass pattern):
- Simpler and more straightforward
- No magic or proxy objects
- Existing registry pattern already handles lazy initialization
- Better aligns with Python idioms
Implementation Plan
- Refactor built-in tools (
FinishTool,ThinkTool) to be subclasses - Make
ToolBase.create()an@abstractmethod - Remove the
NotImplementedErrorinToolDefinition.create() - Update tests to verify all tools follow the pattern
- Update documentation to clarify the architecture
Open Questions
- Should we rename
ToolDefinitionto something clearer (e.g.,ToolClass,ToolFactory)? - Should we consider making
executornon-optional inToolDefinition(with a separate "ToolSpec" for serialization)? - How do we handle backward compatibility for users who may have created direct
ToolDefinitioninstances?
Related
- Original discussion: Implement automatic tool registration on import #862
- TODO comment in code: Improve tools implementation #493