NAME
Langertha::Engine::Virtual - Rule-driven engine that answers without network, key or model
VERSION
version 0.504
SYNOPSIS
use Langertha::Engine::Virtual;
use Langertha::Virtual::Rules qw( answer tool after );
my $engine = Langertha::Engine::Virtual->new(
rules => [
'Hello' => answer('Hi there!'),
qr/\AAdd (\d+) and (\d+)\z/ => sub {
my ( $request, $first, $second ) = @_;
return tool( add => { a => $first, b => $second } );
},
after('add') => sub {
my ( $request ) = @_;
return answer( 'The sum is '.$request->{tool_results}[0]{content} );
},
],
fallback => answer('I only know a few prompts.'),
);
print $engine->simple_chat('Hello'); # Hi there!
# The MCP tool loop runs as with any engine
my $tooled = Langertha::Engine::Virtual->new(
rules => $engine->rules,
fallback => $engine->fallback,
mcp_servers => [ $mcp ],
);
my $text = await $tooled->chat_with_tools_f('Add 7 and 15'); # The sum is 22
# One reply per request, in order
my $scripted = Langertha::Engine::Virtual->new(
script => [ tool( read_file => { path => 'a.txt' } ), answer('Done.') ],
);
# Ready-made rule sets
my $echo = Langertha::Engine::Virtual->preset('echo');
my $files = Langertha::Engine::Virtual->preset( files => mcp_servers => [ $mcp ] );
# Rules from a YAML file
my $from_file = Langertha::Engine::Virtual->new( rules_file => 't/data/rules.yml' );
# Token usage, and streaming in pieces of four characters
my $counted = Langertha::Engine::Virtual->new(
rules => [ 'Hello' => answer( 'Hi there!', usage => { prompt_tokens => 3, completion_tokens => 2 } ) ],
stream_chunk_size => 4,
);
$counted->simple_chat('Hello')->usage->total_tokens; # 5
my ( $content, $chunks ) = await $counted->chat_stream_realtime_f(
messages => [ { role => 'user', content => 'Hello' } ],
chunk_callback => sub { print $_[0]->content }, # Hi t, here, !
);
$counted->aggregate_usage($chunks)->{total_tokens}; # 5, from the last chunk
# What happened
$engine->requests; # every request the engine answered, as seen by the rules
$engine->calls; # every tool call it emitted: [ $name, \%arguments ]
$engine->results; # every tool result the tool loop handed back
DESCRIPTION
An engine that needs no network, no API key and no model: it answers each request by "RULES" you give it (or by a "script"), so a test, a demo or a manual run gets exactly the replies it asks for. Everything above the transport is the real thing: requests are built by Langertha::Role::OpenAICompatible, replies are parsed into Langertha::Response by the same code that reads OpenAI, and "chat_with_tools_f" in Langertha::Role::Tools (or another tool loop, such as langertha-raider's) runs the tools of its "mcp_servers" in Langertha::Role::Tools for real. Only the HTTP round trip is replaced: the request never leaves the process; the engine reads it, picks a reaction and answers it as an OpenAI chat completion. That holds for the chat traffic only: if LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY are set in the environment, an explicit langfuse_flush can still send traces over the network. Pass langfuse_enabled => 0 to keep a test or demo fully offline. Synchronous (simple_chat) and _f calls both work; the _f calls run synchronously (their futures are already complete), and "async_loop" in Langertha::Role::AsyncHTTP is undef. The in-process transport is the engine's own user_agent (and the _async_http built over it); passing either to the constructor replaces it.
The engine supports chat, tool calling and streaming (chat, tools_native, the tool_choice_* flags, parallel_tool_use, response_size, system_prompt, streaming, stream_usage; see "STREAMING"). There is no embedding, no transcription and no image generation. Request parameters it has no use for (tool_choice, a response_format, max_tokens, ...) are accepted and visible in "requests", but do not change the reaction. A reply carries token usage only when a reaction or "default_usage" gives it (see "USAGE").
Langertha::Engine::Mock is the same engine under the name Mock.
RULES
rules is a list of TRIGGER => REACTION pairs, checked in order for every request; the first pair whose trigger matches gives the reaction. When none matches, "fallback" answers. Without a fallback the request fails like a request to a server that answers with an error, here 500 No Reaction: simple_chat croaks and the _f calls fail with <class> request failed: 500 No Reaction - no rule matches and no fallback is set (request N, last message: ...), so a missing rule is never a silent answer. A sub reaction that dies, or returns something that is not a reaction, fails the request the same way with 500 Reaction Failed and the error.
Triggers
String and Regexp triggers are matched against the text of the last message, if it is a user message. The text of a message whose content is a list of parts is its text parts joined with a newline.
a string matches when it is exactly equal to that text - no trimming, no case folding.
a Regexp matches as
=~does. Its captures are passed to asubreaction after the request.a sub is a predicate: it is called with the request (see "The request") and matches when it returns true. It is checked for every request, whatever the last message is.
an after trigger matches when the last message is a tool result:
after('read_file')for aread_fileresult,after()for any tool,after( tool => qr/.../, result => qr/.../ )to match the tool name and the result text. With several results at the end of the conversation (parallel calls) the first one that matches counts. Captures of itsRegexps are passed to asubreaction, the tool name's first.
So a string or Regexp rule fires on the user's prompt but not again when the tool loop comes back with the result of the call it asked for; that turn is answered by an after rule, a predicate or the fallback.
Reactions
answer($text)({ answer => $text }): reply with this text.tool( $name => \%arguments )({ tool_calls => [ [ $name, \%arguments ] ] }): call this tool.tools( [ [ $name => \%arguments ], ... ] ): call several tools in one turn.error( $status, $message )({ error => { status => 429, message => '...' } }): fail the request like a server that answers with this HTTP status (400 to 599) and message, so the caller's error path runs:simple_chatcroaks and the_fcalls fail with<class> request failed: 429 Too Many Requests - .... It cannot be combined with an answer or tool calls. Asubmay return it. Served over HTTP by Langertha::Virtual::Server it is the response with that status and an OpenAI-style error body.a plain string: the same as
answerof it.a HashRef with
answer(or its aliastext) andtool_callstogether: a reply that carries text and calls.any of these HashRefs (not
error) withusage, a HashRef: the reply's token usage (see "USAGE"). The helpers take it as an option:answer( $text, usage => \%usage ),tool( $name => \%arguments, usage => \%usage ),tools( \@calls, usage => \%usage ).a sub: called with the request and the captures of the trigger,
$sub->( $request, @captures ); it returns one of the above (not another sub).
The helpers live in Langertha::Virtual::Rules. Rules and reactions are checked when the engine is built: a malformed one croaks there, not on the first request. Every emitted tool call gets a unique id (call_virtual_1, call_virtual_2, ...) and the reply's finish_reason is tool_calls; a reply without calls finishes with stop.
The request
Sub triggers and sub reactions get the request as a HashRef:
text- the text of the last message when it is a user message, elseundeflast_user_text- the text of the most recent user message anywhere in the conversationtool_results- the tool results at the end of the conversation, in order, each{ id, name, content }(contentis the result's text,namethe tool name of the call it answers)messages- the conversation as sent (OpenAI chat format)tools- the tool definitions sent with the request (OpenAI format), or[]model- the model the request namednumber- the request's number, from 1body- the whole decoded request body
Script mode
With script instead of rules, the engine answers the first request with the first reaction, the second with the second, and so on, whatever they contain. Entries are reactions as above; a sub entry gets the request. When the script runs out, "fallback" answers, or the request fails with 500 No Reaction (the script has run out ...). script and rules cannot be combined.
Rules files
"rules_file" loads the rules from YAML, which cannot hold subs or Regexps:
rules: # trigger text => reaction, matched exactly
Write a note.:
tool_calls:
- [ write_file, { path: notes/a.txt, content: "hello\n" } ]
Hello: { answer: Hi there! }
after: # tool name => reaction
write_file: { answer: Fertig. }
fallback: { answer: done }
or, for script mode, script: with a list of reactions:
script:
- tool_calls:
- [ read_file, { path: app.txt } ]
- text: Done.
A reaction is { answer: ... } (or { text: ... }), { tool_calls: [ [ name, { arguments } ], ... ] } or both, each optionally with usage: { prompt_tokens: ..., completion_tokens: ... }, or a plain string. The keys rules, after, fallback and script are the only ones allowed; anything else croaks. after rules come after the rules ones. A key the file sets cannot also be passed to the constructor.
The keys of rules and after are read by YAML before they are texts: a trigger that YAML takes for a boolean, null or number (true, null, ~, 1.0, 0x10) must be quoted, else the rule never matches the prompt. The entries of each map are taken in alphabetical order of their keys, not file order, so after rules too: when several match one tool result, the first in that order wins.
USAGE
By default a reply carries no usage block, so "has_usage" in Langertha::Response is false, as with a server that reports none. A reaction's usage gives the reply one; without it, "default_usage" does, when set. The block is OpenAI's wire usage (prompt_tokens, completion_tokens, total_tokens, and any further keys such as prompt_tokens_details, passed on unchanged); total_tokens is added as the sum of the other two when it is missing and both are given. It arrives where a provider's would: on the chat completion, read by the OpenAI-dialect parser into "usage" in Langertha::Response (a Langertha::Usage), and on a stream only when the request asks for it with stream_options => { include_usage => 1 }, as a last chunk with an empty choices list (see "STREAMING"). The engine's own stream request asks for it (it claims stream_usage, see Langertha::Role::StreamUsage); pass stream_options => { include_usage => 0 } to leave it out. Langertha::Virtual::Server serves the same block over HTTP.
my $engine = Langertha::Engine::Virtual->new(
rules => [ 'Hello' => answer( 'Hi', usage => { prompt_tokens => 3, completion_tokens => 1 } ) ],
);
$engine->simple_chat('Hello')->usage->total_tokens; # 4
STREAMING
The engine streams like an OpenAI-dialect server: a request built by chat_stream_request ("chat_stream_realtime_f" in Langertha::Role::Chat, simple_chat_stream, simple_chat_stream_iterator, ...) is answered with text/event-stream chat.completion.chunk events, parsed by the same parse_stream_chunk that reads OpenAI. The reaction is chosen as for any request, then cut into events ("virtual_stream_chunks"): the text in pieces of "stream_chunk_size" characters, each tool call as an opening fragment (index, id, type, function.name, empty arguments) followed by its arguments in pieces of the same size, then an event with the finish_reason, and with include_usage the usage event. The stream gives the same text and the same Langertha::ToolCalls as the non-streaming reply. An error reaction, or a request no rule answers, fails the stream like an error status from a server.
The in-process transport hands the whole event stream over at once: the chunk callback runs once per event, but all of them after the reply is complete, so time to first token is not meaningful here.
PRESETS
"preset" builds an engine with a ready-made rule set. These are the prompts that trigger them, exactly as written:
echo
Answers every request with the text of the most recent user message, unchanged. Every prompt triggers it.
files
Drives the file tools of an MCP server such as langertha-raider's stock files and bash tools (read_file, write_file, bash). PATH is one word without spaces; CONTENT and COMMAND run to the end of the prompt, newlines included. The words around them must match exactly:
Read the file PATH- callsread_filewith{ path => PATH }Write the file PATH: CONTENT- callswrite_filewith{ path => PATH, content => CONTENT }Run the command COMMAND- callsbashwith{ command => COMMAND }
After any tool result it answers with the result's text (several results joined with a newline). Any other prompt is answered with a short list of these prompts.
ATTRIBUTES AND METHODS
Besides the chat and tool-calling API every engine has (Langertha::Role::Chat, Langertha::Role::Tools), the engine has the following. "requests", "calls" and "results" keep what the engine saw and did, in order, for a test to check; they are for reading.
url
The URL the requests are addressed to. Defaults to http://virtual.invalid/v1; nothing is ever sent there (the .invalid top-level domain cannot resolve).
default_model
virtual. Any model may be passed; it is sent in the request and comes back as "model" in Langertha::Response.
api_key_env
undef: the engine reads no API key.
rules
rules => [ 'Hello' => answer('Hi'), qr/\ABye/ => 'Bye!', after('ls') => answer('ok') ]
The rules, as TRIGGER => REACTION pairs (see "RULES"). Defaults to [].
fallback
fallback => answer('I do not know.')
The reaction for a request no rule matches, or that comes after the end of the "script". Without one, such a request fails with 500 No Reaction.
script
script => [ tool( ls => {} ), answer('Done.') ]
Script mode (see "Script mode"): one reaction per request, in order. Cannot be combined with "rules".
rules_file
rules_file => 't/data/virtual_rules.yml'
A YAML file with rules, after, fallback and/or script (see "Rules files"), read when the engine is built.
default_usage
default_usage => { prompt_tokens => 10, completion_tokens => 5 }
default_usage => sub {
my ( $request, $reaction ) = @_;
return { prompt_tokens => length $request->{last_user_text},
completion_tokens => length( $reaction->{answer} // '' ) };
}
The usage of every reply whose reaction gives none (see "USAGE"): a HashRef, or a sub called with the request (see "The request") and the reaction as data ({ answer => $text_or_undef, tool_calls => [ [ $name, \%arguments ], ... ] }) that returns the usage HashRef, or undef for none. A sub that dies or returns anything else fails the request with 500 Reaction Failed. Not set by default: replies then carry no usage.
stream_chunk_size
stream_chunk_size => 1 # one character per event
The number of characters per event when a reply is streamed (see "STREAMING"), for the text and for each tool call's arguments. Default 8; at least 1.
requests
Every chat request the engine received, in order, including the ones that failed (no rule matched, a reaction died), as the HashRef the rules saw (see "The request").
calls
Every tool call the engine emitted, in order, as [ $name, \%arguments ].
results
Every tool result a tool loop handed back through "format_tool_results" in Langertha::Role::Tools, in order, as the loop passed it: { tool_call => ..., result => \%mcp_result }, where result is the MCP call_tool result (content, isError, ...).
preset
my $engine = Langertha::Engine::Virtual->preset('echo');
my $engine = Langertha::Engine::Virtual->preset( files => mcp_servers => [ $mcp ] );
Class method. Builds an engine with the named rule set (see "PRESETS"); further arguments go to the constructor and replace the preset's own rules or fallback when they name them. Combined with rules_file, preset croaks, because the preset's rules and the file's collide. Croaks on an unknown name.
list_models
my $ids = $engine->list_models; # [ 'virtual' ]
The configured "model" in Langertha::Role::Models, without a request.
virtual_reply
my $reply = $engine->virtual_reply( POST => '/v1/chat/completions', $json_text );
# { code => 200, message => 'OK', content => \%completion }
The engine's answer to one HTTP request, as data: the status code and message, and the content, which is the chat completion (a HashRef) on success and an error text otherwise. The in-process transport and Langertha::Virtual::Server both answer through it; it records the request like any other. A path other than .../chat/completions or a method other than POST is a 404. The body must be the JSON of a chat request. The completion is the same for a request with "stream": true; the transports cut it into events with "virtual_stream_chunks". It carries usage when the reaction or "default_usage" gives one (see "USAGE").
virtual_stream_chunks
my $chunks = $engine->virtual_stream_chunks( $completion, include_usage => 1 );
Cuts a chat completion (the content of a successful "virtual_reply") into the chat.completion.chunk events an OpenAI server streams, as an ArrayRef of HashRefs, without the closing [DONE]: the text in pieces of "stream_chunk_size" characters (the first event carries role => 'assistant'), each tool call as an opening fragment with index, id, type, its function.name and empty arguments, then its arguments in pieces, then an event with an empty delta and the finish_reason. With include_usage and a completion that carries usage, a last event with an empty choices list carries it, as stream_options => { include_usage => 1 } asks of OpenAI. The in-process transport and Langertha::Virtual::Server both stream through it.
SEE ALSO
Langertha::Virtual::Rules -
answer,tool,toolsandafterLangertha::Engine::Mock - The same engine under the name
MockLangertha::Role::Tools - The MCP tool loop the engine runs in
Langertha::Role::OpenAICompatible - The request builder and parser it reuses
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.