From 949bd6173cea737db4e10c7d8c4c8da33088630f Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Wed, 4 Feb 2026 12:37:32 -0300 Subject: [PATCH 01/28] WIP first subagents working --- AGENTS.md | 1 + docs/protocol.md | 18 +- resources/prompts/tools/spawn_agent.md | 12 + src/eca/config.clj | 3 +- src/eca/features/agents.clj | 136 ++++++++ src/eca/features/chat.clj | 448 +++++++++++++------------ src/eca/features/tools.clj | 32 +- src/eca/features/tools/agent.clj | 164 +++++++++ 8 files changed, 596 insertions(+), 218 deletions(-) create mode 100644 resources/prompts/tools/spawn_agent.md create mode 100644 src/eca/features/agents.clj create mode 100644 src/eca/features/tools/agent.clj diff --git a/AGENTS.md b/AGENTS.md index 18e12dbfe..07bbeb5ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,3 +28,4 @@ ECA Agent Guide (AGENTS.md) - Use `clojure.test` + `nubank/matcher-combinators`; keep tests deterministic. - Put shared test helpers under `test/eca/test_helper.clj`. - Use java class typing to avoid GraalVM reflection issues +- Avoid adding too many comments, only add essential or when you think is really important to mention something. diff --git a/docs/protocol.md b/docs/protocol.md index 78e907260..36d353b21 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -524,6 +524,12 @@ interface ChatContentReceivedParams { * The chat session identifier this content belongs to */ chatId: string; + + /** + * If this chat is a subagent, the parent chat id. + * Useful for clients to associate subagent messages with the parent conversation. + */ + parentChatId?: string; /** * The content received from the LLM @@ -1000,7 +1006,7 @@ interface ChatToolCallRejectedContent { type ToolCallOrigin = 'mcp' | 'native'; -type ToolCallDetails = FileChangeDetails | JsonOutputsDetails; +type ToolCallDetails = FileChangeDetails | JsonOutputsDetails | SubagentDetails; interface FileChangeDetails { type: 'fileChange'; @@ -1035,6 +1041,16 @@ interface JsonOutputsDetails { jsons: string[]; } +interface SubagentDetails { + type: 'subagent'; + + /** + * The chatId of this running subagent, useful to link other chat/ContentReceived + * messages to this tool call. + */ + subagentChatId: string; +} + /** * Extra information about a chat */ diff --git a/resources/prompts/tools/spawn_agent.md b/resources/prompts/tools/spawn_agent.md new file mode 100644 index 000000000..c04764219 --- /dev/null +++ b/resources/prompts/tools/spawn_agent.md @@ -0,0 +1,12 @@ +Spawn a specialized agent to perform a focused task in isolated context. + +The agent runs independently with its own conversation history and returns a summary of its findings/actions. Use this for: +- Codebase exploration without polluting your context +- Focused research on specific areas +- Delegating specialized tasks (review, analysis, etc.) + +The spawned agent: +- Has access only to its configured tools +- Cannot spawn other agents (no nesting) +- Returns a summary when complete +- Does not share your conversation history diff --git a/src/eca/config.clj b/src/eca/config.clj index eb345d38d..47f9e4dc8 100644 --- a/src/eca/config.clj +++ b/src/eca/config.clj @@ -124,7 +124,8 @@ "eca__directory_tree" {} "eca__grep" {} "eca__editor_diagnostics" {} - "eca__skill" {}} + "eca__skill" {} + "eca__spawn_agent" {}} :ask {} :deny {}} :readFile {:maxLines 2000} diff --git a/src/eca/features/agents.clj b/src/eca/features/agents.clj new file mode 100644 index 000000000..2eaefffe7 --- /dev/null +++ b/src/eca/features/agents.clj @@ -0,0 +1,136 @@ +(ns eca.features.agents + "Load and parse agent definitions for subagent spawning. + + Agent definitions are Markdown files with YAML frontmatter. + They can be defined at: + - Project level: .eca/agents/*.md (highest priority) + - User level: ~/.config/eca/agents/*.md" + (:require + [babashka.fs :as fs] + [clojure.java.io :as io] + [clojure.string :as str] + [eca.config :as config] + [eca.shared :as shared])) + +(set! *warn-on-reflection* true) + +(defn ^:private parse-yaml-value + "Parses a simple YAML value, handling strings (quoted or unquoted), and basic types." + [s] + (let [trimmed (str/trim s)] + (cond + ;; Empty or null + (or (empty? trimmed) + (= "null" (str/lower-case trimmed))) + nil + + ;; Double-quoted string + (and (str/starts-with? trimmed "\"") (str/ends-with? trimmed "\"")) + (subs trimmed 1 (dec (count trimmed))) + + ;; Single-quoted string + (and (str/starts-with? trimmed "'") (str/ends-with? trimmed "'")) + (subs trimmed 1 (dec (count trimmed))) + + ;; Number + (re-matches #"-?\d+" trimmed) + (parse-long trimmed) + + ;; Unquoted string + :else trimmed))) + +(defn ^:private parse-yaml-list [lines] + (loop [remaining lines + items []] + (if (empty? remaining) + [items remaining] + (let [line (first remaining) + trimmed (str/trim line)] + (if (str/starts-with? trimmed "- ") + (recur (rest remaining) + (conj items (parse-yaml-value (subs trimmed 2)))) + [items remaining]))))) + +(defn ^:private parse-frontmatter [lines] + (loop [remaining lines + result {}] + (if (empty? remaining) + result + (let [line (first remaining)] + (if-let [[_ k v] (re-matches #"^([a-zA-Z_][a-zA-Z0-9_-]*)\s*:\s*(.*)$" line)] + (let [key (keyword k) + value-str (str/trim v)] + (if (empty? value-str) + ;; Empty value - might be followed by list items + (let [[list-items rest-lines] (parse-yaml-list (rest remaining))] + (if (seq list-items) + (recur rest-lines (assoc result key list-items)) + (recur (rest remaining) result))) + ;; Inline value + (recur (rest remaining) (assoc result key (parse-yaml-value value-str))))) + (recur (rest remaining) result)))))) + +(defn ^:private parse-md [md-file] + (let [content (slurp (str md-file)) + lines (str/split-lines content)] + (if (and (seq lines) + (= "---" (str/trim (first lines)))) + (let [after-opening (rest lines) + metadata-lines (take-while #(not= "---" (str/trim %)) after-opening) + body-lines (rest (drop-while #(not= "---" (str/trim %)) after-opening)) + metadata (parse-frontmatter metadata-lines)] + (assoc metadata :content (str/trim (str/join "\n" body-lines)))) + {:content content}))) + +(defn ^:private agent-file->agent [agent-file] + (let [{:keys [name description tools model maxTurns content]} (parse-md agent-file)] + (when (and name description) + {:name name + :description description + :tools (or tools []) + :model model + :max-turns (or maxTurns 25) + :content content + :source (str (fs/canonicalize agent-file))}))) + +(defn global-agents-dir ^java.io.File [] + (let [xdg-config-home (or (config/get-env "XDG_CONFIG_HOME") + (io/file (config/get-property "user.home") ".config"))] + (io/file xdg-config-home "eca" "agents"))) + +(defn ^:private global-agents + [] + (let [agents-dir (global-agents-dir)] + (when (fs/exists? agents-dir) + (keep agent-file->agent + (fs/glob agents-dir "*.md" {:follow-links true}))))) + +(defn ^:private local-agents + [roots] + (->> roots + (mapcat (fn [{:keys [uri]}] + (let [agents-dir (fs/file (shared/uri->filename uri) ".eca" "agents")] + (when (fs/exists? agents-dir) + (fs/glob agents-dir "*.md" {:follow-links true}))))) + (keep agent-file->agent))) + +(defn all + "Returns all available agent definitions. + Priority: local > global (later definitions override earlier ones by name)." + [config roots] + (let [agents-list (concat (when-not (:pureConfig config) + (global-agents)) + (local-agents roots))] + (->> agents-list + (reduce (fn [m agent] + (assoc m (:name agent) agent)) + {}) + vals + vec))) + +(defn get-agent + "Get a specific agent definition by name. + Returns nil if not found." + [agent-name config roots] + (let [agents (all config roots)] + (first (filter #(= agent-name (:name %)) agents)))) diff --git a/src/eca/features/chat.clj b/src/eca/features/chat.clj index 734e82981..df69a9687 100644 --- a/src/eca/features/chat.clj +++ b/src/eca/features/chat.clj @@ -43,12 +43,13 @@ (let [current-percentage (* (/ session-tokens (:context limit)) 100)] (>= current-percentage compact-threshold)))))) -(defn ^:private send-content! [{:keys [messenger chat-id]} role content] +(defn ^:private send-content! [{:keys [messenger chat-id parent-chat-id]} role content] (messenger/chat-content-received messenger - {:chat-id chat-id - :role role - :content content})) + (assoc-some {:chat-id chat-id + :role role + :content content} + :parent-chat-id parent-chat-id))) (defn ^:private notify-before-hook-action! [chat-ctx {:keys [id name type visible?]}] (when visible? @@ -865,208 +866,230 @@ chat-ctx)))) nil)) +(defn ^:private check-subagent-max-turns! + "Check if subagent has reached max turns. Increments turn count. + Returns true if max turns reached, false otherwise. + Only applies to subagents (chats with :agent-def)." + [db* chat-id] + ;; presence of :agent-def indicates this is a subagent + (when-let [agent-def (get-in @db* [:chats chat-id :agent-def])] + (let [max-turns (:max-turns agent-def 25) + new-db (swap! db* update-in [:chats chat-id :current-turn] (fnil inc 0)) + new-turn (get-in new-db [:chats chat-id :current-turn])] + (>= new-turn max-turns)))) + (defn ^:private on-tools-called! [{:keys [db* config chat-id behavior full-model messenger metrics] :as chat-ctx} received-msgs* add-to-history! user-messages] (fn [tool-calls] - (let [all-tools (f.tools/all-tools chat-id behavior @db* config)] + (let [all-tools (f.tools/all-tools chat-id behavior @db* config) + max-turns-reached? (check-subagent-max-turns! db* chat-id)] (assert-chat-not-stopped! chat-ctx) - (when-not (string/blank? @received-msgs*) - (add-to-history! {:role "assistant" :content [{:type :text :text @received-msgs*}]}) - (reset! received-msgs* "")) - (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]} (tool-by-full-name full-name all-tools) - server-name (:name server) - decision-plan (decide-tool-call-action - tool-call all-tools @db* config behavior chat-id - {:on-before-hook-action (partial notify-before-hook-action! chat-ctx) - :on-after-hook-action (partial notify-after-hook-action! chat-ctx)}) - {:keys [decision arguments hook-rejected? reason hook-continue - hook-stop-reason arguments-modified?]} decision-plan - _ (when arguments-modified? - (send-content! chat-ctx :system {:type :hookActionFinished - :action-type "shell" - :id (str (random-uuid)) - :name "input-modification" - :status 0 - :output "Hook modified tool arguments"})) - _ (swap! db* assoc-in [:chats chat-id :tool-calls id :arguments] arguments) - tool-call (assoc tool-call :arguments arguments) - ask? (= :ask decision) - details (f.tools/tool-call-details-before-invocation name arguments server @db* ask?) - summary (f.tools/tool-call-summary all-tools full-name arguments config)] - (when-not (#{:stopping :cleanup} (:status (get-tool-call-state @db* chat-id id))) - (transition-tool-call! db* chat-ctx id :tool-run {:approved?* approved?* - :future-cleanup-complete?* (promise) - :name name - :server server-name - :origin origin - :arguments arguments - :manual-approval ask? - :details details - :summary summary})) - (when-not (#{:stopping :cleanup :rejected} (:status (get-tool-call-state @db* chat-id id))) - (case decision - :ask (transition-tool-call! db* chat-ctx id :approval-ask {:progress-text "Waiting for tool call approval"}) - :allow (transition-tool-call! db* chat-ctx id :approval-allow {:reason reason}) - :deny (transition-tool-call! db* chat-ctx id :approval-deny {:reason reason}) - (logger/warn logger-tag "Unknown value of approval" {:approval decision :tool-call-id id}))) - (if (and @approved?* (not hook-rejected?)) - (when-not (#{:stopping :cleanup} (:status (get-tool-call-state @db* chat-id id))) - (assert-chat-not-stopped! chat-ctx) - (let [delayed-future - (delay - (future - (let [result (f.tools/call-tool! full-name - arguments - chat-id - id - behavior - db* - config - messenger - metrics - (partial get-tool-call-state @db* chat-id id) - (partial transition-tool-call! db* chat-ctx id)) - details (f.tools/tool-call-details-after-invocation name arguments details result) - {:keys [start-time]} (get-tool-call-state @db* chat-id id) - total-time-ms (- (System/currentTimeMillis) start-time)] - (add-to-history! {:role "tool_call" - :content (assoc tool-call - :name name - :details details - :summary summary - :origin origin - :server server-name)}) - (add-to-history! {:role "tool_call_output" - :content (assoc tool-call - :name name - :error (:error result) - :output result - :total-time-ms total-time-ms - :details details - :summary summary - :origin origin - :server server-name)}) - (let [state (get-tool-call-state @db* chat-id id) status (:status state)] - (case status - :executing (transition-tool-call! db* - chat-ctx - id - :execution-end {:origin origin - :name name - :server server-name - :arguments arguments - :error (:error result) - :outputs (:contents result) - :total-time-ms total-time-ms - :progress-text "Generating" - :details details - :summary summary}) - :stopping (transition-tool-call! db* - chat-ctx - id - :stop-attempted {:origin origin - :name name - :server server-name - :arguments arguments - :error (:error result) - :outputs (:contents result) - :total-time-ms total-time-ms - :reason :user-stop :details - details - :summary summary}) - (logger/warn logger-tag "Unexpected value of :status in tool call" {:status status}))))))] - (transition-tool-call! db* - chat-ctx - id - :execution-start {:delayed-future delayed-future - :origin origin - :name name - :server server-name - :arguments arguments - :start-time (System/currentTimeMillis) - :details details - :summary summary - :progress-text "Calling tool"}))) - (let [tool-call-state (get-tool-call-state @db* chat-id id) - {:keys [code text]} (:decision-reason tool-call-state) - effective-hook-continue (when hook-rejected? hook-continue) - effective-hook-stop-reason (when hook-rejected? hook-stop-reason)] - (add-to-history! {:role "tool_call" :content tool-call}) - (add-to-history! {:role "tool_call_output" - :content (assoc tool-call :output {:error true :contents [{:text text :type :text}]})}) - (reset! rejected-tool-call-info* {:code code - :hook-continue effective-hook-continue - :hook-stop-reason effective-hook-stop-reason}) - (transition-tool-call! db* chat-ctx id :send-reject {:origin origin - :name name - :server server-name - :arguments arguments - :reason code - :details details - :summary summary}))))) - tool-calls) - (assert-chat-not-stopped! chat-ctx) - (doseq [[tool-call-id state] (get-active-tool-calls @db* chat-id)] - (when-let [f (:future state)] - (try (deref f) - (catch java.util.concurrent.CancellationException _ - (when-let [p (:future-cleanup-complete?* state)] - (logger/debug logger-tag - "Caught CancellationException. Waiting for future to finish cleanup." - {:tool-call-id tool-call-id :promise p}) - (deref p))) - (catch Throwable t - (logger/debug logger-tag - "Ignoring a Throwable while deref'ing a tool call future" - {:tool-call-id tool-call-id - :ex-data (ex-data t) - :message (.getMessage t) - :cause (.getCause t)})) - (finally (try (let [tool-call-state (get-tool-call-state @db* (:chat-id chat-ctx) tool-call-id)] - (transition-tool-call! - db* - chat-ctx - tool-call-id - :cleanup-finished (merge {:name (:name tool-call-state) - :full-name (:full-name tool-call-state)} - (select-keys tool-call-state [:outputs :error :total-time-ms])))) - (catch Throwable t - (logger/debug logger-tag "Ignoring an exception while finishing tool call" - {:tool-call-id tool-call-id - :ex-data (ex-data t) - :message (.getMessage t) - :cause (.getCause t)}))))))) - (let [all-tools (f.tools/all-tools chat-id behavior @db* config)] - (if-let [rejection-info @rejected-tool-call-info*] - (let [reason-code - (if (map? rejection-info) (:code rejection-info) rejection-info) - hook-continue - (when (map? rejection-info) (:hook-continue rejection-info)) - hook-stop-reason - (when (map? rejection-info) (:hook-stop-reason rejection-info))] - (if (= :hook-rejected reason-code) - (if (false? hook-continue) - (do (send-content! chat-ctx :system {:type :text - :text (or hook-stop-reason "Tool rejected by hook")}) - (finish-chat-prompt! :idle chat-ctx) nil) - {:tools all-tools - :new-messages (get-in @db* [:chats chat-id :messages])}) - (do (send-content! chat-ctx :system {:type :text - :text "Tell ECA what to do differently for the rejected tool(s)"}) - (add-to-history! {:role "user" - :content [{:type :text - :text "I rejected one or more tool calls with the following reason"}]}) - (finish-chat-prompt! :idle chat-ctx) - nil))) - (do - (maybe-renew-auth-token chat-ctx) - (if (auto-compact? chat-id behavior full-model config @db*) - (trigger-auto-compact! chat-ctx all-tools user-messages) - {:tools all-tools - :new-messages (get-in @db* [:chats chat-id :messages])})))))))) + ;; Check subagent max turns - if reached, finish without executing more tools + (if max-turns-reached? + (do + (logger/info logger-tag "Subagent reached max turns, finishing" {:chat-id chat-id}) + (when-not (string/blank? @received-msgs*) + (add-to-history! {:role "assistant" :content [{:type :text :text @received-msgs*}]})) + (finish-chat-prompt! :idle chat-ctx) + nil) + (do + (when-not (string/blank? @received-msgs*) + (add-to-history! {:role "assistant" :content [{:type :text :text @received-msgs*}]}) + (reset! received-msgs* "")) + (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]} (tool-by-full-name full-name all-tools) + server-name (:name server) + decision-plan (decide-tool-call-action + tool-call all-tools @db* config behavior chat-id + {:on-before-hook-action (partial notify-before-hook-action! chat-ctx) + :on-after-hook-action (partial notify-after-hook-action! chat-ctx)}) + {:keys [decision arguments hook-rejected? reason hook-continue + hook-stop-reason arguments-modified?]} decision-plan + _ (when arguments-modified? + (send-content! chat-ctx :system {:type :hookActionFinished + :action-type "shell" + :id (str (random-uuid)) + :name "input-modification" + :status 0 + :output "Hook modified tool arguments"})) + _ (swap! db* assoc-in [:chats chat-id :tool-calls id :arguments] arguments) + tool-call (assoc tool-call :arguments arguments) + ask? (= :ask decision) + details (f.tools/tool-call-details-before-invocation name arguments server @db* ask? id) + summary (f.tools/tool-call-summary all-tools full-name arguments config)] + (when-not (#{:stopping :cleanup} (:status (get-tool-call-state @db* chat-id id))) + (transition-tool-call! db* chat-ctx id :tool-run {:approved?* approved?* + :future-cleanup-complete?* (promise) + :name name + :server server-name + :origin origin + :arguments arguments + :manual-approval ask? + :details details + :summary summary})) + (when-not (#{:stopping :cleanup :rejected} (:status (get-tool-call-state @db* chat-id id))) + (case decision + :ask (transition-tool-call! db* chat-ctx id :approval-ask {:progress-text "Waiting for tool call approval"}) + :allow (transition-tool-call! db* chat-ctx id :approval-allow {:reason reason}) + :deny (transition-tool-call! db* chat-ctx id :approval-deny {:reason reason}) + (logger/warn logger-tag "Unknown value of approval" {:approval decision :tool-call-id id}))) + (if (and @approved?* (not hook-rejected?)) + (when-not (#{:stopping :cleanup} (:status (get-tool-call-state @db* chat-id id))) + (assert-chat-not-stopped! chat-ctx) + (let [delayed-future + (delay + (future + (let [result (f.tools/call-tool! full-name + arguments + chat-id + id + behavior + db* + config + messenger + metrics + (partial get-tool-call-state @db* chat-id id) + (partial transition-tool-call! db* chat-ctx id)) + details (f.tools/tool-call-details-after-invocation name arguments details result) + {:keys [start-time]} (get-tool-call-state @db* chat-id id) + total-time-ms (- (System/currentTimeMillis) start-time)] + (add-to-history! {:role "tool_call" + :content (assoc tool-call + :name name + :details details + :summary summary + :origin origin + :server server-name)}) + (add-to-history! {:role "tool_call_output" + :content (assoc tool-call + :name name + :error (:error result) + :output result + :total-time-ms total-time-ms + :details details + :summary summary + :origin origin + :server server-name)}) + (let [state (get-tool-call-state @db* chat-id id) status (:status state)] + (case status + :executing (transition-tool-call! db* + chat-ctx + id + :execution-end {:origin origin + :name name + :server server-name + :arguments arguments + :error (:error result) + :outputs (:contents result) + :total-time-ms total-time-ms + :progress-text "Generating" + :details details + :summary summary}) + :stopping (transition-tool-call! db* + chat-ctx + id + :stop-attempted {:origin origin + :name name + :server server-name + :arguments arguments + :error (:error result) + :outputs (:contents result) + :total-time-ms total-time-ms + :reason :user-stop :details + details + :summary summary}) + (logger/warn logger-tag "Unexpected value of :status in tool call" {:status status}))))))] + (transition-tool-call! db* + chat-ctx + id + :execution-start {:delayed-future delayed-future + :origin origin + :name name + :server server-name + :arguments arguments + :start-time (System/currentTimeMillis) + :details details + :summary summary + :progress-text "Calling tool"}))) + (let [tool-call-state (get-tool-call-state @db* chat-id id) + {:keys [code text]} (:decision-reason tool-call-state) + effective-hook-continue (when hook-rejected? hook-continue) + effective-hook-stop-reason (when hook-rejected? hook-stop-reason)] + (add-to-history! {:role "tool_call" :content tool-call}) + (add-to-history! {:role "tool_call_output" + :content (assoc tool-call :output {:error true :contents [{:text text :type :text}]})}) + (reset! rejected-tool-call-info* {:code code + :hook-continue effective-hook-continue + :hook-stop-reason effective-hook-stop-reason}) + (transition-tool-call! db* chat-ctx id :send-reject {:origin origin + :name name + :server server-name + :arguments arguments + :reason code + :details details + :summary summary}))))) + tool-calls) + (assert-chat-not-stopped! chat-ctx) + (doseq [[tool-call-id state] (get-active-tool-calls @db* chat-id)] + (when-let [f (:future state)] + (try (deref f) + (catch java.util.concurrent.CancellationException _ + (when-let [p (:future-cleanup-complete?* state)] + (logger/debug logger-tag + "Caught CancellationException. Waiting for future to finish cleanup." + {:tool-call-id tool-call-id :promise p}) + (deref p))) + (catch Throwable t + (logger/debug logger-tag + "Ignoring a Throwable while deref'ing a tool call future" + {:tool-call-id tool-call-id + :ex-data (ex-data t) + :message (.getMessage t) + :cause (.getCause t)})) + (finally (try (let [tool-call-state (get-tool-call-state @db* (:chat-id chat-ctx) tool-call-id)] + (transition-tool-call! + db* + chat-ctx + tool-call-id + :cleanup-finished (merge {:name (:name tool-call-state) + :full-name (:full-name tool-call-state)} + (select-keys tool-call-state [:outputs :error :total-time-ms])))) + (catch Throwable t + (logger/debug logger-tag "Ignoring an exception while finishing tool call" + {:tool-call-id tool-call-id + :ex-data (ex-data t) + :message (.getMessage t) + :cause (.getCause t)}))))))) + (let [all-tools (f.tools/all-tools chat-id behavior @db* config)] + (if-let [rejection-info @rejected-tool-call-info*] + (let [reason-code + (if (map? rejection-info) (:code rejection-info) rejection-info) + hook-continue + (when (map? rejection-info) (:hook-continue rejection-info)) + hook-stop-reason + (when (map? rejection-info) (:hook-stop-reason rejection-info))] + (if (= :hook-rejected reason-code) + (if (false? hook-continue) + (do (send-content! chat-ctx :system {:type :text + :text (or hook-stop-reason "Tool rejected by hook")}) + (finish-chat-prompt! :idle chat-ctx) nil) + {:tools all-tools + :new-messages (get-in @db* [:chats chat-id :messages])}) + (do (send-content! chat-ctx :system {:type :text + :text "Tell ECA what to do differently for the rejected tool(s)"}) + (add-to-history! {:role "user" + :content [{:type :text + :text "I rejected one or more tool calls with the following reason"}]}) + (finish-chat-prompt! :idle chat-ctx) + nil))) + (do + (maybe-renew-auth-token chat-ctx) + (if (auto-compact? chat-id behavior full-model config @db*) + (trigger-auto-compact! chat-ctx all-tools user-messages) + {:tools all-tools + :new-messages (get-in @db* [:chats chat-id :messages])})))))))))) (defn ^:private assert-compatible-apis-between-models! "Ensure new request is compatible with last api used. @@ -1447,16 +1470,17 @@ (swap! db* assoc-in [:chats new-id] {:id new-id}) new-id)) selected-behavior (config/validate-behavior-name raw-behavior config) - base-chat-ctx {:metrics metrics - :config config - :contexts contexts - :db* db* - :messenger messenger - :user-content-id (new-content-id) - :message (string/trim message) - :chat-id chat-id - :behavior selected-behavior - :behavior-config (get-in config [:behavior selected-behavior])}] + base-chat-ctx (assoc-some {:metrics metrics + :config config + :contexts contexts + :db* db* + :messenger messenger + :user-content-id (new-content-id) + :message (string/trim message) + :chat-id chat-id + :behavior selected-behavior + :behavior-config (get-in config [:behavior selected-behavior])} + :parent-chat-id (get-in @db* [:chats chat-id :parent-chat-id]))] (try (prompt* params base-chat-ctx) (catch Exception e diff --git a/src/eca/features/tools.clj b/src/eca/features/tools.clj index 01ecd13da..031f24ff4 100644 --- a/src/eca/features/tools.clj +++ b/src/eca/features/tools.clj @@ -4,6 +4,7 @@ (:require [clojure.string :as string] [clojure.walk :as walk] + [eca.features.tools.agent :as f.tools.agent] [eca.features.tools.chat :as f.tools.chat] [eca.features.tools.custom :as f.tools.custom] [eca.features.tools.editor :as f.tools.editor] @@ -147,17 +148,34 @@ f.tools.editor/definitions f.tools.chat/definitions f.tools.skill/definitions + (f.tools.agent/definitions db config) (f.tools.custom/definitions config)))) (defn native-tools [db config] (mapv #(assoc % :server {:name "eca"}) (vals (native-definitions db config)))) +(defn ^:private filter-subagent-tools + "Filter tools for subagent execution. + - Only allow tools specified in the agent definition + - Always exclude spawn_agent to prevent nesting" + [tools agent-def] + (let [allowed-tools (set (:tools agent-def))] + (->> tools + ;; Always exclude spawn_agent to prevent nesting + (remove #(= "spawn_agent" (:name %))) + ;; If agent has tool restrictions, apply them + (filterv #(or (empty? allowed-tools) + (contains? allowed-tools (:name %))))))) + (defn all-tools "Returns all available tools, including both native ECA tools (like filesystem and shell tools) and tools provided by MCP servers. - Removes denied tools." + Removes denied tools. + When chat is a subagent (has :agent-def), filters tools based on agent definition." [chat-id behavior db config] (let [disabled-tools (get-disabled-tools config behavior) + ;; presence of :agent-def indicates this is a subagent + agent-def (get-in db [:chats chat-id :agent-def]) all-tools (->> (concat (mapv #(assoc % :origin :native) (native-tools db config)) (mapv #(assoc % :origin :mcp) (f.mcp/all-tools db))) @@ -175,7 +193,11 @@ {:behavior behavior :db db :chat-id chat-id - :config config})))))] + :config config}))))) + ;; Apply subagent tool filtering if applicable + all-tools (if agent-def + (filter-subagent-tools all-tools agent-def) + all-tools)] (remove (fn [tool] (= :deny (approval all-tools tool {} db config behavior))) all-tools))) @@ -207,6 +229,7 @@ :config config :messenger messenger :behavior behavior + :metrics metrics :chat-id chat-id :tool-call-id tool-call-id :call-state-fn call-state-fn @@ -278,10 +301,11 @@ (defn tool-call-details-before-invocation "Return the tool call details before invoking the tool." - [name arguments server db ask-approval?] + [name arguments server db ask-approval? tool-call-id] (try (tools.util/tool-call-details-before-invocation name arguments server {:db db - :ask-approval? ask-approval?}) + :ask-approval? ask-approval? + :tool-call-id tool-call-id}) (catch Exception e ;; Avoid failling tool call because of error on getting details. (logger/error logger-tag (format "Error getting details for %s with args %s: %s" name arguments e)) diff --git a/src/eca/features/tools/agent.clj b/src/eca/features/tools/agent.clj new file mode 100644 index 000000000..88d0e3285 --- /dev/null +++ b/src/eca/features/tools/agent.clj @@ -0,0 +1,164 @@ +(ns eca.features.tools.agent + "Tool for spawning subagents to perform focused tasks in isolated context." + (:require + [clojure.string :as str] + [eca.features.agents :as f.agents] + [eca.features.tools.util :as tools.util] + [eca.logger :as logger])) + +(set! *warn-on-reflection* true) + +(def ^:private logger-tag "[AGENT-TOOL]") + +(defn ^:private build-task-prompt + [task max-turns] + (format "%s\n\nIMPORTANT: You have a maximum of %d turns to complete this task. Be efficient and provide a clear summary of your findings before reaching the limit." + task max-turns)) + +(defn ^:private extract-final-summary + "Extract the final assistant message as summary from chat messages." + [messages] + (let [assistant-messages (->> messages + (filter #(= "assistant" (:role %))) + (map :content) + (filter seq))] + (if (seq assistant-messages) + (let [last-content (last assistant-messages)] + (->> last-content + (filter #(= :text (:type %))) + (map :text) + (str/join "\n"))) + "Agent completed without producing output."))) + +(defn subagent-chat-id + "Generate a deterministic subagent chat id from the tool-call-id." + [tool-call-id] + (str "subagent-" tool-call-id)) + +(defn ^:private spawn-agent + "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 behavior tool-call-id]}] + (let [agent-name (get arguments "agent") + task (get arguments "task") + db @db* + + ;; Check for nesting - prevent subagents from spawning other subagents + ;; (presence of :agent-def indicates this is a subagent) + _ (when (get-in db [:chats chat-id :agent-def]) + (throw (ex-info "Agents cannot spawn other agents (nesting not allowed)" + {:agent-name agent-name + :parent-chat-id chat-id}))) + + ;; Load agent definition + agent-def (f.agents/get-agent agent-name config (:workspace-folders db)) + _ (when-not agent-def + (let [available (f.agents/all config (:workspace-folders db))] + (throw (ex-info (format "Agent '%s' not found. Available agents: %s" + agent-name + (if (seq available) + (str/join ", " (map :name available)) + "none")) + {:agent-name agent-name + :available (map :name available)})))) + + ;; Create subagent chat session using deterministic id based on tool-call-id + subagent-chat-id* (subagent-chat-id tool-call-id) + + ;; Determine model (inherit from parent if not specified) + parent-model (get-in db [:chats chat-id :model]) + subagent-model (or (:model agent-def) parent-model)] + + (logger/info logger-tag (format "Spawning agent '%s' for task: %s" agent-name task)) + + ;; Initialize subagent chat in db + ;; Note: presence of :agent-def indicates this is a subagent + (let [max-turns (:max-turns agent-def 25)] + (swap! db* assoc-in [:chats subagent-chat-id*] + {:id subagent-chat-id* + :parent-chat-id chat-id + :agent-name agent-name + :agent-def agent-def + :max-turns max-turns + :current-turn 0}) + + ;; Require chat ns here to avoid circular dependency + (let [chat-prompt (requiring-resolve 'eca.features.chat/prompt) + task-prompt (build-task-prompt task max-turns)] + ;; Start subagent execution + (chat-prompt + {:message task-prompt + :chat-id subagent-chat-id* + :model subagent-model + :behavior behavior + :contexts []} + db* + messenger + config + metrics))) + + ;; Wait for subagent to complete by polling status + ;; TODO: In future, use a proper callback/promise mechanism + (loop [wait-count 0] + (let [status (get-in @db* [:chats subagent-chat-id* :status])] + (cond + ;; Completed + (#{:idle :error} status) + (let [messages (get-in @db* [:chats subagent-chat-id* :messages] []) + summary (extract-final-summary messages) + turn-count (get-in @db* [:chats subagent-chat-id* :current-turn] 0)] + (logger/info logger-tag (format "Agent '%s' completed after %d turns" agent-name turn-count)) + ;; Cleanup subagent chat + (swap! db* update :chats dissoc subagent-chat-id*) + {:error false + :contents [{:type :text + :text (format "## Agent '%s' Result\n\n%s" agent-name summary)}]}) + + ;; Timeout after ~5 minutes + (> wait-count 300) + (do + (logger/warn logger-tag (format "Agent '%s' timed out" agent-name)) + (swap! db* update :chats dissoc subagent-chat-id*) + {:error true + :contents [{:type :text + :text (format "Agent '%s' timed out after 10 minutes" agent-name)}]}) + + ;; Keep waiting + :else + (do + (Thread/sleep 1000) + (recur (inc wait-count)))))))) + +(defn ^:private build-description + "Build tool description with available agents listed." + [db config] + (let [base-description (tools.util/read-tool-description "spawn_agent") + agents (f.agents/all config (:workspace-folders db)) + agents-section (str "\n\nAvailable agents:\n" + (->> agents + (map (fn [{:keys [name description]}] + (str "- " name ": " description))) + (str/join "\n")))] + (str base-description agents-section))) + +(defn definitions + [db config] + {"spawn_agent" + {:description (build-description db config) + :parameters {:type "object" + :properties {"agent" {:type "string" + :description "Name of the agent to spawn"} + "task" {:type "string" + :description "Clear description of what the agent should accomplish"}} + :required ["agent" "task"]} + :handler #'spawn-agent + :summary-fn (fn [{:keys [args]}] + (if-let [agent-name (get args "agent")] + (format "Running agent '%s'" agent-name) + "Spawning agent"))}}) + +(defmethod tools.util/tool-call-details-before-invocation :spawn_agent + [_name _arguments _server {:keys [tool-call-id]}] + (when tool-call-id + {:type :subagent + :subagent-chat-id (subagent-chat-id tool-call-id)})) From 0cae2b1ffb44d938d3e013d7cbc785295892159f Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Fri, 6 Feb 2026 20:10:14 -0300 Subject: [PATCH 02/28] Improve stop subagent --- docs/protocol.md | 3 +- src/eca/features/chat.clj | 2 +- src/eca/features/tools.clj | 4 +- src/eca/features/tools/agent.clj | 86 +++++++++++++++++++------------- 4 files changed, 58 insertions(+), 37 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index 36d353b21..44bd5fdc6 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -1047,8 +1047,9 @@ interface SubagentDetails { /** * The chatId of this running subagent, useful to link other chat/ContentReceived * messages to this tool call. + * Available from toolCallRun afterwards */ - subagentChatId: string; + subagentChatId?: string; } /** diff --git a/src/eca/features/chat.clj b/src/eca/features/chat.clj index 6e65cfbca..2a4ea0590 100644 --- a/src/eca/features/chat.clj +++ b/src/eca/features/chat.clj @@ -917,7 +917,7 @@ _ (swap! db* assoc-in [:chats chat-id :tool-calls id :arguments] arguments) tool-call (assoc tool-call :arguments arguments) ask? (= :ask decision) - details (f.tools/tool-call-details-before-invocation name arguments server @db* ask? id) + details (f.tools/tool-call-details-before-invocation name arguments server @db* config chat-id ask? id) summary (f.tools/tool-call-summary all-tools full-name arguments config @db*)] (when-not (#{:stopping :cleanup} (:status (get-tool-call-state @db* chat-id id))) (transition-tool-call! db* chat-ctx id :tool-run {:approved?* approved?* diff --git a/src/eca/features/tools.clj b/src/eca/features/tools.clj index 076547584..4edabe1bd 100644 --- a/src/eca/features/tools.clj +++ b/src/eca/features/tools.clj @@ -303,9 +303,11 @@ (defn tool-call-details-before-invocation "Return the tool call details before invoking the tool." - [name arguments server db ask-approval? tool-call-id] + [name arguments server db config chat-id ask-approval? tool-call-id] (try (tools.util/tool-call-details-before-invocation name arguments server {:db db + :config config + :chat-id chat-id :ask-approval? ask-approval? :tool-call-id tool-call-id}) (catch Exception e diff --git a/src/eca/features/tools/agent.clj b/src/eca/features/tools/agent.clj index 88d0e3285..aa13a6250 100644 --- a/src/eca/features/tools/agent.clj +++ b/src/eca/features/tools/agent.clj @@ -35,10 +35,20 @@ [tool-call-id] (str "subagent-" tool-call-id)) +(defn ^:private stop-subagent-chat! + "Stop a running subagent chat and clean up its state from db." + [db* messenger metrics subagent-chat-id agent-name] + (let [prompt-stop (requiring-resolve 'eca.features.chat/prompt-stop)] + (try + (prompt-stop {:chat-id subagent-chat-id} db* messenger metrics) + (catch Exception e + (logger/warn logger-tag (format "Error stopping subagent '%s': %s" agent-name (.getMessage e)))))) + (swap! db* update :chats dissoc subagent-chat-id)) + (defn ^:private spawn-agent "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 behavior tool-call-id]}] + [arguments {:keys [db* config messenger metrics chat-id behavior tool-call-id call-state-fn]}] (let [agent-name (get arguments "agent") task (get arguments "task") db @db* @@ -98,36 +108,38 @@ metrics))) ;; Wait for subagent to complete by polling status - ;; TODO: In future, use a proper callback/promise mechanism - (loop [wait-count 0] - (let [status (get-in @db* [:chats subagent-chat-id* :status])] - (cond - ;; Completed - (#{:idle :error} status) - (let [messages (get-in @db* [:chats subagent-chat-id* :messages] []) - summary (extract-final-summary messages) - turn-count (get-in @db* [:chats subagent-chat-id* :current-turn] 0)] - (logger/info logger-tag (format "Agent '%s' completed after %d turns" agent-name turn-count)) - ;; Cleanup subagent chat - (swap! db* update :chats dissoc subagent-chat-id*) - {:error false - :contents [{:type :text - :text (format "## Agent '%s' Result\n\n%s" agent-name summary)}]}) - - ;; Timeout after ~5 minutes - (> wait-count 300) - (do - (logger/warn logger-tag (format "Agent '%s' timed out" agent-name)) - (swap! db* update :chats dissoc subagent-chat-id*) - {:error true - :contents [{:type :text - :text (format "Agent '%s' timed out after 10 minutes" agent-name)}]}) - - ;; Keep waiting - :else - (do - (Thread/sleep 1000) - (recur (inc wait-count)))))))) + (let [stopped-result (fn [] + (logger/info logger-tag (format "Agent '%s' stopped by parent chat" agent-name)) + (stop-subagent-chat! db* messenger metrics subagent-chat-id* agent-name) + {:error true + :contents [{:type :text + :text (format "Agent '%s' was stopped because the parent chat was stopped." agent-name)}]})] + (try + (loop [] + (let [status (get-in @db* [:chats subagent-chat-id* :status])] + (cond + ;; Parent chat stopped — propagate stop to subagent + (= :stopping (:status (call-state-fn))) + (stopped-result) + + ;; Subagent completed + (#{:idle :error} status) + (let [messages (get-in @db* [:chats subagent-chat-id* :messages] []) + summary (extract-final-summary messages) + turn-count (get-in @db* [:chats subagent-chat-id* :current-turn] 0)] + (logger/info logger-tag (format "Agent '%s' completed after %d turns" agent-name turn-count)) + (swap! db* update :chats dissoc subagent-chat-id*) + {:error false + :contents [{:type :text + :text (format "## Agent '%s' Result\n\n%s" agent-name summary)}]}) + + ;; Keep waiting + :else + (do + (Thread/sleep 1000) + (recur))))) + (catch InterruptedException _ + (stopped-result)))))) (defn ^:private build-description "Build tool description with available agents listed." @@ -158,7 +170,13 @@ "Spawning agent"))}}) (defmethod tools.util/tool-call-details-before-invocation :spawn_agent - [_name _arguments _server {:keys [tool-call-id]}] - (when tool-call-id + [_name arguments _server {:keys [db config chat-id tool-call-id]}] + (let [agent-name (get arguments "agent") + agent-def (when agent-name + (f.agents/get-agent agent-name config (:workspace-folders db))) + parent-model (get-in db [:chats chat-id :model]) + subagent-model (or (:model agent-def) parent-model)] {:type :subagent - :subagent-chat-id (subagent-chat-id tool-call-id)})) + :subagent-chat-id (when tool-call-id + (subagent-chat-id tool-call-id)) + :model subagent-model})) \ No newline at end of file From 8848ccdd32cbdc08de4037d951813c4b6960016f Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Sat, 7 Feb 2026 00:13:13 -0300 Subject: [PATCH 03/28] Enhance protocol --- docs/protocol.md | 15 +++ src/eca/features/chat.clj | 9 +- src/eca/features/tools.clj | 4 +- src/eca/features/tools/agent.clj | 96 +++++++++++++------ src/eca/features/tools/mcp/clojure_mcp.clj | 12 +-- src/eca/features/tools/util.clj | 4 +- .../features/tools/mcp/clojure_mcp_test.clj | 12 ++- 7 files changed, 105 insertions(+), 47 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index 44bd5fdc6..d52ae62fb 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -1050,6 +1050,21 @@ interface SubagentDetails { * Available from toolCallRun afterwards */ subagentChatId?: string; + + /** + * The model this subagent is using. + */ + model: string; + + /** + * The max number of turns this subagent is limited. + */ + maxTurns: number; + + /** + * The current turn. + */ + turn: number; } /** diff --git a/src/eca/features/chat.clj b/src/eca/features/chat.clj index 2a4ea0590..d7aa6c261 100644 --- a/src/eca/features/chat.clj +++ b/src/eca/features/chat.clj @@ -874,7 +874,7 @@ ;; presence of :agent-def indicates this is a subagent (when-let [agent-def (get-in @db* [:chats chat-id :agent-def])] (let [max-turns (:max-turns agent-def 25) - new-db (swap! db* update-in [:chats chat-id :current-turn] (fnil inc 0)) + new-db (swap! db* update-in [:chats chat-id :current-turn] (fnil inc 1)) new-turn (get-in new-db [:chats chat-id :current-turn])] (>= new-turn max-turns)))) @@ -952,7 +952,11 @@ metrics (partial get-tool-call-state @db* chat-id id) (partial transition-tool-call! db* chat-ctx id)) - details (f.tools/tool-call-details-after-invocation name arguments details result) + details (f.tools/tool-call-details-after-invocation name arguments details result + {:db @db* + :config config + :chat-id chat-id + :tool-call-id id}) {:keys [start-time]} (get-tool-call-state @db* chat-id id) total-time-ms (- (System/currentTimeMillis) start-time)] (add-to-history! {:role "tool_call" @@ -1152,6 +1156,7 @@ user-messages)] (when user-messages (swap! db* assoc-in [:chats chat-id :status] :running) + (swap! db* assoc-in [:chats chat-id :model] full-model) (let [_ (maybe-renew-auth-token chat-ctx) db @db* past-messages (get-in db [:chats chat-id :messages] []) diff --git a/src/eca/features/tools.clj b/src/eca/features/tools.clj index 4edabe1bd..00e5202f2 100644 --- a/src/eca/features/tools.clj +++ b/src/eca/features/tools.clj @@ -317,8 +317,8 @@ (defn tool-call-details-after-invocation "Return the tool call details after invoking the tool." - [name arguments details result] - (tools.util/tool-call-details-after-invocation name arguments details result)) + [name arguments details result ctx] + (tools.util/tool-call-details-after-invocation name arguments details result ctx)) (defn tool-call-destroy-resource! "Destroy the resource in the tool call named `name`." diff --git a/src/eca/features/tools/agent.clj b/src/eca/features/tools/agent.clj index aa13a6250..aea726bbe 100644 --- a/src/eca/features/tools/agent.clj +++ b/src/eca/features/tools/agent.clj @@ -4,16 +4,15 @@ [clojure.string :as str] [eca.features.agents :as f.agents] [eca.features.tools.util :as tools.util] - [eca.logger :as logger])) + [eca.logger :as logger] + [eca.messenger :as messenger])) (set! *warn-on-reflection* true) (def ^:private logger-tag "[AGENT-TOOL]") -(defn ^:private build-task-prompt - [task max-turns] - (format "%s\n\nIMPORTANT: You have a maximum of %d turns to complete this task. Be efficient and provide a clear summary of your findings before reaching the limit." - task max-turns)) +(defn ^:private max-turns [agent-def] + (or (:max-turns agent-def) 25)) (defn ^:private extract-final-summary "Extract the final assistant message as summary from chat messages." @@ -30,11 +29,32 @@ (str/join "\n"))) "Agent completed without producing output."))) -(defn subagent-chat-id +(defn ->subagent-chat-id "Generate a deterministic subagent chat id from the tool-call-id." [tool-call-id] (str "subagent-" tool-call-id)) +(defn ^:private send-turn-progress! + "Send a toolCallRunning notification with current turn progress to the parent chat." + [messenger chat-id tool-call-id agent-name subagent-chat-id turn max-turns model arguments] + (messenger/chat-content-received + messenger + {:chat-id chat-id + :role :assistant + :content {:type :toolCallRunning + :id tool-call-id + :name "spawn_agent" + :server "eca" + :origin "native" + :summary (format "Running agent '%s'" agent-name) + :arguments arguments + :details {:type :subagent + :subagent-chat-id subagent-chat-id + :model model + :agent-name agent-name + :turn turn + :max-turns max-turns}}})) + (defn ^:private stop-subagent-chat! "Stop a running subagent chat and clean up its state from db." [db* messenger metrics subagent-chat-id agent-name] @@ -54,7 +74,6 @@ db @db* ;; Check for nesting - prevent subagents from spawning other subagents - ;; (presence of :agent-def indicates this is a subagent) _ (when (get-in db [:chats chat-id :agent-def]) (throw (ex-info "Agents cannot spawn other agents (nesting not allowed)" {:agent-name agent-name @@ -73,32 +92,29 @@ :available (map :name available)})))) ;; Create subagent chat session using deterministic id based on tool-call-id - subagent-chat-id* (subagent-chat-id tool-call-id) + subagent-chat-id (->subagent-chat-id tool-call-id) - ;; Determine model (inherit from parent if not specified) parent-model (get-in db [:chats chat-id :model]) subagent-model (or (:model agent-def) parent-model)] (logger/info logger-tag (format "Spawning agent '%s' for task: %s" agent-name task)) - ;; Initialize subagent chat in db - ;; Note: presence of :agent-def indicates this is a subagent - (let [max-turns (:max-turns agent-def 25)] - (swap! db* assoc-in [:chats subagent-chat-id*] - {:id subagent-chat-id* + (let [max-turns (max-turns agent-def)] + (swap! db* assoc-in [:chats subagent-chat-id] + {:id subagent-chat-id :parent-chat-id chat-id :agent-name agent-name :agent-def agent-def :max-turns max-turns - :current-turn 0}) + :current-turn 1}) ;; Require chat ns here to avoid circular dependency (let [chat-prompt (requiring-resolve 'eca.features.chat/prompt) - task-prompt (build-task-prompt task max-turns)] - ;; Start subagent execution + task-prompt (format "%s\n\nIMPORTANT: You have a maximum of %d turns to complete this task. Be efficient and provide a clear summary of your findings before reaching the limit." + task max-turns)] (chat-prompt {:message task-prompt - :chat-id subagent-chat-id* + :chat-id subagent-chat-id :model subagent-model :behavior behavior :contexts []} @@ -110,13 +126,19 @@ ;; Wait for subagent to complete by polling status (let [stopped-result (fn [] (logger/info logger-tag (format "Agent '%s' stopped by parent chat" agent-name)) - (stop-subagent-chat! db* messenger metrics subagent-chat-id* agent-name) + (stop-subagent-chat! db* messenger metrics subagent-chat-id agent-name) {:error true :contents [{:type :text :text (format "Agent '%s' was stopped because the parent chat was stopped." agent-name)}]})] (try - (loop [] - (let [status (get-in @db* [:chats subagent-chat-id* :status])] + (loop [last-turn 0] + (let [db @db* + status (get-in db [:chats subagent-chat-id :status]) + current-turn (get-in db [:chats subagent-chat-id :current-turn] 1)] + ;; Send turn progress when turn advances + (when (> current-turn last-turn) + (send-turn-progress! messenger chat-id tool-call-id agent-name + subagent-chat-id current-turn (max-turns agent-def) subagent-model arguments)) (cond ;; Parent chat stopped — propagate stop to subagent (= :stopping (:status (call-state-fn))) @@ -124,11 +146,13 @@ ;; Subagent completed (#{:idle :error} status) - (let [messages (get-in @db* [:chats subagent-chat-id* :messages] []) - summary (extract-final-summary messages) - turn-count (get-in @db* [:chats subagent-chat-id* :current-turn] 0)] - (logger/info logger-tag (format "Agent '%s' completed after %d turns" agent-name turn-count)) - (swap! db* update :chats dissoc subagent-chat-id*) + (let [messages (get-in db [:chats subagent-chat-id :messages] []) + summary (extract-final-summary messages)] + (logger/info logger-tag (format "Agent '%s' completed after %d turns" agent-name current-turn)) + (swap! db* (fn [db] + (-> db + (assoc-in [:chats chat-id :tool-calls tool-call-id :subagent-final-turn] current-turn) + (update :chats dissoc subagent-chat-id)))) {:error false :contents [{:type :text :text (format "## Agent '%s' Result\n\n%s" agent-name summary)}]}) @@ -137,7 +161,7 @@ :else (do (Thread/sleep 1000) - (recur))))) + (recur (long (max last-turn current-turn))))))) (catch InterruptedException _ (stopped-result)))))) @@ -175,8 +199,18 @@ agent-def (when agent-name (f.agents/get-agent agent-name config (:workspace-folders db))) parent-model (get-in db [:chats chat-id :model]) - subagent-model (or (:model agent-def) parent-model)] + subagent-model (or (:model agent-def) parent-model) + subagent-chat-id (when tool-call-id + (->subagent-chat-id tool-call-id))] {:type :subagent - :subagent-chat-id (when tool-call-id - (subagent-chat-id tool-call-id)) - :model subagent-model})) \ No newline at end of file + :subagent-chat-id subagent-chat-id + :model subagent-model + :agent-name agent-name + :turn (get-in db [:chats subagent-chat-id :current-turn] 1) + :max-turns (max-turns agent-def)})) + +(defmethod tools.util/tool-call-details-after-invocation :spawn_agent + [_name _arguments before-details _result {:keys [db chat-id tool-call-id]}] + (let [final-turn (get-in db [:chats chat-id :tool-calls tool-call-id :subagent-final-turn] + (or (:turn before-details) 1))] + (assoc before-details :turn final-turn))) diff --git a/src/eca/features/tools/mcp/clojure_mcp.clj b/src/eca/features/tools/mcp/clojure_mcp.clj index 8d19fc24d..89620676e 100644 --- a/src/eca/features/tools/mcp/clojure_mcp.clj +++ b/src/eca/features/tools/mcp/clojure_mcp.clj @@ -35,13 +35,13 @@ (defmethod tools.util/tool-call-details-before-invocation :file_write [name args server ctx] (clojure-edit-details-before-invocation name args server ctx true)) -(defmethod tools.util/tool-call-details-after-invocation :clojure_edit [_name arguments details result] - (tools.util/tool-call-details-after-invocation :file_edit arguments details result)) +(defmethod tools.util/tool-call-details-after-invocation :clojure_edit [_name arguments details result ctx] + (tools.util/tool-call-details-after-invocation :file_edit arguments details result ctx)) -(defmethod tools.util/tool-call-details-after-invocation :clojure_edit_replace_sexp [_name arguments details result] - (tools.util/tool-call-details-after-invocation :file_edit arguments details result)) +(defmethod tools.util/tool-call-details-after-invocation :clojure_edit_replace_sexp [_name arguments details result ctx] + (tools.util/tool-call-details-after-invocation :file_edit arguments details result ctx)) -(defmethod tools.util/tool-call-details-after-invocation :file_edit [_name arguments _details result] +(defmethod tools.util/tool-call-details-after-invocation :file_edit [_name arguments _details result _ctx] (when-not (:error result) (when-let [diff (some->> result :contents (filter #(= :text (:type %))) first :text)] (let [{:keys [added removed]} (diff/unified-diff-counts diff)] @@ -51,7 +51,7 @@ :linesRemoved removed :diff diff})))) -(defmethod tools.util/tool-call-details-after-invocation :file_write [_name arguments _details result] +(defmethod tools.util/tool-call-details-after-invocation :file_write [_name arguments _details result _ctx] (when-not (:error result) (when-let [diff (some->> result :contents (filter #(= :text (:type %))) diff --git a/src/eca/features/tools/util.clj b/src/eca/features/tools/util.clj index 141911391..0491d2e58 100644 --- a/src/eca/features/tools/util.clj +++ b/src/eca/features/tools/util.clj @@ -20,7 +20,7 @@ (defmulti tool-call-details-after-invocation "Return the tool call details after invoking the tool." - (fn [name _arguments _before-details _result] (keyword name))) + (fn [name _arguments _before-details _result _ctx] (keyword name))) (defn ^:private json-outputs-if-any [result] (when-let [jsons (->> (:contents result) @@ -40,7 +40,7 @@ {:type :jsonOutputs :jsons jsons})) -(defmethod tool-call-details-after-invocation :default [_name _arguments before-details result] +(defmethod tool-call-details-after-invocation :default [_name _arguments before-details result _ctx] (or before-details (json-outputs-if-any result))) diff --git a/test/eca/features/tools/mcp/clojure_mcp_test.clj b/test/eca/features/tools/mcp/clojure_mcp_test.clj index 1a1448e92..566223842 100644 --- a/test/eca/features/tools/mcp/clojure_mcp_test.clj +++ b/test/eca/features/tools/mcp/clojure_mcp_test.clj @@ -28,7 +28,8 @@ "operation" "replace" "content" "b\nc"} nil - {:error false :contents [{:type :text :text example-diff}]}))))) + {:error false :contents [{:type :text :text example-diff}]} + nil))))) (deftest tool-call-details-after-invocation-clojure-mcp-clojure-edit-replace-sexp-test (testing "Tool call details for the Clojure MCP clojure_edit_replace_sexp tool" @@ -44,7 +45,8 @@ "new_form" "b\nc" "replace_all" false} nil - {:error false :contents [{:type :text :text example-diff}]}))))) + {:error false :contents [{:type :text :text example-diff}]} + nil))))) (deftest tool-call-details-after-invocation-clojure-mcp-file-edit-test (testing "Tool call details for the Clojure MCP file_edit tool" @@ -59,7 +61,8 @@ "old_string" "a" "new_string" "b\nc"} nil - {:error false :contents [{:type :text :text example-diff}]}))))) + {:error false :contents [{:type :text :text example-diff}]} + nil))))) (deftest tool-call-details-after-invocation-clojure-mcp-file-write-test (testing "Tool call details for the Clojure MCP file_write tool" @@ -77,4 +80,5 @@ :contents [{:type :text :text (string/join "\n" ["Clojure file updated: /home/alice/my-org/my-proj/project.clj" - "Changes:" example-diff])}]}))))) + "Changes:" example-diff])}]} + nil))))) From bbfa0c8df0189144f69d8a2586439d74e54ea65f Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Sat, 7 Feb 2026 19:14:09 -0300 Subject: [PATCH 04/28] Remove markdown, only via config --- AGENTS.md | 1 + docs/protocol.md | 9 +- src/eca/config.clj | 14 +++- src/eca/features/agents.clj | 136 ------------------------------- src/eca/features/chat.clj | 24 +++--- src/eca/features/prompt.clj | 9 +- src/eca/features/tools.clj | 17 ++-- src/eca/features/tools/agent.clj | 101 +++++++++++++---------- src/eca/handlers.clj | 2 +- 9 files changed, 104 insertions(+), 209 deletions(-) delete mode 100644 src/eca/features/agents.clj diff --git a/AGENTS.md b/AGENTS.md index 07bbeb5ce..fb70b2fb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,3 +29,4 @@ ECA Agent Guide (AGENTS.md) - Put shared test helpers under `test/eca/test_helper.clj`. - Use java class typing to avoid GraalVM reflection issues - Avoid adding too many comments, only add essential or when you think is really important to mention something. +- ECA's protocol specification of client <-> server lives in docs/protocol.md diff --git a/docs/protocol.md b/docs/protocol.md index d52ae62fb..5a9000358 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -1057,14 +1057,15 @@ interface SubagentDetails { model: string; /** - * The max number of turns this subagent is limited. + * The max number of steps this subagent is limited. + * When not set, the subagent runs with no step limit (infinite interaction). */ - maxTurns: number; + maxSteps?: number; /** - * The current turn. + * The current step. */ - turn: number; + step: number; } /** diff --git a/src/eca/config.clj b/src/eca/config.clj index 128e12ba8..95c606489 100644 --- a/src/eca/config.clj +++ b/src/eca/config.clj @@ -88,9 +88,11 @@ "gemini-3-flash-preview" {}}} "ollama" {:url "${env:OLLAMA_API_URL:http://localhost:11434}"}} :defaultBehavior "agent" - :behavior {"agent" {:prompts {:chat "${classpath:prompts/agent_behavior.md}"} + :behavior {"agent" {:mode :primary + :prompts {:chat "${classpath:prompts/agent_behavior.md}"} :disabledTools ["preview_file_change"]} - "plan" {:prompts {:chat "${classpath:prompts/plan_behavior.md}"} + "plan" {:mode :primary + :prompts {:chat "${classpath:prompts/plan_behavior.md}"} :disabledTools ["edit_file" "write_file" "move_file"] :toolCall {:approval {:allow {"eca__shell_command" {:argsMatchers {"command" ["pwd"]}} @@ -209,6 +211,14 @@ (def ^:private fallback-behavior "agent") +(defn primary-behavior-names + "Returns the names of behaviors that are not subagents (mode is nil or \"primary\")." + [config] + (->> (:behavior config) + (remove (fn [[_ v]] (= "subagent" (:mode v)))) + (map key) + distinct)) + (defn validate-behavior-name "Validates if a behavior exists in config. Returns the behavior if valid, or the fallback behavior if not." diff --git a/src/eca/features/agents.clj b/src/eca/features/agents.clj deleted file mode 100644 index 2eaefffe7..000000000 --- a/src/eca/features/agents.clj +++ /dev/null @@ -1,136 +0,0 @@ -(ns eca.features.agents - "Load and parse agent definitions for subagent spawning. - - Agent definitions are Markdown files with YAML frontmatter. - They can be defined at: - - Project level: .eca/agents/*.md (highest priority) - - User level: ~/.config/eca/agents/*.md" - (:require - [babashka.fs :as fs] - [clojure.java.io :as io] - [clojure.string :as str] - [eca.config :as config] - [eca.shared :as shared])) - -(set! *warn-on-reflection* true) - -(defn ^:private parse-yaml-value - "Parses a simple YAML value, handling strings (quoted or unquoted), and basic types." - [s] - (let [trimmed (str/trim s)] - (cond - ;; Empty or null - (or (empty? trimmed) - (= "null" (str/lower-case trimmed))) - nil - - ;; Double-quoted string - (and (str/starts-with? trimmed "\"") (str/ends-with? trimmed "\"")) - (subs trimmed 1 (dec (count trimmed))) - - ;; Single-quoted string - (and (str/starts-with? trimmed "'") (str/ends-with? trimmed "'")) - (subs trimmed 1 (dec (count trimmed))) - - ;; Number - (re-matches #"-?\d+" trimmed) - (parse-long trimmed) - - ;; Unquoted string - :else trimmed))) - -(defn ^:private parse-yaml-list [lines] - (loop [remaining lines - items []] - (if (empty? remaining) - [items remaining] - (let [line (first remaining) - trimmed (str/trim line)] - (if (str/starts-with? trimmed "- ") - (recur (rest remaining) - (conj items (parse-yaml-value (subs trimmed 2)))) - [items remaining]))))) - -(defn ^:private parse-frontmatter [lines] - (loop [remaining lines - result {}] - (if (empty? remaining) - result - (let [line (first remaining)] - (if-let [[_ k v] (re-matches #"^([a-zA-Z_][a-zA-Z0-9_-]*)\s*:\s*(.*)$" line)] - (let [key (keyword k) - value-str (str/trim v)] - (if (empty? value-str) - ;; Empty value - might be followed by list items - (let [[list-items rest-lines] (parse-yaml-list (rest remaining))] - (if (seq list-items) - (recur rest-lines (assoc result key list-items)) - (recur (rest remaining) result))) - ;; Inline value - (recur (rest remaining) (assoc result key (parse-yaml-value value-str))))) - (recur (rest remaining) result)))))) - -(defn ^:private parse-md [md-file] - (let [content (slurp (str md-file)) - lines (str/split-lines content)] - (if (and (seq lines) - (= "---" (str/trim (first lines)))) - (let [after-opening (rest lines) - metadata-lines (take-while #(not= "---" (str/trim %)) after-opening) - body-lines (rest (drop-while #(not= "---" (str/trim %)) after-opening)) - metadata (parse-frontmatter metadata-lines)] - (assoc metadata :content (str/trim (str/join "\n" body-lines)))) - {:content content}))) - -(defn ^:private agent-file->agent [agent-file] - (let [{:keys [name description tools model maxTurns content]} (parse-md agent-file)] - (when (and name description) - {:name name - :description description - :tools (or tools []) - :model model - :max-turns (or maxTurns 25) - :content content - :source (str (fs/canonicalize agent-file))}))) - -(defn global-agents-dir ^java.io.File [] - (let [xdg-config-home (or (config/get-env "XDG_CONFIG_HOME") - (io/file (config/get-property "user.home") ".config"))] - (io/file xdg-config-home "eca" "agents"))) - -(defn ^:private global-agents - [] - (let [agents-dir (global-agents-dir)] - (when (fs/exists? agents-dir) - (keep agent-file->agent - (fs/glob agents-dir "*.md" {:follow-links true}))))) - -(defn ^:private local-agents - [roots] - (->> roots - (mapcat (fn [{:keys [uri]}] - (let [agents-dir (fs/file (shared/uri->filename uri) ".eca" "agents")] - (when (fs/exists? agents-dir) - (fs/glob agents-dir "*.md" {:follow-links true}))))) - (keep agent-file->agent))) - -(defn all - "Returns all available agent definitions. - Priority: local > global (later definitions override earlier ones by name)." - [config roots] - (let [agents-list (concat (when-not (:pureConfig config) - (global-agents)) - (local-agents roots))] - (->> agents-list - (reduce (fn [m agent] - (assoc m (:name agent) agent)) - {}) - vals - vec))) - -(defn get-agent - "Get a specific agent definition by name. - Returns nil if not found." - [agent-name config roots] - (let [agents (all config roots)] - (first (filter #(= agent-name (:name %)) agents)))) diff --git a/src/eca/features/chat.clj b/src/eca/features/chat.clj index d7aa6c261..cc4b622bb 100644 --- a/src/eca/features/chat.clj +++ b/src/eca/features/chat.clj @@ -866,28 +866,30 @@ chat-ctx)))) nil)) -(defn ^:private check-subagent-max-turns! - "Check if subagent has reached max turns. Increments turn count. - Returns true if max turns reached, false otherwise. +(defn ^:private check-subagent-max-steps! + "Check if subagent has reached max steps. Increments step count. + Returns true if max steps reached, false otherwise. + When max-steps is nil, the subagent runs with no step limit. Only applies to subagents (chats with :agent-def)." [db* chat-id] ;; presence of :agent-def indicates this is a subagent (when-let [agent-def (get-in @db* [:chats chat-id :agent-def])] - (let [max-turns (:max-turns agent-def 25) - new-db (swap! db* update-in [:chats chat-id :current-turn] (fnil inc 1)) - new-turn (get-in new-db [:chats chat-id :current-turn])] - (>= new-turn max-turns)))) + (let [max-steps (:max-steps agent-def) + new-db (swap! db* update-in [:chats chat-id :current-step] (fnil inc 1)) + new-step (get-in new-db [:chats chat-id :current-step])] + (when max-steps + (>= new-step max-steps))))) (defn ^:private on-tools-called! [{:keys [db* config chat-id behavior full-model messenger metrics] :as chat-ctx} received-msgs* add-to-history! user-messages] (fn [tool-calls] (let [all-tools (f.tools/all-tools chat-id behavior @db* config) - max-turns-reached? (check-subagent-max-turns! db* chat-id)] + max-steps-reached? (check-subagent-max-steps! db* chat-id)] (assert-chat-not-stopped! chat-ctx) - ;; Check subagent max turns - if reached, finish without executing more tools - (if max-turns-reached? + ;; Check subagent max steps - if reached, finish without executing more tools + (if max-steps-reached? (do - (logger/info logger-tag "Subagent reached max turns, finishing" {:chat-id chat-id}) + (logger/info logger-tag "Subagent reached max steps, finishing" {:chat-id chat-id}) (when-not (string/blank? @received-msgs*) (add-to-history! {:role "assistant" :content [{:type :text :text @received-msgs*}]})) (finish-chat-prompt! :idle chat-ctx) diff --git a/src/eca/features/prompt.clj b/src/eca/features/prompt.clj index 0407f2c11..16c946ca4 100644 --- a/src/eca/features/prompt.clj +++ b/src/eca/features/prompt.clj @@ -33,11 +33,16 @@ (get-in config [:prompts key]))) (defn ^:private eca-chat-prompt [behavior config] - (let [config-prompt (get-config-prompt :chat behavior config) - behavior-config (get-in config [:behavior behavior]) + (let [behavior-config (get-in config [:behavior behavior]) + subagent-prompt (and (= "subagent" (:mode behavior-config)) + (:systemPrompt behavior-config)) + config-prompt (get-config-prompt :chat behavior config) legacy-config-prompt (:systemPrompt behavior-config) legacy-config-prompt-file (:systemPromptFile behavior-config)] (cond + subagent-prompt + subagent-prompt + legacy-config-prompt legacy-config-prompt diff --git a/src/eca/features/tools.clj b/src/eca/features/tools.clj index 00e5202f2..99927bc0f 100644 --- a/src/eca/features/tools.clj +++ b/src/eca/features/tools.clj @@ -148,7 +148,7 @@ f.tools.editor/definitions f.tools.chat/definitions f.tools.skill/definitions - (f.tools.agent/definitions db config) + (f.tools.agent/definitions config) (f.tools.custom/definitions config)))) (defn native-tools [db config] @@ -156,16 +156,9 @@ (defn ^:private filter-subagent-tools "Filter tools for subagent execution. - - Only allow tools specified in the agent definition - - Always exclude spawn_agent to prevent nesting" - [tools agent-def] - (let [allowed-tools (set (:tools agent-def))] - (->> tools - ;; Always exclude spawn_agent to prevent nesting - (remove #(= "spawn_agent" (:name %))) - ;; If agent has tool restrictions, apply them - (filterv #(or (empty? allowed-tools) - (contains? allowed-tools (:name %))))))) + Excludes spawn_agent to prevent nesting." + [tools] + (filterv #(not= "spawn_agent" (:name %)) tools)) (defn all-tools "Returns all available tools, including both native ECA tools @@ -196,7 +189,7 @@ :config config}))))) ;; Apply subagent tool filtering if applicable all-tools (if agent-def - (filter-subagent-tools all-tools agent-def) + (filter-subagent-tools all-tools) all-tools)] (remove (fn [tool] (= :deny (approval all-tools tool {} db config behavior))) diff --git a/src/eca/features/tools/agent.clj b/src/eca/features/tools/agent.clj index aea726bbe..03aff6127 100644 --- a/src/eca/features/tools/agent.clj +++ b/src/eca/features/tools/agent.clj @@ -2,7 +2,6 @@ "Tool for spawning subagents to perform focused tasks in isolated context." (:require [clojure.string :as str] - [eca.features.agents :as f.agents] [eca.features.tools.util :as tools.util] [eca.logger :as logger] [eca.messenger :as messenger])) @@ -11,8 +10,26 @@ (def ^:private logger-tag "[AGENT-TOOL]") -(defn ^:private max-turns [agent-def] - (or (:max-turns agent-def) 25)) +(defn ^:private all-agents + [config] + (->> (:behavior config) + (keep (fn [[behavior-name behavior-config]] + (when (and (= "subagent" (:mode behavior-config)) + (:description behavior-config)) + {:name behavior-name + :description (:description behavior-config) + :model (:defaultModel behavior-config) + :max-steps (:maxSteps behavior-config) + :system-prompt (:systemPrompt behavior-config) + :tool-call (:toolCall behavior-config)}))) + vec)) + +(defn ^:private get-agent + [agent-name config] + (first (filter #(= agent-name (:name %)) (all-agents config)))) + +(defn ^:private max-steps [agent-def] + (:max-steps agent-def)) (defn ^:private extract-final-summary "Extract the final assistant message as summary from chat messages." @@ -29,14 +46,14 @@ (str/join "\n"))) "Agent completed without producing output."))) -(defn ->subagent-chat-id +(defn ^:private ->subagent-chat-id "Generate a deterministic subagent chat id from the tool-call-id." [tool-call-id] (str "subagent-" tool-call-id)) -(defn ^:private send-turn-progress! - "Send a toolCallRunning notification with current turn progress to the parent chat." - [messenger chat-id tool-call-id agent-name subagent-chat-id turn max-turns model arguments] +(defn ^:private send-step-progress! + "Send a toolCallRunning notification with current step progress to the parent chat." + [messenger chat-id tool-call-id agent-name subagent-chat-id step max-steps model arguments] (messenger/chat-content-received messenger {:chat-id chat-id @@ -52,8 +69,8 @@ :subagent-chat-id subagent-chat-id :model model :agent-name agent-name - :turn turn - :max-turns max-turns}}})) + :step step + :max-steps max-steps}}})) (defn ^:private stop-subagent-chat! "Stop a running subagent chat and clean up its state from db." @@ -68,7 +85,7 @@ (defn ^:private spawn-agent "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 behavior tool-call-id call-state-fn]}] + [arguments {:keys [db* config messenger metrics chat-id tool-call-id call-state-fn]}] (let [agent-name (get arguments "agent") task (get arguments "task") db @db* @@ -80,9 +97,9 @@ :parent-chat-id chat-id}))) ;; Load agent definition - agent-def (f.agents/get-agent agent-name config (:workspace-folders db)) + agent-def (get-agent agent-name config) _ (when-not agent-def - (let [available (f.agents/all config (:workspace-folders db))] + (let [available (all-agents config)] (throw (ex-info (format "Agent '%s' not found. Available agents: %s" agent-name (if (seq available) @@ -99,24 +116,26 @@ (logger/info logger-tag (format "Spawning agent '%s' for task: %s" agent-name task)) - (let [max-turns (max-turns agent-def)] + (let [max-steps (max-steps agent-def)] (swap! db* assoc-in [:chats subagent-chat-id] - {:id subagent-chat-id - :parent-chat-id chat-id - :agent-name agent-name - :agent-def agent-def - :max-turns max-turns - :current-turn 1}) + (cond-> {:id subagent-chat-id + :parent-chat-id chat-id + :agent-name agent-name + :agent-def agent-def + :current-step 1} + max-steps (assoc :max-steps max-steps))) ;; Require chat ns here to avoid circular dependency (let [chat-prompt (requiring-resolve 'eca.features.chat/prompt) - task-prompt (format "%s\n\nIMPORTANT: You have a maximum of %d turns to complete this task. Be efficient and provide a clear summary of your findings before reaching the limit." - task max-turns)] + task-prompt (if max-steps + (format "%s\n\nIMPORTANT: You have a maximum of %d steps to complete this task. Be efficient and provide a clear summary of your findings before reaching the limit." + task max-steps) + task)] (chat-prompt {:message task-prompt :chat-id subagent-chat-id :model subagent-model - :behavior behavior + :behavior agent-name :contexts []} db* messenger @@ -131,14 +150,14 @@ :contents [{:type :text :text (format "Agent '%s' was stopped because the parent chat was stopped." agent-name)}]})] (try - (loop [last-turn 0] + (loop [last-step 0] (let [db @db* status (get-in db [:chats subagent-chat-id :status]) - current-turn (get-in db [:chats subagent-chat-id :current-turn] 1)] - ;; Send turn progress when turn advances - (when (> current-turn last-turn) - (send-turn-progress! messenger chat-id tool-call-id agent-name - subagent-chat-id current-turn (max-turns agent-def) subagent-model arguments)) + current-step (get-in db [:chats subagent-chat-id :current-step] 1)] + ;; Send step progress when step advances + (when (> current-step last-step) + (send-step-progress! messenger chat-id tool-call-id agent-name + subagent-chat-id current-step (max-steps agent-def) subagent-model arguments)) (cond ;; Parent chat stopped — propagate stop to subagent (= :stopping (:status (call-state-fn))) @@ -148,10 +167,10 @@ (#{:idle :error} status) (let [messages (get-in db [:chats subagent-chat-id :messages] []) summary (extract-final-summary messages)] - (logger/info logger-tag (format "Agent '%s' completed after %d turns" agent-name current-turn)) + (logger/info logger-tag (format "Agent '%s' completed after %d steps" agent-name current-step)) (swap! db* (fn [db] (-> db - (assoc-in [:chats chat-id :tool-calls tool-call-id :subagent-final-turn] current-turn) + (assoc-in [:chats chat-id :tool-calls tool-call-id :subagent-final-step] current-step) (update :chats dissoc subagent-chat-id)))) {:error false :contents [{:type :text @@ -161,15 +180,15 @@ :else (do (Thread/sleep 1000) - (recur (long (max last-turn current-turn))))))) + (recur (long (max last-step current-step))))))) (catch InterruptedException _ (stopped-result)))))) (defn ^:private build-description "Build tool description with available agents listed." - [db config] + [config] (let [base-description (tools.util/read-tool-description "spawn_agent") - agents (f.agents/all config (:workspace-folders db)) + agents (all-agents config) agents-section (str "\n\nAvailable agents:\n" (->> agents (map (fn [{:keys [name description]}] @@ -178,9 +197,9 @@ (str base-description agents-section))) (defn definitions - [db config] + [config] {"spawn_agent" - {:description (build-description db config) + {:description (build-description config) :parameters {:type "object" :properties {"agent" {:type "string" :description "Name of the agent to spawn"} @@ -197,7 +216,7 @@ [_name arguments _server {:keys [db config chat-id tool-call-id]}] (let [agent-name (get arguments "agent") agent-def (when agent-name - (f.agents/get-agent agent-name config (:workspace-folders db))) + (get-agent agent-name config)) parent-model (get-in db [:chats chat-id :model]) subagent-model (or (:model agent-def) parent-model) subagent-chat-id (when tool-call-id @@ -206,11 +225,11 @@ :subagent-chat-id subagent-chat-id :model subagent-model :agent-name agent-name - :turn (get-in db [:chats subagent-chat-id :current-turn] 1) - :max-turns (max-turns agent-def)})) + :step (get-in db [:chats subagent-chat-id :current-step] 1) + :max-steps (max-steps agent-def)})) (defmethod tools.util/tool-call-details-after-invocation :spawn_agent [_name _arguments before-details _result {:keys [db chat-id tool-call-id]}] - (let [final-turn (get-in db [:chats chat-id :tool-calls tool-call-id :subagent-final-turn] - (or (:turn before-details) 1))] - (assoc before-details :turn final-turn))) + (let [final-step (get-in db [:chats chat-id :tool-calls tool-call-id :subagent-final-step] + (or (:step before-details) 1))] + (assoc before-details :step final-step))) diff --git a/src/eca/handlers.clj b/src/eca/handlers.clj index 4dd963adc..d27baa527 100644 --- a/src/eca/handlers.clj +++ b/src/eca/handlers.clj @@ -44,7 +44,7 @@ (config/notify-fields-changed-only! {:chat {:models (sort (keys models)) - :behaviors (distinct (keys (:behavior config))) + :behaviors (config/primary-behavior-names config) :select-model (f.chat/default-model db config) :select-behavior (config/validate-behavior-name (or (:defaultBehavior (:chat config)) ;;legacy From 09267837c27eb520a764021f2a903580bb7ae828 Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Sat, 7 Feb 2026 21:53:27 -0300 Subject: [PATCH 05/28] rejected tool calls --- src/eca/features/chat.clj | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/eca/features/chat.clj b/src/eca/features/chat.clj index cc4b622bb..5a3f4dc58 100644 --- a/src/eca/features/chat.clj +++ b/src/eca/features/chat.clj @@ -1083,13 +1083,21 @@ (finish-chat-prompt! :idle chat-ctx) nil) {:tools all-tools :new-messages (get-in @db* [:chats chat-id :messages])}) - (do (send-content! chat-ctx :system {:type :text - :text "Tell ECA what to do differently for the rejected tool(s)"}) - (add-to-history! {:role "user" - :content [{:type :text - :text "I rejected one or more tool calls with the following reason"}]}) - (finish-chat-prompt! :idle chat-ctx) - nil))) + (if (get-in @db* [:chats chat-id :agent-def]) + ;; Subagent: user can't provide rejection input directly, so continue + ;; the LLM loop with a rejection message letting the subagent adapt + (do (add-to-history! {:role "user" + :content [{:type :text + :text "I rejected one or more tool calls. The tool call was not allowed. Try a different approach to complete the task."}]}) + {:tools all-tools + :new-messages (get-in @db* [:chats chat-id :messages])}) + (do (send-content! chat-ctx :system {:type :text + :text "Tell ECA what to do differently for the rejected tool(s)"}) + (add-to-history! {:role "user" + :content [{:type :text + :text "I rejected one or more tool calls with the following reason"}]}) + (finish-chat-prompt! :idle chat-ctx) + nil)))) (do (maybe-renew-auth-token chat-ctx) (if (auto-compact? chat-id behavior full-model config @db*) From 7299a22f6bde788aeee46285ec040991bb35ba6d Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Mon, 9 Feb 2026 14:17:26 -0300 Subject: [PATCH 06/28] config --- src/eca/config.clj | 40 +++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/src/eca/config.clj b/src/eca/config.clj index 7500d277d..6e7ca84f0 100644 --- a/src/eca/config.clj +++ b/src/eca/config.clj @@ -65,26 +65,28 @@ :models {"gemini-2.5-pro" {}}} "ollama" {:url "${env:OLLAMA_API_URL:http://localhost:11434}"}} :defaultAgent "code" - :agent {"code" {:prompts {:chat "${classpath:prompts/code_agent.md}"} + :agent {"code" {:mode :primary + :prompts {:chat "${classpath:prompts/code_agent.md}"} :disabledTools ["preview_file_change"]} - "plan" {:prompts {:chat "${classpath:prompts/plan_agent.md}"} - :disabledTools ["edit_file" "write_file" "move_file"] - :toolCall {:approval {:allow {"eca__shell_command" - {:argsMatchers {"command" ["pwd"]}} - "eca__preview_file_change" {} - "eca__grep" {} - "eca__read_file" {} - "eca__directory_tree" {}} - :deny {"eca__shell_command" - {:argsMatchers {"command" ["[12&]?>>?\\s*(?!/dev/null($|\\s))\\S+" - ".*>.*", - ".*\\|\\s*(tee|dd|xargs).*", - ".*\\b(sed|awk|perl)\\s+.*-i.*", - ".*\\b(rm|mv|cp|touch|mkdir)\\b.*", - ".*git\\s+(add|commit|push).*", - ".*npm\\s+install.*", - ".*-c\\s+[\"'].*open.*[\"']w[\"'].*", - ".*bash.*-c.*>.*"]}}}}}}} + "plan" {:mode :primary + :prompts {:chat "${classpath:prompts/plan_agent.md}"} + :disabledTools ["edit_file" "write_file" "move_file"] + :toolCall {:approval {:allow {"eca__shell_command" + {:argsMatchers {"command" ["pwd"]}} + "eca__preview_file_change" {} + "eca__grep" {} + "eca__read_file" {} + "eca__directory_tree" {}} + :deny {"eca__shell_command" + {:argsMatchers {"command" ["[12&]?>>?\\s*(?!/dev/null($|\\s))\\S+" + ".*>.*", + ".*\\|\\s*(tee|dd|xargs).*", + ".*\\b(sed|awk|perl)\\s+.*-i.*", + ".*\\b(rm|mv|cp|touch|mkdir)\\b.*", + ".*git\\s+(add|commit|push).*", + ".*npm\\s+install.*", + ".*-c\\s+[\"'].*open.*[\"']w[\"'].*", + ".*bash.*-c.*>.*"]}}}}}}} :defaultModel nil :prompts {:chat "${classpath:prompts/code_agent.md}" ;; default to code agent :chatTitle "${classpath:prompts/title.md}" From 2b7cf2168bcd1a563151bed22b6afd9b26879662 Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Mon, 9 Feb 2026 16:20:45 -0300 Subject: [PATCH 07/28] rename behavior -> agent --- src/eca/features/tools/agent.clj | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/eca/features/tools/agent.clj b/src/eca/features/tools/agent.clj index 03aff6127..7a0946133 100644 --- a/src/eca/features/tools/agent.clj +++ b/src/eca/features/tools/agent.clj @@ -12,16 +12,16 @@ (defn ^:private all-agents [config] - (->> (:behavior config) - (keep (fn [[behavior-name behavior-config]] - (when (and (= "subagent" (:mode behavior-config)) - (:description behavior-config)) - {:name behavior-name - :description (:description behavior-config) - :model (:defaultModel behavior-config) - :max-steps (:maxSteps behavior-config) - :system-prompt (:systemPrompt behavior-config) - :tool-call (:toolCall behavior-config)}))) + (->> (:agent config) + (keep (fn [[agent-name agent-config]] + (when (and (= "subagent" (:mode agent-config)) + (:description agent-config)) + {:name agent-name + :description (:description agent-config) + :model (:defaultModel agent-config) + :max-steps (:maxSteps agent-config) + :system-prompt (:systemPrompt agent-config) + :tool-call (:toolCall agent-config)}))) vec)) (defn ^:private get-agent @@ -135,7 +135,7 @@ {:message task-prompt :chat-id subagent-chat-id :model subagent-model - :behavior agent-name + :agent agent-name :contexts []} db* messenger From d4983d8fddd35f096f8cce2b9adfcb4aa8efd36a Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Mon, 9 Feb 2026 16:50:05 -0300 Subject: [PATCH 08/28] MD support --- src/eca/config.clj | 21 ++- src/eca/features/agents.clj | 81 +++++++++++ src/eca/features/skills.clj | 39 +----- src/eca/shared.clj | 36 ++++- test/eca/features/agents_test.clj | 214 ++++++++++++++++++++++++++++++ 5 files changed, 351 insertions(+), 40 deletions(-) create mode 100644 src/eca/features/agents.clj create mode 100644 test/eca/features/agents_test.clj diff --git a/src/eca/config.clj b/src/eca/config.clj index 6e7ca84f0..2c0609c4e 100644 --- a/src/eca/config.clj +++ b/src/eca/config.clj @@ -402,6 +402,17 @@ (-> (assoc-in [:chat :defaultAgent] (migrate-legacy-agent-name (get-in config [:chat :defaultBehavior]))) (update :chat dissoc :defaultBehavior)))) +(defn ^:private md-agents + "Discovers markdown-defined agents from agents/ directories. + Uses requiring-resolve to avoid circular dependency with eca.features.agents." + [roots] + (try + (let [all-md-agents (requiring-resolve 'eca.features.agents/all-md-agents)] + (all-md-agents roots)) + (catch Exception e + (logger/warn logger-tag "Error loading markdown agents:" (.getMessage e)) + {}))) + (defn ^:private all* [db] (let [initialization-config @initialization-config* pure-config? (:pureConfig initialization-config) @@ -417,7 +428,15 @@ (-> $ (merge-config (when-not pure-config? (config-from-global-file))) (merge-config (when-not pure-config? (config-from-local-file (:workspace-folders db))))))) - migrate-legacy-config))) + migrate-legacy-config + ;; Merge markdown-defined agents (lowest priority — JSON config agents win) + (as-> config + (let [md-agent-configs (when-not pure-config? + (md-agents (:workspace-folders db)))] + (if (seq md-agent-configs) + (update config :agent (fn [existing] + (merge md-agent-configs existing))) + config)))))) (def all (memoize/ttl all* :ttl/threshold ttl-cache-config-ms)) diff --git a/src/eca/features/agents.clj b/src/eca/features/agents.clj new file mode 100644 index 000000000..5cf463cfa --- /dev/null +++ b/src/eca/features/agents.clj @@ -0,0 +1,81 @@ +(ns eca.features.agents + "Discovers agent definitions from markdown files (YAML frontmatter + body as system prompt). + Scans both global (~/.config/eca/agents/*.md) and local (.eca/agents/*.md) directories. + Compatible with Claude Code / OpenCode agent markdown format." + (:require + [babashka.fs :as fs] + [clojure.java.io :as io] + [clojure.string :as string] + [eca.config :as config] + [eca.logger :as logger] + [eca.shared :as shared])) + +(set! *warn-on-reflection* true) + +(def ^:private logger-tag "[AGENTS-MD]") + +(defn ^:private tools-list->approval-map + [tool-names] + (when (seq tool-names) + (into {} (map (fn [name] [(str name) {}]) tool-names)))) + +(defn ^:private md->agent-config + [{:keys [description mode model steps tools body]}] + (cond-> {} + description (assoc :description description) + mode (assoc :mode (str mode)) + model (assoc :defaultModel (str model)) + steps (assoc :maxSteps (long steps)) + (seq body) (assoc :systemPrompt body) + tools (assoc :toolCall + (let [tools-map (if (map? tools) tools (into {} tools))] + (cond-> {:approval {}} + (get tools-map "byDefault") + (assoc-in [:approval :byDefault] (get tools-map "byDefault")) + + (get tools-map "allow") + (assoc-in [:approval :allow] (tools-list->approval-map (get tools-map "allow"))) + + (get tools-map "deny") + (assoc-in [:approval :deny] (tools-list->approval-map (get tools-map "deny"))) + + (get tools-map "ask") + (assoc-in [:approval :ask] (tools-list->approval-map (get tools-map "ask")))))))) + +(defn ^:private agent-md-file->agent + [md-file] + (try + (let [agent-name (string/lower-case (fs/strip-ext (fs/file-name md-file))) + content (slurp (str md-file)) + parsed (shared/parse-md content) + agent-config (md->agent-config parsed)] + (when (seq agent-config) + [agent-name agent-config])) + (catch Exception e + (logger/warn logger-tag (format "Error parsing agent file '%s': %s" (str md-file) (.getMessage e))) + nil))) + +(defn ^:private global-md-agents + [] + (let [agents-dir (io/file (config/global-config-dir) "agents")] + (when (fs/exists? agents-dir) + (keep agent-md-file->agent + (fs/glob agents-dir "*.md" {:follow-links true}))))) + +(defn ^:private local-md-agents + [roots] + (->> roots + (mapcat (fn [{:keys [uri]}] + (let [agents-dir (fs/file (shared/uri->filename uri) ".eca" "agents")] + (when (fs/exists? agents-dir) + (fs/glob agents-dir "*.md" {:follow-links true}))))) + (keep agent-md-file->agent))) + +(defn all-md-agents + "Discovers all markdown-defined agents from global and local directories. + Returns a map of {agent-name agent-config} suitable for merging into config :agent. + Local agents override global agents of the same name." + [roots] + (into {} + (concat (global-md-agents) + (local-md-agents roots)))) diff --git a/src/eca/features/skills.clj b/src/eca/features/skills.clj index 848ee9a24..40538cb2f 100644 --- a/src/eca/features/skills.clj +++ b/src/eca/features/skills.clj @@ -2,50 +2,13 @@ (:require [babashka.fs :as fs] [clojure.java.io :as io] - [clojure.string :as str] [eca.config :as config] [eca.shared :as shared])) (set! *warn-on-reflection* true) -(defn ^:private parse-yaml-value - "Parses a simple YAML value, handling strings (quoted or unquoted), and basic types." - [s] - (let [trimmed (str/trim s)] - (cond - ;; Double-quoted string - (and (str/starts-with? trimmed "\"") (str/ends-with? trimmed "\"")) - (subs trimmed 1 (dec (count trimmed))) - - ;; Single-quoted string - (and (str/starts-with? trimmed "'") (str/ends-with? trimmed "'")) - (subs trimmed 1 (dec (count trimmed))) - - ;; Unquoted string - :else trimmed))) - -(defn ^:private parse-md - "Parses YAML front matter and body from a markdown file. - Front matter must be delimited by --- at the start and end. - Returns a map with metadata keys and :body (content after front matter)." - [md-file] - (let [content (slurp (str md-file)) - lines (str/split-lines content)] - (if (and (seq lines) - (= "---" (str/trim (first lines)))) - (let [after-opening (rest lines) - metadata-lines (take-while #(not= "---" (str/trim %)) after-opening) - body-lines (rest (drop-while #(not= "---" (str/trim %)) after-opening)) - metadata (into {} - (keep (fn [line] - (when-let [[_ k v] (re-matches #"^([a-zA-Z_][a-zA-Z0-9_-]*)\s*:\s*(.*)$" line)] - [(keyword k) (parse-yaml-value v)]))) - metadata-lines)] - (assoc metadata :body (str/join "\n" body-lines))) - {:body content}))) - (defn ^:private skill-file->skill [skill-file] - (let [{:keys [name description body]} (parse-md skill-file)] + (let [{:keys [name description body]} (shared/parse-md (slurp (str skill-file)))] (when (and name description) {:name name :description description diff --git a/src/eca/shared.clj b/src/eca/shared.clj index 2f39e6f43..565ba7205 100644 --- a/src/eca/shared.clj +++ b/src/eca/shared.clj @@ -11,10 +11,44 @@ [java.net URI] [java.nio.file Paths] [java.time Instant ZoneId ZoneOffset] - [java.time.format DateTimeFormatter])) + [java.time.format DateTimeFormatter] + [org.yaml.snakeyaml Yaml])) (set! *warn-on-reflection* true) +(defn ^:private java->clj + "Recursively converts Java collections from SnakeYAML to Clojure equivalents." + [x] + (cond + (instance? java.util.Map x) + (into {} (map (fn [[k v]] [(str k) (java->clj v)])) x) + + (instance? java.util.List x) + (mapv java->clj x) + + :else x)) + +(defn parse-md + "Parses YAML frontmatter and body from a markdown string. + Frontmatter must be delimited by --- at the start and end. + Uses SnakeYAML to handle nested structures (maps, lists). + Returns a map with parsed YAML keys (as keywords) and :body (content after frontmatter)." + [content] + (let [lines (string/split-lines content)] + (if (and (seq lines) + (= "---" (string/trim (first lines)))) + (let [after-opening (rest lines) + metadata-lines (take-while #(not= "---" (string/trim %)) after-opening) + body-lines (rest (drop-while #(not= "---" (string/trim %)) after-opening)) + yaml-str (string/join "\n" metadata-lines) + yaml (Yaml.) + parsed (.load yaml ^String yaml-str) + metadata (when (instance? java.util.Map parsed) + (into {} (map (fn [[k v]] [(keyword k) (java->clj v)])) + parsed))] + (assoc (or metadata {}) :body (string/trim (string/join "\n" body-lines)))) + {:body (string/trim content)}))) + (def windows-os? (.contains (System/getProperty "os.name") "Windows")) diff --git a/test/eca/features/agents_test.clj b/test/eca/features/agents_test.clj new file mode 100644 index 000000000..c13df910f --- /dev/null +++ b/test/eca/features/agents_test.clj @@ -0,0 +1,214 @@ +(ns eca.features.agents-test + (:require + [babashka.fs :as fs] + [clojure.test :refer [deftest is testing]] + [eca.config :as config] + [eca.features.agents :as agents] + [eca.shared :as shared] + [eca.test-helper :as h] + [matcher-combinators.test :refer [match?]])) + +(h/reset-components-before-test) + +(deftest parse-md-test + (testing "parses simple frontmatter with body" + (is (match? {:description "A test agent" + :mode "subagent" + :body "Hello world"} + (shared/parse-md "---\ndescription: A test agent\nmode: subagent\n---\n\nHello world")))) + + (testing "parses nested YAML (tools config)" + (let [md (str "---\n" + "description: Reviewer\n" + "tools:\n" + " byDefault: ask\n" + " allow:\n" + " - eca__read_file\n" + " - eca__grep\n" + " deny:\n" + " - eca__shell_command\n" + "---\n" + "\n" + "Review the code")] + (is (match? {:description "Reviewer" + :tools {"byDefault" "ask" + "allow" ["eca__read_file" "eca__grep"] + "deny" ["eca__shell_command"]} + :body "Review the code"} + (shared/parse-md md))))) + + (testing "parses numeric values" + (is (match? {:steps 5 + :body "Do things"} + (shared/parse-md "---\nsteps: 5\n---\n\nDo things")))) + + (testing "no frontmatter returns body only" + (is (match? {:body "Just a prompt with no config"} + (shared/parse-md "Just a prompt with no config")))) + + (testing "empty frontmatter returns body" + (is (match? {:body "Content after empty frontmatter"} + (shared/parse-md "---\n---\n\nContent after empty frontmatter")))) + + (testing "trims body whitespace" + (is (match? {:description "test" + :body "Trimmed body"} + (shared/parse-md "---\ndescription: test\n---\n\n\n Trimmed body \n\n"))))) + +(deftest md->agent-config-test + (testing "full markdown agent converts to config format" + (let [md (str "---\n" + "description: You sleep one second when asked\n" + "mode: subagent\n" + "model: nubank-anthropic/sonnet-4.5\n" + "steps: 5\n" + "tools:\n" + " byDefault: ask\n" + " deny:\n" + " - foo\n" + " allow:\n" + " - eca__shell_command\n" + "---\n" + "\n" + "You should run sleep 1 and return \"I sleeped 1 second\"") + parsed (shared/parse-md md) + config (#'agents/md->agent-config parsed)] + (is (match? {:description "You sleep one second when asked" + :mode "subagent" + :defaultModel "nubank-anthropic/sonnet-4.5" + :maxSteps 5 + :systemPrompt "You should run sleep 1 and return \"I sleeped 1 second\"" + :toolCall {:approval {:byDefault "ask" + :allow {"eca__shell_command" {}} + :deny {"foo" {}}}}} + config)))) + + (testing "minimal agent with only description and body" + (let [parsed (shared/parse-md "---\ndescription: Simple agent\nmode: subagent\n---\n\nDo stuff") + config (#'agents/md->agent-config parsed)] + (is (match? {:description "Simple agent" + :mode "subagent" + :systemPrompt "Do stuff"} + config)) + (is (nil? (:toolCall config))) + (is (nil? (:defaultModel config))) + (is (nil? (:maxSteps config))))) + + (testing "agent with no tools config omits toolCall" + (let [parsed (shared/parse-md "---\ndescription: No tools\n---\n\nPrompt") + config (#'agents/md->agent-config parsed)] + (is (nil? (:toolCall config)))))) + +(deftest md-agents-from-directory-test + (let [tmp-dir (fs/create-temp-dir) + agents-dir (fs/file tmp-dir "agents")] + (try + (fs/create-dirs agents-dir) + ;; Create test agent files + (spit (fs/file agents-dir "reviewer.md") + (str "---\n" + "description: Reviews code changes\n" + "mode: subagent\n" + "model: anthropic/sonnet-4.5\n" + "steps: 10\n" + "---\n\n" + "You are a code reviewer.")) + (spit (fs/file agents-dir "sleeper.md") + (str "---\n" + "description: Sleeps for testing\n" + "mode: subagent\n" + "---\n\n" + "Sleep 1 second.")) + ;; Non-md files should be ignored (glob *.md) + (spit (fs/file agents-dir "notes.txt") "not an agent") + + (let [reviewer (#'agents/agent-md-file->agent (fs/file agents-dir "reviewer.md")) + sleeper (#'agents/agent-md-file->agent (fs/file agents-dir "sleeper.md"))] + (is (match? ["reviewer" {:description "Reviews code changes" + :mode "subagent" + :defaultModel "anthropic/sonnet-4.5" + :maxSteps 10 + :systemPrompt "You are a code reviewer."}] + reviewer)) + (is (match? ["sleeper" {:description "Sleeps for testing" + :mode "subagent" + :systemPrompt "Sleep 1 second."}] + sleeper))) + (finally + (fs/delete-tree tmp-dir))))) + +(deftest md-agents-merge-with-config-test + (let [tmp-dir (fs/create-temp-dir) + local-agents-dir (fs/file tmp-dir ".eca" "agents")] + (try + (fs/create-dirs local-agents-dir) + (spit (fs/file local-agents-dir "tester.md") + (str "---\n" + "description: Runs tests\n" + "mode: subagent\n" + "steps: 3\n" + "---\n\n" + "Run the test suite.")) + + (let [roots [{:uri (shared/filename->uri (str tmp-dir))}] + md-agents (agents/all-md-agents roots)] + (is (match? {"tester" {:description "Runs tests" + :mode "subagent" + :maxSteps 3 + :systemPrompt "Run the test suite."}} + md-agents))) + (finally + (fs/delete-tree tmp-dir))))) + +(deftest config-integration-test + (let [tmp-dir (fs/create-temp-dir) + local-agents-dir (fs/file tmp-dir ".eca" "agents")] + (try + (fs/create-dirs local-agents-dir) + (spit (fs/file local-agents-dir "md-agent.md") + (str "---\n" + "description: From markdown\n" + "mode: subagent\n" + "---\n\n" + "I am from markdown.")) + ;; Agent defined in JSON config should take precedence over MD agent + (spit (fs/file local-agents-dir "json-override.md") + (str "---\n" + "description: MD version\n" + "mode: subagent\n" + "---\n\n" + "MD prompt.")) + + (reset! config/initialization-config* + {:pureConfig false + :agent {"json-override" {:mode "subagent" + :description "JSON version" + :systemPrompt "JSON prompt."}}}) + (let [db {:workspace-folders [{:uri (shared/filename->uri (str tmp-dir))}]} + result (#'config/all* db)] + (testing "markdown agent is present in config" + (is (match? {:description "From markdown" + :mode "subagent" + :systemPrompt "I am from markdown."} + (get-in result [:agent "md-agent"])))) + (testing "JSON config agent takes precedence over same-named MD agent" + (is (= "JSON version" (get-in result [:agent "json-override" :description]))) + (is (= "JSON prompt." (get-in result [:agent "json-override" :systemPrompt]))))) + (finally + (fs/delete-tree tmp-dir))))) + +(deftest agent-name-derived-from-filename-test + (let [tmp-dir (fs/create-temp-dir) + agents-dir (fs/file tmp-dir "agents")] + (try + (fs/create-dirs agents-dir) + (spit (fs/file agents-dir "My-Reviewer.md") + (str "---\n" + "description: Case test\n" + "mode: subagent\n" + "---\n\n" + "Prompt.")) + (let [[agent-name _] (#'agents/agent-md-file->agent (fs/file agents-dir "My-Reviewer.md"))] + (is (= "my-reviewer" agent-name))) + (finally + (fs/delete-tree tmp-dir))))) From f195589ed3d01546b6269dda6d776e991e02645c Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Mon, 9 Feb 2026 16:56:42 -0300 Subject: [PATCH 09/28] Improve requiring-resolve --- src/eca/config.clj | 23 ++++------------------- src/eca/features/agents.clj | 3 +-- src/eca/features/context.clj | 3 +-- src/eca/shared.clj | 8 ++++++++ 4 files changed, 14 insertions(+), 23 deletions(-) diff --git a/src/eca/config.clj b/src/eca/config.clj index 2c0609c4e..fab2a2421 100644 --- a/src/eca/config.clj +++ b/src/eca/config.clj @@ -16,6 +16,7 @@ [clojure.java.io :as io] [clojure.string :as string] [clojure.walk :as walk] + [eca.features.agents :as f.agents] [eca.logger :as logger] [eca.messenger :as messenger] [eca.secrets :as secrets] @@ -232,19 +233,14 @@ (def ^:private config-from-custom (memoize config-from-custom*)) -(defn global-config-dir ^File [] - (let [xdg-config-home (or (get-env "XDG_CONFIG_HOME") - (io/file (get-property "user.home") ".config"))] - (io/file xdg-config-home "eca"))) - (defn global-config-file ^File [] - (io/file (global-config-dir) "config.json")) + (io/file (shared/global-config-dir) "config.json")) (defn ^:private config-from-global-file [] (let [config-file (global-config-file)] (when (.exists config-file) (some-> (safe-read-json-string (slurp config-file) (var *global-config-error*)) - (parse-dynamic-string-values (global-config-dir)))))) + (parse-dynamic-string-values (shared/global-config-dir)))))) (defn ^:private config-from-local-file [roots] (reduce @@ -402,17 +398,6 @@ (-> (assoc-in [:chat :defaultAgent] (migrate-legacy-agent-name (get-in config [:chat :defaultBehavior]))) (update :chat dissoc :defaultBehavior)))) -(defn ^:private md-agents - "Discovers markdown-defined agents from agents/ directories. - Uses requiring-resolve to avoid circular dependency with eca.features.agents." - [roots] - (try - (let [all-md-agents (requiring-resolve 'eca.features.agents/all-md-agents)] - (all-md-agents roots)) - (catch Exception e - (logger/warn logger-tag "Error loading markdown agents:" (.getMessage e)) - {}))) - (defn ^:private all* [db] (let [initialization-config @initialization-config* pure-config? (:pureConfig initialization-config) @@ -432,7 +417,7 @@ ;; Merge markdown-defined agents (lowest priority — JSON config agents win) (as-> config (let [md-agent-configs (when-not pure-config? - (md-agents (:workspace-folders db)))] + (f.agents/all-md-agents (:workspace-folders db)))] (if (seq md-agent-configs) (update config :agent (fn [existing] (merge md-agent-configs existing))) diff --git a/src/eca/features/agents.clj b/src/eca/features/agents.clj index 5cf463cfa..1b4d8739c 100644 --- a/src/eca/features/agents.clj +++ b/src/eca/features/agents.clj @@ -6,7 +6,6 @@ [babashka.fs :as fs] [clojure.java.io :as io] [clojure.string :as string] - [eca.config :as config] [eca.logger :as logger] [eca.shared :as shared])) @@ -57,7 +56,7 @@ (defn ^:private global-md-agents [] - (let [agents-dir (io/file (config/global-config-dir) "agents")] + (let [agents-dir (io/file (shared/global-config-dir) "agents")] (when (fs/exists? agents-dir) (keep agent-md-file->agent (fs/glob agents-dir "*.md" {:follow-links true}))))) diff --git a/src/eca/features/context.clj b/src/eca/features/context.clj index 0dc6bb8e5..d1c53db9c 100644 --- a/src/eca/features/context.clj +++ b/src/eca/features/context.clj @@ -2,7 +2,6 @@ (:require [babashka.fs :as fs] [clojure.string :as string] - [eca.config :as config] [eca.features.index :as f.index] [eca.features.tools.mcp :as f.mcp] [eca.llm-api :as llm-api] @@ -68,7 +67,7 @@ (when (fs/readable? agent-file) (fs/canonicalize agent-file)))) (:workspace-folders db)) - global-agent-file (let [agent-file (fs/path (config/global-config-dir) agent-file)] + global-agent-file (let [agent-file (fs/path (shared/global-config-dir) agent-file)] (when (fs/readable? agent-file) (fs/canonicalize agent-file)))] (->> (concat local-agent-files diff --git a/src/eca/shared.clj b/src/eca/shared.clj index 565ba7205..d5a158517 100644 --- a/src/eca/shared.clj +++ b/src/eca/shared.clj @@ -49,6 +49,14 @@ (assoc (or metadata {}) :body (string/trim (string/join "\n" body-lines)))) {:body (string/trim content)}))) +(defn global-config-dir + "Returns the global ECA config directory as a java.io.File. + Respects XDG_CONFIG_HOME, defaults to ~/.config/eca." + ^java.io.File [] + (let [xdg-config-home (or (System/getenv "XDG_CONFIG_HOME") + (io/file (System/getProperty "user.home") ".config"))] + (io/file xdg-config-home "eca"))) + (def windows-os? (.contains (System/getProperty "os.name") "Windows")) From b181a386e5903a00fae5549664b2586c50525a39 Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Mon, 9 Feb 2026 17:24:22 -0300 Subject: [PATCH 10/28] max steps check --- src/eca/features/chat.clj | 1 + src/eca/features/tools/agent.clj | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/eca/features/chat.clj b/src/eca/features/chat.clj index 95a78cabd..57c57b0af 100644 --- a/src/eca/features/chat.clj +++ b/src/eca/features/chat.clj @@ -890,6 +890,7 @@ (if max-steps-reached? (do (logger/info logger-tag "Subagent reached max steps, finishing" {:chat-id chat-id}) + (swap! db* assoc-in [:chats chat-id :max-steps-reached?] true) (when-not (string/blank? @received-msgs*) (add-to-history! {:role "assistant" :content [{:type :text :text @received-msgs*}]})) (finish-chat-prompt! :idle chat-ctx) diff --git a/src/eca/features/tools/agent.clj b/src/eca/features/tools/agent.clj index 7a0946133..655063b89 100644 --- a/src/eca/features/tools/agent.clj +++ b/src/eca/features/tools/agent.clj @@ -166,15 +166,24 @@ ;; Subagent completed (#{:idle :error} status) (let [messages (get-in db [:chats subagent-chat-id :messages] []) - summary (extract-final-summary messages)] - (logger/info logger-tag (format "Agent '%s' completed after %d steps" agent-name current-step)) + summary (extract-final-summary messages) + max-steps-reached? (get-in db [:chats subagent-chat-id :max-steps-reached?]) + max-steps-limit (max-steps agent-def)] + (if max-steps-reached? + (logger/info logger-tag (format "Agent '%s' halted after reaching max steps (%d)" agent-name max-steps-limit)) + (logger/info logger-tag (format "Agent '%s' completed after %d steps" agent-name current-step))) (swap! db* (fn [db] (-> db (assoc-in [:chats chat-id :tool-calls tool-call-id :subagent-final-step] current-step) (update :chats dissoc subagent-chat-id)))) - {:error false - :contents [{:type :text - :text (format "## Agent '%s' Result\n\n%s" agent-name summary)}]}) + (if max-steps-reached? + {:error true + :contents [{:type :text + :text (format "## Agent '%s' Halted\n\nAgent was halted because it reached the maximum number of steps (%d). The result below may be incomplete.\n\n%s" + agent-name max-steps-limit summary)}]} + {:error false + :contents [{:type :text + :text (format "## Agent '%s' Result\n\n%s" agent-name summary)}]})) ;; Keep waiting :else From 29a0cc82cfd5572c44d7387acf72792c6ecb2cec Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Mon, 9 Feb 2026 17:30:36 -0300 Subject: [PATCH 11/28] renames --- src/eca/features/chat.clj | 9 ++++----- src/eca/features/tools.clj | 7 +++---- src/eca/features/tools/agent.clj | 29 ++++++++++++++--------------- 3 files changed, 21 insertions(+), 24 deletions(-) diff --git a/src/eca/features/chat.clj b/src/eca/features/chat.clj index 57c57b0af..894678dc7 100644 --- a/src/eca/features/chat.clj +++ b/src/eca/features/chat.clj @@ -870,11 +870,10 @@ "Check if subagent has reached max steps. Increments step count. Returns true if max steps reached, false otherwise. When max-steps is nil, the subagent runs with no step limit. - Only applies to subagents (chats with :agent-def)." + Only applies to subagents (chats with :subagent)." [db* chat-id] - ;; presence of :agent-def indicates this is a subagent - (when-let [agent-def (get-in @db* [:chats chat-id :agent-def])] - (let [max-steps (:max-steps agent-def) + (when-let [subagent (get-in @db* [:chats chat-id :subagent])] + (let [max-steps (:max-steps subagent) new-db (swap! db* update-in [:chats chat-id :current-step] (fnil inc 1)) new-step (get-in new-db [:chats chat-id :current-step])] (when max-steps @@ -1084,7 +1083,7 @@ (finish-chat-prompt! :idle chat-ctx) nil) {:tools all-tools :new-messages (get-in @db* [:chats chat-id :messages])}) - (if (get-in @db* [:chats chat-id :agent-def]) + (if (get-in @db* [:chats chat-id :subagent]) ;; Subagent: user can't provide rejection input directly, so continue ;; the LLM loop with a rejection message letting the subagent adapt (do (add-to-history! {:role "user" diff --git a/src/eca/features/tools.clj b/src/eca/features/tools.clj index 0a14ef884..a959a1620 100644 --- a/src/eca/features/tools.clj +++ b/src/eca/features/tools.clj @@ -164,11 +164,10 @@ "Returns all available tools, including both native ECA tools (like filesystem and shell tools) and tools provided by MCP servers. Removes denied tools. - When chat is a subagent (has :agent-def), filters tools based on agent definition." + When chat is a subagent (has :subagent), filters tools based on agent definition." [chat-id agent-name db config] (let [disabled-tools (get-disabled-tools config agent-name) - ;; presence of :agent-def indicates this is a subagent - agent-def (get-in db [:chats chat-id :agent-def]) + subagent (get-in db [:chats chat-id :subagent]) all-tools (->> (concat (mapv #(assoc % :origin :native) (native-tools db config)) (mapv #(assoc % :origin :mcp) (f.mcp/all-tools db))) @@ -188,7 +187,7 @@ :chat-id chat-id :config config}))))) ;; Apply subagent tool filtering if applicable - all-tools (if agent-def + all-tools (if subagent (filter-subagent-tools all-tools) all-tools)] (remove (fn [tool] diff --git a/src/eca/features/tools/agent.clj b/src/eca/features/tools/agent.clj index 655063b89..9c352c566 100644 --- a/src/eca/features/tools/agent.clj +++ b/src/eca/features/tools/agent.clj @@ -28,8 +28,8 @@ [agent-name config] (first (filter #(= agent-name (:name %)) (all-agents config)))) -(defn ^:private max-steps [agent-def] - (:max-steps agent-def)) +(defn ^:private max-steps [subagent] + (:max-steps subagent)) (defn ^:private extract-final-summary "Extract the final assistant message as summary from chat messages." @@ -91,14 +91,13 @@ db @db* ;; Check for nesting - prevent subagents from spawning other subagents - _ (when (get-in db [:chats chat-id :agent-def]) + _ (when (get-in db [:chats chat-id :subagent]) (throw (ex-info "Agents cannot spawn other agents (nesting not allowed)" {:agent-name agent-name :parent-chat-id chat-id}))) - ;; Load agent definition - agent-def (get-agent agent-name config) - _ (when-not agent-def + subagent (get-agent agent-name config) + _ (when-not subagent (let [available (all-agents config)] (throw (ex-info (format "Agent '%s' not found. Available agents: %s" agent-name @@ -112,16 +111,16 @@ subagent-chat-id (->subagent-chat-id tool-call-id) parent-model (get-in db [:chats chat-id :model]) - subagent-model (or (:model agent-def) parent-model)] + subagent-model (or (:model subagent) parent-model)] (logger/info logger-tag (format "Spawning agent '%s' for task: %s" agent-name task)) - (let [max-steps (max-steps agent-def)] + (let [max-steps (max-steps subagent)] (swap! db* assoc-in [:chats subagent-chat-id] (cond-> {:id subagent-chat-id :parent-chat-id chat-id :agent-name agent-name - :agent-def agent-def + :subagent subagent :current-step 1} max-steps (assoc :max-steps max-steps))) @@ -157,7 +156,7 @@ ;; Send step progress when step advances (when (> current-step last-step) (send-step-progress! messenger chat-id tool-call-id agent-name - subagent-chat-id current-step (max-steps agent-def) subagent-model arguments)) + subagent-chat-id current-step (max-steps subagent) subagent-model arguments)) (cond ;; Parent chat stopped — propagate stop to subagent (= :stopping (:status (call-state-fn))) @@ -168,7 +167,7 @@ (let [messages (get-in db [:chats subagent-chat-id :messages] []) summary (extract-final-summary messages) max-steps-reached? (get-in db [:chats subagent-chat-id :max-steps-reached?]) - max-steps-limit (max-steps agent-def)] + max-steps-limit (max-steps subagent)] (if max-steps-reached? (logger/info logger-tag (format "Agent '%s' halted after reaching max steps (%d)" agent-name max-steps-limit)) (logger/info logger-tag (format "Agent '%s' completed after %d steps" agent-name current-step))) @@ -224,10 +223,10 @@ (defmethod tools.util/tool-call-details-before-invocation :spawn_agent [_name arguments _server {:keys [db config chat-id tool-call-id]}] (let [agent-name (get arguments "agent") - agent-def (when agent-name - (get-agent agent-name config)) + subagent (when agent-name + (get-agent agent-name config)) parent-model (get-in db [:chats chat-id :model]) - subagent-model (or (:model agent-def) parent-model) + subagent-model (or (:model subagent) parent-model) subagent-chat-id (when tool-call-id (->subagent-chat-id tool-call-id))] {:type :subagent @@ -235,7 +234,7 @@ :model subagent-model :agent-name agent-name :step (get-in db [:chats subagent-chat-id :current-step] 1) - :max-steps (max-steps agent-def)})) + :max-steps (max-steps subagent)})) (defmethod tools.util/tool-call-details-after-invocation :spawn_agent [_name _arguments before-details _result {:keys [db chat-id tool-call-id]}] From 011750d6c756581624c4f40fd8424c955957c1d7 Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Mon, 9 Feb 2026 20:51:15 -0300 Subject: [PATCH 12/28] docs --- docs/config/agents.md | 58 ++++++++++++++++++++++++++++++++++++++++--- mkdocs.yml | 2 +- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/docs/config/agents.md b/docs/config/agents.md index ff0b78dc7..aedcbf33f 100644 --- a/docs/config/agents.md +++ b/docs/config/agents.md @@ -1,11 +1,16 @@ -# Agents +# Agents / Subagents When using ECA chat, you can choose which agent it will use, each allows you to customize its system prompt, tool call approvals, disabled tools, default model, skills and more. +There are 2 types of agents defined via `mode` field (when absent, defaults to primary): + +- `primary`: Main agents, used in chat. +- `subagent`: an agent allowed to be spawned inside a chat to do a specific task and return a output to the main agent. + ## Built-in agents -- `code`: default agent, generic, used to do most tasks. -- `plan`: specialized agent to build a plan before switching to code agent and executing the complex task. +- `code`: default primary agent, generic, used to do most tasks. +- `plan`: specialized primary agent to build a plan before switching to code agent and executing the complex task. ## Custom agents and prompts @@ -37,3 +42,50 @@ You can create an agent and define its prompt, tool call approval and default mo } ``` +## Subagents + +ECA can spawn foreground subagents in a chat, they are agents which `mode` is `subagent`. + +Subagents can be configured in config or markdown and require `description` and `prompt` (or markdown content): + +=== "Markdown" + + Agents can be defined in local or global markdown files inside a `agents` folder, those will be merged to ECA config. Example: + + ```markdown title="~/.config/eca/agents/my-agent.md" + --- + mode: subagent + description: You sleep one second when asked + model: anthropic/sonnet-4.5 + tools: + byDefault: ask + deny: + - my_mcp__my_tool + allow: + - eca__shell_command + --- + + You should run sleep 1 and return "I slept 1 second" + ``` + + !!! info "Tool call approval" + + For more complex tool call approval, use toolCall via config + +=== "Config" + + ```javascript title="~/.config/eca/config.json" + { + "agent": { + "sleeper": { + "mode": "subagent", + "description": "You sleep one second when asked", + "prompt": "You should run sleep 1 and return \"I sleeped 1 second\"" + "defaultModel": "anthropic/sonnet-4.5", + "toolCall": {...}, + "steps": 25 // Optional: to limit turns in subagent + } + } + } + ``` + diff --git a/mkdocs.yml b/mkdocs.yml index 985a73c53..b582273f7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -15,7 +15,7 @@ nav: - Skills: config/skills.md - Rules: config/rules.md - Commands: config/commands.md - - Agents: config/agents.md + - Agents / Subagents: config/agents.md - Hooks: config/hooks.md - Completion: config/completion.md - Rewrite: config/rewrite.md From 1c14b55797084aeccd2af0b9f187133ddb95ddfa Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Mon, 9 Feb 2026 21:51:56 -0300 Subject: [PATCH 13/28] add 2 subagents --- docs/config/agents.md | 10 +++-- .../eca/eca/native-image.properties | 1 + resources/prompts/explorer.md | 15 +++++++ src/eca/config.clj | 40 ++++++++++++++----- 4 files changed, 52 insertions(+), 14 deletions(-) create mode 100644 resources/prompts/explorer.md diff --git a/docs/config/agents.md b/docs/config/agents.md index aedcbf33f..84161be24 100644 --- a/docs/config/agents.md +++ b/docs/config/agents.md @@ -9,8 +9,12 @@ There are 2 types of agents defined via `mode` field (when absent, defaults to p ## Built-in agents -- `code`: default primary agent, generic, used to do most tasks. -- `plan`: specialized primary agent to build a plan before switching to code agent and executing the complex task. +| name | mode | description | +|--------------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| __code__ | primary | Default, generic, used to do most tasks, has access to all tools by default | +| __plan__ | primary | Specialized in building a plan before user switches to code agent and executes the complex task. Has no edit tools available, only preview changes. | +| __explorer__ | subagent | Fast agent specialized for exploring codebases. Finds files by patterns, searches code for keywords, or answers questions about the codebase. Read-only, no edit tools. | +| __general__ | subagent | General-purpose agent for researching complex questions and executing multi-step tasks. Can be used to execute multiple units of work in parallel. | ## Custom agents and prompts @@ -46,7 +50,7 @@ You can create an agent and define its prompt, tool call approval and default mo ECA can spawn foreground subagents in a chat, they are agents which `mode` is `subagent`. -Subagents can be configured in config or markdown and require `description` and `prompt` (or markdown content): +Subagents can be configured in config or markdown and require `description` and `systemPrompt` (or markdown content): === "Markdown" diff --git a/resources/META-INF/native-image/eca/eca/native-image.properties b/resources/META-INF/native-image/eca/eca/native-image.properties index 8454df3de..49c9e0eb0 100644 --- a/resources/META-INF/native-image/eca/eca/native-image.properties +++ b/resources/META-INF/native-image/eca/eca/native-image.properties @@ -17,6 +17,7 @@ Args=-J-Dborkdude.dynaload.aot=true \ -H:IncludeResources=ECA_VERSION \ -H:IncludeResources=prompts/plan_agent.md \ -H:IncludeResources=prompts/code_agent.md \ + -H:IncludeResources=prompts/explorer_agent.md \ -H:IncludeResources=prompts/additional_system_info.md \ -H:IncludeResources=prompts/init.md \ -H:IncludeResources=prompts/skill_create.md \ diff --git a/resources/prompts/explorer.md b/resources/prompts/explorer.md new file mode 100644 index 000000000..ec390d544 --- /dev/null +++ b/resources/prompts/explorer.md @@ -0,0 +1,15 @@ +You are a file search specialist. You excel at thoroughly navigating and exploring codebases. + +Your strengths: +- Rapidly finding files using glob patterns +- Searching code and text with powerful regex patterns +- Reading and analyzing file contents + +Guidelines: +- Return file paths as absolute paths in your final response +- For clear communication, avoid using emojis +- Adapt your search approach based on the thoroughness level specified by the caller +- Do not create any files, or run bash commands that modify the user's system state in any way + +Complete the user's search request efficiently and report your findings clearly. + diff --git a/src/eca/config.clj b/src/eca/config.clj index 361c08d72..22c93f500 100644 --- a/src/eca/config.clj +++ b/src/eca/config.clj @@ -40,6 +40,17 @@ (defn get-env [env] (System/getenv env)) (defn get-property [property] (System/getProperty property)) +(def ^:private dangerous-commands-regexes + ["[12&]?>>?\\s*(?!/dev/null($|\\s))\\S+" + ".*>.*", + ".*\\|\\s*(tee|dd|xargs).*", + ".*\\b(sed|awk|perl)\\s+.*-i.*", + ".*\\b(rm|mv|cp|touch|mkdir)\\b.*", + ".*git\\s+(add|commit|push).*", + ".*npm\\s+install.*", + ".*-c\\s+[\"'].*open.*[\"']w[\"'].*", + ".*bash.*-c.*>.*"]) + (def ^:private initial-config* {:providers {"openai" {:api "openai-responses" :url "${env:OPENAI_API_URL:https://api.openai.com}" @@ -66,10 +77,10 @@ :models {"gemini-2.5-pro" {}}} "ollama" {:url "${env:OLLAMA_API_URL:http://localhost:11434}"}} :defaultAgent "code" - :agent {"code" {:mode :primary + :agent {"code" {:mode "primary" :prompts {:chat "${classpath:prompts/code_agent.md}"} :disabledTools ["preview_file_change"]} - "plan" {:mode :primary + "plan" {:mode "primary" :prompts {:chat "${classpath:prompts/plan_agent.md}"} :disabledTools ["edit_file" "write_file" "move_file"] :toolCall {:approval {:allow {"eca__shell_command" @@ -79,15 +90,22 @@ "eca__read_file" {} "eca__directory_tree" {}} :deny {"eca__shell_command" - {:argsMatchers {"command" ["[12&]?>>?\\s*(?!/dev/null($|\\s))\\S+" - ".*>.*", - ".*\\|\\s*(tee|dd|xargs).*", - ".*\\b(sed|awk|perl)\\s+.*-i.*", - ".*\\b(rm|mv|cp|touch|mkdir)\\b.*", - ".*git\\s+(add|commit|push).*", - ".*npm\\s+install.*", - ".*-c\\s+[\"'].*open.*[\"']w[\"'].*", - ".*bash.*-c.*>.*"]}}}}}}} + {:argsMatchers {"command" dangerous-commands-regexes}}}}}} + "explorer" {:mode "subagent" + :description "Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns, search code for keywords, or answer questions about the codebase." + :systemPrompt "${classpath:prompts/explorer_agent.md}" + :disabledTools ["edit_file" "write_file" "move_file" "preview_file_change"] + :toolCall {:approval {:allow {"eca__shell_command" + {:argsMatchers {"command" ["pwd"]}} + "eca__grep" {} + "eca__read_file" {} + "eca__directory_tree" {}} + :deny {"eca__shell_command" + {:argsMatchers {"command" dangerous-commands-regexes}}}}}} + "general" {:mode "subagent" + :description "General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel." + :systemPrompt "${classpath:prompts/code_agent.md}" + :disabledTools ["preview_file_change"]}} :defaultModel nil :prompts {:chat "${classpath:prompts/code_agent.md}" ;; default to code agent :chatTitle "${classpath:prompts/title.md}" From f2c4c584e4591db9cf19c3bdf54f571aa00c189d Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Mon, 9 Feb 2026 22:09:24 -0300 Subject: [PATCH 14/28] fix graal --- resources/META-INF/native-image/eca/eca/native-image.properties | 1 + resources/prompts/{explorer.md => explorer_agent.md} | 0 2 files changed, 1 insertion(+) rename resources/prompts/{explorer.md => explorer_agent.md} (100%) diff --git a/resources/META-INF/native-image/eca/eca/native-image.properties b/resources/META-INF/native-image/eca/eca/native-image.properties index 49c9e0eb0..b4b1dab49 100644 --- a/resources/META-INF/native-image/eca/eca/native-image.properties +++ b/resources/META-INF/native-image/eca/eca/native-image.properties @@ -34,6 +34,7 @@ Args=-J-Dborkdude.dynaload.aot=true \ -H:IncludeResources=prompts/tools/read_file.md \ -H:IncludeResources=prompts/tools/shell_command.md \ -H:IncludeResources=prompts/tools/skill.md \ + -H:IncludeResources=prompts/tools/spawn_agent.md \ -H:IncludeResources=prompts/tools/write_file.md \ -H:IncludeResources=webpages/oauth.html \ -H:IncludeResources=logo.svg diff --git a/resources/prompts/explorer.md b/resources/prompts/explorer_agent.md similarity index 100% rename from resources/prompts/explorer.md rename to resources/prompts/explorer_agent.md From 15324acf26b0dc0f80b10bd2b5305918a86c1ac3 Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Tue, 10 Feb 2026 09:47:12 -0300 Subject: [PATCH 15/28] Fix tool call approval of subagents --- src/eca/config.clj | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/eca/config.clj b/src/eca/config.clj index 22c93f500..f328d2e1a 100644 --- a/src/eca/config.clj +++ b/src/eca/config.clj @@ -83,23 +83,32 @@ "plan" {:mode "primary" :prompts {:chat "${classpath:prompts/plan_agent.md}"} :disabledTools ["edit_file" "write_file" "move_file"] - :toolCall {:approval {:allow {"eca__shell_command" + :toolCall {:approval {:byDefault "ask" + :allow {"eca__shell_command" {:argsMatchers {"command" ["pwd"]}} + "eca__compact_chat" {} "eca__preview_file_change" {} - "eca__grep" {} "eca__read_file" {} - "eca__directory_tree" {}} + "eca__directory_tree" {} + "eca__grep" {} + "eca__editor_diagnostics" {} + "eca__skill" {} + "eca__spawn_agent" {}} :deny {"eca__shell_command" {:argsMatchers {"command" dangerous-commands-regexes}}}}}} "explorer" {:mode "subagent" :description "Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns, search code for keywords, or answer questions about the codebase." :systemPrompt "${classpath:prompts/explorer_agent.md}" :disabledTools ["edit_file" "write_file" "move_file" "preview_file_change"] - :toolCall {:approval {:allow {"eca__shell_command" + :toolCall {:approval {:byDefault "ask" + :allow {"eca__shell_command" {:argsMatchers {"command" ["pwd"]}} - "eca__grep" {} + "eca__compact_chat" {} "eca__read_file" {} - "eca__directory_tree" {}} + "eca__directory_tree" {} + "eca__grep" {} + "eca__editor_diagnostics" {} + "eca__skill" {}} :deny {"eca__shell_command" {:argsMatchers {"command" dangerous-commands-regexes}}}}}} "general" {:mode "subagent" From 4eb0cdad0f3e32aa4f5132d4311ae2a9fb475549 Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Tue, 10 Feb 2026 10:06:00 -0300 Subject: [PATCH 16/28] Improve UI for agents summary --- src/eca/features/tools/agent.clj | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/eca/features/tools/agent.clj b/src/eca/features/tools/agent.clj index 9c352c566..8ac0d8a8d 100644 --- a/src/eca/features/tools/agent.clj +++ b/src/eca/features/tools/agent.clj @@ -53,7 +53,7 @@ (defn ^:private send-step-progress! "Send a toolCallRunning notification with current step progress to the parent chat." - [messenger chat-id tool-call-id agent-name subagent-chat-id step max-steps model arguments] + [messenger chat-id tool-call-id agent-name activity subagent-chat-id step max-steps model arguments] (messenger/chat-content-received messenger {:chat-id chat-id @@ -63,7 +63,7 @@ :name "spawn_agent" :server "eca" :origin "native" - :summary (format "Running agent '%s'" agent-name) + :summary (format "%s: %s" agent-name activity) :arguments arguments :details {:type :subagent :subagent-chat-id subagent-chat-id @@ -88,6 +88,7 @@ [arguments {:keys [db* config messenger metrics chat-id tool-call-id call-state-fn]}] (let [agent-name (get arguments "agent") task (get arguments "task") + activity (get arguments "activity" "working") db @db* ;; Check for nesting - prevent subagents from spawning other subagents @@ -155,7 +156,7 @@ current-step (get-in db [:chats subagent-chat-id :current-step] 1)] ;; Send step progress when step advances (when (> current-step last-step) - (send-step-progress! messenger chat-id tool-call-id agent-name + (send-step-progress! messenger chat-id tool-call-id agent-name activity subagent-chat-id current-step (max-steps subagent) subagent-model arguments)) (cond ;; Parent chat stopped — propagate stop to subagent @@ -212,12 +213,15 @@ :properties {"agent" {:type "string" :description "Name of the agent to spawn"} "task" {:type "string" - :description "Clear description of what the agent should accomplish"}} - :required ["agent" "task"]} + :description "Clear description of what the agent should accomplish"} + "activity" {:type "string" + :description "Concise label (max 3 words) shown in the UI while the agent runs, e.g. \"exploring codebase\", \"reviewing changes\", \"analyzing tests\"."}} + :required ["agent" "task" "activity"]} :handler #'spawn-agent :summary-fn (fn [{:keys [args]}] (if-let [agent-name (get args "agent")] - (format "Running agent '%s'" agent-name) + (let [activity (get args "activity" "working")] + (format "%s: %s" agent-name activity)) "Spawning agent"))}}) (defmethod tools.util/tool-call-details-before-invocation :spawn_agent From 0f2c0fb8cc4c5e0c00e908b6cc1b1782d07df2d3 Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Tue, 10 Feb 2026 11:00:11 -0300 Subject: [PATCH 17/28] Update label summary --- src/eca/features/tools/agent.clj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/eca/features/tools/agent.clj b/src/eca/features/tools/agent.clj index 8ac0d8a8d..f22fdf847 100644 --- a/src/eca/features/tools/agent.clj +++ b/src/eca/features/tools/agent.clj @@ -215,7 +215,7 @@ "task" {:type "string" :description "Clear description of what the agent should accomplish"} "activity" {:type "string" - :description "Concise label (max 3 words) shown in the UI while the agent runs, e.g. \"exploring codebase\", \"reviewing changes\", \"analyzing tests\"."}} + :description "Concise label (max 3-4 words) shown in the UI while the agent runs, e.g. \"exploring codebase\", \"reviewing changes\", \"analyzing tests\"."}} :required ["agent" "task" "activity"]} :handler #'spawn-agent :summary-fn (fn [{:keys [args]}] From 663ebb83f7cd6bef1e0a64dd279e8e2190bdc79e Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Tue, 10 Feb 2026 14:50:37 -0300 Subject: [PATCH 18/28] Add subagentFinished hook --- docs/config/hooks.md | 21 +++++++++++++++- src/eca/features/chat.clj | 19 +++++++++------ src/eca/features/hooks.clj | 4 +-- test/eca/features/hooks_test.clj | 42 ++++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 10 deletions(-) diff --git a/docs/config/hooks.md b/docs/config/hooks.md index 76ed68b27..db6ee35fd 100644 --- a/docs/config/hooks.md +++ b/docs/config/hooks.md @@ -11,7 +11,8 @@ Hooks are shell actions that run before or after specific events, useful for not | `chatStart` | New chat or resumed chat | Can inject `additionalContext` | | `chatEnd` | Chat deleted | - | | `preRequest` | Before prompt sent to LLM | Can rewrite prompt, inject context, stop request | -| `postRequest` | After prompt finished | - | +| `postRequest` | After primary agent prompt finished | - | +| `subagentFinished` | After a subagent prompt finished | - | | `preToolCall` | Before tool execution | Can modify args, override approval, reject | | `postToolCall` | After tool execution | Can inject context for next LLM turn | @@ -35,6 +36,7 @@ Hooks receive JSON via stdin with event data (top-level keys `snake_case`, neste - Chat hooks add: `chat_id`, `agent`, `behavior` (deprecated alias) - Tool hooks add: `tool_name`, `server`, `tool_input`, `approval` (pre) or `tool_response`, `error` (post) - `chatStart` adds: `resumed` (boolean) +- `subagentFinished` adds: `parent_chat_id` Hooks can output JSON to control execution: @@ -159,6 +161,23 @@ To reject a tool call, either output `{"approval": "deny"}` or exit with code `2 } ``` +=== "Notify when subagent finishes" + + ```javascript title="~/.config/eca/config.json" + { + "hooks": { + "subagent-done": { + "type": "subagentFinished", + "visible": false, + "actions": [{ + "type": "shell", + "shell": "jq -r '.agent' | xargs -I{} notify-send 'Subagent {} finished'" + }] + } + } + } + ``` + === "Use external script file" ```javascript title="~/.config/eca/config.json" diff --git a/src/eca/features/chat.clj b/src/eca/features/chat.clj index 80d634343..f7d7d239c 100644 --- a/src/eca/features/chat.clj +++ b/src/eca/features/chat.clj @@ -106,13 +106,18 @@ (defn finish-chat-prompt! [status {:keys [message chat-id db* metrics config on-finished-side-effect] :as chat-ctx}] (when-not (get-in @db* [:chats chat-id :auto-compacting?]) (swap! db* assoc-in [:chats chat-id :status] status) - (f.hooks/trigger-if-matches! :postRequest - (merge (f.hooks/chat-hook-data @db* chat-id (:agent chat-ctx)) - {:prompt message}) - {:on-before-action (partial notify-before-hook-action! chat-ctx) - :on-after-action (partial notify-after-hook-action! chat-ctx)} - @db* - config) + (let [db @db* + subagent? (some? (get-in db [:chats chat-id :subagent])) + hook-type (if subagent? :subagentFinished :postRequest) + hook-data (cond-> (merge (f.hooks/chat-hook-data db chat-id (:agent chat-ctx)) + {:prompt message}) + subagent? (assoc :parent-chat-id (get-in db [:chats chat-id :parent-chat-id])))] + (f.hooks/trigger-if-matches! hook-type + hook-data + {:on-before-action (partial notify-before-hook-action! chat-ctx) + :on-after-action (partial notify-after-hook-action! chat-ctx)} + db + config)) (send-content! chat-ctx :system {:type :progress :state :finished}) diff --git a/src/eca/features/hooks.clj b/src/eca/features/hooks.clj index a2c06054a..818aa0661 100644 --- a/src/eca/features/hooks.clj +++ b/src/eca/features/hooks.clj @@ -23,7 +23,7 @@ (defn chat-hook-data "Returns common fields for CHAT-RELATED hooks. Includes base fields plus chat-specific fields (chat-id, agent). - Use this for: preRequest, postRequest, preToolCall, postToolCall, chatStart, chatEnd." + Use this for: preRequest, postRequest, subagentFinished, preToolCall, postToolCall, chatStart, chatEnd." [db chat-id agent-name] (merge (base-hook-data db) {:chat-id chat-id @@ -105,7 +105,7 @@ "Execute a single hook action. Supported hook types: - :sessionStart, :sessionEnd (session lifecycle) - :chatStart, :chatEnd (chat lifecycle) - - :preRequest, :postRequest (prompt lifecycle) + - :preRequest, :postRequest, :subagentFinished (prompt lifecycle) - :preToolCall, :postToolCall (tool lifecycle) Returns map with :exit, :raw-output, :raw-error, :parsed" diff --git a/test/eca/features/hooks_test.clj b/test/eca/features/hooks_test.clj index 4796c2f3c..00c87821e 100644 --- a/test/eca/features/hooks_test.clj +++ b/test/eca/features/hooks_test.clj @@ -260,3 +260,45 @@ {:tool-name "tool" :server "eca" :error true}) {} (h/db) (h/config))) (is (true? @ran?*))))) + +(deftest subagent-finished-test + (testing "subagentFinished hook triggers with parent_chat_id" + (h/reset-components!) + (swap! (h/db*) assoc :chats {"sub-1" {:agent "explorer"}}) + (h/config! {:hooks {"test" {:type "subagentFinished" + :actions [{:type "shell" :shell "cat"}]}}}) + (let [result* (atom nil)] + (with-redefs [f.hooks/run-shell-cmd (fn [opts] + (reset! result* (json/parse-string (:input opts) true)) + {:exit 0 :out "" :err nil})] + (f.hooks/trigger-if-matches! :subagentFinished + (merge (f.hooks/chat-hook-data (h/db) "sub-1" "explorer") + {:prompt "explore the codebase" + :parent-chat-id "parent-1"}) + {} (h/db) (h/config))) + (is (= "sub-1" (:chat_id @result*))) + (is (= "explorer" (:agent @result*))) + (is (= "parent-1" (:parent_chat_id @result*))) + (is (= "explore the codebase" (:prompt @result*))))) + + (testing "postRequest does not trigger for subagentFinished hook type" + (h/reset-components!) + (h/config! {:hooks {"test" {:type "postRequest" + :actions [{:type "shell" :shell "echo hey"}]}}}) + (let [ran?* (atom false)] + (with-redefs [f.hooks/run-shell-cmd (fn [_] (reset! ran?* true) {:exit 0 :out "" :err nil})] + (f.hooks/trigger-if-matches! :subagentFinished + {:prompt "task"} + {} (h/db) (h/config))) + (is (false? @ran?*)))) + + (testing "subagentFinished does not trigger for postRequest hook type" + (h/reset-components!) + (h/config! {:hooks {"test" {:type "subagentFinished" + :actions [{:type "shell" :shell "echo hey"}]}}}) + (let [ran?* (atom false)] + (with-redefs [f.hooks/run-shell-cmd (fn [_] (reset! ran?* true) {:exit 0 :out "" :err nil})] + (f.hooks/trigger-if-matches! :postRequest + {:prompt "task"} + {} (h/db) (h/config))) + (is (false? @ran?*))))) From 132a5a9bc70beecce50098dcc93cb70a460ce39e Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Tue, 10 Feb 2026 15:45:57 -0300 Subject: [PATCH 19/28] Improve subagent thread handling --- src/eca/client_http.clj | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/eca/client_http.clj b/src/eca/client_http.clj index 3ca897234..7299a37ef 100644 --- a/src/eca/client_http.clj +++ b/src/eca/client_http.clj @@ -10,7 +10,10 @@ Proxy Proxy$Type ProxySelector - URI])) + URI] + [java.util.concurrent Executors])) + +(set! *warn-on-reflection* true) (defn hato-client-make "Builds an options map for creating a Hato HTTP client. @@ -71,6 +74,9 @@ proxy-creds (assoc :authenticator proxy-creds)))) +(def ^:private shared-executor* + (delay (Executors/newCachedThreadPool))) + (def ^:dynamic *hato-http-client* "Global Hato HTTP client used throughout the application for making HTTP requests" @@ -100,7 +106,7 @@ the corresponding proxy configuration is added to the build." [hato-opts] (let [{:keys [http https] :as _env-proxies} (proxy/env-proxy-urls-parse) - opts (cond-> hato-opts + opts (cond-> (assoc hato-opts :executor ^java.util.concurrent.ExecutorService @shared-executor*) http (assoc :eca.client-http/proxy-http http) https From 723ec7588af54908bc776e5b9ddc0e878a49f89b Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Tue, 10 Feb 2026 17:06:28 -0300 Subject: [PATCH 20/28] Fix ForkJoinPool.commonPool deadlock in LLM provider HTTP requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch all provider base-request functions from async HTTP (deref'd CompletableFuture) to synchronous HTTP calls. The async path ran thenApply callbacks on ForkJoinPool.commonPool workers, and the recursive base-request pattern (stream → tool_use → on-tools-called → base-request) would block one commonPool worker per tool-use turn. After ~N turns (where N = availableProcessors - 1), all workers were blocked and new CompletableFuture completions could never execute, causing a complete deadlock visible as subagents stuck on "Sending body..." and eventually all chats hanging on "Waiting Model...". Since the code always immediately deref'd the async result, switching to synchronous HTTP loses nothing and eliminates the ForkJoinPool dependency entirely. 🤖 Generated with [eca](https://eca.dev) Co-Authored-By: eca --- src/eca/llm_providers/anthropic.clj | 51 ++++++++++++--------------- src/eca/llm_providers/ollama.clj | 50 ++++++++++++-------------- src/eca/llm_providers/openai.clj | 48 ++++++++++++------------- src/eca/llm_providers/openai_chat.clj | 50 ++++++++++++-------------- 4 files changed, 91 insertions(+), 108 deletions(-) diff --git a/src/eca/llm_providers/anthropic.clj b/src/eca/llm_providers/anthropic.clj index 99e6065c9..a1797ccbd 100644 --- a/src/eca/llm_providers/anthropic.clj +++ b/src/eca/llm_providers/anthropic.clj @@ -88,34 +88,29 @@ (llm-util/log-response logger-tag rid "response-error" body) (reset! response* error-data)))] (llm-util/log-request logger-tag rid url body headers) - @(http/post - url - {:headers headers - :body (json/generate-string body) - :throw-exceptions? false - :async? true - :http-client (client/merge-with-global-http-client http-client) - :as (if on-stream :stream :json)} - (fn [{:keys [status body]}] - (try - (if (not= 200 status) - (let [body-str (if on-stream (slurp body) body)] - (logger/warn logger-tag "Unexpected response status: %s body: %s" status body-str) - (on-error {:message (format "Anthropic response status: %s body: %s" status body-str)})) - (if on-stream - (with-open [rdr (io/reader body)] - (doseq [[event data] (llm-util/event-data-seq rdr)] - (llm-util/log-response logger-tag rid event data) - (on-stream event data content-block* reason-id))) - - (do - (llm-util/log-response logger-tag rid "response" body) - (reset! response* - {:output-text (:text (last (:content body)))})))) - (catch Exception e - (on-error {:exception e})))) - (fn [e] - (on-error {:exception e}))) + (let [{:keys [status body]} (http/post + url + {:headers headers + :body (json/generate-string body) + :throw-exceptions? false + :http-client (client/merge-with-global-http-client http-client) + :as (if on-stream :stream :json)})] + (try + (if (not= 200 status) + (let [body-str (if on-stream (slurp body) body)] + (logger/warn logger-tag "Unexpected response status: %s body: %s" status body-str) + (on-error {:message (format "Anthropic response status: %s body: %s" status body-str)})) + (if on-stream + (with-open [rdr (io/reader body)] + (doseq [[event data] (llm-util/event-data-seq rdr)] + (llm-util/log-response logger-tag rid event data) + (on-stream event data content-block* reason-id))) + (do + (llm-util/log-response logger-tag rid "response" body) + (reset! response* + {:output-text (:text (last (:content body)))})))) + (catch Exception e + (on-error {:exception e})))) @response*)) (defn ^:private normalize-messages [past-messages supports-image?] diff --git a/src/eca/llm_providers/ollama.clj b/src/eca/llm_providers/ollama.clj index c72579f2d..a908a1553 100644 --- a/src/eca/llm_providers/ollama.clj +++ b/src/eca/llm_providers/ollama.clj @@ -65,33 +65,29 @@ (llm-util/log-response logger-tag rid "response-error" body) (reset! response* error-data)))] (llm-util/log-request logger-tag rid url body headers) - @(http/post - url - {:headers headers - :body (json/generate-string body) - :throw-exceptions? false - :async? true - :http-client (client/merge-with-global-http-client {}) - :as (if on-stream :stream :json)} - (fn [{:keys [status body]}] - (try - (if (not= 200 status) - (let [body-str (if on-stream (slurp body) body)] - (logger/warn logger-tag (format "Unexpected response status: %s body: %s" status body-str)) - (on-error {:message (format "Ollama response status: %s body: %s" status body-str)})) - (if on-stream - (with-open [rdr (io/reader body)] - (doseq [[event data] (llm-util/event-data-seq rdr)] - (llm-util/log-response logger-tag rid event data) - (on-stream rid event data reasoning?* reason-id))) - (do - (llm-util/log-response logger-tag rid "response" body) - (reset! response* - {:output-text (:content (:message body))})))) - (catch Exception e - (on-error {:exception e})))) - (fn [e] - (on-error {:exception e}))) + (let [{:keys [status body]} (http/post + url + {:headers headers + :body (json/generate-string body) + :throw-exceptions? false + :http-client (client/merge-with-global-http-client {}) + :as (if on-stream :stream :json)})] + (try + (if (not= 200 status) + (let [body-str (if on-stream (slurp body) body)] + (logger/warn logger-tag (format "Unexpected response status: %s body: %s" status body-str)) + (on-error {:message (format "Ollama response status: %s body: %s" status body-str)})) + (if on-stream + (with-open [rdr (io/reader body)] + (doseq [[event data] (llm-util/event-data-seq rdr)] + (llm-util/log-response logger-tag rid event data) + (on-stream rid event data reasoning?* reason-id))) + (do + (llm-util/log-response logger-tag rid "response" body) + (reset! response* + {:output-text (:content (:message body))})))) + (catch Exception e + (on-error {:exception e})))) @response*)) (defn ^:private ->tools [tools] diff --git a/src/eca/llm_providers/openai.clj b/src/eca/llm_providers/openai.clj index 14edfd4fe..3c09408e9 100644 --- a/src/eca/llm_providers/openai.clj +++ b/src/eca/llm_providers/openai.clj @@ -66,32 +66,28 @@ (llm-util/log-response logger-tag rid "response-error" body) {:error error-data}))] (llm-util/log-request logger-tag rid url body headers) - @(http/post - url - {:headers headers - :body (json/generate-string body) - :throw-exceptions? false - :async? true - :http-client (client/merge-with-global-http-client http-client) - :as (if on-stream :stream :json)} - (fn [{:keys [status body]}] - (try - (if (not= 200 status) - (let [body-str (if on-stream (slurp body) body)] - (logger/warn logger-tag "Unexpected response status: %s body: %s" status body-str) - (on-error {:message (format "OpenAI response status: %s body: %s" status body-str)})) - (if on-stream - (with-open [rdr (io/reader body)] - (doseq [[event data] (llm-util/event-data-seq rdr)] - (llm-util/log-response logger-tag rid event data) - (on-stream event data))) - (do - (llm-util/log-response logger-tag rid "response" body) - (response-body->result body)))) - (catch Exception e - (on-error {:exception e})))) - (fn [e] - (on-error {:exception e}))))) + (let [{:keys [status body]} (http/post + url + {:headers headers + :body (json/generate-string body) + :throw-exceptions? false + :http-client (client/merge-with-global-http-client http-client) + :as (if on-stream :stream :json)})] + (try + (if (not= 200 status) + (let [body-str (if on-stream (slurp body) body)] + (logger/warn logger-tag "Unexpected response status: %s body: %s" status body-str) + (on-error {:message (format "OpenAI response status: %s body: %s" status body-str)})) + (if on-stream + (with-open [rdr (io/reader body)] + (doseq [[event data] (llm-util/event-data-seq rdr)] + (llm-util/log-response logger-tag rid event data) + (on-stream event data))) + (do + (llm-util/log-response logger-tag rid "response" body) + (response-body->result body)))) + (catch Exception e + (on-error {:exception e})))))) (defn ^:private normalize-messages [messages supports-image?] (keep (fn [{:keys [role content] :as msg}] diff --git a/src/eca/llm_providers/openai_chat.clj b/src/eca/llm_providers/openai_chat.clj index b6c0345ff..c437cb3a1 100644 --- a/src/eca/llm_providers/openai_chat.clj +++ b/src/eca/llm_providers/openai_chat.clj @@ -129,33 +129,29 @@ "Content-Type" "application/json"} extra-headers))] (llm-util/log-request logger-tag rid url body headers) - @(http/post - url - {:headers headers - :body (json/generate-string body) - :throw-exceptions? false - :async? true - :http-client (client/merge-with-global-http-client http-client) - :as (if on-stream :stream :json)} - (fn [{:keys [status body]}] - (try - (if (not= 200 status) - (let [body-str (if on-stream (slurp body) body)] - (logger/warn logger-tag rid "Unexpected response status: %s body: %s" status body-str) - (on-error {:message (format "LLM response status: %s body: %s" status body-str)})) - (if on-stream - (with-open [rdr (io/reader body)] - (doseq [[event data] (llm-util/event-data-seq rdr)] - (llm-util/log-response logger-tag rid event data) - (on-stream event data)) - (on-stream "stream-end" {})) - (do - (llm-util/log-response logger-tag rid "full-response" body) - (response-body->result body on-tools-called-wrapper)))) - (catch Exception e - (on-error {:exception e})))) - (fn [e] - (on-error {:exception e}))))) + (let [{:keys [status body]} (http/post + url + {:headers headers + :body (json/generate-string body) + :throw-exceptions? false + :http-client (client/merge-with-global-http-client http-client) + :as (if on-stream :stream :json)})] + (try + (if (not= 200 status) + (let [body-str (if on-stream (slurp body) body)] + (logger/warn logger-tag rid "Unexpected response status: %s body: %s" status body-str) + (on-error {:message (format "LLM response status: %s body: %s" status body-str)})) + (if on-stream + (with-open [rdr (io/reader body)] + (doseq [[event data] (llm-util/event-data-seq rdr)] + (llm-util/log-response logger-tag rid event data) + (on-stream event data)) + (on-stream "stream-end" {})) + (do + (llm-util/log-response logger-tag rid "full-response" body) + (response-body->result body on-tools-called-wrapper)))) + (catch Exception e + (on-error {:exception e})))))) (defn ^:private transform-message "Transform a single ECA message to OpenAI format. Returns nil for unsupported roles. From 6c91813be15bf63c3fd2be1463b31030841b4331 Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Tue, 10 Feb 2026 20:58:00 -0300 Subject: [PATCH 21/28] Fixes by code-review agent --- docs/config/agents.md | 4 +- src/eca/features/chat.clj | 2 +- src/eca/features/tools/agent.clj | 143 ++++++++------- test/eca/features/tools/agent_test.clj | 245 +++++++++++++++++++++++++ 4 files changed, 321 insertions(+), 73 deletions(-) create mode 100644 test/eca/features/tools/agent_test.clj diff --git a/docs/config/agents.md b/docs/config/agents.md index 84161be24..99ca0a744 100644 --- a/docs/config/agents.md +++ b/docs/config/agents.md @@ -84,10 +84,10 @@ Subagents can be configured in config or markdown and require `description` and "sleeper": { "mode": "subagent", "description": "You sleep one second when asked", - "prompt": "You should run sleep 1 and return \"I sleeped 1 second\"" + "systemPrompt": "You should run sleep 1 and return \"I slept 1 second\"", "defaultModel": "anthropic/sonnet-4.5", "toolCall": {...}, - "steps": 25 // Optional: to limit turns in subagent + "maxSteps": 25 // Optional: to limit turns in subagent } } } diff --git a/src/eca/features/chat.clj b/src/eca/features/chat.clj index f7d7d239c..7f726c626 100644 --- a/src/eca/features/chat.clj +++ b/src/eca/features/chat.clj @@ -879,7 +879,7 @@ [db* chat-id] (when-let [subagent (get-in @db* [:chats chat-id :subagent])] (let [max-steps (:max-steps subagent) - new-db (swap! db* update-in [:chats chat-id :current-step] (fnil inc 1)) + new-db (swap! db* update-in [:chats chat-id :current-step] (fnil inc 0)) new-step (get-in new-db [:chats chat-id :current-step])] (when max-steps (>= new-step max-steps))))) diff --git a/src/eca/features/tools/agent.clj b/src/eca/features/tools/agent.clj index f22fdf847..0f9313356 100644 --- a/src/eca/features/tools/agent.clj +++ b/src/eca/features/tools/agent.clj @@ -116,82 +116,85 @@ (logger/info logger-tag (format "Spawning agent '%s' for task: %s" agent-name task)) - (let [max-steps (max-steps subagent)] + (let [max-steps-limit (max-steps subagent)] (swap! db* assoc-in [:chats subagent-chat-id] (cond-> {:id subagent-chat-id :parent-chat-id chat-id :agent-name agent-name :subagent subagent - :current-step 1} - max-steps (assoc :max-steps max-steps))) - - ;; Require chat ns here to avoid circular dependency - (let [chat-prompt (requiring-resolve 'eca.features.chat/prompt) - task-prompt (if max-steps - (format "%s\n\nIMPORTANT: You have a maximum of %d steps to complete this task. Be efficient and provide a clear summary of your findings before reaching the limit." - task max-steps) - task)] - (chat-prompt - {:message task-prompt - :chat-id subagent-chat-id - :model subagent-model - :agent agent-name - :contexts []} - db* - messenger - config - metrics))) - - ;; Wait for subagent to complete by polling status - (let [stopped-result (fn [] - (logger/info logger-tag (format "Agent '%s' stopped by parent chat" agent-name)) - (stop-subagent-chat! db* messenger metrics subagent-chat-id agent-name) - {:error true - :contents [{:type :text - :text (format "Agent '%s' was stopped because the parent chat was stopped." agent-name)}]})] + :current-step 0} + max-steps-limit (assoc :max-steps max-steps-limit))) + (try - (loop [last-step 0] - (let [db @db* - status (get-in db [:chats subagent-chat-id :status]) - current-step (get-in db [:chats subagent-chat-id :current-step] 1)] - ;; Send step progress when step advances - (when (> current-step last-step) - (send-step-progress! messenger chat-id tool-call-id agent-name activity - subagent-chat-id current-step (max-steps subagent) subagent-model arguments)) - (cond - ;; Parent chat stopped — propagate stop to subagent - (= :stopping (:status (call-state-fn))) - (stopped-result) - - ;; Subagent completed - (#{:idle :error} status) - (let [messages (get-in db [:chats subagent-chat-id :messages] []) - summary (extract-final-summary messages) - max-steps-reached? (get-in db [:chats subagent-chat-id :max-steps-reached?]) - max-steps-limit (max-steps subagent)] - (if max-steps-reached? - (logger/info logger-tag (format "Agent '%s' halted after reaching max steps (%d)" agent-name max-steps-limit)) - (logger/info logger-tag (format "Agent '%s' completed after %d steps" agent-name current-step))) - (swap! db* (fn [db] - (-> db - (assoc-in [:chats chat-id :tool-calls tool-call-id :subagent-final-step] current-step) - (update :chats dissoc subagent-chat-id)))) - (if max-steps-reached? - {:error true - :contents [{:type :text - :text (format "## Agent '%s' Halted\n\nAgent was halted because it reached the maximum number of steps (%d). The result below may be incomplete.\n\n%s" - agent-name max-steps-limit summary)}]} - {:error false - :contents [{:type :text - :text (format "## Agent '%s' Result\n\n%s" agent-name summary)}]})) - - ;; Keep waiting - :else - (do - (Thread/sleep 1000) - (recur (long (max last-step current-step))))))) - (catch InterruptedException _ - (stopped-result)))))) + ;; Require chat ns here to avoid circular dependency + (let [chat-prompt (requiring-resolve 'eca.features.chat/prompt) + task-prompt (if max-steps-limit + (format "%s\n\nIMPORTANT: You have a maximum of %d steps to complete this task. Be efficient and provide a clear summary of your findings before reaching the limit." + task max-steps-limit) + task)] + (chat-prompt + {:message task-prompt + :chat-id subagent-chat-id + :model subagent-model + :agent agent-name + :contexts []} + db* + messenger + config + metrics)) + + ;; Wait for subagent to complete by polling status + (let [stopped-result (fn [] + (logger/info logger-tag (format "Agent '%s' stopped by parent chat" agent-name)) + (stop-subagent-chat! db* messenger metrics subagent-chat-id agent-name) + {:error true + :contents [{:type :text + :text (format "Agent '%s' was stopped because the parent chat was stopped." agent-name)}]})] + (try + (loop [last-step 0] + (let [db @db* + status (get-in db [:chats subagent-chat-id :status]) + current-step (get-in db [:chats subagent-chat-id :current-step] 0)] + ;; Send step progress when step advances + (when (> current-step last-step) + (send-step-progress! messenger chat-id tool-call-id agent-name activity + subagent-chat-id current-step max-steps-limit subagent-model arguments)) + (cond + ;; Parent chat stopped — propagate stop to subagent + (= :stopping (:status (call-state-fn))) + (stopped-result) + + ;; Subagent completed + (#{:idle :error} status) + (let [messages (get-in db [:chats subagent-chat-id :messages] []) + summary (extract-final-summary messages) + max-steps-reached? (get-in db [:chats subagent-chat-id :max-steps-reached?])] + (if max-steps-reached? + (logger/info logger-tag (format "Agent '%s' halted after reaching max steps (%d)" agent-name max-steps-limit)) + (logger/info logger-tag (format "Agent '%s' completed after %d steps" agent-name current-step))) + (swap! db* (fn [db] + (-> db + (assoc-in [:chats chat-id :tool-calls tool-call-id :subagent-final-step] current-step) + (update :chats dissoc subagent-chat-id)))) + (if max-steps-reached? + {:error true + :contents [{:type :text + :text (format "## Agent '%s' Halted\n\nAgent was halted because it reached the maximum number of steps (%d). The result below may be incomplete.\n\n%s" + agent-name max-steps-limit summary)}]} + {:error false + :contents [{:type :text + :text (format "## Agent '%s' Result\n\n%s" agent-name summary)}]})) + + ;; Keep waiting + :else + (do + (Thread/sleep 1000) + (recur (long (max last-step current-step))))))) + (catch InterruptedException _ + (stopped-result)))) + (catch Exception e + (swap! db* update :chats dissoc subagent-chat-id) + (throw e)))))) (defn ^:private build-description "Build tool description with available agents listed." diff --git a/test/eca/features/tools/agent_test.clj b/test/eca/features/tools/agent_test.clj new file mode 100644 index 000000000..9b4238278 --- /dev/null +++ b/test/eca/features/tools/agent_test.clj @@ -0,0 +1,245 @@ +(ns eca.features.tools.agent-test + (:require + [clojure.test :refer [deftest is testing]] + [eca.features.tools.agent :as f.tools.agent] + [eca.test-helper :as h] + [matcher-combinators.test :refer [match?]])) + +(h/reset-components-before-test) + +(def ^:private test-config + {:agent {"explorer" {:mode "subagent" + :description "Explores codebases" + :maxSteps 5 + :systemPrompt "You are an explorer."} + "general" {:mode "subagent" + :description "General purpose agent"} + "code" {:mode "primary" + :description "Code agent"}}}) + +(defn ^:private spawn-handler [] + (get-in (f.tools.agent/definitions test-config) ["spawn_agent" :handler])) + +(deftest spawn-agent-not-found-test + (testing "throws when agent is not found" + (let [db* (atom {:chats {"chat-1" {:id "chat-1"}}}) + result (try + ((spawn-handler) + {"agent" "nonexistent" "task" "do stuff" "activity" "working"} + {:db* db* + :config test-config + :messenger (h/messenger) + :metrics (h/metrics) + :chat-id "chat-1" + :tool-call-id "tc-1" + :call-state-fn (constantly {:status :executing})}) + (catch Exception e + {:error true :ex-data (ex-data e) :message (ex-message e)}))] + (is (match? {:error true + :message #"not found"} + result)) + (is (match? {:agent-name "nonexistent"} + (:ex-data result)))))) + +(deftest spawn-agent-nesting-prevention-test + (testing "throws when subagent tries to spawn another subagent" + (let [db* (atom {:chats {"sub-chat" {:id "sub-chat" + :subagent {:name "explorer"}}}}) + result (try + ((spawn-handler) + {"agent" "general" "task" "do stuff" "activity" "working"} + {:db* db* + :config test-config + :messenger (h/messenger) + :metrics (h/metrics) + :chat-id "sub-chat" + :tool-call-id "tc-1" + :call-state-fn (constantly {:status :executing})}) + (catch Exception e + {:error true :message (ex-message e)}))] + (is (match? {:error true + :message #"nesting not allowed"} + result))))) + +(deftest spawn-agent-completion-test + (testing "returns summary when subagent completes successfully" + (let [db* (atom {:chats {"chat-1" {:id "chat-1" :model "test/model"}}}) + subagent-chat-id "subagent-tc-1" + chat-prompt-called* (promise)] + (with-redefs [;; Mock chat/prompt to simulate subagent running and completing + requiring-resolve + (fn [sym] + (case sym + eca.features.chat/prompt + (fn [params _db* _messenger _config _metrics] + (deliver chat-prompt-called* params) + ;; Simulate the subagent completing with a response + (swap! db* assoc-in [:chats subagent-chat-id :status] :idle) + (swap! db* assoc-in [:chats subagent-chat-id :messages] + [{:role "assistant" + :content [{:type :text :text "Found 3 files matching the pattern."}]}])) + (clojure.lang.RT/var (namespace sym) (name sym))))] + (let [result ((spawn-handler) + {"agent" "explorer" "task" "find files" "activity" "exploring"} + {:db* db* + :config test-config + :messenger (h/messenger) + :metrics (h/metrics) + :chat-id "chat-1" + :tool-call-id "tc-1" + :call-state-fn (constantly {:status :executing})})] + (is (match? {:error false + :contents [{:type :text + :text #"Found 3 files"}]} + result)) + (testing "passes correct params to chat/prompt" + (is (match? {:chat-id subagent-chat-id + :agent "explorer" + :model "test/model"} + @chat-prompt-called*))) + (testing "cleans up subagent chat state" + (is (nil? (get-in @db* [:chats subagent-chat-id]))))))))) + +(deftest spawn-agent-max-steps-reached-test + (testing "returns halted result when subagent reaches max steps" + (let [db* (atom {:chats {"chat-1" {:id "chat-1" :model "test/model"}}}) + subagent-chat-id "subagent-tc-1"] + (with-redefs [requiring-resolve + (fn [sym] + (case sym + eca.features.chat/prompt + (fn [_params _db* _messenger _config _metrics] + (swap! db* assoc-in [:chats subagent-chat-id :status] :idle) + (swap! db* assoc-in [:chats subagent-chat-id :max-steps-reached?] true) + (swap! db* assoc-in [:chats subagent-chat-id :messages] + [{:role "assistant" + :content [{:type :text :text "Partial results so far."}]}])) + (clojure.lang.RT/var (namespace sym) (name sym))))] + (let [result ((spawn-handler) + {"agent" "explorer" "task" "find files" "activity" "exploring"} + {:db* db* + :config test-config + :messenger (h/messenger) + :metrics (h/metrics) + :chat-id "chat-1" + :tool-call-id "tc-1" + :call-state-fn (constantly {:status :executing})})] + (is (match? {:error true + :contents [{:type :text + :text #"(?s)Halted.*maximum number of steps \(5\)"}]} + result))))))) + +(deftest spawn-agent-parent-stop-test + (testing "stops subagent when parent chat is stopped" + (let [db* (atom {:chats {"chat-1" {:id "chat-1" :model "test/model"}}}) + subagent-chat-id "subagent-tc-1" + call-state* (atom {:status :executing})] + (with-redefs [requiring-resolve + (fn [sym] + (case sym + eca.features.chat/prompt + (fn [_params _db* _messenger _config _metrics] + ;; Simulate subagent still running — parent will stop it + (swap! db* assoc-in [:chats subagent-chat-id :status] :running) + ;; Signal parent stop so the poll loop picks it up + (reset! call-state* {:status :stopping})) + eca.features.chat/prompt-stop + (fn [_params _db* _messenger _metrics] + (swap! db* assoc-in [:chats subagent-chat-id :status] :idle)) + (clojure.lang.RT/var (namespace sym) (name sym))))] + (let [result ((spawn-handler) + {"agent" "explorer" "task" "explore" "activity" "exploring"} + {:db* db* + :config test-config + :messenger (h/messenger) + :metrics (h/metrics) + :chat-id "chat-1" + :tool-call-id "tc-1" + :call-state-fn #(deref call-state*)})] + (is (match? {:error true + :contents [{:type :text :text #"was stopped"}]} + result)) + (testing "cleans up subagent state" + (is (nil? (get-in @db* [:chats subagent-chat-id]))))))))) + +(deftest spawn-agent-cleanup-on-exception-test + (testing "cleans up subagent state when chat/prompt throws" + (let [db* (atom {:chats {"chat-1" {:id "chat-1" :model "test/model"}}}) + subagent-chat-id "subagent-tc-1"] + (with-redefs [requiring-resolve + (fn [sym] + (case sym + eca.features.chat/prompt + (fn [_params _db* _messenger _config _metrics] + (throw (ex-info "LLM provider error" {}))) + (clojure.lang.RT/var (namespace sym) (name sym))))] + (is (thrown? Exception + ((spawn-handler) + {"agent" "explorer" "task" "explore" "activity" "exploring"} + {:db* db* + :config test-config + :messenger (h/messenger) + :metrics (h/metrics) + :chat-id "chat-1" + :tool-call-id "tc-1" + :call-state-fn (constantly {:status :executing})}))) + (testing "subagent chat state is cleaned up" + (is (nil? (get-in @db* [:chats subagent-chat-id])))))))) + +(deftest extract-final-summary-test + (testing "extracts text from last assistant message" + (is (= "Hello world" + (#'f.tools.agent/extract-final-summary + [{:role "user" :content [{:type :text :text "Hi"}]} + {:role "assistant" :content [{:type :text :text "Hello world"}]}])))) + + (testing "uses last assistant message when multiple exist" + (is (= "Final answer" + (#'f.tools.agent/extract-final-summary + [{:role "assistant" :content [{:type :text :text "First response"}]} + {:role "user" :content [{:type :text :text "More?"}]} + {:role "assistant" :content [{:type :text :text "Final answer"}]}])))) + + (testing "joins multiple text blocks with newline" + (is (= "Part 1\nPart 2" + (#'f.tools.agent/extract-final-summary + [{:role "assistant" :content [{:type :text :text "Part 1"} + {:type :text :text "Part 2"}]}])))) + + (testing "ignores non-text content types" + (is (= "Text only" + (#'f.tools.agent/extract-final-summary + [{:role "assistant" :content [{:type :tool-use :text "ignored"} + {:type :text :text "Text only"}]}])))) + + (testing "returns default when no assistant messages" + (is (= "Agent completed without producing output." + (#'f.tools.agent/extract-final-summary + [{:role "user" :content [{:type :text :text "Hi"}]}]))))) + +(deftest definitions-test + (testing "spawn_agent tool definition has correct structure" + (let [defs (f.tools.agent/definitions test-config) + tool (get defs "spawn_agent")] + (is (some? tool)) + (is (string? (:description tool))) + (is (match? {:type "object" + :properties {"agent" {:type "string"} + "task" {:type "string"} + "activity" {:type "string"}} + :required ["agent" "task" "activity"]} + (:parameters tool))))) + + (testing "description includes available subagents" + (let [desc (:description (get (f.tools.agent/definitions test-config) "spawn_agent"))] + (is (re-find #"explorer" desc)) + (is (re-find #"general" desc)) + (is (not (re-find #"\bcode\b" desc)) + "primary agents should not appear in subagent list"))) + + (testing "summary-fn formats agent name with activity" + (let [summary-fn (:summary-fn (get (f.tools.agent/definitions test-config) "spawn_agent"))] + (is (= "explorer: searching files" + (summary-fn {:args {"agent" "explorer" "activity" "searching files"}}))) + (is (= "Spawning agent" + (summary-fn {:args {}})))))) From 9149963ff5ac0c4fa09bf066eb7ea04b74184a23 Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Tue, 10 Feb 2026 21:21:26 -0300 Subject: [PATCH 22/28] explorer prompt tunning improvement --- resources/prompts/explorer_agent.md | 30 ++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/resources/prompts/explorer_agent.md b/resources/prompts/explorer_agent.md index ec390d544..3d02dc51f 100644 --- a/resources/prompts/explorer_agent.md +++ b/resources/prompts/explorer_agent.md @@ -1,15 +1,23 @@ -You are a file search specialist. You excel at thoroughly navigating and exploring codebases. +You are a fast, efficient codebase explorer. Your caller is an LLM agent, not a human — optimize your output for machine consumption, not readability. -Your strengths: -- Rapidly finding files using glob patterns -- Searching code and text with powerful regex patterns -- Reading and analyzing file contents +Your goal is to answer the question with the fewest tool calls and shortest output possible. -Guidelines: -- Return file paths as absolute paths in your final response -- For clear communication, avoid using emojis -- Adapt your search approach based on the thoroughness level specified by the caller -- Do not create any files, or run bash commands that modify the user's system state in any way +## Efficiency rules -Complete the user's search request efficiently and report your findings clearly. +- Stop as soon as you have enough information. Do not exhaustively verify or over-explore. +- Prefer `eca__grep` to locate code, only use `eca__read_file` when you need to analyze content beyond what grep shows. +- Use targeted regex patterns and file-glob filters (`include`) to narrow searches. Avoid broad unfiltered searches. +- Batch independent searches into a single response when possible (multiple tool calls at once). +- Use `eca__directory_tree` with a shallow `max_depth` first. Only go deeper if needed. +- Never read an entire large file when a line range suffices — use `line_offset` and `limit`. + +## Output rules + +- Return file paths as absolute paths. +- Be terse: return raw data (paths, line numbers, code snippets) not prose. Skip introductions, summaries, and explanations unless specifically asked. +- No markdown formatting, headers, or bullet lists unless it aids parsing. Plain text is preferred. + +## Restrictions + +- Read-only: do not create or modify any files, or run state-modifying shell commands. From 6f0ef3dfb8ef9f1a32ae3af1dcd5d591bbe0cf65 Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Wed, 11 Feb 2026 09:15:25 -0300 Subject: [PATCH 23/28] Add /subagents command --- src/eca/features/commands.clj | 45 ++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/src/eca/features/commands.clj b/src/eca/features/commands.clj index 54edc21e1..77a26792c 100644 --- a/src/eca/features/commands.clj +++ b/src/eca/features/commands.clj @@ -127,7 +127,11 @@ {:name "prompt-show" :type :native :description "Prompt sent to LLM as system instructions." - :arguments [{:name "optional-prompt"}]}] + :arguments [{:name "optional-prompt"}]} + {:name "subagents" + :type :native + :description "List available subagents and their configuration." + :arguments []}] custom-cmds (map (fn [custom] {:name (:name custom) :type :custom-prompt @@ -157,6 +161,42 @@ content-with-args (map-indexed vector args))))) +(defn ^:private format-tool-permissions [{:keys [toolCall]}] + (when-let [approval (:approval toolCall)] + (let [by-default (:byDefault approval) + allow-tools (keys (:allow approval)) + deny-tools (keys (:deny approval)) + ask-tools (keys (:ask approval)) + parts (cond-> [] + by-default (conj (str " Default: " by-default)) + (seq allow-tools) (conj (str " Allow: " (string/join ", " (sort allow-tools)))) + (seq ask-tools) (conj (str " Ask: " (string/join ", " (sort ask-tools)))) + (seq deny-tools) (conj (str " Deny: " (string/join ", " (sort deny-tools)))))] + (when (seq parts) + (string/join "\n" parts))))) + +(defn ^:private subagents-msg [config] + (let [subagents (->> (:agent config) + (filter (fn [[_ v]] (= "subagent" (:mode v)))) + (sort-by first))] + (if (empty? subagents) + "No subagents configured, double check your configuration via json or markdown." + (reduce + (fn [s [agent-name agent-config]] + (let [desc (:description agent-config) + model (:defaultModel agent-config) + steps (:maxSteps agent-config) + permissions (format-tool-permissions agent-config)] + (str s "- **" agent-name "**" + (when desc (str ": " desc)) + "\n" + (when model (str " Model: " model "\n")) + (when steps (str " Max steps: " steps "\n")) + (when permissions (str " Tool permissions:\n" permissions "\n")) + "\n"))) + "Subagents available:\n\n" + subagents)))) + (defn ^:private doctor-msg [db config] (let [model (llm-api/default-model db config) cred-check (secrets/check-credential-files (:netrcFile config)) @@ -335,6 +375,9 @@ :chats {chat-id {:messages [{:role "system" :content [{:type :text :text full-prompt}]}]}}}) + "subagents" (let [msg (subagents-msg config)] + {:type :chat-messages + :chats {chat-id {:messages [{:role "system" :content [{:type :text :text msg}]}]}}}) ;; else check if a custom command or skill (if-let [custom-command-prompt (get-custom-command command args custom-cmds)] From 448a4fd82d5bbe7a243d9bc2584fa8686be8c4d6 Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Wed, 11 Feb 2026 10:19:07 -0300 Subject: [PATCH 24/28] Do not return subagents chats in resume --- src/eca/features/commands.clj | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/eca/features/commands.clj b/src/eca/features/commands.clj index 77a26792c..653a1a793 100644 --- a/src/eca/features/commands.clj +++ b/src/eca/features/commands.clj @@ -284,7 +284,8 @@ {:type :new-chat-status :status :login}) "resume" (let [chats (into {} - (filter #(not= chat-id (first %))) + (filter #(and (not= chat-id (first %)) + (not (:subagent (second %))))) (:chats db)) chats-ids (vec (sort-by #(:created-at (get chats %)) (keys chats))) selected-chat-id (try (if (= "latest" (first args)) From 48722aa6d69838c369fa245effee2615e1f7b95c Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Wed, 11 Feb 2026 11:14:48 -0300 Subject: [PATCH 25/28] Fix subagent tool calls not showing after resume --- src/eca/features/chat.clj | 42 +++++++++++++++++++++++--- src/eca/features/tools/agent.clj | 11 ++----- test/eca/features/tools/agent_test.clj | 14 ++++----- 3 files changed, 48 insertions(+), 19 deletions(-) diff --git a/src/eca/features/chat.clj b/src/eca/features/chat.clj index 7f726c626..801367ca5 100644 --- a/src/eca/features/chat.clj +++ b/src/eca/features/chat.clj @@ -1324,6 +1324,26 @@ :arguments-text "" :id (:id message-content)}}] "tool_call_output" [{:role :assistant + :content (assoc-some + {:type :toolCallRun + :id (:id message-content) + :name (:name message-content) + :server (:server message-content) + :origin (:origin message-content) + :arguments (:arguments message-content)} + :details (:details message-content) + :summary (:summary message-content))} + {:role :assistant + :content (assoc-some + {:type :toolCallRunning + :id (:id message-content) + :name (:name message-content) + :server (:server message-content) + :origin (:origin message-content) + :arguments (:arguments message-content)} + :details (:details message-content) + :summary (:summary message-content))} + {:role :assistant :content {:type :toolCalled :origin (:origin message-content) :name (:name message-content) @@ -1349,10 +1369,24 @@ (defn ^:private send-chat-contents! [messages chat-ctx] (doseq [message messages] - (doseq [{:keys [role content]} (message-content->chat-content (:role message) (:content message) (:content-id message))] - (send-content! chat-ctx - role - content)))) + (let [chat-contents (message-content->chat-content (:role message) (:content message) (:content-id message)) + subagent-chat-id (when (= "tool_call_output" (:role message)) + (get-in message [:content :details :subagent-chat-id]))] + (if-let [subagent-messages (when subagent-chat-id + (get-in @(:db* chat-ctx) [:chats subagent-chat-id :messages]))] + ;; For subagent tool calls: send toolCallRun + toolCallRunning, then + ;; subagent messages, then toolCalled — matching live execution order. + (let [before-called (butlast chat-contents) + called (last chat-contents)] + (doseq [{:keys [role content]} before-called] + (send-content! chat-ctx role content)) + (send-chat-contents! subagent-messages + (assoc chat-ctx + :chat-id subagent-chat-id + :parent-chat-id (:chat-id chat-ctx))) + (send-content! chat-ctx (:role called) (:content called))) + (doseq [{:keys [role content]} chat-contents] + (send-content! chat-ctx role content)))))) (defn ^:private handle-command! [{:keys [command args]} chat-ctx] (try diff --git a/src/eca/features/tools/agent.clj b/src/eca/features/tools/agent.clj index 0f9313356..902a8537e 100644 --- a/src/eca/features/tools/agent.clj +++ b/src/eca/features/tools/agent.clj @@ -73,14 +73,13 @@ :max-steps max-steps}}})) (defn ^:private stop-subagent-chat! - "Stop a running subagent chat and clean up its state from db." + "Stop a running subagent chat." [db* messenger metrics subagent-chat-id agent-name] (let [prompt-stop (requiring-resolve 'eca.features.chat/prompt-stop)] (try (prompt-stop {:chat-id subagent-chat-id} db* messenger metrics) (catch Exception e - (logger/warn logger-tag (format "Error stopping subagent '%s': %s" agent-name (.getMessage e)))))) - (swap! db* update :chats dissoc subagent-chat-id)) + (logger/warn logger-tag (format "Error stopping subagent '%s': %s" agent-name (.getMessage e))))))) (defn ^:private spawn-agent "Handler for the spawn_agent tool. @@ -172,10 +171,7 @@ (if max-steps-reached? (logger/info logger-tag (format "Agent '%s' halted after reaching max steps (%d)" agent-name max-steps-limit)) (logger/info logger-tag (format "Agent '%s' completed after %d steps" agent-name current-step))) - (swap! db* (fn [db] - (-> db - (assoc-in [:chats chat-id :tool-calls tool-call-id :subagent-final-step] current-step) - (update :chats dissoc subagent-chat-id)))) + (swap! db* assoc-in [:chats chat-id :tool-calls tool-call-id :subagent-final-step] current-step) (if max-steps-reached? {:error true :contents [{:type :text @@ -193,7 +189,6 @@ (catch InterruptedException _ (stopped-result)))) (catch Exception e - (swap! db* update :chats dissoc subagent-chat-id) (throw e)))))) (defn ^:private build-description diff --git a/test/eca/features/tools/agent_test.clj b/test/eca/features/tools/agent_test.clj index 9b4238278..a0ff3b85e 100644 --- a/test/eca/features/tools/agent_test.clj +++ b/test/eca/features/tools/agent_test.clj @@ -97,8 +97,8 @@ :agent "explorer" :model "test/model"} @chat-prompt-called*))) - (testing "cleans up subagent chat state" - (is (nil? (get-in @db* [:chats subagent-chat-id]))))))))) + (testing "preserves subagent chat for resume replay" + (is (some? (get-in @db* [:chats subagent-chat-id]))))))))) (deftest spawn-agent-max-steps-reached-test (testing "returns halted result when subagent reaches max steps" @@ -159,11 +159,11 @@ (is (match? {:error true :contents [{:type :text :text #"was stopped"}]} result)) - (testing "cleans up subagent state" - (is (nil? (get-in @db* [:chats subagent-chat-id]))))))))) + (testing "preserves subagent chat for resume replay" + (is (some? (get-in @db* [:chats subagent-chat-id]))))))))) (deftest spawn-agent-cleanup-on-exception-test - (testing "cleans up subagent state when chat/prompt throws" + (testing "preserves subagent state when chat/prompt throws" (let [db* (atom {:chats {"chat-1" {:id "chat-1" :model "test/model"}}}) subagent-chat-id "subagent-tc-1"] (with-redefs [requiring-resolve @@ -183,8 +183,8 @@ :chat-id "chat-1" :tool-call-id "tc-1" :call-state-fn (constantly {:status :executing})}))) - (testing "subagent chat state is cleaned up" - (is (nil? (get-in @db* [:chats subagent-chat-id])))))))) + (testing "subagent chat is preserved for resume replay" + (is (some? (get-in @db* [:chats subagent-chat-id])))))))) (deftest extract-final-summary-test (testing "extracts text from last assistant message" From b3d68e8686e9dcded70d4abe30f797166cca2204 Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Wed, 11 Feb 2026 12:26:20 -0300 Subject: [PATCH 26/28] Rename hook --- docs/config/hooks.md | 6 +++--- src/eca/features/chat.clj | 2 +- src/eca/features/hooks.clj | 4 ++-- test/eca/features/hooks_test.clj | 16 ++++++++-------- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/config/hooks.md b/docs/config/hooks.md index db6ee35fd..4b5072235 100644 --- a/docs/config/hooks.md +++ b/docs/config/hooks.md @@ -12,7 +12,7 @@ Hooks are shell actions that run before or after specific events, useful for not | `chatEnd` | Chat deleted | - | | `preRequest` | Before prompt sent to LLM | Can rewrite prompt, inject context, stop request | | `postRequest` | After primary agent prompt finished | - | -| `subagentFinished` | After a subagent prompt finished | - | +| `subagentPostRequest` | After a subagent prompt finished | - | | `preToolCall` | Before tool execution | Can modify args, override approval, reject | | `postToolCall` | After tool execution | Can inject context for next LLM turn | @@ -36,7 +36,7 @@ Hooks receive JSON via stdin with event data (top-level keys `snake_case`, neste - Chat hooks add: `chat_id`, `agent`, `behavior` (deprecated alias) - Tool hooks add: `tool_name`, `server`, `tool_input`, `approval` (pre) or `tool_response`, `error` (post) - `chatStart` adds: `resumed` (boolean) -- `subagentFinished` adds: `parent_chat_id` +- `subagentPostRequest` adds: `parent_chat_id` Hooks can output JSON to control execution: @@ -167,7 +167,7 @@ To reject a tool call, either output `{"approval": "deny"}` or exit with code `2 { "hooks": { "subagent-done": { - "type": "subagentFinished", + "type": "subagentPostRequest", "visible": false, "actions": [{ "type": "shell", diff --git a/src/eca/features/chat.clj b/src/eca/features/chat.clj index 801367ca5..852c41034 100644 --- a/src/eca/features/chat.clj +++ b/src/eca/features/chat.clj @@ -108,7 +108,7 @@ (swap! db* assoc-in [:chats chat-id :status] status) (let [db @db* subagent? (some? (get-in db [:chats chat-id :subagent])) - hook-type (if subagent? :subagentFinished :postRequest) + hook-type (if subagent? :subagentPostRequest :postRequest) hook-data (cond-> (merge (f.hooks/chat-hook-data db chat-id (:agent chat-ctx)) {:prompt message}) subagent? (assoc :parent-chat-id (get-in db [:chats chat-id :parent-chat-id])))] diff --git a/src/eca/features/hooks.clj b/src/eca/features/hooks.clj index 818aa0661..50780c864 100644 --- a/src/eca/features/hooks.clj +++ b/src/eca/features/hooks.clj @@ -23,7 +23,7 @@ (defn chat-hook-data "Returns common fields for CHAT-RELATED hooks. Includes base fields plus chat-specific fields (chat-id, agent). - Use this for: preRequest, postRequest, subagentFinished, preToolCall, postToolCall, chatStart, chatEnd." + Use this for: preRequest, postRequest, subagentPostRequest, preToolCall, postToolCall, chatStart, chatEnd." [db chat-id agent-name] (merge (base-hook-data db) {:chat-id chat-id @@ -105,7 +105,7 @@ "Execute a single hook action. Supported hook types: - :sessionStart, :sessionEnd (session lifecycle) - :chatStart, :chatEnd (chat lifecycle) - - :preRequest, :postRequest, :subagentFinished (prompt lifecycle) + - :preRequest, :postRequest, :subagentPostRequest (prompt lifecycle) - :preToolCall, :postToolCall (tool lifecycle) Returns map with :exit, :raw-output, :raw-error, :parsed" diff --git a/test/eca/features/hooks_test.clj b/test/eca/features/hooks_test.clj index 00c87821e..0dff1036e 100644 --- a/test/eca/features/hooks_test.clj +++ b/test/eca/features/hooks_test.clj @@ -261,17 +261,17 @@ {} (h/db) (h/config))) (is (true? @ran?*))))) -(deftest subagent-finished-test - (testing "subagentFinished hook triggers with parent_chat_id" +(deftest subagent-post-request-test + (testing "subagentPostRequest hook triggers with parent_chat_id" (h/reset-components!) (swap! (h/db*) assoc :chats {"sub-1" {:agent "explorer"}}) - (h/config! {:hooks {"test" {:type "subagentFinished" + (h/config! {:hooks {"test" {:type "subagentPostRequest" :actions [{:type "shell" :shell "cat"}]}}}) (let [result* (atom nil)] (with-redefs [f.hooks/run-shell-cmd (fn [opts] (reset! result* (json/parse-string (:input opts) true)) {:exit 0 :out "" :err nil})] - (f.hooks/trigger-if-matches! :subagentFinished + (f.hooks/trigger-if-matches! :subagentPostRequest (merge (f.hooks/chat-hook-data (h/db) "sub-1" "explorer") {:prompt "explore the codebase" :parent-chat-id "parent-1"}) @@ -281,20 +281,20 @@ (is (= "parent-1" (:parent_chat_id @result*))) (is (= "explore the codebase" (:prompt @result*))))) - (testing "postRequest does not trigger for subagentFinished hook type" + (testing "postRequest does not trigger for subagentPostRequest hook type" (h/reset-components!) (h/config! {:hooks {"test" {:type "postRequest" :actions [{:type "shell" :shell "echo hey"}]}}}) (let [ran?* (atom false)] (with-redefs [f.hooks/run-shell-cmd (fn [_] (reset! ran?* true) {:exit 0 :out "" :err nil})] - (f.hooks/trigger-if-matches! :subagentFinished + (f.hooks/trigger-if-matches! :subagentPostRequest {:prompt "task"} {} (h/db) (h/config))) (is (false? @ran?*)))) - (testing "subagentFinished does not trigger for postRequest hook type" + (testing "subagentPostRequest does not trigger for postRequest hook type" (h/reset-components!) - (h/config! {:hooks {"test" {:type "subagentFinished" + (h/config! {:hooks {"test" {:type "subagentPostRequest" :actions [{:type "shell" :shell "echo hey"}]}}}) (let [ran?* (atom false)] (with-redefs [f.hooks/run-shell-cmd (fn [_] (reset! ran?* true) {:exit 0 :out "" :err nil})] From d281bcd28b24ada4fd2b40bf2252b590371f5118 Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Wed, 11 Feb 2026 12:35:58 -0300 Subject: [PATCH 27/28] Fix integration tests --- integration-test/integration/chat/commands_test.clj | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/integration-test/integration/chat/commands_test.clj b/integration-test/integration/chat/commands_test.clj index ebdc76f1f..3f0312262 100644 --- a/integration-test/integration/chat/commands_test.clj +++ b/integration-test/integration/chat/commands_test.clj @@ -31,7 +31,8 @@ {:name "config" :arguments []} {:name "doctor" :arguments []} {:name "repo-map-show" :arguments []} - {:name "prompt-show" :arguments [{:name "optional-prompt"}]}]} + {:name "prompt-show" :arguments [{:name "optional-prompt"}]} + {:name "subagents" :arguments []}]} resp)))) (testing "We query specific commands" @@ -43,7 +44,8 @@ {:name "skill-create" :arguments [{:name "name"} {:name "prompt"}]} {:name "costs" :arguments []} {:name "compact" :arguments [{:name "additional-input"}]} - {:name "config" :arguments []}]} + {:name "config" :arguments []} + {:name "subagents" :arguments []}]} resp)))) (testing "We send a built-in command" From e1c317770b8952ceba557b6aafb2d177c334636c Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Wed, 11 Feb 2026 13:58:43 -0300 Subject: [PATCH 28/28] docs --- docs/config/agents.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/config/agents.md b/docs/config/agents.md index 99ca0a744..ef0cd8073 100644 --- a/docs/config/agents.md +++ b/docs/config/agents.md @@ -4,7 +4,7 @@ When using ECA chat, you can choose which agent it will use, each allows you to There are 2 types of agents defined via `mode` field (when absent, defaults to primary): -- `primary`: Main agents, used in chat. +- `primary`: main agents, used in chat, can spawn subagents. - `subagent`: an agent allowed to be spawned inside a chat to do a specific task and return a output to the main agent. ## Built-in agents @@ -50,6 +50,8 @@ You can create an agent and define its prompt, tool call approval and default mo ECA can spawn foreground subagents in a chat, they are agents which `mode` is `subagent`. +Subagents help you control context for specific tasks avoiding to polute primary agent context with its tool calls. + Subagents can be configured in config or markdown and require `description` and `systemPrompt` (or markdown content): === "Markdown"