branch: externals/ellama
commit 1a4cfe368b6cdaef6a33c8739782f7188a81c24e
Author: Sergey Kostyaev <[email protected]>
Commit: Sergey Kostyaev <[email protected]>
Keep agent loops running after LLM errors
Handle non-tool LLM failures in plan-and-act and subagent loops by
continuing after the first error, compacting after repeated errors, and
resuming once compaction completes.
Propagate agent error callbacks through plan-and-act entry points, clear
request state before invoking error callbacks, and add regression coverage for
both agent loop recovery paths.
---
ellama-tools.el | 99 +++++++++++++++++++++++++++---------
ellama.el | 8 ++-
tests/test-ellama-tools.el | 123 +++++++++++++++++++++++++++++++++++++++++++++
tests/test-ellama.el | 24 +++++++++
4 files changed, 229 insertions(+), 25 deletions(-)
diff --git a/ellama-tools.el b/ellama-tools.el
index 73853178aa..7604531260 100644
--- a/ellama-tools.el
+++ b/ellama-tools.el
@@ -4083,6 +4083,12 @@ ERROR-CB is called on non-tool LLM errors to track
consecutive failures."
(when parsed
(ellama-tools--agent-insert-state
session buffer (if (plist-get parsed :plan) "Plan" "Status")))
+ (when (and (stringp text)
+ (plist-get state :consecutive-error-count))
+ (setq state
+ (ellama-tools--agent-state-put
+ state :consecutive-error-count 0))
+ (ellama-tools--agent-put-state session state))
(cond
((not (ellama-session-p session))
(message "Plan-and-act session is not available."))
@@ -4138,7 +4144,34 @@ ERROR-CB is called on non-tool LLM errors to track
consecutive failures."
text session buffer system
(ellama-tools--make-agent-error-callback session)))))
-(declare-function ellama--session-compact "ellama" (session &key automatic))
+(declare-function ellama--session-compact "ellama"
+ (session &key automatic on-done))
+
+(defun ellama-tools--agent-error-message (err)
+ "Return human-readable message for agent loop ERR."
+ (cond
+ ((stringp err) err)
+ ((and (consp err) (symbolp (car err)))
+ (error-message-string err))
+ (t (format "%s" err))))
+
+(defun ellama-tools--continue-agent-after-error (session)
+ "Continue plan-and-act SESSION after an LLM error."
+ (ellama-tools--agent-loop-handler
+ nil session nil nil
+ (ellama-tools--make-agent-error-callback session)))
+
+(defun ellama-tools--compact-and-continue-agent (session)
+ "Compact plan-and-act SESSION and continue the loop."
+ (let (continued)
+ (cl-labels
+ ((continue ()
+ (unless continued
+ (setq continued t)
+ (ellama-tools--continue-agent-after-error session))))
+ (unless (ellama--session-compact
+ session :automatic t :on-done #'continue)
+ (continue)))))
(defun ellama-tools--make-agent-error-callback (session)
"Return error callback for plan-and-act loop of SESSION.
@@ -4147,26 +4180,26 @@ and restarts the loop."
(let ((session session))
(lambda (err)
(let* ((extra (ellama-session-extra session))
- (state (plist-get extra :agent-loop)))
+ (state (plist-get extra :agent-loop))
+ (message (ellama-tools--agent-error-message err)))
(unless (and state (plist-get state :completed))
(let* ((count (or (plist-get state :consecutive-error-count) 0))
(new-count (1+ count)))
(message "Agent loop error: %s (consecutive: %d)"
- (error-message-string err) new-count)
+ message new-count)
(if (>= new-count 2)
(progn
(message "Compacting session after repeated errors...")
- (ellama--session-compact session :automatic t)
- (setq state (ellama-tools--agent-state-put state
:consecutive-error-count 0))
+ (setq state
+ (ellama-tools--agent-state-put
+ state :consecutive-error-count 0))
(ellama-tools--agent-put-state session state)
- (ellama-tools--agent-loop-handler
- nil session nil nil
- (ellama-tools--make-agent-error-callback session)))
- (setq state (ellama-tools--agent-state-put state
:consecutive-error-count new-count))
+ (ellama-tools--compact-and-continue-agent session))
+ (setq state
+ (ellama-tools--agent-state-put
+ state :consecutive-error-count new-count))
(ellama-tools--agent-put-state session state)
- (ellama-tools--agent-loop-handler
- nil session nil nil
- (ellama-tools--make-agent-error-callback session)))))))))
+ (ellama-tools--continue-agent-after-error session))))))))
(defun ellama-tools-start-plan-and-act
(session buffer prompt &optional system tools max-steps)
@@ -4204,6 +4237,7 @@ automatic continuations."
buffer "Status" "Planning started.")
(list :system agent-system
:tools combined-tools
+ :on-error (ellama-tools--make-agent-error-callback session)
:on-done (ellama-tools--make-agent-loop-handler
session buffer agent-system))))
@@ -4229,6 +4263,7 @@ SYSTEM and TOOLS can override the stored runtime values."
(ellama-tools--session-extra-with session :tools combined-tools))
(list :system agent-system
:tools combined-tools
+ :on-error (ellama-tools--make-agent-error-callback session)
:on-done (ellama-tools--make-agent-loop-handler
session buffer agent-system))))
@@ -4424,8 +4459,8 @@ When the task is COMPLETE you MUST call `report_result`
exactly once."
(and (buffer-live-p session-buffer) session-buffer))
(and (buffer-live-p buffer) buffer)))
-(defun ellama--subagent-loop-handler (_text &optional session buffer system
error-cb)
- "Continue subagent SESSION loop in BUFFER with SYSTEM message.
+(defun ellama--subagent-loop-handler (text &optional session buffer system
error-cb)
+ "Continue subagent SESSION loop after TEXT in BUFFER with SYSTEM message.
ERROR-CB is called on non-tool LLM errors to track consecutive failures."
(let* ((session (or session ellama--current-session))
(extra (and (ellama-session-p session)
@@ -4438,6 +4473,10 @@ ERROR-CB is called on non-tool LLM errors to track
consecutive failures."
(tools (plist-get extra :tools))
(system (or system (plist-get extra :system)))
(buffer (ellama-tools--subagent-buffer session buffer)))
+ (when (and (stringp text)
+ (plist-get extra :consecutive-error-count))
+ (setq extra (plist-put extra :consecutive-error-count 0))
+ (ellama-tools--set-session-extra session extra))
(cond
((not (ellama-session-p session))
(message "Subagent session is not available."))
@@ -4479,6 +4518,24 @@ ERROR-CB is called on non-tool LLM errors to track
consecutive failures."
text session buffer system
(ellama-tools--make-subagent-error-callback session)))))
+(defun ellama-tools--continue-subagent-after-error (session)
+ "Continue subagent SESSION after an LLM error."
+ (ellama--subagent-loop-handler
+ nil session nil nil
+ (ellama-tools--make-subagent-error-callback session)))
+
+(defun ellama-tools--compact-and-continue-subagent (session)
+ "Compact subagent SESSION and continue the loop."
+ (let (continued)
+ (cl-labels
+ ((continue ()
+ (unless continued
+ (setq continued t)
+ (ellama-tools--continue-subagent-after-error session))))
+ (unless (ellama--session-compact
+ session :automatic t :on-done #'continue)
+ (continue)))))
+
(defun ellama-tools--make-subagent-error-callback (session)
"Return error callback for subagent loop of SESSION.
Increments consecutive-error-count; if it reaches 2, compacts the session
@@ -4487,23 +4544,19 @@ and restarts the loop."
(lambda (err)
(let* ((extra (ellama-session-extra session))
(count (or (plist-get extra :consecutive-error-count) 0))
- (new-count (1+ count)))
+ (new-count (1+ count))
+ (message (ellama-tools--agent-error-message err)))
(message "Subagent error: %s (consecutive: %d)"
- (error-message-string err) new-count)
+ message new-count)
(if (>= new-count 2)
(progn
(message "Compacting subagent session after repeated errors...")
- (ellama--session-compact session :automatic t)
(ellama-tools--set-session-extra
session (plist-put extra :consecutive-error-count 0))
- (ellama--subagent-loop-handler
- nil session nil nil
- (ellama-tools--make-subagent-error-callback session)))
+ (ellama-tools--compact-and-continue-subagent session))
(ellama-tools--set-session-extra
session (plist-put extra :consecutive-error-count new-count))
- (ellama--subagent-loop-handler
- nil session nil nil
- (ellama-tools--make-subagent-error-callback session)))))))
+ (ellama-tools--continue-subagent-after-error session))))))
(defun ellama-tools-task-tool
(callback &optional description role template template-base arguments)
diff --git a/ellama.el b/ellama.el
index d0d5dc6091..db570a00e8 100644
--- a/ellama.el
+++ b/ellama.el
@@ -3235,8 +3235,8 @@ REQUEST-CONTEXT is request context."
(accept-change-group ellama--change-group))
(when ellama-spinner-enabled
(spinner-stop))
- (funcall errcb msg)
- (ellama--deactivate-current-request request-context)))))
+ (ellama--deactivate-current-request request-context)
+ (funcall errcb msg)))))
(defun ellama--run-done-callback (donecb text)
"Call DONECB with TEXT."
@@ -4142,6 +4142,7 @@ after compaction."
max-steps)))
(agent-system (plist-get agent :system))
(agent-tools (plist-get agent :tools))
+ (errcb (plist-get agent :on-error))
(donecb (plist-get agent :on-done)))
(ellama-stream
prompt
@@ -4149,6 +4150,7 @@ after compaction."
:system agent-system
:tools agent-tools
:max-tokens max-tokens
+ :on-error errcb
:on-done donecb
:filter (when (derived-mode-p 'org-mode)
#'ellama--translate-markdown-to-org-filter)))))))
@@ -4191,12 +4193,14 @@ after compaction."
session (current-buffer))))
(system (plist-get agent :system))
(tools (plist-get agent :tools))
+ (errcb (plist-get agent :on-error))
(donecb (or (plist-get agent :on-done)
#'ellama-chat-done)))
(ellama-stream text
:session session
:system system
:tools tools
+ :on-error errcb
:on-done donecb
:filter (when (derived-mode-p 'org-mode)
#'ellama--translate-markdown-to-org-filter)))))
diff --git a/tests/test-ellama-tools.el b/tests/test-ellama-tools.el
index b2ba8fe8b9..596ed0219d 100644
--- a/tests/test-ellama-tools.el
+++ b/tests/test-ellama-tools.el
@@ -2881,6 +2881,7 @@ Return list with result and prompt."
(plist-get
(ellama-tools--agent-state session)
:system)))
+ (should (functionp (plist-get result :on-error)))
(should (functionp (plist-get result :on-done)))
(should (equal (plist-get
(ellama-tools--agent-state session)
@@ -2906,6 +2907,68 @@ Return list with result and prompt."
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
+(ert-deftest test-ellama-agent-error-callback-continues-and-compacts-on-repeat
()
+ (ellama-test--ensure-local-ellama-tools)
+ (let* ((buffer (generate-new-buffer " *ellama-agent-error-test*"))
+ (base-tool (llm-make-tool :name "read_file" :function #'ignore))
+ (session
+ (make-ellama-session
+ :id "agent-error"
+ :extra (list :uid "agent-error-uid"
+ :tools (list base-tool)
+ :agent-loop
+ (list :phase 'acting
+ :plan (list (list :id 1
+ :title "Keep working"
+ :status 'pending))
+ :step-count 0
+ :consecutive-error-count 0
+ :max-steps 5
+ :completed nil
+ :system "System"))))
+ (stream-calls nil)
+ (compact-count 0)
+ (compact-done nil))
+ (unwind-protect
+ (progn
+ (with-current-buffer buffer
+ (org-mode))
+ (cl-letf (((symbol-function 'ellama-get-session-buffer)
+ (lambda (id)
+ (and (member id '("agent-error"
+ "agent-error-uid"))
+ buffer)))
+ ((symbol-function 'ellama-stream)
+ (lambda (prompt &rest args)
+ (push (list prompt args) stream-calls)))
+ ((symbol-function 'ellama--session-compact)
+ (lambda (_session &rest args)
+ (setq compact-count (1+ compact-count))
+ (setq compact-done (plist-get args :on-done))
+ t))
+ ((symbol-function 'message)
+ (lambda (&rest _args) nil)))
+ (let ((callback (ellama-tools--make-agent-error-callback session)))
+ (funcall callback "temporary failure")
+ (should (= (plist-get (ellama-tools--agent-state session)
+ :consecutive-error-count)
+ 1))
+ (should (= (length stream-calls) 1))
+ (should (= compact-count 0))
+ (funcall callback "temporary failure again")
+ (should (= (plist-get (ellama-tools--agent-state session)
+ :consecutive-error-count)
+ 0))
+ (should (= compact-count 1))
+ (should (= (length stream-calls) 1))
+ (should (functionp compact-done))
+ (funcall compact-done)
+ (should (= (length stream-calls) 2))
+ (should (string-match-p "Current plan state"
+ (caar stream-calls))))))
+ (when (buffer-live-p buffer)
+ (kill-buffer buffer)))))
+
(ert-deftest
test-ellama-agent-loop-handler-parses-fallback-state-and-continues ()
(ellama-test--ensure-local-ellama-tools)
(let* ((buffer (generate-new-buffer " *ellama-agent-loop-test*"))
@@ -2956,6 +3019,7 @@ END_ELLAMA_AGENT_STATE"))
session))
(should (equal (plist-get (cadr stream-call) :tools)
(list base-tool)))
+ (should (functionp (plist-get (cadr stream-call) :on-error)))
(should (functionp (plist-get (cadr stream-call) :on-done)))
(should (string-match-p "Current plan state"
(car stream-call)))
@@ -3119,6 +3183,65 @@ END_ELLAMA_AGENT_STATE"))
(when (buffer-live-p worker-buffer)
(kill-buffer worker-buffer)))))
+(ert-deftest
test-ellama-subagent-error-callback-continues-and-compacts-on-repeat ()
+ (ellama-test--ensure-local-ellama-tools)
+ (let* ((worker-buffer (generate-new-buffer " *ellama-worker-error-test*"))
+ (role-tool (llm-make-tool :name "read_file" :function #'ignore))
+ (session
+ (make-ellama-session
+ :id "worker-error"
+ :extra (list :uid "worker-error-uid"
+ :task-completed nil
+ :step-count 0
+ :consecutive-error-count 0
+ :max-steps 5
+ :tools (list role-tool)
+ :system "System"
+ :result-callback #'ignore)))
+ (stream-calls nil)
+ (compact-count 0)
+ (compact-done nil))
+ (unwind-protect
+ (progn
+ (with-current-buffer worker-buffer
+ (org-mode))
+ (cl-letf (((symbol-function 'ellama-get-session-buffer)
+ (lambda (id)
+ (and (member id '("worker-error"
+ "worker-error-uid"))
+ worker-buffer)))
+ ((symbol-function 'ellama-stream)
+ (lambda (prompt &rest args)
+ (push (list prompt args) stream-calls)))
+ ((symbol-function 'ellama--session-compact)
+ (lambda (_session &rest args)
+ (setq compact-count (1+ compact-count))
+ (setq compact-done (plist-get args :on-done))
+ t))
+ ((symbol-function 'message)
+ (lambda (&rest _args) nil)))
+ (let ((callback
+ (ellama-tools--make-subagent-error-callback session)))
+ (funcall callback "temporary failure")
+ (should (= (plist-get (ellama-session-extra session)
+ :consecutive-error-count)
+ 1))
+ (should (= (length stream-calls) 1))
+ (should (= compact-count 0))
+ (funcall callback "temporary failure again")
+ (should (= (plist-get (ellama-session-extra session)
+ :consecutive-error-count)
+ 0))
+ (should (= compact-count 1))
+ (should (= (length stream-calls) 1))
+ (should (functionp compact-done))
+ (funcall compact-done)
+ (should (= (length stream-calls) 2))
+ (should (equal (caar stream-calls)
+ ellama-tools-subagent-continue-prompt)))))
+ (when (buffer-live-p worker-buffer)
+ (kill-buffer worker-buffer)))))
+
(ert-deftest test-ellama-subagent-loop-handler-uses-current-session-buffer ()
(ellama-test--ensure-local-ellama-tools)
(let* ((stale-buffer (generate-new-buffer " *ellama-worker-stale-test*"))
diff --git a/tests/test-ellama.el b/tests/test-ellama.el
index 85840c5853..68c30107f0 100644
--- a/tests/test-ellama.el
+++ b/tests/test-ellama.el
@@ -1075,6 +1075,7 @@ detailed comparison to help you decide:
(should (equal (car stream-call) "Do work"))
(should (eq (plist-get (cadr stream-call) :session)
session))
+ (should (functionp (plist-get (cadr stream-call) :on-error)))
(should (functionp (plist-get (cadr stream-call) :on-done)))
(should (string-match-p
"PLAN AND ACT INSTRUCTIONS"
@@ -1139,6 +1140,7 @@ detailed comparison to help you decide:
session))
(should (equal (plist-get (cadr stream-call) :system)
"Existing system"))
+ (should (functionp (plist-get (cadr stream-call) :on-error)))
(should (functionp (plist-get (cadr stream-call) :on-done)))
(should (equal (plist-get
(car (plist-get
@@ -1347,6 +1349,7 @@ detailed comparison to help you decide:
(should (eq (plist-get (cadr stream-call) :session) session))
(should (equal (plist-get (cadr stream-call) :system)
"Agent system"))
+ (should (functionp (plist-get (cadr stream-call) :on-error)))
(should (functionp (plist-get (cadr stream-call) :on-done)))
(should (not (eq (plist-get (cadr stream-call) :on-done)
#'ellama-chat-done)))
@@ -2052,6 +2055,27 @@ detailed comparison to help you decide:
(should (equal done-text "Recovered answer"))
(should (equal (buffer-string) "Recovered answer"))))))
+(ert-deftest test-ellama-error-handler-deactivates-before-on-error ()
+ (let ((ellama-spinner-enabled nil)
+ (ellama-undo-on-error nil)
+ (active-request-during-callback :unset))
+ (with-temp-buffer
+ (let* ((request-context (ellama--make-request-context
+ (list (current-buffer))))
+ (handler (ellama--error-handler
+ (current-buffer)
+ (lambda (_msg)
+ (setq active-request-during-callback
+ ellama--current-request))
+ nil nil request-context)))
+ (setq ellama--change-group (prepare-change-group))
+ (activate-change-group ellama--change-group)
+ (ellama--set-current-request
+ 'request (list (current-buffer)) request-context)
+ (funcall handler 'error "temporary failure")
+ (should (null active-request-during-callback))
+ (should (null ellama--current-request))))))
+
(ert-deftest test-ellama-normalize-tool-use-args-decodes-unibyte-json ()
(let* ((raw-json
(encode-coding-string