resolve shell from PATH (fallback /bin/sh); rewrite write tool with lineOffset/byteOffset + delLines/delBytes

This commit is contained in:
Luxferre
2026-10-14 10:58:08 +03:00
parent 34f1da34ca
commit 6e80a3e8fc
5 changed files with 132 additions and 56 deletions
+18 -10
View File
@@ -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`.)
+4 -3
View File
@@ -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<std::string> &tmps);
void cleanup_temps(std::vector<std::string> &tmps);
+5 -27
View File
@@ -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";
+16 -7
View File
@@ -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 {
+89 -9
View File
@@ -15,6 +15,34 @@
#include <vector>
/* ----------------------------- 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<std::string> &tmps) {
std::vector<std::string> &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();