Files
bantam/main.go
T

1894 lines
59 KiB
Go
Raw Normal View History

2026-08-09 09:26:44 +03:00
// Bantam agent: tiny, powerful, DIY
// Created by Luxferre in 2026, released into the public domain
2026-08-08 23:10:10 +03:00
package main
import (
"bufio"
"bytes"
"context"
2026-09-01 09:46:13 +03:00
"crypto/md5"
"encoding/hex"
2026-08-08 23:10:10 +03:00
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
2026-09-01 11:02:25 +03:00
"net/url"
2026-08-08 23:10:10 +03:00
"os"
"os/exec"
2026-08-15 17:09:26 +03:00
"os/signal"
2026-08-08 23:10:10 +03:00
"path/filepath"
2026-08-15 08:50:39 +03:00
"regexp"
2026-08-08 23:10:10 +03:00
"sort"
2026-08-18 10:17:52 +03:00
"sync"
2026-08-08 23:10:10 +03:00
"strconv"
"strings"
"time"
2026-08-18 09:24:42 +03:00
"unicode"
2026-08-08 23:10:10 +03:00
"unicode/utf8"
)
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
ContextWindow int
2026-08-15 08:27:24 +03:00
Raw map[string]string
2026-08-08 23:10:10 +03:00
}
2026-08-18 10:17:52 +03:00
// internalKey reports whether a model.cfg key is an agent-internal parameter
// that must never be forwarded to the chat completions API.
func internalKey(k string) bool {
switch k {
case "endpoint", "model", "temperature", "stream", "api_key", "timeout",
"shell_timeout", "max_al_iterations", "color", "context_window":
return true
}
return false
}
2026-09-01 11:02:25 +03:00
func proxyFromEnv(req *http.Request) (*url.URL, error) {
socks := strings.TrimSpace(os.Getenv("SOCKS_PROXY"))
if socks == "" {
socks = strings.TrimSpace(os.Getenv("socks_proxy"))
}
if socks != "" {
if !strings.Contains(socks, "://") {
socks = "socks5://" + socks
}
return url.Parse(socks)
}
return http.ProxyFromEnvironment(req)
}
2026-08-18 10:17:52 +03:00
// llmTransport is a shared HTTP transport reused across all LLM calls so that
// connections are pooled instead of recreated per request.
var llmTransport = &http.Transport{
2026-09-01 11:02:25 +03:00
Proxy: proxyFromEnv,
2026-08-18 10:17:52 +03:00
DialContext: (&net.Dialer{Timeout: 300 * time.Second}).DialContext,
}
var defCfg = Cfg{"https://opencode.ai/zen/v1", "big-pickle", "-", 0.7, 300, 120, 1000, true, "auto", 262144, nil}
2026-08-08 23:10:10 +03:00
func atoiD(s string, d int) int {
if v, e := strconv.Atoi(strings.TrimSpace(s)); e == nil {
return v
}
return d
}
func queryModelsContextWindow(cfg *Cfg) int {
2026-09-01 11:02:25 +03:00
client := &http.Client{
Transport: &http.Transport{
Proxy: proxyFromEnv,
DialContext: (&net.Dialer{Timeout: 3 * time.Second}).DialContext,
},
Timeout: 3 * time.Second,
}
req, err := http.NewRequest("GET", strings.TrimRight(cfg.Endpoint, "/")+"/models", nil)
if err != nil { return 0 }
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)
if err != nil || resp.StatusCode >= 400 { return 0 }
defer resp.Body.Close()
var res struct {
Data []map[string]any `json:"data"`
Models []map[string]any `json:"models"`
}
if json.NewDecoder(resp.Body).Decode(&res) != nil { return 0 }
list := res.Data
if len(list) == 0 { list = res.Models }
for _, item := range list {
id, _ := item["id"].(string)
if id == cfg.Model || strings.EqualFold(id, cfg.Model) {
for _, key := range []string{"context_window", "context_length", "max_context_length", "max_model_len", "context_size", "max_tokens", "max_input_tokens"} {
if val, ok := item[key]; ok {
switch v := val.(type) {
case float64:
if v > 0 { return int(v) }
case string:
if n := atoiD(v, 0); n > 0 { return n }
}
}
}
}
}
return 0
}
2026-08-18 10:17:52 +03:00
var cwCacheMu sync.Mutex
var cwCache = map[string]int{}
func fetchContextWindow(cfg *Cfg) int {
2026-08-18 10:17:52 +03:00
// Only values discovered from the /models endpoint are cached, keyed by
// endpoint+model. The context_window-override and 262144 default are derived
2026-08-18 10:17:52 +03:00
// per call from cfg so they never shadow each other across configs.
key := cfg.Endpoint + "\x00" + cfg.Model
cwCacheMu.Lock()
if cw, ok := cwCache[key]; ok {
cwCacheMu.Unlock()
return cw
}
cwCacheMu.Unlock()
if cw := queryModelsContextWindow(cfg); cw > 0 {
2026-08-18 10:17:52 +03:00
cwCacheMu.Lock()
cwCache[key] = cw
cwCacheMu.Unlock()
return cw
}
if v, ok := cfg.Raw["context_window"]; ok {
return atoiD(v, 262144)
}
return 262144
}
2026-08-28 09:30:48 +03:00
// listModels queries the /models path on the configured inference endpoint and
// returns a plain newline-separated list of supported model IDs. The currently
// configured model is marked with a leading "* ". On transport or HTTP errors a
// non-nil error is returned so the caller can surface it.
func listModels(cfg *Cfg) (string, error) {
t := cfg.Timeout
if t < 10 { t = 10 }
2026-09-01 11:02:25 +03:00
client := &http.Client{
Transport: &http.Transport{
Proxy: proxyFromEnv,
DialContext: (&net.Dialer{Timeout: time.Duration(t) * time.Second}).DialContext,
},
Timeout: time.Duration(t) * time.Second,
}
2026-08-28 09:30:48 +03:00
req, err := http.NewRequest("GET", strings.TrimRight(cfg.Endpoint, "/")+"/models", nil)
if err != nil { return "", err }
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)
if err != nil { return "", err }
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return "", fmt.Errorf("HTTP %d from /models", resp.StatusCode)
}
var res struct {
Data []map[string]any `json:"data"`
Models []map[string]any `json:"models"`
}
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return "", fmt.Errorf("failed to parse /models response: %v", err)
}
list := res.Data
if len(list) == 0 { list = res.Models }
if len(list) == 0 {
return "No models returned by the endpoint.", nil
}
var b strings.Builder
for _, item := range list {
id, _ := item["id"].(string)
if id == "" { continue }
if id == cfg.Model || strings.EqualFold(id, cfg.Model) {
b.WriteString("* " + id + "\n")
} else {
b.WriteString(" " + id + "\n")
}
}
return b.String(), nil
}
2026-08-08 23:10:10 +03:00
func getCfg(path string) Cfg {
cfg := defCfg
2026-08-15 08:27:24 +03:00
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),
"context_window": strconv.Itoa(cfg.ContextWindow),
2026-08-15 08:27:24 +03:00
}
2026-08-08 23:10:10 +03:00
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)
2026-08-15 08:27:24 +03:00
cfg.Raw[k] = v
2026-08-08 23:10:10 +03:00
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
case "context_window": cfg.ContextWindow = atoiD(v, cfg.ContextWindow)
2026-08-08 23:10:10 +03:00
}
}
}
2026-08-15 08:27:24 +03:00
if (cfg.APIKey == "" || cfg.APIKey == "-") && os.Getenv("OPENAI_API_KEY") != "" {
cfg.APIKey = os.Getenv("OPENAI_API_KEY")
cfg.Raw["api_key"] = cfg.APIKey
}
2026-08-08 23:10:10 +03:00
return cfg
}
2026-08-15 08:27:24 +03:00
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)
}
// configPath returns the configuration file to load: .bantam.cfg takes
// priority over model.cfg when both exist in the current working directory,
// falling back to model.cfg (which may be absent, triggering defaults).
func configPath() string {
if _, err := os.Stat(".bantam.cfg"); err == nil {
return ".bantam.cfg"
}
return "model.cfg"
}
2026-08-08 23:20:25 +03:00
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.
2026-09-01 09:17:47 +03:00
- write_file: write content to a file with optional offset and byte deletion; returns status.
2026-08-08 23:20:25 +03:00
2026-09-01 09:17:47 +03:00
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. Stop as soon as the goal is met and report concisely: results, not process.
2026-08-08 23:20:25 +03:00
When generating code:
- Always use two-space indentation, not tabs, except Makefiles that must use tabs.
2026-08-28 17:05:12 +03:00
- No whitespace between keywords and opening parentheses in C-like languages.
2026-08-08 23:20:25 +03:00
- Write optimally and with as few third-party dependencies as possible.
- Always test.
- No emojis in code or documentation.
2026-09-01 09:17:47 +03:00
- Respect AGENTS.md contents in the project.`
2026-08-28 10:56:49 +03:00
func toolsDir(cfg *Cfg) string {
if v := strings.TrimSpace(os.Getenv("BANTAM_TOOLS_DIR")); v != "" {
return v
}
if v := strings.TrimSpace(cfg.Raw["bantam_tools_dir"]); v != "" {
return v
}
return ""
}
2026-08-08 23:10:10 +03:00
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()))
}
2026-08-15 08:50:39 +03:00
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, "|") {
2026-08-15 08:55:41 +03:00
return renderInline(line)
2026-08-15 08:50:39 +03:00
}
return renderInline(line)
}
2026-08-15 08:55:41 +03:00
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, "|")
}
2026-08-15 09:00:48 +03:00
func wrapCell(s string, width int) []string {
if width <= 0 { return []string{""} }
s = strings.ReplaceAll(s, "<br>", "\n")
s = strings.ReplaceAll(s, "<br/>", "\n")
s = strings.ReplaceAll(s, "<br />", "\n")
var lines []string
for _, p := range strings.Split(s, "\n") {
p = strings.TrimSpace(p)
if p == "" {
lines = append(lines, "")
continue
}
if visibleLen(p) <= width {
lines = append(lines, p)
continue
}
words := strings.Fields(p)
if len(words) == 0 {
lines = append(lines, "")
continue
}
var curLine string
var activeEsc string
updateActiveEsc := func(token string) {
for i := 0; i < len(token); {
if token[i] == 0x1b {
j := i + 1
if j < len(token) && token[j] == '[' {
j++
for j < len(token) && !(token[j] >= 0x40 && token[j] <= 0x7e) { j++ }
if j < len(token) { j++ }
}
esc := token[i:j]
if esc == "\033[0m" || esc == "\033[m" {
activeEsc = ""
} else {
activeEsc = esc
}
i = j
} else {
i++
}
}
}
flushLine := func() {
if curLine != "" {
out := curLine
if COL && activeEsc != "" && !strings.HasSuffix(out, "\033[0m") {
out += "\033[0m"
}
lines = append(lines, out)
curLine = ""
}
}
for _, word := range words {
wLen := visibleLen(word)
if wLen > width {
flushLine()
var chunk strings.Builder
cLen := 0
for i := 0; i < len(word); {
if word[i] == 0x1b {
j := i + 1
if j < len(word) && word[j] == '[' {
j++
for j < len(word) && !(word[j] >= 0x40 && word[j] <= 0x7e) { j++ }
if j < len(word) { j++ }
}
chunk.WriteString(word[i:j])
updateActiveEsc(word[i:j])
i = j
continue
}
r, size := utf8.DecodeRuneInString(word[i:])
if cLen >= width {
if COL && activeEsc != "" { chunk.WriteString("\033[0m") }
lines = append(lines, chunk.String())
chunk.Reset()
if COL && activeEsc != "" { chunk.WriteString(activeEsc) }
cLen = 0
}
chunk.WriteRune(r)
cLen++
i += size
}
if chunk.Len() > 0 { curLine = chunk.String() }
continue
}
cLen := visibleLen(curLine)
if curLine == "" {
curLine = word
updateActiveEsc(word)
} else if cLen+1+wLen <= width {
curLine += " " + word
updateActiveEsc(word)
} else {
flushLine()
if COL && activeEsc != "" {
curLine = activeEsc + word
} else {
curLine = word
}
updateActiveEsc(word)
}
}
flushLine()
}
if len(lines) == 0 { lines = []string{""} }
return lines
}
2026-08-15 08:55:41 +03:00
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 }
}
2026-08-15 09:00:48 +03:00
maxTableWidth := termWidth()
if maxTableWidth < 20 { maxTableWidth = 80 }
overhead := 3*numCols + 1
availContent := maxTableWidth - overhead
if availContent < numCols*3 { availContent = numCols * 3 }
tot := 0
for _, w := range colWidths { tot += w }
for tot > availContent {
maxIdx := 0
maxVal := colWidths[0]
for i := 1; i < numCols; i++ {
if colWidths[i] > maxVal {
maxVal = colWidths[i]
maxIdx = i
}
}
if maxVal <= 3 { break }
colWidths[maxIdx]--
tot--
}
2026-08-15 08:55:41 +03:00
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 {
2026-08-15 09:00:48 +03:00
headerCols := make([][]string, numCols)
maxHeaderLines := 1
2026-08-15 08:55:41 +03:00
for i, h := range headerRow {
2026-08-15 09:00:48 +03:00
wrapped := wrapCell(h, colWidths[i])
if len(wrapped) > maxHeaderLines { maxHeaderLines = len(wrapped) }
headerCols[i] = wrapped
}
for lineIdx := 0; lineIdx < maxHeaderLines; lineIdx++ {
var hCells []string
for i := 0; i < numCols; i++ {
txt := ""
if lineIdx < len(headerCols[i]) { txt = headerCols[i][lineIdx] }
rh := c(txt, 1, 36)
pad := strings.Repeat(" ", colWidths[i]-visibleLen(txt))
hCells = append(hCells, " "+rh+pad+" ")
}
res = append(res, c("│", 2)+strings.Join(hCells, c("│", 2))+c("│", 2))
2026-08-15 08:55:41 +03:00
}
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 {
2026-08-15 09:00:48 +03:00
rowCols := make([][]string, numCols)
maxRowLines := 1
2026-08-15 08:55:41 +03:00
for i, cell := range r {
rc := renderInline(cell)
2026-08-15 09:00:48 +03:00
wrapped := wrapCell(rc, colWidths[i])
if len(wrapped) > maxRowLines { maxRowLines = len(wrapped) }
rowCols[i] = wrapped
}
for lineIdx := 0; lineIdx < maxRowLines; lineIdx++ {
var rCells []string
for i := 0; i < numCols; i++ {
txt := ""
if lineIdx < len(rowCols[i]) { txt = rowCols[i][lineIdx] }
pad := strings.Repeat(" ", colWidths[i]-visibleLen(txt))
rCells = append(rCells, " "+txt+pad+" ")
}
res = append(res, c("│", 2)+strings.Join(rCells, c("│", 2))+c("│", 2))
2026-08-15 08:55:41 +03:00
}
}
var botParts []string
for _, w := range colWidths { botParts = append(botParts, strings.Repeat("─", w+2)) }
res = append(res, c("└"+strings.Join(botParts, "┴")+"┘", 2))
return res
}
2026-08-15 08:50:39 +03:00
func renderMD(text string) string {
2026-08-15 08:55:41 +03:00
if !COL { return text }
2026-08-15 08:50:39 +03:00
lines := strings.Split(text, "\n")
var out []string
var st mdState
2026-08-15 08:55:41 +03:00
var tbl []string
flushTable := func() {
if len(tbl) > 0 {
out = append(out, renderTable(tbl)...)
tbl = nil
}
}
2026-08-15 08:50:39 +03:00
for _, ln := range lines {
2026-08-15 08:55:41 +03:00
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()
2026-08-15 08:50:39 +03:00
out = append(out, renderMDLine(ln, &st))
}
2026-08-15 08:55:41 +03:00
flushTable()
2026-08-15 08:50:39 +03:00
return strings.Join(out, "\n")
}
2026-08-08 23:10:10 +03:00
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"}}}},
2026-09-01 09:17:47 +03:00
{"type": "function", "function": map[string]any{"name": "write_file", "description": "Write content to a file at a byte offset, optionally deleting bytes first.", "parameters": map[string]any{"type": "object", "properties": map[string]any{"path": map[string]any{"type": "string"}, "offset": map[string]any{"type": "integer"}, "del_bytes": map[string]any{"type": "integer"}, "content": map[string]any{"type": "string"}}, "required": []string{"path", "content"}}}},
2026-08-08 23:10:10 +03:00
}
func strp(s string) *string { return &s }
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
PromptTokensDetails struct {
CachedTokens int `json:"cached_tokens"`
} `json:"prompt_tokens_details"`
CachedTokens int `json:"cached_tokens"`
}
func (u Usage) Cached() int {
if u.PromptTokensDetails.CachedTokens > 0 { return u.PromptTokensDetails.CachedTokens }
return u.CachedTokens
}
2026-08-18 10:17:52 +03:00
// estTokens is a coarse chars/4 fallback used only when the provider omits
// usage in its response; when real usage is present it is never used.
func estTokens(msgs []Message) int {
chars := 0
for _, m := range msgs {
if m.Content != nil { chars += len(*m.Content) }
chars += len(m.ReasoningContent)
for _, tc := range m.ToolCalls {
chars += len(tc.Function.Name) + len(tc.Function.Arguments)
}
}
if chars == 0 { return 0 }
t := chars / 4
if t == 0 { t = 1 }
return t
}
2026-08-18 10:17:52 +03:00
func contextPct(u Usage, cw int) float64 {
if cw <= 0 { cw = 262144 }
2026-08-18 10:17:52 +03:00
return float64(u.PromptTokens) * 100.0 / float64(cw)
}
func formatUsage(u Usage, cw int) string {
pct := contextPct(u, cw)
cached := u.Cached()
if cached > 0 {
uncached := u.PromptTokens - cached
if uncached < 0 { uncached = 0 }
return fmt.Sprintf("[tokens: %d prompt (%d cached, %d uncached) + %d completion | context: %d/%d (%.1f%%)]", u.PromptTokens, cached, uncached, u.CompletionTokens, u.PromptTokens, cw, pct)
}
return fmt.Sprintf("[tokens: %d prompt + %d completion | context: %d/%d (%.1f%%)]", u.PromptTokens, u.CompletionTokens, u.PromptTokens, cw, pct)
}
2026-08-08 23:10:10 +03:00
type streamDelta struct {
Choices []struct {
Delta struct {
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
2026-08-16 08:41:12 +03:00
Thought string `json:"thought"`
2026-08-08 23:10:10 +03:00
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"`
Usage *Usage `json:"usage"`
2026-08-08 23:10:10 +03:00
}
2026-08-16 08:41:12 +03:00
func cleanMessagesForLLM(msgs []Message) []Message {
out := make([]Message, len(msgs))
for i, m := range msgs {
2026-08-18 09:28:24 +03:00
out[i] = Message{Role: m.Role, Content: m.Content, ToolCalls: m.ToolCalls, ToolCallID: m.ToolCallID}
2026-08-16 08:41:12 +03:00
}
return out
}
2026-08-18 09:24:42 +03:00
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)
2026-08-18 10:17:52 +03:00
} else if unicode.IsPrint(r) {
2026-08-18 09:24:42 +03:00
b.WriteRune(r)
}
}
return b.String()
}
2026-08-09 15:19:36 +03:00
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]
2026-08-18 09:24:42 +03:00
tc.Function.Arguments = filterText(tc.Function.Arguments)
2026-08-09 15:19:36 +03:00
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)
}
}
2026-08-18 09:24:42 +03:00
} else if msgs[i].Role == "tool" && msgs[i].Content != nil {
msgs[i].Content = strp(filterText(*msgs[i].Content))
2026-08-09 15:19:36 +03:00
}
}
}
2026-08-11 11:36:04 +03:00
func isInvalidAssistantErr(err error) bool {
2026-08-18 10:17:52 +03:00
if err == nil { return false }
s := strings.ToLower(err.Error())
return strings.Contains(s, "invalid assistant message") ||
strings.Contains(s, "content or tool_calls must be set") ||
strings.Contains(s, "tool_calls must be set") ||
strings.Contains(s, "content must be set")
2026-08-11 11:36:04 +03:00
}
func llm(ctx context.Context, cfg *Cfg, msgs []Message, tools []map[string]any) (Message, Usage, error) {
if err := ctx.Err(); err != nil { return Message{}, Usage{}, err }
2026-08-16 08:41:12 +03:00
cleanMsgs := cleanMessagesForLLM(msgs)
sanitizeMessages(cleanMsgs)
p := map[string]any{"model": cfg.Model, "temperature": cfg.Temperature, "messages": cleanMsgs, "stream": cfg.Stream}
2026-08-08 23:10:10 +03:00
if tools != nil { p["tools"] = tools }
if cfg.Stream { p["stream_options"] = map[string]any{"include_usage": true} }
2026-08-15 08:27:24 +03:00
for k, v := range cfg.Raw {
2026-08-18 10:17:52 +03:00
if internalKey(k) {
2026-08-15 08:27:24 +03:00
continue
2026-08-18 10:17:52 +03:00
}
{
2026-08-15 08:27:24 +03:00
var jv any
if err := json.Unmarshal([]byte(v), &jv); err == nil {
p[k] = jv
} else {
p[k] = v
}
}
}
2026-08-08 23:10:10 +03:00
body, _ := json.Marshal(p)
2026-08-18 10:17:52 +03:00
llmTransport.ResponseHeaderTimeout = time.Duration(cfg.Timeout) * time.Second
client := &http.Client{Transport: llmTransport}
2026-08-08 23:10:10 +03:00
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++ {
2026-08-15 17:09:26 +03:00
if err := ctx.Err(); err != nil {
if COL { fmt.Print("\r\033[K") }
return Message{}, Usage{}, err
2026-08-15 17:09:26 +03:00
}
2026-08-08 23:10:10 +03:00
if COL { fmt.Print("\r" + pend) } else { fmt.Println(pend) }
2026-08-15 17:09:26 +03:00
req, _ := http.NewRequestWithContext(ctx, "POST", strings.TrimRight(cfg.Endpoint, "/")+"/chat/completions", bytes.NewReader(body))
2026-08-08 23:15:53 +03:00
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) }
2026-08-08 23:10:10 +03:00
resp, err = client.Do(req)
2026-08-09 15:19:36 +03:00
var is4xxClientErr bool
2026-08-08 23:10:10 +03:00
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)))
2026-08-09 15:19:36 +03:00
if resp.StatusCode < 500 && resp.StatusCode != 408 && resp.StatusCode != 429 {
is4xxClientErr = true
}
2026-08-08 23:10:10 +03:00
resp = nil
}
if err == nil { break }
if COL { fmt.Print("\r\033[K") }
2026-08-15 17:09:26 +03:00
if errors.Is(err, context.Canceled) || ctx.Err() != nil {
return Message{}, Usage{}, ctx.Err()
2026-08-15 17:09:26 +03:00
}
2026-08-09 15:19:36 +03:00
if is4xxClientErr {
return Message{}, Usage{}, err
2026-08-09 15:19:36 +03:00
}
2026-08-08 23:10:10 +03:00
if i < len(fib) {
fmt.Println(c(fmt.Sprintf("[network error: %v, retrying in %ds...]", err, fib[i]), 31))
2026-08-15 17:09:26 +03:00
select {
case <-ctx.Done():
return Message{}, Usage{}, ctx.Err()
2026-08-15 17:09:26 +03:00
case <-time.After(time.Duration(fib[i]) * time.Second):
}
2026-08-08 23:10:10 +03:00
}
}
if err != nil {
if COL { fmt.Print("\r\033[K") }
return Message{}, Usage{}, err
2026-08-08 23:10:10 +03:00
}
defer resp.Body.Close()
if COL { fmt.Print("\r\033[K") }
if !cfg.Stream {
var cr struct {
Choices []struct {
2026-08-08 23:15:53 +03:00
Message struct {
Message
Reasoning string `json:"reasoning"`
2026-08-16 08:41:12 +03:00
Thought string `json:"thought"`
2026-08-08 23:15:53 +03:00
} `json:"message"`
2026-08-08 23:10:10 +03:00
} `json:"choices"`
Usage Usage `json:"usage"`
2026-08-08 23:10:10 +03:00
}
2026-08-15 17:09:26 +03:00
if err := json.NewDecoder(resp.Body).Decode(&cr); err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() != nil { return Message{}, Usage{}, ctx.Err() }
return Message{}, Usage{}, err
2026-08-15 17:09:26 +03:00
}
if len(cr.Choices) == 0 { return Message{}, Usage{}, errors.New("empty choices in LLM response") }
2026-08-08 23:15:53 +03:00
m := cr.Choices[0].Message.Message
if m.ReasoningContent == "" { m.ReasoningContent = cr.Choices[0].Message.Reasoning }
2026-08-16 08:41:12 +03:00
if m.ReasoningContent == "" { m.ReasoningContent = cr.Choices[0].Message.Thought }
u := cr.Usage
if u.PromptTokens == 0 {
u.PromptTokens = estTokens(msgs)
u.CompletionTokens = estTokens([]Message{m})
u.TotalTokens = u.PromptTokens + u.CompletionTokens
}
return m, u, nil
2026-08-08 23:10:10 +03:00
}
m, u, err := parseStream(ctx, resp.Body)
if err == nil && u.PromptTokens == 0 {
u.PromptTokens = estTokens(msgs)
u.CompletionTokens = estTokens([]Message{m})
u.TotalTokens = u.PromptTokens + u.CompletionTokens
}
return m, u, err
2026-08-08 23:10:10 +03:00
}
func parseStream(ctx context.Context, r io.Reader) (Message, Usage, error) {
2026-08-08 23:10:10 +03:00
var content, reas string
2026-08-16 08:41:12 +03:00
var inReasoning bool
2026-08-15 08:50:39 +03:00
var lineBuf string
var mdSt mdState
2026-08-15 08:55:41 +03:00
var tblBuf []string
var lastUsage Usage
2026-08-08 23:10:10 +03:00
tcs := map[int]*ToolCall{}
var order []int
2026-08-15 08:55:41 +03:00
flushTable := func() {
if len(tblBuf) > 0 {
for _, tln := range renderTable(tblBuf) { fmt.Println(tln) }
tblBuf = nil
}
}
2026-08-08 23:10:10 +03:00
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
if err := ctx.Err(); err != nil { return Message{}, lastUsage, err }
2026-08-08 23:10:10 +03:00
ln := strings.TrimSpace(sc.Text())
if !strings.HasPrefix(ln, "data:") { continue }
data := strings.TrimSpace(ln[5:])
if data == "[DONE]" { break }
2026-08-08 23:15:53 +03:00
var d streamDelta
if json.Unmarshal([]byte(data), &d) != nil { continue }
if d.Usage != nil && (d.Usage.PromptTokens > 0 || d.Usage.TotalTokens > 0) {
lastUsage = *d.Usage
}
if len(d.Choices) == 0 { continue }
2026-08-08 23:10:10 +03:00
dl := d.Choices[0].Delta
rc := dl.ReasoningContent
if rc == "" { rc = dl.Reasoning }
2026-08-16 08:41:12 +03:00
if rc == "" { rc = dl.Thought }
2026-08-08 23:10:10 +03:00
if rc != "" {
2026-08-16 08:41:12 +03:00
if !inReasoning {
if content != "" {
flushTable()
if lineBuf != "" { fmt.Println(renderMDLine(lineBuf, &mdSt)); lineBuf = "" }
fmt.Println()
}
fmt.Println(c("--- reasoning start ---", 36))
inReasoning = true
}
2026-08-08 23:10:10 +03:00
fmt.Print(c(rc, 2))
reas += rc
}
if dl.Content != "" {
2026-08-16 08:41:12 +03:00
if inReasoning {
fmt.Print("\n" + c("--- reasoning end ---", 36) + "\n\n")
inReasoning = false
}
2026-08-08 23:10:10 +03:00
content += dl.Content
2026-08-15 08:50:39 +03:00
lineBuf += dl.Content
for {
idx := strings.IndexByte(lineBuf, '\n')
if idx == -1 { break }
curLine := lineBuf[:idx]
lineBuf = lineBuf[idx+1:]
2026-08-15 08:55:41 +03:00
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))
}
2026-08-15 08:50:39 +03:00
}
2026-08-08 23:10:10 +03:00
}
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 }
}
}
if err := ctx.Err(); err != nil { return Message{}, lastUsage, err }
2026-08-15 08:55:41 +03:00
flushTable()
2026-08-15 08:50:39 +03:00
if lineBuf != "" {
2026-08-15 08:55:41 +03:00
if !mdSt.inCode && isTableLine(lineBuf) {
tblBuf = append(tblBuf, lineBuf)
flushTable()
} else {
fmt.Println(renderMDLine(lineBuf, &mdSt))
}
2026-08-15 08:50:39 +03:00
}
2026-08-16 08:41:12 +03:00
if inReasoning {
2026-08-08 23:10:10 +03:00
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]) }
}
if lastUsage.TotalTokens == 0 && lastUsage.PromptTokens > 0 {
lastUsage.TotalTokens = lastUsage.PromptTokens + lastUsage.CompletionTokens
}
return m, lastUsage, sc.Err()
2026-08-08 23:10:10 +03:00
}
2026-08-15 17:09:26 +03:00
func shell(ctx context.Context, cmd string, timeout int) string {
2026-08-18 09:24:42 +03:00
cmd = filterText(cmd)
2026-08-15 17:09:26 +03:00
cmdCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
2026-08-08 23:10:10 +03:00
defer cancel()
2026-08-15 17:09:26 +03:00
c := exec.CommandContext(cmdCtx, "sh", "-c", cmd)
c.WaitDelay = 100 * time.Millisecond
out, err := c.CombinedOutput()
2026-08-18 09:24:42 +03:00
res := strings.TrimSpace(filterText(string(out)))
2026-08-15 17:09:26 +03:00
if ctx.Err() != nil {
return "[interrupted]\n\nexit: -1"
}
if cmdCtx.Err() == context.DeadlineExceeded {
2026-08-08 23:10:10 +03:00
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 ""
}
2026-08-28 09:30:48 +03:00
// lastRole returns the role of the last message in the conversation, or "" if
// it is empty. It is used to detect when a turn ended on a tool result rather
// than a normal assistant reply, so the conversation can be auto-continued.
func lastRole(msgs []Message) string {
if len(msgs) == 0 { return "" }
return msgs[len(msgs)-1].Role
}
2026-09-01 09:17:47 +03:00
func writeFile(path string, offset, delBytes int, content string) (string, error) {
path = strings.TrimSpace(path)
if path == "" {
return "", errors.New("path is required")
}
if offset < 0 {
offset = 0
}
if delBytes < 0 {
delBytes = 0
}
var data []byte
if fileExists(path) {
var err error
data, err = os.ReadFile(path)
if err != nil {
return "", err
}
} else {
dir := filepath.Dir(path)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
return "", err
}
}
}
if offset > len(data) {
padding := make([]byte, offset-len(data))
data = append(data, padding...)
}
prefix := data[:offset]
var suffix []byte
endDel := offset + delBytes
if endDel < len(data) {
suffix = data[endDel:]
}
contentBytes := []byte(content)
newData := make([]byte, 0, len(prefix)+len(contentBytes)+len(suffix))
newData = append(newData, prefix...)
newData = append(newData, contentBytes...)
newData = append(newData, suffix...)
if err := os.WriteFile(path, newData, 0644); err != nil {
return "", err
}
return fmt.Sprintf("Successfully wrote %d bytes to %s", len(contentBytes), path), nil
}
func AL(ctx context.Context, cfg *Cfg, msgs []Message) ([]Message, Usage, error) {
2026-08-08 23:10:10 +03:00
done := false
var turnUsage Usage
2026-08-08 23:10:10 +03:00
for i := 0; i < cfg.MaxALIterations && !done; i++ {
if err := ctx.Err(); err != nil { return msgs, turnUsage, err }
m, u, err := llm(ctx, cfg, msgs, TOOLS)
2026-08-11 11:36:04 +03:00
if err != nil {
2026-08-15 17:09:26 +03:00
if errors.Is(err, context.Canceled) || ctx.Err() != nil {
return msgs, turnUsage, err
2026-08-15 17:09:26 +03:00
}
2026-08-11 11:36:04 +03:00
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, turnUsage, err
2026-08-11 11:36:04 +03:00
}
turnUsage.PromptTokens = u.PromptTokens
turnUsage.CompletionTokens += u.CompletionTokens
turnUsage.TotalTokens += u.TotalTokens
if u.Cached() > 0 { turnUsage.CachedTokens = u.Cached() }
2026-08-09 15:19:36 +03:00
for j := range m.ToolCalls {
tc := &m.ToolCalls[j]
2026-08-18 09:24:42 +03:00
tc.Function.Arguments = filterText(tc.Function.Arguments)
2026-08-09 15:19:36 +03:00
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)
}
}
2026-08-08 23:10:10 +03:00
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))
}
2026-08-15 08:50:39 +03:00
if m.Content != nil { fmt.Println(renderMD(*m.Content)) }
2026-08-08 23:10:10 +03:00
}
if len(m.ToolCalls) == 0 {
// If the model returned only a reasoning block with no non-reasoning
// tokens or tool calls, nudge it to continue rather than ending the turn.
if m.ReasoningContent != "" && (m.Content == nil || strings.TrimSpace(*m.Content) == "") {
fmt.Println(c("[auto continue: response was reasoning-only]", 33))
msgs = append(msgs, Message{Role: "user", Content: strp("continue")})
continue
}
done = true
break
}
2026-08-08 23:10:10 +03:00
for _, tc := range m.ToolCalls {
if err := ctx.Err(); err != nil { return msgs, turnUsage, err }
2026-08-18 09:24:42 +03:00
fn, astr := tc.Function.Name, filterText(tc.Function.Arguments)
2026-08-08 23:10:10 +03:00
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)
2026-08-18 09:24:42 +03:00
cmd = filterText(cmd)
2026-08-15 17:09:26 +03:00
res = shell(ctx, cmd, cfg.ShellTimeout)
if err := ctx.Err(); err != nil { return msgs, turnUsage, err }
2026-09-01 09:17:47 +03:00
case "write_file":
path, _ := a["path"].(string)
path = filterText(path)
contentVal, hasContent := a["content"]
var content string
if hasContent && contentVal != nil {
if s, ok := contentVal.(string); ok {
content = s
2026-08-15 17:09:26 +03:00
}
2026-09-01 09:17:47 +03:00
}
offset := 0
if v, ok := a["offset"]; ok {
switch n := v.(type) {
case float64:
offset = int(n)
case int:
offset = n
case string:
offset = atoiD(n, 0)
}
}
delBytes := 0
if v, ok := a["del_bytes"]; ok {
switch n := v.(type) {
case float64:
delBytes = int(n)
case int:
delBytes = n
case string:
delBytes = atoiD(n, 0)
}
}
if strings.TrimSpace(path) == "" {
res, sty = "[tool error: write_file requires 'path' parameter]", 31
} else if !hasContent {
res, sty = "[tool error: write_file requires 'content' parameter]", 31
} else {
out, err := writeFile(path, offset, delBytes, content)
if err != nil {
res, sty = fmt.Sprintf("[tool error: write_file %s: %v]", path, err), 31
2026-08-15 17:09:26 +03:00
} else {
2026-09-01 09:17:47 +03:00
res, sty = out, 2
2026-08-15 17:09:26 +03:00
}
2026-08-08 23:10:10 +03:00
}
default:
2026-08-15 17:09:26 +03:00
res, sty = "Unknown tool: " + fn, 31
2026-08-08 23:10:10 +03:00
}
}
2026-08-18 09:24:42 +03:00
res = filterText(res)
2026-08-08 23:10:10 +03:00
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, turnUsage, nil
2026-08-08 23:10:10 +03:00
}
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
}
2026-09-01 09:46:13 +03:00
func projectID() string {
dir, err := os.Getwd()
if err != nil {
dir = "."
2026-08-08 23:10:10 +03:00
}
2026-09-01 09:46:13 +03:00
if abs, err := filepath.Abs(dir); err == nil {
dir = abs
}
h := md5.Sum([]byte(dir))
return hex.EncodeToString(h[:])
}
func saveSession(msgs []Message, sid string) (string, string) {
sid = strings.TrimSpace(sid)
if sid == "" {
sid = projectID()
}
d := sdir()
path := filepath.Join(d, sid+".json")
2026-08-08 23:10:10 +03:00
s := Session{sid, time.Now().Format("2006-01-02 15:04:05"), summary(msgs), msgs}
2026-08-18 10:17:52 +03:00
b, err := json.MarshalIndent(s, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "saveSession: marshal error: %v\n", err)
return sid, s.Summary
}
if err := os.WriteFile(path, b, 0644); err != nil {
fmt.Fprintf(os.Stderr, "saveSession: write error: %v\n", err)
}
2026-08-08 23:10:10 +03:00
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) {
2026-09-01 09:46:13 +03:00
saveSession(msgs, projectID())
2026-08-08 23:10:10 +03:00
}
const compactionPrompt = "You are now acting as a compaction engine. Summarize the preceding conversation concisely but completely, preserving all important facts, decisions, code snippets, tool outputs, errors, and current task state so work can seamlessly continue. Output only the summary."
2026-08-08 23:10:10 +03:00
2026-08-15 17:09:26 +03:00
func compact(ctx context.Context, cfg *Cfg, msgs []Message) ([]Message, string, error) {
2026-08-08 23:10:10 +03:00
if len(msgs) == 0 || msgs[0].Role != "system" {
return msgs, "", errors.New("session has no system message")
}
if len(msgs) <= 1 {
return msgs, "", errors.New("nothing to compact")
}
cMsgs := append(append([]Message{}, msgs...), Message{
Role: "user",
Content: strp(compactionPrompt),
})
cc := *cfg
cc.Stream = false
m, _, err := llm(ctx, &cc, cMsgs, nil)
2026-08-08 23:10:10 +03:00
if err != nil { return msgs, "", err }
s := ""
if m.Content != nil { s = *m.Content }
if s == "" { s = m.ReasoningContent }
s = strings.TrimSpace(s)
if s == "" { return msgs, "", errors.New("LLM returned an empty summary") }
newMsgs := []Message{
{Role: "system", Content: msgs[0].Content},
{Role: "user", Content: strp("Summary of the previous conversation:\n" + s + "\n\nPlease continue from here.")},
}
return newMsgs, s, nil
}
func summarize(ctx context.Context, cfg *Cfg, msgs []Message) (string, error) {
_, s, err := compact(ctx, cfg, msgs)
return s, err
2026-08-08 23:10:10 +03:00
}
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 {
2026-08-09 09:26:44 +03:00
fmt.Print("\r\n")
2026-08-08 23:10:10 +03:00
return "", false
}
switch rn {
case '\r':
2026-08-15 08:27:24 +03:00
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)
}
2026-08-09 09:26:44 +03:00
fmt.Print("\r\n")
2026-08-08 23:10:10 +03:00
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:
2026-08-15 17:09:26 +03:00
fmt.Print("^C\r\n")
return "", true
2026-08-08 23:10:10 +03:00
case 0x04:
if len(e.buf) == 0 {
2026-08-09 09:26:44 +03:00
fmt.Print("\r\n")
2026-08-08 23:10:10 +03:00
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++
}
}
}
}
2026-08-18 09:28:24 +03:00
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
}
2026-08-08 23:10:10 +03:00
func main() {
2026-09-01 09:17:47 +03:00
sp := defaultSystemPrompt
cfg := getCfg(configPath())
cfg.ContextWindow = fetchContextWindow(&cfg)
2026-08-28 10:56:49 +03:00
if td := toolsDir(&cfg); td != "" {
sp += "\n\nExtra shell tools can be found at " + td
}
2026-08-08 23:10:10 +03:00
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)
}
2026-08-15 16:58:25 +03:00
u := strings.TrimSpace(string(data))
if strings.HasPrefix(u, "!") {
2026-08-18 09:28:24 +03:00
runDirectShell(strings.TrimPrefix(u, "!"), cfg.ShellTimeout)
2026-08-15 16:58:25 +03:00
return
}
msgs = append(msgs, Message{Role: "user", Content: strp(u)})
2026-08-15 17:09:26 +03:00
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
var usg Usage
2026-09-01 09:17:47 +03:00
msgs, usg, err = AL(sigCtx, &cfg, msgs)
2026-08-28 09:30:48 +03:00
if lastRole(msgs) == "tool" {
msgs = append(msgs, Message{Role: "user", Content: strp("continue")})
fmt.Println(c("[auto continue: last message was a tool result]", 33))
2026-09-01 09:17:47 +03:00
msgs, usg, err = AL(sigCtx, &cfg, msgs)
2026-08-28 09:30:48 +03:00
}
interrupted := sigCtx.Err() != nil
2026-08-15 17:09:26 +03:00
cancel()
if err != nil {
if interrupted || errors.Is(err, context.Canceled) {
2026-08-15 17:09:26 +03:00
fmt.Println(c("\n[interrupted]", 33))
} else {
fmt.Println(c("[error: "+err.Error()+"]", 31))
}
2026-08-08 23:10:10 +03:00
os.Exit(1)
}
fmt.Println(c(formatUsage(usg, cfg.ContextWindow), 2))
2026-08-08 23:10:10 +03:00
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 context: %d", cfg.Endpoint, cfg.Model, cfg.Temperature, cfg.ContextWindow), 2))
2026-08-08 23:10:10 +03:00
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
2026-09-01 09:46:13 +03:00
case u == "/save" || strings.HasPrefix(u, "/save "):
name := strings.TrimSpace(strings.TrimPrefix(u, "/save"))
sid, sm := saveSession(msgs, name)
2026-08-08 23:10:10 +03:00
fmt.Println(c("[session saved: "+sid+"]", 32) + " " + c(sm, 2))
continue
2026-09-01 09:46:13 +03:00
case u == "/continue" || u == "/cont":
pid := projectID()
lm, err := loadSession(pid)
if err != nil {
fmt.Println(c("No session found for current project: "+err.Error(), 31))
continue
}
msgs = lm
autosave(msgs)
fmt.Println(c("[session continued: "+pid+"]", 32) + " " + c(summary(msgs), 2))
continue
2026-08-08 23:10:10 +03:00
case u == "/list":
ss := sessions()
if len(ss) == 0 {
fmt.Println(c("No sessions saved yet.", 33))
continue
}
2026-09-01 09:46:13 +03:00
pid := projectID()
2026-08-08 23:10:10 +03:00
for _, s := range ss {
2026-09-01 09:46:13 +03:00
mk := ""
if s.ID == pid { mk = c(" (current project)", 33) }
2026-08-08 23:10:10 +03:00
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":
2026-08-18 09:28:24 +03:00
msgs = doCompact(&cfg, msgs)
2026-08-08 23:10:10 +03:00
continue
2026-08-15 08:27:24 +03:00
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(".bantam.cfg", k, v); err != nil {
2026-08-15 08:27:24 +03:00
fmt.Println(c("[cfg error: "+err.Error()+"]", 31))
continue
}
cfg = getCfg(configPath())
if k == "model" || k == "endpoint" || k == "api_key" {
cfg.ContextWindow = fetchContextWindow(&cfg)
}
2026-08-15 08:27:24 +03:00
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
2026-08-28 09:30:48 +03:00
case u == "/models":
ml, err := listModels(&cfg)
if err != nil {
fmt.Println(c("[error querying /models: "+err.Error()+"]", 31))
continue
}
fmt.Println(c("Supported models at "+cfg.Endpoint+":", 1, 36))
fmt.Print(renderMD(ml))
continue
2026-08-15 16:58:25 +03:00
case strings.HasPrefix(u, "!"):
2026-08-18 09:28:24 +03:00
runDirectShell(strings.TrimPrefix(u, "!"), cfg.ShellTimeout)
2026-08-15 16:58:25 +03:00
continue
2026-08-08 23:10:10 +03:00
case u == "/help":
fmt.Println(c("Bantam commands:", 1, 36))
2026-09-01 09:46:13 +03:00
for _, kv := range [][2]string{
{"/quit", "exit"},
{"/clear", "reset to system prompt"},
{"/save [name]", "save session (default: project MD5)"},
{"/continue", "continue project session (alias: /cont)"},
{"/list", "list sessions"},
{"/load <id>", "load session"},
{"/compact", "compact context"},
{"/cfg <k> [v]", "get/set config"},
{"!<cmd>", "run shell command directly"},
{"/models", "list models at endpoint"},
{"/help", "show help"},
} {
fmt.Println(c(fmt.Sprintf(" %-18s", kv[0]), 1, 32) + kv[1])
2026-08-08 23:10:10 +03:00
}
continue
}
2026-08-15 17:09:26 +03:00
turnMsgs := append([]Message{}, msgs...)
turnMsgs = append(turnMsgs, Message{Role: "user", Content: strp(u)})
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
2026-09-01 09:17:47 +03:00
resMsgs, usg, err := AL(sigCtx, &cfg, turnMsgs)
2026-08-28 09:30:48 +03:00
if lastRole(resMsgs) == "tool" {
resMsgs = append(resMsgs, Message{Role: "user", Content: strp("continue")})
fmt.Println(c("[auto continue: last message was a tool result]", 33))
2026-09-01 09:17:47 +03:00
resMsgs, usg, err = AL(sigCtx, &cfg, resMsgs)
2026-08-28 09:30:48 +03:00
}
interrupted := sigCtx.Err() != nil
2026-08-15 17:09:26 +03:00
cancel()
if err != nil {
if interrupted || errors.Is(err, context.Canceled) {
2026-08-15 17:09:26 +03:00
fmt.Println(c("\n[interrupted]", 33))
} else {
fmt.Println(c("[error: "+err.Error()+"]", 31))
}
continue
2026-08-08 23:10:10 +03:00
}
2026-08-15 17:09:26 +03:00
msgs = resMsgs
2026-08-08 23:10:10 +03:00
autosave(msgs)
fmt.Println(c(formatUsage(usg, cfg.ContextWindow), 2))
2026-08-18 10:17:52 +03:00
pct := contextPct(usg, 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))
2026-08-18 09:28:24 +03:00
if ans, ok := readPlain(""); ok {
ans = strings.TrimSpace(strings.ToLower(ans))
if ans == "" || ans == "y" || ans == "yes" {
2026-08-18 09:28:24 +03:00
msgs = doCompact(&cfg, msgs)
}
}
}
2026-08-08 23:10:10 +03:00
}
done:
autosave(msgs)
saveHistory()
}