NAME

Langertha::Role::Tools - Role for MCP tool calling support

VERSION

version 0.503

SYNOPSIS

use IO::Async::Loop;
use Future::AsyncAwait;

my $loop = IO::Async::Loop->new;

# Set up any Net::Async::MCP-compatible client (langertha-raider uses
# Net::Async::MCP directly)
my $mcp = SomeMCPClient->new(server => $my_mcp_server);
$loop->add($mcp);
await $mcp->initialize;

# Create engine with MCP servers (native tool calling)
my $engine = Langertha::Engine::Anthropic->new(
    api_key     => $ENV{ANTHROPIC_API_KEY},
    model       => 'claude-sonnet-4-6',
    mcp_servers => [$mcp],
);

my $response = await $engine->chat_with_tools_f(
    'Use the available tools to answer my question'
);

# Hermes tool calling (for APIs without native tool support)
my $engine = Langertha::Engine::AKI->new(
    api_key     => $ENV{AKI_API_KEY},
    mcp_servers => [$mcp],
);

DESCRIPTION

This role adds MCP (Model Context Protocol) tool calling support to Langertha engines. It provides the "chat_with_tools_f" method which implements the full async tool-calling loop:

1. Gather available tools from all configured MCP servers
2. Send a chat request with tool definitions to the LLM
3. If the LLM returns tool calls, execute them via MCP
4. Feed tool results back to the LLM and repeat
5. When the LLM returns final text, return it

All tool wire-translation is tag-driven: an engine declares its dialect via "tool_wire_format" (openai | anthropic | gemini | ollama | responses | hermes) and the default implementations of format_tools, response_tool_calls, extract_tool_call, format_tool_results, and response_text_content delegate to the Langertha::Tool, Langertha::ToolCall, and Langertha::ToolResult value objects keyed by that tag. Engines carry no per-format tool code. The hermes dialect injects tools into the system prompt and parses <tool_call> XML; its tag names and prompt template come from Langertha::Role::HermesTools.

mcp_servers

mcp_servers => [$mcp1, $mcp2]

ArrayRef of MCP client objects to use as tool providers — any Net::Async::MCP-compatible client (for example a Net::Async::MCP client as used by the langertha-raider distribution). Each entry must respond to list_tools and call_tool. Defaults to an empty ArrayRef. At least one server must be configured before calling "chat_with_tools_f".

tool_max_iterations

tool_max_iterations => 20

Maximum number of tool-calling round trips before aborting with an error. Defaults to 10. Increase for complex multi-step tool workflows.

tool_wire_format

tool_wire_format => 'anthropic'

The single per-engine enum naming which tool dialect this engine speaks — openai | anthropic | gemini | ollama | responses | hermes. This one tag drives all tool wire-translation: the outbound tool definitions (Langertha::Tool), the inbound tool calls (Langertha::ToolCall), the result blocks (Langertha::ToolResult), the final-text extraction, and the outbound transport (native API parameter vs. Hermes prompt injection).

The default follows the engine base-class hierarchy: OpenAIBase leaves it at openai, AnthropicBase overrides to anthropic, and so on — so the ~25 concrete engines inherit it and carry no tool-format code of their own. Override _build_tool_wire_format to change it.

build_tool_chat_request

my $request = $self->build_tool_chat_request($conversation, $formatted_tools);

Builds an HTTP request for a tool-calling chat turn. For native wire formats the tools are passed as an API parameter via chat_request; for the hermes format they are injected into the system prompt as XML.

format_tools

my $tools = $engine->format_tools($mcp_tools);

Converts an ArrayRef of MCP tool definitions to the wire tools payload for this engine's "tool_wire_format" via "format_list" in Langertha::Tool.

response_tool_calls

my $tool_calls = $engine->response_tool_calls($raw_data);

Returns the ArrayRef of raw tool-call structures in $raw_data: the calls "tool_loop_response" puts on "tool_calls" in Langertha::Response for the same body, in the same order, as this engine's format spells them. Located via "locate" in Langertha::ToolCall; a structure that parses to no call (no name) is left out. For hermes, each call is { name, arguments }: the <tool_call> XML tags parsed out of the model's text (with think_tag_filter on, a call inside the thinking is not returned), or the native calls the engine's parser found. May be empty. Calls cut off by the token limit are still returned; "tool_loop_calls" drops them.

extract_tool_call

my ($name, $args) = $engine->extract_tool_call($tool_call);

Extracts the tool name and decoded argument HashRef from a single raw tool-call structure, via "from_fmt" in Langertha::ToolCall.

response_text_content

my $text = $engine->response_text_content($raw_data);

Returns the assistant's final text from a decoded response body (what parse_response returns): the content of the Langertha::Response the engine's chat_response builds from it, the text "chat_f" in Langertha::Role::Chat and the tool loops answer. Gemini thought parts and Anthropic thinking blocks stay out, a content-chunk list (Mistral) becomes its text, <think> tags are filtered when think_tag_filter is on, and for hermes the <tool_call> tags are stripped.

A body chat_response rejects (an error in the body, a shape it cannot read) does not croak here: the text is read per "tool_wire_format" straight off the body, or ''. The engine's "rate_limit" in Langertha::Engine::Remote is left as it was.

format_tool_results

my @messages = $engine->format_tool_results($raw_data, $results);

Assembles tool execution results into the provider-shaped message envelope for the next turn: the assistant echo of the prior turn plus one Langertha::ToolResult block per result (arity varies by format).

For the openai and ollama dialects the echo also carries the provider's reasoning back when the turn had it — reasoning_content, reasoning, reasoning_details and thinking respectively — because DeepSeek rejects a tool loop whose earlier assistant turn lost it.

Always returns a LIST, for every tool_wire_format — the callers append it straight onto the conversation with push @$conversation, $engine->format_tool_results(...), so a single arrayref would land as one bogus conversation element.

Each result's tool_call may be a Langertha::ToolCall (what the tool loops pass) or the raw structure "response_tool_calls" located.

An image in a tool's output reaches the model as an image, not as a text placeholder, on the responses wire (input_image parts in function_call_output.output), on Gemini 3 (functionResponse.parts) and on the anthropic wire (an image block in the tool_result), when supports('image_input') is true for the configured model; see "DESCRIPTION" in Langertha::ToolResult. The responses and Gemini forms are documentation-derived, not live-verified, and so is the anthropic form on the /anthropic shims (Kimi documents it, MiniMax does not say). Langertha::Engine::AKIAnthropic keeps the placeholder for every model: its shim answers a tool_result image without error, but the model does not see it.

A PDF in a tool's output (an embedded resource blob, application/pdf) reaches the model as a file, not as a text placeholder, on Langertha::Engine::OpenAIResponses (an input_file part in function_call_output.output) and on Gemini 3 (functionResponse.parts), when supports('image_input') is true for the configured model: both providers document PDF understanding as a vision feature. Both forms are documentation-derived, not live-verified. Langertha::Engine::Perplexity keeps the placeholder (its Agent API documents only text and image parts there), as do the OpenAI chat, Ollama and Hermes wires and Gemini before 3.

On the anthropic wire an embedded text resource or PDF goes out as a document block, except on Langertha::Engine::AKIAnthropic, Langertha::Engine::MoonshotAnthropic and (conservatively, not live-verified) Langertha::Engine::LMStudioAnthropic, whose /anthropic endpoints take no document or search_result in a tool_result: there the text goes out as a text block and a PDF as a placeholder. The PDF document block elsewhere does not depend on image_input (k371). An Anthropic-native document or search_result block in a tool's output becomes a text block with its text there too, and the engine warns once.

tool_loop_response

my $reply = $engine->tool_loop_response($http_response);
my $reply = $engine->tool_loop_response($data);   # decoded body

Reads one tool-loop turn's reply the way "chat_with_tools_f" and the Langertha::Chat tool loops do, and returns the Langertha::Response. Takes the HTTP::Response of the turn or the body parse_response decoded from it (the engine's "rate_limit" in Langertha::Engine::Remote is only updated from an HTTP::Response). Meant for sibling distributions that run their own tool loop, such as langertha-raider, so they read replies as core does.

The reply is parsed by the engine's chat_response, the parser "chat_f" in Langertha::Role::Chat uses: an error in a 200 body croaks with chat_f's text, and the content is chat_f's final text. The calls to run are "tool_calls" in Langertha::Response; on hermes engines the <tool_call> blocks of the text are lifted there and stripped from content, unless the engine's parser already did. A prompt the provider refused outright (Gemini's promptFeedback.blockReason) croaks with <engine class> prompt blocked: REASON. raw is the wire body the assistant echo ("format_tool_results") is built from. To leave out calls cut off by the token limit, pass the reply to "tool_loop_calls".

tool_loop_calls

my ( $calls, $data ) = $engine->tool_loop_calls( $reply, $data );

The calls one tool-loop turn runs, and the wire body to build its assistant echo ("format_tool_results") from, for a $reply from "tool_loop_response". $data defaults to $reply->raw. Public since k341 for sibling distributions that run their own tool loop, such as langertha-raider.

$calls is an ArrayRef of Langertha::ToolCall. When the reply hit its token limit (finish_reason length, max_tokens, MAX_TOKENS or incomplete), a call whose arguments do not decode ("arguments_undecodable" in Langertha::ToolCall) is dropped: with no call left this croaks tool call arguments truncated (finish_reason REASON); raise response_size, otherwise one warning names the dropped calls and the returned $data is a copy without them, so the next turn has no call without a result. Otherwise the reply's calls and $data come back unchanged.

chat_with_tools_f

my $response = await $engine->chat_with_tools_f(@messages);

Async tool-calling chat loop. Accepts the same message arguments as "simple_chat" in Langertha::Role::Chat. Gathers tools from all "mcp_servers", sends the request, executes any tool calls returned by the LLM, and repeats until the LLM returns a final text response or "tool_max_iterations" is exceeded. Returns a Future that resolves to the final text response.

Each reply is read by the engine's chat_response, the parser "chat_f" in Langertha::Role::Chat uses: a response whose body reports an error fails with the text chat_f croaks, the calls run are the reply's "tool_calls" in Langertha::Response, and the final text is its content. A prompt the provider refuses outright (Gemini's promptFeedback.blockReason) dies with prompt blocked: REASON, where chat_f returns the Response.

A reply that hit its token limit (finish_reason length, max_tokens, MAX_TOKENS or incomplete) never runs a call whose arguments do not decode ("arguments_undecodable" in Langertha::ToolCall): if no other call is left the loop dies with tool call arguments truncated, otherwise the complete calls run, one warning names the dropped ones, and the dropped calls are left out of the conversation.

A call to a tool no server offers does not stop the loop: it is answered with an error result unknown tool NAME, and the other calls of the turn still run. A tool name offered by two servers is sent once and runs on the first server in "mcp_servers", with a warning naming both.

A call whose arguments do not decode on a reply that did not hit its token limit is not run either: it is answered with an error result arguments are not valid JSON: REASON ("arguments_error" in Langertha::ToolCall), and the loop continues so the model can retry.

SEE ALSO

SUPPORT

Issues

Please report bugs and feature requests on GitHub at https://github.com/Getty/langertha/issues.

IRC

Join #langertha on irc.perl.org or message Getty directly.

CONTRIBUTING

Contributions are welcome! Please fork the repository and submit a pull request.

AUTHOR

Torsten Raudssus <getty@cpan.org>

COPYRIGHT AND LICENSE

This software is copyright (c) 2026 by Torsten Raudssus https://raudssus.de/.

This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.