From e53bd1e43fe8c1f10dff6180cc07429f3a6b3be5 Mon Sep 17 00:00:00 2001 From: Jakub Zika Date: Fri, 24 Apr 2026 18:45:50 +0200 Subject: [PATCH 1/2] Improve rules with filters, conditions, and scoped loading - Add YAML frontmatter support for filtering rules by agent, model, paths, and enforcement mode. - Split rules into static prompt-injected rules and path-scoped rules that can be loaded on demand with `fetch_rule`. - Add condition-variable rendering, `/rules` inspection, docs, and tests for the expanded rules behavior. - Add template variables documentation (as that can be used in Rules as well) --- CHANGELOG.md | 1 + docs/config/agents.md | 2 +- docs/config/rules.md | 175 +++++- docs/config/template.md | 37 ++ mkdocs.yml | 1 + resources/prompts/tools/fetch_rule.md | 4 + src/eca/config.clj | 5 +- src/eca/features/chat.clj | 37 +- src/eca/features/commands.clj | 38 ++ src/eca/features/prompt.clj | 166 ++++-- src/eca/features/rules.clj | 234 ++++++-- src/eca/features/tools.clj | 63 ++- src/eca/features/tools/fetch_rule.clj | 137 +++++ src/eca/features/tools/filesystem.clj | 75 +-- src/eca/features/tools/path_rules.clj | 97 ++++ src/eca/features/tools/skill.clj | 2 + src/eca/features/tools/util.clj | 13 +- src/eca/oauth.clj | 32 +- src/eca/shared.clj | 94 +++- test/eca/features/chat_test.clj | 60 ++ test/eca/features/commands_test.clj | 116 +++- test/eca/features/prompt_test.clj | 105 +++- test/eca/features/rules_test.clj | 587 +++++++++++++++++++- test/eca/features/tools/filesystem_test.clj | 42 +- test/eca/features/tools_test.clj | 233 ++++++++ test/eca/shared_test.clj | 69 ++- 26 files changed, 2182 insertions(+), 243 deletions(-) create mode 100644 docs/config/template.md create mode 100644 resources/prompts/tools/fetch_rule.md create mode 100644 src/eca/features/tools/fetch_rule.clj create mode 100644 src/eca/features/tools/path_rules.clj diff --git a/CHANGELOG.md b/CHANGELOG.md index e3333a214..d10e59362 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Improve rules with frontmatter filters, condition variables, path-scoped loading, enforcement support, and clearer documentation. #222 - Add support for gpt-5.5 variants ## 0.129.1 diff --git a/docs/config/agents.md b/docs/config/agents.md index a3819f0a7..3f23a8e4d 100644 --- a/docs/config/agents.md +++ b/docs/config/agents.md @@ -55,7 +55,7 @@ This is useful when you want a variant of a built-in or custom agent with small ## Custom agents and prompts -You can create an agent and define its prompt, tool call approval and default model. +You can create an agent and define its prompt, tool call approval and default model. Custom prompts can use [template variables](template.md#condition-variables). === "Example: my-agent" diff --git a/docs/config/rules.md b/docs/config/rules.md index 36fc28dbe..31cc45ac7 100644 --- a/docs/config/rules.md +++ b/docs/config/rules.md @@ -4,33 +4,188 @@ description: "Configure ECA rules: define coding standards, conventions, and con # Rules -Rules are contexts that are passed to the LLM during a prompt and are useful to tune prompts or LLM responses. -Rules are text files (typically `.md`, but any format works): +Rules let you influence how the model behaves without changing an agent prompt. Use them for coding standards, review checklists, project conventions, safety constraints, or file-specific guidance. -There are 3 possible ways to configure rules following this order of priority: +A rule is a Markdown file. By default, rule content is rendered into the system prompt for every chat where the rule matches. Add YAML frontmatter when a rule should apply only to specific agents, models, or paths. + +## Rule locations + +ECA loads rules from 3 sources: === "Project file" - A `.eca/rules` folder from the workspace root containing `.md` files with the rules. + Files inside `.eca/rules` under a workspace root. - ```markdown title=".eca/rules/talk_funny.md" - - Talk funny like Mickey! + ```markdown title=".eca/rules/code-style.md" + Prefer small, focused functions and idiomatic Clojure. ``` === "Global file" - A `$XDG_CONFIG_HOME/eca/rules` or `~/.config/eca/rules` folder containing `.md` files with the rules. + Files inside `$XDG_CONFIG_HOME/eca/rules` or `~/.config/eca/rules`. - ```markdown title="~/.config/eca/rules/talk_funny.md" - - Talk funny like Mickey! + ```markdown title="~/.config/eca/rules/answers.md" + Be concise and explain trade-offs when suggesting code changes. ``` === "Config" - Just add to your config the `:rules` pointing to `.md` files that will be searched from the workspace root if not an absolute path: + Paths listed in the `rules` config key. Relative paths are searched from each workspace root. Absolute paths inside a workspace behave as project rules; absolute paths outside workspaces behave as global rules. ```javascript title="~/.config/eca/config.json" { "rules": [{"path": "my-rule.md"}] } ``` + +## Static and path-scoped rules + +Most rules should be **static rules**: rules without `paths`. Their full content is automatically included in the system prompt. Use them for guidance that should always be available, such as coding style, response tone, or repository-wide conventions. + +```markdown title=".eca/rules/clojure-style.md" +Prefer immutable data, kebab-case names, and small functions. +``` + +Use **path-scoped rules** when the guidance only matters for matching files. Add `paths` in frontmatter. Instead of injecting the full rule into every prompt, ECA lists a small catalog of matching path-scoped rules. The model can then call `fetch_rule` with the exact rule id and target path to load the full content when needed. + +```markdown title=".eca/rules/html-style.md" +--- +paths: "**.html" +--- + +Use semantic HTML and keep accessibility in mind. +``` + +If `fetch_rule` is unavailable in the current chat, path-scoped rules are treated as inactive and are omitted from the prompt. Use the `/rules` command to inspect which rules are available for the current agent and model. + +## Frontmatter + +Rules support YAML frontmatter. Recognized fields are `agent`, `model`, `paths`, and `enforce`. Rules without frontmatter are static rules that apply to all agents and models. + +### `agent` + +Restricts a rule to one agent or a list of agents. + +```markdown title=".eca/rules/code-only.md" +--- +agent: code +--- + +Prefer making the smallest safe code change. +``` + +```markdown title=".eca/rules/shared.md" +--- +agent: + - code + - plan +--- + +Call out risky assumptions before proceeding. +``` + +### `model` + +Restricts a rule to models whose full model identifier matches a regex pattern. The full identifier includes the provider, such as `anthropic/claude-sonnet-4-20250514` or `openai/gpt-5.2`. + +```markdown title=".eca/rules/high-reasoning-models.md" +--- +model: + - ".*claude-sonnet-4.*" + - ".*gpt-5.*" +--- + +Spend more time validating edge cases before editing. +``` + +Patterns are partial-match regexes using `re-find`, so `gpt-4` also matches `openai/gpt-4o-mini`. Use anchors (`^...$`) when you need an exact match. + +### `paths` + +Marks a rule as path-scoped. It accepts one glob pattern or a list of patterns matched against workspace-relative paths. + +```markdown title=".eca/rules/frontend.md" +--- +paths: + - "src/**.{ts,tsx}" + - "lib/**.ts" +--- + +Follow the project's frontend conventions. +``` + +For project rules, patterns are matched relative to the workspace root that owns the rule. For global rules, patterns can match files inside any current workspace root. + +Path matching uses Java NIO `PathMatcher` glob syntax. Common patterns: + +| Pattern | Matches | Does not match | +|---------|---------|----------------| +| `src/*.clj` | `src/foo.clj` | `src/nested/foo.clj` | +| `src/**/*.clj` | `src/nested/foo.clj` | `src/foo.clj` | +| `src/**.clj` | `src/foo.clj`, `src/nested/foo.clj` | `test/foo.clj` | +| `docs/**.md` | `docs/index.md`, `docs/config/rules.md` | `src/docs.md` | + +Unlike many shell glob matchers, `**/` does not match the zero-directory case. For example, `src/**/*.clj` matches `src/nested/foo.clj`, but not `src/foo.clj`. Use `src/**.clj` or `{src/*.clj,src/**/*.clj}` when you need both. + +### `enforce` + +`enforce` only applies to path-scoped rules. It has no effect without `paths`, because static rules are already included in the system prompt. + +For matching path-scoped rules, `enforce` controls when ECA requires the rule to be fetched before a builtin file tool proceeds. + +| Value | Meaning | +|-------|---------| +| `modify` | Fetch before modifying a matching file. This is the default. | +| `read` | Fetch before reading a matching file with `read_file`. | +| `[read, modify]` | Fetch before both reading and modifying. | + +```markdown title=".eca/rules/api-style.md" +--- +paths: "src/api/**.ts" +--- + +Follow the API naming and error-response conventions. +``` + +```markdown title=".eca/rules/sensitive-data.md" +--- +paths: "src/data/**.clj" +enforce: + - read + - modify +--- + +Never expose PII fields in API responses. +``` + +When `enforce` is omitted, it defaults to `modify`. + +## How to use rules + +Use this rule of thumb: + +- Use a static rule when the model should always know the instruction. +- Use a path-scoped rule when the instruction only matters for certain files. +- 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: +```markdown title=".eca/rules/copilot-ask-user.md" +--- +model: "github-copilot/.*" +--- + +Strongly prefer using the `eca__ask_user` tool over ending your turn or asking questions inline in your response. +``` + +## Condition variables + +Rules support [template variables](template.md#condition-variables) using [Selmer](https://github.com/yogthos/Selmer). If a rule renders to an empty string, ECA skips it. + +```markdown title=".eca/rules/context-aware.md" +{% if isSubagent %} +Be concise and return only the final result. +{% else %} +Provide explanations and mention important trade-offs. +{% endif %} +``` diff --git a/docs/config/template.md b/docs/config/template.md new file mode 100644 index 000000000..c18d3c5b5 --- /dev/null +++ b/docs/config/template.md @@ -0,0 +1,37 @@ +--- +description: "Use templates and condition variables in ECA configuration files, prompts, and rules." +--- + +# Templates + +ECA supports templating in configurable prompts and rules, so instructions can adapt to the current chat context. + +For loading files, environment variables, classpath resources, or netrc credentials in config strings, see [Dynamic string contents](introduction.md#dynamic-string-contents). + +## Condition variables + +Custom agent prompts and [rules](rules.md) are rendered with [Selmer](https://github.com/yogthos/Selmer). Use condition variables when one prompt or rule should behave differently depending on the current chat. + +Available variables: + +| Variable | Type | Description | +|----------|------|-------------| +| `isSubagent` | boolean | `true` when the chat is running as a subagent | +| `workspaceRoots` | string | The current workspace root paths | +| `toolEnabled_` | boolean | `true` when a tool is enabled, using its exact full name, e.g. `toolEnabled_eca__shell_command` | + +```markdown title="Prompt or rule" +{% if isSubagent %} +Be concise and return only the final result. +{% else %} +Explain important trade-offs and assumptions. +{% endif %} + +{% if toolEnabled_eca__shell_command %} +You can run shell commands to verify your work. +{% endif %} + +Current workspace roots: {{ workspaceRoots }} +``` + +If a rule renders to an empty string, ECA skips it and does not add an empty rule block to the system prompt. diff --git a/mkdocs.yml b/mkdocs.yml index ec1ec297a..d20548ee8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -15,6 +15,7 @@ nav: - install.md - Configuration: - Introduction: config/introduction.md + - Templates: config/template.md - Providers / Models: config/models.md - Tools / Approval: config/tools.md - Variants: config/variants.md diff --git a/resources/prompts/tools/fetch_rule.md b/resources/prompts/tools/fetch_rule.md new file mode 100644 index 000000000..f2d2b51aa --- /dev/null +++ b/resources/prompts/tools/fetch_rule.md @@ -0,0 +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. diff --git a/src/eca/config.clj b/src/eca/config.clj index 6503b4e9f..90f5c841c 100644 --- a/src/eca/config.clj +++ b/src/eca/config.clj @@ -123,6 +123,7 @@ "eca__editor_diagnostics" {} "eca__skill" {} "eca__task" {} + "eca__fetch_rule" {} "eca__spawn_agent" {}} :deny {"eca__shell_command" {:argsMatchers {"command" dangerous-commands-regexes}}}}}} @@ -144,7 +145,8 @@ "eca__grep" {} "eca__editor_diagnostics" {} "eca__skill" {} - "eca__task" {}} + "eca__task" {} + "eca__fetch_rule" {}} :deny {"eca__shell_command" {:argsMatchers {"command" dangerous-commands-regexes}}}}}} "general" {:mode "subagent" @@ -175,6 +177,7 @@ "eca__skill" {} "eca__task" {} "eca__ask_user" {} + "eca__fetch_rule" {} "eca__spawn_agent" {}} :ask {} :deny {}} diff --git a/src/eca/features/chat.clj b/src/eca/features/chat.clj index 50f2820d8..1b8464a87 100644 --- a/src/eca/features/chat.clj +++ b/src/eca/features/chat.clj @@ -1076,7 +1076,14 @@ :else (default-model db config)))) - rules (f.rules/all config (:workspace-folders db)) + _ (when (seq contexts) + (lifecycle/send-content! {:messenger messenger :chat-id chat-id} :system {:type :progress + :state :running + :text "Parsing given context"})) + refined-contexts (concat + (f.context/agents-file-contexts db) + (f.context/raw-contexts->refined contexts db)) + {static-rules :static path-scoped-rules :path-scoped} (f.rules/all-rules config (:workspace-folders db) agent full-model) all-tools (f.tools/all-tools chat-id agent @db* config) skills (->> (f.skills/all config (:workspace-folders db)) (remove @@ -1087,25 +1094,20 @@ db config agent))))) - _ (when (seq contexts) - (lifecycle/send-content! {:messenger messenger :chat-id chat-id} :system {:type :progress - :state :running - :text "Parsing given context"})) - refined-contexts (concat - (f.context/agents-file-contexts db) - (f.context/raw-contexts->refined contexts db)) repo-map* (delay (f.index/repo-map db config {:as-string? true})) prompt-cache (get-in db [:chats chat-id :prompt-cache]) - cached-static (when (= (:agent prompt-cache) agent) - (:static prompt-cache)) - instructions (if cached-static - {:static cached-static + instructions (if (and prompt-cache + (= (:agent prompt-cache) agent) + (= (:model prompt-cache) full-model)) + {:static (:static prompt-cache) :dynamic (f.prompt/build-dynamic-instructions refined-contexts db)} (let [result (f.prompt/build-chat-instructions - refined-contexts rules skills repo-map* + refined-contexts static-rules path-scoped-rules skills repo-map* agent config chat-id all-tools db)] - (swap! db* update-in [:chats chat-id :prompt-cache] - assoc :static (:static result) :agent agent) + (swap! db* assoc-in [:chats chat-id :prompt-cache] + {:static (:static result) + :agent agent + :model full-model}) result)) image-contents (->> refined-contexts (filter #(= :image (:type %)))) @@ -1355,7 +1357,6 @@ :messenger messenger})) {})) - (defn ^:private find-last-message-idx "Find the last message index matching content-id by checking both :content-id (user messages) and [:content :id] (tool calls, etc)." @@ -1446,7 +1447,7 @@ (remove :subagent) (sort-by (fn [c] (or (get c primary) (get c secondary) 0)) >) (mapv (fn [{:keys [id title status created-at updated-at model messages]}] - (shared/assoc-some + (assoc-some {:id id :title title :status (or status :idle) @@ -1479,4 +1480,4 @@ (send-chat-contents! messages chat-ctx) (lifecycle/send-content! chat-ctx :system (assoc-some {:type :metadata} :title title)) (config/notify-selected-model-changed! (:model chat) db* messenger config) - {:found? true :chat-id chat-id :title title})))) \ No newline at end of file + {:found? true :chat-id chat-id :title title})))) diff --git a/src/eca/features/commands.clj b/src/eca/features/commands.clj index 5312e22b7..aa4ab7595 100644 --- a/src/eca/features/commands.clj +++ b/src/eca/features/commands.clj @@ -10,8 +10,10 @@ [eca.features.login :as f.login] [eca.features.plugins :as f.plugins] [eca.features.prompt :as f.prompt] + [eca.features.rules :as f.rules] [eca.features.skills :as f.skills] [eca.features.tools.mcp :as f.mcp] + [eca.features.tools.util :as tools.util] [eca.llm-api :as llm-api] [eca.llm-util :as llm-util] [eca.messenger :as messenger] @@ -161,6 +163,10 @@ :type :native :description "Actual repoMap of current session." :arguments []} + {:name "rules" + :type :native + :description "List available rules and their filtering metadata (agent, model, paths)." + :arguments []} {:name "prompt-show" :type :native :description "Prompt sent to LLM as system instructions." @@ -326,6 +332,34 @@ existing-files)) "Credential files: None found")))) +(defn ^:private rules-msg [config roots agent full-model all-tools] + (let [{static :static path-scoped :path-scoped} (f.rules/all-rules config roots agent full-model) + visible-path-scoped (when (tools.util/tool-available? all-tools "eca__fetch_rule") + path-scoped) + format-content-preview (fn [content] + (let [content (or content "") + preview (subs content 0 (min 120 (count content)))] + (str preview (when (> (count content) 120) "...")))) + format-rule (fn [{rule-name :name :keys [scope path agents models paths content]} include-content?] + (str "### " rule-name " (" (some-> scope name) ")\n" + "Source: " path "\n" + "Agent filter: " (if agents (string/join ", " agents) "all") "\n" + "Model filter: " (if models (string/join ", " models) "all") "\n" + (when paths + (str "Path filter: " (string/join ", " paths) "\n")) + (when include-content? + (str "Content preview: " (format-content-preview content) "\n")) + "\n"))] + (if (or (seq static) (seq visible-path-scoped)) + (str + (when (seq static) + (str "Static rules (full content is included directly in the system prompt):\n\n" + (reduce (fn [s rule] (str s (format-rule rule true))) "" static))) + (when (seq visible-path-scoped) + (str "Path-scoped rules (only a catalog is included in the system prompt; load full content with fetch_rule using the rule id and target path):\n\n" + (reduce (fn [s rule] (str s (format-rule rule false))) "" visible-path-scoped)))) + "No rules available for the current agent and model."))) + (defn handle-command! [command args {:keys [chat-id db* config messenger full-model agent all-tools instructions user-messages metrics] :as chat-ctx}] (let [db @db* custom-cmds (custom-commands config (:workspace-folders db)) @@ -547,6 +581,10 @@ :chats {chat-id {:messages [{:role "system" :content [{:type :text :text (doctor-msg db config)}]}]}}} "repo-map-show" {:type :chat-messages :chats {chat-id {:messages [{:role "system" :content [{:type :text :text (f.index/repo-map db config {:as-string? true})}]}]}}} + "rules" (let [roots (:workspace-folders db) + msg (rules-msg config roots agent full-model all-tools)] + {:type :chat-messages + :chats {chat-id {:messages [{:role "system" :content [{:type :text :text msg}]}]}}}) "prompt-show" (let [full-prompt (str "Instructions:\n" instructions "\n" "Prompt:\n" (reduce (fn [s {:keys [content]}] diff --git a/src/eca/features/prompt.clj b/src/eca/features/prompt.clj index db4f5c72f..b52a5e731 100644 --- a/src/eca/features/prompt.clj +++ b/src/eca/features/prompt.clj @@ -5,9 +5,9 @@ [clojure.string :as string] [eca.features.skills :as f.skills] [eca.features.tools.mcp :as f.mcp] + [eca.features.tools.util :as tools.util] [eca.logger :as logger] - [eca.shared :refer [multi-str] :as shared] - [selmer.parser :as selmer]) + [eca.shared :refer [multi-str] :as shared]) (:import [java.util Map])) @@ -92,7 +92,7 @@ (str "\n\n" startup-ctx "\n\n\n")) "")) -(defn ^:private ->base-selmer-ctx +(defn ->base-selmer-ctx ([all-tools db] (->base-selmer-ctx all-tools nil db)) ([all-tools chat-id db] @@ -130,24 +130,96 @@ "Context types that change between turns and belong in the dynamic prompt block." #{:cursor :mcpResource}) +(defn ^:private rule-section + [tag description rendered-rules] + (when (seq rendered-rules) + [(format "<%s description=\"%s\">" tag description) + (string/join "\n" rendered-rules) + (format "" tag) + ""])) + +(defn ^:private path-scoped-rule-attrs + [{rule-name :name :keys [id paths enforce scope workspace-root]}] + (let [attr (fn [[k v]] + (when v + (str " " k "=\"" v "\"")))] + (->> [["id" id] + ["name" rule-name] + ["scope" (name scope)] + ["workspace-root" workspace-root] + ["paths" (string/join "," paths)] + ["enforce" (string/join "," (or enforce ["modify"]))]] + (keep attr) + string/join))) + +(defn ^:private path-scoped-rule-catalog-entry + [rule] + (str "")) + +(defn ^:private path-scoped-rule-sections + [path-scoped-rules render-entry] + (let [global-rules (filter #(= :global (:scope %)) path-scoped-rules) + project-rules (filter #(= :project (:scope %)) path-scoped-rules)] + (multi-str + (when (seq global-rules) + ["" + (->> global-rules + (map render-entry) + (string/join "\n")) + ""]) + (->> project-rules + (group-by :workspace-root) + (sort-by first) + (map (fn [[workspace-root rules]] + (str "\n" + (->> rules + (map render-entry) + (string/join "\n")) + "\n"))))))) + +(defn ^:private path-scoped-rule-catalog + [path-scoped-rules] + (path-scoped-rule-sections path-scoped-rules path-scoped-rule-catalog-entry)) + (defn build-static-instructions "Builds the stable portion of the system prompt: agent prompt, rules, skills, - stable contexts, and additional system info. Stable within a session — callers - should cache the result in [:chats chat-id :prompt-cache :static]." - [refined-contexts rules skills repo-map* agent-name config chat-id all-tools db] + stable contexts, and additional system info. Volatile contexts and MCP server + instructions are handled by build-dynamic-instructions." + [refined-contexts static-rules path-scoped-rules skills repo-map* agent-name config chat-id all-tools db] (let [selmer-ctx (->base-selmer-ctx all-tools chat-id db) - stable-contexts (remove #(volatile-context-types (:type %)) refined-contexts)] + stable-contexts (remove #(volatile-context-types (:type %)) refined-contexts) + rendered-static-rules (keep (fn [{:keys [name content scope]}] + (when-let [rendered (shared/safe-selmer-render content selmer-ctx (str "rule:" name) nil)] + (when-not (string/blank? rendered) + {:scope (if (= :global scope) :global :project) + :content (format "%s" name rendered)}))) + static-rules) + fetch-rule-available? (tools.util/tool-available? all-tools "eca__fetch_rule") + global-rules (->> rendered-static-rules + (filter #(= :global (:scope %))) + (map :content)) + project-rules (->> rendered-static-rules + (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)] (multi-str - (selmer/render (eca-chat-prompt agent-name config) selmer-ctx) - (when (seq rules) + (shared/safe-selmer-render (eca-chat-prompt agent-name config) + selmer-ctx "chat-prompt") + (when (or has-static-rules? path-scoped-section) ["## Rules" "" - "\n" - (reduce - (fn [rule-str {:keys [name content]}] - (str rule-str (format "%s\n" name content))) - "" - rules) + "" + (rule-section "global-rules" + "Broader rules loaded outside the current workspace. Project rules below are more specific if guidance conflicts." + global-rules) + (rule-section "project-rules" + "Rules loaded from the current workspace. Prefer these when they conflict with broader global rules." + project-rules) + path-scoped-section "" ""]) (when (seq skills) @@ -166,7 +238,8 @@ "" (contexts-str stable-contexts repo-map* (get-in db [:chats chat-id :startup-context]))]) "" - (selmer/render (load-builtin-prompt "additional_system_info.md") selmer-ctx)))) + (shared/safe-selmer-render (load-builtin-prompt "additional_system_info.md") + selmer-ctx "additional-system-info")))) (defn build-dynamic-instructions "Builds the volatile portion of the system prompt: cursor/MCP resource contexts @@ -181,12 +254,11 @@ (defn build-chat-instructions "Returns {:static \"...\" :dynamic \"...\"}. - Static content (agent prompt, rules, skills, stable contexts) is stable within a session. - Dynamic content (cursor, MCP resources, MCP instructions) is recomputed every turn. - Callers should cache :static in [:chats chat-id :prompt-cache :static] for - Anthropic API cache prefix stability across turns." - [refined-contexts rules skills repo-map* agent-name config chat-id all-tools db] - {:static (build-static-instructions refined-contexts rules skills repo-map* + Static content (agent prompt, rules, skills, stable contexts) can be cached + when unchanged across turns. + Dynamic content (cursor, MCP resources, MCP instructions) is recomputed every turn." + [refined-contexts static-rules path-scoped-rules skills repo-map* agent-name config chat-id all-tools db] + {:static (build-static-instructions refined-contexts static-rules path-scoped-rules skills repo-map* agent-name config chat-id all-tools db) :dynamic (build-dynamic-instructions refined-contexts db)}) @@ -216,43 +288,46 @@ ;; Resource path :else (load-builtin-prompt (some-> legacy-prompt-file (string/replace-first #"prompts/" ""))))] - (selmer/render prompt-str - (merge - (->base-selmer-ctx all-tools db) - {:text text - :path (when path - (str "- File path: " path)) - :rangeText (multi-str - (str "- Start line: " (-> range :start :line)) - (str "- Start character: " (-> range :start :character)) - (str "- End line: " (-> range :end :line)) - (str "- End character: " (-> range :end :character))) - :fullText (when full-text - (multi-str - "- Full file content" - "```" - full-text - "```"))})))) + (shared/safe-selmer-render prompt-str + (merge + (->base-selmer-ctx all-tools db) + {:text text + :path (when path + (str "- File path: " path)) + :rangeText (multi-str + (str "- Start line: " (-> range :start :line)) + (str "- Start character: " (-> range :start :character)) + (str "- End line: " (-> range :end :line)) + (str "- End character: " (-> range :end :character))) + :fullText (when full-text + (multi-str + "- Full file content" + "```" + full-text + "```"))}) + "rewrite-prompt"))) (defn init-prompt [all-tools agent-name db config] - (selmer/render + (shared/safe-selmer-render (get-config-prompt :init agent-name config) - (->base-selmer-ctx all-tools db))) + (->base-selmer-ctx all-tools db) + "init-prompt")) (defn skill-create-prompt [skill-name user-prompt all-tools agent-name db config] - (selmer/render + (shared/safe-selmer-render (get-config-prompt :skillCreate agent-name config) (merge (->base-selmer-ctx all-tools db) {:skillFilePath (str (fs/file (f.skills/global-skills-dir) skill-name "SKILL.md")) :skillName skill-name - :userPrompt user-prompt}))) + :userPrompt user-prompt}) + "skill-create-prompt")) (defn chat-title-prompt [agent-name config] (get-config-prompt :chatTitle agent-name config)) (defn compact-prompt [additional-input all-tools agent-name config db] - (selmer/render + (shared/safe-selmer-render (or (:compactPrompt config) ;; legacy (get-config-prompt :compact agent-name config) (compact-prompt-template (:compactPromptFile config)) ;; legacy @@ -261,7 +336,8 @@ (->base-selmer-ctx all-tools db) {:additionalUserInput (if additional-input (format "You MUST respect this user input in the summarization: %s." additional-input) - "")}))) + "")}) + "compact-prompt")) (defn inline-completion-prompt [config] (let [legacy-config-file-prompt (get-in config [:completion :systemPromptFile]) diff --git a/src/eca/features/rules.clj b/src/eca/features/rules.clj index 914bb3d2d..3c396f08d 100644 --- a/src/eca/features/rules.clj +++ b/src/eca/features/rules.clj @@ -1,12 +1,134 @@ (ns eca.features.rules (:require - [babashka.fs :as fs] [clojure.java.io :as io] + [clojure.string :as string] + [babashka.fs :as fs] [eca.config :as config] - [eca.shared :as shared])) + [eca.logger :as logger] + [eca.shared :as shared :refer [assoc-some]]) + (:import + [java.nio.file FileSystems Path PathMatcher])) (set! *warn-on-reflection* true) +(defn ^:private normalize-string-or-seq + [field] + (cond + (and (string? field) (not (string/blank? field))) [(string/trim field)] + (sequential? field) (->> field + (map string/trim) + (remove string/blank?) + vec + not-empty))) + +(defn ^:private workspace-root-for-path + [roots path] + (let [path (shared/normalize-path path)] + (->> roots + (keep (fn [{:keys [uri]}] + (let [root (shared/normalize-path (shared/uri->filename uri))] + (when (shared/path-inside-root? path root) + root)))) + (sort-by #(count (str %)) >) + first))) + +(defn ^:private rule-file->rule + ([type path content] + (rule-file->rule type path content {})) + ([type path content {:keys [workspace-root]}] + (let [path (shared/normalize-path path) + workspace-root (some-> workspace-root shared/normalize-path) + base-rule (cond-> {:id path + :name (fs/file-name path) + :path path + :type type + :scope (if workspace-root :project :global)} + workspace-root (assoc :workspace-root workspace-root))] + (try + (let [{:keys [body agent model paths enforce]} (shared/parse-md content) + agents (normalize-string-or-seq agent) + models (normalize-string-or-seq model) + parsed-paths (normalize-string-or-seq paths) + parsed-enforce (normalize-string-or-seq enforce)] + (assoc-some (assoc base-rule :content body) + :agents agents + :models models + :paths parsed-paths + :enforce parsed-enforce)) + (catch Exception e + (logger/warn "Failed to parse rule file, skipping" (str path) (ex-message e)) + nil))))) + +(defn ^:private agent-matches? + [rule agent-name] + (let [agents (:agents rule)] + (or (nil? agents) + (nil? agent-name) + (some #(= agent-name %) agents)))) + +(defn ^:private model-matches? + "Check if a rule's model patterns match the current full-model string. + Patterns are regex strings matched against the full model identifier + (e.g. \"anthropic/claude-sonnet-4-20250514\" or \"github-copilot/.*\" for provider matching). + Returns true if rule has no :models (matches all models)." + [rule full-model] + (if-let [patterns (:models rule)] + (boolean + (when full-model + (some (fn [^String pattern] + (try + (re-find (re-pattern pattern) full-model) + (catch Exception e + (logger/warn "Invalid model regex pattern" (pr-str pattern) (ex-message e)) + false))) + patterns))) + true)) + +(defn ^:private relative-path-within-root + [root path] + (when (shared/path-inside-root? path root) + (str (fs/relativize (fs/path root) (fs/path path))))) + +(defn ^:private glob-pattern-matches-path? + [pattern relative-path] + (try + (let [^java.nio.file.FileSystem fs (FileSystems/getDefault) + ^PathMatcher matcher (.getPathMatcher fs (str "glob:" pattern)) + ^Path relative-path (fs/path relative-path)] + (.matches matcher relative-path)) + (catch Exception e + (logger/warn "Invalid rule path glob pattern, skipping match" (pr-str pattern) (ex-message e)) + false))) + +(defn match-path-scoped-rule + "Returns detailed match info for applying a path-scoped rule to a target path. + `target-path` must be absolute. Matching uses Java NIO PathMatcher glob syntax + against workspace-relative paths." + [rule roots target-path] + (let [input-path (some-> target-path str string/trim not-empty) + absolute? (and input-path (fs/absolute? input-path)) + normalized-path (when absolute? (shared/normalize-path input-path)) + workspace-root (cond + (not absolute?) nil + (= :project (:scope rule)) (:workspace-root rule) + (= :global (:scope rule)) (workspace-root-for-path roots normalized-path)) + relative-path (when workspace-root + (relative-path-within-root workspace-root normalized-path)) + matched-pattern (when relative-path + (some #(when (glob-pattern-matches-path? % relative-path) %) (:paths rule))) + reason (cond + (not absolute?) :path-not-absolute + (and (= :project (:scope rule)) (nil? relative-path)) :outside-rule-workspace + (nil? relative-path) :outside-workspaces + (not matched-pattern) :pattern-mismatch)] + {:match? (boolean matched-pattern) + :reason reason + :path (or normalized-path input-path) + :workspace-root workspace-root + :relative-path relative-path + :matched-pattern matched-pattern + :paths (:paths rule)})) + (defn ^:private global-file-rules [] (let [xdg-config-home (or (config/get-env "XDG_CONFIG_HOME") (io/file (config/get-property "user.home") ".config")) @@ -14,47 +136,91 @@ (when (fs/exists? rules-dir) (keep (fn [file] (when-not (fs/directory? file) - {:name (fs/file-name file) - :path (str (fs/canonicalize file)) - :type :user-global-file - :content (slurp (fs/file file))})) + (rule-file->rule :user-global-file + (fs/canonicalize file) + (slurp (fs/file file))))) (fs/glob rules-dir "**" {:follow-links true}))))) (defn ^:private local-file-rules [roots] (->> roots (mapcat (fn [{:keys [uri]}] - (let [rules-dir (fs/file (shared/uri->filename uri) ".eca" "rules")] + (let [workspace-root (shared/normalize-path (shared/uri->filename uri)) + rules-dir (fs/file workspace-root ".eca" "rules")] (when (fs/exists? rules-dir) - (fs/glob rules-dir "**" {:follow-links true}))))) - (keep (fn [file] - (when-not (fs/directory? file) - {:name (fs/file-name file) - :path (str (fs/canonicalize file)) - :type :user-local-file - :content (slurp (fs/file file))}))))) + (keep (fn [file] + (when-not (fs/directory? file) + (rule-file->rule :user-local-file + (fs/canonicalize file) + (slurp (fs/file file)) + {:workspace-root workspace-root}))) + (fs/glob rules-dir "**" {:follow-links true})))))))) + +(defn ^:private config-rule-candidates + [roots path] + (if (fs/absolute? path) + [{:path path + :workspace-root (workspace-root-for-path roots path)}] + (map (fn [{:keys [uri]}] + (let [workspace-root (shared/normalize-path (shared/uri->filename uri))] + {:path (fs/file workspace-root path) + :workspace-root workspace-root + :canonicalize? true})) + roots))) (defn ^:private config-rules [config roots] (->> (get config :rules) - (map - (fn [{:keys [path]}] - (if (fs/absolute? path) - (when (fs/exists? path) - {:name (fs/file-name path) - :path path - :type :user-config - :content (slurp path)}) - (keep (fn [{:keys [uri]}] - (let [f (fs/file (shared/uri->filename uri) path)] - (when (fs/exists? f) - {:name (fs/file-name f) - :path (str (fs/canonicalize f)) - :type :user-config - :content (slurp f)}))) - roots)))) - (flatten) - (remove nil?))) - -(defn all [config roots] + (mapcat (fn [{:keys [path]}] + (config-rule-candidates roots path))) + (keep (fn [{:keys [path workspace-root canonicalize?]}] + (when (fs/exists? path) + (rule-file->rule :user-config + (cond-> path canonicalize? fs/canonicalize) + (slurp path) + {:workspace-root workspace-root})))))) + +(defn ^:private loaded-rules + [config roots] (concat (config-rules config roots) (global-file-rules) (local-file-rules roots))) + +(defn ^:private filter-rules + [rules agent-name full-model] + (filter #(and (agent-matches? % agent-name) + (model-matches? % full-model)) + rules)) + +(defn all-rules + "Loads all rules from disk once, filters by agent/model, and partitions + into {:static [...] :path-scoped [...]}. Use this when you need both + partitions to avoid loading rule files from disk twice." + [config roots agent-name full-model] + (let [filtered (filter-rules (loaded-rules config roots) agent-name full-model)] + {:static (vec (remove :paths filtered)) + :path-scoped (vec (filter :paths filtered))})) + +(defn path-scoped-rules + "Returns rules with :paths — these are rendered as a catalog in the + system prompt and their full content is loaded on demand via fetch_rule." + [config roots agent-name full-model] + (filter-rules (filter :paths (loaded-rules config roots)) agent-name full-model)) + +(defn matching-path-scoped-rules + "Returns all path-scoped rules that match `target-path`, together with the + detailed match info returned by `match-path-scoped-rule`." + [config roots agent-name full-model target-path] + (->> (path-scoped-rules config roots agent-name full-model) + (keep (fn [rule] + (let [match-info (match-path-scoped-rule rule roots target-path)] + (when (:match? match-info) + {:rule rule + :match match-info})))) + vec)) + +(defn find-rule-by-id + "Finds a path-scoped rule by id after applying the same agent/model + filtering used for the fetch_rule catalog. Returns the rule map or nil + if not found or not available for the current chat context." + [config roots rule-id agent-name full-model] + (first (filter #(= rule-id (:id %)) + (path-scoped-rules config roots agent-name full-model)))) diff --git a/src/eca/features/tools.clj b/src/eca/features/tools.clj index cba516307..9f4a9b3bd 100644 --- a/src/eca/features/tools.clj +++ b/src/eca/features/tools.clj @@ -9,6 +9,7 @@ [eca.features.tools.chat :as f.tools.chat] [eca.features.tools.custom :as f.tools.custom] [eca.features.tools.editor :as f.tools.editor] + [eca.features.tools.fetch-rule :as f.tools.fetch-rule] [eca.features.tools.filesystem :as f.tools.filesystem] [eca.features.tools.git :as f.tools.git] [eca.features.tools.mcp :as f.mcp] @@ -21,8 +22,7 @@ [eca.logger :as logger] [eca.messenger :as messenger] [eca.metrics :as metrics] - [eca.shared :refer [assoc-some]] - [selmer.parser :as selmer]) + [eca.shared :refer [assoc-some] :as shared]) (:import [java.util Map])) @@ -140,34 +140,40 @@ (walk/postwalk (fn [x] (if (string? x) - (selmer/render x vars) + (shared/safe-selmer-render x vars "tool-config") x)) m)) -(defn ^:private native-definitions [db config] +(defn ^:private native-definitions + [chat-id agent-name db config] (into - {} - (map (fn [[name tool]] - [name (-> tool - (assoc :name name) - (replace-string-values-with-vars - {:workspaceRoots (tools.util/workspace-roots-strs db) - :readFileMaxLines (get-in config [:toolCall :readFile :maxLines])}))])) - (merge {} - f.tools.filesystem/definitions - f.tools.shell/definitions - f.tools.git/definitions - f.tools.editor/definitions - f.tools.chat/definitions - f.tools.skill/definitions - f.tools.task/definitions - f.tools.background/definitions - f.tools.ask-user/definitions - (f.tools.agent/definitions config db) - (f.tools.custom/definitions config)))) - -(defn native-tools [db config] - (mapv #(assoc % :server {:name "eca"}) (vals (native-definitions db config)))) + {} + (map (fn [[name tool]] + [name (-> tool + (assoc :name name) + (replace-string-values-with-vars + {:workspaceRoots (tools.util/workspace-roots-strs db) + :readFileMaxLines (get-in config [:toolCall :readFile :maxLines])}))])) + (merge {} + f.tools.filesystem/definitions + f.tools.shell/definitions + f.tools.git/definitions + f.tools.editor/definitions + f.tools.chat/definitions + f.tools.skill/definitions + f.tools.task/definitions + f.tools.background/definitions + f.tools.ask-user/definitions + (f.tools.agent/definitions config db) + (f.tools.custom/definitions config) + (f.tools.fetch-rule/definitions config db chat-id agent-name)))) + +(defn native-tools + ([db config] + (native-tools nil nil db config)) + ([chat-id agent-name db config] + (mapv #(assoc % :server {:name "eca"}) + (vals (native-definitions chat-id agent-name db config))))) (defn ^:private filter-subagent-tools "Filter tools for subagent execution. @@ -188,7 +194,7 @@ (let [disabled-tools (get-disabled-tools config agent-name) subagent (get-in db [:chats chat-id :subagent]) all-tools (->> (concat - (mapv #(assoc % :origin :native) (native-tools db config)) + (mapv #(assoc % :origin :native) (native-tools chat-id agent-name db config)) (mapv #(assoc % :origin :mcp) (f.mcp/all-tools db))) (mapv #(update % :parameters tools.util/reorder-schema-required-first)) (mapv #(assoc % :full-name (str (-> % :server :name) "__" (:name %)))) @@ -238,7 +244,7 @@ (let [result (-> (if required-args-error required-args-error (if-let [native-tool-handler (and (= "eca" server-name) - (get-in (native-definitions db config) [tool-name :handler]))] + (get-in (native-definitions chat-id agent-name db config) [tool-name :handler]))] (native-tool-handler arguments {:db db :db* db* :config config @@ -246,6 +252,7 @@ :agent agent-name :metrics metrics :chat-id chat-id + :all-tools all-tools :tool-call-id tool-call-id :call-state-fn call-state-fn :state-transition-fn state-transition-fn diff --git a/src/eca/features/tools/fetch_rule.clj b/src/eca/features/tools/fetch_rule.clj new file mode 100644 index 000000000..4840e8eaa --- /dev/null +++ b/src/eca/features/tools/fetch_rule.clj @@ -0,0 +1,137 @@ +(ns eca.features.tools.fetch-rule + (:require + [clojure.string :as string] + [eca.features.prompt :as f.prompt] + [eca.features.rules :as f.rules] + [eca.features.tools.path-rules :as f.tools.path-rules] + [eca.features.tools.util :as tools.util] + [eca.shared :as shared])) + +(set! *warn-on-reflection* true) + +(defn ^:private rendered-rule-content + [{:keys [content name]} all-tools db chat-id] + (when-let [rendered (shared/safe-selmer-render content + (f.prompt/->base-selmer-ctx all-tools chat-id db) + (str "path-scoped-rule:" name) + nil)] + (when-not (string/blank? rendered) + rendered))) + +(defn ^:private path-mismatch-message + [{:keys [id scope workspace-root paths]} {:keys [path reason relative-path]}] + (let [java-glob-note (str "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`.")] + (case reason + :path-not-absolute + (str "Path '" path "' must be absolute. Pass the exact absolute file or directory path you plan to work with.\n" + java-glob-note) + + :outside-rule-workspace + (str "Rule id '" id "' does not apply to path '" path "' because it is outside the rule workspace root '" + workspace-root + "'.\n" + "Allowed patterns: " (string/join ", " paths) "\n" + java-glob-note) + + :outside-workspaces + (str "Rule id '" id "' does not apply to path '" path "' because global path-scoped rules are matched only against the current workspace roots.\n" + "Allowed patterns: " (string/join ", " paths) "\n" + java-glob-note) + + :pattern-mismatch + (str "Rule id '" id "' does not apply to path '" path "'.\n" + "Checked relative path: " relative-path + (when (= :project scope) + (str " (from workspace root '" workspace-root "')")) + "\nAllowed patterns: " (string/join ", " paths) "\n" + java-glob-note) + + (str "Rule id '" id "' does not apply to path '" path "'.")))) + +(defn ^:private fetch-rule + [arguments {:keys [all-tools db db* config chat-id agent]}] + (let [rule-id (get arguments "id") + target-path (get arguments "path") + roots (:workspace-folders db) + full-model (get-in db [:chats chat-id :model]) + rule (f.rules/find-rule-by-id config roots rule-id agent full-model) + match-info (when rule + (f.rules/match-path-scoped-rule rule roots target-path))] + (cond + (nil? rule) + {:error true + :contents [{:type :text + :text (format "Rule id '%s' not found in the current path-scoped rules catalog. Use the exact id from the catalog or /rules command." rule-id)}]} + + (not (:match? match-info)) + {:error true + :contents [{:type :text + :text (path-mismatch-message rule match-info)}]} + + (f.tools.path-rules/validated-rule? db chat-id (:path match-info) rule-id) + {:error false + :contents [{:type :text + :text (str "**" (:name rule) "** — already loaded for this path, reuse the previously fetched content.")}]} + + :else + (do + (f.tools.path-rules/record-validated-rule! db* chat-id rule match-info) + (let [header (str "**Rule**: " (:name rule) "\n" + "**Path**: " (:path match-info) "\n" + "**Matched pattern**: " (:matched-pattern match-info) "\n")] + (if-let [content (rendered-rule-content rule all-tools db chat-id)] + {:error false + :contents [{:type :text + :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.")}]})))))) + +(defn ^:private describe-rule + [{rule-name :name :keys [id paths enforce scope workspace-root]}] + (str "- " rule-name "\n" + " id: " id "\n" + " scope: " (some-> scope name) "\n" + (when workspace-root + (str " workspace-root: " workspace-root "\n")) + " paths: " (string/join ", " paths) "\n" + " enforce: " (string/join ", " (or enforce ["modify"])))) + +(defn ^:private build-description + [config db chat-id agent-name] + (let [base-description (tools.util/read-tool-description "fetch_rule") + roots (:workspace-folders db) + full-model (get-in db [:chats chat-id :model]) + rules (f.rules/path-scoped-rules config roots agent-name full-model)] + (if (seq rules) + (str base-description + "\n\nAvailable path-scoped rules" + (when chat-id + " for the current chat") + ":\n" + (->> rules + (map describe-rule) + (string/join "\n"))) + base-description))) + +(defn definitions + [config db chat-id agent-name] + {"fetch_rule" + {:description (build-description config db chat-id agent-name) + :parameters {:type "object" + :properties {"id" {:type "string" + :description "The exact rule id from the path-scoped rules catalog"} + "path" {:type "string" + :description "The exact absolute file or directory path you plan to work with"}} + :required ["id" "path"]} + :handler #'fetch-rule + :enabled-fn (fn [{:keys [db config agent chat-id]}] + (let [roots (:workspace-folders db) + full-model (get-in db [:chats chat-id :model])] + (seq (f.rules/path-scoped-rules config roots agent full-model)))) + :summary-fn (fn [{:keys [args]}] + (if-let [rule-id (get args "id")] + (format "Fetching rule '%s'" rule-id) + "Fetching rule"))}}) diff --git a/src/eca/features/tools/filesystem.clj b/src/eca/features/tools/filesystem.clj index f3f4294ec..38a9e002f 100644 --- a/src/eca/features/tools/filesystem.clj +++ b/src/eca/features/tools/filesystem.clj @@ -6,6 +6,7 @@ [clojure.string :as string] [eca.diff :as diff] [eca.features.index :as f.index] + [eca.features.tools.path-rules :as f.tools.path-rules] [eca.features.tools.smart-edit :as smart-edit] [eca.features.tools.text-match :as text-match] [eca.features.tools.util :as tools.util] @@ -20,11 +21,11 @@ (def ^:private directory-tree-max-depth 10) (defn ^:private path->root-filename [db path] - (let [path (str (fs/canonicalize path))] + (let [path (shared/normalize-path path)] (->> (:workspace-folders db) (map :uri) (map shared/uri->filename) - (filter #(fs/starts-with? path %)) + (filter #(shared/path-inside-root? path %)) (sort-by count >) first))) @@ -66,22 +67,23 @@ summary (format "%d directories, %d files" @dir-count* @file-count*)] (tools.util/single-text-content (str body "\n\n" summary))))))) -(defn ^:private read-file [arguments {:keys [config]}] +(defn ^:private read-file [{:strs [path] :as arguments} {:keys [config] :as ctx}] (or (tools.util/invalid-arguments arguments (concat (path-validations) [["path" fs/readable? "File $path is not readable"] ["path" (complement fs/directory?) "$path is a directory, not a file"]])) - (let [line-offset (or (get arguments "line_offset") 0) - limit (->> [(get arguments "limit") - (get-in config [:toolCall :readFile :maxLines])] - (filter number?) - (apply min)) - full-content-lines (string/split-lines (slurp (fs/file (fs/canonicalize (get arguments "path"))))) - maybe-truncated-content-lines (cond-> full-content-lines - line-offset (->> (drop line-offset)) - limit (->> (take limit))) - was-truncated? (not= (- (count full-content-lines) line-offset) - (count maybe-truncated-content-lines)) - content (string/join "\n" maybe-truncated-content-lines)] + (f.tools.path-rules/require-fetched-path-scoped-rules-for-read path ctx) + (let [line-offset (or (get arguments "line_offset") 0) + limit (->> [(get arguments "limit") + (get-in config [:toolCall :readFile :maxLines])] + (filter number?) + (apply min)) + full-content-lines (string/split-lines (slurp (fs/file (fs/canonicalize path)))) + maybe-truncated-content-lines (cond->> full-content-lines + line-offset (drop line-offset) + limit (take limit)) + was-truncated? (not= (- (count full-content-lines) line-offset) + (count maybe-truncated-content-lines)) + content (string/join "\n" maybe-truncated-content-lines)] (tools.util/single-text-content (if was-truncated? (str content "\n\n" "[CONTENT TRUNCATED] Showing lines " (if line-offset (inc line-offset) 1) @@ -105,15 +107,16 @@ (+ line-offset limit)))))) "Reading file")) -(defn ^:private write-file [arguments _] +(defn ^:private write-file [arguments ctx] (let [path (get arguments "path") - content (get arguments "content") - old-content (try (slurp path) (catch Exception _ nil))] - (fs/create-dirs (fs/parent (fs/path path))) - (spit path content) - (assoc (tools.util/single-text-content (format "Successfully wrote to %s" path)) - :rollback-changes [{:path path - :content old-content}]))) + content (get arguments "content")] + (or (f.tools.path-rules/require-fetched-path-scoped-rules path ctx) + (let [old-content (try (slurp path) (catch Exception _ nil))] + (fs/create-dirs (fs/parent (fs/path path))) + (spit path content) + (assoc (tools.util/single-text-content (format "Successfully wrote to %s" path)) + :rollback-changes [{:path path + :content old-content}]))))) (defn ^:private write-file-summary [{:keys [args]}] (if (get args "path") @@ -307,25 +310,25 @@ (text-match/apply-content-change-to-string file-content original-content new-content all? path) (smart-edit/apply-smart-edit file-content original-content new-content path))) -(defn ^:private edit-file [arguments {:keys [_db]}] +(defn ^:private edit-file [{:strs [path] :as arguments} ctx] (or (tools.util/invalid-arguments arguments (concat (path-validations) [["path" fs/readable? "File $path is not readable"]])) - (let [path (get arguments "path") - original-content (get arguments "original_content") - new-content (get arguments "new_content") - all? (boolean (get arguments "all_occurrences")) - initial-content (slurp path) - result (apply-file-edit-strategy initial-content original-content new-content all? path) - write! (fn [res] - (spit path (:new-full-content res)) - (-> (handle-file-change-result res path (format "Successfully replaced content in %s." path)) - (assoc :rollback-changes [{:path path - :content initial-content}])))] + (f.tools.path-rules/require-fetched-path-scoped-rules path ctx) + (let [original-content (get arguments "original_content") + new-content (get arguments "new_content") + all? (boolean (get arguments "all_occurrences")) + initial-content (slurp path) + result (apply-file-edit-strategy initial-content original-content new-content all? path) + write! (fn [res] + (spit path (:new-full-content res)) + (-> (handle-file-change-result res path (format "Successfully replaced content in %s." path)) + (assoc :rollback-changes [{:path path + :content initial-content}])))] (if (:new-full-content result) (let [current-content (slurp path)] (if (= current-content (:original-full-content result)) (write! result) - ;; Optimistic retry once against latest content + ;; Optimistic retry once against latest content (let [retry (apply-file-edit-strategy current-content original-content new-content all? path)] (if (:new-full-content retry) (write! retry) diff --git a/src/eca/features/tools/path_rules.clj b/src/eca/features/tools/path_rules.clj new file mode 100644 index 000000000..fe0b849d3 --- /dev/null +++ b/src/eca/features/tools/path_rules.clj @@ -0,0 +1,97 @@ +(ns eca.features.tools.path-rules + (:require + [clojure.string :as string] + [eca.features.rules :as f.rules] + [eca.features.tools.util :as tools.util] + [eca.shared :as shared])) + +(set! *warn-on-reflection* true) + +(def ^:private validated-path-rules-key :validated-path-rules) + +(defn fetch-rule-available? + [all-tools] + (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)}))) + +(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]))) + +(defn enforce-on-modify? + "Returns true if the rule should be enforced before file modification. + Default (nil enforce) is modify — backwards compatible." + [rule] + (let [enforce (:enforce rule)] + (or (nil? enforce) + (some #(= "modify" %) enforce)))) + +(defn enforce-on-read? + "Returns true if the rule should be enforced before file reading. + Default (nil enforce) is false — only opt-in via explicit `enforce: read`." + [rule] + (some #(= "read" %) (:enforce rule))) + +(defn applicable-path-scoped-rules + [config db chat-id agent all-tools target-path] + (when (and (fetch-rule-available? all-tools) + (not (string/blank? target-path))) + (let [full-model (get-in db [:chats chat-id :model])] + (f.rules/matching-path-scoped-rules config + (:workspace-folders db) + agent + full-model + target-path)))) + +(defn ^:private missing-path-scoped-rules + [config db chat-id agent all-tools target-path enforce?] + (let [target-path (shared/normalize-path target-path)] + (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)))) + vec + not-empty)))) + +(defn missing-path-scoped-rules-for-modify + "Returns rules that require fetching before modifying the target path." + [config db chat-id agent all-tools target-path] + (missing-path-scoped-rules config db chat-id agent all-tools target-path enforce-on-modify?)) + +(defn missing-path-scoped-rules-for-read + "Returns rules that require fetching before reading the target path." + [config db chat-id agent all-tools target-path] + (missing-path-scoped-rules config db chat-id agent all-tools target-path enforce-on-read?)) + +(defn missing-path-scoped-rules-error + [target-path missing-rules action] + (tools.util/single-text-content + (str "Path-scoped rules must be fetched before " action " '" target-path "'.\n" + "Fetch the missing rule(s) first:\n" + (->> missing-rules + (map (fn [{{:keys [id name]} :rule {:keys [matched-pattern]} :match}] + (str "- " name "\n" + " id: " id "\n" + " path: " target-path "\n" + " matched-pattern: " matched-pattern "\n" + " next: call `fetch_rule` with this exact `id` and `path`."))) + (string/join "\n"))) + :error)) + +(defn require-fetched-path-scoped-rules + [target-path {:keys [config db chat-id agent all-tools]}] + (when-let [missing-rules (missing-path-scoped-rules-for-modify config db chat-id agent all-tools target-path)] + (missing-path-scoped-rules-error (shared/normalize-path target-path) missing-rules "modifying"))) + +(defn require-fetched-path-scoped-rules-for-read + [target-path {:keys [config db chat-id agent all-tools]}] + (when-let [missing-rules (missing-path-scoped-rules-for-read config db chat-id agent all-tools target-path)] + (missing-path-scoped-rules-error (shared/normalize-path target-path) missing-rules "reading"))) diff --git a/src/eca/features/tools/skill.clj b/src/eca/features/tools/skill.clj index 523718902..07c9f6dae 100644 --- a/src/eca/features/tools/skill.clj +++ b/src/eca/features/tools/skill.clj @@ -33,6 +33,8 @@ :description "The skill identifier from available skills to load (e.g. review-pr)"}} :required ["name"]} :handler #'skill + :enabled-fn (fn [{:keys [db config]}] + (seq (f.skills/all config (:workspace-folders db)))) :summary-fn (fn [{:keys [args]}] (if-let [name (get args "name")] (format "Loading skill '%s'" name) diff --git a/src/eca/features/tools/util.clj b/src/eca/features/tools/util.clj index dd4911f8b..da5466e44 100644 --- a/src/eca/features/tools/util.clj +++ b/src/eca/features/tools/util.clj @@ -1,6 +1,5 @@ (ns eca.features.tools.util (:require - [babashka.fs :as fs] [cheshire.core :as json] [clojure.java.io :as io] [clojure.java.shell :as shell] @@ -57,6 +56,10 @@ :contents [{:type :text :text text}]}) +(defn tool-available? + [all-tools full-name] + (boolean (some #(= full-name (:full-name %)) all-tools))) + (defn workspace-roots-strs [db] (->> (:workspace-folders db) (map #(shared/uri->filename (:uri %))) @@ -70,13 +73,13 @@ (defn path-outside-workspace? "Returns true if `path` is outside any workspace root in `db`. Paths inside the ECA tool-call-outputs cache dir are always considered 'inside'. - Works for existing or non-existing paths by absolutizing." + Uses shared path normalization so existing and non-existing paths behave consistently." [db path] - (let [p (when path (str (fs/absolutize path))) + (let [p (shared/normalize-path path) roots (workspace-root-paths db)] (and p - (not (fs/starts-with? p (str (cache/tool-call-outputs-dir)))) - (not-any? #(fs/starts-with? p %) roots)))) + (not (shared/path-inside-root? p (cache/tool-call-outputs-dir))) + (not-any? #(shared/path-inside-root? p %) roots)))) (defn require-approval-when-outside-workspace "Returns a function suitable for tool `:require-approval-fn` that triggers diff --git a/src/eca/oauth.clj b/src/eca/oauth.clj index d6d2a12da..de1b33114 100644 --- a/src/eca/oauth.clj +++ b/src/eca/oauth.clj @@ -4,13 +4,13 @@ [clojure.java.io :as io] [clojure.string :as string] [eca.logger :as logger] + [selmer.parser :as selmer] [hato.client :as http] [ring.adapter.jetty :as jetty] [ring.middleware.keyword-params :refer [wrap-keyword-params]] [ring.middleware.params :refer [wrap-params]] [ring.util.codec :as ring.util] - [ring.util.response :as response] - [selmer.parser :as selmer]) + [ring.util.response :as response]) (:import [java.nio.charset StandardCharsets] [java.security KeyStore MessageDigest SecureRandom] @@ -252,19 +252,19 @@ (:client_id (:body res)))))) {:keys [challenge verifier]} (generate-pkce) client-id (or configured-client-id new-client-id eca-client-id) - scope (when-let [scopes (:scopes_supported meta)] - (if (coll? scopes) - (string/join " " scopes) - scopes)) - query-params (ring.util/form-encode - (cond-> {:response_type "code" - :client_id client-id - :code_challenge_method "S256" - :code_challenge challenge - :state verifier - :redirect_uri redirect-uri - :resource url} - scope (assoc :scope scope)))] + scope (when-let [scopes (:scopes_supported meta)] + (if (coll? scopes) + (string/join " " scopes) + scopes)) + query-params (ring.util/form-encode + (cond-> {:response_type "code" + :client_id client-id + :code_challenge_method "S256" + :code_challenge challenge + :state verifier + :redirect_uri redirect-uri + :resource url} + scope (assoc :scope scope)))] (cond-> {:callback-port callback-port :token-endpoint (or (:token_endpoint meta) (str auth-server "/access_token")) @@ -438,4 +438,4 @@ (or (:expires_in parsed-body) 3600))})) (catch Exception e (logger/warn logger-tag (format "Failed to refresh token: %s" (.getMessage e))) - nil))) \ No newline at end of file + nil))) diff --git a/src/eca/shared.clj b/src/eca/shared.clj index fcab0b719..303055452 100644 --- a/src/eca/shared.clj +++ b/src/eca/shared.clj @@ -1,12 +1,15 @@ (ns eca.shared (:require + [babashka.fs :as fs] [camel-snake-kebab.core :as csk] [clojure.core.memoize :as memoize] [clojure.java.io :as io] [clojure.string :as string] [clojure.walk :as walk] [eca.cache :as cache] - [eca.messenger :as messenger]) + [eca.logger :as logger] + [eca.messenger :as messenger] + [selmer.parser :as selmer]) (:import [java.net URI] [java.nio.file Paths] @@ -32,23 +35,66 @@ "Parses YAML frontmatter and body from a markdown string. Frontmatter must be delimited by --- at the start and end. Uses SnakeYAML to handle nested structures (maps, lists). - Returns a map with parsed YAML keys (as keywords) and :body (content after frontmatter)." + Returns a map with parsed YAML keys (as keywords) and :body (content after frontmatter). + Throws when frontmatter is unclosed, invalid YAML, or not a YAML mapping." [content] (let [lines (string/split-lines content)] (if (and (seq lines) (= "---" (string/trim (first lines)))) (let [after-opening (rest lines) - metadata-lines (take-while #(not= "---" (string/trim %)) after-opening) - body-lines (rest (drop-while #(not= "---" (string/trim %)) after-opening)) - yaml-str (string/join "\n" metadata-lines) - yaml (Yaml.) - parsed (.load yaml ^String yaml-str) - metadata (when (instance? java.util.Map parsed) - (into {} (map (fn [[k v]] [(keyword k) (java->clj v)])) - parsed))] - (assoc (or metadata {}) :body (string/trim (string/join "\n" body-lines)))) + closing-idx (some (fn [[idx line]] + (when (= "---" (string/trim line)) + idx)) + (map-indexed vector after-opening))] + (when-not (some? closing-idx) + (throw (ex-info "Unclosed YAML frontmatter" {}))) + (let [metadata-lines (take closing-idx after-opening) + body-lines (drop (inc closing-idx) after-opening) + yaml-str (string/join "\n" metadata-lines) + yaml (Yaml.) + parsed (.load yaml ^String yaml-str) + metadata (cond + (nil? parsed) {} + (instance? java.util.Map parsed) + (into {} (map (fn [[k v]] [(keyword k) (java->clj v)])) + parsed) + :else + (throw (ex-info "YAML frontmatter must be a mapping" {:parsed-type (type parsed)})))] + (assoc metadata :body (string/trim (string/join "\n" body-lines))))) {:body (string/trim content)}))) +;; Walks up from a non-existing path to find the nearest existing ancestor, +;; canonicalizes it (resolving symlinks), then re-attaches the missing segments. +;; This is needed because workspace roots can be symlinks — for write_file +;; creating a new file inside a symlinked workspace, the path must resolve +;; through the symlink to match the canonicalized root in path-inside-root?. +;; A simple fs/absolutize would not resolve symlinks, breaking prefix matching +;; when the root is canonicalized. +(defn ^:private normalize-missing-path + [path] + (let [reattach (fn [base segments] + (str (fs/normalize (reduce #(fs/path %1 %2) base (rseq segments)))))] + (loop [current (fs/absolutize (fs/file path)) + segments []] + (cond + (fs/exists? current) + (reattach (fs/canonicalize current) segments) + + (fs/parent current) + (recur (fs/parent current) + (conj segments (str (.getFileName (.toPath (fs/file current)))))) + + :else + (reattach current segments))))) + +(defn normalize-path + [path] + (when path + (let [path (fs/absolutize (fs/file path))] + (if (fs/exists? path) + (str (fs/canonicalize path)) + (normalize-missing-path path))))) + (defn global-config-dir "Returns the global ECA config directory as a java.io.File. Respects XDG_CONFIG_HOME, defaults to ~/.config/eca." @@ -107,6 +153,15 @@ string/lower-case)) (string/replace ":" "%3A"))))) +(defn path-inside-root? + [path root] + (let [path (normalize-path path) + root (normalize-path root)] + (and path + root + (or (= path root) + (fs/starts-with? path root))))) + (defn workspaces-as-str [db] (string/join ", " (map (comp uri->filename :uri) (:workspace-folders db)))) @@ -348,8 +403,8 @@ :text (str "The conversation was compacted/summarized, consider this summary:\n" summary)}]}))))) - ;; Zero chat usage - (swap! db* update-in [:chats chat-id] dissoc :usage) + ;; Zero chat usage and clear transient per-chat rule validations. + (swap! db* update-in [:chats chat-id] dissoc :usage :validated-path-rules) (messenger/chat-content-received messenger {:chat-id chat-id @@ -384,3 +439,16 @@ (defn full-model->provider+model [full-model] (string/split full-model #"/" 2)) + +(defn safe-selmer-render + "Render a Selmer template string, returning `fallback` on failure. + Defaults to the raw template so callers keep the previous behavior unless + they explicitly opt into skipping broken templates." + ([^String template context ^String label] + (safe-selmer-render template context label template)) + ([^String template context ^String label fallback] + (try + (selmer/render template context) + (catch Exception e + (logger/warn "[SELMER]" (format "Failed to render template '%s': %s" label (ex-message e))) + fallback)))) diff --git a/test/eca/features/chat_test.clj b/test/eca/features/chat_test.clj index cc258d55e..3a9265267 100644 --- a/test/eca/features/chat_test.clj +++ b/test/eca/features/chat_test.clj @@ -1186,4 +1186,64 @@ (f.chat/open-chat! {:chat-id chat-id} (h/db*) (h/messenger) (h/config)) (is (nil? (:config-updated (h/messages))))))) +(deftest prompt-cache-agent-and-model-switch-test + (testing "local static prompt cache is reused only when both agent and model match" + (h/reset-components!) + (h/config! {:env "test" + :agent {"code" {:mode "primary"} + "plan" {:mode "primary"}}}) + (swap! (h/db*) update :models + (fn [models] + (merge {"openai/gpt-5.2" {:tools true} + "openai/gpt-4.1" {:tools true}} + (or models {})))) + (let [build-calls* (atom 0) + api-mock (fn [{:keys [on-first-response-received on-message-received]}] + (on-first-response-received {:type :text :text "ok"}) + (on-message-received {:type :text :text "ok"}) + (on-message-received {:type :finish})) + call-prompt! (fn [params] + (with-redefs [llm-api/sync-or-async-prompt! api-mock + llm-api/sync-prompt! (constantly nil) + f.tools/call-tool! (constantly nil) + f.tools/all-tools (constantly []) + f.tools/approval (constantly :allow) + config/await-plugins-resolved! (constantly true) + f.prompt/build-chat-instructions (fn [& _] + (swap! build-calls* inc) + {:static (str "static-" @build-calls*) + :dynamic "dynamic"})] + (f.chat/prompt params (h/db*) (h/messenger) (h/config) (h/metrics)))) + resp (call-prompt! {:message "Hello" + :agent "code" + :model "openai/gpt-5.2"}) + chat-id (:chat-id resp)] + (is (match? {:chat-id string? :status :prompting} resp)) + (is (= 1 @build-calls*) "First call should build instructions") + + (h/reset-messenger!) + (call-prompt! {:message "Hello again" + :chat-id chat-id + :agent "code" + :model "openai/gpt-5.2"}) + (is (= 1 @build-calls*) "Same agent and model should reuse cached static instructions") + + (h/reset-messenger!) + (call-prompt! {:message "Switch agent" + :chat-id chat-id + :agent "plan" + :model "openai/gpt-5.2"}) + (is (= 2 @build-calls*) "Changing agent should rebuild instructions") + + (h/reset-messenger!) + (call-prompt! {:message "Switch model" + :chat-id chat-id + :agent "plan" + :model "openai/gpt-4.1"}) + (is (= 3 @build-calls*) "Changing model should rebuild instructions") + (is (= {:static "static-3" + :agent "plan" + :model "openai/gpt-4.1"} + (get-in (h/db) [:chats chat-id :prompt-cache])))))) + diff --git a/test/eca/features/commands_test.clj b/test/eca/features/commands_test.clj index 41edaf9fa..4ab412464 100644 --- a/test/eca/features/commands_test.clj +++ b/test/eca/features/commands_test.clj @@ -1,8 +1,10 @@ (ns eca.features.commands-test (:require [babashka.fs :as fs] + [clojure.string :as string] [clojure.test :refer [deftest is testing]] [eca.features.commands :as f.commands] + [eca.features.rules :as f.rules] [eca.test-helper :as h])) (h/reset-components-before-test) @@ -107,7 +109,7 @@ :type :native :description "Select model for current chat (Ex: /model anthropic/claude-sonnet-4-6)" :arguments [{:name "full-model"}]} - %) + %) commands)))) (deftest handle-model-command-test @@ -178,3 +180,115 @@ :metrics (h/metrics)})] (is (re-find #"Unknown model: `bad/model`" (get-in result [:chats "chat-1" :messages 0 :content 0 :text])))))) + +(deftest rules-command-test + (testing "/rules shows static and path-scoped rules separately with filter metadata" + (with-redefs [f.rules/all-rules (fn [_config _roots agent full-model] + (is (= "code" agent)) + (is (= "anthropic/claude-sonnet-4-20250514" full-model)) + {:static [{:name "coding-style.md" + :scope :project + :path "/repo/.eca/rules/coding-style.md" + :agents ["code"] + :models ["claude-sonnet-4.*"] + :content "Prefer small functions."}] + :path-scoped [{:name "format.md" + :scope :project + :path "/repo/.eca/rules/format.md" + :agents ["code"] + :models ["claude-sonnet-4.*"] + :paths ["src/**/*.clj"] + :content "Run formatter before saving."}]})] + (let [db* (atom {:workspace-folders [] + :chats {"chat-1" {:agent "code" + :model "anthropic/claude-sonnet-4-20250514"}}}) + result (#'f.commands/handle-command! "rules" + [] + {:chat-id "chat-1" + :db* db* + :config {} + :messenger (h/messenger) + :full-model "anthropic/claude-sonnet-4-20250514" + :agent "code" + :all-tools [{:full-name "eca__fetch_rule"}] + :instructions "ignored" + :user-messages [] + :metrics (h/metrics)}) + text (get-in result [:chats "chat-1" :messages 0 :content 0 :text])] + (is (string/includes? text "Static rules (full content is included directly in the system prompt):")) + (is (string/includes? text "Path-scoped rules (only a catalog is included in the system prompt; load full content with fetch_rule using the rule id and target path):")) + (is (string/includes? text "### coding-style.md (project)")) + (is (string/includes? text "Agent filter: code")) + (is (string/includes? text "Model filter: claude-sonnet-4.*")) + (is (string/includes? text "Content preview: Prefer small functions.")) + (is (string/includes? text "### format.md (project)")) + (is (string/includes? text "Path filter: src/**/*.clj")) + (is (not (string/includes? text "Content preview: Run formatter before saving.")))))) + + (testing "/rules falls back to all when filters are omitted in rule metadata" + (with-redefs [f.rules/all-rules (fn [& _] + {:static [{:name "global.md" + :scope :global + :path "/home/user/.config/eca/rules/global.md" + :content "Always active."}] + :path-scoped []})] + (let [result (#'f.commands/handle-command! "rules" + [] + {:chat-id "chat-1" + :db* (atom {:workspace-folders [] + :chats {"chat-1" {:agent "code" + :model "openai/gpt-4o"}}}) + :config {} + :messenger (h/messenger) + :full-model "openai/gpt-4o" + :agent "code" + :all-tools [] + :instructions "ignored" + :user-messages [] + :metrics (h/metrics)}) + text (get-in result [:chats "chat-1" :messages 0 :content 0 :text])] + (is (string/includes? text "Agent filter: all")) + (is (string/includes? text "Model filter: all"))))) + + (testing "/rules hides path-scoped rules when fetch_rule is unavailable" + (with-redefs [f.rules/all-rules (constantly {:static [] + :path-scoped [{:name "format.md" + :scope :project + :path "/repo/.eca/rules/format.md" + :paths ["src/**/*.clj"] + :content "Run formatter before saving."}]})] + (let [result (#'f.commands/handle-command! "rules" + [] + {:chat-id "chat-1" + :db* (atom {:workspace-folders [] + :chats {"chat-1" {:agent "code" + :model "openai/gpt-4o"}}}) + :config {} + :messenger (h/messenger) + :full-model "openai/gpt-4o" + :agent "code" + :all-tools [] + :instructions "ignored" + :user-messages [] + :metrics (h/metrics)}) + text (get-in result [:chats "chat-1" :messages 0 :content 0 :text])] + (is (= "No rules available for the current agent and model." text))))) + + (testing "/rules reports when there are no available rules" + (with-redefs [f.rules/all-rules (constantly {:static [] :path-scoped []})] + (let [result (#'f.commands/handle-command! "rules" + [] + {:chat-id "chat-1" + :db* (atom {:workspace-folders [] + :chats {"chat-1" {:agent "code" + :model "openai/gpt-4o"}}}) + :config {} + :messenger (h/messenger) + :full-model "openai/gpt-4o" + :agent "code" + :all-tools [] + :instructions "ignored" + :user-messages [] + :metrics (h/metrics)}) + text (get-in result [:chats "chat-1" :messages 0 :content 0 :text])] + (is (= "No rules available for the current agent and model." text)))))) diff --git a/test/eca/features/prompt_test.clj b/test/eca/features/prompt_test.clj index 4db7baf32..726417585 100644 --- a/test/eca/features/prompt_test.clj +++ b/test/eca/features/prompt_test.clj @@ -5,29 +5,43 @@ [eca.features.prompt :as prompt] [eca.test-helper :as h])) +(defn ^:private build-instructions + [refined-contexts static-rules path-scoped-rules skills repo-map* agent-name config chat-id all-tools db] + (prompt/build-chat-instructions refined-contexts static-rules path-scoped-rules skills repo-map* agent-name config chat-id all-tools db)) + (deftest build-instructions-test (testing "Should return a map with :static and :dynamic keys" - (let [result (prompt/build-chat-instructions [] [] [] (delay "TREE") "code" {} nil [] (h/db))] + (let [result (build-instructions [] [] [] [] (delay "TREE") "code" {} nil [] (h/db))] (is (map? result)) (is (contains? result :static)) (is (contains? result :dynamic)) (is (string? (:static result))))) - (testing "Should create instructions with rules, contexts, and code agent" + + (testing "Should create instructions with static rules, path-scoped catalog, contexts, and code agent" (let [refined-contexts [{:type :file :path "foo.clj" :content "(ns foo)"} {:type :file :path "bar.clj" :content "(def a 1)" :lines-range {:start 1 :end 1}} {:type :repoMap}] - rules [{:name "rule1" :content "First rule"} - {:name "rule2" :content "Second rule"}] + static-rules [{:name "rule1" :content "First rule" :scope :global} + {:name "rule2" :content "Second rule" :scope :project}] + path-scoped-rules [{:id "/workspace/a/.eca/rules/format.md" :name "format.md" :scope :project :workspace-root "/workspace/a" :paths ["**/*.clj"]} + {:id "/home/user/.config/eca/rules/no-network.md" :name "no-network.md" :scope :global :paths ["src/**/*.clj"]}] skills [{:name "review-pr" :description "Review a PR"} {:name "lint-fix" :description "Fix a lint"}] fake-repo-map (delay "TREE") agent-name "code" config {} - {:keys [static dynamic]} (prompt/build-chat-instructions refined-contexts rules skills fake-repo-map agent-name config nil [] (h/db))] + {:keys [static dynamic]} (build-instructions refined-contexts static-rules path-scoped-rules skills fake-repo-map agent-name config nil [{:full-name "eca__fetch_rule"}] (h/db))] (is (string/includes? static "You are ECA")) - (is (string/includes? static "")) + (is (string/includes? static "")) + (is (string/includes? static "")) + (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 "")) (is (string/includes? static "")) (is (string/includes? static "")) (is (string/includes? static "")) @@ -36,22 +50,25 @@ (is (string/includes? static "TREE")) (is (string/includes? static "")) (is (nil? dynamic) "dynamic should be nil when no volatile contexts or MCP servers"))) - (testing "Should create instructions with rules, contexts, and plan agent" + + (testing "Should create instructions with static rules, path-scoped catalog, contexts, and plan agent" (let [refined-contexts [{:type :file :path "foo.clj" :content "(ns foo)"} {:type :file :path "bar.clj" :content "(def a 1)" :lines-range {:start 1 :end 1}} {:type :repoMap}] - rules [{:name "rule1" :content "First rule"} - {:name "rule2" :content "Second rule"}] + static-rules [{:name "rule1" :content "First rule" :scope :global} + {:name "rule2" :content "Second rule" :scope :project}] + path-scoped-rules [{:id "/workspace/a/.eca/rules/format.md" :name "format.md" :scope :project :workspace-root "/workspace/a" :paths ["**/*.clj"]}] skills [{:name "review-pr" :description "Review a PR"} {:name "lint-fix" :description "Fix a lint"}] fake-repo-map (delay "TREE") agent-name "plan" config {} - {:keys [static dynamic]} (prompt/build-chat-instructions refined-contexts rules skills fake-repo-map agent-name config nil [] (h/db))] + {:keys [static dynamic]} (build-instructions refined-contexts static-rules path-scoped-rules skills fake-repo-map agent-name config nil [{:full-name "eca__fetch_rule"}] (h/db))] (is (string/includes? static "You are ECA")) - (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 "")) @@ -61,25 +78,59 @@ (is (string/includes? static "")) (is (nil? dynamic) "dynamic should be nil when no volatile contexts or MCP servers")))) +(deftest build-instructions-skip-empty-rule-group-test + (testing "omits empty global rules section when only project-scoped rules render" + (let [static-rules [{:name "rule1" :content "Only project rule" :scope :project}] + {:keys [static]} (build-instructions [] static-rules [] [] (delay "TREE") "code" {} nil [] (h/db))] + (is (string/includes? static "")) + (is (not (string/includes? static "")) + (is (string/includes? static "")) + (is (string/includes? static "")) + (is (not (string/includes? static "(ns foo)")) (is (not (string/includes? static "cursor"))) (is (string? dynamic)) @@ -99,7 +150,7 @@ (testing "mcpResource context goes into :dynamic, not :static" (let [refined-contexts [{:type :file :path "foo.clj" :content "(ns foo)"} {:type :mcpResource :uri "custom://my-resource" :content "volatile-content"}] - {:keys [static dynamic]} (prompt/build-chat-instructions refined-contexts [] [] (delay "TREE") "code" {} nil [] (h/db))] + {:keys [static dynamic]} (build-instructions refined-contexts [] [] [] (delay "TREE") "code" {} nil [] (h/db))] (is (string/includes? static "(ns foo)")) (is (not (string/includes? static "volatile-content"))) (is (string? dynamic)) @@ -110,3 +161,23 @@ (is (= "static\ndynamic" (prompt/instructions->str {:static "static" :dynamic "dynamic"})))) (testing "flattens map with nil dynamic to just static" (is (= "static" (prompt/instructions->str {:static "static" :dynamic nil}))))) + +(deftest build-instructions-rule-condition-test + (testing "renders rule content using Selmer condition variables" + (let [static-rules [{:name "rule1" + :content "{% if isSubagent %}SUB-RULE{% else %}MAIN-RULE{% endif %} {% if toolEnabled_eca__shell_command %}HAS-SHELL{% endif %}" + :scope :project}] + all-tools [{:full-name "eca__shell_command"}] + db (assoc-in (h/db) [:chats "sub-chat" :subagent] {:name "explorer"}) + {:keys [static]} (build-instructions [] static-rules [] [] (delay "TREE") "code" {} "sub-chat" all-tools db)] + (is (string/includes? static "SUB-RULE HAS-SHELL")) + (is (not (string/includes? static "MAIN-RULE")))))) + +(deftest build-instructions-rule-template-error-test + (testing "skips broken rule content when Selmer rendering fails" + (let [static-rules [{:name "broken-rule" + :content "{% if isSubagent %}BROKEN" + :scope :project}] + {:keys [static]} (build-instructions [] static-rules [] [] (delay "TREE") "code" {} "chat-1" [] (h/db))] + (is (not (string/includes? static "broken-rule"))) + (is (not (string/includes? static "## Rules")))))) diff --git a/test/eca/features/rules_test.clj b/test/eca/features/rules_test.clj index caefb119c..e641adb24 100644 --- a/test/eca/features/rules_test.clj +++ b/test/eca/features/rules_test.clj @@ -4,12 +4,17 @@ [clojure.test :refer [deftest is testing]] [eca.config :as config] [eca.features.rules :as f.rules] + [eca.shared :as shared] [eca.test-helper :as h] [matcher-combinators.matchers :as m] [matcher-combinators.test :refer [match?]])) -(deftest all-test - (testing "absolute config rule" +(defn ^:private names + [rules] + (map :name rules)) + +(deftest rule-loading-test + (testing "absolute config rule outside the workspace is global static" (with-redefs [clojure.core/slurp (constantly "MY_RULE_CONTENT") fs/absolute? (constantly true) fs/exists? #(= (h/file-path "/path/to/my-rule.md") %) @@ -20,10 +25,27 @@ (m/embeds [{:type :user-config :path (h/file-path "/path/to/my-rule.md") :name "cool-name" + :scope :global :content "MY_RULE_CONTENT"}]) - (f.rules/all config [])))))) + (:static (f.rules/all-rules config [] nil nil))))))) - (testing "relative config rule" + (testing "absolute config rule inside the workspace is project-scoped static" + (with-redefs [clojure.core/slurp (constantly "MY_RULE_CONTENT") + fs/absolute? (constantly true) + fs/exists? #(= (h/file-path "/my/project/.foo/cool-rule.md") %) + fs/canonicalize identity + fs/file-name (constantly "cool-name")] + (let [config {:rules [{:path (h/file-path "/my/project/.foo/cool-rule.md")}]}] + (is (match? + (m/embeds [{:type :user-config + :path (h/file-path "/my/project/.foo/cool-rule.md") + :name "cool-name" + :scope :project + :workspace-root (h/file-path "/my/project") + :content "MY_RULE_CONTENT"}]) + (:static (f.rules/all-rules config [{:uri (h/file-uri "file:///my/project")}] nil nil))))))) + + (testing "relative config rule is project-scoped static" (with-redefs [fs/absolute? (constantly false) fs/exists? #(contains? #{(h/file-path "/my/project/.eca/rules") (h/file-path "/my/project/.foo/cool-rule.md")} (str %)) @@ -37,33 +59,568 @@ (m/embeds [{:type :user-config :path (h/file-path "/my/project/.foo/cool-rule.md") :name "cool-name" + :scope :project + :workspace-root (h/file-path "/my/project") :content "MY_RULE_CONTENT"}]) - (f.rules/all config roots)))))) + (:static (f.rules/all-rules config roots nil nil))))))) - (testing "local file rules" - (with-redefs [fs/exists? #(= (h/file-path "/my/project/.eca/rules") (str %)) - fs/glob (constantly [(fs/path "cool.md")]) + (testing "local file rules load as project-scoped static rules" + (with-redefs [fs/exists? #(contains? #{(h/file-path "/my/project/.eca/rules") + (h/file-path "/my/project") + (h/file-path "/my/project/.eca/rules/cool.md")} (str %)) + fs/glob (constantly [(fs/path (h/file-path "/my/project/.eca/rules/cool.md"))]) fs/canonicalize identity fs/file-name (constantly "cool-name") clojure.core/slurp (constantly "MY_RULE_CONTENT")] (let [roots [{:uri (h/file-uri "file:///my/project")}]] (is (match? (m/embeds [{:type :user-local-file - :path "cool.md" + :path (h/file-path "/my/project/.eca/rules/cool.md") :name "cool-name" + :scope :project + :workspace-root (h/file-path "/my/project") :content "MY_RULE_CONTENT"}]) - (f.rules/all {} roots)))))) - (testing "global file rules" + (:static (f.rules/all-rules {} roots nil nil))))))) + + (testing "global file rules load as global static rules" (with-redefs [config/get-env (constantly (h/file-path "/home/someuser/.config")) - fs/exists? #(= (h/file-path "/home/someuser/.config/eca/rules") (str %)) - fs/glob (constantly [(fs/path "cool.md")]) + fs/exists? #(contains? #{(h/file-path "/home/someuser/.config/eca/rules") + (h/file-path "/home/someuser/.config/eca/rules/cool.md") + (h/file-path "/home/someuser/.config") + (h/file-path "/home/someuser/.config/eca")} (str %)) + fs/glob (constantly [(fs/path (h/file-path "/home/someuser/.config/eca/rules/cool.md"))]) fs/canonicalize identity fs/file-name (constantly "cool-name") clojure.core/slurp (constantly "MY_RULE_CONTENT")] (let [roots [{:uri (h/file-uri "file:///my/project")}]] (is (match? (m/embeds [{:type :user-global-file - :path "cool.md" + :path (h/file-path "/home/someuser/.config/eca/rules/cool.md") :name "cool-name" + :scope :global :content "MY_RULE_CONTENT"}]) - (f.rules/all {} roots))))))) + (:static (f.rules/all-rules {} roots nil nil)))))))) + +(deftest agent-filtering-test + (testing "keeps backward compatibility for rules without frontmatter" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(= (h/file-path "/path/to/my-rule.md") %) + fs/file-name (constantly "cool-name") + clojure.core/slurp (constantly "RAW_RULE_CONTENT")] + (let [config {:rules [{:path (h/file-path "/path/to/my-rule.md")}]}] + (is (match? + [{:type :user-config + :path (h/file-path "/path/to/my-rule.md") + :name "cool-name" + :content "RAW_RULE_CONTENT"}] + (:static (f.rules/all-rules config [] "code" nil))))))) + + (testing "filters rule by single agent in frontmatter" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(= (h/file-path "/path/to/my-rule.md") %) + fs/file-name (constantly "cool-name") + clojure.core/slurp (constantly "---\nagent: code\n---\n\nOnly code")] + (let [config {:rules [{:path (h/file-path "/path/to/my-rule.md")}]}] + (is (match? + [{:type :user-config + :path (h/file-path "/path/to/my-rule.md") + :name "cool-name" + :content "Only code" + :agents ["code"]}] + (:static (f.rules/all-rules config [] "code" nil)))) + (is (empty? (:static (f.rules/all-rules config [] "plan" nil))))))) + + (testing "filters rule by multiple agents in frontmatter" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(= (h/file-path "/path/to/my-rule.md") %) + fs/file-name (constantly "cool-name") + clojure.core/slurp (constantly "---\nagent:\n - code\n - plan\n---\n\nShared rule")] + (let [config {:rules [{:path (h/file-path "/path/to/my-rule.md")}]}] + (is (= 1 (count (:static (f.rules/all-rules config [] "code" nil))))) + (is (= 1 (count (:static (f.rules/all-rules config [] "plan" nil))))) + (is (empty? (:static (f.rules/all-rules config [] "reviewer" nil))))))) + + (testing "empty agent frontmatter applies to all agents" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(= (h/file-path "/path/to/my-rule.md") %) + fs/file-name (constantly "cool-name") + clojure.core/slurp (constantly "---\nagent: \"\"\n---\n\nGlobal rule")] + (let [config {:rules [{:path (h/file-path "/path/to/my-rule.md")}]}] + (is (= 1 (count (:static (f.rules/all-rules config [] "code" nil))))) + (is (= 1 (count (:static (f.rules/all-rules config [] "plan" nil)))))))) + + (testing "frontmatter without agent key is global" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(= (h/file-path "/path/to/my-rule.md") %) + fs/file-name (constantly "cool-name") + clojure.core/slurp (constantly "---\ntitle: some-rule\n---\n\nRule body")] + (let [config {:rules [{:path (h/file-path "/path/to/my-rule.md")}]}] + (is (match? + [{:type :user-config + :name "cool-name" + :content "Rule body"}] + (:static (f.rules/all-rules config [] "code" nil)))) + (is (match? + [{:content "Rule body"}] + (:static (f.rules/all-rules config [] "plan" nil))))))) + + (testing "malformed frontmatter skips invalid rule" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(= (h/file-path "/path/to/my-rule.md") %) + fs/file-name (constantly "cool-name") + clojure.core/slurp (constantly "---\n: invalid yaml [[\n---\n\nBody")] + (let [config {:rules [{:path (h/file-path "/path/to/my-rule.md")}]}] + (is (empty? (:static (f.rules/all-rules config [] "code" nil))))))) + + (testing "unclosed frontmatter skips invalid rule" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(= (h/file-path "/path/to/my-rule.md") %) + fs/file-name (constantly "cool-name") + clojure.core/slurp (constantly "---\nagent: code\npaths: src/**/*.clj")] + (let [config {:rules [{:path (h/file-path "/path/to/my-rule.md")}]}] + (is (empty? (:static (f.rules/all-rules config [] "code" nil)))))))) + +(deftest paths-frontmatter-test + (testing "single paths string is parsed into :paths vector" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(= (h/file-path "/path/to/my-rule.md") %) + fs/file-name (constantly "cool-name") + clojure.core/slurp (constantly "---\npaths: \"src/**/*.clj\"\n---\n\nRule body")] + (let [config {:rules [{:path (h/file-path "/path/to/my-rule.md")}]}] + (is (match? + [{:name "cool-name" + :content "Rule body" + :paths ["src/**/*.clj"]}] + (f.rules/path-scoped-rules config [] nil nil)))))) + + (testing "paths list is parsed into :paths vector" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(= (h/file-path "/path/to/my-rule.md") %) + fs/file-name (constantly "cool-name") + clojure.core/slurp (constantly "---\npaths:\n - \"src/**/*.{ts,tsx}\"\n - \"lib/**/*.ts\"\n---\n\nRule body")] + (let [config {:rules [{:path (h/file-path "/path/to/my-rule.md")}]}] + (is (match? + [{:name "cool-name" + :content "Rule body" + :paths ["src/**/*.{ts,tsx}" "lib/**/*.ts"]}] + (f.rules/path-scoped-rules config [] nil nil)))))) + + (testing "blank paths behaves like no path filter" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(= (h/file-path "/path/to/my-rule.md") %) + fs/file-name (constantly "cool-name") + clojure.core/slurp (constantly "---\npaths: \"\"\n---\n\nRule body")] + (let [config {:rules [{:path (h/file-path "/path/to/my-rule.md")}]}] + (is (match? + [{:name "cool-name" + :content "Rule body"}] + (:static (f.rules/all-rules config [] nil nil)))) + (is (nil? (:paths (first (:static (f.rules/all-rules config [] nil nil))))))))) + + (testing "combined agent + paths frontmatter retains both" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(= (h/file-path "/path/to/my-rule.md") %) + fs/file-name (constantly "cool-name") + clojure.core/slurp (constantly "---\nagent: code\npaths: \"src/**/*.clj\"\n---\n\nRule body")] + (let [config {:rules [{:path (h/file-path "/path/to/my-rule.md")}]}] + (is (match? + [{:name "cool-name" + :content "Rule body" + :agents ["code"] + :paths ["src/**/*.clj"]}] + (f.rules/path-scoped-rules config [] "code" nil))))))) + +(deftest model-frontmatter-test + (testing "single model string is parsed into :models vector" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(= (h/file-path "/path/to/my-rule.md") %) + fs/file-name (constantly "cool-name") + clojure.core/slurp (constantly "---\nmodel: \"claude-sonnet-4.*\"\n---\n\nRule body")] + (let [config {:rules [{:path (h/file-path "/path/to/my-rule.md")}]}] + (is (match? + [{:name "cool-name" + :content "Rule body" + :models ["claude-sonnet-4.*"]}] + (:static (f.rules/all-rules config [] nil "anthropic/claude-sonnet-4-20250514"))))))) + + (testing "model list is parsed into :models vector" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(= (h/file-path "/path/to/my-rule.md") %) + fs/file-name (constantly "cool-name") + clojure.core/slurp (constantly "---\nmodel:\n - \"claude-sonnet-4.*\"\n - \"gpt-4.*\"\n---\n\nRule body")] + (let [config {:rules [{:path (h/file-path "/path/to/my-rule.md")}]}] + (is (match? + [{:name "cool-name" + :content "Rule body" + :models ["claude-sonnet-4.*" "gpt-4.*"]}] + (:static (f.rules/all-rules config [] nil "anthropic/claude-sonnet-4-20250514"))))))) + + (testing "blank model behaves like no filter" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(= (h/file-path "/path/to/my-rule.md") %) + fs/file-name (constantly "cool-name") + clojure.core/slurp (constantly "---\nmodel: \"\"\n---\n\nRule body")] + (let [config {:rules [{:path (h/file-path "/path/to/my-rule.md")}]}] + (is (match? + [{:name "cool-name" + :content "Rule body"}] + (:static (f.rules/all-rules config [] nil nil)))) + (is (nil? (:models (first (:static (f.rules/all-rules config [] nil nil))))))))) + + (testing "combined agent + model frontmatter retains both" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(= (h/file-path "/path/to/my-rule.md") %) + fs/file-name (constantly "cool-name") + clojure.core/slurp (constantly "---\nagent: code\nmodel: \"claude-sonnet-4.*\"\n---\n\nRule body")] + (let [config {:rules [{:path (h/file-path "/path/to/my-rule.md")}]}] + (is (match? + [{:name "cool-name" + :content "Rule body" + :agents ["code"] + :models ["claude-sonnet-4.*"]}] + (:static (f.rules/all-rules config [] "code" "anthropic/claude-sonnet-4-20250514")))))))) + +(deftest model-filtering-test + (testing "rule without :models always matches any full-model" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(= (h/file-path "/path/to/my-rule.md") %) + fs/file-name (constantly "no-model-rule") + clojure.core/slurp (constantly "No model filter")] + (let [config {:rules [{:path (h/file-path "/path/to/my-rule.md")}]}] + (is (= 1 (count (:static (f.rules/all-rules config [] nil "anthropic/claude-sonnet-4-20250514"))))) + (is (= 1 (count (:static (f.rules/all-rules config [] nil "openai/gpt-4o"))))) + (is (= 1 (count (:static (f.rules/all-rules config [] nil nil)))))))) + + (testing "rule with :models only appears when full-model matches regex" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(= (h/file-path "/path/to/my-rule.md") %) + fs/file-name (constantly "model-rule") + clojure.core/slurp (constantly "---\nmodel: \"claude-sonnet-4.*\"\n---\n\nClaude rule")] + (let [config {:rules [{:path (h/file-path "/path/to/my-rule.md")}]}] + (is (= 1 (count (:static (f.rules/all-rules config [] nil "anthropic/claude-sonnet-4-20250514"))))) + (is (empty? (:static (f.rules/all-rules config [] nil "openai/gpt-4o"))))))) + + (testing "rule with :models is excluded when full-model is nil" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(= (h/file-path "/path/to/my-rule.md") %) + fs/file-name (constantly "model-rule") + clojure.core/slurp (constantly "---\nmodel: \"claude-sonnet-4.*\"\n---\n\nClaude rule")] + (let [config {:rules [{:path (h/file-path "/path/to/my-rule.md")}]}] + (is (empty? (:static (f.rules/all-rules config [] nil nil))))))) + + (testing "model regex matching works with provider-prefixed full-model" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(= (h/file-path "/path/to/my-rule.md") %) + fs/file-name (constantly "model-rule") + clojure.core/slurp (constantly "---\nmodel: \"claude-sonnet-4.*\"\n---\n\nClaude rule")] + (let [config {:rules [{:path (h/file-path "/path/to/my-rule.md")}]}] + (is (= 1 (count (:static (f.rules/all-rules config [] nil "anthropic/claude-sonnet-4-20250514"))))) + (is (= 1 (count (:static (f.rules/all-rules config [] nil "claude-sonnet-4-20250514")))))))) + + (testing "static-rules respects model filtering" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(contains? #{(h/file-path "/path/to/no-model.md") + (h/file-path "/path/to/model-rule.md")} %) + fs/file-name (fn [p] + (cond (= (h/file-path "/path/to/no-model.md") p) "no-model" + (= (h/file-path "/path/to/model-rule.md") p) "model-rule" + :else "unknown")) + clojure.core/slurp (fn [p] + (cond (= (h/file-path "/path/to/no-model.md") p) "Always active" + (= (h/file-path "/path/to/model-rule.md") p) "---\nmodel: \"claude.*\"\n---\n\nClaude only"))] + (let [config {:rules [{:path (h/file-path "/path/to/no-model.md")} + {:path (h/file-path "/path/to/model-rule.md")}]}] + (is (= 2 (count (:static (f.rules/all-rules config [] nil "anthropic/claude-sonnet-4-20250514"))))) + (is (= 1 (count (:static (f.rules/all-rules config [] nil "openai/gpt-4o"))))) + (is (= ["no-model"] (names (:static (f.rules/all-rules config [] nil "openai/gpt-4o")))))))) + + (testing "path-scoped-rules respects model filtering" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(contains? #{(h/file-path "/path/to/path-rule.md") + (h/file-path "/path/to/no-model-path.md")} %) + fs/file-name (fn [p] + (cond (= (h/file-path "/path/to/path-rule.md") p) "path-rule" + (= (h/file-path "/path/to/no-model-path.md") p) "no-model-path" + :else "unknown")) + clojure.core/slurp (fn [p] + (cond (= (h/file-path "/path/to/path-rule.md") p) "---\nmodel: \"gpt.*\"\npaths: \"src/**/*.ts\"\n---\n\nGPT path rule" + (= (h/file-path "/path/to/no-model-path.md") p) "---\npaths: \"src/**/*.clj\"\n---\n\nClojure path rule"))] + (let [config {:rules [{:path (h/file-path "/path/to/path-rule.md")} + {:path (h/file-path "/path/to/no-model-path.md")}]}] + (is (= 2 (count (f.rules/path-scoped-rules config [] nil "openai/gpt-4o")))) + (is (= 1 (count (f.rules/path-scoped-rules config [] nil "anthropic/claude-sonnet-4-20250514")))) + (is (= ["no-model-path"] (names (f.rules/path-scoped-rules config [] nil "anthropic/claude-sonnet-4-20250514")))))))) + +(deftest static-rules-test + (testing "static-rules returns only rules without :paths" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(contains? #{(h/file-path "/path/to/static.md") + (h/file-path "/path/to/scoped.md")} %) + fs/file-name (fn [p] + (cond (= (h/file-path "/path/to/static.md") p) "static" + (= (h/file-path "/path/to/scoped.md") p) "scoped" + :else "unknown")) + clojure.core/slurp (fn [p] + (cond (= (h/file-path "/path/to/static.md") p) "Static rule" + (= (h/file-path "/path/to/scoped.md") p) "---\npaths: \"src/**/*.clj\"\n---\n\nScoped rule"))] + (let [config {:rules [{:path (h/file-path "/path/to/static.md")} + {:path (h/file-path "/path/to/scoped.md")}]}] + (is (= 1 (count (:static (f.rules/all-rules config [] nil nil))))) + (is (= ["static"] (names (:static (f.rules/all-rules config [] nil nil))))) + (is (nil? (:paths (first (:static (f.rules/all-rules config [] nil nil))))))))) + + (testing "path-scoped-rules returns only rules with :paths" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(contains? #{(h/file-path "/path/to/static.md") + (h/file-path "/path/to/scoped.md")} %) + fs/file-name (fn [p] + (cond (= (h/file-path "/path/to/static.md") p) "static" + (= (h/file-path "/path/to/scoped.md") p) "scoped" + :else "unknown")) + clojure.core/slurp (fn [p] + (cond (= (h/file-path "/path/to/static.md") p) "Static rule" + (= (h/file-path "/path/to/scoped.md") p) "---\npaths: \"src/**/*.clj\"\n---\n\nScoped rule"))] + (let [config {:rules [{:path (h/file-path "/path/to/static.md")} + {:path (h/file-path "/path/to/scoped.md")}]}] + (is (= 1 (count (f.rules/path-scoped-rules config [] nil nil)))) + (is (= ["scoped"] (names (f.rules/path-scoped-rules config [] nil nil)))) + (is (some? (:paths (first (f.rules/path-scoped-rules config [] nil nil)))))))) + + (testing "static-rules is filtered by agent and model" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(contains? #{(h/file-path "/path/to/global-rule.md") + (h/file-path "/path/to/code-rule.md") + (h/file-path "/path/to/claude-rule.md")} %) + fs/file-name (fn [p] + (cond (= (h/file-path "/path/to/global-rule.md") p) "global-rule" + (= (h/file-path "/path/to/code-rule.md") p) "code-rule" + (= (h/file-path "/path/to/claude-rule.md") p) "claude-rule" + :else "unknown")) + clojure.core/slurp (fn [p] + (cond (= (h/file-path "/path/to/global-rule.md") p) "Global rule" + (= (h/file-path "/path/to/code-rule.md") p) "---\nagent: code\n---\n\nCode rule" + (= (h/file-path "/path/to/claude-rule.md") p) "---\nmodel: \"claude.*\"\n---\n\nClaude rule"))] + (let [config {:rules [{:path (h/file-path "/path/to/global-rule.md")} + {:path (h/file-path "/path/to/code-rule.md")} + {:path (h/file-path "/path/to/claude-rule.md")}]}] + (is (= 3 (count (:static (f.rules/all-rules config [] "code" "anthropic/claude-sonnet-4-20250514"))))) + (is (= 2 (count (:static (f.rules/all-rules config [] "plan" "anthropic/claude-sonnet-4-20250514"))))) + (is (= 2 (count (:static (f.rules/all-rules config [] "code" "openai/gpt-4o")))))))) + + (testing "path-scoped-rules is filtered by agent and model" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(contains? #{(h/file-path "/path/to/global-path.md") + (h/file-path "/path/to/code-path.md") + (h/file-path "/path/to/claude-path.md")} %) + fs/file-name (fn [p] + (cond (= (h/file-path "/path/to/global-path.md") p) "global-path" + (= (h/file-path "/path/to/code-path.md") p) "code-path" + (= (h/file-path "/path/to/claude-path.md") p) "claude-path" + :else "unknown")) + clojure.core/slurp (fn [p] + (cond (= (h/file-path "/path/to/global-path.md") p) "---\npaths: \"src/**\"\n---\n\nGlobal path rule" + (= (h/file-path "/path/to/code-path.md") p) "---\nagent: code\npaths: \"src/**/*.clj\"\n---\n\nCode path rule" + (= (h/file-path "/path/to/claude-path.md") p) "---\nmodel: \"claude.*\"\npaths: \"src/**/*.ts\"\n---\n\nClaude path rule"))] + (let [config {:rules [{:path (h/file-path "/path/to/global-path.md")} + {:path (h/file-path "/path/to/code-path.md")} + {:path (h/file-path "/path/to/claude-path.md")}]}] + (is (= 3 (count (f.rules/path-scoped-rules config [] "code" "anthropic/claude-sonnet-4-20250514")))) + (is (= 2 (count (f.rules/path-scoped-rules config [] "plan" "anthropic/claude-sonnet-4-20250514")))) + (is (= 2 (count (f.rules/path-scoped-rules config [] "code" "openai/gpt-4o")))))))) + +(deftest matching-path-scoped-rules-test + (testing "returns every matching path-scoped rule with match details" + (with-redefs [f.rules/path-scoped-rules (constantly [{:id "/workspace-a/.eca/rules/format.md" + :name "format.md" + :scope :project + :workspace-root (h/file-path "/workspace-a") + :paths ["src/**.clj"]} + {:id "/workspace-a/.eca/rules/notes.md" + :name "notes.md" + :scope :project + :workspace-root (h/file-path "/workspace-a") + :paths ["docs/**.md"]}])] + (is (match? [{:rule {:id (h/file-path "/workspace-a/.eca/rules/format.md")} + :match {:match? true + :matched-pattern "src/**.clj" + :relative-path (str (fs/path "src" "core.clj"))}}] + (f.rules/matching-path-scoped-rules {} + [{:uri (h/file-uri "file:///workspace-a")}] + "code" + "openai/gpt-4o" + (h/file-path "/workspace-a/src/core.clj"))))))) + +(deftest find-rule-by-id-test + (testing "returns the matching path-scoped rule after agent/model filtering" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(contains? #{(h/file-path "/path/to/rule-a.md") + (h/file-path "/path/to/rule-b.md") + (h/file-path "/path/to/static.md")} %) + fs/file-name (fn [p] + (cond (= (h/file-path "/path/to/rule-a.md") p) "rule-a" + (= (h/file-path "/path/to/rule-b.md") p) "rule-b" + (= (h/file-path "/path/to/static.md") p) "static" + :else "unknown")) + clojure.core/slurp (fn [p] + (cond (= (h/file-path "/path/to/rule-a.md") p) "---\nagent: code\npaths: \"src/**/*.clj\"\n---\n\nRule A content" + (= (h/file-path "/path/to/rule-b.md") p) "---\nmodel: \"claude.*\"\npaths: \"src/**/*.md\"\n---\n\nRule B content" + (= (h/file-path "/path/to/static.md") p) "Static content"))] + (let [config {:rules [{:path (h/file-path "/path/to/rule-a.md")} + {:path (h/file-path "/path/to/rule-b.md")} + {:path (h/file-path "/path/to/static.md")}]}] + (is (match? {:id (h/file-path "/path/to/rule-a.md") :name "rule-a" :content "Rule A content" :paths ["src/**/*.clj"]} + (f.rules/find-rule-by-id config [] (h/file-path "/path/to/rule-a.md") "code" "openai/gpt-4o"))) + (is (match? {:id (h/file-path "/path/to/rule-b.md") :name "rule-b" :content "Rule B content" :paths ["src/**/*.md"]} + (f.rules/find-rule-by-id config [] (h/file-path "/path/to/rule-b.md") "plan" "anthropic/claude-sonnet-4-20250514"))) + (is (nil? (f.rules/find-rule-by-id config [] (h/file-path "/path/to/rule-a.md") "plan" "openai/gpt-4o"))) + (is (nil? (f.rules/find-rule-by-id config [] (h/file-path "/path/to/rule-b.md") "plan" "openai/gpt-4o"))) + (is (nil? (f.rules/find-rule-by-id config [] (h/file-path "/path/to/static.md") "code" "openai/gpt-4o")))))) + + (testing "returns nil when no rule matches the id" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(= (h/file-path "/path/to/my-rule.md") %) + fs/file-name (constantly "existing-rule") + clojure.core/slurp (constantly "---\npaths: \"src/**/*.clj\"\n---\n\nRule content")] + (let [config {:rules [{:path (h/file-path "/path/to/my-rule.md")}]}] + (is (nil? (f.rules/find-rule-by-id config [] (h/file-path "/path/to/nonexistent.md") "code" "openai/gpt-4o")))))) + + (testing "different rules can share the same basename because lookup uses exact id" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(contains? #{(h/file-path "/workspace-a/.eca/rules/format.md") + (h/file-path "/workspace-b/.eca/rules/format.md")} %) + fs/file-name (constantly "format.md") + clojure.core/slurp (fn [p] + (cond (= (h/file-path "/workspace-a/.eca/rules/format.md") p) "---\npaths: \"src/a/**\"\n---\n\nWorkspace A rule" + (= (h/file-path "/workspace-b/.eca/rules/format.md") p) "---\npaths: \"src/b/**\"\n---\n\nWorkspace B rule"))] + (let [config {:rules [{:path (h/file-path "/workspace-a/.eca/rules/format.md")} + {:path (h/file-path "/workspace-b/.eca/rules/format.md")}]}] + (is (= "Workspace A rule" + (:content (f.rules/find-rule-by-id config [] (h/file-path "/workspace-a/.eca/rules/format.md") "code" "openai/gpt-4o")))) + (is (= "Workspace B rule" + (:content (f.rules/find-rule-by-id config [] (h/file-path "/workspace-b/.eca/rules/format.md") "code" "openai/gpt-4o")))))))) + +(deftest match-path-scoped-rule-test + (let [workspace-a (h/file-path "/workspace-a") + workspace-b (h/file-path "/workspace-b") + roots [{:uri (h/file-uri "file:///workspace-a")} + {:uri (h/file-uri "file:///workspace-b")}]] + (testing "project rule matches nested path inside its workspace root" + (is (match? {:match? true + :reason nil + :workspace-root workspace-a + :relative-path (str (fs/path "src" "nested" "core.clj")) + :matched-pattern "src/**/*.clj" + :paths ["src/**/*.clj"]} + (f.rules/match-path-scoped-rule {:scope :project + :workspace-root workspace-a + :paths ["src/**/*.clj"]} + roots + (h/file-path "/workspace-a/src/nested/core.clj"))))) + + (testing "project rule rejects files outside its workspace root" + (is (match? {:match? false + :reason :outside-rule-workspace + :path workspace-b + :workspace-root workspace-a + :relative-path nil} + (f.rules/match-path-scoped-rule {:scope :project + :workspace-root workspace-a + :paths ["src/**/*.clj"]} + roots + workspace-b)))) + + (testing "global rule matches relative path inside the containing workspace root" + (is (match? {:match? true + :reason nil + :workspace-root workspace-b + :relative-path (str (fs/path "lib" "foo.cljs")) + :matched-pattern "lib/**.cljs"} + (f.rules/match-path-scoped-rule {:scope :global + :paths ["lib/**.cljs"]} + roots + (h/file-path "/workspace-b/lib/foo.cljs"))))) + + (testing "matching requires an absolute target path" + (is (match? {:match? false + :reason :path-not-absolute + :path "src/foo.clj" + :workspace-root nil + :relative-path nil} + (f.rules/match-path-scoped-rule {:scope :project + :workspace-root workspace-a + :paths ["src/**.clj"]} + roots + "src/foo.clj")))))) + +(deftest real-file-rule-loading-test + (testing "loads local path-scoped rules from real workspace files and matches target paths" + (let [tmp-dir (fs/create-temp-dir {:prefix "eca-rules-test-"}) + rules-dir (fs/file tmp-dir ".eca" "rules") + rule-file (fs/file rules-dir "clojure.md") + target-file (fs/file tmp-dir "src" "nested" "foo.clj")] + (try + (fs/create-dirs rules-dir) + (fs/create-dirs (fs/parent target-file)) + (spit rule-file "---\nagent: code\nmodel: \"openai/.*\"\npaths: \"src/**/*.clj\"\nenforce:\n - read\n - modify\n---\n\nUse project Clojure style.") + (spit target-file "(ns foo)") + (let [workspace-root (shared/normalize-path tmp-dir) + roots [{:uri (shared/filename->uri (str tmp-dir))}] + rules (f.rules/path-scoped-rules {} roots "code" "openai/gpt-5.2") + rule (first rules)] + (is (= 1 (count rules))) + (is (match? {:name "clojure.md" + :scope :project + :workspace-root workspace-root + :content "Use project Clojure style." + :agents ["code"] + :models ["openai/.*"] + :paths ["src/**/*.clj"] + :enforce ["read" "modify"]} + rule)) + (is (match? {:match? true + :reason nil + :workspace-root workspace-root + :relative-path (str (fs/path "src" "nested" "foo.clj")) + :matched-pattern "src/**/*.clj"} + (f.rules/match-path-scoped-rule rule roots (str target-file))))) + (finally + (fs/delete-tree tmp-dir)))))) + +(deftest all-rules-test + (testing "loads rules once and partitions into :static and :path-scoped" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(contains? #{(h/file-path "/path/to/static.md") + (h/file-path "/path/to/scoped.md")} %) + fs/file-name (fn [p] + (cond (= (h/file-path "/path/to/static.md") p) "static.md" + (= (h/file-path "/path/to/scoped.md") p) "scoped.md" + :else "unknown")) + clojure.core/slurp (fn [p] + (cond (= (h/file-path "/path/to/static.md") p) "Always active" + (= (h/file-path "/path/to/scoped.md") p) "---\npaths: \"src/**/*.clj\"\n---\n\nPath rule"))] + (let [config {:rules [{:path (h/file-path "/path/to/static.md")} + {:path (h/file-path "/path/to/scoped.md")}]} + {:keys [static path-scoped]} (f.rules/all-rules config [] nil nil)] + (is (= 1 (count static))) + (is (= "static.md" (:name (first static)))) + (is (nil? (:paths (first static)))) + (is (= 1 (count path-scoped))) + (is (= "scoped.md" (:name (first path-scoped)))) + (is (= ["src/**/*.clj"] (:paths (first path-scoped))))))) + + (testing "applies agent and model filters" + (with-redefs [fs/absolute? (constantly true) + fs/exists? #(contains? #{(h/file-path "/path/to/code-only.md") + (h/file-path "/path/to/global.md")} %) + fs/file-name (fn [p] + (cond (= (h/file-path "/path/to/code-only.md") p) "code-only.md" + (= (h/file-path "/path/to/global.md") p) "global.md" + :else "unknown")) + clojure.core/slurp (fn [p] + (cond (= (h/file-path "/path/to/code-only.md") p) "---\nagent: code\n---\n\nCode only" + (= (h/file-path "/path/to/global.md") p) "For everyone"))] + (let [config {:rules [{:path (h/file-path "/path/to/code-only.md")} + {:path (h/file-path "/path/to/global.md")}]}] + (is (= 2 (count (:static (f.rules/all-rules config [] "code" nil))))) + (is (= 1 (count (:static (f.rules/all-rules config [] "plan" nil))))) + (is (= ["global.md"] (names (:static (f.rules/all-rules config [] "plan" nil))))))))) diff --git a/test/eca/features/tools/filesystem_test.clj b/test/eca/features/tools/filesystem_test.clj index 5f17d4c48..bf82a4cee 100644 --- a/test/eca/features/tools/filesystem_test.clj +++ b/test/eca/features/tools/filesystem_test.clj @@ -5,6 +5,7 @@ [clojure.string :as string] [clojure.test :refer [deftest is testing]] [eca.features.tools.filesystem :as f.tools.filesystem] + [eca.features.tools.path-rules :as f.tools.path-rules] [eca.features.tools.util :as tools.util] [eca.shared :refer [multi-str]] [eca.test-helper :as h] @@ -127,7 +128,26 @@ (let [require-approval-fn (get-in f.tools.filesystem/definitions ["write_file" :require-approval-fn]) args {"path" (h/file-path "/foo/qux/new_file.clj")} db {:workspace-folders [{:uri (h/file-uri "file:///foo/bar") :name "bar"}]}] - (is (true? (require-approval-fn args {:db db})))))) + (is (true? (require-approval-fn args {:db db}))))) + + (testing "Path-scoped rules are skipped when fetch_rule is unavailable" + (let [writes* (atom {}) + path (h/file-path "/project/foo/src/new_file.clj")] + (is (match? + {:error false + :contents [{:type :text + :text (format "Successfully wrote to %s" path)}]} + (with-redefs [fs/create-dirs (constantly nil) + spit (fn [f content] (swap! writes* assoc f content))] + ((get-in f.tools.filesystem/definitions ["write_file" :handler]) + {"path" path + "content" "(ns new-file)"} + {:db {:workspace-folders [{:uri (h/file-uri "file:///project/foo") :name "foo"}]} + :config {} + :chat-id "chat-1" + :agent "code" + :all-tools []})))) + (is (= "(ns new-file)" (get @writes* path)))))) (deftest grep-test (testing "invalid pattern" @@ -279,6 +299,26 @@ "new_content" "new"} {:db {:workspace-folders [{:uri (h/file-uri "file:///foo/bar") :name "foo"}]}}))))) + (testing "Blocks edits until matching path-scoped rules were fetched" + (let [path (h/file-path "/project/foo/src/my_file.clj")] + (is (match? + {:error true + :contents [{:type :text + :text "fetch rules first"}]} + (with-redefs [fs/exists? (constantly true) + fs/readable? (constantly true) + f.tools.path-rules/require-fetched-path-scoped-rules (fn [_ _] + (tools.util/single-text-content "fetch rules first" :error))] + ((get-in f.tools.filesystem/definitions ["edit_file" :handler]) + {"path" path + "original_content" "old" + "new_content" "new"} + {:db {:workspace-folders [{:uri (h/file-uri "file:///project/foo") :name "foo"}]} + :config {} + :chat-id "chat-1" + :agent "code" + :all-tools [{:full-name "eca__fetch_rule"}]})))))) + (testing "Replace first occurrence only" (let [file-content* (atom {})] (is (match? diff --git a/test/eca/features/tools_test.clj b/test/eca/features/tools_test.clj index 41b07ec61..2285fe775 100644 --- a/test/eca/features/tools_test.clj +++ b/test/eca/features/tools_test.clj @@ -1,7 +1,10 @@ (ns eca.features.tools-test (:require + [babashka.fs :as fs] + [clojure.string :as string] [clojure.test :refer [are deftest is testing]] [eca.config :as config] + [eca.features.rules :as f.rules] [eca.features.tools :as f.tools] [eca.features.tools.filesystem :as f.tools.filesystem] [eca.features.tools.mcp :as f.mcp] @@ -438,6 +441,236 @@ identity nil)))))) +(deftest fetch-rule-tool-test + (testing "fetch_rule renders the matching path-scoped rule content for the current chat context" + (with-redefs [f.rules/path-scoped-rules (fn [_config _roots agent full-model] + (is (= "code" agent)) + (is (= "anthropic/claude-sonnet-4-20250514" full-model)) + [{:id "/workspace/a/.eca/rules/format.md" + :name "format.md" + :scope :project + :path "/workspace/a/.eca/rules/format.md" + :workspace-root "/workspace/a" + :paths ["src/**/*.clj"] + :content "{% if toolEnabled_eca__read_file %}Model rule{% endif %}{% if toolEnabled_eca__shell_command %} + shell enabled{% endif %}"}])] + (let [db {:workspace-folders [] + :chats {"chat-1" {:agent "code" + :model "anthropic/claude-sonnet-4-20250514"}}} + db* (atom db) + handler (some #(when (= "fetch_rule" (:name %)) %) (f.tools/all-tools "chat-1" "code" db {})) + result (f.tools/call-tool! "eca__fetch_rule" + {"id" "/workspace/a/.eca/rules/format.md" + "path" "/workspace/a/src/nested/foo.clj"} + "chat-1" + "call-1" + "code" + db* + {} + (h/messenger) + (h/metrics) + identity + identity + nil) + text (get-in result [:contents 0 :text])] + (is (false? (:error result))) + (is (string/includes? text "**Rule**: format.md\n**Path**: /workspace/a/src/nested/foo.clj\n**Matched pattern**: src/**/*.clj\n")) + (is (string/includes? text "Model rule + shell enabled")) + (is (= "fetch_rule" (:name handler))) + (is (match? {"/workspace/a/src/nested/foo.clj" + {"/workspace/a/.eca/rules/format.md" + {:matched-pattern "src/**/*.clj" + :rule-path "/workspace/a/.eca/rules/format.md" + :workspace-root "/workspace/a"}}} + (get-in @db* [:chats "chat-1" :validated-path-rules])))))) + + (testing "fetch_rule rejects a path that does not match the selected rule" + (let [rule {:id "/workspace/a/.eca/rules/format.md" + :name "format.md" + :scope :project + :workspace-root "/workspace/a" + :paths ["src/**/*.clj"] + :content "Rule body"}] + (with-redefs [f.rules/path-scoped-rules (constantly [rule]) + f.rules/find-rule-by-id (constantly rule)] + (let [db {:workspace-folders [] + :chats {"chat-1" {:agent "code" + :model "anthropic/claude-sonnet-4-20250514"}}} + result (f.tools/call-tool! "eca__fetch_rule" + {"id" "/workspace/a/.eca/rules/format.md" + "path" "/workspace/a/src/foo.clj"} + "chat-1" + "call-mismatch" + "code" + (atom db) + {} + (h/messenger) + (h/metrics) + identity + identity + nil) + text (get-in result [:contents 0 :text])] + (is (:error result)) + (is (string/includes? text "Rule id '/workspace/a/.eca/rules/format.md' does not apply to path '/workspace/a/src/foo.clj'.")) + (is (re-find #"Checked relative path: src[/\\]foo\.clj" text)) + (is (string/includes? text "Allowed patterns: src/**/*.clj")) + (is (string/includes? text "Java NIO `PathMatcher` glob syntax")) + (is (string/includes? text "`src/**/*.clj` matches nested files under `src/` but not `src/foo.clj`")))))) + + (testing "fetch_rule suppresses broken rendered template content" + (with-redefs [f.rules/path-scoped-rules (constantly [{:id "/workspace/a/.eca/rules/broken.md" + :name "broken.md" + :scope :project + :workspace-root "/workspace/a" + :paths ["src/**/*.clj"] + :content "{% if isSubagent %}BROKEN"}])] + (let [db {:workspace-folders [] + :chats {"chat-1" {:agent "code" + :model "anthropic/claude-sonnet-4-20250514"}}} + result (f.tools/call-tool! "eca__fetch_rule" + {"id" "/workspace/a/.eca/rules/broken.md" + "path" "/workspace/a/src/nested/foo.clj"} + "chat-1" + "call-broken" + "code" + (atom db) + {} + (h/messenger) + (h/metrics) + identity + identity + nil) + text (get-in result [:contents 0 :text])] + (is (false? (:error result))) + (is (string/includes? text "**Matched pattern**: src/**/*.clj")) + (is (string/includes? text "This rule contains no usable content for the current chat context and does not need to be loaded again for this path."))))) + + (testing "fetch_rule rejects rules outside the current filtered catalog" + (with-redefs [f.rules/path-scoped-rules (constantly [{:id "/other/rule.md" + :name "other.md" + :scope :project + :paths ["**/*.rb"]}]) + f.rules/find-rule-by-id (constantly nil)] + (let [db {:workspace-folders [] + :chats {"chat-1" {:agent "code" + :model "openai/gpt-4o"}}} + result (f.tools/call-tool! "eca__fetch_rule" + {"id" "/workspace/a/.eca/rules/format.md" + "path" "/workspace/a/src/foo.clj"} + "chat-1" + "call-2" + "code" + (atom db) + {} + (h/messenger) + (h/metrics) + identity + identity + nil)] + (is (match? {:error true + :contents [{:type :text + :text "Rule id '/workspace/a/.eca/rules/format.md' not found in the current path-scoped rules catalog. Use the exact id from the catalog or /rules command."}]} + result))))) + + (testing "fetch_rule tool description lists only the current chat's path-scoped rules and explains the path contract" + (with-redefs [f.rules/path-scoped-rules (fn [_config _roots agent full-model] + (if (and (= agent "code") + (= full-model "anthropic/claude-sonnet-4-20250514")) + [{:id "/workspace/a/.eca/rules/format.md" + :name "format.md" + :scope :project + :workspace-root "/workspace/a" + :paths ["src/**/*.clj"]} + {:id "/home/user/.config/eca/rules/no-network.md" + :name "no-network.md" + :scope :global + :paths ["**/*.clj" "**/*.cljs"]}] + []))] + (let [db {:workspace-folders [] + :chats {"chat-1" {:agent "code" + :model "anthropic/claude-sonnet-4-20250514"} + "chat-2" {:agent "plan" + :model "openai/gpt-4o"}}} + fetch-tool (some #(when (= "fetch_rule" (:name %)) %) (f.tools/all-tools "chat-1" "code" db {}))] + (is (string? (:description fetch-tool))) + (is (re-find #"exact absolute target path" (:description fetch-tool))) + (is (re-find #"Java NIO `PathMatcher` glob syntax" (:description fetch-tool))) + (is (re-find #"id: /workspace/a/\.eca/rules/format\.md" (:description fetch-tool))) + (is (re-find #"scope: project" (:description fetch-tool))) + (is (re-find #"workspace-root: /workspace/a" (:description fetch-tool))) + (is (re-find #"id: /home/user/\.config/eca/rules/no-network\.md" (:description fetch-tool))) + (is (re-find #"scope: global" (:description fetch-tool))) + (is (not (re-find #"chat-2" (:description fetch-tool)))))))) + +(deftest file-tools-path-rule-enforcement-test + (testing "write_file is blocked until fetch_rule validates the same path" + (let [rule {:id "/workspace/a/.eca/rules/format.md" + :name "format.md" + :scope :project + :workspace-root "/workspace/a" + :path "/workspace/a/.eca/rules/format.md" + :paths ["src/**.clj"] + :content "Use the project formatter."} + db {:workspace-folders [{:uri "file:///workspace/a"}] + :chats {"chat-1" {:agent "code" + :model "openai/gpt-5.2"}}} + db* (atom db) + writes* (atom {})] + (with-redefs [f.rules/path-scoped-rules (constantly [rule]) + f.rules/find-rule-by-id (constantly rule) + fs/create-dirs (constantly nil) + spit (fn [path content] (swap! writes* assoc path content)) + slurp (fn [path] + (if (= "/workspace/a/src/foo.clj" (str path)) + (throw (java.io.FileNotFoundException. "missing")) + ""))] + (let [blocked (f.tools/call-tool! "eca__write_file" + {"path" "/workspace/a/src/foo.clj" + "content" "(ns foo)"} + "chat-1" + "call-write-1" + "code" + db* + {} + (h/messenger) + (h/metrics) + identity + identity + nil)] + (is (:error blocked)) + (is (string/includes? (get-in blocked [:contents 0 :text]) "Path-scoped rules must be fetched before modifying '/workspace/a/src/foo.clj'.")) + (is (string/includes? (get-in blocked [:contents 0 :text]) "call `fetch_rule` with this exact `id` and `path`")) + (is (empty? @writes*))) + + (let [fetched (f.tools/call-tool! "eca__fetch_rule" + {"id" "/workspace/a/.eca/rules/format.md" + "path" "/workspace/a/src/foo.clj"} + "chat-1" + "call-fetch-1" + "code" + db* + {} + (h/messenger) + (h/metrics) + identity + identity + nil) + written (f.tools/call-tool! "eca__write_file" + {"path" "/workspace/a/src/foo.clj" + "content" "(ns foo)"} + "chat-1" + "call-write-2" + "code" + db* + {} + (h/messenger) + (h/metrics) + identity + identity + nil)] + (is (false? (:error fetched))) + (is (false? (:error written))) + (is (= "(ns foo)" (get @writes* "/workspace/a/src/foo.clj")))))))) + (deftest call-tool!-omits-optional-empty-string-args-test (testing "optional empty string args are omitted before native tool invocation" (let [received-args* (atom nil)] diff --git a/test/eca/shared_test.clj b/test/eca/shared_test.clj index 2c4deb12d..9fd489328 100644 --- a/test/eca/shared_test.clj +++ b/test/eca/shared_test.clj @@ -1,5 +1,6 @@ (ns eca.shared-test (:require + [babashka.fs :as fs] [clojure.test :refer [deftest is testing]] [eca.shared :as shared] [eca.test-helper :as h] @@ -35,6 +36,60 @@ (is (thrown? IllegalArgumentException (shared/assoc-some {} :a 1 :b))))) +(deftest normalize-path-symlink-test + (when-not h/windows? + (let [tmp-dir (fs/create-temp-dir {:prefix "eca-path-test-"}) + real-root (fs/path tmp-dir "real") + symlink-root (fs/path tmp-dir "link") + missing-file (fs/path symlink-root "src" "foo.clj")] + (try + (fs/create-dirs real-root) + (java.nio.file.Files/createSymbolicLink (.toPath (fs/file symlink-root)) + (.toPath (fs/file real-root)) + (make-array java.nio.file.attribute.FileAttribute 0)) + (is (= (str (fs/path real-root "src" "foo.clj")) + (shared/normalize-path missing-file))) + (is (true? (shared/path-inside-root? missing-file symlink-root))) + (fs/create-dirs (fs/parent missing-file)) + (spit (str missing-file) "") + (is (= (str (fs/path real-root "src" "foo.clj")) + (shared/normalize-path missing-file))) + (is (true? (shared/path-inside-root? missing-file symlink-root))) + (finally + (fs/delete-tree tmp-dir)))))) + +(deftest normalize-path-missing-parent-segments-test + (let [root (h/file-path "/tmp/root") + escaped (h/file-path "/tmp/root/../outside")] + (is (= (h/file-path "/tmp/outside") + (shared/normalize-path escaped))) + (is (false? (shared/path-inside-root? escaped root))))) + +(deftest safe-selmer-render-fallback-test + (testing "defaults to raw template fallback for non-rule call sites" + (is (= "{% if broken %}" + (shared/safe-selmer-render "{% if broken %}" {} "label")))) + + (testing "supports explicit nil fallback for rule-like callers" + (is (nil? (shared/safe-selmer-render "{% if broken %}" {} "label" nil))))) + +(deftest parse-md-invalid-frontmatter-test + (testing "throws on unclosed frontmatter" + (let [result (try + (shared/parse-md "---\nagent: code\npaths: src/**/*.clj") + :no-error + (catch clojure.lang.ExceptionInfo e + (ex-message e)))] + (is (= "Unclosed YAML frontmatter" result)))) + + (testing "throws when frontmatter is not a mapping" + (let [result (try + (shared/parse-md "---\n- one\n- two\n---\nBody") + :no-error + (catch clojure.lang.ExceptionInfo e + (ex-message e)))] + (is (= "YAML frontmatter must be a mapping" result))))) + (deftest tokens->cost-test (let [model-capabilities {:input-token-cost 0.01 :output-token-cost 0.02 @@ -203,6 +258,18 @@ (testing "returns nil when base is blank" (is (nil? (shared/join-api-url " " "/chat/completions"))))) +(deftest compact-side-effect-clears-validated-path-rules-test + (let [db* (atom {:chats {"chat-1" {:last-summary "Short summary" + :messages [] + :validated-path-rules {"/workspace/a/src/foo.clj" + {"/workspace/a/.eca/rules/format.md" {:matched-pattern "src/**.clj"}}}}}})] + (shared/compact-side-effect! {:chat-id "chat-1" + :full-model "openai/gpt-5.2" + :db* db* + :messenger (h/messenger)} + false) + (is (nil? (get-in @db* [:chats "chat-1" :validated-path-rules]))))) + (deftest messages-after-last-compact-marker-test (testing "returns all messages when no compact_marker exists" (let [messages [{:role "user" :content [{:type :text :text "hello"}]} @@ -236,5 +303,3 @@ (testing "handles empty messages" (is (= [] (shared/messages-after-last-compact-marker []))))) - - From 86cef74f583269b11bfddeeae37d7376658858c5 Mon Sep 17 00:00:00 2001 From: Jakub Zika Date: Sun, 26 Apr 2026 19:00:04 +0200 Subject: [PATCH 2/2] Improve rules tests --- docs/config/rules.md | 5 + .../integration/chat/commands_test.clj | 1 + .../eca/eca/native-image.properties | 1 + test/eca/features/rules_test.clj | 45 +-- test/eca/features/tools_test.clj | 282 +++++++++--------- test/eca/shared_test.clj | 13 +- 6 files changed, 185 insertions(+), 162 deletions(-) diff --git a/docs/config/rules.md b/docs/config/rules.md index 31cc45ac7..a5b794ffd 100644 --- a/docs/config/rules.md +++ b/docs/config/rules.md @@ -8,6 +8,11 @@ Rules let you influence how the model behaves without changing an agent prompt. A rule is a Markdown file. By default, rule content is rendered into the system prompt for every chat where the rule matches. Add YAML frontmatter when a rule should apply only to specific agents, models, or paths. +## Rules vs Skills + +- Use rules to make ECA automatically follow project conventions, safety constraints, or guidance for matching paths. +- Use [skills](./skills.md) to teach ECA reusable workflows, tools, or domain knowledge it can load on demand for specific tasks. + ## Rule locations ECA loads rules from 3 sources: diff --git a/integration-test/integration/chat/commands_test.clj b/integration-test/integration/chat/commands_test.clj index 396596041..92112db38 100644 --- a/integration-test/integration/chat/commands_test.clj +++ b/integration-test/integration/chat/commands_test.clj @@ -35,6 +35,7 @@ {:name "config" :arguments []} {:name "doctor" :arguments []} {:name "repo-map-show" :arguments []} + {:name "rules" :arguments []} {:name "prompt-show" :arguments [{:name "optional-prompt"}]} {:name "subagents" :arguments []} {:name "plugins" :arguments []} diff --git a/resources/META-INF/native-image/eca/eca/native-image.properties b/resources/META-INF/native-image/eca/eca/native-image.properties index 10d43e0ef..4a759877b 100644 --- a/resources/META-INF/native-image/eca/eca/native-image.properties +++ b/resources/META-INF/native-image/eca/eca/native-image.properties @@ -39,6 +39,7 @@ Args=-J-Dborkdude.dynaload.aot=true \ -H:IncludeResources=prompts/tools/skill.md \ -H:IncludeResources=prompts/tools/spawn_agent.md \ -H:IncludeResources=prompts/tools/write_file.md \ + -H:IncludeResources=prompts/tools/fetch_rule.md \ -H:IncludeResources=webpages/oauth.html \ -H:IncludeResources=logo.svg \ -H:IncludeResources=tls/local-eca-dev-fullchain.pem \ diff --git a/test/eca/features/rules_test.clj b/test/eca/features/rules_test.clj index e641adb24..bbc95f492 100644 --- a/test/eca/features/rules_test.clj +++ b/test/eca/features/rules_test.clj @@ -430,12 +430,12 @@ (deftest matching-path-scoped-rules-test (testing "returns every matching path-scoped rule with match details" - (with-redefs [f.rules/path-scoped-rules (constantly [{:id "/workspace-a/.eca/rules/format.md" + (with-redefs [f.rules/path-scoped-rules (constantly [{:id (h/file-path "/workspace-a/.eca/rules/format.md") :name "format.md" :scope :project :workspace-root (h/file-path "/workspace-a") :paths ["src/**.clj"]} - {:id "/workspace-a/.eca/rules/notes.md" + {:id (h/file-path "/workspace-a/.eca/rules/notes.md") :name "notes.md" :scope :project :workspace-root (h/file-path "/workspace-a") @@ -563,26 +563,27 @@ (fs/create-dirs (fs/parent target-file)) (spit rule-file "---\nagent: code\nmodel: \"openai/.*\"\npaths: \"src/**/*.clj\"\nenforce:\n - read\n - modify\n---\n\nUse project Clojure style.") (spit target-file "(ns foo)") - (let [workspace-root (shared/normalize-path tmp-dir) - roots [{:uri (shared/filename->uri (str tmp-dir))}] - rules (f.rules/path-scoped-rules {} roots "code" "openai/gpt-5.2") - rule (first rules)] - (is (= 1 (count rules))) - (is (match? {:name "clojure.md" - :scope :project - :workspace-root workspace-root - :content "Use project Clojure style." - :agents ["code"] - :models ["openai/.*"] - :paths ["src/**/*.clj"] - :enforce ["read" "modify"]} - rule)) - (is (match? {:match? true - :reason nil - :workspace-root workspace-root - :relative-path (str (fs/path "src" "nested" "foo.clj")) - :matched-pattern "src/**/*.clj"} - (f.rules/match-path-scoped-rule rule roots (str target-file))))) + (with-redefs [config/get-env (constantly (str (fs/path tmp-dir "no-global-config")))] + (let [workspace-root (shared/normalize-path tmp-dir) + roots [{:uri (shared/filename->uri (str tmp-dir))}] + rules (f.rules/path-scoped-rules {} roots "code" "openai/gpt-5.2") + rule (first rules)] + (is (= 1 (count rules))) + (is (match? {:name "clojure.md" + :scope :project + :workspace-root workspace-root + :content "Use project Clojure style." + :agents ["code"] + :models ["openai/.*"] + :paths ["src/**/*.clj"] + :enforce ["read" "modify"]} + rule)) + (is (match? {:match? true + :reason nil + :workspace-root workspace-root + :relative-path (str (fs/path "src" "nested" "foo.clj")) + :matched-pattern "src/**/*.clj"} + (f.rules/match-path-scoped-rule rule roots (str target-file)))))) (finally (fs/delete-tree tmp-dir)))))) diff --git a/test/eca/features/tools_test.clj b/test/eca/features/tools_test.clj index 2285fe775..9e58d2a50 100644 --- a/test/eca/features/tools_test.clj +++ b/test/eca/features/tools_test.clj @@ -443,51 +443,54 @@ (deftest fetch-rule-tool-test (testing "fetch_rule renders the matching path-scoped rule content for the current chat context" - (with-redefs [f.rules/path-scoped-rules (fn [_config _roots agent full-model] - (is (= "code" agent)) - (is (= "anthropic/claude-sonnet-4-20250514" full-model)) - [{:id "/workspace/a/.eca/rules/format.md" - :name "format.md" - :scope :project - :path "/workspace/a/.eca/rules/format.md" - :workspace-root "/workspace/a" - :paths ["src/**/*.clj"] - :content "{% if toolEnabled_eca__read_file %}Model rule{% endif %}{% if toolEnabled_eca__shell_command %} + shell enabled{% endif %}"}])] - (let [db {:workspace-folders [] - :chats {"chat-1" {:agent "code" - :model "anthropic/claude-sonnet-4-20250514"}}} - db* (atom db) - handler (some #(when (= "fetch_rule" (:name %)) %) (f.tools/all-tools "chat-1" "code" db {})) - result (f.tools/call-tool! "eca__fetch_rule" - {"id" "/workspace/a/.eca/rules/format.md" - "path" "/workspace/a/src/nested/foo.clj"} - "chat-1" - "call-1" - "code" - db* - {} - (h/messenger) - (h/metrics) - identity - identity - nil) - text (get-in result [:contents 0 :text])] - (is (false? (:error result))) - (is (string/includes? text "**Rule**: format.md\n**Path**: /workspace/a/src/nested/foo.clj\n**Matched pattern**: src/**/*.clj\n")) - (is (string/includes? text "Model rule + shell enabled")) - (is (= "fetch_rule" (:name handler))) - (is (match? {"/workspace/a/src/nested/foo.clj" - {"/workspace/a/.eca/rules/format.md" - {:matched-pattern "src/**/*.clj" - :rule-path "/workspace/a/.eca/rules/format.md" - :workspace-root "/workspace/a"}}} - (get-in @db* [:chats "chat-1" :validated-path-rules])))))) + (let [rule-id (h/file-path "/workspace/a/.eca/rules/format.md") + target-path (h/file-path "/workspace/a/src/nested/foo.clj") + workspace-root (h/file-path "/workspace/a")] + (with-redefs [f.rules/path-scoped-rules (constantly [{:id rule-id + :name "format.md" + :scope :project + :path rule-id + :workspace-root workspace-root + :paths ["src/**/*.clj"] + :content "{% if toolEnabled_eca__read_file %}Model rule{% endif %}{% if toolEnabled_eca__shell_command %} + shell enabled{% endif %}"}])] + (let [db {:workspace-folders [] + :chats {"chat-1" {:agent "code" + :model "anthropic/claude-sonnet-4-20250514"}}} + db* (atom db) + handler (some #(when (= "fetch_rule" (:name %)) %) (f.tools/all-tools "chat-1" "code" db {})) + result (f.tools/call-tool! "eca__fetch_rule" + {"id" rule-id + "path" target-path} + "chat-1" + "call-1" + "code" + db* + {} + (h/messenger) + (h/metrics) + identity + identity + nil) + text (get-in result [:contents 0 :text])] + (is (false? (:error result))) + (is (string/includes? text (str "**Rule**: format.md\n**Path**: " target-path "\n**Matched pattern**: src/**/*.clj\n"))) + (is (string/includes? text "Model rule + shell enabled")) + (is (= "fetch_rule" (:name handler))) + (is (match? {target-path + {rule-id + {:matched-pattern "src/**/*.clj" + :rule-path rule-id + :workspace-root workspace-root}}} + (get-in @db* [:chats "chat-1" :validated-path-rules]))))))) (testing "fetch_rule rejects a path that does not match the selected rule" - (let [rule {:id "/workspace/a/.eca/rules/format.md" + (let [rule-id (h/file-path "/workspace/a/.eca/rules/format.md") + target-path (h/file-path "/workspace/a/src/foo.clj") + workspace-root (h/file-path "/workspace/a") + rule {:id rule-id :name "format.md" :scope :project - :workspace-root "/workspace/a" + :workspace-root workspace-root :paths ["src/**/*.clj"] :content "Rule body"}] (with-redefs [f.rules/path-scoped-rules (constantly [rule]) @@ -496,8 +499,8 @@ :chats {"chat-1" {:agent "code" :model "anthropic/claude-sonnet-4-20250514"}}} result (f.tools/call-tool! "eca__fetch_rule" - {"id" "/workspace/a/.eca/rules/format.md" - "path" "/workspace/a/src/foo.clj"} + {"id" rule-id + "path" target-path} "chat-1" "call-mismatch" "code" @@ -510,107 +513,118 @@ nil) text (get-in result [:contents 0 :text])] (is (:error result)) - (is (string/includes? text "Rule id '/workspace/a/.eca/rules/format.md' does not apply to path '/workspace/a/src/foo.clj'.")) + (is (string/includes? text (str "Rule id '" rule-id "' does not apply to path '" target-path "'."))) (is (re-find #"Checked relative path: src[/\\]foo\.clj" text)) (is (string/includes? text "Allowed patterns: src/**/*.clj")) (is (string/includes? text "Java NIO `PathMatcher` glob syntax")) (is (string/includes? text "`src/**/*.clj` matches nested files under `src/` but not `src/foo.clj`")))))) (testing "fetch_rule suppresses broken rendered template content" - (with-redefs [f.rules/path-scoped-rules (constantly [{:id "/workspace/a/.eca/rules/broken.md" - :name "broken.md" - :scope :project - :workspace-root "/workspace/a" - :paths ["src/**/*.clj"] - :content "{% if isSubagent %}BROKEN"}])] - (let [db {:workspace-folders [] - :chats {"chat-1" {:agent "code" - :model "anthropic/claude-sonnet-4-20250514"}}} - result (f.tools/call-tool! "eca__fetch_rule" - {"id" "/workspace/a/.eca/rules/broken.md" - "path" "/workspace/a/src/nested/foo.clj"} - "chat-1" - "call-broken" - "code" - (atom db) - {} - (h/messenger) - (h/metrics) - identity - identity - nil) - text (get-in result [:contents 0 :text])] - (is (false? (:error result))) - (is (string/includes? text "**Matched pattern**: src/**/*.clj")) - (is (string/includes? text "This rule contains no usable content for the current chat context and does not need to be loaded again for this path."))))) + (let [rule-id (h/file-path "/workspace/a/.eca/rules/broken.md") + target-path (h/file-path "/workspace/a/src/nested/foo.clj") + workspace-root (h/file-path "/workspace/a")] + (with-redefs [f.rules/path-scoped-rules (constantly [{:id rule-id + :name "broken.md" + :scope :project + :workspace-root workspace-root + :paths ["src/**/*.clj"] + :content "{% if isSubagent %}BROKEN"}])] + (let [db {:workspace-folders [] + :chats {"chat-1" {:agent "code" + :model "anthropic/claude-sonnet-4-20250514"}}} + result (f.tools/call-tool! "eca__fetch_rule" + {"id" rule-id + "path" target-path} + "chat-1" + "call-broken" + "code" + (atom db) + {} + (h/messenger) + (h/metrics) + identity + identity + nil) + text (get-in result [:contents 0 :text])] + (is (false? (:error result))) + (is (string/includes? text "**Matched pattern**: src/**/*.clj")) + (is (string/includes? text "This rule contains no usable content for the current chat context and does not need to be loaded again for this path.")))))) (testing "fetch_rule rejects rules outside the current filtered catalog" - (with-redefs [f.rules/path-scoped-rules (constantly [{:id "/other/rule.md" - :name "other.md" - :scope :project - :paths ["**/*.rb"]}]) - f.rules/find-rule-by-id (constantly nil)] - (let [db {:workspace-folders [] - :chats {"chat-1" {:agent "code" - :model "openai/gpt-4o"}}} - result (f.tools/call-tool! "eca__fetch_rule" - {"id" "/workspace/a/.eca/rules/format.md" - "path" "/workspace/a/src/foo.clj"} - "chat-1" - "call-2" - "code" - (atom db) - {} - (h/messenger) - (h/metrics) - identity - identity - nil)] - (is (match? {:error true - :contents [{:type :text - :text "Rule id '/workspace/a/.eca/rules/format.md' not found in the current path-scoped rules catalog. Use the exact id from the catalog or /rules command."}]} - result))))) + (let [rule-id (h/file-path "/workspace/a/.eca/rules/format.md") + target-path (h/file-path "/workspace/a/src/foo.clj")] + (with-redefs [f.rules/path-scoped-rules (constantly [{:id (h/file-path "/other/rule.md") + :name "other.md" + :scope :project + :paths ["**/*.rb"]}]) + f.rules/find-rule-by-id (constantly nil)] + (let [db {:workspace-folders [] + :chats {"chat-1" {:agent "code" + :model "openai/gpt-4o"}}} + result (f.tools/call-tool! "eca__fetch_rule" + {"id" rule-id + "path" target-path} + "chat-1" + "call-2" + "code" + (atom db) + {} + (h/messenger) + (h/metrics) + identity + identity + nil)] + (is (match? {:error true + :contents [{:type :text + :text (str "Rule id '" rule-id "' not found in the current path-scoped rules catalog. Use the exact id from the catalog or /rules command.")}]} + result)))))) (testing "fetch_rule tool description lists only the current chat's path-scoped rules and explains the path contract" - (with-redefs [f.rules/path-scoped-rules (fn [_config _roots agent full-model] - (if (and (= agent "code") - (= full-model "anthropic/claude-sonnet-4-20250514")) - [{:id "/workspace/a/.eca/rules/format.md" - :name "format.md" - :scope :project - :workspace-root "/workspace/a" - :paths ["src/**/*.clj"]} - {:id "/home/user/.config/eca/rules/no-network.md" - :name "no-network.md" - :scope :global - :paths ["**/*.clj" "**/*.cljs"]}] - []))] - (let [db {:workspace-folders [] - :chats {"chat-1" {:agent "code" - :model "anthropic/claude-sonnet-4-20250514"} - "chat-2" {:agent "plan" - :model "openai/gpt-4o"}}} - fetch-tool (some #(when (= "fetch_rule" (:name %)) %) (f.tools/all-tools "chat-1" "code" db {}))] - (is (string? (:description fetch-tool))) - (is (re-find #"exact absolute target path" (:description fetch-tool))) - (is (re-find #"Java NIO `PathMatcher` glob syntax" (:description fetch-tool))) - (is (re-find #"id: /workspace/a/\.eca/rules/format\.md" (:description fetch-tool))) - (is (re-find #"scope: project" (:description fetch-tool))) - (is (re-find #"workspace-root: /workspace/a" (:description fetch-tool))) - (is (re-find #"id: /home/user/\.config/eca/rules/no-network\.md" (:description fetch-tool))) - (is (re-find #"scope: global" (:description fetch-tool))) - (is (not (re-find #"chat-2" (:description fetch-tool)))))))) + (let [format-id (h/file-path "/workspace/a/.eca/rules/format.md") + network-id (h/file-path "/home/user/.config/eca/rules/no-network.md") + workspace-root (h/file-path "/workspace/a")] + (with-redefs [f.rules/path-scoped-rules (fn [_config _roots agent full-model] + (if (and (= agent "code") + (= full-model "anthropic/claude-sonnet-4-20250514")) + [{:id format-id + :name "format.md" + :scope :project + :workspace-root workspace-root + :paths ["src/**/*.clj"]} + {:id network-id + :name "no-network.md" + :scope :global + :paths ["**/*.clj" "**/*.cljs"]}] + []))] + (let [db {:workspace-folders [] + :chats {"chat-1" {:agent "code" + :model "anthropic/claude-sonnet-4-20250514"} + "chat-2" {:agent "plan" + :model "openai/gpt-4o"}}} + fetch-tool (some #(when (= "fetch_rule" (:name %)) %) (f.tools/all-tools "chat-1" "code" db {}))] + (is (string? (:description fetch-tool))) + (is (re-find #"exact absolute target path" (:description fetch-tool))) + (is (re-find #"Java NIO `PathMatcher` glob syntax" (:description fetch-tool))) + (is (re-find (re-pattern (str "id: " (java.util.regex.Pattern/quote format-id))) (:description fetch-tool))) + (is (re-find #"scope: project" (:description fetch-tool))) + (is (re-find (re-pattern (str "workspace-root: " (java.util.regex.Pattern/quote workspace-root))) (:description fetch-tool))) + (is (re-find (re-pattern (str "id: " (java.util.regex.Pattern/quote network-id))) (:description fetch-tool))) + (is (re-find #"scope: global" (:description fetch-tool))) + (is (not (re-find #"chat-2" (:description fetch-tool))))))))) (deftest file-tools-path-rule-enforcement-test (testing "write_file is blocked until fetch_rule validates the same path" - (let [rule {:id "/workspace/a/.eca/rules/format.md" + (let [rule-id (h/file-path "/workspace/a/.eca/rules/format.md") + target-path (h/file-path "/workspace/a/src/foo.clj") + workspace-root (h/file-path "/workspace/a") + rule {:id rule-id :name "format.md" :scope :project - :workspace-root "/workspace/a" - :path "/workspace/a/.eca/rules/format.md" + :workspace-root workspace-root + :path rule-id :paths ["src/**.clj"] :content "Use the project formatter."} - db {:workspace-folders [{:uri "file:///workspace/a"}] + db {:workspace-folders [{:uri (h/file-uri "file:///workspace/a")}] :chats {"chat-1" {:agent "code" :model "openai/gpt-5.2"}}} db* (atom db) @@ -620,11 +634,11 @@ fs/create-dirs (constantly nil) spit (fn [path content] (swap! writes* assoc path content)) slurp (fn [path] - (if (= "/workspace/a/src/foo.clj" (str path)) + (if (= target-path (str path)) (throw (java.io.FileNotFoundException. "missing")) ""))] (let [blocked (f.tools/call-tool! "eca__write_file" - {"path" "/workspace/a/src/foo.clj" + {"path" target-path "content" "(ns foo)"} "chat-1" "call-write-1" @@ -637,13 +651,13 @@ identity nil)] (is (:error blocked)) - (is (string/includes? (get-in blocked [:contents 0 :text]) "Path-scoped rules must be fetched before modifying '/workspace/a/src/foo.clj'.")) + (is (string/includes? (get-in blocked [:contents 0 :text]) (str "Path-scoped rules must be fetched before modifying '" target-path "'."))) (is (string/includes? (get-in blocked [:contents 0 :text]) "call `fetch_rule` with this exact `id` and `path`")) (is (empty? @writes*))) (let [fetched (f.tools/call-tool! "eca__fetch_rule" - {"id" "/workspace/a/.eca/rules/format.md" - "path" "/workspace/a/src/foo.clj"} + {"id" rule-id + "path" target-path} "chat-1" "call-fetch-1" "code" @@ -655,7 +669,7 @@ identity nil) written (f.tools/call-tool! "eca__write_file" - {"path" "/workspace/a/src/foo.clj" + {"path" target-path "content" "(ns foo)"} "chat-1" "call-write-2" @@ -669,7 +683,7 @@ nil)] (is (false? (:error fetched))) (is (false? (:error written))) - (is (= "(ns foo)" (get @writes* "/workspace/a/src/foo.clj")))))))) + (is (= "(ns foo)" (get @writes* target-path)))))))) (deftest call-tool!-omits-optional-empty-string-args-test (testing "optional empty string args are omitted before native tool invocation" diff --git a/test/eca/shared_test.clj b/test/eca/shared_test.clj index 9fd489328..9c7ad17e3 100644 --- a/test/eca/shared_test.clj +++ b/test/eca/shared_test.clj @@ -37,7 +37,8 @@ (shared/assoc-some {} :a 1 :b))))) (deftest normalize-path-symlink-test - (when-not h/windows? + (if h/windows? + (is true "Symlinks are not tested on Windows") (let [tmp-dir (fs/create-temp-dir {:prefix "eca-path-test-"}) real-root (fs/path tmp-dir "real") symlink-root (fs/path tmp-dir "link") @@ -47,13 +48,13 @@ (java.nio.file.Files/createSymbolicLink (.toPath (fs/file symlink-root)) (.toPath (fs/file real-root)) (make-array java.nio.file.attribute.FileAttribute 0)) - (is (= (str (fs/path real-root "src" "foo.clj")) - (shared/normalize-path missing-file))) + (let [expected (str (fs/path (shared/normalize-path real-root) "src" "foo.clj"))] + (is (= expected (shared/normalize-path missing-file)))) (is (true? (shared/path-inside-root? missing-file symlink-root))) (fs/create-dirs (fs/parent missing-file)) (spit (str missing-file) "") - (is (= (str (fs/path real-root "src" "foo.clj")) - (shared/normalize-path missing-file))) + (let [expected (str (fs/path (shared/normalize-path real-root) "src" "foo.clj"))] + (is (= expected (shared/normalize-path missing-file)))) (is (true? (shared/path-inside-root? missing-file symlink-root))) (finally (fs/delete-tree tmp-dir)))))) @@ -61,7 +62,7 @@ (deftest normalize-path-missing-parent-segments-test (let [root (h/file-path "/tmp/root") escaped (h/file-path "/tmp/root/../outside")] - (is (= (h/file-path "/tmp/outside") + (is (= (shared/normalize-path (h/file-path "/tmp/outside")) (shared/normalize-path escaped))) (is (false? (shared/path-inside-root? escaped root)))))