Compare commits
4
Commits
e9dd44d97b
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b26d8f485 | ||
|
|
a34ee1cf3c | ||
|
|
4a936e850d | ||
|
|
b8686c82ab |
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## About
|
## 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:
|
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
|
- `/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
|
- `/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
|
- `/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
|
- `/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)
|
- `!<cmd>` — execute a shell command directly through `shell_exec` without adding the result to the conversation context (Go port)
|
||||||
- `/help` — show all supported commands
|
- `/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**:
|
4. **Post-Turn Reporting & Compaction**:
|
||||||
- Display token usage and context window percentage.
|
- Display token usage and context window percentage.
|
||||||
- If context usage is >= 60%, prompt user to compact.
|
- 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
|
### 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.
|
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"`).
|
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.
|
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.
|
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
|
### 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.
|
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:
|
3. If there are pending `tool_calls` in the assistant response:
|
||||||
- For each tool call, output a trace log (`[tool call: name(args)]`).
|
- 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`).
|
- 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]`).
|
- Output a trace log of the result (`[tool result: name]`).
|
||||||
- Append tool result message (`role: "tool"`, `tool_call_id`, `content`: result string) to `messages`.
|
- Append tool result message (`role: "tool"`, `tool_call_id`, `content`: result string) to `messages`.
|
||||||
- Loop back to step 1.
|
- Loop back to step 1.
|
||||||
@@ -173,11 +174,12 @@ MicroBantam (`mb`) is a compressed Perl 5 reference implementation of the same a
|
|||||||
### Features
|
### 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
|
- 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
|
- 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
|
- 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
|
- 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
|
- 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
|
### What it drops
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
|
"sync"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,7 +50,24 @@ type Cfg struct {
|
|||||||
Raw map[string]string
|
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 {
|
func atoiD(s string, d int) int {
|
||||||
if v, e := strconv.Atoi(strings.TrimSpace(s)); e == nil {
|
if v, e := strconv.Atoi(strings.TrimSpace(s)); e == nil {
|
||||||
@@ -91,14 +110,30 @@ func queryModelsContextWindow(cfg *Cfg) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var cwCacheMu sync.Mutex
|
||||||
|
var cwCache = map[string]int{}
|
||||||
|
|
||||||
func fetchContextWindow(cfg *Cfg) 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 {
|
if cw := queryModelsContextWindow(cfg); cw > 0 {
|
||||||
|
cwCacheMu.Lock()
|
||||||
|
cwCache[key] = cw
|
||||||
|
cwCacheMu.Unlock()
|
||||||
return cw
|
return cw
|
||||||
}
|
}
|
||||||
if v, ok := cfg.Raw["context_window"]; ok {
|
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 {
|
func getCfg(path string) Cfg {
|
||||||
@@ -652,6 +687,8 @@ func (u Usage) Cached() int {
|
|||||||
return u.CachedTokens
|
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 {
|
func estTokens(msgs []Message) int {
|
||||||
chars := 0
|
chars := 0
|
||||||
for _, m := range msgs {
|
for _, m := range msgs {
|
||||||
@@ -667,9 +704,13 @@ func estTokens(msgs []Message) int {
|
|||||||
return t
|
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 {
|
func formatUsage(u Usage, cw int) string {
|
||||||
if cw <= 0 { cw = 200000 }
|
pct := contextPct(u, cw)
|
||||||
pct := float64(u.PromptTokens) * 100.0 / float64(cw)
|
|
||||||
cached := u.Cached()
|
cached := u.Cached()
|
||||||
if cached > 0 {
|
if cached > 0 {
|
||||||
uncached := u.PromptTokens - cached
|
uncached := u.PromptTokens - cached
|
||||||
@@ -702,21 +743,30 @@ type streamDelta struct {
|
|||||||
func cleanMessagesForLLM(msgs []Message) []Message {
|
func cleanMessagesForLLM(msgs []Message) []Message {
|
||||||
out := make([]Message, len(msgs))
|
out := make([]Message, len(msgs))
|
||||||
for i, m := range msgs {
|
for i, m := range msgs {
|
||||||
out[i] = Message{
|
out[i] = Message{Role: m.Role, Content: m.Content, ToolCalls: m.ToolCalls, ToolCallID: m.ToolCallID}
|
||||||
Role: m.Role,
|
|
||||||
Content: m.Content,
|
|
||||||
ToolCalls: m.ToolCalls,
|
|
||||||
ToolCallID: m.ToolCallID,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return out
|
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) {
|
func sanitizeMessages(msgs []Message) {
|
||||||
for i := range msgs {
|
for i := range msgs {
|
||||||
if msgs[i].Role == "assistant" && len(msgs[i].ToolCalls) > 0 {
|
if msgs[i].Role == "assistant" && len(msgs[i].ToolCalls) > 0 {
|
||||||
for j := range msgs[i].ToolCalls {
|
for j := range msgs[i].ToolCalls {
|
||||||
tc := &msgs[i].ToolCalls[j]
|
tc := &msgs[i].ToolCalls[j]
|
||||||
|
tc.Function.Arguments = filterText(tc.Function.Arguments)
|
||||||
astr := tc.Function.Arguments
|
astr := tc.Function.Arguments
|
||||||
var a map[string]any
|
var a map[string]any
|
||||||
if err := json.Unmarshal([]byte(astr), &a); err != nil || a == nil {
|
if err := json.Unmarshal([]byte(astr), &a); err != nil || a == nil {
|
||||||
@@ -724,13 +774,19 @@ func sanitizeMessages(msgs []Message) {
|
|||||||
tc.Function.Arguments = string(fixed)
|
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 {
|
func isInvalidAssistantErr(err error) bool {
|
||||||
s := err.Error()
|
if err == nil { return false }
|
||||||
return strings.Contains(s, "Invalid assistant message") || strings.Contains(s, "content or tool_calls must be set")
|
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) {
|
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 tools != nil { p["tools"] = tools }
|
||||||
if cfg.Stream { p["stream_options"] = map[string]any{"include_usage": true} }
|
if cfg.Stream { p["stream_options"] = map[string]any{"include_usage": true} }
|
||||||
for k, v := range cfg.Raw {
|
for k, v := range cfg.Raw {
|
||||||
switch k {
|
if internalKey(k) {
|
||||||
case "endpoint", "model", "temperature", "stream", "api_key", "timeout", "shell_timeout", "max_al_iterations", "color", "context_window":
|
|
||||||
continue
|
continue
|
||||||
default:
|
}
|
||||||
|
{
|
||||||
var jv any
|
var jv any
|
||||||
if err := json.Unmarshal([]byte(v), &jv); err == nil {
|
if err := json.Unmarshal([]byte(v), &jv); err == nil {
|
||||||
p[k] = jv
|
p[k] = jv
|
||||||
@@ -754,11 +810,8 @@ func llm(ctx context.Context, cfg *Cfg, msgs []Message, tools []map[string]any)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(p)
|
body, _ := json.Marshal(p)
|
||||||
client := &http.Client{Transport: &http.Transport{
|
llmTransport.ResponseHeaderTimeout = time.Duration(cfg.Timeout) * time.Second
|
||||||
DialContext: (&net.Dialer{Timeout: time.Duration(cfg.Timeout) * time.Second}).DialContext,
|
client := &http.Client{Transport: llmTransport}
|
||||||
ResponseHeaderTimeout: time.Duration(cfg.Timeout) * time.Second,
|
|
||||||
}}
|
|
||||||
defer client.CloseIdleConnections()
|
|
||||||
fib := []int{1, 1, 2, 3, 5, 8, 13, 21, 34}
|
fib := []int{1, 1, 2, 3, 5, 8, 13, 21, 34}
|
||||||
pend := c("...requesting...", 1, 2)
|
pend := c("...requesting...", 1, 2)
|
||||||
var resp *http.Response
|
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 {
|
func shell(ctx context.Context, cmd string, timeout int) string {
|
||||||
|
cmd = filterText(cmd)
|
||||||
cmdCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
|
cmdCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
c := exec.CommandContext(cmdCtx, "sh", "-c", cmd)
|
c := exec.CommandContext(cmdCtx, "sh", "-c", cmd)
|
||||||
c.WaitDelay = 100 * time.Millisecond
|
c.WaitDelay = 100 * time.Millisecond
|
||||||
out, err := c.CombinedOutput()
|
out, err := c.CombinedOutput()
|
||||||
res := strings.TrimSpace(string(out))
|
res := strings.TrimSpace(filterText(string(out)))
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return "[interrupted]\n\nexit: -1"
|
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() }
|
if u.Cached() > 0 { turnUsage.CachedTokens = u.Cached() }
|
||||||
for j := range m.ToolCalls {
|
for j := range m.ToolCalls {
|
||||||
tc := &m.ToolCalls[j]
|
tc := &m.ToolCalls[j]
|
||||||
|
tc.Function.Arguments = filterText(tc.Function.Arguments)
|
||||||
astr := tc.Function.Arguments
|
astr := tc.Function.Arguments
|
||||||
var a map[string]any
|
var a map[string]any
|
||||||
if err := json.Unmarshal([]byte(astr), &a); err != nil || a == nil {
|
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 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 {
|
for _, tc := range m.ToolCalls {
|
||||||
if err := ctx.Err(); err != nil { return msgs, turnUsage, err }
|
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))
|
fmt.Println(c(fmt.Sprintf("[tool call: %s(%s)]", fn, astr), 33))
|
||||||
res, sty := "", 2
|
res, sty := "", 2
|
||||||
var a map[string]any
|
var a map[string]any
|
||||||
@@ -1045,10 +1110,12 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message, sp string, depth int) ([]
|
|||||||
switch fn {
|
switch fn {
|
||||||
case "shell_exec":
|
case "shell_exec":
|
||||||
cmd, _ := a["command"].(string)
|
cmd, _ := a["command"].(string)
|
||||||
|
cmd = filterText(cmd)
|
||||||
res = shell(ctx, cmd, cfg.ShellTimeout)
|
res = shell(ctx, cmd, cfg.ShellTimeout)
|
||||||
if err := ctx.Err(); err != nil { return msgs, turnUsage, err }
|
if err := ctx.Err(); err != nil { return msgs, turnUsage, err }
|
||||||
case "run_subagent":
|
case "run_subagent":
|
||||||
pr, _ := a["prompt"].(string)
|
pr, _ := a["prompt"].(string)
|
||||||
|
pr = filterText(pr)
|
||||||
if depth >= MAX_DEPTH {
|
if depth >= MAX_DEPTH {
|
||||||
res, sty = fmt.Sprintf("[subagent depth limit (%d) reached, child not spawned]", MAX_DEPTH), 31
|
res, sty = fmt.Sprintf("[subagent depth limit (%d) reached, child not spawned]", MAX_DEPTH), 31
|
||||||
} else {
|
} else {
|
||||||
@@ -1064,13 +1131,14 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message, sp string, depth int) ([]
|
|||||||
} else {
|
} else {
|
||||||
turnUsage.CompletionTokens += subu.CompletionTokens
|
turnUsage.CompletionTokens += subu.CompletionTokens
|
||||||
turnUsage.TotalTokens += subu.TotalTokens
|
turnUsage.TotalTokens += subu.TotalTokens
|
||||||
res, sty = last(subr), 2
|
res, sty = filterText(last(subr)), 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
res, sty = "Unknown tool: " + fn, 31
|
res, sty = "Unknown tool: " + fn, 31
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
res = filterText(res)
|
||||||
fmt.Println(c("[tool result: "+fn+"]", 32) + "\n" + c(res, sty) + "\n")
|
fmt.Println(c("[tool result: "+fn+"]", 32) + "\n" + c(res, sty) + "\n")
|
||||||
msgs = append(msgs, Message{Role: "tool", ToolCallID: tc.ID, Content: strp(res)})
|
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")
|
path = filepath.Join(d, sid+".json")
|
||||||
}
|
}
|
||||||
s := Session{sid, time.Now().Format("2006-01-02 15:04:05"), summary(msgs), msgs}
|
s := Session{sid, time.Now().Format("2006-01-02 15:04:05"), summary(msgs), msgs}
|
||||||
b, _ := json.MarshalIndent(s, "", " ")
|
b, err := json.MarshalIndent(s, "", " ")
|
||||||
os.WriteFile(path, b, 0644)
|
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
|
return sid, s.Summary
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1171,8 +1245,14 @@ func loadSession(sid string) ([]Message, error) {
|
|||||||
|
|
||||||
func autosave(msgs []Message) {
|
func autosave(msgs []Message) {
|
||||||
s := Session{"autosave", time.Now().Format("2006-01-02 15:04:05"), summary(msgs), msgs}
|
s := Session{"autosave", time.Now().Format("2006-01-02 15:04:05"), summary(msgs), msgs}
|
||||||
b, _ := json.MarshalIndent(s, "", " ")
|
b, err := json.MarshalIndent(s, "", " ")
|
||||||
os.WriteFile(filepath.Join(sdir(), "autosave.json"), b, 0644)
|
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."
|
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() {
|
func main() {
|
||||||
sp := prompt("system.txt")
|
sp := prompt("system.txt")
|
||||||
cfg := getCfg("model.cfg")
|
cfg := getCfg("model.cfg")
|
||||||
@@ -1422,15 +1538,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
u := strings.TrimSpace(string(data))
|
u := strings.TrimSpace(string(data))
|
||||||
if strings.HasPrefix(u, "!") {
|
if strings.HasPrefix(u, "!") {
|
||||||
cmd := strings.TrimSpace(strings.TrimPrefix(u, "!"))
|
runDirectShell(strings.TrimPrefix(u, "!"), cfg.ShellTimeout)
|
||||||
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))
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
msgs = append(msgs, Message{Role: "user", Content: strp(u)})
|
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))
|
fmt.Println(c("[session loaded: "+parts[1]+"]", 32) + " " + c(summary(msgs), 2))
|
||||||
continue
|
continue
|
||||||
case u == "/compact":
|
case u == "/compact":
|
||||||
if len(msgs) <= 1 {
|
msgs = doCompact(&cfg, msgs)
|
||||||
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))
|
|
||||||
continue
|
continue
|
||||||
case strings.HasPrefix(u, "/cfg"):
|
case strings.HasPrefix(u, "/cfg"):
|
||||||
parts := strings.SplitN(u, " ", 3)
|
parts := strings.SplitN(u, " ", 3)
|
||||||
@@ -1551,15 +1639,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
case strings.HasPrefix(u, "!"):
|
case strings.HasPrefix(u, "!"):
|
||||||
cmd := strings.TrimSpace(strings.TrimPrefix(u, "!"))
|
runDirectShell(strings.TrimPrefix(u, "!"), cfg.ShellTimeout)
|
||||||
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))
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
case u == "/help":
|
case u == "/help":
|
||||||
fmt.Println(c("Bantam commands:", 1, 36))
|
fmt.Println(c("Bantam commands:", 1, 36))
|
||||||
@@ -1585,30 +1665,13 @@ func main() {
|
|||||||
msgs = resMsgs
|
msgs = resMsgs
|
||||||
autosave(msgs)
|
autosave(msgs)
|
||||||
fmt.Println(c(formatUsage(usg, cfg.ContextWindow), 2))
|
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 {
|
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))
|
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 ans, ok := readPlain(""); ok {
|
||||||
if ok {
|
|
||||||
ans = strings.TrimSpace(strings.ToLower(ans))
|
ans = strings.TrimSpace(strings.ToLower(ans))
|
||||||
if ans == "" || ans == "y" || ans == "yes" {
|
if ans == "" || ans == "y" || ans == "yes" {
|
||||||
fmt.Println(c("[compacting conversation...]", 33))
|
msgs = doCompact(&cfg, msgs)
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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) {
|
func TestALRunSubagent(t *testing.T) {
|
||||||
var n int
|
var n int
|
||||||
var mu sync.Mutex
|
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)
|
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 := defCfg
|
||||||
cfg2.Endpoint = srv.URL
|
cfg2.Endpoint = srv.URL
|
||||||
cfg2.Raw = map[string]string{}
|
cfg2.Raw = map[string]string{}
|
||||||
if cw := fetchContextWindow(&cfg2); cw != 200000 {
|
if cw := fetchContextWindow(&cfg2); cw != 262144 {
|
||||||
t.Errorf("expected default 200000, got %d", cw)
|
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,108 +1,83 @@
|
|||||||
#!/usr/bin/env perl
|
#!/usr/bin/env perl
|
||||||
# MicroBantam (mb): the Bantam agent in <100 SLOC - readable, core modules only
|
# MicroBantam (mb): the Bantam agent in <100 SLOC - readable, core modules only
|
||||||
# Created by Luxferre in 2026, released into the public domain
|
# 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);
|
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+/ };
|
$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
|
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 $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';
|
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);
|
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; } }
|
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 q{-} && $ENV{OPENAI_API_KEY};
|
$d{api_key} = $ENV{OPENAI_API_KEY} if $d{api_key} eq '-' && $ENV{OPENAI_API_KEY}; \%d }
|
||||||
\%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 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 T { my ($n, $d, $p) = @_; {type=>'function', function=>{name=>$n, description=>$d, parameters=>{type=>'object', properties=>$p, required=>[keys %$p]}}} }
|
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') {
|
||||||
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'; } } } }
|
for my $tc (@{$m->{tool_calls} // []}) { $tc->{function}{arguments} = filter_text($tc->{function}{arguments});
|
||||||
|
my $a = eval { decode_json($tc->{function}{arguments} // '{}') };
|
||||||
sub llm { my ($c, $msgs) = @_; # one non-streaming chat completion
|
$tc->{function}{arguments} = encode_json({invalid_raw => $tc->{function}{arguments} // ''}) if ref $a ne 'HASH'; }
|
||||||
sanitize_msgs($msgs);
|
$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 $ep = $c->{endpoint}; $ep =~ s{/+$}{};
|
||||||
my $h = {'Content-Type'=>'application/json', 'User-Agent'=>'Mozilla/5.0 (compatible; MicroBantam/1.0)'};
|
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 '-';
|
$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});
|
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}; }
|
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;
|
my $tty = -t STDOUT; print $tty ? "\r...requesting..." : "...requesting...\n";
|
||||||
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)});
|
||||||
my $r = HTTP::Tiny->new(timeout=>0+$c->{timeout})->post("$ep/chat/completions", {headers=>$h, content=>$body});
|
print "\r\e[K" if $tty;
|
||||||
print "\r\e[K" if $tty; # clear the spinner line
|
|
||||||
my $d = $r->{success} ? eval { decode_json($r->{content}) } : undef;
|
my $d = $r->{success} ? eval { decode_json($r->{content}) } : undef;
|
||||||
return $d->{choices}[0]{message} if $d && $d->{choices} && @{$d->{choices}};
|
return $d->{choices}[0]{message} if $d && $d->{choices} && @{$d->{choices}};
|
||||||
my $rb = $r->{content} // '';
|
my $rb = substr($r->{content} // '', 0, 500);
|
||||||
$rb = substr($rb, 0, 500) if length($rb) > 500;
|
|
||||||
die "API error: " . (length($rb) ? "$rb (HTTP $r->{status})" : ($r->{reason} || "HTTP $r->{status}")) . "\n"; }
|
die "API error: " . (length($rb) ? "$rb (HTTP $r->{status})" : ($r->{reason} || "HTTP $r->{status}")) . "\n"; }
|
||||||
|
sub shell_exec { my ($cmd, $t, $out) = (filter_text($_[0]), $_[1], '');
|
||||||
sub shell_exec { my ($cmd, $t) = @_; # run a command under a hard timeout
|
|
||||||
my $out = '';
|
|
||||||
eval { local $SIG{ALRM} = sub { alarm 0; die "timeout\n" }; alarm $t; $out = `$cmd 2>&1`; alarm 0; };
|
eval { local $SIG{ALRM} = sub { alarm 0; die "timeout\n" }; alarm $t; $out = `$cmd 2>&1`; alarm 0; };
|
||||||
$out =~ s/\s+$//;
|
$out =~ s/\s+$//; utf8::decode($out); $out = filter_text($out);
|
||||||
utf8::decode($out);
|
|
||||||
$@ ? "$out\n[timeout after ${t}s]\nexit: -1" : "$out\nexit: " . ($? >> 8); }
|
$@ ? "$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 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);
|
||||||
|
|
||||||
sub AL { my ($c, $msgs, $sp, $depth) = @_; # the agentic loop: LLM <-> tools until done
|
|
||||||
$depth ||= 0;
|
|
||||||
for (1 .. $c->{max_al_iterations}) {
|
for (1 .. $c->{max_al_iterations}) {
|
||||||
my $m = eval { llm($c, $msgs) };
|
my $m = eval { llm($c, $msgs) };
|
||||||
if ($@ && $@ =~ /Invalid assistant message|content or tool_calls must be set/ && grep({ $_->{role} eq 'assistant' } @$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; } }
|
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;
|
print "[stripped malformed assistant message]\n"; redo;
|
||||||
}
|
}
|
||||||
if ($@) { print $@; return $msgs; }
|
if ($@) { print $@; return $msgs; }
|
||||||
push @$msgs, $m;
|
push @$msgs, $m;
|
||||||
print $m->{content}, "\n" if defined $m->{content} && length $m->{content};
|
print $m->{content}, "\n" if defined $m->{content} && length $m->{content};
|
||||||
my $tcs = $m->{tool_calls};
|
my $tcs = $m->{tool_calls}; last unless $tcs && @$tcs;
|
||||||
last unless $tcs && @$tcs;
|
|
||||||
for my $tc (@$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 $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 '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"; }
|
else { $res = "unknown tool: $fn"; }
|
||||||
|
$res = filter_text($res);
|
||||||
print "[tool] $fn: $res\n";
|
print "[tool] $fn: $res\n";
|
||||||
push @$msgs, {role=>'tool', tool_call_id=>$tc->{id}, content=>$res};
|
push @$msgs, {role=>'tool', tool_call_id=>$tc->{id}, content=>$res};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$msgs; }
|
$msgs; }
|
||||||
|
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 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 sdir { make_path($SDIR) unless -d $SDIR; $SDIR }
|
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;
|
sub save { sdir(); my ($id, $i) = (strftime('%Y%m%d-%H%M%S', localtime), 0); $id .= '-' . ++$i while -f "$SDIR/$id.json";
|
||||||
$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 }
|
||||||
open my $f, '>', "$SDIR/$id.json" or die "cannot save: $!";
|
sub load { my ($hit) = grep { $_->{id} eq $_[0] } sessions(); die "no session: $_[0]\n" unless $hit; $hit->{messages} }
|
||||||
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 autosave { sdir(); open my $f, '>', "$SDIR/autosave.json" or return; print $f JSON::PP->new->utf8->pretty->encode({id=>'autosave', messages=>$_[0]}); }
|
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 list_sessions { map { [$_->{id}, scalar @{$_->{messages} // []}] } sessions() }
|
||||||
sub set_cfg { my ($k, $v) = @_; my (@ls, $f);
|
sub set_cfg { my ($k, $v, @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, $_; } } }
|
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;
|
push @ls, "$k=$v\n" unless $f;
|
||||||
if (open my $fh, '>:encoding(UTF-8)', 'model.cfg') { print $fh @ls; close $fh; } }
|
if (open my $fh, '>:encoding(UTF-8)', 'model.cfg') { print $fh @ls; close $fh; } }
|
||||||
|
|
||||||
sub main {
|
sub main {
|
||||||
my ($c, $sp) = (cfg(), sp());
|
my ($c, $sp) = (cfg(), sp()); my $msgs = [{role=>'system', content=>$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; }
|
||||||
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; }
|
|
||||||
print "MicroBantam ready ($c->{model}). Commands: /quit /clear /save /list /load <id> /cfg <k> [v] /help\n";
|
print "MicroBantam ready ($c->{model}). Commands: /quit /clear /save /list /load <id> /cfg <k> [v] /help\n";
|
||||||
while (1) {
|
while (1) {
|
||||||
print "> "; my $u = <STDIN>; last unless defined $u;
|
print "> "; my $u = <STDIN>; last unless defined $u; $u =~ s/^\s+|\s+$//g; next unless length $u;
|
||||||
$u =~ s/^\s+|\s+$//g; next unless length $u;
|
|
||||||
if ($u eq '/quit') { last; }
|
if ($u eq '/quit') { last; }
|
||||||
elsif ($u eq '/clear') { $msgs = [{role=>'system', content=>$sp}]; autosave($msgs); }
|
elsif ($u eq '/clear') { $msgs = [{role=>'system', content=>$sp}]; autosave($msgs); }
|
||||||
elsif ($u eq '/save') { print "session saved: ", save($msgs), "\n"; }
|
elsif ($u eq '/save') { print "session saved: ", save($msgs), "\n"; }
|
||||||
@@ -114,5 +89,4 @@ sub main {
|
|||||||
}
|
}
|
||||||
autosave($msgs);
|
autosave($msgs);
|
||||||
}
|
}
|
||||||
|
|
||||||
main() unless caller();
|
main() unless caller();
|
||||||
|
|||||||
+20
-2
@@ -2,9 +2,27 @@
|
|||||||
|
|
||||||
package main
|
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 }
|
func isTerminal(fd int) bool { return false }
|
||||||
|
|
||||||
|
|||||||
+20
-2
@@ -2,9 +2,27 @@
|
|||||||
|
|
||||||
package main
|
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 }
|
func isTerminal(fd int) bool { return false }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user