]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
server: (tools) add x-tool-cwd header (#26420)
authorXuan-Son Nguyen <redacted>
Mon, 3 Aug 2026 08:47:21 +0000 (10:47 +0200)
committerGitHub <redacted>
Mon, 3 Aug 2026 08:47:21 +0000 (10:47 +0200)
* server: (tools) add x-tool-cwd header

* reuse str_to_lower from server-models

tools/server/README-dev.md
tools/server/server-tools.cpp
tools/server/tests/unit/test_tools_builtin.py

index b41d70c63ac87f2c0ba67432a1cde9ba05b647d4..45bcdcca76990caa7391342711390c6db715f42b 100644 (file)
@@ -199,6 +199,9 @@ Invoke a tool call, request body is a JSON object with:
 - `tool` (string): the name of the tool
 - `params` (object): a mapping from argument name (string) to argument value
 
+Headers:
+- `x-tool-cwd`: optional; if set, use as the CWD for tool; this is not part of tool's params because it's meant to be set by the runtime, not the LLM itself
+
 Returns JSON object. There are two response formats (MCP tools use the same two formats: their result content is concatenated into `plain_text_response`, and RPC or tool errors are surfaced as the `error` string):
 
 Format 1: Plain text. The text will be placed into a field called `plain_text_response`, example:
index 90b7e2a9f0ee0cfbf5c0a17bc2872d44e1ec9d02..4a6c5ed4420a9af03c88dcbb81ee430dbdc94c3b 100644 (file)
@@ -64,24 +64,27 @@ public:
 
 class tools_io_basic : public tools_io {
 public:
+    // 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)) {}
+
     bool is_directory(const std::string & path) const override {
         std::error_code ec;
-        return fs::is_directory(path, ec) && !ec;
+        return fs::is_directory(resolve(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;
+        return fs::is_regular_file(resolve(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);
+        out_size = fs::file_size(resolve(path), ec);
         return !ec;
     }
 
     bool read_file(const std::string & path, std::string & out) const override {
-        std::ifstream f(path, std::ios::binary);
+        std::ifstream f(resolve(path), std::ios::binary);
         if (!f) return false;
         std::ostringstream ss;
         ss << f.rdbuf();
@@ -91,12 +94,12 @@ public:
 
     bool write_file(const std::string & path, const std::string & content) const override {
         std::error_code ec;
-        fs::path fpath(path);
+        fs::path fpath(resolve(path));
         if (fpath.has_parent_path()) {
             fs::create_directories(fpath.parent_path(), ec);
             if (ec) return false;
         }
-        std::ofstream f(path, std::ios::binary);
+        std::ofstream f(fpath, std::ios::binary);
         if (!f) return false;
         f << content;
         return (bool) f;
@@ -104,13 +107,14 @@ public:
 
     std::vector<std::string> list_files(const std::string & base, std::string & err) const override {
         err.clear();
+        std::string abs_base = resolve(base);
         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"},
+            {"git", "-C", abs_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) {
@@ -128,7 +132,7 @@ public:
             return result;
         }
 
-        return list_files_fallback(base);
+        return list_files_fallback(abs_base);
     }
 
     exec_result run(
@@ -145,7 +149,7 @@ public:
                     | subprocess_option_inherit_environment
                     | subprocess_option_search_user_path;
 
-        if (!proc.create(args, options)) {
+        if (!proc.create(args, options, {}, cwd.empty() ? nullptr : cwd.c_str())) {
             res.output = "failed to spawn process";
             return res;
         }
@@ -205,6 +209,16 @@ public:
     }
 
 private:
+    std::string cwd;
+
+    // resolves `path` against `cwd` if `path` is relative and `cwd` is set; otherwise returns `path` unchanged
+    std::string resolve(const std::string & path) const {
+        if (cwd.empty() || fs::path(path).is_absolute()) {
+            return path;
+        }
+        return (fs::path(cwd) / path).string();
+    }
+
     static const std::unordered_set<std::string> & junk_dir_names() {
         static const std::unordered_set<std::string> names = {
             ".git", ".svn", ".hg", "node_modules", "__pycache__",
@@ -244,8 +258,8 @@ private:
 };
 
 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>();
+    std::string cwd = json_value(params, "cwd", std::string());
+    return std::make_unique<tools_io_basic>(cwd);
 }
 
 // no '/' in pattern -> match basename at any depth; else match full relative path
@@ -1188,6 +1202,22 @@ static std::vector<std::unique_ptr<server_tool>> build_tools() {
     return tools;
 }
 
+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;
+}
+
 void server_tools::setup(const std::vector<std::string> & enabled_tools,
                          server_mcp & mcp_mgr) {
     if (!enabled_tools.empty()) {
@@ -1271,6 +1301,12 @@ void server_tools::setup(const std::vector<std::string> & enabled_tools,
             json params = body.value("params", json::object());
             bool stream = body.value("stream", false);
 
+            // 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;
+            }
+
             server_tool & tool = find_tool(tools, tool_name, stream);
 
             if (stream) {
index 1b2d0db432319846f1153944583fed4833285a1a..fb194cac669ac091a552c7b58e8b8cd61deef331 100755 (executable)
@@ -19,8 +19,8 @@ def create_server():
     server.server_tools = "all"
 
 
-def call_tool(name: str, params: dict) -> dict:
-    res = server.make_request("POST", "/tools", data={"tool": name, "params": params})
+def call_tool(name: str, params: dict, headers: dict | None = None) -> dict:
+    res = server.make_request("POST", "/tools", data={"tool": name, "params": params}, headers=headers)
     assert res.status_code == 200, res.body
     assert "error" not in res.body, res.body
     return res.body
@@ -123,6 +123,29 @@ def test_tools_builtin_exec_shell_command_stream():
     assert "[exit code: 0]" in chunks
 
 
+def test_tools_builtin_cwd_header():
+    global server
+    server.start()
+
+    cwd_dir = os.path.join(PROJECT_ROOT, "tools", "server", "tests", "unit")
+    headers = {"x-tool-cwd": cwd_dir}
+
+    res = call_tool("read_file", {"path": "test_tools_builtin.py"}, headers=headers)
+    assert GREP_MARKER in res["plain_text_response"]
+
+    # exec_shell_command should also run with that directory as its working directory:
+    # writing to a relative filename must land inside cwd_dir
+    marker_name = "llama_cpp_test_tools_builtin_cwd_marker.txt"
+    marker_path = os.path.join(cwd_dir, marker_name)
+    try:
+        command = f"echo hello > {marker_name}"
+        call_tool("exec_shell_command", {"command": command}, headers=headers)
+        assert os.path.exists(marker_path)
+    finally:
+        if os.path.exists(marker_path):
+            os.remove(marker_path)
+
+
 def test_tools_builtin_edit_file_rejects_overlapping_edits():
     global server
     server.start()