view release on metacpan or search on metacpan
.claude/skills/perl-ai-langertha/SKILL.md view on Meta::CPAN
<raider>
## Raider â Autonomous Agent
```perl
use Langertha::Raider;
my $raider = Langertha::Raider->new(
engine => $engine, # With MCP servers
mission => 'You are a code reviewer.',
max_iterations => 10, # Max tool rounds per raid
# Optional:
max_context_tokens => 4000,
context_compress_threshold => 0.75,
compression_engine => $cheap_model,
raider_mcp => 1, # Enable self-tools (ask_user, pause, abort)
plugins => ['Langfuse'],
);
# Raid (autonomous tool-calling loop)
my $result = await $raider->raid_f('Review lib/App.pm');
.claude/skills/perl-ai-langertha/SKILL.md view on Meta::CPAN
if ($result->is_question) {
my $next = await $raider->respond_f('Yes, go ahead.');
}
# History management
$raider->add_history('user', $content); # Replay from DB
$raider->clear_history; # Reset
# Metrics
my $m = $raider->metrics;
say "Iterations: $m->{iterations}";
say "Tool calls: $m->{tool_calls}";
```
### Raid Loop (simplified)
1. Auto-compress history if context threshold exceeded
2. Gather tools from MCP servers + inline tools + self-tools
3. Build conversation: mission + history + new messages
4. Call LLM with tools
5. If tool calls: execute via MCP, add results to conversation, loop
6. If no tool calls: extract final text, persist to history, return result
7. Max iterations safety limit
</raider>
<plugins>
## Plugin System
```perl
package Langertha::Plugin::MyGuardrails;
use Langertha qw( Plugin );
async sub plugin_before_tool_call {
## Raider (Autonomous Agent)
`Langertha::Raider` wraps an engine with conversation history, MCP tools, and a multi-turn tool-calling loop.
### Key Features
- **Conversation history** persisted across raids (only user + final assistant messages)
- **Session history** â full archive including tool calls (never compressed)
- **Auto-compression** â summarizes history when token threshold exceeded
- **Metrics** â tracks raids, iterations, tool calls, timing
- **Langfuse integration** â traces, spans, generations per raid
- **Hermes tool calling** â for models without native tool support
- **Mid-raid injection** â `inject()` and `on_iteration` callback
- **Self-tools** (virtual) â `raider_mcp => 1` enables agent-controlled tools:
- `raider_ask_user` â ask user questions (sync callback or async pause)
- `raider_pause` â pause execution for later resumption
- `raider_abort` â abort the raid
- `raider_wait` â wait N seconds
- `raider_wait_for` â wait for external condition
- `raider_session_history` â query/search session history
clear_history and reset. Queryable by the LLM via MCP tool
registered with register_session_history_tool().
- Add MiniMax to live tool calling test (t/80_live_tool_calling.t)
and live raider test (t/82_live_raider.t)
- Add t/83_live_minimax.t: dedicated MiniMax live test covering
simple_chat, list_models, and Raider with Coding Plan web search
- Add Raider inject() method for mid-raid context injection â
queue messages from async callbacks, timers, or other tasks
that get picked up at the next iteration naturally
- Add Raider on_iteration callback â called before each LLM call
(iterations 2+) with ($raider, $iteration), returns messages
to inject. Injected messages are persisted in history.
- Add Langertha::Engine::MiniMax for MiniMax AI API
(chat, streaming, tool calling via OpenAI-compatible API)
- Rewrite all POD to inline style across all modules â
=attr directly after has, =method directly after sub.
Add POD to all previously undocumented modules.
- Improve =seealso cross-links: remove redundant main module
links, add meaningful related module references
0.200 2026-02-22 21:53:36Z
existing code treating responses as strings continues to work.
- All chat_response methods now return Langertha::Response objects:
- Role::OpenAICompatible: extracts id, model, created, finish_reason, usage
- Engine::Anthropic: extracts id, model, stop_reason, input/output_tokens
- Engine::Gemini: extracts modelVersion, finishReason, usageMetadata
(normalized to prompt_tokens/completion_tokens/total_tokens)
- Engine::Ollama: extracts model, done_reason, eval counts, timing fields
- Engine::AKI: extracts model_name, total_duration
- Add Langertha::Raider: autonomous agent with conversation history and
MCP tool calling. Features mission (system prompt), persistent history
across raids, cumulative metrics (raids, iterations, tool_calls, time_ms),
clear_history and reset methods. Supports Hermes tool calling.
Auto-instruments raids with Langfuse traces and per-iteration
generation events when Langfuse is enabled on the engine.
- Add Langertha::Role::Langfuse: observability integration with Langfuse
REST API. Composed into Role::Chat â every engine has Langfuse support
built in. Auto-instruments simple_chat with trace and generation events.
Batched ingestion via POST /api/public/ingestion with Basic Auth.
Disabled by default â active when langfuse_public_key and
langfuse_secret_key are set (via constructor or LANGFUSE_PUBLIC_KEY /
LANGFUSE_SECRET_KEY / LANGFUSE_URL env vars).
- Rewrite all POD to inline style across all 37 modules â
=attr directly after has, =method directly after sub.
Add POD to 18 previously undocumented modules.
0.100 2026-02-20 05:33:44Z
- Add MCP (Model Context Protocol) tool calling support
- New Langertha::Role::Tools for engine-agnostic tool calling
- Anthropic engine: full tool calling support (format_tools,
response_tool_calls, format_tool_results, response_text_content)
- Async chat_with_tools_f() method for automatic multi-round
tool-calling loop with configurable max iterations
- Requires Net::Async::MCP for MCP server communication
- Add Future::AsyncAwait support for async/await syntax
- All _f methods (simple_chat_f, simple_chat_stream_f, etc.)
- Streaming with real-time async callbacks
- Add streaming support
- Synchronous callback, iterator, and Future-based APIs
- SSE parsing for OpenAI/Anthropic/Groq/Mistral/DeepSeek
- NDJSON parsing for Ollama
- Add Gemini engine (Google AI Studio)
- Add dynamic model listing via provider APIs with caching
ex/raider_rag.pl view on Meta::CPAN
async sub main {
my $raider = Langertha::Raider->new(
engine => $engine,
raider_mcp => 1,
mission => 'You are a helpful Perl programming assistant. Be concise.',
);
my $r1 = await $raider->raid_f('What is Moose in Perl? One paragraph.');
printf "Answer:\n%s\n\n", $r1;
printf "Metrics: raids=%d, iterations=%d, tool_calls=%d, time=%.0fms\n",
$raider->metrics->{raids},
$raider->metrics->{iterations},
$raider->metrics->{tool_calls},
$raider->metrics->{time_ms};
}
main()->get;
ex/raider_run.pl view on Meta::CPAN
# ââ 8. State tracking for change detection ââ
my $prev_engine = 'default';
my %prev_active_mcps;
# ââ 9. Raider ââ
my $raider = Langertha::Raider->new(
engine => $sonnet,
max_iterations => 25,
engine_catalog => {
fast => { engine => $haiku, description => 'Fast model for quick tasks' },
smart => { engine => $sonnet, description => 'Smart model for analysis' },
},
mcp_catalog => {
shell => { server => $shell_mcp, description => 'Shell command execution (run tool)' },
notes => { server => $notes_mcp, description => 'Notebook for saving findings' },
},
raider_mcp => 1,
mission => join("\n",
ex/raider_run.pl view on Meta::CPAN
info("[RESULT] pause: ${\$result->content}");
} elsif ($result->is_abort) {
info("[RESULT] abort: ${\$result->content}");
}
# ââ 12. Metrics ââ
banner("METRICS");
my $m = $raider->metrics;
info(sprintf "Raids: %d", $m->{raids});
info(sprintf "Iterations: %d", $m->{iterations});
info(sprintf "Tool calls: %d", $m->{tool_calls});
info(sprintf "Total time: %.1f s", $m->{time_ms} / 1000);
# ââ 13. Notebook ââ
if (@notebook) {
banner("NOTEBOOK (saved by LLM via notes MCP)");
for my $note (@notebook) {
info("[$note->{title}] $note->{text}");
}
lib/Langertha/Chat.pm view on Meta::CPAN
isa => 'Num',
predicate => 'has_temperature',
);
has mcp_servers => (
is => 'ro',
isa => 'ArrayRef',
default => sub { [] },
);
has tool_max_iterations => (
is => 'ro',
isa => 'Int',
default => 10,
);
sub _extra {
my ( $self ) = @_;
return (
($self->has_model ? (model => $self->model) : ()),
lib/Langertha/Chat.pm view on Meta::CPAN
return ($conversation, $data);
}
sub simple_chat_with_tools {
my ( $self, @messages ) = @_;
my $engine = $self->_assert_chat_engine;
croak ref($engine) . " does not support tools"
unless $engine->does('Langertha::Role::Tools');
my ($all_tools, $tool_server_map) = $self->_gather_tools;
$log->debugf("[Chat] simple_chat_with_tools via %s, %d tools, max_iterations=%d",
ref $engine, scalar @$all_tools, $self->tool_max_iterations);
my $formatted_tools = $engine->format_tools($all_tools);
my $conversation = $self->_build_messages(@messages);
for my $iteration (1..$self->tool_max_iterations) {
($conversation, my $data) = $self->_tool_loop_iteration(
$engine, $conversation, $formatted_tools, $iteration,
);
my $tool_calls = $engine->response_tool_calls($data);
unless (@$tool_calls) {
my $text = $engine->response_text_content($data);
if ($engine->think_tag_filter) {
($text) = $engine->filter_think_content($text);
lib/Langertha/Chat.pm view on Meta::CPAN
# Plugin hook: after tool call
$result = $self->_run_plugin_after_tool_call($name, $input, $result)->get;
push @results, { tool_call => $tc, result => $result };
}
push @$conversation, $engine->format_tool_results($data, \@results);
}
die "Tool calling loop exceeded " . $self->tool_max_iterations . " iterations";
}
async sub simple_chat_with_tools_f {
my ( $self, @messages ) = @_;
my $engine = $self->_assert_chat_engine;
croak ref($engine) . " does not support tools"
unless $engine->does('Langertha::Role::Tools');
my ($all_tools, $tool_server_map) = $self->_gather_tools;
my $formatted_tools = $engine->format_tools($all_tools);
my $conversation = $self->_build_messages(@messages);
for my $iteration (1..$self->tool_max_iterations) {
$conversation = await $self->_run_plugin_before_llm_call($conversation, $iteration);
my $request = $engine->build_tool_chat_request($conversation, $formatted_tools, $self->_extra);
my $response = await $engine->_async_http->do_request(request => $request);
unless ($response->is_success) {
die "" . (ref $engine) . " tool chat request failed: " . $response->status_line;
}
my $data = $engine->parse_response($response);
lib/Langertha/Chat.pm view on Meta::CPAN
});
});
$result = await $self->_run_plugin_after_tool_call($name, $input, $result);
push @results, { tool_call => $tc, result => $result };
}
push @$conversation, $engine->format_tool_results($data, \@results);
}
die "Tool calling loop exceeded " . $self->tool_max_iterations . " iterations";
}
__PACKAGE__->meta->make_immutable;
1;
__END__
lib/Langertha/Chat.pm view on Meta::CPAN
=head2 temperature
Optional temperature override. When set, overrides the engine's
temperature.
=head2 mcp_servers
ArrayRef of L<Net::Async::MCP> instances for tool calling.
=head2 tool_max_iterations
Maximum tool-calling round trips. Defaults to C<10>.
=head2 simple_chat
my $response = $chat->simple_chat('Hello!');
Sends a synchronous chat request. Fires C<plugin_before_llm_call> and
C<plugin_after_llm_response> hooks.
lib/Langertha/Raid/Loop.pm view on Meta::CPAN
extends 'Langertha::Raid';
has max_loops => (
is => 'ro',
isa => 'Int',
default => 1,
);
has max_iterations => (
is => 'ro',
isa => 'Int',
predicate => 'has_max_iterations',
);
has continue_while => (
is => 'ro',
isa => 'CodeRef',
predicate => 'has_continue_while',
);
sub BUILD {
my ( $self ) = @_;
croak "Loop requires max_loops/max_iterations >= 1"
if $self->_loop_limit < 1;
}
sub _loop_limit {
my ( $self ) = @_;
return $self->has_max_iterations ? $self->max_iterations : $self->max_loops;
}
async sub run_f {
my ( $self, $ctx ) = @_;
$ctx = $self->_coerce_context($ctx);
my $limit = $self->_loop_limit;
my $last = Langertha::Result->final($ctx->input // '');
$ctx->add_trace({
lib/Langertha/Raid/Loop.pm view on Meta::CPAN
event => 'loop_iteration_start',
iteration => $iteration,
});
my $result = await $self->_run_steps_sequentially_f(
$ctx,
loop_iteration => $iteration,
);
if (!$result->is_final) {
$ctx->metadata->{loop_iterations} = $iteration;
return $self->_with_context_result($result, $ctx);
}
$last = $result;
if ($self->has_continue_while) {
my $continue = $self->continue_while->($ctx, $iteration, $result);
last unless $continue;
}
}
$ctx->metadata->{loop_iterations} = $ctx->state->{loop_iteration} // 0;
return $self->_with_context_result($last, $ctx);
}
__PACKAGE__->meta->make_immutable;
1;
__END__
lib/Langertha/Raid/Loop.pm view on Meta::CPAN
my $result = await $raid->run_f($ctx);
=head1 DESCRIPTION
Repeats child step execution on orchestration level (not Raider's internal
tool loop). The loop stops when:
=over 4
=item * C<max_loops>/C<max_iterations> is reached
=item * a step returns C<question>, C<pause>, or C<abort>
=item * optional C<continue_while> callback returns false
=back
=head2 max_loops
Maximum number of loop iterations (default limit).
=head2 max_iterations
Alias/override for C<max_loops> when explicitly provided.
=head2 continue_while
Optional callback C<< sub ($ctx, $iteration, $result) >> controlling whether
the loop should continue after each final iteration.
=head2 run_f
my $result = await $raid->run_f($ctx);
Executes loop iterations with explicit iteration state and safe stop rules.
=head1 SUPPORT
=head2 Issues
Please report bugs and feature requests on GitHub at
L<https://github.com/Getty/langertha/issues>.
=head2 IRC
lib/Langertha/Raider.pm view on Meta::CPAN
);
has history => (
is => 'rw',
isa => 'ArrayRef',
default => sub { [] },
);
has max_iterations => (
is => 'ro',
isa => 'Int',
default => 10,
);
has max_context_tokens => (
is => 'ro',
isa => 'Int',
predicate => 'has_max_context_tokens',
lib/Langertha/Raider.pm view on Meta::CPAN
is => 'rw',
isa => 'CodeRef',
predicate => 'has_on_iteration',
);
has metrics => (
is => 'rw',
isa => 'HashRef',
default => sub { {
raids => 0, iterations => 0, tool_calls => 0, time_ms => 0,
} },
);
has langfuse_trace_name => (
is => 'ro',
isa => 'Str',
default => 'raid',
);
lib/Langertha/Raider.pm view on Meta::CPAN
return $self;
}
sub reset {
my ( $self ) = @_;
$self->clear_history;
$self->_clear_active_engine;
$self->_active_engine_name(undef);
$self->metrics({
raids => 0, iterations => 0, tool_calls => 0, time_ms => 0,
});
return $self;
}
sub active_engine {
my ($self) = @_;
return $self->_has_active_engine ? $self->_active_engine : $self->engine;
}
lib/Langertha/Raider.pm view on Meta::CPAN
push @conversation, { role => 'system', content => $self->mission }
if $self->has_mission;
push @conversation, @{$self->history};
push @conversation, @user_msgs;
# Plugin hook: transform assembled conversation
for my $plugin (@{$self->_plugin_instances}) {
@conversation = @{await $plugin->plugin_build_conversation(\@conversation)};
}
my $raid_iterations = 0;
my $raid_tool_calls = 0;
my @injected_history;
# Package loop state for potential continuation
my $state = {
engine => $engine,
t0 => $t0,
langfuse => $langfuse,
trace_id => $trace_id,
tool_server_map => $tool_server_map,
formatted_tools => $formatted_tools,
model_params => $model_params,
user_msgs => \@user_msgs,
conversation => \@conversation,
raid_iterations => \$raid_iterations,
raid_tool_calls => \$raid_tool_calls,
injected_history => \@injected_history,
};
return await $self->_run_raid_loop($state, 1);
}
async sub _run_raid_loop {
my ( $self, $state, $start_iteration ) = @_;
my $engine = $state->{engine};
my $langfuse = $state->{langfuse};
my $trace_id = $state->{trace_id};
my $tool_server_map = $state->{tool_server_map};
my $formatted_tools = $state->{formatted_tools};
my $model_params = $state->{model_params};
my $user_msgs = $state->{user_msgs};
my $conversation = $state->{conversation};
my $raid_iterations = $state->{raid_iterations};
my $raid_tool_calls = $state->{raid_tool_calls};
my $injected_history = $state->{injected_history};
my $t0 = $state->{t0};
for my $iteration ($start_iteration..$self->max_iterations) {
$$raid_iterations++;
# Re-gather tools if catalog/engine changed
if ($self->_tools_dirty) {
$engine = $self->active_engine;
$state->{engine} = $engine;
my ( $all_tools, $new_map ) = await $self->_gather_tools_f;
$formatted_tools = $engine->format_tools($all_tools);
$tool_server_map = $new_map;
$state->{formatted_tools} = $formatted_tools;
$state->{tool_server_map} = $tool_server_map;
if ($langfuse) {
$model_params = $self->_langfuse_model_parameters($engine);
$state->{model_params} = $model_params;
}
$self->_tools_dirty(0);
}
# Drain injections for iterations 2+
if ($iteration > $start_iteration || $start_iteration > 1) {
my @injected;
if (@{$self->_injections}) {
push @injected, splice @{$self->_injections};
}
if ($self->has_on_iteration) {
my $cb_msgs = $self->on_iteration->($self, $iteration);
push @injected, @$cb_msgs if $cb_msgs && @$cb_msgs;
}
if (@injected) {
lib/Langertha/Raider.pm view on Meta::CPAN
push @{$self->history}, @$injected_history if @$injected_history;
push @{$self->history}, { role => 'assistant', content => $text };
# Push final assistant response to session_history
$self->_push_session_history({ role => 'assistant', content => $text });
# Update metrics
my $elapsed = tv_interval($t0) * 1000;
my $m = $self->metrics;
$m->{raids}++;
$m->{iterations} += $$raid_iterations;
$m->{tool_calls} += $$raid_tool_calls;
$m->{time_ms} += $elapsed;
my $result = Langertha::Raider::Result->new(type => 'final', text => $text);
# Plugin hook: transform final result before return
for my $plugin (@{$self->_plugin_instances}) {
$result = await $plugin->plugin_after_raid($result);
}
lib/Langertha/Raider.pm view on Meta::CPAN
type => 'pause',
content => $self_result->{reason},
);
}
}
if ($self_result->{type} eq 'abort') {
# Finalize metrics before aborting
my $elapsed = tv_interval($t0) * 1000;
my $m = $self->metrics;
$m->{iterations} += $$raid_iterations;
$m->{tool_calls} += $$raid_tool_calls;
$m->{time_ms} += $elapsed;
return Langertha::Raider::Result->new(
type => 'abort',
content => $self_result->{reason},
);
}
if ($self_result->{type} eq 'wait') {
lib/Langertha/Raider.pm view on Meta::CPAN
},
);
}
# Append assistant + tool results to conversation and session_history
my @tool_msgs = $engine->format_tool_results($data, \@results);
push @$conversation, @tool_msgs;
$self->_push_session_history(@tool_msgs);
}
die "Raider tool loop exceeded ".$self->max_iterations." iterations";
}
async sub respond_f {
my ( $self, $answer ) = @_;
croak "No pending interaction â call raid_f first"
unless $self->has_continuation;
my $cont = $self->_continuation;
$self->clear_continuation;
lib/Langertha/Raider.pm view on Meta::CPAN
Optional system prompt for the Raider. This is separate from the
engine's own C<system_prompt> â the Raider's mission takes precedence
and is prepended to every conversation.
=head2 history
ArrayRef of message hashes representing the conversation history.
Automatically managed by C<raid>/C<raid_f>. Can be inspected or
manually set.
=head2 max_iterations
Maximum number of tool-calling round trips per raid. Defaults to C<10>.
=head2 max_context_tokens
Optional. Enables auto-compression when set. When prompt token usage
exceeds C<context_compress_threshold * max_context_tokens>, the working
history is summarized via LLM before the next raid.
=head2 context_compress_threshold
lib/Langertha/Raider.pm view on Meta::CPAN
Falls back to C<engine> when not set.
=head2 session_history
Full chronological archive of ALL messages including tool calls and
results. Never auto-compressed. Persists across C<clear_history> and
C<reset>. Only cleared manually via C<< $raider->session_history([]) >>.
=head2 on_iteration
Optional CodeRef called before each LLM call (iterations 2+). Receives
C<($raider, $iteration)> and returns an arrayref of messages to inject,
or undef/empty to skip.
my $raider = Langertha::Raider->new(
engine => $engine,
on_iteration => sub {
my ($raider, $iteration) = @_;
return ['Check the error log'] if $iteration == 3;
return;
},
);
=head2 metrics
HashRef of cumulative metrics across all raids:
{
raids => 3, # Number of completed raids
iterations => 7, # Total LLM round trips
tool_calls => 12, # Total tool invocations
time_ms => 4500.2, # Total wall-clock time in milliseconds
}
=head2 langfuse_trace_name
Name for the Langfuse trace created per raid. Defaults to C<'raid'>.
=head2 langfuse_user_id
lib/Langertha/Raider.pm view on Meta::CPAN
Appends a message to the conversation history. Useful for replaying
persisted messages into a fresh Raider instance.
=head2 inject
$raider->inject('Also check the test files');
$raider->inject({ role => 'user', content => 'Focus on .pm files' });
Queues messages to be injected into the conversation at the next iteration.
Strings are automatically wrapped as user messages. The Raider drains the
queue before each LLM call (iterations 2+).
=head2 reset
$raider->reset;
Clears conversation history, metrics, and resets to the default engine.
=head2 active_engine
my $engine = $raider->active_engine;
lib/Langertha/Role/Tools.pm view on Meta::CPAN
with 'Langertha::Role::ParallelToolUse';
has mcp_servers => (
is => 'ro',
isa => 'ArrayRef',
default => sub { [] },
);
has tool_max_iterations => (
is => 'ro',
isa => 'Int',
default => 10,
);
sub build_tool_chat_request {
my ( $self, $conversation, $formatted_tools, %extra ) = @_;
return $self->chat_request($conversation, tools => $formatted_tools, %extra);
}
lib/Langertha/Role/Tools.pm view on Meta::CPAN
my $tools = await $mcp->list_tools;
for my $tool (@$tools) {
$tool_server_map{$tool->{name}} = $mcp;
push @all_tools, $tool;
}
}
my $formatted_tools = $self->format_tools(\@all_tools);
my $conversation = $self->chat_messages(@messages);
$log->debugf("[%s] chat_with_tools_f: %d tools from %d MCP servers, max_iterations=%d",
ref $self, scalar @all_tools, scalar @{$self->mcp_servers}, $self->tool_max_iterations);
for my $iteration (1..$self->tool_max_iterations) {
$log->debugf("[%s] Tool loop iteration %d/%d",
ref $self, $iteration, $self->tool_max_iterations);
my $request = $self->build_tool_chat_request($conversation, $formatted_tools);
my $response = await $self->_async_http->do_request(request => $request);
unless ($response->is_success) {
die "".(ref $self)." tool chat request failed: ".$response->status_line;
}
my $data = $self->parse_response($response);
my $tool_calls = $self->response_tool_calls($data);
lib/Langertha/Role/Tools.pm view on Meta::CPAN
});
});
push @results, { tool_call => $tc, result => $result };
}
# Append assistant message and tool results to conversation
push @$conversation, $self->format_tool_results($data, \@results);
}
die "Tool calling loop exceeded ".$self->tool_max_iterations." iterations";
}
1;
__END__
=pod
lib/Langertha/Role/Tools.pm view on Meta::CPAN
L<Langertha::Role::HermesTools> which provides implementations using XML tags.
=head2 mcp_servers
mcp_servers => [$mcp1, $mcp2]
ArrayRef of L<Net::Async::MCP> instances to use as tool providers. Defaults to
an empty ArrayRef. At least one server must be configured before calling
L</chat_with_tools_f>.
=head2 tool_max_iterations
tool_max_iterations => 20
Maximum number of tool-calling round trips before aborting with an error.
Defaults to C<10>. Increase for complex multi-step tool workflows.
=head2 build_tool_chat_request
my $request = $self->build_tool_chat_request($conversation, $formatted_tools);
Builds an HTTP request for a tool-calling chat turn. The default implementation
passes tools as an API parameter via C<chat_request>. Overridden by
L<Langertha::Role::HermesTools> to inject tools into the system prompt instead.
=head2 chat_with_tools_f
my $response = await $engine->chat_with_tools_f(@messages);
Async tool-calling chat loop. Accepts the same message arguments as
L<Langertha::Role::Chat/simple_chat>. Gathers tools from all L</mcp_servers>,
sends the request, executes any tool calls returned by the LLM, and repeats
until the LLM returns a final text response or L</tool_max_iterations> is
exceeded. Returns a L<Future> that resolves to the final text response.
=head1 SEE ALSO
=over
=item * L<Langertha::Role::HermesTools> - Hermes-style tool calling via XML tags
=item * L<Langertha::Role::Chat> - Chat role this is built on top of
t/60_tool_calling.t view on Meta::CPAN
my $anthropic = Langertha::Engine::Anthropic->new(
api_key => 'test-key',
model => 'claude-sonnet-4-6',
system_prompt => 'You are a helpful assistant',
response_size => 1024,
);
# Test: mcp_servers defaults to empty
is_deeply($anthropic->mcp_servers, [], 'mcp_servers defaults to empty');
is($anthropic->tool_max_iterations, 10, 'tool_max_iterations defaults to 10');
# Test: format_tools converts MCP format to Anthropic format
{
my $mcp_tools = [
{
name => 'echo',
description => 'Echo the input text',
inputSchema => {
type => 'object',
properties => { message => { type => 'string' } },
t/61_tool_calling_openai.t view on Meta::CPAN
my $json = JSON::MaybeXS->new->canonical(1)->utf8(1);
my $openai = Langertha::Engine::OpenAI->new(
api_key => 'test-key',
model => 'gpt-4o-mini',
system_prompt => 'You are a helpful assistant',
);
# Test: mcp_servers defaults to empty
is_deeply($openai->mcp_servers, [], 'mcp_servers defaults to empty');
is($openai->tool_max_iterations, 10, 'tool_max_iterations defaults to 10');
# Test: format_tools converts MCP format to OpenAI function format
{
my $mcp_tools = [
{
name => 'echo',
description => 'Echo the input text',
inputSchema => {
type => 'object',
properties => { message => { type => 'string' } },
t/65_tool_calling_vllm.t view on Meta::CPAN
ok($vllm->does('Langertha::Role::OpenAICompatible'), 'vLLM composes OpenAICompatible');
# Test: tool calling methods available
ok($vllm->can('format_tools'), 'has format_tools');
ok($vllm->can('response_tool_calls'), 'has response_tool_calls');
ok($vllm->can('extract_tool_call'), 'has extract_tool_call');
ok($vllm->can('format_tool_results'), 'has format_tool_results');
ok($vllm->can('response_text_content'), 'has response_text_content');
ok($vllm->can('chat_with_tools_f'), 'has chat_with_tools_f');
is_deeply($vllm->mcp_servers, [], 'mcp_servers defaults to empty');
is($vllm->tool_max_iterations, 10, 'tool_max_iterations defaults to 10');
# Test: format_tools (same as OpenAI)
{
my $mcp_tools = [
{
name => 'add',
description => 'Add two numbers',
inputSchema => {
type => 'object',
properties => {
t/93_chat.t view on Meta::CPAN
sub json { JSON::MaybeXS->new(utf8 => 1) }
__PACKAGE__->meta->make_immutable;
}
subtest 'Chat has tool-calling attributes' => sub {
my $engine = MockChatEngine->new;
my $chat = Langertha::Chat->new(engine => $engine);
is_deeply($chat->mcp_servers, [], 'mcp_servers defaults to empty');
is($chat->tool_max_iterations, 10, 'tool_max_iterations defaults to 10');
};
subtest 'simple_chat_with_tools_f dies without MCP servers' => sub {
my $engine = MockToolChatEngine->new;
my $chat = Langertha::Chat->new(engine => $engine);
eval { $chat->simple_chat_with_tools_f('hello')->get };
like($@, qr/No MCP servers/, 'dies without MCP servers');
};
t/96_raid_orchestration.t view on Meta::CPAN
ok($question_result->is_question, 'question propagates when no abort exists');
my $dying = Langertha::Raid::Parallel->new(steps => [
Test::Runnable::Step->new(name => 'explode', die_message => 'parallel boom'),
]);
my $dying_result = $dying->run_f(Langertha::RunContext->new(input => 'x'))->get;
ok($dying_result->is_abort, 'branch exception becomes abort');
like($dying_result->content, qr/parallel boom/, 'abort keeps branch failure reason');
};
subtest 'Loop max iterations and stop callback' => sub {
my $step = Test::Runnable::Step->new(
name => 'counter',
result_cb => sub {
my ( $ctx ) = @_;
$ctx->state->{n} = ($ctx->state->{n} // 0) + 1;
return Langertha::Result->final('n=' . $ctx->state->{n});
},
);
my $loop = Langertha::Raid::Loop->new(
steps => [$step],
max_loops => 3,
);
my $ctx = Langertha::RunContext->new(input => 'start');
my $result = $loop->run_f($ctx)->get;
ok($result->is_final, 'loop finalizes after max_loops');
is($result->text, 'n=3', 'loop ran exactly three iterations');
is($ctx->metadata->{loop_iterations}, 3, 'loop stores total iterations');
my $loop_cb = Langertha::Raid::Loop->new(
steps => [$step],
max_loops => 10,
continue_while => sub {
my ( $ctx, $iteration ) = @_;
return $iteration < 2;
},
);
my $ctx2 = Langertha::RunContext->new(input => 'start');
my $result2 = $loop_cb->run_f($ctx2)->get;
is($result2->text, 'n=2', 'continue_while can stop before max_loops');
is($ctx2->metadata->{loop_iterations}, 2, 'iteration metadata reflects early stop');
};
subtest 'Loop propagation and error path' => sub {
my $loop_pause = Langertha::Raid::Loop->new(
steps => [Test::Runnable::Step->new(name => 'pause', type => 'pause', content => 'wait')],
max_loops => 5,
);
my $pause_result = $loop_pause->run_f(Langertha::RunContext->new(input => 'x'))->get;
ok($pause_result->is_pause, 'pause propagates out of loop');