2026-03-27 10:07:11 +01:00
#include "server-tools.h"
#include <sheredom/subprocess.h>
#include <filesystem>
#include <fstream>
#include <regex>
#include <thread>
#include <chrono>
#include <atomic>
#include <cstring>
#include <climits>
2026-05-05 06:35:27 +03:00
#include <algorithm>
2026-06-20 21:15:06 -05:00
#include <unordered_set>
2026-07-11 12:42:55 +02:00
#include <functional>
2026-03-27 10:07:11 +01:00
namespace fs = std :: filesystem ;
//
// internal helpers
//
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 },
{ "type" , "builtin" },
{ "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
static constexpr int SERVER_TOOL_GIT_LS_FILES_TIMEOUT = 15 ; // seconds
class tools_io {
public :
struct exec_result {
std :: string output ;
int exit_code = - 1 ;
bool timed_out = false ;
};
virtual ~ tools_io () = default ;
virtual bool is_directory ( const std :: string & path ) const = 0 ;
virtual bool is_regular_file ( const std :: string & path ) const = 0 ;
virtual bool file_size ( const std :: string & path , uintmax_t & out_size ) const = 0 ;
virtual bool read_file ( const std :: string & path , std :: string & out ) const = 0 ;
virtual bool write_file ( const std :: string & path , const std :: string & content ) const = 0 ;
// paths relative to `base`, '/'-separated; sets `err` if `base` isn't a directory
virtual std :: vector < std :: string > list_files ( const std :: string & base , std :: string & err ) const = 0 ;
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 :
bool is_directory ( const std :: string & path ) const override {
std :: error_code ec ;
return fs :: is_directory ( path , ec ) && ! ec ;
}
bool is_regular_file ( const std :: string & path ) const override {
std :: error_code ec ;
return fs :: is_regular_file ( path , ec ) && ! ec ;
}
bool file_size ( const std :: string & path , uintmax_t & out_size ) const override {
std :: error_code ec ;
out_size = fs :: file_size ( path , ec );
return ! ec ;
}
bool read_file ( const std :: string & path , std :: string & out ) const override {
std :: ifstream f ( path , std :: ios :: binary );
if ( ! f ) return false ;
std :: ostringstream ss ;
ss << f . rdbuf ();
out = ss . str ();
return true ;
}
bool write_file ( const std :: string & path , const std :: string & content ) const override {
std :: error_code ec ;
fs :: path fpath ( path );
if ( fpath . has_parent_path ()) {
fs :: create_directories ( fpath . parent_path (), ec );
if ( ec ) return false ;
}
std :: ofstream f ( path , std :: ios :: binary );
if ( ! f ) return false ;
f << content ;
return ( bool ) f ;
}
std :: vector < std :: string > list_files ( const std :: string & base , std :: string & err ) const override {
err . clear ();
if ( ! is_directory ( base )) {
err = "path does not exist or is not a directory: " + base ;
return {};
}
auto res = run (
{ "git" , "-C" , base , "ls-files" , "--cached" , "--others" , "--exclude-standard" },
SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT , SERVER_TOOL_GIT_LS_FILES_TIMEOUT );
if ( res . exit_code == 0 && ! res . timed_out ) {
std :: vector < std :: string > result ;
std :: istringstream iss ( res . output );
std :: string line ;
while ( std :: getline ( iss , line )) {
if ( ! line . empty () && line . back () == '\r' ) line . pop_back ();
if ( line . empty ()) continue ;
std :: replace ( line . begin (), line . end (), '\\' , '/' );
if ( is_regular_file (( fs :: path ( base ) / line ). string ())) {
result . push_back ( line );
}
}
return result ;
}
return list_files_fallback ( base );
}
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 ;
subprocess_s proc ;
auto argv = to_cstr_vec ( args );
int options = subprocess_option_no_window
| subprocess_option_combined_stdout_stderr
| subprocess_option_inherit_environment
| subprocess_option_search_user_path ;
if ( subprocess_create ( argv . data (), options , & proc ) != 0 ) {
res . output = "failed to spawn process" ;
return res ;
}
std :: atomic < bool > done { false };
std :: atomic < bool > timed_out { false };
std :: thread timeout_thread ([ & ]() {
auto deadline = std :: chrono :: steady_clock :: now () + std :: chrono :: seconds ( timeout_secs );
while ( ! done . load ()) {
if ( std :: chrono :: steady_clock :: now () >= deadline ) {
timed_out . store ( true );
subprocess_terminate ( & proc );
return ;
}
std :: this_thread :: sleep_for ( std :: chrono :: milliseconds ( 100 ));
}
});
FILE * f = subprocess_stdout ( & proc );
std :: string output ;
bool truncated = false ;
if ( f ) {
char buf [ 4096 ];
while ( fgets ( buf , sizeof ( buf ), f ) != nullptr ) {
if ( ! truncated ) {
size_t len = strlen ( buf );
if ( output . size () + len <= max_output ) {
output . append ( buf , len );
2026-07-11 12:42:55 +02:00
if ( on_chunk && ! on_chunk ( std :: string ( buf , len ))) {
subprocess_terminate ( & proc );
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 );
if ( on_chunk && remaining > 0 ) on_chunk ( std :: string ( buf , remaining ));
2026-07-10 11:52:59 +02:00
truncated = true ;
}
}
}
}
done . store ( true );
if ( timeout_thread . joinable ()) {
timeout_thread . join ();
}
subprocess_join ( & proc , & res . exit_code );
subprocess_destroy ( & proc );
res . output = output ;
res . timed_out = timed_out . load ();
if ( truncated ) {
res . output += " \n [output truncated]" ;
}
return res ;
}
private :
static std :: vector < char *> to_cstr_vec ( const std :: vector < std :: string > & v ) {
std :: vector < char *> r ;
r . reserve ( v . size () + 1 );
for ( const auto & s : v ) {
r . push_back ( const_cast < char *> ( s . c_str ()));
}
r . push_back ( nullptr );
return r ;
}
static const std :: unordered_set < std :: string > & junk_dir_names () {
static const std :: unordered_set < std :: string > names = {
".git" , ".svn" , ".hg" , "node_modules" , "__pycache__" ,
".venv" , "venv" , "dist" , "build" , "target" , ".cache" , ".idea" , ".vscode" ,
};
return names ;
}
std :: vector < std :: string > list_files_fallback ( const std :: string & base ) const {
std :: vector < std :: string > result ;
std :: error_code ec ;
std :: vector < std :: pair < fs :: path , fs :: path >> stack ;
stack . emplace_back ( fs :: path ( base ), fs :: path ());
while ( ! stack . empty ()) {
auto [ dir , rel_dir ] = stack . back ();
stack . pop_back ();
for ( const auto & entry : fs :: directory_iterator ( dir , fs :: directory_options :: skip_permission_denied , ec )) {
if ( ec ) break ;
std :: string fname = entry . path (). filename (). string ();
std :: error_code tec ;
if ( entry . is_directory ( tec )) {
if ( junk_dir_names (). count ( fname ) > 0 ) continue ;
stack . emplace_back ( entry . path (), rel_dir / fname );
} else if ( entry . is_regular_file ( tec )) {
std :: string rel = ( rel_dir / fname ). string ();
std :: replace ( rel . begin (), rel . end (), '\\' , '/' );
result . push_back ( rel );
}
}
}
return result ;
}
};
static std :: unique_ptr < tools_io > make_tools_io ( const json & params ) {
GGML_UNUSED ( params ); // TODO in follow-up PR
return std :: make_unique < tools_io_basic > ();
}
// no '/' in pattern -> match basename at any depth; else match full relative path
static bool path_glob_match ( const std :: string & pattern , const std :: string & rel_path ) {
if ( pattern . find ( '/' ) == std :: string :: npos ) {
return glob_match ( pattern , fs :: path ( rel_path ). filename (). string ());
}
if ( pattern == "**" || pattern . rfind ( "**/" , 0 ) == 0 || pattern . rfind ( '/' , 0 ) == 0 ) {
return glob_match ( pattern , rel_path );
}
return glob_match ( "**/" + pattern , rel_path );
}
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
//
static constexpr size_t SERVER_TOOL_FILE_SEARCH_MAX_RESULTS = 100 ;
struct server_tool_file_glob_search : server_tool {
server_tool_file_glob_search () {
name = "file_glob_search" ;
display_name = "File search" ;
permission_write = false ;
}
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 "
" \" **/ \" or a leading '/', it is automatically prefixed with \" **/ \" ." },
2026-03-27 10:07:11 +01:00
{ "parameters" , {
{ "type" , "object" },
{ "properties" , {
{ "path" , {{ "type" , "string" }, { "description" , "Base directory to search in" }}},
2026-07-10 11:52:59 +02:00
{ "include" , {{ "type" , "string" }, { "description" , "Glob pattern for files to include (e.g. \" *.cpp \" or \" src/**/*.cpp \" ). Default: **" }}},
2026-03-27 10:07:11 +01:00
{ "exclude" , {{ "type" , "string" }, { "description" , "Glob pattern for files to exclude" }}},
}},
{ "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 base = params . at ( "path" ). get < std :: string > ();
std :: string include = json_value ( params , "include" , std :: string ( "**" ));
std :: string exclude = json_value ( params , "exclude" , std :: string ( "" ));
2026-07-10 11:52:59 +02:00
auto io = make_tools_io ( params );
std :: string err ;
auto files = io -> list_files ( base , err );
if ( ! err . empty ()) {
return {{ "error" , err }};
2026-03-27 10:07:11 +01:00
}
2026-07-10 11:52:59 +02:00
std :: vector < std :: string > matches ;
for ( const auto & rel : files ) {
if ( ! path_glob_match ( include , rel )) continue ;
if ( ! exclude . empty () && path_glob_match ( exclude , rel )) continue ;
matches . push_back ( rel );
}
size_t total = matches . size ();
size_t shown = std :: min ( total , SERVER_TOOL_FILE_SEARCH_MAX_RESULTS );
std :: ostringstream output_text ;
for ( size_t i = 0 ; i < shown ; i ++ ) {
output_text << matches [ i ] << " \n " ;
}
output_text << " \n --- \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-03-27 10:07:11 +01:00
return {{ "plain_text_response" , output_text . str ()}};
}
};
//
// grep_search: search for a regex pattern in files
//
static constexpr size_t SERVER_TOOL_GREP_SEARCH_MAX_RESULTS = 100 ;
struct server_tool_grep_search : server_tool {
server_tool_grep_search () {
name = "grep_search" ;
display_name = "Grep search" ;
permission_write = false ;
}
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 ;
if ( io -> is_regular_file ( path )) {
files . emplace_back ( path , path );
} else if ( io -> is_directory ( path )) {
std :: string err ;
auto candidates = io -> list_files ( path , err );
if ( ! err . empty ()) {
return {{ "error" , err }};
2026-03-27 10:07:11 +01:00
}
2026-07-10 11:52:59 +02:00
for ( const auto & rel : candidates ) {
if ( ! path_glob_match ( include , rel )) continue ;
if ( ! exclude . empty () && path_glob_match ( exclude , rel )) continue ;
files . emplace_back (( fs :: path ( path ) / rel ). string (), rel );
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 },
{ "description" , "Returns the current date and time" },
}},
};
}
2026-07-11 12:42:55 +02:00
json invoke ( json , server_tool :: stream * ) const override {
2026-05-04 06:19:41 -04:00
auto now = std :: chrono :: system_clock :: now ();
auto time = std :: chrono :: system_clock :: to_time_t ( now );
return {{ "result" , std :: ctime ( & time )}};
}
};
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 );
}
}
};
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-03-27 10:07:11 +01:00
return tools ;
}
void server_tools :: setup ( const std :: vector < std :: string > & enabled_tools ) {
if ( ! enabled_tools . empty ()) {
std :: unordered_set < std :: string > enabled_set ( enabled_tools . begin (), enabled_tools . end ());
auto all_tools = build_tools ();
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 ));
}
}
}
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 );
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 ;
};
}