From 7f9f53124bf1a0ed0110332ed1377a728328e99c Mon Sep 17 00:00:00 2001 From: Xuan Son Nguyen Date: Thu, 19 Mar 2026 23:03:33 +0100 Subject: [PATCH 1/4] refactor --- tools/server/README-dev.md | 43 +++++- tools/server/server-tools.cpp | 270 ++++++++++++++++++++++++++++++---- 2 files changed, 282 insertions(+), 31 deletions(-) diff --git a/tools/server/README-dev.md b/tools/server/README-dev.md index 8318c5852d..326cb357b4 100644 --- a/tools/server/README-dev.md +++ b/tools/server/README-dev.md @@ -101,7 +101,12 @@ This endpoint is intended to be used internally by the Web UI and subject to cha **GET /tools** -Get a list of tools, the tool definition is in OAI-compat format. +Get a list of tools, each tool has these fields: +- `tool` (string): the ID name of the tool, to be used in POST call. Example: `read_file` +- `displayName` (string): the name to be displayed on UI. Example: `Read file` +- `type` (string): always be `"builtin"` for now +- `permissions` (object): a mapping string --> boolean that indicates the permission required by this tool. This is useful for the UI to ask the user before calling the tool. For now, the only permission supported is `"write"` +- `definition` (object): the OAI-compat definition of this tool **POST /tools** @@ -109,7 +114,41 @@ 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 -Returns JSON object, the schema depends on the tool itself. +Returns JSON object. There are two response formats: + +Format 1: Plain text. The text will be placed into a field called `plain_text_response`, example: + +```json +{ + "plain_text_response": "this is a text response" +} +``` + +The client should extract this value and place it inside message content (note: content is no longer a JSON), example + +```json +{ + "role": "tool", + "content": "this is a text response" +} +``` + +Format 2: Normal JSON response, example: + +```json +{ + "error": "cannot open this file" +} +``` + +That requires `JSON.stringify` when formatted to message content: + +```json +{ + "role": "tool", + "content": "{\"error\":\"cannot open this file\"}" +} +``` ### Notable Related PRs diff --git a/tools/server/server-tools.cpp b/tools/server/server-tools.cpp index a040c42051..d5c7921efd 100644 --- a/tools/server/server-tools.cpp +++ b/tools/server/server-tools.cpp @@ -139,11 +139,25 @@ static bool glob_match(const std::string & pattern, const std::string & str) { struct server_tool { std::string name; + std::string displayName; json definition; bool permission_write = false; + virtual ~server_tool() = default; - virtual json to_json() = 0; + virtual json get_definition() = 0; virtual json invoke(json params) = 0; + + json to_json() { + return { + {"displayName", displayName}, + {"tool", name}, + {"type", "builtin"}, + {"permissions", json{ + {"write", permission_write} + }}, + {"definition", get_definition()}, + }; + } }; // @@ -153,9 +167,13 @@ struct server_tool { 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"; permission_write = false; } + server_tool_read_file() { + name = "read_file"; + displayName = "Read file"; + permission_write = false; + } - json to_json() override { + json get_definition() override { return { {"type", "function"}, {"function", { @@ -221,7 +239,7 @@ struct server_tool_read_file : server_tool { result += out_line; } - return {{"content", result}}; + return {{"plain_text_response", result}}; } }; @@ -232,9 +250,13 @@ struct server_tool_read_file : server_tool { 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"; permission_write = false; } + server_tool_file_glob_search() { + name = "file_glob_search"; + displayName = "File search"; + permission_write = false; + } - json to_json() override { + json get_definition() override { return { {"type", "function"}, {"function", { @@ -258,7 +280,8 @@ struct server_tool_file_glob_search : server_tool { std::string include = json_value(params, "include", std::string("**")); std::string exclude = json_value(params, "exclude", std::string("")); - json files = json::array(); + std::ostringstream output_text; + size_t count = 0; std::error_code ec; for (const auto & entry : fs::recursive_directory_iterator(base, @@ -272,13 +295,15 @@ struct server_tool_file_glob_search : server_tool { if (!glob_match(include, rel)) continue; if (!exclude.empty() && glob_match(exclude, rel)) continue; - files.push_back(entry.path().string()); - if (files.size() >= SERVER_TOOL_FILE_SEARCH_MAX_RESULTS) { + output_text << entry.path().string() << "\n"; + if (++count >= SERVER_TOOL_FILE_SEARCH_MAX_RESULTS) { break; } } - return {{"files", files}, {"count", files.size()}}; + output_text << "\n---\nTotal matches: " << count << "\n"; + + return {{"plain_text_response", output_text.str()}}; } }; @@ -289,9 +314,13 @@ struct server_tool_file_glob_search : server_tool { 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"; permission_write = false; } + server_tool_grep_search() { + name = "grep_search"; + displayName = "Grep search"; + permission_write = false; + } - json to_json() override { + json get_definition() override { return { {"type", "function"}, {"function", { @@ -326,7 +355,7 @@ struct server_tool_grep_search : server_tool { return {{"error", std::string("invalid regex: ") + e.what()}}; } - json matches = json::array(); + std::ostringstream output_text; size_t total = 0; auto search_file = [&](const fs::path & fpath) { @@ -337,11 +366,11 @@ struct server_tool_grep_search : server_tool { while (std::getline(f, line) && total < SERVER_TOOL_GREP_SEARCH_MAX_RESULTS) { lineno++; if (std::regex_search(line, pattern)) { - json match = {{"file", fpath.string()}, {"content", line}}; + output_text << fpath.string() << ":"; if (show_lineno) { - match["line"] = lineno; + output_text << lineno << ":"; } - matches.push_back(match); + output_text << line << "\n"; total++; } } @@ -369,7 +398,9 @@ struct server_tool_grep_search : server_tool { return {{"error", "path does not exist: " + path}}; } - return {{"matches", matches}, {"count", total}}; + output_text << "\n\n---\nTotal matches: " << total << "\n"; + + return {{"plain_text_response", output_text.str()}}; } }; @@ -381,9 +412,13 @@ static constexpr size_t SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_OUTPUT_SIZE = 16 * 10 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"; permission_write = true; } + server_tool_exec_shell_command() { + name = "exec_shell_command"; + displayName = "Execute shell command"; + permission_write = true; + } - json to_json() override { + json get_definition() override { return { {"type", "function"}, {"function", { @@ -418,11 +453,13 @@ struct server_tool_exec_shell_command : server_tool { auto res = run_process(args, max_output, timeout); - json out = {{"output", res.output}, {"exit_code", res.exit_code}}; + std::string text_output = res.output; + text_output += string_format("\n[exit code: %d]", res.exit_code); if (res.timed_out) { - out["timed_out"] = true; + text_output += " [exit due to timed out]"; } - return out; + + return {{"plain_text_response", text_output}}; } }; @@ -431,14 +468,18 @@ struct server_tool_exec_shell_command : server_tool { // struct server_tool_write_file : server_tool { - server_tool_write_file() { name = "write_file"; permission_write = true; } + server_tool_write_file() { + name = "write_file"; + displayName = "Write file"; + permission_write = true; + } - json to_json() override { + json get_definition() override { return { {"type", "function"}, {"function", { {"name", name}, - {"description", "Write content to a file, creating it (including parent directories) if it does not exist."}, + {"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", { @@ -478,18 +519,188 @@ struct server_tool_write_file : server_tool { }; // -// edit_file: apply a unified diff via git apply +// edit_file: edit file content via line-based changes // struct server_tool_edit_file : server_tool { - server_tool_edit_file() { name = "edit_file"; permission_write = true; } + server_tool_edit_file() { + name = "edit_file"; + displayName = "Edit file"; + permission_write = true; + } - json to_json() override { + json get_definition() override { return { {"type", "function"}, {"function", { {"name", name}, - {"description", "Apply a unified diff to edit one or more files using git apply."}, + {"description", + "Edit a file by applying a list of line-based changes. " + "Each change targets a 1-based inclusive line range and has a mode: " + "\"replace\" (replace lines with content), " + "\"delete\" (remove lines, content must be empty string), " + "\"append\" (insert content after lineEnd). " + "Set lineStart to -1 to target the end of file (lineEnd is ignored in that case). " + "Changes must not overlap. They are applied in reverse line order automatically."}, + {"parameters", { + {"type", "object"}, + {"properties", { + {"path", {{"type", "string"}, {"description", "Path to the file to edit"}}}, + {"changes", { + {"type", "array"}, + {"description", "List of changes to apply"}, + {"items", { + {"type", "object"}, + {"properties", { + {"mode", {{"type", "string"}, {"description", "\"replace\", \"delete\", or \"append\""}}}, + {"lineStart", {{"type", "integer"}, {"description", "First line of the range (1-based); use -1 for end of file"}}}, + {"lineEnd", {{"type", "integer"}, {"description", "Last line of the range (1-based, inclusive); ignored when lineStart is -1"}}}, + {"content", {{"type", "string"}, {"description", "Content to insert; must be empty string for delete mode"}}}, + }}, + {"required", json::array({"mode", "lineStart", "lineEnd", "content"})}, + }}, + }}, + }}, + {"required", json::array({"path", "changes"})}, + }}, + }}, + }; + } + + json invoke(json params) override { + std::string path = params.at("path").get(); + const json & changes = params.at("changes"); + + if (!changes.is_array()) { + return {{"error", "\"changes\" must be an array"}}; + } + + // read file into lines + std::ifstream fin(path); + if (!fin) { + return {{"error", "failed to open file: " + path}}; + } + std::vector lines; + { + std::string line; + while (std::getline(fin, line)) { + lines.push_back(line); + } + } + fin.close(); + + // validate and collect changes, then sort descending by lineStart + struct change_entry { + std::string mode; + int line_start; // 1-based + int line_end; // 1-based inclusive + std::string content; + }; + std::vector entries; + entries.reserve(changes.size()); + + for (const auto & ch : changes) { + change_entry e; + e.mode = ch.at("mode").get(); + e.line_start = ch.at("lineStart").get(); + e.line_end = ch.at("lineEnd").get(); + e.content = ch.at("content").get(); + + if (e.mode != "replace" && e.mode != "delete" && e.mode != "append") { + return {{"error", "invalid mode \"" + e.mode + "\"; must be replace, delete, or append"}}; + } + if (e.mode == "delete" && !e.content.empty()) { + return {{"error", "content must be empty string for delete mode"}}; + } + int n = (int) lines.size(); + if (e.line_start == -1) { + // -1 means end of file; lineEnd is ignored — normalize to point past last line + e.line_start = n + 1; + e.line_end = n + 1; + } else { + if (e.line_start < 1 || e.line_end < e.line_start) { + return {{"error", string_format("invalid line range [%d, %d]", e.line_start, e.line_end)}}; + } + if (e.line_end > n) { + return {{"error", string_format("lineEnd %d exceeds file length %d", e.line_end, n)}}; + } + } + entries.push_back(std::move(e)); + } + + // sort descending so earlier-indexed changes don't shift later ones + std::sort(entries.begin(), entries.end(), [](const change_entry & a, const change_entry & b) { + return a.line_start > b.line_start; + }); + + // apply changes (0-based indices internally) + for (const auto & e : entries) { + int idx_start = e.line_start - 1; // 0-based + int idx_end = e.line_end - 1; // 0-based inclusive + + // split content into lines (preserve trailing newline awareness) + std::vector new_lines; + if (!e.content.empty()) { + std::istringstream ss(e.content); + std::string ln; + while (std::getline(ss, ln)) { + new_lines.push_back(ln); + } + // if content ends with \n, getline consumed it — no extra empty line needed + // if content does NOT end with \n, last line is still captured correctly + } + + if (e.mode == "replace") { + // erase [idx_start, idx_end] and insert new_lines + lines.erase(lines.begin() + idx_start, lines.begin() + idx_end + 1); + lines.insert(lines.begin() + idx_start, new_lines.begin(), new_lines.end()); + } else if (e.mode == "delete") { + lines.erase(lines.begin() + idx_start, lines.begin() + idx_end + 1); + } else { // append + // idx_end + 1 may equal lines.size() when lineStart == -1 (end of file) + lines.insert(lines.begin() + idx_end + 1, new_lines.begin(), new_lines.end()); + } + } + + // write file back + std::ofstream fout(path, std::ios::binary); + if (!fout) { + return {{"error", "failed to open file for writing: " + path}}; + } + for (size_t i = 0; i < lines.size(); i++) { + fout << lines[i]; + if (i + 1 < lines.size()) { + fout << "\n"; + } + } + if (!lines.empty()) { + fout << "\n"; + } + if (!fout) { + return {{"error", "failed to write file: " + path}}; + } + + return {{"result", "file edited successfully"}, {"path", path}, {"lines", (int) lines.size()}}; + } +}; + +// +// apply_diff: apply a unified diff via git apply +// + +struct server_tool_apply_diff : server_tool { + server_tool_apply_diff() { + name = "apply_diff"; + displayName = "Apply diff"; + permission_write = true; + } + + json get_definition() override { + return { + {"type", "function"}, + {"function", { + {"name", name}, + {"description", "Apply a unified diff to edit one or more files using git apply. Use this instead of edit_file when the changes are complex."}, {"parameters", { {"type", "object"}, {"properties", { @@ -541,6 +752,7 @@ static std::vector> build_tools() { tools.push_back(std::make_unique()); tools.push_back(std::make_unique()); tools.push_back(std::make_unique()); + tools.push_back(std::make_unique()); return tools; } From 718bfb0777018927e9cbc3f4b8cdae6d27a4849b Mon Sep 17 00:00:00 2001 From: Xuan Son Nguyen Date: Thu, 19 Mar 2026 23:08:34 +0100 Subject: [PATCH 2/4] displayName -> display_name --- tools/server/README-dev.md | 2 +- tools/server/server-tools.cpp | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tools/server/README-dev.md b/tools/server/README-dev.md index 326cb357b4..f9fae5a6cc 100644 --- a/tools/server/README-dev.md +++ b/tools/server/README-dev.md @@ -103,7 +103,7 @@ This endpoint is intended to be used internally by the Web UI and subject to cha Get a list of tools, each tool has these fields: - `tool` (string): the ID name of the tool, to be used in POST call. Example: `read_file` -- `displayName` (string): the name to be displayed on UI. Example: `Read file` +- `display_name` (string): the name to be displayed on UI. Example: `Read file` - `type` (string): always be `"builtin"` for now - `permissions` (object): a mapping string --> boolean that indicates the permission required by this tool. This is useful for the UI to ask the user before calling the tool. For now, the only permission supported is `"write"` - `definition` (object): the OAI-compat definition of this tool diff --git a/tools/server/server-tools.cpp b/tools/server/server-tools.cpp index d5c7921efd..7e371bfb81 100644 --- a/tools/server/server-tools.cpp +++ b/tools/server/server-tools.cpp @@ -139,7 +139,7 @@ static bool glob_match(const std::string & pattern, const std::string & str) { struct server_tool { std::string name; - std::string displayName; + std::string display_name; json definition; bool permission_write = false; @@ -149,7 +149,7 @@ struct server_tool { json to_json() { return { - {"displayName", displayName}, + {"display_name", display_name}, {"tool", name}, {"type", "builtin"}, {"permissions", json{ @@ -169,7 +169,7 @@ 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"; - displayName = "Read file"; + display_name = "Read file"; permission_write = false; } @@ -252,7 +252,7 @@ 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"; - displayName = "File search"; + display_name = "File search"; permission_write = false; } @@ -316,7 +316,7 @@ 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"; - displayName = "Grep search"; + display_name = "Grep search"; permission_write = false; } @@ -414,7 +414,7 @@ static constexpr int SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_TIMEOUT = 60; struct server_tool_exec_shell_command : server_tool { server_tool_exec_shell_command() { name = "exec_shell_command"; - displayName = "Execute shell command"; + display_name = "Execute shell command"; permission_write = true; } @@ -470,7 +470,7 @@ struct server_tool_exec_shell_command : server_tool { struct server_tool_write_file : server_tool { server_tool_write_file() { name = "write_file"; - displayName = "Write file"; + display_name = "Write file"; permission_write = true; } @@ -525,7 +525,7 @@ struct server_tool_write_file : server_tool { struct server_tool_edit_file : server_tool { server_tool_edit_file() { name = "edit_file"; - displayName = "Edit file"; + display_name = "Edit file"; permission_write = true; } @@ -691,7 +691,7 @@ struct server_tool_edit_file : server_tool { struct server_tool_apply_diff : server_tool { server_tool_apply_diff() { name = "apply_diff"; - displayName = "Apply diff"; + display_name = "Apply diff"; permission_write = true; } From 6aba54e7d710f228e8c652c1eb548c54eb1450c5 Mon Sep 17 00:00:00 2001 From: Xuan Son Nguyen Date: Thu, 19 Mar 2026 23:16:10 +0100 Subject: [PATCH 3/4] snake_case everywhere --- tools/server/server-tools.cpp | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tools/server/server-tools.cpp b/tools/server/server-tools.cpp index 7e371bfb81..7f7d7a6d56 100644 --- a/tools/server/server-tools.cpp +++ b/tools/server/server-tools.cpp @@ -539,8 +539,8 @@ struct server_tool_edit_file : server_tool { "Each change targets a 1-based inclusive line range and has a mode: " "\"replace\" (replace lines with content), " "\"delete\" (remove lines, content must be empty string), " - "\"append\" (insert content after lineEnd). " - "Set lineStart to -1 to target the end of file (lineEnd is ignored in that case). " + "\"append\" (insert content after line_end). " + "Set line_start to -1 to target the end of file (line_end is ignored in that case). " "Changes must not overlap. They are applied in reverse line order automatically."}, {"parameters", { {"type", "object"}, @@ -552,12 +552,12 @@ struct server_tool_edit_file : server_tool { {"items", { {"type", "object"}, {"properties", { - {"mode", {{"type", "string"}, {"description", "\"replace\", \"delete\", or \"append\""}}}, - {"lineStart", {{"type", "integer"}, {"description", "First line of the range (1-based); use -1 for end of file"}}}, - {"lineEnd", {{"type", "integer"}, {"description", "Last line of the range (1-based, inclusive); ignored when lineStart is -1"}}}, - {"content", {{"type", "string"}, {"description", "Content to insert; must be empty string for delete mode"}}}, + {"mode", {{"type", "string"}, {"description", "\"replace\", \"delete\", or \"append\""}}}, + {"line_start", {{"type", "integer"}, {"description", "First line of the range (1-based); use -1 for end of file"}}}, + {"line_end", {{"type", "integer"}, {"description", "Last line of the range (1-based, inclusive); ignored when line_start is -1"}}}, + {"content", {{"type", "string"}, {"description", "Content to insert; must be empty string for delete mode"}}}, }}, - {"required", json::array({"mode", "lineStart", "lineEnd", "content"})}, + {"required", json::array({"mode", "line_start", "line_end", "content"})}, }}, }}, }}, @@ -589,7 +589,7 @@ struct server_tool_edit_file : server_tool { } fin.close(); - // validate and collect changes, then sort descending by lineStart + // validate and collect changes, then sort descending by line_start struct change_entry { std::string mode; int line_start; // 1-based @@ -602,8 +602,8 @@ struct server_tool_edit_file : server_tool { for (const auto & ch : changes) { change_entry e; e.mode = ch.at("mode").get(); - e.line_start = ch.at("lineStart").get(); - e.line_end = ch.at("lineEnd").get(); + e.line_start = ch.at("line_start").get(); + e.line_end = ch.at("line_end").get(); e.content = ch.at("content").get(); if (e.mode != "replace" && e.mode != "delete" && e.mode != "append") { @@ -614,7 +614,7 @@ struct server_tool_edit_file : server_tool { } int n = (int) lines.size(); if (e.line_start == -1) { - // -1 means end of file; lineEnd is ignored — normalize to point past last line + // -1 means end of file; line_end is ignored — normalize to point past last line e.line_start = n + 1; e.line_end = n + 1; } else { @@ -622,7 +622,7 @@ struct server_tool_edit_file : server_tool { return {{"error", string_format("invalid line range [%d, %d]", e.line_start, e.line_end)}}; } if (e.line_end > n) { - return {{"error", string_format("lineEnd %d exceeds file length %d", e.line_end, n)}}; + return {{"error", string_format("line_end %d exceeds file length %d", e.line_end, n)}}; } } entries.push_back(std::move(e)); @@ -657,7 +657,7 @@ struct server_tool_edit_file : server_tool { } else if (e.mode == "delete") { lines.erase(lines.begin() + idx_start, lines.begin() + idx_end + 1); } else { // append - // idx_end + 1 may equal lines.size() when lineStart == -1 (end of file) + // idx_end + 1 may equal lines.size() when line_start == -1 (end of file) lines.insert(lines.begin() + idx_end + 1, new_lines.begin(), new_lines.end()); } } From c33fd6f10c399bb5da7fc7cc1842bcb09a68166f Mon Sep 17 00:00:00 2001 From: Xuan Son Nguyen Date: Thu, 19 Mar 2026 23:25:22 +0100 Subject: [PATCH 4/4] rm redundant field --- tools/server/server-tools.cpp | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tools/server/server-tools.cpp b/tools/server/server-tools.cpp index 7f7d7a6d56..d7c116abd8 100644 --- a/tools/server/server-tools.cpp +++ b/tools/server/server-tools.cpp @@ -140,8 +140,7 @@ static bool glob_match(const std::string & pattern, const std::string & str) { struct server_tool { std::string name; std::string display_name; - json definition; - bool permission_write = false; + bool permission_write = false; virtual ~server_tool() = default; virtual json get_definition() = 0; @@ -592,8 +591,8 @@ struct server_tool_edit_file : server_tool { // validate and collect changes, then sort descending by line_start struct change_entry { std::string mode; - int line_start; // 1-based - int line_end; // 1-based inclusive + int line_start; // 1-based + int line_end; // 1-based inclusive std::string content; }; std::vector entries;