llm-tools.el

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

llm-tools.el (17835B)


      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 
     33 (defgroup llm-tools nil
     34   "Opinionated tools for use by an LLM."
     35   :group 'convenience)
     36 
     37 (require 'llm-tools-hl)
     38 (require 'llm-tools-web)
     39 
     40 ;; read_file (hl-file-read)
     41 (gptel-make-tool
     42  :name "read_file"
     43  :function (lambda (file beg end only-anchors? as-list?)
     44 	     (json-encode-string (llm-tools--hl-file-read
     45 				  file beg end only-anchors? as-list?)))
     46  :description "\
     47 Read a file (or a range of lines) and return its contents in hashline
     48 format. Each line is represented as LINE_NUMBER HASH|CONTENT, where
     49 LINE_NUMBER is the 1-based line number, HASH is a 2-character
     50 content-derived bigram, and CONTENT is the verbatim line text. Example
     51 output:
     52 
     53 1aa|const char *TITLE = \"Mr\";
     54 2ab|void greet(char *name) {
     55 3cd|	return name;
     56 
     57 The LINE_NUMBER and HASH together form an anchor (e.g., 1aa, 2ab) that
     58 uniquely identifies a line by position and content. Copy anchors
     59 verbatim from the output when referencing lines in edit operations. Do
     60 not fabricate or modify hashes. Call this tool before editing any file
     61 to obtain current content and valid anchors. Re-call if anchors no
     62 longer match after external changes.
     63 
     64 Use optional BEG and END arguments to read only a specific range of
     65 lines (1-based, inclusive). Use ONLY_ANCHORS to retrieve just the line
     66 anchors without content. Use AS_LIST to receive output as a list rather
     67 than a newline-delimited string."
     68  :args '((:name "file"
     69 		:type string
     70 		:description "\
     71 Path to the file to read, relative to the working directory.")
     72 	 (:name "beg"
     73 		:type integer
     74 		:description "\
     75 Optional 1-based line number (inclusive) to start reading from. Omit to
     76 read from the beginning."
     77 		:optional t)
     78 	 (:name "end"
     79 		:type integer
     80 		:description "\
     81 Optional 1-based line number (inclusive) to stop reading at. Omit to
     82 read to the end."
     83 		:optional t)
     84 	 (:name "only_anchors"
     85 		:type boolean
     86 		:description "\
     87 Optional boolean. When true, returns only the anchor (line number +
     88 hash) without the content portion after the pipe."
     89 		:optional t)
     90 	 (:name "as_list"
     91 		:type boolean
     92 		:description "\
     93 Optional boolean. When true, returns each hashline as a list element
     94 instead of a single newline-delimited string."
     95 		:optional t))
     96  :async nil
     97  :confirm nil
     98  :include t)
     99 
    100 ;; write_file (write-region)
    101 (gptel-make-tool
    102  :name "write_file"
    103  :function (lambda (file contents)
    104 	     (write-region contents nil file))
    105  :description "\
    106 Overwrite an entire file with new contents. This is a destructive
    107 operation that replaces all existing content. Use this tool for:
    108 
    109 - Creating new files from scratch
    110 - Completely replacing small files where the new content is the
    111   intended final state
    112 - Writing generated output, configuration files, or test fixtures
    113 
    114 Do NOT use this for partial edits, appending, or modifying existing
    115 files. Use `edit_file` instead for surgical changes to existing
    116 content. Always call `read_file` first if the file already exists to
    117 preserve content you intend to keep. This tool requires user
    118 confirmation before executing."
    119  :args '((:name "file"
    120 		:type string
    121 		:description "Path to the file to write to, relative to the working directory.")
    122 	 (:name "contents"
    123 		:type string
    124 		:description "\
    125 Full file contents to write. This will replace all existing
    126 content. Include newlines and indentation as they should appear in the
    127 final file."))
    128  :async nil
    129  :confirm t
    130  :include t)
    131 
    132 ;; edit_file (hl-edit)
    133 (gptel-make-tool
    134  :name "edit_file"
    135  :function #'llm-tools--hl-edit
    136  :description "\
    137 Edit an existing file surgically using the hashline patch
    138 format. Call `read_file` first to obtain current content and valid
    139 line anchors. Use `write_file` instead for creating new files or
    140 completely replacing content. Returns a success message if the patch
    141 applied, or an error string describing what went wrong (structural
    142 issues or stale anchors)."
    143  :args '((:name "patch"
    144 		:type string
    145 		:description "\
    146 A hashline patch string containing one or more file sections. Each
    147 section begins with `@@ PATH` on its own line, followed by operations
    148 that reference lines by their anchor (line number + 2-character hash,
    149 e.g. `5ff`). Copy anchors verbatim from `read_file` output; never
    150 fabricate or modify them.
    151 
    152 CRITICAL: This is NOT a unified diff. Do NOT use `---`, `+++`, `@@ -1,3
    153 +1,4 @@`, `-old`, `+new`, or any unified diff syntax. The header is
    154 `@@ PATH`; operations use `<`, `+`, `-`, `=`. This format is purely
    155 textual -- the tool has NO awareness of language, indentation,
    156 brackets, fences, or table widths. You must emit valid syntax in
    157 replacements and insertions.
    158 
    159 Operations:
    160   + ANCHOR    Insert lines AFTER the anchored line (or `EOF` to append)
    161   < ANCHOR    Insert lines BEFORE the anchored line (or `BOF` to prepend)
    162   - A..B      Delete the inclusive line range A through B
    163   = A..B      Replace the inclusive range A through B with payload lines
    164 
    165 Payload lines follow the operation line and must start with `~`
    166 immediately followed by content -- no space after `~`. Every character
    167 after `~` is verbatim file content. Op lines carry no content:
    168 
    169 WRONG: + 5pg some code
    170 RIGHT:
    171 + 5pg
    172 ~some code
    173 
    174 One operation can have many payload lines. To insert N consecutive
    175 lines, write ONE op followed by N `~` lines:
    176 
    177 WRONG (one op per line, with fabricated anchors):
    178   + 5pg
    179   ~first new line
    180   + 6xx     <- FABRICATED
    181   ~second new line
    182 
    183 RIGHT (one op, many payload lines):
    184   + 5pg
    185   ~first new line
    186   ~second new line
    187 
    188 Rules:
    189 - Payload is ONLY what is NEW relative to your range. Do not repeat
    190   existing lines or duplicate nearby content.
    191 - Anchors reference the file as last read via `read_file`. Do not shift
    192   line numbers for prior operations in the same patch.
    193 - Prefer narrow operations (`+`, `-`) over wide replacements (`=`).
    194   Two narrow ops beat one wide `=`.
    195 - When editing a multiline construct, widen to the whole construct
    196   rather than bisecting it.
    197 - `= A..B` deletes the range; payload is what is written. Before using
    198   `=`, mentally delete A..B. If that splits an unclosed brace or
    199   orphans a closer, you are bisecting a construct -- widen the range.
    200 - Every payload line must start with `~`. Raw content without `~` is
    201   invalid.
    202 - Comments (lines starting with `#`) and blank lines are ignored.
    203 
    204 Examples:
    205 
    206 Replace a single line:
    207 @@ greeting.el
    208 = 2in..2in
    209 ~(defconst greeting-title \"Mrs\")
    210 
    211 Replace a multiline block (widen to self-contained boundary):
    212 @@ greeting.el
    213 = 4ei..7be
    214 ~  (concat
    215 ~    greeting-title
    216 ~    (or (string-trim name) \"guest\"))
    217 ~  )
    218 
    219 Insert after:
    220 @@ greeting.el
    221 + 4ei
    222 ~    greeting-title
    223 
    224 Insert before:
    225 @@ greeting.el
    226 < 5ff
    227 ~    greeting-title
    228 
    229 Append to end of file:
    230 @@ greeting.el
    231 + EOF
    232 ~(provide 'greeting)
    233 
    234 Prepend to beginning of file:
    235 @@ greeting.el
    236 < BOF
    237 ~;; greeting.el -- utilities
    238 
    239 Delete a single line:
    240 @@ greeting.el
    241 - 5ff..5ff
    242 
    243 Blank a line (replace with empty):
    244 @@ greeting.el
    245 = 5ff..5ff
    246 
    247 Multiple operations in one section:
    248 @@ greeting.el
    249 + 1vx
    250 ~(defconst DEBUG nil)
    251 - 5ff..5ff
    252 
    253 Multiple files in one patch:
    254 @@ a.el
    255 + 1vx
    256 ~(require 'b)
    257 @@ b.el
    258 = 2in..2in
    259 ~(defconst VALUE 42)
    260 
    261 Insert multiple lines with one op:
    262 @@ greeting.el
    263 + 1vx
    264 ~(defconst GREETING \"Hello\")
    265 ~(defconst FAREWELL \"Goodbye\")
    266 
    267 Replace one line with multiple lines:
    268 @@ greeting.el
    269 = 1vx..1vx
    270 ~(defconst TITLE \"Dr\")
    271 ~(defconst SUBTITLE \"Assistant\")"))
    272  :async nil
    273  :confirm t
    274  :include t)
    275 
    276 ;; bash (make-process, taken from gptel-agent)
    277 (defun llm-tools--execute-bash (callback command)
    278   "Execute COMMAND asynchronously in bash and call CALLBACK with output.
    279 
    280 CALLBACK is called with the command output string when the process finishes.
    281 COMMAND is the bash command string to execute."
    282   (let* ((output-buffer (generate-new-buffer " *llm-tools-bash*"))
    283          (proc (make-process
    284                 :name "llm-tools-bash"
    285                 :buffer output-buffer
    286                 :command (list "bash" "-c" command)
    287                 :connection-type 'pipe
    288                 :sentinel
    289                 (lambda (process _event)
    290                   (when (memq (process-status process) '(exit signal))
    291                     (let* ((exit-code (process-exit-status process))
    292                            (output (with-current-buffer (process-buffer process)
    293 				     (buffer-string))))
    294 		      (kill-buffer (process-buffer process))
    295 		      (funcall callback
    296 			       (if (zerop exit-code)
    297                                    output
    298                                  (format "Command failed with exit code %d:\nSTDOUT+STDERR:\n%s"
    299                                          exit-code output)))))))))
    300     proc))
    301 
    302 (defun llm-tools--bash-confirm-p (command)
    303     (let ((destructive
    304          '("\\brm\\b"
    305            "\\bdd\\b"
    306            "\\bshred\\b"
    307            "\\btruncate\\b"
    308            "\\bmkfs\\b"
    309            "\\bsudo\\b"
    310            "\\bdoas\\b"
    311 	   "[^>]>[^>&0-9]" ; any singular > that isn't >> nor file-descriptor redirect
    312 	   "\\$("
    313 	   "`"
    314            "|[ \t]*\\bsh\\b"
    315            "|[ \t]*\\bbash\\b")))
    316     (cl-some (lambda (regexp)
    317 	       (string-match-p regexp command))
    318 	     destructive)))
    319 
    320 (gptel-make-tool
    321  :name "bash"
    322  :function #'llm-tools--execute-bash
    323  :description "Execute Bash commands.
    324 
    325 This tool provides access to a Bash shell with GNU coreutils (or
    326 equivalents) available. Use this to inspect system state, run builds,
    327 tests or other development or system administration tasks.
    328 
    329 Use the dedicated tools for file operations, searching, and code
    330 navigation instead of shell commands.
    331 
    332 - For file contents: use `read_file` or `web_fetch`
    333 - For file discovery/metadata: use `find_files`
    334 - For searching text in files: use `grep`
    335 - For editing files: use `edit_file` or `write_file`
    336 
    337 Bash should be a last resort, used only when no other tool fits the task.
    338 
    339 - Quote file paths with spaces using double quotes.
    340 - Chain dependent commands with && (or ; if failures are OK)
    341 - Use absolute paths instead of cd when possible
    342 - For parallel commands, make multiple `bash` calls in one message
    343 - Run tests, check your work or otherwise close the loop to verify changes you make.
    344 
    345 EXAMPLES:
    346 - Check file type: 'file document.pdf'
    347 - Count lines: 'wc -l *.txt'
    348 
    349 The command will be executed in the current working directory. Output is
    350 returned as a string. Long outputs should be filtered/limited using pipes."
    351  :args '((:name "command"
    352 		:type string
    353 		:description "The Bash command to execute.\
    354 Can include pipes and standard shell operators.
    355 Example: 'ls -la | head -20' or 'grep -i error app.log | tail -50'"))
    356  :confirm #'llm-tools--bash-confirm-p
    357  :include t
    358  :async t)
    359 
    360 ;; find_files (directory-files-recursively)
    361 (gptel-make-tool
    362  :name "find_files"
    363  :function (lambda (dir &optional regexp)
    364 	     (let ((git-dir (concat (file-name-as-directory dir) ".git")))
    365 	       (delq nil (mapcar (lambda (f)
    366 				   (unless (string-prefix-p git-dir f) f))
    367 				 (directory-files-recursively dir (or regexp ""))))))
    368  :description "\
    369 Recursively find all files in a given directory, excluding files within
    370 .git directories. Returns a list of absolute file paths. Optionally
    371 accepts a pattern to filter file names (e.g., '*.el' for Emacs Lisp
    372 files). Use this to discover files in a project directory before reading
    373 or editing them."
    374  :args '((:name "directory"
    375 		:type string
    376 		:description "Path to the directory to be read, relative to the working directory.")
    377 	 (:name "regexp"
    378 		:type string
    379 		:description "Optional regular expression pattern to search for, uses extended regex syntax."
    380 		:optional t))
    381  :async nil
    382  :confirm nil
    383  :include t)
    384 
    385 ;; grep (just grep (or git grep) :D)
    386 (defun llm-tools--grep (regexp &optional glob path)
    387   (with-temp-buffer
    388     (if (magit-gitdir)
    389 	(let ((pathspec (cond
    390 			 ((and path glob)
    391 			  (concat (directory-file-name path) "/" glob))
    392 			 (glob glob)
    393 			 (path path)
    394 			 (t "."))))
    395 	  (call-process "git" nil t nil
    396 			"grep" "-rnEH"
    397 			regexp "--" pathspec))
    398       (apply #'call-process
    399 	     (append
    400 	      '("grep" nil t nil)
    401 	      (delq nil
    402 		    `("-rnEH"
    403 		      ,(if glob (concat "--include=" glob))
    404 		      ,regexp
    405 		      ,(or path "."))))))
    406     (buffer-string)))
    407 
    408 (gptel-make-tool
    409  :name "grep"
    410  :function (lambda (regexp &optional glob path)
    411 	     (llm-tools--grep regexp glob path))
    412  :description "\
    413 Recursively search for lines matching a regexp pattern in files under a
    414 given path. Uses `git grep` when inside a Git repository, otherwise falls
    415 back to GNU grep. Both are invoked with flags -rnEH (recursive, line
    416 numbers, extended regexp, always show filename). When using `git grep`,
    417 only tracked files are searched. Returns raw output with one match per
    418 line in the format FILE:LINE:CONTENT. Use this tool to find code
    419 patterns, locate function definitions, search for strings, or identify
    420 files containing specific text before reading them. For simple file name
    421 discovery, use `find_files` instead. For reading file contents, use
    422 `read_file`. The path argument is relative to the working directory;
    423 omit it to search from the current directory."
    424  :args '((:name "regexp"
    425 		:type string
    426 		:description "\
    427 Regular expression pattern to search for. Uses GNU grep extended syntax.")
    428 	 (:name "glob"
    429 		:type string
    430 		:description "\
    431 Optional restricts search to files matching the glob
    432 pattern. (e.g. \"*.el\", \"*.org\", \"*.c\")"
    433 		:optional t)
    434 	 (:name "path"
    435 		:type string
    436 		:description "\
    437 Optional file or directory path to search within, relative to the
    438 working directory. Defaults to current directory if omitted."
    439 		:optional t))
    440  :async nil
    441  :confirm nil
    442  :include t)
    443 
    444 ;; web_search (taken wholesale from gptel-agent)
    445 (gptel-make-tool
    446  :name "web_search"
    447  :function #'llm-tools--web-search-eww
    448  :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.
    449 
    450 This tool uses the Emacs web browser (eww) with its default search engine to perform searches. No API key is required.
    451 
    452 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."
    453  :args '((:name "query"
    454                 :type string
    455                 :description "The natural language search query, can be multiple words.")
    456          (:name "count"
    457                 :type integer
    458                 :description "Number of results to return (default 5)"
    459                 :optional t))
    460  :include t
    461  :async t
    462  :confirm nil)
    463 
    464 ;; fetch_url (use gptel-agent's one as a base, but adds mime handling like PDFs and images)
    465 ; TODO add mime handling for documents and images
    466 (gptel-make-tool
    467  :name "web_fetch"
    468  :function #'llm-tools--read-url
    469  :description "Fetch and read the contents of a URL.
    470 
    471 - Returns the text of the URL (not HTML) formatted for reading.
    472 - Request times out after 30 seconds."
    473  :args '(( :name "url"
    474            :type string
    475            :description "The URL to read"))
    476  :async t
    477  :confirm nil
    478  :include t)
    479 
    480 ;;;; Preview Functions
    481 (defun llm-tools--preview-teardown (ov)
    482   "Delete region covered by overlay OV and remove the overlay."
    483   (when (overlayp ov)
    484     (with-current-buffer (overlay-buffer ov)
    485       (let ((inhibit-read-only t))
    486         (delete-region (overlay-start ov) (overlay-end ov)))
    487       (delete-overlay ov))))
    488 
    489 (defun llm-tools--make-preview-setup (formatter)
    490   "Return a preview setup function that calls FORMATTER with ARG-VALUES.
    491 FORMATTER is a function (ARG-VALUES) that inserts the preview text."
    492   (lambda (arg-values _info)
    493     (let ((from (point)))
    494       (funcall formatter arg-values)
    495       (make-overlay from (point)))))
    496 
    497 (defun llm-tools--format-bash-preview (arg-values)
    498   (let ((command (car arg-values)))
    499     (insert "("
    500             (propertize "bash" 'font-lock-face 'font-lock-keyword-face)
    501             " " command ")\n")))
    502 
    503 (defun llm-tools--format-edit-file-preview (arg-values)
    504   (let ((patch (car arg-values)))
    505     (insert "("
    506             (propertize "edit_file" 'font-lock-face 'font-lock-keyword-face)
    507             ")\n" patch "\n")))
    508 
    509 (defun llm-tools--format-write-file-preview (arg-values)
    510   (let ((file (car arg-values))
    511         (contents (cadr arg-values)))
    512     (insert "("
    513             (propertize "write_file" 'font-lock-face 'font-lock-keyword-face)
    514             " " (prin1-to-string file) ")\n" contents "\n")))
    515 
    516 (dolist (tool '(("bash" llm-tools--format-bash-preview)
    517                 ("edit_file" llm-tools--format-edit-file-preview)
    518                 ("write_file" llm-tools--format-write-file-preview)))
    519   (setf (alist-get (car tool)
    520 		   gptel--tool-preview-alist
    521 		   nil
    522 		   nil
    523 		   #'equal)
    524         (list (llm-tools--make-preview-setup (cadr tool))
    525 	      #'llm-tools--preview-teardown)))
    526 
    527 (provide 'llm-tools)