diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b931b8ec..cf0ab0785 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Add agent `spawnableBy` configuration to restrict subagent discovery and spawning to specific primary agents. + ## 0.146.2 - Fix 400 on GitHub Copilot Claude adaptive-thinking models (e.g. Opus 4.8) when no variant is selected, by defaulting to adaptive thinking. #528 diff --git a/docs/config.json b/docs/config.json index 0b4a76a39..d0403b020 100644 --- a/docs/config.json +++ b/docs/config.json @@ -844,6 +844,25 @@ } ] }, + "spawnableBy": { + "description": "Primary agent name or names allowed to discover and spawn this agent as a subagent. Matching uses exact resolved agent IDs. When omitted or empty, every primary agent may spawn it. This does not affect primary-agent selectability.", + "markdownDescription": "Primary agent ID or IDs allowed to discover and spawn this agent as a subagent. Matching uses exact resolved agent IDs. When omitted or empty, every primary agent may spawn it. Restricted agents are filtered from `spawn_agent`, `/subagents`, and contextual diagnostics, and direct spawn attempts are enforced server-side. This does not affect whether `mode` includes `primary`. The field participates in normal agent inheritance/merge behavior.", + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + } + ], + "examples": ["duel", ["duel", "another-orchestrator"]] + }, "description": { "type": "string", "description": "Description of this agent's purpose. Required for subagents — shown to the LLM so it knows when to spawn this agent. Supports dynamic strings like ${file:path} and ${classpath:path}.", diff --git a/docs/config/agents.md b/docs/config/agents.md index b60d80b7e..0032ffc47 100644 --- a/docs/config/agents.md +++ b/docs/config/agents.md @@ -108,10 +108,74 @@ Subagents can be configured in config or markdown and support/require these fiel - `systemPrompt` or the markdown content (required unless using `inherit`): Instructions for the subagent to what do when receive a task. - `inherit` (optional): name of another agent to inherit all settings from. The subagent's own fields are merged on top. +- `spawnableBy` (optional): one primary agent ID or a collection of primary agent IDs allowed to discover and spawn this subagent. When omitted or empty, the subagent remains available to every primary agent. - `model` (optional): which full model to use for this subagent, using primary agent model if not specified. - `tools` (optional): same as ECA tool approval logic to control what tools are allowed/askable/denied. - `maxSteps` (optional): set a max limit of turns/steps that his subagent must finish and return an answer. +### Parent-scoped subagents + +Use `spawnableBy` for workflow-specific workers that should only be available to one or more orchestrator agents. It accepts either a single agent ID or a collection: + +```yaml +spawnableBy: duel +``` + +```yaml +spawnableBy: + - duel + - another-orchestrator +``` + +Matching uses exact resolved agent IDs. Markdown agent IDs and Markdown `spawnableBy` values are trimmed and lowercased during loading; JSON configuration values are matched against the configured agent keys exactly. + +When `spawnableBy` is omitted or empty, the subagent is unrestricted, preserving the default behavior. When it contains IDs: + +- only a listed current primary agent sees the subagent in the `spawn_agent` tool description, `/subagents`, and contextual diagnostics such as the built-in `eca-info` skill; +- a missing current parent agent cannot discover or spawn the restricted subagent; +- direct `spawn_agent` calls are checked server-side and rejected with a generic unavailable error, even if the caller supplies the exact hidden agent ID; +- `spawnableBy` only controls subagent discovery and spawning. An agent whose `mode` also includes `primary` remains selectable as a primary agent; +- existing subagent nesting restrictions remain unchanged. + +`spawnableBy` participates in normal agent inheritance. An inherited value is preserved unless the child overrides it through the usual configuration merge behavior. + +The `/config` command intentionally remains an administrative, raw resolved-configuration view and can show all agent configuration, including `spawnableBy`. It is not a discovery or spawning surface. + +=== "Example: private orchestrator workers" + + ```yaml title=".eca/agents/duel-plan-adversary.md" + --- + mode: subagent + description: Challenge the orchestrator's implementation plan + spawnableBy: duel + --- + + Review the proposed plan adversarially and return concrete risks. + ``` + + ```yaml title=".eca/agents/duel-implementer.md" + --- + mode: subagent + description: Implement the plan approved by the duel orchestrator + spawnableBy: + - duel + --- + + Implement only the approved plan and report the changes made. + ``` + + ```yaml title=".eca/agents/duel-code-reviewer.md" + --- + mode: subagent + description: Review the duel implementer's changes + spawnableBy: duel + --- + + Review the implementation for correctness, regressions, and missing tests. + ``` + + With a primary agent named `duel`, these workers appear in its `spawn_agent` tool and `/subagents` output. Other primary agents cannot discover or spawn them. + === "Markdown" Agents can be defined in local or global markdown files inside a `agents` folder, those will be merged to ECA config. Example: diff --git a/src/eca/config.clj b/src/eca/config.clj index 6a999fcd9..e79cd204f 100644 --- a/src/eca/config.clj +++ b/src/eca/config.clj @@ -336,6 +336,26 @@ (and (coll? mode) (seq mode)) (set mode) :else default-modes))) +(defn subagent-available? + "Returns whether an agent config is available as a subagent to parent-agent-name." + [agent-config parent-agent-name] + (let [spawnable-by (:spawnableBy agent-config) + allowed-parents (cond + (string? spawnable-by) #{spawnable-by} + (coll? spawnable-by) (set spawnable-by) + :else #{})] + (and (contains? (agent-modes agent-config) "subagent") + (or (empty? allowed-parents) + (and parent-agent-name + (contains? allowed-parents parent-agent-name)))))) + +(defn available-subagents + "Returns configured subagents visible to parent-agent-name." + [config parent-agent-name] + (filter (fn [[_ agent-config]] + (subagent-available? agent-config parent-agent-name)) + (:agent config))) + (defn primary-agent-names "Returns the names of agents usable as primary (i.e. whose effective modes include \"primary\")." diff --git a/src/eca/features/agents.clj b/src/eca/features/agents.clj index 6ae4dfc77..ee9bf7de4 100644 --- a/src/eca/features/agents.clj +++ b/src/eca/features/agents.clj @@ -60,12 +60,43 @@ (sequential? tools) {"byDefault" "ask" "allow" (vec tools)} :else nil)) +(defn ^:private normalize-agent-id + [agent-id] + (when (string? agent-id) + (let [normalized (-> agent-id string/trim string/lower-case)] + (when-not (string/blank? normalized) + normalized)))) + +(defn ^:private normalize-spawnable-by + [spawnable-by] + (cond + (nil? spawnable-by) nil + (string? spawnable-by) (or (normalize-agent-id spawnable-by) + (do + (logger/warn logger-tag "Ignoring blank spawnableBy agent id") + nil)) + (coll? spawnable-by) (let [normalized (->> spawnable-by + (keep (fn [agent-id] + (or (normalize-agent-id agent-id) + (do + (logger/warn logger-tag (format "Ignoring malformed spawnableBy agent id: %s" (pr-str agent-id))) + nil)))) + distinct + vec)] + (when (seq normalized) + normalized)) + :else (do + (logger/warn logger-tag (format "Ignoring malformed spawnableBy value: %s" (pr-str spawnable-by))) + nil))) + (defn ^:private md->agent-config - [{:keys [description mode model steps tools body inherit]}] - (let [tools-map (normalize-tools tools)] + [{:keys [description mode model steps tools body inherit spawnableBy]}] + (let [tools-map (normalize-tools tools) + spawnable-by (normalize-spawnable-by spawnableBy)] (cond-> {} inherit (assoc :inherit (str inherit)) description (assoc :description description) + spawnable-by (assoc :spawnableBy spawnable-by) mode (assoc :mode (if (sequential? mode) (mapv str mode) (str mode))) @@ -91,9 +122,7 @@ trimmed and lowercased. Returns nil otherwise." [parsed] (when-let [n (:name parsed)] - (let [s (-> n str string/trim)] - (when-not (string/blank? s) - (string/lower-case s))))) + (normalize-agent-id (str n)))) (defn ^:private agent-name-from-filename "Returns the agent id derived from a markdown filename by stripping all diff --git a/src/eca/features/commands.clj b/src/eca/features/commands.clj index b80595fd3..377736690 100644 --- a/src/eca/features/commands.clj +++ b/src/eca/features/commands.clj @@ -288,9 +288,8 @@ (when (seq parts) (string/join "\n" parts))))) -(defn ^:private subagents-msg [config] - (let [subagents (->> (:agent config) - (filter (fn [[_ v]] (contains? (config/agent-modes v) "subagent"))) +(defn ^:private subagents-msg [config parent-agent-name] + (let [subagents (->> (config/available-subagents config parent-agent-name) (sort-by first))] (if (empty? subagents) "No subagents configured, double check your configuration via json or markdown." @@ -910,7 +909,7 @@ :chats {chat-id {:messages [{:role "system" :content [{:type :text :text (prompt-show-text instructions user-messages)}]}]}}} - "subagents" (let [msg (subagents-msg config)] + "subagents" (let [msg (subagents-msg config agent)] {:type :chat-messages :chats {chat-id {:messages [{:role "system" :content [{:type :text :text msg}]}]}}}) "plugins" (let [plugins-config (:plugins config) diff --git a/src/eca/features/skills/builtin.clj b/src/eca/features/skills/builtin.clj index 16d94b631..5d0a2a43e 100644 --- a/src/eca/features/skills/builtin.clj +++ b/src/eca/features/skills/builtin.clj @@ -120,9 +120,8 @@ (string/join "\n")) "- (none)"))) -(defn ^:private subagents-section [config] - (let [subagents (->> (:agent config) - (filter (fn [[_ v]] (= "subagent" (:mode v)))) +(defn ^:private subagents-section [config parent-agent-name] + (let [subagents (->> (config/available-subagents config parent-agent-name) (sort-by first))] (multi-str (str "## Subagents (" (count subagents) ")") @@ -183,7 +182,7 @@ (string/join "\n")) "- (none found)")))) -(defn ^:private eca-info-body [{:keys [db config skills]}] +(defn ^:private eca-info-body [{:keys [db config skills agent]}] (multi-str "# ECA Self-Debug Report" "" (versions-section db) @@ -200,7 +199,7 @@ "" (skills-section skills) "" - (subagents-section config) + (subagents-section config agent) "" (env-vars-section) "" @@ -210,7 +209,7 @@ "Returns the list of built-in skills. Each skill has :name, :description and :handler-fn (a function of - {:keys [db config skills]} returning the markdown body). + {:keys [db config skills agent]} returning the markdown body). Built-in skills compute their body lazily; they have no :body or :dir." [] [{:name "eca-info" diff --git a/src/eca/features/tools.clj b/src/eca/features/tools.clj index 7d8b19c7e..8a016fce6 100644 --- a/src/eca/features/tools.clj +++ b/src/eca/features/tools.clj @@ -152,7 +152,7 @@ f.tools.task/definitions f.tools.background/definitions f.tools.ask-user/definitions - (f.tools.agent/definitions config db) + (f.tools.agent/definitions config db agent-name) (f.tools.custom/definitions config) (f.tools.fetch-rule/definitions config db chat-id agent-name)))) diff --git a/src/eca/features/tools/agent.clj b/src/eca/features/tools/agent.clj index 89afb50d0..44dbabbf4 100644 --- a/src/eca/features/tools/agent.clj +++ b/src/eca/features/tools/agent.clj @@ -28,11 +28,10 @@ activity))))) (defn ^:private all-agents - [config] - (->> (:agent config) + [config parent-agent-name] + (->> (config/available-subagents config parent-agent-name) (keep (fn [[agent-name agent-config]] - (when (and (contains? (config/agent-modes agent-config) "subagent") - (:description agent-config)) + (when (:description agent-config) {:name agent-name :description (:description agent-config) :model (:defaultModel agent-config) @@ -42,8 +41,8 @@ vec)) (defn ^:private get-agent - [agent-name config] - (first (filter #(= agent-name (:name %)) (all-agents config)))) + [agent-name config parent-agent-name] + (first (filter #(= agent-name (:name %)) (all-agents config parent-agent-name)))) (defn ^:private max-steps [subagent] (:max-steps subagent)) @@ -125,11 +124,12 @@ (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 trust]}] + [arguments {:keys [db* config messenger metrics chat-id tool-call-id call-state-fn trust agent]}] (let [arguments (normalize-arguments arguments) agent-name (get arguments "agent") task (get arguments "task") activity (get arguments "activity") + parent-agent-name agent db @db* ;; Check for nesting - prevent subagents from spawning other subagents @@ -138,11 +138,10 @@ {:agent-name agent-name :parent-chat-id chat-id}))) - subagent (get-agent agent-name config) + subagent (get-agent agent-name config parent-agent-name) _ (when-not subagent - (let [available (all-agents config)] - (throw (ex-info (format "Agent '%s' not found. Available agents: %s" - agent-name + (let [available (all-agents config parent-agent-name)] + (throw (ex-info (format "Agent not found or not available. Available agents: %s" (if (seq available) (str/join ", " (map :name available)) "none")) @@ -269,9 +268,9 @@ (defn ^:private build-description "Build tool description with available agents and models listed." - [config] + [config parent-agent-name] (let [base-description (tools.util/read-tool-description "spawn_agent") - agents (all-agents config) + agents (all-agents config parent-agent-name) agents-section (str "\n\nAvailable agents:\n" (->> agents (map (fn [{:keys [name description]}] @@ -280,36 +279,39 @@ (str base-description agents-section))) (defn definitions - [config _db] - {"spawn_agent" - {:description (build-description config) - :parameters {:type "object" - :properties {"agent" {:type "string" - :description "Name of the agent to spawn"} - "task" {:type "string" - :description "The detailed instructions for the agent"} - "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\"."} - "model" {:type "string" - :description "Optional sub-agent model override. Reserved for explicit user override only. Omit unless the user explicitly named a model."} - "variant" {:type "string" - :description "Optional sub-agent model variant override. Reserved for explicit user override only. Omit unless the user explicitly named a variant."}} - :required ["agent" "task"]} - :handler #'spawn-agent - :summary-fn (fn [{:keys [args]}] - (if-let [agent-name (get args "agent")] - (if-let [activity (get (normalize-arguments args) "activity")] - (format "%s: %s" agent-name activity) - agent-name) - "Spawning agent"))}}) + ([config db] + (definitions config db nil)) + ([config _db parent-agent-name] + {"spawn_agent" + {:description (build-description config parent-agent-name) + :parameters {:type "object" + :properties {"agent" {:type "string" + :description "Name of the agent to spawn"} + "task" {:type "string" + :description "The detailed instructions for the agent"} + "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\"."} + "model" {:type "string" + :description "Optional sub-agent model override. Reserved for explicit user override only. Omit unless the user explicitly named a model."} + "variant" {:type "string" + :description "Optional sub-agent model variant override. Reserved for explicit user override only. Omit unless the user explicitly named a variant."}} + :required ["agent" "task"]} + :handler #'spawn-agent + :summary-fn (fn [{:keys [args]}] + (if-let [agent-name (get args "agent")] + (if-let [activity (get (normalize-arguments args) "activity")] + (format "%s: %s" agent-name activity) + agent-name) + "Spawning agent"))}})) (defmethod tools.util/tool-call-details-before-invocation :spawn_agent [_name arguments _server {:keys [db config chat-id tool-call-id]}] (let [agent-name (get arguments "agent") user-model (get arguments "model") user-variant (get arguments "variant") + parent-agent-name (get-in db [:chats chat-id :agent]) subagent (when agent-name - (get-agent agent-name config)) + (get-agent agent-name config parent-agent-name)) parent-model (get-in db [:chats chat-id :model]) subagent-model (or user-model (:model subagent) parent-model) subagent-chat-id (when tool-call-id diff --git a/src/eca/features/tools/skill.clj b/src/eca/features/tools/skill.clj index 7bde57e1c..5890276fd 100644 --- a/src/eca/features/tools/skill.clj +++ b/src/eca/features/tools/skill.clj @@ -7,7 +7,7 @@ (set! *warn-on-reflection* true) (defn ^:private skill - [arguments {:keys [db config]}] + [arguments {:keys [db config agent]}] (let [skill-name (get arguments "name") all-skills (f.skills/all config (:workspace-folders db)) skill (first (filter @@ -15,7 +15,7 @@ all-skills))] (if skill (let [body (if-let [handler (:handler-fn skill)] - (handler {:db db :config config :skills all-skills}) + (handler {:db db :config config :skills all-skills :agent agent}) (:body skill))] {:error false :contents [{:type :text diff --git a/test/eca/config_test.clj b/test/eca/config_test.clj index dfd9b9de9..1e35ecf0f 100644 --- a/test/eca/config_test.clj +++ b/test/eca/config_test.clj @@ -232,6 +232,17 @@ :description "custom plan"}} (#'config/resolve-agent-inheritance agents))))) + (testing "spawnableBy follows normal inheritance and child override behavior" + (let [agents {"worker" {:mode "subagent" + :spawnableBy ["duel"]} + "inherited-worker" {:inherit "worker" + :description "inherits restriction"} + "overridden-worker" {:inherit "worker" + :spawnableBy ["other"]}} + resolved (#'config/resolve-agent-inheritance agents)] + (is (= ["duel"] (get-in resolved ["inherited-worker" :spawnableBy]))) + (is (= ["other"] (get-in resolved ["overridden-worker" :spawnableBy]))))) + (testing "child values override parent values" (let [agents {"code" {:mode "primary" :disabledTools ["preview_file_change"] @@ -870,12 +881,31 @@ (is (= #{"primary" "subagent"} (config/agent-modes {:mode nil}))) (is (= #{"primary" "subagent"} (config/agent-modes {:mode []}))))) +(deftest subagent-availability-test + (let [unrestricted {:mode "subagent"} + restricted-string {:mode "subagent" :spawnableBy "duel"} + restricted-collection {:mode ["primary" "subagent"] + :spawnableBy ["duel" "another"]} + primary-only {:mode "primary" :spawnableBy "duel"}] + (testing "unrestricted subagents are available to every or no parent" + (is (config/subagent-available? unrestricted "code")) + (is (config/subagent-available? unrestricted nil))) + (testing "restricted subagents require an exact allowed parent id" + (is (config/subagent-available? restricted-string "duel")) + (is (config/subagent-available? restricted-collection "another")) + (is (not (config/subagent-available? restricted-string "Duel"))) + (is (not (config/subagent-available? restricted-string "code"))) + (is (not (config/subagent-available? restricted-string nil)))) + (testing "spawnableBy does not make primary-only agents into subagents" + (is (not (config/subagent-available? primary-only "duel")))))) + (deftest primary-agent-names-test (testing "includes agents whose effective modes contain 'primary'" (let [config {:agent {"a-primary" {:mode "primary"} "a-subagent-only" {:mode "subagent"} "a-list-subagent-only" {:mode ["subagent"]} - "a-list-both" {:mode ["primary" "subagent"]} + "a-list-both" {:mode ["primary" "subagent"] + :spawnableBy ["orchestrator"]} "a-unspecified" {:description "no mode set"}}} names (set (config/primary-agent-names config))] (is (contains? names "a-primary")) diff --git a/test/eca/features/agents_test.clj b/test/eca/features/agents_test.clj index d13144d99..95b76b682 100644 --- a/test/eca/features/agents_test.clj +++ b/test/eca/features/agents_test.clj @@ -142,6 +142,39 @@ config (#'agents/md->agent-config parsed)] (is (= ["primary" "subagent"] (:mode config))))) + (testing "spawnableBy string is normalized to one agent id" + (let [parsed (shared/parse-md (str "---\n" + "description: Private worker\n" + "spawnableBy: ' Duel '\n" + "---\n\n" + "Body.")) + config (#'agents/md->agent-config parsed)] + (is (= "duel" (:spawnableBy config))))) + + (testing "spawnableBy collection is normalized to distinct agent ids" + (let [parsed (shared/parse-md (str "---\n" + "description: Private worker\n" + "spawnableBy:\n" + " - Duel\n" + " - another-Orchestrator\n" + " - duel\n" + "---\n\n" + "Body.")) + config (#'agents/md->agent-config parsed)] + (is (= ["duel" "another-orchestrator"] (:spawnableBy config))))) + + (testing "omitted or empty spawnableBy preserves unrestricted configuration" + (is (nil? (:spawnableBy (#'agents/md->agent-config {:description "open"})))) + (is (nil? (:spawnableBy (#'agents/md->agent-config {:description "open" + :spawnableBy []}))))) + + (testing "malformed spawnableBy is ignored" + (is (nil? (:spawnableBy (#'agents/md->agent-config {:description "open" + :spawnableBy 42})))) + (is (= ["duel"] + (:spawnableBy (#'agents/md->agent-config {:description "partially valid" + :spawnableBy ["duel" 42 nil]}))))) + (testing "tools as a YAML list normalizes to byDefault=ask + allow map (Claude form)" (let [md (str "---\n" "description: Reviewer\n" diff --git a/test/eca/features/commands_test.clj b/test/eca/features/commands_test.clj index e96b70cb0..25d592804 100644 --- a/test/eca/features/commands_test.clj +++ b/test/eca/features/commands_test.clj @@ -10,6 +10,43 @@ (h/reset-components-before-test) +(deftest subagents-msg-parent-visibility-test + (let [config {:agent {"explorer" {:mode "subagent" + :description "Unrestricted explorer"} + "duel-worker" {:mode "subagent" + :description "Private duel worker" + :spawnableBy "duel"} + "dual-role" {:mode ["primary" "subagent"] + :description "Private dual-role agent" + :spawnableBy ["duel"]} + "primary-only" {:mode "primary" + :description "Primary only"}}}] + (testing "/subagents includes unrestricted and authorized restricted agents" + (let [message (#'f.commands/subagents-msg config "duel")] + (is (string/includes? message "explorer")) + (is (string/includes? message "duel-worker")) + (is (string/includes? message "dual-role")) + (is (not (string/includes? message "primary-only"))))) + + (testing "/subagents hides restricted agents from other or missing parents" + (doseq [parent-agent-name ["code" nil]] + (let [message (#'f.commands/subagents-msg config parent-agent-name)] + (is (string/includes? message "explorer")) + (is (not (string/includes? message "duel-worker"))) + (is (not (string/includes? message "dual-role")))))) + + (testing "/subagents dispatch passes the current primary agent" + (let [db* (atom {:workspace-folders []}) + result (f.commands/handle-command! "subagents" [] + {:chat-id "chat-1" + :db* db* + :config (assoc config :pureConfig true) + :messenger (h/messenger) + :agent "duel"}) + message (get-in result [:chats "chat-1" :messages 0 :content 0 :text])] + (is (string/includes? message "duel-worker")) + (is (string/includes? message "dual-role")))))) + (deftest prompt-show-text-test (testing "uses compact section headings and omits the /prompt-show command itself" (let [result (#'f.commands/prompt-show-text diff --git a/test/eca/features/skills_test.clj b/test/eca/features/skills_test.clj index bf5714e34..89275e38c 100644 --- a/test/eca/features/skills_test.clj +++ b/test/eca/features/skills_test.clj @@ -213,4 +213,28 @@ (is (string/includes? body "## Skills")) (is (string/includes? body "## Subagents")) (is (string/includes? body "## Credential files")) - (is (string/includes? body "test-client 1.0"))))) + (is (string/includes? body "test-client 1.0")))) + + (testing "eca-info lists only subagents visible to the current primary agent" + (let [config {:pureConfig true + :agent {"explorer" {:mode "subagent" + :description "Unrestricted"} + "duel-worker" {:mode "subagent" + :description "Private" + :spawnableBy "duel"}}} + skill (->> (f.skills/all config []) + (filter #(= "eca-info" (:name %))) + first) + body-for (fn [agent] + ((:handler-fn skill) + {:db {:workspace-folders [] + :auth {} + :models {} + :mcp-clients {}} + :config config + :skills [skill] + :agent agent}))] + (is (string/includes? (body-for "duel") "duel-worker")) + (is (not (string/includes? (body-for "code") "duel-worker"))) + (is (not (string/includes? (body-for nil) "duel-worker"))) + (is (string/includes? (body-for "code") "explorer"))))) diff --git a/test/eca/features/tools/agent_test.clj b/test/eca/features/tools/agent_test.clj index 8fc4ed805..78c8e3e05 100644 --- a/test/eca/features/tools/agent_test.clj +++ b/test/eca/features/tools/agent_test.clj @@ -1,5 +1,6 @@ (ns eca.features.tools.agent-test (:require + [clojure.string :as string] [clojure.test :refer [deftest is testing]] [eca.config :as config] [eca.features.chat :as f.chat] @@ -21,7 +22,13 @@ "code" {:mode "primary" :description "Code agent"} "swiss-knife" {:mode ["primary" "subagent"] - :description "Works as primary or subagent"}} + :description "Works as primary or subagent"} + "duel-worker" {:mode "subagent" + :description "Private duel worker" + :spawnableBy "duel"} + "duel-reviewer" {:mode "subagent" + :description "Private duel reviewer" + :spawnableBy ["duel" "another-orchestrator"]}} :variantsByModel {".*sonnet[-._]4[-._]6|opus[-._]4[-._][56]" {:variants {"low" {:thinking {:type "adaptive"}} "medium" {:thinking {:type "adaptive"}} @@ -44,6 +51,37 @@ (defn ^:private spawn-summary [args] ((get-in (f.tools.agent/definitions test-config test-db) ["spawn_agent" :summary-fn]) {:args args})) +(defn ^:private spawn-description [parent-agent-name] + (get-in (f.tools.agent/definitions test-config test-db parent-agent-name) + ["spawn_agent" :description])) + +(deftest spawn-agent-parent-visibility-test + (testing "unrestricted subagents are visible to every primary agent and without a parent" + (doseq [parent-agent-name [nil "code" "duel"]] + (let [description (spawn-description parent-agent-name)] + (is (string/includes? description "explorer: Explores codebases")) + (is (string/includes? description "general: General purpose agent"))))) + + (testing "restricted subagents are visible only to allowed parents" + (is (string/includes? (spawn-description "duel") "duel-worker: Private duel worker")) + (is (string/includes? (spawn-description "duel") "duel-reviewer: Private duel reviewer")) + (is (string/includes? (spawn-description "another-orchestrator") "duel-reviewer: Private duel reviewer")) + (is (not (string/includes? (spawn-description "another-orchestrator") "duel-worker"))) + (is (not (string/includes? (spawn-description "code") "duel-worker"))) + (is (not (string/includes? (spawn-description nil) "duel-worker")))) + + (testing "native tool generation propagates the current primary agent" + (let [duel-description (->> (f.tools/native-tools "chat-1" "duel" test-db test-config) + (filter #(= "spawn_agent" (:name %))) + first + :description) + code-description (->> (f.tools/native-tools "chat-1" "code" test-db test-config) + (filter #(= "spawn_agent" (:name %))) + first + :description)] + (is (string/includes? duel-description "duel-worker")) + (is (not (string/includes? code-description "duel-worker")))))) + (deftest spawn-agent-activity-summary-test (testing "normal activity label is unchanged" (is (= "explorer: searching files" @@ -104,6 +142,78 @@ (is (match? {:agent-name "nonexistent"} (:ex-data result)))))) +(deftest spawn-agent-parent-authorization-test + (testing "an allowed parent can spawn a restricted subagent" + (let [db* (atom {:chats {"chat-1" {:id "chat-1" :model "test/model"}}}) + subagent-chat-id "subagent-tc-private" + chat-prompt-called* (promise)] + (with-redefs [requiring-resolve + (fn [sym] + (case sym + eca.features.chat/prompt + (fn [params _db* _messenger _config _metrics] + (deliver chat-prompt-called* params) + (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 "Private work complete."}]}])) + (clojure.lang.RT/var (namespace sym) (name sym))))] + (let [result ((spawn-handler) + {"agent" "duel-worker" "task" "implement"} + {:db* db* + :config test-config + :messenger (h/messenger) + :metrics (h/metrics) + :agent "duel" + :chat-id "chat-1" + :tool-call-id "tc-private" + :call-state-fn (constantly {:status :executing})})] + (is (match? {:error false + :contents [{:text #"Private work complete"}]} + result)) + (is (= "duel-worker" (:agent @chat-prompt-called*))))))) + + (testing "an unauthorized parent cannot spawn a restricted subagent or discover it in the error" + (let [db* (atom {:chats {"chat-1" {:id "chat-1" :model "test/model"}}}) + result (try + ((spawn-handler) + {"agent" "duel-worker" "task" "implement"} + {:db* db* + :config test-config + :messenger (h/messenger) + :metrics (h/metrics) + :agent "code" + :chat-id "chat-1" + :tool-call-id "tc-denied" + :call-state-fn (constantly {:status :executing})}) + (catch Exception e + {:message (ex-message e) + :data (ex-data e)}))] + (is (string/includes? (:message result) "not found or not available")) + (is (not (string/includes? (:message result) "duel-worker"))) + (is (not (string/includes? (:message result) "Private duel worker"))) + (is (not (some #{"duel-worker"} (:available (:data result))))) + (is (nil? (get-in @db* [:chats "subagent-tc-denied"]))))) + + (testing "a missing parent cannot spawn a restricted subagent" + (let [db* (atom {:chats {"chat-1" {:id "chat-1" :model "test/model"}}}) + result (try + ((spawn-handler) + {"agent" "duel-worker" "task" "implement"} + {:db* db* + :config test-config + :messenger (h/messenger) + :metrics (h/metrics) + :chat-id "chat-1" + :tool-call-id "tc-no-parent" + :call-state-fn (constantly {:status :executing})}) + (catch Exception e + {:message (ex-message e) + :data (ex-data e)}))] + (is (string/includes? (:message result) "not found or not available")) + (is (not (some #{"duel-worker"} (:available (:data result))))) + (is (nil? (get-in @db* [:chats "subagent-tc-no-parent"])))))) + (deftest spawn-agent-nesting-prevention-test (testing "throws when subagent tries to spawn another subagent" (let [db* (atom {:chats {"sub-chat" {:id "sub-chat"