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)))))))