From 49ae3c84fda365b50bc6389c40ce53b2bc783258 Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 24 Mar 2025 15:07:14 +0800 Subject: [PATCH] feat: add view_range parameter and remove read_file llm tool (#1690) --- lua/avante/llm_tools/dispatch_agent.lua | 6 +- lua/avante/llm_tools/init.lua | 22 ++-- lua/avante/llm_tools/read_file.lua | 68 ----------- lua/avante/llm_tools/view.lua | 108 ++++++++++++++++++ lua/avante/providers/claude.lua | 15 +-- lua/avante/providers/openai.lua | 10 +- .../templates/_tools-guidelines.avanterules | 8 +- lua/avante/types.lua | 3 +- lua/avante/utils/init.lua | 26 +++++ tests/llm_tools_spec.lua | 26 +++-- 10 files changed, 179 insertions(+), 113 deletions(-) delete mode 100644 lua/avante/llm_tools/read_file.lua create mode 100644 lua/avante/llm_tools/view.lua diff --git a/lua/avante/llm_tools/dispatch_agent.lua b/lua/avante/llm_tools/dispatch_agent.lua index a7e68a4..e769ba0 100644 --- a/lua/avante/llm_tools/dispatch_agent.lua +++ b/lua/avante/llm_tools/dispatch_agent.lua @@ -9,10 +9,10 @@ local M = setmetatable({}, Base) M.name = "dispatch_agent" M.description = - [[Launch a new agent that has access to the following tools: `glob`, `grep`, `ls`, `read_file`. When you are searching for a keyword or file and are not confident that you will find the right match on the first try, use the Agent tool to perform the search for you. For example: + [[Launch a new agent that has access to the following tools: `glob`, `grep`, `ls`, `view`. When you are searching for a keyword or file and are not confident that you will find the right match on the first try, use the Agent tool to perform the search for you. For example: - If you are searching for a keyword like "config" or "logger", the Agent tool is appropriate -- If you want to read a specific file path, use the `read_file` or `glob` tool instead of the `dispatch_agent` tool, to find the match more quickly +- If you want to read a specific file path, use the `view` or `glob` tool instead of the `dispatch_agent` tool, to find the match more quickly - If you are searching for a specific class definition like "class Foo", use the `glob` tool instead, to find the match more quickly Usage notes: @@ -54,7 +54,7 @@ local function get_available_tools() require("avante.llm_tools.ls"), require("avante.llm_tools.grep"), require("avante.llm_tools.glob"), - require("avante.llm_tools.read_file"), + require("avante.llm_tools.view"), } end diff --git a/lua/avante/llm_tools/init.lua b/lua/avante/llm_tools/init.lua index e510387..80ae2e0 100644 --- a/lua/avante/llm_tools/init.lua +++ b/lua/avante/llm_tools/init.lua @@ -25,7 +25,7 @@ function M.read_file_toplevel_symbols(opts, on_log) return definitions, nil end ----@type AvanteLLMToolFunc<{ command: "view" | "str_replace" | "create" | "insert" | "undo_edit", path: string, old_str?: string, new_str?: string, file_text?: string, insert_line?: integer, new_str?: string }> +---@type AvanteLLMToolFunc<{ command: "view" | "str_replace" | "create" | "insert" | "undo_edit", path: string, old_str?: string, new_str?: string, file_text?: string, insert_line?: integer, new_str?: string, view_range?: integer[] }> function M.str_replace_editor(opts, on_log, on_complete) if on_log then on_log("command: " .. opts.command) end if on_log then on_log("path: " .. vim.inspect(opts.path)) end @@ -42,14 +42,16 @@ function M.str_replace_editor(opts, on_log, on_complete) return bufnr end if opts.command == "view" then - if not Path:new(abs_path):exists() then return false, "File not found: " .. abs_path end - if not Path:new(abs_path):is_file() then return false, "Path is not a file: " .. abs_path end - local file = io.open(abs_path, "r") - if not file then return false, "file not found: " .. abs_path end - local lines = Utils.read_file_from_buf_or_disk(abs_path) - local content = lines and table.concat(lines, "\n") or "" - on_complete(content, nil) - return + local view = require("avante.llm_tools.view") + local opts_ = { path = opts.path } + if opts.view_range then + local start_line, end_line = unpack(opts.view_range) + opts_.view_range = { + start_line = start_line, + end_line = end_line, + } + end + return view(opts_, on_log, on_complete) end if opts.command == "str_replace" then if not Path:new(abs_path):exists() then return false, "File not found: " .. abs_path end @@ -910,7 +912,7 @@ M._tools = { }, }, }, - require("avante.llm_tools.read_file"), + require("avante.llm_tools.view"), { name = "read_global_file", description = "Read the contents of a file in the global scope. If the file content is already in the context, do not use this tool.", diff --git a/lua/avante/llm_tools/read_file.lua b/lua/avante/llm_tools/read_file.lua deleted file mode 100644 index 79c5d08..0000000 --- a/lua/avante/llm_tools/read_file.lua +++ /dev/null @@ -1,68 +0,0 @@ -local Utils = require("avante.utils") -local Base = require("avante.llm_tools.base") -local Helpers = require("avante.llm_tools.helpers") - ----@class AvanteLLMTool -local M = setmetatable({}, Base) - -M.name = "read_file" - -M.description = - "Read the contents of a file in current project scope. If the file content is already in the context, do not use this tool." - -M.enabled = function(opts) - if opts.user_input:match("@read_global_file") then return false end - for _, message in ipairs(opts.history_messages) do - if message.role == "user" then - local content = message.content - if type(content) == "string" and content:match("@read_global_file") then return false end - if type(content) == "table" then - for _, item in ipairs(content) do - if type(item) == "string" and item:match("@read_global_file") then return false end - end - end - end - end - return true -end - ----@type AvanteLLMToolParam -M.param = { - type = "table", - fields = { - { - name = "rel_path", - description = "Relative path to the file in current project scope", - type = "string", - }, - }, -} - ----@type AvanteLLMToolReturn[] -M.returns = { - { - name = "content", - description = "Contents of the file", - type = "string", - }, - { - name = "error", - description = "Error message if the file was not read successfully", - type = "string", - optional = true, - }, -} - ----@type AvanteLLMToolFunc<{ rel_path: string }> -function M.func(opts, on_log) - local abs_path = Helpers.get_abs_path(opts.rel_path) - if not Helpers.has_permission_to_access(abs_path) then return "", "No permission to access path: " .. abs_path end - if on_log then on_log("path: " .. abs_path) end - local file = io.open(abs_path, "r") - if not file then return "", "file not found: " .. abs_path end - local lines = Utils.read_file_from_buf_or_disk(abs_path) - local content = lines and table.concat(lines, "\n") or "" - return content, nil -end - -return M diff --git a/lua/avante/llm_tools/view.lua b/lua/avante/llm_tools/view.lua new file mode 100644 index 0000000..25ed697 --- /dev/null +++ b/lua/avante/llm_tools/view.lua @@ -0,0 +1,108 @@ +local Path = require("plenary.path") +local Utils = require("avante.utils") +local Base = require("avante.llm_tools.base") +local Helpers = require("avante.llm_tools.helpers") + +---@class AvanteLLMTool +local M = setmetatable({}, Base) + +M.name = "view" + +M.description = + "The view command allows you to examine the contents of a file or list the contents of a directory. It can read the entire file or a specific range of lines. If the file content is already in the context, do not use this tool." + +M.enabled = function(opts) + if opts.user_input:match("@read_global_file") then return false end + for _, message in ipairs(opts.history_messages) do + if message.role == "user" then + local content = message.content + if type(content) == "string" and content:match("@read_global_file") then return false end + if type(content) == "table" then + for _, item in ipairs(content) do + if type(item) == "string" and item:match("@read_global_file") then return false end + end + end + end + end + return true +end + +---@type AvanteLLMToolParam +M.param = { + type = "table", + fields = { + { + name = "path", + description = "The path to the file in the current project scope", + type = "string", + }, + { + name = "view_range", + description = "The range of the file to view. This parameter only applies when viewing files, not directories.", + type = "object", + optional = true, + fields = { + { + name = "start_line", + description = "The start line of the range, 1-indexed", + type = "integer", + }, + { + name = "end_line", + description = "The end line of the range, 1-indexed, and -1 for the end line means read to the end of the file", + type = "integer", + }, + }, + }, + }, +} + +---@type AvanteLLMToolReturn[] +M.returns = { + { + name = "content", + description = "Contents of the file", + type = "string", + }, + { + name = "error", + description = "Error message if the file was not read successfully", + type = "string", + optional = true, + }, +} + +---@type AvanteLLMToolFunc<{ path: string, view_range?: { start_line: integer, end_line: integer } }> +function M.func(opts, on_log, on_complete) + if not on_complete then return false, "on_complete not provided" end + local abs_path = Helpers.get_abs_path(opts.path) + if not Helpers.has_permission_to_access(abs_path) then return false, "No permission to access path: " .. abs_path end + if on_log then on_log("path: " .. abs_path) end + if not Path:new(abs_path):exists() then return false, "Path not found: " .. abs_path end + if Path:new(abs_path):is_dir() then + local files = vim.fn.glob(abs_path .. "/*", false, true) + if #files == 0 then return false, "Directory is empty: " .. abs_path end + local result = {} + for _, file in ipairs(files) do + if not Path:new(file):is_file() then goto continue end + local lines = Utils.read_file_from_buf_or_disk(file) + local content = lines and table.concat(lines, "\n") or "" + table.insert(result, { path = file, content = content }) + ::continue:: + end + on_complete(vim.json.encode(result), nil) + return + end + local file = io.open(abs_path, "r") + if not file then return false, "file not found: " .. abs_path end + local lines = Utils.read_file_from_buf_or_disk(abs_path) + if opts.view_range then + local start_line = opts.view_range.start_line + local end_line = opts.view_range.end_line + if start_line and end_line and lines then lines = vim.list_slice(lines, start_line, end_line) end + end + local content = lines and table.concat(lines, "\n") or "" + on_complete(content, nil) +end + +return M diff --git a/lua/avante/providers/claude.lua b/lua/avante/providers/claude.lua index d25db42..d591aca 100644 --- a/lua/avante/providers/claude.lua +++ b/lua/avante/providers/claude.lua @@ -34,15 +34,7 @@ end ---@param tool AvanteLLMTool ---@return AvanteClaudeTool function M:transform_tool(tool) - local input_schema_properties = {} - local required = {} - for _, field in ipairs(tool.param.fields) do - input_schema_properties[field.name] = { - type = field.type, - description = field.description, - } - if not field.optional then table.insert(required, field.name) end - end + local input_schema_properties, required = Utils.llm_tool_param_fields_to_json_schema(tool.param.fields) return { name = tool.name, description = tool.description, @@ -340,7 +332,10 @@ function M:parse_curl_args(prompt_opts) local tools = {} if not disable_tools and prompt_opts.tools then for _, tool in ipairs(prompt_opts.tools) do - if tool.name == "create_file" then goto continue end + if Config.behaviour.enable_claude_text_editor_tool_mode then + if tool.name == "create_file" then goto continue end + if tool.name == "view" then goto continue end + end table.insert(tools, self:transform_tool(tool)) ::continue:: end diff --git a/lua/avante/providers/openai.lua b/lua/avante/providers/openai.lua index b0d40df..a66b641 100644 --- a/lua/avante/providers/openai.lua +++ b/lua/avante/providers/openai.lua @@ -18,15 +18,7 @@ function M:is_disable_stream() return false end ---@param tool AvanteLLMTool ---@return AvanteOpenAITool function M:transform_tool(tool) - local input_schema_properties = {} - local required = {} - for _, field in ipairs(tool.param.fields) do - input_schema_properties[field.name] = { - type = field.type, - description = field.description, - } - if not field.optional then table.insert(required, field.name) end - end + local input_schema_properties, required = Utils.llm_tool_param_fields_to_json_schema(tool.param.fields) ---@type AvanteOpenAIToolFunctionParameters local parameters = nil if not vim.tbl_isempty(input_schema_properties) then diff --git a/lua/avante/templates/_tools-guidelines.avanterules b/lua/avante/templates/_tools-guidelines.avanterules index 0a28641..d5a7f88 100644 --- a/lua/avante/templates/_tools-guidelines.avanterules +++ b/lua/avante/templates/_tools-guidelines.avanterules @@ -4,15 +4,15 @@ Tools Usage Guide: - You have access to tools, but only use them when necessary. If a tool is not required, respond as normal. - Please DON'T be so aggressive in using tools, as many tasks can be better completed without tools. - Files will be provided to you as context through tag! - - Before using the `read_file` tool each time, always repeatedly check whether the file is already in the tag. If it is already there, do not use the `read_file` tool, just read the file content directly from the tag. - - If you use the `read_file` tool when file content is already provided in the tag, you will be fired! + - Before using the `view` tool each time, always repeatedly check whether the file is already in the tag. If it is already there, do not use the `view` tool, just read the file content directly from the tag. + - If you use the `view` tool when file content is already provided in the tag, you will be fired! - If the `rag_search` tool exists, prioritize using it to do the search! - - If the `rag_search` tool exists, only use tools like `search_keyword` `search_files` `read_file` `list_files` etc when absolutely necessary! + - If the `rag_search` tool exists, only use tools like `search_keyword` `search_files` `view` `list_files` etc when absolutely necessary! - Keep the `query` parameter of `rag_search` tool as concise as possible! Try to keep it within five English words! - If you encounter a URL, prioritize using the `fetch` tool to obtain its content. - If you have information that you don't know, please proactively use the tools provided by users! Especially the `web_search` tool. - When available tools cannot meet the requirements, please try to use the `run_command` tool to solve the problem whenever possible. - - When attempting to modify a file that is not in the context, please first use the `list_files` tool and `search_files` tool to check if the file you want to modify exists, then use the `read_file` tool to read the file content. Don't modify blindly! + - When attempting to modify a file that is not in the context, please first use the `list_files` tool and `search_files` tool to check if the file you want to modify exists, then use the `view` tool to read the file content. Don't modify blindly! - When generating files, first use `list_files` tool to read the directory structure, don't generate blindly! - When creating files, first check if the directory exists. If it doesn't exist, create the directory before creating the file. - After `web_search` tool returns, if you don't get detailed enough information, do not continue use `web_search` tool, just continue using the `fetch` tool to get more information you need from the links in the search results. diff --git a/lua/avante/types.lua b/lua/avante/types.lua index 671f0b0..0cec64b 100644 --- a/lua/avante/types.lua +++ b/lua/avante/types.lua @@ -361,7 +361,8 @@ vim.g.avante_login = vim.g.avante_login ---@class AvanteLLMToolParamField ---@field name string ---@field description string ----@field type 'string' | 'integer' | 'boolean' +---@field type 'string' | 'integer' | 'boolean' | 'object' +---@field fields? AvanteLLMToolParamField[] ---@field optional? boolean ---@class AvanteLLMToolReturn diff --git a/lua/avante/utils/init.lua b/lua/avante/utils/init.lua index 4f0ded6..b7119e0 100644 --- a/lua/avante/utils/init.lua +++ b/lua/avante/utils/init.lua @@ -1193,4 +1193,30 @@ function M.should_hidden_border(win_a, win_b) return M.is_left_adjacent(win_a, win_b) or M.is_top_adjacent(win_a, win_b) end +---@param fields AvanteLLMToolParamField[] +---@return table[] properties +---@return string[] required +function M.llm_tool_param_fields_to_json_schema(fields) + local properties = {} + local required = {} + for _, field in ipairs(fields) do + if field.type == "object" and field.fields then + local properties_, required_ = M.llm_tool_param_fields_to_json_schema(field.fields) + properties[field.name] = { + type = field.type, + description = field.description, + properties = properties_, + required = required_, + } + else + properties[field.name] = { + type = field.type, + description = field.description, + } + end + if not field.optional then table.insert(required, field.name) end + end + return properties, required +end + return M diff --git a/tests/llm_tools_spec.lua b/tests/llm_tools_spec.lua index 194842e..c53cb1c 100644 --- a/tests/llm_tools_spec.lua +++ b/tests/llm_tools_spec.lua @@ -6,7 +6,7 @@ local Utils = require("avante.utils") local ls = require("avante.llm_tools.ls") local grep = require("avante.llm_tools.grep") local glob = require("avante.llm_tools.glob") -local read_file = require("avante.llm_tools.read_file") +local view = require("avante.llm_tools.view") local bash = require("avante.llm_tools.bash") LlmToolHelpers.confirm = function(msg, cb) return cb(true) end @@ -75,17 +75,27 @@ describe("llm_tools", function() end) end) - describe("read_file", function() + describe("view", function() it("should read file content", function() - local content, err = read_file({ rel_path = "test.txt" }) - assert.is_nil(err) - assert.equals("test content", content) + view({ path = "test.txt" }, nil, function(content, err) + assert.is_nil(err) + assert.equals("test content", content) + end) end) it("should return error for non-existent file", function() - local content, err = read_file({ rel_path = "non_existent.txt" }) - assert.truthy(err) - assert.equals("", content) + view({ path = "non_existent.txt" }, nil, function(content, err) + assert.truthy(err) + assert.equals("", content) + end) + end) + + it("should read directory content", function() + view({ path = test_dir }, nil, function(content, err) + assert.is_nil(err) + assert.truthy(content:find("test.txt")) + assert.truthy(content:find("test content")) + end) end) end)