Files
bantam/main.go
T

1201 lines
38 KiB
Go

// Bantam agent: tiny, powerful, DIY
// Created by Luxferre in 2026, released into the public domain
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"unicode/utf8"
)
const MAX_DEPTH = 5 // subagent recursion depth limit
var (
COL bool // ANSI color enabled
hist []string // line history (mirrors ~/.bantam_history)
histF string // history file path
stdin *bufio.Reader // stdin reader for the REPL
)
type Cfg struct {
Endpoint string
Model string
APIKey string
Temperature float64
Timeout int
ShellTimeout int
MaxALIterations int
Stream bool
Color string
Raw map[string]string
}
var defCfg = Cfg{"https://opencode.ai/zen/v1", "big-pickle", "-", 0.7, 300, 120, 1000, true, "auto", nil}
func atoiD(s string, d int) int {
if v, e := strconv.Atoi(strings.TrimSpace(s)); e == nil {
return v
}
return d
}
func getCfg(path string) Cfg {
cfg := defCfg
cfg.Raw = map[string]string{
"endpoint": cfg.Endpoint, "model": cfg.Model, "temperature": fmt.Sprintf("%v", cfg.Temperature),
"api_key": cfg.APIKey, "stream": strconv.FormatBool(cfg.Stream), "color": cfg.Color,
"timeout": strconv.Itoa(cfg.Timeout), "shell_timeout": strconv.Itoa(cfg.ShellTimeout),
"max_al_iterations": strconv.Itoa(cfg.MaxALIterations),
}
if d, err := os.ReadFile(path); err == nil {
for _, ln := range strings.Split(string(d), "\n") {
ln = strings.TrimSpace(ln)
if ln == "" || ln[0] == '#' || !strings.Contains(ln, "=") { continue }
k, v, _ := strings.Cut(ln, "=")
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
cfg.Raw[k] = v
switch k {
case "endpoint": cfg.Endpoint = v
case "model": cfg.Model = v
case "api_key": cfg.APIKey = v
case "temperature": if f, e := strconv.ParseFloat(v, 64); e == nil { cfg.Temperature = f }
case "timeout": cfg.Timeout = atoiD(v, cfg.Timeout)
case "shell_timeout": cfg.ShellTimeout = atoiD(v, cfg.ShellTimeout)
case "max_al_iterations": cfg.MaxALIterations = atoiD(v, cfg.MaxALIterations)
case "stream": cfg.Stream = v == "true" || v == "1" || v == "yes"
case "color": cfg.Color = v
}
}
}
if (cfg.APIKey == "" || cfg.APIKey == "-") && os.Getenv("OPENAI_API_KEY") != "" {
cfg.APIKey = os.Getenv("OPENAI_API_KEY")
cfg.Raw["api_key"] = cfg.APIKey
}
return cfg
}
func setCfg(path, key, val string) error {
var lines []string
found := false
if d, err := os.ReadFile(path); err == nil {
for _, ln := range strings.Split(string(d), "\n") {
trimmed := strings.TrimSpace(ln)
if !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "=") {
k, _, _ := strings.Cut(trimmed, "=")
if strings.TrimSpace(k) == key {
lines = append(lines, key+"="+val)
found = true
continue
}
}
lines = append(lines, ln)
}
}
if !found {
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines[len(lines)-1] = key + "=" + val
lines = append(lines, "")
} else {
lines = append(lines, key+"="+val)
}
}
return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0644)
}
const defaultSystemPrompt = `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.`
func prompt(path string) string {
if d, err := os.ReadFile(path); err == nil {
return strings.TrimSpace(string(d))
}
return defaultSystemPrompt
}
func c(t string, cs ...int) string {
if !COL || len(cs) == 0 { return t }
s := make([]string, len(cs))
for i, x := range cs { s[i] = strconv.Itoa(x) }
return "\033[" + strings.Join(s, ";") + "m" + t + "\033[0m"
}
func col(cfg Cfg) bool {
if os.Getenv("NO_COLOR") != "" || os.Getenv("BANTAM_NO_COLOR") != "" { return false }
switch strings.ToLower(cfg.Color) {
case "always": return true
case "never": return false
}
return isTerminal(int(os.Stdout.Fd()))
}
type mdState struct {
inCode bool
lang string
}
var (
reCode = regexp.MustCompile("`([^`]+)`")
reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
reBI1 = regexp.MustCompile(`\*\*\*(.*?)\*\*\*`)
reBI2 = regexp.MustCompile(`___(.*?)___`)
reB1 = regexp.MustCompile(`\*\*(.*?)\*\*`)
reB2 = regexp.MustCompile(`__(.*?)__`)
reI1 = regexp.MustCompile(`\*(.*?)\*`)
reI2 = regexp.MustCompile(`_(.*?)_`)
reS = regexp.MustCompile(`~~(.*?)~~`)
reOrd = regexp.MustCompile(`^(\d+\.)\s+(.*)`)
)
func renderInline(s string) string {
if !COL { return s }
var codes []string
s = reCode.ReplaceAllStringFunc(s, func(m string) string {
codes = append(codes, c(m[1:len(m)-1], 33))
return fmt.Sprintf("\x00CD%d\x00", len(codes)-1)
})
s = reLink.ReplaceAllStringFunc(s, func(m string) string {
sm := reLink.FindStringSubmatch(m)
if len(sm) == 3 { return c(sm[1], 4, 36) + " " + c("("+sm[2]+")", 2) }
return m
})
s = reBI1.ReplaceAllStringFunc(s, func(m string) string { return c(m[3:len(m)-3], 1, 3) })
s = reBI2.ReplaceAllStringFunc(s, func(m string) string { return c(m[3:len(m)-3], 1, 3) })
s = reB1.ReplaceAllStringFunc(s, func(m string) string { return c(m[2:len(m)-2], 1) })
s = reB2.ReplaceAllStringFunc(s, func(m string) string { return c(m[2:len(m)-2], 1) })
s = reI1.ReplaceAllStringFunc(s, func(m string) string { return c(m[1:len(m)-1], 3) })
s = reI2.ReplaceAllStringFunc(s, func(m string) string { return c(m[1:len(m)-1], 3) })
s = reS.ReplaceAllStringFunc(s, func(m string) string { return c(m[2:len(m)-2], 9) })
for i, code := range codes {
s = strings.ReplaceAll(s, fmt.Sprintf("\x00CD%d\x00", i), code)
}
return s
}
func renderMDLine(line string, st *mdState) string {
if !COL { return line }
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "```") {
if !st.inCode {
st.inCode = true
st.lang = strings.TrimSpace(strings.TrimPrefix(trimmed, "```"))
title := ""
if st.lang != "" { title = " [ " + st.lang + " ]" }
return c("───"+title+"──────────────────────────────────────────", 2)
}
st.inCode = false
st.lang = ""
return c("───────────────────────────────────────────────────", 2)
}
if st.inCode {
return c(" ", 2) + c(line, 32)
}
if trimmed == "---" || trimmed == "***" || trimmed == "___" || trimmed == "----" || trimmed == "------" {
return c("───────────────────────────────────────────────────", 2)
}
if strings.HasPrefix(trimmed, "#") {
lvl := 0
for lvl < len(trimmed) && trimmed[lvl] == '#' { lvl++ }
if lvl < len(trimmed) && trimmed[lvl] == ' ' {
htext := strings.TrimSpace(trimmed[lvl:])
switch lvl {
case 1: return c("■ ", 35) + c(htext, 1, 37)
case 2: return c("▲ ", 34) + c(htext, 1, 36)
case 3: return c("● ", 32) + c(htext, 1, 32)
case 4: return c("◆ ", 33) + c(htext, 1, 33)
default: return c(htext, 1)
}
}
}
if strings.HasPrefix(trimmed, ">") {
qtext := strings.TrimSpace(strings.TrimPrefix(trimmed, ">"))
return c("▎ ", 34) + c(renderInline(qtext), 3)
}
if strings.HasPrefix(trimmed, "- [ ] ") || strings.HasPrefix(trimmed, "* [ ] ") {
return strings.Repeat(" ", len(line)-len(strings.TrimLeft(line, " "))) + c("☐ ", 33) + renderInline(trimmed[6:])
}
if strings.HasPrefix(trimmed, "- [x] ") || strings.HasPrefix(trimmed, "* [x] ") || strings.HasPrefix(trimmed, "- [X] ") || strings.HasPrefix(trimmed, "* [X] ") {
return strings.Repeat(" ", len(line)-len(strings.TrimLeft(line, " "))) + c("☑ ", 32) + renderInline(trimmed[6:])
}
if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") || strings.HasPrefix(trimmed, "+ ") {
indent := strings.Repeat(" ", len(line)-len(strings.TrimLeft(line, " ")))
return indent + c("• ", 36) + renderInline(trimmed[2:])
}
if m := reOrd.FindStringSubmatch(trimmed); len(m) == 3 {
indent := strings.Repeat(" ", len(line)-len(strings.TrimLeft(line, " ")))
return indent + c(m[1]+" ", 33) + renderInline(m[2])
}
if strings.HasPrefix(trimmed, "|") && strings.HasSuffix(trimmed, "|") {
return renderInline(line)
}
return renderInline(line)
}
func isTableSep(cells []string) bool {
if len(cells) == 0 { return false }
for _, cell := range cells {
c := strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(cell), "-", ""), ":", "")
if c != "" { return false }
}
return true
}
func parseTableCells(line string) []string {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "|") { trimmed = trimmed[1:] }
if strings.HasSuffix(trimmed, "|") { trimmed = trimmed[:len(trimmed)-1] }
parts := strings.Split(trimmed, "|")
cells := make([]string, len(parts))
for i, p := range parts { cells[i] = strings.TrimSpace(p) }
return cells
}
func isTableLine(line string) bool {
trimmed := strings.TrimSpace(line)
if !strings.Contains(trimmed, "|") { return false }
return strings.HasPrefix(trimmed, "|") || strings.HasSuffix(trimmed, "|")
}
func renderTable(lines []string) []string {
if len(lines) == 0 { return nil }
var rows [][]string
var headerRow []string
hasHeader := false
for _, ln := range lines {
cells := parseTableCells(ln)
if isTableSep(cells) {
if len(rows) > 0 && !hasHeader {
headerRow = rows[len(rows)-1]
rows = rows[:len(rows)-1]
hasHeader = true
}
continue
}
rows = append(rows, cells)
}
if !hasHeader && len(lines) < 2 {
var out []string
var st mdState
for _, ln := range lines { out = append(out, renderMDLine(ln, &st)) }
return out
}
numCols := len(headerRow)
for _, r := range rows {
if len(r) > numCols { numCols = len(r) }
}
if numCols == 0 { return nil }
if hasHeader {
for len(headerRow) < numCols { headerRow = append(headerRow, "") }
}
for i := range rows {
for len(rows[i]) < numCols { rows[i] = append(rows[i], "") }
}
colWidths := make([]int, numCols)
for i := 0; i < numCols; i++ {
if hasHeader {
vl := visibleLen(headerRow[i])
if vl > colWidths[i] { colWidths[i] = vl }
}
for _, r := range rows {
vl := visibleLen(renderInline(r[i]))
if vl > colWidths[i] { colWidths[i] = vl }
}
if colWidths[i] < 3 { colWidths[i] = 3 }
}
var res []string
var topParts []string
for _, w := range colWidths { topParts = append(topParts, strings.Repeat("─", w+2)) }
res = append(res, c("┌"+strings.Join(topParts, "┬")+"┐", 2))
if hasHeader {
var hCells []string
for i, h := range headerRow {
rh := c(h, 1, 36)
pad := strings.Repeat(" ", colWidths[i]-visibleLen(h))
hCells = append(hCells, " "+rh+pad+" ")
}
res = append(res, c("│", 2)+strings.Join(hCells, c("│", 2))+c("│", 2))
var midParts []string
for _, w := range colWidths { midParts = append(midParts, strings.Repeat("─", w+2)) }
res = append(res, c("├"+strings.Join(midParts, "┼")+"┤", 2))
}
for _, r := range rows {
var rCells []string
for i, cell := range r {
rc := renderInline(cell)
pad := strings.Repeat(" ", colWidths[i]-visibleLen(rc))
rCells = append(rCells, " "+rc+pad+" ")
}
res = append(res, c("│", 2)+strings.Join(rCells, c("│", 2))+c("│", 2))
}
var botParts []string
for _, w := range colWidths { botParts = append(botParts, strings.Repeat("─", w+2)) }
res = append(res, c("└"+strings.Join(botParts, "┴")+"┘", 2))
return res
}
func renderMD(text string) string {
if !COL { return text }
lines := strings.Split(text, "\n")
var out []string
var st mdState
var tbl []string
flushTable := func() {
if len(tbl) > 0 {
out = append(out, renderTable(tbl)...)
tbl = nil
}
}
for _, ln := range lines {
trimmed := strings.TrimSpace(ln)
if strings.HasPrefix(trimmed, "```") {
flushTable()
out = append(out, renderMDLine(ln, &st))
continue
}
if st.inCode {
out = append(out, renderMDLine(ln, &st))
continue
}
if isTableLine(ln) {
tbl = append(tbl, ln)
continue
}
flushTable()
out = append(out, renderMDLine(ln, &st))
}
flushTable()
return strings.Join(out, "\n")
}
type Message struct {
Role string `json:"role"`
Content *string `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
var TOOLS = []map[string]any{
{"type": "function", "function": map[string]any{"name": "shell_exec", "description": "Run a shell command, return output and exit code.", "parameters": map[string]any{"type": "object", "properties": map[string]any{"command": map[string]any{"type": "string"}}, "required": []string{"command"}}}},
{"type": "function", "function": map[string]any{"name": "run_subagent", "description": "Run a child agent with a prompt.", "parameters": map[string]any{"type": "object", "properties": map[string]any{"prompt": map[string]any{"type": "string"}}, "required": []string{"prompt"}}}},
}
func strp(s string) *string { return &s }
type streamDelta struct {
Choices []struct {
Delta struct {
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
Content string `json:"content"`
ToolCalls []struct {
Index int `json:"index"`
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"delta"`
} `json:"choices"`
}
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]
astr := tc.Function.Arguments
var a map[string]any
if err := json.Unmarshal([]byte(astr), &a); err != nil || a == nil {
fixed, _ := json.Marshal(map[string]string{"invalid_raw": astr})
tc.Function.Arguments = string(fixed)
}
}
}
}
}
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}
if tools != nil { p["tools"] = tools }
for k, v := range cfg.Raw {
switch k {
case "endpoint", "api_key", "timeout", "shell_timeout", "max_al_iterations", "color":
continue
default:
var jv any
if err := json.Unmarshal([]byte(v), &jv); err == nil {
p[k] = jv
} else {
p[k] = v
}
}
}
body, _ := json.Marshal(p)
client := &http.Client{Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: time.Duration(cfg.Timeout) * time.Second}).DialContext,
ResponseHeaderTimeout: time.Duration(cfg.Timeout) * time.Second,
}}
fib := []int{1, 1, 2, 3, 5, 8, 13, 21, 34}
pend := c("...requesting...", 1, 2)
var resp *http.Response
var err error
for i := 0; i <= len(fib); i++ {
if COL { fmt.Print("\r" + pend) } else { fmt.Println(pend) }
req, _ := http.NewRequest("POST", strings.TrimRight(cfg.Endpoint, "/")+"/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Bantam/1.0)")
if cfg.APIKey != "" && cfg.APIKey != "-" { req.Header.Set("Authorization", "Bearer "+cfg.APIKey) }
resp, err = client.Do(req)
var is4xxClientErr bool
if err == nil && resp.StatusCode >= 400 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
err = fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
if resp.StatusCode < 500 && resp.StatusCode != 408 && resp.StatusCode != 429 {
is4xxClientErr = true
}
resp = nil
}
if err == nil { break }
if COL { fmt.Print("\r\033[K") }
if is4xxClientErr {
return Message{}, err
}
if i < len(fib) {
fmt.Println(c(fmt.Sprintf("[network error: %v, retrying in %ds...]", err, fib[i]), 31))
time.Sleep(time.Duration(fib[i]) * time.Second)
}
}
if err != nil {
if COL { fmt.Print("\r\033[K") }
return Message{}, err
}
defer resp.Body.Close()
if COL { fmt.Print("\r\033[K") }
if !cfg.Stream {
var cr struct {
Choices []struct {
Message struct {
Message
Reasoning string `json:"reasoning"`
} `json:"message"`
} `json:"choices"`
}
if err := json.NewDecoder(resp.Body).Decode(&cr); err != nil { return Message{}, err }
if len(cr.Choices) == 0 { return Message{}, errors.New("empty choices in LLM response") }
m := cr.Choices[0].Message.Message
if m.ReasoningContent == "" { m.ReasoningContent = cr.Choices[0].Message.Reasoning }
return m, nil
}
return parseStream(resp.Body)
}
func parseStream(r io.Reader) (Message, error) {
var content, reas string
var rh, ch bool
var lineBuf string
var mdSt mdState
var tblBuf []string
tcs := map[int]*ToolCall{}
var order []int
flushTable := func() {
if len(tblBuf) > 0 {
for _, tln := range renderTable(tblBuf) { fmt.Println(tln) }
tblBuf = nil
}
}
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
ln := strings.TrimSpace(sc.Text())
if !strings.HasPrefix(ln, "data:") { continue }
data := strings.TrimSpace(ln[5:])
if data == "[DONE]" { break }
var d streamDelta
if json.Unmarshal([]byte(data), &d) != nil || len(d.Choices) == 0 { continue }
dl := d.Choices[0].Delta
rc := dl.ReasoningContent
if rc == "" { rc = dl.Reasoning }
if rc != "" {
if !rh { fmt.Println(c("--- reasoning start ---", 36)); rh = true }
fmt.Print(c(rc, 2))
reas += rc
}
if dl.Content != "" {
if rh && !ch { fmt.Print("\n" + c("--- reasoning end ---", 36) + "\n\n") }
ch = true
content += dl.Content
lineBuf += dl.Content
for {
idx := strings.IndexByte(lineBuf, '\n')
if idx == -1 { break }
curLine := lineBuf[:idx]
lineBuf = lineBuf[idx+1:]
trimmed := strings.TrimSpace(curLine)
if strings.HasPrefix(trimmed, "```") {
flushTable()
fmt.Println(renderMDLine(curLine, &mdSt))
} else if mdSt.inCode {
fmt.Println(renderMDLine(curLine, &mdSt))
} else if isTableLine(curLine) {
tblBuf = append(tblBuf, curLine)
} else {
flushTable()
fmt.Println(renderMDLine(curLine, &mdSt))
}
}
}
for _, tc := range dl.ToolCalls {
t, ok := tcs[tc.Index]
if !ok {
t = &ToolCall{Type: "function"}
tcs[tc.Index] = t
order = append(order, tc.Index)
}
if tc.ID != "" { t.ID = tc.ID }
if tc.Function.Name != "" { t.Function.Name += tc.Function.Name }
if tc.Function.Arguments != "" { t.Function.Arguments += tc.Function.Arguments }
}
}
flushTable()
if lineBuf != "" {
if !mdSt.inCode && isTableLine(lineBuf) {
tblBuf = append(tblBuf, lineBuf)
flushTable()
} else {
fmt.Println(renderMDLine(lineBuf, &mdSt))
}
}
if rh && !ch {
fmt.Print("\n" + c("--- reasoning end ---", 36) + "\n")
}
m := Message{Role: "assistant"}
if content != "" { m.Content = strp(content) }
if reas != "" { m.ReasoningContent = reas }
if len(tcs) > 0 {
m.ToolCalls = make([]ToolCall, 0, len(order))
for _, idx := range order { m.ToolCalls = append(m.ToolCalls, *tcs[idx]) }
}
return m, sc.Err()
}
func shell(cmd string, timeout int) string {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "sh", "-c", cmd).CombinedOutput()
res := strings.TrimSpace(string(out))
if ctx.Err() == context.DeadlineExceeded {
return fmt.Sprintf("%s\n\n[shell timeout after %ds]\nexit: -1", res, timeout)
}
code := 0
if err != nil {
if ee, ok := err.(*exec.ExitError); ok {
code = ee.ExitCode()
} else {
code = -1
}
}
return fmt.Sprintf("%s\n\nexit: %d", res, code)
}
func last(msgs []Message) string {
for i := len(msgs) - 1; i >= 0; i-- {
if msgs[i].Role == "assistant" && msgs[i].Content != nil && *msgs[i].Content != "" {
return *msgs[i].Content
}
}
return ""
}
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 {
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
var a map[string]any
if err := json.Unmarshal([]byte(astr), &a); err != nil || a == nil {
fixed, _ := json.Marshal(map[string]string{"invalid_raw": astr})
tc.Function.Arguments = string(fixed)
}
}
msgs = append(msgs, m)
if !cfg.Stream {
if m.ReasoningContent != "" {
fmt.Println(c("--- reasoning start ---", 36) + "\n" + c(m.ReasoningContent, 2) + "\n" + c("--- reasoning end ---", 36))
}
if m.Content != nil { fmt.Println(renderMD(*m.Content)) }
}
if len(m.ToolCalls) == 0 { done = true; break }
for _, tc := range m.ToolCalls {
fn, astr := tc.Function.Name, tc.Function.Arguments
fmt.Println(c(fmt.Sprintf("[tool call: %s(%s)]", fn, astr), 33))
res, sty := "", 2
var a map[string]any
if err := json.Unmarshal([]byte(astr), &a); err != nil || a == nil {
res, sty = fmt.Sprintf("[tool error: invalid JSON args for %s: %v. Raw: %q]", fn, err, astr), 31
} else {
switch fn {
case "shell_exec":
cmd, _ := a["command"].(string)
res = shell(cmd, cfg.ShellTimeout)
case "run_subagent":
pr, _ := a["prompt"].(string)
if depth >= MAX_DEPTH {
res, sty = fmt.Sprintf("[subagent depth limit (%d) reached, child not spawned]", MAX_DEPTH), 31
} else if subr, err := AL(cfg, []Message{{Role: "system", Content: strp(sp + "\n\nImportant: this is a child agent")}, {Role: "user", Content: strp(pr)}}, sp, depth+1); err != nil {
res, sty = "[subagent error: "+err.Error()+"]", 31
} else {
res, sty = last(subr), 2
}
default:
res, sty = "Unknown tool: "+fn, 31
}
}
fmt.Println(c("[tool result: "+fn+"]", 32) + "\n" + c(res, sty) + "\n")
msgs = append(msgs, Message{Role: "tool", ToolCallID: tc.ID, Content: strp(res)})
}
}
if !done {
msgs = append(msgs, Message{Role: "assistant", Content: strp(fmt.Sprintf("[max AL iterations (%d) reached]", cfg.MaxALIterations))})
}
return msgs, nil
}
func homeDir() string {
if h, err := os.UserHomeDir(); err == nil && h != "" {
return h
}
return "."
}
func sdir() string {
d := filepath.Join(homeDir(), ".bantam", "sessions")
os.MkdirAll(d, 0755)
return d
}
type Session struct {
ID string `json:"id"`
Created string `json:"created"`
Summary string `json:"summary"`
Messages []Message `json:"messages"`
}
func summary(msgs []Message) string {
for _, m := range msgs {
if m.Role == "user" && m.Content != nil && strings.TrimSpace(*m.Content) != "" {
t := strings.Join(strings.Fields(*m.Content), " ")
if len(t) > 80 { t = t[:80] + "..." }
return t
}
}
return "(empty session)"
}
func fileExists(p string) bool {
_, err := os.Stat(p)
return err == nil
}
func saveSession(msgs []Message) (string, string) {
d := sdir()
base := time.Now().Format("20060102-150405")
sid, path := base, filepath.Join(d, base+".json")
for i := 1; fileExists(path); i++ {
sid = fmt.Sprintf("%s-%d", base, i)
path = filepath.Join(d, sid+".json")
}
s := Session{sid, time.Now().Format("2006-01-02 15:04:05"), summary(msgs), msgs}
b, _ := json.MarshalIndent(s, "", " ")
os.WriteFile(path, b, 0644)
return sid, s.Summary
}
func sessions() []Session {
out := []Session{}
d := filepath.Join(homeDir(), ".bantam", "sessions")
entries, err := os.ReadDir(d)
if err != nil {
return out
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { continue }
b, err := os.ReadFile(filepath.Join(d, e.Name()))
if err != nil { continue }
var s Session
if json.Unmarshal(b, &s) != nil { continue }
if s.ID == "" { s.ID = strings.TrimSuffix(e.Name(), ".json") }
out = append(out, s)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
return out
}
func loadSession(sid string) ([]Message, error) {
ss := sessions()
for i := range ss {
if ss[i].ID == sid { return ss[i].Messages, nil }
}
var pref []*Session
for i := range ss {
if strings.HasPrefix(ss[i].ID, sid) { pref = append(pref, &ss[i]) }
}
if len(pref) == 1 { return pref[0].Messages, nil }
if len(pref) > 1 {
names := make([]string, len(pref))
for i, s := range pref { names[i] = s.ID }
return nil, fmt.Errorf("ambiguous prefix: %s", strings.Join(names, ", "))
}
return nil, fmt.Errorf("session not found: %s", sid)
}
func autosave(msgs []Message) {
s := Session{"autosave", time.Now().Format("2006-01-02 15:04:05"), summary(msgs), msgs}
b, _ := json.MarshalIndent(s, "", " ")
os.WriteFile(filepath.Join(sdir(), "autosave.json"), b, 0644)
}
func summarize(cfg *Cfg, msgs []Message) (string, error) {
var sb strings.Builder
for _, m := range msgs {
if m.Role == "system" { continue }
ct := ""
if m.Content != nil { ct = *m.Content }
if ct == "" && len(m.ToolCalls) > 0 {
jc := make([]map[string]any, 0, len(m.ToolCalls))
for _, tc := range m.ToolCalls {
jc = append(jc, map[string]any{"function": map[string]any{"name": tc.Function.Name, "arguments": tc.Function.Arguments}})
}
b, _ := json.Marshal(jc)
ct = string(b)
}
if ct == "" { continue }
if len(ct) > 4000 { ct = ct[:4000] + "...[truncated]" }
sb.WriteString(m.Role + ": " + ct + "\n\n")
}
if sb.Len() == 0 { return "", errors.New("no conversation to summarize") }
joined := sb.String()
if len(joined) > 100000 { joined = joined[len(joined)-100000:] + "\n...[earlier parts truncated]" }
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."
cc := *cfg
cc.Stream = false
m, err := llm(&cc, []Message{{Role: "system", Content: strp(sys)}, {Role: "user", Content: strp("Summarize this conversation:\n\n" + joined)}}, nil)
if err != nil { return "", err }
s := ""
if m.Content != nil { s = *m.Content }
if s == "" { s = m.ReasoningContent }
if strings.TrimSpace(s) == "" { return "", errors.New("LLM returned an empty summary") }
return strings.TrimSpace(s), nil
}
func compact(cfg *Cfg, msgs []Message) ([]Message, string, error) {
if len(msgs) == 0 || msgs[0].Role != "system" {
return msgs, "", errors.New("session has no system message")
}
s, err := summarize(cfg, msgs)
if err != nil { return msgs, "", err }
return []Message{{Role: "system", Content: msgs[0].Content}, {Role: "user", Content: strp("Summary of the previous conversation:\n" + s + "\n\nPlease continue from here.")}}, s, nil
}
func loadHistory() {
histF = filepath.Join(homeDir(), ".bantam_history")
b, err := os.ReadFile(histF)
if err != nil { return }
for _, ln := range strings.Split(string(b), "\n") {
if ln = strings.TrimRight(ln, "\r"); ln != "" { hist = append(hist, ln) }
}
}
func addHistory(s string) {
if s == "" || (len(hist) > 0 && hist[len(hist)-1] == s) { return }
hist = append(hist, s)
}
func saveHistory() {
os.WriteFile(histF, []byte(strings.Join(hist, "\n")+"\n"), 0644)
}
func visibleLen(s string) int {
n := 0
for i := 0; i < len(s); {
switch s[i] {
case 0x1b:
j := i + 1
if j < len(s) && s[j] == '[' {
j++
for j < len(s) && !(s[j] >= 0x40 && s[j] <= 0x7e) { j++ }
if j < len(s) { j++ }
i = j
} else {
i++
}
case 0x01, 0x02:
i++
default:
_, size := utf8.DecodeRuneInString(s[i:])
n++
i += size
}
}
return n
}
type editor struct {
prompt string
buf []rune
pos int
hist []string
hpos int
draft string
crow int
}
func textPos(promptLen, W int, s string, pos int) (row, col int) {
row, col = 0, promptLen
pend := false
for i, r := range []rune(s) {
if i == pos { return row, col }
if r == '\n' {
row++
col = 0
pend = false
continue
}
if pend {
row++
col = 0
pend = false
}
if col == W-1 {
pend = true
} else {
col++
}
}
return row, col
}
func (e *editor) draw() {
W := termWidth()
s := string(e.buf)
P := visibleLen(e.prompt)
er, _ := textPos(P, W, s, len([]rune(s)))
pr, pc := textPos(P, W, s, e.pos)
if e.crow > 0 { fmt.Printf("\033[%dA", e.crow) }
fmt.Print("\r\033[J" + e.prompt + s)
if up := er - pr; up > 0 { fmt.Printf("\033[%dA", up) }
fmt.Print("\r")
if pc > 0 { fmt.Printf("\033[%dC", pc) }
e.crow = pr
}
func (e *editor) histNav(up bool) {
if up {
if len(e.hist) == 0 { return }
if e.hpos < 0 {
e.draft = string(e.buf)
e.hpos = len(e.hist) - 1
} else if e.hpos > 0 {
e.hpos--
}
e.buf = []rune(e.hist[e.hpos])
} else {
if e.hpos < 0 { return }
e.hpos++
if e.hpos >= len(e.hist) {
e.hpos = -1
e.buf = []rune(e.draft)
} else {
e.buf = []rune(e.hist[e.hpos])
}
}
e.pos = len(e.buf)
}
func readPlain(prompt string) (string, bool) {
fmt.Print(prompt)
line, err := stdin.ReadString('\n')
if err != nil && line == "" { return "", false }
return strings.TrimRight(line, "\r\n"), true
}
func readLine(prompt string) (string, bool) {
if !isTerminal(int(os.Stdin.Fd())) { return readPlain(prompt) }
restore, err := makeRaw(int(os.Stdin.Fd()))
if err != nil { return readPlain(prompt) }
defer restore()
e := &editor{prompt: prompt, hpos: -1, hist: hist}
for {
e.draw()
rn, _, err := stdin.ReadRune()
if err != nil {
fmt.Print("\r\n")
return "", false
}
switch rn {
case '\r':
if stdin.Buffered() > 0 {
if b, _ := stdin.Peek(1); len(b) > 0 && b[0] == '\n' {
stdin.ReadByte()
}
}
W := termWidth()
s := string(e.buf)
P := visibleLen(e.prompt)
er, _ := textPos(P, W, s, len([]rune(s)))
pr, _ := textPos(P, W, s, e.pos)
if down := er - pr; down > 0 {
fmt.Printf("\033[%dB", down)
}
fmt.Print("\r\n")
return string(e.buf), true
case '\n':
e.buf = append(e.buf, 0)
copy(e.buf[e.pos+1:], e.buf[e.pos:])
e.buf[e.pos] = '\n'
e.pos++
case 0x03:
fmt.Print("\r\n")
return "", false
case 0x04:
if len(e.buf) == 0 {
fmt.Print("\r\n")
return "", false
}
case 0x7f, 0x08:
if e.pos > 0 {
e.buf = append(e.buf[:e.pos-1], e.buf[e.pos:]...)
e.pos--
}
case 0x1b:
b1, err1 := stdin.ReadByte()
b2, err2 := stdin.ReadByte()
if err1 != nil || err2 != nil { continue }
if b1 == '[' {
switch b2 {
case 'A':
e.histNav(true)
case 'B':
e.histNav(false)
case 'C':
if e.pos < len(e.buf) { e.pos++ }
case 'D':
if e.pos > 0 { e.pos-- }
}
}
default:
if rn >= 32 {
e.buf = append(e.buf, 0)
copy(e.buf[e.pos+1:], e.buf[e.pos:])
e.buf[e.pos] = rn
e.pos++
}
}
}
}
func main() {
sp := prompt("system.txt")
cfg := getCfg("model.cfg")
COL = col(cfg)
stdin = bufio.NewReader(os.Stdin)
msgs := []Message{{Role: "system", Content: strp(sp)}}
if len(os.Args) > 1 && os.Args[1] != "" {
p := os.Args[1]
data, err := os.ReadFile(p)
if err != nil {
fmt.Println(c("Error: file '"+p+"' not found.", 31))
os.Exit(1)
}
msgs = append(msgs, Message{Role: "user", Content: strp(strings.TrimSpace(string(data)))})
if msgs, err = AL(&cfg, msgs, sp, 0); err != nil {
fmt.Println(c("[error: "+err.Error()+"]", 31))
os.Exit(1)
}
autosave(msgs)
return
}
loadHistory()
fmt.Println(c("Bantam Agent ready", 1, 32) + c(" (Ctrl+J = new line)", 2))
fmt.Println(c(fmt.Sprintf("endpoint: %s model: %s temp: %v", cfg.Endpoint, cfg.Model, cfg.Temperature), 2))
for {
u, ok := readLine(c("> ", 1, 36))
if !ok {
fmt.Println()
break
}
u = strings.TrimSpace(u)
if u == "" { continue }
addHistory(u)
switch {
case u == "/quit":
goto done
case u == "/clear":
msgs = []Message{{Role: "system", Content: strp(sp)}}
autosave(msgs)
continue
case u == "/save":
sid, sm := saveSession(msgs)
fmt.Println(c("[session saved: "+sid+"]", 32) + " " + c(sm, 2))
continue
case u == "/list":
ss := sessions()
if len(ss) == 0 {
fmt.Println(c("No sessions saved yet.", 33))
continue
}
for _, s := range ss {
mk := c(" (autosave)", 33)
if s.ID != "autosave" { mk = "" }
fmt.Println(c(s.ID, 32) + mk + c(fmt.Sprintf(" %s [%d msgs]", s.Created, len(s.Messages)), 2))
fmt.Println(" " + c(s.Summary, 2))
}
continue
case strings.HasPrefix(u, "/load"):
parts := strings.Fields(u)
if len(parts) < 2 {
fmt.Println(c("Usage: /load <session-id>", 31))
continue
}
lm, err := loadSession(parts[1])
if err != nil {
fmt.Println(c("Session not found: "+err.Error(), 31))
continue
}
msgs = lm
autosave(msgs)
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))
nm, sm, err := compact(&cfg, msgs)
if err != nil {
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))
continue
case strings.HasPrefix(u, "/cfg"):
parts := strings.SplitN(u, " ", 3)
if len(parts) == 2 {
k := strings.TrimSpace(parts[1])
if v, ok := cfg.Raw[k]; ok {
fmt.Println(c(k+"="+v, 32))
} else {
fmt.Println(c(k+" not set", 31))
}
} else if len(parts) >= 3 {
k, v := strings.TrimSpace(parts[1]), strings.TrimSpace(parts[2])
if err := setCfg("model.cfg", k, v); err != nil {
fmt.Println(c("[cfg error: "+err.Error()+"]", 31))
continue
}
cfg = getCfg("model.cfg")
COL = col(cfg)
fmt.Println(c(fmt.Sprintf("[config updated: %s=%s]", k, v), 32))
} else {
fmt.Println(c("Usage: /cfg <param> [val]", 31))
}
continue
case u == "/help":
fmt.Println(c("Bantam commands:", 1, 36))
for _, kv := range [][2]string{{"/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"}} {
fmt.Println(c(fmt.Sprintf(" %-15s", kv[0]), 1, 32) + kv[1])
}
continue
}
msgs = append(msgs, Message{Role: "user", Content: strp(u)})
var err error
if msgs, err = AL(&cfg, msgs, sp, 0); err != nil {
fmt.Println(c("[error: "+err.Error()+"]", 31))
}
autosave(msgs)
}
done:
autosave(msgs)
saveHistory()
}