Skip to content

Commit bb7cd79

Browse files
committed
Restrict subagents to configured parent agents
Add spawnableBy discovery filtering and server-side authorization so workflow-specific subagents stay private while unrestricted agents retain existing behavior.
1 parent 04810b6 commit bb7cd79

15 files changed

Lines changed: 425 additions & 57 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Add agent `spawnableBy` configuration to restrict subagent discovery and spawning to specific primary agents.
6+
57
## 0.146.2
68

79
- 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

docs/config.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -844,6 +844,25 @@
844844
}
845845
]
846846
},
847+
"spawnableBy": {
848+
"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.",
849+
"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.",
850+
"oneOf": [
851+
{
852+
"type": "string",
853+
"minLength": 1
854+
},
855+
{
856+
"type": "array",
857+
"items": {
858+
"type": "string",
859+
"minLength": 1
860+
},
861+
"uniqueItems": true
862+
}
863+
],
864+
"examples": ["duel", ["duel", "another-orchestrator"]]
865+
},
847866
"description": {
848867
"type": "string",
849868
"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}.",

docs/config/agents.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,74 @@ Subagents can be configured in config or markdown and support/require these fiel
108108
- `systemPrompt` or the markdown content (required unless using `inherit`): Instructions for the subagent to what do when receive a task.
109109

110110
- `inherit` (optional): name of another agent to inherit all settings from. The subagent's own fields are merged on top.
111+
- `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.
111112
- `model` (optional): which full model to use for this subagent, using primary agent model if not specified.
112113
- `tools` (optional): same as ECA tool approval logic to control what tools are allowed/askable/denied.
113114
- `maxSteps` (optional): set a max limit of turns/steps that his subagent must finish and return an answer.
114115

116+
### Parent-scoped subagents
117+
118+
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:
119+
120+
```yaml
121+
spawnableBy: duel
122+
```
123+
124+
```yaml
125+
spawnableBy:
126+
- duel
127+
- another-orchestrator
128+
```
129+
130+
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.
131+
132+
When `spawnableBy` is omitted or empty, the subagent is unrestricted, preserving the default behavior. When it contains IDs:
133+
134+
- 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;
135+
- a missing current parent agent cannot discover or spawn the restricted subagent;
136+
- 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;
137+
- `spawnableBy` only controls subagent discovery and spawning. An agent whose `mode` also includes `primary` remains selectable as a primary agent;
138+
- existing subagent nesting restrictions remain unchanged.
139+
140+
`spawnableBy` participates in normal agent inheritance. An inherited value is preserved unless the child overrides it through the usual configuration merge behavior.
141+
142+
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.
143+
144+
=== "Example: private orchestrator workers"
145+
146+
```yaml title=".eca/agents/duel-plan-adversary.md"
147+
---
148+
mode: subagent
149+
description: Challenge the orchestrator's implementation plan
150+
spawnableBy: duel
151+
---
152+
153+
Review the proposed plan adversarially and return concrete risks.
154+
```
155+
156+
```yaml title=".eca/agents/duel-implementer.md"
157+
---
158+
mode: subagent
159+
description: Implement the plan approved by the duel orchestrator
160+
spawnableBy:
161+
- duel
162+
---
163+
164+
Implement only the approved plan and report the changes made.
165+
```
166+
167+
```yaml title=".eca/agents/duel-code-reviewer.md"
168+
---
169+
mode: subagent
170+
description: Review the duel implementer's changes
171+
spawnableBy: duel
172+
---
173+
174+
Review the implementation for correctness, regressions, and missing tests.
175+
```
176+
177+
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.
178+
115179
=== "Markdown"
116180

117181
Agents can be defined in local or global markdown files inside a `agents` folder, those will be merged to ECA config. Example:

src/eca/config.clj

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,26 @@
336336
(and (coll? mode) (seq mode)) (set mode)
337337
:else default-modes)))
338338

339+
(defn subagent-available?
340+
"Returns whether an agent config is available as a subagent to parent-agent-name."
341+
[agent-config parent-agent-name]
342+
(let [spawnable-by (:spawnableBy agent-config)
343+
allowed-parents (cond
344+
(string? spawnable-by) #{spawnable-by}
345+
(coll? spawnable-by) (set spawnable-by)
346+
:else #{})]
347+
(and (contains? (agent-modes agent-config) "subagent")
348+
(or (empty? allowed-parents)
349+
(and parent-agent-name
350+
(contains? allowed-parents parent-agent-name))))))
351+
352+
(defn available-subagents
353+
"Returns configured subagents visible to parent-agent-name."
354+
[config parent-agent-name]
355+
(filter (fn [[_ agent-config]]
356+
(subagent-available? agent-config parent-agent-name))
357+
(:agent config)))
358+
339359
(defn primary-agent-names
340360
"Returns the names of agents usable as primary (i.e. whose effective
341361
modes include \"primary\")."

src/eca/features/agents.clj

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,43 @@
6060
(sequential? tools) {"byDefault" "ask" "allow" (vec tools)}
6161
:else nil))
6262

63+
(defn ^:private normalize-agent-id
64+
[agent-id]
65+
(when (string? agent-id)
66+
(let [normalized (-> agent-id string/trim string/lower-case)]
67+
(when-not (string/blank? normalized)
68+
normalized))))
69+
70+
(defn ^:private normalize-spawnable-by
71+
[spawnable-by]
72+
(cond
73+
(nil? spawnable-by) nil
74+
(string? spawnable-by) (or (normalize-agent-id spawnable-by)
75+
(do
76+
(logger/warn logger-tag "Ignoring blank spawnableBy agent id")
77+
nil))
78+
(coll? spawnable-by) (let [normalized (->> spawnable-by
79+
(keep (fn [agent-id]
80+
(or (normalize-agent-id agent-id)
81+
(do
82+
(logger/warn logger-tag (format "Ignoring malformed spawnableBy agent id: %s" (pr-str agent-id)))
83+
nil))))
84+
distinct
85+
vec)]
86+
(when (seq normalized)
87+
normalized))
88+
:else (do
89+
(logger/warn logger-tag (format "Ignoring malformed spawnableBy value: %s" (pr-str spawnable-by)))
90+
nil)))
91+
6392
(defn ^:private md->agent-config
64-
[{:keys [description mode model steps tools body inherit]}]
65-
(let [tools-map (normalize-tools tools)]
93+
[{:keys [description mode model steps tools body inherit spawnableBy]}]
94+
(let [tools-map (normalize-tools tools)
95+
spawnable-by (normalize-spawnable-by spawnableBy)]
6696
(cond-> {}
6797
inherit (assoc :inherit (str inherit))
6898
description (assoc :description description)
99+
spawnable-by (assoc :spawnableBy spawnable-by)
69100
mode (assoc :mode (if (sequential? mode)
70101
(mapv str mode)
71102
(str mode)))
@@ -91,9 +122,7 @@
91122
trimmed and lowercased. Returns nil otherwise."
92123
[parsed]
93124
(when-let [n (:name parsed)]
94-
(let [s (-> n str string/trim)]
95-
(when-not (string/blank? s)
96-
(string/lower-case s)))))
125+
(normalize-agent-id (str n))))
97126

98127
(defn ^:private agent-name-from-filename
99128
"Returns the agent id derived from a markdown filename by stripping all

src/eca/features/commands.clj

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -288,9 +288,8 @@
288288
(when (seq parts)
289289
(string/join "\n" parts)))))
290290

291-
(defn ^:private subagents-msg [config]
292-
(let [subagents (->> (:agent config)
293-
(filter (fn [[_ v]] (contains? (config/agent-modes v) "subagent")))
291+
(defn ^:private subagents-msg [config parent-agent-name]
292+
(let [subagents (->> (config/available-subagents config parent-agent-name)
294293
(sort-by first))]
295294
(if (empty? subagents)
296295
"No subagents configured, double check your configuration via json or markdown."
@@ -910,7 +909,7 @@
910909
:chats {chat-id {:messages [{:role "system"
911910
:content [{:type :text
912911
:text (prompt-show-text instructions user-messages)}]}]}}}
913-
"subagents" (let [msg (subagents-msg config)]
912+
"subagents" (let [msg (subagents-msg config agent)]
914913
{:type :chat-messages
915914
:chats {chat-id {:messages [{:role "system" :content [{:type :text :text msg}]}]}}})
916915
"plugins" (let [plugins-config (:plugins config)

src/eca/features/skills/builtin.clj

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,8 @@
120120
(string/join "\n"))
121121
"- (none)")))
122122

123-
(defn ^:private subagents-section [config]
124-
(let [subagents (->> (:agent config)
125-
(filter (fn [[_ v]] (= "subagent" (:mode v))))
123+
(defn ^:private subagents-section [config parent-agent-name]
124+
(let [subagents (->> (config/available-subagents config parent-agent-name)
126125
(sort-by first))]
127126
(multi-str
128127
(str "## Subagents (" (count subagents) ")")
@@ -183,7 +182,7 @@
183182
(string/join "\n"))
184183
"- (none found)"))))
185184

186-
(defn ^:private eca-info-body [{:keys [db config skills]}]
185+
(defn ^:private eca-info-body [{:keys [db config skills agent]}]
187186
(multi-str "# ECA Self-Debug Report"
188187
""
189188
(versions-section db)
@@ -200,7 +199,7 @@
200199
""
201200
(skills-section skills)
202201
""
203-
(subagents-section config)
202+
(subagents-section config agent)
204203
""
205204
(env-vars-section)
206205
""
@@ -210,7 +209,7 @@
210209
"Returns the list of built-in skills.
211210
212211
Each skill has :name, :description and :handler-fn (a function of
213-
{:keys [db config skills]} returning the markdown body).
212+
{:keys [db config skills agent]} returning the markdown body).
214213
Built-in skills compute their body lazily; they have no :body or :dir."
215214
[]
216215
[{:name "eca-info"

src/eca/features/tools.clj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@
152152
f.tools.task/definitions
153153
f.tools.background/definitions
154154
f.tools.ask-user/definitions
155-
(f.tools.agent/definitions config db)
155+
(f.tools.agent/definitions config db agent-name)
156156
(f.tools.custom/definitions config)
157157
(f.tools.fetch-rule/definitions config db chat-id agent-name))))
158158

src/eca/features/tools/agent.clj

Lines changed: 38 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,10 @@
2828
activity)))))
2929

3030
(defn ^:private all-agents
31-
[config]
32-
(->> (:agent config)
31+
[config parent-agent-name]
32+
(->> (config/available-subagents config parent-agent-name)
3333
(keep (fn [[agent-name agent-config]]
34-
(when (and (contains? (config/agent-modes agent-config) "subagent")
35-
(:description agent-config))
34+
(when (:description agent-config)
3635
{:name agent-name
3736
:description (:description agent-config)
3837
:model (:defaultModel agent-config)
@@ -42,8 +41,8 @@
4241
vec))
4342

4443
(defn ^:private get-agent
45-
[agent-name config]
46-
(first (filter #(= agent-name (:name %)) (all-agents config))))
44+
[agent-name config parent-agent-name]
45+
(first (filter #(= agent-name (:name %)) (all-agents config parent-agent-name))))
4746

4847
(defn ^:private max-steps [subagent]
4948
(:max-steps subagent))
@@ -125,11 +124,12 @@
125124
(defn ^:private spawn-agent
126125
"Handler for the spawn_agent tool.
127126
Spawns a subagent to perform a focused task and returns the result."
128-
[arguments {:keys [db* config messenger metrics chat-id tool-call-id call-state-fn trust]}]
127+
[arguments {:keys [db* config messenger metrics chat-id tool-call-id call-state-fn trust agent]}]
129128
(let [arguments (normalize-arguments arguments)
130129
agent-name (get arguments "agent")
131130
task (get arguments "task")
132131
activity (get arguments "activity")
132+
parent-agent-name agent
133133
db @db*
134134

135135
;; Check for nesting - prevent subagents from spawning other subagents
@@ -138,11 +138,10 @@
138138
{:agent-name agent-name
139139
:parent-chat-id chat-id})))
140140

141-
subagent (get-agent agent-name config)
141+
subagent (get-agent agent-name config parent-agent-name)
142142
_ (when-not subagent
143-
(let [available (all-agents config)]
144-
(throw (ex-info (format "Agent '%s' not found. Available agents: %s"
145-
agent-name
143+
(let [available (all-agents config parent-agent-name)]
144+
(throw (ex-info (format "Agent not found or not available. Available agents: %s"
146145
(if (seq available)
147146
(str/join ", " (map :name available))
148147
"none"))
@@ -269,9 +268,9 @@
269268

270269
(defn ^:private build-description
271270
"Build tool description with available agents and models listed."
272-
[config]
271+
[config parent-agent-name]
273272
(let [base-description (tools.util/read-tool-description "spawn_agent")
274-
agents (all-agents config)
273+
agents (all-agents config parent-agent-name)
275274
agents-section (str "\n\nAvailable agents:\n"
276275
(->> agents
277276
(map (fn [{:keys [name description]}]
@@ -280,36 +279,39 @@
280279
(str base-description agents-section)))
281280

282281
(defn definitions
283-
[config _db]
284-
{"spawn_agent"
285-
{:description (build-description config)
286-
:parameters {:type "object"
287-
:properties {"agent" {:type "string"
288-
:description "Name of the agent to spawn"}
289-
"task" {:type "string"
290-
:description "The detailed instructions for the agent"}
291-
"activity" {:type "string"
292-
:description "Concise label (max 3-4 words) shown in the UI while the agent runs, e.g. \"exploring codebase\", \"reviewing changes\", \"analyzing tests\"."}
293-
"model" {:type "string"
294-
:description "Optional sub-agent model override. Reserved for explicit user override only. Omit unless the user explicitly named a model."}
295-
"variant" {:type "string"
296-
:description "Optional sub-agent model variant override. Reserved for explicit user override only. Omit unless the user explicitly named a variant."}}
297-
:required ["agent" "task"]}
298-
:handler #'spawn-agent
299-
:summary-fn (fn [{:keys [args]}]
300-
(if-let [agent-name (get args "agent")]
301-
(if-let [activity (get (normalize-arguments args) "activity")]
302-
(format "%s: %s" agent-name activity)
303-
agent-name)
304-
"Spawning agent"))}})
282+
([config db]
283+
(definitions config db nil))
284+
([config _db parent-agent-name]
285+
{"spawn_agent"
286+
{:description (build-description config parent-agent-name)
287+
:parameters {:type "object"
288+
:properties {"agent" {:type "string"
289+
:description "Name of the agent to spawn"}
290+
"task" {:type "string"
291+
:description "The detailed instructions for the agent"}
292+
"activity" {:type "string"
293+
:description "Concise label (max 3-4 words) shown in the UI while the agent runs, e.g. \"exploring codebase\", \"reviewing changes\", \"analyzing tests\"."}
294+
"model" {:type "string"
295+
:description "Optional sub-agent model override. Reserved for explicit user override only. Omit unless the user explicitly named a model."}
296+
"variant" {:type "string"
297+
:description "Optional sub-agent model variant override. Reserved for explicit user override only. Omit unless the user explicitly named a variant."}}
298+
:required ["agent" "task"]}
299+
:handler #'spawn-agent
300+
:summary-fn (fn [{:keys [args]}]
301+
(if-let [agent-name (get args "agent")]
302+
(if-let [activity (get (normalize-arguments args) "activity")]
303+
(format "%s: %s" agent-name activity)
304+
agent-name)
305+
"Spawning agent"))}}))
305306

306307
(defmethod tools.util/tool-call-details-before-invocation :spawn_agent
307308
[_name arguments _server {:keys [db config chat-id tool-call-id]}]
308309
(let [agent-name (get arguments "agent")
309310
user-model (get arguments "model")
310311
user-variant (get arguments "variant")
312+
parent-agent-name (get-in db [:chats chat-id :agent])
311313
subagent (when agent-name
312-
(get-agent agent-name config))
314+
(get-agent agent-name config parent-agent-name))
313315
parent-model (get-in db [:chats chat-id :model])
314316
subagent-model (or user-model (:model subagent) parent-model)
315317
subagent-chat-id (when tool-call-id

0 commit comments

Comments
 (0)