Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Unreleased

- Stop the `shell_command` prompt from asking for a description of the command, as the tool has no such param and calls with it now fail.
- Allow `spawn_agent` to continue a subagent conversation with optional `chat_id`, keeping its history and (unless overridden) model; each run gets fresh step/time budgets. #614

## 0.163.0

Expand Down
2 changes: 2 additions & 0 deletions docs/config/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ The major advantages of subagents are:
- __Less context window usage__: Since subagents work as different chats/context/cleaner context, they have their own context window and when done the tools and process done there doesn't affect the primary agent context window, resulting and bigger conversations and less compaction needed.
- __Parallel subagents__: subagents are spawned as tools, and ECA supports parallel tool calls if LLM supports, this increase speed of task solution if LLM needs for example to explore 2-3 different things with `explorer` subagent, spawning those in parallel.

Parents can continue a subagent conversation once its prior work has settled by passing its returned `chat_id` to `spawn_agent`, preserving its context. It keeps its model and variant unless `model` or `variant` is given; a new `model` gets the agent's configured variant. Continuation is limited to the same parent chat. Subagents from before an ECA restart cannot be continued, even if their IDs are still in the parent's saved history. Each continued run gets a fresh `maxSteps` and `timeoutSeconds` budget, and its limits, tools and approval rules follow the current config; the system prompt stays as it was unless `chat.autoSyncSystemPrompt` is enabled. A subagent halted by a limit can also be continued after its final summary. While it is continuable, a subagent chat can only be prompted by its parent, through `spawn_agent`.

Subagents can be configured in config or markdown and support/require these fields:

- `mode`: set to `"subagent"` (or `["subagent"]`) to restrict an agent to subagent use only. Omit or include `"primary"` to also allow chat use.
Expand Down
11 changes: 10 additions & 1 deletion docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -1330,10 +1330,19 @@ interface SubagentDetails {
/**
* The chatId of this running subagent, useful to link other chat/ContentReceived
* messages to this tool call.
* Available from toolCallRun afterwards
* Available from toolCallRun afterwards.
* A subagent continued with spawn_agent's `chat_id` keeps its chatId, so several
* tool calls of the same parent chat can share it.
*/
subagentChatId?: string;

/**
* The [start, end) indexes of the subagent chat messages that this tool call ran,
* [0, 0] when it did not run. Set on toolCalled. History replay uses it to show
* each call's part of a continued subagent.
*/
subagentMessageRange?: [number, number];

/**
* The model this subagent is using.
*/
Expand Down
4 changes: 2 additions & 2 deletions integration-test/integration/chat/subagent_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,10 @@
:name "spawn_agent"
:error false
:outputs (m/embeds [{:type "text"
:text #"^## Agent 'explorer' Result"}])}
:text #"^Subagent chat_id: subagent-[^\n]+\n\n## Agent 'explorer' Result"}])}
(:content e))))
events)
"Expected toolCalled for spawn_agent with output text starting with \"## Agent 'explorer' Result\"")))
"Expected toolCalled for spawn_agent with the reusable chat ID followed by the result heading")))

(testing "parent receives final assistant text after subagent completes"
(is (some (fn [e]
Expand Down
5 changes: 3 additions & 2 deletions resources/prompts/tools/spawn_agent.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Spawn an isolated sub-agent to handle complex, multi-step tasks without polluting your current context.
Spawn or continue an isolated sub-agent to handle complex, multi-step tasks without polluting your current context.

Use for: Codebase exploration, codebase editing and refactoring, focused research, or delegating specialized tasks.
Proactive use: If the specific agent's description suggests proactive use, use it whenever the task complexity justifies delegation.
Expand All @@ -7,5 +7,6 @@ Agent Limits: Sub-agents cannot spawn other agents (no nesting) and have access

Strict rules for arguments:
- 'task': Provide a highly detailed prompt. Explicitly state whether it should write/edit code or just research, how to verify its work, and exactly what specific information it must return to you.
- 'activity': Must be a concise 3-4 word label for the UI (e.g., "exploring codebase", "refactoring module").
- 'activity': Optional concise 3-4 word label for the UI (e.g., "exploring codebase", "refactoring module").
- 'chat_id': Optional returned ID to continue a conversation in the same parent chat. Reuse its 'agent' and supply a new 'task'. It keeps its model and variant unless you override them. If continuation is rejected because the subagent is unavailable, omit 'chat_id' to spawn a new subagent and include the needed context in its 'task'.
- 'model' & 'variant': - NEVER include these arguments if the user hasn't explicitly requested a specific model or variant.
5 changes: 5 additions & 0 deletions src/eca/db.clj
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@
;; chat ids deleted in this session; excluded from workspace cache writes so
;; the merge-on-write never resurrects them from a shared cache file.
:deleted-chat-ids #{:string}
;; chats only their owner may prompt (spawn_agent for subagents); the owner
;; passes its :token as :owner-token, :workers counts unwinding prompt workers.
:managed-chats {"<chat-id>" {:token ::object :workers :number :interrupted? :boolean}}
:models {"<model-name>" {:web-search :boolean
:tools :boolean
:reason? :boolean
Expand Down Expand Up @@ -176,6 +179,8 @@
:tool-calls {}
;; Chat ids deleted in this session (not cached), see _db-spec.
:deleted-chat-ids #{}
;; Chats only their owner may prompt (not cached), see _db-spec.
:managed-chats {}

;; cacheable; bump `chats-version` when changing :chats shape, `version`
;; when changing :auth/:mcp-auth shape
Expand Down
45 changes: 37 additions & 8 deletions src/eca/features/chat.clj
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
[eca.features.rules :as f.rules]
[eca.features.skills :as f.skills]
[eca.features.tools :as f.tools]
[eca.features.tools.agent :as f.tools.agent]
[eca.features.tools.mcp :as f.mcp]
[eca.features.tools.task :as f.tools.task]
[eca.llm-api :as llm-api]
Expand Down Expand Up @@ -408,7 +409,8 @@
subagent-chat-id (when (= "tool_call_output" (:role message))
(get-in message [:content :details :subagent-chat-id]))
subagent-messages (when subagent-chat-id
(get-in db [:chats subagent-chat-id :messages]))]
(f.tools.agent/replayed-messages (get-in db [:chats subagent-chat-id :messages])
(:content message)))]
(if (some? subagent-messages)
;; For subagent tool calls: toolCallRun + toolCallRunning, then
;; subagent messages, then toolCalled — matching live execution order.
Expand Down Expand Up @@ -993,6 +995,18 @@
(string/trim)
(as-> t (subs t 0 (min (count t) 40)))))))

(defn ^:private start-prompt-worker!
"Runs `thunk` in a prompt worker thread. For a managed chat (see
`:managed-chats` in the db), counts the worker from before dispatch until its
whole cleanup has unwound, since the chat may publish idle earlier."
[{:keys [db* config chat-id]} thunk]
(if-not (contains? (:managed-chats @db*) chat-id)
(future* config (thunk))
(do (swap! db* update-in [:managed-chats chat-id :workers] inc)
(future* config
(try (thunk)
(finally (swap! db* update-in [:managed-chats chat-id :workers] dec)))))))

(defn ^:private prompt-messages!
"Send user messages to LLM with hook processing.
source-type controls hook agent.
Expand Down Expand Up @@ -1172,7 +1186,8 @@
(if (and (lifecycle/auto-compact? chat-id agent full-model config @db*)
(not (:auto-compacted? chat-ctx)))
(trigger-auto-compact! chat-ctx all-tools user-messages)
(future* config
(start-prompt-worker! chat-ctx
(fn []
(try
(llm-api/sync-or-async-prompt!
{:model model
Expand Down Expand Up @@ -1779,9 +1794,11 @@
;; Only notify client if finish-chat-prompt! hasn't already run,
;; otherwise the belated statusChanged causes duplicate finished handling.
(when-not (get-in @db* [:chats chat-id :prompt-finished?])
(when (contains? (:managed-chats @db*) chat-id)
(swap! db* assoc-in [:managed-chats chat-id :interrupted?] true))
(messenger/chat-status-changed (:messenger chat-ctx) {:chat-id chat-id :status :idle})
(lifecycle/trigger-chat-status-hook! chat-ctx))
(db/save-chat! @db* chat-id metrics))))))))))
(db/save-chat! @db* chat-id metrics)))))))))))

(defn ^:private send-mcp-prompt!
[{:keys [prompt args] :as _decision}
Expand Down Expand Up @@ -2112,12 +2129,21 @@
config should pass the map."
[{:keys [message agent behavior chat-id contexts variant trust] :as params} db* messenger config metrics]
(let [provided-chat-id chat-id
invalid-id-reason (when (and (some? provided-chat-id)
(not (server-managed-subagent-chat-id? @db* provided-chat-id)))
managed (get-in @db* [:managed-chats chat-id])
invalid-id-reason (cond
;; Only the run holding the owner token may prompt a managed chat.
(and managed
(not (and (:token managed)
(identical? (:token managed) (:owner-token params)))))
"this chat is managed (e.g. a subagent) and can only be prompted by its owner"

(and (some? provided-chat-id)
(not (server-managed-subagent-chat-id? @db* provided-chat-id)))
(validate-client-chat-id provided-chat-id))]
(if invalid-id-reason
(do (logger/warn logger-tag "Rejected chat/prompt with invalid chat-id"
{:chat-id provided-chat-id :reason invalid-id-reason})
(do (logger/with-chat-context provided-chat-id (db/parent-chat-id @db* provided-chat-id)
(logger/warn logger-tag "Rejected chat/prompt with invalid chat-id"
{:chat-id provided-chat-id :reason invalid-id-reason}))
{:chat-id provided-chat-id
:model "error"
:status :error})
Expand Down Expand Up @@ -2464,7 +2490,10 @@
(when (identical? :running (get-in @db* [:chats chat-id :status]))
;; Set :stopping immediately to prevent race with stream callbacks
;; that check status via assert-chat-not-stopped! or cancelled?
(swap! db* assoc-in [:chats chat-id :status] :stopping)
(swap! db* (fn [db]
(cond-> (assoc-in db [:chats chat-id :status] :stopping)
(contains? (:managed-chats db) chat-id)
(assoc-in [:managed-chats chat-id :interrupted?] true))))
(let [chat-ctx {:chat-id chat-id
:db* db*
:config config
Expand Down
31 changes: 19 additions & 12 deletions src/eca/features/chat/tool_calls.clj
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,8 @@
Note: All actions are run in the order specified.
Note: The :send-* actions should be last, so that they have the latest values of the state context.
Note: The :status is updated before any actions are run, so the actions are in the context of the latest :status.
Note: :finally-actions run after the actions and the status hook, even when they throw.
The future-cleanup promise is delivered there, so a stop joins the post-tool hooks too.

Note: all choices (i.e. conditionals) have to be made in code and result
in different events being sent to the state machine.
Expand Down Expand Up @@ -278,7 +280,8 @@

[:executing :execution-end]
{:status :cleanup
:actions [:save-execution-result :deliver-future-cleanup-completed :send-toolCalled :log-metrics :send-progress :trigger-post-tool-call-hook]}
:actions [:save-execution-result :send-toolCalled :log-metrics :send-progress :trigger-post-tool-call-hook]
:finally-actions [:deliver-future-cleanup-completed]}

[:cleanup :cleanup-finished]
{:status :completed
Expand All @@ -298,7 +301,8 @@

[:stopping :stop-attempted]
{:status :cleanup
:actions [:save-execution-result :deliver-future-cleanup-completed :send-toolCallRejected :trigger-post-tool-call-hook]}
:actions [:save-execution-result :send-toolCallRejected :trigger-post-tool-call-hook]
:finally-actions [:deliver-future-cleanup-completed]}

;; And now all the :stop-requested transitions

Expand Down Expand Up @@ -574,7 +578,8 @@
- event: Event keyword (e.g., :tool-prepare, :tool-run, :user-approve)
- event-data: Optional map with event-specific data

Returns: {:status new-status :actions actions-executed}
Returns: the transition, {:status new-status :actions actions-executed}
plus its :finally-actions, if any.

Throws: ex-info if the transition is invalid for the current state.

Expand All @@ -584,7 +589,7 @@
(let [current-state (get-tool-call-state @db* (:chat-id chat-ctx) tool-call-id)
current-status (:status current-state :initial) ; Default to :initial if no state
transition-key [current-status event]
{:keys [status actions]} (get tool-call-state-machine transition-key)]
{:keys [status actions finally-actions] :as transition} (get tool-call-state-machine transition-key)]

(logger/debug logger-tag "Tool call transition"
{:tool-call-id tool-call-id :current-status current-status :event event :status status})
Expand All @@ -601,13 +606,15 @@
;; Atomic status update
(swap! db* assoc-in [:chats (:chat-id chat-ctx) :tool-calls tool-call-id :status] status)

;; Execute all actions sequentially
(doseq [action actions]
(execute-action! action db* chat-ctx tool-call-id event-data))
(try
(doseq [action actions]
(execute-action! action db* chat-ctx tool-call-id event-data))
(lifecycle/trigger-chat-status-hook! (assoc chat-ctx :db* db*))
(finally
(doseq [action finally-actions]
(execute-action! action db* chat-ctx tool-call-id event-data))))

(lifecycle/trigger-chat-status-hook! (assoc chat-ctx :db* db*))

{:status status :actions actions}))
transition))

(def ^:private hook-approval-rank
{"allow" 1
Expand Down Expand Up @@ -890,7 +897,7 @@
config
messenger
metrics
(partial get-tool-call-state @db* chat-id id)
#(get-tool-call-state @db* chat-id id)
(partial transition-tool-call! db* chat-ctx id)
{:trust (db/resolve-trust @db* chat-id)})
details (f.tools/tool-call-details-after-invocation name arguments details result
Expand Down Expand Up @@ -980,7 +987,6 @@
(reduced nil))))
nil
tool-calls)
(lifecycle/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)
Expand Down Expand Up @@ -1011,6 +1017,7 @@
:ex-data (ex-data t)
:message (.getMessage ^Throwable t)
:cause (.getCause ^Throwable t)})))))))
(lifecycle/assert-chat-not-stopped! chat-ctx)
(f.tools.mcp/await-pending-tools-refresh @db* 5000)
;; Token can expire during long tool calls (e.g. spawn_agent),
;; so renew before any continuation branch.
Expand Down
Loading
Loading