From 7cf11b3135f0bd9db2f77bc99536672a37393913 Mon Sep 17 00:00:00 2001 From: Jakub Zika Date: Wed, 29 Apr 2026 15:46:27 +0200 Subject: [PATCH] Fix repeated path-scoped rule fetch enforcement Treat a successfully fetched path-scoped rule as loaded for the current chat instead of only for the exact target path used during fetch_rule validation. --- CHANGELOG.md | 1 + docs/config/rules.md | 2 +- resources/prompts/tools/fetch_rule.md | 2 +- src/eca/features/prompt.clj | 2 +- src/eca/features/tools/fetch_rule.clj | 6 +- src/eca/features/tools/path_rules.clj | 15 +- test/eca/features/prompt_test.clj | 4 +- test/eca/features/tools_test.clj | 195 ++++++++++++++++---------- test/eca/shared_test.clj | 3 +- 9 files changed, 134 insertions(+), 96 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e8956451..e6faf7405 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Fix inline completion crash when renewing auth tokens before completion requests. #437 - Bugfix: avoid `Divide by zero` crash in chat auto-compact when models.dev reports `0` for a model's context/output limits (e.g. `openai/chatgpt-image-latest`); such limits are now normalized to `nil` and `auto-compact?` skips models without a known positive context window. - Bugfix: image edit follow-up turns no longer fail on the OpenAI Responses API when prior generations are replayed; generated images are now persisted under a dedicated `image_generation_call` history role and replayed as a user-role `input_image` data URL across providers. +- Bugfix: path-scoped rule enforcement now treats a fetched rule as loaded for the current chat, so matching files do not require fetching the same rule again. - Support regex patterns in markdown agent tool entries (e.g. `eca__shell_command(npm run .*)`) for fine-grained tool approval, currently limited to `eca__shell_command`. diff --git a/docs/config/rules.md b/docs/config/rules.md index 480746de4..0c96911fb 100644 --- a/docs/config/rules.md +++ b/docs/config/rules.md @@ -181,7 +181,7 @@ Use this rule of thumb: - Add `enforce: read` or `enforce: modify` when the model must fetch the matching rule before using the corresponding builtin file tool. - Use `agent` and `model` filters when the rule is only relevant for specific chat modes or model families. -Path-scoped rules keep the base prompt smaller while still making file-specific guidance available. When the model calls `fetch_rule`, ECA validates the exact rule id and absolute target path, renders the rule content, and records that the rule was fetched for that path in the current chat. You can also use this to influence behavior for a specific provider. For example, if you want more tool calls instead of user prompts, you can do something like: +Path-scoped rules keep the base prompt smaller while still making file-specific guidance available. When the model calls `fetch_rule`, ECA validates the exact rule id and absolute target path, renders the rule content, and records that the rule was fetched in the current chat. You can also use this to influence behavior for a specific provider. For example, if you want more tool calls instead of user prompts, you can do something like: ```markdown title=".eca/rules/copilot-ask-user.md" --- model: "github-copilot/.*" diff --git a/resources/prompts/tools/fetch_rule.md b/resources/prompts/tools/fetch_rule.md index f2d2b51aa..ac028bc7d 100644 --- a/resources/prompts/tools/fetch_rule.md +++ b/resources/prompts/tools/fetch_rule.md @@ -1,4 +1,4 @@ Fetch the full content of a path-scoped rule by its exact id and the exact absolute target path you plan to work with. Path-scoped rules are listed in the system prompt and also repeated in this tool description with their id, name, scope, workspace root when relevant, path patterns, and enforce attribute. Path matching uses Java NIO `PathMatcher` glob syntax against workspace-relative paths. Unlike most editor and shell-style glob matchers, patterns containing `**/` do not match the zero-directory case: `**/*.clj` does not match `foo.clj`, and `src/**/*.clj` matches nested files under `src/` but not `src/foo.clj`. -Each rule has an enforce attribute that determines when you must fetch it: `modify` (default) means fetch before editing a matching file; `read` means fetch before reading; `modify,read` means fetch before both. Copy the exact rule id from the catalog, pass the exact absolute target path, and call this tool to validate the match and get the rule's full content. If the tool reports a mismatch, choose a different rule or correct the path. Fetch each matching rule only once per target path per chat — once you have the tool output, you don't need to fetch it again. Re-fetching a previously fetched rule for the same path will return a short confirmation instead of the full content. +Each rule has an enforce attribute that determines when you must fetch it: `modify` (default) means fetch before editing a matching file; `read` means fetch before reading; `modify,read` means fetch before both. Copy the exact rule id from the catalog, pass the exact absolute target path, and call this tool to validate the match and get the rule's full content. If the tool reports a mismatch, choose a different rule or correct the path. Fetch each matching rule only once per chat — once you have the tool output, the same rule does not need to be fetched again for other paths it matches. Re-fetching a previously fetched rule will return a short confirmation instead of the full content. diff --git a/src/eca/features/prompt.clj b/src/eca/features/prompt.clj index b52a5e731..6e55cf9c2 100644 --- a/src/eca/features/prompt.clj +++ b/src/eca/features/prompt.clj @@ -202,7 +202,7 @@ (remove #(= :global (:scope %))) (map :content)) path-scoped-section (when (and fetch-rule-available? (seq path-scoped-rules)) - ["" + ["" (path-scoped-rule-catalog path-scoped-rules) ""]) has-static-rules? (seq rendered-static-rules)] diff --git a/src/eca/features/tools/fetch_rule.clj b/src/eca/features/tools/fetch_rule.clj index 4840e8eaa..52638ce25 100644 --- a/src/eca/features/tools/fetch_rule.clj +++ b/src/eca/features/tools/fetch_rule.clj @@ -70,10 +70,10 @@ :contents [{:type :text :text (path-mismatch-message rule match-info)}]} - (f.tools.path-rules/validated-rule? db chat-id (:path match-info) rule-id) + (f.tools.path-rules/validated-rule? db chat-id rule-id) {:error false :contents [{:type :text - :text (str "**" (:name rule) "** — already loaded for this path, reuse the previously fetched content.")}]} + :text (str "**" (:name rule) "** — already loaded in this chat, reuse the previously fetched content.")}]} :else (do @@ -87,7 +87,7 @@ :text (str header "\n" content)}]} {:error false :contents [{:type :text - :text (str header "\nThis rule contains no usable content for the current chat context and does not need to be loaded again for this path.")}]})))))) + :text (str header "\nThis rule contains no usable content for the current chat context and does not need to be loaded again in this chat.")}]})))))) (defn ^:private describe-rule [{rule-name :name :keys [id paths enforce scope workspace-root]}] diff --git a/src/eca/features/tools/path_rules.clj b/src/eca/features/tools/path_rules.clj index fe0b849d3..40f14107b 100644 --- a/src/eca/features/tools/path_rules.clj +++ b/src/eca/features/tools/path_rules.clj @@ -14,17 +14,12 @@ (tools.util/tool-available? all-tools "eca__fetch_rule")) (defn record-validated-rule! - [db* chat-id rule match-info] - (when-let [path (some-> (:path match-info) shared/normalize-path)] - (swap! db* assoc-in [:chats chat-id validated-path-rules-key path (:id rule)] - {:matched-pattern (:matched-pattern match-info) - :rule-path (:path rule) - :workspace-root (:workspace-root match-info)}))) + [db* chat-id rule _match-info] + (swap! db* update-in [:chats chat-id validated-path-rules-key] (fnil conj #{}) (:id rule))) (defn validated-rule? - [db chat-id target-path rule-id] - (boolean - (get-in db [:chats chat-id validated-path-rules-key (shared/normalize-path target-path) rule-id]))) + [db chat-id rule-id] + (contains? (get-in db [:chats chat-id validated-path-rules-key] #{}) rule-id)) (defn enforce-on-modify? "Returns true if the rule should be enforced before file modification. @@ -57,7 +52,7 @@ (when target-path (->> (applicable-path-scoped-rules config db chat-id agent all-tools target-path) (filter (fn [{:keys [rule]}] (enforce? rule))) - (remove (fn [{:keys [rule]}] (validated-rule? db chat-id target-path (:id rule)))) + (remove (fn [{:keys [rule]}] (validated-rule? db chat-id (:id rule)))) vec not-empty)))) diff --git a/test/eca/features/prompt_test.clj b/test/eca/features/prompt_test.clj index 726417585..34b63b0f7 100644 --- a/test/eca/features/prompt_test.clj +++ b/test/eca/features/prompt_test.clj @@ -37,7 +37,7 @@ (is (string/includes? static "")) (is (string/includes? static "First rule")) (is (string/includes? static "Second rule")) - (is (string/includes? static "")) + (is (string/includes? static "")) (is (string/includes? static "")) (is (string/includes? static "")) (is (string/includes? static "")) @@ -93,7 +93,7 @@ :workspace-root "/workspace/a" :paths ["**/*.clj" "**/*.cljs"]}] {:keys [static]} (build-instructions [] [] path-scoped-rules [] (delay "TREE") "code" {} nil [{:full-name "eca__fetch_rule"}] (h/db))] - (is (string/includes? static "")) + (is (string/includes? static "")) (is (string/includes? static "")) (is (string/includes? static "")) (is (not (string/includes? static "