llm-tools.el

Opinionated tools for use by a (local) LLM
Log | Files | Refs | LICENSE

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)