Skip to content

Commit 34e9cc9

Browse files
committed
Improve subagent failure result for provider errors
When a subagent dies on a provider error the parent agent tends to give up on it and do the task itself. The failure result now includes the rate limit reset time and a hint to re-spawn the agent (optionally with another model) for transient errors, or to switch model for permanent ones. Anthropic error bodies are parsed so the error code and a clean message replace the raw status/body dump.
1 parent 086f421 commit 34e9cc9

5 files changed

Lines changed: 136 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Improve subagent failure result: rate-limit reset time, parsed Anthropic error code, and retry guidance so the parent agent can re-spawn or switch model.
6+
57
## 0.157.0
68

79
- Add `chat/inlinePrompt` protocol method: inline editor prompts backed by regular chats, optionally forking an existing chat without replay. New `chatInline` config for model/agent/variant defaults.

src/eca/features/tools/agent.clj

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
[clojure.string :as str]
55
[eca.config :as config]
66
[eca.features.tools.util :as tools.util]
7+
[eca.llm-providers.errors :as llm-providers.errors]
78
[eca.logger :as logger]
89
[eca.messenger :as messenger]
910
[eca.models :as models]
@@ -66,8 +67,16 @@
6667
(or (extract-final-assistant-text messages)
6768
"Agent completed without producing output."))
6869

70+
(defn ^:private failure-guidance
71+
"Actionable next-step hint for the parent agent based on the error type."
72+
[error-type]
73+
(when error-type
74+
(if (contains? llm-providers.errors/retryable-error-types error-type)
75+
"This is a transient provider error. Prefer spawning this agent again for the same task (optionally with a different `model`) instead of performing the task yourself."
76+
"Retrying this agent the same way is unlikely to help. Consider spawning it again with a different `model` or handling the task yourself.")))
77+
6978
(defn ^:private failed-agent-result [agent-name prompt-error partial-output]
70-
(let [{:keys [message error-type status code request-id response-id]} prompt-error]
79+
(let [{:keys [message error-type status code request-id response-id rate-limit-resets-at]} prompt-error]
7180
{:error true
7281
:contents [{:type :text
7382
:text (str "## Agent '" agent-name "' Failed\n\n"
@@ -82,6 +91,10 @@
8291
(str "\nRequest ID: " request-id))
8392
(when response-id
8493
(str "\nResponse ID: " response-id))
94+
(when rate-limit-resets-at
95+
(str "\nRate limit resets at: " (java.time.Instant/ofEpochMilli (long rate-limit-resets-at))))
96+
(when-let [guidance (failure-guidance error-type)]
97+
(str "\n\n" guidance))
8598
(when partial-output
8699
(str "\n\n## Partial result\n\n" partial-output)))}]}))
87100

src/eca/llm_providers/errors.clj

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,31 @@
215215

216216
(defmethod enrich-provider-error :default [{:keys [error-data]}] error-data)
217217

218+
(defn ^:private anthropic-structured-error
219+
"Parses Anthropic's structured error body, e.g.
220+
{\"type\":\"error\",\"error\":{\"type\":\"rate_limit_error\",\"message\":\"...\"},\"request_id\":\"...\"}.
221+
Returns {:code .. :message .. :request-id ..} or nil when not parseable."
222+
[body]
223+
(when (string? body)
224+
(let [parsed (try (json/parse-string body) (catch Exception _ nil))
225+
error (when (map? parsed) (get parsed "error"))
226+
code (when (map? error) (get error "type"))
227+
message (when (map? error) (get error "message"))]
228+
(when (or code message)
229+
(cond-> {}
230+
code (assoc :code code)
231+
message (assoc :message message)
232+
(get parsed "request_id") (assoc :request-id (get parsed "request_id")))))))
233+
234+
(defmethod enrich-provider-error "anthropic"
235+
[{:keys [error-data]}]
236+
(if-let [{:keys [code message request-id]} (anthropic-structured-error (:body error-data))]
237+
(cond-> error-data
238+
code (assoc :code code)
239+
message (assoc :message (str "Anthropic " (or code "error") ": " message))
240+
request-id (assoc :request-id request-id))
241+
error-data))
242+
218243
(defmulti recoverable-error?
219244
"Pure predicate: true when the provider offers an interactive recovery for
220245
this terminal error (e.g. Copilot per-model policy consent). Dispatches on

test/eca/features/tools/agent_test.clj

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,64 @@
305305
:text #"(?s)Failed.*servers are currently overloaded.*Error type: overloaded.*Request ID: req_overloaded.*Partial result.*Partial findings"}]}
306306
result))))))
307307

308+
(testing "rate-limited error includes reset time and retry guidance"
309+
(let [db* (atom {:chats {"chat-1" {:id "chat-1" :model "test/model"}}})
310+
subagent-chat-id "subagent-tc-rate-limited"]
311+
(with-redefs [requiring-resolve
312+
(fn [sym]
313+
(case sym
314+
eca.features.chat/prompt
315+
(fn [_params _db* _messenger _config _metrics]
316+
(swap! db* assoc-in [:chats subagent-chat-id :status] :idle)
317+
(swap! db* assoc-in [:chats subagent-chat-id :prompt-error]
318+
{:message "Anthropic rate_limit_error: This request would exceed your rate limit"
319+
:error-type :rate-limited
320+
:status 429
321+
:code "rate_limit_error"
322+
:rate-limit-resets-at 1756204800000}))
323+
(clojure.lang.RT/var (namespace sym) (name sym))))]
324+
(let [result ((spawn-handler)
325+
{"agent" "explorer" "task" "find files"}
326+
{:db* db*
327+
:config test-config
328+
:messenger (h/messenger)
329+
:metrics (h/metrics)
330+
:chat-id "chat-1"
331+
:tool-call-id "tc-rate-limited"
332+
:call-state-fn (constantly {:status :executing})})]
333+
(is (match? {:error true
334+
:contents [{:type :text
335+
:text #"(?s)Failed.*rate limit.*Error type: rate-limited.*Status: 429.*Code: rate_limit_error.*Rate limit resets at: 2025-08-26T10:40:00Z.*transient provider error\. Prefer spawning this agent again"}]}
336+
result))))))
337+
338+
(testing "non-retryable error advises against retrying the same way"
339+
(let [db* (atom {:chats {"chat-1" {:id "chat-1" :model "test/model"}}})
340+
subagent-chat-id "subagent-tc-auth"]
341+
(with-redefs [requiring-resolve
342+
(fn [sym]
343+
(case sym
344+
eca.features.chat/prompt
345+
(fn [_params _db* _messenger _config _metrics]
346+
(swap! db* assoc-in [:chats subagent-chat-id :status] :idle)
347+
(swap! db* assoc-in [:chats subagent-chat-id :prompt-error]
348+
{:message "Invalid API key"
349+
:error-type :auth
350+
:status 401}))
351+
(clojure.lang.RT/var (namespace sym) (name sym))))]
352+
(let [result ((spawn-handler)
353+
{"agent" "explorer" "task" "find files"}
354+
{:db* db*
355+
:config test-config
356+
:messenger (h/messenger)
357+
:metrics (h/metrics)
358+
:chat-id "chat-1"
359+
:tool-call-id "tc-auth"
360+
:call-state-fn (constantly {:status :executing})})]
361+
(is (match? {:error true
362+
:contents [{:type :text
363+
:text #"(?s)Failed.*Invalid API key.*Error type: auth.*Status: 401.*unlikely to help"}]}
364+
result))))))
365+
308366
(testing "an error status without structured details still returns failure"
309367
(let [db* (atom {:chats {"chat-1" {:id "chat-1" :model "test/model"}}})
310368
subagent-chat-id "subagent-tc-error-status"]

test/eca/llm_providers/errors_test.clj

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,43 @@
1313
(is (nil? (llm-providers.errors/recover-error!
1414
{:provider "unknown" :error-data error-data :db {}}))))))
1515

16+
(deftest enrich-anthropic-provider-error-test
17+
(let [body "{\"type\":\"error\",\"error\":{\"type\":\"rate_limit_error\",\"message\":\"This request would exceed your rate limit\"},\"request_id\":\"req_123\"}"]
18+
(testing "structured error body populates code, message and request-id, keeping raw body"
19+
(is (= {:status 429
20+
:body body
21+
:message "Anthropic rate_limit_error: This request would exceed your rate limit"
22+
:code "rate_limit_error"
23+
:request-id "req_123"}
24+
(llm-providers.errors/enrich-provider-error
25+
{:provider "anthropic"
26+
:model "m"
27+
:error-data {:status 429
28+
:body body
29+
:message "Anthropic response status: 429 body: ..."}}))))
30+
31+
(testing "enriched error still classifies as rate-limited"
32+
(is (= {:error/type :rate-limited}
33+
(llm-providers.errors/classify-error
34+
(llm-providers.errors/enrich-provider-error
35+
{:provider "anthropic"
36+
:model "m"
37+
:error-data {:status 429
38+
:body body
39+
:message "Anthropic response status: 429 body: ..."}})))))
40+
41+
(testing "non-JSON body keeps error untouched"
42+
(let [error-data {:status 502
43+
:body "<html>bad gateway</html>"
44+
:message "Anthropic response status: 502 body: <html>bad gateway</html>"}]
45+
(is (= error-data (llm-providers.errors/enrich-provider-error
46+
{:provider "anthropic" :model "m" :error-data error-data})))))
47+
48+
(testing "missing body keeps error untouched"
49+
(let [error-data {:message "Anthropic error response: something went wrong"}]
50+
(is (= error-data (llm-providers.errors/enrich-provider-error
51+
{:provider "anthropic" :model "m" :error-data error-data})))))))
52+
1653
(deftest classify-error-context-overflow-test
1754
(testing "Anthropic prompt too long"
1855
(is (= {:error/type :context-overflow}

0 commit comments

Comments
 (0)