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

- Allow `spawn_agent` to continue a subagent conversation using optional `chat_id`, preserving its history and model selections within the same live parent chat.

## 0.161.2

- List the new OpenAI `gpt-6-sol`/`gpt-6-luna` models for ChatGPT OAuth accounts by bumping the Codex client version the backend gates on.
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 and model selection. Continuation is limited to the same parent chat and server session, with a fresh `maxSteps` budget. Changes to configuration, workspace, or trust require a new subagent.

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
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 and server session. Reuse its 'agent', supply a new 'task', and omit 'model' and 'variant'.
- 'model' & 'variant': - NEVER include these arguments if the user hasn't explicitly requested a specific model or variant.
60 changes: 53 additions & 7 deletions src/eca/features/chat.clj
Original file line number Diff line number Diff line change
Expand Up @@ -407,11 +407,17 @@
(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]))
subagent-messages (when subagent-chat-id
(get-in db [:chats subagent-chat-id :messages]))]
child (get-in db [:chats subagent-chat-id])
subagent-messages (when (and (shared/not-blank subagent-chat-id)
(not= chat-id subagent-chat-id)
(:subagent child)
(= chat-id (:parent-chat-id child))
(= (get-in message [:content :details :agent-name])
(:agent-name child)))
(:messages child))]
(if (some? subagent-messages)
;; For subagent tool calls: toolCallRun + toolCallRunning, then
;; subagent messages, then toolCalled — matching live execution order.
;; Render the child transcript between the tool-running
;; and tool-called notifications.
(concat (map ->payload (butlast chat-contents))
(messages->contents subagent-messages
{:chat-id subagent-chat-id
Expand Down Expand Up @@ -982,6 +988,33 @@
(string/trim)
(as-> t (subs t 0 (min (count t) 40)))))))

(defn ^:private start-prompt-worker!
[{:keys [db* config chat-id]} thunk]
(logger/with-chat-context chat-id (get-in @db* [:chats chat-id :parent-chat-id])
(let [managed? (contains? (:subagent-runs @db*) chat-id)
started? (volatile! false)]
;; Count before dispatch; idle may be published before nested workers start.
;; Release only after the prompt's entire cleanup has unwound.
(when managed?
(swap! db* update-in [:subagent-runs chat-id :workers] inc))
(try
(future* config
(vreset! started? true)
(try
(thunk)
(catch Throwable e
(when managed?
(swap! db* assoc-in [:subagent-runs chat-id :interrupted?] true))
(throw e))
(finally
(when managed?
(swap! db* update-in [:subagent-runs chat-id :workers] dec)))))
(catch Throwable e
(when (and managed? (not @started?))
(swap! db* update-in [:subagent-runs chat-id]
#(-> % (update :workers dec) (assoc :interrupted? true))))
(throw e))))))

(defn ^:private prompt-messages!
"Send user messages to LLM with hook processing.
source-type controls hook agent.
Expand Down Expand Up @@ -1154,7 +1187,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 @@ -1732,6 +1766,8 @@
(db/save-chat! @db* chat-id metrics)
(lifecycle/finish-chat-prompt! :idle (lifecycle/strip-hook-callbacks chat-ctx))))))))})
(catch Exception e
(when (contains? (:subagent-runs @db*) chat-id)
(swap! db* assoc-in [:subagent-runs chat-id :interrupted?] true))
(when-not (:silent? (ex-data e))
(logger/error e)
(swap! db* assoc-in [:chats chat-id :prompt-error]
Expand All @@ -1748,13 +1784,16 @@
(finally
(when (and (= prompt-id (get-in @db* [:chats chat-id :prompt-id]))
(contains? #{:stopping :running} (get-in @db* [:chats chat-id :status])))
(when (and (contains? (:subagent-runs @db*) chat-id)
(not (get-in @db* [:chats chat-id :prompt-finished?])))
(swap! db* assoc-in [:subagent-runs chat-id :interrupted?] true))
(swap! db* assoc-in [:chats chat-id :status] :idle)
;; 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?])
(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 @@ -2085,6 +2124,10 @@
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
_ (when-let [run (get-in @db* [:subagent-runs chat-id])]
(when-not (and (:token run)
(identical? (:token run) (:subagent-token params)))
(throw (ex-info "Managed subagents must be prompted through spawn_agent." {}))))
invalid-id-reason (when (and (some? provided-chat-id)
(not (server-managed-subagent-chat-id? @db* provided-chat-id)))
(validate-client-chat-id provided-chat-id))]
Expand Down Expand Up @@ -2437,7 +2480,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? (:subagent-runs db) chat-id)
(assoc-in [:subagent-runs chat-id :interrupted?] true))))
(let [chat-ctx {:chat-id chat-id
:db* db*
:config config
Expand Down
21 changes: 12 additions & 9 deletions src/eca/features/chat/tool_calls.clj
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@

[: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]}

[:cleanup :cleanup-finished]
{:status :completed
Expand All @@ -297,7 +297,7 @@

[: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]}

;; And now all the :stop-requested transitions

Expand Down Expand Up @@ -600,11 +600,14 @@
;; 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))

(lifecycle/trigger-chat-status-hook! (assoc chat-ctx :db* db*))
(try
;; Hooks may still update history; cancelled futures must join this work too.
(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
(when (#{:execution-end :stop-attempted} event)
(execute-action! :deliver-future-cleanup-completed db* chat-ctx tool-call-id event-data))))

{:status status :actions actions}))

Expand Down Expand Up @@ -882,7 +885,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 @@ -972,7 +975,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 @@ -1003,6 +1005,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