diff --git a/AGENTS.md b/AGENTS.md index 18e12dbfe..fb70b2fb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,3 +28,5 @@ 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. +- ECA's protocol specification of client <-> server lives in docs/protocol.md diff --git a/docs/config/agents.md b/docs/config/agents.md index ff0b78dc7..ef0cd8073 100644 --- a/docs/config/agents.md +++ b/docs/config/agents.md @@ -1,11 +1,20 @@ -# 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, 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 -- `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. +| 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 @@ -37,3 +46,52 @@ 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 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" + + 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", + "systemPrompt": "You should run sleep 1 and return \"I slept 1 second\"", + "defaultModel": "anthropic/sonnet-4.5", + "toolCall": {...}, + "maxSteps": 25 // Optional: to limit turns in subagent + } + } + } + ``` + diff --git a/docs/config/hooks.md b/docs/config/hooks.md index 76ed68b27..4b5072235 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 | - | +| `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 | @@ -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) +- `subagentPostRequest` 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": "subagentPostRequest", + "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/docs/protocol.md b/docs/protocol.md index de2c9e2e0..56575290e 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,33 @@ 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 steps this subagent is limited. + * When not set, the subagent runs with no step limit (infinite interaction). + */ + maxSteps?: number; + + /** + * The current step. + */ + step: number; +} + /** * Extra information about a chat */ 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" diff --git a/mkdocs.yml b/mkdocs.yml index c6fb8a3d4..f43aebd3d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -16,7 +16,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 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..b4b1dab49 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 \ @@ -33,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_agent.md b/resources/prompts/explorer_agent.md new file mode 100644 index 000000000..3d02dc51f --- /dev/null +++ b/resources/prompts/explorer_agent.md @@ -0,0 +1,23 @@ +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 goal is to answer the question with the fewest tool calls and shortest output possible. + +## Efficiency rules + +- 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. + 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/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 diff --git a/src/eca/config.clj b/src/eca/config.clj index 8267ca970..ab5471f71 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] @@ -39,6 +40,16 @@ (defn get-env [env] (System/getenv env)) (defn get-property [property] (System/getProperty property)) +(def ^:private dangerous-commands-regexes + [".*[12&]?>>?\\s*(?!/dev/null($|\\s))(?!&\\d+($|\\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.*[12&]?>>?\\s*(?!/dev/null($|\\s))(?!&\\d+($|\\s))\\S+.*"]) + (def ^:private initial-config* {:providers {"openai" {:api "openai-responses" :url "${env:OPENAI_API_URL:https://api.openai.com}" @@ -65,25 +76,44 @@ :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))(?!&\\d+($|\\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.*[12&]?>>?\\s*(?!/dev/null($|\\s))(?!&\\d+($|\\s))\\S+.*"]}}}}}}} + "plan" {:mode "primary" + :prompts {:chat "${classpath:prompts/plan_agent.md}"} + :disabledTools ["edit_file" "write_file" "move_file"] + :toolCall {:approval {:byDefault "ask" + :allow {"eca__shell_command" + {:argsMatchers {"command" ["pwd"]}} + "eca__compact_chat" {} + "eca__preview_file_change" {} + "eca__read_file" {} + "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 {:byDefault "ask" + :allow {"eca__shell_command" + {:argsMatchers {"command" ["pwd"]}} + "eca__compact_chat" {} + "eca__read_file" {} + "eca__directory_tree" {} + "eca__grep" {} + "eca__editor_diagnostics" {} + "eca__skill" {}} + :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}" @@ -103,7 +133,8 @@ "eca__directory_tree" {} "eca__grep" {} "eca__editor_diagnostics" {} - "eca__skill" {}} + "eca__skill" {} + "eca__spawn_agent" {}} :ask {} :deny {}} :readFile {:maxLines 2000} @@ -184,6 +215,14 @@ (def ^:private fallback-agent "code") +(defn primary-agent-names + "Returns the names of agents that are not subagents (mode is nil or not \"subagent\")." + [config] + (->> (:agent config) + (remove (fn [[_ v]] (= "subagent" (:mode v)))) + (map key) + distinct)) + (defn validate-agent-name "Validates if an agent exists in config. Returns the agent name if valid, or the fallback agent if not." @@ -220,19 +259,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 @@ -405,7 +439,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? + (f.agents/all-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..1b4d8739c --- /dev/null +++ b/src/eca/features/agents.clj @@ -0,0 +1,80 @@ +(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.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 (shared/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/chat.clj b/src/eca/features/chat.clj index 3840455ae..852c41034 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? @@ -105,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? :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])))] + (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}) @@ -865,208 +871,244 @@ chat-ctx)))) nil)) +(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 :subagent)." + [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 0)) + 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 agent 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 agent @db* config)] + (let [all-tools (f.tools/all-tools chat-id agent @db* config) + max-steps-reached? (check-subagent-max-steps! 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 agent 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 - agent - 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 agent @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 agent 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 steps - if reached, finish without executing more tools + (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) + 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 agent 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 + agent + 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 agent @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])}) + (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" + :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 agent 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. @@ -1131,6 +1173,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] []) @@ -1281,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) @@ -1306,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 @@ -1454,16 +1531,17 @@ (swap! db* assoc-in [:chats new-id] {:id new-id}) new-id)) selected-agent (config/validate-agent-name raw-agent 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 - :agent selected-agent - :agent-config (get-in config [:agent selected-agent])}] + 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 + :agent selected-agent + :agent-config (get-in config [:agent selected-agent])} + :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/commands.clj b/src/eca/features/commands.clj index 54edc21e1..653a1a793 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)) @@ -244,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)) @@ -335,6 +376,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)] 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/features/hooks.clj b/src/eca/features/hooks.clj index a2c06054a..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, 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 (prompt lifecycle) + - :preRequest, :postRequest, :subagentPostRequest (prompt lifecycle) - :preToolCall, :postToolCall (tool lifecycle) Returns map with :exit, :raw-output, :raw-error, :parsed" diff --git a/src/eca/features/prompt.clj b/src/eca/features/prompt.clj index c05dd87fc..9b2ea1fd7 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 [agent-name config] - (let [config-prompt (get-config-prompt :chat agent-name config) - agent-config (get-in config [:agent agent-name]) + (let [agent-config (get-in config [:agent agent-name]) + subagent-prompt (and (= "subagent" (:mode agent-config)) + (:systemPrompt agent-config)) + config-prompt (get-config-prompt :chat agent-name config) legacy-config-prompt (:systemPrompt agent-config) legacy-config-prompt-file (:systemPromptFile agent-config)] (cond + subagent-prompt + subagent-prompt + legacy-config-prompt legacy-config-prompt 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/features/tools.clj b/src/eca/features/tools.clj index 1dd3d18e4..a959a1620 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,26 @@ f.tools.editor/definitions f.tools.chat/definitions f.tools.skill/definitions + (f.tools.agent/definitions 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. + 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 (like filesystem and shell tools) and tools provided by MCP servers. - Removes denied tools." + Removes denied tools. + 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) + 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))) @@ -175,7 +185,11 @@ {:agent agent-name :db db :chat-id chat-id - :config config})))))] + :config config}))))) + ;; Apply subagent tool filtering if applicable + all-tools (if subagent + (filter-subagent-tools all-tools) + all-tools)] (remove (fn [tool] (= :deny (approval all-tools tool {} db config agent-name))) all-tools))) @@ -207,6 +221,7 @@ :config config :messenger messenger :agent agent-name + :metrics metrics :chat-id chat-id :tool-call-id tool-call-id :call-state-fn call-state-fn @@ -280,10 +295,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 +309,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..902a8537e --- /dev/null +++ b/src/eca/features/tools/agent.clj @@ -0,0 +1,245 @@ +(ns eca.features.tools.agent + "Tool for spawning subagents to perform focused tasks in isolated context." + (:require + [clojure.string :as str] + [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 all-agents + [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 + [agent-name config] + (first (filter #(= agent-name (:name %)) (all-agents config)))) + +(defn ^:private max-steps [subagent] + (:max-steps subagent)) + +(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 ^: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-step-progress! + "Send a toolCallRunning notification with current step progress to the parent chat." + [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 + :role :assistant + :content {:type :toolCallRunning + :id tool-call-id + :name "spawn_agent" + :server "eca" + :origin "native" + :summary (format "%s: %s" agent-name activity) + :arguments arguments + :details {:type :subagent + :subagent-chat-id subagent-chat-id + :model model + :agent-name agent-name + :step step + :max-steps max-steps}}})) + +(defn ^:private stop-subagent-chat! + "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))))))) + +(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 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 + _ (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}))) + + 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 + (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 subagent) parent-model)] + + (logger/info logger-tag (format "Spawning agent '%s' for task: %s" agent-name task)) + + (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 0} + max-steps-limit (assoc :max-steps max-steps-limit))) + + (try + ;; 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* assoc-in [:chats chat-id :tool-calls tool-call-id :subagent-final-step] current-step) + (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 + (throw e)))))) + +(defn ^:private build-description + "Build tool description with available agents listed." + [config] + (let [base-description (tools.util/read-tool-description "spawn_agent") + agents (all-agents config) + 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 + [config] + {"spawn_agent" + {:description (build-description 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"} + "activity" {:type "string" + :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]}] + (if-let [agent-name (get args "agent")] + (let [activity (get args "activity" "working")] + (format "%s: %s" agent-name activity)) + "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") + subagent (when agent-name + (get-agent agent-name config)) + parent-model (get-in db [:chats chat-id :model]) + subagent-model (or (:model subagent) 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 + :step (get-in db [:chats subagent-chat-id :current-step] 1) + :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]}] + (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/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/src/eca/handlers.clj b/src/eca/handlers.clj index 0c653032a..3848a837e 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)) - :agents (distinct (keys (:agent config))) + :agents (config/primary-agent-names config) :select-model (f.chat/default-model db config) :select-agent (config/validate-agent-name (or (:defaultAgent (:chat config)) ;;legacy diff --git a/src/eca/llm_providers/anthropic.clj b/src/eca/llm_providers/anthropic.clj index 3dd3fd478..09acef1a6 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. diff --git a/src/eca/shared.clj b/src/eca/shared.clj index 2f39e6f43..d5a158517 100644 --- a/src/eca/shared.clj +++ b/src/eca/shared.clj @@ -11,10 +11,52 @@ [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)}))) + +(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")) 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))))) diff --git a/test/eca/features/hooks_test.clj b/test/eca/features/hooks_test.clj index 4796c2f3c..0dff1036e 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-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 "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! :subagentPostRequest + (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 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! :subagentPostRequest + {:prompt "task"} + {} (h/db) (h/config))) + (is (false? @ran?*)))) + + (testing "subagentPostRequest does not trigger for postRequest hook type" + (h/reset-components!) + (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})] + (f.hooks/trigger-if-matches! :postRequest + {:prompt "task"} + {} (h/db) (h/config))) + (is (false? @ran?*))))) diff --git a/test/eca/features/tools/agent_test.clj b/test/eca/features/tools/agent_test.clj new file mode 100644 index 000000000..a0ff3b85e --- /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 "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" + (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 "preserves subagent chat for resume replay" + (is (some? (get-in @db* [:chats subagent-chat-id]))))))))) + +(deftest spawn-agent-cleanup-on-exception-test + (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 + (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 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" + (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 {}})))))) 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)))))