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)