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

Filter by extension

Filter by extension

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

## Unreleased

- Add `rename_chat_title`, a native tool that lets an approved agent rename the current chat title.

## 0.161.1

- Add Claude Opus 5.5 support.
Expand Down
2 changes: 2 additions & 0 deletions docs/config/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,8 @@ Globally ECA allows its read-only builtin tools and asks for everything else:
}
```

The `rename_chat_title` builtin changes chat metadata, so it is not in the default `allow` list. It asks for approval unless you add an explicit rule or enable trust mode.

The builtin `plan` and `explorer` agents replace these with stricter rules: `allow` only covers the read-only builtin tools plus read-only shell commands (`pwd`, `git diff/log/show`, `find`, `ls`), and `deny` blocks dangerous shell patterns (file mutations like `rm`/`mv`/`cp`/`touch`/`mkdir`, output redirections, pipes to `tee`/`dd`/`xargs`, in-place `sed`/`awk`/`perl`, `git add/commit/push`, `npm install`). Check the up-to-date values in [config.clj](https://github.com/editor-code-assistant/eca/blob/master/src/eca/config.clj).

### Debugging approval rules
Expand Down
7 changes: 7 additions & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ ECA support built-in tools to avoid user extra installation and configuration, t
- `editor_definition`: Ask client for the definition locations of a symbol (like LSP definition). Requires client capability, can be disabled via `toolCall.editorNav.enabled` config.
- `editor_references`: Ask client for the references of a symbol (like LSP references). Requires client capability, can be disabled via `toolCall.editorNav.enabled` config.

=== "Chat"

Provides access to chat metadata and lifecycle actions.

- `compact_chat`: submit a summary during chat compaction.
- `rename_chat_title`: rename the current chat title after the user explicitly asks for it. This tool asks for approval by default.

!!! info "Custom Tools"

Besides the built-in native tools, ECA allows you to define your own tools by wrapping any command-line executable. This feature enables you to extend ECA's capabilities to match your specific workflows, such as running custom scripts, interacting with internal services, or using your favorite CLI tools.
Expand Down
15 changes: 9 additions & 6 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -3025,13 +3025,16 @@ interface EcaServerUpdatedParams {
/**
* The built-in tools supported by eca.
*
* Built-in tools include: read_file, view_image, write_file, edit_file, move_file,
* directory_tree, shell_command, editor_diagnostics, editor_definition,
* editor_references, compact_chat, skill, spawn_agent, and task.
* Built-in tools include: ask_user, bg_job, compact_chat, directory_tree,
* edit_file, editor_definition, editor_diagnostics, editor_references,
* fetch_rule, git, grep, move_file, preview_file_change, read_file,
* rename_chat_title, search_tools, shell_command, skill, spawn_agent,
* task, view_image, and write_file.
*
* Note: `spawn_agent` and `task` are excluded from subagent tool sets.
* `spawn_agent` is excluded to prevent nesting, and `task` because
* task list state is chat-local and should be managed by the parent agent.
* Note: `spawn_agent`, `task`, `git`, `ask_user`, and `rename_chat_title`
* are excluded from subagent tool sets. `spawn_agent` is excluded to prevent
* nesting. `task`, `git`, `ask_user`, and `rename_chat_title` require
* parent-agent state, user interaction, or visible parent chat ownership.
*/
tools: ServerTool[];
}
Expand Down
4 changes: 3 additions & 1 deletion integration-test/integration/chat/commands_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,10 @@
{: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" :arguments nil}]}
resp))))
Expand Down
73 changes: 23 additions & 50 deletions src/eca/features/chat.clj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
[eca.features.background-tasks :as bg]
[eca.features.chat.history :as history]
[eca.features.chat.lifecycle :as lifecycle]
[eca.features.chat.persistence :as chat.persistence]
[eca.features.chat.title :as chat.title]
[eca.features.chat.tool-calls :as tc]
[eca.features.commands :as f.commands]
[eca.features.context :as f.context]
Expand Down Expand Up @@ -955,32 +957,7 @@
(pos? m) (format "%dm%02ds" m s)
:else (format "%ds" s))))

(defn ^:private sanitize-title
"Clean up a chat title: take first meaningful line, strip control chars,
markdown header prefixes, collapse whitespace, and truncate to 40 chars.

If the first non-blank line is a bare markdown header with nothing else
(e.g. '## Understand' — a planning-mode section the title model sometimes
mimics), fall through to the next non-blank line when one exists."
[^String s]
(when s
(let [lines (->> (string/split s #"\n")
(map string/trim)
(remove string/blank?))
bare-header? (fn [^String line]
(boolean (re-matches #"#+\s+\S.*" line)))
picked (or (when-let [first-line (first lines)]
(if (and (bare-header? first-line)
(seq (rest lines)))
(first (rest lines))
first-line))
"")]
(-> picked
(string/replace #"[\x00-\x1f\x7f]" " ")
(string/replace #"^#+\s*" "")
(string/replace #"\s+" " ")
(string/trim)
(as-> t (subs t 0 (min (count t) 40)))))))
(def ^:private sanitize-title chat.title/sanitize-title)

(defn ^:private prompt-messages!
"Send user messages to LLM with hook processing.
Expand Down Expand Up @@ -1084,7 +1061,7 @@
;; *_result entry appended right after, which
;; triggers the save in their place.
(when-not (#{"tool_call" "server_tool_use"} role)
(db/save-chat! @db* chat-id metrics))))
(chat.persistence/save-chat-current! db* chat-id metrics))))
on-usage-updated (fn [usage]
(when-let [usage (shared/usage-msg->usage usage full-model chat-ctx)]
;; Never let the context-breakdown (a display-only
Expand Down Expand Up @@ -1145,11 +1122,14 @@
:provider-auth provider-auth
:subagent? true})]
(when output-text
(let [title (sanitize-title output-text)]
(swap! db* assoc-in [:chats chat-id :title] title)
(lifecycle/send-content! chat-ctx :system (assoc-some {:type :metadata} :title title))
(when (= :idle (get-in @db* [:chats chat-id :status]))
(db/save-chat! @db* chat-id metrics))))))))
(chat.title/update-generated-chat-title!
db* chat-id output-text
{:messenger messenger
:metrics metrics
:parent-chat-id (:parent-chat-id chat-ctx)
:role :system
:expected-prompt-id prompt-id
:expected-user-prompt-count prompt-count}))))))
(lifecycle/send-content! chat-ctx :system {:type :progress :state :running :text "Waiting model"})
(if (and (lifecycle/auto-compact? chat-id agent full-model config @db*)
(not (:auto-compacted? chat-ctx)))
Expand Down Expand Up @@ -1634,7 +1614,7 @@
(swap! db* assoc-in [:chats chat-id :prompt-error]
(prompt-error-data error-data error-type))
(lifecycle/send-content! chat-ctx :system {:type :text :text text})
(db/save-chat! @db* chat-id metrics)
(chat.persistence/save-chat-current! db* chat-id metrics)
(lifecycle/finish-chat-prompt! :idle (lifecycle/strip-hook-callbacks chat-ctx)))))

:else
Expand Down Expand Up @@ -1729,7 +1709,7 @@
;; :prompt-finished? was already set or the prompt-id rotated,
;; which would leave a chat that hit an error without a save.
;; Persist explicitly so users can always /resume an errored chat.
(db/save-chat! @db* chat-id metrics)
(chat.persistence/save-chat-current! db* chat-id metrics)
(lifecycle/finish-chat-prompt! :idle (lifecycle/strip-hook-callbacks chat-ctx))))))))})
(catch Exception e
(when-not (:silent? (ex-data e))
Expand All @@ -1743,7 +1723,7 @@
(lifecycle/send-content! chat-ctx :system {:type :text :text (str "\n\n" "Error: " (or (ex-message e) (.getName (class e))))})
;; Belt-and-suspenders: persist before finish-chat-prompt!,
;; which may short-circuit. See note above in :on-error.
(db/save-chat! @db* chat-id metrics)
(chat.persistence/save-chat-current! db* chat-id metrics)
(lifecycle/finish-chat-prompt! :idle (lifecycle/strip-hook-callbacks chat-ctx))))
(finally
(when (and (= prompt-id (get-in @db* [:chats chat-id :prompt-id]))
Expand All @@ -1754,7 +1734,7 @@
(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))))))))))
(chat.persistence/save-chat-current! db* chat-id metrics))))))))))

(defn ^:private send-mcp-prompt!
[{:keys [prompt args] :as _decision}
Expand Down Expand Up @@ -2270,7 +2250,7 @@
(swap-vals! db* update-in [:chats chat-id] #(or % new-chat)))
created? (and new-chat (nil? (get-in old-db [:chats chat-id])))
_ (when created?
(db/save-chat! @db* chat-id metrics)
(chat.persistence/save-chat-current! db* chat-id metrics)
(messenger/chat-opened messenger {:chat-id chat-id :title (:title new-chat)})
(when (:trust new-chat)
(config/notify-fields-changed-only! {:chat {:select-trust true}} messenger db* chat-id)))
Expand Down Expand Up @@ -2501,7 +2481,7 @@
(dissoc :tool-calls :last-api :usage :task
:prompt-cache :last-editor-state)))))
(messenger/chat-cleared messenger {:chat-id chat-id :messages messages})
(db/save-chat! @db* chat-id metrics)))
(chat.persistence/save-chat-current! db* chat-id metrics)))

(defn update-chat
"Update chat metadata like title and trust.
Expand All @@ -2513,14 +2493,7 @@
(when (some? trust)
(swap! db* assoc-in [:chats chat-id :trust] trust))
(when title
(let [title (sanitize-title title)]
(swap! db* assoc-in [:chats chat-id :title] title)
(swap! db* assoc-in [:chats chat-id :title-custom?] true)
(messenger/chat-content-received messenger
{:chat-id chat-id
:role "system"
:content {:type :metadata :title title}})
(db/save-chat! @db* chat-id metrics))))
(chat.title/update-chat-title! db* chat-id title messenger metrics)))
{})

(defn rollback-chat
Expand Down Expand Up @@ -2553,7 +2526,7 @@
;; Rollback is the user's recovery tool for a chat that got into a bad
;; state. Persist immediately so the cleaned-up history survives a
;; restart instead of relying on the next unrelated save.
(db/save-chat! @db* chat-id metrics)
(chat.persistence/save-chat-current! db* chat-id metrics)
(messenger/chat-cleared
messenger
{:chat-id chat-id
Expand Down Expand Up @@ -2593,7 +2566,7 @@
new-messages (into (subvec messages 0 insert-after)
(cons flag-msg (subvec messages insert-after)))]
(swap! db* assoc-in [:chats chat-id :messages] new-messages)
(db/save-chat! @db* chat-id metrics)
(chat.persistence/save-chat-current! db* chat-id metrics)
(messenger/chat-cleared messenger {:chat-id chat-id :messages true})
(send-chat-contents! new-messages {:chat-id chat-id :db* db* :messenger messenger})))
{}))
Expand All @@ -2608,7 +2581,7 @@
messages))]
(when (not= (count new-messages) (count messages))
(swap! db* assoc-in [:chats chat-id :messages] new-messages)
(db/save-chat! @db* chat-id metrics))))
(chat.persistence/save-chat-current! db* chat-id metrics))))
{})

(defn fork-chat
Expand Down Expand Up @@ -2636,7 +2609,7 @@
:prompt-finished? true}]
(swap! db* assoc-in [:chats new-id] new-chat)
(mark-editor-open! db* new-id)
(db/save-chat! @db* new-id metrics)
(chat.persistence/save-chat-current! db* new-id metrics)
(messenger/chat-opened messenger {:chat-id new-id :title new-title})
(send-chat-contents! kept-messages {:chat-id new-id :db* db* :messenger messenger})
(lifecycle/send-content! {:messenger messenger :chat-id new-id}
Expand Down
3 changes: 2 additions & 1 deletion src/eca/features/chat/lifecycle.clj
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
(:require
[clojure.string :as string]
[eca.db :as db]
[eca.features.chat.persistence :as chat.persistence]
[eca.features.hooks :as f.hooks]
[eca.features.login :as f.login]
[eca.logger :as logger]
Expand Down Expand Up @@ -508,7 +509,7 @@
(dispatch-finish-callbacks! chat-ctx {:follow-up-text follow-up-text
:stop-turn? stop-turn?
:stopping? stopping?})
(db/save-chat! @db* chat-id metrics)))))
(chat.persistence/save-chat-current! db* chat-id metrics)))))

(defn finish-chat-prompt-stopped!
"Finish a turn that was halted by a hook (continue:false) or otherwise aborted.
Expand Down
19 changes: 19 additions & 0 deletions src/eca/features/chat/persistence.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
(ns eca.features.chat.persistence
(:require
[eca.db :as db]))

(set! *warn-on-reflection* true)

(defonce ^:private chat-save-lock (Object.))

(defn with-save-lock!
"Run F while holding the chat save lock."
[f]
(locking chat-save-lock
(f)))

(defn save-chat-current!
"Persist CHAT-ID from the current DB atom snapshot."
[db* chat-id metrics]
(with-save-lock!
#(db/save-chat! @db* chat-id metrics)))
113 changes: 113 additions & 0 deletions src/eca/features/chat/title.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
(ns eca.features.chat.title
(:require
[clojure.string :as string]
[eca.features.chat.persistence :as chat.persistence]
[eca.messenger :as messenger]
[eca.shared :refer [assoc-some]]))

(set! *warn-on-reflection* true)

(defn sanitize-title
"Clean up a chat title: take first meaningful line, strip control chars,
markdown header prefixes, collapse whitespace, and truncate to 40 chars.

If the first non-blank line is a bare markdown header with nothing else
(e.g. '## Understand' - a planning-mode section the title model sometimes
mimics), fall through to the next non-blank line when one exists."
[^String s]
(when s
(let [lines (->> (string/split s #"\n")
(map string/trim)
(remove string/blank?))
bare-header? (fn [^String line]
(boolean (re-matches #"#+\s+\S.*" line)))
picked (or (when-let [first-line (first lines)]
(if (and (bare-header? first-line)
(seq (rest lines)))
(first (rest lines))
first-line))
"")]
(-> picked
(string/replace #"[\x00-\x1f\x7f]" " ")
(string/replace #"^#+\s*" "")
(string/replace #"\s+" " ")
(string/trim)
(as-> t (subs t 0 (min (count t) 40)))))))

(defn- commit-title-update!
[db* chat-id title update-chat]
(loop []
(let [db @db*
chat (get-in db [:chats chat-id])]
(if-not chat
nil
(let [title (sanitize-title title)
updated-chat (update-chat chat title)]
(if-not updated-chat
nil
(let [new-db (assoc-in db [:chats chat-id] updated-chat)]
(if (compare-and-set! db* db new-db)
{:db new-db
:chat updated-chat
:title title}
(recur)))))))))

(defn- notify-title! [messenger chat-id parent-chat-id role title]
(messenger/chat-content-received messenger
(assoc-some {:chat-id chat-id
:role role
:content {:type :metadata :title title}}
:parent-chat-id parent-chat-id)))

(defn- update-title-with-side-effects!
[db* chat-id title update-chat {:keys [messenger metrics parent-chat-id role save?]
:or {role "system"
save? (constantly true)}}]
(chat.persistence/with-save-lock!
(fn []
(when-let [{:keys [title] :as result}
(commit-title-update! db* chat-id title update-chat)]
(let [db @db*
chat (get-in db [:chats chat-id])]
(when (and chat (= title (:title chat)))
(when messenger
(notify-title! messenger chat-id parent-chat-id role title))
(when (and metrics (save? chat))
(chat.persistence/save-chat-current! db* chat-id metrics))
(assoc result :db @db* :chat (get-in @db* [:chats chat-id]))))))))

(defn update-chat-title!
"Set CHAT-ID's title to TITLE, mark it custom, notify clients, and save it."
[db* chat-id title messenger metrics]
(when-let [{:keys [title]}
(update-title-with-side-effects!
db* chat-id title
(fn [chat title]
(assoc chat
:title title
:title-custom? true
:updated-at (System/currentTimeMillis)))
{:messenger messenger
:metrics metrics})]
title))

(defn- expected-chat-state?
[chat opts]
(and (or (not (contains? opts :expected-prompt-id))
(= (:expected-prompt-id opts) (:prompt-id chat)))
(or (not (contains? opts :expected-user-prompt-count))
(= (:expected-user-prompt-count opts) (:user-prompt-count chat)))))

(defn update-generated-chat-title!
"Set CHAT-ID's generated title when no custom title won the race."
([db* chat-id title]
(update-generated-chat-title! db* chat-id title nil))
([db* chat-id title opts]
(let [opts (or opts {})]
(update-title-with-side-effects!
db* chat-id title
(fn [chat title]
(when (and (not (:title-custom? chat))
(expected-chat-state? chat opts))
(assoc chat :title title)))
(assoc opts :save? #(= :idle (:status %)))))))
Loading
Loading