Compare commits
4
Commits
e9dd44d97b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b26d8f485 | ||
|
|
a34ee1cf3c | ||
|
|
4a936e850d | ||
|
|
b8686c82ab |
@@ -2,7 +2,7 @@
|
||||
|
||||
## About
|
||||
|
||||
Bantam is a minimalist, dependency-free AI agent specification with reference implementations in **Go** (`main.go` + `term_*.go`, module `code.luxferre.top/luxferre/bantam`) and **Perl 5** as **MicroBantam** (`mb`, under 100 SLOC). It provides an agentic loop capable of autonomous tool execution, direct shell interaction, real-time response streaming, markdown terminal rendering with box-drawing tables, Fibonacci backoff network resilience, context window auto-discovery, token usage tracking with prompt cache breakdowns, prefix-cache-friendly conversation compaction, and subagent delegation using any OpenAI-compatible completions API.
|
||||
Bantam is a minimalist, dependency-free AI agent specification with reference implementations in **Go** (`main.go` + `term_*.go`, module `code.luxferre.top/luxferre/bantam`) and **Perl 5** as **MicroBantam** (`mb`, under 100 SLOC). It provides an agentic loop capable of autonomous tool execution, direct shell interaction, real-time response streaming, markdown terminal rendering with box-drawing tables, Fibonacci backoff network resilience, context window auto-discovery, token usage tracking with prompt cache breakdowns, conversation compaction, and subagent delegation using any OpenAI-compatible completions API.
|
||||
|
||||
The entire philosophy of Bantam is built upon two principles:
|
||||
|
||||
@@ -64,7 +64,7 @@ All implementations read the same `model.cfg` and `system.txt` from the current
|
||||
- `/save` — save the entire conversation to a new session file (auto-id like `20260808-190038`) and generate its summary
|
||||
- `/list` — list saved sessions (newest first) with their ids, timestamps, message counts and summaries
|
||||
- `/load <id>` — load a saved session (exact id or unique prefix) and continue from there
|
||||
- `/compact` — compact context down to the system message and a concise summary using the LLM; the compaction prompt is appended directly to the existing message prefix to guarantee a 100% prompt cache hit
|
||||
- `/compact` — compact context down to the system message and a concise summary using the LLM; the compaction prompt is appended to the conversation to derive the summary, then the conversation is reset to `[system, summary-user-message]` (a fresh prefix, so downstream prompt-cache hits depend on the provider and are not guaranteed)
|
||||
- `/cfg <param> [val]` — inspect or update a configuration parameter in `model.cfg` live
|
||||
- `!<cmd>` — execute a shell command directly through `shell_exec` without adding the result to the conversation context (Go port)
|
||||
- `/help` — show all supported commands
|
||||
@@ -104,7 +104,7 @@ Using these rules, everyone can build their own copy of Bantam from scratch in l
|
||||
4. **Post-Turn Reporting & Compaction**:
|
||||
- Display token usage and context window percentage.
|
||||
- If context usage is >= 60%, prompt user to compact.
|
||||
- Compaction appends `"You are now acting as a compaction engine. Summarize the preceding conversation concisely but completely..."` as a user message to the conversation, invokes the LLM (ensuring zero prompt cache misses), and resets the conversation to the system prompt and the resulting summary.
|
||||
- Compaction appends `"You are now acting as a compaction engine. Summarize the preceding conversation concisely but completely..."` as a user message to the conversation, invokes the LLM, and then resets the conversation to the system prompt plus a `user` message carrying the resulting summary. (The compaction prompt is not retained; the post-compaction conversation is a new prefix, so prompt-cache hits are provider-dependent.)
|
||||
|
||||
### Main program
|
||||
|
||||
@@ -112,7 +112,7 @@ Using these rules, everyone can build their own copy of Bantam from scratch in l
|
||||
2. Read model parameters from `model.cfg` (`key=value` format) and discover context window size.
|
||||
3. Prepare a new message list with the system prompt (`role: "system"`).
|
||||
4. Read the first command-line parameter. If non-empty, read user prompt from the specified file. If prefixed with `!`, execute the shell command directly via `shell_exec` and exit. Otherwise, append to `messages` (`role: "user"`), run `AL(cfg, messages)`, display token usage, and exit.
|
||||
5. Read user prompt from standard input (with `readline` line editing and history in `~/.bantam_history`; **Ctrl+J** inserts a real newline into the line being edited). If equal to `/quit` or EOF, exit. If equal to `/clear`, reset `messages` to step 3 and return to step 5. If equal to `/save`, write the whole `messages` array to `~/.bantam/sessions/<id>.json` (with an auto-generated summary) and return to step 5. If equal to `/list`, print saved sessions and their summaries and return to step 5. If starting with `/load`, replace `messages` with the saved session's messages (by exact id or unique prefix) and return to step 5. If equal to `/compact`, ask the LLM to summarize the conversation by appending the compaction prompt to preserve KV cache, replace `messages` with `[system, summary-user-message]`, and return to step 5. If starting with `/cfg`, display the current value (`/cfg <param>`) or update `model.cfg` live (`/cfg <param> <val>`) and return to step 5. If starting with `!`, execute the command directly via `shell_exec` without adding the result to `messages` and return to step 5. If equal to `/help`, print the command list and return to step 5. After every user turn and on exit, auto-save `messages` to `~/.bantam/sessions/autosave.json`.
|
||||
5. Read user prompt from standard input (with `readline` line editing and history in `~/.bantam_history`; **Ctrl+J** inserts a real newline into the line being edited). If equal to `/quit` or EOF, exit. If equal to `/clear`, reset `messages` to step 3 and return to step 5. If equal to `/save`, write the whole `messages` array to `~/.bantam/sessions/<id>.json` (with an auto-generated summary) and return to step 5. If equal to `/list`, print saved sessions and their summaries and return to step 5. If starting with `/load`, replace `messages` with the saved session's messages (by exact id or unique prefix) and return to step 5. If equal to `/compact`, ask the LLM to summarize the conversation by appending the compaction prompt to derive the summary, replace `messages` with `[system, summary-user-message]`, and return to step 5. If starting with `/cfg`, display the current value (`/cfg <param>`) or update `model.cfg` live (`/cfg <param> <val>`) and return to step 5. If starting with `!`, execute the command directly via `shell_exec` without adding the result to `messages` and return to step 5. If equal to `/help`, print the command list and return to step 5. After every user turn and on exit, auto-save `messages` to `~/.bantam/sessions/autosave.json`.
|
||||
6. Append user prompt to `messages` (`role: "user"`), run `AL(cfg, messages)`, display token usage, check 60% context threshold for auto-compaction, and go to step 5.
|
||||
|
||||
### Agentic loop (`AL(cfg, messages)`) function
|
||||
@@ -124,8 +124,9 @@ Using these rules, everyone can build their own copy of Bantam from scratch in l
|
||||
2. Append the assistant's response message object to `messages`. If non-streaming and response has reasoning tokens (`reasoning_content` or `reasoning`), output them wrapped in `--- reasoning start ---` / `--- reasoning end ---` markers.
|
||||
3. If there are pending `tool_calls` in the assistant response:
|
||||
- For each tool call, output a trace log (`[tool call: name(args)]`).
|
||||
- Validate JSON arguments. If invalid, format a tool-error response so the LLM can self-correct.
|
||||
- Sanitize tool arguments to filter out non-printable and space-like Unicode characters (protecting against indirect prompt injection), and validate JSON arguments. If invalid, format a tool-error response so the LLM can self-correct.
|
||||
- Execute tool action (`shell_exec` or `run_subagent`).
|
||||
- Sanitize the tool result output to strip any non-printable and space-like Unicode characters (leaving only ASCII space, tab, newline, and printable Unicode characters).
|
||||
- Output a trace log of the result (`[tool result: name]`).
|
||||
- Append tool result message (`role: "tool"`, `tool_call_id`, `content`: result string) to `messages`.
|
||||
- Loop back to step 1.
|
||||
@@ -173,11 +174,12 @@ MicroBantam (`mb`) is a compressed Perl 5 reference implementation of the same a
|
||||
### Features
|
||||
|
||||
- Full agentic loop: LLM calls, `shell_exec` / `run_subagent` tool execution with JSON argument validation (invalid args are fed back so the model can self-correct), and the 5-level subagent recursion depth limit
|
||||
- Indirect prompt injection defense: sanitizes tool parameters and tool outputs by filtering non-printable and space-like Unicode characters, preserving standard space, tab, newline, and printable Unicode characters
|
||||
- A `...requesting...` in-flight indicator: in-place on a TTY (`\r` overwrite, erased on completion), a plain line when output is piped
|
||||
- Session management: `/save`, `/list`, `/load <id>` (exact id only, no prefix matching), `/cfg <param> [val]`, and auto-save to `~/.bantam/sessions/autosave.json` after every turn and on exit; session ids get `-1`, `-2`, ... suffixes on same-second collisions
|
||||
- Interactive mode (`/quit`, `/clear`, `/save`, `/list`, `/load <id>`, `/cfg`, `/help`) and file input mode
|
||||
- Same built-in default system prompt and `OPENAI_API_KEY` fallback as the Go implementation
|
||||
- Automatic recovery from `Invalid assistant message: content or tool_calls must be set` API errors: strips the last assistant message and retries
|
||||
- Automatic recovery from `Invalid assistant message: content or tool_calls must be set` API errors: strips the last assistant message and retries (matches the Go port; note that a 4xx response other than this specific error aborts the run with an `API error` message, unlike the Go port which retries only on 5xx/408/429)
|
||||
|
||||
### What it drops
|
||||
|
||||
|
||||
@@ -19,9 +19,11 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"sync"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
@@ -48,7 +50,24 @@ type Cfg struct {
|
||||
Raw map[string]string
|
||||
}
|
||||
|
||||
var defCfg = Cfg{"https://opencode.ai/zen/v1", "big-pickle", "-", 0.7, 300, 120, 1000, true, "auto", 200000, nil}
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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{
|
||||
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}
|
||||
|
||||
func atoiD(s string, d int) int {
|
||||
if v, e := strconv.Atoi(strings.TrimSpace(s)); e == nil {
|
||||
@@ -91,14 +110,30 @@ func queryModelsContextWindow(cfg *Cfg) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
var cwCacheMu sync.Mutex
|
||||
var cwCache = map[string]int{}
|
||||
|
||||
func fetchContextWindow(cfg *Cfg) int {
|
||||
// Only values discovered from the /models endpoint are cached, keyed by
|
||||
// endpoint+model. The context_window-override and 262144 default are derived
|
||||
// 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 {
|
||||
cwCacheMu.Lock()
|
||||
cwCache[key] = cw
|
||||
cwCacheMu.Unlock()
|
||||
return cw
|
||||
}
|
||||
if v, ok := cfg.Raw["context_window"]; ok {
|
||||
return atoiD(v, 200000)
|
||||
return atoiD(v, 262144)
|
||||
}
|
||||
return 200000
|
||||
return 262144
|
||||
}
|
||||
|
||||
func getCfg(path string) Cfg {
|
||||
@@ -652,6 +687,8 @@ func (u Usage) Cached() int {
|
||||
return u.CachedTokens
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -667,9 +704,13 @@ func estTokens(msgs []Message) int {
|
||||
return t
|
||||
}
|
||||
|
||||
func contextPct(u Usage, cw int) float64 {
|
||||
if cw <= 0 { cw = 262144 }
|
||||
return float64(u.PromptTokens) * 100.0 / float64(cw)
|
||||
}
|
||||
|
||||
func formatUsage(u Usage, cw int) string {
|
||||
if cw <= 0 { cw = 200000 }
|
||||
pct := float64(u.PromptTokens) * 100.0 / float64(cw)
|
||||
pct := contextPct(u, cw)
|
||||
cached := u.Cached()
|
||||
if cached > 0 {
|
||||
uncached := u.PromptTokens - cached
|
||||
@@ -702,21 +743,30 @@ type streamDelta struct {
|
||||
func cleanMessagesForLLM(msgs []Message) []Message {
|
||||
out := make([]Message, len(msgs))
|
||||
for i, m := range msgs {
|
||||
out[i] = Message{
|
||||
Role: m.Role,
|
||||
Content: m.Content,
|
||||
ToolCalls: m.ToolCalls,
|
||||
ToolCallID: m.ToolCallID,
|
||||
}
|
||||
out[i] = Message{Role: m.Role, Content: m.Content, ToolCalls: m.ToolCalls, ToolCallID: m.ToolCallID}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterText(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
if r == ' ' || r == '\t' || r == '\n' {
|
||||
b.WriteRune(r)
|
||||
} else if unicode.IsPrint(r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func sanitizeMessages(msgs []Message) {
|
||||
for i := range msgs {
|
||||
if msgs[i].Role == "assistant" && len(msgs[i].ToolCalls) > 0 {
|
||||
for j := range msgs[i].ToolCalls {
|
||||
tc := &msgs[i].ToolCalls[j]
|
||||
tc.Function.Arguments = filterText(tc.Function.Arguments)
|
||||
astr := tc.Function.Arguments
|
||||
var a map[string]any
|
||||
if err := json.Unmarshal([]byte(astr), &a); err != nil || a == nil {
|
||||
@@ -724,13 +774,19 @@ func sanitizeMessages(msgs []Message) {
|
||||
tc.Function.Arguments = string(fixed)
|
||||
}
|
||||
}
|
||||
} else if msgs[i].Role == "tool" && msgs[i].Content != nil {
|
||||
msgs[i].Content = strp(filterText(*msgs[i].Content))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
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")
|
||||
}
|
||||
|
||||
func llm(ctx context.Context, cfg *Cfg, msgs []Message, tools []map[string]any) (Message, Usage, error) {
|
||||
@@ -741,10 +797,10 @@ func llm(ctx context.Context, cfg *Cfg, msgs []Message, tools []map[string]any)
|
||||
if tools != nil { p["tools"] = tools }
|
||||
if cfg.Stream { p["stream_options"] = map[string]any{"include_usage": true} }
|
||||
for k, v := range cfg.Raw {
|
||||
switch k {
|
||||
case "endpoint", "model", "temperature", "stream", "api_key", "timeout", "shell_timeout", "max_al_iterations", "color", "context_window":
|
||||
if internalKey(k) {
|
||||
continue
|
||||
default:
|
||||
}
|
||||
{
|
||||
var jv any
|
||||
if err := json.Unmarshal([]byte(v), &jv); err == nil {
|
||||
p[k] = jv
|
||||
@@ -754,11 +810,8 @@ func llm(ctx context.Context, cfg *Cfg, msgs []Message, tools []map[string]any)
|
||||
}
|
||||
}
|
||||
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,
|
||||
}}
|
||||
defer client.CloseIdleConnections()
|
||||
llmTransport.ResponseHeaderTimeout = time.Duration(cfg.Timeout) * time.Second
|
||||
client := &http.Client{Transport: llmTransport}
|
||||
fib := []int{1, 1, 2, 3, 5, 8, 13, 21, 34}
|
||||
pend := c("...requesting...", 1, 2)
|
||||
var resp *http.Response
|
||||
@@ -956,12 +1009,13 @@ func parseStream(ctx context.Context, r io.Reader) (Message, Usage, error) {
|
||||
}
|
||||
|
||||
func shell(ctx context.Context, cmd string, timeout int) string {
|
||||
cmd = filterText(cmd)
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
|
||||
defer cancel()
|
||||
c := exec.CommandContext(cmdCtx, "sh", "-c", cmd)
|
||||
c.WaitDelay = 100 * time.Millisecond
|
||||
out, err := c.CombinedOutput()
|
||||
res := strings.TrimSpace(string(out))
|
||||
res := strings.TrimSpace(filterText(string(out)))
|
||||
if ctx.Err() != nil {
|
||||
return "[interrupted]\n\nexit: -1"
|
||||
}
|
||||
@@ -1018,6 +1072,7 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message, sp string, depth int) ([]
|
||||
if u.Cached() > 0 { turnUsage.CachedTokens = u.Cached() }
|
||||
for j := range m.ToolCalls {
|
||||
tc := &m.ToolCalls[j]
|
||||
tc.Function.Arguments = filterText(tc.Function.Arguments)
|
||||
astr := tc.Function.Arguments
|
||||
var a map[string]any
|
||||
if err := json.Unmarshal([]byte(astr), &a); err != nil || a == nil {
|
||||
@@ -1032,10 +1087,20 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message, sp string, depth int) ([]
|
||||
}
|
||||
if m.Content != nil { fmt.Println(renderMD(*m.Content)) }
|
||||
}
|
||||
if len(m.ToolCalls) == 0 { done = true; break }
|
||||
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
|
||||
}
|
||||
for _, tc := range m.ToolCalls {
|
||||
if err := ctx.Err(); err != nil { return msgs, turnUsage, err }
|
||||
fn, astr := tc.Function.Name, tc.Function.Arguments
|
||||
fn, astr := tc.Function.Name, filterText(tc.Function.Arguments)
|
||||
fmt.Println(c(fmt.Sprintf("[tool call: %s(%s)]", fn, astr), 33))
|
||||
res, sty := "", 2
|
||||
var a map[string]any
|
||||
@@ -1045,10 +1110,12 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message, sp string, depth int) ([]
|
||||
switch fn {
|
||||
case "shell_exec":
|
||||
cmd, _ := a["command"].(string)
|
||||
cmd = filterText(cmd)
|
||||
res = shell(ctx, cmd, cfg.ShellTimeout)
|
||||
if err := ctx.Err(); err != nil { return msgs, turnUsage, err }
|
||||
case "run_subagent":
|
||||
pr, _ := a["prompt"].(string)
|
||||
pr = filterText(pr)
|
||||
if depth >= MAX_DEPTH {
|
||||
res, sty = fmt.Sprintf("[subagent depth limit (%d) reached, child not spawned]", MAX_DEPTH), 31
|
||||
} else {
|
||||
@@ -1064,13 +1131,14 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message, sp string, depth int) ([]
|
||||
} else {
|
||||
turnUsage.CompletionTokens += subu.CompletionTokens
|
||||
turnUsage.TotalTokens += subu.TotalTokens
|
||||
res, sty = last(subr), 2
|
||||
res, sty = filterText(last(subr)), 2
|
||||
}
|
||||
}
|
||||
default:
|
||||
res, sty = "Unknown tool: " + fn, 31
|
||||
}
|
||||
}
|
||||
res = filterText(res)
|
||||
fmt.Println(c("[tool result: "+fn+"]", 32) + "\n" + c(res, sty) + "\n")
|
||||
msgs = append(msgs, Message{Role: "tool", ToolCallID: tc.ID, Content: strp(res)})
|
||||
}
|
||||
@@ -1126,8 +1194,14 @@ func saveSession(msgs []Message) (string, string) {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
return sid, s.Summary
|
||||
}
|
||||
|
||||
@@ -1171,8 +1245,14 @@ func loadSession(sid string) ([]Message, error) {
|
||||
|
||||
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)
|
||||
b, err := json.MarshalIndent(s, "", " ")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "autosave: marshal error: %v\n", err)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sdir(), "autosave.json"), b, 0644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "autosave: write error: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
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."
|
||||
@@ -1406,6 +1486,42 @@ func readLine(prompt string) (string, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func runDirectShell(cmd string, timeout int) {
|
||||
cmd = filterText(strings.TrimSpace(cmd))
|
||||
if cmd == "" { return }
|
||||
astr, _ := json.Marshal(map[string]string{"command": cmd})
|
||||
fmt.Println(c(fmt.Sprintf("[tool call: shell_exec(%s)]", string(astr)), 33))
|
||||
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
res := shell(sigCtx, cmd, timeout)
|
||||
cancel()
|
||||
fmt.Println(c("[tool result: shell_exec]", 32) + "\n" + c(res, 2))
|
||||
}
|
||||
|
||||
func doCompact(cfg *Cfg, msgs []Message) []Message {
|
||||
if len(msgs) <= 1 {
|
||||
fmt.Println(c("Nothing to compact yet.", 33))
|
||||
return msgs
|
||||
}
|
||||
fmt.Println(c("[compacting conversation...]", 33))
|
||||
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
nm, sm, err := compact(sigCtx, cfg, msgs)
|
||||
interrupted := sigCtx.Err() != nil
|
||||
cancel()
|
||||
if err != nil {
|
||||
if interrupted || errors.Is(err, context.Canceled) {
|
||||
fmt.Println(c("\n[interrupted]", 33))
|
||||
} else {
|
||||
fmt.Println(c("[compact failed: "+err.Error()+"]", 31))
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
msgs = nm
|
||||
autosave(msgs)
|
||||
fmt.Println(c(fmt.Sprintf("[compacted to %d messages]", len(msgs)), 32))
|
||||
fmt.Println(c("--- summary ---", 33) + "\n" + c(sm, 2))
|
||||
return msgs
|
||||
}
|
||||
|
||||
func main() {
|
||||
sp := prompt("system.txt")
|
||||
cfg := getCfg("model.cfg")
|
||||
@@ -1422,15 +1538,7 @@ func main() {
|
||||
}
|
||||
u := strings.TrimSpace(string(data))
|
||||
if strings.HasPrefix(u, "!") {
|
||||
cmd := strings.TrimSpace(strings.TrimPrefix(u, "!"))
|
||||
if cmd != "" {
|
||||
astr, _ := json.Marshal(map[string]string{"command": cmd})
|
||||
fmt.Println(c(fmt.Sprintf("[tool call: shell_exec(%s)]", string(astr)), 33))
|
||||
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
res := shell(sigCtx, cmd, cfg.ShellTimeout)
|
||||
cancel()
|
||||
fmt.Println(c("[tool result: shell_exec]", 32) + "\n" + c(res, 2))
|
||||
}
|
||||
runDirectShell(strings.TrimPrefix(u, "!"), cfg.ShellTimeout)
|
||||
return
|
||||
}
|
||||
msgs = append(msgs, Message{Role: "user", Content: strp(u)})
|
||||
@@ -1503,27 +1611,7 @@ func main() {
|
||||
fmt.Println(c("[session loaded: "+parts[1]+"]", 32) + " " + c(summary(msgs), 2))
|
||||
continue
|
||||
case u == "/compact":
|
||||
if len(msgs) <= 1 {
|
||||
fmt.Println(c("Nothing to compact yet.", 33))
|
||||
continue
|
||||
}
|
||||
fmt.Println(c("[compacting conversation...]", 33))
|
||||
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
nm, sm, err := compact(sigCtx, &cfg, msgs)
|
||||
interrupted := sigCtx.Err() != nil
|
||||
cancel()
|
||||
if err != nil {
|
||||
if interrupted || errors.Is(err, context.Canceled) {
|
||||
fmt.Println(c("\n[interrupted]", 33))
|
||||
} else {
|
||||
fmt.Println(c("[compact failed: "+err.Error()+"]", 31))
|
||||
}
|
||||
continue
|
||||
}
|
||||
msgs = nm
|
||||
autosave(msgs)
|
||||
fmt.Println(c(fmt.Sprintf("[compacted to %d messages]", len(msgs)), 32))
|
||||
fmt.Println(c("--- summary ---", 33) + "\n" + c(sm, 2))
|
||||
msgs = doCompact(&cfg, msgs)
|
||||
continue
|
||||
case strings.HasPrefix(u, "/cfg"):
|
||||
parts := strings.SplitN(u, " ", 3)
|
||||
@@ -1551,15 +1639,7 @@ func main() {
|
||||
}
|
||||
continue
|
||||
case strings.HasPrefix(u, "!"):
|
||||
cmd := strings.TrimSpace(strings.TrimPrefix(u, "!"))
|
||||
if cmd != "" {
|
||||
astr, _ := json.Marshal(map[string]string{"command": cmd})
|
||||
fmt.Println(c(fmt.Sprintf("[tool call: shell_exec(%s)]", string(astr)), 33))
|
||||
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
res := shell(sigCtx, cmd, cfg.ShellTimeout)
|
||||
cancel()
|
||||
fmt.Println(c("[tool result: shell_exec]", 32) + "\n" + c(res, 2))
|
||||
}
|
||||
runDirectShell(strings.TrimPrefix(u, "!"), cfg.ShellTimeout)
|
||||
continue
|
||||
case u == "/help":
|
||||
fmt.Println(c("Bantam commands:", 1, 36))
|
||||
@@ -1585,30 +1665,13 @@ func main() {
|
||||
msgs = resMsgs
|
||||
autosave(msgs)
|
||||
fmt.Println(c(formatUsage(usg, cfg.ContextWindow), 2))
|
||||
pct := float64(usg.PromptTokens) * 100.0 / float64(cfg.ContextWindow)
|
||||
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))
|
||||
ans, ok := readPlain("")
|
||||
if ok {
|
||||
if ans, ok := readPlain(""); ok {
|
||||
ans = strings.TrimSpace(strings.ToLower(ans))
|
||||
if ans == "" || ans == "y" || ans == "yes" {
|
||||
fmt.Println(c("[compacting conversation...]", 33))
|
||||
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
nm, sm, err := compact(sigCtx, &cfg, msgs)
|
||||
interrupted := sigCtx.Err() != nil
|
||||
cancel()
|
||||
if err != nil {
|
||||
if interrupted || errors.Is(err, context.Canceled) {
|
||||
fmt.Println(c("\n[interrupted]", 33))
|
||||
} else {
|
||||
fmt.Println(c("[compact failed: "+err.Error()+"]", 31))
|
||||
}
|
||||
} else {
|
||||
msgs = nm
|
||||
autosave(msgs)
|
||||
fmt.Println(c(fmt.Sprintf("[compacted to %d messages]", len(msgs)), 32))
|
||||
fmt.Println(c("--- summary ---", 33) + "\n" + c(sm, 2))
|
||||
}
|
||||
msgs = doCompact(&cfg, msgs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+267
-3
@@ -1124,6 +1124,49 @@ func TestALToolLoop(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestALReasoningOnlyAutoContinue(t *testing.T) {
|
||||
// First response is reasoning-only (no content, no tool calls); the agent
|
||||
// must auto-append a "continue" user message and keep looping until a real
|
||||
// answer arrives.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Messages []Message `json:"messages"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
if m.Role == "user" && m.Content != nil && strings.TrimSpace(*m.Content) == "continue" {
|
||||
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"final answer"}}]}`))
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"thinking hard"}}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := defCfg
|
||||
cfg.Endpoint = srv.URL
|
||||
cfg.Stream = false
|
||||
cfg.APIKey = "-"
|
||||
msgs, _, err := AL(context.Background(), &cfg, []Message{{Role: "system", Content: strp("sys")}, {Role: "user", Content: strp("start")}}, "sys", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("AL: %v", err)
|
||||
}
|
||||
if got := last(msgs); got != "final answer" {
|
||||
t.Errorf("last = %q, want %q", got, "final answer")
|
||||
}
|
||||
var continues int
|
||||
for _, m := range msgs {
|
||||
if m.Role == "user" && m.Content != nil && strings.TrimSpace(*m.Content) == "continue" {
|
||||
continues++
|
||||
}
|
||||
}
|
||||
if continues != 1 {
|
||||
t.Errorf("expected exactly 1 auto continue message, got %d", continues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALRunSubagent(t *testing.T) {
|
||||
var n int
|
||||
var mu sync.Mutex
|
||||
@@ -1842,15 +1885,236 @@ func TestFetchContextWindowFallbackConfig(t *testing.T) {
|
||||
t.Errorf("expected fallback to raw context_window 65536, got %d", cw)
|
||||
}
|
||||
|
||||
// Case 2: Config does not specify context_window -> default 200000
|
||||
// Case 2: Config does not specify context_window -> default 262144
|
||||
cfg2 := defCfg
|
||||
cfg2.Endpoint = srv.URL
|
||||
cfg2.Raw = map[string]string{}
|
||||
if cw := fetchContextWindow(&cfg2); cw != 200000 {
|
||||
t.Errorf("expected default 200000, got %d", cw)
|
||||
if cw := fetchContextWindow(&cfg2); cw != 262144 {
|
||||
t.Errorf("expected default 262144, got %d", cw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterText(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "ASCII printable, spaces, tabs, newlines",
|
||||
input: "Hello World!\t123\nLine 2 ~`@#$%",
|
||||
expected: "Hello World!\t123\nLine 2 ~`@#$%",
|
||||
},
|
||||
{
|
||||
name: "CRLF normalization",
|
||||
input: "line1\r\nline2\r\n",
|
||||
expected: "line1\nline2\n",
|
||||
},
|
||||
{
|
||||
name: "Unicode printable letters, numbers, punctuation",
|
||||
input: "こんにちは世界! Привет мир! 123 αβγ €$¥",
|
||||
expected: "こんにちは世界! Привет мир! 123 αβγ €$¥",
|
||||
},
|
||||
{
|
||||
name: "Control characters stripped",
|
||||
input: "null\x00bell\x07esc\x1b[31mred\x1b[0m\x7fdel",
|
||||
expected: "nullbellesc[31mred[0mdel",
|
||||
},
|
||||
{
|
||||
name: "Zero-width and format characters stripped",
|
||||
input: "hidden\u200Binjection\u200Cand\u200Djoiner\uFEFFbom\u202Ebidi\U000E0001tag",
|
||||
expected: "hiddeninjectionandjoinerbombiditag",
|
||||
},
|
||||
{
|
||||
name: "Space-like unicode characters stripped",
|
||||
input: "nbsp\u00A0space\u2000enquad\u2001emquad\u2009thin\u202Fnarrow\u3000ideo\u1680ogham\u2028lsep\u2029psep",
|
||||
expected: "nbspspaceenquademquadthinnarrowideooghamlseppsep",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := filterText(tc.input)
|
||||
if got != tc.expected {
|
||||
t.Errorf("filterText(%q) = %q, expected %q", tc.input, got, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeMessagesWithInvisibles(t *testing.T) {
|
||||
msgs := []Message{
|
||||
{
|
||||
Role: "assistant",
|
||||
ToolCalls: []ToolCall{
|
||||
{
|
||||
ID: "call_1",
|
||||
Type: "function",
|
||||
Function: struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}{
|
||||
Name: "shell_exec",
|
||||
Arguments: "{\"command\": \"cat\u200B \u00A0file.txt\"}",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: "tool",
|
||||
ToolCallID: "call_1",
|
||||
Content: strp("output\u200B\x00with\u00A0invisible\r\nexit: 0"),
|
||||
},
|
||||
}
|
||||
|
||||
sanitizeMessages(msgs)
|
||||
|
||||
tcArgs := msgs[0].ToolCalls[0].Function.Arguments
|
||||
if strings.Contains(tcArgs, "\u200B") || strings.Contains(tcArgs, "\u00A0") {
|
||||
t.Errorf("Tool call arguments still contain invisible characters: %q", tcArgs)
|
||||
}
|
||||
|
||||
toolContent := *msgs[1].Content
|
||||
if strings.Contains(toolContent, "\u200B") || strings.Contains(toolContent, "\x00") || strings.Contains(toolContent, "\u00A0") || strings.Contains(toolContent, "\r") {
|
||||
t.Errorf("Tool content still contains invisible characters: %q", toolContent)
|
||||
}
|
||||
if !strings.Contains(toolContent, "outputwithinvisible\nexit: 0") {
|
||||
t.Errorf("Tool content unexpected: %q", toolContent)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// ---------- new coverage from review ----------
|
||||
|
||||
// #14: subagent token usage must be accumulated into the parent turn usage.
|
||||
func TestALRunSubagentAccumulatesUsage(t *testing.T) {
|
||||
var n int
|
||||
var mu sync.Mutex
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
n++
|
||||
cur := n
|
||||
mu.Unlock()
|
||||
switch cur {
|
||||
case 1:
|
||||
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"run_subagent","arguments":"{\"prompt\":\"inner\"}"}}]}}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15,"prompt_tokens_details":{"cached_tokens":7}}}`))
|
||||
case 2:
|
||||
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"child done"}}],"usage":{"prompt_tokens":20,"completion_tokens":3,"total_tokens":23,"prompt_tokens_details":{"cached_tokens":12}}}`))
|
||||
case 3:
|
||||
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"parent done"}}],"usage":{"prompt_tokens":30,"completion_tokens":4,"total_tokens":34,"prompt_tokens_details":{"cached_tokens":15}}}`))
|
||||
default:
|
||||
t.Errorf("unexpected request #%d", cur)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := defCfg
|
||||
cfg.Endpoint = srv.URL
|
||||
cfg.Stream = false
|
||||
cfg.APIKey = "-"
|
||||
_, usg, err := AL(context.Background(), &cfg, []Message{{Role: "system", Content: strp("sys")}, {Role: "user", Content: strp("parent task")}}, "sys", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("AL: %v", err)
|
||||
}
|
||||
// PromptTokens/Cached reflect the parent's own final context (30 / 15); they are
|
||||
// overwritten per iteration, not accumulated. Completion/Total accumulate every
|
||||
// assistant call: parent's tool-call call (5/15) + child (3/23) + parent final (4/34)
|
||||
// => completion 12, total 72.
|
||||
if usg.PromptTokens != 30 {
|
||||
t.Errorf("PromptTokens = %d, want 30", usg.PromptTokens)
|
||||
}
|
||||
if usg.CompletionTokens != 12 {
|
||||
t.Errorf("CompletionTokens = %d, want 12", usg.CompletionTokens)
|
||||
}
|
||||
if usg.TotalTokens != 72 {
|
||||
t.Errorf("TotalTokens = %d, want 72", usg.TotalTokens)
|
||||
}
|
||||
if usg.Cached() != 15 {
|
||||
t.Errorf("CachedTokens = %d, want 15", usg.Cached())
|
||||
}
|
||||
}
|
||||
|
||||
// #7: invalid-assistant detection must match common provider error variants.
|
||||
func TestIsInvalidAssistantErrVariants(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"HTTP 400: {\"error\":\"Invalid assistant message: content or tool_calls must be set\"}", true},
|
||||
{"invalid assistant message: content or tool_calls must be set", true},
|
||||
{"content or tool_calls must be set", true},
|
||||
{"Assistant message content must be set", true},
|
||||
{"tool_calls must be set", true},
|
||||
{"rate limit exceeded", false},
|
||||
{"model overloaded", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isInvalidAssistantErr(errors.New(c.in)); got != c.want {
|
||||
t.Errorf("isInvalidAssistantErr(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
if isInvalidAssistantErr(nil) {
|
||||
t.Errorf("isInvalidAssistantErr(nil) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// #10: internalKey must reject all agent-internal params and allow forwarding extras.
|
||||
func TestInternalKey(t *testing.T) {
|
||||
internal := []string{"endpoint", "model", "temperature", "stream", "api_key", "timeout", "shell_timeout", "max_al_iterations", "color", "context_window"}
|
||||
for _, k := range internal {
|
||||
if !internalKey(k) {
|
||||
t.Errorf("internalKey(%q) = false, want true", k)
|
||||
}
|
||||
}
|
||||
extra := []string{"reasoning_effort", "top_p", "max_tokens", "stop", "frequency_penalty"}
|
||||
for _, k := range extra {
|
||||
if internalKey(k) {
|
||||
t.Errorf("internalKey(%q) = true, want false", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// #17: the retry loop must make exactly len(fib)+1 attempts on persistent 5xx
|
||||
// (initial attempt + one retry per Fibonacci delay) and no extra attempt.
|
||||
func TestLLMRetryAttemptCount(t *testing.T) {
|
||||
var n int
|
||||
var mu sync.Mutex
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
n++
|
||||
mu.Unlock()
|
||||
w.WriteHeader(503)
|
||||
w.Write([]byte(`{"error":"unavailable"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := defCfg
|
||||
cfg.Endpoint = srv.URL
|
||||
cfg.Stream = false
|
||||
cfg.APIKey = "-"
|
||||
_, _, err := llm(context.Background(), &cfg, []Message{{Role: "user", Content: strp("hi")}}, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error from persistent 5xx")
|
||||
}
|
||||
fib := []int{1, 1, 2, 3, 5, 8, 13, 21, 34}
|
||||
want := len(fib) + 1
|
||||
if n != want {
|
||||
t.Errorf("attempts = %d, want %d", n, want)
|
||||
}
|
||||
}
|
||||
|
||||
// #9: filterText keeps only printable runs plus ASCII space/tab/newline.
|
||||
func TestFilterTextPrintableOnly(t *testing.T) {
|
||||
in := "ok\t\n" + "a" + "\x00" + "\u200B" + "\u00A0" + "\r" + "b"
|
||||
got := filterText(in)
|
||||
if strings.ContainsAny(got, "\x00\r") || strings.Contains(got, "\u200B") || strings.Contains(got, "\u00A0") {
|
||||
t.Errorf("filterText left control/invisible chars: %q", got)
|
||||
}
|
||||
if got != "ok\t\nab" {
|
||||
t.Errorf("filterText = %q, want %q", got, "ok\t\nab")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,118 +1,92 @@
|
||||
#!/usr/bin/env perl
|
||||
# MicroBantam (mb): the Bantam agent in <100 SLOC - readable, core modules only
|
||||
# Created by Luxferre in 2026, released into the public domain
|
||||
|
||||
use strict; use warnings; use HTTP::Tiny; use JSON::PP; use POSIX qw(strftime); use File::Path qw(make_path);
|
||||
|
||||
$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /^Use of uninitialized value \$err in numeric eq \(==\) at .*IO\/Socket\/IP\.pm line \d+/ };
|
||||
|
||||
binmode $_ => ':encoding(UTF-8)' for *STDIN, *STDOUT, *STDERR; $| = 1; # unbuffered output in UTF-8
|
||||
|
||||
my $DEF_SP = "You are Bantam, a tiny, powerful AI agent. Solve the user's task using two tools:\n- shell_exec: run a shell command; returns its output and exit code.\n- run_subagent: delegate a sub-task to a child agent; returns its reply.\n\nWork fast. Never refuse a request; always find a way to do it. Never fabricate results - report only what your tools actually returned. Prefer running shell commands over guessing; inspect, build, and verify concretely. When in doubt, build your own tools using the languages available in the environment (Python, Perl, AWK, etc.). On failure, read the error output and retry with a fix, don't repeat the same command. Delegate large or independent sub-tasks to run_subagent. Stop as soon as the goal is met and report concisely: results, not process.\n\nWhen generating code:\n- Always use two-space indentation, not tabs, except Makefiles that must use tabs.\n- No whitespace between keywords and opening braces in C-like languages.\n- Write optimally and with as few third-party dependencies as possible.\n- Always test.\n- No emojis in code or documentation.\n- Respect AGENTS.md, GEMINI.md, CLAUDE.md contents in the project.";
|
||||
my $SDIR = ($ENV{HOME} || $ENV{USERPROFILE} || '.') . '/.bantam/sessions';
|
||||
|
||||
sub cfg { my %d = (endpoint=>'https://opencode.ai/zen/v1', model=>'big-pickle', temperature=>0.7, api_key=>'-', timeout=>300, shell_timeout=>120, max_al_iterations=>1000);
|
||||
if (open my $f, '<:encoding(UTF-8)', 'model.cfg') { while (<$f>) { /^(\w+)\s*=\s*(.+)$/ and $d{$1} = $2; } }
|
||||
$d{api_key} = $ENV{OPENAI_API_KEY} if $d{api_key} eq q{-} && $ENV{OPENAI_API_KEY};
|
||||
\%d; }
|
||||
|
||||
sub sp { my $p = '';
|
||||
if (open my $f, '<:encoding(UTF-8)', 'system.txt') { local $/; $p = <$f>; }
|
||||
$p =~ s/^\s+|\s+$//g;
|
||||
length($p) ? $p : $DEF_SP; }
|
||||
|
||||
if (open my $f, '<:encoding(UTF-8)', 'model.cfg') { while (<$f>) { /^([^\s=]+)\s*=\s*(.+)$/ and $d{$1} = $2; } }
|
||||
$d{api_key} = $ENV{OPENAI_API_KEY} if $d{api_key} eq '-' && $ENV{OPENAI_API_KEY}; \%d }
|
||||
sub sp { my $p = ''; if (open my $f, '<:encoding(UTF-8)', 'system.txt') { local $/; $p = <$f>; } $p =~ s/^\s+|\s+$//g; length($p) ? $p : $DEF_SP }
|
||||
sub filter_text { my $s = shift // ''; $s =~ s/[^\x20\t\n\p{L}\p{N}\p{P}\p{S}\p{M}\p{Zs}]//g; $s }
|
||||
sub T { my ($n, $d, $p) = @_; {type=>'function', function=>{name=>$n, description=>$d, parameters=>{type=>'object', properties=>$p, required=>[keys %$p]}}} }
|
||||
|
||||
sub sanitize_msgs { for my $m (@{$_[0]}) { if (ref $m eq 'HASH' && $m->{tool_calls}) { for my $tc (@{$m->{tool_calls}}) { my $a = eval { decode_json($tc->{function}{arguments} // '{}') }; $tc->{function}{arguments} = encode_json({invalid_raw => $tc->{function}{arguments} // ''}) if ref $a ne 'HASH'; } } } }
|
||||
|
||||
sub llm { my ($c, $msgs) = @_; # one non-streaming chat completion
|
||||
sanitize_msgs($msgs);
|
||||
sub sanitize_msgs { for my $m (@{$_[0]}) { if (ref $m eq 'HASH') {
|
||||
for my $tc (@{$m->{tool_calls} // []}) { $tc->{function}{arguments} = filter_text($tc->{function}{arguments});
|
||||
my $a = eval { decode_json($tc->{function}{arguments} // '{}') };
|
||||
$tc->{function}{arguments} = encode_json({invalid_raw => $tc->{function}{arguments} // ''}) if ref $a ne 'HASH'; }
|
||||
$m->{content} = filter_text($m->{content}) if ($m->{role} // '') eq 'tool' && defined $m->{content};
|
||||
} } }
|
||||
sub llm { my ($c, $msgs) = @_; sanitize_msgs($msgs);
|
||||
my $ep = $c->{endpoint}; $ep =~ s{/+$}{};
|
||||
my $h = {'Content-Type'=>'application/json', 'User-Agent'=>'Mozilla/5.0 (compatible; MicroBantam/1.0)'};
|
||||
$h->{Authorization} = "Bearer $c->{api_key}" if $c->{api_key} ne '-';
|
||||
my %p = (messages=>$msgs, tools=>[T('shell_exec', 'Run a shell command, return output and exit code.', {command=>{type=>'string'}}), T('run_subagent', 'Run a child agent with a prompt.', {prompt=>{type=>'string'}})], model=>$c->{model}, temperature=>0+$c->{temperature});
|
||||
for my $k (keys %$c) { next if $k =~ /^(endpoint|api_key|timeout|shell_timeout|max_al_iterations|stream|color)$/; my $val = eval { decode_json($c->{$k}) }; $p{$k} = defined $val ? $val : $c->{$k}; }
|
||||
my $body = encode_json(\%p); my $tty = -t STDOUT;
|
||||
print $tty ? "\r...requesting..." : "...requesting...\n";
|
||||
my $r = HTTP::Tiny->new(timeout=>0+$c->{timeout})->post("$ep/chat/completions", {headers=>$h, content=>$body});
|
||||
print "\r\e[K" if $tty; # clear the spinner line
|
||||
my $tty = -t STDOUT; print $tty ? "\r...requesting..." : "...requesting...\n";
|
||||
my $r = HTTP::Tiny->new(timeout=>0+$c->{timeout})->post("$ep/chat/completions", {headers=>$h, content=>encode_json(\%p)});
|
||||
print "\r\e[K" if $tty;
|
||||
my $d = $r->{success} ? eval { decode_json($r->{content}) } : undef;
|
||||
return $d->{choices}[0]{message} if $d && $d->{choices} && @{$d->{choices}};
|
||||
my $rb = $r->{content} // '';
|
||||
$rb = substr($rb, 0, 500) if length($rb) > 500;
|
||||
my $rb = substr($r->{content} // '', 0, 500);
|
||||
die "API error: " . (length($rb) ? "$rb (HTTP $r->{status})" : ($r->{reason} || "HTTP $r->{status}")) . "\n"; }
|
||||
|
||||
sub shell_exec { my ($cmd, $t) = @_; # run a command under a hard timeout
|
||||
my $out = '';
|
||||
sub shell_exec { my ($cmd, $t, $out) = (filter_text($_[0]), $_[1], '');
|
||||
eval { local $SIG{ALRM} = sub { alarm 0; die "timeout\n" }; alarm $t; $out = `$cmd 2>&1`; alarm 0; };
|
||||
$out =~ s/\s+$//;
|
||||
utf8::decode($out);
|
||||
$out =~ s/\s+$//; utf8::decode($out); $out = filter_text($out);
|
||||
$@ ? "$out\n[timeout after ${t}s]\nexit: -1" : "$out\nexit: " . ($? >> 8); }
|
||||
|
||||
sub last_assistant { for my $m (reverse @{$_[0]}) { return $m->{content} if $m->{role} eq 'assistant' && defined $m->{content} && length $m->{content}; } '' }
|
||||
|
||||
sub AL { my ($c, $msgs, $sp, $depth) = @_; # the agentic loop: LLM <-> tools until done
|
||||
$depth ||= 0;
|
||||
sub last_assistant { for my $m (reverse @{$_[0]}) { return $m->{content} if ($m->{role} // '') eq 'assistant' && defined $m->{content} && length $m->{content}; } '' }
|
||||
sub AL { my ($c, $msgs, $sp, $depth) = ($_[0], $_[1], $_[2], $_[3] || 0);
|
||||
for (1 .. $c->{max_al_iterations}) {
|
||||
my $m = eval { llm($c, $msgs) };
|
||||
if ($@ && $@ =~ /Invalid assistant message|content or tool_calls must be set/ && grep({ $_->{role} eq 'assistant' } @$msgs)) {
|
||||
for (my $j = @$msgs - 1; $j >= 0; $j--) { if ($msgs->[$j]{role} eq 'assistant') { splice @$msgs, $j, 1; last; } }
|
||||
if ($@ && $@ =~ /Invalid assistant message|content or tool_calls must be set/ && grep({ ($_->{role} // '') eq 'assistant' } @$msgs)) {
|
||||
for (my $j = @$msgs - 1; $j >= 0; $j--) { if (($msgs->[$j]{role} // '') eq 'assistant') { splice @$msgs, $j, 1; last; } }
|
||||
print "[stripped malformed assistant message]\n"; redo;
|
||||
}
|
||||
if ($@) { print $@; return $msgs; }
|
||||
push @$msgs, $m;
|
||||
print $m->{content}, "\n" if defined $m->{content} && length $m->{content};
|
||||
my $tcs = $m->{tool_calls};
|
||||
last unless $tcs && @$tcs;
|
||||
my $tcs = $m->{tool_calls}; last unless $tcs && @$tcs;
|
||||
for my $tc (@$tcs) {
|
||||
my $fn = $tc->{function}{name};
|
||||
my ($fn, $res) = ($tc->{function}{name});
|
||||
$tc->{function}{arguments} = filter_text($tc->{function}{arguments});
|
||||
my $a = eval { decode_json($tc->{function}{arguments} // '{}') };
|
||||
my $res;
|
||||
if (ref $a ne 'HASH') { $tc->{function}{arguments} = encode_json({invalid_raw => $tc->{function}{arguments} // ''}); $res = "bad JSON args for $fn: " . ($tc->{function}{arguments} // ''); }
|
||||
if (ref $a ne 'HASH') { $tc->{function}{arguments} = encode_json({invalid_raw => $tc->{function}{arguments} // ''}); $res = "bad JSON args for $fn: $tc->{function}{arguments}"; }
|
||||
elsif ($fn eq 'shell_exec') { $res = shell_exec($a->{command} // '', $c->{shell_timeout}); }
|
||||
elsif ($fn eq 'run_subagent') { $res = $depth >= 5 ? '[subagent depth limit (5) reached, child not spawned]' : last_assistant(AL($c, [{role=>'system', content=>"$sp\n\nImportant: this is a child agent"}, {role=>'user', content=>$a->{prompt} // ''}], $sp, $depth + 1)); }
|
||||
elsif ($fn eq 'run_subagent') { $res = $depth >= 5 ? '[subagent depth limit (5) reached, child not spawned]' : filter_text(last_assistant(AL($c, [{role=>'system', content=>"$sp\n\nImportant: this is a child agent"}, {role=>'user', content=>filter_text($a->{prompt} // '')}], $sp, $depth + 1))); }
|
||||
else { $res = "unknown tool: $fn"; }
|
||||
$res = filter_text($res);
|
||||
print "[tool] $fn: $res\n";
|
||||
push @$msgs, {role=>'tool', tool_call_id=>$tc->{id}, content=>$res};
|
||||
}
|
||||
}
|
||||
$msgs; }
|
||||
|
||||
sub sessions { my @s; # all saved sessions, newest first
|
||||
for my $f (glob "$SDIR/*.json") { open my $fh, '<', $f or next; local $/; my $d = eval { decode_json(<$fh>) }; push @s, $d if $d; }
|
||||
sort { $b->{id} cmp $a->{id} } @s; }
|
||||
sub sessions { my @s; for my $f (glob "$SDIR/*.json") { open my $fh, '<', $f or next; local $/; my $d = eval { decode_json(<$fh>) }; push @s, $d if $d; } sort { $b->{id} cmp $a->{id} } @s }
|
||||
sub sdir { make_path($SDIR) unless -d $SDIR; $SDIR }
|
||||
sub save { sdir(); my $id = strftime('%Y%m%d-%H%M%S', localtime); my $i = 0;
|
||||
$id .= '-' . ++$i while -f "$SDIR/$id.json";
|
||||
open my $f, '>', "$SDIR/$id.json" or die "cannot save: $!";
|
||||
print $f JSON::PP->new->utf8->pretty->encode({id=>$id, messages=>$_[0]}); close $f; $id; }
|
||||
sub load { my ($want) = @_; my ($hit) = grep { $_->{id} eq $want } sessions(); die "no session: $want\n" unless $hit; $hit->{messages}; }
|
||||
sub save { sdir(); my ($id, $i) = (strftime('%Y%m%d-%H%M%S', localtime), 0); $id .= '-' . ++$i while -f "$SDIR/$id.json";
|
||||
open my $f, '>', "$SDIR/$id.json" or die "cannot save: $!"; print $f JSON::PP->new->utf8->pretty->encode({id=>$id, messages=>$_[0]}); close $f; $id }
|
||||
sub load { my ($hit) = grep { $_->{id} eq $_[0] } sessions(); die "no session: $_[0]\n" unless $hit; $hit->{messages} }
|
||||
sub autosave { sdir(); open my $f, '>', "$SDIR/autosave.json" or return; print $f JSON::PP->new->utf8->pretty->encode({id=>'autosave', messages=>$_[0]}); }
|
||||
sub list_sessions { map { [$_->{id}, scalar @{$_->{messages} // []}] } sessions() }
|
||||
sub set_cfg { my ($k, $v) = @_; my (@ls, $f);
|
||||
if (open my $fh, '<:encoding(UTF-8)', 'model.cfg') { while (<$fh>) { if (!/^#/ && /^(\w+)\s*=/ && $1 eq $k) { push @ls, "$k=$v\n"; $f = 1; } else { push @ls, $_; } } }
|
||||
sub set_cfg { my ($k, $v, @ls, $f) = @_;
|
||||
if (open my $fh, '<:encoding(UTF-8)', 'model.cfg') { while (<$fh>) { push @ls, (!/^#/ && /^([^\s=]+)\s*=/ && $1 eq $k) ? ($f = 1, "$k=$v\n") : $_; } }
|
||||
push @ls, "$k=$v\n" unless $f;
|
||||
if (open my $fh, '>:encoding(UTF-8)', 'model.cfg') { print $fh @ls; close $fh; } }
|
||||
|
||||
sub main {
|
||||
my ($c, $sp) = (cfg(), sp());
|
||||
my $msgs = [{role=>'system', content=>$sp}];
|
||||
if (@ARGV) { open my $f, '<:encoding(UTF-8)', $ARGV[0] or die "cannot open $ARGV[0]: $!"; # file mode
|
||||
local $/; push @$msgs, {role=>'user', content=><$f>}; AL($c, $msgs, $sp); autosave($msgs); return; }
|
||||
my ($c, $sp) = (cfg(), sp()); my $msgs = [{role=>'system', content=>$sp}];
|
||||
if (@ARGV) { open my $f, '<:encoding(UTF-8)', $ARGV[0] or die "cannot open $ARGV[0]: $!"; local $/; push @$msgs, {role=>'user', content=><$f>}; AL($c, $msgs, $sp); autosave($msgs); return; }
|
||||
print "MicroBantam ready ($c->{model}). Commands: /quit /clear /save /list /load <id> /cfg <k> [v] /help\n";
|
||||
while (1) {
|
||||
print "> "; my $u = <STDIN>; last unless defined $u;
|
||||
$u =~ s/^\s+|\s+$//g; next unless length $u;
|
||||
if ($u eq '/quit') { last; }
|
||||
print "> "; my $u = <STDIN>; last unless defined $u; $u =~ s/^\s+|\s+$//g; next unless length $u;
|
||||
if ($u eq '/quit') { last; }
|
||||
elsif ($u eq '/clear') { $msgs = [{role=>'system', content=>$sp}]; autosave($msgs); }
|
||||
elsif ($u eq '/save') { print "session saved: ", save($msgs), "\n"; }
|
||||
elsif ($u eq '/list') { print "$_->[0] [$_->[1] msgs]\n" for list_sessions(); }
|
||||
elsif ($u eq '/save') { print "session saved: ", save($msgs), "\n"; }
|
||||
elsif ($u eq '/list') { print "$_->[0] [$_->[1] msgs]\n" for list_sessions(); }
|
||||
elsif ($u =~ /^\/load(?:\s+(\S+))?$/) { if (defined $1) { $msgs = eval { load($1) }; $@ ? print($@) : (autosave($msgs), print "loaded: $1\n"); } else { print "usage: /load <session id>\n"; } }
|
||||
elsif ($u =~ /^\/cfg(?:\s+(\S+)(?:\s+(.+))?)?$/) { if (defined $2) { set_cfg($1, $2); $c = cfg(); print "config: $1=$2\n"; } elsif (defined $1) { print exists $c->{$1} ? "$1=$c->{$1}\n" : "$1 not set\n"; } else { print "usage: /cfg <param> [val]\n"; } }
|
||||
elsif ($u eq '/help') { print "Commands: /quit /clear /save /list /load <id> /cfg <k> [v] /help\n"; }
|
||||
elsif ($u eq '/help') { print "Commands: /quit /clear /save /list /load <id> /cfg <k> [v] /help\n"; }
|
||||
else { push @$msgs, {role=>'user', content=>$u}; AL($c, $msgs, $sp); autosave($msgs); }
|
||||
}
|
||||
autosave($msgs);
|
||||
}
|
||||
|
||||
main() unless caller();
|
||||
|
||||
+20
-2
@@ -2,9 +2,27 @@
|
||||
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func termWidth() int { return 80 }
|
||||
func termWidth() int {
|
||||
if w := os.Getenv("COLUMNS"); w != "" {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(w)); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
if out, err := exec.Command("tput", "cols").Output(); err == nil {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 80
|
||||
}
|
||||
|
||||
func isTerminal(fd int) bool { return false }
|
||||
|
||||
|
||||
+20
-2
@@ -2,9 +2,27 @@
|
||||
|
||||
package main
|
||||
|
||||
import "errors"
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func termWidth() int { return 80 }
|
||||
func termWidth() int {
|
||||
if w := os.Getenv("COLUMNS"); w != "" {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(w)); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
if out, err := exec.Command("tput", "cols").Output(); err == nil {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 80
|
||||
}
|
||||
|
||||
func isTerminal(fd int) bool { return false }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user