Files
bantam/mb
T

117 lines
8.1 KiB
Perl
Raw Normal View History

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 13:22:33 +03:00
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-09 13:22:33 +03:00
binmode $_ => ':encoding(UTF-8)' for *STDIN, *STDOUT, *STDERR;
2026-08-09 13:22:33 +03:00
2026-08-09 13:12:15 +03:00
$| = 1; # unbuffered output: live spinner, responsive prompt
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-09 10:37:07 +00:00
if (open my $f, '<:encoding(UTF-8)', 'model.cfg') { while (<$f>) { /^(\w+)\s*=\s*(.+)$/ and $d{$1} = $2; } }
2026-08-09 13:12:15 +03:00
$d{api_key} = $ENV{OPENAI_API_KEY} if $d{api_key} eq q{-} && $ENV{OPENAI_API_KEY};
\%d; }
sub sp { my $p = '';
2026-08-09 10:37:07 +00:00
if (open my $f, '<:encoding(UTF-8)', 'system.txt') { local $/; $p = <$f>; }
2026-08-09 13:12:15 +03:00
$p =~ s/^\s+|\s+$//g;
length($p) ? $p : $DEF_SP; }
sub T { my ($n, $d, $p) = @_; {type=>'function', function=>{name=>$n, description=>$d, parameters=>{type=>'object', properties=>$p, required=>[keys %$p]}}} }
2026-08-09 15:19:36 +03:00
sub sanitize_msgs { for my $m (@{$_[0]}) { if (ref $m eq 'HASH' && $m->{tool_calls}) { for my $tc (@{$m->{tool_calls}}) { my $a = eval { decode_json($tc->{function}{arguments} // '{}') }; $tc->{function}{arguments} = encode_json({invalid_raw => $tc->{function}{arguments} // ''}) if ref $a ne 'HASH'; } } } }
2026-08-09 13:12:15 +03:00
sub llm { my ($c, $msgs) = @_; # one non-streaming chat completion
2026-08-09 15:19:36 +03:00
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 '-';
my $body = encode_json({model=>$c->{model}, temperature=>0+$c->{temperature}, 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'}})]});
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=>$body});
print "\r\e[K" if $tty; # clear the spinner line
my $d = $r->{success} ? eval { decode_json($r->{content}) } : undef;
return $d->{choices}[0]{message} if $d && $d->{choices} && @{$d->{choices}};
2026-08-11 11:36:04 +03:00
my $rb = $r->{content} // '';
$rb = substr($rb, 0, 500) if length($rb) > 500;
die "API error: " . (length($rb) ? "$rb (HTTP $r->{status})" : ($r->{reason} || "HTTP $r->{status}")) . "\n"; }
2026-08-09 13:12:15 +03:00
sub shell_exec { my ($cmd, $t) = @_; # run a command under a hard timeout
my $out = '';
eval { local $SIG{ALRM} = sub { alarm 0; die "timeout\n" }; alarm $t; $out = `$cmd 2>&1`; alarm 0; };
$out =~ s/\s+$//;
2026-08-09 10:37:07 +00:00
utf8::decode($out);
2026-08-09 13:12:15 +03:00
$@ ? "$out\n[timeout after ${t}s]\nexit: -1" : "$out\nexit: " . ($? >> 8); }
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) = @_; # the agentic loop: LLM <-> tools until done
$depth ||= 0;
for (1 .. $c->{max_al_iterations}) {
my $m = eval { llm($c, $msgs) };
2026-08-11 11:36:04 +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; } }
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};
my $tcs = $m->{tool_calls};
last unless $tcs && @$tcs;
for my $tc (@$tcs) {
my $fn = $tc->{function}{name};
my $a = eval { decode_json($tc->{function}{arguments} // '{}') };
my $res;
2026-08-09 15:19:36 +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} // ''); }
2026-08-09 13:12:15 +03:00
elsif ($fn eq 'shell_exec') { $res = shell_exec($a->{command} // '', $c->{shell_timeout}); }
2026-08-09 15:19:36 +03:00
elsif ($fn eq 'run_subagent') { $res = $depth >= 5 ? '[subagent depth limit (5) reached, child not spawned]' : last_assistant(AL($c, [{role=>'system', content=>"$sp\n\nImportant: this is a child agent"}, {role=>'user', content=>$a->{prompt} // ''}], $sp, $depth + 1)); }
2026-08-09 13:12:15 +03:00
else { $res = "unknown tool: $fn"; }
print "[tool] $fn: $res\n";
push @$msgs, {role=>'tool', tool_call_id=>$tc->{id}, content=>$res};
}
}
$msgs; }
sub sessions { my @s; # all saved sessions, newest first
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; }
sub sdir { make_path($SDIR) unless -d $SDIR; $SDIR }
sub save { sdir(); my $id = strftime('%Y%m%d-%H%M%S', localtime); my $i = 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; }
2026-08-09 15:19:36 +03:00
sub load { my ($want) = @_; my ($hit) = grep { $_->{id} eq $want } sessions(); die "no session: $want\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() }
sub main {
my ($c, $sp) = (cfg(), sp());
my $msgs = [{role=>'system', content=>$sp}];
2026-08-09 10:37:07 +00:00
if (@ARGV) { open my $f, '<:encoding(UTF-8)', $ARGV[0] or die "cannot open $ARGV[0]: $!"; # file mode
2026-08-09 15:19:36 +03:00
local $/; push @$msgs, {role=>'user', content=><$f>}; AL($c, $msgs, $sp); autosave($msgs); return; }
2026-08-09 13:12:15 +03:00
print "MicroBantam ready ($c->{model}). Commands: /quit /clear /save /list /load <id> /help\n";
while (1) {
2026-08-09 15:19:36 +03:00
print "> "; my $u = <STDIN>; last unless defined $u;
$u =~ s/^\s+|\s+$//g; next unless length $u;
2026-08-09 13:12:15 +03:00
if ($u eq '/quit') { last; }
elsif ($u eq '/clear') { $msgs = [{role=>'system', content=>$sp}]; autosave($msgs); }
elsif ($u eq '/save') { print "session saved: ", save($msgs), "\n"; }
elsif ($u eq '/list') { print "$_->[0] [$_->[1] msgs]\n" for list_sessions(); }
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"; } }
elsif ($u eq '/help') { print "Commands: /quit /clear /save /list /load <id> /help\n"; }
else { push @$msgs, {role=>'user', content=>$u}; AL($c, $msgs, $sp); autosave($msgs); }
}
autosave($msgs);
}
main() unless caller();