Skip to main content

Tool Calling Internals

Most users can skip this page. For day-to-day usage, start with Basic Agent, Tools, and AgentBuilder & Capabilities.
The agent’s ability to use tools is built on a clean separation of concerns: a driver decides which tools to call (by consulting the LLM), and an executor runs the actual tools. Two contracts define this boundary, and three driver implementations satisfy the first contract in different ways.

Architecture Overview

The AgentLoop owns both the driver and the executor. Before the first step, it binds the tool runtime to the driver via CanAcceptToolRuntime::withToolRuntime(), ensuring the driver has access to the same Tools collection and ToolExecutor that the loop manages. This binding happens once per execute() / iterate() call.

The Two Contracts

CanUseTools (Driver Contract)

The driver receives the current AgentState, consults the LLM (or a scripted scenario), and returns an updated state with a new AgentStep attached. The step may contain tool calls, a final response, or an error:
The driver is responsible for:
  • Compiling messages from state via CanCompileMessages
  • Sending the messages to the LLM with tool schemas
  • Parsing the LLM response for tool calls
  • Delegating tool execution to the ToolExecutor
  • Formatting execution results as follow-up messages
  • Building and attaching the AgentStep to the returned state

CanExecuteToolCalls (Executor Contract)

The executor receives a set of ToolCalls and the current AgentState, runs each tool, and returns the results:
The executor is responsible for:
  • Resolving tool instances from the Tools collection
  • Injecting context (agent state, tool call metadata) into tools that request it
  • Validating arguments against the tool schema
  • Running the tool and capturing the result
  • Handling errors, interception hooks, and events

ToolCallingDriver

ToolCallingDriver uses the LLM’s native function calling API. This is the default driver created by AgentLoop::default() and is the recommended choice for models that support function calling (GPT-4o, Claude, Gemini, etc.).

How It Works

Each invocation of useTools() follows this sequence:
  1. Compile messages. The message compiler (default: ConversationWithCurrentToolTrace) produces a Messages collection from the agent state. This compiler includes the full conversation history plus trace messages from the current execution only.
  2. Build the inference request. The driver assembles an InferenceRequest with the compiled messages, tool schemas from the Tools collection, the model name, tool choice strategy, and any cached context.
  3. Send to the LLM. The request is dispatched through the InferenceRuntime, which handles provider-specific API formatting, retries, and streaming.
  4. Parse tool calls. The InferenceResponse is inspected for toolCalls. If present, they are forwarded to the ToolExecutor.
  5. Execute tools. The ToolExecutor runs each tool call and returns ToolExecutions.
  6. Format results. The ToolExecutionFormatter converts each ToolExecution into a pair of messages: an assistant message with tool_calls metadata, and a tool role message with the execution result (or error).
  7. Build the step. An AgentStep is created with the input messages, output messages, inference response, and tool executions, then attached to the state via withCurrentStep().

Configuration

Note: You will need use Cognesy\Polyglot\Inference\Data\ToolChoice; for the ToolChoice value object.

Tool Choice Strategies

The toolChoice parameter accepts a ToolChoice value object:

Tool Args Leak Protection

Some LLM providers accidentally echo tool call arguments as the response content. The ToolCallingDriver detects this by parsing the content as JSON and comparing it against the tool call arguments. If they match, the content is silently discarded to prevent duplicate data in the conversation.

ReActDriver

ReActDriver implements the ReAct (Reasoning + Acting) pattern using structured output extraction. Instead of relying on native function calling, it prompts the LLM to output a JSON decision with explicit thought, type, tool, args, and answer fields.

How It Works

  1. Build system prompt. The MakeReActPrompt action generates a system prompt that describes the available tools and the expected ReAct JSON format.
  2. Extract decision. The StructuredOutputRuntime extracts a ReActDecision object from the LLM response. This uses the configured OutputMode (typically JSON) and includes retry logic for extraction failures.
  3. Validate decision. The ReActValidator checks that the decision has a valid type, references an existing tool, and includes valid arguments.
  4. Route by type.
    • If the decision type is call_tool: convert it to ToolCalls, execute via the ToolExecutor, and format the results as Thought/Action/Observation messages.
    • If the decision type is final_answer: extract the answer text and build a final response step.
  5. Optional final inference. When finalViaInference is true, the driver makes a separate LLM call to produce the final answer, using the full conversation as context. This can improve answer quality at the cost of an extra API call.

Configuration

Error Handling

The ReActDriver handles two categories of extraction failures:
  • Extraction failure. If the StructuredOutputRuntime cannot parse the LLM output into a ReActDecision, the driver builds a failure step with a decision_extraction pseudo-tool execution and marks the state as failed.
  • Validation failure. If the decision is extracted but fails validation (invalid type, unknown tool, missing arguments), the driver builds a failure step with a decision_validation pseudo-tool execution and marks the state as failed.
Both failure types emit dedicated events (DecisionExtractionFailed, ValidationFailed) for observability.

ToolExecutor

ToolExecutor is the default CanExecuteToolCalls implementation. It is created automatically by AgentLoop::default() and handles the complete lifecycle of executing a tool call, including interception hooks, event emission, and error handling.

Execution Pipeline

For each tool call in the ToolCalls collection, the executor runs this pipeline:

Tool Context Injection

Tools can opt into receiving execution context by implementing one or both of these interfaces: CanAccessAgentState — The tool receives a read-only copy of the current AgentState before invocation. This is useful for tools that need to inspect the conversation history, metadata, or execution status:
CanAccessToolCall — The tool receives the ToolCall object that triggered it. Useful for correlation and tracing, especially in subagent tools that emit their own events:

Configuration

Error Handling Modes

The throwOnToolFailure and stopOnToolBlock flags control how the executor responds to problems: When both flags are false (the default), the executor collects all results — successes, failures, and blocked executions — and returns them as a ToolExecutions collection. The driver then formats them as messages and includes them in the step output, allowing the LLM to see and react to the errors on the next iteration.

ToolExecution Result

Each tool execution produces a ToolExecution value object containing:
You can inspect the result using:

Message Formatting

After tool execution, the results must be formatted as messages that the LLM can understand on the next iteration. Each driver handles this differently:

ToolCallingDriver: Native Format

The ToolExecutionFormatter produces two messages per tool execution:
  1. Assistant message with tool_calls metadata — represents the LLM’s decision to call the tool.
  2. Tool message with the execution result — either the successful return value or an error description.
Both messages carry a tool_execution_id metadata tag for correlation.

ReActDriver: Observation Format

The ReActFormatter produces messages in the Thought/Action/Observation pattern:
  1. Assistant message containing the thought and action text from the ReActDecision.
  2. User message (observation) containing the tool execution result, formatted as Observation: <result>.

Events

Both drivers and the executor emit events at key lifecycle points. These can be observed via AgentLoop::wiretap() or AgentLoop::onEvent():

When to Use Which Driver

Custom Drivers

You can implement CanUseTools to create a custom driver. If your driver uses tools, also implement CanAcceptToolRuntime so the AgentLoop can inject the tool collection and executor:
The AgentLoop will call withToolRuntime() before the first step, passing the same Tools and ToolExecutor it manages internally.