defmodule AI.Tools.File.Search do @max_search_results 25 @behaviour AI.Tools @impl AI.Tools def async?, do: true @doc """ This tool requires that the project has been indexed to use. If the project has not been indexed, the tool should not be made available. """ @impl AI.Tools def is_available?(), do: AI.Tools.has_indexed_project() @impl AI.Tools def ui_note_on_request(args), do: {"Semantic search", args["query"]} @impl AI.Tools def ui_note_on_result(%{"query" => query}, result) do re = ~r/Semantic search found (\d+) matching files in (\d+) ms:/ case Regex.run(re, result) do [_, count_str, took_ms] -> count = String.to_integer(count_str) {"Semantic search", "#{query} -> #{count} file matches in #{took_ms} ms"} _ -> {"Semantic search", "#{query} -> #{result}"} end end @impl AI.Tools def tool_call_failure_message(_args, _reason), do: :default @impl AI.Tools def read_args(%{"query" => query}), do: {:ok, %{"query" => query}} @impl AI.Tools def spec() do %{ type: "function", name: "file_search_tool", description: """ Semantic search over project files. Your query is converted to an embedding and compared against pre-computed file embeddings using cosine similarity. This means your query must be **natural language** describing the concept or topic you're looking for. Your query is embedded as-is. Regex, syntax fragments, function signatures, and boolean operators will NOT work - they will be interpreted as literal text about those topics, not as patterns. For exact string or pattern matching, use `rg` via `cmd_tool`. Good queries: "user authentication", "database connection settings", "error handling for HTTP requests", "modules that parse JSON" Bad queries: "def.*auth", "conn |> put_status", "TODO|FIXME" Note that this tool does not have access to historical data or commit messages. It only searches the most recently indexed version of the project. """, parameters: %{ additionalProperties: false, type: "object", required: ["query"], properties: %{ query: %{ type: "string", description: """ A natural language query describing what you're looking for. Write it as you would describe the topic to a person, not as a code pattern or regex. Examples: - "user authentication" - "feature flag definitions" - "settings related to database connections" - "functions that call the payment API" - "unit tests related to the parser" - "modules that read or write json data" """ } } } } end @impl AI.Tools def call(args) do with {:ok, query} <- Map.fetch(args, "query"), start_time = DateTime.utc_now(), {:ok, matches} <- search(query), took_ms = DateTime.diff(DateTime.utc_now(), start_time, :millisecond), {:ok, index_state_msg} <- index_state() do {:ok, build_response(matches, took_ms, index_state_msg)} end end # Build the complete tool response by formatting matches and the index state banner. defp build_response(matches, took_ms, index_state_msg) do header = "[search_tool]" body = matches |> Enum.map(&format_entry/1) |> Enum.join("\n-----\n") |> then(fn body -> """ Semantic search found #{length(matches)} matching files in #{took_ms} ms: ----- #{body} """ end) [header, body, "-----", index_state_msg] |> Enum.join("\n") end # Format a single search result entry as markdown with file, similarity, and summary. defp format_entry({file, score, data}) do """ # `#{file}` (cosine similarity: #{score}) #{data["summary"]} """ end # ----------------------------------------------------------------------------- # Returns the current state of the project index, including new, stale, and # deleted files. # ----------------------------------------------------------------------------- defp index_state do with {:ok, project} <- Store.get_project() do %{new: new, stale: stale, deleted: deleted} = Store.Project.index_status(project) msg = """ The results of this search may be affected by the state of the project index. The current index state is: - New files (not yet indexed): #{length(new)} - Stale files (outdated index): #{length(stale)} - Deleted files (indexed but deleted in project): #{length(deleted)} If you are seeing unexpected search results, try reindexing with the `file_reindex_tool` tool. """ {:ok, msg} end end # ----------------------------------------------------------------------------- # Searches the database for matches to the search query. Returns a list of # `{file, score, data}` tuples. # ----------------------------------------------------------------------------- defp search(query) do %{ detail: true, limit: @max_search_results, query: query } |> Search.Files.new() |> Search.Files.get_results() |> case do {:ok, results} -> results |> Enum.map(fn {entry, score, data} -> {entry.rel_path, score, data} end) |> then(&{:ok, &1}) {:error, reason} -> {:error, reason} end end end