Compare commits

..
2 Commits
Author SHA1 Message Date
Luxferre ed5eb88ba9 C-j fix; tool doc upd 2026-09-09 20:33:07 +03:00
Luxferre 4ed12ed213 fixed write semantics and visual bug with newline insertion 2026-09-09 20:23:37 +03:00
4 changed files with 81 additions and 32 deletions
+4 -4
View File
@@ -166,12 +166,12 @@ When enabled, the interactive console uses a subtle ANSI palette: the pending-re
#### `write_file` tool #### `write_file` tool
- Parameters: - Parameters:
- `path` (string, required): JSON-escaped file path to write to (must be created unless existing). - `path` (string, required): JSON-escaped file path to write to.
- `offset` (integer, optional, default 0): byte offset to start writing from (defaults to 0, start of file; does not append). - `offset` (integer, optional): byte offset to start writing from. If omitted together with `del_bytes`, the whole file is overwritten instead. Defaults to 0 (start of file).
- `del_bytes` (integer, optional, default 0): bytes to delete starting from the `offset` prior to writing. - `del_bytes` (integer, optional): bytes to delete starting at `offset`. If omitted together with `offset`, the whole file is overwritten instead.
- `content` (string, required, may be empty): JSON-escaped content to write to the file. - `content` (string, required, may be empty): JSON-escaped content to write to the file.
- Return value: string - Return value: string
- Action: write `content` into the file at `path` starting at `offset` after deleting `del_bytes` bytes (creating the file and any necessary parent directories). - Action: if `offset` and `del_bytes` are both omitted, the entire file is overwritten with `content` (the file, and any necessary parent directories, are created if missing); otherwise `content` is written at `offset`, optionally deleting `del_bytes` bytes first (splicing prefix + content + suffix and never appending to the end of an existing file), creating the file and parent directories as needed.
#### `shell_exec` tool #### `shell_exec` tool
+50 -14
View File
@@ -284,7 +284,7 @@ func configPath() string {
} }
const defaultSystemPrompt = `You are Bantam, a tiny, powerful AI agent. Solve the user's task using two tools: 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. - shell_exec: run a shell command; returns its output and exit code.
- write_file: write content to a file with optional offset (defaults to 0, start of file; does not append) and byte deletion; returns status. - write_file: write content to a file; if offset and del_bytes are both omitted it overwrites the entire file, otherwise it writes at the given byte offset (optionally deleting bytes first); 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. 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.
@@ -751,7 +751,7 @@ type ToolCall struct {
var TOOLS = []map[string]any{ 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": "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": "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", "description": "Byte offset to start writing from (defaults to 0, start of file; does not append)."}, "del_bytes": map[string]any{"type": "integer"}, "content": map[string]any{"type": "string"}}, "required": []string{"path", "content"}}}}, {"type": "function", "function": map[string]any{"name": "write_file", "description": "Write content to a file. If offset and del_bytes are both omitted, the entire file is overwritten with the new content; otherwise content is written at the given 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", "description": "Byte offset to start writing from. If omitted together with del_bytes, the whole file is overwritten instead. Defaults to 0 (start of file)."}, "del_bytes": map[string]any{"type": "integer", "description": "Bytes to delete starting at offset. If omitted together with offset, the whole file is overwritten instead."}, "content": map[string]any{"type": "string"}}, "required": []string{"path", "content"}}}},
} }
func strp(s string) *string { return &s } func strp(s string) *string { return &s }
@@ -1156,6 +1156,10 @@ func lastRole(msgs []Message) string {
return msgs[len(msgs)-1].Role return msgs[len(msgs)-1].Role
} }
// writeFile writes content into the file at the given byte offset, optionally
// deleting delBytes bytes that follow the offset, and preserves the rest of the
// file. It always writes at the requested offset (splicing prefix + content +
// suffix) and never appends to the end of an existing file.
func writeFile(path string, offset, delBytes int, content string) (string, error) { func writeFile(path string, offset, delBytes int, content string) (string, error) {
path = strings.TrimSpace(path) path = strings.TrimSpace(path)
if path == "" { if path == "" {
@@ -1285,8 +1289,10 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message) ([]Message, Usage, error)
content = s content = s
} }
} }
hasOffset := false
offset := 0 offset := 0
if v, ok := a["offset"]; ok && v != nil { if v, ok := a["offset"]; ok && v != nil {
hasOffset = true
switch n := v.(type) { switch n := v.(type) {
case float64: case float64:
offset = int(n) offset = int(n)
@@ -1299,8 +1305,10 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message) ([]Message, Usage, error)
if offset < 0 { if offset < 0 {
offset = 0 offset = 0
} }
hasDel := false
delBytes := 0 delBytes := 0
if v, ok := a["del_bytes"]; ok { if v, ok := a["del_bytes"]; ok {
hasDel = true
switch n := v.(type) { switch n := v.(type) {
case float64: case float64:
delBytes = int(n) delBytes = int(n)
@@ -1314,6 +1322,24 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message) ([]Message, Usage, error)
res, sty = "[tool error: write_file requires 'path' parameter]", 31 res, sty = "[tool error: write_file requires 'path' parameter]", 31
} else if !hasContent { } else if !hasContent {
res, sty = "[tool error: write_file requires 'content' parameter]", 31 res, sty = "[tool error: write_file requires 'content' parameter]", 31
} else if !hasOffset && !hasDel {
// Neither offset nor del_bytes was supplied: overwrite the entire
// file with the new content. The LLM usually just wants to replace a
// file and should not have to know about the tool's offset quirks;
// insertion/replace semantics are preserved when either is given.
p := strings.TrimSpace(path)
if dir := filepath.Dir(p); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
res, sty = fmt.Sprintf("[tool error: write_file %s: %v]", p, err), 31
}
}
if res == "" {
if err := os.WriteFile(p, []byte(content), 0644); err != nil {
res, sty = fmt.Sprintf("[tool error: write_file %s: %v]", p, err), 31
} else {
res, sty = fmt.Sprintf("Successfully wrote %d bytes to %s", len(content), p), 2
}
}
} else { } else {
out, err := writeFile(path, offset, delBytes, content) out, err := writeFile(path, offset, delBytes, content)
if err != nil { if err != nil {
@@ -1535,22 +1561,29 @@ type editor struct {
func textPos(promptLen, W int, s string, pos int) (row, col int) { func textPos(promptLen, W int, s string, pos int) (row, col int) {
row, col = 0, promptLen row, col = 0, promptLen
pend := false if W < 1 {
for i, r := range []rune(s) { W = 1
if i == pos { return row, col } }
runes := []rune(s)
n := len(runes)
for i := 0; i < n; i++ {
r := runes[i]
if i == pos {
return row, col
}
if r == '\n' { if r == '\n' {
row++ row++
col = 0 col = 0
pend = false
continue continue
} }
if pend { // The rune at index i is displayed at (row, col). If it lands on the last
row++ // column, the NEXT rune wraps to the start of the following line (unless
col = 0 // this is the final rune, in which case the cursor stays at that column).
pend = false if col >= W-1 {
} if i < n-1 {
if col == W-1 { row++
pend = true col = 0
}
} else { } else {
col++ col++
} }
@@ -1565,7 +1598,10 @@ func (e *editor) draw() {
er, _ := textPos(P, W, s, len([]rune(s))) er, _ := textPos(P, W, s, len([]rune(s)))
pr, pc := textPos(P, W, s, e.pos) pr, pc := textPos(P, W, s, e.pos)
if e.crow > 0 { fmt.Printf("\033[%dA", e.crow) } if e.crow > 0 { fmt.Printf("\033[%dA", e.crow) }
fmt.Print("\r\033[J" + e.prompt + s) // In raw mode OPOST is off, so a bare \n only moves down without
// returning to column 0. Emit \r\n so each logical line starts at
// column 0, matching the column-reset assumption in textPos().
fmt.Print("\r\033[J" + e.prompt + strings.ReplaceAll(s, "\n", "\r\n"))
if up := er - pr; up > 0 { fmt.Printf("\033[%dA", up) } if up := er - pr; up > 0 { fmt.Printf("\033[%dA", up) }
fmt.Print("\r") fmt.Print("\r")
if pc > 0 { fmt.Printf("\033[%dC", pc) } if pc > 0 { fmt.Printf("\033[%dC", pc) }
+11 -11
View File
@@ -2389,7 +2389,7 @@ func TestSanitizeMessagesCleansReasoningContent(t *testing.T) {
} }
} }
func TestWriteFileOmittedOrZeroOffsetDoesNotAppend(t *testing.T) { func TestWriteFileOmittedOverwritesWholeFile(t *testing.T) {
tmp := t.TempDir() tmp := t.TempDir()
target := filepath.Join(tmp, "test.txt") target := filepath.Join(tmp, "test.txt")
@@ -2398,7 +2398,7 @@ func TestWriteFileOmittedOrZeroOffsetDoesNotAppend(t *testing.T) {
t.Fatalf("failed to write initial file: %v", err) t.Fatalf("failed to write initial file: %v", err)
} }
// 1. Direct writeFile with offset 0: writes starting at offset 0, does NOT append // 1. Low-level writeFile with explicit offset 0 / del_bytes 0 still inserts at the start
res, err := writeFile(target, 0, 0, "PREFIX_") res, err := writeFile(target, 0, 0, "PREFIX_")
if err != nil { if err != nil {
t.Fatalf("writeFile: %v", err) t.Fatalf("writeFile: %v", err)
@@ -2414,7 +2414,7 @@ func TestWriteFileOmittedOrZeroOffsetDoesNotAppend(t *testing.T) {
t.Errorf("expected 'PREFIX_EXISTING', got %q", string(data)) t.Errorf("expected 'PREFIX_EXISTING', got %q", string(data))
} }
// 2. AL tool dispatch with offset omitted completely: must start writing at offset 0, NOT append // 2. AL dispatch with offset and del_bytes omitted: overwrite the entire file
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Messages []Message `json:"messages"` Messages []Message `json:"messages"`
@@ -2448,11 +2448,11 @@ func TestWriteFileOmittedOrZeroOffsetDoesNotAppend(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("readFile: %v", err) t.Fatalf("readFile: %v", err)
} }
if string(data) != "START_PREFIX_EXISTING" { if string(data) != "START_" {
t.Errorf("expected 'START_PREFIX_EXISTING', got %q", string(data)) t.Errorf("expected 'START_' (full overwrite), got %q", string(data))
} }
// 3. AL tool dispatch with explicit offset: 0 // 3. AL dispatch with explicit offset 0 / del_bytes 0: insertion semantics preserved
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Messages []Message `json:"messages"` Messages []Message `json:"messages"`
@@ -2484,11 +2484,11 @@ func TestWriteFileOmittedOrZeroOffsetDoesNotAppend(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("readFile: %v", err) t.Fatalf("readFile: %v", err)
} }
if string(data) != "ZERO_START_PREFIX_EXISTING" { if string(data) != "ZERO_START_" {
t.Errorf("expected 'ZERO_START_PREFIX_EXISTING', got %q", string(data)) t.Errorf("expected 'ZERO_START_' (insert at start), got %q", string(data))
} }
// 4. AL tool dispatch with offset: null // 4. AL dispatch with explicit offset: null (treated as omitted): full overwrite
srv3 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv3 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Messages []Message `json:"messages"` Messages []Message `json:"messages"`
@@ -2519,8 +2519,8 @@ func TestWriteFileOmittedOrZeroOffsetDoesNotAppend(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("readFile: %v", err) t.Fatalf("readFile: %v", err)
} }
if string(data) != "NULL_ZERO_START_PREFIX_EXISTING" { if string(data) != "NULL_" {
t.Errorf("expected 'NULL_ZERO_START_PREFIX_EXISTING', got %q", string(data)) t.Errorf("expected 'NULL_' (full overwrite), got %q", string(data))
} }
} }
+16 -3
View File
@@ -4,7 +4,7 @@
use strict; use warnings; use HTTP::Tiny; use JSON::PP; use POSIX qw(strftime); use File::Path qw(make_path); use Digest::MD5 qw(md5_hex); use Cwd qw(abs_path); use strict; use warnings; use HTTP::Tiny; use JSON::PP; use POSIX qw(strftime); use File::Path qw(make_path); use Digest::MD5 qw(md5_hex); use Cwd qw(abs_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- 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 $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; if offset and del_bytes are both omitted it overwrites the entire file, otherwise it writes at the given byte offset (optionally deleting bytes first); 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'; my $SDIR = ($ENV{HOME} || $ENV{USERPROFILE} || '.') . '/.bantam/sessions';
sub cfg { my %d = (endpoint=>'https://api.kilo.ai/api/openrouter', model=>'openrouter/free', temperature=>0.7, api_key=>'-', timeout=>300, shell_timeout=>120, max_al_iterations=>1000, reasoning_effort=>'high'); sub cfg { my %d = (endpoint=>'https://api.kilo.ai/api/openrouter', model=>'openrouter/free', temperature=>0.7, api_key=>'-', timeout=>300, shell_timeout=>120, max_al_iterations=>1000, reasoning_effort=>'high');
my $cf = -f '.bantam.cfg' ? '.bantam.cfg' : 'model.cfg'; my $cf = -f '.bantam.cfg' ? '.bantam.cfg' : 'model.cfg';
@@ -22,7 +22,7 @@ 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('write_file', 'Write content to a file with optional offset and byte deletion.', {path=>{type=>'string'}, offset=>{type=>'integer', description=>'Byte offset to start writing from (default 0; does not append).'}, del_bytes=>{type=>'integer'}, content=>{type=>'string'}}, ['path', 'content'])], 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. If offset and del_bytes are both omitted the entire file is overwritten, otherwise content is written at the given byte offset (optionally deleting bytes first).', {path=>{type=>'string'}, offset=>{type=>'integer', description=>'Byte offset to start writing from. If omitted together with del_bytes the whole file is overwritten instead. Defaults to 0.'}, del_bytes=>{type=>'integer', description=>'Bytes to delete starting at offset. If omitted together with offset the whole file is overwritten instead.'}, 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}; } 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 $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)}); my $r = HTTP::Tiny->new(timeout=>0+$c->{timeout})->post("$ep/chat/completions", {headers=>$h, content=>encode_json(\%p)});
@@ -64,7 +64,20 @@ sub AL { my ($c, $msgs) = ($_[0], $_[1]);
my $a = eval { decode_json($tc->{function}{arguments} // '{}') }; 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}"; } 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 'write_file') { $res = write_file($a->{path}, $a->{offset}, $a->{del_bytes}, $a->{content}); } elsif ($fn eq 'write_file') {
my ($p, $cnt) = ($a->{path}, defined $a->{content} ? $a->{content} : '');
if (!(exists $a->{offset} && defined $a->{offset}) && !(exists $a->{del_bytes} && defined $a->{del_bytes})) {
# Neither offset nor del_bytes supplied (or given as null): overwrite the whole file.
if (!defined $p || $p eq '') { $res = "[write_file error: path required]"; }
else {
my $dir = $p =~ m{^(.*)/[^/]+$} ? $1 : ''; make_path($dir) if length($dir) && !-d $dir;
open my $wfh, '>:raw', $p or $res = "[write_file error: cannot write $p: $!]";
if (!$res) { print $wfh $cnt; close $wfh; $res = "Successfully wrote " . length($cnt) . " bytes to $p"; }
}
} else {
$res = write_file($p, $a->{offset}, $a->{del_bytes}, $cnt);
}
}
else { $res = "unknown tool: $fn"; } else { $res = "unknown tool: $fn"; }
$res = filter_text($res); $res = filter_text($res);
print "[tool] $fn: $res\n"; print "[tool] $fn: $res\n";