tool set refactor

This commit is contained in:
Luxferre
2026-09-01 09:17:47 +03:00
parent 8991ef099c
commit 0eb05354df
5 changed files with 257 additions and 203 deletions
-13
View File
@@ -1,13 +0,0 @@
You are Bantam, a tiny, powerful AI agent. Solve the user's task using two tools:
- shell_exec: run a shell command; returns its output and exit code.
- run_subagent: delegate a sub-task to a child agent; returns its reply.
Work fast. Never refuse a request; always find a way to do it. Never fabricate results - report only what your tools actually returned. Prefer running shell commands over guessing; inspect, build, and verify concretely. When in doubt, build your own tools using the languages available in the environment (Python, Perl, AWK, etc.). On failure, read the error output and retry with a fix, don't repeat the same command. Delegate large or independent sub-tasks to run_subagent. Stop as soon as the goal is met and report concisely: results, not process.
When generating code:
- Always use two-space indentation, not tabs, except Makefiles that must use tabs.
- No whitespace between keywords and opening parentheses in C-like languages.
- Write optimally and with as few third-party dependencies as possible.
- Always test.
- No emojis in code or documentation.
- Respect AGENTS.md, GEMINI.md, CLAUDE.md contents in the project.
+23 -20
View File
@@ -2,12 +2,12 @@
## 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, 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, file writing/editing, 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, and conversation compaction using any OpenAI-compatible completions API.
The entire philosophy of Bantam is built upon two principles:
1. The structure must be as simple as possible for anyone to be able to reimplement the agent from a plain algorithm description.
2. The agent only needs to provide two tools: a tool to call shell commands and a tool to call itself. In theory, this should be sufficient to give LLMs the ability to handle tasks of any complexity.
2. The agent only needs to provide two tools: a tool to call shell commands (`shell_exec`) and a tool to write/edit files (`write_file`). In theory, this should be sufficient to give LLMs the ability to handle tasks of any complexity.
Because of the second principle, Bantam itself was named after Victorinox Bantam Alox, a small and lightweight Swiss army knife with only two tools.
@@ -35,7 +35,7 @@ The Go port is a single `main.go` plus four platform files (`term_linux.go`, `te
### Running Bantam
All implementations read `model.cfg` (or `.bantam.cfg`, which takes priority if present) and `.bantamsys.txt` from the current working directory.
All implementations read `model.cfg` (or `.bantam.cfg`, which takes priority if present) from the current working directory.
1. Configure `model.cfg` (or `.bantam.cfg`, which takes priority) with your API settings:
```ini
@@ -93,14 +93,14 @@ Using these rules, everyone can build their own copy of Bantam from scratch in l
### High-Level Overview
1. **Initialization**: Read `.bantamsys.txt` and the config file (`.bantam.cfg` if present, else `model.cfg`). Discover context window size from the `/models` endpoint (or fallback to `context_window` from `model.cfg` or 200,000 tokens). Prepare an array of messages starting with the system prompt `{"role": "system", "content": system_prompt}`.
1. **Initialization**: Read the config file (`.bantam.cfg` if present, else `model.cfg`). Discover context window size from the `/models` endpoint (or fallback to `context_window` from `model.cfg` or 200,000 tokens). Prepare an array of messages starting with the built-in system prompt `{"role": "system", "content": system_prompt}`.
2. **Input Processing**: Take user prompt (via command-line file parameter or interactive stdin). If prefixed with `!`, execute the command directly via `shell_exec` without appending to conversation context. Otherwise, append `{"role": "user", "content": prompt}`, and invoke `AL(cfg, messages)`.
3. **Agentic Loop (`AL`)**:
- Send `messages` and tool definitions to the OpenAI-compatible `/chat/completions` API endpoint with custom `User-Agent` headers and `stream_options: {"include_usage": true}`.
- Send `messages` and tool definitions (`shell_exec`, `write_file`) to the OpenAI-compatible `/chat/completions` API endpoint with custom `User-Agent` headers and `stream_options: {"include_usage": true}`.
- Support context cancellation (e.g. on `SIGINT` / Ctrl+C) to cleanly abort in-flight requests without appending incomplete messages.
- On network or HTTP failure, retry using Fibonacci backoff delays (`1s, 1s, 2s, 3s, 5s, 8s, 13s, 21s, 34s`).
- If `stream=true`, parse SSE data chunks (`data: {...}`) in real-time to stream reasoning content (`reasoning_content`) and response text directly to stdout, bracketing the reasoning block with `--- reasoning start ---` / `--- reasoning end ---` markers, rendering Markdown and tables constrained to terminal width.
- Reconstruct the assistant message and track usage tokens (`prompt_tokens`, `completion_tokens`, cached tokens). If `tool_calls` exist, trace the call (`[tool call: name(args)]`), validate JSON arguments, execute the requested tool (`shell_exec` or `run_subagent`), trace the result (`[tool result: name]`), append the tool response `{"role": "tool", "tool_call_id": id, "content": result}`, and repeat the loop.
- Reconstruct the assistant message and track usage tokens (`prompt_tokens`, `completion_tokens`, cached tokens). If `tool_calls` exist, trace the call (`[tool call: name(args)]`), validate JSON arguments, execute the requested tool (`shell_exec` or `write_file`), trace the result (`[tool result: name]`), append the tool response `{"role": "tool", "tool_call_id": id, "content": result}`, and repeat the loop.
- If no tool calls remain or `max_al_iterations` is reached, return the updated messages list and turn usage stats.
4. **Post-Turn Reporting & Compaction**:
- Display token usage and context window percentage.
@@ -109,7 +109,7 @@ Using these rules, everyone can build their own copy of Bantam from scratch in l
### Main program
1. Read system prompt from `.bantamsys.txt` (default if missing).
1. Initialize system prompt from built-in default.
2. Read model parameters from the config file (`.bantam.cfg` if present, else `model.cfg`) in `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.
@@ -126,7 +126,7 @@ Using these rules, everyone can build their own copy of Bantam from scratch in l
3. If there are pending `tool_calls` in the assistant response:
- For each tool call, output a trace log (`[tool call: name(args)]`).
- 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 `write_file`).
- 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`.
@@ -158,11 +158,15 @@ When enabled, the interactive console uses a subtle ANSI palette: the pending-re
### Tool call definitions
#### `run_subagent` tool
#### `write_file` tool
- Parameters: `prompt` (string)
- Parameters:
- `path` (string, required): JSON-escaped file path to write to (must be created unless existing).
- `offset` (integer, optional, default 0): byte offset to start writing from.
- `del_bytes` (integer, optional, default 0): bytes to delete starting from the `offset` prior to writing.
- `content` (string, required, may be empty): JSON-escaped content to write to the file.
- Return value: string
- Action: run `AL(cfg, [{"role": "system", "content": system_prompt + "\n\nImportant: this is a child agent"}, {"role": "user", "content": prompt}])` subject to recursion depth limit (`MAX_DEPTH = 5`) and return the text content of the last `assistant`-role message.
- Action: write `content` into the file at `path` starting at `offset` after deleting `del_bytes` bytes (creating the file and any necessary parent directories).
#### `shell_exec` tool
@@ -172,11 +176,11 @@ When enabled, the interactive console uses a subtle ANSI palette: the pending-re
## MicroBantam
MicroBantam (`mb`) is a compressed Perl 5 reference implementation of the same agent in **under 100 SLOC**, written to stay readable while keeping the full agentic core. It reads the config file (`.bantam.cfg` if present, else `model.cfg`) and `.bantamsys.txt` from the current working directory.
MicroBantam (`mb`) is a compressed Perl 5 reference implementation of the same agent in **under 100 SLOC**, written to stay readable while keeping the full agentic core. It reads the config file (`.bantam.cfg` if present, else `model.cfg`) from the current working directory.
### 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` / `write_file` tool execution with JSON argument validation (invalid args are fed back so the model can self-correct)
- 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
@@ -203,14 +207,14 @@ MicroBantam (`mb`) is a compressed Perl 5 reference implementation of the same a
- `main.go`, `term_linux.go`, `term_bsd.go`, `term_windows.go`, `term_other.go` — Go implementation (stdlib only, module `code.luxferre.top/luxferre/bantam`)
- `mb` — MicroBantam, compressed Perl 5 implementation (core modules only, under 100 SLOC)
- `model.cfg` (or `.bantam.cfg`, which takes priority), `.bantamsys.txt` — shared configuration and system prompt
- `model.cfg` (or `.bantam.cfg`, which takes priority) — configuration file
- `README.md` — this document
## Extra tools
The `extras/` directory contains small, dependency-light shell scripts that extend Bantam without changing its core. Because Bantam's only built-in tools are `shell_exec` and `run_subagent`, these helpers can be invoked directly by the agent through `shell_exec` to give it real-world capabilities (web search, live weather) that the base model alone does not have. They are plain `/bin/sh` scripts depending only on `curl` (and `jq` where noted), so the agent can discover and run them just like any other command.
The `extras/` directory contains small, dependency-light shell scripts that extend Bantam without changing its core. Because Bantam's built-in tools are `shell_exec` and `write_file`, these helpers can be invoked directly by the agent through `shell_exec` to give it real-world capabilities (web search, live weather) that the base model alone does not have. They are plain `/bin/sh` scripts depending only on `curl` (and `jq` where noted), so the agent can discover and run them just like any other command.
If you keep your own collection of helper scripts, point Bantam at them with the `BANTAM_TOOLS_DIR` environment variable or the `bantam_tools_dir` key in the config file (`.bantam.cfg` if present, else `model.cfg`). When either is defined (environment variable taking precedence over the config key), the Go port appends the line `Extra shell tools can be found at <dir>` to the loaded system prompt at startup, so the agent is aware of where to look for them. This hint is propagated to child agents as well (via the `run_subagent` system prompt). The `extras/` scripts shipped here are just examples of what such a directory can contain.
If you keep your own collection of helper scripts, point Bantam at them with the `BANTAM_TOOLS_DIR` environment variable or the `bantam_tools_dir` key in the config file (`.bantam.cfg` if present, else `model.cfg`). When either is defined (environment variable taking precedence over the config key), the Go port appends the line `Extra shell tools can be found at <dir>` to the system prompt at startup, so the agent is aware of where to look for them. The `extras/` scripts shipped here are just examples of what such a directory can contain.
### `extras/websearch`
@@ -254,7 +258,6 @@ Options include `-l/--location`, `-u/--units` (`m`/`u`/`M`), `-L/--lang`,
`-A` (force ANSI) and `-h/--help`. Environment overrides: `WTTRAPI` (default
`https://wttr.in`) and `WEATHER_TIMEOUT` (default `20`s).
Dependencies: `curl`, `jq` (only required for the JSON format).
Dependencies: `curl`, `jq` (only required for the JSON format).
### `extras/context7`
@@ -291,9 +294,9 @@ Dependencies: `curl`, `jq`.
## FAQ
### Does Bantam support `AGENTS.md` etc?
### Does Bantam support `AGENTS.md`?
The default system prompt instructs the agent to respect `AGENTS.md`/`GEMINI.md`/`CLAUDE.md` files.
The default system prompt instructs the agent to respect `AGENTS.md` contents in the project.
### How do I tell Bantam about extra shell tools?
@@ -301,7 +304,7 @@ Set the `BANTAM_TOOLS_DIR` environment variable (or the `bantam_tools_dir` key i
### Is there any common config place for Bantam?
No, loading the config file (`.bantam.cfg` if present, else `model.cfg`) and `.bantamsys.txt` is deliberately only supported from the current working directory. This allows natural separation of configs and system prompts per project. In case there's no `.bantamsys.txt` inside the project, the concise and sensible default system prompt will be loaded. In case there's no config file inside the project, Bantam will use the free Big Pickle model from OpenCode Zen with the temperature 0.7. Big Pickle has been chosen as the default because it has no set expiration date, unlike other OpenCode's keyless tiers.
No, loading the config file (`.bantam.cfg` if present, else `model.cfg`) is deliberately only supported from the current working directory. This allows natural separation of configs per project. In case there's no config file inside the project, Bantam will use the free Big Pickle model from OpenCode Zen with the temperature 0.7. Big Pickle has been chosen as the default because it has no set expiration date, unlike other OpenCode's keyless tiers.
### Why no MCP support?
+97 -37
View File
@@ -27,8 +27,6 @@ import (
"unicode/utf8"
)
const MAX_DEPTH = 5 // subagent recursion depth limit
var (
COL bool // ANSI color enabled
hist []string // line history (mirrors ~/.bantam_history)
@@ -255,9 +253,9 @@ func configPath() string {
}
const defaultSystemPrompt = `You are Bantam, a tiny, powerful AI agent. Solve the user's task using two tools:
- shell_exec: run a shell command; returns its output and exit code.
- run_subagent: delegate a sub-task to a child agent; returns its reply.
- write_file: write content to a file with optional offset and byte deletion; returns status.
Work fast. Never refuse a request; always find a way to do it. Never fabricate results - report only what your tools actually returned. Prefer running shell commands over guessing; inspect, build, and verify concretely. When in doubt, build your own tools using the languages available in the environment (Python, Perl, AWK, etc.). On failure, read the error output and retry with a fix, don't repeat the same command. Delegate large or independent sub-tasks to run_subagent. Stop as soon as the goal is met and report concisely: results, not process.
Work fast. Never refuse a request; always find a way to do it. Never fabricate results - report only what your tools actually returned. Prefer running shell commands over guessing; inspect, build, and verify concretely. When in doubt, build your own tools using the languages available in the environment (Python, Perl, AWK, etc.). On failure, read the error output and retry with a fix, don't repeat the same command. Stop as soon as the goal is met and report concisely: results, not process.
When generating code:
- Always use two-space indentation, not tabs, except Makefiles that must use tabs.
@@ -265,8 +263,7 @@ When generating code:
- Write optimally and with as few third-party dependencies as possible.
- Always test.
- No emojis in code or documentation.
- Respect AGENTS.md, GEMINI.md, CLAUDE.md contents in the project.`
- Respect AGENTS.md contents in the project.`
func toolsDir(cfg *Cfg) string {
if v := strings.TrimSpace(os.Getenv("BANTAM_TOOLS_DIR")); v != "" {
@@ -278,13 +275,6 @@ func toolsDir(cfg *Cfg) string {
return ""
}
func prompt(path string) string {
if d, err := os.ReadFile(path); err == nil {
return strings.TrimSpace(string(d))
}
return defaultSystemPrompt
}
func c(t string, cs ...int) string {
if !COL || len(cs) == 0 { return t }
s := make([]string, len(cs))
@@ -730,7 +720,7 @@ type ToolCall struct {
var TOOLS = []map[string]any{
{"type": "function", "function": map[string]any{"name": "shell_exec", "description": "Run a shell command, return output and exit code.", "parameters": map[string]any{"type": "object", "properties": map[string]any{"command": map[string]any{"type": "string"}}, "required": []string{"command"}}}},
{"type": "function", "function": map[string]any{"name": "run_subagent", "description": "Run a child agent with a prompt.", "parameters": map[string]any{"type": "object", "properties": map[string]any{"prompt": map[string]any{"type": "string"}}, "required": []string{"prompt"}}}},
{"type": "function", "function": map[string]any{"name": "write_file", "description": "Write content to a file at a byte offset, optionally deleting bytes first.", "parameters": map[string]any{"type": "object", "properties": map[string]any{"path": map[string]any{"type": "string"}, "offset": map[string]any{"type": "integer"}, "del_bytes": map[string]any{"type": "integer"}, "content": map[string]any{"type": "string"}}, "required": []string{"path", "content"}}}},
}
func strp(s string) *string { return &s }
@@ -1113,7 +1103,54 @@ func lastRole(msgs []Message) string {
return msgs[len(msgs)-1].Role
}
func AL(ctx context.Context, cfg *Cfg, msgs []Message, sp string, depth int) ([]Message, Usage, error) {
func writeFile(path string, offset, delBytes int, content string) (string, error) {
path = strings.TrimSpace(path)
if path == "" {
return "", errors.New("path is required")
}
if offset < 0 {
offset = 0
}
if delBytes < 0 {
delBytes = 0
}
var data []byte
if fileExists(path) {
var err error
data, err = os.ReadFile(path)
if err != nil {
return "", err
}
} else {
dir := filepath.Dir(path)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
return "", err
}
}
}
if offset > len(data) {
padding := make([]byte, offset-len(data))
data = append(data, padding...)
}
prefix := data[:offset]
var suffix []byte
endDel := offset + delBytes
if endDel < len(data) {
suffix = data[endDel:]
}
contentBytes := []byte(content)
newData := make([]byte, 0, len(prefix)+len(contentBytes)+len(suffix))
newData = append(newData, prefix...)
newData = append(newData, contentBytes...)
newData = append(newData, suffix...)
if err := os.WriteFile(path, newData, 0644); err != nil {
return "", err
}
return fmt.Sprintf("Successfully wrote %d bytes to %s", len(contentBytes), path), nil
}
func AL(ctx context.Context, cfg *Cfg, msgs []Message) ([]Message, Usage, error) {
done := false
var turnUsage Usage
for i := 0; i < cfg.MaxALIterations && !done; i++ {
@@ -1184,25 +1221,48 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message, sp string, depth int) ([]
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 {
subMsgs := []Message{
{Role: "system", Content: strp(sp + "\n\nImportant: this is a child agent")},
{Role: "user", Content: strp(pr)},
case "write_file":
path, _ := a["path"].(string)
path = filterText(path)
contentVal, hasContent := a["content"]
var content string
if hasContent && contentVal != nil {
if s, ok := contentVal.(string); ok {
content = s
}
if subr, subu, err := AL(ctx, cfg, subMsgs, sp, depth+1); err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() != nil {
return msgs, turnUsage, err
}
res, sty = "[subagent error: "+err.Error()+"]", 31
}
offset := 0
if v, ok := a["offset"]; ok {
switch n := v.(type) {
case float64:
offset = int(n)
case int:
offset = n
case string:
offset = atoiD(n, 0)
}
}
delBytes := 0
if v, ok := a["del_bytes"]; ok {
switch n := v.(type) {
case float64:
delBytes = int(n)
case int:
delBytes = n
case string:
delBytes = atoiD(n, 0)
}
}
if strings.TrimSpace(path) == "" {
res, sty = "[tool error: write_file requires 'path' parameter]", 31
} else if !hasContent {
res, sty = "[tool error: write_file requires 'content' parameter]", 31
} else {
out, err := writeFile(path, offset, delBytes, content)
if err != nil {
res, sty = fmt.Sprintf("[tool error: write_file %s: %v]", path, err), 31
} else {
turnUsage.CompletionTokens += subu.CompletionTokens
turnUsage.TotalTokens += subu.TotalTokens
res, sty = filterText(last(subr)), 2
res, sty = out, 2
}
}
default:
@@ -1594,7 +1654,7 @@ func doCompact(cfg *Cfg, msgs []Message) []Message {
}
func main() {
sp := prompt(".bantamsys.txt")
sp := defaultSystemPrompt
cfg := getCfg(configPath())
cfg.ContextWindow = fetchContextWindow(&cfg)
if td := toolsDir(&cfg); td != "" {
@@ -1618,11 +1678,11 @@ func main() {
msgs = append(msgs, Message{Role: "user", Content: strp(u)})
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
var usg Usage
msgs, usg, err = AL(sigCtx, &cfg, msgs, sp, 0)
msgs, usg, err = AL(sigCtx, &cfg, msgs)
if lastRole(msgs) == "tool" {
msgs = append(msgs, Message{Role: "user", Content: strp("continue")})
fmt.Println(c("[auto continue: last message was a tool result]", 33))
msgs, usg, err = AL(sigCtx, &cfg, msgs, sp, 0)
msgs, usg, err = AL(sigCtx, &cfg, msgs)
}
interrupted := sigCtx.Err() != nil
cancel()
@@ -1739,11 +1799,11 @@ func main() {
turnMsgs := append([]Message{}, msgs...)
turnMsgs = append(turnMsgs, Message{Role: "user", Content: strp(u)})
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
resMsgs, usg, err := AL(sigCtx, &cfg, turnMsgs, sp, 0)
resMsgs, usg, err := AL(sigCtx, &cfg, turnMsgs)
if lastRole(resMsgs) == "tool" {
resMsgs = append(resMsgs, Message{Role: "user", Content: strp("continue")})
fmt.Println(c("[auto continue: last message was a tool result]", 33))
resMsgs, usg, err = AL(sigCtx, &cfg, resMsgs, sp, 0)
resMsgs, usg, err = AL(sigCtx, &cfg, resMsgs)
}
interrupted := sigCtx.Err() != nil
cancel()
+113 -116
View File
@@ -6,10 +6,12 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
@@ -183,7 +185,7 @@ func TestParseStreamToolCallSplitAcrossChunks(t *testing.T) {
func TestParseStreamMultipleToolCallsKeepFirstAppearanceOrder(t *testing.T) {
sseData := strings.Join([]string{
`data: {"choices":[{"delta":{"tool_calls":[{"index":1,"id":"c2","function":{"name":"run_subagent","arguments":"{\"prompt\":\"p\"}"}}]}}]}`,
`data: {"choices":[{"delta":{"tool_calls":[{"index":1,"id":"c2","function":{"name":"write_file","arguments":"{\"path\":\"a.txt\",\"content\":\"hello\"}"}}]}}]}`,
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"shell_exec","arguments":"{\"command\":\"ls\"}"}}]}}]}`,
`data: [DONE]`,
}, "\n")
@@ -240,24 +242,21 @@ func TestParseStreamReasoningAfterContent(t *testing.T) {
// ---------- prompt / getCfg / atoiD ----------
func TestDefaultSystemPromptFallback(t *testing.T) {
p := prompt("non_existent_file.txt")
if !strings.Contains(p, "You are Bantam, a tiny, powerful AI agent.") {
t.Errorf("expected prompt to contain base description, got: %q", p)
func TestDefaultSystemPrompt(t *testing.T) {
if !strings.Contains(defaultSystemPrompt, "You are Bantam, a tiny, powerful AI agent.") {
t.Errorf("expected prompt to contain base description, got: %q", defaultSystemPrompt)
}
if !strings.Contains(p, "shell_exec") || !strings.Contains(p, "run_subagent") {
t.Errorf("expected prompt to list shell_exec and run_subagent tools, got: %q", p)
if !strings.Contains(defaultSystemPrompt, "shell_exec") || !strings.Contains(defaultSystemPrompt, "write_file") {
t.Errorf("expected prompt to list shell_exec and write_file tools, got: %q", defaultSystemPrompt)
}
}
func TestPromptReadsAndTrimsFile(t *testing.T) {
p := filepath.Join(t.TempDir(), "sys.txt")
if err := os.WriteFile(p, []byte(" hello\nworld \n"), 0644); err != nil {
t.Fatalf("write: %v", err)
if strings.Contains(defaultSystemPrompt, "run_subagent") {
t.Errorf("prompt should not mention run_subagent: %q", defaultSystemPrompt)
}
got := prompt(p)
if got != "hello\nworld" {
t.Errorf("expected trimmed content, got %q", got)
if !strings.Contains(defaultSystemPrompt, "AGENTS.md") {
t.Errorf("expected prompt to mention AGENTS.md, got: %q", defaultSystemPrompt)
}
if strings.Contains(defaultSystemPrompt, "GEMINI.md") || strings.Contains(defaultSystemPrompt, "CLAUDE.md") {
t.Errorf("prompt should not mention GEMINI.md or CLAUDE.md: %q", defaultSystemPrompt)
}
}
@@ -1100,7 +1099,7 @@ func TestALToolLoop(t *testing.T) {
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("run")}}, "sys", 0)
msgs, _, err := AL(context.Background(), &cfg, []Message{{Role: "system", Content: strp("sys")}, {Role: "user", Content: strp("run")}})
if err != nil {
t.Fatalf("AL: %v", err)
}
@@ -1149,7 +1148,7 @@ func TestALReasoningOnlyAutoContinue(t *testing.T) {
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)
msgs, _, err := AL(context.Background(), &cfg, []Message{{Role: "system", Content: strp("sys")}, {Role: "user", Content: strp("start")}})
if err != nil {
t.Fatalf("AL: %v", err)
}
@@ -1167,37 +1166,92 @@ func TestALReasoningOnlyAutoContinue(t *testing.T) {
}
}
func TestALRunSubagent(t *testing.T) {
var n int
var mu sync.Mutex
func TestWriteFile(t *testing.T) {
tmp := t.TempDir()
target := filepath.Join(tmp, "sub", "dir", "test.txt")
// 1. Create file and write initial content
res, err := writeFile(target, 0, 0, "Hello World")
if err != nil {
t.Fatalf("writeFile create: %v", err)
}
if !strings.Contains(res, "Successfully wrote 11 bytes") {
t.Errorf("unexpected res: %q", res)
}
data, err := os.ReadFile(target)
if err != nil || string(data) != "Hello World" {
t.Fatalf("read = %q, want Hello World", string(data))
}
// 2. Overwrite / replace "World" with "Bantam" (offset 6, del_bytes 5)
res, err = writeFile(target, 6, 5, "Bantam")
if err != nil {
t.Fatalf("writeFile replace: %v", err)
}
data, _ = os.ReadFile(target)
if string(data) != "Hello Bantam" {
t.Fatalf("read = %q, want Hello Bantam", string(data))
}
// 3. Insert without deletion (offset 5, del_bytes 0, content " dear")
res, err = writeFile(target, 5, 0, " dear")
if err != nil {
t.Fatalf("writeFile insert: %v", err)
}
data, _ = os.ReadFile(target)
if string(data) != "Hello dear Bantam" {
t.Fatalf("read = %q, want Hello dear Bantam", string(data))
}
// 4. Pure deletion (offset 5, del_bytes 5, content "")
res, err = writeFile(target, 5, 5, "")
if err != nil {
t.Fatalf("writeFile delete: %v", err)
}
data, _ = os.ReadFile(target)
if string(data) != "Hello Bantam" {
t.Fatalf("read = %q, want Hello Bantam", string(data))
}
// 5. Offset beyond file length -> padded with null bytes
res, err = writeFile(target, 15, 0, "end")
if err != nil {
t.Fatalf("writeFile beyond len: %v", err)
}
data, _ = os.ReadFile(target)
if len(data) != 18 || !strings.HasSuffix(string(data), "end") {
t.Fatalf("read length = %d, want 18", len(data))
}
// 6. Error on empty path
_, err = writeFile("", 0, 0, "abc")
if err == nil {
t.Fatalf("expected error for empty path")
}
}
func TestALWriteFile(t *testing.T) {
tmp := t.TempDir()
target := filepath.Join(tmp, "out.txt")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
n++
cur := n
mu.Unlock()
var req struct {
Messages []Message `json:"messages"`
}
json.NewDecoder(r.Body).Decode(&req)
switch cur {
case 1: // parent's first call -> delegate to subagent
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"run_subagent","arguments":"{\"prompt\":\"inner task\"}"}}]}}]}`))
case 2: // child's call -> verify it got the child system prompt, then finish
isChild := false
for _, m := range req.Messages {
if m.Role == "system" && m.Content != nil && strings.Contains(*m.Content, "Important: this is a child agent") {
isChild = true
}
for _, m := range req.Messages {
if m.Role == "tool" {
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"file is ready"}}]}`))
return
}
if !isChild {
t.Errorf("request #2 did not carry the child system prompt")
}
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"child done"}}]}`))
case 3: // parent's second call -> finish
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"parent done"}}]}`))
default:
t.Errorf("unexpected request #%d", cur)
}
args, _ := json.Marshal(map[string]any{
"path": target,
"offset": 0,
"del_bytes": 0,
"content": "sample file content",
})
w.Write([]byte(fmt.Sprintf(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"write_file","arguments":%s}}]}}]}`, strconv.Quote(string(args)))))
}))
defer srv.Close()
@@ -1205,67 +1259,16 @@ func TestALRunSubagent(t *testing.T) {
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("parent task")}}, "sys", 0)
msgs, _, err := AL(context.Background(), &cfg, []Message{{Role: "system", Content: strp("sys")}, {Role: "user", Content: strp("write a file")}})
if err != nil {
t.Fatalf("AL: %v", err)
}
if got := last(msgs); got != "parent done" {
t.Errorf("last = %q, want parent done", got)
if got := last(msgs); got != "file is ready" {
t.Errorf("last = %q, want 'file is ready'", got)
}
found := false
for _, m := range msgs {
if m.Role == "tool" && m.Content != nil && strings.Contains(*m.Content, "child done") {
found = true
}
}
if !found {
t.Errorf("expected subagent result 'child done' in tool messages")
}
}
func TestALSubagentDepthLimit(t *testing.T) {
// depth >= MAX_DEPTH must not spawn a child request; the LLM gets the
// depth-limit tool result and is expected to acknowledge and finish.
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\":\"deep\"}"}}]}}]}`))
case 2:
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"done"}}]}`))
default:
t.Errorf("unexpected request #%d (child must not be spawned)", cur)
}
}))
defer srv.Close()
cfg := defCfg
cfg.Endpoint = srv.URL
cfg.Stream = false
cfg.APIKey = "-"
msgs, _, err := AL(context.Background(), &cfg, []Message{{Role: "user", Content: strp("go")}}, "sys", MAX_DEPTH)
if err != nil {
t.Fatalf("AL: %v", err)
}
if n != 2 {
t.Errorf("expected exactly 2 LLM requests, got %d", n)
}
if got := last(msgs); got != "done" {
t.Errorf("last = %q, want done", got)
}
found := false
for _, m := range msgs {
if m.Role == "tool" && m.Content != nil && strings.Contains(*m.Content, "depth limit") {
found = true
}
}
if !found {
t.Errorf("expected depth-limit tool result")
content, err := os.ReadFile(target)
if err != nil || string(content) != "sample file content" {
t.Errorf("file content = %q, want 'sample file content'", string(content))
}
}
@@ -1295,7 +1298,7 @@ func TestALStripsInvalidAssistantAndRetries(t *testing.T) {
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("go")}}, "sys", 0)
msgs, _, err := AL(context.Background(), &cfg, []Message{{Role: "system", Content: strp("sys")}, {Role: "user", Content: strp("go")}})
if err != nil {
t.Fatalf("AL: %v", err)
}
@@ -1714,7 +1717,7 @@ func TestALContextCancellation(t *testing.T) {
origMsgs := []Message{{Role: "system", Content: strp("sys")}, {Role: "user", Content: strp("hello")}}
inputMsgs := append([]Message{}, origMsgs...)
msgs, _, err := AL(ctx, &cfg, inputMsgs, "sys", 0)
msgs, _, err := AL(ctx, &cfg, inputMsgs)
if err == nil {
t.Fatalf("expected context cancellation error, got nil")
}
@@ -2048,8 +2051,8 @@ func TestSanitizeMessagesWithInvisibles(t *testing.T) {
// ---------- new coverage from review ----------
// #14: subagent token usage must be accumulated into the parent turn usage.
func TestALRunSubagentAccumulatesUsage(t *testing.T) {
// #14: tool call loop token usage must be accumulated into turn usage.
func TestALToolLoopAccumulatesUsage(t *testing.T) {
var n int
var mu sync.Mutex
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -2059,11 +2062,9 @@ func TestALRunSubagentAccumulatesUsage(t *testing.T) {
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}}}`))
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"shell_exec","arguments":"{\"command\":\"echo 1\"}"}}]}}],"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}}}`))
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"done"}}],"usage":{"prompt_tokens":30,"completion_tokens":4,"total_tokens":34,"prompt_tokens_details":{"cached_tokens":15}}}`))
default:
t.Errorf("unexpected request #%d", cur)
}
@@ -2074,22 +2075,18 @@ func TestALRunSubagentAccumulatesUsage(t *testing.T) {
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)
_, usg, err := AL(context.Background(), &cfg, []Message{{Role: "system", Content: strp("sys")}, {Role: "user", Content: strp("parent task")}})
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.CompletionTokens != 9 {
t.Errorf("CompletionTokens = %d, want 9", usg.CompletionTokens)
}
if usg.TotalTokens != 72 {
t.Errorf("TotalTokens = %d, want 72", usg.TotalTokens)
if usg.TotalTokens != 49 {
t.Errorf("TotalTokens = %d, want 49", usg.TotalTokens)
}
if usg.Cached() != 15 {
t.Errorf("CachedTokens = %d, want 15", usg.Cached())
+24 -17
View File
@@ -4,14 +4,14 @@
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 parentheses 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- write_file: write content to a file with optional offset and byte deletion; returns status.\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. 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 parentheses 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 contents in the project.";
my $SDIR = ($ENV{HOME} || $ENV{USERPROFILE} || '.') . '/.bantam/sessions';
sub cfg_file { -f '.bantam.cfg' ? '.bantam.cfg' : 'model.cfg' }
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>) { /^([^\s=]+)\s*=\s*(.+)$/ and $d{$1} = $2; } }
if (open my $f, '<:encoding(UTF-8)', cfg_file()) { 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)', '.bantamsys.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 T { my ($n, $d, $p, $r) = @_; {type=>'function', function=>{name=>$n, description=>$d, parameters=>{type=>'object', properties=>$p, required=>$r || [keys %$p]}}} }
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} // '{}') };
@@ -22,7 +22,7 @@ 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});
my %p = (messages=>$msgs, tools=>[T('shell_exec', 'Run a shell command, return output and exit code.', {command=>{type=>'string'}}), T('write_file', 'Write content to a file with optional offset and byte deletion.', {path=>{type=>'string'}, offset=>{type=>'integer'}, del_bytes=>{type=>'integer'}, content=>{type=>'string'}}, ['path', 'content'])], 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 $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)});
@@ -35,8 +35,19 @@ 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 = 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) = ($_[0], $_[1], $_[2], $_[3] || 0);
sub write_file { my ($p, $off, $del, $cnt) = ($_[0], $_[1] || 0, $_[2] || 0, $_[3] // '');
return "[write_file error: path required]" unless defined $p && length $p;
$off = 0 if $off < 0; $del = 0 if $del < 0;
my $dir = $p =~ m{^(.*)/[^/]+$} ? $1 : ''; make_path($dir) if length($dir) && !-d $dir;
my $data = '';
if (-f $p) { open my $fh, '<:raw', $p or return "[write_file error: cannot read $p: $!]"; local $/; $data = <$fh> // ''; close $fh; }
my $len = length($data); $data .= "\0" x ($off - $len) if $off > $len;
my $pfx = substr($data, 0, $off);
my $sfx = ($off + $del < length($data)) ? substr($data, $off + $del) : '';
open my $wfh, '>:raw', $p or return "[write_file error: cannot write $p: $!]";
print $wfh ($pfx . $cnt . $sfx); close $wfh;
"Successfully wrote " . length($cnt) . " bytes to $p" }
sub AL { my ($c, $msgs) = ($_[0], $_[1]);
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)) {
@@ -53,7 +64,7 @@ sub AL { my ($c, $msgs, $sp, $depth) = ($_[0], $_[1], $_[2], $_[3] || 0);
my $a = eval { decode_json($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]' : 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))); }
elsif ($fn eq 'write_file') { $res = write_file($a->{path}, $a->{offset}, $a->{del_bytes}, $a->{content}); }
else { $res = "unknown tool: $fn"; }
$res = filter_text($res);
print "[tool] $fn: $res\n";
@@ -63,18 +74,14 @@ sub AL { my ($c, $msgs, $sp, $depth) = ($_[0], $_[1], $_[2], $_[3] || 0);
$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 sdir { make_path($SDIR) unless -d $SDIR; $SDIR }
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 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, @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 set_cfg { my ($k, $v, @ls, $f) = @_; if (open my $fh, '<:encoding(UTF-8)', '.bantam.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)', '.bantam.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]: $!"; local $/; push @$msgs, {role=>'user', content=><$f>}; AL($c, $msgs, $sp); autosave($msgs); return; }
my ($c, $sp) = (cfg(), $DEF_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); 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;
@@ -85,7 +92,7 @@ sub main {
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"; }
else { push @$msgs, {role=>'user', content=>$u}; AL($c, $msgs, $sp); autosave($msgs); }
else { push @$msgs, {role=>'user', content=>$u}; AL($c, $msgs); autosave($msgs); }
}
autosave($msgs);
}