Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}.",
Expand Down
64 changes: 64 additions & 0 deletions docs/config/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
20 changes: 20 additions & 0 deletions src/eca/config.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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\")."
Expand Down
39 changes: 34 additions & 5 deletions src/eca/features/agents.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand All @@ -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
Expand Down
7 changes: 3 additions & 4 deletions src/eca/features/commands.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 5 additions & 6 deletions src/eca/features/skills/builtin.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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) ")")
Expand Down Expand Up @@ -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)
Expand All @@ -200,7 +199,7 @@
""
(skills-section skills)
""
(subagents-section config)
(subagents-section config agent)
""
(env-vars-section)
""
Expand All @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/eca/features/tools.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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))))

Expand Down
74 changes: 38 additions & 36 deletions src/eca/features/tools/agent.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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))
Expand Down Expand Up @@ -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
Expand All @@ -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"))
Expand Down Expand Up @@ -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]}]
Expand All @@ -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
Expand Down
Loading
Loading