Building Tools: Advanced Patterns
Most projects only need Building Tools withFunctionTool or BaseTool. This page covers advanced patterns for when you need lower-level control: context-aware tools, raw SimpleTool subclasses, custom descriptors, the ToolRegistry, and deferred tool providers.
Class Hierarchy
The tool class hierarchy is designed so each layer adds exactly one concern. You extend only the level you need:| Class | What it adds | When to use |
|---|---|---|
SimpleTool | Descriptor + result wrapper + $this->arg() | Full manual control, no state or schema magic |
ReflectiveSchemaTool | Auto-generates toToolSchema() from __invoke() | Rarely used directly; base for FunctionTool |
FunctionTool | Wraps a callable with cached reflective schema | Typed callable tools (most common) |
StateAwareTool | withAgentState() / $this->agentState | Read current execution state without schema support |
BaseTool | State + reflective schema + metadata/instructions defaults | State-aware class tools (most common class-based approach) |
ContextAwareTool | State + withToolCall() / $this->toolCall | Tools that need the raw ToolCall for correlation or tracing |
Traits Under the Hood
Each layer in the hierarchy is composed from focused traits. Understanding these traits helps when you need to implementToolInterface directly rather than extending one of the base classes:
| Trait | Provides | Used by |
|---|---|---|
HasDescriptor | Delegates name(), description(), metadata(), instructions() to a CanDescribeTool instance | SimpleTool |
HasResultWrapper | Implements use() by calling __invoke() in a try/catch, wrapping results in Result::success() or Result::failure() | SimpleTool |
HasArgs | Provides $this->arg($args, $name, $position, $default) for named/positional parameter extraction | SimpleTool |
HasAgentState | Provides $this->agentState and withAgentState() (immutable clone + inject) | StateAwareTool |
HasToolCall | Provides $this->toolCall and withToolCall() (immutable clone + inject) | ContextAwareTool |
HasReflectiveSchema | Provides toToolSchema() and paramsJsonSchema() via CallableSchemaFactory reflection on __invoke | ReflectiveSchemaTool, BaseTool |
ContextAwareTool
ContextAwareTool extends StateAwareTool and adds access to the raw ToolCall object via $this->toolCall. This gives your tool the call ID, the tool name as the LLM specified it, and the raw arguments. It is particularly useful for tools that need to correlate their output with specific invocations — for example, auditing tools, subagent spawners, or tools that emit events with tracing metadata.
The framework injects both the AgentState and the ToolCall before each invocation via immutable cloning. You do not need to manage this yourself.
Key Differences from BaseTool
There are two important differences to keep in mind when choosingContextAwareTool over BaseTool:
-
No reflective schema.
ContextAwareTooldoes not include theHasReflectiveSchematrait, so you must always implementtoToolSchema()yourself. -
Constructor signature. The constructor takes a
CanDescribeToolinstance (typically aToolDescriptor) rather than plainnameanddescriptionstrings. This gives you full control over metadata and instructions from the start.
When to Use ContextAwareTool
UseContextAwareTool when your tool needs any of the following:
- The
ToolCallID for log correlation or distributed tracing. - The raw arguments as the LLM specified them, before any processing.
- The tool name as it appears in the LLM’s request (which may differ from the registered name in edge cases).
- Both state and tool call context in the same tool.
BaseTool. If you need neither state nor tool call context, prefer FunctionTool or SimpleTool.
SimpleTool
SimpleTool is the root abstract class in the tool hierarchy. It provides only the essentials: a descriptor for identity, a result wrapper that catches exceptions and returns Result objects, and the $this->arg() helper. Everything else — schema, state access, tool call access — is your responsibility.
Use SimpleTool when you want complete control over a tool’s behavior and do not need agent state or reflective schema generation.
The Result Wrapper
SimpleTool (via the HasResultWrapper trait) implements ToolInterface::use() by calling your __invoke() method inside a try/catch block. The behavior is straightforward:
- If
__invoke()returns normally, the value is wrapped inResult::success(). - If
__invoke()throws any exception, the exception is wrapped inResult::failure()and the error message is sent back to the LLM. - The one exception that is never caught is
AgentStopException. Throwing this from within a tool immediately halts the agent loop with the providedStopSignal.
__invoke() as a normal method that throws on error, and the framework will handle it gracefully:
Stopping the Agent Loop From a Tool
If your tool detects a condition that should stop the entire agent, throw anAgentStopException with a StopSignal:
StateAwareTool
StateAwareTool sits between SimpleTool and BaseTool in the hierarchy. It adds CanAccessAgentState support (via the HasAgentState trait) but does not include reflective schema generation or default metadata/instructions.
Use StateAwareTool directly when you need agent state access but want full manual control over everything else. In practice, most developers use BaseTool instead, which adds schema and metadata defaults on top of StateAwareTool.
ReflectiveSchemaTool
ReflectiveSchemaTool extends SimpleTool and adds automatic toToolSchema() generation from the __invoke() method signature via the HasReflectiveSchema trait. It is the base class for FunctionTool and is rarely extended directly.
The reflective schema uses CallableSchemaFactory to introspect the __invoke method at runtime and generates a JSON Schema from the parameter types and #[Description] attributes. The result is cached after the first call to paramsJsonSchema().
If you are building a class-based tool and want reflective schema without state access, extend ReflectiveSchemaTool. However, because __invoke must use the mixed ...$args signature, the generated schema will not be useful for production — making this class primarily an internal building block.
Descriptors as Separate Classes
When a tool’s documentation is extensive — detailed usage instructions, parameter descriptions, error codes, examples — it can overwhelm the tool’s runtime logic. In these cases, extract the documentation into a dedicated descriptor class that extendsToolDescriptor.
The ToolDescriptor Class
ToolDescriptor is a readonly value object that implements CanDescribeTool. Its constructor accepts four arguments:
metadata and instructions arrays are merged with default values at read time:
metadata()merges with['name' => ..., 'summary' => ...]instructions()merges with['name' => ..., 'description' => ..., 'parameters' => [], 'returns' => 'mixed']
Subclassing ToolDescriptor
For tools with extensive documentation, create a dedicated descriptor subclass:How Metadata and Instructions Differ
The two documentation levels serve different audiences:metadata() returns lightweight information suitable for listing or browsing: name, summary, namespace, and tags. It is designed for the “list” action of a tool registry where an agent needs to scan many tools quickly without consuming context.
instructions() returns the full specification: name, description, parameters, return type, errors, examples, and notes. It is designed for the “help” action where an agent needs the complete documentation for a specific tool before using it.
BaseTool provides default implementations that extract a summary from the description (first sentence or first line, truncated to 80 characters) and a namespace from dotted tool names (e.g., file.read yields namespace file).
ToolRegistry
TheToolRegistry is a mutable container that implements CanManageTools. Unlike the immutable Tools collection (which is a value object for passing tools around), ToolRegistry supports lazy instantiation through factories and is designed for managing large numbers of tools at runtime.
Registering Tools
Querying the Registry
get() on a factory-registered tool, the factory is invoked once and the resulting instance is cached for subsequent calls. This makes ToolRegistry suitable for tools that are expensive to construct or that depend on runtime context.
If a tool is not found, get() throws an InvalidToolException.
ToolsTool: Agent-Facing Tool Discovery
TheToolsTool is a built-in tool that exposes the ToolRegistry to the LLM, letting agents discover and browse available tools at runtime. It supports three actions:
| Action | Parameters | Description |
|---|---|---|
list | limit (optional) | Returns metadata() for all registered tools |
help | tool (required) | Returns full instructions() for a specific tool by name |
search | query (required), limit (optional) | Searches tool names, descriptions, summaries, namespaces, and tags by keyword |
ToolsTool to discover relevant tools, then calls them by name.
Deferred Tool Providers
Some tools cannot be constructed until the agent loop is being assembled, because they depend on the tool-use driver, the event dispatcher, or the current set of already-registered tools. Deferred tool providers solve this by delaying tool construction until build time.The CanProvideDeferredTools Interface
Implement this interface to provide tools that are resolved lazily during the AgentBuilder::build() process:
DeferredToolContext gives providers access to three things:
| Method | Returns | Purpose |
|---|---|---|
tools() | Tools | The current tool collection as it exists at resolution time |
toolUseDriver() | CanUseTools | The driver for making nested LLM calls (needed by subagent tools) |
events() | CanHandleEvents | The event dispatcher for emitting events |
The UseToolFactory Capability
For simple cases where you just need a factory closure rather than a full class, the UseToolFactory capability wraps a callable as a deferred provider:
DeferredToolContext provides. The returned ToolInterface is wrapped in a Tools collection and merged into the agent’s tool set.
Schema Strategy Matrix
| Class | Default schema source | Recommendation |
|---|---|---|
FunctionTool | Callable reflection via fromCallable() | Usually no override needed |
BaseTool | Reflection of __invoke(mixed ...$args) | Override toToolSchema() for explicit parameters |
ContextAwareTool | None (no HasReflectiveSchema) | Must implement toToolSchema() |
StateAwareTool | None (no HasReflectiveSchema) | Must implement toToolSchema() |
SimpleTool | None (no HasReflectiveSchema) | Must implement toToolSchema() |
ReflectiveSchemaTool | Reflection of __invoke() | Usually no override needed (but see caveat) |
BaseTool inherits reflective schema support via the HasReflectiveSchema trait, but because __invoke must use mixed ...$args, the auto-generated schema describes a single variadic parameter. This is rarely useful for production prompts. Always override toToolSchema() in BaseTool subclasses.
Building Schema Manually
All manual schemas use theToolSchema and JsonSchema helpers:
Parameter Extraction with $this->arg()
The arg() method (from the HasArgs trait) resolves a parameter from the arguments array using a three-step lookup:
- Named key — checks
$args[$name](the typical case when the LLM passes an associative array) - Positional index — checks
$args[$position](useful for direct invocation in tests) - Default value — falls back to
$default
Implementing ToolInterface Directly
If none of the base classes fit your needs, you can implementToolInterface directly. You must provide three methods:
CanAccessAgentState and/or CanAccessToolCall. The framework checks for these interfaces during tool preparation and calls the appropriate with*() methods.
Building a Complete Tool: Real-World Example
Here is a condensed view of how a production tool is structured, demonstrating theSimpleTool pattern with a separate descriptor, manual schema, and $this->arg():
Related
- Tools — overview, registration, contracts, and execution lifecycle
- Building Tools — quick path with
FunctionToolandBaseTool