;;; ellama-tools.el --- Working with tools -*- lexical-binding: t; package-lint-main-file: "ellama.el"; -*-

;; Copyright (C) 2023-2026  Free Software Foundation, Inc.

;; Author: Sergey Kostyaev <sskostyaev@gmail.com>
;; 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)
(require 'json)
(require 'llm)
(require 'ellama-tools-dlp)

(declare-function llm-standard-provider-p "llm-provider-utils" (provider))
(declare-function ellama-new-session "ellama"
                  (provider prompt &optional ephemeral))
(declare-function ellama-session-p "ellama" (session))
(declare-function ellama-session-extra "ellama" (session))
(declare-function ellama-session-id "ellama" (session))
(declare-function ellama-session-provider "ellama" (session))
(declare-function ellama-get-session-buffer "ellama" (id))
(declare-function ellama-get-nick-prefix-for-mode "ellama" ())
(declare-function ellama--fill-long-lines "ellama" (string))
(declare-function ellama-image-file-p "ellama" (file-name))
(declare-function ellama--image-mime-type "ellama" (file-name))
(declare-function ellama--file-size "ellama" (file-name))
(declare-function ellama--provider-supports-image-input-p "ellama" (provider))
(declare-function ellama--session-add-pending-tool-media
                  "ellama" (session file-name))
(declare-function ellama-stream "ellama" (prompt &rest args))

(defvar ellama-provider)
(defvar ellama-coding-provider)
(defvar ellama-assistant-nick)
(defvar ellama--current-session)
(defvar ellama--current-session-id)

(defvar ellama-tools-available nil
  "Alist containing all registered tools.")

(defvar ellama-tools-enabled nil
  "List of tools that have been enabled.")

(defvar ellama-tools--current-session nil
  "Current Ellama session used while executing tools.")

(defun ellama-tools--set-session-extra (session extra)
  "Set SESSION EXTRA."
  (with-no-warnings
    (setf (ellama-session-extra session) extra)))

(defun ellama-tools--session-extra-with (session &rest pairs)
  "Return SESSION extra plist with PAIRS applied.
PAIRS is a flat plist of keys and values."
  (let ((extra (if (plistp (ellama-session-extra session))
                   (copy-sequence (ellama-session-extra session))
                 nil)))
    (while pairs
      (setq extra (plist-put extra (pop pairs) (pop pairs))))
    extra))

(defcustom ellama-tools-allow-all nil
  "Allow `ellama' using all the tools without user confirmation.
Dangerous.  Use at your own risk."
  :type 'boolean
  :group 'ellama)

(defcustom ellama-tools-allowed nil
  "List of allowed `ellama' tools.
Tools from this list will work without user confirmation."
  :type '(repeat function)
  :group 'ellama)

(defcustom ellama-tools-argument-max-length 50
  "Max length of function argument in the confirmation prompt."
  :type 'integer
  :group 'ellama)

(defcustom ellama-tools-read-file-default-mode 'auto
  "Default mode for the `read_file' tool.
Use `auto' to read text files as text and supported image files as media.
Use `text' to force text reading.
Use `image' to force image handling."
  :type '(choice (const auto)
                 (const text)
                 (const image))
  :group 'ellama)

(defcustom ellama-tools-use-srt nil
  "Run shell-based tools via `srt'.
When non-nil, `shell_command', `grep' and `grep_in_file' run inside the
configured sandbox runtime.
Non-shell file tools also apply local filesystem checks derived from the same
`srt' settings file (`denyRead', `allowWrite', `denyWrite') to reduce policy
drift.  Missing `srt', missing settings, or malformed settings signal a
`user-error' (fail closed)."
  :type 'boolean
  :group 'ellama)

(defcustom ellama-tools-srt-program "srt"
  "Sandbox runtime executable used when `ellama-tools-use-srt' is non-nil."
  :type 'string
  :group 'ellama)

(defcustom ellama-tools-srt-args nil
  "Extra arguments passed to `srt' before the wrapped command.
`--settings'/`-s' in this list also select the config used by local non-shell
filesystem checks.  If not provided, `~/.srt-settings.json' is used."
  :type '(repeat string)
  :group 'ellama)

(defvar ellama-tools--srt-policy-cache nil
  "Cached parsed `srt' filesystem policy.
Plist with keys `:path', `:mtime' and `:policy'.")

(defcustom ellama-tools-subagent-default-max-steps 30
  "Default maximum number of auto-continue steps for a sub-agent."
  :type 'integer
  :group 'ellama)

(defcustom ellama-tools-task-template-dirs
  (list (locate-user-emacs-file "ellama/task-templates"))
  "Directories used to resolve relative task template names.
The `task' tool can render a template before spawning a sub-agent.  When
`template_base' is omitted in the tool call, relative template names are
searched in these directories."
  :type '(repeat directory)
  :group 'ellama)

(defcustom ellama-tools-task-template-allow-absolute-paths nil
  "Allow `task' template names to be absolute file names.
When nil, absolute template names are rejected.  Relative template names are
always resolved under either the tool call's `template_base' argument or
`ellama-tools-task-template-dirs'."
  :type 'boolean
  :group 'ellama)

(defcustom ellama-tools-output-line-budget-enabled t
  "Enable per-tool output line-budget truncation.
The guard applies before output is sent back to the LLM."
  :type 'boolean
  :group 'ellama)

(defcustom ellama-tools-output-line-budget-max-lines 200
  "Maximum line count allowed per tool-output payload."
  :type 'integer
  :group 'ellama)

(defcustom ellama-tools-output-line-budget-max-line-length 4000
  "Maximum character count allowed for one output line."
  :type 'integer
  :group 'ellama)

(defcustom ellama-tools-output-line-budget-save-overflow-file t
  "Save full overflowing output to a temp file when source is unknown."
  :type 'boolean
  :group 'ellama)

(defcustom ellama-tools-subagent-continue-prompt "Task not marked complete. Continue working. If you are done, YOU MUST use the `report_result` tool."
  "Prompt sent to sub-agent to keep the loop going."
  :type 'string
  :group 'ellama)

(defcustom ellama-tools-subagent-roles
  '(("general"
     :system "You are a helpful general assistant."
     :tools :all)

    ("explorer"
     :system "Explore, inspect, and report findings. Do not modify files."
     :tools ("read_file" "directory_tree" "grep" "grep_in_file"
             "count_lines" "lines_range" "project_root" "shell_command"))

    ("coder"
     :system "You are an expert software developer. Make precise changes."
     :tools ("read_file" "write_file" "edit_file" "append_file" "prepend_file"
             "move_file" "grep" "grep_in_file" "project_root" "directory_tree"
             "count_lines" "lines_range" "shell_command")
     :provider 'ellama-coding-provider)

    ("bash"
     :system "You are a bash scripting expert."
     :tools ("shell_command")
     :provider 'ellama-coding-provider))

  "Subagent roles with provider, system prompt and allowed tools."
  :type '(alist :key-type string :value-type plist)
  :group 'ellama)

(defun ellama-tools--for-role (role)
  "Resolve tools allowed for ROLE."
  (let* ((cfg (cdr (assoc role ellama-tools-subagent-roles)))
         (tools (plist-get cfg :tools)))
    (cond
     ((eq tools :all)
      (cl-remove-if
       (lambda (tool) (string= (llm-tool-name tool) "task"))
       ellama-tools-available))
     ((listp tools)
      (cl-remove-if-not
       (lambda (tool) (member (llm-tool-name tool) tools))
       ellama-tools-available))
     (t
      nil))))

(defun ellama-tools--provider-for-role (role)
  "Resolve provider for ROLE."
  (let* ((cfg (cdr (assoc role ellama-tools-subagent-roles)))
         (provider (plist-get cfg :provider)))
    (if (not provider)
        ellama-provider
      (while (not (llm-standard-provider-p provider))
        (setq provider (eval provider)))
      provider)))

(defvar-local ellama-tools-confirm-allowed (make-hash-table)
  "Contains hash table of allowed functions.
Key is a function name symbol.  Value is a boolean t.")

(defconst ellama-tools--call-log-buffer-name "*Ellama Tool Call Logs*"
  "Name of the buffer with tool call confirmation logs.")

(defun ellama-tools--log-call (status function-name args)
  "Append tool call log entry with STATUS for FUNCTION-NAME and ARGS."
  (let ((buf (get-buffer-create ellama-tools--call-log-buffer-name))
        (args-display
         (mapcar (lambda (arg) (format "%S" arg))
                 (cl-remove-if (lambda (arg) (functionp arg)) args))))
    (with-current-buffer buf
      (goto-char (point-max))
      (insert (format-time-string "[%Y-%m-%d %H:%M:%S] "))
      (insert (format "%s %s" status function-name))
      (when args-display
        (insert (format " %s" (mapconcat #'identity args-display ", "))))
      (insert "\n"))))

(defun ellama-tools--confirm-call (function function-name &rest args)
  "Ask for confirmation before calling FUNCTION named FUNCTION-NAME.
ARGS are passed to FUNCTION.
Generates prompt automatically.  User can approve once (y), approve
for all future calls (a), forbid (n), or view the details in a
buffer (v) before deciding.  Returns the result of FUNCTION if
approved, \"Forbidden by the user\" otherwise.
For async tools (callback as first argument), pass string results to the
callback and return nil."
  (let ((confirmation (gethash function ellama-tools-confirm-allowed nil)))
    (cond
     ;; If user has approved all calls, just execute the function
     ((or confirmation
          ellama-tools-allow-all
          (cl-find function ellama-tools-allowed))
      (ellama-tools--log-call "autoaccepted" function-name args)
      (let* ((result (apply function args))
             (result-str (if (stringp result)
                             result
                           (when result
                             (json-encode result))))
             (cb (and args
                      (functionp (car args))
                      (car args))))
        (if cb
            (progn
              (when result-str
                (funcall cb result-str))
              nil)
          (or result-str "done"))))
     ;; Otherwise, ask for confirmation
     (t
      ;; Generate prompt with truncated string arguments
      (save-window-excursion
        (let* ((args-display
                (mapcar (lambda (arg)
                          (cond
                           ((stringp arg)
                            (string-truncate-left
                             arg
                             ellama-tools-argument-max-length))
                           (t
                            (format "%S" arg))))
                        (cl-remove-if (lambda (arg) (functionp arg)) args)))
               (prompt (format "Allow calling %s with arguments: %s?"
                               function-name
                               (mapconcat #'identity args-display ", ")))
               result
               decision)
          (while
              (let ((answer (read-char-choice
                             (format "%s (y)es, (a)lways, (n)o, (r)eply, (v)iew: " prompt)
                             '(?y ?a ?n ?r ?v))))
                (cond
                 ;; View - show buffer with full details
                 ((eq answer ?v)
                  (let* ((buf (get-buffer-create "*Ellama Confirmation*"))
                         (args-full
                          (mapcar (lambda (arg)
                                    (cond
                                     ((stringp arg)
                                      arg)
                                     (t
                                      (format "%S" arg))))
                                  (cl-remove-if (lambda (arg) (functionp arg)) args))))
                    (with-current-buffer buf
                      (erase-buffer)
                      (insert (propertize "Ellama Function Call Confirmation\n"
                                          'face '(:weight bold :height 1.2)))
                      (insert "\n")
                      (insert (format "Function: %s\n\n" function-name))
                      (insert "Arguments:\n")
                      (dolist (arg args-full)
                        (insert (format "  - %s\n" arg))))
                    (display-buffer buf))
                  t) ;; Try again.
                 ;; Yes - execute function once
                 ((eq answer ?y)
                  (setq decision "accepted")
                  (setq result (apply function args))
                  nil) ;; Done.
                 ;; Always - remember approval and execute function
                 ((eq answer ?a)
                  (setq decision "accepted")
                  (puthash function t ellama-tools-confirm-allowed)
                  (setq result (apply function args))
                  nil) ;; done
                 ;; No - return nil
                 ((eq answer ?n)
                  (setq decision "rejected")
                  (setq result "Forbidden by the user")
                  nil) ;; Done.
                 ;; Reply - custom response
                 ((eq answer ?r)
                  (setq decision "rejected")
                  (setq result (read-string "Answer to the agent: "))
                  nil))))
          (when decision
            (ellama-tools--log-call decision function-name args))
          (let ((result-str (if (stringp result)
                                result
                              (when result
                                (json-encode result))))
                (cb (and args
                         (functionp (car args))
                         (car args))))
            (if cb
                (progn
                  (when result-str
                    (funcall cb result-str))
                  nil)
              (or result-str "done")))))))))

(defun ellama-tools-confirm (function &rest args)
  "Ask user for confirmation before calling FUNCTION with ARGS."
  (apply #'ellama-tools--confirm-call
         function
         (if (symbolp function)
             (symbol-name function)
           "anonymous-function")
         args))

(defun ellama-tools-confirm-with-name (function name &rest args)
  "Ask user for confirmation before calling FUNCTION with ARGS.
NAME is fallback label used when FUNCTION has no symbol name."
  (apply #'ellama-tools--confirm-call
         function
         (if (symbolp function)
             (symbol-name function)
           (cond
            ((stringp name) name)
            ((symbolp name) (symbol-name name))
            (t "anonymous-function")))
         args))

(defun ellama-tools--make-confirm-wrapper (func name)
  "Make confirmation wrapper for FUNC.
NAME is fallback label used when FUNC has no symbol name."
  (if (symbolp func)
      (lambda (&rest args)
        (apply #'ellama-tools-confirm func args))
    (lambda (&rest args)
      (apply #'ellama-tools-confirm-with-name func name args))))

(defun ellama-tools--dlp-make-scan-context
    (direction tool-name &optional arg-name tool-metadata)
  "Build DLP scan context for DIRECTION, TOOL-NAME and ARG-NAME.
TOOL-METADATA may include `:tool-origin', `:server-id' and
`:tool-identity' values."
  (let ((tool-origin (plist-get tool-metadata :tool-origin))
        (server-id (plist-get tool-metadata :server-id))
        (tool-identity (plist-get tool-metadata :tool-identity)))
    (ellama-tools-dlp--make-scan-context
     :direction direction
     :tool-name tool-name
     :arg-name arg-name
     :payload-length 0
     :truncated nil
     :tool-origin tool-origin
     :server-id server-id
     :tool-identity tool-identity)))

(defun ellama-tools--tool-category-name (tool-plist)
  "Return normalized category name from TOOL-PLIST, or nil."
  (let ((category (plist-get tool-plist :category)))
    (cond
     ((stringp category) category)
     ((symbolp category) (symbol-name category))
     (t nil))))

(defun ellama-tools--tool-scan-metadata (tool-plist)
  "Return DLP scan metadata plist for TOOL-PLIST."
  (let* ((tool-name (plist-get tool-plist :name))
         (category (ellama-tools--tool-category-name tool-plist)))
    (if (and (stringp category)
             (string-prefix-p "mcp-" category))
        (list :tool-origin 'mcp
              :server-id category
              :tool-identity (format "%s/%s" category tool-name))
      (list :tool-origin 'builtin
            :tool-identity tool-name))))

(defun ellama-tools--tool-call-values (async call-args)
  "Return positional CALL-ARGS, excluding callback when ASYNC."
  (if (and async call-args (functionp (car call-args)))
      (cdr call-args)
    call-args))

(defun ellama-tools--output-source-info (kind path)
  "Return output source info plist for KIND and PATH."
  (when (and (stringp path) (> (length path) 0))
    (list :kind kind
          :path (expand-file-name path))))

(defun ellama-tools--tool-output-source-info (tool-name values)
  "Return source info plist for TOOL-NAME using VALUES."
  (pcase tool-name
    ((or "read_file" "lines_range" "count_lines")
     (ellama-tools--output-source-info 'file (car values)))
    ("grep_in_file"
     (ellama-tools--output-source-info 'file (nth 1 values)))
    ((or "grep" "directory_tree")
     (ellama-tools--output-source-info 'directory (car values)))
    (_
     nil)))

(defun ellama-tools--tool-output-context (tool-plist call-args)
  "Build output context plist from TOOL-PLIST and CALL-ARGS."
  (let* ((tool-name (plist-get tool-plist :name))
         (async (plist-get tool-plist :async))
         (values (ellama-tools--tool-call-values async call-args)))
    (list :source-info (ellama-tools--tool-output-source-info
                        tool-name values))))

(defun ellama-tools--parse-json-string-value (text)
  "Return decoded JSON string from TEXT, or nil when decode fails."
  (condition-case nil
      (let ((decoded (json-parse-string
                      text
                      :object-type 'alist
                      :array-type 'list
                      :null-object nil
                      :false-object nil)))
        (when (stringp decoded)
          decoded))
    (error nil)))

(defun ellama-tools--line-budget-truncate
    (text max-lines max-line-length)
  "Return line-budget truncation plist for TEXT.
MAX-LINES limits how many lines are kept.
MAX-LINE-LENGTH limits one line width in characters."
  (let* ((lines (split-string text "\n" nil))
         (total-lines (length lines))
         (kept 0)
         (long-lines 0)
         (truncated-lines nil)
         (kept-lines nil))
    (dolist (line lines)
      (when (< kept max-lines)
        (if (> (length line) max-line-length)
            (let* ((suffix " ...[line truncated]")
                   (available (- max-line-length (length suffix)))
                   (prefix-len (if (> available 0) available 0))
                   (prefix (if (> prefix-len 0)
                               (substring line 0
                                          (min (length line) prefix-len))
                             ""))
                   (line*
                    (if (> max-line-length (length suffix))
                        (concat prefix suffix)
                      (substring suffix 0 max-line-length))))
              (push line* kept-lines)
              (setq long-lines (1+ long-lines))
              (setq truncated-lines t))
          (push line kept-lines))
        (setq kept (1+ kept))))
    (let ((dropped (max 0 (- total-lines kept))))
      (list :text (string-join (nreverse kept-lines) "\n")
            :truncated (or (> dropped 0) truncated-lines)
            :total-lines total-lines
            :dropped-lines dropped
            :long-lines long-lines))))

(defun ellama-tools--save-overflow-output-file (tool-name text)
  "Save overflowing TEXT for TOOL-NAME to a temp file and return its path."
  (let* ((safe-tool (replace-regexp-in-string
                     "[^[:alnum:]_-]" "-"
                     (or tool-name "tool")))
         (path (make-temp-file
                (format "ellama-%s-output-" safe-tool)
                nil
                ".txt")))
    (condition-case nil
        (progn
          (write-region text nil path nil 'silent)
          path)
      (error nil))))

(defun ellama-tools--output-truncation-notice
    (tool-name truncation source-info saved-path)
  "Return tool output truncation notice string.
TOOL-NAME identifies the tool.
TRUNCATION is a line-budget result plist.
SOURCE-INFO describes source path and kind when known.
SAVED-PATH is optional path to full saved output."
  (let* ((total-lines (plist-get truncation :total-lines))
         (dropped-lines (plist-get truncation :dropped-lines))
         (long-lines (plist-get truncation :long-lines))
         (snippet (plist-get truncation :text))
         (source-kind (plist-get source-info :kind))
         (source-path (plist-get source-info :path)))
    (concat
     "[ELLAMA OUTPUT TRUNCATED]\n"
     (format "Tool `%s` output exceeded line budget and was truncated.\n"
             tool-name)
     (format "Original lines: %d.  Kept up to %d lines.\n"
             total-lines
             (max 0 ellama-tools-output-line-budget-max-lines))
     (when (> dropped-lines 0)
       (format "Dropped lines: %d.\n" dropped-lines))
     (when (> long-lines 0)
       (format
        (concat "Truncated long lines: %d (max %d chars per line).\n")
        long-lines
        (max 1 ellama-tools-output-line-budget-max-line-length)))
     (cond
      ((and source-path (eq source-kind 'file))
       (format
        (concat "Source file: %s\n"
                "Use `lines_range` for more lines, or "
                "`grep_in_file`/`grep` to search.\n")
        source-path))
      ((and source-path (eq source-kind 'directory))
       (format
        (concat "Source directory: %s\n"
                "Use `grep` in this directory, or read a target file with "
                "`read_file`/`lines_range`.\n")
        source-path))
      (saved-path
       (format
        (concat "Full output saved to: %s\n"
                "Use `lines_range` on this file for more lines, or "
                "`grep_in_file`/`grep` to search.\n")
        saved-path))
      (t
       (concat
        "Full output file was not saved.\n"
        "You can rerun the tool with narrower scope and use `grep`.\n")))
     "\n--- BEGIN TRUNCATED OUTPUT ---\n"
     snippet
     "\n--- END TRUNCATED OUTPUT ---")))

(defun ellama-tools--apply-output-line-budget
    (tool-name text &optional output-context)
  "Apply per-output line budget for TOOL-NAME TEXT.
OUTPUT-CONTEXT may include source metadata under `:source-info'."
  (if (or (not ellama-tools-output-line-budget-enabled)
          (not (stringp text)))
      text
    (let* ((max-lines (max 0 ellama-tools-output-line-budget-max-lines))
           (max-line-length
            (max 1 ellama-tools-output-line-budget-max-line-length))
           (source-info (plist-get output-context :source-info))
           (text* (or (ellama-tools--parse-json-string-value text) text))
           (truncation (ellama-tools--line-budget-truncate
                        text* max-lines max-line-length)))
      (if (not (plist-get truncation :truncated))
          text
        (let* ((save-p (and ellama-tools-output-line-budget-save-overflow-file
                            (null source-info)))
               (saved-path (and save-p
                                (ellama-tools--save-overflow-output-file
                                 tool-name text*))))
          (when (fboundp 'ellama-tools-dlp--record-incident)
            (ellama-tools-dlp--record-incident
             (list :type 'output-budget-truncation
                   :tool-name tool-name
                   :action 'truncate
                   :line-count (plist-get truncation :total-lines)
                   :dropped-lines (plist-get truncation :dropped-lines)
                   :long-lines (plist-get truncation :long-lines)
                   :saved-path saved-path)))
          (ellama-tools--output-truncation-notice
           tool-name truncation source-info saved-path))))))

(defun ellama-tools--dlp-handle-output-string
    (tool-name text &optional tool-metadata)
  "Scan output TEXT for TOOL-NAME and return filtered result.
TOOL-METADATA may provide identity fields for scan context."
  (let* ((scan (ellama-tools-dlp--scan-text
                text
                (ellama-tools--dlp-make-scan-context
                 'output tool-name nil tool-metadata)))
         (verdict (plist-get scan :verdict))
         (findings (plist-get scan :findings))
         (action (plist-get verdict :action)))
    (pcase action
      ('allow
       text)
      ((or 'warn 'warn-strong)
       (pcase ellama-tools-dlp-output-warn-behavior
         ('allow
          text)
         ('block
          (or (plist-get verdict :message)
              (format "DLP blocked output for tool %s" tool-name)))
         (_
          (pcase (ellama-tools--dlp-output-warn-choice
                  tool-name (plist-get verdict :message) text findings)
            ('allow
             text)
            ('redact
             (ellama-tools--dlp-redact-output-from-scan
              scan text tool-name))
            (_
             (format "DLP warning denied output for tool %s" tool-name))))))
      ('block
       (or (plist-get verdict :message)
           (format "DLP blocked output for tool %s" tool-name)))
      ('redact
       (or (plist-get verdict :redacted-text)
           (plist-get verdict :message)
           (format "DLP blocked output for tool %s" tool-name)))
      (_
       text))))

(defun ellama-tools--postprocess-output-string
    (tool-name text &optional output-context tool-metadata)
  "Apply output guard and DLP filtering for TOOL-NAME TEXT.
Use OUTPUT-CONTEXT to control budget notices and overflow metadata.
TOOL-METADATA may provide tool identity details for DLP scans."
  (let ((dlp-filtered (if ellama-tools-dlp-enabled
                          (ellama-tools--dlp-handle-output-string
                           tool-name text tool-metadata)
                        text)))
    (ellama-tools--apply-output-line-budget
     tool-name dlp-filtered output-context)))

(defconst ellama-tools--dlp-input-max-walk-depth 24
  "Maximum nested depth traversed when scanning structured tool inputs.")

(defconst ellama-tools--dlp-input-max-walk-nodes 2000
  "Maximum composite/string nodes traversed in one structured input scan.")

(defun ellama-tools--dlp-arg-path-key-name (key)
  "Return path segment name for KEY, or nil when KEY is unsupported."
  (cond
   ((keywordp key)
    (substring (symbol-name key) 1))
   ((symbolp key)
    (symbol-name key))
   ((stringp key)
    key)
   ((numberp key)
    (format "%s" key))
   (t
    nil)))

(defun ellama-tools--dlp-arg-path-append (path child)
  "Return PATH with CHILD segment appended.
CHILD may be a string key segment or an integer index."
  (cond
   ((integerp child)
    (format "%s[%d]" path child))
   ((and (stringp child) (> (length child) 0))
    (format "%s.%s" path child))
   ((stringp child)
    (format "%s.?" path))
   (t
    path)))

(defun ellama-tools--dlp-proper-list-length (value)
  "Return proper list length for VALUE, or nil for dotted/circular lists."
  (and (listp value)
       (condition-case nil
           (length value)
         (error nil))))

(defun ellama-tools--dlp-plist-like-p (value)
  "Return non-nil when VALUE look like a plist with symbol keys."
  (let ((len (ellama-tools--dlp-proper-list-length value))
        (rest value)
        ok)
    (setq ok (and len (zerop (% len 2))))
    (while (and ok rest)
      (let ((key (car rest)))
        (unless (and (symbolp key) key)
          (setq ok nil)))
      (setq rest (cddr rest)))
    ok))

(defun ellama-tools--dlp-alist-like-p (value)
  "Return non-nil when VALUE look like an alist."
  (let ((len (ellama-tools--dlp-proper-list-length value))
        (ok t))
    (when len
      (dolist (entry value)
        (let ((key (and (consp entry) (car entry))))
          (unless (and (consp entry)
                       (or (symbolp key)
                           (stringp key)
                           (numberp key)))
            (setq ok nil)))))
    (and len ok)))

(defun ellama-tools--dlp-alist-entry-value (entry)
  "Return logical value payload for alist ENTRY."
  (if (and (consp (cdr entry))
           (null (cddr entry)))
      (cadr entry)
    (cdr entry)))

(defun ellama-tools--dlp-walk-strings-1
    (node path callback seen node-count depth)
  "Walk NODE and call CALLBACK for string leave at PATH.
SEEN tracks composite objects to avoid cycles.  NODE-COUNT is a one-slot
vector used as a mutable traversal counter.  DEPTH is current nesting depth."
  (when (and (<= depth ellama-tools--dlp-input-max-walk-depth)
             (< (aref node-count 0) ellama-tools--dlp-input-max-walk-nodes))
    (aset node-count 0 (1+ (aref node-count 0)))
    (cond
     ((stringp node)
      (funcall callback node path))
     ((consp node)
      (unless (gethash node seen)
        (puthash node t seen)
        (cond
         ((ellama-tools--dlp-plist-like-p node)
          (let ((rest node)
                (pair-index 0))
            (while rest
              (let* ((key-name
                      (or (ellama-tools--dlp-arg-path-key-name (car rest))
                          (format "key%d" pair-index)))
                     (child-path
                      (ellama-tools--dlp-arg-path-append path key-name)))
                (ellama-tools--dlp-walk-strings-1
                 (cadr rest) child-path callback seen node-count (1+ depth)))
              (setq rest (cddr rest))
              (setq pair-index (1+ pair-index)))))
         ((ellama-tools--dlp-alist-like-p node)
          (let ((entry-index 0))
            (dolist (entry node)
              (let* ((key-name
                      (or (ellama-tools--dlp-arg-path-key-name (car entry))
                          (format "item%d" entry-index)))
                     (child-path
                      (ellama-tools--dlp-arg-path-append path key-name)))
                (ellama-tools--dlp-walk-strings-1
                 (ellama-tools--dlp-alist-entry-value entry)
                 child-path callback seen node-count (1+ depth)))
              (setq entry-index (1+ entry-index)))))
         ((ellama-tools--dlp-proper-list-length node)
          (let ((index 0))
            (dolist (item node)
              (ellama-tools--dlp-walk-strings-1
               item
               (ellama-tools--dlp-arg-path-append path index)
               callback seen node-count (1+ depth))
              (setq index (1+ index)))))
         (t
          (ellama-tools--dlp-walk-strings-1
           (car node)
           (ellama-tools--dlp-arg-path-append path "car")
           callback seen node-count (1+ depth))
          (ellama-tools--dlp-walk-strings-1
           (cdr node)
           (ellama-tools--dlp-arg-path-append path "cdr")
           callback seen node-count (1+ depth))))))
     ((vectorp node)
      (unless (gethash node seen)
        (puthash node t seen)
        (dotimes (index (length node))
          (ellama-tools--dlp-walk-strings-1
           (aref node index)
           (ellama-tools--dlp-arg-path-append path index)
           callback seen node-count (1+ depth)))))
     ((hash-table-p node)
      (unless (gethash node seen)
        (puthash node t seen)
        (let ((entry-index 0))
          (maphash
           (lambda (key item)
             (let* ((key-name
                     (or (ellama-tools--dlp-arg-path-key-name key)
                         (format "key%d" entry-index)))
                    (child-path
                     (ellama-tools--dlp-arg-path-append path key-name)))
               (ellama-tools--dlp-walk-strings-1
                item child-path callback seen node-count (1+ depth))
               (setq entry-index (1+ entry-index))))
           node))))
     (t
      nil))))

(defun ellama-tools--dlp-walk-strings (value root-path callback)
  "Call CALLBACK for each string leaf in VALUE using ROOT-PATH.
CALLBACK receives `(TEXT PATH)'.  Traverse lists, vectors and hash tables."
  (ellama-tools--dlp-walk-strings-1
   value root-path callback
   (make-hash-table :test 'eq)
   (vector 0)
   0))

(defun ellama-tools--dlp-scan-input-value
    (tool-name arg-name value &optional tool-metadata)
  "Scan VALUE for TOOL-NAME under ARG-NAME and return a decision plist."
  (let (warn-message warn-strong-message)
    (catch 'done
      (ellama-tools--dlp-walk-strings
       value arg-name
       (lambda (text path)
         (let* ((scan (ellama-tools-dlp--scan-text
                       text
                       (ellama-tools--dlp-make-scan-context
                        'input tool-name path tool-metadata)))
                (verdict (plist-get scan :verdict))
                (action (plist-get verdict :action))
                (message (plist-get verdict :message)))
           (cond
            ((eq action 'block)
             (throw 'done
                    (list :action 'block
                          :message (or message
                                       (format "DLP blocked input for %s"
                                               tool-name))
                          :audit-sink-failure
                          (plist-get verdict :audit-sink-failure))))
            ((and (eq action 'warn-strong)
                  (not warn-strong-message))
             (setq warn-strong-message
                   (or message
                       (format "DLP warned strongly on input for tool %s"
                               tool-name))))
            ((and (eq action 'warn) (not warn-message))
             (setq warn-message
                   (or message
                       (format "DLP warned on input for tool %s"
                               tool-name))))))))
      (cond
       (warn-strong-message
        (list :action 'warn-strong :message warn-strong-message))
       (warn-message
        (list :action 'warn :message warn-message))
       (t
        (list :action 'allow))))))

(defun ellama-tools--dlp-input-decision (tool-plist call-args)
  "Scan TOOL-PLIST CALL-ARGS and return decision plist.
Return plist with keys `:action' and optional `:message'."
  (let* ((tool-name (plist-get tool-plist :name))
         (async (plist-get tool-plist :async))
         (declared-args (plist-get tool-plist :args))
         (tool-metadata (ellama-tools--tool-scan-metadata tool-plist))
         (values (if (and async call-args (functionp (car call-args)))
                     (cdr call-args)
                   call-args))
         (specs declared-args)
         (index 0)
         warn-message
         warn-strong-message)
    (catch 'done
      (while (and values specs)
        (let* ((value (car values))
               (spec (car specs))
               (raw-name (or (plist-get spec :name)
                             (format "arg%d" (1+ index))))
               (arg-name (if (symbolp raw-name)
                             (symbol-name raw-name)
                           raw-name)))
          (let* ((arg-decision
                  (ellama-tools--dlp-scan-input-value
                   tool-name arg-name value tool-metadata))
                 (action (plist-get arg-decision :action))
                 (message (plist-get arg-decision :message)))
            (cond
             ((eq action 'block)
              (throw 'done arg-decision))
             ((and (eq action 'warn-strong) (not warn-strong-message))
              (setq warn-strong-message message))
             ((and (eq action 'warn) (not warn-message))
              (setq warn-message message)))))
        (setq values (cdr values))
        (setq specs (cdr specs))
        (setq index (1+ index)))
      (if warn-strong-message
          (list :action 'warn-strong :message warn-strong-message)
        (if warn-message
            (list :action 'warn :message warn-message)
          (list :action 'allow))))))

(defun ellama-tools--dlp-confirm-warn (tool-name message &optional subject)
  "Ask explicit confirmation for DLP `warn' on TOOL-NAME with MESSAGE.
SUBJECT describe what is being allowed."
  (eq (read-char-choice
       (format "%s. Proceed with %s %s? (y/n): "
               (or message "DLP warning")
               (or subject "tool")
               tool-name)
       '(?y ?n))
      ?y))

(defun ellama-tools--dlp-blocked-noninteractive-message (tool-name message)
  "Return noninteractive block message for TOOL-NAME with MESSAGE."
  (format
   (concat
    "%s. Interactive typed confirmation is required for irreversible "
    "actions on tool %s")
   (or message "DLP blocked irreversible input")
   tool-name))

(defun ellama-tools--dlp-confirm-warn-strong (tool-name message)
  "Ask typed confirmation for irreversible warning on TOOL-NAME.
MESSAGE is a user-facing warning text."
  (if (not ellama-tools-irreversible-require-typed-confirm)
      (ellama-tools--dlp-confirm-warn tool-name message "irreversible action")
    (let ((typed (read-string
                  (format
                   (concat
                    "%s. Type \"%s\" to proceed with irreversible action "
                    "for tool %s: ")
                   (or message "DLP warn-strong input")
                   ellama-tools-irreversible-typed-confirm-phrase
                   tool-name))))
      (string= typed ellama-tools-irreversible-typed-confirm-phrase))))

(defun ellama-tools--dlp-confirm-audit-sink-failure (tool-name message)
  "Ask explicit confirmation for audit sink failure on TOOL-NAME with MESSAGE."
  (eq (read-char-choice
       (format
        (concat
         "%s. Audit sink write failed for irreversible action on tool %s. "
         "Proceed without durable audit logging? (y/n): ")
        (or message "DLP blocked irreversible input")
        tool-name)
       '(?y ?n))
      ?y))

(defun ellama-tools--dlp-highlight-findings (start text findings)
  "Highlight FINDINGS in TEXT inserted at START."
  (let ((text-length (length text)))
    (dolist (finding findings)
      (let ((span-start (plist-get finding :match-start))
            (span-end (plist-get finding :match-end)))
        (when (and (integerp span-start)
                   (integerp span-end)
                   (<= 0 span-start)
                   (<= span-start span-end)
                   (<= span-end text-length))
          (add-face-text-property (+ start span-start)
                                  (+ start span-end)
                                  'match
                                  t))))))

(defun ellama-tools--dlp-view-output-warning
    (tool-name message text findings)
  "Display output DLP warning details for TOOL-NAME with MESSAGE.
Render TEXT and highlight FINDINGS spans when available."
  (let ((buf (get-buffer-create "*Ellama DLP Warning*")))
    (with-current-buffer buf
      (let ((inhibit-read-only t))
        (erase-buffer)
        (insert (propertize "Ellama DLP Output Warning\n"
                            'face '(:weight bold :height 1.2)))
        (insert "\n")
        (insert (format "Tool: %s\n" tool-name))
        (insert (format "Warning: %s\n\n" (or message "DLP warning")))
        (insert "Output:\n")
        (if (stringp text)
            (let ((text-start (point)))
              (insert text)
              (insert "\n")
              (ellama-tools--dlp-highlight-findings
               text-start text findings))
          (insert "  Output content is unavailable.\n")))
      (goto-char (point-min))
      (view-mode 1))
    (display-buffer buf)))

(defun ellama-tools--dlp-output-warn-choice
    (tool-name message &optional text findings)
  "Return output warn choice for TOOL-NAME with MESSAGE.
When TEXT and FINDINGS are available, allow viewing highlighted output.
Return one of symbols `allow', `redact' or `block'."
  (save-window-excursion
    (catch 'done
      (while t
        (pcase (read-char-choice
                (format
                 "%s. Output from tool %s: (a)llow, (r)edact, (b)lock, (v)iew: "
                 (or message "DLP warning")
                 tool-name)
                '(?a ?r ?b ?y ?n ?v))
          ((or ?a ?y)
           (throw 'done 'allow))
          (?r
           (throw 'done 'redact))
          (?v
           (ellama-tools--dlp-view-output-warning
            tool-name message text findings))
          (_
           (throw 'done 'block)))))))

(defun ellama-tools--dlp-redact-output-from-scan (scan text tool-name)
  "Return best-effort redaction for warn SCAN on TEXT from TOOL-NAME."
  (let ((verdict (plist-get scan :verdict))
        (findings (plist-get scan :findings)))
    (condition-case nil
        (if findings
            (ellama-tools-dlp--apply-redaction text findings)
          (or (plist-get verdict :message)
              (format "DLP blocked output for tool %s" tool-name)))
      (error
       (or (plist-get verdict :message)
           (format "DLP blocked output for tool %s" tool-name))))))

(defun ellama-tools--dlp-wrap-output-callback
    (tool-name callback &optional output-context tool-metadata)
  "Wrap CALLBACK to apply output filtering for TOOL-NAME."
  (lambda (result)
    (funcall callback
             (if (stringp result)
                 (ellama-tools--postprocess-output-string
                  tool-name result output-context tool-metadata)
               result))))

(defun ellama-tools--dlp-return-message (async args message)
  "Return MESSAGE while preserving async callback conventions.
When ASYNC and ARGS start with a callback, send MESSAGE to the callback and
return nil."
  (let ((cb (and async args (functionp (car args)) (car args))))
    (if cb
        (progn
          (funcall cb message)
          nil)
      message)))

(defun ellama-tools--make-dlp-wrapper (func tool-plist)
  "Make DLP wrapper for FUNC using TOOL-PLIST metadata."
  (let ((tool-name (plist-get tool-plist :name))
        (async (plist-get tool-plist :async))
        (tool-metadata (ellama-tools--tool-scan-metadata tool-plist)))
    (lambda (&rest args)
      (let* ((output-context
              (ellama-tools--tool-output-context tool-plist args))
             (wrapped-args
              (if (and async args (functionp (car args)))
                  (cons (ellama-tools--dlp-wrap-output-callback
                         tool-name (car args) output-context tool-metadata)
                        (cdr args))
                args)))
        (if (not ellama-tools-dlp-enabled)
            (let ((result (apply func wrapped-args)))
              (if (and (not async) (stringp result))
                  (ellama-tools--postprocess-output-string
                   tool-name result output-context tool-metadata)
                result))
          (let* ((decision (ellama-tools--dlp-input-decision
                            tool-plist args))
                 (action (plist-get decision :action))
                 (message (plist-get decision :message))
                 (audit-sink-failure
                  (plist-get decision :audit-sink-failure)))
            (pcase action
              ('block
               (if audit-sink-failure
                   (if noninteractive
                       (ellama-tools--dlp-return-message
                        async args
                        (or message
                            (format "DLP blocked input for tool %s"
                                    tool-name)))
                     (if (not (ellama-tools--dlp-confirm-audit-sink-failure
                               tool-name message))
                         (ellama-tools--dlp-return-message
                          async args
                          (format "DLP blocked input for tool %s"
                                  tool-name))
                       (let ((result (apply func wrapped-args)))
                         (if (and (not async) (stringp result))
                             (ellama-tools--postprocess-output-string
                              tool-name result output-context tool-metadata)
                           result))))
                 (ellama-tools--dlp-return-message
                  async args
                  (or message
                      (format "DLP blocked input for tool %s" tool-name)))))
              ('warn
               (if (not (ellama-tools--dlp-confirm-warn tool-name message))
                   (ellama-tools--dlp-return-message
                    async args
                    (format "DLP warning denied tool execution for %s"
                            tool-name))
                 (let ((result (apply func wrapped-args)))
                   (if (and (not async) (stringp result))
                       (ellama-tools--postprocess-output-string
                        tool-name result output-context tool-metadata)
                     result))))
              ('warn-strong
               (if noninteractive
                   (ellama-tools--dlp-return-message
                    async args
                    (ellama-tools--dlp-blocked-noninteractive-message
                     tool-name message))
                 (if (not (ellama-tools--dlp-confirm-warn-strong
                           tool-name message))
                     (ellama-tools--dlp-return-message
                      async args
                      (format "DLP warning denied tool execution for %s"
                              tool-name))
                   (let ((result (apply func wrapped-args)))
                     (if (and (not async) (stringp result))
                         (ellama-tools--postprocess-output-string
                          tool-name result output-context tool-metadata)
                       result)))))
              (_
               (let ((result (apply func wrapped-args)))
                 (if (and (not async) (stringp result))
                     (ellama-tools--postprocess-output-string
                      tool-name result output-context tool-metadata)
                   result))))))))))

(defun ellama-tools-wrap-with-confirm (tool-plist)
  "Wrap a tool's function with automatic confirmation and DLP.
TOOL-PLIST is a property list in the format expected by `llm-make-tool'.
Returns a new tool definition with the :function wrapped."
  (let* ((func (plist-get tool-plist :function))
         (name (plist-get tool-plist :name))
         (args (plist-get tool-plist :args))
         (wrapped-args
          (mapcar
           (lambda (arg)
             (let* ((type (plist-get arg :type))
                    (wrapped-type
                     (if (symbolp type)
                         type
                       (and type (intern type)))))
               (plist-put arg :type wrapped-type)))
           args))
         (confirm-func (ellama-tools--make-confirm-wrapper func name))
         (wrapped-func
          (ellama-tools--make-dlp-wrapper confirm-func tool-plist)))
    ;; Return a new plist with the wrapped function
    (setq tool-plist (plist-put tool-plist :function wrapped-func))
    (plist-put tool-plist :args wrapped-args)))

(defun ellama-tools--tool-name= (tool name)
  "Return non-nil when TOOL name equals NAME."
  (string= name (llm-tool-name tool)))

(defun ellama-tools--remove-tool-by-name (tools name)
  "Return TOOLS list without entries named NAME."
  (seq-remove (lambda (tool)
                (ellama-tools--tool-name= tool name))
              tools))

(defun ellama-tools-define-tool (tool-plist)
  "Define or replace an ellama tool with automatic confirmation wrapping.
TOOL-PLIST is a property list in the format expected by `llm-make-tool'."
  (let* ((wrapped-tool
          (apply 'llm-make-tool (ellama-tools-wrap-with-confirm tool-plist)))
         (name (llm-tool-name wrapped-tool))
         (enabled-p (cl-some (lambda (tool)
                               (ellama-tools--tool-name= tool name))
                             ellama-tools-enabled)))
    (setq ellama-tools-available
          (cons wrapped-tool
                (ellama-tools--remove-tool-by-name
                 ellama-tools-available name)))
    (setq ellama-tools-enabled
          (if enabled-p
              (cons wrapped-tool
                    (ellama-tools--remove-tool-by-name
                     ellama-tools-enabled name))
            (ellama-tools--remove-tool-by-name
             ellama-tools-enabled name)))))

(defun ellama-tools-enable-by-name-tool (name)
  "Add to `ellama-tools-enabled' each tool that matches NAME."
  (let* ((tool-name name)
         (tool (seq-find (lambda (tool) (string= tool-name (llm-tool-name tool)))
                         ellama-tools-available)))
    (when tool
      (add-to-list 'ellama-tools-enabled tool))
    nil))

;;;###autoload
(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))))))
    (ellama-tools-enable-by-name-tool tool-name)))

(defun ellama-tools-disable-by-name-tool (name)
  "Remove from `ellama-tools-enabled' each tool that matches NAME."
  (let* ((tool (seq-find (lambda (tool) (string= name (llm-tool-name tool)))
                         ellama-tools-enabled)))
    (setq ellama-tools-enabled (seq-remove (lambda (enabled-tool) (eq enabled-tool tool))
                                           ellama-tools-enabled))))

;;;###autoload
(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)))))
    (ellama-tools-disable-by-name-tool tool-name)))

;;;###autoload
(defun ellama-tools-enable-all ()
  "Enable all available tools."
  (interactive)
  (setq ellama-tools-enabled ellama-tools-available))

;;;###autoload
(defun ellama-tools-disable-all ()
  "Disable all enabled tools."
  (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--srt-policy-clear-cache ()
  "Clear cached parsed `srt' policy."
  (setq ellama-tools--srt-policy-cache nil))

(defun ellama-tools--srt-settings-file ()
  "Return resolved `srt' settings file path."
  (let ((args ellama-tools-srt-args)
        settings)
    (while args
      (let ((arg (pop args)))
        (cond
         ((member arg '("--settings" "-s"))
          (unless args
            (user-error "Missing value after `%s' in `ellama-tools-srt-args'"
                        arg))
          (setq settings (pop args)))
         ((string-prefix-p "--settings=" arg)
          (setq settings (substring arg (length "--settings=")))))))
    (expand-file-name (or settings "~/.srt-settings.json"))))

(defun ellama-tools--srt-file-mtime (path)
  "Return modification time for PATH as a float."
  (unless (file-exists-p path)
    (user-error "Missing srt settings file: %s" path))
  (float-time
   (file-attribute-modification-time
    (file-attributes path 'integer))))

(defun ellama-tools--srt-array-to-list (value)
  "Convert JSON array VALUE to a list or return nil."
  (cond
   ((null value) nil)
   ((vectorp value) (append value nil))
   ((listp value) value)
   (t value)))

(defun ellama-tools--srt-string-list (value key config-path)
  "Return VALUE as a list of strings for KEY from CONFIG-PATH.
Signal `user-error' when VALUE has an invalid shape."
  (setq value (ellama-tools--srt-array-to-list value))
  (unless (or (null value) (listp value))
    (user-error "Malformed srt config %s: `filesystem.%s' must be a list"
                config-path key))
  (dolist (item value)
    (unless (stringp item)
      (user-error "Malformed srt config %s: `filesystem.%s' must contain strings"
                  config-path key)))
  value)

(defun ellama-tools--srt-policy-load (config-path)
  "Read and parse `srt' settings from CONFIG-PATH."
  (unless (file-exists-p config-path)
    (user-error "Missing srt settings file: %s" config-path))
  (let* ((json (condition-case err
                   (with-temp-buffer
                     (insert-file-contents-literally config-path)
                     (json-parse-buffer :object-type 'alist
                                        :array-type 'array
                                        :null-object :json-null
                                        :false-object :json-false))
                 (json-parse-error
                  (user-error "Invalid srt JSON config %s: %s"
                              config-path
                              (error-message-string err)))
                 (file-error
                  (user-error "Cannot read srt settings file %s: %s"
                              config-path
                              (error-message-string err)))))
         (filesystem-pair (and (listp json) (assq 'filesystem json)))
         (filesystem (cdr filesystem-pair)))
    (unless (listp json)
      (user-error "Malformed srt config %s: top-level JSON object is required"
                  config-path))
    (when filesystem-pair
      (unless (and (listp filesystem)
                   (not (eq filesystem :json-null)))
        (user-error "Malformed srt config %s: `filesystem' must be an object"
                    config-path)))
    (list :config-path config-path
          :deny-read
          (if filesystem-pair
              (ellama-tools--srt-string-list
               (alist-get 'denyRead filesystem) "denyRead" config-path)
            nil)
          :allow-write
          (if filesystem-pair
              (ellama-tools--srt-string-list
               (alist-get 'allowWrite filesystem) "allowWrite" config-path)
            nil)
          :deny-write
          (if filesystem-pair
              (ellama-tools--srt-string-list
               (alist-get 'denyWrite filesystem) "denyWrite" config-path)
            nil))))

(defun ellama-tools--srt-policy-current ()
  "Return current parsed `srt' filesystem policy, using a small cache."
  (let* ((config-path (ellama-tools--srt-settings-file))
         (mtime (ellama-tools--srt-file-mtime config-path))
         (cache ellama-tools--srt-policy-cache))
    (if (and cache
             (equal (plist-get cache :path) config-path)
             (equal (plist-get cache :mtime) mtime))
        (plist-get cache :policy)
      (let ((policy (ellama-tools--srt-policy-load config-path)))
        (setq ellama-tools--srt-policy-cache
              (list :path config-path :mtime mtime :policy policy))
        policy))))

(defun ellama-tools--srt-strip-trailing-slashes (path)
  "Return PATH without trailing slashes, except for root."
  (if (string-match-p "\\`/+\\'" path)
      "/"
    (replace-regexp-in-string "/+\\'" "" path)))

(defun ellama-tools--srt-truename-if-possible (path)
  "Return `file-truename' for PATH when possible, else nil."
  (condition-case nil
      (file-truename path)
    (file-error nil)))

(defun ellama-tools--srt-path-exists-p (path)
  "Return non-nil when PATH exists or is a symlink."
  (let ((expanded (expand-file-name path)))
    (or (file-exists-p expanded)
        (file-symlink-p expanded))))

(defun ellama-tools--srt-normalize-literal-path (path)
  "Return normalized literal PATH for policy comparisons."
  (ellama-tools--srt-strip-trailing-slashes
   (or (ellama-tools--srt-truename-if-possible path)
       (expand-file-name path))))

(defun ellama-tools--srt-normalize-rule-literal-path (path)
  "Return normalized literal rule PATH for policy comparisons.
On macOS, keep the lexical path for exact symlink rules to match observed
`srt' behavior for `denyRead' symlink-path entries."
  (let* ((expanded (expand-file-name path))
         (probe (ellama-tools--srt-strip-trailing-slashes expanded)))
    (ellama-tools--srt-strip-trailing-slashes
     (if (and (eq system-type 'darwin)
              (file-symlink-p probe))
         expanded
       (or (ellama-tools--srt-truename-if-possible expanded)
           expanded)))))

(defun ellama-tools--srt-nearest-existing-dir (dir)
  "Return nearest existing ancestor directory for DIR."
  (let ((current (expand-file-name dir)))
    (while (and current (not (file-directory-p current)))
      (let* ((trimmed (directory-file-name current))
             (parent (file-name-directory trimmed)))
        (setq current
              (unless (or (null parent)
                          (equal (expand-file-name parent)
                                 (expand-file-name current)))
                parent))))
    current))

(defun ellama-tools--srt-normalize-nonexisting-target-path (path)
  "Return normalized PATH for a non-existing write target.
Preserve missing intermediate path segments after resolving the nearest
existing ancestor with `file-truename'."
  (let* ((expanded (expand-file-name path))
         (anchor (ellama-tools--srt-nearest-existing-dir
                  (file-name-directory expanded))))
    (if (not anchor)
        (ellama-tools--srt-strip-trailing-slashes expanded)
      (let* ((anchor-expanded (file-name-as-directory
                               (expand-file-name anchor)))
             (anchor-normalized (file-name-as-directory
                                 (ellama-tools--srt-strip-trailing-slashes
                                  (or (ellama-tools--srt-truename-if-possible
                                       anchor)
                                      anchor-expanded))))
             (suffix (file-relative-name expanded anchor-expanded)))
        (ellama-tools--srt-strip-trailing-slashes
         (expand-file-name suffix anchor-normalized))))))

(defun ellama-tools--srt-normalize-target-path (path op)
  "Return normalized target PATH for OP policy check."
  (let ((expanded (expand-file-name path)))
    (if (or (memq op '(read list))
            (file-exists-p expanded)
            (file-directory-p expanded))
        (ellama-tools--srt-normalize-literal-path expanded)
      (ellama-tools--srt-normalize-nonexisting-target-path expanded))))

(defun ellama-tools--srt-path-has-glob-p (path)
  "Return non-nil when PATH look like a glob pattern."
  (string-match-p "[*?\\[]" path))

(defun ellama-tools--srt-platform-glob-support-p ()
  "Return non-nil when local matcher should treat patterns as globs."
  t)

(defun ellama-tools--srt-glob-pattern-candidates (rule)
  "Return glob pattern candidates for RULE.
On macOS, a path may be referenced via `/var' while `file-truename'
resolves to `/private/var'.  Add a candidate with a normalized
existing directory prefix when possible."
  (let* ((expanded (expand-file-name rule))
         (pattern (if (string-suffix-p "/" rule)
                      (concat expanded "*")
                    expanded))
         (candidates (list pattern))
         (dir (file-name-directory pattern))
         (tail (file-name-nondirectory pattern)))
    (when (and dir (not (ellama-tools--srt-path-has-glob-p dir)))
      (let ((true-dir (ellama-tools--srt-truename-if-possible dir)))
        (when true-dir
          (let ((alt (concat (file-name-as-directory
                              (ellama-tools--srt-strip-trailing-slashes
                               true-dir))
                             tail)))
            (unless (member alt candidates)
              (push alt candidates))))))
    (nreverse candidates)))

(defun ellama-tools--srt-dir-prefix-match-p (dir target)
  "Return non-nil when TARGET is DIR or inside DIR."
  (let ((dir (ellama-tools--srt-strip-trailing-slashes dir))
        (target (ellama-tools--srt-strip-trailing-slashes target)))
    (or (string= dir target)
        (string-prefix-p (concat dir "/") target))))

(defun ellama-tools--srt-rule-match-p (target rule)
  "Return non-nil when normalized TARGET matches filesystem RULE."
  (let* ((raw rule)
         (globp (and (ellama-tools--srt-platform-glob-support-p)
                     (ellama-tools--srt-path-has-glob-p raw)))
         (dir-marked-p (string-suffix-p "/" raw))
         (expanded (expand-file-name raw))
         (dirp (or dir-marked-p
                   (and (not globp) (file-directory-p expanded)))))
    (cond
     (globp
      (catch 'matched
        (dolist (pattern (ellama-tools--srt-glob-pattern-candidates raw))
          (let ((regex (condition-case err
                           (wildcard-to-regexp pattern)
                         (invalid-regexp
                          (user-error "Unsupported srt filesystem pattern `%s': %s"
                                      raw
                                      (error-message-string err))))))
            (when (string-match-p regex target)
              (throw 'matched t))))
        nil))
     (dirp
      (ellama-tools--srt-dir-prefix-match-p
       (ellama-tools--srt-normalize-rule-literal-path expanded)
       target))
     (t
      (string=
       (ellama-tools--srt-normalize-rule-literal-path expanded)
       target)))))

(defun ellama-tools--srt-rule-match-any-p (target rules)
  "Return non-nil when normalized TARGET matches one of RULES."
  (catch 'matched
    (dolist (rule rules)
      (when (ellama-tools--srt-rule-match-p target rule)
        (throw 'matched t)))
    nil))

(defun ellama-tools--srt-literal-file-rule-p (rule)
  "Return non-nil when RULE look like a literal file path rule."
  (let* ((globp (ellama-tools--srt-path-has-glob-p rule))
         (expanded (expand-file-name rule)))
    (and (not globp)
         (not (string-suffix-p "/" rule))
         (not (file-directory-p expanded)))))

(defun ellama-tools--srt-deny-write-match-any-p (target rules target-exists)
  "Return non-nil when deny-write RULES should block TARGET.
When TARGET-EXISTS is nil, skip exact literal file deny rules to match
observed `srt' behavior on macOS for new file creation under an allowed
directory."
  (catch 'matched
    (dolist (rule rules)
      (when (and (ellama-tools--srt-rule-match-p target rule)
                 (or target-exists
                     ;; Current observed parity:
                     ;; macOS may allow creation despite exact file denyWrite.
                     ;; Linux denies it.
                     (not (and (eq system-type 'darwin)
                               (ellama-tools--srt-literal-file-rule-p rule)))))
        (throw 'matched t)))
    nil))

(defun ellama-tools--srt-check-access (path op)
  "Return nil when PATH is allowed for OP, else a deny reason string."
  (let* ((policy (ellama-tools--srt-policy-current))
         (target-exists (ellama-tools--srt-path-exists-p path))
         (target (ellama-tools--srt-normalize-target-path path op))
         (deny-read (plist-get policy :deny-read))
         (allow-write (plist-get policy :allow-write))
         (deny-write (plist-get policy :deny-write)))
    (pcase op
      ((or 'read 'list)
       (when (ellama-tools--srt-rule-match-any-p target deny-read)
         "Denied by `filesystem.denyRead'"))
      ('write
       (cond
        ((ellama-tools--srt-deny-write-match-any-p
          target deny-write target-exists)
         "Denied by `filesystem.denyWrite'")
        ((not (ellama-tools--srt-rule-match-any-p target allow-write))
         "Denied because write access is not allowed by `filesystem.allowWrite'")
        (t nil)))
      (_
       (error "Unsupported srt access operation: %S" op)))))

(defun ellama-tools--tool-check-file-access (path op)
  "Check local `srt' filesystem policy for PATH and OP.
Return error message on denial when `ellama-tools-use-srt' is non-nil."
  (when ellama-tools-use-srt
    (let ((reason (ellama-tools--srt-check-access path op)))
      (when reason
        (let* ((policy (ellama-tools--srt-policy-current))
               (config-path (plist-get policy :config-path))
               (target (ellama-tools--srt-normalize-target-path path op)))
          (format "srt policy denied %s access to %s (target %s) using %s: %s"
                  op path target config-path reason))))))

(defun ellama-tools--command-argv (program &rest args)
  "Return argv for PROGRAM and ARGS.
Wrap command with `srt' when `ellama-tools-use-srt' is non-nil."
  (if (not ellama-tools-use-srt)
      (cons program args)
    (let ((srt-path (executable-find ellama-tools-srt-program)))
      (unless srt-path
        (user-error
         (concat
          "Cannot find `srt' executable `%s'.  Install sandbox-runtime "
          "or disable `ellama-tools-use-srt'")
         ellama-tools-srt-program))
      (append (list srt-path) ellama-tools-srt-args (cons program args)))))

(defun ellama-tools--call-command-to-string (program &rest args)
  "Run PROGRAM with ARGS and return stdout as a string."
  (let ((argv (apply #'ellama-tools--command-argv program args)))
    (with-temp-buffer
      (apply #'call-process (car argv) nil t nil (cdr argv))
      (buffer-string))))

(defun ellama-tools--read-file-mode (mode)
  "Return normalized read file MODE."
  (let ((mode (or mode ellama-tools-read-file-default-mode)))
    (cond
     ((symbolp mode) mode)
     ((stringp mode) (intern (downcase mode)))
     (t 'auto))))

(defun ellama-tools--binary-file-p (file-name)
  "Return non-nil when FILE-NAME appears to be binary."
  (with-temp-buffer
    (set-buffer-multibyte nil)
    (insert-file-contents-literally file-name nil 0 4096)
    (goto-char (point-min))
    (search-forward (string 0) nil t)))

(defun ellama-tools--read-file-as-text (file-name)
  "Read FILE-NAME as text for tool output."
  (if (ellama-tools--binary-file-p file-name)
      (format "File %s appears to be binary; text reading was not performed."
              file-name)
    (let ((content (with-temp-buffer
                     (insert-file-contents file-name)
                     (buffer-string))))
      (ellama-tools--sanitize-tool-text-output
       content
       (format "File %s" file-name)))))

(defun ellama-tools--read-image-file-tool (file-name)
  "Queue image FILE-NAME for model input and return textual tool output."
  (cond
   ((string= (downcase (or (file-name-extension file-name) "")) "svg")
    (format "SVG image input is not supported yet: %s" file-name))
   ((not (ellama-image-file-p file-name))
    (format "File %s is not a supported image file." file-name))
   ((not (ellama-session-p
          (or ellama--current-session ellama-tools--current-session)))
    "Cannot attach image file: no active Ellama session.")
   ((not (ellama--provider-supports-image-input-p
          (ellama-session-provider
           (or ellama--current-session ellama-tools--current-session))))
    (format "Provider %s does not support image input."
            (llm-name
             (ellama-session-provider
              (or ellama--current-session ellama-tools--current-session)))))
   (t
    (ellama--session-add-pending-tool-media
     (or ellama--current-session ellama-tools--current-session)
     file-name)
    (let* ((expanded (expand-file-name file-name))
           (link (if (provided-mode-derived-p major-mode 'org-mode)
                     (format "[[file:%s][%s]]" expanded file-name)
                   (format "[%s](file://%s)" file-name expanded))))
      (format "Image file queued for model input: %s (%s, %d bytes)."
              link
              (ellama--image-mime-type file-name)
              (ellama--file-size file-name))))))

(defun ellama-tools-read-file-tool (file-name &optional mode)
  "Read the file FILE-NAME.
MODE can be `auto', `text' or `image'."
  (or (ellama-tools--tool-check-file-access file-name 'read)
      (json-encode
       (if (not (file-exists-p file-name))
           (format "File %s doesn't exists." file-name)
         (pcase (ellama-tools--read-file-mode mode)
           ('auto
            (if (ellama-image-file-p file-name)
                (ellama-tools--read-image-file-tool file-name)
              (ellama-tools--read-file-as-text file-name)))
           ('text
            (ellama-tools--read-file-as-text file-name))
           ('image
            (ellama-tools--read-image-file-tool file-name))
           (_
            (format "Unsupported read_file mode %S. Use auto, text or image."
                    mode)))))))

(ellama-tools-define-tool
 '(:function
   ellama-tools-read-file-tool
   :name
   "read_file"
   :args
   ((:name
     "file_name"
     :type
     string
     :description
     "File name.")
    (:name
     "mode"
     :type
     string
     :optional
     t
     :enum
     ["auto" "text" "image"]
     :description
     "Read mode: auto, text, or image."))
   :description
   "Read the file FILE_NAME."))

(defun ellama-tools-write-file-tool (file-name content)
  "Write CONTENT to the file FILE-NAME."
  (or (ellama-tools--tool-check-file-access file-name 'write)
      (write-region content nil file-name nil 'silent)))

(ellama-tools-define-tool
 '(:function
   ellama-tools-write-file-tool
   :name
   "write_file"
   :args
   ((:name
     "file_name"
     :type
     string
     :description
     "Name of the file.")
    (:name
     "content"
     :type
     string
     :description
     "Content to write to the file."))
   :description
   "Write CONTENT to the file FILE_NAME."))

(defun ellama-tools-append-file-tool (file-name content)
  "Append CONTENT to the file FILE-NAME."
  (or (ellama-tools--tool-check-file-access file-name 'write)
      (with-current-buffer (find-file-noselect file-name)
        (goto-char (point-max))
        (insert content)
        (save-buffer))))

(ellama-tools-define-tool
 '(:function
   ellama-tools-append-file-tool
   :name
   "append_file"
   :args
   ((:name
     "file_name"
     :type
     string
     :description
     "Name of the file.")
    (:name
     "content"
     :type
     string
     :description
     "Content to append to the file."))
   :description
   "Append CONTENT to the file FILE-NAME."))

(defun ellama-tools-prepend-file-tool (file-name content)
  "Prepend CONTENT to the file FILE-NAME."
  (or (ellama-tools--tool-check-file-access file-name 'write)
      (with-current-buffer (find-file-noselect file-name)
        (goto-char (point-min))
        (insert content)
        (save-buffer))))

(ellama-tools-define-tool
 '(:function
   ellama-tools-prepend-file-tool
   :name
   "prepend_file"
   :args
   ((:name
     "file_name"
     :type
     string
     :description
     "Name of the file.")
    (:name
     "content"
     :type
     string
     :description
     "Content to prepend to the file."))
   :description
   "Prepend CONTENT to the file FILE_NAME."))

(defun ellama-tools-directory-tree-tool (dir &optional depth)
  "Return a string representing the directory tree under DIR.
DEPTH is the current recursion depth, used internally."
  (or (ellama-tools--tool-check-file-access dir 'list)
      (if (not (file-exists-p dir))
          (format "Directory %s doesn't exists" dir)
        (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-tool full (+ (or depth 0) 1)))))))
          tree))))

(ellama-tools-define-tool
 '(:function
   ellama-tools-directory-tree-tool
   :name
   "directory_tree"
   :args
   ((: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-tool (file-name new-file-name)
  "Move the file from the specified FILE-NAME to the NEW-FILE-NAME."
  (or (ellama-tools--tool-check-file-access file-name 'read)
      (ellama-tools--tool-check-file-access file-name 'write)
      (ellama-tools--tool-check-file-access new-file-name 'write)
      (if (and (file-exists-p file-name)
               (not (file-exists-p new-file-name)))
          (progn
            (rename-file file-name new-file-name))
        (error "Cannot move file: source file does not exist or destination already exists"))))

(ellama-tools-define-tool
 '(:function
   ellama-tools-move-file-tool
   :name
   "move_file"
   :args
   ((:name
     "file_name"
     :type
     string
     :description
     "Current name of the file.")
    (:name
     "new_file_name"
     :type
     string
     :description
     "New name of the file."))
   :description
   "Move the file from the specified FILE_NAME to the NEW_FILE_NAME."))

(defun ellama-tools-edit-file-tool (file-name oldcontent newcontent)
  "Edit file FILE-NAME.
Replace OLDCONTENT with NEWCONTENT."
  (or (ellama-tools--tool-check-file-access file-name 'read)
      (ellama-tools--tool-check-file-access file-name 'write)
      (let ((content (with-temp-buffer
                       (insert-file-contents-literally file-name)
                       (buffer-string)))
            (coding-system-for-write 'raw-text))
        (when (string-match (regexp-quote oldcontent) content)
          (with-temp-buffer
            (insert (replace-match newcontent t t content))
            (write-region (point-min) (point-max) file-name))))))

(ellama-tools-define-tool
 '(:function
   ellama-tools-edit-file-tool
   :name
   "edit_file"
   :args
   ((:name
     "file_name"
     :type
     string
     :description
     "Name of 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 FILE_NAME. 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 ((argv (ellama-tools--command-argv
               shell-file-name shell-command-switch cmd)))
    (condition-case err
        (let ((buf (get-buffer-create
                    (concat (make-temp-name " *ellama shell command") "*"))))
          (set-process-sentinel
           (apply #'start-process
                  "*ellama-shell-command*" buf
                  (car argv) (cdr argv))
           (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)

(ellama-tools-define-tool
 '(:function
   ellama-tools-shell-command-tool
   :name
   "shell_command"
   :async t
   :args
   ((:name
     "cmd"
     :type
     string
     :description
     "Shell command to execute."))
   :description
   "Execute shell command CMD."))

(defun ellama-tools-grep-tool (dir search-string)
  "Grep SEARCH-STRING in DIR files."
  (let ((default-directory dir))
    (json-encode
     (string-trim-right
      (ellama-tools--call-command-to-string
       "find" "." "-type" "f" "-exec"
       "grep" "--color=never" "-nH" "-e" search-string "{}" "+")
      "\n"))))

(ellama-tools-define-tool
 '(:function
   ellama-tools-grep-tool
   :name
   "grep"
   :args
   ((:name
     "dir"
     :type
     string
     :description
     "Directory to search in.")
    (:name
     "search_string"
     :type
     string
     :description
     "String to search for."))
   :description
   "Grep SEARCH-STRING in directory files."))

(defun ellama-tools-grep-in-file-tool (search-string file)
  "Grep SEARCH-STRING in FILE."
  (json-encode
   (ellama-tools--call-command-to-string
    "grep" "--color=never" "-nh" search-string (file-truename file))))

(ellama-tools-define-tool
 '(:function
   ellama-tools-grep-in-file-tool
   :name "grep_in_file"
   :args ((:name "search_string" :type string :description "String to search for.")
          (:name "file" :type string :description "File to search in."))
   :description "Grep SEARCH-STRING in FILE."))

(defun ellama-tools-now-tool ()
  "Return current date, time and timezone."
  (format-time-string "%Y-%m-%d %H:%M:%S %Z"))

(ellama-tools-define-tool
 '(:function
   ellama-tools-now-tool
   :name
   "now"
   :args
   nil
   :description
   "Return current date, time and timezone."))

(defun ellama-tools-project-root-tool ()
  "Return current project root directory."
  (when (project-current)
    (project-root (project-current))))

(ellama-tools-define-tool
 '(:function
   ellama-tools-project-root-tool
   :name
   "project_root"
   :args
   nil
   :description
   "Return current project root directory."))

(defun ellama-tools-ask-user-tool (question answer-variant-list)
  "Ask user a QUESTION to receive a clarification.
ANSWER-VARIANT-LIST is a list of possible answer variants."
  (completing-read (concat question " ")
                   (if (stringp answer-variant-list)
                       (seq--into-list (json-parse-string answer-variant-list))
                     (seq--into-list answer-variant-list))))

(ellama-tools-define-tool
 '(:function
   ellama-tools-ask-user-tool
   :name
   "ask_user"
   :args
   ((:name
     "question"
     :type
     string
     :description
     "Question to ask user for clarification.")
    (:name
     "answer_variant_list"
     :type array
     :description
     "List of possible answer variants."
     :items (:type string)))
   :description
   "Ask user a QUESTION to receive a clarification.
ANSWER-VARIANT-LIST is a list of possible answer variants."))

(defun ellama-tools-count-lines-tool (file-name)
  "Count lines in file FILE-NAME."
  (or (ellama-tools--tool-check-file-access file-name 'read)
      (with-current-buffer (find-file-noselect file-name)
        (count-lines (point-min) (point-max)))))

(ellama-tools-define-tool
 '(:function
   ellama-tools-count-lines-tool
   :name
   "count_lines"
   :args
   ((:name
     "file_name"
     :type
     string
     :description
     "Name of the file to count lines in."))
   :description
   "Count lines in file FILE_NAME."))

(defun ellama-tools-lines-range-tool (file-name from to)
  "Return content of file FILE-NAME lines in range FROM TO."
  (or (ellama-tools--tool-check-file-access file-name 'read)
      (json-encode (with-current-buffer (find-file-noselect file-name)
                     (save-excursion
                       (let ((start (progn
                                      (goto-char (point-min))
                                      (forward-line (1- from))
                                      (beginning-of-line)
                                      (point)))
                             (end (progn
                                    (goto-char (point-min))
                                    (forward-line (1- to))
                                    (end-of-line)
                                    (point))))
                         (ellama-tools--sanitize-tool-text-output
                          (buffer-substring-no-properties start end)
                          (format "File %s" file-name))))))))

(ellama-tools-define-tool
 '(:function
   ellama-tools-lines-range-tool
   :name
   "lines_range"
   :args
   ((:name
     "file_name"
     :type
     string
     :description
     "Name of the file to get lines from.")
    (:name
     "from"
     :type
     number
     :description
     "Starting line number.")
    (:name
     "to"
     :type
     number
     :description
     "Ending line number."))
   :description
   "Return content of file FILE_NAME lines in range FROM TO."))

(defun ellama-tools--task-template-placeholder-names (template)
  "Return placeholder names used by TEMPLATE."
  (let ((start 0)
        names)
    (while (string-match "{\\([[:alnum:]_-]+\\)}" template start)
      (push (match-string 1 template) names)
      (setq start (match-end 0)))
    (sort (delete-dups names) #'string<)))

(defun ellama-tools--task-template-argument-name (key)
  "Return string name for template argument KEY."
  (cond
   ((stringp key)
    key)
   ((keywordp key)
    (substring (symbol-name key) 1))
   ((symbolp key)
    (symbol-name key))
   (t
    (format "%s" key))))

(defun ellama-tools--task-template-arguments-alist (arguments)
  "Return normalized alist from template ARGUMENTS."
  (cond
   ((null arguments)
    nil)
   ((hash-table-p arguments)
    (let (result)
      (maphash
       (lambda (key value)
         (push (cons (ellama-tools--task-template-argument-name key)
                     (format "%s" value))
               result))
       arguments)
      result))
   ((and (listp arguments)
         (cl-every
          (lambda (item)
            (and (consp item)
                 (not (keywordp (car item)))))
          arguments))
    (mapcar
     (lambda (item)
       (cons (ellama-tools--task-template-argument-name (car item))
             (format "%s" (cdr item))))
     arguments))
   ((and (listp arguments)
         (zerop (mod (length arguments) 2)))
    (let (result)
      (while arguments
        (push (cons (ellama-tools--task-template-argument-name
                     (pop arguments))
                    (format "%s" (pop arguments)))
              result))
      result))
   (t
    (error "Task template arguments must be an object"))))

(defun ellama-tools--task-template-bullet-list (items)
  "Return ITEMS formatted as a bullet list."
  (if items
      (mapconcat (lambda (item) (format "- %s" item)) items "\n")
    "- none"))

(defun ellama-tools--task-template-example (template template-base role names)
  "Return example task call for TEMPLATE, TEMPLATE-BASE, ROLE and NAMES."
  (format
   "{\n  \"template\": %S,%s\n  \"arguments\": {%s\n  },\n  \"role\": %S\n}"
   template
   (if template-base
       (format "\n  \"template_base\": %S," template-base)
     "")
   (if names
       (concat
        "\n"
        (mapconcat
         (lambda (name)
           (format "    %S: \"...\"" name))
         names
         ",\n"))
     "")
   (or role "general")))

(defun ellama-tools--task-template-validation-error
    (template template-base role required supplied missing unused)
  "Return task template validation message.
TEMPLATE, TEMPLATE-BASE and ROLE describe the failed call.  REQUIRED,
SUPPLIED, MISSING and UNUSED are lists of argument names."
  (concat
   "Task template validation failed. No subagent was started.\n\n"
   (format "Template: %s\n" template)
   (when template-base
     (format "Template base: %s\n" template-base))
   "\nRequired arguments:\n"
   (ellama-tools--task-template-bullet-list required)
   "\n\nSupplied arguments:\n"
   (ellama-tools--task-template-bullet-list supplied)
   "\n\nMissing arguments:\n"
   (ellama-tools--task-template-bullet-list missing)
   (when unused
     (concat
      "\n\nUnused arguments:\n"
      (ellama-tools--task-template-bullet-list unused)
      "\n\nThese were ignored. Use only the required argument names above."))
   "\n\nRetry the task call using this shape:\n"
   (ellama-tools--task-template-example
    template template-base role required)))

(defun ellama-tools--task-template-error (message)
  "Return task template error MESSAGE."
  (format "Task template error. No subagent was started.\n\n%s" message))

(defun ellama-tools--task-template-readable-file (file base)
  "Return FILE when it is a readable regular file under BASE."
  (cond
   ((not (file-directory-p base))
    (error "%s" (ellama-tools--task-template-error
                 (format "Template base is not a directory: %s"
                         base))))
   ((not (file-exists-p file))
    (error "%s" (ellama-tools--task-template-error
                 (format "Template does not exist: %s"
                         file))))
   (t
    (let* ((base-name (file-truename (file-name-as-directory base)))
           (file-name (file-truename file)))
      (cond
       ((not (file-in-directory-p file-name base-name))
        (error "%s" (ellama-tools--task-template-error
                     (format "Template path escapes base directory: %s"
                             file))))
       ((not (file-regular-p file-name))
        (error "%s" (ellama-tools--task-template-error
                     (format "Template is not a regular file: %s"
                             file))))
       ((not (file-readable-p file-name))
        (error "%s" (ellama-tools--task-template-error
                     (format "Template is not readable: %s"
                             file))))
       (t
        file-name))))))

(defun ellama-tools--resolve-task-template (template template-base)
  "Resolve TEMPLATE using TEMPLATE-BASE or configured template directories."
  (cond
   ((or (null template) (string= template ""))
    (error "%s" (ellama-tools--task-template-error
                 "Template name is empty")))
   ((file-name-absolute-p template)
    (if (not ellama-tools-task-template-allow-absolute-paths)
        (error "%s" (ellama-tools--task-template-error
                     "Absolute template paths are disabled.  Use a relative \
template with template_base"))
      (let ((dir (file-name-directory template)))
        (ellama-tools--task-template-readable-file template dir))))
   (template-base
    (ellama-tools--task-template-readable-file
     (expand-file-name template template-base)
     template-base))
   (t
    (let ((dirs ellama-tools-task-template-dirs)
          resolved)
      (while (and dirs (not resolved))
        (let* ((base (expand-file-name (car dirs)))
               (file (expand-file-name template base)))
          (when (file-exists-p file)
            (setq resolved
                  (ellama-tools--task-template-readable-file file base))))
        (setq dirs (cdr dirs)))
      (or resolved
          (error "%s" (ellama-tools--task-template-error
                       (format "Template was not found in configured \
template directories: %s"
                               template))))))))

(defun ellama-tools--render-task-template
    (template-text template template-base role arguments)
  "Render TEMPLATE-TEXT with ARGUMENTS.
TEMPLATE, TEMPLATE-BASE and ROLE are used for validation hints."
  (let* ((required (ellama-tools--task-template-placeholder-names
                    template-text))
         (alist (ellama-tools--task-template-arguments-alist arguments))
         (supplied (sort (delete-dups (mapcar #'car alist)) #'string<))
         (missing (cl-set-difference required supplied :test #'string=))
         (unused (cl-set-difference supplied required :test #'string=))
         (rendered template-text))
    (when missing
      (error "%s" (ellama-tools--task-template-validation-error
                   template template-base role required supplied
                   (sort missing #'string<) (sort unused #'string<))))
    (dolist (item alist)
      (setq rendered
            (replace-regexp-in-string
             (regexp-quote (format "{%s}" (car item)))
             (cdr item)
             rendered t t)))
    rendered))

(defun ellama-tools--task-description
    (description template template-base role arguments)
  "Return task DESCRIPTION or render TEMPLATE.
TEMPLATE-BASE, ROLE and ARGUMENTS are used for template rendering and hints."
  (if (and template (not (string= template "")))
      (let* ((file-name (ellama-tools--resolve-task-template
                         template template-base))
             (template-text
              (with-temp-buffer
                (insert-file-contents file-name)
                (buffer-string))))
        (ellama-tools--render-task-template
         template-text template template-base role arguments))
    (or description
        (error "%s" (ellama-tools--task-template-error
                     "Either description or template is required")))))

(defun ellama-tools--insert-subagent-prompt (buffer description)
  "Insert sub-agent DESCRIPTION into BUFFER as a main-agent turn.
Return insertion point for sub-agent response."
  (with-current-buffer buffer
    (save-excursion
      (goto-char (point-max))
      (let ((prefix (ellama-get-nick-prefix-for-mode)))
        (unless (bobp)
          (insert "\n"))
        (insert prefix " Main agent:\n"
                (ellama--fill-long-lines description) "\n\n"
                prefix " " ellama-assistant-nick ":\n")
        (point)))))

(defun ellama-tools--make-report-result-tool (callback session)
  "Make report_result tool dynamically for SESSION.
CALLBACK will be used to report result asyncronously."
  `(:function
    (lambda (result)
      (let* ((extra (ellama-session-extra ,session))
             (done (plist-get extra :task-completed)))
        (unless done
          (ellama-tools--set-session-extra
           ,session
           (plist-put extra :task-completed t))
          (funcall ,callback result)))
      "Result received. Task completed.")
    :name "report_result"
    :description "Report final result and terminate the task."
    :args ((:name "result" :type string))))

(defun ellama-tools--subagent-system-message (system-msg)
  "Return subagent system message from SYSTEM-MSG."
  (format
   "%s\n\nINSTRUCTIONS:\n\
Work step-by-step. Use tools when needed.\n\
When the task is COMPLETE you MUST call `report_result` exactly once."
   (or system-msg "")))

(defun ellama-tools--subagent-buffer (session &optional buffer)
  "Return live subagent BUFFER for SESSION."
  (or (and (buffer-live-p buffer) buffer)
      (when-let* (((ellama-session-p session))
                  (extra (ellama-session-extra session))
                  (uid (plist-get extra :uid)))
        (ellama-get-session-buffer uid))
      (when (ellama-session-p session)
        (ellama-get-session-buffer (ellama-session-id session)))))

(defun ellama--subagent-loop-handler (_text &optional session buffer system)
  "Continue subagent SESSION loop in BUFFER with SYSTEM message."
  (let* ((session (or session ellama--current-session))
         (extra (and (ellama-session-p session)
                     (ellama-session-extra session)))
         (done (plist-get extra :task-completed))
         (steps (or (plist-get extra :step-count) 0))
         (max (or (plist-get extra :max-steps)
                  ellama-tools-subagent-default-max-steps))
         (callback (plist-get extra :result-callback))
         (tools (plist-get extra :tools))
         (system (or system (plist-get extra :system)))
         (buffer (ellama-tools--subagent-buffer session buffer)))
    (cond
     ((not (ellama-session-p session))
      (message "Subagent session is not available."))
     (done
      (message "Subagent finished."))
     ((>= steps max)
      (ellama-tools--set-session-extra
       session
       (plist-put extra :task-completed t))
      (funcall callback (format "Max steps (%d) reached." max)))
     (t
      (ellama-tools--set-session-extra
       session
       (plist-put extra :step-count (1+ steps)))
      (ellama-stream
       ellama-tools-subagent-continue-prompt
       :buffer buffer
       :session session
       :tools tools
       :system system
       :on-done
       (ellama-tools--make-subagent-loop-handler
        session buffer system))))))

(defun ellama-tools--make-subagent-loop-handler
    (session &optional buffer system)
  "Return subagent loop handler for SESSION.
BUFFER is the session buffer.  SYSTEM is the initial system prompt."
  (lambda (text)
    (ellama--subagent-loop-handler text session buffer system)))

(defun ellama-tools-task-tool
    (callback &optional description role template template-base arguments)
  "Delegate DESCRIPTION or rendered TEMPLATE to a sub-agent asynchronously.

CALLBACK   – function called once with the result string.
ROLE       – role key from `ellama-tools-subagent-roles'.
TEMPLATE   – optional template name or relative path.
TEMPLATE-BASE – optional base directory for relative TEMPLATE.
ARGUMENTS  – object with template substitution values."
  (let ((role-key (if (assoc role ellama-tools-subagent-roles)
                      role
                    "general")))
    (condition-case err
        (let* ((description
                (ellama-tools--task-description
                 description template template-base role-key arguments))
               (parent-id ellama--current-session-id)

               (provider (ellama-tools--provider-for-role role-key))
               (role-cfg   (cdr (assoc role-key ellama-tools-subagent-roles)))
               (system-msg (plist-get role-cfg :system))
               (subagent-system
                (ellama-tools--subagent-system-message system-msg))

               (steps-limit ellama-tools-subagent-default-max-steps)

               ;; ---- create ephemeral worker session ----
               (worker (ellama-new-session provider description t))
               (worker-buffer (ellama-get-session-buffer
                               (ellama-session-id worker)))
               (worker-point (ellama-tools--insert-subagent-prompt
                              worker-buffer description))

               ;; ---- resolve tools for role ----
               (role-tools (ellama-tools--for-role role-key))

               ;; ---- dynamic report_result tool ----
               (report-tool
                (apply #'llm-make-tool
                       (ellama-tools--make-report-result-tool
                        callback worker)))

               ;; IMPORTANT: report tool must be first (termination tool priority)
               (all-tools (cons report-tool role-tools)))

          ;; ============================================================
          ;; Initialize session state (single source of truth)
          ;; ============================================================

          (ellama-tools--set-session-extra
           worker
           (ellama-tools--session-extra-with
            worker
            :parent-session parent-id
            :role role-key
            :tools all-tools
            :result-callback callback
            :task-completed nil
            :step-count 0
            :max-steps steps-limit
            :system subagent-system))

          ;; ============================================================
          ;; Start the agent loop
          ;; ============================================================

          (ellama-stream
           description
           :buffer worker-buffer
           :point worker-point
           :session worker
           :on-done (ellama-tools--make-subagent-loop-handler
                     worker worker-buffer subagent-system)
           :tools all-tools
           :system subagent-system)

          ;; ============================================================
          ;; Immediate response to parent LLM (async contract)
          ;; ============================================================

          (message "Subtask started (session %s, role %s). Waiting for result via callback."
                   (ellama-session-id worker)
                   role-key)
          nil)
      (error
       (funcall callback (error-message-string err))
       nil))))

(ellama-tools-define-tool
 `(:function ellama-tools-task-tool
             :name "task"
             :async t
             :description "Delegate a task to a sub-agent.
Use either a free-form description or a template with arguments.  When using
a template, pass arguments as an object whose keys exactly match template
placeholders.  If validation fails, retry using the returned hint."
             :args ((:name "description" :type string
                           :description "Free-form task prompt. Optional when template is provided.")
                    (:name "role" :type string
                           :enum ,(seq--into-vector (mapcar #'car ellama-tools-subagent-roles)))
                    (:name "template" :type string
                           :description "Template name or relative path.")
                    (:name "template_base" :type string
                           :description "Base directory for resolving a relative template path.")
                    (:name "arguments" :type object
                           :description "Template substitution values keyed by placeholder name."))))

(provide 'ellama-tools)
;;; ellama-tools.el ends here
