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..d52ae62fb 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,32 @@ 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. + * 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; +} + /** * 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 df173872a..128e12ba8 100644 --- a/src/eca/config.clj +++ b/src/eca/config.clj @@ -127,7 +127,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 963f20617..d7aa6c261 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,234 @@ 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 1)) + 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 @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?* - :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* 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?* + :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 + {: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" + :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. @@ -1129,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] []) @@ -1447,16 +1475,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 81800a138..00e5202f2 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 @@ -280,10 +303,13 @@ (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 config chat-id ask-approval? tool-call-id] (try (tools.util/tool-call-details-before-invocation name arguments server {:db db - :ask-approval? ask-approval?}) + :config config + :chat-id chat-id + :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)) @@ -291,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 new file mode 100644 index 000000000..aea726bbe --- /dev/null +++ b/src/eca/features/tools/agent.clj @@ -0,0 +1,216 @@ +(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] + [eca.messenger :as messenger])) + +(set! *warn-on-reflection* true) + +(def ^:private logger-tag "[AGENT-TOOL]") + +(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." + [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 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] + (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 call-state-fn]}] + (let [agent-name (get arguments "agent") + task (get arguments "task") + db @db* + + ;; Check for nesting - prevent subagents from spawning other subagents + _ (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) + + 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)) + + (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 1}) + + ;; 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)] + (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 + (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-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))) + (stopped-result) + + ;; 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 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)}]}) + + ;; Keep waiting + :else + (do + (Thread/sleep 1000) + (recur (long (max last-turn current-turn))))))) + (catch InterruptedException _ + (stopped-result)))))) + +(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 [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) + subagent-chat-id (when tool-call-id + (->subagent-chat-id tool-call-id))] + {:type :subagent + :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)))))