char-fork/llama.cpp

git clone https://git.t4t.associates/char-fork/llama.cpp

Xuan-Son Nguyencommon: add json.h abstraction (#27511)d9f918d2d

char/aliceai-t5-moe
86.1 KiB2172 linesraw
1#include "server-tools.h"
2
3#include "subproc.h"
4#include "base64.hpp"
5
6#include <filesystem>
7#include <fstream>
8#include <regex>
9#include <thread>
10#include <chrono>
11#include <atomic>
12#include <cstring>
13#include <cctype>
14#include <cstdint>
15#include <cstdlib>
16#include <algorithm>
17#include <iterator>
18#include <unordered_set>
19#include <tuple>
20#include <functional>
21#include <memory>
22#include <mutex>
23
24#if defined(_WIN32)
25#   ifndef NOMINMAX
26#       define NOMINMAX
27#   endif
28#   include <windows.h>
29#   include <fcntl.h>
30#   include <io.h>
31#else
32#   include <cerrno>
33#   include <unistd.h>
34#endif
35
36namespace fs = std::filesystem;
37
38//
39// internal helpers
40//
41
42// a child process writes in the OEM code page, so accented output would reach
43// the JSON layer as invalid bytes. run() spawns without a console, so the
44// console code page never applies
45static std::string console_output_to_utf8(const std::string & text) {
46#if defined(_WIN32)
47    // a chunk can end mid sequence, so the incomplete tail is dropped first
48    if (text.empty() || is_valid_utf8(text.substr(0, validate_utf8(text)))) {
49        // never decode twice a child that already emits UTF-8
50        return text;
51    }
52
53    const UINT cp = GetOEMCP();
54
55    // fail rather than emit replacement characters when the code page is wrong
56    const int wide_len = MultiByteToWideChar(cp, MB_ERR_INVALID_CHARS, text.data(), (int) text.size(), nullptr, 0);
57    if (wide_len <= 0) {
58        return text;
59    }
60    std::wstring wide(wide_len, L'\0');
61    MultiByteToWideChar(cp, MB_ERR_INVALID_CHARS, text.data(), (int) text.size(), wide.data(), wide_len);
62
63    const int utf8_len = WideCharToMultiByte(CP_UTF8, 0, wide.data(), wide_len, nullptr, 0, nullptr, nullptr);
64    if (utf8_len <= 0) {
65        return text;
66    }
67    std::string utf8(utf8_len, '\0');
68    WideCharToMultiByte(CP_UTF8, 0, wide.data(), wide_len, utf8.data(), utf8_len, nullptr, nullptr);
69    return utf8;
70#else
71    return text;
72#endif
73}
74
75json server_tool::to_json() const {
76    return {
77        {"display_name", display_name},
78        {"tool", name},
79        {"type", type()},
80        {"permissions", json{
81            {"write", permission_write}
82        }},
83        {"uses_cwd", uses_cwd},
84        {"definition", get_definition()},
85    };
86}
87
88static constexpr size_t SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT = 8 * 1024 * 1024; // 8 MB
89// budget for one listing call, shared by the git and walker paths
90static constexpr int SERVER_TOOL_LIST_ENTRIES_TIMEOUT = 15; // seconds
91
92// entry kinds a directory listing may return
93enum class list_kind {
94    files, // regular files only
95    dirs,  // directories only
96    all,   // both
97};
98
99// a narrow path uses the active code page on Windows, so every crossing between
100// a std::string (always UTF-8 here) and fs::path is converted explicitly
101static fs::path path_from_utf8(const std::string & s) {
102    return fs::u8path(s);
103}
104
105// '/' separators on every platform: Windows accepts them, the web UI needs them
106static std::string path_to_utf8(const fs::path & p) {
107    const auto s = p.generic_u8string();
108    return std::string(s.begin(), s.end());
109}
110
111// home directory, read once at first use (getenv is not thread safe against setenv)
112static const std::string & home_dir() {
113    static const std::string home = [] {
114#ifdef _WIN32
115        // the narrow getenv would return the profile path in the active code page
116        const wchar_t * w = _wgetenv(L"HOME");
117        if (w == nullptr) w = _wgetenv(L"USERPROFILE");
118        return w ? path_to_utf8(fs::path(w)) : std::string();
119#else
120        const char * h = getenv("HOME");
121        return h ? std::string(h) : std::string();
122#endif
123    }();
124    return home;
125}
126
127static std::string expand_home(const std::string & path) {
128    if (path.empty() || path[0] != '~') return path;
129    if (path.size() > 1 && path[1] != '/' && path[1] != '\\') return path;
130    const std::string & home = home_dir();
131    if (home.empty()) return path;
132    return home + path.substr(1);
133}
134
135// depth of a '/'-separated relative path: "a/b/c" is 3
136static int entry_depth(const std::string & rel) {
137    return 1 + (int) std::count(rel.begin(), rel.end(), '/');
138}
139
140// directories that a listing reports but never descends into: they can be enormous
141// lowercase only, the local walker case-folds a name before the lookup
142static const char * const SERVER_TOOL_JUNK_DIR_NAMES[] = {
143    ".git", ".svn", ".hg", "node_modules", "__pycache__",
144    ".venv", "venv", "dist", "build", "target", ".cache", ".idea", ".vscode",
145};
146
147class tools_io {
148public:
149    struct exec_result {
150        std::string output;
151        int  exit_code = -1;
152        bool timed_out = false;
153    };
154
155    virtual ~tools_io() = default;
156
157    virtual bool is_directory(const std::string & path) const = 0;
158    virtual bool is_regular_file(const std::string & path) const = 0;
159    virtual bool file_size(const std::string & path, uintmax_t & out_size) const = 0;
160    virtual bool read_file(const std::string & path, std::string & out) const = 0;
161    virtual bool write_file(const std::string & path, const std::string & content) const = 0;
162    // resolve `path` against the IO's working directory; absolute paths are returned unchanged
163    virtual std::string resolve(const std::string & path) const = 0;
164    struct list_entry {
165        std::string rel; // '/'-separated, relative to `base`
166        bool is_dir = false;
167    };
168    struct list_result {
169        std::vector<list_entry> entries;
170        std::string err;        // set when `base` is not a directory
171        bool truncated = false; // set when the walk could not see everything
172    };
173    // entries relative to `base`, which must already be resolved (absolute)
174    // max_depth == 0 means unlimited, 1 means direct children of `base` only
175    virtual list_result list_entries(const std::string & base, int max_depth, list_kind kind) const = 0;
176    // on_chunk, if set, is called with each chunk of output as it is read (before truncation cuts in);
177    // returning false terminates the process early (e.g. the client disconnected)
178    virtual exec_result run(
179            const std::vector<std::string> & args,
180            size_t max_output,
181            int timeout_secs,
182            const std::function<bool(const std::string &)> & on_chunk = nullptr) const = 0;
183};
184
185// shared subprocess execution helper, used by both the local and the isolate-backed tools_io implementations.
186// combine_stderr=false when the raw stdout bytes must not be tainted by stderr, e.g. reading file contents.
187static tools_io::exec_result run_subprocess(
188        const std::vector<std::string> & args,
189        size_t max_output,
190        int timeout_secs,
191        const std::function<bool(const std::string &)> & on_chunk,
192        bool combine_stderr,
193        const std::string & cwd = "",
194        const std::string * stdin_data = nullptr) {
195    tools_io::exec_result res;
196
197    common_subproc proc;
198
199    int options = subprocess_option_no_window
200                | subprocess_option_inherit_environment
201                | subprocess_option_search_user_path;
202    if (combine_stderr) {
203        options |= subprocess_option_combined_stdout_stderr;
204    }
205
206    if (!proc.create(args, options, {}, cwd.empty() ? nullptr : cwd.c_str())) {
207        res.output = "failed to spawn process";
208        return res;
209    }
210
211    std::atomic<bool> done{false};
212    std::atomic<bool> timed_out{false};
213
214    std::thread timeout_thread([&]() {
215        auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(timeout_secs);
216        while (!done.load()) {
217            if (std::chrono::steady_clock::now() >= deadline) {
218                timed_out.store(true);
219                proc.terminate();
220                return;
221            }
222            std::this_thread::sleep_for(std::chrono::milliseconds(100));
223        }
224    });
225
226    // write stdin before reading stdout, the child drains stdin as it goes
227    // always close stdin, a transport client waits forever if its stdin pipe stays open
228    if (FILE * in = proc.stdin_file()) {
229        if (stdin_data != nullptr && !stdin_data->empty()) {
230#if defined(_WIN32)
231            // pipe fds default to CRT text mode: binary keeps the bytes untranslated
232            _setmode(_fileno(in), _O_BINARY);
233#endif
234            // a short write is not an error by itself, the exit code below decides
235            fwrite(stdin_data->data(), 1, stdin_data->size(), in);
236        }
237        fflush(in);
238    }
239    proc.close_stdin();
240
241    FILE * f = proc.stdout_file();
242    std::string output;
243    bool truncated = false;
244    if (f) {
245#if defined(_WIN32)
246        // pipe fds default to CRT text mode: binary keeps the bytes untranslated
247        _setmode(_fileno(f), _O_BINARY);
248#endif
249        // read raw bytes, not lines: the output can hold NUL and must arrive as soon as it is ready
250        // keep draining past the size cap, else the child blocks on a full pipe
251        char buf[4096];
252        for (;;) {
253#if defined(_WIN32)
254            const int n = _read(_fileno(f), buf, (unsigned) sizeof(buf));
255#else
256            ssize_t n = read(fileno(f), buf, sizeof(buf));
257            while (n < 0 && errno == EINTR) {
258                n = read(fileno(f), buf, sizeof(buf));
259            }
260#endif
261            if (n <= 0) {
262                break;
263            }
264            if (truncated) {
265                continue;
266            }
267            const size_t len = (size_t) n;
268            if (output.size() + len <= max_output) {
269                output.append(buf, len);
270                if (on_chunk && !on_chunk(console_output_to_utf8(std::string(buf, len)))) {
271                    proc.terminate();
272                    break;
273                }
274            } else {
275                size_t remaining = max_output - output.size();
276                output.append(buf, remaining);
277                if (on_chunk && remaining > 0) on_chunk(console_output_to_utf8(std::string(buf, remaining)));
278                truncated = true;
279            }
280        }
281    }
282
283    done.store(true);
284    if (timeout_thread.joinable()) {
285        timeout_thread.join();
286    }
287
288    res.exit_code = proc.join();
289
290    res.output    = console_output_to_utf8(output);
291    res.timed_out = timed_out.load();
292    if (truncated) {
293        res.output += "\n[output truncated]";
294    }
295    return res;
296}
297
298class tools_io_basic : public tools_io {
299public:
300    // cwd, if non-empty, is used to resolve relative paths and as the working directory for run()
301    explicit tools_io_basic(std::string cwd = "") : cwd(std::move(cwd)) {}
302
303    // expands a leading `~`, then resolves `path` against `cwd` (or the server
304    // working directory when `cwd` is unset); the result is always absolute
305    std::string resolve(const std::string & path) const override {
306        const std::string p = expand_home(path);
307
308        fs::path full = path_from_utf8(p);
309        if (!full.is_absolute()) {
310            if (cwd.empty()) {
311                std::error_code ec;
312                const fs::path cur = fs::current_path(ec);
313                if (ec) return p;
314                full = cur / full;
315            } else {
316                full = path_from_utf8(cwd) / full;
317            }
318        }
319
320        // drop "." and ".." so they never reach git or the client
321        full = full.lexically_normal();
322        // a trailing ".." normalizes to a path that ends with a separator
323        if (!full.has_filename() && full != full.root_path()) {
324            full = full.parent_path();
325        }
326        return path_to_utf8(full);
327    }
328
329    bool is_directory(const std::string & path) const override {
330        std::error_code ec;
331        return fs::is_directory(path_from_utf8(resolve(path)), ec) && !ec;
332    }
333
334    bool is_regular_file(const std::string & path) const override {
335        std::error_code ec;
336        return fs::is_regular_file(path_from_utf8(resolve(path)), ec) && !ec;
337    }
338
339    bool file_size(const std::string & path, uintmax_t & out_size) const override {
340        std::error_code ec;
341        out_size = fs::file_size(path_from_utf8(resolve(path)), ec);
342        return !ec;
343    }
344
345    bool read_file(const std::string & path, std::string & out) const override {
346        std::ifstream f(path_from_utf8(resolve(path)), std::ios::binary);
347        if (!f) return false;
348        std::ostringstream ss;
349        ss << f.rdbuf();
350        out = ss.str();
351        return true;
352    }
353
354    bool write_file(const std::string & path, const std::string & content) const override {
355        std::error_code ec;
356        fs::path fpath = path_from_utf8(resolve(path));
357        if (fpath.has_parent_path()) {
358            fs::create_directories(fpath.parent_path(), ec);
359            if (ec) return false;
360        }
361        std::ofstream f(fpath, std::ios::binary);
362        if (!f) return false;
363        f << content;
364        return (bool) f;
365    }
366
367    list_result list_entries(const std::string & base, int max_depth, list_kind kind) const override {
368        list_result out;
369
370        std::error_code ec;
371        if (!fs::is_directory(base, ec) || ec) {
372            out.err = "path does not exist or is not a directory";
373            return out;
374        }
375
376        const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(SERVER_TOOL_LIST_ENTRIES_TIMEOUT);
377
378        // git ls-files cannot list directories; use the walker when they are requested
379        if (kind == list_kind::files) {
380            auto res = run(
381                {"git", "-C", base, "ls-files", "--cached", "--others", "--exclude-standard"},
382                SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT, SERVER_TOOL_LIST_ENTRIES_TIMEOUT);
383
384            if (res.exit_code == 0 && !res.timed_out) {
385                std::istringstream iss(res.output);
386                std::string line;
387                while (std::getline(iss, line)) {
388                    if (!line.empty() && line.back() == '\r') line.pop_back();
389                    if (line.empty()) continue;
390                    std::replace(line.begin(), line.end(), '\\', '/');
391                    if (max_depth > 0 && entry_depth(line) > max_depth) continue;
392                    if (is_regular_file(path_to_utf8(path_from_utf8(base) / path_from_utf8(line)))) {
393                        out.entries.push_back({line, false});
394                    }
395                }
396                return out;
397            }
398        }
399
400        out.entries = list_entries_fallback(base, max_depth, kind, deadline, out.truncated);
401        return out;
402    }
403
404    exec_result run(
405            const std::vector<std::string> & args,
406            size_t max_output,
407            int timeout_secs,
408            const std::function<bool(const std::string &)> & on_chunk = nullptr) const override {
409        return run_subprocess(args, max_output, timeout_secs, on_chunk, /*combine_stderr=*/true, cwd);
410    }
411
412private:
413    std::string cwd;
414
415    // a link can point back to an ancestor and loop forever, so it is never walked
416    static bool is_link(const fs::directory_entry & entry) {
417        std::error_code ec;
418        if (entry.is_symlink(ec) || ec) {
419            return true;
420        }
421#if defined(_WIN32)
422        // a junction looks like a plain directory to std::filesystem, so read the reparse tag
423        WIN32_FIND_DATAW data;
424        const HANDLE h = FindFirstFileW(entry.path().c_str(), &data);
425        if (h == INVALID_HANDLE_VALUE) {
426            return false;
427        }
428        FindClose(h);
429        if ((data.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0) {
430            return false;
431        }
432        // other reparse points (cloud placeholder, dedup stub) are real directories
433        return data.dwReserved0 == IO_REPARSE_TAG_SYMLINK || data.dwReserved0 == IO_REPARSE_TAG_MOUNT_POINT;
434#else
435        return false;
436#endif
437    }
438
439    // NTFS is case insensitive, so Build and build are the same directory
440    static std::string get_effective_name(const std::string & fname) {
441#if defined(_WIN32)
442        std::string lowered = fname;
443        std::transform(lowered.begin(), lowered.end(), lowered.begin(),
444                       [](unsigned char c) { return (char) std::tolower(c); });
445        return lowered;
446#else
447        return fname;
448#endif
449    }
450
451    static const std::unordered_set<std::string> & junk_dir_names() {
452        static const std::unordered_set<std::string> names(
453            std::begin(SERVER_TOOL_JUNK_DIR_NAMES), std::end(SERVER_TOOL_JUNK_DIR_NAMES));
454        return names;
455    }
456
457    std::vector<list_entry> list_entries_fallback(const std::string & base, int max_depth, list_kind kind,
458                                                  std::chrono::steady_clock::time_point deadline, bool & truncated) const {
459        std::vector<list_entry> result;
460
461        std::vector<std::tuple<fs::path, fs::path, int>> stack;
462        stack.emplace_back(path_from_utf8(base), fs::path(), 0);
463
464        while (!stack.empty()) {
465            if (std::chrono::steady_clock::now() >= deadline) {
466                truncated = true;
467                return result;
468            }
469
470            auto [dir, rel_dir, depth] = std::move(stack.back());
471            stack.pop_back();
472
473            std::error_code ec;
474            // step the iterator by hand: the throwing increment escapes on a directory that goes away
475            fs::directory_iterator it(dir, fs::directory_options::skip_permission_denied, ec);
476            // permission errors are skipped above, so this is a subtree the caller never sees
477            if (ec) {
478                truncated = true;
479                continue;
480            }
481            for (const fs::directory_iterator end; it != end; it.increment(ec)) {
482                if (ec) {
483                    truncated = true;
484                    break;
485                }
486                if (std::chrono::steady_clock::now() >= deadline) {
487                    truncated = true;
488                    return result;
489                }
490                const fs::directory_entry & entry = *it;
491                const fs::path fname = entry.path().filename();
492                std::error_code tec;
493                const bool is_dir = entry.is_directory(tec);
494                if (tec) continue;
495                if (is_dir) {
496                    if (kind == list_kind::dirs || kind == list_kind::all) {
497                        result.push_back({path_to_utf8(rel_dir / fname), true});
498                    }
499                    // junk directories stay selectable but are never walked: they can be enormous
500                    if (junk_dir_names().count(get_effective_name(path_to_utf8(fname))) > 0) continue;
501                    if (!is_link(entry) && (max_depth == 0 || depth + 1 < max_depth)) {
502                        stack.emplace_back(entry.path(), rel_dir / fname, depth + 1);
503                    }
504                } else if (entry.is_regular_file(tec)) {
505                    if (kind == list_kind::files || kind == list_kind::all) {
506                        result.push_back({path_to_utf8(rel_dir / fname), false});
507                    }
508                }
509            }
510        }
511
512        return result;
513    }
514};
515
516// timeout for auxiliary isolate calls (stat/mkdir/ls helpers); exec_shell_command uses its own
517// caller-controlled timeout instead, enforced separately in run()
518static constexpr int SERVER_TOOL_ISOLATE_EXEC_TIMEOUT = 15; // seconds
519static constexpr size_t SERVER_TOOL_ISOLATE_READ_FILE_MAX_SIZE = 64 * 1024 * 1024; // 64 MB
520
521// runs every tools_io operation as a command inside an isolate: a container, a remote host, ...
522// the isolate is created, mounted, and torn down externally by the caller
523// it must provide a POSIX environment: sh, cat, wc, mkdir, dirname, find, timeout
524class tools_io_isolate : public tools_io {
525public:
526    // cwd, if non-empty, is used to resolve relative paths and as the working directory for run()
527    explicit tools_io_isolate(std::string cwd = "") : cwd(std::move(cwd)) {}
528
529    // resolves `path` against `cwd` if `path` is relative and `cwd` is set; otherwise returns `path` unchanged.
530    // isolate paths are always POSIX-style ('/'), regardless of host OS.
531    std::string resolve(const std::string & path) const override {
532        if (cwd.empty() || (!path.empty() && path[0] == '/')) {
533            return path;
534        }
535        return cwd + "/" + path;
536    }
537
538    bool is_directory(const std::string & path) const override {
539        return shell_test("-d", resolve(path));
540    }
541
542    bool is_regular_file(const std::string & path) const override {
543        return shell_test("-f", resolve(path));
544    }
545
546    bool file_size(const std::string & path, uintmax_t & out_size) const override {
547        auto res = exec({"sh", "-c", "wc -c < \"$1\"", "_", resolve(path)}, 64, true);
548        if (res.exit_code != 0 || res.timed_out) return false;
549        try {
550            size_t pos;
551            out_size = (uintmax_t) std::stoull(res.output, &pos);
552        } catch (...) {
553            return false;
554        }
555        return true;
556    }
557
558    bool read_file(const std::string & path, std::string & out) const override {
559        // combine_stderr=false: stderr must not be spliced into raw file bytes
560        auto res = exec({"cat", "--", resolve(path)}, SERVER_TOOL_ISOLATE_READ_FILE_MAX_SIZE, false);
561        if (res.exit_code != 0 || res.timed_out) return false;
562        out = res.output;
563        return true;
564    }
565
566    bool write_file(const std::string & path, const std::string & content) const override {
567        // the content travels on stdin: no argv for the far side to re-parse, no temp file on the host
568        auto res = run_subprocess(
569            build_argv({"sh", "-c", "mkdir -p \"$(dirname \"$1\")\" && cat > \"$1\"", "_", resolve(path)},
570                       /*needs_stdin=*/true),
571            4096, SERVER_TOOL_ISOLATE_EXEC_TIMEOUT, nullptr, true, "", &content);
572        return res.exit_code == 0 && !res.timed_out;
573    }
574
575    list_result list_entries(const std::string & base, int max_depth, list_kind kind) const override {
576        list_result out;
577
578        const std::string abs_base = resolve(base);
579        if (!is_directory(base)) {
580            out.err = "path does not exist or is not a directory";
581            return out;
582        }
583
584        // git ls-files cannot list directories; use the walker when they are requested
585        if (kind == list_kind::files) {
586            auto res = exec(
587                {"sh", "-c", "cd \"$1\" && git ls-files --cached --others --exclude-standard", "_", abs_base},
588                SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT, true);
589
590            if (res.exit_code == 0 && !res.timed_out) {
591                for (const auto & rel : split_lines(res.output, /*strip_dot_slash=*/false)) {
592                    if (max_depth > 0 && entry_depth(rel) > max_depth) continue;
593                    out.entries.push_back({rel, false});
594                }
595                return out;
596            }
597        }
598
599        if (kind == list_kind::dirs || kind == list_kind::all) {
600            for (auto & rel : find_entries(abs_base, max_depth, /*dirs=*/true, out.truncated)) {
601                out.entries.push_back({std::move(rel), true});
602            }
603        }
604        if (kind == list_kind::files || kind == list_kind::all) {
605            for (auto & rel : find_entries(abs_base, max_depth, /*dirs=*/false, out.truncated)) {
606                out.entries.push_back({std::move(rel), false});
607            }
608        }
609
610        return out;
611    }
612
613    // wraps the command with an in-isolate `timeout`, since killing the host-side client
614    // does not kill the process tree running inside the isolate
615    exec_result run(
616            const std::vector<std::string> & args,
617            size_t max_output,
618            int timeout_secs,
619            const std::function<bool(const std::string &)> & on_chunk = nullptr) const override {
620        std::vector<std::string> inner = {"timeout", std::to_string(timeout_secs) + "s"};
621        inner.insert(inner.end(), args.begin(), args.end());
622        // small buffer over timeout_secs so the in-isolate `timeout` has a chance to exit cleanly
623        // before the host-side supervisory timeout forcibly kills the client
624        return run_subprocess(
625            build_argv(with_cwd(inner), /*needs_stdin=*/true),
626            max_output, timeout_secs + 5, on_chunk, true);
627    }
628
629protected:
630    // wrap `inner` (a complete POSIX argv) into the host-side argv that runs it in the isolate
631    // a transport that re-parses its args in a remote shell (ssh) must join `inner` with shell_quote_join()
632    virtual std::vector<std::string> build_argv(const std::vector<std::string> & inner, bool needs_stdin) const = 0;
633
634    // quote `argv` into a single string that a POSIX shell re-parses into exactly `argv`
635    static std::string shell_quote_join(const std::vector<std::string> & argv) {
636        std::string out;
637        for (const auto & arg : argv) {
638            if (!out.empty()) out += ' ';
639            out += '\'';
640            for (const char c : arg) {
641                // a single quote cannot be escaped inside single quotes: close, escape, reopen
642                if (c == '\'') out += "'\\''";
643                else           out += c;
644            }
645            out += '\'';
646        }
647        return out;
648    }
649
650private:
651    std::string cwd;
652
653    // set the working directory in the command itself, no `-w` equivalent exists on every transport
654    // auxiliary calls do not need this, they use the absolute paths from resolve()
655    std::vector<std::string> with_cwd(const std::vector<std::string> & inner) const {
656        if (cwd.empty()) {
657            return inner;
658        }
659        // 127 is what a shell reports for a command it could not run
660        std::vector<std::string> out = {"sh", "-c", "cd \"$1\" || exit 127; shift; exec \"$@\"", "_", cwd};
661        out.insert(out.end(), inner.begin(), inner.end());
662        return out;
663    }
664
665    exec_result exec(const std::vector<std::string> & inner, size_t max_output, bool combine_stderr) const {
666        return run_subprocess(
667            build_argv(inner, /*needs_stdin=*/false),
668            max_output, SERVER_TOOL_ISOLATE_EXEC_TIMEOUT, nullptr, combine_stderr);
669    }
670
671    bool shell_run(const std::vector<std::string> & inner) const {
672        auto res = exec(inner, 4096, true);
673        return res.exit_code == 0 && !res.timed_out;
674    }
675
676    bool shell_test(const char * flag, const std::string & path) const {
677        return shell_run({"sh", "-c", std::string("[ ") + flag + " \"$1\" ]", "_", path});
678    }
679
680    static std::vector<std::string> split_lines(const std::string & text, bool strip_dot_slash) {
681        std::vector<std::string> result;
682        std::istringstream iss(text);
683        std::string line;
684        while (std::getline(iss, line)) {
685            if (!line.empty() && line.back() == '\r') line.pop_back();
686            if (line.empty()) continue;
687            if (strip_dot_slash && line.rfind("./", 0) == 0) line = line.substr(2);
688            std::replace(line.begin(), line.end(), '\\', '/');
689            result.push_back(line);
690        }
691        return result;
692    }
693
694    // one `find` pass in the isolate. junk directories stay selectable but are never descended into,
695    // and -mindepth/-maxdepth keep a busybox image working as well as a GNU one
696    std::vector<std::string> find_entries(const std::string & abs_base, int max_depth, bool dirs, bool & truncated) const {
697        std::string prune_expr;
698        for (const char * n : SERVER_TOOL_JUNK_DIR_NAMES) {
699            if (!prune_expr.empty()) prune_expr += " -o ";
700            prune_expr += std::string("-name ") + n;
701        }
702
703        std::string cmd = "cd \"$1\" && find . -mindepth 1";
704        if (max_depth > 0) {
705            cmd += " -maxdepth " + std::to_string(max_depth);
706        }
707        cmd += " \\( " + prune_expr + " \\) -prune";
708        cmd += dirs ? " -print -o -type d -print" : " -o -type f -print";
709
710        auto res = exec({"sh", "-c", cmd, "_", abs_base}, SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT, true);
711        truncated = truncated || res.timed_out;
712        return split_lines(res.output, /*strip_dot_slash=*/true);
713    }
714};
715
716// an already-running container, driven through `<engine> exec`
717// docker and podman take the same verbs and the same argument order, so one class drives both
718class tools_io_container : public tools_io_isolate {
719public:
720    tools_io_container(std::string bin, std::string container_id, std::string cwd = "")
721        : tools_io_isolate(std::move(cwd)), bin(std::move(bin)), container_id(std::move(container_id)) {}
722
723protected:
724    std::vector<std::string> build_argv(const std::vector<std::string> & inner, bool needs_stdin) const override {
725        std::vector<std::string> argv = {bin, "exec"};
726        if (needs_stdin) {
727            argv.push_back("-i");
728        }
729        argv.push_back(container_id);
730        argv.insert(argv.end(), inner.begin(), inner.end());
731        return argv;
732    }
733
734private:
735    std::string bin;
736    std::string container_id;
737};
738
739// a remote host reached over ssh
740// this is remoting, not isolation: the tools can do anything the target account can do
741class tools_io_ssh : public tools_io_isolate {
742public:
743    tools_io_ssh(std::string target, std::string cwd = "")
744        : tools_io_isolate(std::move(cwd)), target(std::move(target)) {}
745
746    // the target can come from a client header, and ssh reads options from its argv
747    // a target starting with '-' would become one, e.g. -oProxyCommand=<anything> runs on the host
748    static bool is_valid_target(const std::string & target) {
749        if (target.empty() || target[0] == '-') {
750            return false;
751        }
752        return std::all_of(target.begin(), target.end(), [](unsigned char c) {
753            return std::isalnum(c) || c == '.' || c == '-' || c == '_' || c == '@';
754        });
755    }
756
757protected:
758    std::vector<std::string> build_argv(const std::vector<std::string> & inner, bool needs_stdin) const override {
759        // the remote shell re-parses the command line, so `inner` travels as one quoted word
760        std::vector<std::string> argv = ssh_argv();
761        if (!needs_stdin) {
762            argv.push_back("-n");
763        }
764        argv.push_back(target);
765        argv.push_back(shell_quote_join(inner));
766        return argv;
767    }
768
769private:
770    std::string target;
771
772    // there is no console here, so a prompt would hang the tool call
773    // key-based auth only, and the admin must trust the host key beforehand
774    static std::vector<std::string> ssh_argv() {
775        return {
776            "ssh",
777            "-o", "BatchMode=yes",
778            "-o", "PasswordAuthentication=no",
779            "-o", "KbdInteractiveAuthentication=no",
780            "-o", "StrictHostKeyChecking=yes",
781        };
782    }
783};
784
785// "<engine>:<image>" spawns a container and owns it, "<engine>-container:<id>" attaches to one
786struct container_runtime_spec {
787    std::string bin;
788    std::string arg; // image name when spawning, container id when attaching
789    bool attach = false;
790
791    static bool parse(const std::string & spec, container_runtime_spec & out) {
792        // docker and podman take the same verbs, hence a single implementation
793        static const char * engines[] = {"docker", "podman"};
794        for (const char * bin : engines) {
795            const std::string attach_prefix = std::string(bin) + "-container:";
796            if (spec.rfind(attach_prefix, 0) == 0) {
797                out = {bin, spec.substr(attach_prefix.size()), true};
798                return true;
799            }
800            const std::string spawn_prefix = std::string(bin) + ":";
801            if (spec.rfind(spawn_prefix, 0) == 0) {
802                out = {bin, spec.substr(spawn_prefix.size()), false};
803                return true;
804            }
805        }
806        return false;
807    }
808
809    // same risk as the ssh target: an id starting with '-' would become an engine option,
810    // e.g. --privileged
811    static bool is_valid_id(const std::string & id) {
812        if (id.empty() || !std::isalnum((unsigned char) id[0])) {
813            return false;
814        }
815        return std::all_of(id.begin(), id.end(), [](unsigned char c) {
816            return std::isalnum(c) || c == '.' || c == '-' || c == '_';
817        });
818    }
819};
820
821static std::unique_ptr<tools_io> make_tools_io(const json & params) {
822    std::string cwd     = json_value(params, "cwd", std::string());
823    std::string runtime = json_value(params, "runtime", std::string());
824    if (runtime.empty()) {
825        // an empty runtime runs the tools on the host
826        return std::make_unique<tools_io_basic>(cwd);
827    }
828    container_runtime_spec container;
829    if (container_runtime_spec::parse(runtime, container)) {
830        // spawning belongs to the runtime that owns the container, a tool call only attaches
831        if (!container.attach) {
832            throw std::runtime_error("tool runtime must name a running container: " + runtime);
833        }
834        if (!container_runtime_spec::is_valid_id(container.arg)) {
835            throw std::runtime_error("invalid container id: " + container.arg);
836        }
837        return std::make_unique<tools_io_container>(container.bin, container.arg, cwd);
838    }
839    const std::string ssh_prefix = "ssh:";
840    if (runtime.rfind(ssh_prefix, 0) == 0) {
841        std::string target = runtime.substr(ssh_prefix.size());
842        if (!tools_io_ssh::is_valid_target(target)) {
843            throw std::runtime_error("invalid ssh target: " + target);
844        }
845        return std::make_unique<tools_io_ssh>(target, cwd);
846    }
847    // do not fall back to the host, the caller asked for an isolate
848    throw std::runtime_error("unknown tool runtime: " + runtime);
849}
850
851// no '/' in pattern -> match basename at any depth; else match full relative path
852static bool path_glob_match(const std::string & pattern, const std::string & rel_path) {
853    if (pattern.find('/') == std::string::npos) {
854        return glob_match(pattern, path_to_utf8(path_from_utf8(rel_path).filename()));
855    }
856    if (pattern == "**" || pattern.rfind("**/", 0) == 0 || pattern.rfind('/', 0) == 0) {
857        return glob_match(pattern, rel_path);
858    }
859    return glob_match("**/" + pattern, rel_path);
860}
861
862//
863// read_file: read a file with optional line range and line-number prefix
864//
865
866static constexpr size_t SERVER_TOOL_READ_FILE_MAX_SIZE = 16 * 1024; // 16 KB
867static constexpr size_t SERVER_TOOL_READ_FILE_MAX_SIZE_BASE64 = 32 * 1024 * 1024; // 32 MB
868
869struct server_tool_read_file : server_tool {
870    server_tool_read_file() {
871        name = "read_file";
872        display_name = "Read file";
873        uses_cwd = true;
874        permission_write = false;
875    }
876
877    json get_definition() const override {
878        return {
879            {"type", "function"},
880            {"function", {
881                {"name", name},
882                {"description", "Read the contents of a file. Optionally specify a 1-based line range. "
883                                "If append_loc is true, each line is prefixed with its line number (e.g. \"1\u2192...\")."},
884                {"parameters", {
885                    {"type", "object"},
886                    {"properties", {
887                        {"path",       {{"type", "string"},  {"description", "Path to the file"}}},
888                        {"start_line", {{"type", "integer"}, {"description", "First line to read, 1-based (default: 1)"}}},
889                        {"end_line",   {{"type", "integer"}, {"description", "Last line to read, 1-based inclusive (default: end of file)"}}},
890                        {"append_loc", {{"type", "boolean"}, {"description", "Prefix each line with its line number"}}},
891                    }},
892                    {"required", json::array({"path"})},
893                }},
894            }},
895        };
896    }
897
898    json invoke(json params, server_tool::stream *) const override {
899        std::string path  = params.at("path").get<std::string>();
900        int  start_line   = json_value(params, "start_line", 1);
901        int  end_line     = json_value(params, "end_line",  -1); // -1 = no limit
902        bool append_loc   = json_value(params, "append_loc", false);
903        // comes from the x-resp-type header, the model cannot ask for it
904        bool as_base64    = json_value(params, "resp_type", std::string()) == "base64";
905
906        auto io = make_tools_io(params);
907
908        uintmax_t file_size = 0;
909        if (!io->file_size(path, file_size)) {
910            return {{"error", "cannot stat file: " + path}};
911        }
912
913        if (as_base64) {
914            if (file_size > SERVER_TOOL_READ_FILE_MAX_SIZE_BASE64) {
915                return {{"error", string_format(
916                    "file too large (%zu bytes, max %zu)",
917                    (size_t)file_size, SERVER_TOOL_READ_FILE_MAX_SIZE_BASE64)}};
918            }
919            std::string content;
920            if (!io->read_file(path, content)) {
921                return {{"error", "failed to open file: " + path}};
922            }
923            return {
924                {"base64",     base64::encode(content.data(), content.size())},
925                {"size_bytes", (size_t) content.size()},
926            };
927        }
928
929        if (file_size > SERVER_TOOL_READ_FILE_MAX_SIZE && end_line == -1) {
930            return {{"error", string_format(
931                "file too large (%zu bytes, max %zu). Use start_line/end_line to read a portion.",
932                (size_t)file_size, SERVER_TOOL_READ_FILE_MAX_SIZE)}};
933        }
934
935        std::string content;
936        if (!io->read_file(path, content)) {
937            return {{"error", "failed to open file: " + path}};
938        }
939
940        std::istringstream f(content);
941        std::string result;
942        std::string line;
943        int lineno = 0;
944
945        while (std::getline(f, line)) {
946            lineno++;
947            if (lineno < start_line) continue;
948            if (end_line != -1 && lineno > end_line) break;
949
950            std::string out_line;
951            if (append_loc) {
952                out_line = std::to_string(lineno) + "\u2192" + line + "\n";
953            } else {
954                out_line = line + "\n";
955            }
956
957            if (result.size() + out_line.size() > SERVER_TOOL_READ_FILE_MAX_SIZE) {
958                result += "[output truncated]";
959                break;
960            }
961            result += out_line;
962        }
963
964        return {{"plain_text_response", result}};
965    }
966};
967
968//
969// file_glob_search: find files matching a glob pattern under a base directory
970//
971
972static constexpr int SERVER_TOOL_FILE_SEARCH_MAX_RESULTS = 100;
973static constexpr const char * SERVER_TOOL_FILE_SEARCH_TYPE_FILE = "file";
974static constexpr const char * SERVER_TOOL_FILE_SEARCH_TYPE_DIR  = "dir";
975static constexpr const char * SERVER_TOOL_FILE_SEARCH_TYPE_ALL  = "all";
976
977struct server_tool_file_glob_search : server_tool {
978    server_tool_file_glob_search() {
979        name = "file_glob_search";
980        display_name = "File search";
981        uses_cwd = true;
982        permission_write = false;
983    }
984
985    json get_definition() const override {
986        return {
987            {"type", "function"},
988            {"function", {
989                {"name", name},
990                {"description",
991                    "Recursively search for files matching a glob pattern under a directory. "
992                    "Automatically skips files ignored by .gitignore (when the directory is inside a git repo) "
993                    "and common junk directories (.git, node_modules, build, dist, etc.) otherwise. "
994                    "A pattern with no '/' (e.g. \"*.cpp\") matches the file's basename at any depth. "
995                    "A pattern containing '/' matches the full relative path; unless already anchored with "
996                    "\"**/\" or a leading '/', it is automatically prefixed with \"**/\". "
997                    "Use type=\"dir\" or \"all\" to also list directories; directory entries are suffixed with '/' in the output. "
998                    "Note: directory listings do not apply .gitignore filtering."},
999                {"parameters", {
1000                    {"type", "object"},
1001                    {"properties", {
1002                        {"path",      {{"type", "string"},  {"description", "Base directory to search in"}}},
1003                        {"include",   {{"type", "string"},  {"description", "Glob pattern for files to include (e.g. \"*.cpp\" or \"src/**/*.cpp\"). Default: **"}}},
1004                        {"exclude",   {{"type", "string"},  {"description", "Glob pattern for files to exclude"}}},
1005                        {"type",      {{"type", "string"},  {"description", "Entry type to return: \"file\" (default), \"dir\" or \"all\""}}},
1006                        {"max_depth", {{"type", "integer"}, {"description", "Maximum depth to descend into subdirectories (default: 0 = unlimited; 1 = direct children only)"}}},
1007                        {"limit",     {{"type", "integer"}, {"description", string_format("Maximum number of results to return, capped at %d (default %d)", SERVER_TOOL_FILE_SEARCH_MAX_RESULTS, SERVER_TOOL_FILE_SEARCH_MAX_RESULTS)}}},
1008                    }},
1009                    {"required", json::array({"path"})},
1010                }},
1011            }},
1012        };
1013    }
1014
1015    json invoke(json params, server_tool::stream *) const override {
1016        auto io = make_tools_io(params);
1017
1018        const std::string path = params.at("path").get<std::string>();
1019
1020        std::string base      = io->resolve(path);
1021        std::string include   = json_value(params, "include", std::string("**"));
1022        std::string exclude   = json_value(params, "exclude", std::string(""));
1023        std::string type      = json_value(params, "type",    std::string("file"));
1024        int         max_depth = std::max(0, json_value(params, "max_depth", 0));
1025        const int   limit_req = json_value(params, "limit", SERVER_TOOL_FILE_SEARCH_MAX_RESULTS);
1026        if (limit_req < 1) {
1027            return {{"error", "invalid limit: " + std::to_string(limit_req) + " (expected 1 or more)"}};
1028        }
1029        const int   limit     = std::min(limit_req, SERVER_TOOL_FILE_SEARCH_MAX_RESULTS);
1030
1031        list_kind kind;
1032        if (type == SERVER_TOOL_FILE_SEARCH_TYPE_FILE) {
1033            kind = list_kind::files;
1034        } else if (type == SERVER_TOOL_FILE_SEARCH_TYPE_DIR) {
1035            kind = list_kind::dirs;
1036        } else if (type == SERVER_TOOL_FILE_SEARCH_TYPE_ALL) {
1037            kind = list_kind::all;
1038        } else {
1039            return {{"error", "invalid type: " + type + " (expected \"file\", \"dir\" or \"all\")"}};
1040        }
1041
1042        const auto listing = io->list_entries(base, max_depth, kind);
1043        if (!listing.err.empty()) {
1044            return {{"error", listing.err + ": " + path}};
1045        }
1046
1047        std::vector<tools_io::list_entry> matches;
1048        for (const auto & entry : listing.entries) {
1049            if (!path_glob_match(include, entry.rel)) continue;
1050            if (!exclude.empty() && path_glob_match(exclude, entry.rel)) continue;
1051            matches.push_back(entry);
1052        }
1053
1054        size_t total = matches.size();
1055        size_t shown = std::min(total, (size_t) limit);
1056
1057        std::ostringstream output_text;
1058        json entries_json = json::array();
1059        for (size_t i = 0; i < shown; i++) {
1060            output_text << matches[i].rel << (matches[i].is_dir ? "/" : "") << "\n";
1061            entries_json.push_back({
1062                {"path", matches[i].rel},
1063                {"type", matches[i].is_dir ? "dir" : "file"},
1064            });
1065        }
1066
1067        output_text << "\n---\nTotal matches: " << total << "\n";
1068        if (total > shown) {
1069            output_text << string_format(
1070                "[%zu results limit reached (%zu total matches). Refine the glob pattern to narrow the search.]\n",
1071                shown, total);
1072        }
1073        if (listing.truncated) {
1074            output_text << "[results truncated: time budget or unreadable directory]\n";
1075        }
1076
1077        // `base` is always absolute (resolve falls back to the server cwd), so
1078        // API clients (e.g. the web UI picker) can join the relative entries
1079        // into absolute paths. `plain_text_response` is what the model sees;
1080        // `entries` is the same data as structured JSON for the UI picker,
1081        // which reads `entries`/`base` instead of re-parsing the text.
1082        return {{"plain_text_response", output_text.str()}, {"entries", entries_json}, {"base", base}};
1083    }
1084};
1085
1086//
1087// grep_search: search for a regex pattern in files
1088//
1089
1090static constexpr size_t SERVER_TOOL_GREP_SEARCH_MAX_RESULTS = 100;
1091
1092struct server_tool_grep_search : server_tool {
1093    server_tool_grep_search() {
1094        name = "grep_search";
1095        display_name = "Grep search";
1096        uses_cwd = true;
1097        permission_write = false;
1098    }
1099
1100    json get_definition() const override {
1101        return {
1102            {"type", "function"},
1103            {"function", {
1104                {"name", name},
1105                {"description",
1106                    "Search for a pattern in files under a path. Returns matching lines with file paths "
1107                    "(and, unless searching a single file, paths relative to the given directory). "
1108                    "Automatically skips files ignored by .gitignore (when the directory is inside a git repo) "
1109                    "and common junk directories (.git, node_modules, build, dist, etc.) otherwise. "
1110                    "include/exclude: a pattern with no '/' matches the basename at any depth; a pattern "
1111                    "containing '/' matches the full relative path (auto-anchored with \"**/\" unless already anchored)."},
1112                {"parameters", {
1113                    {"type", "object"},
1114                    {"properties", {
1115                        {"path",                {{"type", "string"},  {"description", "File or directory to search in"}}},
1116                        {"pattern",             {{"type", "string"},  {"description", "Pattern to search for (regular expression unless literal is true)"}}},
1117                        {"include",             {{"type", "string"},  {"description", "Glob pattern to filter files (default: **)"}}},
1118                        {"exclude",             {{"type", "string"},  {"description", "Glob pattern to exclude files"}}},
1119                        {"return_line_numbers", {{"type", "boolean"}, {"description", "If true, include line numbers in results"}}},
1120                        {"literal",             {{"type", "boolean"}, {"description", "Treat pattern as a literal string instead of a regular expression (default: false)"}}},
1121                        {"ignore_case",         {{"type", "boolean"}, {"description", "Case-insensitive search (default: false)"}}},
1122                        {"context_lines",       {{"type", "integer"}, {"description", "Number of lines of context to show before and after each match (default: 0)"}}},
1123                    }},
1124                    {"required", json::array({"path", "pattern"})},
1125                }},
1126            }},
1127        };
1128    }
1129
1130    json invoke(json params, server_tool::stream *) const override {
1131        std::string path        = params.at("path").get<std::string>();
1132        std::string pat_str     = params.at("pattern").get<std::string>();
1133        std::string include     = json_value(params, "include", std::string("**"));
1134        std::string exclude     = json_value(params, "exclude", std::string(""));
1135        bool        show_lineno = json_value(params, "return_line_numbers", false);
1136        bool        literal     = json_value(params, "literal", false);
1137        bool        ignore_case = json_value(params, "ignore_case", false);
1138        int         ctx_lines   = std::max(0, json_value(params, "context_lines", 0));
1139
1140        std::string pattern_src = pat_str;
1141        if (literal) {
1142            static const std::string specials = "\\^$.|?*+()[]{}";
1143            std::string escaped;
1144            escaped.reserve(pat_str.size() * 2);
1145            for (char c : pat_str) {
1146                if (specials.find(c) != std::string::npos) escaped += '\\';
1147                escaped += c;
1148            }
1149            pattern_src = escaped;
1150        }
1151
1152        std::regex pattern;
1153        try {
1154            auto flags = std::regex::ECMAScript;
1155            if (ignore_case) flags |= std::regex::icase;
1156            pattern = std::regex(pattern_src, flags);
1157        } catch (const std::regex_error & e) {
1158            return {{"error", std::string("invalid regex: ") + e.what()}};
1159        }
1160
1161        auto io = make_tools_io(params);
1162
1163        // collect (absolute_path, display_path) pairs to search
1164        std::vector<std::pair<std::string, std::string>> files;
1165
1166        const std::string abs_path = io->resolve(path);
1167        if (io->is_regular_file(abs_path)) {
1168            files.emplace_back(abs_path, path);
1169        } else if (io->is_directory(abs_path)) {
1170            const auto listing = io->list_entries(abs_path, 0, list_kind::files);
1171            if (!listing.err.empty()) {
1172                return {{"error", listing.err + ": " + path}};
1173            }
1174            for (const auto & entry : listing.entries) {
1175                if (!path_glob_match(include, entry.rel)) continue;
1176                if (!exclude.empty() && path_glob_match(exclude, entry.rel)) continue;
1177                files.emplace_back(path_to_utf8(path_from_utf8(abs_path) / path_from_utf8(entry.rel)), entry.rel);
1178            }
1179        } else {
1180            return {{"error", "path does not exist: " + path}};
1181        }
1182
1183        std::ostringstream output_text;
1184        size_t total = 0;
1185        bool limit_reached = false;
1186        bool show_num = show_lineno || ctx_lines > 0;
1187
1188        for (const auto & file_entry : files) {
1189            if (limit_reached) break;
1190            const std::string & fpath        = file_entry.first;
1191            const std::string & display_path = file_entry.second;
1192
1193            std::string content;
1194            if (!io->read_file(fpath, content)) continue;
1195            std::vector<std::string> lines;
1196            {
1197                std::istringstream f(content);
1198                std::string line;
1199                while (std::getline(f, line)) lines.push_back(line);
1200            }
1201
1202            for (size_t i = 0; i < lines.size(); i++) {
1203                if (total >= SERVER_TOOL_GREP_SEARCH_MAX_RESULTS) {
1204                    limit_reached = true;
1205                    break;
1206                }
1207                if (!std::regex_search(lines[i], pattern)) continue;
1208
1209                long ctx_start = ctx_lines > 0 ? std::max<long>(0, (long) i - ctx_lines) : (long) i;
1210                long ctx_end   = ctx_lines > 0 ? std::min<long>((long) lines.size() - 1, (long) i + ctx_lines) : (long) i;
1211
1212                for (long j = ctx_start; j <= ctx_end; j++) {
1213                    bool is_match = (j == (long) i);
1214                    output_text << display_path << (is_match ? ':' : '-');
1215                    if (show_num) {
1216                        output_text << (j + 1) << (is_match ? ':' : '-');
1217                    }
1218                    output_text << lines[j] << "\n";
1219                }
1220                if (ctx_lines > 0) {
1221                    output_text << "--\n";
1222                }
1223                total++;
1224            }
1225        }
1226
1227        output_text << "\n---\nTotal matches: " << total << "\n";
1228        if (limit_reached) {
1229            output_text << string_format(
1230                "[%zu matches limit reached. Narrow the path/pattern/include to see more.]\n",
1231                SERVER_TOOL_GREP_SEARCH_MAX_RESULTS);
1232        }
1233
1234        return {{"plain_text_response", output_text.str()}};
1235    }
1236};
1237
1238//
1239// exec_shell_command: run an arbitrary shell command
1240//
1241
1242static constexpr size_t SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_OUTPUT_SIZE = 16 * 1024; // 16 KB
1243static constexpr int    SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_TIMEOUT     = 60;        // seconds
1244
1245struct server_tool_exec_shell_command : server_tool {
1246    server_tool_exec_shell_command() {
1247        name = "exec_shell_command";
1248        display_name = "Execute shell command";
1249        uses_cwd = true;
1250        permission_write = true;
1251        support_stream = true;
1252    }
1253
1254    json get_definition() const override {
1255        return {
1256            {"type", "function"},
1257            {"function", {
1258                {"name", name},
1259                {"description", "Execute a shell command and return its output (stdout and stderr combined)."},
1260                {"parameters", {
1261                    {"type", "object"},
1262                    {"properties", {
1263                        {"command",         {{"type", "string"},  {"description", "Shell command to execute"}}},
1264                        {"timeout",         {{"type", "integer"}, {"description", string_format("Timeout in seconds (default 10, max %d)", SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_TIMEOUT)}}},
1265                        {"max_output_size", {{"type", "integer"}, {"description", string_format("Maximum output size in bytes (default %zu)", SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_OUTPUT_SIZE)}}},
1266                    }},
1267                    {"required", json::array({"command"})},
1268                }},
1269            }},
1270        };
1271    }
1272
1273    json invoke(json params, server_tool::stream * st) const override {
1274        std::string command   = params.at("command").get<std::string>();
1275        int    timeout        = json_value(params, "timeout",         10);
1276        size_t max_output     = (size_t) json_value(params, "max_output_size", (int) SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_OUTPUT_SIZE);
1277
1278        timeout    = std::min(timeout,    SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_TIMEOUT);
1279        max_output = std::min(max_output, SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_OUTPUT_SIZE);
1280
1281        // an isolate is always POSIX regardless of host OS, so it always gets `sh -c`
1282#ifdef _WIN32
1283        std::vector<std::string> args = !json_value(params, "runtime", std::string()).empty()
1284            ? std::vector<std::string>{"sh", "-c", command}
1285            : std::vector<std::string>{"cmd", "/c", command};
1286#else
1287        std::vector<std::string> args = {"sh", "-c", command};
1288#endif
1289
1290        auto io = make_tools_io(params);
1291
1292        if (st) {
1293            auto res = io->run(args, max_output, timeout, [st](const std::string & chunk) {
1294                st->push(chunk);
1295                return !st->alive || st->alive();
1296            });
1297            if (st->alive && !st->alive()) {
1298                return json();
1299            }
1300            std::string tail = string_format("\n[exit code: %d]", res.exit_code);
1301            if (res.timed_out) {
1302                tail += " [exit due to timed out]";
1303            }
1304            st->push(tail);
1305            return json();
1306        }
1307
1308        auto res = io->run(args, max_output, timeout);
1309
1310        std::string text_output = res.output;
1311        text_output += string_format("\n[exit code: %d]", res.exit_code);
1312        if (res.timed_out) {
1313            text_output += " [exit due to timed out]";
1314        }
1315
1316        return {{"plain_text_response", text_output}};
1317    }
1318};
1319
1320//
1321// write_file: create or overwrite a file
1322//
1323
1324struct server_tool_write_file : server_tool {
1325    server_tool_write_file() {
1326        name = "write_file";
1327        display_name = "Write file";
1328        uses_cwd = true;
1329        permission_write = true;
1330    }
1331
1332    json get_definition() const override {
1333        return {
1334            {"type", "function"},
1335            {"function", {
1336                {"name", name},
1337                {"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."},
1338                {"parameters", {
1339                    {"type", "object"},
1340                    {"properties", {
1341                        {"path",    {{"type", "string"}, {"description", "Path of the file to write"}}},
1342                        {"content", {{"type", "string"}, {"description", "Content to write"}}},
1343                    }},
1344                    {"required", json::array({"path", "content"})},
1345                }},
1346            }},
1347        };
1348    }
1349
1350    json invoke(json params, server_tool::stream *) const override {
1351        std::string path    = params.at("path").get<std::string>();
1352        std::string content = params.at("content").get<std::string>();
1353
1354        auto io = make_tools_io(params);
1355        if (!io->write_file(path, content)) {
1356            return {{"error", "failed to write file: " + path}};
1357        }
1358
1359        return {{"result", "file written successfully"}, {"path", path}, {"bytes", content.size()}};
1360    }
1361};
1362
1363//
1364// edit_file: exact text replacement, one or more edits per call
1365//
1366
1367struct server_tool_edit_file : server_tool {
1368    server_tool_edit_file() {
1369        name = "edit_file";
1370        display_name = "Edit file";
1371        uses_cwd = true;
1372        permission_write = true;
1373    }
1374
1375    json get_definition() const override {
1376        return {
1377            {"type", "function"},
1378            {"function", {
1379                {"name", name},
1380                {"description",
1381                    "Edit a file using exact text replacement. Each edits[].old_text must be unique in the file "
1382                    "and is matched against the original content, not incrementally. Merge nearby changes into "
1383                    "one edit instead of overlapping edits. Use write_file to replace the whole file."},
1384                {"parameters", {
1385                    {"type", "object"},
1386                    {"properties", {
1387                        {"path",  {{"type", "string"}, {"description", "Path to the file to edit"}}},
1388                        {"edits", {
1389                            {"type", "array"},
1390                            {"description", "One or more exact text replacements to apply"},
1391                            {"items", {
1392                                {"type", "object"},
1393                                {"properties", {
1394                                    {"old_text", {{"type", "string"}, {"description", "Exact text to find; must be unique in the file and must not overlap with other edits"}}},
1395                                    {"new_text", {{"type", "string"}, {"description", "Text to replace old_text with"}}},
1396                                }},
1397                                {"required", json::array({"old_text", "new_text"})},
1398                            }},
1399                        }},
1400                    }},
1401                    {"required", json::array({"path", "edits"})},
1402                }},
1403            }},
1404        };
1405    }
1406
1407    json invoke(json params, server_tool::stream *) const override {
1408        std::string path = params.at("path").get<std::string>();
1409        const json & edits_json = params.at("edits");
1410
1411        if (!edits_json.is_array() || edits_json.empty()) {
1412            return {{"error", "\"edits\" must be a non-empty array"}};
1413        }
1414
1415        struct edit_req {
1416            std::string old_text;
1417            std::string new_text;
1418        };
1419        std::vector<edit_req> edits;
1420        edits.reserve(edits_json.size());
1421        for (const auto & e : edits_json) {
1422            edit_req er;
1423            er.old_text = e.at("old_text").get<std::string>();
1424            er.new_text = e.at("new_text").get<std::string>();
1425            if (er.old_text.empty()) {
1426                return {{"error", string_format("edits[%zu].old_text must not be empty", edits.size())}};
1427            }
1428            edits.push_back(std::move(er));
1429        }
1430
1431        auto io = make_tools_io(params);
1432        std::string original_content;
1433        if (!io->read_file(path, original_content)) {
1434            return {{"error", "failed to open file: " + path}};
1435        }
1436
1437        // does any old_text need fuzzy matching (no exact match found)?
1438        bool any_fuzzy = false;
1439        for (size_t i = 0; i < edits.size(); i++) {
1440            if (original_content.find(edits[i].old_text) != std::string::npos) continue;
1441            std::string fuzzy_content = normalize_for_fuzzy_match(original_content);
1442            std::string fuzzy_old     = normalize_for_fuzzy_match(edits[i].old_text);
1443            if (fuzzy_content.find(fuzzy_old) == std::string::npos) {
1444                return {{"error", string_format(
1445                    "could not find edits[%zu].old_text in %s, it must match the file's current content exactly",
1446                    i, path.c_str())}};
1447            }
1448            any_fuzzy = true;
1449        }
1450
1451        std::string base_content = any_fuzzy ? normalize_for_fuzzy_match(original_content) : original_content;
1452
1453        // uniqueness check always uses fuzzy-normalized text, so a whitespace-only duplicate still counts
1454        std::vector<matched_edit> matched;
1455        matched.reserve(edits.size());
1456        for (size_t i = 0; i < edits.size(); i++) {
1457            std::string needle = any_fuzzy ? normalize_for_fuzzy_match(edits[i].old_text) : edits[i].old_text;
1458            size_t occurrences = count_occurrences(
1459                normalize_for_fuzzy_match(original_content),
1460                normalize_for_fuzzy_match(edits[i].old_text));
1461            if (occurrences > 1) {
1462                return {{"error", string_format(
1463                    "found %zu occurrences of edits[%zu].old_text in %s, it must be unique",
1464                    occurrences, i, path.c_str())}};
1465            }
1466            size_t idx = base_content.find(needle);
1467            matched.push_back({i, idx, needle.size(), edits[i].new_text});
1468        }
1469
1470        std::sort(matched.begin(), matched.end(), [](const matched_edit & a, const matched_edit & b) {
1471            return a.match_index < b.match_index;
1472        });
1473        for (size_t i = 1; i < matched.size(); i++) {
1474            if (matched[i - 1].match_index + matched[i - 1].match_length > matched[i].match_index) {
1475                return {{"error", string_format(
1476                    "edits[%zu] and edits[%zu] overlap in %s; merge them into one edit or target disjoint regions",
1477                    matched[i - 1].edit_index, matched[i].edit_index, path.c_str())}};
1478            }
1479        }
1480
1481        std::string new_content = any_fuzzy
1482            ? apply_replacements_preserving_unchanged_lines(original_content, base_content, matched)
1483            : apply_replacements(base_content, matched, 0);
1484
1485        if (new_content == original_content) {
1486            return {{"error", "no changes made: the replacement(s) produced identical content"}};
1487        }
1488
1489        if (!io->write_file(path, new_content)) {
1490            return {{"error", "failed to write file: " + path}};
1491        }
1492
1493        return {{"result", "file edited successfully"}, {"path", path}, {"edits_applied", (int) matched.size()}};
1494    }
1495
1496private:
1497    // strip trailing whitespace, normalize smart quotes/dashes/spaces to ASCII
1498    static std::string normalize_line_for_fuzzy_match(const std::string & line) {
1499        size_t end = line.size();
1500        while (end > 0 && (line[end - 1] == ' ' || line[end - 1] == '\t' || line[end - 1] == '\r')) {
1501            end--;
1502        }
1503        std::string s = line.substr(0, end);
1504
1505        auto replace_all = [](std::string & str, const std::string & from, const std::string & to) {
1506            if (from.empty()) return;
1507            size_t pos = 0;
1508            while ((pos = str.find(from, pos)) != std::string::npos) {
1509                str.replace(pos, from.size(), to);
1510                pos += to.size();
1511            }
1512        };
1513
1514        // smart single quotes -> '
1515        for (unsigned char b : {0x98, 0x99, 0x9A, 0x9B}) {
1516            replace_all(s, std::string("\xE2\x80") + (char) b, "'");
1517        }
1518        // smart double quotes -> "
1519        for (unsigned char b : {0x9C, 0x9D, 0x9E, 0x9F}) {
1520            replace_all(s, std::string("\xE2\x80") + (char) b, "\"");
1521        }
1522        // various dashes -> -
1523        for (unsigned char b = 0x90; b <= 0x95; b++) {
1524            replace_all(s, std::string("\xE2\x80") + (char) b, "-");
1525        }
1526        replace_all(s, "\xE2\x88\x92", "-"); // minus sign
1527        // special spaces -> ' '
1528        replace_all(s, "\xC2\xA0", " "); // no-break space
1529        for (unsigned char b = 0x82; b <= 0x8A; b++) {
1530            replace_all(s, std::string("\xE2\x80") + (char) b, " ");
1531        }
1532        replace_all(s, "\xE2\x80\xAF", " "); // narrow no-break space
1533        replace_all(s, "\xE2\x81\x9F", " "); // medium mathematical space
1534        replace_all(s, "\xE3\x80\x80", " "); // ideographic space
1535
1536        return s;
1537    }
1538
1539    // applies the per-line transform above to every line; preserves line count/positions
1540    static std::string normalize_for_fuzzy_match(const std::string & content) {
1541        std::string result;
1542        result.reserve(content.size());
1543        size_t start = 0;
1544        while (true) {
1545            size_t nl = content.find('\n', start);
1546            bool   is_last = nl == std::string::npos;
1547            std::string line = is_last ? content.substr(start) : content.substr(start, nl - start);
1548            result += normalize_line_for_fuzzy_match(line);
1549            if (is_last) break;
1550            result += '\n';
1551            start = nl + 1;
1552        }
1553        return result;
1554    }
1555
1556    // lines with trailing '\n' kept, so untouched ones can be reconstructed verbatim
1557    static std::vector<std::string> split_lines_with_endings(const std::string & content) {
1558        std::vector<std::string> lines;
1559        size_t start = 0;
1560        while (start < content.size()) {
1561            size_t nl = content.find('\n', start);
1562            if (nl == std::string::npos) {
1563                lines.push_back(content.substr(start));
1564                break;
1565            }
1566            lines.push_back(content.substr(start, nl - start + 1));
1567            start = nl + 1;
1568        }
1569        return lines;
1570    }
1571
1572    struct line_span {
1573        size_t start;
1574        size_t end;
1575    };
1576
1577    static std::vector<line_span> get_line_spans(const std::string & content) {
1578        std::vector<line_span> spans;
1579        size_t offset = 0;
1580        for (const auto & line : split_lines_with_endings(content)) {
1581            spans.push_back({offset, offset + line.size()});
1582            offset += line.size();
1583        }
1584        return spans;
1585    }
1586
1587    // count non-overlapping occurrences of `needle` in `content`
1588    static size_t count_occurrences(const std::string & content, const std::string & needle) {
1589        if (needle.empty()) return 0;
1590        size_t count = 0, pos = 0;
1591        while ((pos = content.find(needle, pos)) != std::string::npos) {
1592            count++;
1593            pos += needle.size();
1594        }
1595        return count;
1596    }
1597
1598    struct matched_edit {
1599        size_t      edit_index;
1600        size_t      match_index;  // offset into the "base content" (see below)
1601        size_t      match_length;
1602        std::string new_text;
1603    };
1604
1605    // replacements must be sorted ascending by match_index and non-overlapping
1606    static std::string apply_replacements(
1607            const std::string & content,
1608            const std::vector<matched_edit> & replacements,
1609            size_t offset) {
1610        std::string result = content;
1611        for (auto it = replacements.rbegin(); it != replacements.rend(); ++it) {
1612            size_t local_index = it->match_index - offset;
1613            result = result.substr(0, local_index) + it->new_text + result.substr(local_index + it->match_length);
1614        }
1615        return result;
1616    }
1617
1618    // widen a replacement's byte range to the line(s) of `lines` it touches
1619    static bool get_replacement_line_range(
1620            const std::vector<line_span> & lines,
1621            size_t match_index, size_t match_length,
1622            size_t & out_start_line, size_t & out_end_line /* exclusive */) {
1623        size_t replacement_start = match_index;
1624        size_t replacement_end   = match_index + match_length;
1625
1626        size_t start_line = (size_t) -1;
1627        for (size_t i = 0; i < lines.size(); i++) {
1628            if (replacement_start >= lines[i].start && replacement_start < lines[i].end) {
1629                start_line = i;
1630                break;
1631            }
1632        }
1633        if (start_line == (size_t) -1) return false;
1634
1635        size_t end_line = start_line;
1636        while (end_line < lines.size() && lines[end_line].end < replacement_end) {
1637            end_line++;
1638        }
1639        if (end_line >= lines.size()) return false;
1640
1641        out_start_line = start_line;
1642        out_end_line   = end_line + 1;
1643        return true;
1644    }
1645
1646    // like apply_replacements, but untouched lines come from `original_content`
1647    static std::string apply_replacements_preserving_unchanged_lines(
1648            const std::string & original_content,
1649            const std::string & base_content,
1650            const std::vector<matched_edit> & replacements /* ascending, non-overlapping */) {
1651        auto original_lines = split_lines_with_endings(original_content);
1652        auto base_lines      = get_line_spans(base_content);
1653
1654        struct group {
1655            size_t start_line;
1656            size_t end_line; // exclusive
1657            std::vector<matched_edit> reps;
1658        };
1659        std::vector<group> groups;
1660
1661        for (const auto & rep : replacements) {
1662            size_t start_line = 0, end_line = 0;
1663            get_replacement_line_range(base_lines, rep.match_index, rep.match_length, start_line, end_line);
1664            if (!groups.empty() && start_line < groups.back().end_line) {
1665                groups.back().end_line = std::max(groups.back().end_line, end_line);
1666                groups.back().reps.push_back(rep);
1667            } else {
1668                groups.push_back({start_line, end_line, {rep}});
1669            }
1670        }
1671
1672        size_t original_line_index = 0;
1673        std::string result;
1674        for (auto & g : groups) {
1675            for (size_t i = original_line_index; i < g.start_line; i++) {
1676                result += original_lines[i];
1677            }
1678
1679            size_t group_start_offset = base_lines[g.start_line].start;
1680            size_t group_end_offset   = base_lines[g.end_line - 1].end;
1681            std::string slice = base_content.substr(group_start_offset, group_end_offset - group_start_offset);
1682            result += apply_replacements(slice, g.reps, group_start_offset);
1683
1684            original_line_index = g.end_line;
1685        }
1686        for (size_t i = original_line_index; i < original_lines.size(); i++) {
1687            result += original_lines[i];
1688        }
1689
1690        return result;
1691    }
1692};
1693
1694//
1695// get_info: returns runtime info (OS name/version and cwd)
1696//
1697
1698static constexpr size_t SERVER_TOOL_GET_INFO_MAX_OUTPUT = 4096;
1699static constexpr int    SERVER_TOOL_GET_INFO_TIMEOUT    = 5; // seconds
1700
1701struct server_tool_get_info : server_tool {
1702    server_tool_get_info() {
1703        name = "get_info";
1704        display_name = "Get Runtime Info";
1705        uses_cwd = true;
1706        permission_write = false;
1707    }
1708
1709    json get_definition() const override {
1710        return {
1711            {"type", "function"},
1712            {"function", {
1713                {"name", name},
1714                {"description", "Returns runtime info: the OS name/version and the current working directory"},
1715                {"parameters", {
1716                    {"type", "object"},
1717                    {"properties", json::object()},
1718                }},
1719            }},
1720        };
1721    }
1722
1723    json invoke(json params, server_tool::stream *) const override {
1724        auto io = make_tools_io(params);
1725
1726        // inside an isolate, we always use the linux command
1727#ifdef _WIN32
1728        std::vector<std::string> args = !json_value(params, "runtime", std::string()).empty()
1729            ? std::vector<std::string>{"uname", "-a"}
1730            : std::vector<std::string>{"cmd", "/c", "ver"};
1731#else
1732        std::vector<std::string> args = {"uname", "-a"};
1733#endif
1734
1735        auto res = io->run(args, SERVER_TOOL_GET_INFO_MAX_OUTPUT, SERVER_TOOL_GET_INFO_TIMEOUT);
1736        // "ver" prints a blank line before the version, so the output is stripped on both ends;
1737        // a failed spawn or a timeout leaves a diagnostic in res.output, which is not an OS name
1738        std::string os_info = res.exit_code == 0 && !res.timed_out ? string_strip(res.output) : "unknown";
1739
1740        std::string cwd = json_value(params, "cwd", std::string());
1741        if (cwd.empty()) {
1742            if (json_value(params, "runtime", std::string()).empty()) {
1743                std::error_code ec;
1744                cwd = path_to_utf8(fs::current_path(ec));
1745            } else {
1746                auto pwd = io->run({"pwd"}, SERVER_TOOL_GET_INFO_MAX_OUTPUT, SERVER_TOOL_GET_INFO_TIMEOUT);
1747                cwd = pwd.exit_code == 0 && !pwd.timed_out ? string_strip(pwd.output) : "unknown";
1748            }
1749        }
1750
1751        return {
1752            {"os",  os_info},
1753            {"cwd", cwd},
1754        };
1755    }
1756};
1757
1758struct server_tool_stream_result : server_task_result {
1759    std::string chunk;
1760    bool done = false;
1761    std::string error_msg;
1762
1763    json to_json() override {
1764        if (!done) {
1765            return {{"chunk", chunk}};
1766        } else {
1767            json result = {{"done", true}};
1768            if (!error_msg.empty()) {
1769                result["error"] = error_msg;
1770            }
1771            return result;
1772        }
1773    }
1774};
1775
1776void server_tool::stream::push(const std::string & chunk) {
1777    if (chunk.empty()) return;
1778    auto r = std::make_unique<server_tool_stream_result>();
1779    r->id    = id;
1780    r->chunk = chunk;
1781    qr.send(std::move(r));
1782}
1783
1784struct server_tools_res : server_http_res {
1785    std::thread worker;
1786    server_response * qr = nullptr; // set only for streaming responses
1787    int id = -1;
1788
1789    ~server_tools_res() override {
1790        if (worker.joinable()) {
1791            worker.join();
1792        }
1793        if (qr) {
1794            qr->remove_waiting_task_id(id);
1795        }
1796    }
1797};
1798
1799//
1800// server_mcp_tool: exposes one tool from a running MCP server as a server_tool.
1801//
1802struct server_mcp_tool : server_tool {
1803    std::string server_name;
1804    std::string tool_name;
1805    server_mcp_tool_def def;
1806    server_mcp & mcp_mgr;
1807
1808    server_mcp_tool(server_mcp_tool_def d, server_mcp & mgr)
1809        : server_name(d.server_name)
1810        , tool_name(d.name)
1811        , def(std::move(d))
1812        , mcp_mgr(mgr)
1813    {
1814        name = server_name + "_" + tool_name;
1815        display_name = name;
1816        permission_write = false;
1817        support_stream = false;
1818    }
1819
1820    std::string type() const override { return "mcp"; }
1821
1822    json get_definition() const override {
1823        json schema = def.input_schema;
1824        if (schema.is_null() || !schema.is_object()) {
1825            schema = json::object();
1826        }
1827        return {
1828            {"type", "function"},
1829            {"function", {
1830                {"name", name},
1831                {"description", def.description},
1832                {"parameters", schema},
1833            }},
1834        };
1835    }
1836
1837    json invoke(json params, server_tool::stream *) const override {
1838        return mcp_mgr.call_tool(server_name, tool_name, params);
1839    }
1840};
1841
1842// resolves --tools-runtime into the isolate that every tool call runs through
1843// spec() returns the runtime string make_tools_io() takes, and runs once per tool call
1844struct server_tools_runtime {
1845    virtual ~server_tools_runtime() = default;
1846    virtual std::string spec() = 0;
1847};
1848
1849// a target that already exists and needs no lifecycle
1850// the spec is validated once at startup, then passed straight through
1851struct server_tools_static_runtime : server_tools_runtime {
1852    explicit server_tools_static_runtime(std::string spec) : runtime_spec(std::move(spec)) {}
1853    std::string spec() override { return runtime_spec; }
1854
1855private:
1856    std::string runtime_spec;
1857};
1858
1859// owns the container the tools run in, as set by --tools-runtime "<engine>:<image>"
1860// it is spawned here and stopped when the server exits
1861struct server_tools_container_runtime : server_tools_runtime {
1862    server_tools_container_runtime(const server_tools_container_runtime &) = delete;
1863
1864    explicit server_tools_container_runtime(const std::string & spec) {
1865        container_runtime_spec parsed;
1866        if (!container_runtime_spec::parse(spec, parsed)) {
1867            throw std::runtime_error("unknown --tools-runtime option: " + spec);
1868        }
1869
1870        bin   = parsed.bin;
1871        image = parsed.arg;
1872        if (image.empty()) {
1873            throw std::runtime_error("--tools-runtime " + bin + ":<image> requires an image name");
1874        }
1875        spawn();
1876    }
1877
1878    ~server_tools_container_runtime() override {
1879        // closing stdin signals the container's shell (its pid 1) to exit; --rm then removes it
1880        proc.close_stdin();
1881        proc.join();
1882    }
1883
1884    // respawns a container that died on its own, so the returned spec always names a running one
1885    std::string spec() override {
1886        std::lock_guard<std::mutex> lock(mutex);
1887        if (!proc.alive()) {
1888            SRV_WRN("%s tools runtime container \"%s\" died, respawning\n", bin.c_str(), container_id.c_str());
1889            spawn();
1890        }
1891        return bin + "-container:" + container_id;
1892    }
1893
1894private:
1895    std::string bin;
1896    std::string image;
1897    std::string container_id;
1898    common_subproc proc; // `<engine> run` client that keeps the container alive
1899    std::mutex mutex;
1900
1901    // spawns "<engine> run --rm -i <image> sh" and keeps its stdin open; the shell blocks reading stdin,
1902    // so the container stays alive until we close it (see destructor) or it is killed from the outside
1903    void spawn() {
1904        // create() writes over the handle it is given, so the previous one is released first
1905        proc.join();
1906
1907        std::error_code ec;
1908        fs::path cidfile = fs::temp_directory_path(ec) / string_format(
1909            "llama-tools-runtime-cid-%zu.tmp", std::hash<std::thread::id>{}(std::this_thread::get_id()));
1910        fs::remove(cidfile, ec);
1911
1912        std::vector<std::string> args = {bin, "run", "--rm", "-i", "--cidfile", path_to_utf8(cidfile), image, "sh"};
1913        int options = subprocess_option_no_window
1914                    | subprocess_option_inherit_environment
1915                    | subprocess_option_search_user_path;
1916        if (!proc.create(args, options)) {
1917            throw std::runtime_error("failed to spawn " + bin + " container for tools runtime (image: " + image + ")");
1918        }
1919
1920        std::string cid;
1921        for (int i = 0; i < 100 && cid.empty(); i++) {
1922            std::ifstream f(cidfile);
1923            if (f) std::getline(f, cid);
1924            if (cid.empty()) std::this_thread::sleep_for(std::chrono::milliseconds(100));
1925        }
1926        fs::remove(cidfile, ec);
1927        if (cid.empty()) {
1928            proc.terminate();
1929            throw std::runtime_error("timed out waiting for " + bin + " container to start (image: " + image + ")");
1930        }
1931        container_id = cid;
1932    }
1933};
1934
1935static server_tool & find_tool(std::vector<std::unique_ptr<server_tool>> & tools, const std::string & name, bool require_stream) {
1936    for (auto & t : tools) {
1937        if (t->name == name) {
1938            if (require_stream && !t->support_stream) {
1939                throw std::invalid_argument(string_format("tool \"%s\" does not support stream = true", name.c_str()));
1940            }
1941            return *t;
1942        }
1943    }
1944    throw std::invalid_argument(string_format("unknown tool \"%s\"", name.c_str()));
1945}
1946
1947//
1948// public API
1949//
1950
1951static std::vector<std::unique_ptr<server_tool>> build_tools() {
1952    // IMPORTANT: for contributors, please keep this array of tools as minimal as possible
1953    //            we only accept minimal i/o and shell command tools here
1954    //            for example, do not add: web search, get date time, etc.
1955    //            high-level functionality should be added either via MCP or web UI
1956    std::vector<std::unique_ptr<server_tool>> tools;
1957    tools.push_back(std::make_unique<server_tool_read_file>());
1958    tools.push_back(std::make_unique<server_tool_file_glob_search>());
1959    tools.push_back(std::make_unique<server_tool_grep_search>());
1960    tools.push_back(std::make_unique<server_tool_exec_shell_command>());
1961    tools.push_back(std::make_unique<server_tool_write_file>());
1962    tools.push_back(std::make_unique<server_tool_edit_file>());
1963    tools.push_back(std::make_unique<server_tool_get_info>());
1964    return tools;
1965}
1966
1967static std::string str_to_lower(const std::string & value) {
1968    std::string lowered(value.size(), '\0');
1969    std::transform(value.begin(), value.end(), lowered.begin(), [](unsigned char c) { return std::tolower(c); });
1970    return lowered;
1971}
1972
1973static std::string get_header(const std::map<std::string, std::string> & headers, const std::string & key, std::string default_value = "") {
1974    const auto lowered_key = str_to_lower(key);
1975    for (const auto & h : headers) {
1976        if (str_to_lower(h.first) == lowered_key) {
1977            return h.second;
1978        }
1979    }
1980    return default_value;
1981}
1982
1983server_tools::server_tools() = default;
1984server_tools::~server_tools() = default;
1985
1986// the "<engine>:<image>" form owns a container lifecycle
1987// anything else names an existing target, so only its spec is validated here at startup
1988static std::unique_ptr<server_tools_runtime> make_tools_runtime(const std::string & spec) {
1989    container_runtime_spec parsed;
1990    if (container_runtime_spec::parse(spec, parsed) && !parsed.attach) {
1991        return std::make_unique<server_tools_container_runtime>(spec);
1992    }
1993    make_tools_io({{"runtime", spec}}); // nothing to own, just reject a bad spec now
1994    return std::make_unique<server_tools_static_runtime>(spec);
1995}
1996
1997void server_tools::setup(const std::vector<std::string> & enabled_tools,
1998                         server_mcp & mcp_mgr,
1999                         const std::string & tools_runtime) {
2000    if (!tools_runtime.empty()) {
2001        runtime = make_tools_runtime(tools_runtime);
2002    }
2003
2004    if (!enabled_tools.empty()) {
2005        if (!common_subproc::is_supported()) {
2006            throw std::runtime_error("subprocess is not enabled on this build");
2007        }
2008
2009        std::unordered_set<std::string> enabled_set(enabled_tools.begin(), enabled_tools.end());
2010        auto all_tools = build_tools();
2011
2012        // collect all known tool names for validation
2013        std::vector<std::string> known_names;
2014        known_names.reserve(all_tools.size());
2015        for (const auto & t : all_tools) {
2016            known_names.push_back(t->name);
2017        }
2018
2019        // validate that every requested tool is known
2020        for (const auto & name : enabled_tools) {
2021            if (name == "all") continue;
2022            if (std::find(known_names.begin(), known_names.end(), name) == known_names.end()) {
2023                throw std::runtime_error(string_format(
2024                    "unknown tool \"%s\". available tools: %s",
2025                    name.c_str(),
2026                    string_join(known_names, ", ").c_str()));
2027            }
2028        }
2029
2030        tools.clear();
2031        for (auto & t : all_tools) {
2032            if (enabled_set.count(t->name) > 0 || enabled_set.count("all") > 0) {
2033                tools.push_back(std::move(t));
2034            }
2035        }
2036    }
2037
2038    // append MCP tools, skipping any that collide with a server tool or another MCP tool of the same "<server>_<tool>" name
2039    if (!mcp_mgr.empty()) {
2040        std::unordered_set<std::string> seen_names;
2041        for (auto & t : tools) {
2042            seen_names.insert(t->name);
2043        }
2044        size_t n_added = 0;
2045        for (const auto & def : mcp_mgr.list_tools()) {
2046            std::string mcp_name = def.server_name + "_" + def.name;
2047            if (seen_names.count(mcp_name)) {
2048                SRV_WRN("MCP tool \"%s\" from server \"%s\" collides with an existing tool, skipping\n",
2049                    mcp_name.c_str(), def.server_name.c_str());
2050                continue;
2051            }
2052            seen_names.insert(mcp_name);
2053            tools.push_back(std::make_unique<server_mcp_tool>(def, mcp_mgr));
2054            n_added++;
2055        }
2056        if (n_added > 0) {
2057            SRV_INF("Added %zu MCP tools\n", n_added);
2058        }
2059    }
2060
2061    handle_get = [this](const server_http_req &) -> server_http_res_ptr {
2062        auto res = std::make_unique<server_http_res>();
2063        try {
2064            json result = json::array();
2065            for (const auto & t : tools) {
2066                result.push_back(t->to_json());
2067            }
2068            res->data = safe_json_to_str(result);
2069        } catch (const std::exception & e) {
2070            SRV_ERR("got exception: %s\n", e.what());
2071            res->status = 500;
2072            res->data   = safe_json_to_str(format_error_response(e.what(), ERROR_TYPE_SERVER));
2073        }
2074        return res;
2075    };
2076
2077    handle_post = [this](const server_http_req & req) -> server_http_res_ptr {
2078        auto res = std::make_unique<server_tools_res>();
2079        try {
2080            json body = json::parse(req.body);
2081            std::string tool_name = body.at("tool").get<std::string>();
2082            json params = body.value("params", json::object());
2083            bool stream = body.value("stream", false);
2084
2085            // accept x-tool-cwd header to override of the process
2086            if (params.contains("cwd")) {
2087                params.erase("cwd");
2088            }
2089            auto cwd = get_header(req.headers, "x-tool-cwd");
2090            if (!cwd.empty()) {
2091                params["cwd"] = cwd;
2092            }
2093
2094            // accept x-tool-runtime header to route tool I/O through an isolate, e.g. "docker-container:<id>";
2095            // falls back to the --tools-runtime isolate, if configured
2096            if (params.contains("runtime")) {
2097                params.erase("runtime");
2098            }
2099            auto runtime_header = get_header(req.headers, "x-tool-runtime");
2100            if (!runtime_header.empty()) {
2101                params["runtime"] = runtime_header;
2102            } else if (runtime) {
2103                params["runtime"] = runtime->spec();
2104            }
2105
2106            // x-resp-type header is only used by read_file for now
2107            if (params.contains("resp_type")) {
2108                params.erase("resp_type");
2109            }
2110            auto resp_type = get_header(req.headers, "x-resp-type");
2111            if (!resp_type.empty()) {
2112                params["resp_type"] = resp_type;
2113            }
2114
2115            server_tool & tool = find_tool(tools, tool_name, stream);
2116
2117            if (stream) {
2118                int id = res_id.fetch_add(1);
2119                queue_res.add_waiting_task_id(id);
2120                res->qr = &queue_res;
2121                res->id = id;
2122
2123                res->worker = std::thread([this, id, &req, &tool, params]() mutable {
2124                    server_tool::stream st{queue_res, id, [&req]() {
2125                        return !req.should_stop();
2126                    }};
2127
2128                    auto done = std::make_unique<server_tool_stream_result>();
2129                    try {
2130                        tool.invoke(params, &st);
2131                    } catch (const std::exception & e) {
2132                        done->error_msg = e.what();
2133                    } catch (...) {
2134                        done->error_msg = "An unknown error occurred";
2135                    }
2136                    done->id    = st.id;
2137                    done->done  = true;
2138                    st.qr.send(std::move(done));
2139                });
2140
2141                res->content_type = "text/event-stream";
2142                res->status = 200;
2143                res->next   = [this, id](std::string & output) -> bool {
2144                    auto result = queue_res.recv(id);
2145                    auto * r = dynamic_cast<server_tool_stream_result *>(result.get());
2146                    GGML_ASSERT(r != nullptr);
2147                    output = "data: " + safe_json_to_str(r->to_json()) + "\n\n";
2148                    if (r->done) {
2149                        queue_res.remove_waiting_task_id(id);
2150                        return false;
2151                    }
2152                    return true;
2153                };
2154            } else {
2155                json result = tool.invoke(params, nullptr);
2156                res->status = 200;
2157                res->data   = safe_json_to_str(result);
2158            }
2159        } catch (const common_json_error & e) {
2160            res->status = 400;
2161            res->data   = safe_json_to_str(format_error_response(e.what(), ERROR_TYPE_INVALID_REQUEST));
2162        } catch (const std::invalid_argument & e) {
2163            res->status = 404;
2164            res->data   = safe_json_to_str(format_error_response(e.what(), ERROR_TYPE_INVALID_REQUEST));
2165        } catch (const std::exception & e) {
2166            SRV_ERR("got exception: %s\n", e.what());
2167            res->status = 500;
2168            res->data   = safe_json_to_str(format_error_response(e.what(), ERROR_TYPE_SERVER));
2169        }
2170        return res;
2171    };
2172}