Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Bugfix: `postRequest` hook no longer fires for subagent chats; subagents use `subagentPostRequest` only, preventing duplicate hook notifications. (#505)
- Bugfix: clearer rejected tool-call result wording so models no longer assume a rejected edit was applied; it now states the call did not run and changed nothing. (#507)
- Bugfix: `ask_user` normalizes the `options` argument so a malformed value (e.g. a stringified options an LLM sometimes emits) no longer reaches clients as broken choices. Accepts string/object arrays, recovers a JSON-encoded string, and drops unusable input.

Expand Down
4 changes: 2 additions & 2 deletions docs/config/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ Fires before a prompt is sent to the LLM. Use for prompt validation, rewriting,

### `postRequest`

Fires after a primary-agent prompt finishes. Also runs for subagents. Primary use: validate the response or trigger a follow-up turn.
Fires after a primary-agent prompt finishes. It does not run for subagent chats; use [`subagentPostRequest`](#subagentpostrequest) for subagent completions. Primary use: validate the response or trigger a follow-up turn.

!!! note
`postRequest` fires only after LLM responses. Display-only commands (`/hooks`, `/model`, `/costs`) and compaction prompts (`/compact` and auto-compact) do **not** trigger it — use [`postCompact`](#postcompact) for compaction.
Expand All @@ -286,7 +286,7 @@ Fires after a primary-agent prompt finishes. Also runs for subagents. Primary us

### `subagentPostRequest`

Fires after a subagent prompt finishes, **in addition** to `postRequest` (which also runs for subagents). Use for subagent-specific follow-ups or notifications.
Fires after a subagent prompt finishes. Use for subagent-specific follow-ups or notifications; this is the subagent counterpart to primary-chat `postRequest`.

- **Input adds** — `response`, `follow_up_active`, and `parent_chat_id`.
- **Honored output** — same as [`postRequest`](#postrequest): `followUp`, `systemMessage`, `suppressOutput`, `continue: false` + `stopReason`.
Expand Down
24 changes: 10 additions & 14 deletions src/eca/features/chat/lifecycle.clj
Original file line number Diff line number Diff line change
Expand Up @@ -306,15 +306,15 @@
(run-post-compact-hooks! chat-ctx trigger summary)))

(defn ^:private run-post-request-hooks!
"Run postRequest (and subagentPostRequest for subagents) hooks.
"Run postRequest (for primary chats) and subagentPostRequest (for subagents) hooks.
Returns {:follow-up-text string-or-nil :stop-turn? boolean
:stop-reason string-or-nil :stop-hook-name string-or-nil}.

postRequest exit 2 with stderr is treated as followUp: stderr becomes the
followUp text, analogous to how preToolCall/postToolCall exit 2 makes stderr
LLM-visible payload. This is because postRequest runs after the prompt
finished, so exit 2 cannot 'block' the request; instead it contributes a
continuation instruction."
postRequest/subagentPostRequest exit 2 with stderr is treated as followUp:
stderr becomes the followUp text, analogous to how preToolCall/postToolCall
exit 2 makes stderr LLM-visible payload. This is because these hooks run
after the prompt finished, so exit 2 cannot 'block' the request; instead it
contributes a continuation instruction."
[{:keys [db* config chat-id response] :as chat-ctx}]
(let [db @db*
results* (atom [])
Expand All @@ -329,18 +329,14 @@
:on-after-action (fn [result]
(notify-after-hook-action! chat-ctx result)
(swap! results* conj result))}
_ (f.hooks/trigger-if-matches! :postRequest base-hook-data cb db config)
;; A successful continue:false on a postRequest hook stops the turn, so
;; the remaining relevant hooks must not run. For subagents this means
;; subagentPostRequest is skipped, otherwise it could emit side effects
;; (systemMessage, followUp) after the turn was already stopped.
post-request-stopped? (boolean (some f.hooks/successful-continue-false? @results*))
_ (when (and subagent? (not post-request-stopped?))
;; postRequest is primary-only. Subagent chats use subagentPostRequest.
_ (if subagent?
(f.hooks/trigger-if-matches! :subagentPostRequest
(assoc base-hook-data :parent-chat-id (db/parent-chat-id db chat-id))
cb
db
config))
config)
(f.hooks/trigger-if-matches! :postRequest base-hook-data cb db config))
hook-results @results*
follow-ups (->> hook-results
(keep (fn [{:keys [parsed exit raw-error]}]
Expand Down
19 changes: 9 additions & 10 deletions test/eca/features/hooks_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -1854,8 +1854,8 @@
(h/db) false "openai/gpt"))
(is (= ["subagentStart"] @fired-types*)))))

(deftest subagent-finish-fires-both-post-request-hooks-test
(testing "subagent finish fires postRequest then subagentPostRequest"
(deftest subagent-finish-fires-subagentpostrequest-only-test
(testing "subagent finish fires only subagentPostRequest, not postRequest"
(h/reset-components!)
(swap! (h/db*) assoc :chats {"sub-1" {:agent "explorer"
:subagent {:max-steps 10}
Expand All @@ -1875,10 +1875,10 @@
:agent "explorer"
:messenger (h/messenger)
:metrics (h/metrics)}))
(is (= ["postRequest" "subagentPostRequest"] @fired-types*)))))
(is (= ["subagentPostRequest"] @fired-types*)))))

(deftest subagent-postrequest-skipped-when-postrequest-stops-test
(testing "a successful postRequest continue:false stops the turn and skips subagentPostRequest"
(deftest subagent-subagentpostrequest-continue-false-stops-turn-test
(testing "subagentPostRequest continue:false stops the subagent turn"
(h/reset-components!)
(swap! (h/db*) assoc :chats {"sub-1" {:agent "explorer"
:subagent {:max-steps 10}
Expand All @@ -1891,7 +1891,6 @@
(with-redefs [f.hooks/run-shell-cmd (fn [{:keys [input]}]
(let [data (json/parse-string input true)]
(swap! fired-types* conj (:hook_type data)))
;; postRequest returns a successful continue:false
{:exit 0
:out "{\"continue\":false,\"stopReason\":\"halt\"}"
:err nil})]
Expand All @@ -1902,8 +1901,8 @@
:agent "explorer"
:messenger (h/messenger)
:metrics (h/metrics)}))
;; Only postRequest ran; subagentPostRequest was skipped.
(is (= ["postRequest"] @fired-types*))
;; Only subagentPostRequest ran; postRequest is not dispatched for subagents.
(is (= ["subagentPostRequest"] @fired-types*))
;; The turn-stop message is still surfaced on the subagent chat. The binding
;; (chat-id/parent-chat-id) is the contract here; the exact wording is covered
;; by lifecycle/turn-stopped-by-hook-message-test.
Expand Down Expand Up @@ -2011,13 +2010,13 @@
(is (false? @finished*)))))

(deftest subagent-postrequest-stop-binds-to-subagent-chat-test
(testing "subagent postRequest continue:false surfaces the prefixed stop message bound to the subagent chat"
(testing "subagentPostRequest continue:false surfaces the prefixed stop message bound to the subagent chat"
(h/reset-components!)
;; Mark the chat as a subagent so finish runs the post-request hooks for it;
;; the chat-ctx carries :parent-chat-id, so send-content! tags the message
;; with it (nested under the parent in the UI).
(swap! (h/db*) assoc-in [:chats "sub-1" :subagent] {:max-steps nil})
(h/config! {:hooks {"stopper" {:type "postRequest"
(h/config! {:hooks {"stopper" {:type "subagentPostRequest"
:visible false
:actions [{:type "shell" :shell "echo"}]}}})
(with-redefs [f.hooks/run-shell-cmd (constantly {:exit 0
Expand Down
Loading