diff --git a/README.md b/README.md index af2c0dc..962e8fe 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ When enabled, the interactive console uses a subtle ANSI palette: the pending-re - 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. + - `offset` (integer, optional, default 0): byte offset to start writing from (defaults to 0, start of file; does not append). - `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 diff --git a/main.go b/main.go index 60f5e2b..04483b5 100644 --- a/main.go +++ b/main.go @@ -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: - shell_exec: run a shell command; returns its output and exit code. -- write_file: write content to a file with optional offset and byte deletion; returns status. +- write_file: write content to a file with optional offset (defaults to 0, start of file; does not append) 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. 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{ {"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"}, "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 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"}}}}, } func strp(s string) *string { return &s } @@ -1286,7 +1286,7 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message) ([]Message, Usage, error) } } offset := 0 - if v, ok := a["offset"]; ok { + if v, ok := a["offset"]; ok && v != nil { switch n := v.(type) { case float64: offset = int(n) @@ -1296,6 +1296,9 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message) ([]Message, Usage, error) offset = atoiD(n, 0) } } + if offset < 0 { + offset = 0 + } delBytes := 0 if v, ok := a["del_bytes"]; ok { switch n := v.(type) { diff --git a/main_test.go b/main_test.go index e4136f0..a55b823 100644 --- a/main_test.go +++ b/main_test.go @@ -2389,3 +2389,139 @@ func TestSanitizeMessagesCleansReasoningContent(t *testing.T) { } } +func TestWriteFileOmittedOrZeroOffsetDoesNotAppend(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "test.txt") + + // Create file with initial content + if err := os.WriteFile(target, []byte("EXISTING"), 0644); err != nil { + t.Fatalf("failed to write initial file: %v", err) + } + + // 1. Direct writeFile with offset 0: writes starting at offset 0, does NOT append + res, err := writeFile(target, 0, 0, "PREFIX_") + if err != nil { + t.Fatalf("writeFile: %v", err) + } + if !strings.Contains(res, "Successfully wrote 7 bytes") { + t.Errorf("unexpected res: %q", res) + } + data, err := os.ReadFile(target) + if err != nil { + t.Fatalf("readFile: %v", err) + } + if string(data) != "PREFIX_EXISTING" { + 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 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Messages []Message `json:"messages"` + } + json.NewDecoder(r.Body).Decode(&req) + for _, m := range req.Messages { + if m.Role == "tool" { + w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"done"}}]}`)) + return + } + } + // Tool call without "offset" parameter + args, _ := json.Marshal(map[string]any{ + "path": target, + "content": "START_", + }) + 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() + + cfg := defCfg + cfg.Endpoint = srv.URL + cfg.Stream = false + cfg.APIKey = "-" + _, _, err = AL(context.Background(), &cfg, []Message{{Role: "user", Content: strp("write")}}) + if err != nil { + t.Fatalf("AL: %v", err) + } + + data, err = os.ReadFile(target) + if err != nil { + t.Fatalf("readFile: %v", err) + } + if string(data) != "START_PREFIX_EXISTING" { + t.Errorf("expected 'START_PREFIX_EXISTING', got %q", string(data)) + } + + // 3. AL tool dispatch with explicit offset: 0 + srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Messages []Message `json:"messages"` + } + json.NewDecoder(r.Body).Decode(&req) + for _, m := range req.Messages { + if m.Role == "tool" { + w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"done"}}]}`)) + return + } + } + args, _ := json.Marshal(map[string]any{ + "path": target, + "offset": 0, + "del_bytes": 0, + "content": "ZERO_", + }) + 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 srv2.Close() + + cfg.Endpoint = srv2.URL + _, _, err = AL(context.Background(), &cfg, []Message{{Role: "user", Content: strp("write zero")}}) + if err != nil { + t.Fatalf("AL: %v", err) + } + + data, err = os.ReadFile(target) + if err != nil { + t.Fatalf("readFile: %v", err) + } + if string(data) != "ZERO_START_PREFIX_EXISTING" { + t.Errorf("expected 'ZERO_START_PREFIX_EXISTING', got %q", string(data)) + } + + // 4. AL tool dispatch with offset: null + srv3 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Messages []Message `json:"messages"` + } + json.NewDecoder(r.Body).Decode(&req) + for _, m := range req.Messages { + if m.Role == "tool" { + w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"done"}}]}`)) + return + } + } + args, _ := json.Marshal(map[string]any{ + "path": target, + "offset": nil, + "content": "NULL_", + }) + 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 srv3.Close() + + cfg.Endpoint = srv3.URL + _, _, err = AL(context.Background(), &cfg, []Message{{Role: "user", Content: strp("write null")}}) + if err != nil { + t.Fatalf("AL: %v", err) + } + + data, err = os.ReadFile(target) + if err != nil { + t.Fatalf("readFile: %v", err) + } + if string(data) != "NULL_ZERO_START_PREFIX_EXISTING" { + t.Errorf("expected 'NULL_ZERO_START_PREFIX_EXISTING', got %q", string(data)) + } +} + + diff --git a/mb b/mb index 9a23e53..ff299dd 100755 --- a/mb +++ b/mb @@ -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('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}); + 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}); 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)});