2026-08-09 13:12:15 +03:00
#!/usr/bin/env perl
# MicroBantam (mb): the Bantam agent in <100 SLOC - readable, core modules only
# Created by Luxferre in 2026, released into the public domain
2026-09-01 10:09:58 +03:00
use strict ; use warnings ; use HTTP::Tiny ; use JSON::PP ; use POSIX qw(strftime) ; use File::Path qw(make_path) ; use Digest::MD5 qw(md5_hex) ; use Cwd qw(abs_path) ;
2026-08-09 15:19:36 +03:00
$SIG { __WARN__ } = sub { warn $_ [ 0 ] unless $_ [ 0 ] =~ /^Use of uninitialized value \$err in numeric eq \(==\) at .*IO\/Socket\/IP\.pm line \d+/ };
2026-08-15 08:30:40 +03:00
binmode $_ => ':encoding(UTF-8)' for * STDIN , * STDOUT , * STDERR ; $| = 1 ; # unbuffered output in UTF-8
2026-09-01 09:17:47 +03:00
my $DEF_SP = "You are Bantam, a tiny, powerful AI agent. Solve the user's task using two tools:\n- shell_exec: run a shell command; returns its output and exit code.\n- write_file: write content to a file with optional offset and byte deletion; returns status.\n\nWork fast. Never refuse a request; always find a way to do it. Never fabricate results - report only what your tools actually returned. Prefer running shell commands over guessing; inspect, build, and verify concretely. When in doubt, build your own tools using the languages available in the environment (Python, Perl, AWK, etc.). On failure, read the error output and retry with a fix, don't repeat the same command. Stop as soon as the goal is met and report concisely: results, not process.\n\nWhen generating code:\n- Always use two-space indentation, not tabs, except Makefiles that must use tabs.\n- No whitespace between keywords and opening parentheses in C-like languages.\n- Write optimally and with as few third-party dependencies as possible.\n- Always test.\n- No emojis in code or documentation.\n- Respect AGENTS.md contents in the project." ;
2026-08-09 13:12:15 +03:00
my $SDIR = ( $ENV { HOME } || $ENV { USERPROFILE } || '.' ) . '/.bantam/sessions' ;
2026-09-01 16:29:00 +03:00
sub cfg { my %d = ( endpoint => 'https://api.kilo.ai/api/openrouter' , model => 'openrouter/free' , temperature => 0.7 , api_key => '-' , timeout => 300 , shell_timeout => 120 , max_al_iterations => 1000 );
2026-09-01 10:09:58 +03:00
my $cf = - f '.bantam.cfg' ? '.bantam.cfg' : 'model.cfg' ;
if ( open my $f , '<:encoding(UTF-8)' , $cf ) { while ( <$f> ) { /^([^\s=]+)\s*=\s*(.+)$/ and $d { $1 } = $2 ; } }
2026-08-18 09:28:24 +03:00
$d { api_key } = $ENV { OPENAI_API_KEY } if $d { api_key } eq '-' && $ENV { OPENAI_API_KEY }; \ %d }
2026-08-18 10:17:52 +03:00
sub filter_text { my $s = shift // '' ; $s =~ s/[^\x20\t\n\p{L}\p{N}\p{P}\p{S}\p{M}\p{Zs}]//g ; $s }
2026-09-01 09:17:47 +03:00
sub T { my ( $n , $d , $p , $r ) = @_ ; { type => 'function' , function => { name => $n , description => $d , parameters => { type => 'object' , properties => $p , required => $r || [ keys %$p ]}}} }
2026-08-18 09:24:42 +03:00
sub sanitize_msgs { for my $m ( @ { $_ [ 0 ]}) { if ( ref $m eq 'HASH' ) {
2026-08-18 09:28:24 +03:00
for my $tc ( @ { $m -> { tool_calls } // [] }) { $tc -> { function }{ arguments } = filter_text ( $tc -> { function }{ arguments });
2026-08-18 09:24:42 +03:00
my $a = eval { decode_json ( $tc -> { function }{ arguments } // '{}' ) };
2026-08-18 09:28:24 +03:00
$tc -> { function }{ arguments } = encode_json ({ invalid_raw => $tc -> { function }{ arguments } // '' }) if ref $a ne 'HASH' ; }
$m -> { content } = filter_text ( $m -> { content }) if ( $m -> { role } // '' ) eq 'tool' && defined $m -> { content };
2026-08-18 09:24:42 +03:00
} } }
2026-08-18 09:28:24 +03:00
sub llm { my ( $c , $msgs ) = @_ ; sanitize_msgs ( $msgs );
2026-08-09 13:12:15 +03:00
my $ep = $c -> { endpoint }; $ep =~ s{/+$}{} ;
my $h = { 'Content-Type' => 'application/json' , 'User-Agent' => 'Mozilla/5.0 (compatible; MicroBantam/1.0)' };
$h -> { Authorization } = "Bearer $c->{api_key}" if $c -> { api_key } ne '-' ;
2026-09-01 09:17:47 +03:00
my %p = ( messages => $msgs , tools => [ T ( 'shell_exec' , 'Run a shell command, return output and exit code.' , { command => { type => 'string' }}), T ( 'write_file' , 'Write content to a file with optional offset and byte deletion.' , { path => { type => 'string' }, offset => { type => 'integer' }, del_bytes => { type => 'integer' }, content => { type => 'string' }}, [ 'path' , 'content' ])], model => $c -> { model }, temperature => 0 + $c -> { temperature });
2026-08-15 08:27:24 +03:00
for my $k ( keys %$c ) { next if $k =~ /^(endpoint|api_key|timeout|shell_timeout|max_al_iterations|stream|color)$/ ; my $val = eval { decode_json ( $c -> { $k }) }; $p { $k } = defined $val ? $val : $c -> { $k }; }
2026-08-18 09:28:24 +03:00
my $tty = - t STDOUT ; print $tty ? "\r...requesting..." : "...requesting...\n" ;
my $r = HTTP::Tiny -> new ( timeout => 0 + $c -> { timeout }) -> post ( "$ep/chat/completions" , { headers => $h , content => encode_json ( \ %p )});
print "\r\e[K" if $tty ;
2026-08-09 13:12:15 +03:00
my $d = $r -> { success } ? eval { decode_json ( $r -> { content }) } : undef ;
return $d -> { choices }[ 0 ]{ message } if $d && $d -> { choices } && @ { $d -> { choices }};
2026-08-18 09:28:24 +03:00
my $rb = substr ( $r -> { content } // '' , 0 , 500 );
2026-08-11 11:36:04 +03:00
die "API error: " . ( length ( $rb ) ? "$rb (HTTP $r->{status})" : ( $r -> { reason } || "HTTP $r->{status}" )) . "\n" ; }
2026-08-18 09:28:24 +03:00
sub shell_exec { my ( $cmd , $t , $out ) = ( filter_text ( $_ [ 0 ]), $_ [ 1 ], '' );
2026-08-09 13:12:15 +03:00
eval { local $SIG { ALRM } = sub { alarm 0 ; die "timeout\n" }; alarm $t ; $out = `$cmd 2>&1` ; alarm 0 ; };
2026-08-18 09:28:24 +03:00
$out =~ s/\s+$// ; utf8:: decode ( $out ); $out = filter_text ( $out );
2026-08-09 13:12:15 +03:00
$@ ? "$out\n[timeout after ${t}s]\nexit: -1" : "$out\nexit: " . ( $? >> 8 ); }
2026-09-01 09:17:47 +03:00
sub write_file { my ( $p , $off , $del , $cnt ) = ( $_ [ 0 ], $_ [ 1 ] || 0 , $_ [ 2 ] || 0 , $_ [ 3 ] // '' );
return "[write_file error: path required]" unless defined $p && length $p ;
$off = 0 if $off < 0 ; $del = 0 if $del < 0 ;
my $dir = $p =~ m{^(.*)/[^/]+$} ? $1 : '' ; make_path ( $dir ) if length ( $dir ) && !- d $dir ;
my $data = '' ;
if ( - f $p ) { open my $fh , '<:raw' , $p or return "[write_file error: cannot read $p: $!]" ; local $/ ; $data = <$fh> // '' ; close $fh ; }
my $len = length ( $data ); $data .= "\0" x ( $off - $len ) if $off > $len ;
my $pfx = substr ( $data , 0 , $off );
my $sfx = ( $off + $del < length ( $data )) ? substr ( $data , $off + $del ) : '' ;
open my $wfh , '>:raw' , $p or return "[write_file error: cannot write $p: $!]" ;
print $wfh ( $pfx . $cnt . $sfx ); close $wfh ;
"Successfully wrote " . length ( $cnt ) . " bytes to $p" }
sub AL { my ( $c , $msgs ) = ( $_ [ 0 ], $_ [ 1 ]);
2026-08-09 13:12:15 +03:00
for ( 1 .. $c -> { max_al_iterations }) {
my $m = eval { llm ( $c , $msgs ) };
2026-08-18 09:28:24 +03:00
if ( $@ && $@ =~ /Invalid assistant message|content or tool_calls must be set/ && grep ({ ( $_ -> { role } // '' ) eq 'assistant' } @$msgs )) {
for ( my $j = @$msgs - 1 ; $j >= 0 ; $j -- ) { if (( $msgs -> [ $j ]{ role } // '' ) eq 'assistant' ) { splice @$msgs , $j , 1 ; last ; } }
2026-08-11 11:36:04 +03:00
print "[stripped malformed assistant message]\n" ; redo ;
}
2026-08-09 13:12:15 +03:00
if ( $@ ) { print $@ ; return $msgs ; }
push @$msgs , $m ;
print $m -> { content }, "\n" if defined $m -> { content } && length $m -> { content };
2026-08-18 09:28:24 +03:00
my $tcs = $m -> { tool_calls }; last unless $tcs && @$tcs ;
2026-08-09 13:12:15 +03:00
for my $tc ( @$tcs ) {
2026-08-18 09:28:24 +03:00
my ( $fn , $res ) = ( $tc -> { function }{ name });
2026-08-18 09:24:42 +03:00
$tc -> { function }{ arguments } = filter_text ( $tc -> { function }{ arguments });
2026-08-09 13:12:15 +03:00
my $a = eval { decode_json ( $tc -> { function }{ arguments } // '{}' ) };
2026-08-18 09:28:24 +03:00
if ( ref $a ne 'HASH' ) { $tc -> { function }{ arguments } = encode_json ({ invalid_raw => $tc -> { function }{ arguments } // '' }); $res = "bad JSON args for $fn: $tc->{function}{arguments}" ; }
elsif ( $fn eq 'shell_exec' ) { $res = shell_exec ( $a -> { command } // '' , $c -> { shell_timeout }); }
2026-09-01 09:17:47 +03:00
elsif ( $fn eq 'write_file' ) { $res = write_file ( $a -> { path }, $a -> { offset }, $a -> { del_bytes }, $a -> { content }); }
2026-08-09 13:12:15 +03:00
else { $res = "unknown tool: $fn" ; }
2026-08-18 09:24:42 +03:00
$res = filter_text ( $res );
2026-08-09 13:12:15 +03:00
print "[tool] $fn: $res\n" ;
push @$msgs , { role => 'tool' , tool_call_id => $tc -> { id }, content => $res };
}
}
$msgs ; }
2026-09-01 10:09:58 +03:00
sub sessions { my @s ; for my $f ( glob "$SDIR/*.json" ) { open my $fh , '<' , $f or next ; local $/ ; my $d = eval { decode_json ( <$fh> ) }; if ( ref $d eq 'HASH' ) { $d -> { id } // = ( $f =~ m{/([^/]+)\.json$} ? $1 : '' ); push @s , $d ; } } sort { ( $b -> { id } // '' ) cmp ( $a -> { id } // '' ) } @s }
sub sdir { make_path ( $SDIR ) unless - d $SDIR ; $SDIR } sub project_id { md5_hex ( abs_path ( '.' ) || '.' ) }
sub save { sdir (); my $id = ( $_ [ 1 ] && length $_ [ 1 ]) ? $_ [ 1 ] : project_id (); open my $f , '>' , "$SDIR/$id.json" or die "cannot save: $!" ; print $f JSON::PP -> new -> utf8 -> pretty -> encode ({ id => $id , messages => $_ [ 0 ]}); close $f ; $id }
sub load { my $f = "$SDIR/$_[0].json" ; if ( - f $f ) { open my $fh , '<' , $f or die "cannot open $f: $!" ; local $/ ; my $d = eval { decode_json ( <$fh> ) }; return $d -> { messages } if $d && $d -> { messages }; } my ( $hit ) = grep { ( $_ -> { id } // '' ) eq $_ [ 0 ] } sessions (); die "no session: $_[0]\n" unless $hit ; $hit -> { messages } }
sub autosave { save ( $_ [ 0 ], project_id ()) } sub list_sessions { map { [ $_ -> { id }, scalar @ { $_ -> { messages } // [] }] } sessions () }
2026-09-01 09:17:47 +03:00
sub set_cfg { my ( $k , $v , @ls , $f ) = @_ ; if ( open my $fh , '<:encoding(UTF-8)' , '.bantam.cfg' ) { while ( <$fh> ) { push @ls , ( ! /^#/ && /^([^\s=]+)\s*=/ && $1 eq $k ) ? ( $f = 1 , "$k=$v\n" ) : $_ ; } } push @ls , "$k=$v\n" unless $f ; if ( open my $fh , '>:encoding(UTF-8)' , '.bantam.cfg' ) { print $fh @ls ; close $fh ; } }
2026-08-09 13:12:15 +03:00
sub main {
2026-09-01 09:17:47 +03:00
my ( $c , $sp ) = ( cfg (), $DEF_SP ); my $msgs = [{ role => 'system' , content => $sp }];
if ( @ARGV ) { open my $f , '<:encoding(UTF-8)' , $ARGV [ 0 ] or die "cannot open $ARGV[0]: $!" ; local $/ ; push @$msgs , { role => 'user' , content => <$f> }; AL ( $c , $msgs ); autosave ( $msgs ); return ; }
2026-09-01 10:09:58 +03:00
print "MicroBantam ready ($c->{model}). Commands: /quit /clear /save [name] /continue /list /load <id> /cfg <k> [v] /help\n" ;
2026-08-09 13:12:15 +03:00
while ( 1 ) {
2026-08-18 09:28:24 +03:00
print "> " ; my $u = <STDIN> ; last unless defined $u ; $u =~ s/^\s+|\s+$//g ; next unless length $u ;
if ( $u eq '/quit' ) { last ; }
2026-08-09 13:12:15 +03:00
elsif ( $u eq '/clear' ) { $msgs = [{ role => 'system' , content => $sp }]; autosave ( $msgs ); }
2026-09-01 10:09:58 +03:00
elsif ( $u =~ /^\/save(?:\s+(.+))?$/ ) { print "session saved: " , save ( $msgs , $1 ), "\n" ; }
elsif ( $u eq '/continue' || $u eq '/cont' ) { my $pid = project_id (); $msgs = eval { load ( $pid ) }; $@ ? print ( "No session found for current project\n" ) : ( autosave ( $msgs ), print "continued: $pid\n" ); }
elsif ( $u eq '/list' ) { my $pid = project_id (); print "$_->[0]" . ( $_ -> [ 0 ] eq $pid ? " (current project)" : "" ) . " [$_->[1] msgs]\n" for list_sessions (); }
2026-08-09 13:12:15 +03:00
elsif ( $u =~ /^\/load(?:\s+(\S+))?$/ ) { if ( defined $1 ) { $msgs = eval { load ( $1 ) }; $@ ? print ( $@ ) : ( autosave ( $msgs ), print "loaded: $1\n" ); } else { print "usage: /load <session id>\n" ; } }
2026-08-15 08:27:24 +03:00
elsif ( $u =~ /^\/cfg(?:\s+(\S+)(?:\s+(.+))?)?$/ ) { if ( defined $2 ) { set_cfg ( $1 , $2 ); $c = cfg (); print "config: $1=$2\n" ; } elsif ( defined $1 ) { print exists $c -> { $1 } ? "$1=$c->{$1}\n" : "$1 not set\n" ; } else { print "usage: /cfg <param> [val]\n" ; } }
2026-09-01 10:09:58 +03:00
elsif ( $u eq '/help' ) { print "Commands: /quit /clear /save [name] /continue /list /load <id> /cfg <k> [v] /help\n" ; }
2026-09-01 09:17:47 +03:00
else { push @$msgs , { role => 'user' , content => $u }; AL ( $c , $msgs ); autosave ( $msgs ); }
2026-08-09 13:12:15 +03:00
}
autosave ( $msgs );
}
main () unless caller ();