From f039985c6f6060b7e3f8d2dc42eec253efec2c5c Mon Sep 17 00:00:00 2001 From: Luxferre Date: Tue, 11 Aug 2026 11:36:04 +0300 Subject: [PATCH] added some guards --- README.md | 13 +++++++++++++ bantam.pl | 24 +++++++++++++++++++++--- bantam.py | 20 ++++++++++++++++++-- main.go | 21 ++++++++++++++++++++- mb | 8 +++++++- mb.tcl | 49 +++++++++++++++++++++++++++++++++++++------------ model.cfg | 2 +- 7 files changed, 117 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 9d3d96a..e71a2b4 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,8 @@ Using these rules, everyone can build their own copy of Bantam from scratch in l - Loop back to step 1. 4. If no pending tool calls (or if `max_al_iterations` is reached), stop and return `messages`. +If the API rejects the request with an `Invalid assistant message: content or tool_calls must be set` error (usually caused by a previously cut-off stream that left an empty assistant message in the session), all implementations strip the last `assistant`-role message from the session and retry the call. + ### Model configuration parameters (shared by all implementations; `model.cfg` is plain `key=value` with `#` comments) @@ -156,6 +158,7 @@ MicroBantam (`mb`) is a compressed Perl 5 reference implementation of the same a - Session management: `/save`, `/list`, `/load ` (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 - Interactive mode (`/quit`, `/clear`, `/save`, `/list`, `/load `, `/help`) and file input mode - Same built-in default system prompt and `OPENAI_API_KEY` fallback as the full implementation +- 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 @@ -165,6 +168,15 @@ MicroBantam (`mb`) is a compressed Perl 5 reference implementation of the same a - Fibonacci backoff network retries (a failed request aborts with an `API error` message) - `/compact` context summarization +### Jim Tcl port + +`mb.tcl` is a 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 @@ -178,6 +190,7 @@ MicroBantam (`mb`) is a compressed Perl 5 reference implementation of the same a - `main.go`, `term_linux.go`, `term_darwin.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.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 - `README.md` — this document diff --git a/bantam.pl b/bantam.pl index b0f481d..a03befe 100755 --- a/bantam.pl +++ b/bantam.pl @@ -134,7 +134,9 @@ sub llm { return $d->{choices}[0]{message} if $d && $d->{choices} && @{$d->{choices}}; } my $status = $res->{status} // 0; - my $err = $res->{reason} || $res->{content} || "HTTP status $status"; + 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; } @@ -192,7 +194,9 @@ sub llm { return \%msg; } my $status = $res->{status} // 0; - my $err = $res->{reason} || $res->{content} || "HTTP status $status"; + 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; } @@ -210,7 +214,21 @@ sub AL { for my $i (1 .. $mx) { my $m = eval { llm($cfg, $msgs, \@tools) }; - if ($@) { print c($@, 31); return $msgs; } + 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}; diff --git a/bantam.py b/bantam.py index 0538e34..eb15ccd 100755 --- a/bantam.py +++ b/bantam.py @@ -2,7 +2,7 @@ # Bantam agent: tiny, powerful, DIY # Created by Luxferre in 2026, released into the public domain -import sys, os, json, time, subprocess, urllib.request +import sys, os, json, re, time, subprocess, urllib.request try: import readline except ImportError: readline = None @@ -86,6 +86,9 @@ def sanitize_msgs(msgs): 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): @@ -155,7 +158,20 @@ 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): - m = llm(cfg, msgs, TOOLS); msgs.append(m) + 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") diff --git a/main.go b/main.go index 5af137f..437d608 100644 --- a/main.go +++ b/main.go @@ -173,6 +173,11 @@ func sanitizeMessages(msgs []Message) { } } +func isInvalidAssistantErr(err error) bool { + s := err.Error() + return strings.Contains(s, "Invalid assistant message") || strings.Contains(s, "content or tool_calls must be set") +} + func llm(cfg *Cfg, msgs []Message, tools []map[string]any) (Message, error) { sanitizeMessages(msgs) p := map[string]any{"model": cfg.Model, "temperature": cfg.Temperature, "messages": msgs, "stream": cfg.Stream} @@ -324,7 +329,21 @@ func AL(cfg *Cfg, msgs []Message, sp string, depth int) ([]Message, error) { done := false for i := 0; i < cfg.MaxALIterations && !done; i++ { m, err := llm(cfg, msgs, TOOLS) - if err != nil { return msgs, err } + if err != nil { + if isInvalidAssistantErr(err) { + stripped := false + for j := len(msgs) - 1; j >= 0; j-- { + if msgs[j].Role == "assistant" { + fmt.Println(c("[stripped malformed assistant message]", 33)) + msgs = append(msgs[:j], msgs[j+1:]...) + stripped = true + break + } + } + if stripped { continue } + } + return msgs, err + } for j := range m.ToolCalls { tc := &m.ToolCalls[j] astr := tc.Function.Arguments diff --git a/mb b/mb index dff5cdb..8804e0a 100755 --- a/mb +++ b/mb @@ -41,7 +41,9 @@ sub llm { my ($c, $msgs) = @_; # one non-streaming chat completion print "\r\e[K" if $tty; # clear the spinner line my $d = $r->{success} ? eval { decode_json($r->{content}) } : undef; return $d->{choices}[0]{message} if $d && $d->{choices} && @{$d->{choices}}; - die "API error: " . ($r->{reason} || "HTTP $r->{status}") . "\n"; } + my $rb = $r->{content} // ''; + $rb = substr($rb, 0, 500) if length($rb) > 500; + die "API error: " . (length($rb) ? "$rb (HTTP $r->{status})" : ($r->{reason} || "HTTP $r->{status}")) . "\n"; } sub shell_exec { my ($cmd, $t) = @_; # run a command under a hard timeout my $out = ''; @@ -56,6 +58,10 @@ sub AL { my ($c, $msgs, $sp, $depth) = @_; # the agentic loop: LLM <-> tools unt $depth ||= 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; } } + 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}; diff --git a/mb.tcl b/mb.tcl index f246c33..9f46939 100755 --- a/mb.tcl +++ b/mb.tcl @@ -1,5 +1,5 @@ #!/usr/bin/env jimsh -# MicroBantam (mb): the Bantam agent in Jim Tcl (<100 SLOC) +# 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." @@ -21,15 +21,45 @@ proc encode_json_msg {m} { if {![is_dict $m]} { return "\{\}" }; set parts [list 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"]}}}]}; return "\{ \"model\": [jesc [dict get $c model]], \"temperature\": [expr {[dict get $c temperature] + 0}], \"messages\": \[ [join $mjs ", "] \], \"tools\": $tools \}" } -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}]; puts -nonewline [expr {$is_tty ? "\r...requesting..." : "...requesting...\n"}]; flush stdout; 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 res [dict create]; 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} { error "API error: $res" }; if {[dict get $res status] != 200} { error "API error: [expr {[safe_get $res reason] ne "" ? [safe_get $res reason] : "HTTP [dict get $res status]"}]" }; set body [dict get $res body]; if {[catch {json::decode $body} data] || ![is_dict $data] || ![dict exists $data choices]} { error "API error: [expr {[safe_get $res reason] ne "" ? [safe_get $res reason] : "HTTP [dict get $res status]"}]" }; set choices [dict get $data choices]; if {[llength $choices] > 0} { return [dict get [lindex $choices 0] message] }; error "API error: [expr {[safe_get $res reason] ne "" ? [safe_get $res reason] : "HTTP [dict get $res status]"}]" } +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 $out]; return [expr {$exit_code == 124 ? "$out\n\[timeout after ${t}s\]\nexit: -1" : "$out\nexit: $exit_code"}] } +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]} { puts $m; return $msgs } + 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] } @@ -49,7 +79,7 @@ proc AL {c msgs_var sp depth} { 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 id [clock format [clock seconds] -format "%Y%m%d-%H%M%S"]; set i 0; while {[file exists "$sd/$id.json"]} { set fn "$sd/$id-[incr i].json"; set id "$id-$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 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 } @@ -58,8 +88,7 @@ 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 + 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 /help" while {1} { @@ -69,11 +98,7 @@ proc main {argv} { 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 ne ""} { - if {[catch {load_session $want_id} loaded]} { puts $loaded } else { set msgs $loaded; autosave $msgs; puts "loaded: $want_id" } - } else { puts "usage: /load " } - } \ + elseif {[regexp {^\/load(?:\s+(\S+))?$} $u -> want_id]} { if {$want_id eq ""} { puts "usage: /load " } elseif {[catch {load_session $want_id} loaded]} { puts $loaded } else { set msgs $loaded; autosave $msgs; puts "loaded: $want_id" } } \ elseif {$u eq "/help"} { puts "Commands: /quit /clear /save /list /load /help" } \ else { lappend msgs [dict create role user content $u]; AL $c msgs $sp_text 0; autosave $msgs } } diff --git a/model.cfg b/model.cfg index d986281..17283cb 100644 --- a/model.cfg +++ b/model.cfg @@ -1,5 +1,5 @@ endpoint=https://opencode.ai/zen/v1 -model=deepseek-v4-flash-free +model=big-pickle temperature=0.7 api_key=- stream=true