NAME

Langertha::Raider - Autonomous agent with conversation history and MCP tools

VERSION

version 0.504

SYNOPSIS

use IO::Async::Loop;
use Future::AsyncAwait;
use Net::Async::MCP;
use MCP::Server;
use Langertha::Engine::Anthropic;
use Langertha::Raider;

# Set up MCP server with tools
my $server = MCP::Server->new(name => 'demo', version => '1.0');
$server->tool(
    name => 'list_files',
    description => 'List files in a directory',
    input_schema => {
        type => 'object',
        properties => { path => { type => 'string' } },
        required => ['path'],
    },
    code => sub { $_[0]->text_result(join("\n", glob("$_[1]->{path}/*"))) },
);

my $loop = IO::Async::Loop->new;
my $mcp = Net::Async::MCP->new(server => $server);
$loop->add($mcp);

async sub main {
    await $mcp->initialize;

    my $engine = Langertha::Engine::Anthropic->new(
        api_key     => $ENV{ANTHROPIC_API_KEY},
        mcp_servers => [$mcp],
    );

    my $raider = Langertha::Raider->new(
        engine  => $engine,
        mission => 'You are a code explorer. Investigate files thoroughly.',
    );

    # First raid — uses tools, builds history
    my $r1 = await $raider->raid_f('What files are in the current directory?');
    say $r1;

    # Second raid — has context from first conversation
    my $r2 = await $raider->raid_f('Tell me more about the first file you found.');
    say $r2;

    # Check metrics
    my $m = $raider->metrics;
    say "Raids: $m->{raids}, Tool calls: $m->{tool_calls}, Time: $m->{time_ms}ms";

    # Reset for a fresh conversation
    $raider->clear_history;
}

main()->get;

DESCRIPTION

Status: 0.x. The engine, the raider command line and the Hall are covered by the test suite and an acceptance suite that drives the tools through a scripted model (t/scenarios/). Interfaces can still change between 0.x releases; the documented wire formats (machine output, session journal, hall event bus) only change with a new format version.

Langertha::Raider is an autonomous agent that wraps a Langertha engine with MCP tools. It maintains conversation history across multiple interactions (raids), enabling multi-turn conversations where the LLM can reference prior context.

Key features:

  • Conversation history persisted across raids

  • Mission (system prompt) separate from engine's system_prompt

  • Automatic MCP tool calling loop

  • Tool arguments checked against the tool's inputSchema before it runs: a missing required key or a top-level property of the wrong type answers the call with an error result the model can correct, and the tool does not run

  • Cumulative metrics tracking

  • Hermes tool calling support (inherited from engine)

  • Mid-raid context injection via inject() and on_iteration

  • Automatic context management — between-raid LLM history summary plus in-raid mechanical compaction, mission and skills preserved

History management: Only user messages and final assistant text responses are persisted in history. Intermediate tool-call messages (assistant tool requests and tool results) are NOT persisted, preventing token bloat across long conversations. The full turn-by-turn record, tool calls and results included, is kept separately in "session_history".

TOOL RESULTS THAT ADD MESSAGES

A tool result may hand the raid user messages of its own: a list of strings under the key raider/messages of the result's _meta (the place MCP gives a result for such data):

{ content => [ { type => 'text', text => 'Launching skill: pdf' } ],
  _meta   => { 'raider/messages' => [ $skill_body ] } }

After the batch's tool results each string becomes a message { role => 'user', content => $string } of its own, in the order of the calls -- the shape Claude Code gives a loaded skill (ADR 0026). Like an injection ("inject") such a message is not part of its tool exchange: it stays when in-raid compaction drops the exchange, and it is kept in "history" (and "session_history") with the user input and the answer. Only raider's own tool sources (the engine's MCP servers, inline tools) may do this; the key is removed from the result of a mcp_catalog server before any plugin sees it.

engine

Required. A Langertha engine instance with MCP servers configured. The engine must compose Langertha::Role::Tools.

mission

Optional system prompt for the Raider. This is separate from the engine's own system_prompt — the Raider's mission takes precedence and is prepended to every conversation.

history

ArrayRef of message hashes representing the conversation history. Automatically managed by raid/raid_f. Can be inspected or manually set.

max_iterations

Maximum number of tool-calling round trips per raid. Defaults to 10.

max_context_tokens

Optional. Enables auto-compression when set. When prompt token usage exceeds context_compress_threshold * max_context_tokens, the working history is summarized via LLM before the next raid, and within a single long raid the oldest tool-call exchanges are dropped from the in-flight conversation as the tool loop runs, oldest first, so one long-running mission cannot outgrow the context window. The mission (system prompt, including any activated skill instructions) is never compacted — an agent must not lose its instructions mid-raid.

context_compress_threshold

Fraction of max_context_tokens that triggers compression. Defaults to 0.75 (75%).

compression_prompt

System prompt used for history summarization. Customizable. The default instructs the LLM to preserve key facts, decisions, and context.

compression_engine

Optional separate engine for compression (e.g. a cheaper model). Falls back to engine when not set.

session_history

Full chronological archive of ALL messages including tool calls and results. Never auto-compressed. Persists across clear_history and reset. Cleared via clear_session_history, which also empties the internal _session_embeddings array to preserve their 1:1 invariant.

on_iteration

Optional CodeRef called before each LLM call (iterations 2+). Receives ($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;
    },
);

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
}

langfuse_trace_name

Name for the Langfuse trace created per raid. Defaults to 'raid'.

langfuse_user_id

Optional user ID passed to the Langfuse trace.

langfuse_session_id

Optional session ID passed to the Langfuse trace. Use this to group multiple raids into a single Langfuse session.

langfuse_tags

Optional tags (ArrayRef[Str]) passed to the Langfuse trace.

langfuse_release

Optional release identifier passed to the Langfuse trace.

langfuse_version

Optional version string passed to the Langfuse trace.

langfuse_metadata

Optional metadata HashRef merged into the Langfuse trace metadata (alongside auto-generated fields like mission and history_length).

raider_mcp

Enables virtual self-tools that the LLM can call to interact with the Raider itself. Set to 1 to enable all self-tools, or pass a HashRef to enable selectively:

raider_mcp => 1                               # all self-tools
raider_mcp => { ask_user => 1, pause => 1 }   # only these

Available self-tools: ask_user, wait, wait_for, pause, abort, session_history, manage_mcps, switch_engine.

on_ask_user

Optional callback for the raider_ask_user self-tool. Receives ($question, $options) and must return an answer string. When not set, the raid pauses and returns a question Result that can be continued with "respond_f".

on_pause

Optional callback for the raider_pause self-tool. Receives ($reason). When not set, the raid pauses and returns a pause Result.

on_wait_for

Callback for the raider_wait_for self-tool. Receives ($condition, $args) and must return a result string. Required when the LLM uses raider_wait_for — will die if not set.

cancel

$raider->cancel;

Asks the raid in progress to stop (ADR 0009). It stops at its next safe point -- before the next model call, before the next tool call -- and a model response, tool call or raider_wait it waits for right then is abandoned: that future is cancelled, which aborts an HTTP request in flight on Net::Async::HTTP. A tool call cut off this way still gets its result, marked cancelled (text Tool call 'NAME' was cancelled., isError, cancelled => 1), through plugin_after_tool_call. Tool subprocesses are not signalled; that is the caller's to do. A tool call that ends with an error once the cancel is requested -- its subprocess ended by the caller -- counts as cut off too; one that succeeds keeps its result.

The raid then resolves with a cancelled Langertha::Raider::Result. Like a failed raid it adds nothing to "history"; the tool calls made so far stay in "session_history".

It only sets a flag and writes a byte to a pipe the event loop watches, so it is safe to call from a signal handler: the rest happens on the loop. A request made while no raid runs applies to the next one ("clear_cancel" drops it); each raid ends with no request pending ("cancel_requested").

cancel_requested

True from "cancel" until the raid it applies to has ended.

clear_cancel

$raider->clear_cancel;

Drops a cancel request that no raid has used yet ("cancel"). Every raid does this when it ends.

tools

Optional ArrayRef of inline tool definitions. Each entry is a HashRef with name, description, input_schema, and code keys — the same format as "tool" in MCP::Server. An internal MCP server is created automatically.

my $raider = Langertha::Raider->new(
    engine => $engine,
    tools  => [{
        name         => 'greet',
        description  => 'Say hello',
        input_schema => { type => 'object', properties => { name => { type => 'string' } } },
        code         => sub { $_[0]->text_result("Hello $_[1]->{name}!") },
    }],
);

mcp_catalog

HashRef of named MCP servers available for dynamic activation. The LLM can use raider_manage_mcps to list, activate, and deactivate catalog entries.

mcp_catalog => {
    database => { server => $db_mcp, description => 'Database tools', auto => 1 },
    email    => { server => $email_mcp, description => 'Email tools' },
}

Entries with auto => 1 are activated at construction time.

engine_catalog

HashRef of named engines available for runtime switching via switch_engine.

engine_catalog => {
    fast  => { engine => $groq,      description => 'Fast inference' },
    smart => { engine => $anthropic,  description => 'Complex reasoning' },
    code  => { engine => $deepseek,   description => 'Code generation' },
}

Entries without an engine key refer to the default engine (the one passed as engine at construction). This lets you give the default engine a named catalog entry with a description:

engine_catalog => {
    sonnet => { description => 'Balanced model for everyday tasks' },
    fast   => { engine => $groq,  description => 'Fast inference' },
    smart  => { engine => $opus,  description => 'Complex reasoning' },
}

The LLM always sees a default entry (reset to original) plus all catalog keys in the raider_switch_engine tool enum.

Use switch_engine, reset_engine, active_engine, and engine_info to control which engine is used during raids.

async_tools

Optional HashRef of asynchronous in-process tools (ADR 0027 §9), by tool name. A value is the handler, or a HashRef with the handler as code and concurrent => 1:

async_tools => {
    agent        => { code => \&spawn_agent_f, concurrent => 1 },
    send_message => \&send_message_f,
},

sub spawn_agent_f {
    my ( $input, %ctx ) = @_;          # $ctx{id}: the model's tool call id
    return $future;                    # of an MCP result { content => [...], isError => ... }
}

The tool itself still comes from a tool source -- an MCP server, the inline "tools" -- which carries its name, description and inputSchema, so the prompt and the tool list do not change; only its call is different. A call of a tool with a handler takes the whole path of any tool call -- plugin_before_tool_call, the tool hook, the inputSchema check, the gate, plugin_after_tool_call, "cancel" -- and in the place of the source's call_tool the handler's Future is awaited: it runs on the raid's loop while nothing else waits for it, and is abandoned (cancelled) like any tool call when a cancel cuts it off. A handler that dies or whose Future fails gives an error result. %ctx is the call's context: id, the tool call id of the model's reply (undef when the engine's reply has none), and what the plugins recorded on it (see "plugins").

A HashRef may also carry applies, a code reference asked with the source the tool comes from in this raid (the MCP client that offers it): a false answer leaves the call to that source, as if there were no handler. So a handler can stand in for one particular server's tool and leave a tool of the same name from another source alone:

async_tools => { bash => { code => \&bash_f, applies => sub { $mine{ refaddr $_[0] } } } },

Consecutive calls of one model turn whose tools are concurrent run at the same time: each through the whole path above, their results appended in the order of the calls. Any other call -- and a self-tool always -- ends such a group; nothing else changes the order. When a call of a group ends the raid ("cancel"), the other calls of the group are still awaited first.

embedding_engine

Optional engine with Langertha::Role::Embedding for semantic history search. When not set, auto-detects if the main engine supports embeddings. Set "no_session_embeddings" to disable auto-detection.

Each session_history entry is embedded in the background through simple_embedding_f, so the raid never waits for it; a raider that goes away lets the requests still in flight finish. Like every engine of the raider, the embedding engine must run on the same event loop as engine (see "raid_f").

no_session_embeddings

When true, no session history entry is embedded, and the search of raider_session_history falls back to a plain text match. Use it when the engine supports embeddings but they are not wanted, e.g. to save the extra request per message. With it set, embedding_engine is also left out of the event-loop check of "raid_f".

clear_history

$raider->clear_history;

Clears conversation history and pending injections while preserving metrics.

clear_session_history

$raider->clear_session_history;

Empties session_history and the matching _session_embeddings array in lock-step, so the 1:1 invariant both readers of session_history rely on is preserved. Use this instead of splicing session_history directly, which leaves _session_embeddings stale. Embedding requests still in flight for the cleared entries are cancelled.

add_history

$raider->add_history('user', 'Hello');
$raider->add_history('assistant', 'Hi there!');

Appends a message to the conversation history. Useful for replaying persisted messages into a fresh Raider instance.

add_session_history

$raider->add_session_history(
  { role => 'user', content => 'Hello' },
  { role => 'tool', name => 'bash', content => 'README.md' },
);

Appends entries to session_history, keeping the session embeddings in step. Useful for replaying a persisted session into a fresh Raider instance; history is replayed separately with add_history.

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

reset

$raider->reset;

Clears conversation history, metrics, and resets to the default engine.

active_engine

my $engine = $raider->active_engine;

Returns the currently active engine. If switch_engine was called, returns the catalog engine; otherwise returns the default engine.

active_engine_name

my $name = $raider->active_engine_name;  # 'smart' or undef

Returns the name of the currently active catalog engine, or undef if using the default engine.

switch_engine

$raider->switch_engine('smart');

Switches to a named engine from the engine_catalog. Sets _tools_dirty so the raid loop re-gathers and re-formats tools for the new engine. Croaks if the name is not in the catalog.

reset_engine

$raider->reset_engine;

Switches back to the default engine (the one passed at construction).

override_engine

my $restore = $raider->override_engine($engine);
...                                  # raids run on $engine
$restore->();                        # back to the engine before

Makes $engine, which need not be in the engine_catalog, the active engine until the returned CodeRef is called. Calling it puts back the engine that was active before (a switch_engine choice or the default). Both ways the tools are re-gathered for the engine on the next iteration; active_engine_name is left as it is. For a temporary engine, such as the model of one run.

engine_info

my $info = $raider->engine_info;
# { name => 'smart', class => 'Langertha::Engine::Anthropic', model => 'claude-sonnet-4-6' }

Returns a hashref with the active engine's name, class, and model.

list_engines

my $engines = $raider->list_engines;

Returns a hashref of all available engines (default + catalog entries), each with engine, description (if from catalog), and active flag.

add_engine

$raider->add_engine('vision', engine => $vision_engine, description => 'Vision model');
$raider->add_engine('main', description => 'Default model for general tasks');

Adds a new engine to the catalog at runtime. If engine is omitted, the entry refers to the default engine. The LLM will see it in the raider_switch_engine tool after the next tool re-gather.

remove_engine

$raider->remove_engine('vision');

Removes an engine from the catalog. If the removed engine is currently active, automatically resets to the default engine. Croaks when the name is not in the catalog.

compress_history_f

my $summary = await $raider->compress_history_f;
my $summary = await $raider->compress_history_f( instructions => 'Keep the API decisions' );

Async. Summarizes the current working history via LLM and replaces it with the summary. Uses compression_engine if set, otherwise falls back to engine. A marker is added to session_history. Returns the summary; with an empty history nothing, and no request is sent. The last prompt size (what max_context_tokens is checked against) is forgotten, so the next raid does not compress the summary again; the next response reports the new size.

instructions (optional) focus the summary: they are appended to the summary request after Additional instructions:, as Claude Code's /compact INSTRUCTIONS does. Without them the request is unchanged.

The summary is read through the engine's public chat_response (the parser "chat_f" in Langertha::Role::Chat uses), so an error carried in a 200 body croaks instead of silently producing an empty summary, and Gemini thought parts stay out of the text. An engine that lacks chat_response falls back to response_text_content.

compress_history

my $summary = $raider->compress_history;
my $summary = $raider->compress_history( instructions => 'Keep the API decisions' );

Synchronous wrapper around compress_history_f, with the same arguments.

register_session_history_tool

$raider->register_session_history_tool($mcp_server);

Registers a session_history MCP tool on the given server, allowing the LLM to query its own full session history. Supports query (text filter) and last_n (return last N messages) parameters.

run_f

my $result = await $raider->run_f($ctx);   # a Langertha::RunContext, or plain input

The Langertha::Role::Runnable entry point that lets a raider be a step of a Langertha::Raid. Runs "raid_f" on the context's input (a string, or an ArrayRef of messages; plain input is wrapped in a new Langertha::RunContext). A final answer becomes the context's input and state->{last_output} for the next step; last_result_type, last_result and history are updated too. Returns the Langertha::Raider::Result with the context attached.

raid

my $response = $raider->raid(@messages);

Synchronous wrapper around raid_f. Sends messages, runs the tool loop, and returns the Langertha::Raider::Result, which stringifies to the final text. Updates history and metrics.

raid_f

my $result = await $raider->raid_f(@messages);

Async tool-calling conversation. Accepts the same message arguments as simple_chat (strings become user messages, hashrefs pass through). Returns a Future resolving to a Langertha::Raider::Result.

The result stringifies to the final text (backward compatible), but also provides type, is_final, is_question, is_pause, is_abort for programmatic handling of interactive self-tools, and is_cancelled for a raid stopped by "cancel".

All engines of a raider (engine, compression_engine, every engine_catalog engine, and embedding_engine unless no_session_embeddings is set) must run on the same event loop, since one raid is driven by one loop. An engine without a loop (the synchronous fallback) counts as the process-wide IO::Async::Loop->new. raid_f and respond_f fail right away, naming the engine, when one runs on another loop.

They also fail right away, naming the engine and the module, when a module Net::Async::HTTP needs to connect to an engine's url does not load: IO::Async::Internals::Connector, and for https IO::Async::SSL (which needs IO::Socket::SSL, Net::SSLeay and the system libssl). Sent anyway, the first request would fail and every later one to that host would wait forever.

respond_f

my $result = await $raider->respond_f($answer);

Continue a paused raid after a question or pause result. The answer is used as the tool result of the pausing self-tool call -- handed through plugin_after_tool_call like any tool result -- and the raid loop resumes. Returns the next Langertha::Raider::Result.

respond

my $result = $raider->respond($answer);

Synchronous wrapper around respond_f.

plugins

my $raider = Langertha::Raider->new(
    plugins => ['Langfuse', 'MyApp::CustomPlugin'],
    engine  => $engine,
);

Arrayref of plugin names or Langertha::Plugin instances. Short names are resolved first to Langertha::Plugin::$name, then to LangerthaX::Plugin::$name. Fully qualified names (with ::) are used as-is.

A leading + (+My::Plugin) loads the class as named, without the prefix search, and a name may be followed by a HashRef of constructor arguments for that plugin.

Plugin instances are created automatically with host => $self. Extra constructor arguments can be passed via plugin_args.

Besides the tool-calling hooks every Langertha::Plugin has, a raider calls plugin_before_raid (the raid's messages), plugin_build_conversation (the conversation sent to the model) and plugin_after_raid (the final Langertha::Raider::Result); the contract is in Langertha::Plugin.

A raider passes one more argument to plugin_before_tool_call and plugin_after_tool_call, after the ones Langertha::Plugin names: the call's context, a HashRef that is the same for both hooks of one call. It holds id, the tool call id of the model's reply (undef when the reply has none). A plugin may record keys of its own in it -- the only way to tell which call a result belongs to while calls run at the same time ("async_tools") -- and an async tool's handler gets them as its %ctx. A plugin that ignores the argument works as before.

SEE ALSO

SUPPORT

Issues

Please report bugs and feature requests at https://github.com/Getty/langertha-raider/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 <torsten@raudssus.de> https://raudssus.de/

COPYRIGHT AND LICENSE

This software is copyright (c) 2026 by Torsten Raudssus.

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

https://raudssus.de/