char-fork/llama.cpp
git clone https://git.t4t.associates/char-fork/llama.cpp
d9f918d2d
char/aliceai-t5-moe
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 48if (text .empty ()|| is_valid_utf8 (text .substr (0 ,validate_utf8 (text )))) { 49// never decode twice a child that already emits UTF-8 50return text ; 51 } 52 53const UINT cp = GetOEMCP (); 54 55// fail rather than emit replacement characters when the code page is wrong 56const int wide_len = MultiByteToWideChar (cp ,MB_ERR_INVALID_CHARS ,text .data (), (int )text .size (),nullptr ,0 ); 57if (wide_len <=0 ) { 58return text ; 59 } 60 std::wstring wide (wide_len ,L'\0' ); 61MultiByteToWideChar (cp ,MB_ERR_INVALID_CHARS ,text .data (), (int )text .size (),wide .data (),wide_len ); 62 63const int utf8_len = WideCharToMultiByte (CP_UTF8 ,0 ,wide .data (),wide_len ,nullptr ,0 ,nullptr ,nullptr ); 64if (utf8_len <=0 ) { 65return text ; 66 } 67 std::string utf8 (utf8_len ,'\0' ); 68WideCharToMultiByte (CP_UTF8 ,0 ,wide .data (),wide_len ,utf8 .data (),utf8_len ,nullptr ,nullptr ); 69return utf8 ; 70#else 71return text ; 72#endif 73} 74 75json server_tool::to_json ()const { 76return { 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 { 94files ,// regular files only 95dirs ,// directories only 96all ,// 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 ) { 102return 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 ) { 107const auto s = p .generic_u8string (); 108return 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 () { 113static const std::string home = [] { 114#ifdef _WIN32 115// the narrow getenv would return the profile path in the active code page 116const wchar_t * w = _wgetenv (L"HOME" ); 117if (w == nullptr )w = _wgetenv (L"USERPROFILE" ); 118return w ?path_to_utf8 (fs::path (w )) : std::string (); 119#else 120const char * h = getenv ("HOME" ); 121return h ? std::string (h ) : std::string (); 122#endif 123 }(); 124return home ; 125} 126 127static std::string expand_home (const std::string & path ) { 128if (path .empty ()|| path [0 ]!= '~' )return path ; 129if (path .size ()> 1 && path [1 ]!= '/' && path [1 ]!= '\\' )return path ; 130const std::string & home = home_dir (); 131if (home .empty ())return path ; 132return 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 ) { 137return 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 : 149struct exec_result { 150 std::string output ; 151int exit_code = -1 ; 152bool timed_out = false; 153 }; 154 155virtual ~tools_io ()= default ; 156 157virtual bool is_directory (const std::string & path )const = 0 ; 158virtual bool is_regular_file (const std::string & path )const = 0 ; 159virtual bool file_size (const std::string & path ,uintmax_t & out_size )const = 0 ; 160virtual bool read_file (const std::string & path , std::string & out )const = 0 ; 161virtual 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 163virtual std::string resolve (const std::string & path )const = 0 ; 164struct list_entry { 165 std::string rel ;// '/'-separated, relative to `base` 166bool is_dir = false; 167 }; 168struct list_result { 169 std::vector < list_entry > entries ; 170 std::string err ;// set when `base` is not a directory 171bool 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 175virtual 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) 178virtual exec_result run ( 179const std::vector < std::string > & args , 180size_t max_output , 181int timeout_secs , 182const 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 ( 188const std::vector < std::string > & args , 189size_t max_output , 190int timeout_secs , 191const std::function < bool (const std::string & )> & on_chunk , 192bool combine_stderr , 193const std::string & cwd = "" , 194const std::string * stdin_data = nullptr ) { 195 tools_io::exec_result res ; 196 197common_subproc proc ; 198 199int options = subprocess_option_no_window 200 |subprocess_option_inherit_environment 201 |subprocess_option_search_user_path ; 202if (combine_stderr ) { 203options |=subprocess_option_combined_stdout_stderr ; 204 } 205 206if (!proc .create (args ,options , {},cwd .empty () ?nullptr :cwd .c_str ())) { 207res .output = "failed to spawn process" ; 208return res ; 209 } 210 211 std::atomic < bool > done {false}; 212 std::atomic < bool > timed_out {false}; 213 214 std::thread timeout_thread ([& ]() { 215auto deadline = std::chrono::steady_clock::now ()+ std::chrono::seconds (timeout_secs ); 216while (!done .load ()) { 217if (std::chrono::steady_clock::now () >=deadline ) { 218timed_out .store (true); 219proc .terminate (); 220return ; 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 228if (FILE * in = proc .stdin_file ()) { 229if (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 235fwrite (stdin_data -> data (),1 ,stdin_data -> size (),in ); 236 } 237fflush (in ); 238 } 239proc .close_stdin (); 240 241FILE * f = proc .stdout_file (); 242 std::string output ; 243bool truncated = false; 244if (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 251char buf [4096 ]; 252for (;;) { 253#if defined(_WIN32 ) 254const int n = _read (_fileno (f ),buf , (unsigned )sizeof (buf )); 255#else 256ssize_t n = read (fileno (f ),buf ,sizeof (buf )); 257while (n < 0 && errno == EINTR ) { 258n = read (fileno (f ),buf ,sizeof (buf )); 259 } 260#endif 261if (n <=0 ) { 262break ; 263 } 264if (truncated ) { 265continue ; 266 } 267const size_t len = (size_t )n ; 268if (output .size ()+ len <=max_output ) { 269output .append (buf ,len ); 270if (on_chunk && !on_chunk (console_output_to_utf8 (std::string (buf ,len )))) { 271proc .terminate (); 272break ; 273 } 274 }else { 275size_t remaining = max_output - output .size (); 276output .append (buf ,remaining ); 277if (on_chunk && remaining > 0 )on_chunk (console_output_to_utf8 (std::string (buf ,remaining ))); 278truncated = true; 279 } 280 } 281 } 282 283done .store (true); 284if (timeout_thread .joinable ()) { 285timeout_thread .join (); 286 } 287 288res .exit_code = proc .join (); 289 290res .output = console_output_to_utf8 (output ); 291res .timed_out = timed_out .load (); 292if (truncated ) { 293res .output += "\n[output truncated]" ; 294 } 295return 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() 301explicit 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 { 306const std::string p = expand_home (path ); 307 308 fs::path full = path_from_utf8 (p ); 309if (!full .is_absolute ()) { 310if (cwd .empty ()) { 311 std::error_code ec ; 312const fs::path cur = fs::current_path (ec ); 313if (ec )return p ; 314full = cur /full ; 315 }else { 316full = path_from_utf8 (cwd ) /full ; 317 } 318 } 319 320// drop "." and ".." so they never reach git or the client 321full = full .lexically_normal (); 322// a trailing ".." normalizes to a path that ends with a separator 323if (!full .has_filename ()&& full != full .root_path ()) { 324full = full .parent_path (); 325 } 326return path_to_utf8 (full ); 327 } 328 329bool is_directory (const std::string & path )const override { 330 std::error_code ec ; 331return fs::is_directory (path_from_utf8 (resolve (path )),ec )&& !ec ; 332 } 333 334bool is_regular_file (const std::string & path )const override { 335 std::error_code ec ; 336return fs::is_regular_file (path_from_utf8 (resolve (path )),ec )&& !ec ; 337 } 338 339bool file_size (const std::string & path ,uintmax_t & out_size )const override { 340 std::error_code ec ; 341out_size = fs::file_size (path_from_utf8 (resolve (path )),ec ); 342return !ec ; 343 } 344 345bool read_file (const std::string & path , std::string & out )const override { 346 std::ifstream f (path_from_utf8 (resolve (path )), std::ios::binary ); 347if (!f )return false; 348 std::ostringstream ss ; 349ss <<f .rdbuf (); 350out = ss .str (); 351return true; 352 } 353 354bool 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 )); 357if (fpath .has_parent_path ()) { 358 fs::create_directories (fpath .parent_path (),ec ); 359if (ec )return false; 360 } 361 std::ofstream f (fpath , std::ios::binary ); 362if (!f )return false; 363f <<content ; 364return (bool )f ; 365 } 366 367list_result list_entries (const std::string & base ,int max_depth ,list_kind kind )const override { 368list_result out ; 369 370 std::error_code ec ; 371if (!fs::is_directory (base ,ec )|| ec ) { 372out .err = "path does not exist or is not a directory" ; 373return out ; 374 } 375 376const 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 379if (kind == list_kind::files ) { 380auto res = run ( 381 {"git" ,"-C" ,base ,"ls-files" ,"--cached" ,"--others" ,"--exclude-standard" }, 382SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT ,SERVER_TOOL_LIST_ENTRIES_TIMEOUT ); 383 384if (res .exit_code == 0 && !res .timed_out ) { 385 std::istringstream iss (res .output ); 386 std::string line ; 387while (std::getline (iss ,line )) { 388if (!line .empty ()&& line .back ()== '\r' )line .pop_back (); 389if (line .empty ())continue ; 390 std::replace (line .begin (),line .end (),'\\' ,'/' ); 391if (max_depth > 0 && entry_depth (line )> max_depth )continue ; 392if (is_regular_file (path_to_utf8 (path_from_utf8 (base ) /path_from_utf8 (line )))) { 393out .entries .push_back ({line , false}); 394 } 395 } 396return out ; 397 } 398 } 399 400out .entries = list_entries_fallback (base ,max_depth ,kind ,deadline ,out .truncated ); 401return out ; 402 } 403 404exec_result run ( 405const std::vector < std::string > & args , 406size_t max_output , 407int timeout_secs , 408const std::function < bool (const std::string & )> & on_chunk = nullptr )const override { 409return 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 416static bool is_link (const fs::directory_entry & entry ) { 417 std::error_code ec ; 418if (entry .is_symlink (ec )|| ec ) { 419return true; 420 } 421#if defined(_WIN32 ) 422// a junction looks like a plain directory to std::filesystem, so read the reparse tag 423WIN32_FIND_DATAW data ; 424const HANDLE h = FindFirstFileW (entry .path ().c_str (),& data ); 425if (h == INVALID_HANDLE_VALUE ) { 426return false; 427 } 428FindClose (h ); 429if ((data .dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT )== 0 ) { 430return false; 431 } 432// other reparse points (cloud placeholder, dedup stub) are real directories 433return data .dwReserved0 == IO_REPARSE_TAG_SYMLINK || data .dwReserved0 == IO_REPARSE_TAG_MOUNT_POINT ; 434#else 435return false; 436#endif 437 } 438 439// NTFS is case insensitive, so Build and build are the same directory 440static 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 ); }); 445return lowered ; 446#else 447return fname ; 448#endif 449 } 450 451static const std::unordered_set < std::string > & junk_dir_names () { 452static const std::unordered_set < std::string > names ( 453 std::begin (SERVER_TOOL_JUNK_DIR_NAMES ), std::end (SERVER_TOOL_JUNK_DIR_NAMES )); 454return 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 ; 462stack .emplace_back (path_from_utf8 (base ), fs::path (),0 ); 463 464while (!stack .empty ()) { 465if (std::chrono::steady_clock::now () >=deadline ) { 466truncated = true; 467return result ; 468 } 469 470auto [dir ,rel_dir ,depth ]= std::move (stack .back ()); 471stack .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 477if (ec ) { 478truncated = true; 479continue ; 480 } 481for (const fs::directory_iterator end ;it != end ;it .increment (ec )) { 482if (ec ) { 483truncated = true; 484break ; 485 } 486if (std::chrono::steady_clock::now () >=deadline ) { 487truncated = true; 488return result ; 489 } 490const fs::directory_entry & entry = * it ; 491const fs::path fname = entry .path ().filename (); 492 std::error_code tec ; 493const bool is_dir = entry .is_directory (tec ); 494if (tec )continue ; 495if (is_dir ) { 496if (kind == list_kind::dirs || kind == list_kind::all ) { 497result .push_back ({path_to_utf8 (rel_dir /fname ), true}); 498 } 499// junk directories stay selectable but are never walked: they can be enormous 500if (junk_dir_names ().count (get_effective_name (path_to_utf8 (fname )))> 0 )continue ; 501if (!is_link (entry )&& (max_depth == 0 || depth + 1 < max_depth )) { 502stack .emplace_back (entry .path (),rel_dir /fname ,depth + 1 ); 503 } 504 }else if (entry .is_regular_file (tec )) { 505if (kind == list_kind::files || kind == list_kind::all ) { 506result .push_back ({path_to_utf8 (rel_dir /fname ), false}); 507 } 508 } 509 } 510 } 511 512return 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() 527explicit 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 { 532if (cwd .empty ()|| (!path .empty ()&& path [0 ]== '/' )) { 533return path ; 534 } 535return cwd + "/" + path ; 536 } 537 538bool is_directory (const std::string & path )const override { 539return shell_test ("-d" ,resolve (path )); 540 } 541 542bool is_regular_file (const std::string & path )const override { 543return shell_test ("-f" ,resolve (path )); 544 } 545 546bool file_size (const std::string & path ,uintmax_t & out_size )const override { 547auto res = exec ({"sh" ,"-c" ,"wc -c < \"$1\"" ,"_" ,resolve (path )},64 , true); 548if (res .exit_code != 0 || res .timed_out )return false; 549try { 550size_t pos ; 551out_size = (uintmax_t ) std::stoull (res .output ,& pos ); 552 }catch (...) { 553return false; 554 } 555return true; 556 } 557 558bool read_file (const std::string & path , std::string & out )const override { 559// combine_stderr=false: stderr must not be spliced into raw file bytes 560auto res = exec ({"cat" ,"--" ,resolve (path )},SERVER_TOOL_ISOLATE_READ_FILE_MAX_SIZE , false); 561if (res .exit_code != 0 || res .timed_out )return false; 562out = res .output ; 563return true; 564 } 565 566bool 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 568auto res = run_subprocess ( 569build_argv ({"sh" ,"-c" ,"mkdir -p \"$(dirname \"$1\")\" && cat > \"$1\"" ,"_" ,resolve (path )}, 570/*needs_stdin=*/ true), 5714096 ,SERVER_TOOL_ISOLATE_EXEC_TIMEOUT ,nullptr , true,"" ,& content ); 572return res .exit_code == 0 && !res .timed_out ; 573 } 574 575list_result list_entries (const std::string & base ,int max_depth ,list_kind kind )const override { 576list_result out ; 577 578const std::string abs_base = resolve (base ); 579if (!is_directory (base )) { 580out .err = "path does not exist or is not a directory" ; 581return out ; 582 } 583 584// git ls-files cannot list directories; use the walker when they are requested 585if (kind == list_kind::files ) { 586auto res = exec ( 587 {"sh" ,"-c" ,"cd \"$1\" && git ls-files --cached --others --exclude-standard" ,"_" ,abs_base }, 588SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT , true); 589 590if (res .exit_code == 0 && !res .timed_out ) { 591for (const auto & rel :split_lines (res .output ,/*strip_dot_slash=*/ false)) { 592if (max_depth > 0 && entry_depth (rel )> max_depth )continue ; 593out .entries .push_back ({rel , false}); 594 } 595return out ; 596 } 597 } 598 599if (kind == list_kind::dirs || kind == list_kind::all ) { 600for (auto & rel :find_entries (abs_base ,max_depth ,/*dirs=*/ true,out .truncated )) { 601out .entries .push_back ({std::move (rel ), true}); 602 } 603 } 604if (kind == list_kind::files || kind == list_kind::all ) { 605for (auto & rel :find_entries (abs_base ,max_depth ,/*dirs=*/ false,out .truncated )) { 606out .entries .push_back ({std::move (rel ), false}); 607 } 608 } 609 610return 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 615exec_result run ( 616const std::vector < std::string > & args , 617size_t max_output , 618int timeout_secs , 619const std::function < bool (const std::string & )> & on_chunk = nullptr )const override { 620 std::vector < std::string > inner = {"timeout" , std::to_string (timeout_secs )+ "s" }; 621inner .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 624return run_subprocess ( 625build_argv (with_cwd (inner ),/*needs_stdin=*/ true), 626max_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() 632virtual 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` 635static std::string shell_quote_join (const std::vector < std::string > & argv ) { 636 std::string out ; 637for (const auto & arg :argv ) { 638if (!out .empty ())out += ' ' ; 639out += '\'' ; 640for (const char c :arg ) { 641// a single quote cannot be escaped inside single quotes: close, escape, reopen 642if (c == '\'' )out += "'\\''" ; 643else out += c ; 644 } 645out += '\'' ; 646 } 647return 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 { 656if (cwd .empty ()) { 657return 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 }; 661out .insert (out .end (),inner .begin (),inner .end ()); 662return out ; 663 } 664 665exec_result exec (const std::vector < std::string > & inner ,size_t max_output ,bool combine_stderr )const { 666return run_subprocess ( 667build_argv (inner ,/*needs_stdin=*/ false), 668max_output ,SERVER_TOOL_ISOLATE_EXEC_TIMEOUT ,nullptr ,combine_stderr ); 669 } 670 671bool shell_run (const std::vector < std::string > & inner )const { 672auto res = exec (inner ,4096 , true); 673return res .exit_code == 0 && !res .timed_out ; 674 } 675 676bool shell_test (const char * flag ,const std::string & path )const { 677return shell_run ({"sh" ,"-c" , std::string ("[ " )+ flag + " \"$1\" ]" ,"_" ,path }); 678 } 679 680static 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 ; 684while (std::getline (iss ,line )) { 685if (!line .empty ()&& line .back ()== '\r' )line .pop_back (); 686if (line .empty ())continue ; 687if (strip_dot_slash && line .rfind ("./" ,0 )== 0 )line = line .substr (2 ); 688 std::replace (line .begin (),line .end (),'\\' ,'/' ); 689result .push_back (line ); 690 } 691return 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 ; 698for (const char * n :SERVER_TOOL_JUNK_DIR_NAMES ) { 699if (!prune_expr .empty ())prune_expr += " -o " ; 700prune_expr += std::string ("-name " )+ n ; 701 } 702 703 std::string cmd = "cd \"$1\" && find . -mindepth 1" ; 704if (max_depth > 0 ) { 705cmd += " -maxdepth " + std::to_string (max_depth ); 706 } 707cmd += " \\( " + prune_expr + " \\) -prune" ; 708cmd += dirs ?" -print -o -type d -print" :" -o -type f -print" ; 709 710auto res = exec ({"sh" ,"-c" ,cmd ,"_" ,abs_base },SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT , true); 711truncated = truncated || res .timed_out ; 712return 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 : 720tools_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" }; 726if (needs_stdin ) { 727argv .push_back ("-i" ); 728 } 729argv .push_back (container_id ); 730argv .insert (argv .end (),inner .begin (),inner .end ()); 731return 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 : 743tools_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 748static bool is_valid_target (const std::string & target ) { 749if (target .empty ()|| target [0 ]== '-' ) { 750return false; 751 } 752return std::all_of (target .begin (),target .end (), [](unsigned char c ) { 753return 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 (); 761if (!needs_stdin ) { 762argv .push_back ("-n" ); 763 } 764argv .push_back (target ); 765argv .push_back (shell_quote_join (inner )); 766return 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 774static std::vector < std::string > ssh_argv () { 775return { 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 789bool attach = false; 790 791static bool parse (const std::string & spec ,container_runtime_spec & out ) { 792// docker and podman take the same verbs, hence a single implementation 793static const char * engines []= {"docker" ,"podman" }; 794for (const char * bin :engines ) { 795const std::string attach_prefix = std::string (bin )+ "-container:" ; 796if (spec .rfind (attach_prefix ,0 )== 0 ) { 797out = {bin ,spec .substr (attach_prefix .size ()), true}; 798return true; 799 } 800const std::string spawn_prefix = std::string (bin )+ ":" ; 801if (spec .rfind (spawn_prefix ,0 )== 0 ) { 802out = {bin ,spec .substr (spawn_prefix .size ()), false}; 803return true; 804 } 805 } 806return false; 807 } 808 809// same risk as the ssh target: an id starting with '-' would become an engine option, 810// e.g. --privileged 811static bool is_valid_id (const std::string & id ) { 812if (id .empty ()|| !std::isalnum ((unsigned char )id [0 ])) { 813return false; 814 } 815return std::all_of (id .begin (),id .end (), [](unsigned char c ) { 816return 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 ()); 824if (runtime .empty ()) { 825// an empty runtime runs the tools on the host 826return std::make_unique < tools_io_basic > (cwd ); 827 } 828container_runtime_spec container ; 829if (container_runtime_spec::parse (runtime ,container )) { 830// spawning belongs to the runtime that owns the container, a tool call only attaches 831if (!container .attach ) { 832throw std::runtime_error ("tool runtime must name a running container: " + runtime ); 833 } 834if (!container_runtime_spec::is_valid_id (container .arg )) { 835throw std::runtime_error ("invalid container id: " + container .arg ); 836 } 837return std::make_unique < tools_io_container > (container .bin ,container .arg ,cwd ); 838 } 839const std::string ssh_prefix = "ssh:" ; 840if (runtime .rfind (ssh_prefix ,0 )== 0 ) { 841 std::string target = runtime .substr (ssh_prefix .size ()); 842if (!tools_io_ssh::is_valid_target (target )) { 843throw std::runtime_error ("invalid ssh target: " + target ); 844 } 845return std::make_unique < tools_io_ssh > (target ,cwd ); 846 } 847// do not fall back to the host, the caller asked for an isolate 848throw 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 ) { 853if (pattern .find ('/' )== std::string::npos ) { 854return glob_match (pattern ,path_to_utf8 (path_from_utf8 (rel_path ).filename ())); 855 } 856if (pattern == "**" || pattern .rfind ("**/" ,0 )== 0 || pattern .rfind ('/' ,0 )== 0 ) { 857return glob_match (pattern ,rel_path ); 858 } 859return 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 { 870server_tool_read_file () { 871name = "read_file" ; 872display_name = "Read file" ; 873uses_cwd = true; 874permission_write = false; 875 } 876 877json get_definition ()const override { 878return { 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 898json invoke (json params , server_tool::stream * )const override { 899 std::string path = params .at ("path" ).get < std::string > (); 900int start_line = json_value (params ,"start_line" ,1 ); 901int end_line = json_value (params ,"end_line" ,-1 );// -1 = no limit 902bool append_loc = json_value (params ,"append_loc" , false); 903// comes from the x-resp-type header, the model cannot ask for it 904bool as_base64 = json_value (params ,"resp_type" , std::string ())== "base64" ; 905 906auto io = make_tools_io (params ); 907 908uintmax_t file_size = 0 ; 909if (!io -> file_size (path ,file_size )) { 910return {{"error" ,"cannot stat file: " + path }}; 911 } 912 913if (as_base64 ) { 914if (file_size > SERVER_TOOL_READ_FILE_MAX_SIZE_BASE64 ) { 915return {{"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 ; 920if (!io -> read_file (path ,content )) { 921return {{"error" ,"failed to open file: " + path }}; 922 } 923return { 924 {"base64" , base64::encode (content .data (),content .size ())}, 925 {"size_bytes" , (size_t )content .size ()}, 926 }; 927 } 928 929if (file_size > SERVER_TOOL_READ_FILE_MAX_SIZE && end_line == -1 ) { 930return {{"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 ; 936if (!io -> read_file (path ,content )) { 937return {{"error" ,"failed to open file: " + path }}; 938 } 939 940 std::istringstream f (content ); 941 std::string result ; 942 std::string line ; 943int lineno = 0 ; 944 945while (std::getline (f ,line )) { 946lineno ++ ; 947if (lineno < start_line )continue ; 948if (end_line != -1 && lineno > end_line )break ; 949 950 std::string out_line ; 951if (append_loc ) { 952out_line = std::to_string (lineno )+ "\u2192" + line + "\n" ; 953 }else { 954out_line = line + "\n" ; 955 } 956 957if (result .size ()+ out_line .size ()> SERVER_TOOL_READ_FILE_MAX_SIZE ) { 958result += "[output truncated]" ; 959break ; 960 } 961result += out_line ; 962 } 963 964return {{"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 { 978server_tool_file_glob_search () { 979name = "file_glob_search" ; 980display_name = "File search" ; 981uses_cwd = true; 982permission_write = false; 983 } 984 985json get_definition ()const override { 986return { 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 1015json invoke (json params , server_tool::stream * )const override { 1016auto io = make_tools_io (params ); 1017 1018const 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" )); 1024int max_depth = std::max (0 ,json_value (params ,"max_depth" ,0 )); 1025const int limit_req = json_value (params ,"limit" ,SERVER_TOOL_FILE_SEARCH_MAX_RESULTS ); 1026if (limit_req < 1 ) { 1027return {{"error" ,"invalid limit: " + std::to_string (limit_req )+ " (expected 1 or more)" }}; 1028 } 1029const int limit = std::min (limit_req ,SERVER_TOOL_FILE_SEARCH_MAX_RESULTS ); 1030 1031list_kind kind ; 1032if (type == SERVER_TOOL_FILE_SEARCH_TYPE_FILE ) { 1033kind = list_kind::files ; 1034 }else if (type == SERVER_TOOL_FILE_SEARCH_TYPE_DIR ) { 1035kind = list_kind::dirs ; 1036 }else if (type == SERVER_TOOL_FILE_SEARCH_TYPE_ALL ) { 1037kind = list_kind::all ; 1038 }else { 1039return {{"error" ,"invalid type: " + type + " (expected \"file\", \"dir\" or \"all\")" }}; 1040 } 1041 1042const auto listing = io -> list_entries (base ,max_depth ,kind ); 1043if (!listing .err .empty ()) { 1044return {{"error" ,listing .err + ": " + path }}; 1045 } 1046 1047 std::vector < tools_io::list_entry > matches ; 1048for (const auto & entry :listing .entries ) { 1049if (!path_glob_match (include ,entry .rel ))continue ; 1050if (!exclude .empty ()&& path_glob_match (exclude ,entry .rel ))continue ; 1051matches .push_back (entry ); 1052 } 1053 1054size_t total = matches .size (); 1055size_t shown = std::min (total , (size_t )limit ); 1056 1057 std::ostringstream output_text ; 1058json entries_json = json::array (); 1059for (size_t i = 0 ;i < shown ;i ++ ) { 1060output_text <<matches [i ].rel << (matches [i ].is_dir ?"/" :"" ) <<"\n" ; 1061entries_json .push_back ({ 1062 {"path" ,matches [i ].rel }, 1063 {"type" ,matches [i ].is_dir ?"dir" :"file" }, 1064 }); 1065 } 1066 1067output_text <<"\n---\nTotal matches: " <<total <<"\n" ; 1068if (total > shown ) { 1069output_text <<string_format ( 1070"[%zu results limit reached (%zu total matches). Refine the glob pattern to narrow the search.]\n" , 1071shown ,total ); 1072 } 1073if (listing .truncated ) { 1074output_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. 1082return {{"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 { 1093server_tool_grep_search () { 1094name = "grep_search" ; 1095display_name = "Grep search" ; 1096uses_cwd = true; 1097permission_write = false; 1098 } 1099 1100json get_definition ()const override { 1101return { 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 1130json 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 ("" )); 1135bool show_lineno = json_value (params ,"return_line_numbers" , false); 1136bool literal = json_value (params ,"literal" , false); 1137bool ignore_case = json_value (params ,"ignore_case" , false); 1138int ctx_lines = std::max (0 ,json_value (params ,"context_lines" ,0 )); 1139 1140 std::string pattern_src = pat_str ; 1141if (literal ) { 1142static const std::string specials = "\\^$.|?*+()[]{}" ; 1143 std::string escaped ; 1144escaped .reserve (pat_str .size ()* 2 ); 1145for (char c :pat_str ) { 1146if (specials .find (c )!= std::string::npos )escaped += '\\' ; 1147escaped += c ; 1148 } 1149pattern_src = escaped ; 1150 } 1151 1152 std::regex pattern ; 1153try { 1154auto flags = std::regex::ECMAScript ; 1155if (ignore_case )flags |= std::regex::icase ; 1156pattern = std::regex (pattern_src ,flags ); 1157 }catch (const std::regex_error & e ) { 1158return {{"error" , std::string ("invalid regex: " )+ e .what ()}}; 1159 } 1160 1161auto 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 1166const std::string abs_path = io -> resolve (path ); 1167if (io -> is_regular_file (abs_path )) { 1168files .emplace_back (abs_path ,path ); 1169 }else if (io -> is_directory (abs_path )) { 1170const auto listing = io -> list_entries (abs_path ,0 , list_kind::files ); 1171if (!listing .err .empty ()) { 1172return {{"error" ,listing .err + ": " + path }}; 1173 } 1174for (const auto & entry :listing .entries ) { 1175if (!path_glob_match (include ,entry .rel ))continue ; 1176if (!exclude .empty ()&& path_glob_match (exclude ,entry .rel ))continue ; 1177files .emplace_back (path_to_utf8 (path_from_utf8 (abs_path ) /path_from_utf8 (entry .rel )),entry .rel ); 1178 } 1179 }else { 1180return {{"error" ,"path does not exist: " + path }}; 1181 } 1182 1183 std::ostringstream output_text ; 1184size_t total = 0 ; 1185bool limit_reached = false; 1186bool show_num = show_lineno || ctx_lines > 0 ; 1187 1188for (const auto & file_entry :files ) { 1189if (limit_reached )break ; 1190const std::string & fpath = file_entry .first ; 1191const std::string & display_path = file_entry .second ; 1192 1193 std::string content ; 1194if (!io -> read_file (fpath ,content ))continue ; 1195 std::vector < std::string > lines ; 1196 { 1197 std::istringstream f (content ); 1198 std::string line ; 1199while (std::getline (f ,line ))lines .push_back (line ); 1200 } 1201 1202for (size_t i = 0 ;i < lines .size ();i ++ ) { 1203if (total >=SERVER_TOOL_GREP_SEARCH_MAX_RESULTS ) { 1204limit_reached = true; 1205break ; 1206 } 1207if (!std::regex_search (lines [i ],pattern ))continue ; 1208 1209long ctx_start = ctx_lines > 0 ? std::max < long > (0 , (long )i - ctx_lines ) : (long )i ; 1210long ctx_end = ctx_lines > 0 ? std::min < long > ((long )lines .size ()- 1 , (long )i + ctx_lines ) : (long )i ; 1211 1212for (long j = ctx_start ;j <=ctx_end ;j ++ ) { 1213bool is_match = (j == (long )i ); 1214output_text <<display_path << (is_match ?':' :'-' ); 1215if (show_num ) { 1216output_text << (j + 1 ) << (is_match ?':' :'-' ); 1217 } 1218output_text <<lines [j ] <<"\n" ; 1219 } 1220if (ctx_lines > 0 ) { 1221output_text <<"--\n" ; 1222 } 1223total ++ ; 1224 } 1225 } 1226 1227output_text <<"\n---\nTotal matches: " <<total <<"\n" ; 1228if (limit_reached ) { 1229output_text <<string_format ( 1230"[%zu matches limit reached. Narrow the path/pattern/include to see more.]\n" , 1231SERVER_TOOL_GREP_SEARCH_MAX_RESULTS ); 1232 } 1233 1234return {{"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 { 1246server_tool_exec_shell_command () { 1247name = "exec_shell_command" ; 1248display_name = "Execute shell command" ; 1249uses_cwd = true; 1250permission_write = true; 1251support_stream = true; 1252 } 1253 1254json get_definition ()const override { 1255return { 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 1273json invoke (json params , server_tool::stream * st )const override { 1274 std::string command = params .at ("command" ).get < std::string > (); 1275int timeout = json_value (params ,"timeout" ,10 ); 1276size_t max_output = (size_t )json_value (params ,"max_output_size" , (int )SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_OUTPUT_SIZE ); 1277 1278timeout = std::min (timeout ,SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_TIMEOUT ); 1279max_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 1290auto io = make_tools_io (params ); 1291 1292if (st ) { 1293auto res = io -> run (args ,max_output ,timeout , [st ](const std::string & chunk ) { 1294st -> push (chunk ); 1295return !st -> alive || st -> alive (); 1296 }); 1297if (st -> alive && !st -> alive ()) { 1298return json (); 1299 } 1300 std::string tail = string_format ("\n[exit code: %d]" ,res .exit_code ); 1301if (res .timed_out ) { 1302tail += " [exit due to timed out]" ; 1303 } 1304st -> push (tail ); 1305return json (); 1306 } 1307 1308auto res = io -> run (args ,max_output ,timeout ); 1309 1310 std::string text_output = res .output ; 1311text_output += string_format ("\n[exit code: %d]" ,res .exit_code ); 1312if (res .timed_out ) { 1313text_output += " [exit due to timed out]" ; 1314 } 1315 1316return {{"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 { 1325server_tool_write_file () { 1326name = "write_file" ; 1327display_name = "Write file" ; 1328uses_cwd = true; 1329permission_write = true; 1330 } 1331 1332json get_definition ()const override { 1333return { 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 1350json 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 1354auto io = make_tools_io (params ); 1355if (!io -> write_file (path ,content )) { 1356return {{"error" ,"failed to write file: " + path }}; 1357 } 1358 1359return {{"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 { 1368server_tool_edit_file () { 1369name = "edit_file" ; 1370display_name = "Edit file" ; 1371uses_cwd = true; 1372permission_write = true; 1373 } 1374 1375json get_definition ()const override { 1376return { 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 1407json invoke (json params , server_tool::stream * )const override { 1408 std::string path = params .at ("path" ).get < std::string > (); 1409const json & edits_json = params .at ("edits" ); 1410 1411if (!edits_json .is_array ()|| edits_json .empty ()) { 1412return {{"error" ,"\"edits\" must be a non-empty array" }}; 1413 } 1414 1415struct edit_req { 1416 std::string old_text ; 1417 std::string new_text ; 1418 }; 1419 std::vector < edit_req > edits ; 1420edits .reserve (edits_json .size ()); 1421for (const auto & e :edits_json ) { 1422edit_req er ; 1423er .old_text = e .at ("old_text" ).get < std::string > (); 1424er .new_text = e .at ("new_text" ).get < std::string > (); 1425if (er .old_text .empty ()) { 1426return {{"error" ,string_format ("edits[%zu].old_text must not be empty" ,edits .size ())}}; 1427 } 1428edits .push_back (std::move (er )); 1429 } 1430 1431auto io = make_tools_io (params ); 1432 std::string original_content ; 1433if (!io -> read_file (path ,original_content )) { 1434return {{"error" ,"failed to open file: " + path }}; 1435 } 1436 1437// does any old_text need fuzzy matching (no exact match found)? 1438bool any_fuzzy = false; 1439for (size_t i = 0 ;i < edits .size ();i ++ ) { 1440if (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 ); 1443if (fuzzy_content .find (fuzzy_old )== std::string::npos ) { 1444return {{"error" ,string_format ( 1445"could not find edits[%zu].old_text in %s, it must match the file's current content exactly" , 1446i ,path .c_str ())}}; 1447 } 1448any_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 ; 1455matched .reserve (edits .size ()); 1456for (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 ; 1458size_t occurrences = count_occurrences ( 1459normalize_for_fuzzy_match (original_content ), 1460normalize_for_fuzzy_match (edits [i ].old_text )); 1461if (occurrences > 1 ) { 1462return {{"error" ,string_format ( 1463"found %zu occurrences of edits[%zu].old_text in %s, it must be unique" , 1464occurrences ,i ,path .c_str ())}}; 1465 } 1466size_t idx = base_content .find (needle ); 1467matched .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 ) { 1471return a .match_index < b .match_index ; 1472 }); 1473for (size_t i = 1 ;i < matched .size ();i ++ ) { 1474if (matched [i - 1 ].match_index + matched [i - 1 ].match_length > matched [i ].match_index ) { 1475return {{"error" ,string_format ( 1476"edits[%zu] and edits[%zu] overlap in %s; merge them into one edit or target disjoint regions" , 1477matched [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 1485if (new_content == original_content ) { 1486return {{"error" ,"no changes made: the replacement(s) produced identical content" }}; 1487 } 1488 1489if (!io -> write_file (path ,new_content )) { 1490return {{"error" ,"failed to write file: " + path }}; 1491 } 1492 1493return {{"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 1498static std::string normalize_line_for_fuzzy_match (const std::string & line ) { 1499size_t end = line .size (); 1500while (end > 0 && (line [end - 1 ]== ' ' || line [end - 1 ]== '\t' || line [end - 1 ]== '\r' )) { 1501end -- ; 1502 } 1503 std::string s = line .substr (0 ,end ); 1504 1505auto replace_all = [](std::string & str ,const std::string & from ,const std::string & to ) { 1506if (from .empty ())return ; 1507size_t pos = 0 ; 1508while ((pos = str .find (from ,pos ))!= std::string::npos ) { 1509str .replace (pos ,from .size (),to ); 1510pos += to .size (); 1511 } 1512 }; 1513 1514// smart single quotes -> ' 1515for (unsigned char b : {0x98 ,0x99 ,0x9A ,0x9B }) { 1516replace_all (s , std::string ("\xE2\x80" )+ (char )b ,"'" ); 1517 } 1518// smart double quotes -> " 1519for (unsigned char b : {0x9C ,0x9D ,0x9E ,0x9F }) { 1520replace_all (s , std::string ("\xE2\x80" )+ (char )b ,"\"" ); 1521 } 1522// various dashes -> - 1523for (unsigned char b = 0x90 ;b <=0x95 ;b ++ ) { 1524replace_all (s , std::string ("\xE2\x80" )+ (char )b ,"-" ); 1525 } 1526replace_all (s ,"\xE2\x88\x92" ,"-" );// minus sign 1527// special spaces -> ' ' 1528replace_all (s ,"\xC2\xA0" ," " );// no-break space 1529for (unsigned char b = 0x82 ;b <=0x8A ;b ++ ) { 1530replace_all (s , std::string ("\xE2\x80" )+ (char )b ," " ); 1531 } 1532replace_all (s ,"\xE2\x80\xAF" ," " );// narrow no-break space 1533replace_all (s ,"\xE2\x81\x9F" ," " );// medium mathematical space 1534replace_all (s ,"\xE3\x80\x80" ," " );// ideographic space 1535 1536return s ; 1537 } 1538 1539// applies the per-line transform above to every line; preserves line count/positions 1540static std::string normalize_for_fuzzy_match (const std::string & content ) { 1541 std::string result ; 1542result .reserve (content .size ()); 1543size_t start = 0 ; 1544while (true) { 1545size_t nl = content .find ('\n' ,start ); 1546bool is_last = nl == std::string::npos ; 1547 std::string line = is_last ?content .substr (start ) :content .substr (start ,nl - start ); 1548result += normalize_line_for_fuzzy_match (line ); 1549if (is_last )break ; 1550result += '\n' ; 1551start = nl + 1 ; 1552 } 1553return result ; 1554 } 1555 1556// lines with trailing '\n' kept, so untouched ones can be reconstructed verbatim 1557static std::vector < std::string > split_lines_with_endings (const std::string & content ) { 1558 std::vector < std::string > lines ; 1559size_t start = 0 ; 1560while (start < content .size ()) { 1561size_t nl = content .find ('\n' ,start ); 1562if (nl == std::string::npos ) { 1563lines .push_back (content .substr (start )); 1564break ; 1565 } 1566lines .push_back (content .substr (start ,nl - start + 1 )); 1567start = nl + 1 ; 1568 } 1569return lines ; 1570 } 1571 1572struct line_span { 1573size_t start ; 1574size_t end ; 1575 }; 1576 1577static std::vector < line_span > get_line_spans (const std::string & content ) { 1578 std::vector < line_span > spans ; 1579size_t offset = 0 ; 1580for (const auto & line :split_lines_with_endings (content )) { 1581spans .push_back ({offset ,offset + line .size ()}); 1582offset += line .size (); 1583 } 1584return spans ; 1585 } 1586 1587// count non-overlapping occurrences of `needle` in `content` 1588static size_t count_occurrences (const std::string & content ,const std::string & needle ) { 1589if (needle .empty ())return 0 ; 1590size_t count = 0 ,pos = 0 ; 1591while ((pos = content .find (needle ,pos ))!= std::string::npos ) { 1592count ++ ; 1593pos += needle .size (); 1594 } 1595return count ; 1596 } 1597 1598struct matched_edit { 1599size_t edit_index ; 1600size_t match_index ;// offset into the "base content" (see below) 1601size_t match_length ; 1602 std::string new_text ; 1603 }; 1604 1605// replacements must be sorted ascending by match_index and non-overlapping 1606static std::string apply_replacements ( 1607const std::string & content , 1608const std::vector < matched_edit > & replacements , 1609size_t offset ) { 1610 std::string result = content ; 1611for (auto it = replacements .rbegin ();it != replacements .rend ();++ it ) { 1612size_t local_index = it -> match_index - offset ; 1613result = result .substr (0 ,local_index )+ it -> new_text + result .substr (local_index + it -> match_length ); 1614 } 1615return result ; 1616 } 1617 1618// widen a replacement's byte range to the line(s) of `lines` it touches 1619static bool get_replacement_line_range ( 1620const std::vector < line_span > & lines , 1621size_t match_index ,size_t match_length , 1622size_t & out_start_line ,size_t & out_end_line /* exclusive */ ) { 1623size_t replacement_start = match_index ; 1624size_t replacement_end = match_index + match_length ; 1625 1626size_t start_line = (size_t )-1 ; 1627for (size_t i = 0 ;i < lines .size ();i ++ ) { 1628if (replacement_start >=lines [i ].start && replacement_start < lines [i ].end ) { 1629start_line = i ; 1630break ; 1631 } 1632 } 1633if (start_line == (size_t )-1 )return false; 1634 1635size_t end_line = start_line ; 1636while (end_line < lines .size ()&& lines [end_line ].end < replacement_end ) { 1637end_line ++ ; 1638 } 1639if (end_line >=lines .size ())return false; 1640 1641out_start_line = start_line ; 1642out_end_line = end_line + 1 ; 1643return true; 1644 } 1645 1646// like apply_replacements, but untouched lines come from `original_content` 1647static std::string apply_replacements_preserving_unchanged_lines ( 1648const std::string & original_content , 1649const std::string & base_content , 1650const std::vector < matched_edit > & replacements /* ascending, non-overlapping */ ) { 1651auto original_lines = split_lines_with_endings (original_content ); 1652auto base_lines = get_line_spans (base_content ); 1653 1654struct group { 1655size_t start_line ; 1656size_t end_line ;// exclusive 1657 std::vector < matched_edit > reps ; 1658 }; 1659 std::vector < group > groups ; 1660 1661for (const auto & rep :replacements ) { 1662size_t start_line = 0 ,end_line = 0 ; 1663get_replacement_line_range (base_lines ,rep .match_index ,rep .match_length ,start_line ,end_line ); 1664if (!groups .empty ()&& start_line < groups .back ().end_line ) { 1665groups .back ().end_line = std::max (groups .back ().end_line ,end_line ); 1666groups .back ().reps .push_back (rep ); 1667 }else { 1668groups .push_back ({start_line ,end_line , {rep }}); 1669 } 1670 } 1671 1672size_t original_line_index = 0 ; 1673 std::string result ; 1674for (auto & g :groups ) { 1675for (size_t i = original_line_index ;i < g .start_line ;i ++ ) { 1676result += original_lines [i ]; 1677 } 1678 1679size_t group_start_offset = base_lines [g .start_line ].start ; 1680size_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 ); 1682result += apply_replacements (slice ,g .reps ,group_start_offset ); 1683 1684original_line_index = g .end_line ; 1685 } 1686for (size_t i = original_line_index ;i < original_lines .size ();i ++ ) { 1687result += original_lines [i ]; 1688 } 1689 1690return 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 { 1702server_tool_get_info () { 1703name = "get_info" ; 1704display_name = "Get Runtime Info" ; 1705uses_cwd = true; 1706permission_write = false; 1707 } 1708 1709json get_definition ()const override { 1710return { 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 1723json invoke (json params , server_tool::stream * )const override { 1724auto 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 1735auto 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 ()); 1741if (cwd .empty ()) { 1742if (json_value (params ,"runtime" , std::string ()).empty ()) { 1743 std::error_code ec ; 1744cwd = path_to_utf8 (fs::current_path (ec )); 1745 }else { 1746auto pwd = io -> run ({"pwd" },SERVER_TOOL_GET_INFO_MAX_OUTPUT ,SERVER_TOOL_GET_INFO_TIMEOUT ); 1747cwd = pwd .exit_code == 0 && !pwd .timed_out ?string_strip (pwd .output ) :"unknown" ; 1748 } 1749 } 1750 1751return { 1752 {"os" ,os_info }, 1753 {"cwd" ,cwd }, 1754 }; 1755 } 1756}; 1757 1758struct server_tool_stream_result :server_task_result { 1759 std::string chunk ; 1760bool done = false; 1761 std::string error_msg ; 1762 1763json to_json ()override { 1764if (!done ) { 1765return {{"chunk" ,chunk }}; 1766 }else { 1767json result = {{"done" , true}}; 1768if (!error_msg .empty ()) { 1769result ["error" ]= error_msg ; 1770 } 1771return result ; 1772 } 1773 } 1774}; 1775 1776void server_tool::stream::push (const std::string & chunk ) { 1777if (chunk .empty ())return ; 1778auto r = std::make_unique < server_tool_stream_result > (); 1779r -> id = id ; 1780r -> chunk = chunk ; 1781qr .send (std::move (r )); 1782} 1783 1784struct server_tools_res :server_http_res { 1785 std::thread worker ; 1786server_response * qr = nullptr ;// set only for streaming responses 1787int id = -1 ; 1788 1789 ~server_tools_res ()override { 1790if (worker .joinable ()) { 1791worker .join (); 1792 } 1793if (qr ) { 1794qr -> 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 ; 1805server_mcp_tool_def def ; 1806server_mcp & mcp_mgr ; 1807 1808server_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 { 1814name = server_name + "_" + tool_name ; 1815display_name = name ; 1816permission_write = false; 1817support_stream = false; 1818 } 1819 1820 std::string type ()const override {return "mcp" ; } 1821 1822json get_definition ()const override { 1823json schema = def .input_schema ; 1824if (schema .is_null ()|| !schema .is_object ()) { 1825schema = json::object (); 1826 } 1827return { 1828 {"type" ,"function" }, 1829 {"function" , { 1830 {"name" ,name }, 1831 {"description" ,def .description }, 1832 {"parameters" ,schema }, 1833 }}, 1834 }; 1835 } 1836 1837json invoke (json params , server_tool::stream * )const override { 1838return 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 { 1845virtual ~server_tools_runtime ()= default ; 1846virtual 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 { 1852explicit 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 { 1862server_tools_container_runtime (const server_tools_container_runtime & )= delete ; 1863 1864explicit server_tools_container_runtime (const std::string & spec ) { 1865container_runtime_spec parsed ; 1866if (!container_runtime_spec::parse (spec ,parsed )) { 1867throw std::runtime_error ("unknown --tools-runtime option: " + spec ); 1868 } 1869 1870bin = parsed .bin ; 1871image = parsed .arg ; 1872if (image .empty ()) { 1873throw std::runtime_error ("--tools-runtime " + bin + ":<image> requires an image name" ); 1874 } 1875spawn (); 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 1880proc .close_stdin (); 1881proc .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 ); 1887if (!proc .alive ()) { 1888SRV_WRN ("%s tools runtime container \"%s\" died, respawning\n" ,bin .c_str (),container_id .c_str ()); 1889spawn (); 1890 } 1891return bin + "-container:" + container_id ; 1892 } 1893 1894private : 1895 std::string bin ; 1896 std::string image ; 1897 std::string container_id ; 1898common_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 1903void spawn () { 1904// create() writes over the handle it is given, so the previous one is released first 1905proc .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" }; 1913int options = subprocess_option_no_window 1914 |subprocess_option_inherit_environment 1915 |subprocess_option_search_user_path ; 1916if (!proc .create (args ,options )) { 1917throw std::runtime_error ("failed to spawn " + bin + " container for tools runtime (image: " + image + ")" ); 1918 } 1919 1920 std::string cid ; 1921for (int i = 0 ;i < 100 && cid .empty ();i ++ ) { 1922 std::ifstream f (cidfile ); 1923if (f ) std::getline (f ,cid ); 1924if (cid .empty ()) std::this_thread::sleep_for (std::chrono::milliseconds (100 )); 1925 } 1926 fs::remove (cidfile ,ec ); 1927if (cid .empty ()) { 1928proc .terminate (); 1929throw std::runtime_error ("timed out waiting for " + bin + " container to start (image: " + image + ")" ); 1930 } 1931container_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 ) { 1936for (auto & t :tools ) { 1937if (t -> name == name ) { 1938if (require_stream && !t -> support_stream ) { 1939throw std::invalid_argument (string_format ("tool \"%s\" does not support stream = true" ,name .c_str ())); 1940 } 1941return * t ; 1942 } 1943 } 1944throw 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 ; 1957tools .push_back (std::make_unique < server_tool_read_file > ()); 1958tools .push_back (std::make_unique < server_tool_file_glob_search > ()); 1959tools .push_back (std::make_unique < server_tool_grep_search > ()); 1960tools .push_back (std::make_unique < server_tool_exec_shell_command > ()); 1961tools .push_back (std::make_unique < server_tool_write_file > ()); 1962tools .push_back (std::make_unique < server_tool_edit_file > ()); 1963tools .push_back (std::make_unique < server_tool_get_info > ()); 1964return 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 ); }); 1970return lowered ; 1971} 1972 1973static std::string get_header (const std::map < std::string , std::string > & headers ,const std::string & key , std::string default_value = "" ) { 1974const auto lowered_key = str_to_lower (key ); 1975for (const auto & h :headers ) { 1976if (str_to_lower (h .first )== lowered_key ) { 1977return h .second ; 1978 } 1979 } 1980return 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 ) { 1989container_runtime_spec parsed ; 1990if (container_runtime_spec::parse (spec ,parsed )&& !parsed .attach ) { 1991return std::make_unique < server_tools_container_runtime > (spec ); 1992 } 1993make_tools_io ({{"runtime" ,spec }});// nothing to own, just reject a bad spec now 1994return std::make_unique < server_tools_static_runtime > (spec ); 1995} 1996 1997void server_tools::setup (const std::vector < std::string > & enabled_tools , 1998server_mcp & mcp_mgr , 1999const std::string & tools_runtime ) { 2000if (!tools_runtime .empty ()) { 2001runtime = make_tools_runtime (tools_runtime ); 2002 } 2003 2004if (!enabled_tools .empty ()) { 2005if (!common_subproc::is_supported ()) { 2006throw 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 ()); 2010auto all_tools = build_tools (); 2011 2012// collect all known tool names for validation 2013 std::vector < std::string > known_names ; 2014known_names .reserve (all_tools .size ()); 2015for (const auto & t :all_tools ) { 2016known_names .push_back (t -> name ); 2017 } 2018 2019// validate that every requested tool is known 2020for (const auto & name :enabled_tools ) { 2021if (name == "all" )continue ; 2022if (std::find (known_names .begin (),known_names .end (),name )== known_names .end ()) { 2023throw std::runtime_error (string_format ( 2024"unknown tool \"%s\". available tools: %s" , 2025name .c_str (), 2026string_join (known_names ,", " ).c_str ())); 2027 } 2028 } 2029 2030tools .clear (); 2031for (auto & t :all_tools ) { 2032if (enabled_set .count (t -> name )> 0 || enabled_set .count ("all" )> 0 ) { 2033tools .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 2039if (!mcp_mgr .empty ()) { 2040 std::unordered_set < std::string > seen_names ; 2041for (auto & t :tools ) { 2042seen_names .insert (t -> name ); 2043 } 2044size_t n_added = 0 ; 2045for (const auto & def :mcp_mgr .list_tools ()) { 2046 std::string mcp_name = def .server_name + "_" + def .name ; 2047if (seen_names .count (mcp_name )) { 2048SRV_WRN ("MCP tool \"%s\" from server \"%s\" collides with an existing tool, skipping\n" , 2049mcp_name .c_str (),def .server_name .c_str ()); 2050continue ; 2051 } 2052seen_names .insert (mcp_name ); 2053tools .push_back (std::make_unique < server_mcp_tool > (def ,mcp_mgr )); 2054n_added ++ ; 2055 } 2056if (n_added > 0 ) { 2057SRV_INF ("Added %zu MCP tools\n" ,n_added ); 2058 } 2059 } 2060 2061handle_get = [this ](const server_http_req & )-> server_http_res_ptr { 2062auto res = std::make_unique < server_http_res > (); 2063try { 2064json result = json::array (); 2065for (const auto & t :tools ) { 2066result .push_back (t -> to_json ()); 2067 } 2068res -> data = safe_json_to_str (result ); 2069 }catch (const std::exception & e ) { 2070SRV_ERR ("got exception: %s\n" ,e .what ()); 2071res -> status = 500 ; 2072res -> data = safe_json_to_str (format_error_response (e .what (),ERROR_TYPE_SERVER )); 2073 } 2074return res ; 2075 }; 2076 2077handle_post = [this ](const server_http_req & req )-> server_http_res_ptr { 2078auto res = std::make_unique < server_tools_res > (); 2079try { 2080json body = json::parse (req .body ); 2081 std::string tool_name = body .at ("tool" ).get < std::string > (); 2082json params = body .value ("params" , json::object ()); 2083bool stream = body .value ("stream" , false); 2084 2085// accept x-tool-cwd header to override of the process 2086if (params .contains ("cwd" )) { 2087params .erase ("cwd" ); 2088 } 2089auto cwd = get_header (req .headers ,"x-tool-cwd" ); 2090if (!cwd .empty ()) { 2091params ["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 2096if (params .contains ("runtime" )) { 2097params .erase ("runtime" ); 2098 } 2099auto runtime_header = get_header (req .headers ,"x-tool-runtime" ); 2100if (!runtime_header .empty ()) { 2101params ["runtime" ]= runtime_header ; 2102 }else if (runtime ) { 2103params ["runtime" ]= runtime -> spec (); 2104 } 2105 2106// x-resp-type header is only used by read_file for now 2107if (params .contains ("resp_type" )) { 2108params .erase ("resp_type" ); 2109 } 2110auto resp_type = get_header (req .headers ,"x-resp-type" ); 2111if (!resp_type .empty ()) { 2112params ["resp_type" ]= resp_type ; 2113 } 2114 2115server_tool & tool = find_tool (tools ,tool_name ,stream ); 2116 2117if (stream ) { 2118int id = res_id .fetch_add (1 ); 2119queue_res .add_waiting_task_id (id ); 2120res -> qr = & queue_res ; 2121res -> id = id ; 2122 2123res -> worker = std::thread ([this ,id ,& req ,& tool ,params ]()mutable { 2124 server_tool::stream st {queue_res ,id , [& req ]() { 2125return !req .should_stop (); 2126 }}; 2127 2128auto done = std::make_unique < server_tool_stream_result > (); 2129try { 2130tool .invoke (params ,& st ); 2131 }catch (const std::exception & e ) { 2132done -> error_msg = e .what (); 2133 }catch (...) { 2134done -> error_msg = "An unknown error occurred" ; 2135 } 2136done -> id = st .id ; 2137done -> done = true; 2138st .qr .send (std::move (done )); 2139 }); 2140 2141res -> content_type = "text/event-stream" ; 2142res -> status = 200 ; 2143res -> next = [this ,id ](std::string & output )-> bool { 2144auto result = queue_res .recv (id ); 2145auto * r = dynamic_cast < server_tool_stream_result *> (result .get ()); 2146GGML_ASSERT (r != nullptr ); 2147output = "data: " + safe_json_to_str (r -> to_json ())+ "\n\n" ; 2148if (r -> done ) { 2149queue_res .remove_waiting_task_id (id ); 2150return false; 2151 } 2152return true; 2153 }; 2154 }else { 2155json result = tool .invoke (params ,nullptr ); 2156res -> status = 200 ; 2157res -> data = safe_json_to_str (result ); 2158 } 2159 }catch (const common_json_error & e ) { 2160res -> status = 400 ; 2161res -> data = safe_json_to_str (format_error_response (e .what (),ERROR_TYPE_INVALID_REQUEST )); 2162 }catch (const std::invalid_argument & e ) { 2163res -> status = 404 ; 2164res -> data = safe_json_to_str (format_error_response (e .what (),ERROR_TYPE_INVALID_REQUEST )); 2165 }catch (const std::exception & e ) { 2166SRV_ERR ("got exception: %s\n" ,e .what ()); 2167res -> status = 500 ; 2168res -> data = safe_json_to_str (format_error_response (e .what (),ERROR_TYPE_SERVER )); 2169 } 2170return res ; 2171 }; 2172}