From 4583eb1744931d80e47d386c833692817669f96a Mon Sep 17 00:00:00 2001 From: Juha Itkonen Date: Sun, 20 Sep 2026 15:17:39 +0300 Subject: [PATCH 1/4] Allow subagents to resume retained conversations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let main agents reuse a child's accumulated context through spawn_agent's optional chat_id instead of recreating the conversation for every task. Preserve the original model settings and enforce same-parent ownership, session-local authorization, and non-overlapping invocations. Allow cancelled and provider-failed runs to continue after their work has settled, and wait for dispatched tools and cleanup hooks before reuse. Cover retained history, validation, retry, and cancellation races with regression tests and document the continuation interface. 🤖 Generated with [ECA](https://eca.dev) (openai/gpt-6-astra - xhigh) Co-Authored-By: eca-agent --- CHANGELOG.md | 1 + docs/config/agents.md | 2 + .../integration/chat/subagent_test.clj | 4 +- resources/prompts/tools/spawn_agent.md | 5 +- src/eca/features/chat.clj | 61 +- src/eca/features/chat/tool_calls.clj | 21 +- src/eca/features/tools/agent.clj | 191 ++++-- src/eca/features/tools/util.clj | 5 +- test/eca/features/chat/tool_calls_test.clj | 21 + .../features/chat_tool_call_state_test.clj | 6 +- test/eca/features/tools/agent_test.clj | 574 +++++++++++++++++- test/eca/features/tools/util_test.clj | 7 + 12 files changed, 788 insertions(+), 110 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c9b8a1bb..504866063 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 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. - Expand `@file` references in custom-command and parameterized-skill arguments into file content before sending the prompt. #163 - Fix steered native commands (e.g. `/sync-system-prompt`) being sent to the LLM as text while the chat runs: run them at the next turn boundary or refuse chat-changing ones. #610 diff --git a/docs/config/agents.md b/docs/config/agents.md index 7f360d419..66af6c0e4 100644 --- a/docs/config/agents.md +++ b/docs/config/agents.md @@ -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. diff --git a/integration-test/integration/chat/subagent_test.clj b/integration-test/integration/chat/subagent_test.clj index b301e78e6..1af33640c 100644 --- a/integration-test/integration/chat/subagent_test.clj +++ b/integration-test/integration/chat/subagent_test.clj @@ -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] diff --git a/resources/prompts/tools/spawn_agent.md b/resources/prompts/tools/spawn_agent.md index 8517d9aef..1c2146470 100644 --- a/resources/prompts/tools/spawn_agent.md +++ b/resources/prompts/tools/spawn_agent.md @@ -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. @@ -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. diff --git a/src/eca/features/chat.clj b/src/eca/features/chat.clj index 8e33c0d79..a4ab41ca1 100644 --- a/src/eca/features/chat.clj +++ b/src/eca/features/chat.clj @@ -407,11 +407,18 @@ (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 (string? subagent-chat-id) + (not (string/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 @@ -982,6 +989,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. @@ -1154,7 +1188,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 @@ -1709,6 +1744,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] @@ -1725,13 +1762,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} @@ -2062,6 +2102,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))] @@ -2414,7 +2458,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 diff --git a/src/eca/features/chat/tool_calls.clj b/src/eca/features/chat/tool_calls.clj index d931e45f2..481e4bfb9 100644 --- a/src/eca/features/chat/tool_calls.clj +++ b/src/eca/features/chat/tool_calls.clj @@ -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 @@ -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 @@ -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})) @@ -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 @@ -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) @@ -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. diff --git a/src/eca/features/tools/agent.clj b/src/eca/features/tools/agent.clj index a8e5a63a8..10bedd9f5 100644 --- a/src/eca/features/tools/agent.clj +++ b/src/eca/features/tools/agent.clj @@ -1,5 +1,5 @@ (ns eca.features.tools.agent - "Tool for spawning subagents to perform focused tasks in isolated context." + "Tool for spawning or continuing subagents to perform focused tasks in isolated context." (:require [clojure.string :as str] [eca.config :as config] @@ -73,7 +73,7 @@ [error-type] (when error-type (if (contains? llm-providers.errors/retryable-error-types error-type) - "This is a transient provider error. Prefer spawning this agent again for the same task (optionally with a different `model`) instead of performing the task yourself." + "This is a transient provider error. Continue this agent using the returned `chat_id` and the same agent, without model or variant overrides, instead of performing the task yourself." "Retrying this agent the same way is unlikely to help. Consider spawning it again with a different `model` or handling the task yourself."))) (defn ^:private failed-agent-result [agent-name prompt-error partial-output] @@ -158,9 +158,28 @@ variants (config/effective-model-variants config provider model model-capabilities user-variants)] (config/selectable-variant-names variants)))))) +(defn ^:private resumable-run + [db id parent-id agent-name config trust] + (let [run (get-in db [:subagent-runs id]) + child (get-in db [:chats id])] + (when-not (and run (:subagent child) + (= parent-id (:parent-chat-id child) (:parent-chat-id run)) + (= agent-name (:agent-name child) (:agent-name run))) + (throw (ex-info "chat_id must name a live subagent owned by this parent with the same agent." {}))) + (when (or (:token run) + (pos? (:workers run 0)) + (some #(or (:future %) (seq (:resources %))) (vals (:tool-calls child))) + (not (#{:idle :error} (:status child)))) + (throw (ex-info "Subagent is busy or unsettled and cannot be resumed." {}))) + (when-not (and (= (:config-hash run) (hash config)) + (= (:workspace-folders run) (:workspace-folders db)) + (= (:trust run) trust (:trust child))) + (throw (ex-info "Subagent config, workspace, or trust changed; spawn a new agent." {}))) + run)) + (defn ^:private spawn-agent "Handler for the spawn_agent tool. - Spawns a subagent to perform a focused task and returns the result." + Runs a focused task in a new or existing subagent conversation and returns the result." [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") @@ -185,8 +204,13 @@ {:agent-name agent-name :available (map :name available)})))) - ;; Create subagent chat session using deterministic id based on tool-call-id - subagent-chat-id (->subagent-chat-id tool-call-id) + resume? (contains? arguments "chat_id") + subagent-chat-id (if resume? (get arguments "chat_id") (->subagent-chat-id tool-call-id)) + _ (when (and resume? (or (not (string? subagent-chat-id)) (str/blank? subagent-chat-id))) + (throw (ex-info "chat_id must be a nonblank string." {}))) + _ (when (and resume? (some #(contains? arguments %) ["model" "variant"])) + (throw (ex-info "model and variant overrides are not allowed when resuming." {}))) + run (when resume? (resumable-run db subagent-chat-id chat-id agent-name config trust)) user-model (get arguments "model") _ (when user-model @@ -203,11 +227,13 @@ parent-provider (some-> parent-model shared/full-model->provider+model first) ;; The agent's :defaultModel may be a bare alias resolved against the ;; currently selected (parent) provider; keep it verbatim if it doesn't resolve. - subagent-model (or user-model - (when-let [agent-model (:model subagent)] - (or (models/full-model-for db parent-provider agent-model) - agent-model)) - parent-model) + subagent-model (if resume? + (:model run) + (or user-model + (when-let [agent-model (:model subagent)] + (or (models/full-model-for db parent-provider agent-model) + agent-model)) + parent-model)) ;; Variant validation: reject only when the resolved model has configured ;; variants and the user-specified one isn't among them. Models with no @@ -222,42 +248,67 @@ {:variant user-variant :model subagent-model :available valid-variants}))))) - variant (or user-variant (:variant subagent))] - - (logger/info logger-tag (format "Spawning agent '%s' for task: %s (model: %s, variant: %s)" agent-name task subagent-model (or variant "default"))) - - (let [max-steps-limit (max-steps subagent)] - (swap! db* assoc-in [:chats subagent-chat-id] - (cond-> {:id subagent-chat-id - :parent-chat-id chat-id - :agent-name agent-name - :subagent subagent - :current-step 0} - max-steps-limit (assoc :max-steps max-steps-limit))) - - (try + variant (if resume? (:variant run) (or user-variant (:variant subagent))) + token (Object.) + [before _] (swap-vals! + db* + (fn [db] + (if resume? + (do (resumable-run db subagent-chat-id chat-id agent-name config trust) + (-> db + (update-in [:subagent-runs subagent-chat-id] + #(-> % (assoc :token token) (dissoc :interrupted?))) + (update-in [:chats subagent-chat-id] + #(-> % + (dissoc :max-steps-reached? :prompt-error :prompt-finished? :follow-up-active?) + (assoc :current-step 0))))) + (do + (when (or (contains? (:chats db) subagent-chat-id) + (contains? (:subagent-runs db) subagent-chat-id)) + (throw (ex-info "Subagent chat ID already exists." {}))) + (-> db + (assoc-in [:subagent-runs subagent-chat-id] + {:parent-chat-id chat-id :agent-name agent-name + :model subagent-model :variant variant :trust trust + :config-hash (hash config) :workspace-folders (:workspace-folders db) + :token token :workers 0}) + (assoc-in [:chats subagent-chat-id] + (cond-> {:id subagent-chat-id :parent-chat-id chat-id + :agent-name agent-name :subagent subagent + :trust trust :current-step 0} + (max-steps subagent) (assoc :max-steps (max-steps subagent))))))))) + starting-message-count (count (get-in before [:chats subagent-chat-id :messages])) + max-steps-limit (get-in @db* [:chats subagent-chat-id :max-steps])] + (logger/with-chat-context chat-id (get-in db [:chats chat-id :parent-chat-id]) + (update-in + (try + (logger/info logger-tag (format "Running agent '%s' for task: %s (model: %s, variant: %s)" agent-name task subagent-model (or variant "default"))) ;; Require chat ns here to avoid circular dependency (let [chat-prompt (requiring-resolve 'eca.features.chat/prompt) task-prompt (if max-steps-limit (format "%s\n\nIMPORTANT: You have a maximum of %d steps to complete this task. Be efficient and provide a clear summary of your findings before reaching the limit." task max-steps-limit) - task)] - (chat-prompt - (cond-> {:message task-prompt - :chat-id subagent-chat-id - :model subagent-model - :agent agent-name - :contexts [] - :trust trust} - variant (assoc :variant variant)) - db* - messenger - config - metrics)) + task) + prompt-result (chat-prompt + (cond-> {:message task-prompt + :subagent-token token + :chat-id subagent-chat-id + :model subagent-model + :agent agent-name + :contexts [] + :trust trust} + variant (assoc :variant variant)) + db* messenger config metrics)] + (when (= :error (:status prompt-result)) + (swap! db* assoc-in [:subagent-runs subagent-chat-id :interrupted?] true) + (swap! db* update-in [:chats subagent-chat-id] + #(assoc % :status :error :prompt-error + (or (:prompt-error %) {:message "Subagent prompt setup failed."}))))) ;; Wait for subagent to complete by polling status (let [stopped-result (fn [] (logger/info logger-tag (format "Agent '%s' stopped by parent chat" agent-name)) + (swap! db* assoc-in [:subagent-runs subagent-chat-id :interrupted?] true) (stop-subagent-chat! db* messenger config metrics subagent-chat-id agent-name) {:error true :contents [{:type :text @@ -270,19 +321,23 @@ ;; Send step progress when step advances (when (> current-step last-step) (send-step-progress! messenger chat-id tool-call-id agent-name activity - subagent-chat-id current-step max-steps-limit subagent-model variant arguments)) + subagent-chat-id current-step max-steps-limit + (get-in db [:chats subagent-chat-id :model] subagent-model) + (get-in db [:chats subagent-chat-id :variant] variant) arguments)) (cond ;; Parent chat stopped — propagate stop to subagent (= :stopping (:status (call-state-fn))) (stopped-result) ;; Subagent completed - (#{:idle :error} status) - (let [messages (get-in db [:chats subagent-chat-id :messages] []) + (and (#{:idle :error} status) + (zero? (get-in db [:subagent-runs subagent-chat-id :workers] 0))) + (let [messages (drop starting-message-count (get-in db [:chats subagent-chat-id :messages] [])) summary (extract-final-summary messages) partial-output (extract-final-assistant-text messages) prompt-error (get-in db [:chats subagent-chat-id :prompt-error]) - failed? (boolean (or (= :error status) prompt-error)) + failed? (boolean (or (= :error status) prompt-error + (get-in db [:subagent-runs subagent-chat-id :interrupted?]))) max-steps-reached? (get-in db [:chats subagent-chat-id :max-steps-reached?])] (cond max-steps-reached? @@ -294,6 +349,9 @@ :else (logger/info logger-tag (format "Agent '%s' completed after %d steps" agent-name current-step))) + (swap! db* update-in [:subagent-runs subagent-chat-id] + #(cond-> (merge % (select-keys (get-in db [:chats subagent-chat-id]) [:model :variant])) + failed? (assoc :interrupted? true))) (swap! db* assoc-in [:chats chat-id :tool-calls tool-call-id :subagent-final-step] current-step) (cond max-steps-reached? @@ -318,7 +376,14 @@ (catch InterruptedException _ (stopped-result)))) (catch Exception e - (throw e)))))) + (swap! db* assoc-in [:subagent-runs subagent-chat-id :interrupted?] true) + (when (or (instance? InterruptedException e) + (= :stopping (:status (call-state-fn)))) + (stop-subagent-chat! db* messenger config metrics subagent-chat-id agent-name)) + (failed-agent-result agent-name {:message (ex-message e)} nil)) + (finally + (swap! db* update-in [:subagent-runs subagent-chat-id] dissoc :token))) + [:contents 0 :text] #(str "Subagent chat_id: " subagent-chat-id "\n\n" %))))) (defn ^:private build-description "Build tool description with available agents and models listed." @@ -340,11 +405,14 @@ {:description (build-description config parent-agent-name) :parameters {:type "object" :properties {"agent" {:type "string" - :description "Name of the agent to spawn"} + :description "Name of the agent to spawn or continue"} "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\"."} + :description "Optional concise label (max 3-4 words) shown in the UI while the agent runs, e.g. \"exploring codebase\", \"reviewing changes\", \"analyzing tests\"."} + "chat_id" {:type "string" + :minLength 1 + :description "Resume this live same-parent subagent conversation; repeat its agent and omit model/variant overrides."} "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" @@ -367,20 +435,37 @@ subagent (when agent-name (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) - variant (or user-variant (:variant subagent)) - subagent-chat-id (when tool-call-id - (->subagent-chat-id tool-call-id))] + resume? (contains? arguments "chat_id") + subagent-chat-id (if resume? (get arguments "chat_id") + (when tool-call-id (->subagent-chat-id tool-call-id))) + child (get-in db [:chats subagent-chat-id]) + owned? (and (string? subagent-chat-id) (not (str/blank? subagent-chat-id)) + subagent (:subagent child) + (get-in db [:subagent-runs subagent-chat-id]) + (= chat-id (:parent-chat-id child)) + (= agent-name (:agent-name child))) + child (when owned? child) + subagent-model (if resume? (:model child) + (or user-model + (when-let [model (:model subagent)] + (or (models/full-model-for db (some-> parent-model shared/full-model->provider+model first) model) + model)) + parent-model)) + variant (if resume? (:variant child) (or user-variant (:variant subagent)))] (cond-> {:type :subagent - :subagent-chat-id subagent-chat-id + :subagent-chat-id (when (or (not resume?) owned?) subagent-chat-id) :model subagent-model :agent-name agent-name - :step (get-in db [:chats subagent-chat-id :current-step] 1) - :max-steps (max-steps subagent)} + :step (get child :current-step 1) + :max-steps (if resume? (:max-steps child) (max-steps subagent))} variant (assoc :variant variant)))) (defmethod tools.util/tool-call-details-after-invocation :spawn_agent [_name _arguments before-details _result {:keys [db chat-id tool-call-id]}] (let [final-step (get-in db [:chats chat-id :tool-calls tool-call-id :subagent-final-step] - (or (:step before-details) 1))] - (assoc before-details :step final-step))) + (or (:step before-details) 1)) + child (get-in db [:chats (:subagent-chat-id before-details)])] + (cond-> (assoc before-details :step final-step) + (and (= chat-id (:parent-chat-id child)) + (get-in db [:chats chat-id :tool-calls tool-call-id :subagent-final-step])) + (merge (select-keys child [:model :variant :max-steps]))))) diff --git a/src/eca/features/tools/util.clj b/src/eca/features/tools/util.clj index bf3000a08..e33a7e7f2 100644 --- a/src/eca/features/tools/util.clj +++ b/src/eca/features/tools/util.clj @@ -173,7 +173,7 @@ (defn omit-optional-empty-string-args "Drops optional tool arguments whose value is the empty string. - Required arguments are preserved exactly as provided." + Required arguments and arguments with positive minLength are preserved for validation." [parameters args] (let [required (->> (:required parameters) (map name) @@ -181,7 +181,8 @@ (into {} (remove (fn [[k v]] (and (= "" v) - (not (contains? required (name k)))))) + (not (contains? required (name k))) + (not (pos? (get-in parameters [:properties (name k) :minLength] 0)))))) args))) (defn contents->text diff --git a/test/eca/features/chat/tool_calls_test.clj b/test/eca/features/chat/tool_calls_test.clj index 5aee8fbc7..fb4f6f0c1 100644 --- a/test/eca/features/chat/tool_calls_test.clj +++ b/test/eca/features/chat/tool_calls_test.clj @@ -731,6 +731,27 @@ :post-tool-call-stop-hook-name "guard"} (get-in @db* [:chats "chat-1" :tool-calls "tool-1"])))))) +(deftest cleanup-signal-follows-hooks-test + (doseq [[status event] [[:executing :execution-end] [:stopping :stop-attempted]] + failure [nil :action :status]] + (let [done (promise) + db* (atom {:chats {"child" {:tool-calls {"tool" {:status status + :future-cleanup-complete?* done}}}}}) + execute @#'tc/execute-action!] + (with-redefs [tc/execute-action! + (fn [action & args] + (if (= :deliver-future-cleanup-completed action) + (apply execute action args) + (do (is (not (realized? done)) "Actions must finish before the join is released") + (when (= :action failure) (throw (ex-info "action failed" {})))))) + lifecycle/trigger-chat-status-hook! + (fn [_] + (is (not (realized? done)) "Status hook is part of tool-side work") + (when (= :status failure) (throw (ex-info "status failed" {}))))] + (try (tc/transition-tool-call! db* {:chat-id "child"} "tool" event {}) + (catch clojure.lang.ExceptionInfo e (is failure (ex-message e)))) + (is (realized? done) "Exceptions must also release the join"))))) + (deftest rejected-tool-call-output-contents-test (testing "states the call did not run and made no changes (#507)" (let [text (-> (#'tc/rejected-tool-call-output-contents "Tool call rejected by user choice") diff --git a/test/eca/features/chat_tool_call_state_test.clj b/test/eca/features/chat_tool_call_state_test.clj index 5e21da890..1dae0b3f4 100644 --- a/test/eca/features/chat_tool_call_state_test.clj +++ b/test/eca/features/chat_tool_call_state_test.clj @@ -475,7 +475,7 @@ result (#'tc/transition-tool-call! db* chat-ctx tool-call-id :execution-end result-data)] (is (match? {: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]} result) "Expected transition to :cleanup with send toolCalled and record metrics actions") @@ -546,7 +546,7 @@ "Expected transition from :executing to :stopping with relevant actions")) (let [result (#'tc/transition-tool-call! db* chat-ctx "tool-executing" :stop-attempted)] (is (match? {: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]} result) "Expected transition from :stopping to :cleanup with relevant actions")))) @@ -1025,7 +1025,7 @@ result (#'tc/transition-tool-call! db* chat-ctx tool-call-id :execution-end error-result)] (is (match? {: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]} result) "Expected transition to :cleanup with send toolCalled and record metrics actions") diff --git a/test/eca/features/tools/agent_test.clj b/test/eca/features/tools/agent_test.clj index ed9dbf9c4..986bf5c7b 100644 --- a/test/eca/features/tools/agent_test.clj +++ b/test/eca/features/tools/agent_test.clj @@ -2,8 +2,13 @@ (:require [clojure.string :as string] [clojure.test :refer [deftest is testing]] + [eca.cache :as cache] [eca.config :as config] + [eca.db :as db] [eca.features.chat :as f.chat] + [eca.features.chat.lifecycle :as lifecycle] + [eca.features.chat.tool-calls :as tool-calls] + [eca.features.hooks :as hooks] [eca.features.tools :as f.tools] [eca.features.tools.agent :as f.tools.agent] [eca.features.tools.util :as tools.util] @@ -349,7 +354,7 @@ :call-state-fn (constantly {:status :executing})})] (is (match? {:error true :contents [{:type :text - :text #"(?s)Failed.*rate limit.*Error type: rate-limited.*Status: 429.*Code: rate_limit_error.*Rate limit resets at: 2025-08-26T10:40:00Z.*transient provider error\. Prefer spawning this agent again"}]} + :text #"(?s)Failed.*rate limit.*Error type: rate-limited.*Status: 429.*Code: rate_limit_error.*Rate limit resets at: 2025-08-26T10:40:00Z.*transient provider error\. Continue this agent using the returned `chat_id`"}]} result)))))) (testing "non-retryable error advises against retrying the same way" @@ -519,9 +524,32 @@ (is (match? {:error true :contents [{:type :text :text #"was stopped"}]} result)) + (is (true? (get-in @db* [:subagent-runs subagent-chat-id :interrupted?]))) + (with-redefs [requiring-resolve (fn [sym] + (if (= sym 'eca.features.chat/prompt) + (fn [& _] (swap! db* assoc-in [:chats subagent-chat-id :status] :idle)) + (clojure.lang.RT/var (namespace sym) (name sym))))] + (is (false? (:error + ((spawn-handler) {"agent" "explorer" "task" "resume" "chat_id" subagent-chat-id} + {:db* db* :config test-config :chat-id "chat-1" :tool-call-id "resume" + :call-state-fn (constantly {:status :executing})}))))) (testing "preserves subagent chat for resume replay" (is (some? (get-in @db* [:chats subagent-chat-id]))))))))) +(deftest spawn-agent-setup-cancellation-test + (doseq [failure [(InterruptedException.) (ex-info "setup cancelled" {})]] + (let [db* (atom {:chats {"parent" {:model "test/model"}}}) + stopped* (atom false)] + (with-redefs [f.chat/prompt (fn [& _] + (swap! db* assoc-in [:chats "subagent-setup" :status] :running) + (throw failure)) + f.chat/prompt-stop (fn [& _] (reset! stopped* true))] + (is (true? (:error ((spawn-handler) {"agent" "explorer" "task" "work"} + {:db* db* :config test-config :chat-id "parent" :tool-call-id "setup" + :call-state-fn (constantly {:status (if (instance? InterruptedException failure) + :executing :stopping)})})))) + (is @stopped* "Cancellation during synchronous setup must stop the dispatched child"))))) + (deftest spawn-agent-cleanup-on-exception-test (testing "preserves subagent state when chat/prompt throws" (let [db* (atom {:chats {"chat-1" {:id "chat-1" :model "test/model"}}}) @@ -533,7 +561,7 @@ (fn [_params _db* _messenger _config _metrics] (throw (ex-info "LLM provider error" {}))) (clojure.lang.RT/var (namespace sym) (name sym))))] - (is (thrown? Exception + (is (match? {:error true :contents [{:text #"(?s)^Subagent chat_id: subagent-tc-1\n\n.*Failed.*LLM provider error"}]} ((spawn-handler) {"agent" "explorer" "task" "explore" "activity" "exploring"} {:db* db* @@ -952,10 +980,13 @@ :properties {"agent" {:type "string"} "task" {:type "string"} "activity" {:type "string"} + "chat_id" {:type "string" :minLength 1} "model" {:type "string"} "variant" {:type "string"}} :required ["agent" "task"]} - (:parameters tool))))) + (:parameters tool))) + (is (= ["agent" "task" "activity" "chat_id" "model" "variant"] + (vec (keys (get-in tool [:parameters :properties]))))))) (testing "model and variant enums are absent when no models in db" (let [defs (f.tools.agent/definitions test-config {}) @@ -988,14 +1019,477 @@ (is (= "Spawning agent" (summary-fn {:args {}})))))) +(deftest resume-agent-test + (let [db* (atom (assoc test-db :chats {"parent" {:model "openai/gpt-4.1"}})) + calls* (atom []) + context {:db* db* :config test-config :chat-id "parent" :tool-call-id "resume" + :messenger (h/messenger) :metrics (h/metrics) + :call-state-fn (constantly {:status :executing})} + handler (spawn-handler)] + (with-redefs [f.chat/prompt + (fn [params & _] + (swap! calls* conj params) + (swap! db* update-in [:chats (:chat-id params)] + #(-> % (assoc :status :idle :current-step 3 :prompt-cache {:kept true}) + (update :messages (fnil conj []) + {:role "assistant" :content [{:type :text :text (:message params)}]}))))] + (handler {"agent" "explorer" "task" "first"} context) + (let [history (get-in @db* [:chats "subagent-resume" :messages])] + (swap! db* assoc-in [:chats "parent" :model] "anthropic/claude-opus-4-6") + (let [result (handler {"agent" "explorer" "task" "second" "chat_id" "subagent-resume"} + (assoc context :tool-call-id "second"))] + (is (string/includes? (tools.util/contents->text (:contents result)) "subagent-resume")) + (is (= "openai/gpt-4.1" (:model (last @calls*)))) + (is (= history (take (count history) (get-in @db* [:chats "subagent-resume" :messages])))) + (is (= {:kept true} (get-in @db* [:chats "subagent-resume" :prompt-cache]))))) + (doseq [selector [nil "" " " 42 [] "missing" "parent"]] + (let [before @db* calls @calls*] + (is (thrown? clojure.lang.ExceptionInfo + (handler {"agent" "explorer" "task" "invalid" "chat_id" selector} + (assoc context :tool-call-id "invalid")))) + (is (= before @db*)) + (is (= calls @calls*)))) + (testing "changed caller-supplied trust rejects resume" + (let [before @db*] + (is (thrown? clojure.lang.ExceptionInfo + (handler {"agent" "explorer" "task" "next" "chat_id" "subagent-resume"} + (assoc context :trust true)))) + (is (= before @db*))))))) + +(deftest resume-admission-and-run-isolation-test + (let [db* (atom {:chats {"parent" {:model "openai/gpt-4.1"}}}) + context {:db* db* :config test-config :messenger (h/messenger) :metrics (h/metrics) + :chat-id "parent" :tool-call-id "isolation" :trust true + :call-state-fn (constantly {:status :executing})} + handler (spawn-handler) + args {"agent" "explorer" "task" "next" "chat_id" "subagent-isolation"}] + (with-redefs [f.chat/prompt (fn [{:keys [chat-id]} & _] + (swap! db* update-in [:chats chat-id] + #(assoc % :status :idle :current-step 5 :max-steps-reached? true + :messages [{:role "assistant" :content [{:type :text :text "old answer"}]}])))] + (handler {"agent" "explorer" "task" "first" "variant" "high"} context)) + (let [baseline @db*] + (doseq [[label change changed-args changed-context] + [["foreign parent" identity args (assoc context :chat-id "foreign")] + ["different agent" identity (assoc args "agent" "general") context] + ["authorization" identity args (assoc-in context [:config :agent "explorer" :spawnableBy] "other")] + ["model override" identity (assoc args "model" "openai/gpt-4.1") context] + ["variant override" identity (assoc args "variant" "") context] + ["config drift" identity args (assoc-in context [:config :changed] true)] + ["workspace drift" #(assoc % :workspace-folders [{:uri (h/file-uri "/other")}]) args context] + ["trust drift" identity args (assoc context :trust false)] + ["child trust drift" #(assoc-in % [:chats "subagent-isolation" :trust] false) args context] + ["ordinary child" #(update-in % [:chats "subagent-isolation"] dissoc :subagent) args context] + ["tool future" #(assoc-in % [:chats "subagent-isolation" :tool-calls "old" :future] (delay nil)) args context] + ["tool resources" #(assoc-in % [:chats "subagent-isolation" :tool-calls "old" :resources] {:process :remaining}) args context] + ["outstanding worker" #(assoc-in % [:subagent-runs "subagent-isolation" :workers] 1) args context] + ["running" #(assoc-in % [:chats "subagent-isolation" :status] :running) args context] + ["stopping" #(assoc-in % [:chats "subagent-isolation" :status] :stopping) args context]]] + (testing label + (reset! db* (change baseline)) + (let [before @db*] + (with-redefs [f.chat/prompt (fn [& _] (is false "Rejected resume must not prompt"))] + (is (thrown? clojure.lang.ExceptionInfo (handler changed-args changed-context))) + (is (= before @db*)))))) + (reset! db* baseline) + (testing "failed resource destruction retains the future and blocks admission" + (swap! db* assoc-in [:chats "subagent-isolation" :tool-calls "old"] + {:status :cleanup :future (delay nil) :resources {:process :remaining}}) + (with-redefs [f.tools/tool-call-destroy-resource! (fn [& _] (throw (ex-info "cleanup failed" {})))] + (is (thrown? clojure.lang.ExceptionInfo + (tool-calls/transition-tool-call! db* {:chat-id "subagent-isolation"} + "old" :cleanup-finished {})))) + (is (some? (get-in @db* [:chats "subagent-isolation" :tool-calls "old" :future]))) + (is (thrown? clojure.lang.ExceptionInfo (handler args context)))) + (reset! db* baseline) + (testing "before details never disclose a foreign child's settings" + (is (match? {:model nil :max-steps nil :step 1} + (tools.util/tool-call-details-before-invocation + :spawn_agent args nil {:db @db* :config test-config :chat-id "foreign" :tool-call-id "x"})))) + (testing "settled interruption resets its outcome and ignores unstarted cleanup promises" + (swap! db* assoc-in [:subagent-runs "subagent-isolation" :interrupted?] true) + (swap! db* assoc-in [:chats "subagent-isolation" :tool-calls "rejected"] + {:status :rejected :future-cleanup-complete?* (promise)}) + (with-redefs [f.chat/prompt (fn [params & _] + (is (= "high" (:variant params))) + (is (= 0 (get-in @db* [:chats "subagent-isolation" :current-step]))) + (is (nil? (get-in @db* [:chats "subagent-isolation" :max-steps-reached?]))) + (is (nil? (get-in @db* [:chats "subagent-isolation" :prompt-error]))) + (swap! db* assoc-in [:chats "subagent-isolation" :status] :idle))] + (let [result (handler args (assoc context :tool-call-id "empty"))] + (is (false? (:error result))) + (is (not (string/includes? (tools.util/contents->text (:contents result)) "old answer")))))) + (testing "immediate prompt errors finish without polling and allow settled reuse" + (with-redefs [f.chat/prompt (constantly {:status :error})] + (let [run (future (handler args (assoc context :tool-call-id "error")))] + (try + (is (match? {:error true :contents [{:text #"(?s)^Subagent chat_id: subagent-isolation\n\n.*Failed.*setup failed"}]} + (deref run 5000 ::timeout))) + (is (true? (:error (handler args context)))) + (finally (when-not (realized? run) (future-cancel run)))))))))) + +(deftest managed-subagent-worker-unwind-test + (doseq [failure [:silent-stop :finally-error]] + (testing (name failure) + (h/reset-components!) + (h/config! {:env "test" :agent {"explorer" {:mode "subagent" :description "Explorer"}}}) + (swap! (h/db*) assoc-in [:chats "parent"] {:model "openai/gpt-5.2"}) + (let [worker-entered?* (atom false) + context {:db* (h/db*) :config (h/config) :messenger (h/messenger) :metrics (h/metrics) + :chat-id "parent" :tool-call-id "unwind" :call-state-fn (constantly {:status :executing})} + handler (spawn-handler)] + (with-redefs [llm-api/sync-prompt! (constantly nil) + f.tools/all-tools (constantly []) + config/await-plugins-resolved! (constantly true) + db/save-chat! (fn [& _] + (when (and @worker-entered?* (= :finally-error failure)) + (throw (ex-info "save failure" {})))) + llm-api/sync-or-async-prompt! (fn [_] + (reset! worker-entered?* true) + (when (= :silent-stop failure) + (throw (ex-info "stopped" {:silent? true}))))] + (let [result (handler {"agent" "explorer" "task" "work"} context)] + (is @worker-entered?*) + (is (true? (:error result))) + (is (string/includes? (tools.util/contents->text (:contents result)) "subagent-unwind")) + (is (= 0 (get-in @(h/db*) [:subagent-runs "subagent-unwind" :workers]))) + (is (true? (get-in @(h/db*) [:subagent-runs "subagent-unwind" :interrupted?]))) + (is (nil? (get-in @(h/db*) [:subagent-runs "subagent-unwind" :token]))) + (is (true? (:error + (handler {"agent" "explorer" "task" "resume" "chat_id" "subagent-unwind"} context)))))))))) + +(deftest spawn-agent-replay-authorization-test + (doseq [[id child allowed?] + [["owned" {:subagent {:name "explorer"} :parent-chat-id "parent" :agent-name "explorer"} true] + ["foreign" {:subagent {:name "explorer"} :parent-chat-id "other" :agent-name "explorer"} false] + ["ordinary" {:parent-chat-id "parent" :agent-name "explorer"} false] + ["wrong-agent" {:subagent {:name "general"} :parent-chat-id "parent" :agent-name "general"} false] + [42 {:subagent {:name "explorer"} :parent-chat-id "parent" :agent-name "explorer"} false] + ["" {:subagent {:name "explorer"} :parent-chat-id "parent" :agent-name "explorer"} false] + [nil {} false]]] + (testing (str "reference " (pr-str id)) + (let [db {:chats {"parent" {:agent "code"} + id (assoc child :messages [{:role "assistant" :content [{:type :text :text "Child transcript"}]}])} + :subagent-runs {id {:parent-chat-id (:parent-chat-id child) :agent-name (:agent-name child)}}} + details (tools.util/tool-call-details-before-invocation + :spawn_agent {"agent" "explorer" "chat_id" id} nil + {:db db :config test-config :chat-id "parent" :tool-call-id "rejected"}) + replay (fn [details] + (f.chat/messages->contents + [{:role "tool_call_output" + :content {:id "rejected" :name "spawn_agent" :error (not allowed?) + :details details :output {:contents [{:type :text :text "Tool result"}]}}}] + {:chat-id "parent" :db (dissoc db :subagent-runs)}))] + (is (= (when allowed? id) (:subagent-chat-id details))) + (doseq [stored [details {:type :subagent :agent-name "explorer" :subagent-chat-id id}]] + (is (= allowed? (boolean (some #(= "\nChild transcript" (get-in % [:content :text])) + (replay stored)))))))))) + +(deftest spawn-agent-truncated-id-test + (doseq [failed? [false true]] + (testing (if failed? "failure with partial output" "success") + (h/reset-components!) + (h/config! {:agent {"explorer" {:mode "subagent" :description "Explorer"}} + :toolCall {:outputTruncation {:lines 100 :sizeKb 1}}}) + (let [id "subagent-truncated" + saved* (atom nil)] + (with-redefs [cache/save-tool-call-output! (fn [_ text] (reset! saved* text) "/unused/output.txt") + f.chat/prompt (fn [& _] + (swap! (h/db*) update-in [:chats id] + #(cond-> (assoc % :status :idle + :messages [{:role "assistant" + :content [{:type :text :text (apply str (repeat 5000 "x"))}]}]) + failed? (assoc :prompt-error {:message "Provider failed"}))))] + (let [result (f.tools/call-tool! "eca__spawn_agent" {"agent" "explorer" "task" "work"} + "parent" "truncated" "code" (h/db*) (h/config) (h/messenger) (h/metrics) + (constantly {:status :executing}) (fn [& _]) {}) + text (tools.util/contents->text (:contents result))] + (is (= failed? (:error result))) + (is (string/starts-with? text (str "Subagent chat_id: " id "\n\n"))) + (is (string/includes? text (if failed? "## Agent 'explorer' Failed" "## Agent 'explorer' Result"))) + (when failed? + (is (string/includes? text "Provider failed")) + (is (string/includes? text "## Partial result"))) + (is (string/includes? text "[OUTPUT TRUNCATED]")) + (is (> (count @saved*) (count text))))))))) + +(deftest spawn-agent-empty-selector-call-tool-test + (h/config! {:agent {"explorer" {:mode "subagent" :description "Explorer"}}}) + (with-redefs [f.chat/prompt (fn [& _] (is false "Empty selector must never spawn a fresh child"))] + (let [before @(h/db*) + result (f.tools/call-tool! "eca__spawn_agent" {"agent" "explorer" "task" "work" "chat_id" ""} + "parent" "empty" "code" (h/db*) (h/config) (h/messenger) (h/metrics) + (constantly {:status :executing}) (fn [& _]) {})] + (is (true? (:error result))) + (is (= before @(h/db*)))))) + +(deftest managed-subagent-followup-workers-test + (h/config! {:env "dev" :hooks {"status" {:type "chatStatusChanged"}} + :agent {"explorer" {:mode "subagent" :description "Explorer"}}}) + (swap! (h/db*) assoc-in [:chats "parent"] {:model "openai/gpt-5.2"}) + (let [idle (promise) release-idle (promise) polled (promise) + second-finished (promise) release-worker (promise) + requests* (atom 0) idle-count* (atom 0) + context {:db* (h/db*) :config (h/config) :messenger (h/messenger) :metrics (h/metrics) + :chat-id "parent" :tool-call-id "workers" + :call-state-fn (fn [] + (when (= :idle (get-in @(h/db*) [:chats "subagent-workers" :status])) + (deliver polled true)) + {:status :executing})} + handler (spawn-handler)] + (with-redefs [llm-api/sync-prompt! (constantly nil) + config/await-plugins-resolved! (constantly true) + f.tools/all-tools (constantly []) + hooks/trigger-if-matches! + (fn [type data callbacks & _] + (when (and (= :subagentPostRequest type) (not (:follow-up-active data))) + ((:on-after-action callbacks) {:name "follow" :exit 0 :parsed {"followUp" "continue"}})) + (when (and (= :chatStatusChanged type) (= :idle (:status data)) + (= 1 (swap! idle-count* inc))) + (deliver idle true) + (is (= true (deref release-idle 10000 ::timeout))))) + llm-api/sync-or-async-prompt! + (fn [{:keys [on-first-response-received on-message-received]}] + (let [n (swap! requests* inc)] + (on-first-response-received {:type :text :text "answer"}) + (on-message-received {:type :text :text (str "answer " n)}) + (on-message-received {:type :finish}) + (when (= 2 n) + (deliver second-finished true) + (is (= true (deref release-worker 10000 ::timeout))))))] + (let [run (future (handler {"agent" "explorer" "task" "work"} context))] + (try + (is (= true (deref idle 10000 ::timeout))) + (is (= true (deref polled 10000 ::timeout))) + (is (not (realized? run))) + (is (= 1 (get-in @(h/db*) [:subagent-runs "subagent-workers" :workers]))) + (doseq [args [{"agent" "explorer" "task" "duplicate"} + {"agent" "explorer" "task" "resume" "chat_id" "subagent-workers"}]] + (is (thrown? clojure.lang.ExceptionInfo (handler args context)))) + (let [before @(h/db*)] + (is (thrown? clojure.lang.ExceptionInfo + (f.chat/prompt {:chat-id "subagent-workers" :message "bypass"} + (h/db*) (h/messenger) (h/config) (h/metrics)))) + (is (= before @(h/db*)))) + (deliver release-idle true) + (is (= true (deref second-finished 10000 ::timeout))) + (is (pos? (get-in @(h/db*) [:subagent-runs "subagent-workers" :workers]))) + (is (not (realized? run))) + (deliver release-worker true) + (let [result (deref run 10000 ::timeout)] + (is (map? result)) + (is (string/includes? (tools.util/contents->text (:contents result)) "answer 2"))) + (is (= 0 (get-in @(h/db*) [:subagent-runs "subagent-workers" :workers]))) + (is (nil? (get-in @(h/db*) [:subagent-runs "subagent-workers" :token]))) + (finally + (deliver release-idle true) + (deliver release-worker true) + (when (= ::timeout (deref run 10000 ::timeout)) (future-cancel run)))))))) + +(deftest spawn-agent-real-max-steps-resume-test + (h/config! {:env "test" + :agent {"explorer" {:mode "subagent" :description "Explorer" :maxSteps 1}}}) + (swap! (h/db*) assoc-in [:chats "parent"] {:model "openai/gpt-5.2"}) + (let [requests* (atom []) + id "subagent-budget" + context {:db* (h/db*) :config (h/config) :messenger (h/messenger) :metrics (h/metrics) + :chat-id "parent" :tool-call-id "budget" + :call-state-fn (constantly {:status :executing})}] + (with-redefs [llm-api/sync-prompt! (constantly nil) + config/await-plugins-resolved! (constantly true) + f.tools/all-tools (constantly []) + llm-api/sync-or-async-prompt! + (fn [{:keys [on-first-response-received on-message-received on-tools-called] :as request}] + (swap! requests* conj request) + (on-first-response-received {:type :text :text "Findings"}) + (on-message-received {:type :text :text "Findings"}) + (if (= 1 (count @requests*)) + (on-tools-called [{:id "lookup" :full-name "eca__lookup" :arguments {}}]) + (do + (is (= 0 (get-in @(h/db*) [:chats id :current-step]))) + (is (nil? (get-in @(h/db*) [:chats id :max-steps-reached?]))) + (on-message-received {:type :finish}))))] + (let [halted ((spawn-handler) {"agent" "explorer" "task" "find"} context) + history (get-in @(h/db*) [:chats id :messages])] + (is (true? (:error halted))) + (is (re-find #"^Subagent chat_id: subagent-budget\n\n## Agent 'explorer' Halted" + (tools.util/contents->text (:contents halted)))) + (is (true? (get-in @(h/db*) [:chats id :max-steps-reached?]))) + (is (= 1 (get-in @(h/db*) [:chats id :current-step]))) + (is (not (get-in @(h/db*) [:subagent-runs id :interrupted?]))) + (let [resumed ((spawn-handler) {"agent" "explorer" "task" "finish" "chat_id" id} + (assoc context :tool-call-id "continued"))] + (is (false? (:error resumed))) + (is (re-find #"^Subagent chat_id: subagent-budget\n\n## Agent 'explorer' Result" + (tools.util/contents->text (:contents resumed)))) + (is (= 2 (count @requests*))) + (is (seq history)) + (is (= history (take (count history) (get-in @(h/db*) [:chats id :messages])))) + (is (some #(= "assistant" (:role %)) (:past-messages (last @requests*)))) + (is (= 0 (get-in @(h/db*) [:subagent-runs id :workers])))))))) + +(deftest spawn-agent-provider-failure-resume-test + (h/config! {:env "test" :providers {"openai" {:retry {:maxAutoContinues 0}}} + :agent {"explorer" {:mode "subagent" :description "Explorer"}}}) + (swap! (h/db*) assoc-in [:chats "parent"] {:model "openai/gpt-5.2"}) + (let [requests* (atom []) + context {:db* (h/db*) :config (h/config) :messenger (h/messenger) :metrics (h/metrics) + :chat-id "parent" :tool-call-id "network" :call-state-fn (constantly {:status :executing})} + handler (spawn-handler)] + (with-redefs [llm-api/sync-prompt! (constantly nil) + config/await-plugins-resolved! (constantly true) + f.tools/all-tools (constantly [{:name "lookup" :full-name "eca__lookup" + :server {:name "eca"} :origin :native}]) + f.tools/approval (constantly :allow) + f.tools/call-tool! (constantly {:contents [{:type :text :text "earlier tool result"}]}) + llm-api/sync-or-async-prompt! + (fn [{:keys [on-first-response-received on-message-received on-prepare-tool-call + on-tools-called on-error] :as request}] + (swap! requests* conj request) + (on-first-response-received {:type :text :text "started"}) + (case (count @requests*) + 1 (do (on-prepare-tool-call {:id "lookup" :full-name "eca__lookup" :arguments-text "{}"}) + (on-tools-called [{:id "lookup" :full-name "eca__lookup" :arguments {}}]) + (on-message-received {:type :text :text "partial finding"}) + (on-error {:message "Connection lost" :exception (java.net.ConnectException. "Connection refused")})) + (do (is (nil? (get-in @(h/db*) [:chats "subagent-network" :prompt-error]))) + (on-message-received {:type :text :text "recovered answer"}) + (on-message-received {:type :finish}))))] + (let [failed (handler {"agent" "explorer" "task" "find"} context) + history (get-in @(h/db*) [:chats "subagent-network" :messages])] + (is (true? (:error failed))) + (is (string/includes? (tools.util/contents->text (:contents failed)) "returned `chat_id`")) + (let [result (handler {"agent" "explorer" "task" "continue" "chat_id" "subagent-network"} context) + past (:past-messages (last @requests*))] + (is (false? (:error result))) + (is (string/includes? (tools.util/contents->text (:contents result)) "recovered answer")) + (is (= 2 (count @requests*))) + (is (some #(= "earlier tool result" (get-in % [:content :output :contents 0 :text])) past)) + (is (some #(= "partial finding" (get-in % [:content 0 :text])) past)) + (is (= history (take (count history) (get-in @(h/db*) [:chats "subagent-network" :messages]))))))))) + +(deftest spawn-agent-stopped-tool-resume-test + (doseq [phase [:dispatch :post-hook :status-hook :cooperative :uninterruptible]] + (testing (name phase) + (h/reset-components!) + (h/config! {:env "dev" :agent {"explorer" {:mode "subagent" :description "Explorer"}}}) + (swap! (h/db*) assoc-in [:chats "parent"] {:model "openai/gpt-5.2"}) + (let [entered (promise) release (promise) stopped (promise) joining (promise) tool-ended (promise) + requests* (atom []) call-state* (atom {:status :executing}) callback* (atom nil) + tool-thread* (atom nil) + id "subagent-cancel" + context {:db* (h/db*) :config (h/config) :messenger (h/messenger) :metrics (h/metrics) + :chat-id "parent" :tool-call-id "cancel" :call-state-fn #(deref call-state*)} + handler (spawn-handler) + transition tool-calls/transition-tool-call! + active tool-calls/get-active-tool-calls + ;; Ignore cancellation only until the test explicitly releases old work. + wait! (fn [] (loop [] + (let [result (try (deref release 30000 ::timeout) + (catch InterruptedException _ ::interrupted))] + (if (= ::interrupted result) + (recur) + (is (= true result)))))) + settled? (fn [] (loop [n 1000] + (cond (zero? (get-in @(h/db*) [:subagent-runs id :workers] 0)) true + (zero? n) false + :else (do (Thread/sleep 10) (recur (dec n)))))) + stop! (fn [] + (reset! call-state* {:status :stopping}) + (f.chat/prompt-stop {:chat-id id} (h/db*) (h/messenger) (h/config) (h/metrics) {:silent? true}) + (deliver stopped true))] + (with-redefs [llm-api/sync-prompt! (constantly nil) + config/await-plugins-resolved! (constantly true) + f.tools/all-tools (constantly [{:name "lookup" :full-name "eca__lookup" + :server {:name "eca"} :origin :native}]) + f.tools/approval (constantly :allow) + tool-calls/get-active-tool-calls (fn [db chat-id] + (when (realized? stopped) (deliver joining true)) + (active db chat-id)) + tool-calls/transition-tool-call! + (fn [db* ctx tool-id event & data] + (try + (let [result (apply transition db* ctx tool-id event data)] + (when (and (= phase :dispatch) (= event :execution-start)) + (is (= true (deref entered 10000 ::timeout))) + (stop!)) + result) + (finally + (when (#{:execution-end :stop-attempted} event) (deliver tool-ended true))))) + hooks/trigger-if-matches! + (fn [type _ callbacks & _] + (when (and (= phase :post-hook) (= type :postToolCall)) + (deliver entered true) + (wait!) + ((:on-after-action callbacks) {:name "amend" :exit 0 + :parsed {"replacedOutput" "hook result"}}))) + lifecycle/trigger-chat-status-hook! + (fn [_] + (when (and (= phase :status-hook) + (= @tool-thread* (Thread/currentThread)) + (= :cleanup (get-in @(h/db*) [:chats id :tool-calls "lookup" :status]))) + (deliver entered true) + (wait!))) + f.tools/call-tool! + (fn [& args] + (reset! tool-thread* (Thread/currentThread)) + (reset! callback* (nth args 9)) + (when (#{:dispatch :cooperative :uninterruptible} phase) + (deliver entered true) + (if (= :cooperative phase) + (try (deref release 10000 ::timeout) (catch InterruptedException _ nil)) + (wait!))) + {:contents [{:type :text :text "old tool result"}]}) + llm-api/sync-or-async-prompt! + (fn [{:keys [on-first-response-received on-message-received on-prepare-tool-call on-tools-called] + :as request}] + (swap! requests* conj request) + (on-first-response-received {:type :text :text "start"}) + (if (= 1 (count @requests*)) + (do (on-prepare-tool-call {:id "lookup" :full-name "eca__lookup" :arguments-text "{}"}) + (on-tools-called [{:id "lookup" :full-name "eca__lookup" :arguments {}}])) + (do (on-message-received {:type :text :text "corrected answer"}) + (on-message-received {:type :finish}))))] + (let [run (future (handler {"agent" "explorer" "task" "work"} context))] + (try + (is (= true (deref entered 10000 ::timeout))) + (when-not (= phase :dispatch) (stop!)) + (is (= true (deref stopped 10000 ::timeout))) + (when-not (= phase :cooperative) + (is (= (if (#{:post-hook :status-hook} phase) :cleanup :stopping) + (:status (@callback*))) "Tool observes live state") + (when (#{:post-hook :status-hook} phase) + ;; A cleanup-state future is not cancelled by prompt-stop; cancel it + ;; to exercise the same join path as a tool stopped while executing. + (future-cancel (get-in @(h/db*) [:chats id :tool-calls "lookup" :future]))) + (when (= phase :dispatch) (is (= true (deref joining 1000 ::timeout)))) + (is (map? (deref run 10000 ::timeout))) + (is (= 1 (get-in @(h/db*) [:subagent-runs id :workers]))) + (is (thrown? clojure.lang.ExceptionInfo + (handler {"agent" "explorer" "task" "too soon" "chat_id" id} context)))) + (deliver release true) + (is (map? (deref run 10000 ::timeout))) + (is (settled?)) + (reset! call-state* {:status :executing}) + (let [history (get-in @(h/db*) [:chats id :messages]) + resumed (handler {"agent" "explorer" "task" "correct course" "chat_id" id} context) + past (:past-messages (last @requests*))] + (is (false? (:error resumed))) + (is (string/includes? (tools.util/contents->text (:contents resumed)) "corrected answer")) + (is (= 2 (count @requests*)) "No provider continuation after stop") + (is (every? (set (map :role past)) ["tool_call" "tool_call_output"])) + (is (some #(= (if (= phase :post-hook) "hook result" "old tool result") + (get-in % [:content :output :contents 0 :text])) past)) + (is (= history (take (count history) (get-in @(h/db*) [:chats id :messages]))))) + (finally + (deliver release true) + (is (= true (deref tool-ended 10000 ::timeout))) + (is (not= ::timeout (deref run 10000 ::timeout))) + (is (settled?)))))))))) + (deftest spawn-agent-real-chat-prompt-test - ;; Regression test for v0.133.1 -> v0.133.2: spawn_agent failed end-to-end - ;; because chat/prompt's validate-client-chat-id rejected the deterministic - ;; "subagent-..." chat id used internally by spawn-agent. Every other test - ;; in this namespace mocks chat/prompt via with-redefs of requiring-resolve, - ;; so the validator path was never exercised. This test runs the REAL - ;; chat/prompt and only mocks the LLM transport, asserting the spawn handler - ;; reaches success through the chat layer. + ;; Regression: chat/prompt must accept the server-managed "subagent-..." ID. + ;; Exercise the real chat layer for both fresh and resumed delegation. (testing "spawn handler drives real chat/prompt to success" (h/reset-components!) (h/config! {:env "test" @@ -1006,26 +1500,27 @@ (fn [models] (merge {"openai/gpt-5.2" {:tools true}} (or models {})))) (swap! (h/db*) assoc-in [:chats "parent-1"] {:id "parent-1" :model "openai/gpt-5.2"}) - (let [api-mock (fn [{:keys [on-first-response-received on-message-received]}] + (let [requests* (atom []) + api-mock (fn [{:keys [on-first-response-received on-message-received + on-prepare-tool-call on-tools-called] :as request}] + (swap! requests* conj request) (on-first-response-received {:type :text :text "Found it"}) + (when (= 1 (count @requests*)) + (on-prepare-tool-call {:id "lookup" :full-name "eca__lookup" :arguments-text "{}"}) + (on-tools-called [{:id "lookup" :full-name "eca__lookup" :arguments {}}])) (on-message-received {:type :text :text "Found it"}) (on-message-received {:type :finish}))] (with-redefs [llm-api/sync-or-async-prompt! api-mock llm-api/sync-prompt! (constantly nil) - f.tools/all-tools (constantly []) + f.tools/all-tools (constantly [{:name "lookup" :full-name "eca__lookup" + :server {:name "eca"} :origin :native + :parameters {:type "object" :properties {}}}]) + f.tools/call-tool! (constantly {:error false :contents [{:type :text :text "file found"}]}) f.tools/approval (constantly :allow) config/await-plugins-resolved! (constantly true)] (let [handler (get-in (f.tools.agent/definitions (h/config) (h/db)) ["spawn_agent" :handler]) - ;; Run in a future with a timeout so a regression that causes - ;; chat/prompt to reject the subagent chat-id (and thus never - ;; flip the chat to :idle) fails the test instead of hanging - ;; the polling loop forever. The budget is generous because - ;; chat/prompt does its real work in a future* and the agent - ;; polling loop in agent.clj sleeps 1s between status checks, - ;; so on slower CI runners (notably macOS GitHub runners with - ;; cold JIT) a healthy run can still take a few polling - ;; iterations. A genuine regression hangs forever, so 30s is - ;; still a fast failure for that case. + ;; Bound the wait so a stalled prompt fails the test; allow headroom + ;; for slower CI runners. result-fut (future (handler {"agent" "explorer" "task" "find files" "activity" "exploring"} @@ -1043,16 +1538,35 @@ :messages (h/messages)}))] (when (identical? ::timeout result) (future-cancel result-fut)) - (testing "spawn handler completes (regression would hang the polling loop)" + (testing "spawn handler completes within the timeout" (is (not (identical? ::timeout result)) - (str "spawn handler did not complete in 30s — chat/prompt likely rejected the subagent chat-id. " - timeout-details))) + (str "spawn handler did not complete in 30s. " timeout-details))) (when (map? result) - (testing "spawn handler returns success (would be :error true under v0.133.1)" + (testing "spawn handler returns success" (is (match? {:error false :contents [{:type :text - :text #"^## Agent 'explorer' Result"}]} + :text #"^Subagent chat_id: subagent-tc-1\n\n## Agent 'explorer' Result"}]} result))) + (testing "resume retains the real transcript and original selections" + (let [child (get-in @(h/db*) [:chats "subagent-tc-1"]) + history (:messages child) + cache (:prompt-cache child)] + (is (every? (set (map :role history)) ["user" "assistant" "tool_call" "tool_call_output"])) + (swap! (h/db*) assoc-in [:chats "parent-1" :model] "other/model") + (let [resumed (handler {"agent" "explorer" "task" "Explain that finding" "chat_id" "subagent-tc-1"} + {:db* (h/db*) :config (h/config) :messenger (h/messenger) :metrics (h/metrics) + :chat-id "parent-1" :tool-call-id "tc-2" + :call-state-fn (constantly {:status :executing})})] + (is (false? (:error resumed))) + (is (= history (take (count history) (get-in @(h/db*) [:chats "subagent-tc-1" :messages])))) + (is (every? (set (map :role (:past-messages (last @requests*)))) + ["user" "assistant" "tool_call" "tool_call_output"])) + (is (some #(= "file found" (get-in % [:content :output :contents 0 :text])) + (:past-messages (last @requests*)))) + (is (= "Explain that finding" + (get-in (last @requests*) [:user-messages 0 :content 0 :text]))) + (is (= "gpt-5.2" (:model (last @requests*)))) + (is (= cache (get-in @(h/db*) [:chats "subagent-tc-1" :prompt-cache])))))) (testing "subagent chat reaches :idle through real chat/prompt" (is (= :idle (get-in @(h/db*) [:chats "subagent-tc-1" :status])))) (testing "subagent chat carries the parent-chat-id" @@ -1062,8 +1576,4 @@ (= "parent-1" (:parent-chat-id m)) (= :assistant (:role m)) (= {:type :text :text "Found it"} (:content m)))) - (:chat-content-received (h/messages))))))))) - ;; Reference f.chat to keep the require non-unused; the actual - ;; eca.features.chat/prompt is invoked indirectly via requiring-resolve - ;; inside spawn-agent. - (is (var? #'f.chat/prompt)))) + (:chat-content-received (h/messages))))))))))) diff --git a/test/eca/features/tools/util_test.clj b/test/eca/features/tools/util_test.clj index a51884490..4de386b7b 100644 --- a/test/eca/features/tools/util_test.clj +++ b/test/eca/features/tools/util_test.clj @@ -9,6 +9,13 @@ [eca.test-helper :as h] [matcher-combinators.test :refer [match?]])) +(deftest constrained-empty-argument-test + (is (= {"chat_id" "" "required" ""} + (tools.util/omit-optional-empty-string-args + {:properties {"chat_id" {:minLength 1} "optional" {:type "string"}} + :required ["required"]} + {"chat_id" "" "optional" "" "required" ""})))) + (def ^:private test-tool-call-id "test-truncation-call-1") (def ^:private ^:dynamic *temp-cache-dir* nil) From 5da59d0c6226e1fb665f8b13d161d3bce4408746 Mon Sep 17 00:00:00 2001 From: Juha Itkonen <37696708+itkonen@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:21:55 +0300 Subject: [PATCH 2/4] Fix query-commands integration test: include eca-info description metadata --- integration-test/integration/chat/commands_test.clj | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/integration-test/integration/chat/commands_test.clj b/integration-test/integration/chat/commands_test.clj index dcb8f360a..44d01ac71 100644 --- a/integration-test/integration/chat/commands_test.clj +++ b/integration-test/integration/chat/commands_test.clj @@ -55,7 +55,9 @@ {:name "plugin-uninstall" :arguments [{:name "plugin" :description "Plugin name" :required true}]} {:name "hooks" :arguments []} - {:name "eca-info" :arguments nil}]} + {:name "eca-info" + :description "Inspect running ECA for self-debug: versions, client, default model, providers/auth, MCP servers (status/tools), skills, subagents, env vars, credential files." + :arguments nil}]} resp)))) (testing "We query specific commands" From b8890dd1c8a7a64b640c24bf4f0a1038bcf57755 Mon Sep 17 00:00:00 2001 From: Juha Itkonen Date: Wed, 23 Sep 2026 05:12:12 +0300 Subject: [PATCH 3/4] Fix command integration expectations after master merge Account for plugin-update and the updated plugin-uninstall argument metadata. Remove the unrelated eca-info description assertion from the previous fix. --- integration-test/integration/chat/commands_test.clj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/integration-test/integration/chat/commands_test.clj b/integration-test/integration/chat/commands_test.clj index 44d01ac71..dfab6dc95 100644 --- a/integration-test/integration/chat/commands_test.clj +++ b/integration-test/integration/chat/commands_test.clj @@ -52,12 +52,12 @@ {:name "plugins" :arguments []} {:name "plugin-install" :arguments [{:name "plugin" :description "Plugin name or plugin@marketplace" :required true}]} + {:name "plugin-update" + :arguments [{:name "marketplace" :description "Configured marketplace source name" :required true}]} {:name "plugin-uninstall" - :arguments [{:name "plugin" :description "Plugin name" :required true}]} + :arguments [{:name "plugin" :description "Plugin name or plugin@marketplace" :required true}]} {:name "hooks" :arguments []} - {:name "eca-info" - :description "Inspect running ECA for self-debug: versions, client, default model, providers/auth, MCP servers (status/tools), skills, subagents, env vars, credential files." - :arguments nil}]} + {:name "eca-info" :arguments nil}]} resp)))) (testing "We query specific commands" From 1be708c700da80e04dad4977cae0f02a30f41ac5 Mon Sep 17 00:00:00 2001 From: Juha Itkonen Date: Tue, 29 Sep 2026 21:22:20 +0300 Subject: [PATCH 4/4] refactor(agent): simplify subagent resume logic and remove unused code - Remove unused and redundant functions related to subagent summary and max-steps - Consolidate and clarify resume validation logic for subagents - Update tests to reflect new resume behavior and remove obsolete test cases - Ensure assistant text extraction returns nil when no assistant messages - Clean up test code and assertions for subagent worker and resume scenarios --- src/eca/features/chat.clj | 3 +- src/eca/features/tools/agent.clj | 31 ++-- test/eca/features/tools/agent_test.clj | 214 ++++--------------------- 3 files changed, 40 insertions(+), 208 deletions(-) diff --git a/src/eca/features/chat.clj b/src/eca/features/chat.clj index b70d38748..d6abc893e 100644 --- a/src/eca/features/chat.clj +++ b/src/eca/features/chat.clj @@ -408,8 +408,7 @@ subagent-chat-id (when (= "tool_call_output" (:role message)) (get-in message [:content :details :subagent-chat-id])) child (get-in db [:chats subagent-chat-id]) - subagent-messages (when (and (string? subagent-chat-id) - (not (string/blank? 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)) diff --git a/src/eca/features/tools/agent.clj b/src/eca/features/tools/agent.clj index 10bedd9f5..95efd2f5a 100644 --- a/src/eca/features/tools/agent.clj +++ b/src/eca/features/tools/agent.clj @@ -46,9 +46,6 @@ [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)) - (defn ^:private extract-final-assistant-text "Extracts text from the final assistant message, or nil when none exists." [messages] @@ -62,12 +59,6 @@ (str/join "\n") not-empty)) -(defn ^:private extract-final-summary - "Extract the final assistant message as summary from chat messages." - [messages] - (or (extract-final-assistant-text messages) - "Agent completed without producing output.")) - (defn ^:private failure-guidance "Actionable next-step hint for the parent agent based on the error type." [error-type] @@ -158,7 +149,7 @@ variants (config/effective-model-variants config provider model model-capabilities user-variants)] (config/selectable-variant-names variants)))))) -(defn ^:private resumable-run +(defn ^:private validate-resume! [db id parent-id agent-name config trust] (let [run (get-in db [:subagent-runs id]) child (get-in db [:chats id])] @@ -174,8 +165,7 @@ (when-not (and (= (:config-hash run) (hash config)) (= (:workspace-folders run) (:workspace-folders db)) (= (:trust run) trust (:trust child))) - (throw (ex-info "Subagent config, workspace, or trust changed; spawn a new agent." {}))) - run)) + (throw (ex-info "Subagent config, workspace, or trust changed; spawn a new agent." {}))))) (defn ^:private spawn-agent "Handler for the spawn_agent tool. @@ -206,11 +196,11 @@ resume? (contains? arguments "chat_id") subagent-chat-id (if resume? (get arguments "chat_id") (->subagent-chat-id tool-call-id)) - _ (when (and resume? (or (not (string? subagent-chat-id)) (str/blank? subagent-chat-id))) + _ (when (and resume? (not (shared/not-blank subagent-chat-id))) (throw (ex-info "chat_id must be a nonblank string." {}))) _ (when (and resume? (some #(contains? arguments %) ["model" "variant"])) (throw (ex-info "model and variant overrides are not allowed when resuming." {}))) - run (when resume? (resumable-run db subagent-chat-id chat-id agent-name config trust)) + run (when resume? (get-in db [:subagent-runs subagent-chat-id])) user-model (get arguments "model") _ (when user-model @@ -254,7 +244,7 @@ db* (fn [db] (if resume? - (do (resumable-run db subagent-chat-id chat-id agent-name config trust) + (do (validate-resume! db subagent-chat-id chat-id agent-name config trust) (-> db (update-in [:subagent-runs subagent-chat-id] #(-> % (assoc :token token) (dissoc :interrupted?))) @@ -276,7 +266,7 @@ (cond-> {:id subagent-chat-id :parent-chat-id chat-id :agent-name agent-name :subagent subagent :trust trust :current-step 0} - (max-steps subagent) (assoc :max-steps (max-steps subagent))))))))) + (:max-steps subagent) (assoc :max-steps (:max-steps subagent))))))))) starting-message-count (count (get-in before [:chats subagent-chat-id :messages])) max-steps-limit (get-in @db* [:chats subagent-chat-id :max-steps])] (logger/with-chat-context chat-id (get-in db [:chats chat-id :parent-chat-id]) @@ -333,8 +323,8 @@ (and (#{:idle :error} status) (zero? (get-in db [:subagent-runs subagent-chat-id :workers] 0))) (let [messages (drop starting-message-count (get-in db [:chats subagent-chat-id :messages] [])) - summary (extract-final-summary messages) partial-output (extract-final-assistant-text messages) + summary (or partial-output "Agent completed without producing output.") prompt-error (get-in db [:chats subagent-chat-id :prompt-error]) failed? (boolean (or (= :error status) prompt-error (get-in db [:subagent-runs subagent-chat-id :interrupted?]))) @@ -350,8 +340,7 @@ :else (logger/info logger-tag (format "Agent '%s' completed after %d steps" agent-name current-step))) (swap! db* update-in [:subagent-runs subagent-chat-id] - #(cond-> (merge % (select-keys (get-in db [:chats subagent-chat-id]) [:model :variant])) - failed? (assoc :interrupted? true))) + merge (select-keys (get-in db [:chats subagent-chat-id]) [:model :variant])) (swap! db* assoc-in [:chats chat-id :tool-calls tool-call-id :subagent-final-step] current-step) (cond max-steps-reached? @@ -439,7 +428,7 @@ subagent-chat-id (if resume? (get arguments "chat_id") (when tool-call-id (->subagent-chat-id tool-call-id))) child (get-in db [:chats subagent-chat-id]) - owned? (and (string? subagent-chat-id) (not (str/blank? subagent-chat-id)) + owned? (and (shared/not-blank subagent-chat-id) subagent (:subagent child) (get-in db [:subagent-runs subagent-chat-id]) (= chat-id (:parent-chat-id child)) @@ -457,7 +446,7 @@ :model subagent-model :agent-name agent-name :step (get child :current-step 1) - :max-steps (if resume? (:max-steps child) (max-steps subagent))} + :max-steps (if resume? (:max-steps child) (:max-steps subagent))} variant (assoc :variant variant)))) (defmethod tools.util/tool-call-details-after-invocation :spawn_agent diff --git a/test/eca/features/tools/agent_test.clj b/test/eca/features/tools/agent_test.clj index 986bf5c7b..689b68bde 100644 --- a/test/eca/features/tools/agent_test.clj +++ b/test/eca/features/tools/agent_test.clj @@ -2,11 +2,8 @@ (:require [clojure.string :as string] [clojure.test :refer [deftest is testing]] - [eca.cache :as cache] [eca.config :as config] - [eca.db :as db] [eca.features.chat :as f.chat] - [eca.features.chat.lifecycle :as lifecycle] [eca.features.chat.tool-calls :as tool-calls] [eca.features.hooks :as hooks] [eca.features.tools :as f.tools] @@ -524,15 +521,6 @@ (is (match? {:error true :contents [{:type :text :text #"was stopped"}]} result)) - (is (true? (get-in @db* [:subagent-runs subagent-chat-id :interrupted?]))) - (with-redefs [requiring-resolve (fn [sym] - (if (= sym 'eca.features.chat/prompt) - (fn [& _] (swap! db* assoc-in [:chats subagent-chat-id :status] :idle)) - (clojure.lang.RT/var (namespace sym) (name sym))))] - (is (false? (:error - ((spawn-handler) {"agent" "explorer" "task" "resume" "chat_id" subagent-chat-id} - {:db* db* :config test-config :chat-id "chat-1" :tool-call-id "resume" - :call-state-fn (constantly {:status :executing})}))))) (testing "preserves subagent chat for resume replay" (is (some? (get-in @db* [:chats subagent-chat-id]))))))))) @@ -939,36 +927,35 @@ (is (= "company-litellm/explorer-small" (:model @chat-prompt-called*)) "bare alias should resolve to the parent provider's model")))))) -(deftest extract-final-summary-test +(deftest extract-final-assistant-text-test (testing "extracts text from last assistant message" (is (= "Hello world" - (#'f.tools.agent/extract-final-summary + (#'f.tools.agent/extract-final-assistant-text [{:role "user" :content [{:type :text :text "Hi"}]} {:role "assistant" :content [{:type :text :text "Hello world"}]}])))) (testing "uses last assistant message when multiple exist" (is (= "Final answer" - (#'f.tools.agent/extract-final-summary + (#'f.tools.agent/extract-final-assistant-text [{:role "assistant" :content [{:type :text :text "First response"}]} {:role "user" :content [{:type :text :text "More?"}]} {:role "assistant" :content [{:type :text :text "Final answer"}]}])))) (testing "joins multiple text blocks with newline" (is (= "Part 1\nPart 2" - (#'f.tools.agent/extract-final-summary + (#'f.tools.agent/extract-final-assistant-text [{:role "assistant" :content [{:type :text :text "Part 1"} {:type :text :text "Part 2"}]}])))) (testing "ignores non-text content types" (is (= "Text only" - (#'f.tools.agent/extract-final-summary + (#'f.tools.agent/extract-final-assistant-text [{:role "assistant" :content [{:type :tool-use :text "ignored"} {:type :text :text "Text only"}]}])))) - (testing "returns default when no assistant messages" - (is (= "Agent completed without producing output." - (#'f.tools.agent/extract-final-summary - [{:role "user" :content [{:type :text :text "Hi"}]}]))))) + (testing "returns nil when no assistant messages" + (is (nil? (#'f.tools.agent/extract-final-assistant-text + [{:role "user" :content [{:type :text :text "Hi"}]}]))))) (deftest definitions-test (testing "spawn_agent tool definition has correct structure" @@ -1019,43 +1006,6 @@ (is (= "Spawning agent" (summary-fn {:args {}})))))) -(deftest resume-agent-test - (let [db* (atom (assoc test-db :chats {"parent" {:model "openai/gpt-4.1"}})) - calls* (atom []) - context {:db* db* :config test-config :chat-id "parent" :tool-call-id "resume" - :messenger (h/messenger) :metrics (h/metrics) - :call-state-fn (constantly {:status :executing})} - handler (spawn-handler)] - (with-redefs [f.chat/prompt - (fn [params & _] - (swap! calls* conj params) - (swap! db* update-in [:chats (:chat-id params)] - #(-> % (assoc :status :idle :current-step 3 :prompt-cache {:kept true}) - (update :messages (fnil conj []) - {:role "assistant" :content [{:type :text :text (:message params)}]}))))] - (handler {"agent" "explorer" "task" "first"} context) - (let [history (get-in @db* [:chats "subagent-resume" :messages])] - (swap! db* assoc-in [:chats "parent" :model] "anthropic/claude-opus-4-6") - (let [result (handler {"agent" "explorer" "task" "second" "chat_id" "subagent-resume"} - (assoc context :tool-call-id "second"))] - (is (string/includes? (tools.util/contents->text (:contents result)) "subagent-resume")) - (is (= "openai/gpt-4.1" (:model (last @calls*)))) - (is (= history (take (count history) (get-in @db* [:chats "subagent-resume" :messages])))) - (is (= {:kept true} (get-in @db* [:chats "subagent-resume" :prompt-cache]))))) - (doseq [selector [nil "" " " 42 [] "missing" "parent"]] - (let [before @db* calls @calls*] - (is (thrown? clojure.lang.ExceptionInfo - (handler {"agent" "explorer" "task" "invalid" "chat_id" selector} - (assoc context :tool-call-id "invalid")))) - (is (= before @db*)) - (is (= calls @calls*)))) - (testing "changed caller-supplied trust rejects resume" - (let [before @db*] - (is (thrown? clojure.lang.ExceptionInfo - (handler {"agent" "explorer" "task" "next" "chat_id" "subagent-resume"} - (assoc context :trust true)))) - (is (= before @db*))))))) - (deftest resume-admission-and-run-isolation-test (let [db* (atom {:chats {"parent" {:model "openai/gpt-4.1"}}}) context {:db* db* :config test-config :messenger (h/messenger) :metrics (h/metrics) @@ -1069,6 +1019,11 @@ :messages [{:role "assistant" :content [{:type :text :text "old answer"}]}])))] (handler {"agent" "explorer" "task" "first" "variant" "high"} context)) (let [baseline @db*] + (with-redefs [f.chat/prompt (fn [& _] (is false "Rejected resume must not prompt"))] + (doseq [selector [nil "" " " 42 [] "missing" "parent"]] + (is (thrown? clojure.lang.ExceptionInfo + (handler (assoc args "chat_id" selector) context))) + (is (= baseline @db*)))) (doseq [[label change changed-args changed-context] [["foreign parent" identity args (assoc context :chat-id "foreign")] ["different agent" identity (assoc args "agent" "general") context] @@ -1092,16 +1047,6 @@ (is (thrown? clojure.lang.ExceptionInfo (handler changed-args changed-context))) (is (= before @db*)))))) (reset! db* baseline) - (testing "failed resource destruction retains the future and blocks admission" - (swap! db* assoc-in [:chats "subagent-isolation" :tool-calls "old"] - {:status :cleanup :future (delay nil) :resources {:process :remaining}}) - (with-redefs [f.tools/tool-call-destroy-resource! (fn [& _] (throw (ex-info "cleanup failed" {})))] - (is (thrown? clojure.lang.ExceptionInfo - (tool-calls/transition-tool-call! db* {:chat-id "subagent-isolation"} - "old" :cleanup-finished {})))) - (is (some? (get-in @db* [:chats "subagent-isolation" :tool-calls "old" :future]))) - (is (thrown? clojure.lang.ExceptionInfo (handler args context)))) - (reset! db* baseline) (testing "before details never disclose a foreign child's settings" (is (match? {:model nil :max-steps nil :step 1} (tools.util/tool-call-details-before-invocation @@ -1112,12 +1057,11 @@ {:status :rejected :future-cleanup-complete?* (promise)}) (with-redefs [f.chat/prompt (fn [params & _] (is (= "high" (:variant params))) - (is (= 0 (get-in @db* [:chats "subagent-isolation" :current-step]))) - (is (nil? (get-in @db* [:chats "subagent-isolation" :max-steps-reached?]))) - (is (nil? (get-in @db* [:chats "subagent-isolation" :prompt-error]))) (swap! db* assoc-in [:chats "subagent-isolation" :status] :idle))] (let [result (handler args (assoc context :tool-call-id "empty"))] (is (false? (:error result))) + (is (string/ends-with? (get-in result [:contents 0 :text]) + "Agent completed without producing output.")) (is (not (string/includes? (tools.util/contents->text (:contents result)) "old answer")))))) (testing "immediate prompt errors finish without polling and allow settled reuse" (with-redefs [f.chat/prompt (constantly {:status :error})] @@ -1128,36 +1072,6 @@ (is (true? (:error (handler args context)))) (finally (when-not (realized? run) (future-cancel run)))))))))) -(deftest managed-subagent-worker-unwind-test - (doseq [failure [:silent-stop :finally-error]] - (testing (name failure) - (h/reset-components!) - (h/config! {:env "test" :agent {"explorer" {:mode "subagent" :description "Explorer"}}}) - (swap! (h/db*) assoc-in [:chats "parent"] {:model "openai/gpt-5.2"}) - (let [worker-entered?* (atom false) - context {:db* (h/db*) :config (h/config) :messenger (h/messenger) :metrics (h/metrics) - :chat-id "parent" :tool-call-id "unwind" :call-state-fn (constantly {:status :executing})} - handler (spawn-handler)] - (with-redefs [llm-api/sync-prompt! (constantly nil) - f.tools/all-tools (constantly []) - config/await-plugins-resolved! (constantly true) - db/save-chat! (fn [& _] - (when (and @worker-entered?* (= :finally-error failure)) - (throw (ex-info "save failure" {})))) - llm-api/sync-or-async-prompt! (fn [_] - (reset! worker-entered?* true) - (when (= :silent-stop failure) - (throw (ex-info "stopped" {:silent? true}))))] - (let [result (handler {"agent" "explorer" "task" "work"} context)] - (is @worker-entered?*) - (is (true? (:error result))) - (is (string/includes? (tools.util/contents->text (:contents result)) "subagent-unwind")) - (is (= 0 (get-in @(h/db*) [:subagent-runs "subagent-unwind" :workers]))) - (is (true? (get-in @(h/db*) [:subagent-runs "subagent-unwind" :interrupted?]))) - (is (nil? (get-in @(h/db*) [:subagent-runs "subagent-unwind" :token]))) - (is (true? (:error - (handler {"agent" "explorer" "task" "resume" "chat_id" "subagent-unwind"} context)))))))))) - (deftest spawn-agent-replay-authorization-test (doseq [[id child allowed?] [["owned" {:subagent {:name "explorer"} :parent-chat-id "parent" :agent-name "explorer"} true] @@ -1185,34 +1099,6 @@ (is (= allowed? (boolean (some #(= "\nChild transcript" (get-in % [:content :text])) (replay stored)))))))))) -(deftest spawn-agent-truncated-id-test - (doseq [failed? [false true]] - (testing (if failed? "failure with partial output" "success") - (h/reset-components!) - (h/config! {:agent {"explorer" {:mode "subagent" :description "Explorer"}} - :toolCall {:outputTruncation {:lines 100 :sizeKb 1}}}) - (let [id "subagent-truncated" - saved* (atom nil)] - (with-redefs [cache/save-tool-call-output! (fn [_ text] (reset! saved* text) "/unused/output.txt") - f.chat/prompt (fn [& _] - (swap! (h/db*) update-in [:chats id] - #(cond-> (assoc % :status :idle - :messages [{:role "assistant" - :content [{:type :text :text (apply str (repeat 5000 "x"))}]}]) - failed? (assoc :prompt-error {:message "Provider failed"}))))] - (let [result (f.tools/call-tool! "eca__spawn_agent" {"agent" "explorer" "task" "work"} - "parent" "truncated" "code" (h/db*) (h/config) (h/messenger) (h/metrics) - (constantly {:status :executing}) (fn [& _]) {}) - text (tools.util/contents->text (:contents result))] - (is (= failed? (:error result))) - (is (string/starts-with? text (str "Subagent chat_id: " id "\n\n"))) - (is (string/includes? text (if failed? "## Agent 'explorer' Failed" "## Agent 'explorer' Result"))) - (when failed? - (is (string/includes? text "Provider failed")) - (is (string/includes? text "## Partial result"))) - (is (string/includes? text "[OUTPUT TRUNCATED]")) - (is (> (count @saved*) (count text))))))))) - (deftest spawn-agent-empty-selector-call-tool-test (h/config! {:agent {"explorer" {:mode "subagent" :description "Explorer"}}}) (with-redefs [f.chat/prompt (fn [& _] (is false "Empty selector must never spawn a fresh child"))] @@ -1262,7 +1148,6 @@ (is (= true (deref idle 10000 ::timeout))) (is (= true (deref polled 10000 ::timeout))) (is (not (realized? run))) - (is (= 1 (get-in @(h/db*) [:subagent-runs "subagent-workers" :workers]))) (doseq [args [{"agent" "explorer" "task" "duplicate"} {"agent" "explorer" "task" "resume" "chat_id" "subagent-workers"}]] (is (thrown? clojure.lang.ExceptionInfo (handler args context)))) @@ -1273,14 +1158,11 @@ (is (= before @(h/db*)))) (deliver release-idle true) (is (= true (deref second-finished 10000 ::timeout))) - (is (pos? (get-in @(h/db*) [:subagent-runs "subagent-workers" :workers]))) (is (not (realized? run))) (deliver release-worker true) (let [result (deref run 10000 ::timeout)] (is (map? result)) (is (string/includes? (tools.util/contents->text (:contents result)) "answer 2"))) - (is (= 0 (get-in @(h/db*) [:subagent-runs "subagent-workers" :workers]))) - (is (nil? (get-in @(h/db*) [:subagent-runs "subagent-workers" :token]))) (finally (deliver release-idle true) (deliver release-worker true) @@ -1316,7 +1198,6 @@ (tools.util/contents->text (:contents halted)))) (is (true? (get-in @(h/db*) [:chats id :max-steps-reached?]))) (is (= 1 (get-in @(h/db*) [:chats id :current-step]))) - (is (not (get-in @(h/db*) [:subagent-runs id :interrupted?]))) (let [resumed ((spawn-handler) {"agent" "explorer" "task" "finish" "chat_id" id} (assoc context :tool-call-id "continued"))] (is (false? (:error resumed))) @@ -1325,8 +1206,7 @@ (is (= 2 (count @requests*))) (is (seq history)) (is (= history (take (count history) (get-in @(h/db*) [:chats id :messages])))) - (is (some #(= "assistant" (:role %)) (:past-messages (last @requests*)))) - (is (= 0 (get-in @(h/db*) [:subagent-runs id :workers])))))))) + (is (some #(= "assistant" (:role %)) (:past-messages (last @requests*))))))))) (deftest spawn-agent-provider-failure-resume-test (h/config! {:env "test" :providers {"openai" {:retry {:maxAutoContinues 0}}} @@ -1369,14 +1249,13 @@ (is (= history (take (count history) (get-in @(h/db*) [:chats "subagent-network" :messages]))))))))) (deftest spawn-agent-stopped-tool-resume-test - (doseq [phase [:dispatch :post-hook :status-hook :cooperative :uninterruptible]] + (doseq [phase [:dispatch :uninterruptible]] (testing (name phase) (h/reset-components!) (h/config! {:env "dev" :agent {"explorer" {:mode "subagent" :description "Explorer"}}}) (swap! (h/db*) assoc-in [:chats "parent"] {:model "openai/gpt-5.2"}) (let [entered (promise) release (promise) stopped (promise) joining (promise) tool-ended (promise) requests* (atom []) call-state* (atom {:status :executing}) callback* (atom nil) - tool-thread* (atom nil) id "subagent-cancel" context {:db* (h/db*) :config (h/config) :messenger (h/messenger) :metrics (h/metrics) :chat-id "parent" :tool-call-id "cancel" :call-state-fn #(deref call-state*)} @@ -1416,29 +1295,11 @@ result) (finally (when (#{:execution-end :stop-attempted} event) (deliver tool-ended true))))) - hooks/trigger-if-matches! - (fn [type _ callbacks & _] - (when (and (= phase :post-hook) (= type :postToolCall)) - (deliver entered true) - (wait!) - ((:on-after-action callbacks) {:name "amend" :exit 0 - :parsed {"replacedOutput" "hook result"}}))) - lifecycle/trigger-chat-status-hook! - (fn [_] - (when (and (= phase :status-hook) - (= @tool-thread* (Thread/currentThread)) - (= :cleanup (get-in @(h/db*) [:chats id :tool-calls "lookup" :status]))) - (deliver entered true) - (wait!))) f.tools/call-tool! (fn [& args] - (reset! tool-thread* (Thread/currentThread)) (reset! callback* (nth args 9)) - (when (#{:dispatch :cooperative :uninterruptible} phase) - (deliver entered true) - (if (= :cooperative phase) - (try (deref release 10000 ::timeout) (catch InterruptedException _ nil)) - (wait!))) + (deliver entered true) + (wait!) {:contents [{:type :text :text "old tool result"}]}) llm-api/sync-or-async-prompt! (fn [{:keys [on-first-response-received on-message-received on-prepare-tool-call on-tools-called] @@ -1455,18 +1316,11 @@ (is (= true (deref entered 10000 ::timeout))) (when-not (= phase :dispatch) (stop!)) (is (= true (deref stopped 10000 ::timeout))) - (when-not (= phase :cooperative) - (is (= (if (#{:post-hook :status-hook} phase) :cleanup :stopping) - (:status (@callback*))) "Tool observes live state") - (when (#{:post-hook :status-hook} phase) - ;; A cleanup-state future is not cancelled by prompt-stop; cancel it - ;; to exercise the same join path as a tool stopped while executing. - (future-cancel (get-in @(h/db*) [:chats id :tool-calls "lookup" :future]))) - (when (= phase :dispatch) (is (= true (deref joining 1000 ::timeout)))) - (is (map? (deref run 10000 ::timeout))) - (is (= 1 (get-in @(h/db*) [:subagent-runs id :workers]))) - (is (thrown? clojure.lang.ExceptionInfo - (handler {"agent" "explorer" "task" "too soon" "chat_id" id} context)))) + (is (= :stopping (:status (@callback*))) "Tool observes live state") + (when (= phase :dispatch) (is (= true (deref joining 1000 ::timeout)))) + (is (map? (deref run 10000 ::timeout))) + (is (thrown? clojure.lang.ExceptionInfo + (handler {"agent" "explorer" "task" "too soon" "chat_id" id} context))) (deliver release true) (is (map? (deref run 10000 ::timeout))) (is (settled?)) @@ -1478,8 +1332,7 @@ (is (string/includes? (tools.util/contents->text (:contents resumed)) "corrected answer")) (is (= 2 (count @requests*)) "No provider continuation after stop") (is (every? (set (map :role past)) ["tool_call" "tool_call_output"])) - (is (some #(= (if (= phase :post-hook) "hook result" "old tool result") - (get-in % [:content :output :contents 0 :text])) past)) + (is (some #(= "old tool result" (get-in % [:content :output :contents 0 :text])) past)) (is (= history (take (count history) (get-in @(h/db*) [:chats id :messages]))))) (finally (deliver release true) @@ -1501,21 +1354,14 @@ (swap! (h/db*) assoc-in [:chats "parent-1"] {:id "parent-1" :model "openai/gpt-5.2"}) (let [requests* (atom []) - api-mock (fn [{:keys [on-first-response-received on-message-received - on-prepare-tool-call on-tools-called] :as request}] + api-mock (fn [{:keys [on-first-response-received on-message-received] :as request}] (swap! requests* conj request) (on-first-response-received {:type :text :text "Found it"}) - (when (= 1 (count @requests*)) - (on-prepare-tool-call {:id "lookup" :full-name "eca__lookup" :arguments-text "{}"}) - (on-tools-called [{:id "lookup" :full-name "eca__lookup" :arguments {}}])) (on-message-received {:type :text :text "Found it"}) (on-message-received {:type :finish}))] (with-redefs [llm-api/sync-or-async-prompt! api-mock llm-api/sync-prompt! (constantly nil) - f.tools/all-tools (constantly [{:name "lookup" :full-name "eca__lookup" - :server {:name "eca"} :origin :native - :parameters {:type "object" :properties {}}}]) - f.tools/call-tool! (constantly {:error false :contents [{:type :text :text "file found"}]}) + f.tools/all-tools (constantly []) f.tools/approval (constantly :allow) config/await-plugins-resolved! (constantly true)] (let [handler (get-in (f.tools.agent/definitions (h/config) (h/db)) ["spawn_agent" :handler]) @@ -1551,7 +1397,7 @@ (let [child (get-in @(h/db*) [:chats "subagent-tc-1"]) history (:messages child) cache (:prompt-cache child)] - (is (every? (set (map :role history)) ["user" "assistant" "tool_call" "tool_call_output"])) + (is (every? (set (map :role history)) ["user" "assistant"])) (swap! (h/db*) assoc-in [:chats "parent-1" :model] "other/model") (let [resumed (handler {"agent" "explorer" "task" "Explain that finding" "chat_id" "subagent-tc-1"} {:db* (h/db*) :config (h/config) :messenger (h/messenger) :metrics (h/metrics) @@ -1560,9 +1406,7 @@ (is (false? (:error resumed))) (is (= history (take (count history) (get-in @(h/db*) [:chats "subagent-tc-1" :messages])))) (is (every? (set (map :role (:past-messages (last @requests*)))) - ["user" "assistant" "tool_call" "tool_call_output"])) - (is (some #(= "file found" (get-in % [:content :output :contents 0 :text])) - (:past-messages (last @requests*)))) + ["user" "assistant"])) (is (= "Explain that finding" (get-in (last @requests*) [:user-messages 0 :content 0 :text]))) (is (= "gpt-5.2" (:model (last @requests*))))