2026-03-27 10:07:11 +01:00
#include "server-tools.h"
2026-07-26 20:54:25 +02:00
#include "subproc.h"
2026-03-27 10:07:11 +01:00
#include <filesystem>
#include <fstream>
#include <regex>
#include <thread>
#include <chrono>
2026-07-25 21:15:15 +02:00
#include <ctime>
2026-03-27 10:07:11 +01:00
#include <atomic>
#include <cstring>
2026-08-04 19:05:48 +02:00
#include <cstdlib>
2026-05-05 06:35:27 +03:00
#include <algorithm>
2026-06-20 21:15:06 -05:00
#include <unordered_set>
2026-08-04 19:05:48 +02:00
#include <tuple>
2026-07-11 12:42:55 +02:00
#include <functional>
2026-07-26 01:08:49 +02:00
#include <memory>
2026-03-27 10:07:11 +01:00
2026-08-04 22:24:55 +02:00
#if defined(_WIN32)
# ifndef NOMINMAX
# define NOMINMAX
# endif
# include <windows.h>
#endif
2026-03-27 10:07:11 +01:00
namespace fs = std :: filesystem ;
//
// internal helpers
//
2026-08-05 21:31:54 +02:00
// a child process writes in the OEM code page, so accented output would reach
// the JSON layer as invalid bytes. run() spawns without a console, so the
// console code page never applies
2026-08-04 22:24:55 +02:00
static std :: string console_output_to_utf8 ( const std :: string & text ) {
2026-08-05 21:31:54 +02:00
#if defined(_WIN32)
// a chunk can end mid sequence, so the incomplete tail is dropped first
if ( text . empty () || is_valid_utf8 ( text . substr ( 0 , validate_utf8 ( text )))) {
// never decode twice a child that already emits UTF-8
2026-08-04 22:24:55 +02:00
return text ;
}
const UINT cp = GetOEMCP ();
// fail rather than emit replacement characters when the code page is wrong
const int wide_len = MultiByteToWideChar ( cp , MB_ERR_INVALID_CHARS , text . data (), ( int ) text . size (), nullptr , 0 );
if ( wide_len <= 0 ) {
return text ;
}
std :: wstring wide ( wide_len , L '\0' );
MultiByteToWideChar ( cp , MB_ERR_INVALID_CHARS , text . data (), ( int ) text . size (), wide . data (), wide_len );
const int utf8_len = WideCharToMultiByte ( CP_UTF8 , 0 , wide . data (), wide_len , nullptr , 0 , nullptr , nullptr );
if ( utf8_len <= 0 ) {
return text ;
}
std :: string utf8 ( utf8_len , '\0' );
WideCharToMultiByte ( CP_UTF8 , 0 , wide . data (), wide_len , utf8 . data (), utf8_len , nullptr , nullptr );
return utf8 ;
#else
return text ;
#endif
2026-08-05 21:31:54 +02:00
}
2026-08-04 22:24:55 +02:00
2026-07-10 11:52:59 +02:00
json server_tool :: to_json () const {
2026-03-27 10:07:11 +01:00
return {
{ "display_name" , display_name },
{ "tool" , name },
2026-07-26 01:08:49 +02:00
{ "type" , type ()},
2026-03-27 10:07:11 +01:00
{ "permissions" , json {
{ "write" , permission_write }
}},
{ "definition" , get_definition ()},
};
}
2026-07-10 11:52:59 +02:00
static constexpr size_t SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT = 8 * 1024 * 1024 ; // 8 MB
2026-08-04 19:05:48 +02:00
// budget for one listing call, shared by the git and walker paths
static constexpr int SERVER_TOOL_LIST_ENTRIES_TIMEOUT = 15 ; // seconds
// entry kinds a directory listing may return
enum class list_kind {
files , // regular files only
dirs , // directories only
all , // both
};
2026-08-05 21:31:54 +02:00
// a narrow path uses the active code page on Windows, so every crossing between
// a std::string (always UTF-8 here) and fs::path is converted explicitly
static fs :: path path_from_utf8 ( const std :: string & s ) {
return fs :: u8path ( s );
}
// '/' separators on every platform: Windows accepts them, the web UI needs them
static std :: string path_to_utf8 ( const fs :: path & p ) {
const auto s = p . generic_u8string ();
return std :: string ( s . begin (), s . end ());
}
2026-08-04 19:05:48 +02:00
// home directory, read once at first use (getenv is not thread safe against setenv)
static const std :: string & home_dir () {
static const std :: string home = [] {
#ifdef _WIN32
2026-08-05 21:31:54 +02:00
// the narrow getenv would return the profile path in the active code page
const wchar_t * w = _wgetenv ( L "HOME" );
if ( w == nullptr ) w = _wgetenv ( L "USERPROFILE" );
return w ? path_to_utf8 ( fs :: path ( w )) : std :: string ();
#else
const char * h = getenv ( "HOME" );
2026-08-04 19:05:48 +02:00
return h ? std :: string ( h ) : std :: string ();
2026-08-05 21:31:54 +02:00
#endif
2026-08-04 19:05:48 +02:00
}();
return home ;
}
static std :: string expand_home ( const std :: string & path ) {
if ( path . empty () || path [ 0 ] != '~' ) return path ;
if ( path . size () > 1 && path [ 1 ] != '/' && path [ 1 ] != '\\' ) return path ;
const std :: string & home = home_dir ();
if ( home . empty ()) return path ;
return home + path . substr ( 1 );
}
// depth of a '/'-separated relative path: "a/b/c" is 3
static int entry_depth ( const std :: string & rel ) {
return 1 + ( int ) std :: count ( rel . begin (), rel . end (), '/' );
}
2026-07-10 11:52:59 +02:00
class tools_io {
public :
struct exec_result {
std :: string output ;
int exit_code = - 1 ;
bool timed_out = false ;
};
virtual ~ tools_io () = default ;
virtual bool is_directory ( const std :: string & path ) const = 0 ;
virtual bool is_regular_file ( const std :: string & path ) const = 0 ;
virtual bool file_size ( const std :: string & path , uintmax_t & out_size ) const = 0 ;
virtual bool read_file ( const std :: string & path , std :: string & out ) const = 0 ;
virtual bool write_file ( const std :: string & path , const std :: string & content ) const = 0 ;
2026-08-04 19:05:48 +02:00
// resolve `path` against the IO's working directory; absolute paths are returned unchanged
virtual std :: string resolve ( const std :: string & path ) const = 0 ;
struct list_entry {
std :: string rel ; // '/'-separated, relative to `base`
bool is_dir = false ;
};
2026-08-05 21:31:54 +02:00
struct list_result {
std :: vector < list_entry > entries ;
std :: string err ; // set when `base` is not a directory
bool truncated = false ; // set when the walk could not see everything
};
// entries relative to `base`, which must already be resolved (absolute)
2026-08-04 19:05:48 +02:00
// max_depth == 0 means unlimited, 1 means direct children of `base` only
2026-08-05 21:31:54 +02:00
virtual list_result list_entries ( const std :: string & base , int max_depth , list_kind kind ) const = 0 ;
2026-07-11 12:42:55 +02:00
// on_chunk, if set, is called with each chunk of output as it is read (before truncation cuts in);
// returning false terminates the process early (e.g. the client disconnected)
virtual exec_result run (
const std :: vector < std :: string > & args ,
size_t max_output ,
int timeout_secs ,
const std :: function < bool ( const std :: string & ) > & on_chunk = nullptr ) const = 0 ;
2026-07-10 11:52:59 +02:00
};
class tools_io_basic : public tools_io {
public :
2026-08-03 10:47:21 +02:00
// cwd, if non-empty, is used to resolve relative paths and as the working directory for run()
explicit tools_io_basic ( std :: string cwd = "" ) : cwd ( std :: move ( cwd )) {}
2026-08-04 19:05:48 +02:00
// expands a leading `~`, then resolves `path` against `cwd` (or the server
// working directory when `cwd` is unset); the result is always absolute
std :: string resolve ( const std :: string & path ) const override {
2026-08-05 21:31:54 +02:00
const std :: string p = expand_home ( path );
fs :: path full = path_from_utf8 ( p );
if ( ! full . is_absolute ()) {
if ( cwd . empty ()) {
std :: error_code ec ;
const fs :: path cur = fs :: current_path ( ec );
if ( ec ) return p ;
full = cur / full ;
} else {
full = path_from_utf8 ( cwd ) / full ;
}
2026-08-04 19:05:48 +02:00
}
2026-08-05 21:31:54 +02:00
// drop "." and ".." so they never reach git or the client
full = full . lexically_normal ();
// a trailing ".." normalizes to a path that ends with a separator
if ( ! full . has_filename () && full != full . root_path ()) {
full = full . parent_path ();
2026-08-04 19:05:48 +02:00
}
2026-08-05 21:31:54 +02:00
return path_to_utf8 ( full );
2026-08-04 19:05:48 +02:00
}
2026-07-10 11:52:59 +02:00
bool is_directory ( const std :: string & path ) const override {
std :: error_code ec ;
2026-08-05 21:31:54 +02:00
return fs :: is_directory ( path_from_utf8 ( resolve ( path )), ec ) && ! ec ;
2026-07-10 11:52:59 +02:00
}
bool is_regular_file ( const std :: string & path ) const override {
std :: error_code ec ;
2026-08-05 21:31:54 +02:00
return fs :: is_regular_file ( path_from_utf8 ( resolve ( path )), ec ) && ! ec ;
2026-07-10 11:52:59 +02:00
}
bool file_size ( const std :: string & path , uintmax_t & out_size ) const override {
std :: error_code ec ;
2026-08-05 21:31:54 +02:00
out_size = fs :: file_size ( path_from_utf8 ( resolve ( path )), ec );
2026-07-10 11:52:59 +02:00
return ! ec ;
}
bool read_file ( const std :: string & path , std :: string & out ) const override {
2026-08-05 21:31:54 +02:00
std :: ifstream f ( path_from_utf8 ( resolve ( path )), std :: ios :: binary );
2026-07-10 11:52:59 +02:00
if ( ! f ) return false ;
std :: ostringstream ss ;
ss << f . rdbuf ();
out = ss . str ();
return true ;
}
bool write_file ( const std :: string & path , const std :: string & content ) const override {
std :: error_code ec ;
2026-08-05 21:31:54 +02:00
fs :: path fpath = path_from_utf8 ( resolve ( path ));
2026-07-10 11:52:59 +02:00
if ( fpath . has_parent_path ()) {
fs :: create_directories ( fpath . parent_path (), ec );
if ( ec ) return false ;
}
2026-08-03 10:47:21 +02:00
std :: ofstream f ( fpath , std :: ios :: binary );
2026-07-10 11:52:59 +02:00
if ( ! f ) return false ;
f << content ;
return ( bool ) f ;
}
2026-08-05 21:31:54 +02:00
list_result list_entries ( const std :: string & base , int max_depth , list_kind kind ) const override {
list_result out ;
2026-08-04 19:05:48 +02:00
std :: error_code ec ;
if ( ! fs :: is_directory ( base , ec ) || ec ) {
2026-08-05 21:31:54 +02:00
out . err = "path does not exist or is not a directory" ;
return out ;
2026-07-10 11:52:59 +02:00
}
2026-08-04 19:05:48 +02:00
const auto deadline = std :: chrono :: steady_clock :: now () + std :: chrono :: seconds ( SERVER_TOOL_LIST_ENTRIES_TIMEOUT );
2026-07-10 11:52:59 +02:00
2026-08-04 19:05:48 +02:00
// git ls-files cannot list directories; use the walker when they are requested
if ( kind == list_kind :: files ) {
auto res = run (
{ "git" , "-C" , base , "ls-files" , "--cached" , "--others" , "--exclude-standard" },
SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT , SERVER_TOOL_LIST_ENTRIES_TIMEOUT );
if ( res . exit_code == 0 && ! res . timed_out ) {
std :: istringstream iss ( res . output );
std :: string line ;
while ( std :: getline ( iss , line )) {
if ( ! line . empty () && line . back () == '\r' ) line . pop_back ();
if ( line . empty ()) continue ;
std :: replace ( line . begin (), line . end (), '\\' , '/' );
if ( max_depth > 0 && entry_depth ( line ) > max_depth ) continue ;
2026-08-05 21:31:54 +02:00
if ( is_regular_file ( path_to_utf8 ( path_from_utf8 ( base ) / path_from_utf8 ( line )))) {
out . entries . push_back ({ line , false });
2026-08-04 19:05:48 +02:00
}
2026-07-10 11:52:59 +02:00
}
2026-08-05 21:31:54 +02:00
return out ;
2026-07-10 11:52:59 +02:00
}
}
2026-08-05 21:31:54 +02:00
out . entries = list_entries_fallback ( base , max_depth , kind , deadline , out . truncated );
return out ;
2026-07-10 11:52:59 +02:00
}
2026-07-11 12:42:55 +02:00
exec_result run (
const std :: vector < std :: string > & args ,
size_t max_output ,
int timeout_secs ,
const std :: function < bool ( const std :: string & ) > & on_chunk = nullptr ) const override {
2026-07-10 11:52:59 +02:00
exec_result res ;
2026-07-26 20:54:25 +02:00
common_subproc proc ;
2026-07-10 11:52:59 +02:00
int options = subprocess_option_no_window
| subprocess_option_combined_stdout_stderr
| subprocess_option_inherit_environment
| subprocess_option_search_user_path ;
2026-08-03 10:47:21 +02:00
if ( ! proc . create ( args , options , {}, cwd . empty () ? nullptr : cwd . c_str ())) {
2026-07-10 11:52:59 +02:00
res . output = "failed to spawn process" ;
return res ;
}
std :: atomic < bool > done { false };
std :: atomic < bool > timed_out { false };
std :: thread timeout_thread ([ & ]() {
auto deadline = std :: chrono :: steady_clock :: now () + std :: chrono :: seconds ( timeout_secs );
while ( ! done . load ()) {
if ( std :: chrono :: steady_clock :: now () >= deadline ) {
timed_out . store ( true );
2026-07-26 20:54:25 +02:00
proc . terminate ();
2026-07-10 11:52:59 +02:00
return ;
}
std :: this_thread :: sleep_for ( std :: chrono :: milliseconds ( 100 ));
}
});
2026-07-26 20:54:25 +02:00
FILE * f = proc . stdout_file ();
2026-07-10 11:52:59 +02:00
std :: string output ;
bool truncated = false ;
if ( f ) {
char buf [ 4096 ];
while ( fgets ( buf , sizeof ( buf ), f ) != nullptr ) {
if ( ! truncated ) {
size_t len = strlen ( buf );
if ( output . size () + len <= max_output ) {
output . append ( buf , len );
2026-08-04 22:24:55 +02:00
if ( on_chunk && ! on_chunk ( console_output_to_utf8 ( std :: string ( buf , len )))) {
2026-07-26 20:54:25 +02:00
proc . terminate ();
2026-07-11 12:42:55 +02:00
break ;
}
2026-07-10 11:52:59 +02:00
} else {
2026-07-11 12:42:55 +02:00
size_t remaining = max_output - output . size ();
output . append ( buf , remaining );
2026-08-04 22:24:55 +02:00
if ( on_chunk && remaining > 0 ) on_chunk ( console_output_to_utf8 ( std :: string ( buf , remaining )));
2026-07-10 11:52:59 +02:00
truncated = true ;
}
}
}
}
done . store ( true );
if ( timeout_thread . joinable ()) {
timeout_thread . join ();
}
2026-07-26 20:54:25 +02:00
res . exit_code = proc . join ();
2026-07-10 11:52:59 +02:00
2026-08-04 22:24:55 +02:00
res . output = console_output_to_utf8 ( output );
2026-07-10 11:52:59 +02:00
res . timed_out = timed_out . load ();
if ( truncated ) {
res . output += " \n [output truncated]" ;
}
return res ;
}
private :
2026-08-03 10:47:21 +02:00
std :: string cwd ;
2026-08-05 21:31:54 +02:00
// a link can point back to an ancestor and loop forever, so it is never walked
static bool is_link ( const fs :: directory_entry & entry ) {
std :: error_code ec ;
if ( entry . is_symlink ( ec ) || ec ) {
return true ;
}
#if defined(_WIN32)
// a junction looks like a plain directory to std::filesystem, so read the reparse tag
WIN32_FIND_DATAW data ;
const HANDLE h = FindFirstFileW ( entry . path (). c_str (), & data );
if ( h == INVALID_HANDLE_VALUE ) {
return false ;
}
FindClose ( h );
if (( data . dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT ) == 0 ) {
return false ;
}
// other reparse points (cloud placeholder, dedup stub) are real directories
return data . dwReserved0 == IO_REPARSE_TAG_SYMLINK || data . dwReserved0 == IO_REPARSE_TAG_MOUNT_POINT ;
#else
return false ;
#endif
}
// NTFS is case insensitive, so Build and build are the same directory
static std :: string get_effective_name ( const std :: string & fname ) {
#if defined(_WIN32)
std :: string lowered = fname ;
std :: transform ( lowered . begin (), lowered . end (), lowered . begin (),
[]( unsigned char c ) { return ( char ) std :: tolower ( c ); });
return lowered ;
#else
return fname ;
#endif
}
2026-07-10 11:52:59 +02:00
static const std :: unordered_set < std :: string > & junk_dir_names () {
static const std :: unordered_set < std :: string > names = {
".git" , ".svn" , ".hg" , "node_modules" , "__pycache__" ,
".venv" , "venv" , "dist" , "build" , "target" , ".cache" , ".idea" , ".vscode" ,
};
return names ;
}
2026-08-04 19:05:48 +02:00
std :: vector < list_entry > list_entries_fallback ( const std :: string & base , int max_depth , list_kind kind ,
std :: chrono :: steady_clock :: time_point deadline , bool & truncated ) const {
std :: vector < list_entry > result ;
2026-07-10 11:52:59 +02:00
2026-08-04 19:05:48 +02:00
std :: vector < std :: tuple < fs :: path , fs :: path , int >> stack ;
2026-08-05 21:31:54 +02:00
stack . emplace_back ( path_from_utf8 ( base ), fs :: path (), 0 );
2026-07-10 11:52:59 +02:00
while ( ! stack . empty ()) {
2026-08-05 21:31:54 +02:00
if ( std :: chrono :: steady_clock :: now () >= deadline ) {
truncated = true ;
return result ;
}
auto [ dir , rel_dir , depth ] = std :: move ( stack . back ());
2026-07-10 11:52:59 +02:00
stack . pop_back ();
2026-08-05 21:31:54 +02:00
std :: error_code ec ;
// step the iterator by hand: the throwing increment escapes on a directory that goes away
2026-08-04 19:05:48 +02:00
fs :: directory_iterator it ( dir , fs :: directory_options :: skip_permission_denied , ec );
2026-08-05 21:31:54 +02:00
// permission errors are skipped above, so this is a subtree the caller never sees
if ( ec ) {
truncated = true ;
continue ;
}
2026-08-04 19:05:48 +02:00
for ( const fs :: directory_iterator end ; it != end ; it . increment ( ec )) {
2026-08-05 21:31:54 +02:00
if ( ec ) {
truncated = true ;
break ;
}
2026-08-04 19:05:48 +02:00
if ( std :: chrono :: steady_clock :: now () >= deadline ) {
truncated = true ;
return result ;
}
const fs :: directory_entry & entry = * it ;
2026-08-05 21:31:54 +02:00
const fs :: path fname = entry . path (). filename ();
2026-07-10 11:52:59 +02:00
std :: error_code tec ;
2026-08-05 21:31:54 +02:00
const bool is_dir = entry . is_directory ( tec );
if ( tec ) continue ;
if ( is_dir ) {
2026-08-04 19:05:48 +02:00
if ( kind == list_kind :: dirs || kind == list_kind :: all ) {
2026-08-05 21:31:54 +02:00
result . push_back ({ path_to_utf8 ( rel_dir / fname ), true });
2026-08-04 19:05:48 +02:00
}
2026-08-05 21:31:54 +02:00
// junk directories stay selectable but are never walked: they can be enormous
if ( junk_dir_names (). count ( get_effective_name ( path_to_utf8 ( fname ))) > 0 ) continue ;
if ( ! is_link ( entry ) && ( max_depth == 0 || depth + 1 < max_depth )) {
2026-08-04 19:05:48 +02:00
stack . emplace_back ( entry . path (), rel_dir / fname , depth + 1 );
}
2026-07-10 11:52:59 +02:00
} else if ( entry . is_regular_file ( tec )) {
2026-08-04 19:05:48 +02:00
if ( kind == list_kind :: files || kind == list_kind :: all ) {
2026-08-05 21:31:54 +02:00
result . push_back ({ path_to_utf8 ( rel_dir / fname ), false });
2026-08-04 19:05:48 +02:00
}
2026-07-10 11:52:59 +02:00
}
}
}
return result ;
}
};
static std :: unique_ptr < tools_io > make_tools_io ( const json & params ) {
2026-08-03 10:47:21 +02:00
std :: string cwd = json_value ( params , "cwd" , std :: string ());
return std :: make_unique < tools_io_basic > ( cwd );
2026-07-10 11:52:59 +02:00
}
// no '/' in pattern -> match basename at any depth; else match full relative path
static bool path_glob_match ( const std :: string & pattern , const std :: string & rel_path ) {
if ( pattern . find ( '/' ) == std :: string :: npos ) {
2026-08-05 21:31:54 +02:00
return glob_match ( pattern , path_to_utf8 ( path_from_utf8 ( rel_path ). filename ()));
2026-07-10 11:52:59 +02:00
}
if ( pattern == "**" || pattern . rfind ( "**/" , 0 ) == 0 || pattern . rfind ( '/' , 0 ) == 0 ) {
return glob_match ( pattern , rel_path );
}
return glob_match ( "**/" + pattern , rel_path );
}
2026-03-27 10:07:11 +01:00
//
// read_file: read a file with optional line range and line-number prefix
//
static constexpr size_t SERVER_TOOL_READ_FILE_MAX_SIZE = 16 * 1024 ; // 16 KB
struct server_tool_read_file : server_tool {
server_tool_read_file () {
name = "read_file" ;
display_name = "Read file" ;
permission_write = false ;
}
2026-07-10 11:52:59 +02:00
json get_definition () const override {
2026-03-27 10:07:11 +01:00
return {
{ "type" , "function" },
{ "function" , {
{ "name" , name },
{ "description" , "Read the contents of a file. Optionally specify a 1-based line range. "
2026-07-15 13:46:46 +02:00
"If append_loc is true, each line is prefixed with its line number (e.g. \" 1 \u2192 ... \" )." },
2026-03-27 10:07:11 +01:00
{ "parameters" , {
{ "type" , "object" },
{ "properties" , {
{ "path" , {{ "type" , "string" }, { "description" , "Path to the file" }}},
{ "start_line" , {{ "type" , "integer" }, { "description" , "First line to read, 1-based (default: 1)" }}},
{ "end_line" , {{ "type" , "integer" }, { "description" , "Last line to read, 1-based inclusive (default: end of file)" }}},
{ "append_loc" , {{ "type" , "boolean" }, { "description" , "Prefix each line with its line number" }}},
}},
{ "required" , json :: array ({ "path" })},
}},
}},
};
}
2026-07-11 12:42:55 +02:00
json invoke ( json params , server_tool :: stream * ) const override {
2026-03-27 10:07:11 +01:00
std :: string path = params . at ( "path" ). get < std :: string > ();
int start_line = json_value ( params , "start_line" , 1 );
int end_line = json_value ( params , "end_line" , - 1 ); // -1 = no limit
bool append_loc = json_value ( params , "append_loc" , false );
2026-07-10 11:52:59 +02:00
auto io = make_tools_io ( params );
uintmax_t file_size = 0 ;
if ( ! io -> file_size ( path , file_size )) {
return {{ "error" , "cannot stat file: " + path }};
2026-03-27 10:07:11 +01:00
}
if ( file_size > SERVER_TOOL_READ_FILE_MAX_SIZE && end_line == - 1 ) {
return {{ "error" , string_format (
"file too large (%zu bytes, max %zu). Use start_line/end_line to read a portion." ,
( size_t ) file_size , SERVER_TOOL_READ_FILE_MAX_SIZE )}};
}
2026-07-10 11:52:59 +02:00
std :: string content ;
if ( ! io -> read_file ( path , content )) {
2026-03-27 10:07:11 +01:00
return {{ "error" , "failed to open file: " + path }};
}
2026-07-10 11:52:59 +02:00
std :: istringstream f ( content );
2026-03-27 10:07:11 +01:00
std :: string result ;
std :: string line ;
int lineno = 0 ;
while ( std :: getline ( f , line )) {
lineno ++ ;
if ( lineno < start_line ) continue ;
if ( end_line != - 1 && lineno > end_line ) break ;
std :: string out_line ;
if ( append_loc ) {
2026-07-15 13:46:46 +02:00
out_line = std :: to_string ( lineno ) + " \u2192 " + line + " \n " ;
2026-03-27 10:07:11 +01:00
} else {
out_line = line + " \n " ;
}
if ( result . size () + out_line . size () > SERVER_TOOL_READ_FILE_MAX_SIZE ) {
result += "[output truncated]" ;
break ;
}
result += out_line ;
}
return {{ "plain_text_response" , result }};
}
};
//
// file_glob_search: find files matching a glob pattern under a base directory
//
2026-08-05 21:31:54 +02:00
static constexpr int SERVER_TOOL_FILE_SEARCH_MAX_RESULTS = 100 ;
2026-08-04 19:05:48 +02:00
static constexpr const char * SERVER_TOOL_FILE_SEARCH_TYPE_FILE = "file" ;
static constexpr const char * SERVER_TOOL_FILE_SEARCH_TYPE_DIR = "dir" ;
static constexpr const char * SERVER_TOOL_FILE_SEARCH_TYPE_ALL = "all" ;
2026-03-27 10:07:11 +01:00
struct server_tool_file_glob_search : server_tool {
server_tool_file_glob_search () {
name = "file_glob_search" ;
display_name = "File search" ;
permission_write = false ;
}
2026-07-10 11:52:59 +02:00
json get_definition () const override {
2026-03-27 10:07:11 +01:00
return {
{ "type" , "function" },
{ "function" , {
{ "name" , name },
2026-07-10 11:52:59 +02:00
{ "description" ,
"Recursively search for files matching a glob pattern under a directory. "
"Automatically skips files ignored by .gitignore (when the directory is inside a git repo) "
"and common junk directories (.git, node_modules, build, dist, etc.) otherwise. "
"A pattern with no '/' (e.g. \" *.cpp \" ) matches the file's basename at any depth. "
"A pattern containing '/' matches the full relative path; unless already anchored with "
2026-08-04 19:05:48 +02:00
" \" **/ \" or a leading '/', it is automatically prefixed with \" **/ \" . "
"Use type= \" dir \" or \" all \" to also list directories; directory entries are suffixed with '/' in the output. "
"Note: directory listings do not apply .gitignore filtering." },
2026-03-27 10:07:11 +01:00
{ "parameters" , {
{ "type" , "object" },
{ "properties" , {
2026-08-04 19:05:48 +02:00
{ "path" , {{ "type" , "string" }, { "description" , "Base directory to search in" }}},
{ "include" , {{ "type" , "string" }, { "description" , "Glob pattern for files to include (e.g. \" *.cpp \" or \" src/**/*.cpp \" ). Default: **" }}},
{ "exclude" , {{ "type" , "string" }, { "description" , "Glob pattern for files to exclude" }}},
{ "type" , {{ "type" , "string" }, { "description" , "Entry type to return: \" file \" (default), \" dir \" or \" all \" " }}},
{ "max_depth" , {{ "type" , "integer" }, { "description" , "Maximum depth to descend into subdirectories (default: 0 = unlimited; 1 = direct children only)" }}},
2026-08-05 21:31:54 +02:00
{ "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 )}}},
2026-03-27 10:07:11 +01:00
}},
{ "required" , json :: array ({ "path" })},
}},
}},
};
}
2026-07-11 12:42:55 +02:00
json invoke ( json params , server_tool :: stream * ) const override {
2026-07-10 11:52:59 +02:00
auto io = make_tools_io ( params );
2026-08-04 19:05:48 +02:00
2026-08-05 21:31:54 +02:00
const std :: string path = params . at ( "path" ). get < std :: string > ();
std :: string base = io -> resolve ( path );
2026-08-04 19:05:48 +02:00
std :: string include = json_value ( params , "include" , std :: string ( "**" ));
std :: string exclude = json_value ( params , "exclude" , std :: string ( "" ));
std :: string type = json_value ( params , "type" , std :: string ( "file" ));
int max_depth = std :: max ( 0 , json_value ( params , "max_depth" , 0 ));
2026-08-05 21:31:54 +02:00
const int limit_req = json_value ( params , "limit" , SERVER_TOOL_FILE_SEARCH_MAX_RESULTS );
if ( limit_req < 1 ) {
return {{ "error" , "invalid limit: " + std :: to_string ( limit_req ) + " (expected 1 or more)" }};
}
const int limit = std :: min ( limit_req , SERVER_TOOL_FILE_SEARCH_MAX_RESULTS );
2026-08-04 19:05:48 +02:00
list_kind kind ;
if ( type == SERVER_TOOL_FILE_SEARCH_TYPE_FILE ) {
kind = list_kind :: files ;
} else if ( type == SERVER_TOOL_FILE_SEARCH_TYPE_DIR ) {
kind = list_kind :: dirs ;
} else if ( type == SERVER_TOOL_FILE_SEARCH_TYPE_ALL ) {
kind = list_kind :: all ;
} else {
return {{ "error" , "invalid type: " + type + " (expected \" file \" , \" dir \" or \" all \" )" }};
}
2026-08-05 21:31:54 +02:00
const auto listing = io -> list_entries ( base , max_depth , kind );
if ( ! listing . err . empty ()) {
return {{ "error" , listing . err + ": " + path }};
2026-03-27 10:07:11 +01:00
}
2026-08-04 19:05:48 +02:00
std :: vector < tools_io :: list_entry > matches ;
2026-08-05 21:31:54 +02:00
for ( const auto & entry : listing . entries ) {
2026-08-04 19:05:48 +02:00
if ( ! path_glob_match ( include , entry . rel )) continue ;
if ( ! exclude . empty () && path_glob_match ( exclude , entry . rel )) continue ;
matches . push_back ( entry );
2026-07-10 11:52:59 +02:00
}
size_t total = matches . size ();
2026-08-04 19:05:48 +02:00
size_t shown = std :: min ( total , ( size_t ) limit );
2026-07-10 11:52:59 +02:00
std :: ostringstream output_text ;
2026-08-04 19:05:48 +02:00
json entries_json = json :: array ();
2026-07-10 11:52:59 +02:00
for ( size_t i = 0 ; i < shown ; i ++ ) {
2026-08-04 19:05:48 +02:00
output_text << matches [ i ]. rel << ( matches [ i ]. is_dir ? "/" : "" ) << " \n " ;
entries_json . push_back ({
{ "path" , matches [ i ]. rel },
{ "type" , matches [ i ]. is_dir ? "dir" : "file" },
});
2026-07-10 11:52:59 +02:00
}
output_text << " \n --- \n Total matches: " << total << " \n " ;
if ( total > shown ) {
output_text << string_format (
"[%zu results limit reached (%zu total matches). Refine the glob pattern to narrow the search.] \n " ,
shown , total );
}
2026-08-05 21:31:54 +02:00
if ( listing . truncated ) {
output_text << "[results truncated: time budget or unreadable directory] \n " ;
2026-08-04 19:05:48 +02:00
}
2026-03-27 10:07:11 +01:00
2026-08-04 19:05:48 +02:00
// `base` is always absolute (resolve falls back to the server cwd), so
// API clients (e.g. the web UI picker) can join the relative entries
// into absolute paths. `plain_text_response` is what the model sees;
// `entries` is the same data as structured JSON for the UI picker,
// which reads `entries`/`base` instead of re-parsing the text.
return {{ "plain_text_response" , output_text . str ()}, { "entries" , entries_json }, { "base" , base }};
2026-03-27 10:07:11 +01:00
}
};
//
// grep_search: search for a regex pattern in files
//
static constexpr size_t SERVER_TOOL_GREP_SEARCH_MAX_RESULTS = 100 ;
struct server_tool_grep_search : server_tool {
server_tool_grep_search () {
name = "grep_search" ;
display_name = "Grep search" ;
permission_write = false ;
}
2026-07-10 11:52:59 +02:00
json get_definition () const override {
2026-03-27 10:07:11 +01:00
return {
{ "type" , "function" },
{ "function" , {
{ "name" , name },
2026-07-10 11:52:59 +02:00
{ "description" ,
"Search for a pattern in files under a path. Returns matching lines with file paths "
"(and, unless searching a single file, paths relative to the given directory). "
"Automatically skips files ignored by .gitignore (when the directory is inside a git repo) "
"and common junk directories (.git, node_modules, build, dist, etc.) otherwise. "
"include/exclude: a pattern with no '/' matches the basename at any depth; a pattern "
"containing '/' matches the full relative path (auto-anchored with \" **/ \" unless already anchored)." },
2026-03-27 10:07:11 +01:00
{ "parameters" , {
{ "type" , "object" },
{ "properties" , {
{ "path" , {{ "type" , "string" }, { "description" , "File or directory to search in" }}},
2026-07-10 11:52:59 +02:00
{ "pattern" , {{ "type" , "string" }, { "description" , "Pattern to search for (regular expression unless literal is true)" }}},
2026-03-27 10:07:11 +01:00
{ "include" , {{ "type" , "string" }, { "description" , "Glob pattern to filter files (default: **)" }}},
{ "exclude" , {{ "type" , "string" }, { "description" , "Glob pattern to exclude files" }}},
{ "return_line_numbers" , {{ "type" , "boolean" }, { "description" , "If true, include line numbers in results" }}},
2026-07-10 11:52:59 +02:00
{ "literal" , {{ "type" , "boolean" }, { "description" , "Treat pattern as a literal string instead of a regular expression (default: false)" }}},
{ "ignore_case" , {{ "type" , "boolean" }, { "description" , "Case-insensitive search (default: false)" }}},
{ "context_lines" , {{ "type" , "integer" }, { "description" , "Number of lines of context to show before and after each match (default: 0)" }}},
2026-03-27 10:07:11 +01:00
}},
{ "required" , json :: array ({ "path" , "pattern" })},
}},
}},
};
}
2026-07-11 12:42:55 +02:00
json invoke ( json params , server_tool :: stream * ) const override {
2026-07-10 11:52:59 +02:00
std :: string path = params . at ( "path" ). get < std :: string > ();
std :: string pat_str = params . at ( "pattern" ). get < std :: string > ();
std :: string include = json_value ( params , "include" , std :: string ( "**" ));
std :: string exclude = json_value ( params , "exclude" , std :: string ( "" ));
bool show_lineno = json_value ( params , "return_line_numbers" , false );
bool literal = json_value ( params , "literal" , false );
bool ignore_case = json_value ( params , "ignore_case" , false );
int ctx_lines = std :: max ( 0 , json_value ( params , "context_lines" , 0 ));
std :: string pattern_src = pat_str ;
if ( literal ) {
static const std :: string specials = " \\ ^$.|?*+()[]{}" ;
std :: string escaped ;
escaped . reserve ( pat_str . size () * 2 );
for ( char c : pat_str ) {
if ( specials . find ( c ) != std :: string :: npos ) escaped += '\\' ;
escaped += c ;
}
pattern_src = escaped ;
}
2026-03-27 10:07:11 +01:00
std :: regex pattern ;
try {
2026-07-10 11:52:59 +02:00
auto flags = std :: regex :: ECMAScript ;
if ( ignore_case ) flags |= std :: regex :: icase ;
pattern = std :: regex ( pattern_src , flags );
2026-03-27 10:07:11 +01:00
} catch ( const std :: regex_error & e ) {
return {{ "error" , std :: string ( "invalid regex: " ) + e . what ()}};
}
2026-07-10 11:52:59 +02:00
auto io = make_tools_io ( params );
2026-03-27 10:07:11 +01:00
2026-07-10 11:52:59 +02:00
// collect (absolute_path, display_path) pairs to search
std :: vector < std :: pair < std :: string , std :: string >> files ;
2026-08-04 19:05:48 +02:00
const std :: string abs_path = io -> resolve ( path );
if ( io -> is_regular_file ( abs_path )) {
files . emplace_back ( abs_path , path );
} else if ( io -> is_directory ( abs_path )) {
2026-08-05 21:31:54 +02:00
const auto listing = io -> list_entries ( abs_path , 0 , list_kind :: files );
if ( ! listing . err . empty ()) {
return {{ "error" , listing . err + ": " + path }};
2026-03-27 10:07:11 +01:00
}
2026-08-05 21:31:54 +02:00
for ( const auto & entry : listing . entries ) {
2026-08-04 19:05:48 +02:00
if ( ! path_glob_match ( include , entry . rel )) continue ;
if ( ! exclude . empty () && path_glob_match ( exclude , entry . rel )) continue ;
2026-08-05 21:31:54 +02:00
files . emplace_back ( path_to_utf8 ( path_from_utf8 ( abs_path ) / path_from_utf8 ( entry . rel )), entry . rel );
2026-03-27 10:07:11 +01:00
}
} else {
return {{ "error" , "path does not exist: " + path }};
}
2026-07-10 11:52:59 +02:00
std :: ostringstream output_text ;
size_t total = 0 ;
bool limit_reached = false ;
bool show_num = show_lineno || ctx_lines > 0 ;
for ( const auto & file_entry : files ) {
if ( limit_reached ) break ;
const std :: string & fpath = file_entry . first ;
const std :: string & display_path = file_entry . second ;
std :: string content ;
if ( ! io -> read_file ( fpath , content )) continue ;
std :: vector < std :: string > lines ;
{
std :: istringstream f ( content );
std :: string line ;
while ( std :: getline ( f , line )) lines . push_back ( line );
}
for ( size_t i = 0 ; i < lines . size (); i ++ ) {
if ( total >= SERVER_TOOL_GREP_SEARCH_MAX_RESULTS ) {
limit_reached = true ;
break ;
}
if ( ! std :: regex_search ( lines [ i ], pattern )) continue ;
long ctx_start = ctx_lines > 0 ? std :: max < long > ( 0 , ( long ) i - ctx_lines ) : ( long ) i ;
long ctx_end = ctx_lines > 0 ? std :: min < long > (( long ) lines . size () - 1 , ( long ) i + ctx_lines ) : ( long ) i ;
for ( long j = ctx_start ; j <= ctx_end ; j ++ ) {
bool is_match = ( j == ( long ) i );
output_text << display_path << ( is_match ? ':' : '-' );
if ( show_num ) {
output_text << ( j + 1 ) << ( is_match ? ':' : '-' );
}
output_text << lines [ j ] << " \n " ;
}
if ( ctx_lines > 0 ) {
output_text << "-- \n " ;
}
total ++ ;
}
}
output_text << " \n --- \n Total matches: " << total << " \n " ;
if ( limit_reached ) {
output_text << string_format (
"[%zu matches limit reached. Narrow the path/pattern/include to see more.] \n " ,
SERVER_TOOL_GREP_SEARCH_MAX_RESULTS );
}
2026-03-27 10:07:11 +01:00
return {{ "plain_text_response" , output_text . str ()}};
}
};
//
// exec_shell_command: run an arbitrary shell command
//
static constexpr size_t SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_OUTPUT_SIZE = 16 * 1024 ; // 16 KB
static constexpr int SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_TIMEOUT = 60 ; // seconds
struct server_tool_exec_shell_command : server_tool {
server_tool_exec_shell_command () {
name = "exec_shell_command" ;
display_name = "Execute shell command" ;
permission_write = true ;
2026-07-11 12:42:55 +02:00
support_stream = true ;
2026-03-27 10:07:11 +01:00
}
2026-07-10 11:52:59 +02:00
json get_definition () const override {
2026-03-27 10:07:11 +01:00
return {
{ "type" , "function" },
{ "function" , {
{ "name" , name },
{ "description" , "Execute a shell command and return its output (stdout and stderr combined)." },
{ "parameters" , {
{ "type" , "object" },
{ "properties" , {
{ "command" , {{ "type" , "string" }, { "description" , "Shell command to execute" }}},
{ "timeout" , {{ "type" , "integer" }, { "description" , string_format ( "Timeout in seconds (default 10, max %d)" , SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_TIMEOUT )}}},
{ "max_output_size" , {{ "type" , "integer" }, { "description" , string_format ( "Maximum output size in bytes (default %zu)" , SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_OUTPUT_SIZE )}}},
}},
{ "required" , json :: array ({ "command" })},
}},
}},
};
}
2026-07-11 12:42:55 +02:00
json invoke ( json params , server_tool :: stream * st ) const override {
2026-03-27 10:07:11 +01:00
std :: string command = params . at ( "command" ). get < std :: string > ();
int timeout = json_value ( params , "timeout" , 10 );
size_t max_output = ( size_t ) json_value ( params , "max_output_size" , ( int ) SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_OUTPUT_SIZE );
timeout = std :: min ( timeout , SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_TIMEOUT );
max_output = std :: min ( max_output , SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_OUTPUT_SIZE );
#ifdef _WIN32
std :: vector < std :: string > args = { "cmd" , "/c" , command };
#else
std :: vector < std :: string > args = { "sh" , "-c" , command };
#endif
2026-07-11 12:42:55 +02:00
auto io = make_tools_io ( params );
if ( st ) {
auto res = io -> run ( args , max_output , timeout , [ st ]( const std :: string & chunk ) {
st -> push ( chunk );
return ! st -> alive || st -> alive ();
});
if ( st -> alive && ! st -> alive ()) {
return json ();
}
std :: string tail = string_format ( " \n [exit code: %d]" , res . exit_code );
if ( res . timed_out ) {
tail += " [exit due to timed out]" ;
}
st -> push ( tail );
return json ();
}
2026-07-10 11:52:59 +02:00
auto res = io -> run ( args , max_output , timeout );
2026-03-27 10:07:11 +01:00
std :: string text_output = res . output ;
text_output += string_format ( " \n [exit code: %d]" , res . exit_code );
if ( res . timed_out ) {
text_output += " [exit due to timed out]" ;
}
return {{ "plain_text_response" , text_output }};
}
};
//
// write_file: create or overwrite a file
//
struct server_tool_write_file : server_tool {
server_tool_write_file () {
name = "write_file" ;
display_name = "Write file" ;
permission_write = true ;
}
2026-07-10 11:52:59 +02:00
json get_definition () const override {
2026-03-27 10:07:11 +01:00
return {
{ "type" , "function" },
{ "function" , {
{ "name" , name },
{ "description" , "Write content to a file, creating it (including parent directories) if it does not exist. May use with edit_file for more complex edits." },
{ "parameters" , {
{ "type" , "object" },
{ "properties" , {
{ "path" , {{ "type" , "string" }, { "description" , "Path of the file to write" }}},
{ "content" , {{ "type" , "string" }, { "description" , "Content to write" }}},
}},
{ "required" , json :: array ({ "path" , "content" })},
}},
}},
};
}
2026-07-11 12:42:55 +02:00
json invoke ( json params , server_tool :: stream * ) const override {
2026-03-27 10:07:11 +01:00
std :: string path = params . at ( "path" ). get < std :: string > ();
std :: string content = params . at ( "content" ). get < std :: string > ();
2026-07-10 11:52:59 +02:00
auto io = make_tools_io ( params );
if ( ! io -> write_file ( path , content )) {
2026-03-27 10:07:11 +01:00
return {{ "error" , "failed to write file: " + path }};
}
return {{ "result" , "file written successfully" }, { "path" , path }, { "bytes" , content . size ()}};
}
};
//
2026-07-10 11:52:59 +02:00
// edit_file: exact text replacement, one or more edits per call
2026-03-27 10:07:11 +01:00
//
struct server_tool_edit_file : server_tool {
server_tool_edit_file () {
name = "edit_file" ;
display_name = "Edit file" ;
permission_write = true ;
}
2026-07-10 11:52:59 +02:00
json get_definition () const override {
2026-03-27 10:07:11 +01:00
return {
{ "type" , "function" },
{ "function" , {
{ "name" , name },
{ "description" ,
2026-07-10 11:52:59 +02:00
"Edit a file using exact text replacement. Each edits[].old_text must be unique in the file "
"and is matched against the original content, not incrementally. Merge nearby changes into "
"one edit instead of overlapping edits. Use write_file to replace the whole file." },
2026-03-27 10:07:11 +01:00
{ "parameters" , {
{ "type" , "object" },
{ "properties" , {
2026-07-10 11:52:59 +02:00
{ "path" , {{ "type" , "string" }, { "description" , "Path to the file to edit" }}},
{ "edits" , {
2026-03-27 10:07:11 +01:00
{ "type" , "array" },
2026-07-10 11:52:59 +02:00
{ "description" , "One or more exact text replacements to apply" },
2026-03-27 10:07:11 +01:00
{ "items" , {
{ "type" , "object" },
{ "properties" , {
2026-07-10 11:52:59 +02:00
{ "old_text" , {{ "type" , "string" }, { "description" , "Exact text to find; must be unique in the file and must not overlap with other edits" }}},
{ "new_text" , {{ "type" , "string" }, { "description" , "Text to replace old_text with" }}},
2026-03-27 10:07:11 +01:00
}},
2026-07-10 11:52:59 +02:00
{ "required" , json :: array ({ "old_text" , "new_text" })},
2026-03-27 10:07:11 +01:00
}},
}},
}},
2026-07-10 11:52:59 +02:00
{ "required" , json :: array ({ "path" , "edits" })},
2026-03-27 10:07:11 +01:00
}},
}},
};
}
2026-07-11 12:42:55 +02:00
json invoke ( json params , server_tool :: stream * ) const override {
2026-03-27 10:07:11 +01:00
std :: string path = params . at ( "path" ). get < std :: string > ();
2026-07-10 11:52:59 +02:00
const json & edits_json = params . at ( "edits" );
2026-03-27 10:07:11 +01:00
2026-07-10 11:52:59 +02:00
if ( ! edits_json . is_array () || edits_json . empty ()) {
return {{ "error" , " \" edits \" must be a non-empty array" }};
2026-03-27 10:07:11 +01:00
}
2026-07-10 11:52:59 +02:00
struct edit_req {
std :: string old_text ;
std :: string new_text ;
};
std :: vector < edit_req > edits ;
edits . reserve ( edits_json . size ());
for ( const auto & e : edits_json ) {
edit_req er ;
er . old_text = e . at ( "old_text" ). get < std :: string > ();
er . new_text = e . at ( "new_text" ). get < std :: string > ();
if ( er . old_text . empty ()) {
return {{ "error" , string_format ( "edits[%zu].old_text must not be empty" , edits . size ())}};
}
edits . push_back ( std :: move ( er ));
}
auto io = make_tools_io ( params );
std :: string original_content ;
if ( ! io -> read_file ( path , original_content )) {
2026-03-27 10:07:11 +01:00
return {{ "error" , "failed to open file: " + path }};
}
2026-07-10 11:52:59 +02:00
// does any old_text need fuzzy matching (no exact match found)?
bool any_fuzzy = false ;
for ( size_t i = 0 ; i < edits . size (); i ++ ) {
if ( original_content . find ( edits [ i ]. old_text ) != std :: string :: npos ) continue ;
std :: string fuzzy_content = normalize_for_fuzzy_match ( original_content );
std :: string fuzzy_old = normalize_for_fuzzy_match ( edits [ i ]. old_text );
if ( fuzzy_content . find ( fuzzy_old ) == std :: string :: npos ) {
return {{ "error" , string_format (
"could not find edits[%zu].old_text in %s, it must match the file's current content exactly" ,
i , path . c_str ())}};
2026-03-27 10:07:11 +01:00
}
2026-07-10 11:52:59 +02:00
any_fuzzy = true ;
2026-03-27 10:07:11 +01:00
}
2026-07-10 11:52:59 +02:00
std :: string base_content = any_fuzzy ? normalize_for_fuzzy_match ( original_content ) : original_content ;
// uniqueness check always uses fuzzy-normalized text, so a whitespace-only duplicate still counts
std :: vector < matched_edit > matched ;
matched . reserve ( edits . size ());
for ( size_t i = 0 ; i < edits . size (); i ++ ) {
std :: string needle = any_fuzzy ? normalize_for_fuzzy_match ( edits [ i ]. old_text ) : edits [ i ]. old_text ;
size_t occurrences = count_occurrences (
normalize_for_fuzzy_match ( original_content ),
normalize_for_fuzzy_match ( edits [ i ]. old_text ));
if ( occurrences > 1 ) {
return {{ "error" , string_format (
"found %zu occurrences of edits[%zu].old_text in %s, it must be unique" ,
occurrences , i , path . c_str ())}};
}
size_t idx = base_content . find ( needle );
matched . push_back ({ i , idx , needle . size (), edits [ i ]. new_text });
}
std :: sort ( matched . begin (), matched . end (), []( const matched_edit & a , const matched_edit & b ) {
return a . match_index < b . match_index ;
2026-03-27 10:07:11 +01:00
});
2026-07-10 11:52:59 +02:00
for ( size_t i = 1 ; i < matched . size (); i ++ ) {
if ( matched [ i - 1 ]. match_index + matched [ i - 1 ]. match_length > matched [ i ]. match_index ) {
return {{ "error" , string_format (
"edits[%zu] and edits[%zu] overlap in %s; merge them into one edit or target disjoint regions" ,
matched [ i - 1 ]. edit_index , matched [ i ]. edit_index , path . c_str ())}};
2026-03-27 10:07:11 +01:00
}
}
2026-07-10 11:52:59 +02:00
std :: string new_content = any_fuzzy
? apply_replacements_preserving_unchanged_lines ( original_content , base_content , matched )
: apply_replacements ( base_content , matched , 0 );
if ( new_content == original_content ) {
return {{ "error" , "no changes made: the replacement(s) produced identical content" }};
2026-03-27 10:07:11 +01:00
}
2026-07-10 11:52:59 +02:00
if ( ! io -> write_file ( path , new_content )) {
2026-03-27 10:07:11 +01:00
return {{ "error" , "failed to write file: " + path }};
}
2026-07-10 11:52:59 +02:00
return {{ "result" , "file edited successfully" }, { "path" , path }, { "edits_applied" , ( int ) matched . size ()}};
2026-03-27 10:07:11 +01:00
}
2026-07-10 11:52:59 +02:00
private :
// strip trailing whitespace, normalize smart quotes/dashes/spaces to ASCII
static std :: string normalize_line_for_fuzzy_match ( const std :: string & line ) {
size_t end = line . size ();
while ( end > 0 && ( line [ end - 1 ] == ' ' || line [ end - 1 ] == '\t' || line [ end - 1 ] == '\r' )) {
end -- ;
}
std :: string s = line . substr ( 0 , end );
2026-03-27 10:07:11 +01:00
2026-07-10 11:52:59 +02:00
auto replace_all = []( std :: string & str , const std :: string & from , const std :: string & to ) {
if ( from . empty ()) return ;
size_t pos = 0 ;
while (( pos = str . find ( from , pos )) != std :: string :: npos ) {
str . replace ( pos , from . size (), to );
pos += to . size ();
}
};
// smart single quotes -> '
for ( unsigned char b : { 0x98 , 0x99 , 0x9A , 0x9B }) {
replace_all ( s , std :: string ( " \xE2\x80 " ) + ( char ) b , "'" );
}
// smart double quotes -> "
for ( unsigned char b : { 0x9C , 0x9D , 0x9E , 0x9F }) {
replace_all ( s , std :: string ( " \xE2\x80 " ) + ( char ) b , " \" " );
}
// various dashes -> -
for ( unsigned char b = 0x90 ; b <= 0x95 ; b ++ ) {
replace_all ( s , std :: string ( " \xE2\x80 " ) + ( char ) b , "-" );
}
replace_all ( s , " \xE2\x88\x92 " , "-" ); // minus sign
// special spaces -> ' '
replace_all ( s , " \xC2\xA0 " , " " ); // no-break space
for ( unsigned char b = 0x82 ; b <= 0x8A ; b ++ ) {
replace_all ( s , std :: string ( " \xE2\x80 " ) + ( char ) b , " " );
}
replace_all ( s , " \xE2\x80\xAF " , " " ); // narrow no-break space
replace_all ( s , " \xE2\x81\x9F " , " " ); // medium mathematical space
replace_all ( s , " \xE3\x80\x80 " , " " ); // ideographic space
return s ;
}
// applies the per-line transform above to every line; preserves line count/positions
static std :: string normalize_for_fuzzy_match ( const std :: string & content ) {
std :: string result ;
result . reserve ( content . size ());
size_t start = 0 ;
while ( true ) {
size_t nl = content . find ( '\n' , start );
bool is_last = nl == std :: string :: npos ;
std :: string line = is_last ? content . substr ( start ) : content . substr ( start , nl - start );
result += normalize_line_for_fuzzy_match ( line );
if ( is_last ) break ;
result += '\n' ;
start = nl + 1 ;
}
return result ;
}
// lines with trailing '\n' kept, so untouched ones can be reconstructed verbatim
static std :: vector < std :: string > split_lines_with_endings ( const std :: string & content ) {
std :: vector < std :: string > lines ;
size_t start = 0 ;
while ( start < content . size ()) {
size_t nl = content . find ( '\n' , start );
if ( nl == std :: string :: npos ) {
lines . push_back ( content . substr ( start ));
break ;
}
lines . push_back ( content . substr ( start , nl - start + 1 ));
start = nl + 1 ;
}
return lines ;
}
struct line_span {
size_t start ;
size_t end ;
};
static std :: vector < line_span > get_line_spans ( const std :: string & content ) {
std :: vector < line_span > spans ;
size_t offset = 0 ;
for ( const auto & line : split_lines_with_endings ( content )) {
spans . push_back ({ offset , offset + line . size ()});
offset += line . size ();
}
return spans ;
}
// count non-overlapping occurrences of `needle` in `content`
static size_t count_occurrences ( const std :: string & content , const std :: string & needle ) {
if ( needle . empty ()) return 0 ;
size_t count = 0 , pos = 0 ;
while (( pos = content . find ( needle , pos )) != std :: string :: npos ) {
count ++ ;
pos += needle . size ();
}
return count ;
}
struct matched_edit {
size_t edit_index ;
size_t match_index ; // offset into the "base content" (see below)
size_t match_length ;
std :: string new_text ;
};
// replacements must be sorted ascending by match_index and non-overlapping
static std :: string apply_replacements (
const std :: string & content ,
const std :: vector < matched_edit > & replacements ,
size_t offset ) {
std :: string result = content ;
for ( auto it = replacements . rbegin (); it != replacements . rend (); ++ it ) {
size_t local_index = it -> match_index - offset ;
result = result . substr ( 0 , local_index ) + it -> new_text + result . substr ( local_index + it -> match_length );
}
return result ;
}
// widen a replacement's byte range to the line(s) of `lines` it touches
static bool get_replacement_line_range (
const std :: vector < line_span > & lines ,
size_t match_index , size_t match_length ,
size_t & out_start_line , size_t & out_end_line /* exclusive */ ) {
size_t replacement_start = match_index ;
size_t replacement_end = match_index + match_length ;
size_t start_line = ( size_t ) - 1 ;
for ( size_t i = 0 ; i < lines . size (); i ++ ) {
if ( replacement_start >= lines [ i ]. start && replacement_start < lines [ i ]. end ) {
start_line = i ;
break ;
}
}
if ( start_line == ( size_t ) - 1 ) return false ;
size_t end_line = start_line ;
while ( end_line < lines . size () && lines [ end_line ]. end < replacement_end ) {
end_line ++ ;
}
if ( end_line >= lines . size ()) return false ;
out_start_line = start_line ;
out_end_line = end_line + 1 ;
return true ;
}
// like apply_replacements, but untouched lines come from `original_content`
static std :: string apply_replacements_preserving_unchanged_lines (
const std :: string & original_content ,
const std :: string & base_content ,
const std :: vector < matched_edit > & replacements /* ascending, non-overlapping */ ) {
auto original_lines = split_lines_with_endings ( original_content );
auto base_lines = get_line_spans ( base_content );
struct group {
size_t start_line ;
size_t end_line ; // exclusive
std :: vector < matched_edit > reps ;
};
std :: vector < group > groups ;
for ( const auto & rep : replacements ) {
size_t start_line = 0 , end_line = 0 ;
get_replacement_line_range ( base_lines , rep . match_index , rep . match_length , start_line , end_line );
if ( ! groups . empty () && start_line < groups . back (). end_line ) {
groups . back (). end_line = std :: max ( groups . back (). end_line , end_line );
groups . back (). reps . push_back ( rep );
} else {
groups . push_back ({ start_line , end_line , { rep }});
2026-03-27 10:07:11 +01:00
}
}
2026-07-10 11:52:59 +02:00
size_t original_line_index = 0 ;
std :: string result ;
for ( auto & g : groups ) {
for ( size_t i = original_line_index ; i < g . start_line ; i ++ ) {
result += original_lines [ i ];
}
2026-03-27 10:07:11 +01:00
2026-07-10 11:52:59 +02:00
size_t group_start_offset = base_lines [ g . start_line ]. start ;
size_t group_end_offset = base_lines [ g . end_line - 1 ]. end ;
std :: string slice = base_content . substr ( group_start_offset , group_end_offset - group_start_offset );
result += apply_replacements ( slice , g . reps , group_start_offset );
2026-03-27 10:07:11 +01:00
2026-07-10 11:52:59 +02:00
original_line_index = g . end_line ;
2026-03-27 10:07:11 +01:00
}
2026-07-10 11:52:59 +02:00
for ( size_t i = original_line_index ; i < original_lines . size (); i ++ ) {
result += original_lines [ i ];
}
return result ;
2026-03-27 10:07:11 +01:00
}
};
2026-05-04 06:19:41 -04:00
//
// get_datetime: returns the current date and time
//
struct server_tool_get_datetime : server_tool {
server_tool_get_datetime () {
name = "get_datetime" ;
display_name = "Get Date & Time" ;
permission_write = false ;
}
2026-07-10 11:52:59 +02:00
json get_definition () const override {
2026-05-04 06:19:41 -04:00
return {
{ "type" , "function" },
{ "function" , {
{ "name" , name },
2026-07-25 21:15:15 +02:00
{ "description" , "Returns the current date and time in UTC" },
{ "parameters" , {
{ "type" , "object" },
{ "properties" , {
{ "format" , {
{ "type" , "string" },
{ "description" ,
"strftime()-style format string for the output (default: \" %Y-%m-%dT%H:%M:%SZ \" , "
"e.g. ISO 8601). Choose your own format if you need something else, "
"e.g. \" %A, %B %d %Y \" for a human-readable date." },
}},
}},
}},
2026-05-04 06:19:41 -04:00
}},
};
}
2026-07-25 21:15:15 +02:00
json invoke ( json params , server_tool :: stream * ) const override {
std :: string format = json_value ( params , "format" , std :: string ( "%Y-%m-%dT%H:%M:%SZ" ));
2026-05-04 06:19:41 -04:00
2026-07-25 21:15:15 +02:00
auto now = std :: chrono :: system_clock :: now ();
auto time = std :: chrono :: system_clock :: to_time_t ( now );
std :: tm tm_utc ;
#ifdef _WIN32
gmtime_s ( & tm_utc , & time );
#else
gmtime_r ( & time , & tm_utc );
#endif
char buf [ 256 ];
size_t len = std :: strftime ( buf , sizeof ( buf ), format . c_str (), & tm_utc );
if ( len == 0 ) {
return {{ "error" , "invalid format string" }};
}
return {{ "result" , std :: string ( buf , len )}};
2026-05-04 06:19:41 -04:00
}
};
2026-08-03 18:51:02 +02:00
//
// get_info: returns runtime info (OS name/version and cwd)
//
2026-08-04 19:05:48 +02:00
static constexpr size_t SERVER_TOOL_GET_INFO_MAX_OUTPUT = 4096 ;
static constexpr int SERVER_TOOL_GET_INFO_TIMEOUT = 5 ; // seconds
2026-08-03 18:51:02 +02:00
struct server_tool_get_info : server_tool {
server_tool_get_info () {
name = "get_info" ;
display_name = "Get Runtime Info" ;
permission_write = false ;
}
json get_definition () const override {
return {
{ "type" , "function" },
{ "function" , {
{ "name" , name },
{ "description" , "Returns runtime info: the OS name/version and the current working directory" },
{ "parameters" , {
{ "type" , "object" },
{ "properties" , json :: object ()},
}},
}},
};
}
json invoke ( json params , server_tool :: stream * ) const override {
auto io = make_tools_io ( params );
#ifdef _WIN32
2026-08-04 19:05:48 +02:00
auto res = io -> run ({ "cmd" , "/c" , "ver" }, SERVER_TOOL_GET_INFO_MAX_OUTPUT , SERVER_TOOL_GET_INFO_TIMEOUT );
2026-08-03 18:51:02 +02:00
#else
2026-08-04 19:05:48 +02:00
auto res = io -> run ({ "uname" , "-a" }, SERVER_TOOL_GET_INFO_MAX_OUTPUT , SERVER_TOOL_GET_INFO_TIMEOUT );
2026-08-03 18:51:02 +02:00
#endif
// "ver" prints a blank line before the version, so the output is stripped on both ends;
// a failed spawn or a timeout leaves a diagnostic in res.output, which is not an OS name
std :: string os_info = res . exit_code == 0 && ! res . timed_out ? string_strip ( res . output ) : "unknown" ;
std :: string cwd = json_value ( params , "cwd" , std :: string ());
if ( cwd . empty ()) {
std :: error_code ec ;
2026-08-05 21:31:54 +02:00
cwd = path_to_utf8 ( fs :: current_path ( ec ));
2026-08-03 18:51:02 +02:00
}
return {
{ "os" , os_info },
{ "cwd" , cwd },
};
}
};
2026-07-11 12:42:55 +02:00
struct server_tool_stream_result : server_task_result {
std :: string chunk ;
bool done = false ;
std :: string error_msg ;
json to_json () override {
if ( ! done ) {
return {{ "chunk" , chunk }};
} else {
json result = {{ "done" , true }};
if ( ! error_msg . empty ()) {
result [ "error" ] = error_msg ;
}
return result ;
}
}
};
void server_tool :: stream :: push ( const std :: string & chunk ) {
if ( chunk . empty ()) return ;
auto r = std :: make_unique < server_tool_stream_result > ();
r -> id = id ;
r -> chunk = chunk ;
qr . send ( std :: move ( r ));
}
struct server_tools_res : server_http_res {
std :: thread worker ;
server_response * qr = nullptr ; // set only for streaming responses
int id = - 1 ;
~ server_tools_res () override {
if ( worker . joinable ()) {
worker . join ();
}
if ( qr ) {
qr -> remove_waiting_task_id ( id );
}
}
};
2026-07-26 01:08:49 +02:00
//
// server_mcp_tool: exposes one tool from a running MCP server as a server_tool.
//
struct server_mcp_tool : server_tool {
std :: string server_name ;
std :: string tool_name ;
server_mcp_tool_def def ;
server_mcp & mcp_mgr ;
server_mcp_tool ( server_mcp_tool_def d , server_mcp & mgr )
: server_name ( d . server_name )
, tool_name ( d . name )
, def ( std :: move ( d ))
, mcp_mgr ( mgr )
{
name = server_name + "_" + tool_name ;
display_name = name ;
permission_write = false ;
support_stream = false ;
}
std :: string type () const override { return "mcp" ; }
json get_definition () const override {
json schema = def . input_schema ;
if ( schema . is_null () || ! schema . is_object ()) {
schema = json :: object ();
}
return {
{ "type" , "function" },
{ "function" , {
{ "name" , name },
{ "description" , def . description },
{ "parameters" , schema },
}},
};
}
json invoke ( json params , server_tool :: stream * ) const override {
return mcp_mgr . call_tool ( server_name , tool_name , params );
}
};
2026-07-11 12:42:55 +02:00
static server_tool & find_tool ( std :: vector < std :: unique_ptr < server_tool >> & tools , const std :: string & name , bool require_stream ) {
for ( auto & t : tools ) {
if ( t -> name == name ) {
if ( require_stream && ! t -> support_stream ) {
throw std :: invalid_argument ( string_format ( "tool \" %s \" does not support stream = true" , name . c_str ()));
}
return * t ;
}
}
throw std :: invalid_argument ( string_format ( "unknown tool \" %s \" " , name . c_str ()));
}
2026-03-27 10:07:11 +01:00
//
// public API
//
static std :: vector < std :: unique_ptr < server_tool >> build_tools () {
std :: vector < std :: unique_ptr < server_tool >> tools ;
tools . push_back ( std :: make_unique < server_tool_read_file > ());
tools . push_back ( std :: make_unique < server_tool_file_glob_search > ());
tools . push_back ( std :: make_unique < server_tool_grep_search > ());
tools . push_back ( std :: make_unique < server_tool_exec_shell_command > ());
tools . push_back ( std :: make_unique < server_tool_write_file > ());
tools . push_back ( std :: make_unique < server_tool_edit_file > ());
2026-05-04 06:19:41 -04:00
tools . push_back ( std :: make_unique < server_tool_get_datetime > ());
2026-08-03 18:51:02 +02:00
tools . push_back ( std :: make_unique < server_tool_get_info > ());
2026-03-27 10:07:11 +01:00
return tools ;
}
2026-08-03 10:47:21 +02:00
static std :: string str_to_lower ( const std :: string & value ) {
std :: string lowered ( value . size (), '\0' );
std :: transform ( value . begin (), value . end (), lowered . begin (), []( unsigned char c ) { return std :: tolower ( c ); });
return lowered ;
}
static std :: string get_header ( const std :: map < std :: string , std :: string > & headers , const std :: string & key , std :: string default_value = "" ) {
const auto lowered_key = str_to_lower ( key );
for ( const auto & h : headers ) {
if ( str_to_lower ( h . first ) == lowered_key ) {
return h . second ;
}
}
return default_value ;
}
2026-07-26 01:08:49 +02:00
void server_tools :: setup ( const std :: vector < std :: string > & enabled_tools ,
server_mcp & mcp_mgr ) {
2026-03-27 10:07:11 +01:00
if ( ! enabled_tools . empty ()) {
2026-07-26 20:54:25 +02:00
if ( ! common_subproc :: is_supported ()) {
throw std :: runtime_error ( "subprocess is not enabled on this build" );
}
2026-03-27 10:07:11 +01:00
std :: unordered_set < std :: string > enabled_set ( enabled_tools . begin (), enabled_tools . end ());
auto all_tools = build_tools ();
2026-05-05 06:35:27 +03:00
// collect all known tool names for validation
std :: vector < std :: string > known_names ;
known_names . reserve ( all_tools . size ());
for ( const auto & t : all_tools ) {
known_names . push_back ( t -> name );
}
// validate that every requested tool is known
for ( const auto & name : enabled_tools ) {
if ( name == "all" ) continue ;
if ( std :: find ( known_names . begin (), known_names . end (), name ) == known_names . end ()) {
throw std :: runtime_error ( string_format (
"unknown tool \" %s \" . available tools: %s" ,
name . c_str (),
string_join ( known_names , ", " ). c_str ()));
}
}
2026-03-27 10:07:11 +01:00
tools . clear ();
for ( auto & t : all_tools ) {
if ( enabled_set . count ( t -> name ) > 0 || enabled_set . count ( "all" ) > 0 ) {
tools . push_back ( std :: move ( t ));
}
}
}
2026-07-26 01:08:49 +02:00
// append MCP tools, skipping any that collide with a built-in or another MCP tool of the same "<server>_<tool>" name
if ( ! mcp_mgr . empty ()) {
std :: unordered_set < std :: string > seen_names ;
for ( auto & t : tools ) {
seen_names . insert ( t -> name );
}
size_t n_added = 0 ;
for ( const auto & def : mcp_mgr . list_tools ()) {
std :: string mcp_name = def . server_name + "_" + def . name ;
if ( seen_names . count ( mcp_name )) {
SRV_WRN ( "MCP tool \" %s \" from server \" %s \" collides with an existing tool, skipping \n " ,
mcp_name . c_str (), def . server_name . c_str ());
continue ;
}
seen_names . insert ( mcp_name );
tools . push_back ( std :: make_unique < server_mcp_tool > ( def , mcp_mgr ));
n_added ++ ;
}
if ( n_added > 0 ) {
SRV_INF ( "Added %zu MCP tools \n " , n_added );
}
}
2026-03-27 10:07:11 +01:00
handle_get = [ this ]( const server_http_req & ) -> server_http_res_ptr {
auto res = std :: make_unique < server_http_res > ();
try {
json result = json :: array ();
for ( const auto & t : tools ) {
result . push_back ( t -> to_json ());
}
res -> data = safe_json_to_str ( result );
} catch ( const std :: exception & e ) {
SRV_ERR ( "got exception: %s \n " , e . what ());
res -> status = 500 ;
res -> data = safe_json_to_str ( format_error_response ( e . what (), ERROR_TYPE_SERVER ));
}
return res ;
};
handle_post = [ this ]( const server_http_req & req ) -> server_http_res_ptr {
2026-07-11 12:42:55 +02:00
auto res = std :: make_unique < server_tools_res > ();
2026-03-27 10:07:11 +01:00
try {
json body = json :: parse ( req . body );
std :: string tool_name = body . at ( "tool" ). get < std :: string > ();
json params = body . value ( "params" , json :: object ());
2026-07-11 12:42:55 +02:00
bool stream = body . value ( "stream" , false );
2026-08-03 10:47:21 +02:00
// accept x-tool-cwd header to override of the process
auto cwd = get_header ( req . headers , "x-tool-cwd" );
if ( ! cwd . empty ()) {
params [ "cwd" ] = cwd ;
}
2026-07-11 12:42:55 +02:00
server_tool & tool = find_tool ( tools , tool_name , stream );
if ( stream ) {
int id = res_id . fetch_add ( 1 );
queue_res . add_waiting_task_id ( id );
res -> qr = & queue_res ;
res -> id = id ;
res -> worker = std :: thread ([ this , id , & req , & tool , params ]() mutable {
server_tool :: stream st { queue_res , id , [ & req ]() {
return ! req . should_stop ();
}};
auto done = std :: make_unique < server_tool_stream_result > ();
try {
tool . invoke ( params , & st );
} catch ( const std :: exception & e ) {
done -> error_msg = e . what ();
} catch (...) {
done -> error_msg = "An unknown error occurred" ;
}
done -> id = st . id ;
done -> done = true ;
st . qr . send ( std :: move ( done ));
});
res -> content_type = "text/event-stream" ;
res -> status = 200 ;
res -> next = [ this , id ]( std :: string & output ) -> bool {
auto result = queue_res . recv ( id );
auto * r = dynamic_cast < server_tool_stream_result *> ( result . get ());
GGML_ASSERT ( r != nullptr );
output = "data: " + safe_json_to_str ( r -> to_json ()) + " \n\n " ;
if ( r -> done ) {
queue_res . remove_waiting_task_id ( id );
return false ;
}
return true ;
};
} else {
json result = tool . invoke ( params , nullptr );
res -> status = 200 ;
res -> data = safe_json_to_str ( result );
}
2026-03-27 10:07:11 +01:00
} catch ( const json :: exception & e ) {
res -> status = 400 ;
res -> data = safe_json_to_str ( format_error_response ( e . what (), ERROR_TYPE_INVALID_REQUEST ));
2026-07-11 12:42:55 +02:00
} catch ( const std :: invalid_argument & e ) {
res -> status = 404 ;
res -> data = safe_json_to_str ( format_error_response ( e . what (), ERROR_TYPE_INVALID_REQUEST ));
2026-03-27 10:07:11 +01:00
} catch ( const std :: exception & e ) {
SRV_ERR ( "got exception: %s \n " , e . what ());
res -> status = 500 ;
res -> data = safe_json_to_str ( format_error_response ( e . what (), ERROR_TYPE_SERVER ));
}
return res ;
};
}