branch: externals/ellama
commit 9352e29a3b3e85bccefd2d61626970004a0495fe
Merge: d3ede72b26 4cc6d2e87b
Author: Sergey Kostyaev <[email protected]>
Commit: GitHub <[email protected]>

    Merge pull request #387 from s-kostyaev/fix-system-message-spam
    
    Fix duplicate system message handling in chat prompts
---
 AGENTS.md            |   2 +
 NEWS.org             |  14 +++
 ellama-tools.el      |  82 ++++++++++++++---
 ellama.el            | 255 +++++++++++++++++++++++++++++++--------------------
 tests/test-ellama.el |  99 ++++++++++++++++++++
 5 files changed, 337 insertions(+), 115 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md
index 1d1b1b3828..647bb3160d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -17,6 +17,8 @@
 - **Docstrings**: One‑line summary, then optional details; keep under 80 chars
   per line. Do not add empty lines. If there are multiple sentences on one 
line,
   sentences should be separated by two spaces.
+  Use base verb form in function docstrings (no `-s`), e.g. `contain` not
+  `contains`, `return` not `returns`.
 - **Comments**: Prefix with `;;` for buffer comments; avoid inline `#` clutter.
 
 ## Operation Guidelines
diff --git a/NEWS.org b/NEWS.org
index 6b80c149a0..5af8a452a3 100644
--- a/NEWS.org
+++ b/NEWS.org
@@ -1,3 +1,17 @@
+* Version 1.12.11
+- Fix duplicate system message handling in chat prompts. This change removes 
the
+  redundant ~llm-chat-prompt-append-response~ call that was appending the 
system
+  message on each interaction, ensuring the system message is included only 
once
+  in the initial prompt context setup.
+- Fix bad src block replacement nil match crash that occurred when handling
+  certain source code blocks.
+- Warn LLM when reading binary content to prevent potential parsing errors and
+  improve robustness of tool integration.
+- Generalize docstring verb-form rule to apply consistently across 
documentation
+  and code.
+- Implement automatic retry for tool call errors. This feature adds retry logic
+  to handle transient failures during tool usage by appending error messages to
+  the prompt and re-invoking requests, improving overall request reliability.
 * Version 1.12.10
 - Fix stack overflow in markdown code fence conversion in
   ~ellama--translate-markdown-to-org-filter~ by replacing expensive regular
diff --git a/ellama-tools.el b/ellama-tools.el
index a349f27492..d88d6fa0df 100644
--- a/ellama-tools.el
+++ b/ellama-tools.el
@@ -306,13 +306,38 @@ TOOL-PLIST is a property list in the format expected by 
`llm-make-tool'."
   (interactive)
   (setq ellama-tools-enabled nil))
 
+(defun ellama-tools--string-has-raw-bytes-p (string)
+  "Return non-nil when STRING contain binary-like chars.
+Treat Emacs raw-byte chars and NUL bytes as binary-like."
+  (let ((idx 0)
+        (len (length string))
+        found)
+    (while (and (not found) (< idx len))
+      (when (or (> (aref string idx) #x10FFFF)
+                (= (aref string idx) 0))
+        (setq found t))
+      (setq idx (1+ idx)))
+    found))
+
+(defun ellama-tools--sanitize-tool-text-output (text label)
+  "Return TEXT or a warning when TEXT is binary-like.
+LABEL is used to identify the source in the warning."
+  (if (ellama-tools--string-has-raw-bytes-p text)
+      (concat label
+              " appears to contain binary data.  Reading binary data as "
+              "text is a bad idea for this tool.")
+    text))
+
 (defun ellama-tools-read-file-tool (file-name)
   "Read the file FILE-NAME."
   (json-encode (if (not (file-exists-p file-name))
                    (format "File %s doesn't exists." file-name)
-                 (with-temp-buffer
-                   (insert-file-contents-literally file-name)
-                   (buffer-string)))))
+                 (let ((content (with-temp-buffer
+                                  (insert-file-contents file-name)
+                                  (buffer-string))))
+                   (ellama-tools--sanitize-tool-text-output
+                    content
+                    (format "File %s" file-name))))))
 
 (ellama-tools-define-tool
  '(:function
@@ -522,17 +547,42 @@ Replace OLDCONTENT with NEWCONTENT."
 (defun ellama-tools-shell-command-tool (callback cmd)
   "Execute shell command CMD.
 CALLBACK – function called once with the result string."
-  (let ((buf (get-buffer-create (concat (make-temp-name " *ellama shell 
command") "*"))))
-    (set-process-sentinel
-     (start-process "*ellama-shell-command*" buf shell-file-name 
shell-command-switch cmd)
-     (lambda (process _)
-       (when (not (process-live-p process))
-         (funcall callback
-                  ;; we need to trim trailing newline
-                  (string-trim-right
-                   (with-current-buffer buf (buffer-string))
-                   "\n"))
-         (kill-buffer buf)))))
+  (condition-case err
+      (let ((buf (get-buffer-create
+                 (concat (make-temp-name " *ellama shell command") "*"))))
+       (set-process-sentinel
+        (start-process
+         "*ellama-shell-command*" buf shell-file-name shell-command-switch cmd)
+        (lambda (process _)
+          (when (not (process-live-p process))
+            (let* ((raw-output
+                    ;; trim trailing newline to reduce noisy tool output
+                    (string-trim-right
+                     (with-current-buffer buf (buffer-string))
+                     "\n"))
+                   (output
+                    (ellama-tools--sanitize-tool-text-output
+                     raw-output
+                     "Command output"))
+                   (exit-code (process-exit-status process))
+                   (result
+                    (cond
+                     ((and (string= output "") (zerop exit-code))
+                      "Command completed successfully with no output.")
+                     ((string= output "")
+                      (format "Command failed with exit code %d and no output."
+                              exit-code))
+                     ((zerop exit-code)
+                      output)
+                     (t
+                      (format "Command failed with exit code %d.\n%s"
+                              exit-code output)))))
+              (funcall callback result)
+              (kill-buffer buf))))))
+    (error
+     (funcall callback
+             (format "Failed to start shell command: %s"
+                     (error-message-string err)))))
   ;; async tool should always return nil
   ;; to work properly with the llm library
   nil)
@@ -691,7 +741,9 @@ ANSWER-VARIANT-LIST is a list of possible answer 
variants."))
                                 (forward-line (1- to))
                                 (end-of-line)
                                 (point))))
-                     (buffer-substring-no-properties start end))))))
+                     (ellama-tools--sanitize-tool-text-output
+                      (buffer-substring-no-properties start end)
+                      (format "File %s" file-name)))))))
 
 (ellama-tools-define-tool
  '(:function
diff --git a/ellama.el b/ellama.el
index 78d2f2bd34..c3fd36d428 100644
--- a/ellama.el
+++ b/ellama.el
@@ -6,7 +6,7 @@
 ;; URL: http://github.com/s-kostyaev/ellama
 ;; Keywords: help local tools
 ;; Package-Requires: ((emacs "28.1") (llm "0.24.0") (plz "0.8") (transient 
"0.7") (compat "29.1") (yaml "1.2.3"))
-;; Version: 1.12.10
+;; Version: 1.12.11
 ;; SPDX-License-Identifier: GPL-3.0-or-later
 ;; Created: 8th Oct 2023
 
@@ -568,17 +568,18 @@ It should be a function with single argument generated 
text string."
   "Replace code src blocks in TEXT."
   (with-temp-buffer
     (insert (propertize text 'hard t))
-    (goto-char (point-min))
-    ;; skip good code blocks
-    (while (re-search-forward "#\\+BEGIN_SRC\\(.\\|\n\\)*?#\\+END_SRC" nil t))
-    (while (re-search-forward "#\\+END_SRC\\(\\(.\\|\n\\)*?\\)#\\+END_SRC" nil 
t)
-      (unless (string-match-p "#\\+BEGIN_SRC" (match-string 1))
-       (replace-match "#+BEGIN_SRC\\1#+END_SRC")))
-    (goto-char (match-beginning 0))
-    (while (re-search-backward "#\\+END_SRC\\(\\(.\\|\n\\)*?\\)#\\+END_SRC" 
nil t)
-      (unless (string-match-p "#\\+BEGIN_SRC" (match-string 1))
-       (replace-match "#+BEGIN_SRC\\1#+END_SRC"))
-      (goto-char (match-beginning 0)))
+    (let ((open-blocks 0)
+          (pattern
+           
"^\\([[:blank:]]*\\)#\\+\\(BEGIN_SRC\\|END_SRC\\)\\(?:[[:blank:]].*\\)?$"))
+      (goto-char (point-min))
+      (while (re-search-forward pattern nil t)
+        (if (string= (match-string 2) "BEGIN_SRC")
+            (setq open-blocks (1+ open-blocks))
+          (if (> open-blocks 0)
+              (setq open-blocks (1- open-blocks))
+            (let ((indent (match-string 1)))
+              (replace-match (concat indent "#+BEGIN_SRC") t t))
+            (setq open-blocks (1+ open-blocks))))))
     (buffer-substring-no-properties (point-min) (point-max))))
 
 (defun ellama--replace (from to beg end)
@@ -1441,18 +1442,43 @@ REASONING-BUFFER is a buffer for reasoning."
        (when text
          (string-trim text)))))))
 
-(defun ellama--error-handler (buffer errcb)
+(defun ellama--tool-call-error-p (err-type)
+  "Return non-nil when ERR-TYPE indicates a tool call error."
+  (and err-type
+       (memq 'llm-tool-call-error
+            (get err-type 'error-conditions))))
+
+(defun ellama--append-tool-error-to-prompt (prompt msg)
+  "Append tool call error MSG to PROMPT."
+  (when prompt
+    (llm-chat-prompt-append-response
+     prompt
+     (if (stringp msg)
+        msg
+       (format "%s" (or msg "Unknown tool call error")))
+     'system)))
+
+(defun ellama--error-handler (buffer errcb &optional prompt
+                                    retry-fn)
   "Error handler function.
 BUFFER is the current ellama buffer.
-ERRCB is an error callback."
-  (lambda (_ msg)
+ERRCB is an error callback.
+PROMPT is the active prompt.
+RETRY-FN is called to retry the request."
+  (lambda (err-type 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))))
+      (if (and retry-fn
+              prompt
+              (ellama--tool-call-error-p err-type))
+         (progn
+           (ellama--append-tool-error-to-prompt prompt msg)
+           (funcall retry-fn))
+       (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 (result-handler reasoning-buffer buffer donecb 
errcb provider llm-prompt async filter)
   "Response handler function.
@@ -1475,41 +1501,56 @@ inserted into the BUFFER."
                (not reasoning))
        (when (not tool-result) (kill-buffer reasoning-buffer)))
       (if tool-result
-         (let* ((insert-text
-                 (ellama--insert buffer (with-current-buffer buffer (if 
ellama--current-session
-                                                                        
(point-max)
-                                                                      (point)))
-                                 filter))
-                (insert-reasoning
-                 (ellama--insert reasoning-buffer nil 
#'ellama--translate-markdown-to-org-filter))
-                (handler (ellama--handle-partial insert-text insert-reasoning 
reasoning-buffer))
-                (cnt 0)
-                (skip-handler
-                 (lambda (request)
-                   (if (= cnt ellama-response-process-method)
-                       (progn
-                         (funcall handler request)
-                         (setq cnt 0))
-                     (cl-incf cnt)))))
-           (with-current-buffer buffer
-             (if async
-                 (llm-chat-async
-                  provider
-                  llm-prompt
-                  (ellama--response-handler
-                   handler reasoning-buffer buffer donecb errcb provider 
llm-prompt async filter)
-                  (ellama--error-handler buffer errcb)
-                  t)
-               (llm-chat-streaming
-                provider
-                llm-prompt
-                (if (integerp ellama-response-process-method)
-                    skip-handler handler)
-                (ellama--response-handler
-                 handler
-                 reasoning-buffer buffer donecb errcb provider llm-prompt 
async filter)
-                (ellama--error-handler buffer errcb)
-                t))))
+         (cl-labels
+             ((start-request ()
+                (let* ((insert-text
+                        (ellama--insert
+                         buffer
+                         (with-current-buffer buffer
+                           (if ellama--current-session
+                               (point-max)
+                             (point)))
+                         filter))
+                       (insert-reasoning
+                        (ellama--insert reasoning-buffer nil
+                                        
#'ellama--translate-markdown-to-org-filter))
+                       (handler
+                        (ellama--handle-partial
+                         insert-text insert-reasoning reasoning-buffer))
+                       (cnt 0)
+                       (skip-handler
+                        (lambda (request)
+                          (if (= cnt ellama-response-process-method)
+                              (progn
+                                (funcall handler request)
+                                (setq cnt 0))
+                            (cl-incf cnt))))
+                       (error-handler
+                        (ellama--error-handler
+                         buffer errcb llm-prompt
+                         (lambda ()
+                           (start-request)))))
+                  (with-current-buffer buffer
+                    (if async
+                        (llm-chat-async
+                         provider
+                         llm-prompt
+                         (ellama--response-handler
+                          handler reasoning-buffer buffer donecb errcb provider
+                          llm-prompt async filter)
+                         error-handler
+                         t)
+                      (llm-chat-streaming
+                       provider
+                       llm-prompt
+                       (if (integerp ellama-response-process-method)
+                           skip-handler handler)
+                       (ellama--response-handler
+                        handler reasoning-buffer buffer donecb errcb provider
+                        llm-prompt async filter)
+                       error-handler
+                       t))))))
+           (start-request))
        (with-current-buffer buffer
          (accept-change-group ellama--change-group)
          (when ellama-spinner-enabled
@@ -1596,10 +1637,8 @@ failure (with BUFFER current).
                                prompt-with-ctx)
                               (setf (llm-chat-prompt-tools 
(ellama-session-prompt session))
                                     tools)
-                              (when system
-                                (llm-chat-prompt-append-response
-                                 (ellama-session-prompt session)
-                                 system 'system))
+                              ;; System message is part of prompt context and 
should not be
+                              ;; appended on each interaction.
                               (ellama-session-prompt session))
                           (setf (ellama-session-prompt session)
                                 (llm-make-chat-prompt prompt-with-ctx :context 
system
@@ -1610,48 +1649,64 @@ failure (with BUFFER current).
       (org-mode))
     (with-current-buffer buffer
       (ellama-request-mode +1)
-      (let* ((insert-text
-             (ellama--insert buffer point filter))
-            (insert-reasoning
-             (ellama--insert reasoning-buffer nil 
#'ellama--translate-markdown-to-org-filter)))
-       (setq ellama--change-group (prepare-change-group))
-       (activate-change-group ellama--change-group)
-       (when ellama-spinner-enabled
-         (require 'spinner)
-         (spinner-start ellama-spinner-type))
-       (let* ((handler (ellama--handle-partial insert-text insert-reasoning 
reasoning-buffer))
-              (request (pcase ellama-response-process-method
-                         ('async (llm-chat-async
+      (cl-labels
+         ((start-request ()
+            (let* ((insert-text
+                    (ellama--insert buffer point filter))
+                   (insert-reasoning
+                    (ellama--insert reasoning-buffer nil
+                                    
#'ellama--translate-markdown-to-org-filter))
+                   (handler
+                    (ellama--handle-partial
+                     insert-text insert-reasoning reasoning-buffer))
+                   (error-handler
+                    (ellama--error-handler
+                     buffer errcb llm-prompt
+                     #'start-request))
+                   (request (pcase ellama-response-process-method
+                              ('async (llm-chat-async
+                                       provider
+                                       llm-prompt
+                                       (ellama--response-handler
+                                        handler reasoning-buffer buffer donecb 
errcb provider
+                                        llm-prompt t filter)
+                                       error-handler
+                                       t))
+                              ('streaming (llm-chat-streaming
+                                           provider
+                                           llm-prompt
+                                           handler
+                                           (ellama--response-handler
+                                            handler reasoning-buffer buffer 
donecb errcb
+                                            provider llm-prompt nil filter)
+                                           error-handler
+                                           t))
+                              ((pred integerp)
+                               (let* ((cnt 0)
+                                      (skip-handler
+                                       (lambda (request)
+                                         (if (= cnt 
ellama-response-process-method)
+                                             (progn
+                                               (funcall handler request)
+                                               (setq cnt 0))
+                                           (cl-incf cnt)))))
+                                 (llm-chat-streaming
                                   provider
                                   llm-prompt
-                                  (ellama--response-handler handler 
reasoning-buffer buffer donecb errcb provider llm-prompt t filter)
-                                  (ellama--error-handler buffer errcb)
-                                  t))
-                         ('streaming (llm-chat-streaming
-                                      provider
-                                      llm-prompt
-                                      handler
-                                      (ellama--response-handler handler 
reasoning-buffer buffer donecb errcb provider llm-prompt nil filter)
-                                      (ellama--error-handler buffer errcb)
-                                      t))
-                         ((pred integerp)
-                          (let* ((cnt 0)
-                                 (skip-handler
-                                  (lambda (request)
-                                    (if (= cnt ellama-response-process-method)
-                                        (progn
-                                          (funcall handler request)
-                                          (setq cnt 0))
-                                      (cl-incf cnt)))))
-                            (llm-chat-streaming
-                             provider
-                             llm-prompt
-                             skip-handler
-                             (ellama--response-handler handler 
reasoning-buffer buffer donecb errcb provider llm-prompt t filter)
-                             (ellama--error-handler buffer errcb)
-                             t))))))
-         (with-current-buffer buffer
-           (setq ellama--current-request request)))))))
+                                  skip-handler
+                                  (ellama--response-handler
+                                   handler reasoning-buffer buffer donecb 
errcb provider
+                                   llm-prompt t filter)
+                                  error-handler
+                                  t))))))
+              (setq ellama--change-group (prepare-change-group))
+              (activate-change-group ellama--change-group)
+              (when ellama-spinner-enabled
+                (require 'spinner)
+                (spinner-start ellama-spinner-type))
+              (with-current-buffer buffer
+                (setq ellama--current-request request)))))
+       (start-request)))))
 
 (defun ellama-chain (initial-prompt forms &optional acc)
   "Call chain of FORMS on INITIAL-PROMPT.
diff --git a/tests/test-ellama.el b/tests/test-ellama.el
index b81c5f5ba4..1552d0a505 100644
--- a/tests/test-ellama.el
+++ b/tests/test-ellama.el
@@ -31,6 +31,12 @@
 (require 'ert)
 (require 'llm-fake)
 
+(defconst ellama-test-root
+  (expand-file-name
+   ".."
+   (file-name-directory (or load-file-name buffer-file-name)))
+  "Project root directory for test assets.")
+
 (ert-deftest test-ellama--code-filter ()
   (should (equal "" (ellama--code-filter "")))
   (should (equal "(hello)" (ellama--code-filter "(hello)")))
@@ -842,6 +848,10 @@ That's it."))))
     (should (string-match-p "#\\+END_SRC" result))
     (should (string-match-p "#\\+END_QUOTE" result))))
 
+(ert-deftest test-ellama-replace-bad-code-blocks-no-src-blocks ()
+  (let ((text "\n#+BEGIN_QUOTE\n((shell_command . ))\n#+END_QUOTE\n"))
+    (should (string-equal (ellama--replace-bad-code-blocks text) text))))
+
 (ert-deftest test-ellama-md-to-org-code-inline-latex ()
   (let ((result (ellama--translate-markdown-to-org-filter "_some italic_
 $$P_\\theta(Y_T, ..., Y_2|Y_1, x_1, ..., x_T)$$
@@ -1019,6 +1029,95 @@ region, season, or type)! 🍎🍊"))))
   (should (equal (ellama--string-without-last-two-lines "Line1\nLine2")
                  "")))
 
+(ert-deftest test-ellama--append-tool-error-to-prompt-uses-llm-message ()
+  (let (captured)
+    (cl-letf (((symbol-function 'llm-chat-prompt-append-response)
+              (lambda (_prompt msg role)
+                (setq captured (list msg role)))))
+      (ellama--append-tool-error-to-prompt
+       'prompt
+       "Unknown tool 'search' called"))
+    (should (equal captured
+                  '("Unknown tool 'search' called" system)))))
+
+(defun ellama-test--ensure-local-ellama-tools ()
+  "Ensure tests use local `ellama-tools.el' from project root."
+  (unless (fboundp 'ellama-tools--sanitize-tool-text-output)
+    (load-file (expand-file-name "ellama-tools.el" ellama-test-root))))
+
+(defun ellama-test--wait-shell-command-result (cmd)
+  "Run shell tool CMD and wait for a result string."
+  (ellama-test--ensure-local-ellama-tools)
+  (let ((result :pending)
+       (deadline (+ (float-time) 3.0)))
+    (ellama-tools-shell-command-tool
+     (lambda (res)
+       (setq result res))
+     cmd)
+    (while (and (eq result :pending)
+               (< (float-time) deadline))
+      (accept-process-output nil 0.01))
+    (when (eq result :pending)
+      (ert-fail (format "Timeout while waiting result for: %s" cmd)))
+    result))
+
+(ert-deftest test-ellama-shell-command-tool-empty-success-output ()
+  (should
+   (string=
+    (ellama-test--wait-shell-command-result "sh -c 'true'")
+    "Command completed successfully with no output.")))
+
+(ert-deftest test-ellama-shell-command-tool-empty-failure-output ()
+  (should
+   (string-match-p
+    "Command failed with exit code 7 and no output\\."
+    (ellama-test--wait-shell-command-result "sh -c 'exit 7'"))))
+
+(ert-deftest test-ellama-shell-command-tool-returns-stdout ()
+  (should
+   (string=
+    (ellama-test--wait-shell-command-result "printf 'ok\\n'")
+    "ok")))
+
+(ert-deftest test-ellama-shell-command-tool-rejects-binary-output ()
+  (should
+   (string-match-p
+    "binary data"
+    (ellama-test--wait-shell-command-result
+     "awk 'BEGIN { printf \"%c\", 0 }'"))))
+
+(ert-deftest test-ellama-read-file-tool-rejects-binary-content ()
+  (ellama-test--ensure-local-ellama-tools)
+  (let ((file (make-temp-file "ellama-read-file-bin-")))
+    (unwind-protect
+        (progn
+          (let ((coding-system-for-write 'no-conversion))
+            (with-temp-buffer
+              (set-buffer-multibyte nil)
+              (insert "%PDF-1.5\n%")
+              (insert (char-to-string 143))
+              (insert "\n")
+              (write-region (point-min) (point-max) file nil 'silent)))
+          (let ((result (ellama-tools-read-file-tool file)))
+            (should (string-match-p "binary data" result))
+            (should (string-match-p "bad idea" result))))
+      (when (file-exists-p file)
+        (delete-file file)))))
+
+(ert-deftest test-ellama-read-file-tool-accepts-utf8-markdown-text ()
+  (ellama-test--ensure-local-ellama-tools)
+  (let ((file (make-temp-file "ellama-read-file-utf8-" nil ".md")))
+    (unwind-protect
+        (progn
+          (with-temp-file file
+            (insert "# Research Plan\n\n")
+            (insert "Sub‑topics: temporal reasoning overview.\n"))
+          (let ((result (ellama-tools-read-file-tool file)))
+            (should-not (string-match-p "binary data" result))
+            (should (string-match-p "Research Plan" result))))
+      (when (file-exists-p file)
+        (delete-file file)))))
+
 (provide 'test-ellama)
 
 ;;; test-ellama.el ends here

Reply via email to