Compare commits
2
Commits
e9dd44d97b
...
4a936e850d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a936e850d | ||
|
|
b8686c82ab |
@@ -124,8 +124,9 @@ Using these rules, everyone can build their own copy of Bantam from scratch in l
|
||||
2. Append the assistant's response message object to `messages`. If non-streaming and response has reasoning tokens (`reasoning_content` or `reasoning`), output them wrapped in `--- reasoning start ---` / `--- reasoning end ---` markers.
|
||||
3. If there are pending `tool_calls` in the assistant response:
|
||||
- For each tool call, output a trace log (`[tool call: name(args)]`).
|
||||
- Validate JSON arguments. If invalid, format a tool-error response so the LLM can self-correct.
|
||||
- Sanitize tool arguments to filter out non-printable and space-like Unicode characters (protecting against indirect prompt injection), and validate JSON arguments. If invalid, format a tool-error response so the LLM can self-correct.
|
||||
- Execute tool action (`shell_exec` or `run_subagent`).
|
||||
- Sanitize the tool result output to strip any non-printable and space-like Unicode characters (leaving only ASCII space, tab, newline, and printable Unicode characters).
|
||||
- Output a trace log of the result (`[tool result: name]`).
|
||||
- Append tool result message (`role: "tool"`, `tool_call_id`, `content`: result string) to `messages`.
|
||||
- Loop back to step 1.
|
||||
@@ -173,6 +174,7 @@ MicroBantam (`mb`) is a compressed Perl 5 reference implementation of the same a
|
||||
### Features
|
||||
|
||||
- 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
|
||||
- Indirect prompt injection defense: sanitizes tool parameters and tool outputs by filtering non-printable and space-like Unicode characters, preserving standard space, tab, newline, and printable Unicode characters
|
||||
- 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), `/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>`, `/cfg`, `/help`) and file input mode
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
@@ -702,21 +703,30 @@ type streamDelta struct {
|
||||
func cleanMessagesForLLM(msgs []Message) []Message {
|
||||
out := make([]Message, len(msgs))
|
||||
for i, m := range msgs {
|
||||
out[i] = Message{
|
||||
Role: m.Role,
|
||||
Content: m.Content,
|
||||
ToolCalls: m.ToolCalls,
|
||||
ToolCallID: m.ToolCallID,
|
||||
}
|
||||
out[i] = Message{Role: m.Role, Content: m.Content, ToolCalls: m.ToolCalls, ToolCallID: m.ToolCallID}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterText(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
if r == ' ' || r == '\t' || r == '\n' {
|
||||
b.WriteRune(r)
|
||||
} else if !unicode.Is(unicode.Z, r) && !unicode.IsControl(r) && !unicode.Is(unicode.C, r) && unicode.IsPrint(r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func sanitizeMessages(msgs []Message) {
|
||||
for i := range msgs {
|
||||
if msgs[i].Role == "assistant" && len(msgs[i].ToolCalls) > 0 {
|
||||
for j := range msgs[i].ToolCalls {
|
||||
tc := &msgs[i].ToolCalls[j]
|
||||
tc.Function.Arguments = filterText(tc.Function.Arguments)
|
||||
astr := tc.Function.Arguments
|
||||
var a map[string]any
|
||||
if err := json.Unmarshal([]byte(astr), &a); err != nil || a == nil {
|
||||
@@ -724,6 +734,8 @@ func sanitizeMessages(msgs []Message) {
|
||||
tc.Function.Arguments = string(fixed)
|
||||
}
|
||||
}
|
||||
} else if msgs[i].Role == "tool" && msgs[i].Content != nil {
|
||||
msgs[i].Content = strp(filterText(*msgs[i].Content))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -956,12 +968,13 @@ func parseStream(ctx context.Context, r io.Reader) (Message, Usage, error) {
|
||||
}
|
||||
|
||||
func shell(ctx context.Context, cmd string, timeout int) string {
|
||||
cmd = filterText(cmd)
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
|
||||
defer cancel()
|
||||
c := exec.CommandContext(cmdCtx, "sh", "-c", cmd)
|
||||
c.WaitDelay = 100 * time.Millisecond
|
||||
out, err := c.CombinedOutput()
|
||||
res := strings.TrimSpace(string(out))
|
||||
res := strings.TrimSpace(filterText(string(out)))
|
||||
if ctx.Err() != nil {
|
||||
return "[interrupted]\n\nexit: -1"
|
||||
}
|
||||
@@ -1018,6 +1031,7 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message, sp string, depth int) ([]
|
||||
if u.Cached() > 0 { turnUsage.CachedTokens = u.Cached() }
|
||||
for j := range m.ToolCalls {
|
||||
tc := &m.ToolCalls[j]
|
||||
tc.Function.Arguments = filterText(tc.Function.Arguments)
|
||||
astr := tc.Function.Arguments
|
||||
var a map[string]any
|
||||
if err := json.Unmarshal([]byte(astr), &a); err != nil || a == nil {
|
||||
@@ -1035,7 +1049,7 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message, sp string, depth int) ([]
|
||||
if len(m.ToolCalls) == 0 { done = true; break }
|
||||
for _, tc := range m.ToolCalls {
|
||||
if err := ctx.Err(); err != nil { return msgs, turnUsage, err }
|
||||
fn, astr := tc.Function.Name, tc.Function.Arguments
|
||||
fn, astr := tc.Function.Name, filterText(tc.Function.Arguments)
|
||||
fmt.Println(c(fmt.Sprintf("[tool call: %s(%s)]", fn, astr), 33))
|
||||
res, sty := "", 2
|
||||
var a map[string]any
|
||||
@@ -1045,10 +1059,12 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message, sp string, depth int) ([]
|
||||
switch fn {
|
||||
case "shell_exec":
|
||||
cmd, _ := a["command"].(string)
|
||||
cmd = filterText(cmd)
|
||||
res = shell(ctx, cmd, cfg.ShellTimeout)
|
||||
if err := ctx.Err(); err != nil { return msgs, turnUsage, err }
|
||||
case "run_subagent":
|
||||
pr, _ := a["prompt"].(string)
|
||||
pr = filterText(pr)
|
||||
if depth >= MAX_DEPTH {
|
||||
res, sty = fmt.Sprintf("[subagent depth limit (%d) reached, child not spawned]", MAX_DEPTH), 31
|
||||
} else {
|
||||
@@ -1064,13 +1080,14 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message, sp string, depth int) ([]
|
||||
} else {
|
||||
turnUsage.CompletionTokens += subu.CompletionTokens
|
||||
turnUsage.TotalTokens += subu.TotalTokens
|
||||
res, sty = last(subr), 2
|
||||
res, sty = filterText(last(subr)), 2
|
||||
}
|
||||
}
|
||||
default:
|
||||
res, sty = "Unknown tool: " + fn, 31
|
||||
}
|
||||
}
|
||||
res = filterText(res)
|
||||
fmt.Println(c("[tool result: "+fn+"]", 32) + "\n" + c(res, sty) + "\n")
|
||||
msgs = append(msgs, Message{Role: "tool", ToolCallID: tc.ID, Content: strp(res)})
|
||||
}
|
||||
@@ -1406,6 +1423,42 @@ func readLine(prompt string) (string, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func runDirectShell(cmd string, timeout int) {
|
||||
cmd = filterText(strings.TrimSpace(cmd))
|
||||
if cmd == "" { return }
|
||||
astr, _ := json.Marshal(map[string]string{"command": cmd})
|
||||
fmt.Println(c(fmt.Sprintf("[tool call: shell_exec(%s)]", string(astr)), 33))
|
||||
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
res := shell(sigCtx, cmd, timeout)
|
||||
cancel()
|
||||
fmt.Println(c("[tool result: shell_exec]", 32) + "\n" + c(res, 2))
|
||||
}
|
||||
|
||||
func doCompact(cfg *Cfg, msgs []Message) []Message {
|
||||
if len(msgs) <= 1 {
|
||||
fmt.Println(c("Nothing to compact yet.", 33))
|
||||
return msgs
|
||||
}
|
||||
fmt.Println(c("[compacting conversation...]", 33))
|
||||
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
nm, sm, err := compact(sigCtx, cfg, msgs)
|
||||
interrupted := sigCtx.Err() != nil
|
||||
cancel()
|
||||
if err != nil {
|
||||
if interrupted || errors.Is(err, context.Canceled) {
|
||||
fmt.Println(c("\n[interrupted]", 33))
|
||||
} else {
|
||||
fmt.Println(c("[compact failed: "+err.Error()+"]", 31))
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
msgs = nm
|
||||
autosave(msgs)
|
||||
fmt.Println(c(fmt.Sprintf("[compacted to %d messages]", len(msgs)), 32))
|
||||
fmt.Println(c("--- summary ---", 33) + "\n" + c(sm, 2))
|
||||
return msgs
|
||||
}
|
||||
|
||||
func main() {
|
||||
sp := prompt("system.txt")
|
||||
cfg := getCfg("model.cfg")
|
||||
@@ -1422,15 +1475,7 @@ func main() {
|
||||
}
|
||||
u := strings.TrimSpace(string(data))
|
||||
if strings.HasPrefix(u, "!") {
|
||||
cmd := strings.TrimSpace(strings.TrimPrefix(u, "!"))
|
||||
if cmd != "" {
|
||||
astr, _ := json.Marshal(map[string]string{"command": cmd})
|
||||
fmt.Println(c(fmt.Sprintf("[tool call: shell_exec(%s)]", string(astr)), 33))
|
||||
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
res := shell(sigCtx, cmd, cfg.ShellTimeout)
|
||||
cancel()
|
||||
fmt.Println(c("[tool result: shell_exec]", 32) + "\n" + c(res, 2))
|
||||
}
|
||||
runDirectShell(strings.TrimPrefix(u, "!"), cfg.ShellTimeout)
|
||||
return
|
||||
}
|
||||
msgs = append(msgs, Message{Role: "user", Content: strp(u)})
|
||||
@@ -1503,27 +1548,7 @@ func main() {
|
||||
fmt.Println(c("[session loaded: "+parts[1]+"]", 32) + " " + c(summary(msgs), 2))
|
||||
continue
|
||||
case u == "/compact":
|
||||
if len(msgs) <= 1 {
|
||||
fmt.Println(c("Nothing to compact yet.", 33))
|
||||
continue
|
||||
}
|
||||
fmt.Println(c("[compacting conversation...]", 33))
|
||||
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
nm, sm, err := compact(sigCtx, &cfg, msgs)
|
||||
interrupted := sigCtx.Err() != nil
|
||||
cancel()
|
||||
if err != nil {
|
||||
if interrupted || errors.Is(err, context.Canceled) {
|
||||
fmt.Println(c("\n[interrupted]", 33))
|
||||
} else {
|
||||
fmt.Println(c("[compact failed: "+err.Error()+"]", 31))
|
||||
}
|
||||
continue
|
||||
}
|
||||
msgs = nm
|
||||
autosave(msgs)
|
||||
fmt.Println(c(fmt.Sprintf("[compacted to %d messages]", len(msgs)), 32))
|
||||
fmt.Println(c("--- summary ---", 33) + "\n" + c(sm, 2))
|
||||
msgs = doCompact(&cfg, msgs)
|
||||
continue
|
||||
case strings.HasPrefix(u, "/cfg"):
|
||||
parts := strings.SplitN(u, " ", 3)
|
||||
@@ -1551,15 +1576,7 @@ func main() {
|
||||
}
|
||||
continue
|
||||
case strings.HasPrefix(u, "!"):
|
||||
cmd := strings.TrimSpace(strings.TrimPrefix(u, "!"))
|
||||
if cmd != "" {
|
||||
astr, _ := json.Marshal(map[string]string{"command": cmd})
|
||||
fmt.Println(c(fmt.Sprintf("[tool call: shell_exec(%s)]", string(astr)), 33))
|
||||
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
res := shell(sigCtx, cmd, cfg.ShellTimeout)
|
||||
cancel()
|
||||
fmt.Println(c("[tool result: shell_exec]", 32) + "\n" + c(res, 2))
|
||||
}
|
||||
runDirectShell(strings.TrimPrefix(u, "!"), cfg.ShellTimeout)
|
||||
continue
|
||||
case u == "/help":
|
||||
fmt.Println(c("Bantam commands:", 1, 36))
|
||||
@@ -1588,27 +1605,10 @@ func main() {
|
||||
pct := float64(usg.PromptTokens) * 100.0 / float64(cfg.ContextWindow)
|
||||
if pct >= 60.0 && len(msgs) > 1 {
|
||||
fmt.Print(c(fmt.Sprintf("Context usage is at %.1f%% (%d / %d tokens). Compact conversation? [Y/n]: ", pct, usg.PromptTokens, cfg.ContextWindow), 33))
|
||||
ans, ok := readPlain("")
|
||||
if ok {
|
||||
if ans, ok := readPlain(""); ok {
|
||||
ans = strings.TrimSpace(strings.ToLower(ans))
|
||||
if ans == "" || ans == "y" || ans == "yes" {
|
||||
fmt.Println(c("[compacting conversation...]", 33))
|
||||
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
nm, sm, err := compact(sigCtx, &cfg, msgs)
|
||||
interrupted := sigCtx.Err() != nil
|
||||
cancel()
|
||||
if err != nil {
|
||||
if interrupted || errors.Is(err, context.Canceled) {
|
||||
fmt.Println(c("\n[interrupted]", 33))
|
||||
} else {
|
||||
fmt.Println(c("[compact failed: "+err.Error()+"]", 31))
|
||||
}
|
||||
} else {
|
||||
msgs = nm
|
||||
autosave(msgs)
|
||||
fmt.Println(c(fmt.Sprintf("[compacted to %d messages]", len(msgs)), 32))
|
||||
fmt.Println(c("--- summary ---", 33) + "\n" + c(sm, 2))
|
||||
}
|
||||
msgs = doCompact(&cfg, msgs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1851,6 +1851,95 @@ func TestFetchContextWindowFallbackConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterText(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "ASCII printable, spaces, tabs, newlines",
|
||||
input: "Hello World!\t123\nLine 2 ~`@#$%",
|
||||
expected: "Hello World!\t123\nLine 2 ~`@#$%",
|
||||
},
|
||||
{
|
||||
name: "CRLF normalization",
|
||||
input: "line1\r\nline2\r\n",
|
||||
expected: "line1\nline2\n",
|
||||
},
|
||||
{
|
||||
name: "Unicode printable letters, numbers, punctuation",
|
||||
input: "こんにちは世界! Привет мир! 123 αβγ €$¥",
|
||||
expected: "こんにちは世界! Привет мир! 123 αβγ €$¥",
|
||||
},
|
||||
{
|
||||
name: "Control characters stripped",
|
||||
input: "null\x00bell\x07esc\x1b[31mred\x1b[0m\x7fdel",
|
||||
expected: "nullbellesc[31mred[0mdel",
|
||||
},
|
||||
{
|
||||
name: "Zero-width and format characters stripped",
|
||||
input: "hidden\u200Binjection\u200Cand\u200Djoiner\uFEFFbom\u202Ebidi\U000E0001tag",
|
||||
expected: "hiddeninjectionandjoinerbombiditag",
|
||||
},
|
||||
{
|
||||
name: "Space-like unicode characters stripped",
|
||||
input: "nbsp\u00A0space\u2000enquad\u2001emquad\u2009thin\u202Fnarrow\u3000ideo\u1680ogham\u2028lsep\u2029psep",
|
||||
expected: "nbspspaceenquademquadthinnarrowideooghamlseppsep",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := filterText(tc.input)
|
||||
if got != tc.expected {
|
||||
t.Errorf("filterText(%q) = %q, expected %q", tc.input, got, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeMessagesWithInvisibles(t *testing.T) {
|
||||
msgs := []Message{
|
||||
{
|
||||
Role: "assistant",
|
||||
ToolCalls: []ToolCall{
|
||||
{
|
||||
ID: "call_1",
|
||||
Type: "function",
|
||||
Function: struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}{
|
||||
Name: "shell_exec",
|
||||
Arguments: "{\"command\": \"cat\u200B \u00A0file.txt\"}",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: "tool",
|
||||
ToolCallID: "call_1",
|
||||
Content: strp("output\u200B\x00with\u00A0invisible\r\nexit: 0"),
|
||||
},
|
||||
}
|
||||
|
||||
sanitizeMessages(msgs)
|
||||
|
||||
tcArgs := msgs[0].ToolCalls[0].Function.Arguments
|
||||
if strings.Contains(tcArgs, "\u200B") || strings.Contains(tcArgs, "\u00A0") {
|
||||
t.Errorf("Tool call arguments still contain invisible characters: %q", tcArgs)
|
||||
}
|
||||
|
||||
toolContent := *msgs[1].Content
|
||||
if strings.Contains(toolContent, "\u200B") || strings.Contains(toolContent, "\x00") || strings.Contains(toolContent, "\u00A0") || strings.Contains(toolContent, "\r") {
|
||||
t.Errorf("Tool content still contains invisible characters: %q", toolContent)
|
||||
}
|
||||
if !strings.Contains(toolContent, "outputwithinvisible\nexit: 0") {
|
||||
t.Errorf("Tool content unexpected: %q", toolContent)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,118 +1,92 @@
|
||||
#!/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);
|
||||
|
||||
$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /^Use of uninitialized value \$err in numeric eq \(==\) at .*IO\/Socket\/IP\.pm line \d+/ };
|
||||
|
||||
binmode $_ => ':encoding(UTF-8)' for *STDIN, *STDOUT, *STDERR; $| = 1; # unbuffered output in UTF-8
|
||||
|
||||
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);
|
||||
if (open my $f, '<:encoding(UTF-8)', 'model.cfg') { while (<$f>) { /^(\w+)\s*=\s*(.+)$/ and $d{$1} = $2; } }
|
||||
$d{api_key} = $ENV{OPENAI_API_KEY} if $d{api_key} eq q{-} && $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; }
|
||||
|
||||
$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 }
|
||||
sub filter_text { my $s = shift // ''; $s =~ s/[^\x20\t\n\p{L}\p{N}\p{P}\p{S}\p{M}]//g; $s }
|
||||
sub T { my ($n, $d, $p) = @_; {type=>'function', function=>{name=>$n, description=>$d, parameters=>{type=>'object', properties=>$p, required=>[keys %$p]}}} }
|
||||
|
||||
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'; } } } }
|
||||
|
||||
sub llm { my ($c, $msgs) = @_; # one non-streaming chat completion
|
||||
sanitize_msgs($msgs);
|
||||
sub sanitize_msgs { for my $m (@{$_[0]}) { if (ref $m eq 'HASH') {
|
||||
for my $tc (@{$m->{tool_calls} // []}) { $tc->{function}{arguments} = filter_text($tc->{function}{arguments});
|
||||
my $a = eval { decode_json($tc->{function}{arguments} // '{}') };
|
||||
$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};
|
||||
} } }
|
||||
sub llm { my ($c, $msgs) = @_; sanitize_msgs($msgs);
|
||||
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 %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}; }
|
||||
my $body = encode_json(\%p); 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 $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;
|
||||
my $d = $r->{success} ? eval { decode_json($r->{content}) } : undef;
|
||||
return $d->{choices}[0]{message} if $d && $d->{choices} && @{$d->{choices}};
|
||||
my $rb = $r->{content} // '';
|
||||
$rb = substr($rb, 0, 500) if length($rb) > 500;
|
||||
my $rb = substr($r->{content} // '', 0, 500);
|
||||
die "API error: " . (length($rb) ? "$rb (HTTP $r->{status})" : ($r->{reason} || "HTTP $r->{status}")) . "\n"; }
|
||||
|
||||
sub shell_exec { my ($cmd, $t) = @_; # run a command under a hard timeout
|
||||
my $out = '';
|
||||
sub shell_exec { my ($cmd, $t, $out) = (filter_text($_[0]), $_[1], '');
|
||||
eval { local $SIG{ALRM} = sub { alarm 0; die "timeout\n" }; alarm $t; $out = `$cmd 2>&1`; alarm 0; };
|
||||
$out =~ s/\s+$//;
|
||||
utf8::decode($out);
|
||||
$out =~ s/\s+$//; utf8::decode($out); $out = filter_text($out);
|
||||
$@ ? "$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;
|
||||
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);
|
||||
for (1 .. $c->{max_al_iterations}) {
|
||||
my $m = eval { llm($c, $msgs) };
|
||||
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; } }
|
||||
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;
|
||||
}
|
||||
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;
|
||||
my $tcs = $m->{tool_calls}; last unless $tcs && @$tcs;
|
||||
for my $tc (@$tcs) {
|
||||
my $fn = $tc->{function}{name};
|
||||
my ($fn, $res) = ($tc->{function}{name});
|
||||
$tc->{function}{arguments} = filter_text($tc->{function}{arguments});
|
||||
my $a = eval { decode_json($tc->{function}{arguments} // '{}') };
|
||||
my $res;
|
||||
if (ref $a ne 'HASH') { $tc->{function}{arguments} = encode_json({invalid_raw => $tc->{function}{arguments} // ''}); $res = "bad JSON args for $fn: " . ($tc->{function}{arguments} // ''); }
|
||||
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}); }
|
||||
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)); }
|
||||
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))); }
|
||||
else { $res = "unknown tool: $fn"; }
|
||||
$res = filter_text($res);
|
||||
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 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 }
|
||||
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; }
|
||||
sub load { my ($want) = @_; my ($hit) = grep { $_->{id} eq $want } sessions(); die "no session: $want\n" unless $hit; $hit->{messages}; }
|
||||
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} }
|
||||
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 set_cfg { my ($k, $v) = @_; my (@ls, $f);
|
||||
if (open my $fh, '<:encoding(UTF-8)', 'model.cfg') { while (<$fh>) { if (!/^#/ && /^(\w+)\s*=/ && $1 eq $k) { push @ls, "$k=$v\n"; $f = 1; } else { push @ls, $_; } } }
|
||||
sub set_cfg { my ($k, $v, @ls, $f) = @_;
|
||||
if (open my $fh, '<:encoding(UTF-8)', 'model.cfg') { while (<$fh>) { push @ls, (!/^#/ && /^(\w+)\s*=/ && $1 eq $k) ? ($f = 1, "$k=$v\n") : $_; } }
|
||||
push @ls, "$k=$v\n" unless $f;
|
||||
if (open my $fh, '>:encoding(UTF-8)', 'model.cfg') { print $fh @ls; close $fh; } }
|
||||
|
||||
sub main {
|
||||
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]: $!"; # file mode
|
||||
local $/; push @$msgs, {role=>'user', content=><$f>}; AL($c, $msgs, $sp); autosave($msgs); return; }
|
||||
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; }
|
||||
print "MicroBantam ready ($c->{model}). Commands: /quit /clear /save /list /load <id> /cfg <k> [v] /help\n";
|
||||
while (1) {
|
||||
print "> "; my $u = <STDIN>; last unless defined $u;
|
||||
$u =~ s/^\s+|\s+$//g; next unless length $u;
|
||||
if ($u eq '/quit') { last; }
|
||||
print "> "; my $u = <STDIN>; last unless defined $u; $u =~ s/^\s+|\s+$//g; next unless length $u;
|
||||
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 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 =~ /^\/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"; } }
|
||||
elsif ($u eq '/help') { print "Commands: /quit /clear /save /list /load <id> /cfg <k> [v] /help\n"; }
|
||||
elsif ($u eq '/help') { print "Commands: /quit /clear /save /list /load <id> /cfg <k> [v] /help\n"; }
|
||||
else { push @$msgs, {role=>'user', content=>$u}; AL($c, $msgs, $sp); autosave($msgs); }
|
||||
}
|
||||
autosave($msgs);
|
||||
}
|
||||
|
||||
main() unless caller();
|
||||
|
||||
Reference in New Issue
Block a user