From c653c71a359e21beba25097a8117d3bc9826bb81 Mon Sep 17 00:00:00 2001 From: Juha Itkonen Date: Fri, 8 May 2026 17:54:17 +0300 Subject: [PATCH 1/3] Guard spawn agent activity labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normalize model-generated spawn_agent activity labels before they are displayed, stored, or replayed so pathological labels do not leak into UI or downstream context. 🤖 Generated with [eca](https://eca.dev) Co-Authored-By: eca-agent --- src/eca/features/chat/tool_calls.clj | 18 ++++++++--- src/eca/features/tools/agent.clj | 30 ++++++++++++++---- test/eca/features/tools/agent_test.clj | 44 +++++++++++++++++++++++++- 3 files changed, 80 insertions(+), 12 deletions(-) diff --git a/src/eca/features/chat/tool_calls.clj b/src/eca/features/chat/tool_calls.clj index 5add6deea..0e8b69f23 100644 --- a/src/eca/features/chat/tool_calls.clj +++ b/src/eca/features/chat/tool_calls.clj @@ -4,6 +4,7 @@ [eca.features.chat.lifecycle :as lifecycle] [eca.features.hooks :as f.hooks] [eca.features.tools :as f.tools] + [eca.features.tools.agent :as f.tools.agent] [eca.features.tools.mcp :as f.tools.mcp] [eca.features.tools.util :as tools.util] [eca.llm-util :as llm-util] @@ -666,19 +667,26 @@ (let [rejected-tool-call-info* (atom nil)] (run! (fn do-tool-call [{:keys [id full-name] :as tool-call}] (let [approved?* (promise) - {:keys [origin name server parameters]} (tool-by-full-name full-name all-tools) + tool (tool-by-full-name full-name all-tools) + {:keys [origin name server parameters]} tool server-name (:name server) + spawn-agent? (and (= :native origin) + (= "eca" server-name) + (= "spawn_agent" name)) tool-call (update tool-call :arguments - #(if parameters - (tools.util/omit-optional-empty-string-args parameters %) - %)) + #(cond-> (if parameters + (tools.util/omit-optional-empty-string-args parameters %) + %) + spawn-agent? f.tools.agent/normalize-arguments)) decision-plan (decide-tool-call-action tool-call all-tools @db* config agent chat-id {:on-before-hook-action (partial lifecycle/notify-before-hook-action! chat-ctx) :on-after-hook-action (partial lifecycle/notify-after-hook-action! chat-ctx) :trust (get-in @db* [:chats chat-id :trust])}) - {:keys [decision arguments hook-rejected? reason hook-continue + {:keys [decision hook-rejected? reason hook-continue hook-stop-reason arguments-modified?]} decision-plan + arguments (cond-> (:arguments decision-plan) + spawn-agent? f.tools.agent/normalize-arguments) _ (when arguments-modified? (lifecycle/send-content! chat-ctx :system {:type :hookActionFinished :action-type "shell" diff --git a/src/eca/features/tools/agent.clj b/src/eca/features/tools/agent.clj index 8c857c520..9b92871d6 100644 --- a/src/eca/features/tools/agent.clj +++ b/src/eca/features/tools/agent.clj @@ -10,6 +10,20 @@ (set! *warn-on-reflection* true) (def ^:private logger-tag "[AGENT-TOOL]") +(def ^:private activity-summary-max-length 40) + +(defn normalize-arguments + "Normalize spawn_agent arguments before display, history, and invocation." + [arguments] + (let [activity (when (string? (get arguments "activity")) + (-> (get arguments "activity") + str/trim + (str/replace #"\s+" " ")))] + (if (str/blank? activity) + (dissoc arguments "activity") + (assoc arguments "activity" (if (> (count activity) activity-summary-max-length) + (str (subs activity 0 activity-summary-max-length) "...") + activity))))) (defn ^:private all-agents [config] @@ -64,7 +78,9 @@ :name "spawn_agent" :server "eca" :origin "native" - :summary (format "%s: %s" agent-name activity) + :summary (if activity + (format "%s: %s" agent-name activity) + agent-name) :arguments arguments :details (cond-> {:type :subagent :subagent-chat-id subagent-chat-id @@ -107,9 +123,10 @@ "Handler for the spawn_agent tool. Spawns a subagent to perform a focused task and returns the result." [arguments {:keys [db* config messenger metrics chat-id tool-call-id call-state-fn trust]}] - (let [agent-name (get arguments "agent") + (let [arguments (normalize-arguments arguments) + agent-name (get arguments "agent") task (get arguments "task") - activity (get arguments "activity" "working") + activity (get arguments "activity") db @db* ;; Check for nesting - prevent subagents from spawning other subagents @@ -267,12 +284,13 @@ :description "Optional sub-agent model override. Reserved for explicit user override only. Omit unless the user explicitly named a model."} "variant" {:type "string" :description "Optional sub-agent model variant override. Reserved for explicit user override only. Omit unless the user explicitly named a variant."}} - :required ["agent" "task" "activity"]} + :required ["agent" "task"]} :handler #'spawn-agent :summary-fn (fn [{:keys [args]}] (if-let [agent-name (get args "agent")] - (let [activity (get args "activity" "working")] - (format "%s: %s" agent-name activity)) + (if-let [activity (get (normalize-arguments args) "activity")] + (format "%s: %s" agent-name activity) + agent-name) "Spawning agent"))}}) (defmethod tools.util/tool-call-details-before-invocation :spawn_agent diff --git a/test/eca/features/tools/agent_test.clj b/test/eca/features/tools/agent_test.clj index 80ed7707f..5c15d72a0 100644 --- a/test/eca/features/tools/agent_test.clj +++ b/test/eca/features/tools/agent_test.clj @@ -35,6 +35,48 @@ (defn ^:private spawn-handler [] (get-in (f.tools.agent/definitions test-config test-db) ["spawn_agent" :handler])) +(defn ^:private spawn-summary [args] + ((get-in (f.tools.agent/definitions test-config test-db) ["spawn_agent" :summary-fn]) {:args args})) + +(deftest spawn-agent-activity-summary-test + (testing "normal activity label is unchanged" + (is (= "explorer: searching files" + (spawn-summary {"agent" "explorer" "activity" "searching files"})))) + + (testing "whitespace and newlines are collapsed" + (is (= "explorer: searching files" + (spawn-summary {"agent" "explorer" "activity" " searching\n\t files "})))) + + (testing "long activity label is truncated" + (let [long-label (apply str (repeat 80 "a"))] + (is (= (str "explorer: " (apply str (repeat 40 "a")) "...") + (spawn-summary {"agent" "explorer" "activity" long-label}))))) + + (testing "blank activity omits summary suffix" + (is (= "explorer" + (spawn-summary {"agent" "explorer" "activity" " \n "}))) + (is (= "explorer" + (spawn-summary {"agent" "explorer"}))))) + +(deftest spawn-agent-normalize-arguments-test + (is (= {"agent" "explorer" "task" "find" "activity" "searching files"} + (f.tools.agent/normalize-arguments {"agent" "explorer" + "task" "find" + "activity" " searching\nfiles "}))) + (is (= {"agent" "explorer" "task" "find"} + (f.tools.agent/normalize-arguments {"agent" "explorer" + "task" "find" + "activity" ""}))) + (is (= {"agent" "explorer" "task" "find"} + (f.tools.agent/normalize-arguments {"agent" "explorer" + "task" "find" + "activity" ["not" "string"]}))) + (is (= {"agent" "explorer" "task" "find" "activity" "searching files"} + (f.tools.agent/normalize-arguments + (f.tools.agent/normalize-arguments {"agent" "explorer" + "task" "find" + "activity" " searching\nfiles "}))))) + (deftest spawn-agent-not-found-test (testing "throws when agent is not found" (let [db* (atom {:chats {"chat-1" {:id "chat-1"}}}) @@ -585,7 +627,7 @@ "activity" {:type "string"} "model" {:type "string"} "variant" {:type "string"}} - :required ["agent" "task" "activity"]} + :required ["agent" "task"]} (:parameters tool))))) (testing "model and variant enums are absent when no models in db" From fffc3b0e946b05d2f622bfef49cc61ec7a802f54 Mon Sep 17 00:00:00 2001 From: Juha Itkonen Date: Fri, 8 May 2026 17:55:08 +0300 Subject: [PATCH 2/3] Add changelog entry for activity label guardrail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [eca](https://eca.dev) Co-Authored-By: eca-agent --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77e9e820e..b47ece52a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Guard subagent activity labels against overly long model-generated text. + ## 0.133.0 - Add `chat/promptSteerRemove` notification for discarding a pending steer message before it is consumed at the next LLM turn boundary. Idempotent: silent no-op when no steer is pending. From 7649d1dfe4e7cc45afdc44a57c6ed382ddbad0bb Mon Sep 17 00:00:00 2001 From: Juha Itkonen Date: Fri, 8 May 2026 20:33:04 +0300 Subject: [PATCH 3/3] Stabilize spawn agent chat prompt test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR check failed on macOS while the spawn handler had already reached completion output. Keep the regression coverage but allow more time for the end-to-end chat/prompt path and include state details if it times out. 🤖 Generated with [eca](https://eca.dev) Co-Authored-By: eca-agent --- test/eca/features/tools/agent_test.clj | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/eca/features/tools/agent_test.clj b/test/eca/features/tools/agent_test.clj index 0d921e2a6..64f566ef0 100644 --- a/test/eca/features/tools/agent_test.clj +++ b/test/eca/features/tools/agent_test.clj @@ -705,12 +705,17 @@ :chat-id "parent-1" :tool-call-id "tc-1" :call-state-fn (constantly {:status :executing})})) - result (deref result-fut 5000 ::timeout)] + result (deref result-fut 30000 ::timeout) + timeout-details (when (identical? ::timeout result) + (pr-str {:parent-chat (get-in @(h/db*) [:chats "parent-1"]) + :subagent-chat (get-in @(h/db*) [:chats "subagent-tc-1"]) + :messages (h/messages)}))] (when (identical? ::timeout result) (future-cancel result-fut)) (testing "spawn handler completes (regression would hang the polling loop)" (is (not (identical? ::timeout result)) - "spawn handler did not complete in 5s — chat/prompt likely rejected the subagent chat-id")) + (str "spawn handler did not complete in 30s — chat/prompt likely rejected the subagent chat-id. " + timeout-details))) (when (map? result) (testing "spawn handler returns success (would be :error true under v0.133.1)" (is (match? {:error false