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 @@ -3,6 +3,7 @@
## Unreleased

- Serialize concurrent edits to the same file and preserve the latest pre-edit content for rollback. #621
- Fix auto-compaction running only once per turn: long (e.g. steered) turns now compact again when the context refills, up to `autoCompactMaxPerTurn` (default 3).

## 0.170.0

Expand Down
13 changes: 13 additions & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,13 @@
"markdownDescription": "Remaining context tokens (model context limit minus used tokens) at or below which auto-compact triggers. Takes precedence over `autoCompactPercentage`, which is only used as fallback when this value is not smaller than the model context limit. Useful when switching between models with very different context sizes.",
"minimum": 1
},
"autoCompactMaxPerTurn": {
"type": "integer",
"description": "Maximum number of auto-compactions in a single turn. Each compaction sends the whole context to the LLM, so this stops a turn that keeps refilling the context from compacting without end. When reached, a context overflow is shown as an error instead.",
"markdownDescription": "Maximum number of auto-compactions in a single turn. Each compaction sends the whole context to the LLM, so this stops a turn that keeps refilling the context from compacting without end. When reached, a context overflow is shown as an error instead.",
"minimum": 1,
"default": 3
},
"includeParentAgentsFiles": {
"type": "boolean",
"description": "When true, also include AGENTS.md files from each workspace folder's parent directories, ordered outermost parent first, then the workspace's own AGENTS.md.",
Expand Down Expand Up @@ -1046,6 +1053,12 @@
"markdownDescription": "Remaining context tokens at or below which auto-compact triggers for this agent. Takes precedence over `autoCompactPercentage`.",
"minimum": 1
},
"autoCompactMaxPerTurn": {
"type": "integer",
"description": "Maximum number of auto-compactions in a single turn for this agent.",
"markdownDescription": "Maximum number of auto-compactions in a single turn for this agent.",
"minimum": 1
},
"toolCall": {
"$ref": "#/definitions/toolCallConfig"
},
Expand Down
11 changes: 10 additions & 1 deletion docs/config/context-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,19 @@ If you switch between models with very different context sizes (e.g. 128k and 1M

When set (globally or per-agent), it takes precedence over `autoCompactPercentage`, which is only used as a fallback for models whose context window is not larger than that value.

A long turn can fill the context again after a compaction, so ECA can auto-compact more than once in the same turn. Each compaction sends the whole context to the LLM, so `autoCompactMaxPerTurn` limits how many times a single turn may auto-compact. This stops a turn that keeps refilling the context (e.g. reading the same huge file again after each compaction) from compacting without end. When the limit is reached, a context overflow is shown as an error. The next prompt starts counting from zero.

```javascript title="~/.config/eca/config.json"
{
"autoCompactMaxPerTurn": 3
}
```

| Property | Default | Description |
|-------------------------|---------|-------------------------------------------------------------------|
| `autoCompactPercentage` | `75` | Percentage of context window usage that triggers auto-compaction. |
| `autoCompactTokensLeft` | - | Remaining context tokens at or below which auto-compaction triggers. Takes precedence over `autoCompactPercentage`. |
| `autoCompactMaxPerTurn` | `3` | Maximum number of auto-compactions in a single turn. |

## Context overflow recovery

Expand All @@ -91,7 +100,7 @@ Instead of showing this error to the user, ECA automatically recovers:

This happens transparently — the user sees a brief "Context window exceeded. Auto-compacting conversation..." notice, and the task continues without manual intervention.

If recovery itself fails (e.g., the conversation is too large even after pruning), ECA falls back to displaying the error so the user can manually compact or start a new chat.
If recovery itself fails (e.g., the conversation is too large even after pruning), or the turn already reached `autoCompactMaxPerTurn`, ECA falls back to displaying the error so the user can manually compact or start a new chat.

## File Reading

Expand Down
3 changes: 2 additions & 1 deletion docs/config/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,8 @@ By default ECA consider the following as the base configuration:
},
"defaultAgent": "code",
"welcomeMessage" : "Welcome to ECA!\n\nType '/' for commands\n\n",
"autoCompactPercentage": 85,
"autoCompactPercentage": 75,
"autoCompactMaxPerTurn": 3,
"index" : {
"ignoreFiles" : [ {
"type" : "gitignore"
Expand Down
1 change: 1 addition & 0 deletions src/eca/config.clj
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@
:completion {:model "openai/gpt-4.1"}
:netrcFile nil
:autoCompactPercentage 75
:autoCompactMaxPerTurn 3
:includeParentAgentsFiles false
:plugins {"eca" {:source "https://github.com/editor-code-assistant/eca-plugins.git"}}
:remote {:enabled false}
Expand Down
68 changes: 51 additions & 17 deletions src/eca/features/chat.clj
Original file line number Diff line number Diff line change
Expand Up @@ -721,16 +721,44 @@
(lifecycle/finish-chat-prompt-stopped! :idle current-prompt-ctx))
{:stop-after-finish? (or stop-turn? (not compacted?))}))

(def ^:private resume-after-compact-message
{:role "user"
:content [{:type :text
:text "Continue with the task. The previous user request was:"}]})

(defn ^:private resume-after-compact!
"on-after-finish! for a mid-turn compaction: resume the original user task."
"on-after-finish! for a mid-turn compaction: resume the original user task.
A turn can compact more than once, so a resume prefix from an earlier
compaction is not repeated. The resumed turn carries a fresh post-compact
guard, see `auto-compact-suppressed?`."
[chat-ctx user-messages]
(prompt-messages!
(concat [{:role "user"
:content [{:type :text
:text "Continue with the task. The previous user request was:"}]}]
user-messages)
(cons resume-after-compact-message
(remove #(= resume-after-compact-message (select-keys % [:role :content])) user-messages))
:auto-compact
(assoc chat-ctx :auto-compacted? true)))
(assoc chat-ctx :post-compact-guard* (atom true))))

(defn ^:private auto-compact-suppressed?
"Whether auto-compaction must not run (again) in this turn: a preCompact hook
blocked it (`:auto-compacted?`, kept for the rest of the turn), the turn
reached `autoCompactMaxPerTurn`, or the chat was just compacted and no
response showed the compacted context under the threshold yet
(`:post-compact-guard*`). Compacting again before that could loop, as the
compacted context alone may still be too big. #391"
[{:keys [config agent auto-compactions post-compact-guard*] :as chat-ctx}]
(let [max-per-turn (lifecycle/auto-compact-max-per-turn config agent)]
(boolean (or (:auto-compacted? chat-ctx)
(and max-per-turn (>= (or auto-compactions 0) max-per-turn))
(some-> post-compact-guard* deref)))))

(defn ^:private release-post-compact-guard!
"Called on each usage update: once the compacted context is under the
auto-compact threshold, compaction may run again later in the same turn."
[{:keys [db* chat-id agent full-model config post-compact-guard*]}]
(when (and post-compact-guard*
@post-compact-guard*
(not (lifecycle/auto-compact? chat-id agent full-model config @db*)))
(reset! post-compact-guard* false)))

(defn ^:private trigger-compact!
"Compact the chat in the middle of a running turn, then resume the original
Expand Down Expand Up @@ -779,15 +807,20 @@
(defn ^:private trigger-auto-compact!
"Trigger auto-compact: send compact prompt, then resume the original task.
When a hook blocks compaction without stopping the turn, the original task is
(re)sent as a new prompt, marked so auto-compaction is not retried."
(re)sent as a new prompt, marked so auto-compaction is not retried.
Counts the auto-compactions of the turn, see `autoCompactMaxPerTurn`."
[chat-ctx all-tools user-messages]
(trigger-compact! chat-ctx all-tools user-messages
{:trigger "auto"
:on-blocked (fn []
(prompt-messages! user-messages
:auto-compact-blocked
(assoc chat-ctx :auto-compacted? true))
nil)}))
(let [{:keys [chat-id config agent auto-compactions] :as chat-ctx} (update chat-ctx :auto-compactions (fnil inc 0))]
(when (= (lifecycle/auto-compact-max-per-turn config agent) auto-compactions)
(logger/warn logger-tag "Last auto-compaction allowed in this turn" {:chat-id chat-id
:auto-compactions auto-compactions}))
(trigger-compact! chat-ctx all-tools user-messages
{:trigger "auto"
:on-blocked (fn []
(prompt-messages! user-messages
:auto-compact-blocked
(assoc chat-ctx :auto-compacted? true))
nil)})))

(defn ^:private log-api-switch!
"Log when the user swaps to a model whose api differs from the chat's
Expand Down Expand Up @@ -1171,6 +1204,7 @@
(logger/warn logger-tag "Failed to update provider quota" e)))))
on-usage-updated (fn [usage]
(when-let [usage (shared/usage-msg->usage usage full-model chat-ctx)]
(release-post-compact-guard! chat-ctx)
;; Never let the context-breakdown (a display-only
;; aid) throw into the streaming path, or the error
;; gets misclassified as a retryable provider error.
Expand Down Expand Up @@ -1239,7 +1273,7 @@
(db/save-chat! @db* chat-id metrics))))))))
(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)))
(not (auto-compact-suppressed? chat-ctx)))
(trigger-auto-compact! chat-ctx all-tools user-messages)
(start-prompt-worker! chat-ctx
(fn []
Expand Down Expand Up @@ -1455,7 +1489,7 @@
;; and the task resumes, so the LLM never sees them as text. #610
(consume-steered-native-command! chat-id db* chat-ctx)
(if (and (lifecycle/auto-compact? chat-id agent full-model config @db*)
(not (:auto-compacted? chat-ctx)))
(not (auto-compact-suppressed? chat-ctx)))
(trigger-auto-compact! chat-ctx tc-all-tools tc-user-messages)
(continue-turn!))))))))
received-msgs* add-to-history! user-messages)
Expand Down Expand Up @@ -1632,7 +1666,7 @@

(and (= :context-overflow error-type)
(not compacting?)
(not (:auto-compacted? chat-ctx))
(not (auto-compact-suppressed? chat-ctx))
compactable?)
(do
(logger/warn logger-tag "Context overflow detected, pruning tool results and auto-compacting"
Expand Down
7 changes: 7 additions & 0 deletions src/eca/features/chat/lifecycle.clj
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@
(or (get-in config [:agent agent-name :autoCompactPercentage])
(get-in config [:autoCompactPercentage])))

(defn auto-compact-max-per-turn
"How many times a single turn may auto-compact (`autoCompactMaxPerTurn`).
Per-agent value wins over the global one; nil when unset (no limit)."
[config agent-name]
(or (get-in config [:agent agent-name :autoCompactMaxPerTurn])
(get-in config [:autoCompactMaxPerTurn])))

(defn ^:private auto-compact-tokens-left
"Remaining-tokens threshold (`autoCompactTokensLeft`) at which the
conversation is auto-compacted. Per-agent value wins over the global one;
Expand Down
12 changes: 12 additions & 0 deletions test/eca/features/chat/lifecycle_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,18 @@
(testing "nil when auto-compact is disabled"
(is (nil? (lifecycle/auto-compact-threshold {} agent-name 1000000)))))

(deftest auto-compact-max-per-turn-test
(testing "returns the global value"
(is (= 3 (lifecycle/auto-compact-max-per-turn {:autoCompactMaxPerTurn 3} agent-name))))

(testing "respects per-agent autoCompactMaxPerTurn override"
(is (= 1 (lifecycle/auto-compact-max-per-turn {:agent {agent-name {:autoCompactMaxPerTurn 1}}
:autoCompactMaxPerTurn 3}
agent-name))))

(testing "nil when unset"
(is (nil? (lifecycle/auto-compact-max-per-turn {} agent-name)))))

(deftest auto-compact?-respects-in-progress-flags
(testing "returns nil when chat is already compacting"
(let [db (-> (db-with {:last-input-tokens 800 :last-output-tokens 0}
Expand Down
Loading
Loading