diff --git a/README.md b/README.md index 622da2d..06573dd 100644 --- a/README.md +++ b/README.md @@ -357,20 +357,28 @@ anything is sent back to the model. ### `write` tool -- Parameters: `filePath` (string, required; also accepts `path`); `offset` - (integer, optional, byte offset); `del_bytes` (integer, optional); `content` - (string, required, may be empty). -- 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). (Also accepted as - legacy `write_file`.) +- Parameters: `filePath` (string, required; also accepts `path`); `content` + (string, required, may be empty); `lineOffset` (integer, optional, 1-indexed + line number to start writing from — takes precedence over `byteOffset`); + `byteOffset` (integer, optional, byte offset — used only when `lineOffset` is + not given); `delLines` (integer, optional, number of lines to delete starting + at the insertion point — takes precedence over `delBytes`); `delBytes` + (integer, optional, number of bytes to delete — used only when `delLines` is + not given). +- Action: if none of `lineOffset`, `byteOffset`, `delLines` and `delBytes` are + provided, the entire file is overwritten with `content` (the file and any + necessary parent directories are created if missing). Otherwise `content` is + written at the resolved line/byte offset, optionally deleting `delLines` + lines or `delBytes` bytes first (splicing prefix + content + suffix). Line + numbers are 1-indexed and match the `read` tool's numbering; a `lineOffset` + past the end of the file appends at the end. (Also accepted as legacy + `write_file`; legacy `offset`/`del_bytes` are no longer emitted but the + `byteOffset`/`delBytes` byte-based mode covers the same behavior.) ### `bash` tool - Parameters: `command` (string, required); `workdir` (string, optional). -- Action: run `command` via `bash -c` subject to `shell_timeout` +- Action: run `command` through the system shell (`bash` resolved from `PATH`, falling back to `/bin/sh` if bash is unavailable) via `-c`, subject to `shell_timeout` (default `120s`) and return `output + '\nexit: ' + exit_code`. (Also accepted as legacy `shell_exec`.) diff --git a/bantam.hpp b/bantam.hpp index 30952a1..b73e87e 100644 --- a/bantam.hpp +++ b/bantam.hpp @@ -225,7 +225,7 @@ bool parse_nonstream_response(std::string_view body, Message &out, Usage &usage) /* ------------------------------------------------------------------ */ /* llm.cpp — HTTP client (libcurl), headers, backoff, dispatch. */ -/* ------------------------------------------------------------------ */ +/* -------------------------------------------------------------- */ int http_get(Cfg &cfg, std::string_view url, std::string &body, long &code, std::string &errmsg); int llm(Cfg &cfg, Messages &msgs, bool use_tools, Message &out, Usage &usage, @@ -238,8 +238,9 @@ std::string shell_with_workdir(std::string_view cmd, std::string_view workdir, int timeout, volatile int *interrupted); std::string read_file_or_dir(std::string_view path, int offset, int limit, int *style); -std::string write_file(std::string_view path, int offset, int del_bytes, - std::string_view content, int *err); +std::string write_file(std::string_view path, long line_offset, long byte_offset, + long del_lines, long del_bytes, + std::string_view content, int *err); std::string offload_tool_result(std::string_view res, int max_res, std::vector &tmps); void cleanup_temps(std::vector &tmps); diff --git a/json.cpp b/json.cpp index d3817a3..1940810 100644 --- a/json.cpp +++ b/json.cpp @@ -401,30 +401,6 @@ struct JParser { return true; } if (c == '[') { - pos++; - skip_ws(); - out = Json::array(); - if (pos < src.size() && src[pos] == ']') { - pos++; - return true; - } - while (true) { - Json elem; - if (!parse_value(elem, depth + 1)) return false; - out.aval.push_back(std::move(elem)); - skip_ws(); - if (pos < src.size() && src[pos] == ',') { - pos++; - continue; - } - if (pos < src.size() && src[pos] == ']') { - pos++; - return true; - } - return false; - } - } - if (c == '{') { pos++; skip_ws(); out = Json::object(); @@ -491,12 +467,14 @@ static const char *TOOLS_JSON = R"JSON([ },"required":["filePath"]}}}, {"type":"function","function":{ "name":"write", - "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.", + "description":"Write content to a file. If none of lineOffset, byteOffset, delLines and delBytes are provided, the entire file is overwritten with the new content (the file and any necessary parent directories are created if missing); otherwise content is written at the given line or byte offset, optionally deleting lines or bytes first. Line-based editing is primary.", "parameters":{"type":"object","properties":{ "filePath":{"type":"string","description":"The path to the file to write"}, "content":{"type":"string","description":"The content to write to the file"}, - "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 (start of file)."}, - "del_bytes":{"type":"integer","description":"Bytes to delete starting at offset. If omitted together with offset, the whole file is overwritten instead."} + "lineOffset":{"type":"integer","description":"1-indexed line number where writing begins (1 = first line). Takes precedence over byteOffset when both are given."}, + "byteOffset":{"type":"integer","description":"Byte offset where writing begins. Used only when lineOffset is not given."}, + "delLines":{"type":"integer","description":"Number of lines to delete starting at the insertion point. Takes precedence over delBytes when both are given."}, + "delBytes":{"type":"integer","description":"Number of bytes to delete starting at the insertion point. Used only when delLines is not given."} },"required":["filePath","content"]}}} ])JSON"; diff --git a/main.cpp b/main.cpp index 417914c..0337f09 100644 --- a/main.cpp +++ b/main.cpp @@ -68,7 +68,7 @@ static const char *default_system_prompt = "You are Bantam, a tiny, powerful AI agent. Solve the user's task using three tools:\n" "- bash: run a bash command; returns its output and exit code.\n" "- read: read a file or directory from the local filesystem; returns numbered lines or directory entries.\n" -"- write: 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\n" +"- write: write content to a file; if lineOffset/byteOffset and delLines/delBytes are all omitted it overwrites the entire file, otherwise it writes at the given line or byte offset (optionally deleting lines or bytes first); line-based edits are preferred; returns status.\n\n" "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.\n" "Use the target system's filesystem deliberately:\n" "- Use only $TMPDIR/bantam as the temporary directory for intermediate or scratch files (logs, downloads, temporary build outputs, etc.); create it if it does not exist.\n" @@ -212,13 +212,22 @@ static int AL(Cfg &cfg, Messages &msgs, Usage &out, volatile int *interrupted, s std::string fp = filter_text(p); std::string cv = get_str(a, "content"); bool has_content = a.contains("content") && a["content"].is_string(); - bool has_offset = a.contains("offset") && !a["offset"].is_null(); - bool has_del = a.contains("del_bytes") && !a["del_bytes"].is_null(); - int offset = has_offset ? get_int(a, "offset") : 0; if (offset < 0) offset = 0; - int del = has_del ? get_int(a, "del_bytes") : 0; + bool has_line = a.contains("lineOffset") && !a["lineOffset"].is_null(); + bool has_byte = a.contains("byteOffset") && !a["byteOffset"].is_null(); + bool has_del_lines = a.contains("delLines") && !a["delLines"].is_null(); + bool has_del_bytes = a.contains("delBytes") && !a["delBytes"].is_null(); + long line_off = has_line ? (long)get_int(a, "lineOffset") : -1L; + long byte_off = has_byte ? (long)get_int(a, "byteOffset") : -1L; + long del_lines = has_del_lines ? (long)get_int(a, "delLines") : -1L; + long del_bytes = has_del_bytes ? (long)get_int(a, "delBytes") : -1L; + if (line_off < 1) line_off = -1L; + if (byte_off < 0) byte_off = -1L; + if (del_lines < 0) del_lines = -1L; + if (del_bytes < 0) del_bytes = -1L; if (fp.empty()) { res = "[tool error: write requires 'filePath' parameter]"; sty = 31; } else if (!has_content) { res = "[tool error: write requires 'content' parameter]"; sty = 31; } - else if (!has_offset && !has_del) { + else if (!has_line && !has_byte && !has_del_lines && !has_del_bytes) { + /* No offset/deletion parameters: overwrite the whole file. */ std::string c = filter_text(cv); auto sl = fp.find_last_of('/'); if (sl != std::string::npos) mkdir_p(std::string_view(fp).substr(0, sl)); @@ -227,7 +236,7 @@ static int AL(Cfg &cfg, Messages &msgs, Usage &out, volatile int *interrupted, s else { res = sfmt("Successfully wrote %zu bytes to %s", c.size(), fp.c_str()); } } else { int werr = 0; std::string c = filter_text(cv); - res = write_file(fp, offset, del, c, &werr); + res = write_file(fp, line_off, byte_off, del_lines, del_bytes, c, &werr); if (werr) sty = 31; } } else { diff --git a/tools.cpp b/tools.cpp index 94f47fe..ea5c05c 100644 --- a/tools.cpp +++ b/tools.cpp @@ -15,6 +15,34 @@ #include /* ----------------------------- shell ---------------------------------- */ +/* Resolve the Bourne-again shell from the system PATH. Returns a static + * buffer holding the absolute path to `bash`, or "/bin/sh" when bash cannot + * be located anywhere on PATH. Never returns a hardcoded bash path. */ +static const char *resolve_shell() { + static char buf[4096]; + const char *path = getenv("PATH"); + if (path && *path) { + std::string p(path); + size_t start = 0; + while (start <= p.size()) { + size_t end = p.find(':', start); + std::string dir = (end == std::string::npos) ? p.substr(start) + : p.substr(start, end - start); + if (!dir.empty() && dir.back() != '/') dir += '/'; + std::string cand = dir + "bash"; + struct stat st; + if (stat(cand.c_str(), &st) == 0 && S_ISREG(st.st_mode) && + (st.st_mode & (S_IXUSR | S_IXGRP | S_IXOTH)) != 0) { + snprintf(buf, sizeof buf, "%s", cand.c_str()); + return buf; + } + if (end == std::string::npos) break; + start = end + 1; + } + } + return "/bin/sh"; +} + std::string shell_with_workdir(std::string_view cmd, std::string_view workdir, int timeout, volatile int *interrupted) { std::string fcmd = filter_text(cmd); @@ -35,7 +63,9 @@ std::string shell_with_workdir(std::string_view cmd, std::string_view workdir, close(pipefd[0]); close(pipefd[1]); if (!workdir.empty()) chdir(std::string(workdir).c_str()); - execl("/bin/bash", "bash", "-c", fcmd.c_str(), (char *)nullptr); + const char *sh = resolve_shell(); + const char *shname = (strrchr(sh, '/') ? strrchr(sh, '/') + 1 : sh); + execl(sh, shname, "-c", fcmd.c_str(), (char *)nullptr); _exit(127); } close(pipefd[1]); @@ -171,7 +201,38 @@ std::string read_file_or_dir(std::string_view path, int offset, int limit, int * } /* ----------------------------- write ---------------------------------- */ -std::string write_file(std::string_view path, int offset, int del_bytes, +/* Convert a 1-indexed line number to the byte offset of the start of that + * line within `data`. Line 1 begins at byte 0; a line number past the end of + * the file maps to data.size() (i.e. append at end of file). */ +static size_t line_to_byte_offset(std::string_view data, long line_no) { + if (line_no <= 1) return 0; + size_t pos = 0; + long cur = 1; + while (pos < data.size()) { + if (data[pos] == '\n') { + cur++; + if (cur == line_no) return pos + 1; + } + pos++; + } + return data.size(); +} + +/* Byte length of `nlines` complete lines beginning at byte offset `start`. + * Counts the newlines terminating each line and stops at end of data. */ +static size_t lines_to_byte_len(std::string_view data, size_t start, long nlines) { + if (nlines <= 0 || start > data.size()) return 0; + size_t pos = start; + long consumed = 0; + while (pos < data.size() && consumed < nlines) { + if (data[pos] == '\n') consumed++; + pos++; + } + return pos - start; +} + +std::string write_file(std::string_view path, long line_offset, long byte_offset, + long del_lines, long del_bytes, std::string_view content, int *err) { *err = 0; std::string p(path); @@ -179,8 +240,6 @@ std::string write_file(std::string_view path, int offset, int del_bytes, while (s < p.size() && (p[s] == ' ' || p[s] == '\t')) s++; p = p.substr(s); if (p.empty()) { *err = 1; return "[tool error: path is required]"; } - if (offset < 0) offset = 0; - if (del_bytes < 0) del_bytes = 0; std::string data; struct stat st; @@ -198,11 +257,32 @@ std::string write_file(std::string_view path, int offset, int del_bytes, if (slash != std::string::npos) mkdir_p(std::string_view(p).substr(0, slash)); else mkdir_p("."); } - if ((size_t)offset > data.size()) { - data.resize((size_t)offset, '\0'); + + /* Resolve insertion byte offset. Line-based takes precedence over byte-based + * ("first and foremost line-based writing"); if neither is given the whole + * file is overwritten by the caller. */ + size_t ins_off = 0; + if (line_offset >= 0) { + ins_off = line_to_byte_offset(data, line_offset); + } else if (byte_offset >= 0) { + ins_off = (size_t)byte_offset; + if (ins_off > data.size()) data.resize(ins_off, '\0'); + } else { + ins_off = 0; } - size_t end_del = (size_t)offset + (size_t)del_bytes; - size_t prefix_len = (size_t)offset; + + /* Resolve deletion span. Line-based takes precedence over byte-based. */ + size_t del_len = 0; + if (del_lines >= 0) { + del_len = lines_to_byte_len(data, ins_off, del_lines); + } else if (del_bytes >= 0) { + del_len = (size_t)del_bytes; + if (ins_off + del_len > data.size()) + del_len = (ins_off < data.size()) ? (data.size() - ins_off) : 0; + } + + size_t end_del = ins_off + del_len; + size_t prefix_len = ins_off; size_t suffix_len = (end_del < data.size()) ? (data.size() - end_del) : 0; size_t clen = content.size(); size_t newlen = prefix_len + clen + suffix_len; @@ -220,7 +300,7 @@ std::string write_file(std::string_view path, int offset, int del_bytes, /* ----------------------------- offload -------------------------------- */ std::string offload_tool_result(std::string_view res, int max_res, - std::vector &tmps) { + std::vector &tmps) { if (max_res <= 0) max_res = 65536; if (res.size() <= (size_t)max_res) return std::string(res); std::string dir = tool_res_dir();