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
use strict ; use warnings ; use HTTP::Tiny ; use JSON::PP ; use POSIX qw(strftime) ; use File::Path qw(make_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-08-09 13:12:15 +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- run_subagent: delegate a sub-task to a child agent; returns its reply.\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. Delegate large or independent sub-tasks to run_subagent. 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 braces 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, GEMINI.md, CLAUDE.md contents in the project." ;
my $SDIR = ( $ENV { HOME } || $ENV { USERPROFILE } || '.' ) . '/.bantam/sessions' ;
sub cfg { my %d = ( endpoint => 'https://opencode.ai/zen/v1' , model => 'big-pickle' , temperature => 0.7 , api_key => '-' , timeout => 300 , shell_timeout => 120 , max_al_iterations => 1000 );
2026-08-18 10:17:52 +03:00
if ( open my $f , '<:encoding(UTF-8)' , 'model.cfg' ) { 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 }
sub sp { my $p = '' ; if ( open my $f , '<:encoding(UTF-8)' , 'system.txt' ) { local $/ ; $p = <$f> ; } $p =~ s/^\s+|\s+$//g ; length ( $p ) ? $p : $DEF_SP }
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-08-09 13:12:15 +03:00
sub T { my ( $n , $d , $p ) = @_ ; { type => 'function' , function => { name => $n , description => $d , parameters => { type => 'object' , properties => $p , required => [ 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-08-15 08:27:24 +03:00
my %p = ( messages => $msgs , tools => [ T ( 'shell_exec' , 'Run a shell command, return output and exit code.' , { command => { type => 'string' }}), T ( 'run_subagent' , 'Run a child agent with a prompt.' , { prompt => { type => 'string' }})], model => $c -> { model }, temperature => 0 + $c -> { temperature });
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-08-18 09:28:24 +03:00
sub last_assistant { for my $m ( reverse @ { $_ [ 0 ]}) { return $m -> { content } if ( $m -> { role } // '' ) eq 'assistant' && defined $m -> { content } && length $m -> { content }; } '' }
sub AL { my ( $c , $msgs , $sp , $depth ) = ( $_ [ 0 ], $_ [ 1 ], $_ [ 2 ], $_ [ 3 ] || 0 );
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-08-18 09:24:42 +03:00
elsif ( $fn eq 'run_subagent' ) { $res = $depth >= 5 ? '[subagent depth limit (5) reached, child not spawned]' : filter_text ( last_assistant ( AL ( $c , [{ role => 'system' , content => "$sp\n\nImportant: this is a child agent" }, { role => 'user' , content => filter_text ( $a -> { prompt } // '' )}], $sp , $depth + 1 ))); }
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-08-18 09:28:24 +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> ) }; push @s , $d if $d ; } sort { $b -> { id } cmp $a -> { id } } @s }
2026-08-09 13:12:15 +03:00
sub sdir { make_path ( $SDIR ) unless - d $SDIR ; $SDIR }
2026-08-18 09:28:24 +03:00
sub save { sdir (); my ( $id , $i ) = ( strftime ( '%Y%m%d-%H%M%S' , localtime ), 0 ); $id .= '-' . ++ $i while - f "$SDIR/$id.json" ;
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 ( $hit ) = grep { $_ -> { id } eq $_ [ 0 ] } sessions (); die "no session: $_[0]\n" unless $hit ; $hit -> { messages } }
2026-08-09 13:12:15 +03:00
sub autosave { sdir (); open my $f , '>' , "$SDIR/autosave.json" or return ; print $f JSON::PP -> new -> utf8 -> pretty -> encode ({ id => 'autosave' , messages => $_ [ 0 ]}); }
sub list_sessions { map { [ $_ -> { id }, scalar @ { $_ -> { messages } // [] }] } sessions () }
2026-08-18 09:28:24 +03:00
sub set_cfg { my ( $k , $v , @ls , $f ) = @_ ;
2026-08-18 10:17:52 +03:00
if ( open my $fh , '<:encoding(UTF-8)' , 'model.cfg' ) { while ( <$fh> ) { push @ls , ( ! /^#/ && /^([^\s=]+)\s*=/ && $1 eq $k ) ? ( $f = 1 , "$k=$v\n" ) : $_ ; } }
2026-08-15 08:27:24 +03:00
push @ls , "$k=$v\n" unless $f ;
if ( open my $fh , '>:encoding(UTF-8)' , 'model.cfg' ) { print $fh @ls ; close $fh ; } }
2026-08-09 13:12:15 +03:00
sub main {
2026-08-18 09:28:24 +03:00
my ( $c , $sp ) = ( cfg (), 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 , $sp ); autosave ( $msgs ); return ; }
2026-08-15 08:27:24 +03:00
print "MicroBantam ready ($c->{model}). Commands: /quit /clear /save /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-08-18 09:28:24 +03:00
elsif ( $u eq '/save' ) { print "session saved: " , save ( $msgs ), "\n" ; }
elsif ( $u eq '/list' ) { print "$_->[0] [$_->[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-08-18 09:28:24 +03:00
elsif ( $u eq '/help' ) { print "Commands: /quit /clear /save /list /load <id> /cfg <k> [v] /help\n" ; }
2026-08-09 13:12:15 +03:00
else { push @$msgs , { role => 'user' , content => $u }; AL ( $c , $msgs , $sp ); autosave ( $msgs ); }
}
autosave ( $msgs );
}
main () unless caller ();