llm-tools.el (16307B)
1 ;; llm-tools.el --- Opinionated tools for use by an LLM -*- lexical-binding: t; -*- 2 3 ;; Copyright (C) 2026 Vineet K 4 5 ;; Author: Vineet K <git@vineetk.net> 6 ;; Version: 1.0 7 ;; Keywords: llm, gptel 8 ;; Package-Requires: ((emacs "28.1") (gptel "0.9.6") (magit "3.3.0")) 9 10 ;; This program is free software; you can redistribute it and/or modify 11 ;; it under the terms of the GNU General Public License as published by 12 ;; the Free Software Foundation, either version 3 of the License, or 13 ;; (at your option) any later version. 14 15 ;; This program is distributed in the hope that it will be useful, 16 ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 17 ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 ;; GNU General Public License for more details. 19 20 ;; You should have received a copy of the GNU General Public License 21 ;; along with this program. If not, see <https://www.gnu.org/licenses/>. 22 23 ;;; Commentary: 24 25 ;; This package provides a set of opinionated tools for use by an LLM via GPTel. 26 ;; It includes tools for file operations, web search, and bash execution. 27 28 ;;; Code: 29 30 (require 'cl-lib) 31 (require 'magit) 32 (require 'gptel) 33 34 (defgroup llm-tools nil 35 "Opinionated tools for use by an LLM." 36 :group 'convenience) 37 38 (require 'llm-tools-edit) 39 (require 'llm-tools-web) 40 41 ;; read_file 42 (gptel-make-tool 43 :name "read_file" 44 :function (lambda (file &optional beg end as-list? tail) 45 (llm-tools--edit-file-read file beg end as-list? tail)) 46 :description "Read file contents, including build logs. Use instead of cat, head, tail, or sed. 47 Call this before editing to obtain the current text. 48 Optional beg and end select an inclusive, 1-based line range. 49 Use tail to read the last N lines without knowing the file length. 50 Set as_list to return a list of lines instead of a newline-delimited string." 51 :args '((:name "file" 52 :type string 53 :description "File path, absolute or relative to the working directory.") 54 (:name "beg" 55 :type integer 56 :description "Optional first line to read (1-based, inclusive)." 57 :optional t) 58 (:name "end" 59 :type integer 60 :description "Optional last line to read (1-based, inclusive)." 61 :optional t) 62 (:name "as_list" 63 :type boolean 64 :description "Return a list of lines instead of a string." 65 :optional t) 66 (:name "tail" 67 :type integer 68 :description "Read the last N lines (positive integer); omit beg and end." 69 :optional t)) 70 :async nil 71 :confirm nil 72 :include t) 73 74 ;; write_file (write-region) 75 (gptel-make-tool 76 :name "write_file" 77 :function (lambda (file contents) 78 (write-region contents nil file)) 79 :description "\ 80 Overwrite an entire file with new contents. This is a destructive 81 operation that replaces all existing content. Use this tool for: 82 83 - Creating new files from scratch 84 - Completely replacing small files where the new content is the 85 intended final state 86 - Writing generated output, configuration files, or test fixtures 87 88 Do NOT use this for partial edits, appending, or modifying existing 89 files. Use `edit_file` instead for surgical changes to existing 90 content. Always call `read_file` first if the file already exists to 91 preserve content you intend to keep. This tool requires user 92 confirmation before executing." 93 :args '((:name "file" 94 :type string 95 :description "Path to the file to write to, relative to the working directory.") 96 (:name "contents" 97 :type string 98 :description "\ 99 Full file contents to write. This will replace all existing 100 content. Include newlines and indentation as they should appear in the 101 final file.")) 102 :async nil 103 :confirm t 104 :include t) 105 106 ;; edit_file 107 (gptel-make-tool 108 :name "edit_file" 109 :function #'llm-tools--edit 110 :description "Edit an existing file by replacing literal text. Read the file first. 111 Matching is case-sensitive and preserves whitespace, with CRLF normalized to LF. 112 By default, old_string must occur exactly once. Include surrounding text in both 113 old_string and new_string to uniquely identify a location while preserving context. 114 Set replace_all to true only when every occurrence should change. 115 Use an empty new_string to delete the matched text. To insert text, replace 116 existing context with that context plus the new text. Use write_file to create files. 117 Returns a success message or an error explaining why the edit could not be applied." 118 :args '((:name "file_path" 119 :type string 120 :description "File path, absolute or relative to the working directory.") 121 (:name "old_string" 122 :type string 123 :description "Non-empty literal text to replace, including exact whitespace and indentation.") 124 (:name "new_string" 125 :type string 126 :description "Literal replacement text, different from old_string. Use an empty string to delete.") 127 (:name "replace_all" 128 :type boolean 129 :description "Replace all non-overlapping matches. Defaults to false, requiring a unique match." 130 :optional t)) 131 :async nil 132 :confirm t 133 :include t) 134 135 ;; bash (make-process, taken from gptel-agent) 136 (defun llm-tools--execute-bash (callback command) 137 "Execute COMMAND asynchronously in bash and call CALLBACK with output. 138 139 CALLBACK is called with the command output string when the process finishes. 140 COMMAND is the bash command string to execute." 141 (let* ((output-buffer (generate-new-buffer " *llm-tools-bash*")) 142 (proc (make-process 143 :name "llm-tools-bash" 144 :buffer output-buffer 145 :command (list "bash" "-c" command) 146 :connection-type 'pipe 147 :sentinel 148 (lambda (process _event) 149 (when (memq (process-status process) '(exit signal)) 150 (let* ((exit-code (process-exit-status process)) 151 (output (with-current-buffer (process-buffer process) 152 (buffer-string)))) 153 (kill-buffer (process-buffer process)) 154 (funcall callback 155 (if (zerop exit-code) 156 output 157 (format "Command failed with exit code %d:\nSTDOUT+STDERR:\n%s" 158 exit-code output))))))))) 159 proc)) 160 161 (defun llm-tools--bash-confirm-p (command) 162 (let ((destructive 163 '("\\brm\\b" 164 "\\bdd\\b" 165 "\\bshred\\b" 166 "\\btruncate\\b" 167 "\\bmkfs\\b" 168 "\\bsudo\\b" 169 "\\bdoas\\b" 170 "[^>]>[^>&0-9]" ; any singular > that isn't >> nor file-descriptor redirect 171 "\\$(" 172 "`" 173 "|[ \t]*\\bsh\\b" 174 "|[ \t]*\\bbash\\b"))) 175 (let ((command 176 (replace-regexp-in-string 177 ">[ \t]*/dev/null\\([ \t\n;&|]\\|\\'\\)" 178 "\\1" command t))) 179 (cl-some (lambda (regexp) 180 (string-match-p regexp command)) 181 destructive)))) 182 183 (gptel-make-tool 184 :name "bash" 185 :function #'llm-tools--execute-bash 186 :description "Run builds, tests, programs, and inspect system state. 187 188 This tool provides access to a Bash shell with GNU coreutils (or 189 equivalents) available. Use this to inspect system state, run builds, 190 tests or other development or system administration tasks. 191 192 Use the dedicated tools for file operations, searching, and code 193 navigation instead of shell commands. 194 195 - For file contents: use `read_file` or `web_fetch` 196 - For file discovery/metadata: use `find_files` 197 - For searching text in files: use `grep` 198 - For editing files: use `edit_file` or `write_file` 199 200 Bash should be a last resort, used only when no other tool fits the task. 201 202 - Quote file paths with spaces using double quotes. 203 - Chain dependent commands with && (or ; if failures are OK) 204 - Use absolute paths instead of cd when possible 205 - For parallel commands, make multiple `bash` calls in one message 206 - Run tests, check your work or otherwise close the loop to verify changes you make. 207 208 EXAMPLES: 209 - Check file type: 'file document.pdf' 210 - Run tests: 'make test' 211 212 The command will be executed in the current working directory. Output is 213 returned as a string. Long outputs should be filtered/limited using pipes." 214 :args '((:name "command" 215 :type string 216 :description "The Bash command to execute.\ 217 Can include pipes and standard shell operators. 218 Use read_file for reading files and grep for searching, including logs. 219 Example: 'make test'")) 220 :confirm #'llm-tools--bash-confirm-p 221 :include t 222 :async t) 223 224 ;; find_files (directory-files-recursively) 225 (gptel-make-tool 226 :name "find_files" 227 :function (lambda (dir &optional regexp) 228 (let ((git-dir (concat (file-name-as-directory dir) ".git"))) 229 (delq nil (mapcar (lambda (f) 230 (unless (string-prefix-p git-dir f) f)) 231 (directory-files-recursively dir (or regexp "")))))) 232 :description "\ 233 Recursively find all files in a given directory, excluding files within 234 .git directories. Returns a list of absolute file paths. Optionally 235 accepts a pattern to filter file names (e.g., '*.el' for Emacs Lisp 236 files). Use this to discover files in a project directory before reading 237 or editing them." 238 :args '((:name "directory" 239 :type string 240 :description "Path to the directory to be read, relative to the working directory.") 241 (:name "regexp" 242 :type string 243 :description "Optional regular expression pattern to search for, uses extended regex syntax." 244 :optional t)) 245 :async nil 246 :confirm nil 247 :include t) 248 249 ;; grep (just plain grep) :D) 250 (defun llm-tools--grep (regexp &optional glob path ignore-case max-lines context exclude) 251 "Search REGEXP in PATH, optionally restricted by GLOB. 252 IGNORE-CASE folds case. MAX-LINES caps output after EXCLUDE filtering. 253 CONTEXT adds surrounding lines. EXCLUDE is a GNU extended regexp applied 254 to output lines before truncation. Search untracked files and treat binary 255 files as text, making build logs searchable." 256 (dolist (value (list max-lines context)) 257 (when (and value (not (and (integerp value) (>= value 0)))) 258 (error "Limits must be non-negative integers"))) 259 (with-temp-buffer 260 (let ((status 261 (apply #'call-process "grep" nil t nil 262 (append '("-rnEH" "--binary-files=text" "--exclude-dir=.git") 263 (when ignore-case '("-i")) 264 (when glob (list (concat "--include=" glob))) 265 (when context (list "-C" (number-to-string context))) 266 (list "-e" regexp "--" (or path ".")))))) 267 (unless (memq status '(0 1)) 268 (error "grep failed: %s" (buffer-string)))) 269 (when exclude 270 (let ((status (call-process-region 271 (point-min) (point-max) "grep" t t nil 272 "-aEv" (if ignore-case "-i" "--color=never") 273 "-e" exclude))) 274 (unless (memq status '(0 1)) 275 (error "grep exclusion failed: %s" (buffer-string))))) 276 (when max-lines 277 (goto-char (point-min)) 278 (forward-line max-lines) 279 (unless (eobp) 280 (delete-region (point) (point-max)) 281 (insert "[Output truncated; increase max_lines or narrow the search.]\n"))) 282 (buffer-string))) 283 284 (gptel-make-tool 285 :name "grep" 286 :function #'llm-tools--grep 287 :description "Search file contents, including untracked and ignored build logs. 288 Use instead of shell grep, rg, or search pipelines. Supports GNU extended 289 regexps, case-insensitive search, exclusions, context, and bounded output. 290 Searches recursively, excluding .git directories; binary files are read as text. 291 Returns FILE:LINE:CONTENT, with context lines when requested. 292 Example: regexp=error|fatal|failed, path=build.log, ignore_case=true, 293 exclude=warning, max_lines=40. For file names use find_files; for reading 294 line ranges or the end of a log use read_file." 295 :args '((:name "regexp" :type string 296 :description "GNU extended regular expression (use | for alternatives).") 297 (:name "glob" :type string :optional t 298 :description "Restrict to file names matching this glob, e.g. *.el.") 299 (:name "path" :type string :optional t 300 :description "File or directory to search; defaults to current directory.") 301 (:name "ignore_case" :type boolean :optional t 302 :description "Case-insensitive matching (default false).") 303 (:name "max_lines" :type integer :optional t 304 :description "Maximum output lines after filtering, including context. Omit for all.") 305 (:name "context" :type integer :optional t 306 :description "Number of lines before and after matches (default 0).") 307 (:name "exclude" :type string :optional t 308 :description "GNU extended regexp: remove matching output lines before limiting. Uses ignore_case.")) 309 :async nil 310 :confirm nil 311 :include t) 312 313 ;; web_search (taken wholesale from gptel-agent) 314 (gptel-make-tool 315 :name "web_search" 316 :function #'llm-tools--web-search-eww 317 :description "Search the web for the first five results to a query. The query can be an arbitrary string. Returns the top five results from the search engine as a list of plists. Each object has the keys `:url` and `:excerpt` for the corresponding search result. 318 319 This tool uses the Emacs web browser (eww) with its default search engine to perform searches. No API key is required. 320 321 If required, consider using the url as the input to the `Read` tool to get the contents of the url. Note that this might not work as the `Read` tool does not handle javascript-enabled pages." 322 :args '((:name "query" 323 :type string 324 :description "The natural language search query, can be multiple words.") 325 (:name "count" 326 :type integer 327 :description "Number of results to return (default 5)" 328 :optional t)) 329 :include t 330 :async t 331 :confirm nil) 332 333 ;; fetch_url (use gptel-agent's one as a base, but adds mime handling like PDFs and images) 334 ; TODO add mime handling for documents and images 335 (gptel-make-tool 336 :name "web_fetch" 337 :function #'llm-tools--read-url 338 :description "Fetch and read the contents of a URL. 339 340 - Returns the text of the URL (not HTML) formatted for reading. 341 - Request times out after 30 seconds." 342 :args '(( :name "url" 343 :type string 344 :description "The URL to read")) 345 :async t 346 :confirm nil 347 :include t) 348 349 ;;;; Preview Functions 350 (defun llm-tools--preview-teardown (ov) 351 "Delete region covered by overlay OV and remove the overlay." 352 (when (overlayp ov) 353 (with-current-buffer (overlay-buffer ov) 354 (let ((inhibit-read-only t)) 355 (delete-region (overlay-start ov) (overlay-end ov))) 356 (delete-overlay ov)))) 357 358 (defun llm-tools--make-preview-setup (formatter) 359 "Return a preview setup function that calls FORMATTER with ARG-VALUES. 360 FORMATTER is a function (ARG-VALUES) that inserts the preview text." 361 (lambda (arg-values _info) 362 (let ((from (point))) 363 (funcall formatter arg-values) 364 (make-overlay from (point))))) 365 366 (defun llm-tools--format-bash-preview (arg-values) 367 (let ((command (car arg-values))) 368 (insert "(" 369 (propertize "bash" 'font-lock-face 'font-lock-keyword-face) 370 " " command ")\n"))) 371 372 (defun llm-tools--format-edit-file-preview (arg-values) 373 "Insert the file path, replacement scope, and literal texts from ARG-VALUES." 374 (pcase-let ((`(,file ,old-string ,new-string . ,rest) arg-values)) 375 (insert "(" 376 (propertize "edit_file" 'font-lock-face 'font-lock-keyword-face) 377 " " (prin1-to-string file) 378 (if (car rest) " replace_all=true" "") 379 ")\nOld text:\n" old-string "\nNew text:\n" new-string "\n"))) 380 381 (defun llm-tools--format-write-file-preview (arg-values) 382 (let ((file (car arg-values)) 383 (contents (cadr arg-values))) 384 (insert "(" 385 (propertize "write_file" 'font-lock-face 'font-lock-keyword-face) 386 " " (prin1-to-string file) ")\n" contents "\n"))) 387 388 (dolist (tool '(("bash" llm-tools--format-bash-preview) 389 ("edit_file" llm-tools--format-edit-file-preview) 390 ("write_file" llm-tools--format-write-file-preview))) 391 (setf (alist-get (car tool) 392 gptel--tool-preview-alist 393 nil 394 nil 395 #'equal) 396 (list (llm-tools--make-preview-setup (cadr tool)) 397 #'llm-tools--preview-teardown))) 398 399 (provide 'llm-tools)