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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
## Unreleased

- Share chat history across git worktrees of the same repo, and merge workspace cache writes from concurrent servers instead of overwriting. #558
- Fix ChatGPT OAuth routing and Responses Lite payloads for GPT-5.6 models.
- Retry overloaded OpenAI post-tool requests without rerunning completed tools.

## 0.151.1

Expand Down
67 changes: 67 additions & 0 deletions integration-test/integration/chat/subagent_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,70 @@
(= "assistant" (:role e))
(matches? {:type "text" :text "Final answer"} (:content e))))
events))))))

(deftest spawn-subagent-retries-overloaded-post-tool-request-test
(eca/start-process!)
(eca/request! (fixture/initialize-request))
(eca/notify! (fixture/initialized-notification))

(llm.mocks/set-case! :subagent-retry-0)
(let [resp (eca/request! (fixture/chat-prompt-request
{:model "openai/gpt-5.2"
:message "What can you find?"}))
parent-chat-id (:chatId resp)
events (drain-content-events-until
(fn [e]
(and (= parent-chat-id (:chatId e))
(= "assistant" (:role e))
(= "text" (-> e :content :type))
(= "Final answer" (-> e :content :text)))))
subagent-chat-id (->> events
(keep :chatId)
(filter #(string/starts-with? % "subagent-"))
first)]
(is (string? subagent-chat-id))

(testing "subagent reports retrying the overloaded post-tool request"
(is (some (fn [e]
(and (= subagent-chat-id (:chatId e))
(= "system" (:role e))
(matches? {:type "progress"
:state "running"
:text #"Provider overloaded.*Retrying"}
(:content e))))
events)))

(testing "subagent tool executes exactly once"
(is (= 1
(count
(filter (fn [e]
(and (= subagent-chat-id (:chatId e))
(= "assistant" (:role e))
(matches? {:type "toolCalled"
:name "directory_tree"
:error false}
(:content e))))
events)))))

(testing "subagent finishes after the retried request succeeds"
(is (some (fn [e]
(and (= subagent-chat-id (:chatId e))
(= parent-chat-id (:parentChatId e))
(= "assistant" (:role e))
(matches? {:type "text" :text "Subagent done"} (:content e))))
events)))

(testing "parent receives a successful subagent result and finishes"
(is (some (fn [e]
(and (= parent-chat-id (:chatId e))
(= "assistant" (:role e))
(matches? {:type "toolCalled"
:name "spawn_agent"
:error false}
(:content e))))
events))
(is (some (fn [e]
(and (= parent-chat-id (:chatId e))
(= "assistant" (:role e))
(matches? {:type "text" :text "Final answer"} (:content e))))
events)))))
152 changes: 87 additions & 65 deletions integration-test/llm_mock/openai.clj
Original file line number Diff line number Diff line change
Expand Up @@ -190,73 +190,94 @@
:status "completed"}})
(hk/close ch))

(defn ^:private send-text-response! [ch text]
(sse-send! ch "response.output_text.delta"
{:type "response.output_text.delta" :delta text})
(sse-send! ch "response.completed"
{:type "response.completed"
:response {:output []
:usage {:input_tokens 10
:output_tokens 5}
:status "completed"}})
(hk/close ch))

(defn ^:private send-function-call-response! [ch item-id call-id tool-name arguments]
(let [args-json (json/generate-string arguments)]
(sse-send! ch "response.output_item.added"
{:type "response.output_item.added"
:item {:type "function_call"
:id item-id
:call_id call-id
:name tool-name
:arguments ""}})
(sse-send! ch "response.function_call_arguments.delta"
{:type "response.function_call_arguments.delta"
:item_id item-id
:delta args-json})
(sse-send! ch "response.completed"
{:type "response.completed"
:response {:output [{:type "function_call"
:id item-id
:call_id call-id
:name tool-name
:arguments args-json}]
:usage {:input_tokens 10
:output_tokens 5}
:status "completed"}})
(hk/close ch)))

(defonce ^:private subagent-follow-up-attempt* (atom 0))

(defn ^:private subagent-spawn-0
"Three-stage scenario for parent <-> subagent communication:
- Parent's first call: returns a tool_use for `eca__spawn_agent`.
- Subagent's call (recognized by user message \"find files\"): returns text and finishes.
- Parent's follow-up call (recognized by `function_call_output` in input): returns final text."
[ch body]
(let [input (:input body)
has-tool-output? (some #(= "function_call_output" (:type %)) input)
first-user-text (some->> input
(filter #(= "user" (:role %)))
first
:content
first
:text)
subagent-call? (and (not has-tool-output?)
(= "find files" first-user-text))]
(cond
has-tool-output?
(do
(sse-send! ch "response.output_text.delta"
{:type "response.output_text.delta" :delta "Final answer"})
(sse-send! ch "response.completed"
{:type "response.completed"
:response {:output []
:usage {:input_tokens 10
:output_tokens 5}
:status "completed"}})
(hk/close ch))
"Parent/subagent scenario, optionally overloading the child's first post-tool request."
([ch body] (subagent-spawn-0 ch body false))
([ch body overload-subagent-follow-up?]
(let [input (:input body)
tool-output-call-ids (->> input
(filter #(= "function_call_output" (:type %)))
(keep :call_id)
set)
parent-follow-up? (contains? tool-output-call-ids "tool-1")
subagent-follow-up? (contains? tool-output-call-ids "sub-tool-1")
first-user-text (some->> input
(filter #(= "user" (:role %)))
first
:content
first
:text)
subagent-call? (and (empty? tool-output-call-ids)
(= "find files" first-user-text))]
(cond
subagent-follow-up?
(if (and overload-subagent-follow-up?
(= 1 (swap! subagent-follow-up-attempt* inc)))
(do
(sse-send! ch "error"
{:type "service_unavailable_error"
:code "server_is_overloaded"
:message "Our servers are currently overloaded. Please try again later."})
(hk/close ch))
(send-text-response! ch "Subagent done"))

subagent-call?
(do
(sse-send! ch "response.output_text.delta"
{:type "response.output_text.delta" :delta "Subagent done"})
(sse-send! ch "response.completed"
{:type "response.completed"
:response {:output []
:usage {:input_tokens 5
:output_tokens 3}
:status "completed"}})
(hk/close ch))
parent-follow-up?
(send-text-response! ch "Final answer")

:else
(let [args-json (json/generate-string {:agent "explorer"
:task "find files"
:activity "exploring"})]
(sse-send! ch "response.output_item.added"
{:type "response.output_item.added"
:item {:type "function_call"
:id "item-1"
:call_id "tool-1"
:name "eca__spawn_agent"
:arguments ""}})
(sse-send! ch "response.function_call_arguments.delta"
{:type "response.function_call_arguments.delta"
:item_id "item-1"
:delta args-json})
(sse-send! ch "response.completed"
{:type "response.completed"
:response {:output [{:type "function_call"
:id "item-1"
:call_id "tool-1"
:name "eca__spawn_agent"
:arguments args-json}]
:usage {:input_tokens 10
:output_tokens 5}
:status "completed"}})
(hk/close ch)))))
subagent-call?
(if overload-subagent-follow-up?
(send-function-call-response! ch "sub-item-1" "sub-tool-1"
"eca__directory_tree"
{:path h/default-root-project-path
:max_depth 1})
(send-text-response! ch "Subagent done"))

:else
(do
(when overload-subagent-follow-up?
(reset! subagent-follow-up-attempt* 0))
(send-function-call-response! ch "item-1" "tool-1" "eca__spawn_agent"
{:agent "explorer"
:task "find files"
:activity "exploring"}))))))

(defn handle-openai-responses [req]
(let [body (some-> (slurp (:body req))
Expand All @@ -281,4 +302,5 @@
:reasoning-0 (reasoning-0 ch)
:reasoning-1 (reasoning-1 ch)
:tool-calling-0 (tool-calling-0 ch body)
:subagent-spawn-0 (subagent-spawn-0 ch body)))))})))
:subagent-spawn-0 (subagent-spawn-0 ch body)
:subagent-retry-0 (subagent-spawn-0 ch body true)))))})))
1 change: 1 addition & 0 deletions src/eca/features/chat.clj
Original file line number Diff line number Diff line change
Expand Up @@ -1055,6 +1055,7 @@
(lifecycle/maybe-renew-auth-token chat-ctx)
(get-in @db* [:auth provider]))
:variant (:variant chat-ctx)
:chat-id chat-id

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this really needed? llm-api should be agnostic of chats and ids ideally

:prompt-cache-key (prompt-cache-key agent)
:subagent? (some? (get-in @db* [:chats chat-id :subagent]))
:cancelled? (fn []
Expand Down
Loading
Loading