Skip to main content

Building Tools: Advanced Patterns

Most projects only need Building Tools with FunctionTool 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:

Traits Under the Hood

Each layer in the hierarchy is composed from focused traits. Understanding these traits helps when you need to implement ToolInterface directly rather than extending one of the base classes:

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 choosing ContextAwareTool over BaseTool:
  1. No reflective schema. ContextAwareTool does not include the HasReflectiveSchema trait, so you must always implement toToolSchema() yourself.
  2. Constructor signature. The constructor takes a CanDescribeTool instance (typically a ToolDescriptor) rather than plain name and description strings. This gives you full control over metadata and instructions from the start.

When to Use ContextAwareTool

Use ContextAwareTool when your tool needs any of the following:
  • The ToolCall ID 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.
If you only need agent state, prefer 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 in Result::success().
  • If __invoke() throws any exception, the exception is wrapped in Result::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 provided StopSignal.
This means you can write __invoke() as a normal method that throws on error, and the framework will handle it gracefully:
The LLM receives the error message and can decide whether to retry with different arguments or take a different approach entirely.

Stopping the Agent Loop From a Tool

If your tool detects a condition that should stop the entire agent, throw an AgentStopException 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 extends ToolDescriptor.

The ToolDescriptor Class

ToolDescriptor is a readonly value object that implements CanDescribeTool. Its constructor accepts four arguments:
The 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']
This means you only need to specify the additional fields your tool requires.

Subclassing ToolDescriptor

For tools with extensive documentation, create a dedicated descriptor subclass:
Then pass the descriptor to your tool’s constructor:
This pattern keeps tool runtime logic clean and makes documentation reusable across tools that share the same descriptor structure.

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

The ToolRegistry 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

When you call 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

The ToolsTool 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: This pattern is useful when an agent has access to many tools but should not receive all their schemas upfront (which would consume context window space). Instead, the agent uses 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:
The DeferredToolContext gives providers access to three things:

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:
The factory callable receives the same three arguments that DeferredToolContext provides. The returned ToolInterface is wrapped in a Tools collection and merged into the agent’s tool set.

Schema Strategy Matrix

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 the ToolSchema and JsonSchema helpers:
The resulting array follows the OpenAI function-calling format:

Parameter Extraction with $this->arg()

The arg() method (from the HasArgs trait) resolves a parameter from the arguments array using a three-step lookup:
  1. Named key — checks $args[$name] (the typical case when the LLM passes an associative array)
  2. Positional index — checks $args[$position] (useful for direct invocation in tests)
  3. Default value — falls back to $default
Always cast the return value to the expected type, since the LLM may pass values as strings even for numeric parameters.

Implementing ToolInterface Directly

If none of the base classes fit your needs, you can implement ToolInterface directly. You must provide three methods:
If your custom tool needs state or tool call injection, also implement 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 the SimpleTool pattern with a separate descriptor, manual schema, and $this->arg():
This structure separates concerns cleanly: the descriptor owns documentation, the tool class owns behavior, and the schema is explicit.
  • Tools — overview, registration, contracts, and execution lifecycle
  • Building Tools — quick path with FunctionTool and BaseTool