Spaces:
Running on Zero
Running on Zero
| namespace fs = std::filesystem; | |
| // | |
| // internal helpers | |
| // | |
| json server_tool::to_json() const { | |
| return { | |
| {"display_name", display_name}, | |
| {"tool", name}, | |
| {"type", "builtin"}, | |
| {"permissions", json{ | |
| {"write", permission_write} | |
| }}, | |
| {"definition", get_definition()}, | |
| }; | |
| } | |
| static constexpr size_t SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT = 8 * 1024 * 1024; // 8 MB | |
| static constexpr int SERVER_TOOL_GIT_LS_FILES_TIMEOUT = 15; // seconds | |
| class tools_io { | |
| public: | |
| struct exec_result { | |
| std::string output; | |
| int exit_code = -1; | |
| bool timed_out = false; | |
| }; | |
| virtual ~tools_io() = default; | |
| virtual bool is_directory(const std::string & path) const = 0; | |
| virtual bool is_regular_file(const std::string & path) const = 0; | |
| virtual bool file_size(const std::string & path, uintmax_t & out_size) const = 0; | |
| virtual bool read_file(const std::string & path, std::string & out) const = 0; | |
| virtual bool write_file(const std::string & path, const std::string & content) const = 0; | |
| // paths relative to `base`, '/'-separated; sets `err` if `base` isn't a directory | |
| virtual std::vector<std::string> list_files(const std::string & base, std::string & err) const = 0; | |
| // on_chunk, if set, is called with each chunk of output as it is read (before truncation cuts in); | |
| // returning false terminates the process early (e.g. the client disconnected) | |
| virtual exec_result run( | |
| const std::vector<std::string> & args, | |
| size_t max_output, | |
| int timeout_secs, | |
| const std::function<bool(const std::string &)> & on_chunk = nullptr) const = 0; | |
| }; | |
| class tools_io_basic : public tools_io { | |
| public: | |
| bool is_directory(const std::string & path) const override { | |
| std::error_code ec; | |
| return fs::is_directory(path, ec) && !ec; | |
| } | |
| bool is_regular_file(const std::string & path) const override { | |
| std::error_code ec; | |
| return fs::is_regular_file(path, ec) && !ec; | |
| } | |
| bool file_size(const std::string & path, uintmax_t & out_size) const override { | |
| std::error_code ec; | |
| out_size = fs::file_size(path, ec); | |
| return !ec; | |
| } | |
| bool read_file(const std::string & path, std::string & out) const override { | |
| std::ifstream f(path, std::ios::binary); | |
| if (!f) return false; | |
| std::ostringstream ss; | |
| ss << f.rdbuf(); | |
| out = ss.str(); | |
| return true; | |
| } | |
| bool write_file(const std::string & path, const std::string & content) const override { | |
| std::error_code ec; | |
| fs::path fpath(path); | |
| if (fpath.has_parent_path()) { | |
| fs::create_directories(fpath.parent_path(), ec); | |
| if (ec) return false; | |
| } | |
| std::ofstream f(path, std::ios::binary); | |
| if (!f) return false; | |
| f << content; | |
| return (bool) f; | |
| } | |
| std::vector<std::string> list_files(const std::string & base, std::string & err) const override { | |
| err.clear(); | |
| if (!is_directory(base)) { | |
| err = "path does not exist or is not a directory: " + base; | |
| return {}; | |
| } | |
| auto res = run( | |
| {"git", "-C", base, "ls-files", "--cached", "--others", "--exclude-standard"}, | |
| SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT, SERVER_TOOL_GIT_LS_FILES_TIMEOUT); | |
| if (res.exit_code == 0 && !res.timed_out) { | |
| std::vector<std::string> result; | |
| std::istringstream iss(res.output); | |
| std::string line; | |
| while (std::getline(iss, line)) { | |
| if (!line.empty() && line.back() == '\r') line.pop_back(); | |
| if (line.empty()) continue; | |
| std::replace(line.begin(), line.end(), '\\', '/'); | |
| if (is_regular_file((fs::path(base) / line).string())) { | |
| result.push_back(line); | |
| } | |
| } | |
| return result; | |
| } | |
| return list_files_fallback(base); | |
| } | |
| exec_result run( | |
| const std::vector<std::string> & args, | |
| size_t max_output, | |
| int timeout_secs, | |
| const std::function<bool(const std::string &)> & on_chunk = nullptr) const override { | |
| exec_result res; | |
| subprocess_s proc; | |
| auto argv = to_cstr_vec(args); | |
| int options = subprocess_option_no_window | |
| | subprocess_option_combined_stdout_stderr | |
| | subprocess_option_inherit_environment | |
| | subprocess_option_search_user_path; | |
| if (subprocess_create(argv.data(), options, &proc) != 0) { | |
| res.output = "failed to spawn process"; | |
| return res; | |
| } | |
| std::atomic<bool> done{false}; | |
| std::atomic<bool> timed_out{false}; | |
| std::thread timeout_thread([&]() { | |
| auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(timeout_secs); | |
| while (!done.load()) { | |
| if (std::chrono::steady_clock::now() >= deadline) { | |
| timed_out.store(true); | |
| subprocess_terminate(&proc); | |
| return; | |
| } | |
| std::this_thread::sleep_for(std::chrono::milliseconds(100)); | |
| } | |
| }); | |
| FILE * f = subprocess_stdout(&proc); | |
| std::string output; | |
| bool truncated = false; | |
| if (f) { | |
| char buf[4096]; | |
| while (fgets(buf, sizeof(buf), f) != nullptr) { | |
| if (!truncated) { | |
| size_t len = strlen(buf); | |
| if (output.size() + len <= max_output) { | |
| output.append(buf, len); | |
| if (on_chunk && !on_chunk(std::string(buf, len))) { | |
| subprocess_terminate(&proc); | |
| break; | |
| } | |
| } else { | |
| size_t remaining = max_output - output.size(); | |
| output.append(buf, remaining); | |
| if (on_chunk && remaining > 0) on_chunk(std::string(buf, remaining)); | |
| truncated = true; | |
| } | |
| } | |
| } | |
| } | |
| done.store(true); | |
| if (timeout_thread.joinable()) { | |
| timeout_thread.join(); | |
| } | |
| subprocess_join(&proc, &res.exit_code); | |
| subprocess_destroy(&proc); | |
| res.output = output; | |
| res.timed_out = timed_out.load(); | |
| if (truncated) { | |
| res.output += "\n[output truncated]"; | |
| } | |
| return res; | |
| } | |
| private: | |
| static std::vector<char *> to_cstr_vec(const std::vector<std::string> & v) { | |
| std::vector<char *> r; | |
| r.reserve(v.size() + 1); | |
| for (const auto & s : v) { | |
| r.push_back(const_cast<char *>(s.c_str())); | |
| } | |
| r.push_back(nullptr); | |
| return r; | |
| } | |
| static const std::unordered_set<std::string> & junk_dir_names() { | |
| static const std::unordered_set<std::string> names = { | |
| ".git", ".svn", ".hg", "node_modules", "__pycache__", | |
| ".venv", "venv", "dist", "build", "target", ".cache", ".idea", ".vscode", | |
| }; | |
| return names; | |
| } | |
| std::vector<std::string> list_files_fallback(const std::string & base) const { | |
| std::vector<std::string> result; | |
| std::error_code ec; | |
| std::vector<std::pair<fs::path, fs::path>> stack; | |
| stack.emplace_back(fs::path(base), fs::path()); | |
| while (!stack.empty()) { | |
| auto [dir, rel_dir] = stack.back(); | |
| stack.pop_back(); | |
| for (const auto & entry : fs::directory_iterator(dir, fs::directory_options::skip_permission_denied, ec)) { | |
| if (ec) break; | |
| std::string fname = entry.path().filename().string(); | |
| std::error_code tec; | |
| if (entry.is_directory(tec)) { | |
| if (junk_dir_names().count(fname) > 0) continue; | |
| stack.emplace_back(entry.path(), rel_dir / fname); | |
| } else if (entry.is_regular_file(tec)) { | |
| std::string rel = (rel_dir / fname).string(); | |
| std::replace(rel.begin(), rel.end(), '\\', '/'); | |
| result.push_back(rel); | |
| } | |
| } | |
| } | |
| return result; | |
| } | |
| }; | |
| static std::unique_ptr<tools_io> make_tools_io(const json & params) { | |
| GGML_UNUSED(params); // TODO in follow-up PR | |
| return std::make_unique<tools_io_basic>(); | |
| } | |
| // no '/' in pattern -> match basename at any depth; else match full relative path | |
| static bool path_glob_match(const std::string & pattern, const std::string & rel_path) { | |
| if (pattern.find('/') == std::string::npos) { | |
| return glob_match(pattern, fs::path(rel_path).filename().string()); | |
| } | |
| if (pattern == "**" || pattern.rfind("**/", 0) == 0 || pattern.rfind('/', 0) == 0) { | |
| return glob_match(pattern, rel_path); | |
| } | |
| return glob_match("**/" + pattern, rel_path); | |
| } | |
| // | |
| // read_file: read a file with optional line range and line-number prefix | |
| // | |
| static constexpr size_t SERVER_TOOL_READ_FILE_MAX_SIZE = 16 * 1024; // 16 KB | |
| struct server_tool_read_file : server_tool { | |
| server_tool_read_file() { | |
| name = "read_file"; | |
| display_name = "Read file"; | |
| permission_write = false; | |
| } | |
| json get_definition() const override { | |
| return { | |
| {"type", "function"}, | |
| {"function", { | |
| {"name", name}, | |
| {"description", "Read the contents of a file. Optionally specify a 1-based line range. " | |
| "If append_loc is true, each line is prefixed with its line number (e.g. \"1\u2192 ...\")."}, | |
| {"parameters", { | |
| {"type", "object"}, | |
| {"properties", { | |
| {"path", {{"type", "string"}, {"description", "Path to the file"}}}, | |
| {"start_line", {{"type", "integer"}, {"description", "First line to read, 1-based (default: 1)"}}}, | |
| {"end_line", {{"type", "integer"}, {"description", "Last line to read, 1-based inclusive (default: end of file)"}}}, | |
| {"append_loc", {{"type", "boolean"}, {"description", "Prefix each line with its line number"}}}, | |
| }}, | |
| {"required", json::array({"path"})}, | |
| }}, | |
| }}, | |
| }; | |
| } | |
| json invoke(json params, server_tool::stream *) const override { | |
| std::string path = params.at("path").get<std::string>(); | |
| int start_line = json_value(params, "start_line", 1); | |
| int end_line = json_value(params, "end_line", -1); // -1 = no limit | |
| bool append_loc = json_value(params, "append_loc", false); | |
| auto io = make_tools_io(params); | |
| uintmax_t file_size = 0; | |
| if (!io->file_size(path, file_size)) { | |
| return {{"error", "cannot stat file: " + path}}; | |
| } | |
| if (file_size > SERVER_TOOL_READ_FILE_MAX_SIZE && end_line == -1) { | |
| return {{"error", string_format( | |
| "file too large (%zu bytes, max %zu). Use start_line/end_line to read a portion.", | |
| (size_t)file_size, SERVER_TOOL_READ_FILE_MAX_SIZE)}}; | |
| } | |
| std::string content; | |
| if (!io->read_file(path, content)) { | |
| return {{"error", "failed to open file: " + path}}; | |
| } | |
| std::istringstream f(content); | |
| std::string result; | |
| std::string line; | |
| int lineno = 0; | |
| while (std::getline(f, line)) { | |
| lineno++; | |
| if (lineno < start_line) continue; | |
| if (end_line != -1 && lineno > end_line) break; | |
| std::string out_line; | |
| if (append_loc) { | |
| out_line = std::to_string(lineno) + "\u2192 " + line + "\n"; | |
| } else { | |
| out_line = line + "\n"; | |
| } | |
| if (result.size() + out_line.size() > SERVER_TOOL_READ_FILE_MAX_SIZE) { | |
| result += "[output truncated]"; | |
| break; | |
| } | |
| result += out_line; | |
| } | |
| return {{"plain_text_response", result}}; | |
| } | |
| }; | |
| // | |
| // file_glob_search: find files matching a glob pattern under a base directory | |
| // | |
| static constexpr size_t SERVER_TOOL_FILE_SEARCH_MAX_RESULTS = 100; | |
| struct server_tool_file_glob_search : server_tool { | |
| server_tool_file_glob_search() { | |
| name = "file_glob_search"; | |
| display_name = "File search"; | |
| permission_write = false; | |
| } | |
| json get_definition() const override { | |
| return { | |
| {"type", "function"}, | |
| {"function", { | |
| {"name", name}, | |
| {"description", | |
| "Recursively search for files matching a glob pattern under a directory. " | |
| "Automatically skips files ignored by .gitignore (when the directory is inside a git repo) " | |
| "and common junk directories (.git, node_modules, build, dist, etc.) otherwise. " | |
| "A pattern with no '/' (e.g. \"*.cpp\") matches the file's basename at any depth. " | |
| "A pattern containing '/' matches the full relative path; unless already anchored with " | |
| "\"**/\" or a leading '/', it is automatically prefixed with \"**/\"."}, | |
| {"parameters", { | |
| {"type", "object"}, | |
| {"properties", { | |
| {"path", {{"type", "string"}, {"description", "Base directory to search in"}}}, | |
| {"include", {{"type", "string"}, {"description", "Glob pattern for files to include (e.g. \"*.cpp\" or \"src/**/*.cpp\"). Default: **"}}}, | |
| {"exclude", {{"type", "string"}, {"description", "Glob pattern for files to exclude"}}}, | |
| }}, | |
| {"required", json::array({"path"})}, | |
| }}, | |
| }}, | |
| }; | |
| } | |
| json invoke(json params, server_tool::stream *) const override { | |
| std::string base = params.at("path").get<std::string>(); | |
| std::string include = json_value(params, "include", std::string("**")); | |
| std::string exclude = json_value(params, "exclude", std::string("")); | |
| auto io = make_tools_io(params); | |
| std::string err; | |
| auto files = io->list_files(base, err); | |
| if (!err.empty()) { | |
| return {{"error", err}}; | |
| } | |
| std::vector<std::string> matches; | |
| for (const auto & rel : files) { | |
| if (!path_glob_match(include, rel)) continue; | |
| if (!exclude.empty() && path_glob_match(exclude, rel)) continue; | |
| matches.push_back(rel); | |
| } | |
| size_t total = matches.size(); | |
| size_t shown = std::min(total, SERVER_TOOL_FILE_SEARCH_MAX_RESULTS); | |
| std::ostringstream output_text; | |
| for (size_t i = 0; i < shown; i++) { | |
| output_text << matches[i] << "\n"; | |
| } | |
| output_text << "\n---\nTotal matches: " << total << "\n"; | |
| if (total > shown) { | |
| output_text << string_format( | |
| "[%zu results limit reached (%zu total matches). Refine the glob pattern to narrow the search.]\n", | |
| shown, total); | |
| } | |
| return {{"plain_text_response", output_text.str()}}; | |
| } | |
| }; | |
| // | |
| // grep_search: search for a regex pattern in files | |
| // | |
| static constexpr size_t SERVER_TOOL_GREP_SEARCH_MAX_RESULTS = 100; | |
| struct server_tool_grep_search : server_tool { | |
| server_tool_grep_search() { | |
| name = "grep_search"; | |
| display_name = "Grep search"; | |
| permission_write = false; | |
| } | |
| json get_definition() const override { | |
| return { | |
| {"type", "function"}, | |
| {"function", { | |
| {"name", name}, | |
| {"description", | |
| "Search for a pattern in files under a path. Returns matching lines with file paths " | |
| "(and, unless searching a single file, paths relative to the given directory). " | |
| "Automatically skips files ignored by .gitignore (when the directory is inside a git repo) " | |
| "and common junk directories (.git, node_modules, build, dist, etc.) otherwise. " | |
| "include/exclude: a pattern with no '/' matches the basename at any depth; a pattern " | |
| "containing '/' matches the full relative path (auto-anchored with \"**/\" unless already anchored)."}, | |
| {"parameters", { | |
| {"type", "object"}, | |
| {"properties", { | |
| {"path", {{"type", "string"}, {"description", "File or directory to search in"}}}, | |
| {"pattern", {{"type", "string"}, {"description", "Pattern to search for (regular expression unless literal is true)"}}}, | |
| {"include", {{"type", "string"}, {"description", "Glob pattern to filter files (default: **)"}}}, | |
| {"exclude", {{"type", "string"}, {"description", "Glob pattern to exclude files"}}}, | |
| {"return_line_numbers", {{"type", "boolean"}, {"description", "If true, include line numbers in results"}}}, | |
| {"literal", {{"type", "boolean"}, {"description", "Treat pattern as a literal string instead of a regular expression (default: false)"}}}, | |
| {"ignore_case", {{"type", "boolean"}, {"description", "Case-insensitive search (default: false)"}}}, | |
| {"context_lines", {{"type", "integer"}, {"description", "Number of lines of context to show before and after each match (default: 0)"}}}, | |
| }}, | |
| {"required", json::array({"path", "pattern"})}, | |
| }}, | |
| }}, | |
| }; | |
| } | |
| json invoke(json params, server_tool::stream *) const override { | |
| std::string path = params.at("path").get<std::string>(); | |
| std::string pat_str = params.at("pattern").get<std::string>(); | |
| std::string include = json_value(params, "include", std::string("**")); | |
| std::string exclude = json_value(params, "exclude", std::string("")); | |
| bool show_lineno = json_value(params, "return_line_numbers", false); | |
| bool literal = json_value(params, "literal", false); | |
| bool ignore_case = json_value(params, "ignore_case", false); | |
| int ctx_lines = std::max(0, json_value(params, "context_lines", 0)); | |
| std::string pattern_src = pat_str; | |
| if (literal) { | |
| static const std::string specials = "\\^$.|?*+()[]{}"; | |
| std::string escaped; | |
| escaped.reserve(pat_str.size() * 2); | |
| for (char c : pat_str) { | |
| if (specials.find(c) != std::string::npos) escaped += '\\'; | |
| escaped += c; | |
| } | |
| pattern_src = escaped; | |
| } | |
| std::regex pattern; | |
| try { | |
| auto flags = std::regex::ECMAScript; | |
| if (ignore_case) flags |= std::regex::icase; | |
| pattern = std::regex(pattern_src, flags); | |
| } catch (const std::regex_error & e) { | |
| return {{"error", std::string("invalid regex: ") + e.what()}}; | |
| } | |
| auto io = make_tools_io(params); | |
| // collect (absolute_path, display_path) pairs to search | |
| std::vector<std::pair<std::string, std::string>> files; | |
| if (io->is_regular_file(path)) { | |
| files.emplace_back(path, path); | |
| } else if (io->is_directory(path)) { | |
| std::string err; | |
| auto candidates = io->list_files(path, err); | |
| if (!err.empty()) { | |
| return {{"error", err}}; | |
| } | |
| for (const auto & rel : candidates) { | |
| if (!path_glob_match(include, rel)) continue; | |
| if (!exclude.empty() && path_glob_match(exclude, rel)) continue; | |
| files.emplace_back((fs::path(path) / rel).string(), rel); | |
| } | |
| } else { | |
| return {{"error", "path does not exist: " + path}}; | |
| } | |
| std::ostringstream output_text; | |
| size_t total = 0; | |
| bool limit_reached = false; | |
| bool show_num = show_lineno || ctx_lines > 0; | |
| for (const auto & file_entry : files) { | |
| if (limit_reached) break; | |
| const std::string & fpath = file_entry.first; | |
| const std::string & display_path = file_entry.second; | |
| std::string content; | |
| if (!io->read_file(fpath, content)) continue; | |
| std::vector<std::string> lines; | |
| { | |
| std::istringstream f(content); | |
| std::string line; | |
| while (std::getline(f, line)) lines.push_back(line); | |
| } | |
| for (size_t i = 0; i < lines.size(); i++) { | |
| if (total >= SERVER_TOOL_GREP_SEARCH_MAX_RESULTS) { | |
| limit_reached = true; | |
| break; | |
| } | |
| if (!std::regex_search(lines[i], pattern)) continue; | |
| long ctx_start = ctx_lines > 0 ? std::max<long>(0, (long) i - ctx_lines) : (long) i; | |
| long ctx_end = ctx_lines > 0 ? std::min<long>((long) lines.size() - 1, (long) i + ctx_lines) : (long) i; | |
| for (long j = ctx_start; j <= ctx_end; j++) { | |
| bool is_match = (j == (long) i); | |
| output_text << display_path << (is_match ? ':' : '-'); | |
| if (show_num) { | |
| output_text << (j + 1) << (is_match ? ':' : '-'); | |
| } | |
| output_text << lines[j] << "\n"; | |
| } | |
| if (ctx_lines > 0) { | |
| output_text << "--\n"; | |
| } | |
| total++; | |
| } | |
| } | |
| output_text << "\n---\nTotal matches: " << total << "\n"; | |
| if (limit_reached) { | |
| output_text << string_format( | |
| "[%zu matches limit reached. Narrow the path/pattern/include to see more.]\n", | |
| SERVER_TOOL_GREP_SEARCH_MAX_RESULTS); | |
| } | |
| return {{"plain_text_response", output_text.str()}}; | |
| } | |
| }; | |
| // | |
| // exec_shell_command: run an arbitrary shell command | |
| // | |
| static constexpr size_t SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_OUTPUT_SIZE = 16 * 1024; // 16 KB | |
| static constexpr int SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_TIMEOUT = 60; // seconds | |
| struct server_tool_exec_shell_command : server_tool { | |
| server_tool_exec_shell_command() { | |
| name = "exec_shell_command"; | |
| display_name = "Execute shell command"; | |
| permission_write = true; | |
| support_stream = true; | |
| } | |
| json get_definition() const override { | |
| return { | |
| {"type", "function"}, | |
| {"function", { | |
| {"name", name}, | |
| {"description", "Execute a shell command and return its output (stdout and stderr combined)."}, | |
| {"parameters", { | |
| {"type", "object"}, | |
| {"properties", { | |
| {"command", {{"type", "string"}, {"description", "Shell command to execute"}}}, | |
| {"timeout", {{"type", "integer"}, {"description", string_format("Timeout in seconds (default 10, max %d)", SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_TIMEOUT)}}}, | |
| {"max_output_size", {{"type", "integer"}, {"description", string_format("Maximum output size in bytes (default %zu)", SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_OUTPUT_SIZE)}}}, | |
| }}, | |
| {"required", json::array({"command"})}, | |
| }}, | |
| }}, | |
| }; | |
| } | |
| json invoke(json params, server_tool::stream * st) const override { | |
| std::string command = params.at("command").get<std::string>(); | |
| int timeout = json_value(params, "timeout", 10); | |
| size_t max_output = (size_t) json_value(params, "max_output_size", (int) SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_OUTPUT_SIZE); | |
| timeout = std::min(timeout, SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_TIMEOUT); | |
| max_output = std::min(max_output, SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_OUTPUT_SIZE); | |
| std::vector<std::string> args = {"cmd", "/c", command}; | |
| std::vector<std::string> args = {"sh", "-c", command}; | |
| auto io = make_tools_io(params); | |
| if (st) { | |
| auto res = io->run(args, max_output, timeout, [st](const std::string & chunk) { | |
| st->push(chunk); | |
| return !st->alive || st->alive(); | |
| }); | |
| if (st->alive && !st->alive()) { | |
| return json(); | |
| } | |
| std::string tail = string_format("\n[exit code: %d]", res.exit_code); | |
| if (res.timed_out) { | |
| tail += " [exit due to timed out]"; | |
| } | |
| st->push(tail); | |
| return json(); | |
| } | |
| auto res = io->run(args, max_output, timeout); | |
| std::string text_output = res.output; | |
| text_output += string_format("\n[exit code: %d]", res.exit_code); | |
| if (res.timed_out) { | |
| text_output += " [exit due to timed out]"; | |
| } | |
| return {{"plain_text_response", text_output}}; | |
| } | |
| }; | |
| // | |
| // write_file: create or overwrite a file | |
| // | |
| struct server_tool_write_file : server_tool { | |
| server_tool_write_file() { | |
| name = "write_file"; | |
| display_name = "Write file"; | |
| permission_write = true; | |
| } | |
| json get_definition() const override { | |
| return { | |
| {"type", "function"}, | |
| {"function", { | |
| {"name", name}, | |
| {"description", "Write content to a file, creating it (including parent directories) if it does not exist. May use with edit_file for more complex edits."}, | |
| {"parameters", { | |
| {"type", "object"}, | |
| {"properties", { | |
| {"path", {{"type", "string"}, {"description", "Path of the file to write"}}}, | |
| {"content", {{"type", "string"}, {"description", "Content to write"}}}, | |
| }}, | |
| {"required", json::array({"path", "content"})}, | |
| }}, | |
| }}, | |
| }; | |
| } | |
| json invoke(json params, server_tool::stream *) const override { | |
| std::string path = params.at("path").get<std::string>(); | |
| std::string content = params.at("content").get<std::string>(); | |
| auto io = make_tools_io(params); | |
| if (!io->write_file(path, content)) { | |
| return {{"error", "failed to write file: " + path}}; | |
| } | |
| return {{"result", "file written successfully"}, {"path", path}, {"bytes", content.size()}}; | |
| } | |
| }; | |
| // | |
| // edit_file: exact text replacement, one or more edits per call | |
| // | |
| struct server_tool_edit_file : server_tool { | |
| server_tool_edit_file() { | |
| name = "edit_file"; | |
| display_name = "Edit file"; | |
| permission_write = true; | |
| } | |
| json get_definition() const override { | |
| return { | |
| {"type", "function"}, | |
| {"function", { | |
| {"name", name}, | |
| {"description", | |
| "Edit a file using exact text replacement. Each edits[].old_text must be unique in the file " | |
| "and is matched against the original content, not incrementally. Merge nearby changes into " | |
| "one edit instead of overlapping edits. Use write_file to replace the whole file."}, | |
| {"parameters", { | |
| {"type", "object"}, | |
| {"properties", { | |
| {"path", {{"type", "string"}, {"description", "Path to the file to edit"}}}, | |
| {"edits", { | |
| {"type", "array"}, | |
| {"description", "One or more exact text replacements to apply"}, | |
| {"items", { | |
| {"type", "object"}, | |
| {"properties", { | |
| {"old_text", {{"type", "string"}, {"description", "Exact text to find; must be unique in the file and must not overlap with other edits"}}}, | |
| {"new_text", {{"type", "string"}, {"description", "Text to replace old_text with"}}}, | |
| }}, | |
| {"required", json::array({"old_text", "new_text"})}, | |
| }}, | |
| }}, | |
| }}, | |
| {"required", json::array({"path", "edits"})}, | |
| }}, | |
| }}, | |
| }; | |
| } | |
| json invoke(json params, server_tool::stream *) const override { | |
| std::string path = params.at("path").get<std::string>(); | |
| const json & edits_json = params.at("edits"); | |
| if (!edits_json.is_array() || edits_json.empty()) { | |
| return {{"error", "\"edits\" must be a non-empty array"}}; | |
| } | |
| struct edit_req { | |
| std::string old_text; | |
| std::string new_text; | |
| }; | |
| std::vector<edit_req> edits; | |
| edits.reserve(edits_json.size()); | |
| for (const auto & e : edits_json) { | |
| edit_req er; | |
| er.old_text = e.at("old_text").get<std::string>(); | |
| er.new_text = e.at("new_text").get<std::string>(); | |
| if (er.old_text.empty()) { | |
| return {{"error", string_format("edits[%zu].old_text must not be empty", edits.size())}}; | |
| } | |
| edits.push_back(std::move(er)); | |
| } | |
| auto io = make_tools_io(params); | |
| std::string original_content; | |
| if (!io->read_file(path, original_content)) { | |
| return {{"error", "failed to open file: " + path}}; | |
| } | |
| // does any old_text need fuzzy matching (no exact match found)? | |
| bool any_fuzzy = false; | |
| for (size_t i = 0; i < edits.size(); i++) { | |
| if (original_content.find(edits[i].old_text) != std::string::npos) continue; | |
| std::string fuzzy_content = normalize_for_fuzzy_match(original_content); | |
| std::string fuzzy_old = normalize_for_fuzzy_match(edits[i].old_text); | |
| if (fuzzy_content.find(fuzzy_old) == std::string::npos) { | |
| return {{"error", string_format( | |
| "could not find edits[%zu].old_text in %s, it must match the file's current content exactly", | |
| i, path.c_str())}}; | |
| } | |
| any_fuzzy = true; | |
| } | |
| std::string base_content = any_fuzzy ? normalize_for_fuzzy_match(original_content) : original_content; | |
| // uniqueness check always uses fuzzy-normalized text, so a whitespace-only duplicate still counts | |
| std::vector<matched_edit> matched; | |
| matched.reserve(edits.size()); | |
| for (size_t i = 0; i < edits.size(); i++) { | |
| std::string needle = any_fuzzy ? normalize_for_fuzzy_match(edits[i].old_text) : edits[i].old_text; | |
| size_t occurrences = count_occurrences( | |
| normalize_for_fuzzy_match(original_content), | |
| normalize_for_fuzzy_match(edits[i].old_text)); | |
| if (occurrences > 1) { | |
| return {{"error", string_format( | |
| "found %zu occurrences of edits[%zu].old_text in %s, it must be unique", | |
| occurrences, i, path.c_str())}}; | |
| } | |
| size_t idx = base_content.find(needle); | |
| matched.push_back({i, idx, needle.size(), edits[i].new_text}); | |
| } | |
| std::sort(matched.begin(), matched.end(), [](const matched_edit & a, const matched_edit & b) { | |
| return a.match_index < b.match_index; | |
| }); | |
| for (size_t i = 1; i < matched.size(); i++) { | |
| if (matched[i - 1].match_index + matched[i - 1].match_length > matched[i].match_index) { | |
| return {{"error", string_format( | |
| "edits[%zu] and edits[%zu] overlap in %s; merge them into one edit or target disjoint regions", | |
| matched[i - 1].edit_index, matched[i].edit_index, path.c_str())}}; | |
| } | |
| } | |
| std::string new_content = any_fuzzy | |
| ? apply_replacements_preserving_unchanged_lines(original_content, base_content, matched) | |
| : apply_replacements(base_content, matched, 0); | |
| if (new_content == original_content) { | |
| return {{"error", "no changes made: the replacement(s) produced identical content"}}; | |
| } | |
| if (!io->write_file(path, new_content)) { | |
| return {{"error", "failed to write file: " + path}}; | |
| } | |
| return {{"result", "file edited successfully"}, {"path", path}, {"edits_applied", (int) matched.size()}}; | |
| } | |
| private: | |
| // strip trailing whitespace, normalize smart quotes/dashes/spaces to ASCII | |
| static std::string normalize_line_for_fuzzy_match(const std::string & line) { | |
| size_t end = line.size(); | |
| while (end > 0 && (line[end - 1] == ' ' || line[end - 1] == '\t' || line[end - 1] == '\r')) { | |
| end--; | |
| } | |
| std::string s = line.substr(0, end); | |
| auto replace_all = [](std::string & str, const std::string & from, const std::string & to) { | |
| if (from.empty()) return; | |
| size_t pos = 0; | |
| while ((pos = str.find(from, pos)) != std::string::npos) { | |
| str.replace(pos, from.size(), to); | |
| pos += to.size(); | |
| } | |
| }; | |
| // smart single quotes -> ' | |
| for (unsigned char b : {0x98, 0x99, 0x9A, 0x9B}) { | |
| replace_all(s, std::string("\xE2\x80") + (char) b, "'"); | |
| } | |
| // smart double quotes -> " | |
| for (unsigned char b : {0x9C, 0x9D, 0x9E, 0x9F}) { | |
| replace_all(s, std::string("\xE2\x80") + (char) b, "\""); | |
| } | |
| // various dashes -> - | |
| for (unsigned char b = 0x90; b <= 0x95; b++) { | |
| replace_all(s, std::string("\xE2\x80") + (char) b, "-"); | |
| } | |
| replace_all(s, "\xE2\x88\x92", "-"); // minus sign | |
| // special spaces -> ' ' | |
| replace_all(s, "\xC2\xA0", " "); // no-break space | |
| for (unsigned char b = 0x82; b <= 0x8A; b++) { | |
| replace_all(s, std::string("\xE2\x80") + (char) b, " "); | |
| } | |
| replace_all(s, "\xE2\x80\xAF", " "); // narrow no-break space | |
| replace_all(s, "\xE2\x81\x9F", " "); // medium mathematical space | |
| replace_all(s, "\xE3\x80\x80", " "); // ideographic space | |
| return s; | |
| } | |
| // applies the per-line transform above to every line; preserves line count/positions | |
| static std::string normalize_for_fuzzy_match(const std::string & content) { | |
| std::string result; | |
| result.reserve(content.size()); | |
| size_t start = 0; | |
| while (true) { | |
| size_t nl = content.find('\n', start); | |
| bool is_last = nl == std::string::npos; | |
| std::string line = is_last ? content.substr(start) : content.substr(start, nl - start); | |
| result += normalize_line_for_fuzzy_match(line); | |
| if (is_last) break; | |
| result += '\n'; | |
| start = nl + 1; | |
| } | |
| return result; | |
| } | |
| // lines with trailing '\n' kept, so untouched ones can be reconstructed verbatim | |
| static std::vector<std::string> split_lines_with_endings(const std::string & content) { | |
| std::vector<std::string> lines; | |
| size_t start = 0; | |
| while (start < content.size()) { | |
| size_t nl = content.find('\n', start); | |
| if (nl == std::string::npos) { | |
| lines.push_back(content.substr(start)); | |
| break; | |
| } | |
| lines.push_back(content.substr(start, nl - start + 1)); | |
| start = nl + 1; | |
| } | |
| return lines; | |
| } | |
| struct line_span { | |
| size_t start; | |
| size_t end; | |
| }; | |
| static std::vector<line_span> get_line_spans(const std::string & content) { | |
| std::vector<line_span> spans; | |
| size_t offset = 0; | |
| for (const auto & line : split_lines_with_endings(content)) { | |
| spans.push_back({offset, offset + line.size()}); | |
| offset += line.size(); | |
| } | |
| return spans; | |
| } | |
| // count non-overlapping occurrences of `needle` in `content` | |
| static size_t count_occurrences(const std::string & content, const std::string & needle) { | |
| if (needle.empty()) return 0; | |
| size_t count = 0, pos = 0; | |
| while ((pos = content.find(needle, pos)) != std::string::npos) { | |
| count++; | |
| pos += needle.size(); | |
| } | |
| return count; | |
| } | |
| struct matched_edit { | |
| size_t edit_index; | |
| size_t match_index; // offset into the "base content" (see below) | |
| size_t match_length; | |
| std::string new_text; | |
| }; | |
| // replacements must be sorted ascending by match_index and non-overlapping | |
| static std::string apply_replacements( | |
| const std::string & content, | |
| const std::vector<matched_edit> & replacements, | |
| size_t offset) { | |
| std::string result = content; | |
| for (auto it = replacements.rbegin(); it != replacements.rend(); ++it) { | |
| size_t local_index = it->match_index - offset; | |
| result = result.substr(0, local_index) + it->new_text + result.substr(local_index + it->match_length); | |
| } | |
| return result; | |
| } | |
| // widen a replacement's byte range to the line(s) of `lines` it touches | |
| static bool get_replacement_line_range( | |
| const std::vector<line_span> & lines, | |
| size_t match_index, size_t match_length, | |
| size_t & out_start_line, size_t & out_end_line /* exclusive */) { | |
| size_t replacement_start = match_index; | |
| size_t replacement_end = match_index + match_length; | |
| size_t start_line = (size_t) -1; | |
| for (size_t i = 0; i < lines.size(); i++) { | |
| if (replacement_start >= lines[i].start && replacement_start < lines[i].end) { | |
| start_line = i; | |
| break; | |
| } | |
| } | |
| if (start_line == (size_t) -1) return false; | |
| size_t end_line = start_line; | |
| while (end_line < lines.size() && lines[end_line].end < replacement_end) { | |
| end_line++; | |
| } | |
| if (end_line >= lines.size()) return false; | |
| out_start_line = start_line; | |
| out_end_line = end_line + 1; | |
| return true; | |
| } | |
| // like apply_replacements, but untouched lines come from `original_content` | |
| static std::string apply_replacements_preserving_unchanged_lines( | |
| const std::string & original_content, | |
| const std::string & base_content, | |
| const std::vector<matched_edit> & replacements /* ascending, non-overlapping */) { | |
| auto original_lines = split_lines_with_endings(original_content); | |
| auto base_lines = get_line_spans(base_content); | |
| struct group { | |
| size_t start_line; | |
| size_t end_line; // exclusive | |
| std::vector<matched_edit> reps; | |
| }; | |
| std::vector<group> groups; | |
| for (const auto & rep : replacements) { | |
| size_t start_line = 0, end_line = 0; | |
| get_replacement_line_range(base_lines, rep.match_index, rep.match_length, start_line, end_line); | |
| if (!groups.empty() && start_line < groups.back().end_line) { | |
| groups.back().end_line = std::max(groups.back().end_line, end_line); | |
| groups.back().reps.push_back(rep); | |
| } else { | |
| groups.push_back({start_line, end_line, {rep}}); | |
| } | |
| } | |
| size_t original_line_index = 0; | |
| std::string result; | |
| for (auto & g : groups) { | |
| for (size_t i = original_line_index; i < g.start_line; i++) { | |
| result += original_lines[i]; | |
| } | |
| size_t group_start_offset = base_lines[g.start_line].start; | |
| size_t group_end_offset = base_lines[g.end_line - 1].end; | |
| std::string slice = base_content.substr(group_start_offset, group_end_offset - group_start_offset); | |
| result += apply_replacements(slice, g.reps, group_start_offset); | |
| original_line_index = g.end_line; | |
| } | |
| for (size_t i = original_line_index; i < original_lines.size(); i++) { | |
| result += original_lines[i]; | |
| } | |
| return result; | |
| } | |
| }; | |
| // | |
| // get_datetime: returns the current date and time | |
| // | |
| struct server_tool_get_datetime : server_tool { | |
| server_tool_get_datetime() { | |
| name = "get_datetime"; | |
| display_name = "Get Date & Time"; | |
| permission_write = false; | |
| } | |
| json get_definition() const override { | |
| return { | |
| {"type", "function"}, | |
| {"function", { | |
| {"name", name}, | |
| {"description", "Returns the current date and time"}, | |
| }}, | |
| }; | |
| } | |
| json invoke(json, server_tool::stream *) const override { | |
| auto now = std::chrono::system_clock::now(); | |
| auto time = std::chrono::system_clock::to_time_t(now); | |
| return {{"result", std::ctime(&time)}}; | |
| } | |
| }; | |
| struct server_tool_stream_result : server_task_result { | |
| std::string chunk; | |
| bool done = false; | |
| std::string error_msg; | |
| json to_json() override { | |
| if (!done) { | |
| return {{"chunk", chunk}}; | |
| } else { | |
| json result = {{"done", true}}; | |
| if (!error_msg.empty()) { | |
| result["error"] = error_msg; | |
| } | |
| return result; | |
| } | |
| } | |
| }; | |
| void server_tool::stream::push(const std::string & chunk) { | |
| if (chunk.empty()) return; | |
| auto r = std::make_unique<server_tool_stream_result>(); | |
| r->id = id; | |
| r->chunk = chunk; | |
| qr.send(std::move(r)); | |
| } | |
| struct server_tools_res : server_http_res { | |
| std::thread worker; | |
| server_response * qr = nullptr; // set only for streaming responses | |
| int id = -1; | |
| ~server_tools_res() override { | |
| if (worker.joinable()) { | |
| worker.join(); | |
| } | |
| if (qr) { | |
| qr->remove_waiting_task_id(id); | |
| } | |
| } | |
| }; | |
| static server_tool & find_tool(std::vector<std::unique_ptr<server_tool>> & tools, const std::string & name, bool require_stream) { | |
| for (auto & t : tools) { | |
| if (t->name == name) { | |
| if (require_stream && !t->support_stream) { | |
| throw std::invalid_argument(string_format("tool \"%s\" does not support stream = true", name.c_str())); | |
| } | |
| return *t; | |
| } | |
| } | |
| throw std::invalid_argument(string_format("unknown tool \"%s\"", name.c_str())); | |
| } | |
| // | |
| // public API | |
| // | |
| static std::vector<std::unique_ptr<server_tool>> build_tools() { | |
| std::vector<std::unique_ptr<server_tool>> tools; | |
| tools.push_back(std::make_unique<server_tool_read_file>()); | |
| tools.push_back(std::make_unique<server_tool_file_glob_search>()); | |
| tools.push_back(std::make_unique<server_tool_grep_search>()); | |
| tools.push_back(std::make_unique<server_tool_exec_shell_command>()); | |
| tools.push_back(std::make_unique<server_tool_write_file>()); | |
| tools.push_back(std::make_unique<server_tool_edit_file>()); | |
| tools.push_back(std::make_unique<server_tool_get_datetime>()); | |
| return tools; | |
| } | |
| void server_tools::setup(const std::vector<std::string> & enabled_tools) { | |
| if (!enabled_tools.empty()) { | |
| std::unordered_set<std::string> enabled_set(enabled_tools.begin(), enabled_tools.end()); | |
| auto all_tools = build_tools(); | |
| // collect all known tool names for validation | |
| std::vector<std::string> known_names; | |
| known_names.reserve(all_tools.size()); | |
| for (const auto & t : all_tools) { | |
| known_names.push_back(t->name); | |
| } | |
| // validate that every requested tool is known | |
| for (const auto & name : enabled_tools) { | |
| if (name == "all") continue; | |
| if (std::find(known_names.begin(), known_names.end(), name) == known_names.end()) { | |
| throw std::runtime_error(string_format( | |
| "unknown tool \"%s\". available tools: %s", | |
| name.c_str(), | |
| string_join(known_names, ", ").c_str())); | |
| } | |
| } | |
| tools.clear(); | |
| for (auto & t : all_tools) { | |
| if (enabled_set.count(t->name) > 0 || enabled_set.count("all") > 0) { | |
| tools.push_back(std::move(t)); | |
| } | |
| } | |
| } | |
| handle_get = [this](const server_http_req &) -> server_http_res_ptr { | |
| auto res = std::make_unique<server_http_res>(); | |
| try { | |
| json result = json::array(); | |
| for (const auto & t : tools) { | |
| result.push_back(t->to_json()); | |
| } | |
| res->data = safe_json_to_str(result); | |
| } catch (const std::exception & e) { | |
| SRV_ERR("got exception: %s\n", e.what()); | |
| res->status = 500; | |
| res->data = safe_json_to_str(format_error_response(e.what(), ERROR_TYPE_SERVER)); | |
| } | |
| return res; | |
| }; | |
| handle_post = [this](const server_http_req & req) -> server_http_res_ptr { | |
| auto res = std::make_unique<server_tools_res>(); | |
| try { | |
| json body = json::parse(req.body); | |
| std::string tool_name = body.at("tool").get<std::string>(); | |
| json params = body.value("params", json::object()); | |
| bool stream = body.value("stream", false); | |
| server_tool & tool = find_tool(tools, tool_name, stream); | |
| if (stream) { | |
| int id = res_id.fetch_add(1); | |
| queue_res.add_waiting_task_id(id); | |
| res->qr = &queue_res; | |
| res->id = id; | |
| res->worker = std::thread([this, id, &req, &tool, params]() mutable { | |
| server_tool::stream st{queue_res, id, [&req]() { | |
| return !req.should_stop(); | |
| }}; | |
| auto done = std::make_unique<server_tool_stream_result>(); | |
| try { | |
| tool.invoke(params, &st); | |
| } catch (const std::exception & e) { | |
| done->error_msg = e.what(); | |
| } catch (...) { | |
| done->error_msg = "An unknown error occurred"; | |
| } | |
| done->id = st.id; | |
| done->done = true; | |
| st.qr.send(std::move(done)); | |
| }); | |
| res->content_type = "text/event-stream"; | |
| res->status = 200; | |
| res->next = [this, id](std::string & output) -> bool { | |
| auto result = queue_res.recv(id); | |
| auto * r = dynamic_cast<server_tool_stream_result *>(result.get()); | |
| GGML_ASSERT(r != nullptr); | |
| output = "data: " + safe_json_to_str(r->to_json()) + "\n\n"; | |
| if (r->done) { | |
| queue_res.remove_waiting_task_id(id); | |
| return false; | |
| } | |
| return true; | |
| }; | |
| } else { | |
| json result = tool.invoke(params, nullptr); | |
| res->status = 200; | |
| res->data = safe_json_to_str(result); | |
| } | |
| } catch (const json::exception & e) { | |
| res->status = 400; | |
| res->data = safe_json_to_str(format_error_response(e.what(), ERROR_TYPE_INVALID_REQUEST)); | |
| } catch (const std::invalid_argument & e) { | |
| res->status = 404; | |
| res->data = safe_json_to_str(format_error_response(e.what(), ERROR_TYPE_INVALID_REQUEST)); | |
| } catch (const std::exception & e) { | |
| SRV_ERR("got exception: %s\n", e.what()); | |
| res->status = 500; | |
| res->data = safe_json_to_str(format_error_response(e.what(), ERROR_TYPE_SERVER)); | |
| } | |
| return res; | |
| }; | |
| } | |