branch: externals/ellama
commit baf078dcb0080a426a6ae38dabe22fc8bc0096db
Author: Sergey Kostyaev <[email protected]>
Commit: Sergey Kostyaev <[email protected]>
tools: warn LLM when reading binary content
---
ellama-tools.el | 82 ++++++++++++++++++++++++++++++++++++++++----------
tests/test-ellama.el | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 151 insertions(+), 15 deletions(-)
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/tests/test-ellama.el b/tests/test-ellama.el
index 375e06abff..8f0e9864b8 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)")))
@@ -1023,6 +1029,84 @@ region, season, or type)! 🍎🍊"))))
(should (equal (ellama--string-without-last-two-lines "Line1\nLine2")
"")))
+(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