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

Filter by extension

Filter by extension

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

## Unreleased

- Improve rules with frontmatter filters, condition variables, path-scoped loading, enforcement support, and clearer documentation. #222
- `preToolCall` hooks now receive `approval: "ask"` for the native `ask_user` tool so notification hooks (e.g. matching `.approval == "ask"`) also fire when the chat is blocked waiting for a user answer, regardless of trust mode.

## 0.129.2
Expand Down
2 changes: 1 addition & 1 deletion docs/config/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
180 changes: 170 additions & 10 deletions docs/config/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,193 @@ 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.

## 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:

=== "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 %}
```
37 changes: 37 additions & 0 deletions docs/config/template.md
Original file line number Diff line number Diff line change
@@ -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_<tool-name>` | 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.
1 change: 1 addition & 0 deletions integration-test/integration/chat/commands_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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 []}
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
4 changes: 4 additions & 0 deletions resources/prompts/tools/fetch_rule.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 4 additions & 1 deletion src/eca/config.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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}}}}}}
Expand All @@ -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"
Expand Down Expand Up @@ -175,6 +177,7 @@
"eca__skill" {}
"eca__task" {}
"eca__ask_user" {}
"eca__fetch_rule" {}
"eca__spawn_agent" {}}
:ask {}
:deny {}}
Expand Down
37 changes: 19 additions & 18 deletions src/eca/features/chat.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 %))))
Expand Down Expand Up @@ -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)."
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1480,4 +1481,4 @@
(lifecycle/send-content! chat-ctx :system (assoc-some {:type :metadata} :title title))
(config/notify-selected-model-changed! (:model chat) db* messenger config)
(config/notify-selected-trust-changed! (:trust chat) db* messenger)
{:found? true :chat-id chat-id :title title}))))
{:found? true :chat-id chat-id :title title}))))
Loading
Loading