Langertha

 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 {

CLAUDE.md  view on Meta::CPAN


## 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

Changes  view on Meta::CPAN

      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

Changes  view on Meta::CPAN

      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).

Changes  view on Meta::CPAN

    - 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');



( run in 2.399 seconds using v1.01-cache-2.11-cpan-71847e10f99 )