many improvements
This commit is contained in:
@@ -31,7 +31,7 @@ go build ./... # produces ./bantam
|
||||
go run . prompt.txt
|
||||
```
|
||||
|
||||
The Go port is a single `main.go` plus four platform files (`term_linux.go`, `term_darwin.go`, `term_windows.go`, `term_other.go`) for the built-in raw-terminal line editor — zero external dependencies, same as the Python and Perl versions.
|
||||
The Go port is a single `main.go` plus four platform files (`term_linux.go`, `term_bsd.go`, `term_windows.go`, `term_other.go`) for the built-in raw-terminal line editor — zero external dependencies, same as the Python and Perl versions.
|
||||
|
||||
### Running Bantam
|
||||
|
||||
@@ -60,6 +60,7 @@ All implementations read the same `model.cfg` and `system.txt` from the current
|
||||
- `/list` — list saved sessions (newest first) with their ids, timestamps, message counts and summaries
|
||||
- `/load <id>` — load a saved session (exact id or unique prefix) and continue from there
|
||||
- `/compact` — summarize the conversation with the LLM and compact the context down to just the system message plus the summary
|
||||
- `/cfg <param> [val]` — inspect or update a configuration parameter in `model.cfg` live
|
||||
- `/help` — show all supported commands
|
||||
- `/clear` — reset the conversation to just the system prompt
|
||||
- `/quit` — exit
|
||||
@@ -94,12 +95,12 @@ 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).
|
||||
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, append to `messages` (`role: "user"`), run `AL(cfg, messages)`, 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, replace `messages` with `[system, summary-user-message]`, 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, 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 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)`, and go to step 5.
|
||||
|
||||
### Agentic loop (`AL(cfg, messages)`) function
|
||||
|
||||
1. Call OpenAI-compatible Completions API (`POST {endpoint}/chat/completions`) using parameters from `cfg` (`model`, `temperature`, optional `api_key` bearer header).
|
||||
1. Call OpenAI-compatible Completions API (`POST {endpoint}/chat/completions`) forwarding relevant parameters from `cfg` (`model`, `temperature`, `stream`, `reasoning_effort`, etc., excluding internal agent configs like `endpoint`, `api_key`, `timeout`, `shell_timeout`, `max_al_iterations`, `color`) and optional `api_key` bearer header.
|
||||
- Set custom `User-Agent` header (`Mozilla/5.0 (compatible; Bantam/1.0)`) to avoid gateway 403 blocks.
|
||||
- Retry network/HTTP errors with Fibonacci backoff delays (`1s, 1s, 2s, 3s, 5s, 8s, 13s, 21s, 34s`).
|
||||
- If `stream=true`, parse SSE stream (`data: {...}`) for real-time reasoning and text output, bracketing reasoning with `--- reasoning start ---` / `--- reasoning end ---` markers.
|
||||
@@ -187,7 +188,7 @@ MicroBantam (`mb`) is a compressed Perl 5 reference implementation of the same a
|
||||
## Repository layout
|
||||
|
||||
- `bantam.py` — Python reference implementation (stdlib only)
|
||||
- `main.go`, `term_linux.go`, `term_darwin.go`, `term_windows.go`, `term_other.go` — Go implementation (stdlib only, module `code.luxferre.top/luxferre/bantam`)
|
||||
- `main.go`, `term_linux.go`, `term_bsd.go`, `term_windows.go`, `term_other.go` — Go implementation (stdlib only, module `code.luxferre.top/luxferre/bantam`)
|
||||
- `bantam.pl` — Perl 5 implementation (core modules only)
|
||||
- `mb` — MicroBantam, compressed Perl 5 implementation (core modules only, under 100 SLOC)
|
||||
- `mb.tcl` — MicroBantam, Jim Tcl port (requires `jimsh` with the `json` and `ssl` extensions; under 100 SLOC)
|
||||
|
||||
@@ -55,6 +55,33 @@ sub get_cfg {
|
||||
return \%d;
|
||||
}
|
||||
|
||||
sub set_cfg {
|
||||
my ($path, $k, $v) = @_;
|
||||
$path //= 'model.cfg';
|
||||
my (@lines, $found);
|
||||
if (-f $path) {
|
||||
open my $fh, '<:encoding(UTF-8)', $path or return;
|
||||
while (my $ln = <$fh>) {
|
||||
my $s = trim($ln);
|
||||
if ($s !~ /^#/ && $s =~ /=/) {
|
||||
my ($pk) = split /=/, $s, 2;
|
||||
if (trim($pk) eq $k) {
|
||||
push @lines, "$k=$v\n";
|
||||
$found = 1;
|
||||
next;
|
||||
}
|
||||
}
|
||||
push @lines, $ln;
|
||||
}
|
||||
}
|
||||
if (!$found) {
|
||||
push @lines, "\n" if @lines && $lines[-1] !~ /\n$/;
|
||||
push @lines, "$k=$v\n";
|
||||
}
|
||||
open my $fh, '>:encoding(UTF-8)', $path or return;
|
||||
print $fh @lines;
|
||||
}
|
||||
|
||||
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.";
|
||||
|
||||
sub prompt {
|
||||
@@ -118,6 +145,13 @@ sub llm {
|
||||
$h{Authorization} = "Bearer $cfg->{api_key}" if $cfg->{api_key} && $cfg->{api_key} ne '-';
|
||||
my $st = ($cfg->{stream} // 'true') =~ /^(true|1|yes)$/i;
|
||||
my %p = (model => $cfg->{model}, temperature => 0 + ($cfg->{temperature} // 0.7), messages => $msgs, stream => $st ? \1 : \0);
|
||||
for my $k (keys %$cfg) {
|
||||
next if $k =~ /^(endpoint|api_key|timeout|shell_timeout|max_al_iterations|color)$/;
|
||||
my $val = eval { decode_json($cfg->{$k}) };
|
||||
$p{$k} = defined $val ? $val : $cfg->{$k};
|
||||
}
|
||||
$p{messages} = $msgs;
|
||||
$p{stream} = $st ? \1 : \0;
|
||||
$p{tools} = $tools if $tools && @$tools;
|
||||
my $body = encode_json(\%p);
|
||||
my $http = HTTP::Tiny->new(timeout => 0 + ($cfg->{timeout} // 300));
|
||||
@@ -411,10 +445,25 @@ sub main {
|
||||
print c("[compacted to " . scalar(@$msgs) . " messages]", 32), "\n";
|
||||
print c("--- summary ---", 33), "\n", c($sm, 2), "\n";
|
||||
next;
|
||||
} elsif ($u =~ /^\/cfg(?:\s+(\S+)(?:\s+(.+))?)?$/) {
|
||||
my ($k, $v) = ($1, $2);
|
||||
if (defined $v) {
|
||||
$v = trim($v);
|
||||
set_cfg('model.cfg', $k, $v);
|
||||
$cfg = get_cfg('model.cfg');
|
||||
$_col = col($cfg);
|
||||
print c("[config updated: $k=$v]", 32), "\n";
|
||||
} elsif (defined $k) {
|
||||
if (exists $cfg->{$k}) { print c("$k=$cfg->{$k}", 32), "\n"; }
|
||||
else { print c("$k not set", 31), "\n"; }
|
||||
} else {
|
||||
print c("Usage: /cfg <param> [val]", 31), "\n";
|
||||
}
|
||||
next;
|
||||
} elsif ($u eq '/help') {
|
||||
print c("Bantam commands:", 1, 36), "\n";
|
||||
my @cmds = (["/quit", "exit"], ["/clear", "reset to system prompt"], ["/save", "save session"], ["/list", "list sessions"], ["/load <id>", "load session"], ["/compact", "compact context"], ["/help", "show help"]);
|
||||
for my $kv (@cmds) { printf "%s%s\n", c(sprintf(" %-12s", $kv->[0]), 1, 32), $kv->[1]; }
|
||||
my @cmds = (["/quit", "exit"], ["/clear", "reset to system prompt"], ["/save", "save session"], ["/list", "list sessions"], ["/load <id>", "load session"], ["/compact", "compact context"], ["/cfg <k> [v]", "get/set config"], ["/help", "show help"]);
|
||||
for my $kv (@cmds) { printf "%s%s\n", c(sprintf(" %-15s", $kv->[0]), 1, 32), $kv->[1]; }
|
||||
next;
|
||||
}
|
||||
push @$msgs, { role => 'user', content => $u };
|
||||
|
||||
@@ -35,6 +35,21 @@ def get_cfg(path="model.cfg"):
|
||||
if not d.get("api_key") and "OPENAI_API_KEY" in os.environ: d["api_key"] = os.environ["OPENAI_API_KEY"]
|
||||
return d
|
||||
|
||||
def set_cfg(path, k, v):
|
||||
lines, found = [], False
|
||||
if os.path.exists(path):
|
||||
for ln in open(path, encoding="utf-8"):
|
||||
s = ln.strip()
|
||||
if not s.startswith("#") and "=" in s and s.split("=", 1)[0].strip() == k:
|
||||
lines.append(f"{k}={v}\n")
|
||||
found = True
|
||||
else:
|
||||
lines.append(ln)
|
||||
if not found:
|
||||
if lines and not lines[-1].endswith("\n"): lines[-1] += "\n"
|
||||
lines.append(f"{k}={v}\n")
|
||||
with open(path, "w", encoding="utf-8") as f: f.writelines(lines)
|
||||
|
||||
def num(cfg, k, d):
|
||||
try: return type(d)(cfg.get(k, d))
|
||||
except (TypeError, ValueError): return d
|
||||
@@ -99,6 +114,11 @@ def llm(cfg, msgs, tools):
|
||||
if k and k != "-": h["Authorization"] = "Bearer " + k
|
||||
st = cfg.get("stream", "true").lower() in ("true", "1", "yes")
|
||||
p = {"model": cfg["model"], "temperature": float(cfg.get("temperature", 0.7)), "messages": msgs, "stream": st}
|
||||
for k, v in cfg.items():
|
||||
if k in ("endpoint", "api_key", "timeout", "shell_timeout", "max_al_iterations", "color"): continue
|
||||
try: p[k] = json.loads(v)
|
||||
except Exception: p[k] = v
|
||||
p["messages"], p["stream"] = msgs, st
|
||||
if tools: p["tools"] = tools
|
||||
pend = c("...requesting...", 1, 2)
|
||||
for i, dly in enumerate(fib + [0]):
|
||||
@@ -331,10 +351,24 @@ def main():
|
||||
msgs = nm; autosave(msgs)
|
||||
print(c(f"[compacted to {len(msgs)} messages]", 32)); print(c("--- summary ---", 33) + "\n" + c(sm, 2))
|
||||
continue
|
||||
elif u.startswith("/cfg"):
|
||||
parts = u.split(None, 2)
|
||||
if len(parts) == 2:
|
||||
k = parts[1]
|
||||
if k in cfg: print(c(f"{k}={cfg[k]}", 32))
|
||||
else: print(c(f"{k} not set", 31))
|
||||
elif len(parts) >= 3:
|
||||
k, v = parts[1], parts[2]
|
||||
set_cfg("model.cfg", k, v)
|
||||
cfg = get_cfg("model.cfg")
|
||||
_COL = col(cfg)
|
||||
print(c(f"[config updated: {k}={v}]", 32))
|
||||
else: print(c("Usage: /cfg <param> [val]", 31))
|
||||
continue
|
||||
elif u == "/help":
|
||||
print(c("Bantam commands:", 1, 36))
|
||||
for k, v in [("/quit", "exit"), ("/clear", "reset to system prompt"), ("/save", "save session"), ("/list", "list sessions"), ("/load <id>", "load session"), ("/compact", "compact context"), ("/help", "show help")]:
|
||||
print(c(f" {k:<12}", 1, 32) + v)
|
||||
for k, v in [("/quit", "exit"), ("/clear", "reset to system prompt"), ("/save", "save session"), ("/list", "list sessions"), ("/load <id>", "load session"), ("/compact", "compact context"), ("/cfg <k> [v]", "get/set config"), ("/help", "show help")]:
|
||||
print(c(f" {k:<15}", 1, 32) + v)
|
||||
continue
|
||||
msgs.append({"role": "user", "content": u})
|
||||
AL(cfg, msgs, sp); autosave(msgs)
|
||||
|
||||
@@ -42,9 +42,10 @@ type Cfg struct {
|
||||
MaxALIterations int
|
||||
Stream bool
|
||||
Color string
|
||||
Raw map[string]string
|
||||
}
|
||||
|
||||
var defCfg = Cfg{"https://opencode.ai/zen/v1", "big-pickle", "-", 0.7, 300, 120, 1000, true, "auto"}
|
||||
var defCfg = Cfg{"https://opencode.ai/zen/v1", "big-pickle", "-", 0.7, 300, 120, 1000, true, "auto", nil}
|
||||
|
||||
func atoiD(s string, d int) int {
|
||||
if v, e := strconv.Atoi(strings.TrimSpace(s)); e == nil {
|
||||
@@ -55,12 +56,19 @@ func atoiD(s string, d int) int {
|
||||
|
||||
func getCfg(path string) Cfg {
|
||||
cfg := defCfg
|
||||
cfg.Raw = map[string]string{
|
||||
"endpoint": cfg.Endpoint, "model": cfg.Model, "temperature": fmt.Sprintf("%v", cfg.Temperature),
|
||||
"api_key": cfg.APIKey, "stream": strconv.FormatBool(cfg.Stream), "color": cfg.Color,
|
||||
"timeout": strconv.Itoa(cfg.Timeout), "shell_timeout": strconv.Itoa(cfg.ShellTimeout),
|
||||
"max_al_iterations": strconv.Itoa(cfg.MaxALIterations),
|
||||
}
|
||||
if d, err := os.ReadFile(path); err == nil {
|
||||
for _, ln := range strings.Split(string(d), "\n") {
|
||||
ln = strings.TrimSpace(ln)
|
||||
if ln == "" || ln[0] == '#' || !strings.Contains(ln, "=") { continue }
|
||||
k, v, _ := strings.Cut(ln, "=")
|
||||
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
||||
cfg.Raw[k] = v
|
||||
switch k {
|
||||
case "endpoint": cfg.Endpoint = v
|
||||
case "model": cfg.Model = v
|
||||
@@ -74,10 +82,41 @@ func getCfg(path string) Cfg {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cfg.APIKey == "" || cfg.APIKey == "-") && os.Getenv("OPENAI_API_KEY") != "" { cfg.APIKey = os.Getenv("OPENAI_API_KEY") }
|
||||
if (cfg.APIKey == "" || cfg.APIKey == "-") && os.Getenv("OPENAI_API_KEY") != "" {
|
||||
cfg.APIKey = os.Getenv("OPENAI_API_KEY")
|
||||
cfg.Raw["api_key"] = cfg.APIKey
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func setCfg(path, key, val string) error {
|
||||
var lines []string
|
||||
found := false
|
||||
if d, err := os.ReadFile(path); err == nil {
|
||||
for _, ln := range strings.Split(string(d), "\n") {
|
||||
trimmed := strings.TrimSpace(ln)
|
||||
if !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "=") {
|
||||
k, _, _ := strings.Cut(trimmed, "=")
|
||||
if strings.TrimSpace(k) == key {
|
||||
lines = append(lines, key+"="+val)
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
lines = append(lines, ln)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||
lines[len(lines)-1] = key + "=" + val
|
||||
lines = append(lines, "")
|
||||
} else {
|
||||
lines = append(lines, key+"="+val)
|
||||
}
|
||||
}
|
||||
return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0644)
|
||||
}
|
||||
|
||||
const defaultSystemPrompt = `You are Bantam, a tiny, powerful AI agent. Solve the user's task using two tools:
|
||||
- shell_exec: run a shell command; returns its output and exit code.
|
||||
- run_subagent: delegate a sub-task to a child agent; returns its reply.
|
||||
@@ -182,6 +221,19 @@ func llm(cfg *Cfg, msgs []Message, tools []map[string]any) (Message, error) {
|
||||
sanitizeMessages(msgs)
|
||||
p := map[string]any{"model": cfg.Model, "temperature": cfg.Temperature, "messages": msgs, "stream": cfg.Stream}
|
||||
if tools != nil { p["tools"] = tools }
|
||||
for k, v := range cfg.Raw {
|
||||
switch k {
|
||||
case "endpoint", "api_key", "timeout", "shell_timeout", "max_al_iterations", "color":
|
||||
continue
|
||||
default:
|
||||
var jv any
|
||||
if err := json.Unmarshal([]byte(v), &jv); err == nil {
|
||||
p[k] = jv
|
||||
} else {
|
||||
p[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
body, _ := json.Marshal(p)
|
||||
client := &http.Client{Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{Timeout: time.Duration(cfg.Timeout) * time.Second}).DialContext,
|
||||
@@ -669,6 +721,19 @@ func readLine(prompt string) (string, bool) {
|
||||
}
|
||||
switch rn {
|
||||
case '\r':
|
||||
if stdin.Buffered() > 0 {
|
||||
if b, _ := stdin.Peek(1); len(b) > 0 && b[0] == '\n' {
|
||||
stdin.ReadByte()
|
||||
}
|
||||
}
|
||||
W := termWidth()
|
||||
s := string(e.buf)
|
||||
P := visibleLen(e.prompt)
|
||||
er, _ := textPos(P, W, s, len([]rune(s)))
|
||||
pr, _ := textPos(P, W, s, e.pos)
|
||||
if down := er - pr; down > 0 {
|
||||
fmt.Printf("\033[%dB", down)
|
||||
}
|
||||
fmt.Print("\r\n")
|
||||
return string(e.buf), true
|
||||
case '\n':
|
||||
@@ -804,10 +869,32 @@ func main() {
|
||||
fmt.Println(c(fmt.Sprintf("[compacted to %d messages]", len(msgs)), 32))
|
||||
fmt.Println(c("--- summary ---", 33) + "\n" + c(sm, 2))
|
||||
continue
|
||||
case strings.HasPrefix(u, "/cfg"):
|
||||
parts := strings.SplitN(u, " ", 3)
|
||||
if len(parts) == 2 {
|
||||
k := strings.TrimSpace(parts[1])
|
||||
if v, ok := cfg.Raw[k]; ok {
|
||||
fmt.Println(c(k+"="+v, 32))
|
||||
} else {
|
||||
fmt.Println(c(k+" not set", 31))
|
||||
}
|
||||
} else if len(parts) >= 3 {
|
||||
k, v := strings.TrimSpace(parts[1]), strings.TrimSpace(parts[2])
|
||||
if err := setCfg("model.cfg", k, v); err != nil {
|
||||
fmt.Println(c("[cfg error: "+err.Error()+"]", 31))
|
||||
continue
|
||||
}
|
||||
cfg = getCfg("model.cfg")
|
||||
COL = col(cfg)
|
||||
fmt.Println(c(fmt.Sprintf("[config updated: %s=%s]", k, v), 32))
|
||||
} else {
|
||||
fmt.Println(c("Usage: /cfg <param> [val]", 31))
|
||||
}
|
||||
continue
|
||||
case u == "/help":
|
||||
fmt.Println(c("Bantam commands:", 1, 36))
|
||||
for _, kv := range [][2]string{{"/quit", "exit"}, {"/clear", "reset to system prompt"}, {"/save", "save session"}, {"/list", "list sessions"}, {"/load <id>", "load session"}, {"/compact", "compact context"}, {"/help", "show help"}} {
|
||||
fmt.Println(c(fmt.Sprintf(" %-12s", kv[0]), 1, 32) + kv[1])
|
||||
for _, kv := range [][2]string{{"/quit", "exit"}, {"/clear", "reset to system prompt"}, {"/save", "save session"}, {"/list", "list sessions"}, {"/load <id>", "load session"}, {"/compact", "compact context"}, {"/cfg <k> [v]", "get/set config"}, {"/help", "show help"}} {
|
||||
fmt.Println(c(fmt.Sprintf(" %-15s", kv[0]), 1, 32) + kv[1])
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1290,3 +1290,86 @@ func TestCompactHappyPath(t *testing.T) {
|
||||
t.Errorf("continuation message = %+v", msgs[1])
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- setCfg and LLM parameter forwarding ----------
|
||||
|
||||
func TestSetCfgUpdatesAndAppends(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "model.cfg")
|
||||
if err := os.WriteFile(p, []byte("model=old-model\ntemperature=0.5\n"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
if err := setCfg(p, "model", "new-model"); err != nil {
|
||||
t.Fatalf("setCfg update: %v", err)
|
||||
}
|
||||
if err := setCfg(p, "reasoning_effort", "high"); err != nil {
|
||||
t.Fatalf("setCfg append: %v", err)
|
||||
}
|
||||
|
||||
cfg := getCfg(p)
|
||||
if cfg.Model != "new-model" {
|
||||
t.Errorf("Model = %q, want new-model", cfg.Model)
|
||||
}
|
||||
if cfg.Raw["reasoning_effort"] != "high" {
|
||||
t.Errorf("Raw[reasoning_effort] = %q, want high", cfg.Raw["reasoning_effort"])
|
||||
}
|
||||
if cfg.Temperature != 0.5 {
|
||||
t.Errorf("Temperature = %v, want 0.5", cfg.Temperature)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMForwardsRelevantParameters(t *testing.T) {
|
||||
var received map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewDecoder(r.Body).Decode(&received)
|
||||
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"ok"}}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfgFile := writeCfg(t, strings.Join([]string{
|
||||
"endpoint=" + srv.URL,
|
||||
"model=custom-llm",
|
||||
"temperature=0.3",
|
||||
"stream=false",
|
||||
"reasoning_effort=medium",
|
||||
"top_p=0.95",
|
||||
"max_tokens=4096",
|
||||
"color=always",
|
||||
"timeout=100",
|
||||
}, "\n"))
|
||||
|
||||
cfg := getCfg(cfgFile)
|
||||
_, err := llm(&cfg, []Message{{Role: "user", Content: strp("hi")}}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("llm: %v", err)
|
||||
}
|
||||
|
||||
if received["model"] != "custom-llm" {
|
||||
t.Errorf("model = %v, want custom-llm", received["model"])
|
||||
}
|
||||
if received["temperature"] != 0.3 {
|
||||
t.Errorf("temperature = %v, want 0.3", received["temperature"])
|
||||
}
|
||||
if received["stream"] != false {
|
||||
t.Errorf("stream = %v, want false", received["stream"])
|
||||
}
|
||||
if received["reasoning_effort"] != "medium" {
|
||||
t.Errorf("reasoning_effort = %v, want medium", received["reasoning_effort"])
|
||||
}
|
||||
if received["top_p"] != 0.95 {
|
||||
t.Errorf("top_p = %v, want 0.95", received["top_p"])
|
||||
}
|
||||
if received["max_tokens"] != float64(4096) {
|
||||
t.Errorf("max_tokens = %v, want 4096", received["max_tokens"])
|
||||
}
|
||||
if _, exists := received["color"]; exists {
|
||||
t.Errorf("color should not be forwarded to OpenAI endpoint")
|
||||
}
|
||||
if _, exists := received["timeout"]; exists {
|
||||
t.Errorf("timeout should not be forwarded to OpenAI endpoint")
|
||||
}
|
||||
if _, exists := received["endpoint"]; exists {
|
||||
t.Errorf("endpoint should not be forwarded to OpenAI endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,9 +32,9 @@ sub llm { my ($c, $msgs) = @_; # one non-streaming chat completion
|
||||
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 $body = encode_json({model=>$c->{model}, temperature=>0+$c->{temperature}, 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'}})]});
|
||||
my %p = (messages=>$msgs, tools=>[T('shell_exec', 'Run a shell command, return output and exit code.', {command=>{type=>'string'}}), T('run_subagent', 'Run a child agent with a prompt.', {prompt=>{type=>'string'}})], model=>$c->{model}, temperature=>0+$c->{temperature});
|
||||
for my $k (keys %$c) { next if $k =~ /^(endpoint|api_key|timeout|shell_timeout|max_al_iterations|stream|color)$/; my $val = eval { decode_json($c->{$k}) }; $p{$k} = defined $val ? $val : $c->{$k}; }
|
||||
my $body = encode_json(\%p);
|
||||
my $tty = -t STDOUT;
|
||||
print $tty ? "\r...requesting..." : "...requesting...\n";
|
||||
my $r = HTTP::Tiny->new(timeout=>0+$c->{timeout})->post("$ep/chat/completions", {headers=>$h, content=>$body});
|
||||
@@ -92,13 +92,17 @@ sub save { sdir(); my $id = strftime('%Y%m%d-%H%M%S', localtime); my $i = 0;
|
||||
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 list_sessions { map { [$_->{id}, scalar @{$_->{messages} // []}] } sessions() }
|
||||
sub set_cfg { my ($k, $v) = @_; my (@ls, $f);
|
||||
if (open my $fh, '<:encoding(UTF-8)', 'model.cfg') { while (<$fh>) { if (!/^#/ && /^(\w+)\s*=/ && $1 eq $k) { push @ls, "$k=$v\n"; $f = 1; } else { push @ls, $_; } } }
|
||||
push @ls, "$k=$v\n" unless $f;
|
||||
if (open my $fh, '>:encoding(UTF-8)', 'model.cfg') { print $fh @ls; close $fh; } }
|
||||
|
||||
sub main {
|
||||
my ($c, $sp) = (cfg(), sp());
|
||||
my $msgs = [{role=>'system', content=>$sp}];
|
||||
if (@ARGV) { open my $f, '<:encoding(UTF-8)', $ARGV[0] or die "cannot open $ARGV[0]: $!"; # file mode
|
||||
local $/; push @$msgs, {role=>'user', content=><$f>}; AL($c, $msgs, $sp); autosave($msgs); return; }
|
||||
print "MicroBantam ready ($c->{model}). Commands: /quit /clear /save /list /load <id> /help\n";
|
||||
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;
|
||||
@@ -107,7 +111,8 @@ sub main {
|
||||
elsif ($u eq '/save') { print "session saved: ", save($msgs), "\n"; }
|
||||
elsif ($u eq '/list') { print "$_->[0] [$_->[1] msgs]\n" for list_sessions(); }
|
||||
elsif ($u =~ /^\/load(?:\s+(\S+))?$/) { if (defined $1) { $msgs = eval { load($1) }; $@ ? print($@) : (autosave($msgs), print "loaded: $1\n"); } else { print "usage: /load <session id>\n"; } }
|
||||
elsif ($u eq '/help') { print "Commands: /quit /clear /save /list /load <id> /help\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); }
|
||||
}
|
||||
autosave($msgs);
|
||||
|
||||
@@ -19,7 +19,7 @@ proc http_request {m url hdrs body {t 300}} { set proto http; set host ""; set p
|
||||
proc clean_msg_for_api {m} { if {![is_dict $m]} { return "" }; set r [safe_get $m role]; if {$r eq ""} { return "" }; set c [safe_get $m content]; set res [dict create role $r]; if {$r eq "system" || $r eq "user"} { dict set res content $c } elseif {$r eq "assistant"} { dict set res content $c; set vtcs [list]; set raw_tcs [safe_get $m tool_calls]; if {[is_tool_call $raw_tcs]} { set raw_tcs [list $raw_tcs] }; foreach tc $raw_tcs { if {[is_tool_call $tc]} { set fn [dict get [dict get $tc function] name]; set a "\{\}"; if {[dict exists $tc function arguments]} { set a [dict get [dict get $tc function] arguments] }; set tcid [safe_get $tc id]; if {$tcid eq ""} { set tcid "call_0" }; set tp "function"; if {[dict exists $tc type]} { set tp [dict get $tc type] }; lappend vtcs [dict create id $tcid type $tp function [dict create name $fn arguments $a]] } }; if {[llength $vtcs]} { dict set res tool_calls $vtcs } } elseif {$r eq "tool"} { set tcid [safe_get $m tool_call_id]; if {$tcid eq ""} { set tcid "call_0" }; dict set res tool_call_id $tcid; dict set res content $c }; return $res }
|
||||
proc encode_json_msg {m} { if {![is_dict $m]} { return "\{\}" }; set parts [list]; dict for {k v} $m { if {$k eq "tool_calls"} { set raw_tcs $v; if {[is_tool_call $raw_tcs]} { set raw_tcs [list $raw_tcs] }; set tcs [list]; foreach tc $raw_tcs { if {[is_tool_call $tc]} { set tcp [list]; dict for {tck tcv} $tc { if {$tck eq "function" && [is_dict $tcv]} { set fnp [list]; dict for {fk fv} $tcv { lappend fnp "[jesc $fk]:[jesc $fv]" }; lappend tcp "[jesc $tck]:\{ [join $fnp ", "] \}" } else { lappend tcp "[jesc $tck]:[jesc $tcv]" } }; lappend tcs "\{ [join $tcp ", "] \}" } }; lappend parts "[jesc $k]:\[ [join $tcs ", "] \]" } else { lappend parts [expr {$v eq "null" ? "[jesc $k]:null" : "[jesc $k]:[jesc $v]"}] } }; return "\{ [join $parts ", "] \}" }
|
||||
proc sanitize_msgs {msgs_var} { upvar 1 $msgs_var msgs; set new [list]; foreach m $msgs { if {[is_dict $m] && [dict exists $m tool_calls] && [set tcs [dict get $m tool_calls]] ne "null" && $tcs ne ""} { if {[is_tool_call $tcs]} { set tcs [list $tcs] }; set ntcs [list]; foreach tc $tcs { if {[is_tool_call $tc]} { set raw [dict get [dict get $tc function] arguments]; if {[catch {json::decode $raw} p] || ![is_dict $p]} { dict set tc function arguments [encode_json_msg [dict create invalid_raw $raw]] } }; lappend ntcs $tc }; dict set m tool_calls $ntcs }; lappend new $m }; set msgs $new }
|
||||
proc encode_payload {c msgs} { set mjs [list]; foreach m $msgs { set cm [clean_msg_for_api $m]; if {$cm ne ""} { lappend mjs [encode_json_msg $cm] } }; set tools {[{"type":"function","function":{"name":"shell_exec","description":"Run a shell command, return output and exit code.","parameters":{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}}},{"type":"function","function":{"name":"run_subagent","description":"Run a child agent with a prompt.","parameters":{"type":"object","properties":{"prompt":{"type":"string"}},"required":["prompt"]}}}]}; return "\{ \"model\": [jesc [dict get $c model]], \"temperature\": [expr {[dict get $c temperature] + 0}], \"messages\": \[ [join $mjs ", "] \], \"tools\": $tools \}" }
|
||||
proc encode_payload {c msgs} { set mjs [list]; foreach m $msgs { set cm [clean_msg_for_api $m]; if {$cm ne ""} { lappend mjs [encode_json_msg $cm] } }; set tools {[{"type":"function","function":{"name":"shell_exec","description":"Run a shell command, return output and exit code.","parameters":{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}}},{"type":"function","function":{"name":"run_subagent","description":"Run a child agent with a prompt.","parameters":{"type":"object","properties":{"prompt":{"type":"string"}},"required":["prompt"]}}}]}; set extra [list]; dict for {k v} $c { if {$k in {endpoint api_key timeout shell_timeout max_al_iterations stream color}} continue; if {![catch {json::decode $v} jv]} { lappend extra "[jesc $k]:$v" } else { lappend extra "[jesc $k]:[jesc $v]" } }; set ex_str [expr {[llength $extra] ? ", [join $extra ", "]" : ""}]; return "\{ \"model\": [jesc [dict get $c model]], \"temperature\": [expr {[dict get $c temperature] + 0}], \"messages\": \[ [join $mjs ", "] \], \"tools\": $tools$ex_str \}" }
|
||||
|
||||
proc llm {c msgs_var} {
|
||||
upvar 1 $msgs_var msgs; sanitize_msgs msgs
|
||||
@@ -83,6 +83,7 @@ proc save {msgs} { set sd [sdir]; set base [clock format [clock seconds] -format
|
||||
proc load_session {want} { foreach s [sessions] { if {[safe_get $s id] eq $want} { return [dict get $s messages] } }; error "no session: $want" }
|
||||
proc autosave {msgs} { set sd [sdir]; set mjs [list]; foreach m $msgs { lappend mjs [encode_json_msg $m] }; if {![catch {open "$sd/autosave.json" w} f]} { puts $f "\{\n \"id\": \"autosave\",\n \"messages\": \[\n [join $mjs ",\n "]\n \]\n\}"; close $f } }
|
||||
proc list_sessions {} { set res [list]; foreach s [sessions] { lappend res [list [safe_get $s id] [llength [expr {[dict exists $s messages] ? [dict get $s messages] : {}}]]] }; return $res }
|
||||
proc set_cfg {k v} { set ls [list]; set f 0; if {[file exists model.cfg] && ![catch {open model.cfg r} fh]} { while {[gets $fh l] >= 0} { if {![regexp {^\s*#} $l] && [regexp {^(\w+)\s*=} $l -> pk] && $pk eq $k} { lappend ls "$k=$v"; set f 1 } else { lappend ls $l } }; close $fh }; if {!$f} { lappend ls "$k=$v" }; if {![catch {open model.cfg w} fh]} { puts $fh [join $ls "\n"]; close $fh } }
|
||||
|
||||
proc main {argv} {
|
||||
set c [cfg]; set sp_text [sp]; set msgs [list [dict create role system content $sp_text]]
|
||||
@@ -90,7 +91,7 @@ proc main {argv} {
|
||||
if {[catch {open [lindex $argv 0] r} f]} { puts stderr "cannot open [lindex $argv 0]: $f"; exit 1 }
|
||||
set content [read $f]; close $f; lappend msgs [dict create role user content $content]; AL $c msgs $sp_text 0; autosave $msgs; return
|
||||
}
|
||||
puts "MicroBantam ready ([dict get $c model]). Commands: /quit /clear /save /list /load <id> /help"
|
||||
puts "MicroBantam ready ([dict get $c model]). Commands: /quit /clear /save /list /load <id> /cfg <k> \[v\] /help"
|
||||
while {1} {
|
||||
puts -nonewline "> "; flush stdout; if {[gets stdin u] < 0} break
|
||||
if {[set u [string trim $u]] eq ""} continue
|
||||
@@ -99,7 +100,8 @@ proc main {argv} {
|
||||
elseif {$u eq "/save"} { puts "session saved: [save $msgs]" } \
|
||||
elseif {$u eq "/list"} { foreach item [list_sessions] { puts "[lindex $item 0] \[[lindex $item 1] msgs\]" } } \
|
||||
elseif {[regexp {^\/load(?:\s+(\S+))?$} $u -> want_id]} { if {$want_id eq ""} { puts "usage: /load <session id>" } elseif {[catch {load_session $want_id} loaded]} { puts $loaded } else { set msgs $loaded; autosave $msgs; puts "loaded: $want_id" } } \
|
||||
elseif {$u eq "/help"} { puts "Commands: /quit /clear /save /list /load <id> /help" } \
|
||||
elseif {[regexp {^\/cfg(?:\s+(\S+)(?:\s+(.+))?)?$} $u -> ck cv]} { if {$cv ne ""} { set_cfg $ck [string trim $cv]; set c [cfg]; puts "config: $ck=[string trim $cv]" } elseif {$ck ne ""} { puts [expr {[dict exists $c $ck] ? "$ck=[dict get $c $ck]" : "$ck not set"}] } else { puts "usage: /cfg <param> \[val\]" } } \
|
||||
elseif {$u eq "/help"} { puts "Commands: /quit /clear /save /list /load <id> /cfg <k> \[v\] /help" } \
|
||||
else { lappend msgs [dict create role user content $u]; AL $c msgs $sp_text 0; autosave $msgs }
|
||||
}
|
||||
autosave $msgs
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build darwin
|
||||
//go:build darwin || dragonfly || freebsd || netbsd || openbsd
|
||||
|
||||
package main
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
//go:build !linux && !darwin && !windows
|
||||
//go:build !linux && !darwin && !dragonfly && !freebsd && !netbsd && !openbsd && !windows
|
||||
|
||||
package main
|
||||
|
||||
|
||||
Reference in New Issue
Block a user