Agents

Loading every rule up front is how none of them apply

An always-loaded rulebook competes with itself for attention. Matching the tool target and injecting only what fires turns 588 rules into the twelve that apply.

MobileByteSensei 9 min read
One drawer pulled from a wall of hundreds, and the twelve cards it lit
agentsarchitecturetooling

There are 588 rule documents in this repository. An agent editing one Kotlin file needs twelve of them.

The question is not how to write the other 576 more concisely. It is how the agent comes to be holding only the twelve.

The case this post is about

One file write, to one path:

workspaces/org/fieldkit/source/fieldkit/feature/profile/src/commonMain/kotlin/ProfileScreen.kt

A Compose screen in a feature module. The rules that genuinely bear on it cover design tokens, dead clickables, incomplete navigation, hardcoded strings, test coverage, and which of three possible sources is the design authority for what this screen looks like.

The rules that do not bear on it cover iOS signing keychains, Gradle daemon warmth, Supabase row level security, secrets vault layout, store listing promotion, and 570-odd others.

The symptom

The first shape is a budget problem, and it is arithmetic rather than judgement. 588 rule documents at a few hundred lines each do not fit in a context window alongside the work. So the always-loaded set gets curated, which means somebody is choosing which rules an agent is allowed to know about before knowing what the agent is about to do.

The second shape is what that curation does under pressure. Every rule's author has the same incentive — a rule that is not loaded does not fire, so get it into the always-loaded tier — and the tier has no natural limit. Ours reached the point where the index of rules was itself a multi-thousand-line document, and the rules that mattered most for a given task were sitting in it next to a hundred that could not possibly apply.

The third shape is the one that is genuinely hard to see: the rules were all present and the specific ones stopped landing. An agent holding 110 rule identifiers, of which four apply, is in a worse position than one holding four — not because the other 106 are wrong, but because nothing in the set distinguishes the applicable ones. Attention is the scarce resource and a long list spends it uniformly.

The fourth is that this is invisible in review. A rulebook that contains the right rule is a rulebook that passes any audit of the form "is this rule documented". The failure is that the agent did not act on it, and the artifact of that failure is a code change that violates a documented rule — which reads as the agent ignoring a rule rather than as the rule never having been in front of it.

Why the obvious fix failed

Write shorter rules. Real and insufficient. Compressing 588 documents to half their length halves a number that needs to drop by an order of magnitude, and compression costs exactly the detail that makes a rule actionable — the measurement, the failing case, the sanctioned alternative. A rule trimmed to its headline is a rule that has to be guessed at.

Curate harder. What we were doing. It fails on the incentive described above, and it fails for a structural reason: the curator is choosing at authoring time and the relevance is only knowable at tool time. "Which rules matter for a Compose screen" has an answer; "which rules matter for whatever an agent does next" does not.

A tiered index — load the index, fetch the rule on demand. Better, and it moves the problem one level up. The index is now the always-loaded artifact, and an index of 588 entries with enough description to decide relevance from is not much smaller than the thing it indexes. Ours was thousands of lines, and the agent still had to decide to look — which is a judgement call at exactly the moment the rule exists to remove one.

Let the agent search for rules when it needs them. This is the version that sounds most appealing and is the most dangerous. It requires the agent to know that a rule might exist, which is precisely what it does not know. The rules that matter most are the ones about failure modes nobody would think to search for — that an adb install -r does not kill the running process, that git add -A can revert a submodule pointer. An agent that knew to search for those would not need them.

The actual mechanism

Match the tool target against trigger patterns at the moment the tool is called, and inject only the rules whose triggers fired.

The shape that makes this work is a pre-execution hook. Before a file write, a shell command, or a command dispatch runs, the target is matched against per-capability shards, and the matching rows are handed to the agent as additional context for that call. No decision, no search, no index — the rules arrive because the path matched.

Three properties turn out to be load-bearing, and two of them I got wrong first.

The always-loaded tier becomes a map, not a list. What stays resident is a domain-to-shard table — fifteen rows naming which file holds the triggers for each domain. Ours is 37 lines. The triggers themselves live in the shards and are read by the matcher, not by the agent.

The matcher must fail open. A rule injector that errors and blocks the tool call has converted a documentation system into an outage. Any failure, any zero-match, any malformed shard exits successfully with an empty injection. The guarantee is "the applicable rules arrive when the matcher works", not "nothing proceeds unless the matcher works", and conflating those two is how a helpful system becomes one people disable.

Some triggers cannot be lowered. This is the one I did not plan for. A matcher that keys on the tool's target can only fire on things the target reveals — a path, a command string. A rule whose condition is "during /implement, when the feature was declared with an action contract" has no path to key on, because the condition is about the surrounding operation rather than the file. Those have to stay always-on, declared explicitly as un-lowerable, and the honest version of this design has a list of them rather than pretending every rule is routable.

The fix

The matcher is small, and the interesting parts are the glob translation and the cap:

ROUTERS="$ROOT/core/rules/routers"   # <!-- preflight-ignore -->
CAP=40

run(){
  # FAIL OPEN, three ways: no shard directory, no target, no match. Each exits 0
  # with an empty injection. A rule injector must never be able to block a tool call.
  [[ -d "$ROUTERS" ]] || { emit ""; }
  [[ -n "$target" ]]  || { emit ""; }

  local matched
  matched="$(LC_ALL=C awk -v TGT="$target" -v CAP="$CAP" '
    # Trigger patterns are authored as globs because that is what a human writing a
    # rule reaches for. Translate rather than asking authors to write regex.
    function globre(t,   r,i,c) {
      r = ""
      for (i = 1; i <= length(t); i++) {
        c = substr(t, i, 1)
        if (c == "*")      r = r ".*"
        else if (c == "?") r = r "."
        else if (c ~ /[][(){}.^$+|\\]/) r = r "\\" c
        else               r = r c
      }
      return r
    }
    # Each shard row is: `trigger` | RULE-A, RULE-B → layers/{cap}/rules/
    /^`/ {
      split($0, parts, "|")
      trig = parts[1]
      gsub(/^`|`$|^[ \t]+|[ \t]+$/, "", trig)
      if (trig == "") next

      # One row can declare several triggers, semicolon-separated.
      n = split(trig, trigs, ";")
      for (j = 1; j <= n; j++) {
        pat = trigs[j]
        gsub(/^[ \t]+|[ \t]+$|^`|`$/, "", pat)
        if (pat == "") continue
        if (TGT ~ globre(pat)) { print $0; hits++; break }
      }
    }
    # A cap, because a pathological target should degrade rather than flood.
    hits >= CAP { exit }
  ' "$ROUTERS"/RULES_ROUTER.*.md 2>/dev/null)" || matched=""

  emit "$matched"
}

And the emit side, which is the whole interface to the agent:

emit(){
  if (( PRINT )); then printf '%s' "$1"            # --print: for /rules, and for humans
  else
    local esc; esc="$(printf '%s' "$1" | escape_for_json)"
    printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","additionalContext":"%s"}}\n' "$esc"
  fi
  exit 0                                            # ALWAYS 0 — see fail-open above
}

Note permissionDecision: "allow" is unconditional. This hook injects; it does not gate. Rules that need to refuse are separate hooks with their own exit codes, and keeping the two apart is what lets the injector be fail-open without weakening anything that blocks.

Run against the file from the top of this post:

$ rule-router-inject.sh "…/feature/profile/src/commonMain/kotlin/ProfileScreen.kt" --print

`**/feature/*/src/**/*Screen.kt` written/edited during `/implement` or `/kmp-implement`; … |
  RULE-IMPL-DEAD-CLICKABLE-001, RULE-PROTO-COMPOSE-INCOMPLETE-NAV-001 → layers/feature/rules/ |
`/feature` | RULE-FEATURE-TEST-001, RULE-FEATURE-COMPLY-001 → layers/feature/rules/ |
`workspaces/*/*/source/*/feature/*/src/commonMain/**/*.kt` written/edited during …; … |
  RULE-IMPL-DESIGN-TOKEN-CONSUMPTION-001, RULE-IMPL-DESIGN-TOKEN-PARITY-001,
  RULE-DESIGN-CONFORMANCE-001, RULE-AUTO-FIX-001, RULE-CI-001 → layers/feature/rules/ |
`idea-layer/screens/*/ui.yaml#ui_source_override`; Compose UI generation in …; … |
  RULE-UI-SOURCE-001, RULE-RENDER-CONVERGENCE-001 → layers/misc/rules/ |
`**/src/**/*.kt` | RULE-TDD-GLOBAL-001 → layers/testing/rules/ |

Five rows, twelve distinct rules, out of 588. And the two cases that prove the matcher is actually discriminating rather than just verbose:

$ rule-router-inject.sh "workspaces/org/fieldkit/secrets-manifest.yaml" --print
`secrets-manifest.yaml` (any project root) | RULE-SECRETS-VAULT-001 → layers/secrets/rules/ |
`workspaces/**/secrets-manifest.yaml` | RULE-SECRETS-ALIAS-REGISTRY-001 → layers/secrets/rules/ |

$ rule-router-inject.sh "README.md" --print
$ echo $?
0

A secrets manifest gets two rules and none of the Compose ones. A README gets nothing at all, and exits 0 — which is the fail-open path and the no-match path being the same path, deliberately.

How we proved it

The ratio is measurable and it is the whole argument. 588 rule documents on disk; 12 injected for the Compose screen; 2 for the secrets manifest; 0 for a README. For the Kotlin path that is roughly 2% of the corpus, selected without the agent deciding anything.

The always-loaded tier is 37 lines. That is the domain-to-shard map, and it is the only rule-related artifact resident for every call. The shards it points at total 476 lines across fifteen files — read by the matcher, never by the agent:

RULES_ROUTER.idea.md          132 lines
RULES_ROUTER.framework.md      66
RULES_ROUTER.secrets.md        49
RULES_ROUTER.feature.md        47
RULES_ROUTER.misc.md           39
RULES_ROUTER.design.md         35
RULES_ROUTER.deployment.md     20
…  (9 more, 9–12 lines each)

The uneven shard sizes are themselves a finding. idea at 132 lines against training at 9 is not a tidiness problem — it says the idea capability has accumulated far more path-specific rules than any other, which is true and worth knowing. A flat index hides that distribution completely.

What I cannot show you is a prevented violation. This is the same gap I keep hitting with anything preventative: I can demonstrate that the applicable rules now arrive, and I cannot produce the counterfactual where they did not arrive and something broke. The argument for the mechanism is the ratio and the structure, not an incident count.

What I got wrong and had to add: the un-lowerable list. My first version assumed every rule could be keyed on a target, and the ones that cannot — conditions about the surrounding operation rather than the file — silently stopped firing when I moved them out of the always-loaded tier. They are now declared explicitly in their own document, which is less elegant than "everything routes" and is the honest shape.

And a limit on the matcher itself: it keys on a path or a command string, so it is matching a proxy for intent. A rule about what happens during a particular command fires on the command string, which is present for a Bash call and absent for a file write made by the same command. That is why the un-lowerable list exists rather than being a temporary compromise.

What this generalizes to

When a body of guidance outgrows the attention available to read it, the fix is not to shorten the guidance — it is to make relevance a property the system computes rather than a judgement the reader makes.

That inverts the usual move. Curating an always-loaded set means deciding relevance at authoring time, when the only thing knowable is what the rule is about. Matching at call time means deciding it when the target is in hand, which is the only moment the question has an answer. Everything else about the design follows from moving that decision later: the resident tier shrinks to a map, the triggers become data, and the hook that reads them has to fail open, because a system that gates on its own relevance computation is a system that stops work when the computation is wrong.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS