retired all ports except go and mb-perl
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## About
|
## About
|
||||||
|
|
||||||
Bantam is a minimalist, dependency-free AI agent specification with reference implementations in **Python** (`bantam.py`, ~280 SLOC), **Go** (`main.go` + `term_*.go`, module `code.luxferre.top/luxferre/bantam`), and **Perl 5** (`bantam.pl`, ~360 SLOC). It provides an agentic loop capable of autonomous tool execution, shell interaction, real-time response streaming, Fibonacci backoff network resilience, and subagent delegation using any OpenAI-compatible completions API.
|
Bantam is a minimalist, dependency-free AI agent specification with reference implementations in **Go** (`main.go` + `term_*.go`, module `code.luxferre.top/luxferre/bantam`) and **Perl 5** as **MicroBantam** (`mb`, under 100 SLOC). It provides an agentic loop capable of autonomous tool execution, shell interaction, real-time response streaming, markdown terminal rendering with box-drawing tables, Fibonacci backoff network resilience, and subagent delegation using any OpenAI-compatible completions API.
|
||||||
|
|
||||||
The entire philosophy of Bantam is built upon two principles:
|
The entire philosophy of Bantam is built upon two principles:
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ Because of the second principle, Bantam itself was named after Victorinox Bantam
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- **Python 3.7+**, **Go 1.21+**, or **Perl 5.14+** (standard library / core modules only)
|
- **Go 1.21+** or **Perl 5.14+** (standard library / core modules only)
|
||||||
- An OpenAI-compatible API endpoint (or OpenAI API key)
|
- An OpenAI-compatible API endpoint (or OpenAI API key)
|
||||||
|
|
||||||
### Installation (Go)
|
### Installation (Go)
|
||||||
@@ -31,7 +31,7 @@ go build ./... # produces ./bantam
|
|||||||
go run . prompt.txt
|
go run . prompt.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
The Go port is a single `main.go` plus four platform files (`term_linux.go`, `term_bsd.go`, `term_windows.go`, `term_other.go`) for the built-in raw-terminal line editor — zero external dependencies, same as the Python and Perl versions.
|
The Go port is a single `main.go` plus four platform files (`term_linux.go`, `term_bsd.go`, `term_windows.go`, `term_other.go`) for the built-in raw-terminal line editor — zero external dependencies.
|
||||||
|
|
||||||
### Running Bantam
|
### Running Bantam
|
||||||
|
|
||||||
@@ -49,11 +49,10 @@ All implementations read the same `model.cfg` and `system.txt` from the current
|
|||||||
2. Interactive mode:
|
2. Interactive mode:
|
||||||
```bash
|
```bash
|
||||||
bantam # Go (or: go run .)
|
bantam # Go (or: go run .)
|
||||||
python3 bantam.py # Python
|
./mb # MicroBantam (Perl 5)
|
||||||
perl bantam.pl # Perl 5
|
|
||||||
```
|
```
|
||||||
|
|
||||||
In interactive mode, prompts can span multiple lines: press **Ctrl+J** to insert a real line break (the cursor moves to the next line), then **Enter** to submit the whole multi-line prompt. The Go port ships its own raw-mode line editor (arrow keys move the cursor, Up/Down browse history, Backspace edits, Ctrl+C/Ctrl+D exit), so this works everywhere without dependencies; the Python and Perl ports use `readline` when available and fall back to single-line prompts otherwise.
|
In interactive mode, prompts can span multiple lines: press **Ctrl+J** to insert a real line break (the cursor moves to the next line), then **Enter** to submit the whole multi-line prompt. The Go port ships its own raw-mode line editor (arrow keys move the cursor, Up/Down browse history, Backspace edits, Ctrl+C/Ctrl+D exit), so this works everywhere without dependencies.
|
||||||
|
|
||||||
Sessions are saved under `~/.bantam/sessions/` and can be managed with these commands:
|
Sessions are saved under `~/.bantam/sessions/` and can be managed with these commands:
|
||||||
- `/save` — save the entire conversation to a new session file (auto-id like `20260808-190038`) and generate its summary
|
- `/save` — save the entire conversation to a new session file (auto-id like `20260808-190038`) and generate its summary
|
||||||
@@ -69,9 +68,8 @@ All implementations read the same `model.cfg` and `system.txt` from the current
|
|||||||
|
|
||||||
3. File input mode:
|
3. File input mode:
|
||||||
```bash
|
```bash
|
||||||
python3 bantam.py prompt.txt # Python
|
|
||||||
bantam prompt.txt # Go
|
bantam prompt.txt # Go
|
||||||
perl bantam.pl prompt.txt # Perl 5
|
./mb prompt.txt # MicroBantam (Perl 5)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Rules of Bantam (The Algorithm)
|
## Rules of Bantam (The Algorithm)
|
||||||
@@ -126,11 +124,11 @@ If the API rejects the request with an `Invalid assistant message: content or to
|
|||||||
- `api_key` (API key / Bearer token, optional; fall back to `OPENAI_API_KEY` env var)
|
- `api_key` (API key / Bearer token, optional; fall back to `OPENAI_API_KEY` env var)
|
||||||
- `stream` (stream response tokens in real-time, default `true`)
|
- `stream` (stream response tokens in real-time, default `true`)
|
||||||
- `color` (ANSI coloring: `auto` (TTY-detected, default), `always`, or `never`; also disabled by `NO_COLOR`/`BANTAM_NO_COLOR` env vars)
|
- `color` (ANSI coloring: `auto` (TTY-detected, default), `always`, or `never`; also disabled by `NO_COLOR`/`BANTAM_NO_COLOR` env vars)
|
||||||
- `timeout` (HTTP timeout in seconds for LLM API calls, default 300; in the Go port it bounds connection setup and time-to-first-byte, so long streaming responses are not cut off mid-stream, matching the Python and Perl ports' per-operation socket timeouts)
|
- `timeout` (HTTP timeout in seconds for LLM API calls, default 300; in the Go port it bounds connection setup and time-to-first-byte, so long streaming responses are not cut off mid-stream)
|
||||||
- `shell_timeout` (timeout in seconds for `shell_exec` commands, default 120)
|
- `shell_timeout` (timeout in seconds for `shell_exec` commands, default 120)
|
||||||
- `max_al_iterations` (max tool-call loop iterations per `AL()` invocation, default 1000)
|
- `max_al_iterations` (max tool-call loop iterations per `AL()` invocation, default 1000)
|
||||||
|
|
||||||
All implementations keep the interactive prompt safe against the classic "long line overwrites the prompt" readline bug: the Python and Perl ports wrap the ANSI escapes in `\001`/`\002` (`RL_PROMPT_START_IGNORE`/`RL_PROMPT_END_IGNORE`) markers (disabling `Term::ReadLine` ornaments in Perl), and the Go port's built-in editor tracks the cursor with its own column math (terminal auto-wrap aware) and redraws from the first line of the buffer, so wrapped input stays clean at any terminal width.
|
The Go port's built-in editor tracks the cursor with its own column math (terminal auto-wrap aware) and redraws from the first line of the buffer, so wrapped input stays clean at any terminal width.
|
||||||
|
|
||||||
When enabled, the interactive console uses a subtle ANSI palette: the pending-request status `...requesting...` is darkened bold, reasoning markers are cyan, reasoning text is dim, `[tool call: ...]` traces are yellow, `[tool result: ...]` headers are green, tool result bodies are dim (red for tool errors/unknown tools), and errors/network retries are red. Tool result payloads fed back to the LLM are never colored.
|
When enabled, the interactive console uses a subtle ANSI palette: the pending-request status `...requesting...` is darkened bold, reasoning markers are cyan, reasoning text is dim, `[tool call: ...]` traces are yellow, `[tool result: ...]` headers are green, tool result bodies are dim (red for tool errors/unknown tools), and errors/network retries are red. Tool result payloads fed back to the LLM are never colored.
|
||||||
|
|
||||||
@@ -156,29 +154,20 @@ MicroBantam (`mb`) is a compressed Perl 5 reference implementation of the same a
|
|||||||
|
|
||||||
- Full agentic loop: LLM calls, `shell_exec` / `run_subagent` tool execution with JSON argument validation (invalid args are fed back so the model can self-correct), and the 5-level subagent recursion depth limit
|
- Full agentic loop: LLM calls, `shell_exec` / `run_subagent` tool execution with JSON argument validation (invalid args are fed back so the model can self-correct), and the 5-level subagent recursion depth limit
|
||||||
- A `...requesting...` in-flight indicator: in-place on a TTY (`\r` overwrite, erased on completion), a plain line when output is piped
|
- A `...requesting...` in-flight indicator: in-place on a TTY (`\r` overwrite, erased on completion), a plain line when output is piped
|
||||||
- Session management: `/save`, `/list`, `/load <id>` (exact id only, no prefix matching), and auto-save to `~/.bantam/sessions/autosave.json` after every turn and on exit; session ids get `-1`, `-2`, ... suffixes on same-second collisions
|
- Session management: `/save`, `/list`, `/load <id>` (exact id only, no prefix matching), `/cfg <param> [val]`, and auto-save to `~/.bantam/sessions/autosave.json` after every turn and on exit; session ids get `-1`, `-2`, ... suffixes on same-second collisions
|
||||||
- Interactive mode (`/quit`, `/clear`, `/save`, `/list`, `/load <id>`, `/help`) and file input mode
|
- Interactive mode (`/quit`, `/clear`, `/save`, `/list`, `/load <id>`, `/cfg`, `/help`) and file input mode
|
||||||
- Same built-in default system prompt and `OPENAI_API_KEY` fallback as the full implementation
|
- Same built-in default system prompt and `OPENAI_API_KEY` fallback as the Go implementation
|
||||||
- Automatic recovery from `Invalid assistant message: content or tool_calls must be set` API errors: strips the last assistant message and retries
|
- Automatic recovery from `Invalid assistant message: content or tool_calls must be set` API errors: strips the last assistant message and retries
|
||||||
|
|
||||||
### What it drops
|
### What it drops
|
||||||
|
|
||||||
- Streaming (requests are non-streaming; `stream` is ignored)
|
- Streaming (requests are non-streaming; `stream` is ignored)
|
||||||
- ANSI coloring/styling (`color` is ignored)
|
- ANSI coloring/styling (`color` is ignored)
|
||||||
- `Term::ReadLine` line editing, Ctrl+J multi-line prompts and readline history (plain single-line prompts)
|
- Line editing, Ctrl+J multi-line prompts and history (plain single-line prompts)
|
||||||
- Fibonacci backoff network retries (a failed request aborts with an `API error` message)
|
- Fibonacci backoff network retries (a failed request aborts with an `API error` message)
|
||||||
- `/compact` context summarization
|
- `/compact` context summarization
|
||||||
|
|
||||||
### Jim Tcl port
|
### Running MicroBantam
|
||||||
|
|
||||||
`mb.tcl` is a **highly experimental** Jim Tcl port of the same agent (under 100 SLOC) with the same feature set as the Perl `mb`. It differs in three ways: it ships its own minimal HTTP client (raw sockets with chunked-transfer decoding) instead of `HTTP::Tiny`; it retries failed requests up to 3 times with a 2-second backoff instead of aborting on the first failure; and it relies on the external `timeout` command for `shell_exec` timeouts instead of `SIGALRM`. Run it the same way:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./mb.tcl # interactive (or: jimsh mb.tcl)
|
|
||||||
./mb.tcl prompt.txt # file input mode
|
|
||||||
```
|
|
||||||
|
|
||||||
### Running
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./mb # interactive (or: perl mb)
|
./mb # interactive (or: perl mb)
|
||||||
@@ -187,11 +176,8 @@ MicroBantam (`mb`) is a compressed Perl 5 reference implementation of the same a
|
|||||||
|
|
||||||
## Repository layout
|
## Repository layout
|
||||||
|
|
||||||
- `bantam.py` — Python reference implementation (stdlib only)
|
|
||||||
- `main.go`, `term_linux.go`, `term_bsd.go`, `term_windows.go`, `term_other.go` — Go implementation (stdlib only, module `code.luxferre.top/luxferre/bantam`)
|
- `main.go`, `term_linux.go`, `term_bsd.go`, `term_windows.go`, `term_other.go` — Go implementation (stdlib only, module `code.luxferre.top/luxferre/bantam`)
|
||||||
- `bantam.pl` — Perl 5 implementation (core modules only)
|
|
||||||
- `mb` — MicroBantam, compressed Perl 5 implementation (core modules only, under 100 SLOC)
|
- `mb` — MicroBantam, compressed Perl 5 implementation (core modules only, under 100 SLOC)
|
||||||
- `mb.tcl` — MicroBantam, Jim Tcl port (requires `jimsh` with the `json` and `ssl` extensions; under 100 SLOC)
|
|
||||||
- `model.cfg`, `system.txt` — shared configuration and system prompt
|
- `model.cfg`, `system.txt` — shared configuration and system prompt
|
||||||
- `README.md` — this document
|
- `README.md` — this document
|
||||||
|
|
||||||
@@ -219,9 +205,9 @@ You can pair Bantam with the [Dynagate](https://code.luxferre.top/luxferre/dynag
|
|||||||
|
|
||||||
### How to run on mobiles?
|
### How to run on mobiles?
|
||||||
|
|
||||||
On Android, any current Bantam/MicroBantam implementation is easily runnable within the Termux environment. Go implementation is preferred for performance reasons.
|
On Android, Bantam (Go) and MicroBantam (`mb`) are easily runnable within the Termux environment. The Go implementation is preferred for performance reasons.
|
||||||
|
|
||||||
On iOS/iPadOS, the easiest way to use Bantam is to run the Perl version (or MicroBantam) inside iSH. Some terminal features may not be available (run with `rlwrap` to bring them back), but the agent itself is fully functional.
|
On iOS/iPadOS, the easiest way to use Bantam is to run MicroBantam (`mb`) inside iSH. Some terminal features may not be available (run with `rlwrap` to bring them back), but the agent itself is fully functional.
|
||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
|
|||||||
@@ -1,476 +0,0 @@
|
|||||||
#!/usr/bin/env perl
|
|
||||||
# Bantam agent: tiny, powerful, DIY
|
|
||||||
# Created by Luxferre in 2026, released into the public domain
|
|
||||||
|
|
||||||
use strict; use warnings;
|
|
||||||
use HTTP::Tiny; use JSON::PP; use File::Spec;
|
|
||||||
|
|
||||||
# core IO::Socket::IP 0.44 emits a spurious "Use of uninitialized value $err"
|
|
||||||
# warning on every timed connect (getsockopt(SO_ERROR) returns undef when the
|
|
||||||
# connection succeeds). Filter that exact noise out on our side; pass through
|
|
||||||
# everything else.
|
|
||||||
$SIG{__WARN__} = sub {
|
|
||||||
my $m = shift;
|
|
||||||
return if $m =~ /^Use of uninitialized value \$err in numeric eq \(==\) at .*IO\/Socket\/IP\.pm line \d+/;
|
|
||||||
warn $m;
|
|
||||||
};
|
|
||||||
|
|
||||||
binmode $_ => ':encoding(UTF-8)' for *STDIN, *STDOUT, *STDERR;
|
|
||||||
|
|
||||||
use File::Path qw(make_path); use POSIX qw(strftime); use Term::ReadLine;
|
|
||||||
use constant MAX_DEPTH => 5;
|
|
||||||
|
|
||||||
my $home = $ENV{HOME} || $ENV{USERPROFILE} || '.';
|
|
||||||
my $hist_file = File::Spec->catfile($home, '.bantam_history');
|
|
||||||
my $sdir = File::Spec->catfile($home, '.bantam', 'sessions');
|
|
||||||
my $auto_file = File::Spec->catfile($sdir, 'autosave.json');
|
|
||||||
my $_col = 0;
|
|
||||||
|
|
||||||
sub trim { my $s = shift // ''; $s =~ s/^\s+|\s+$//g; $s }
|
|
||||||
sub c { my ($t, @cs) = @_; ($_col && @cs) ? "\e[" . join(';', @cs) . "m$t\e[0m" : $t }
|
|
||||||
sub cp { my ($t, @cs) = @_; ($_col && @cs) ? "\001\e[" . join(';', @cs) . "m\002$t\001\e[0m\002" : $t }
|
|
||||||
|
|
||||||
sub col {
|
|
||||||
my ($cfg) = @_;
|
|
||||||
return 0 if $ENV{NO_COLOR} || $ENV{BANTAM_NO_COLOR};
|
|
||||||
my $m = lc(trim($cfg->{color} // 'auto'));
|
|
||||||
return 1 if $m eq 'always';
|
|
||||||
return 0 if $m eq 'never';
|
|
||||||
return -t STDOUT ? 1 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
sub get_cfg {
|
|
||||||
my ($path) = @_;
|
|
||||||
$path //= 'model.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', stream => 'true', color => 'auto');
|
|
||||||
if (-f $path) {
|
|
||||||
open my $fh, '<:encoding(UTF-8)', $path or die "Cannot open $path: $!";
|
|
||||||
while (my $ln = <$fh>) {
|
|
||||||
$ln = trim($ln); next if !$ln || $ln =~ /^#/ || $ln !~ /=/;
|
|
||||||
my ($k, $v) = split /=/, $ln, 2;
|
|
||||||
$d{trim($k)} = trim($v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$d{api_key} = $ENV{OPENAI_API_KEY} if (!$d{api_key} || $d{api_key} eq '-') && $ENV{OPENAI_API_KEY};
|
|
||||||
return \%d;
|
|
||||||
}
|
|
||||||
|
|
||||||
sub set_cfg {
|
|
||||||
my ($path, $k, $v) = @_;
|
|
||||||
$path //= 'model.cfg';
|
|
||||||
my (@lines, $found);
|
|
||||||
if (-f $path) {
|
|
||||||
open my $fh, '<:encoding(UTF-8)', $path or return;
|
|
||||||
while (my $ln = <$fh>) {
|
|
||||||
my $s = trim($ln);
|
|
||||||
if ($s !~ /^#/ && $s =~ /=/) {
|
|
||||||
my ($pk) = split /=/, $s, 2;
|
|
||||||
if (trim($pk) eq $k) {
|
|
||||||
push @lines, "$k=$v\n";
|
|
||||||
$found = 1;
|
|
||||||
next;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
push @lines, $ln;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!$found) {
|
|
||||||
push @lines, "\n" if @lines && $lines[-1] !~ /\n$/;
|
|
||||||
push @lines, "$k=$v\n";
|
|
||||||
}
|
|
||||||
open my $fh, '>:encoding(UTF-8)', $path or return;
|
|
||||||
print $fh @lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
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.";
|
|
||||||
|
|
||||||
sub prompt {
|
|
||||||
my ($p) = @_;
|
|
||||||
$p //= 'system.txt';
|
|
||||||
if (-f $p) {
|
|
||||||
open my $fh, '<:encoding(UTF-8)', $p or return $def_sp;
|
|
||||||
local $/; my $ct = trim(<$fh>);
|
|
||||||
return length $ct ? $ct : $def_sp;
|
|
||||||
}
|
|
||||||
return $def_sp;
|
|
||||||
}
|
|
||||||
|
|
||||||
sub T { my ($n, $d, $p) = @_; { type => 'function', function => { name => $n, description => $d, parameters => { type => 'object', properties => $p, required => [keys %$p] } } } }
|
|
||||||
my @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' } })
|
|
||||||
);
|
|
||||||
|
|
||||||
sub shell_exec {
|
|
||||||
my ($cmd, $t) = @_;
|
|
||||||
$t //= 120;
|
|
||||||
my ($res, $code) = ('', 0);
|
|
||||||
eval {
|
|
||||||
local $SIG{ALRM} = sub { die "timeout\n" };
|
|
||||||
alarm(int($t));
|
|
||||||
$res = `$cmd 2>&1` // '';
|
|
||||||
$code = $? >> 8;
|
|
||||||
alarm(0);
|
|
||||||
};
|
|
||||||
if ($@ && $@ =~ /timeout/) {
|
|
||||||
return trim($res) . "\n\n[shell timeout after ${t}s]\nexit: -1";
|
|
||||||
}
|
|
||||||
return trim($res) . "\n\nexit: $code";
|
|
||||||
}
|
|
||||||
|
|
||||||
sub sanitize_msgs {
|
|
||||||
my ($msgs) = @_;
|
|
||||||
return unless $msgs && ref($msgs) eq 'ARRAY';
|
|
||||||
for my $m (@$msgs) {
|
|
||||||
next unless ref($m) eq 'HASH' && ($m->{role} // '') eq 'assistant' && $m->{tool_calls};
|
|
||||||
for my $tc (@{$m->{tool_calls}}) {
|
|
||||||
next unless ref($tc) eq 'HASH' && $tc->{function};
|
|
||||||
my $astr = $tc->{function}{arguments} // '{}';
|
|
||||||
my $a = eval { decode_json($astr) };
|
|
||||||
if ($@ || ref($a) ne 'HASH') {
|
|
||||||
$tc->{function}{arguments} = encode_json({ invalid_raw => $astr // '' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
my @fib = (1, 1, 2, 3, 5, 8, 13, 21, 34);
|
|
||||||
|
|
||||||
sub llm {
|
|
||||||
my ($cfg, $msgs, $tools) = @_;
|
|
||||||
sanitize_msgs($msgs);
|
|
||||||
my $ep = $cfg->{endpoint} =~ s/\/+$//r;
|
|
||||||
my $url = "$ep/chat/completions";
|
|
||||||
my %h = ('Content-Type' => 'application/json', 'User-Agent' => 'Mozilla/5.0 (compatible; Bantam/1.0)');
|
|
||||||
$h{Authorization} = "Bearer $cfg->{api_key}" if $cfg->{api_key} && $cfg->{api_key} ne '-';
|
|
||||||
my $st = ($cfg->{stream} // 'true') =~ /^(true|1|yes)$/i;
|
|
||||||
my %p = (model => $cfg->{model}, temperature => 0 + ($cfg->{temperature} // 0.7), messages => $msgs, stream => $st ? \1 : \0);
|
|
||||||
for my $k (keys %$cfg) {
|
|
||||||
next if $k =~ /^(endpoint|api_key|timeout|shell_timeout|max_al_iterations|color)$/;
|
|
||||||
my $val = eval { decode_json($cfg->{$k}) };
|
|
||||||
$p{$k} = defined $val ? $val : $cfg->{$k};
|
|
||||||
}
|
|
||||||
$p{messages} = $msgs;
|
|
||||||
$p{stream} = $st ? \1 : \0;
|
|
||||||
$p{tools} = $tools if $tools && @$tools;
|
|
||||||
my $body = encode_json(\%p);
|
|
||||||
my $http = HTTP::Tiny->new(timeout => 0 + ($cfg->{timeout} // 300));
|
|
||||||
my $pend = c("...requesting...", 1, 2);
|
|
||||||
|
|
||||||
for my $i (0 .. $#fib + 1) {
|
|
||||||
my $dly = $i <= $#fib ? $fib[$i] : 0;
|
|
||||||
print $_col ? "\r$pend" : "$pend\n"; STDOUT->flush();
|
|
||||||
if (!$st) {
|
|
||||||
my $res = $http->request('POST', $url, { headers => \%h, content => $body });
|
|
||||||
if ($res->{success}) {
|
|
||||||
print "\r\e[K" if $_col; STDOUT->flush();
|
|
||||||
my $d = eval { decode_json($res->{content}) };
|
|
||||||
return $d->{choices}[0]{message} if $d && $d->{choices} && @{$d->{choices}};
|
|
||||||
}
|
|
||||||
my $status = $res->{status} // 0;
|
|
||||||
my $body = $res->{content} // '';
|
|
||||||
$body = substr($body, 0, 500) if length($body) > 500;
|
|
||||||
my $err = length($body) ? "$body (HTTP $status)" : ($res->{reason} || "HTTP status $status");
|
|
||||||
print "\r\e[K" if $_col; STDOUT->flush();
|
|
||||||
die "[HTTP error: $err]\n" if $status >= 400 && $status < 500 && $status != 408 && $status != 429;
|
|
||||||
if ($i <= $#fib) { print c("[network error: $err, retrying in ${dly}s...]", 31), "\n"; sleep($dly); next; }
|
|
||||||
die "[network error: $err]\n";
|
|
||||||
} else {
|
|
||||||
my ($content, $reas, $rh, $ch, $buf) = (q{}, q{}, 0, 0, q{});
|
|
||||||
my (%tcs, @order);
|
|
||||||
my $res = $http->request('POST', $url, {
|
|
||||||
headers => \%h, content => $body,
|
|
||||||
data_callback => sub {
|
|
||||||
my ($chunk) = @_;
|
|
||||||
if ($_col && !$buf && !$content && !$reas) { print "\r\e[K"; STDOUT->flush(); }
|
|
||||||
$buf .= $chunk;
|
|
||||||
while ($buf =~ s/^(.*?)\r?\n//) {
|
|
||||||
my $ln = trim($1);
|
|
||||||
next unless $ln =~ /^data:/;
|
|
||||||
my $data = trim(substr($ln, 5));
|
|
||||||
last if $data eq '[DONE]';
|
|
||||||
my $d = eval { decode_json($data) };
|
|
||||||
next unless $d && $d->{choices} && @{$d->{choices}};
|
|
||||||
my $dl = $d->{choices}[0]{delta} || {};
|
|
||||||
my $rc = $dl->{reasoning_content} // $dl->{reasoning};
|
|
||||||
if (defined $rc && length $rc) {
|
|
||||||
print c("--- reasoning start ---", 36), "\n" if !$rh; $rh = 1;
|
|
||||||
print c($rc, 2); STDOUT->flush(); $reas .= $rc;
|
|
||||||
}
|
|
||||||
my $cc = $dl->{content};
|
|
||||||
if (defined $cc && length $cc) {
|
|
||||||
print "\n", c("--- reasoning end ---", 36), "\n\n" if $rh && !$ch; $ch = 1;
|
|
||||||
print $cc; STDOUT->flush(); $content .= $cc;
|
|
||||||
}
|
|
||||||
if ($dl->{tool_calls}) {
|
|
||||||
for my $tc (@{$dl->{tool_calls}}) {
|
|
||||||
my $ti = $tc->{index} // 0;
|
|
||||||
if (!$tcs{$ti}) { $tcs{$ti} = {id => '', type => 'function', function => {name => '', arguments => ''}}; push @order, $ti; }
|
|
||||||
$tcs{$ti}{id} = $tc->{id} if $tc->{id};
|
|
||||||
if ($tc->{function}) {
|
|
||||||
$tcs{$ti}{function}{name} .= $tc->{function}{name} if $tc->{function}{name};
|
|
||||||
$tcs{$ti}{function}{arguments} .= $tc->{function}{arguments} if $tc->{function}{arguments};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if ($res->{success}) {
|
|
||||||
print "\r\e[K" if $_col && !$content && !$reas && !%tcs;
|
|
||||||
print "\n", c("--- reasoning end ---", 36), "\n" if $rh && !$ch;
|
|
||||||
print "\n" if $ch;
|
|
||||||
STDOUT->flush();
|
|
||||||
my %msg = (role => 'assistant');
|
|
||||||
$msg{content} = $content if length $content;
|
|
||||||
$msg{reasoning_content} = $reas if length $reas;
|
|
||||||
$msg{tool_calls} = [map { $tcs{$_} } @order] if @order;
|
|
||||||
return \%msg;
|
|
||||||
}
|
|
||||||
my $status = $res->{status} // 0;
|
|
||||||
my $body = $res->{content} // '';
|
|
||||||
$body = substr($body, 0, 500) if length($body) > 500;
|
|
||||||
my $err = length($body) ? "$body (HTTP $status)" : ($res->{reason} || "HTTP status $status");
|
|
||||||
print "\r\e[K" if $_col; STDOUT->flush();
|
|
||||||
die "[HTTP error: $err]\n" if $status >= 400 && $status < 500 && $status != 408 && $status != 429;
|
|
||||||
if ($i <= $#fib) { print c("[network error: $err, retrying in ${dly}s...]", 31), "\n"; sleep($dly); next; }
|
|
||||||
die "[network error: $err]\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sub AL {
|
|
||||||
my ($cfg, $msgs, $sp, $depth) = @_;
|
|
||||||
$depth //= 0;
|
|
||||||
my $stime = 0 + ($cfg->{shell_timeout} // 120);
|
|
||||||
my $mx = 0 + ($cfg->{max_al_iterations} // 1000);
|
|
||||||
my $st = ($cfg->{stream} // 'true') =~ /^(true|1|yes)$/i;
|
|
||||||
|
|
||||||
for my $i (1 .. $mx) {
|
|
||||||
my $m = eval { llm($cfg, $msgs, \@tools) };
|
|
||||||
if ($@) {
|
|
||||||
if ($@ =~ /Invalid assistant message|content or tool_calls must be set/) {
|
|
||||||
my $stripped = 0;
|
|
||||||
for (my $j = @$msgs - 1; $j >= 0; $j--) {
|
|
||||||
if (($msgs->[$j]{role} // '') eq 'assistant') {
|
|
||||||
print c("[stripped malformed assistant message]", 33), "\n";
|
|
||||||
splice @$msgs, $j, 1;
|
|
||||||
$stripped = 1;
|
|
||||||
last;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
redo if $stripped;
|
|
||||||
}
|
|
||||||
print c($@, 31); return $msgs;
|
|
||||||
}
|
|
||||||
push @$msgs, $m;
|
|
||||||
if (!$st) {
|
|
||||||
my $reas = $m->{reasoning_content} // $m->{reasoning};
|
|
||||||
print c("--- reasoning start ---", 36), "\n", c($reas, 2), "\n", c("--- reasoning end ---", 36), "\n" if defined $reas && length $reas;
|
|
||||||
print $m->{content}, "\n" if defined $m->{content} && length $m->{content};
|
|
||||||
}
|
|
||||||
my $tcs = $m->{tool_calls};
|
|
||||||
last if !$tcs || !@$tcs;
|
|
||||||
for my $tc (@$tcs) {
|
|
||||||
my $fn = $tc->{function}{name} // '';
|
|
||||||
my $astr = $tc->{function}{arguments} // '{}';
|
|
||||||
print c("[tool call: $fn($astr)]", 33), "\n";
|
|
||||||
my ($res, $sty) = ('', 2);
|
|
||||||
my $a = eval { decode_json($astr) };
|
|
||||||
if ($@ || ref($a) ne 'HASH') {
|
|
||||||
$tc->{function}{arguments} = encode_json({ invalid_raw => $astr });
|
|
||||||
$res = "[tool error: invalid JSON args for $fn: " . ($@ || 'not a JSON object') . ". Raw: '$astr']"; $sty = 31;
|
|
||||||
} elsif ($fn eq 'shell_exec') {
|
|
||||||
$res = shell_exec($a->{command} // '', $stime);
|
|
||||||
} elsif ($fn eq 'run_subagent') {
|
|
||||||
if ($depth >= MAX_DEPTH) {
|
|
||||||
$res = "[subagent depth limit (" . MAX_DEPTH . ") reached, child not spawned]"; $sty = 31;
|
|
||||||
} else {
|
|
||||||
my $sub = [{ role => 'system', content => "$sp\n\nImportant: this is a child agent" }, { role => 'user', content => $a->{prompt} // '' }];
|
|
||||||
$res = last_assistant(AL($cfg, $sub, $sp, $depth + 1));
|
|
||||||
}
|
|
||||||
} else { $res = "Unknown tool: $fn"; $sty = 31; }
|
|
||||||
print c("[tool result: $fn]", 32), "\n", c($res, $sty), "\n\n";
|
|
||||||
push @$msgs, { role => 'tool', tool_call_id => $tc->{id}, content => $res };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $msgs;
|
|
||||||
}
|
|
||||||
|
|
||||||
sub last_assistant {
|
|
||||||
for my $m (reverse @{$_[0]}) {
|
|
||||||
return $m->{content} if $m->{role} eq 'assistant' && defined $m->{content} && length $m->{content};
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
sub sdir { make_path($sdir) if !-d $sdir; $sdir }
|
|
||||||
|
|
||||||
sub summary {
|
|
||||||
for my $m (@{$_[0]}) {
|
|
||||||
if ($m->{role} eq 'user' && defined $m->{content} && trim($m->{content}) ne '') {
|
|
||||||
my $t = join(' ', split(/\s+/, trim($m->{content})));
|
|
||||||
return length($t) > 80 ? substr($t, 0, 80) . '...' : $t;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '(empty session)';
|
|
||||||
}
|
|
||||||
|
|
||||||
sub save_session {
|
|
||||||
sdir();
|
|
||||||
my $base = strftime('%Y%m%d-%H%M%S', localtime);
|
|
||||||
my ($sid, $path, $i) = ($base, File::Spec->catfile($sdir, "$base.json"), 1);
|
|
||||||
while (-f $path) { $i++; $sid = "$base-$i"; $path = File::Spec->catfile($sdir, "$sid.json"); }
|
|
||||||
my $sum = summary($_[0]);
|
|
||||||
my %data = (id => $sid, created => strftime('%Y-%m-%d %H:%M:%S', localtime), summary => $sum, messages => $_[0]);
|
|
||||||
open my $fh, '>:encoding(UTF-8)', $path or die "Cannot save session: $!";
|
|
||||||
print $fh JSON::PP->new->utf8->pretty->encode(\%data);
|
|
||||||
return ($sid, $sum);
|
|
||||||
}
|
|
||||||
|
|
||||||
sub list_sessions {
|
|
||||||
my @out;
|
|
||||||
if (-d $sdir) {
|
|
||||||
opendir my $dh, $sdir or return ();
|
|
||||||
while (my $fn = readdir $dh) {
|
|
||||||
next unless $fn =~ /\.json$/;
|
|
||||||
open my $fh, '<:encoding(UTF-8)', File::Spec->catfile($sdir, $fn) or next;
|
|
||||||
local $/; my $d = eval { decode_json(<$fh>) }; next unless $d;
|
|
||||||
push @out, [$d->{id} // substr($fn, 0, -5), $d->{created} // '', $d->{summary} // '', ref($d->{messages}) eq 'ARRAY' ? scalar(@{$d->{messages}}) : 0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sort { $b->[0] cmp $a->[0] } @out;
|
|
||||||
}
|
|
||||||
|
|
||||||
sub load_session {
|
|
||||||
my ($sid) = @_;
|
|
||||||
my @entries;
|
|
||||||
if (-d $sdir) {
|
|
||||||
opendir my $dh, $sdir or die "Session directory not found\n";
|
|
||||||
while (my $fn = readdir $dh) {
|
|
||||||
next unless $fn =~ /\.json$/;
|
|
||||||
open my $fh, '<:encoding(UTF-8)', File::Spec->catfile($sdir, $fn) or next;
|
|
||||||
local $/; my $d = eval { decode_json(<$fh>) }; next unless $d;
|
|
||||||
push @entries, [$d->{id} // substr($fn, 0, -5), $d->{messages}];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
my ($hit) = grep { $_->[0] eq $sid } @entries;
|
|
||||||
if (!$hit) {
|
|
||||||
my @pref = grep { $_->[0] =~ /^\Q$sid\E/ } @entries;
|
|
||||||
$hit = $pref[0] if @pref == 1;
|
|
||||||
die "ambiguous prefix: " . join(', ', map { $_->[0] } @pref) . "\n" if @pref > 1;
|
|
||||||
}
|
|
||||||
die "$sid\n" if !$hit;
|
|
||||||
return $hit->[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
sub autosave {
|
|
||||||
sdir();
|
|
||||||
my %data = (id => 'autosave', created => strftime('%Y-%m-%d %H:%M:%S', localtime), summary => summary($_[0]), messages => $_[0]);
|
|
||||||
open my $fh, '>:encoding(UTF-8)', $auto_file or return;
|
|
||||||
print $fh JSON::PP->new->utf8->pretty->encode(\%data);
|
|
||||||
}
|
|
||||||
|
|
||||||
sub compact_conv {
|
|
||||||
my ($cfg, $msgs) = @_;
|
|
||||||
return ($msgs, '', 'session has no system message') if !@$msgs || ($msgs->[0]{role} // '') ne 'system';
|
|
||||||
my @conv;
|
|
||||||
for my $m (@$msgs) {
|
|
||||||
next if ($m->{role} // '') eq 'system';
|
|
||||||
my $ct = $m->{content} // '';
|
|
||||||
$ct = encode_json([map { { function => { name => $_->{function}{name}, arguments => $_->{function}{arguments} } } } @{$m->{tool_calls}}]) if !length($ct) && $m->{tool_calls};
|
|
||||||
next unless length $ct;
|
|
||||||
$ct = substr($ct, 0, 4000) . '...[truncated]' if length($ct) > 4000;
|
|
||||||
push @conv, ($m->{role} // '?') . ": $ct";
|
|
||||||
}
|
|
||||||
return ($msgs, '', 'no conversation to summarize') unless @conv;
|
|
||||||
my $joined = join("\n\n", @conv);
|
|
||||||
$joined = substr($joined, -100000) . "\n...[earlier parts truncated]" if length($joined) > 100000;
|
|
||||||
my $sys = "You are a conversation summarizer for an AI agent's context window. Summarize concisely but completely, preserving all important facts, decisions, code, errors, and the current task state, so the agent can continue the work without the original messages. Output only the summary.";
|
|
||||||
my %cc = (%$cfg, stream => 'false');
|
|
||||||
my $sm = [{ role => 'system', content => $sys }, { role => 'user', content => "Summarize this conversation:\n\n$joined" }];
|
|
||||||
my $m = eval { llm(\%cc, $sm, []) };
|
|
||||||
return ($msgs, '', "LLM error: $@") if $@;
|
|
||||||
my $s = trim($m->{content} // $m->{reasoning_content} // '');
|
|
||||||
return ($msgs, '', 'LLM returned an empty summary') unless length $s;
|
|
||||||
return ([{ role => 'system', content => $msgs->[0]{content} }, { role => 'user', content => "Summary of the previous conversation:\n$s\n\nPlease continue from here." }], $s, undef);
|
|
||||||
}
|
|
||||||
|
|
||||||
sub main {
|
|
||||||
my $sp = prompt(); my $cfg = get_cfg(); $_col = col($cfg);
|
|
||||||
my $msgs = [{ role => 'system', content => $sp }];
|
|
||||||
if (@ARGV && $ARGV[0]) {
|
|
||||||
my $p = $ARGV[0];
|
|
||||||
if (!-f $p) { print c("Error: file '$p' not found.", 31), "\n"; exit 1; }
|
|
||||||
open my $fh, '<:encoding(UTF-8)', $p or die "Cannot open $p: $!";
|
|
||||||
local $/; push @$msgs, { role => 'user', content => trim(<$fh>) };
|
|
||||||
AL($cfg, $msgs, $sp); autosave($msgs); exit 0;
|
|
||||||
}
|
|
||||||
print c("Bantam Agent ready", 1, 32), c(" (Ctrl+J = new line)", 2), "\n";
|
|
||||||
print c("endpoint: $cfg->{endpoint} model: $cfg->{model} temp: " . ($cfg->{temperature} // '0.7'), 2), "\n";
|
|
||||||
my $term = Term::ReadLine->new('bantam');
|
|
||||||
$Term::ReadLine::termcap_nowarn = 1; # silence termcap warning on stub Term::ReadLine
|
|
||||||
$term->ornaments(0) if $term->can('ornaments');
|
|
||||||
my $prompt_str = ref($term) eq 'Term::ReadLine::Gnu' ? cp("> ", 1, 36) : c("> ", 1, 36);
|
|
||||||
while (1) {
|
|
||||||
my $line = -t STDIN ? $term->readline($prompt_str) : do { print c("> ", 1, 36); scalar <STDIN> };
|
|
||||||
last unless defined $line;
|
|
||||||
my $u = trim($line); next unless length $u;
|
|
||||||
if ($u eq '/quit') { last; }
|
|
||||||
elsif ($u eq '/clear') { $msgs = [{ role => 'system', content => $sp }]; autosave($msgs); next; }
|
|
||||||
elsif ($u eq '/save') { my ($sid, $sm) = save_session($msgs); print c("[session saved: $sid]", 32), " ", c($sm, 2), "\n"; next; }
|
|
||||||
elsif ($u eq '/list') {
|
|
||||||
my @ss = list_sessions();
|
|
||||||
if (!@ss) { print c("No sessions saved yet.", 33), "\n"; next; }
|
|
||||||
for my $s (@ss) {
|
|
||||||
my ($sid, $st, $sm, $n) = @$s;
|
|
||||||
my $mk = ($sid eq 'autosave') ? c(" (autosave)", 33) : '';
|
|
||||||
print c($sid, 32), $mk, c(" $st [$n msgs]", 2), "\n ", c($sm, 2), "\n";
|
|
||||||
}
|
|
||||||
next;
|
|
||||||
} elsif ($u =~ /^\/load(?:\s+(.*))?$/) {
|
|
||||||
my $target = trim($1 // '');
|
|
||||||
if (!length $target) { print c("Usage: /load <session-id>", 31), "\n"; next; }
|
|
||||||
my $loaded = eval { load_session($target) };
|
|
||||||
if ($@) { print c("Session not found: " . trim($@), 31), "\n"; next; }
|
|
||||||
$msgs = $loaded; autosave($msgs);
|
|
||||||
print c("[session loaded: $target]", 32), " ", c(summary($msgs), 2), "\n";
|
|
||||||
next;
|
|
||||||
} elsif ($u eq '/compact') {
|
|
||||||
if (@$msgs <= 1) { print c("Nothing to compact yet.", 33), "\n"; next; }
|
|
||||||
print c("[compacting conversation...]", 33), "\n";
|
|
||||||
my ($nm, $sm, $err) = compact_conv($cfg, $msgs);
|
|
||||||
if ($err) { print c("[compact failed: $err]", 31), "\n"; next; }
|
|
||||||
$msgs = $nm; autosave($msgs);
|
|
||||||
print c("[compacted to " . scalar(@$msgs) . " messages]", 32), "\n";
|
|
||||||
print c("--- summary ---", 33), "\n", c($sm, 2), "\n";
|
|
||||||
next;
|
|
||||||
} elsif ($u =~ /^\/cfg(?:\s+(\S+)(?:\s+(.+))?)?$/) {
|
|
||||||
my ($k, $v) = ($1, $2);
|
|
||||||
if (defined $v) {
|
|
||||||
$v = trim($v);
|
|
||||||
set_cfg('model.cfg', $k, $v);
|
|
||||||
$cfg = get_cfg('model.cfg');
|
|
||||||
$_col = col($cfg);
|
|
||||||
print c("[config updated: $k=$v]", 32), "\n";
|
|
||||||
} elsif (defined $k) {
|
|
||||||
if (exists $cfg->{$k}) { print c("$k=$cfg->{$k}", 32), "\n"; }
|
|
||||||
else { print c("$k not set", 31), "\n"; }
|
|
||||||
} else {
|
|
||||||
print c("Usage: /cfg <param> [val]", 31), "\n";
|
|
||||||
}
|
|
||||||
next;
|
|
||||||
} elsif ($u eq '/help') {
|
|
||||||
print c("Bantam commands:", 1, 36), "\n";
|
|
||||||
my @cmds = (["/quit", "exit"], ["/clear", "reset to system prompt"], ["/save", "save session"], ["/list", "list sessions"], ["/load <id>", "load session"], ["/compact", "compact context"], ["/cfg <k> [v]", "get/set config"], ["/help", "show help"]);
|
|
||||||
for my $kv (@cmds) { printf "%s%s\n", c(sprintf(" %-15s", $kv->[0]), 1, 32), $kv->[1]; }
|
|
||||||
next;
|
|
||||||
}
|
|
||||||
push @$msgs, { role => 'user', content => $u };
|
|
||||||
AL($cfg, $msgs, $sp); autosave($msgs);
|
|
||||||
}
|
|
||||||
autosave($msgs);
|
|
||||||
}
|
|
||||||
|
|
||||||
main() if !caller();
|
|
||||||
1;
|
|
||||||
@@ -1,380 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# Bantam agent: tiny, powerful, DIY
|
|
||||||
# Created by Luxferre in 2026, released into the public domain
|
|
||||||
|
|
||||||
import sys, os, json, re, time, subprocess, urllib.request
|
|
||||||
try: import readline
|
|
||||||
except ImportError: readline = None
|
|
||||||
|
|
||||||
HIST = os.path.expanduser("~/.bantam_history")
|
|
||||||
SDIR = os.path.expanduser("~/.bantam/sessions")
|
|
||||||
AUTO = os.path.join(SDIR, "autosave.json")
|
|
||||||
_COL = False
|
|
||||||
|
|
||||||
def c(t, *cs): return t if not _COL or not cs else "\033[" + ";".join(map(str, cs)) + "m" + t + "\033[0m"
|
|
||||||
|
|
||||||
def cp(t, *cs): # colored prompt: hide ANSI escapes from readline's width calc (\001/\002 = RL_PROMPT_START/END_IGNORE)
|
|
||||||
if readline is None or not _COL or not cs: return c(t, *cs)
|
|
||||||
return "\001\033[" + ";".join(map(str, cs)) + "m\002" + t + "\001\033[0m\002"
|
|
||||||
|
|
||||||
def col(cfg):
|
|
||||||
if os.environ.get("NO_COLOR") or os.environ.get("BANTAM_NO_COLOR"): return False
|
|
||||||
m = str(cfg.get("color", "auto")).strip().lower()
|
|
||||||
if m == "always": return True
|
|
||||||
if m == "never": return False
|
|
||||||
try: return sys.stdout.isatty()
|
|
||||||
except Exception: return False
|
|
||||||
|
|
||||||
def get_cfg(path="model.cfg"):
|
|
||||||
d = {"endpoint": "https://opencode.ai/zen/v1", "model": "big-pickle", "temperature": "0.7", "api_key": "-", "timeout": "300", "shell_timeout": "120", "max_al_iterations": "1000", "stream": "true", "color": "auto"}
|
|
||||||
if os.path.exists(path):
|
|
||||||
for ln in open(path, encoding="utf-8"):
|
|
||||||
ln = ln.strip()
|
|
||||||
if ln and not ln.startswith("#") and "=" in ln:
|
|
||||||
k, v = ln.split("=", 1); d[k.strip()] = v.strip()
|
|
||||||
if not d.get("api_key") and "OPENAI_API_KEY" in os.environ: d["api_key"] = os.environ["OPENAI_API_KEY"]
|
|
||||||
return d
|
|
||||||
|
|
||||||
def set_cfg(path, k, v):
|
|
||||||
lines, found = [], False
|
|
||||||
if os.path.exists(path):
|
|
||||||
for ln in open(path, encoding="utf-8"):
|
|
||||||
s = ln.strip()
|
|
||||||
if not s.startswith("#") and "=" in s and s.split("=", 1)[0].strip() == k:
|
|
||||||
lines.append(f"{k}={v}\n")
|
|
||||||
found = True
|
|
||||||
else:
|
|
||||||
lines.append(ln)
|
|
||||||
if not found:
|
|
||||||
if lines and not lines[-1].endswith("\n"): lines[-1] += "\n"
|
|
||||||
lines.append(f"{k}={v}\n")
|
|
||||||
with open(path, "w", encoding="utf-8") as f: f.writelines(lines)
|
|
||||||
|
|
||||||
def num(cfg, k, d):
|
|
||||||
try: return type(d)(cfg.get(k, d))
|
|
||||||
except (TypeError, ValueError): return d
|
|
||||||
|
|
||||||
DEFAULT_SYSTEM_PROMPT = """You are Bantam, a tiny, powerful AI agent. Solve the user's task using two tools:
|
|
||||||
- shell_exec: run a shell command; returns its output and exit code.
|
|
||||||
- run_subagent: delegate a sub-task to a child agent; returns its reply.
|
|
||||||
|
|
||||||
Work 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.
|
|
||||||
|
|
||||||
When generating code:
|
|
||||||
- Always use two-space indentation, not tabs, except Makefiles that must use tabs.
|
|
||||||
- No whitespace between keywords and opening braces in C-like languages.
|
|
||||||
- Write optimally and with as few third-party dependencies as possible.
|
|
||||||
- Always test.
|
|
||||||
- No emojis in code or documentation.
|
|
||||||
- Respect AGENTS.md, GEMINI.md, CLAUDE.md contents in the project."""
|
|
||||||
|
|
||||||
def prompt(path="system.txt"):
|
|
||||||
if os.path.exists(path): return open(path, encoding="utf-8").read().strip()
|
|
||||||
return DEFAULT_SYSTEM_PROMPT
|
|
||||||
|
|
||||||
def T(name, desc, props): return {"type": "function", "function": {"name": name, "description": desc, "parameters": {"type": "object", "properties": props, "required": list(props)}}}
|
|
||||||
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"}})]
|
|
||||||
|
|
||||||
def shell(cmd, t=120):
|
|
||||||
try:
|
|
||||||
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=float(t))
|
|
||||||
return f"{(r.stdout + r.stderr).strip()}\n\nexit: {r.returncode}"
|
|
||||||
except subprocess.TimeoutExpired as e:
|
|
||||||
out, err = e.stdout or "", e.stderr or ""
|
|
||||||
if isinstance(out, bytes): out = out.decode("utf-8", "replace")
|
|
||||||
if isinstance(err, bytes): err = err.decode("utf-8", "replace")
|
|
||||||
return f"{(out + err).strip()}\n\n[shell timeout after {t}s]\nexit: -1"
|
|
||||||
|
|
||||||
def sanitize_msgs(msgs):
|
|
||||||
for m in msgs:
|
|
||||||
if isinstance(m, dict) and m.get("role") == "assistant" and "tool_calls" in m:
|
|
||||||
tcs = m.get("tool_calls") or []
|
|
||||||
for tc in tcs:
|
|
||||||
if isinstance(tc, dict) and "function" in tc:
|
|
||||||
fn = tc.get("function") or {}
|
|
||||||
astr = fn.get("arguments", "{}")
|
|
||||||
try:
|
|
||||||
p = json.loads(astr) if astr else {}
|
|
||||||
if not isinstance(p, dict):
|
|
||||||
fn["arguments"] = json.dumps({"invalid_raw": astr} if astr else {})
|
|
||||||
except Exception:
|
|
||||||
fn["arguments"] = json.dumps({"invalid_raw": astr} if astr else {})
|
|
||||||
|
|
||||||
def is_invalid_assistant_err(e):
|
|
||||||
return bool(re.search(r"Invalid assistant message|content or tool_calls must be set", str(e)))
|
|
||||||
|
|
||||||
fib = [1, 1, 2, 3, 5, 8, 13, 21, 34]
|
|
||||||
|
|
||||||
def llm(cfg, msgs, tools):
|
|
||||||
sanitize_msgs(msgs)
|
|
||||||
url = cfg["endpoint"].rstrip("/") + "/chat/completions"
|
|
||||||
h = {"Content-Type": "application/json", "User-Agent": "Mozilla/5.0 (compatible; Bantam/1.0)"}
|
|
||||||
k = cfg.get("api_key", "").strip()
|
|
||||||
if k and k != "-": h["Authorization"] = "Bearer " + k
|
|
||||||
st = cfg.get("stream", "true").lower() in ("true", "1", "yes")
|
|
||||||
p = {"model": cfg["model"], "temperature": float(cfg.get("temperature", 0.7)), "messages": msgs, "stream": st}
|
|
||||||
for k, v in cfg.items():
|
|
||||||
if k in ("endpoint", "api_key", "timeout", "shell_timeout", "max_al_iterations", "color"): continue
|
|
||||||
try: p[k] = json.loads(v)
|
|
||||||
except Exception: p[k] = v
|
|
||||||
p["messages"], p["stream"] = msgs, st
|
|
||||||
if tools: p["tools"] = tools
|
|
||||||
pend = c("...requesting...", 1, 2)
|
|
||||||
for i, dly in enumerate(fib + [0]):
|
|
||||||
try:
|
|
||||||
if _COL: sys.stdout.write("\r" + pend); sys.stdout.flush()
|
|
||||||
else: sys.stdout.write(pend + "\n"); sys.stdout.flush()
|
|
||||||
req = urllib.request.Request(url, data=json.dumps(p).encode(), headers=h, method="POST")
|
|
||||||
with urllib.request.urlopen(req, timeout=num(cfg, "timeout", 300)) as r:
|
|
||||||
if not st:
|
|
||||||
msg = json.loads(r.read().decode())["choices"][0]["message"]
|
|
||||||
if _COL: sys.stdout.write("\r\033[K"); sys.stdout.flush()
|
|
||||||
return msg
|
|
||||||
if _COL: sys.stdout.write("\r\033[K"); sys.stdout.flush()
|
|
||||||
content, reas, tcs, rh, ch = "", "", {}, False, False
|
|
||||||
for ln in r:
|
|
||||||
ln = ln.decode("utf-8").strip()
|
|
||||||
if not ln.startswith("data:"): continue
|
|
||||||
if ln[5:].strip() == "[DONE]": break
|
|
||||||
try:
|
|
||||||
dl = json.loads(ln[5:].strip())["choices"][0].get("delta", {})
|
|
||||||
rc = dl.get("reasoning_content") or dl.get("reasoning")
|
|
||||||
if rc:
|
|
||||||
if not rh: sys.stdout.write(c("--- reasoning start ---", 36) + "\n"); rh = True
|
|
||||||
sys.stdout.write(c(rc, 2)); sys.stdout.flush(); reas += rc
|
|
||||||
cc = dl.get("content")
|
|
||||||
if cc:
|
|
||||||
if rh and not ch: sys.stdout.write("\n" + c("--- reasoning end ---", 36) + "\n\n")
|
|
||||||
ch = True; sys.stdout.write(cc); sys.stdout.flush(); content += cc
|
|
||||||
for tc in dl.get("tool_calls", []):
|
|
||||||
ti = tc.get("index", 0)
|
|
||||||
if ti not in tcs: tcs[ti] = {"id": tc.get("id", ""), "type": "function", "function": {"name": "", "arguments": ""}}
|
|
||||||
if tc.get("id"): tcs[ti]["id"] = tc["id"]
|
|
||||||
fn = tc.get("function")
|
|
||||||
if fn:
|
|
||||||
if fn.get("name"): tcs[ti]["function"]["name"] += fn["name"]
|
|
||||||
if fn.get("arguments"): tcs[ti]["function"]["arguments"] += fn["arguments"]
|
|
||||||
except Exception: pass
|
|
||||||
if rh and not ch: sys.stdout.write("\n" + c("--- reasoning end ---", 36) + "\n")
|
|
||||||
elif ch: sys.stdout.write("\n")
|
|
||||||
sys.stdout.flush()
|
|
||||||
m = {"role": "assistant", "content": content or None}
|
|
||||||
if reas: m["reasoning_content"] = reas
|
|
||||||
if tcs: m["tool_calls"] = list(tcs.values())
|
|
||||||
return m
|
|
||||||
except Exception as e:
|
|
||||||
if _COL: sys.stdout.write("\r\033[K"); sys.stdout.flush()
|
|
||||||
if isinstance(e, urllib.error.HTTPError):
|
|
||||||
code = e.code
|
|
||||||
body = e.read().decode("utf-8", "replace").strip() if hasattr(e, "read") else str(e)
|
|
||||||
if 400 <= code < 500 and code not in (408, 429):
|
|
||||||
raise RuntimeError(f"HTTP {code}: {body}")
|
|
||||||
if i < len(fib): print(c(f"[network error: {e}, retrying in {dly}s...]", 31)); time.sleep(dly)
|
|
||||||
else: raise
|
|
||||||
|
|
||||||
MAX_DEPTH = 5
|
|
||||||
|
|
||||||
def AL(cfg, msgs, sp, depth=0):
|
|
||||||
stime, mx, st = num(cfg, "shell_timeout", 120), num(cfg, "max_al_iterations", 1000), cfg.get("stream", "true").lower() in ("true", "1", "yes")
|
|
||||||
for _ in range(mx):
|
|
||||||
try:
|
|
||||||
m = llm(cfg, msgs, TOOLS)
|
|
||||||
except RuntimeError as e:
|
|
||||||
if is_invalid_assistant_err(e):
|
|
||||||
for i in range(len(msgs) - 1, -1, -1):
|
|
||||||
if msgs[i].get("role") == "assistant":
|
|
||||||
print(c("[stripped malformed assistant message]", 33))
|
|
||||||
del msgs[i]
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
continue
|
|
||||||
raise
|
|
||||||
msgs.append(m)
|
|
||||||
if not st:
|
|
||||||
reas = m.get("reasoning_content") or m.get("reasoning")
|
|
||||||
if reas: print(c("--- reasoning start ---", 36) + "\n" + c(reas, 2) + "\n" + c("--- reasoning end ---", 36) + "\n")
|
|
||||||
if m.get("content"): print(m["content"])
|
|
||||||
tcs = m.get("tool_calls")
|
|
||||||
if not tcs: break
|
|
||||||
for tc in tcs:
|
|
||||||
fn, astr = tc["function"]["name"], tc["function"].get("arguments", "{}")
|
|
||||||
print(c(f"[tool call: {fn}({astr})]", 33))
|
|
||||||
try:
|
|
||||||
a = json.loads(astr) if astr else {}
|
|
||||||
if not isinstance(a, dict): raise ValueError("args must be a JSON object")
|
|
||||||
err = ""
|
|
||||||
except Exception as e:
|
|
||||||
err, a = str(e), {}
|
|
||||||
tc["function"]["arguments"] = json.dumps({"invalid_raw": astr} if astr else {})
|
|
||||||
if err: res, sty = f"[tool error: invalid JSON args for {fn}: {err}. Raw: {astr!r}]", 31
|
|
||||||
elif fn == "shell_exec": res, sty = shell(a.get("command", ""), stime), 2
|
|
||||||
elif fn == "run_subagent":
|
|
||||||
if depth >= MAX_DEPTH:
|
|
||||||
res, sty = f"[subagent depth limit ({MAX_DEPTH}) reached, child not spawned]", 31
|
|
||||||
else:
|
|
||||||
sub = [{"role": "system", "content": sp + "\n\nImportant: this is a child agent"}, {"role": "user", "content": a.get("prompt", "")}]
|
|
||||||
res, sty = last(AL(cfg, sub, sp, depth + 1)), 2
|
|
||||||
else: res, sty = f"Unknown tool: {fn}", 31
|
|
||||||
print(c(f"[tool result: {fn}]", 32) + "\n" + c(res, sty) + "\n")
|
|
||||||
msgs.append({"role": "tool", "tool_call_id": tc["id"], "content": res})
|
|
||||||
else: msgs.append({"role": "assistant", "content": f"[max AL iterations ({mx}) reached]"})
|
|
||||||
return msgs
|
|
||||||
|
|
||||||
def last(msgs):
|
|
||||||
for m in reversed(msgs):
|
|
||||||
if m.get("role") == "assistant" and m.get("content"): return m["content"]
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def sdir(): os.makedirs(SDIR, exist_ok=True); return SDIR
|
|
||||||
|
|
||||||
def summary(msgs):
|
|
||||||
for m in msgs:
|
|
||||||
if m.get("role") == "user" and isinstance(m.get("content"), str) and m["content"].strip():
|
|
||||||
t = " ".join(m["content"].split()); return t[:80] + ("..." if len(t) > 80 else "")
|
|
||||||
return "(empty session)"
|
|
||||||
|
|
||||||
def save(msgs):
|
|
||||||
d, base, i = sdir(), time.strftime("%Y%m%d-%H%M%S"), 1
|
|
||||||
sid, path = base, os.path.join(d, base + ".json")
|
|
||||||
while os.path.exists(path): i += 1; sid = base + "-" + str(i); path = os.path.join(d, sid + ".json")
|
|
||||||
data = {"id": sid, "created": time.strftime("%Y-%m-%d %H:%M:%S"), "summary": summary(msgs), "messages": msgs}
|
|
||||||
open(path, "w", encoding="utf-8").write(json.dumps(data, ensure_ascii=False, indent=2))
|
|
||||||
return sid, data["summary"]
|
|
||||||
|
|
||||||
def sessions():
|
|
||||||
out = []
|
|
||||||
if os.path.isdir(SDIR):
|
|
||||||
for fn in os.listdir(SDIR):
|
|
||||||
if not fn.endswith(".json"): continue
|
|
||||||
try:
|
|
||||||
d = json.load(open(os.path.join(SDIR, fn), encoding="utf-8"))
|
|
||||||
out.append((d.get("id", fn[:-5]), d.get("created", ""), d.get("summary", ""), len(d.get("messages", []))))
|
|
||||||
except Exception: pass
|
|
||||||
return sorted(out, key=lambda x: x[0], reverse=True)
|
|
||||||
|
|
||||||
def load(sid):
|
|
||||||
entries = []
|
|
||||||
if os.path.isdir(SDIR):
|
|
||||||
for fn in os.listdir(SDIR):
|
|
||||||
if not fn.endswith(".json"): continue
|
|
||||||
try: entries.append((json.load(open(os.path.join(SDIR, fn), encoding="utf-8")).get("id", fn[:-5]), os.path.join(SDIR, fn)))
|
|
||||||
except Exception: pass
|
|
||||||
hit = next((e for e in entries if e[0] == sid), None)
|
|
||||||
if not hit:
|
|
||||||
pref = [e for e in entries if e[0].startswith(sid)]
|
|
||||||
if len(pref) == 1: hit = pref[0]
|
|
||||||
elif len(pref) > 1: raise KeyError("ambiguous prefix: " + ", ".join(e[0] for e in pref))
|
|
||||||
if not hit: raise KeyError(sid)
|
|
||||||
return json.load(open(hit[1], encoding="utf-8"))["messages"]
|
|
||||||
|
|
||||||
def autosave(msgs):
|
|
||||||
d = {"id": "autosave", "created": time.strftime("%Y-%m-%d %H:%M:%S"), "summary": summary(msgs), "messages": msgs}
|
|
||||||
open(os.path.join(sdir(), "autosave.json"), "w", encoding="utf-8").write(json.dumps(d, ensure_ascii=False, indent=2))
|
|
||||||
|
|
||||||
def summarize(cfg, msgs):
|
|
||||||
conv = []
|
|
||||||
for m in msgs:
|
|
||||||
if m.get("role") == "system": continue
|
|
||||||
ct = m.get("content")
|
|
||||||
if not ct and m.get("tool_calls"): ct = json.dumps([{"function": tc["function"]["name"], "arguments": tc["function"]["arguments"]} for tc in m["tool_calls"]], ensure_ascii=False)
|
|
||||||
if not ct: continue
|
|
||||||
if len(ct) > 4000: ct = ct[:4000] + "...[truncated]"
|
|
||||||
conv.append(f"{m.get('role', '?')}: {ct}")
|
|
||||||
if not conv: return "", "no conversation to summarize"
|
|
||||||
joined = "\n\n".join(conv)
|
|
||||||
if len(joined) > 100000: joined = joined[-100000:] + "\n...[earlier parts truncated]"
|
|
||||||
sm = [{"role": "system", "content": "You are a conversation summarizer for an AI agent's context window. Summarize concisely but completely, preserving all important facts, decisions, code, errors, and the current task state, so the agent can continue the work without the original messages. Output only the summary."},
|
|
||||||
{"role": "user", "content": "Summarize this conversation:\n\n" + joined}]
|
|
||||||
cc = dict(cfg); cc["stream"] = "false"
|
|
||||||
try: m = llm(cc, sm, [])
|
|
||||||
except Exception as e: return "", f"LLM error: {e}"
|
|
||||||
s = (m.get("content") or m.get("reasoning_content") or "").strip()
|
|
||||||
return (s, None) if s else ("", "LLM returned an empty summary")
|
|
||||||
|
|
||||||
def compact(cfg, msgs):
|
|
||||||
if not msgs or msgs[0].get("role") != "system": return msgs, "", "session has no system message"
|
|
||||||
s, err = summarize(cfg, msgs)
|
|
||||||
if err: return msgs, "", err
|
|
||||||
return [{"role": "system", "content": msgs[0]["content"]}, {"role": "user", "content": "Summary of the previous conversation:\n" + s + "\n\nPlease continue from here."}], s, None
|
|
||||||
|
|
||||||
def main():
|
|
||||||
global _COL
|
|
||||||
if readline:
|
|
||||||
try: readline.read_history_file(HIST)
|
|
||||||
except OSError: pass
|
|
||||||
try: readline.parse_and_bind('"\\C-j": "\\C-v\\C-j"') # real LF via quoted-insert
|
|
||||||
except Exception: pass
|
|
||||||
sp, cfg = prompt(), get_cfg()
|
|
||||||
_COL = col(cfg)
|
|
||||||
msgs = [{"role": "system", "content": sp}]
|
|
||||||
if len(sys.argv) > 1 and sys.argv[1]:
|
|
||||||
p = sys.argv[1]
|
|
||||||
if not os.path.exists(p): print(c(f"Error: file '{p}' not found.", 31)); sys.exit(1)
|
|
||||||
msgs.append({"role": "user", "content": open(p, encoding="utf-8").read().strip()})
|
|
||||||
AL(cfg, msgs, sp); autosave(msgs)
|
|
||||||
if readline:
|
|
||||||
try: readline.write_history_file(HIST)
|
|
||||||
except OSError: pass
|
|
||||||
sys.exit(0)
|
|
||||||
print(c("Bantam Agent ready", 1, 32) + c(" (Ctrl+J = new line)", 2))
|
|
||||||
print(c(f"endpoint: {cfg['endpoint']} model: {cfg['model']} temp: {cfg.get('temperature', '0.7')}", 2))
|
|
||||||
while True:
|
|
||||||
try: u = input(cp("> ", 1, 36)).strip()
|
|
||||||
except (EOFError, KeyboardInterrupt): print(); break
|
|
||||||
if not u: continue
|
|
||||||
if u == "/quit": break
|
|
||||||
elif u == "/clear": msgs = [{"role": "system", "content": sp}]; autosave(msgs); continue
|
|
||||||
elif u == "/save":
|
|
||||||
sid, sm = save(msgs); print(c(f"[session saved: {sid}]", 32) + " " + c(sm, 2)); continue
|
|
||||||
elif u == "/list":
|
|
||||||
ss = sessions()
|
|
||||||
if not ss: print(c("No sessions saved yet.", 33)); continue
|
|
||||||
for sid, st, sm, n in ss:
|
|
||||||
mk = c(" (autosave)", 33) if sid == "autosave" else ""
|
|
||||||
print(c(sid, 32) + mk + c(f" {st} [{n} msgs]", 2) + "\n " + c(sm, 2))
|
|
||||||
continue
|
|
||||||
elif u.startswith("/load"):
|
|
||||||
parts = u.split(None, 1)
|
|
||||||
if len(parts) < 2: print(c("Usage: /load <session-id>", 31)); continue
|
|
||||||
try:
|
|
||||||
msgs = load(parts[1]); autosave(msgs)
|
|
||||||
print(c(f"[session loaded: {parts[1]}]", 32) + " " + c(summary(msgs), 2))
|
|
||||||
except KeyError as e: print(c(f"Session not found: {e}", 31))
|
|
||||||
continue
|
|
||||||
elif u == "/compact":
|
|
||||||
if len(msgs) <= 1: print(c("Nothing to compact yet.", 33)); continue
|
|
||||||
print(c("[compacting conversation...]", 33))
|
|
||||||
nm, sm, err = compact(cfg, msgs)
|
|
||||||
if err: print(c(f"[compact failed: {err}]", 31)); continue
|
|
||||||
msgs = nm; autosave(msgs)
|
|
||||||
print(c(f"[compacted to {len(msgs)} messages]", 32)); print(c("--- summary ---", 33) + "\n" + c(sm, 2))
|
|
||||||
continue
|
|
||||||
elif u.startswith("/cfg"):
|
|
||||||
parts = u.split(None, 2)
|
|
||||||
if len(parts) == 2:
|
|
||||||
k = parts[1]
|
|
||||||
if k in cfg: print(c(f"{k}={cfg[k]}", 32))
|
|
||||||
else: print(c(f"{k} not set", 31))
|
|
||||||
elif len(parts) >= 3:
|
|
||||||
k, v = parts[1], parts[2]
|
|
||||||
set_cfg("model.cfg", k, v)
|
|
||||||
cfg = get_cfg("model.cfg")
|
|
||||||
_COL = col(cfg)
|
|
||||||
print(c(f"[config updated: {k}={v}]", 32))
|
|
||||||
else: print(c("Usage: /cfg <param> [val]", 31))
|
|
||||||
continue
|
|
||||||
elif u == "/help":
|
|
||||||
print(c("Bantam commands:", 1, 36))
|
|
||||||
for k, v in [("/quit", "exit"), ("/clear", "reset to system prompt"), ("/save", "save session"), ("/list", "list sessions"), ("/load <id>", "load session"), ("/compact", "compact context"), ("/cfg <k> [v]", "get/set config"), ("/help", "show help")]:
|
|
||||||
print(c(f" {k:<15}", 1, 32) + v)
|
|
||||||
continue
|
|
||||||
msgs.append({"role": "user", "content": u})
|
|
||||||
AL(cfg, msgs, sp); autosave(msgs)
|
|
||||||
autosave(msgs)
|
|
||||||
if readline:
|
|
||||||
try: readline.write_history_file(HIST)
|
|
||||||
except OSError: pass
|
|
||||||
|
|
||||||
if __name__ == "__main__": main()
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
#!/usr/bin/env jimsh
|
|
||||||
# MicroBantam (mb): the Bantam agent in Jim Tcl (under 100 SLOC)
|
|
||||||
# Created by Luxferre in 2026, released into the public domain
|
|
||||||
|
|
||||||
set 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."
|
|
||||||
set SDIR "[expr {[info exists env(HOME)] ? $env(HOME) : ([info exists env(USERPROFILE)] ? $env(USERPROFILE) : ".")} ]/.bantam/sessions"
|
|
||||||
|
|
||||||
proc is_dict {d} { return [expr {![catch {dict keys $d}] && [llength $d] % 2 == 0}] }
|
|
||||||
proc safe_get {d args} { foreach k $args { if {![is_dict $d] || ![dict exists $d $k]} { return "" }; set d [dict get $d $k] }; return $d }
|
|
||||||
proc is_tool_call {tc} { return [expr {[is_dict $tc] && [dict exists $tc function] && [is_dict [dict get $tc function]] && [dict exists [dict get $tc function] name]}] }
|
|
||||||
proc jesc {s} { set map [list \\ \\\\ \" \\" \n \\n \r \\r \t \\t \f \\f \b \\b]; set s [string map $map $s]; set res ""; for {set i 0} {$i < [string length $s]} {incr i} { set c [string index $s $i]; scan $c %c k; append res [expr {$k < 32 ? [format "\\u%04x" $k] : $c}] }; return "\"$res\"" }
|
|
||||||
|
|
||||||
proc cfg {} { global env; set d [dict create endpoint https://opencode.ai/zen/v1 model big-pickle temperature 0.7 api_key - timeout 300 shell_timeout 120 max_al_iterations 1000]; if {[file exists model.cfg] && ![catch {open model.cfg r} f]} { while {[gets $f l] >= 0} { if {[regexp {^(\w+)\s*=\s*(.+)$} $l -> k v]} { dict set d $k $v } }; close $f }; if {[dict get $d api_key] eq "-" && [info exists env(OPENAI_API_KEY)]} { dict set d api_key $env(OPENAI_API_KEY) }; return $d }
|
|
||||||
proc sp {} { global DEF_SP; set p ""; if {[file exists system.txt] && ![catch {open system.txt r} f]} { set p [string trim [read $f]]; close $f }; return [expr {[string length $p] ? $p : $DEF_SP}] }
|
|
||||||
|
|
||||||
proc decode_chunked {b} { set res ""; set pos 0; while {$pos < [string length $b]} { set idx [string first "\r\n" $b $pos]; if {$idx == -1} break; scan [lindex [split [string range $b $pos [expr {$idx - 1}]] ";"] 0] "%x" clen; if {$clen == 0} break; set st [expr {$idx + 2}]; append res [string range $b $st [expr {$st + $clen - 1}]]; set pos [expr {$st + $clen + 2}] }; return $res }
|
|
||||||
proc http_request {m url hdrs body {t 300}} { set proto http; set host ""; set port 80; set path "/"; if {[regexp {^(https?)://([^/]+)(/.*)?$} $url -> proto hp reqp]} { set port [expr {$proto eq "https" ? 443 : 80}]; if {$reqp ne ""} { set path $reqp } }; if {![regexp {^([^:]+):(\d+)$} $hp -> host port]} { set host $hp }; set s [socket stream $host:$port]; $s timeout [expr {$t * 1000}]; if {$proto eq "https"} { $s ssl -sni $host }; set req "$m $path HTTP/1.1\r\nHost: $host\r\n"; dict for {k v} $hdrs { append req "$k: $v\r\n" }; append req "Content-Length: [string length $body]\r\nConnection: close\r\n\r\n$body"; $s puts -nonewline $req; $s flush; set resp [$s read]; $s close; set sep [string first "\r\n\r\n" $resp]; set hlen 4; if {$sep == -1} { set sep [string first "\n\n" $resp]; set hlen 2 }; if {$sep == -1} { error "invalid HTTP response" }; set htxt [string range $resp 0 [expr {$sep - 1}]]; set rbody [string range $resp [expr {$sep + $hlen}] end]; set status 0; set reason ""; set chunked 0; regexp {^HTTP/\d\.\d\s+(\d+)(?:\s+(.*))?$} [string trim [lindex [split $htxt "\n"] 0]] -> status reason; foreach l [lrange [split $htxt "\n"] 1 end] { if {[regexp -nocase {^transfer-encoding:\s*chunked$} [string trim $l]]} { set chunked 1 } }; return [dict create status $status reason $reason body [expr {$chunked ? [decode_chunked $rbody] : $rbody}]] }
|
|
||||||
|
|
||||||
proc clean_msg_for_api {m} { if {![is_dict $m]} { return "" }; set r [safe_get $m role]; if {$r eq ""} { return "" }; set c [safe_get $m content]; set res [dict create role $r]; if {$r eq "system" || $r eq "user"} { dict set res content $c } elseif {$r eq "assistant"} { dict set res content $c; set vtcs [list]; set raw_tcs [safe_get $m tool_calls]; if {[is_tool_call $raw_tcs]} { set raw_tcs [list $raw_tcs] }; foreach tc $raw_tcs { if {[is_tool_call $tc]} { set fn [dict get [dict get $tc function] name]; set a "\{\}"; if {[dict exists $tc function arguments]} { set a [dict get [dict get $tc function] arguments] }; set tcid [safe_get $tc id]; if {$tcid eq ""} { set tcid "call_0" }; set tp "function"; if {[dict exists $tc type]} { set tp [dict get $tc type] }; lappend vtcs [dict create id $tcid type $tp function [dict create name $fn arguments $a]] } }; if {[llength $vtcs]} { dict set res tool_calls $vtcs } } elseif {$r eq "tool"} { set tcid [safe_get $m tool_call_id]; if {$tcid eq ""} { set tcid "call_0" }; dict set res tool_call_id $tcid; dict set res content $c }; return $res }
|
|
||||||
proc encode_json_msg {m} { if {![is_dict $m]} { return "\{\}" }; set parts [list]; dict for {k v} $m { if {$k eq "tool_calls"} { set raw_tcs $v; if {[is_tool_call $raw_tcs]} { set raw_tcs [list $raw_tcs] }; set tcs [list]; foreach tc $raw_tcs { if {[is_tool_call $tc]} { set tcp [list]; dict for {tck tcv} $tc { if {$tck eq "function" && [is_dict $tcv]} { set fnp [list]; dict for {fk fv} $tcv { lappend fnp "[jesc $fk]:[jesc $fv]" }; lappend tcp "[jesc $tck]:\{ [join $fnp ", "] \}" } else { lappend tcp "[jesc $tck]:[jesc $tcv]" } }; lappend tcs "\{ [join $tcp ", "] \}" } }; lappend parts "[jesc $k]:\[ [join $tcs ", "] \]" } else { lappend parts [expr {$v eq "null" ? "[jesc $k]:null" : "[jesc $k]:[jesc $v]"}] } }; return "\{ [join $parts ", "] \}" }
|
|
||||||
proc sanitize_msgs {msgs_var} { upvar 1 $msgs_var msgs; set new [list]; foreach m $msgs { if {[is_dict $m] && [dict exists $m tool_calls] && [set tcs [dict get $m tool_calls]] ne "null" && $tcs ne ""} { if {[is_tool_call $tcs]} { set tcs [list $tcs] }; set ntcs [list]; foreach tc $tcs { if {[is_tool_call $tc]} { set raw [dict get [dict get $tc function] arguments]; if {[catch {json::decode $raw} p] || ![is_dict $p]} { dict set tc function arguments [encode_json_msg [dict create invalid_raw $raw]] } }; lappend ntcs $tc }; dict set m tool_calls $ntcs }; lappend new $m }; set msgs $new }
|
|
||||||
proc encode_payload {c msgs} { set mjs [list]; foreach m $msgs { set cm [clean_msg_for_api $m]; if {$cm ne ""} { lappend mjs [encode_json_msg $cm] } }; set tools {[{"type":"function","function":{"name":"shell_exec","description":"Run a shell command, return output and exit code.","parameters":{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}}},{"type":"function","function":{"name":"run_subagent","description":"Run a child agent with a prompt.","parameters":{"type":"object","properties":{"prompt":{"type":"string"}},"required":["prompt"]}}}]}; set extra [list]; dict for {k v} $c { if {$k in {endpoint api_key timeout shell_timeout max_al_iterations stream color}} continue; if {![catch {json::decode $v} jv]} { lappend extra "[jesc $k]:$v" } else { lappend extra "[jesc $k]:[jesc $v]" } }; set ex_str [expr {[llength $extra] ? ", [join $extra ", "]" : ""}]; return "\{ \"model\": [jesc [dict get $c model]], \"temperature\": [expr {[dict get $c temperature] + 0}], \"messages\": \[ [join $mjs ", "] \], \"tools\": $tools$ex_str \}" }
|
|
||||||
|
|
||||||
proc llm {c msgs_var} {
|
|
||||||
upvar 1 $msgs_var msgs; sanitize_msgs msgs
|
|
||||||
set ep [string trimright [dict get $c endpoint] "/"]
|
|
||||||
set payload [encode_payload $c $msgs]
|
|
||||||
if {[catch {json::decode $payload}]} { error "API error: Local JSON validation failed" }
|
|
||||||
set is_tty [expr {[catch {exec sh -c "test -t 1" >@stdout}] == 0}]
|
|
||||||
set hdrs [dict create "Content-Type" "application/json" "User-Agent" "Mozilla/5.0 (compatible; MicroBantam/1.0)"]; if {[dict get $c api_key] ne "-"} { dict set $hdrs "Authorization" "Bearer [dict get $c api_key]" }
|
|
||||||
set last ""
|
|
||||||
for {set a 1} {$a <= 3} {incr a} {
|
|
||||||
if {$a > 1} { after [expr {($a - 1) * 2000}] }; puts -nonewline [expr {$is_tty ? "\r...requesting ($a/3)..." : "...requesting ($a/3)...\n"}]; flush stdout
|
|
||||||
set code [catch {http_request POST "$ep/chat/completions" $hdrs $payload [dict get $c timeout]} res]; if {$is_tty} { puts -nonewline "\r\u001b\[K"; flush stdout }
|
|
||||||
if {$code == 0 && [dict get $res status] == 200} {
|
|
||||||
set body [dict get $res body]
|
|
||||||
if {![catch {json::decode $body} data] && [is_dict $data] && [dict exists $data choices] && [llength [set choices [dict get $data choices]]] > 0} { return [dict get [lindex $choices 0] message] }
|
|
||||||
set last "invalid response body"
|
|
||||||
} else {
|
|
||||||
set st [expr {$code != 0 ? 0 : [dict get $res status]}]; set detail [safe_get $res body]
|
|
||||||
if {[string length $detail] > 400} { set detail [string range $detail 0 399] }
|
|
||||||
set last [expr {$code != 0 ? $res : ([string length $detail] ? "$detail (HTTP $st)" : ([safe_get $res reason] ne "" ? [safe_get $res reason] : "HTTP $st"))}]
|
|
||||||
if {$st != 0 && $st != 403 && $st != 429 && $st < 500} { error "API error: $last" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
error "API error: $last (after 3 attempts)"
|
|
||||||
}
|
|
||||||
|
|
||||||
proc shell_exec_cmd {cmd t} { catch {exec timeout $t sh -c "$cmd 2>&1"} out opts; set exit_code 0; if {[dict get $opts -code] != 0} { set errcode [dict get $opts -errorcode]; set exit_code [expr {[lindex $errcode 0] eq "CHILDSTATUS" ? [lindex $errcode 2] : -1}] }; set out [string trimright [string map [list "\u0000" "\n"] $out] " \t\n\r\v\f"]; return [expr {$exit_code == 124 ? "$out\n\[timeout after ${t}s\]\nexit: -1" : "$out\nexit: $exit_code"}] }
|
|
||||||
proc last_assistant {msgs} { for {set i [expr {[llength $msgs] - 1}]} {$i >= 0} {incr i -1} { set m [lindex $msgs $i]; if {[is_dict $m] && [safe_get $m role] eq "assistant"} { set cnt [safe_get $m content]; if {$cnt ne "" && $cnt ne "null"} { return $cnt } } }; return "" }
|
|
||||||
|
|
||||||
proc AL {c msgs_var sp depth} {
|
|
||||||
upvar 1 $msgs_var msgs
|
|
||||||
for {set iter 0} {$iter < [dict get $c max_al_iterations]} {incr iter} {
|
|
||||||
if {[catch {llm $c msgs} m]} {
|
|
||||||
if {[string match "*Invalid assistant message*" $m] || [string match "*content or tool_calls must be set*" $m]} {
|
|
||||||
set stripped 0
|
|
||||||
for {set j [expr {[llength $msgs] - 1}]} {$j >= 0} {incr j -1} { set mm [lindex $msgs $j]; if {[is_dict $mm] && [safe_get $mm role] eq "assistant"} { set msgs [lreplace $msgs $j $j]; puts "\[stripped malformed assistant message\]"; set stripped 1; break } }
|
|
||||||
if {$stripped} { continue }
|
|
||||||
}
|
|
||||||
puts $m; return $msgs
|
|
||||||
}
|
|
||||||
lappend msgs $m; set cnt [safe_get $m content]; if {$cnt ne "" && $cnt ne "null"} { puts $cnt }
|
|
||||||
if {[set tcs [safe_get $m tool_calls]] eq "" || $tcs eq "null"} break
|
|
||||||
if {[is_tool_call $tcs]} { set tcs [list $tcs] }
|
|
||||||
foreach tc $tcs {
|
|
||||||
if {![is_tool_call $tc]} continue
|
|
||||||
set fn [dict get [dict get $tc function] name]; set args_raw "\{\}"
|
|
||||||
if {[dict exists $tc function arguments]} { set args_raw [dict get [dict get $tc function] arguments] }
|
|
||||||
if {[catch {json::decode $args_raw} a] || ![is_dict $a]} { set bad [encode_json_msg [dict create invalid_raw $args_raw]]; dict set tc function arguments $bad; set res "bad JSON args for $fn: $bad"
|
|
||||||
} elseif {$fn eq "shell_exec"} { set cmd [safe_get $a command]; set res [shell_exec_cmd $cmd [dict get $c shell_timeout]]
|
|
||||||
} elseif {$fn eq "run_subagent"} { set prompt [safe_get $a prompt]; set child [list [dict create role system content "$sp\n\nImportant: this is a child agent"] [dict create role user content $prompt]]; set res [expr {$depth >= 5 ? "\[subagent depth limit (5) reached, child not spawned\]" : [last_assistant [AL $c child $sp [expr {$depth + 1}]]]}]
|
|
||||||
} else { set res "unknown tool: $fn" }
|
|
||||||
puts "\[tool\] $fn: $res"; lappend msgs [dict create role tool tool_call_id [safe_get $tc id] content $res]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $msgs
|
|
||||||
}
|
|
||||||
|
|
||||||
proc sdir {} { global SDIR; if {![file isdirectory $SDIR]} { file mkdir $SDIR }; return $SDIR }
|
|
||||||
proc sessions {} { global SDIR; set s [list]; foreach f [glob -nocomplain "$SDIR/*.json"] { if {![catch {open $f r} fh]} { set c [read $fh]; close $fh; if {![catch {json::decode $c} d] && [is_dict $d]} { lappend s $d } } }; return [lsort -command {apply {{a b} { string compare [safe_get $b id] [safe_get $a id] }}} $s] }
|
|
||||||
proc save {msgs} { set sd [sdir]; set base [clock format [clock seconds] -format "%Y%m%d-%H%M%S"]; set id $base; set i 0; while {[file exists "$sd/$id.json"]} { incr i; set id "$base-$i" }; set mjs [list]; foreach m $msgs { lappend mjs [encode_json_msg $m] }; set f [open "$sd/$id.json" w]; puts $f "\{\n \"id\": [jesc $id],\n \"messages\": \[\n [join $mjs ",\n "]\n \]\n\}"; close $f; return $id }
|
|
||||||
proc load_session {want} { foreach s [sessions] { if {[safe_get $s id] eq $want} { return [dict get $s messages] } }; error "no session: $want" }
|
|
||||||
proc autosave {msgs} { set sd [sdir]; set mjs [list]; foreach m $msgs { lappend mjs [encode_json_msg $m] }; if {![catch {open "$sd/autosave.json" w} f]} { puts $f "\{\n \"id\": \"autosave\",\n \"messages\": \[\n [join $mjs ",\n "]\n \]\n\}"; close $f } }
|
|
||||||
proc list_sessions {} { set res [list]; foreach s [sessions] { lappend res [list [safe_get $s id] [llength [expr {[dict exists $s messages] ? [dict get $s messages] : {}}]]] }; return $res }
|
|
||||||
proc set_cfg {k v} { set ls [list]; set f 0; if {[file exists model.cfg] && ![catch {open model.cfg r} fh]} { while {[gets $fh l] >= 0} { if {![regexp {^\s*#} $l] && [regexp {^(\w+)\s*=} $l -> pk] && $pk eq $k} { lappend ls "$k=$v"; set f 1 } else { lappend ls $l } }; close $fh }; if {!$f} { lappend ls "$k=$v" }; if {![catch {open model.cfg w} fh]} { puts $fh [join $ls "\n"]; close $fh } }
|
|
||||||
|
|
||||||
proc main {argv} {
|
|
||||||
set c [cfg]; set sp_text [sp]; set msgs [list [dict create role system content $sp_text]]
|
|
||||||
if {[llength $argv] > 0} {
|
|
||||||
if {[catch {open [lindex $argv 0] r} f]} { puts stderr "cannot open [lindex $argv 0]: $f"; exit 1 }
|
|
||||||
set content [read $f]; close $f; lappend msgs [dict create role user content $content]; AL $c msgs $sp_text 0; autosave $msgs; return
|
|
||||||
}
|
|
||||||
puts "MicroBantam ready ([dict get $c model]). Commands: /quit /clear /save /list /load <id> /cfg <k> \[v\] /help"
|
|
||||||
while {1} {
|
|
||||||
puts -nonewline "> "; flush stdout; if {[gets stdin u] < 0} break
|
|
||||||
if {[set u [string trim $u]] eq ""} continue
|
|
||||||
if {$u eq "/quit"} { break } \
|
|
||||||
elseif {$u eq "/clear"} { set msgs [list [dict create role system content $sp_text]]; autosave $msgs } \
|
|
||||||
elseif {$u eq "/save"} { puts "session saved: [save $msgs]" } \
|
|
||||||
elseif {$u eq "/list"} { foreach item [list_sessions] { puts "[lindex $item 0] \[[lindex $item 1] msgs\]" } } \
|
|
||||||
elseif {[regexp {^\/load(?:\s+(\S+))?$} $u -> want_id]} { if {$want_id eq ""} { puts "usage: /load <session id>" } elseif {[catch {load_session $want_id} loaded]} { puts $loaded } else { set msgs $loaded; autosave $msgs; puts "loaded: $want_id" } } \
|
|
||||||
elseif {[regexp {^\/cfg(?:\s+(\S+)(?:\s+(.+))?)?$} $u -> ck cv]} { if {$cv ne ""} { set_cfg $ck [string trim $cv]; set c [cfg]; puts "config: $ck=[string trim $cv]" } elseif {$ck ne ""} { puts [expr {[dict exists $c $ck] ? "$ck=[dict get $c $ck]" : "$ck not set"}] } else { puts "usage: /cfg <param> \[val\]" } } \
|
|
||||||
elseif {$u eq "/help"} { puts "Commands: /quit /clear /save /list /load <id> /cfg <k> \[v\] /help" } \
|
|
||||||
else { lappend msgs [dict create role user content $u]; AL $c msgs $sp_text 0; autosave $msgs }
|
|
||||||
}
|
|
||||||
autosave $msgs
|
|
||||||
}
|
|
||||||
|
|
||||||
if {[info exists argv0] && [file tail $argv0] eq "mb.tcl"} { main $argv }
|
|
||||||
Reference in New Issue
Block a user