|
| 1 | +(ns eca.features.chat.lifecycle |
| 2 | + (:require |
| 3 | + [eca.db :as db] |
| 4 | + [eca.features.hooks :as f.hooks] |
| 5 | + [eca.features.login :as f.login] |
| 6 | + [eca.logger :as logger] |
| 7 | + [eca.messenger :as messenger] |
| 8 | + [eca.shared :as shared :refer [assoc-some]])) |
| 9 | + |
| 10 | +(set! *warn-on-reflection* true) |
| 11 | + |
| 12 | +(def ^:private logger-tag "[CHAT]") |
| 13 | + |
| 14 | +(defn new-content-id [] |
| 15 | + (str (random-uuid))) |
| 16 | + |
| 17 | +(defn auto-compact? [chat-id agent-name full-model config db] |
| 18 | + (when (and (not (get-in db [:chats chat-id :compacting?])) |
| 19 | + (not (get-in db [:chats chat-id :auto-compacting?]))) |
| 20 | + (let [compact-threshold (or (get-in config [:agent agent-name :autoCompactPercentage]) |
| 21 | + (get-in config [:autoCompactPercentage])) |
| 22 | + {:keys [session-tokens limit]} (shared/usage-sumary chat-id full-model db)] |
| 23 | + (when (and compact-threshold session-tokens (:context limit)) |
| 24 | + (let [current-percentage (* (/ session-tokens (:context limit)) 100)] |
| 25 | + (>= current-percentage compact-threshold)))))) |
| 26 | + |
| 27 | +(defn send-content! [{:keys [messenger chat-id parent-chat-id]} role content] |
| 28 | + (messenger/chat-content-received |
| 29 | + messenger |
| 30 | + (assoc-some {:chat-id chat-id |
| 31 | + :role role |
| 32 | + :content content} |
| 33 | + :parent-chat-id parent-chat-id))) |
| 34 | + |
| 35 | +(defn- format-hook-output |
| 36 | + "Format hook output for display, showing parsed JSON fields or raw output." |
| 37 | + [{:keys [systemMessage replacedPrompt additionalContext] :as parsed} raw-output] |
| 38 | + (if parsed |
| 39 | + (cond-> (or systemMessage "Hook executed") |
| 40 | + replacedPrompt (str "\nReplacedPrompt: " (pr-str replacedPrompt)) |
| 41 | + additionalContext (str "\nAdditionalContext: " additionalContext)) |
| 42 | + raw-output)) |
| 43 | + |
| 44 | +(defn notify-before-hook-action! [chat-ctx {:keys [id name type visible?]}] |
| 45 | + (when visible? |
| 46 | + (send-content! chat-ctx :system |
| 47 | + {:type :hookActionStarted |
| 48 | + :action-type type |
| 49 | + :name name |
| 50 | + :id id}))) |
| 51 | + |
| 52 | +(defn notify-after-hook-action! [chat-ctx {:keys [id name parsed raw-output raw-error exit type visible?]}] |
| 53 | + (when (and visible? (not (:suppressOutput parsed))) |
| 54 | + (send-content! chat-ctx :system |
| 55 | + {:type :hookActionFinished |
| 56 | + :action-type type |
| 57 | + :id id |
| 58 | + :name name |
| 59 | + :status exit |
| 60 | + :output (format-hook-output parsed raw-output) |
| 61 | + :error raw-error}))) |
| 62 | + |
| 63 | +(defn wrap-additional-context |
| 64 | + "Return XML-wrapped additional context attributed to `from`." |
| 65 | + [from content] |
| 66 | + (format "<additionalContext from=\"%s\">\n%s\n</additionalContext>" |
| 67 | + (name from) |
| 68 | + content)) |
| 69 | + |
| 70 | +(defn finish-chat-prompt! [status {:keys [message chat-id db* metrics config on-finished-side-effect prompt-id] :as chat-ctx}] |
| 71 | + (when-not (and prompt-id (not= prompt-id (get-in @db* [:chats chat-id :prompt-id]))) |
| 72 | + (when-not (get-in @db* [:chats chat-id :auto-compacting?]) |
| 73 | + (swap! db* assoc-in [:chats chat-id :status] status) |
| 74 | + (let [db @db* |
| 75 | + subagent? (some? (get-in db [:chats chat-id :subagent])) |
| 76 | + hook-type (if subagent? :subagentPostRequest :postRequest) |
| 77 | + hook-data (cond-> (merge (f.hooks/chat-hook-data db chat-id (:agent chat-ctx)) |
| 78 | + {:prompt message}) |
| 79 | + subagent? (assoc :parent-chat-id (get-in db [:chats chat-id :parent-chat-id])))] |
| 80 | + (f.hooks/trigger-if-matches! hook-type |
| 81 | + hook-data |
| 82 | + {:on-before-action (partial notify-before-hook-action! chat-ctx) |
| 83 | + :on-after-action (partial notify-after-hook-action! chat-ctx)} |
| 84 | + db |
| 85 | + config)) |
| 86 | + (send-content! chat-ctx :system |
| 87 | + {:type :progress |
| 88 | + :state :finished}) |
| 89 | + (when-not (get-in @db* [:chats chat-id :created-at]) |
| 90 | + (swap! db* assoc-in [:chats chat-id :created-at] (System/currentTimeMillis)))) |
| 91 | + (when on-finished-side-effect |
| 92 | + (on-finished-side-effect)) |
| 93 | + (db/update-workspaces-cache! @db* metrics))) |
| 94 | + |
| 95 | +(defn maybe-renew-auth-token [chat-ctx] |
| 96 | + (f.login/maybe-renew-auth-token! |
| 97 | + {:provider (:provider chat-ctx) |
| 98 | + :on-renewing (fn [] |
| 99 | + (send-content! chat-ctx :system {:type :progress |
| 100 | + :state :running |
| 101 | + :text "Renewing auth token"})) |
| 102 | + :on-error (fn [error-msg] |
| 103 | + (send-content! chat-ctx :system {:type :text :text error-msg}) |
| 104 | + (finish-chat-prompt! :idle (dissoc chat-ctx :on-finished-side-effect)) |
| 105 | + (throw (ex-info "Auth token renew failed" {})))} |
| 106 | + chat-ctx)) |
| 107 | + |
| 108 | +(defn assert-chat-not-stopped! [{:keys [chat-id db* prompt-id] :as chat-ctx}] |
| 109 | + (let [chat (get-in @db* [:chats chat-id]) |
| 110 | + superseded? (and prompt-id (not= prompt-id (:prompt-id chat))) |
| 111 | + stopped? (or (identical? :stopping (:status chat)) superseded?)] |
| 112 | + (when stopped? |
| 113 | + (finish-chat-prompt! :idle (dissoc chat-ctx :on-finished-side-effect)) |
| 114 | + (logger/info logger-tag "Chat prompt stopped:" chat-id (when superseded? "(superseded)")) |
| 115 | + (throw (ex-info "Chat prompt stopped" {:silent? true |
| 116 | + :chat-id chat-id}))))) |
0 commit comments