branch: externals/ellama
commit c3e553633bfdf87c98cdfb6b22c248ea721c1391
Author: Sergey Kostyaev <[email protected]>
Commit: Sergey Kostyaev <[email protected]>

    Add tool support
    
    This commit adds comprehensive tool support to Ellama, enabling interaction 
with
    filesystem operations, shell commands, and other utilities through LLM 
tools.
    Key changes include:
    
    1. New `ellama-tools.el` file implementing:
       - Tool registration and management (enable/disable tools)
       - File operations (read, write, move, edit files)
       - Directory tree exploring
       - Shell command execution
       - Search and listing capabilities for tools
       - Date/time utilities
    
    2. Integration with main `ellama.el`:
       - Added dependency on `ellama-tools`
       - Modified session creation to preserve current directory
       - Refactored error and response handlers for tool execution
       - Integrated tools into LLM prompts via `:tools` parameter
       - Refactored chat handling to support tool execution
    
    The tool system allows LLMs to perform actions like reading/writing files,
    executing shell commands, and exploring directory structures, significantly
    extending Ellama's capabilities.
---
 ellama-tools.el | 406 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 ellama.el       | 164 +++++++++++------------
 2 files changed, 482 insertions(+), 88 deletions(-)

diff --git a/ellama-tools.el b/ellama-tools.el
new file mode 100644
index 0000000000..99287f8715
--- /dev/null
+++ b/ellama-tools.el
@@ -0,0 +1,406 @@
+;;; ellama-tools.el --- Working with tools -*- lexical-binding: t; 
package-lint-main-file: "ellama.el"; -*-
+
+;; Copyright (C) 2023-2025  Free Software Foundation, Inc.
+
+;; Author: Sergey Kostyaev <[email protected]>
+;; SPDX-License-Identifier: GPL-3.0-or-later
+
+;; This file is free software; you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation; either version 3, or (at your option)
+;; any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with GNU Emacs.  If not, see <http://www.gnu.org/licenses/>.
+
+;;; Commentary:
+;;
+;; Ellama is a tool for interacting with large language models from Emacs.
+;; It allows you to ask questions and receive responses from the
+;; LLMs.  Ellama can perform various tasks such as translation, code
+;; review, summarization, enhancing grammar/spelling or wording and
+;; more through the Emacs interface.  Ellama natively supports streaming
+;; output, making it effortless to use with your preferred text editor.
+;;
+
+;;; Code:
+(require 'project)
+
+(defvar ellama-tools-available nil
+  "Alist containing all registered tools.")
+
+(defvar ellama-tools-enabled nil
+  "List of tools that have been enabled.")
+
+(defun ellama-tools-enable-by-name (&optional name)
+  "Add to `ellama-tools-enabled' each tool that matches NAME."
+  (interactive)
+  (let* ((tool-name (or name
+                        (completing-read
+                         "Tool to enable: "
+                         (cl-remove-if
+                          (lambda (tname)
+                            (cl-find-if
+                             (lambda (tool)
+                               (string= tname (llm-tool-name tool)))
+                             ellama-tools-enabled))
+                          (mapcar (lambda (tool) (llm-tool-name tool)) 
ellama-tools-available)))))
+         (tool (seq-find (lambda (tool) (string= tool-name (llm-tool-name 
tool)))
+                         ellama-tools-available)))
+    (add-to-list 'ellama-tools-enabled tool)))
+
+(add-to-list
+ 'ellama-tools-available
+ (llm-make-tool :function
+                'ellama-tools-enable-by-name
+                :name
+                "enable_tool"
+                :args
+                (list '(:name
+                        "name"
+                        :type
+                        string
+                        :description
+                        "Name of the tool to enable."))
+                :description
+                "Enable each tool that matches NAME."))
+
+(defun ellama-tools-disable-by-name (&optional name)
+  "Remove from `ellama-tools-enabled' each tool that matches NAME."
+  (interactive)
+  (let* ((tool-name (or name
+                        (completing-read
+                         "Tool to disable: "
+                         (mapcar (lambda (tool) (llm-tool-name tool)) 
ellama-tools-enabled))))
+         (tool (seq-find (lambda (tool) (string= tool-name (llm-tool-name 
tool)))
+                         ellama-tools-enabled)))
+    (setq ellama-tools-enabled (seq-remove (lambda (enabled-tool) (eq 
enabled-tool tool))
+                                           ellama-tools-enabled))))
+
+(add-to-list
+ 'ellama-tools-available
+ (llm-make-tool :function
+                'ellama-tools-disable-by-name
+                :name
+                "disable_tool"
+                :args
+                (list '(:name
+                        "name"
+                        :type
+                        string
+                        :description
+                        "Name of the tool to disable."))
+                :description
+                "Disable each tool that matches NAME."))
+
+(defun ellama-tools-enable-all ()
+  "Enable all available tools."
+  (interactive)
+  (setq ellama-tools-enabled ellama-tools-available))
+
+(defun ellama-tools-disable-all ()
+  "Disable all enabled tools."
+  (interactive)
+  (setq ellama-tools-enabled nil))
+
+(defun ellama-tools-read-file (path)
+  "Read the file located at the specified PATH."
+  (with-temp-buffer
+    (insert-file-contents-literally path)
+    (buffer-string)))
+
+(add-to-list
+ 'ellama-tools-available
+ (llm-make-tool :function
+                'ellama-tools-read-file
+                :name
+                "read_file"
+                :args
+                (list '(:name
+                        "path"
+                        :type
+                        string
+                        :description
+                        "Path to the file."))
+                :description
+                "Read the file located at the specified PATH."))
+
+(defun ellama-tools-write-file (path content)
+  "Write CONTENT to the file located at the specified PATH."
+  (with-temp-buffer
+    (insert content)
+    (setq buffer-file-name path)
+    (save-buffer)))
+
+(add-to-list
+ 'ellama-tools-available
+ (llm-make-tool :function
+                'ellama-tools-write-file
+                :name
+                "write_file"
+                :args
+                (list '(:name
+                        "path"
+                        :type
+                        string
+                        :description
+                        "Path to the file.")
+                      '(:name
+                        "content"
+                        :type
+                        string
+                        :description
+                        "Content to write to the file."))
+                :description
+                "Write CONTENT to the file located at the specified PATH."))
+
+(defun ellama-tools-directory-tree (dir &optional depth)
+  "Return a string representing the directory tree under DIR.
+DEPTH is the current recursion depth, used internally.
+TREE represents current tree."
+  (let ((indent (make-string (* (or depth 0) 2) ? ))
+        (tree ""))
+    (dolist (f (sort (cl-remove-if
+                      (lambda (f)
+                        (string-prefix-p "." f))
+                      (directory-files dir))
+                     #'string-lessp))
+      (let* ((full   (expand-file-name f dir))
+             (name   (file-name-nondirectory f))
+             (type   (if (file-directory-p full) "|-" "`-"))
+             (line   (concat indent type name "\n")))
+        (setq tree (concat tree line))
+        (when (file-directory-p full)
+          (setq tree (concat tree
+                             (ellama-tools-directory-tree full (+ (or depth 0) 
1)))))))
+    tree))
+
+(add-to-list
+ 'ellama-tools-available
+ (llm-make-tool :function
+                'ellama-tools-directory-tree
+                :name
+                "directory_tree"
+                :args
+                (list '(:name
+                        "dir"
+                        :type
+                        string
+                        :description
+                        "Directory path to generate tree for."))
+                :description
+                "Return a string representing the directory tree under DIR."))
+
+(defun ellama-tools-move-file (path newpath)
+  "Move the file from the specified PATH to the NEWPATH."
+  (if (and (file-exists-p path)
+           (not (file-exists-p newpath)))
+      (progn
+        (rename-file path newpath))
+    (error "Cannot move file: source file does not exist or destination 
already exists")))
+
+(add-to-list
+ 'ellama-tools-available
+ (llm-make-tool :function
+                'ellama-tools-move-file
+                :name
+                "move_file"
+                :args
+                (list '(:name
+                        "path"
+                        :type
+                        string
+                        :description
+                        "Current path of the file.")
+                      '(:name
+                        "newpath"
+                        :type
+                        string
+                        :description
+                        "New path for the file."))
+                :description
+                "Move the file from the specified PATH to the NEWPATH."))
+
+(defun ellama-tools-edit-file (path oldcontent newcontent)
+  "Edit file located at PATH.
+Replace OLDCONTENT with NEWCONTENT."
+  (let ((content (with-temp-buffer
+                   (insert-file-contents-literally path)
+                   (buffer-string))))
+    (when (string-match oldcontent content)
+      (with-temp-buffer
+        (insert content)
+        (goto-char (match-beginning 0))
+        (delete-region (match-beginning 0) (match-end 0))
+        (insert newcontent)
+        (write-region (point-min) (point-max) path)))))
+
+(add-to-list
+ 'ellama-tools-available
+ (llm-make-tool :function
+                'ellama-tools-edit-file
+                :name
+                "edit_file"
+                :args
+                (list '(:name
+                        "path"
+                        :type
+                        string
+                        :description
+                        "Path to the file.")
+                      '(:name
+                        "oldcontent"
+                        :type
+                        string
+                        :description
+                        "Old content to be replaced.")
+                      '(:name
+                        "newcontent"
+                        :type
+                        string
+                        :description
+                        "New content to replace with."))
+                :description
+                "Edit file located at PATH. Replace OLDCONTENT with 
NEWCONTENT."))
+
+(defun ellama-tools-shell-command (cmd)
+  "Execute shell command CMD."
+  (shell-command-to-string cmd))
+
+(add-to-list
+ 'ellama-tools-available
+ (llm-make-tool :function
+                'ellama-tools-shell-command
+                :name
+                "shell_command"
+                :args
+                (list '(:name
+                        "cmd"
+                        :type
+                        string
+                        :description
+                        "Shell command to execute."))
+                :description
+                "Execute shell command CMD."))
+
+(defun ellama-tools-grep (search-string)
+  "Grep SEARCH-STRING in directory files."
+  (shell-command-to-string (format "find . -type f -exec grep --color=never 
-nh -e %s \{\} +" search-string)))
+
+(add-to-list
+ 'ellama-tools-available
+ (llm-make-tool :function
+                'ellama-tools-grep
+                :name
+                "grep"
+                :args
+                (list '(:name
+                        "search-string"
+                        :type
+                        string
+                        :description
+                        "String to search for."))
+                :description
+                "Grep SEARCH-STRING in directory files."))
+
+(defun ellama-tools-list ()
+  "List all available tools."
+  (json-encode (mapcar
+                (lambda (tool)
+                  `(("name" . ,(llm-tool-name tool))
+                    ("description" . ,(llm-tool-description tool))))
+                ellama-tools-available)))
+
+(add-to-list
+ 'ellama-tools-available
+ (llm-make-tool :function
+                'ellama-tools-list
+                :name
+                "list_tools"
+                :args
+                nil
+                :description
+                "List all available tools."))
+
+(defun ellama-tools-search (search-string)
+  "Search available tools that matches SEARCH-STRING."
+  (json-encode
+   (cl-remove-if-not
+    (lambda (item)
+      (or (string-match-p search-string (alist-get "name" item nil nil 
'string=))
+          (string-match-p search-string (alist-get "description" item nil nil 
'string=))))
+    (mapcar
+     (lambda (tool)
+       `(("name" . ,(llm-tool-name tool))
+         ("description" . ,(llm-tool-description tool))))
+     ellama-tools-available))))
+
+(add-to-list
+ 'ellama-tools-available
+ (llm-make-tool :function
+                'ellama-tools-search
+                :name
+                "search_tools"
+                :args
+                (list '(:name
+                        "search-string"
+                        :type
+                        string
+                        :description
+                        "String to search for in tool names or descriptions."))
+                :description
+                "Search available tools that matches SEARCH-STRING."))
+
+(defun ellama-tools-today ()
+  "Return current date."
+  (format-time-string "%Y-%m-%d"))
+
+(add-to-list
+ 'ellama-tools-available
+ (llm-make-tool :function
+                'ellama-tools-today
+                :name
+                "today"
+                :args
+                nil
+                :description
+                "Return current date."))
+
+(defun ellama-tools-now ()
+  "Return current date, time and timezone."
+  (format-time-string "%Y-%m-%d %H:%M:%S %Z"))
+
+(add-to-list
+ 'ellama-tools-available
+ (llm-make-tool :function
+                'ellama-tools-now
+                :name
+                "now"
+                :args
+                nil
+                :description
+                "Return current date, time and timezone."))
+
+(defun ellama-tools-project-root ()
+  "Return current project root directory."
+  (json-encode (when (project-current)
+                 (project-root (project-current)))))
+
+(add-to-list
+ 'ellama-tools-available
+ (llm-make-tool :function
+                'ellama-tools-project-root
+                :name
+                "project_root"
+                :args
+                nil
+                :description
+                "Return current project root directory."))
+
+(provide 'ellama-tools)
+;;; ellama-tools.el ends here
diff --git a/ellama.el b/ellama.el
index e60167da14..ac0bc8f8cf 100644
--- a/ellama.el
+++ b/ellama.el
@@ -40,6 +40,7 @@
 (require 'llm-provider-utils)
 (require 'compat)
 (eval-when-compile (require 'rx))
+(require 'ellama-tools)
 
 (defgroup ellama nil
   "Tool for interacting with LLMs."
@@ -859,7 +860,8 @@ Defaults to md, but supports org.  Depends on 
`ellama-major-mode'."
   "Create new ellama session with unique id.
 Provided PROVIDER and PROMPT will be used in new session.
 If EPHEMERAL non nil new session will not be associated with any file."
-  (let* ((name (ellama-generate-name provider 'ellama prompt))
+  (let* ((dir default-directory)
+        (name (ellama-generate-name provider 'ellama prompt))
         (count 1)
         (name-with-suffix (format "%s %d" name count))
         (id (if (and (not (ellama-get-session-buffer name))
@@ -889,7 +891,7 @@ If EPHEMERAL non nil new session will not be associated 
with any file."
     (setq ellama--current-session-id id)
     (puthash id buffer ellama--active-sessions)
     (with-current-buffer buffer
-      (setq default-directory ellama-sessions-directory)
+      (setq default-directory dir)
       (funcall ellama-major-mode)
       (setq ellama--current-session session)
       (ellama-session-mode +1))
@@ -1361,6 +1363,68 @@ REASONING-BUFFER is a buffer for reasoning."
              (concat "</think>\n" (string-trim text))
            (string-trim text))))))))
 
+(defun ellama--error-handler (buffer errcb)
+  "Error handler function.
+BUFFER is the current ellama buffer.
+ERRCB is an error callback."
+  (lambda (_ msg)
+    (with-current-buffer buffer
+      (cancel-change-group ellama--change-group)
+      (when ellama-spinner-enabled
+       (spinner-stop))
+      (funcall errcb msg)
+      (setq ellama--current-request nil)
+      (ellama-request-mode -1))))
+
+(defun ellama--response-handler (handler reasoning-buffer buffer donecb errcb 
provider llm-prompt async)
+  "Response handler function.
+HANDLER handles text insertion.
+REASONING-BUFFER used for reasoning output.
+BUFFER is the current ellama buffer.
+DONECB is a done callback.
+PROVIDER is an llm provider.
+ERRCB is an error callback.
+LLM-PROMPT is current llm prompt.
+ASYNC flag is for asyncronous requests."
+  (lambda (response)
+    (let ((text (plist-get response :text))
+         (reasoning (plist-get response :reasoning))
+         (tool-result (plist-get response :tool-results)))
+      (if tool-result
+         (progn
+           (message "tool result: %s\nprompt: %s" tool-result llm-prompt)
+           (if async
+               (llm-chat-async
+                provider
+                llm-prompt
+                (ellama--response-handler handler reasoning-buffer buffer 
donecb errcb provider llm-prompt async)
+                (ellama--error-handler buffer errcb)
+                t)
+             (llm-chat-streaming
+              provider
+              llm-prompt
+              handler
+              (ellama--response-handler handler reasoning-buffer buffer donecb 
errcb provider llm-prompt async)
+              (ellama--error-handler buffer errcb)
+              t)))
+       (funcall handler response)
+       (when (or ellama--current-session
+                 (not reasoning))
+         (kill-buffer reasoning-buffer))
+       (with-current-buffer buffer
+         (accept-change-group ellama--change-group)
+         (when ellama-spinner-enabled
+           (spinner-stop))
+         (if (and (listp donecb)
+                  (functionp (car donecb)))
+             (mapc (lambda (fn) (funcall fn text))
+                   donecb)
+           (funcall donecb text))
+         (when ellama-session-hide-org-quotes
+           (ellama-collapse-org-quotes))
+         (setq ellama--current-request nil)
+         (ellama-request-mode -1))))))
+
 (defun ellama-stream (prompt &rest args)
   "Query ellama for PROMPT.
 ARGS contains keys for fine control.
@@ -1430,8 +1494,10 @@ failure (with BUFFER current).
                                  system 'system))
                               (ellama-session-prompt session))
                           (setf (ellama-session-prompt session)
-                                (llm-make-chat-prompt prompt-with-ctx :context 
system)))
-                      (llm-make-chat-prompt prompt-with-ctx :context system))))
+                                (llm-make-chat-prompt prompt-with-ctx :context 
system
+                                                      :tools 
ellama-tools-enabled)))
+                      (llm-make-chat-prompt prompt-with-ctx :context system
+                                            :tools ellama-tools-enabled))))
     (with-current-buffer reasoning-buffer
       (org-mode))
     (with-current-buffer buffer
@@ -1450,67 +1516,15 @@ failure (with BUFFER current).
                          ('async (llm-chat-async
                                   provider
                                   llm-prompt
-                                  (lambda (response)
-                                    (let ((text (plist-get response :text))
-                                          (reasoning (plist-get response 
:reasoning)))
-                                      (funcall handler response)
-                                      (when (or ellama--current-session
-                                                (not reasoning))
-                                        (kill-buffer reasoning-buffer))
-                                      (with-current-buffer buffer
-                                        (accept-change-group 
ellama--change-group)
-                                        (when ellama-spinner-enabled
-                                          (spinner-stop))
-                                        (if (and (listp donecb)
-                                                 (functionp (car donecb)))
-                                            (mapc (lambda (fn) (funcall fn 
text))
-                                                  donecb)
-                                          (funcall donecb text))
-                                        (when ellama-session-hide-org-quotes
-                                          (ellama-collapse-org-quotes))
-                                        (setq ellama--current-request nil)
-                                        (ellama-request-mode -1))))
-                                  (lambda (_ msg)
-                                    (with-current-buffer buffer
-                                      (cancel-change-group 
ellama--change-group)
-                                      (when ellama-spinner-enabled
-                                        (spinner-stop))
-                                      (funcall errcb msg)
-                                      (setq ellama--current-request nil)
-                                      (ellama-request-mode -1)))
+                                  (ellama--response-handler handler 
reasoning-buffer buffer donecb errcb provider llm-prompt t)
+                                  (ellama--error-handler buffer errcb)
                                   t))
                          ('streaming (llm-chat-streaming
                                       provider
                                       llm-prompt
                                       handler
-                                      (lambda (response)
-                                        (let ((text (plist-get response :text))
-                                              (reasoning (plist-get response 
:reasoning)))
-                                          (funcall handler response)
-                                          (when (or ellama--current-session
-                                                    (not reasoning))
-                                            (kill-buffer reasoning-buffer))
-                                          (with-current-buffer buffer
-                                            (accept-change-group 
ellama--change-group)
-                                            (when ellama-spinner-enabled
-                                              (spinner-stop))
-                                            (if (and (listp donecb)
-                                                     (functionp (car donecb)))
-                                                (mapc (lambda (fn) (funcall fn 
text))
-                                                      donecb)
-                                              (funcall donecb text))
-                                            (when 
ellama-session-hide-org-quotes
-                                              (ellama-collapse-org-quotes))
-                                            (setq ellama--current-request nil)
-                                            (ellama-request-mode -1))))
-                                      (lambda (_ msg)
-                                        (with-current-buffer buffer
-                                          (cancel-change-group 
ellama--change-group)
-                                          (when ellama-spinner-enabled
-                                            (spinner-stop))
-                                          (funcall errcb msg)
-                                          (setq ellama--current-request nil)
-                                          (ellama-request-mode -1)))
+                                      (ellama--response-handler handler 
reasoning-buffer buffer donecb errcb provider llm-prompt nil)
+                                      (ellama--error-handler buffer errcb)
                                       t))
                          ((pred integerp)
                           (let* ((cnt 0)
@@ -1525,34 +1539,8 @@ failure (with BUFFER current).
                              provider
                              llm-prompt
                              skip-handler
-                             (lambda (response)
-                               (let ((text (plist-get response :text))
-                                     (reasoning (plist-get response 
:reasoning)))
-                                 (funcall handler response)
-                                 (when (or ellama--current-session
-                                           (not reasoning))
-                                   (kill-buffer reasoning-buffer))
-                                 (with-current-buffer buffer
-                                   (accept-change-group ellama--change-group)
-                                   (when ellama-spinner-enabled
-                                     (spinner-stop))
-                                   (if (and (listp donecb)
-                                            (functionp (car donecb)))
-                                       (mapc (lambda (fn) (funcall fn text))
-                                             donecb)
-                                     (funcall donecb text))
-                                   (when ellama-session-hide-org-quotes
-                                     (ellama-collapse-org-quotes))
-                                   (setq ellama--current-request nil)
-                                   (ellama-request-mode -1))))
-                             (lambda (_ msg)
-                               (with-current-buffer buffer
-                                 (cancel-change-group ellama--change-group)
-                                 (when ellama-spinner-enabled
-                                   (spinner-stop))
-                                 (funcall errcb msg)
-                                 (setq ellama--current-request nil)
-                                 (ellama-request-mode -1)))
+                             (ellama--response-handler handler 
reasoning-buffer buffer donecb errcb provider llm-prompt t)
+                             (ellama--error-handler buffer errcb)
                              t))))))
          (with-current-buffer buffer
            (setq ellama--current-request request)))))))

Reply via email to