2.17.0 のチェンジログを読んでいた夜、「Rules no longer crowd out everything else」という一行で手が止まりました。ルールがほかのものを押し出さなくなった、という文は裏を返せば、これまでは押し出していたということなのです。
私自身、Lab 4 サイトの運用ルール集を何度か分割して薄くしてきましたが、それでも合計は膨らんでおりました。あの分厚いルール集は、私が気づかないところでスキルや MCP ツールを追い出していたのかもしれません——そう思うと胃のあたりが重くなりました。
そこで、更新を入れる前に手元のルール集を数えてみることにしました。数えてみると、枠を 8,613 トークン越えておりました。
2.17.0 で決まった二つの上限
先に、ルールのドキュメントに書かれている上限を整理しておきます。上限は二つあり、単位が違います。
| 上限 | 数え方 | 越えたときの扱い |
|---|---|---|
| 1 ファイル 24,000 バイト | @[label](path) の取り込みを展開したあとのバイト数 | 末尾が切り詰められます |
| 合計 20,000 トークン | グローバルと always_on のルール全体 | 大きいファイルから順に、パスと説明だけのポインタに降ろされます |
この 20,000 トークンは、スキル・サブエージェント・MCP ツールが使う customization の予算とは別枠です。2.17.0 より前は同じ枠を取り合っていたので、ルールが太るとほかが削られていました。
もう一つ大事なのは、model_decision のルールは最初からポインタとして入る、という点です。パスと description だけが前置きに載り、エージェントが必要と判断したときに本文を開きます。つまり枠を越えて降ろされるのも、自分で model_decision にしておくのも、エージェントから見た形はほぼ同じなのです。
手元のルール集を数える小さな道具
数えたかったのは三つです。各ファイルが展開後に 24,000 バイトを越えていないか、always_on の合計が 20,000 トークンに収まっているか、そして収まらないとき、どのファイルからポインタに落ちるのか。
Antigravity 自体は結果を教えてくれますが、それは更新して開いてからの話です。私は入れる前に知りたかったので、ワークスペースのルール一式を読んで試算する Python を書きました。
#!/usr/bin/env python3
"""Antigravity のルール予算を、手元のルール集で事前に測る小さな道具。
- 24,000 バイト/ファイル(@[label](path) 展開後)を超えるものを警告
- always_on(と frontmatter の無い AGENTS.md / GEMINI.md)の合計を 20,000 トークンと比べる
- 超過時は大きいファイルから順にポインタ化される前提で、どれが残るかを試算
トークンは tiktoken cl100k_base の近似値。Gemini の実カウントとは前後します。
"""
import re, sys, pathlib
import tiktoken
FILE_LIMIT_BYTES = 24_000
RULES_BUDGET_TOKENS = 20_000
VALID_TRIGGERS = {"always_on", "model_decision", "glob", "manual"}
enc = tiktoken.get_encoding("cl100k_base")
INCLUDE = re.compile(r"@\[[^\]]*\]\(([^)]+)\)")
def expand_includes(text: str, base: pathlib.Path) -> str:
def repl(m):
target = pathlib.Path(m.group(1)).expanduser()
if not target.is_absolute():
target = (base / target).resolve()
try:
body = target.read_text(encoding="utf-8")
except OSError:
return m.group(0) # 見つからないときは原文のまま
return re.sub(r"\A---\n.*?\n---\n", "", body, flags=re.S) # frontmatter を落とす
return INCLUDE.sub(repl, text)
def read_rule(path: pathlib.Path):
raw = path.read_text(encoding="utf-8")
trigger, desc = None, ""
m = re.match(r"\A---\n(.*?)\n---\n", raw, flags=re.S)
if m:
fm = m.group(1)
t = re.search(r"^trigger:\s*(\S+)", fm, flags=re.M)
d = re.search(r'^description:\s*"?(.*?)"?\s*$', fm, flags=re.M)
trigger = t.group(1) if t else "INVALID"
if trigger not in VALID_TRIGGERS:
trigger = "INVALID" # alwaysOn / modelDecision などは黙って捨てられる
desc = d.group(1) if d else ""
elif path.name in ("AGENTS.md", "GEMINI.md"):
trigger = "always_on" # frontmatter なしで常時有効
else:
trigger = "INVALID" # rules/ 配下で frontmatter 無し → 黙って捨てられる
body = expand_includes(raw, path.parent)
return {"path": path, "trigger": trigger, "desc": desc,
"bytes": len(body.encode("utf-8")), "tokens": len(enc.encode(body))}
def main(root: str):
root = pathlib.Path(root)
files = [p for p in (root / "AGENTS.md", root / "GEMINI.md") if p.exists()]
files += sorted((root / ".agents" / "rules").glob("*.md"))
rules = [read_rule(p) for p in files]
active = [r for r in rules if r["trigger"] == "always_on"]
total = sum(r["tokens"] for r in active)
print(f"{'file':<28}{'trigger':<16}{'bytes':>8}{'tokens':>8} note")
for r in rules:
note = []
if r["bytes"] > FILE_LIMIT_BYTES: note.append("OVER 24,000 B -> truncated")
if r["trigger"] == "INVALID": note.append("discarded (no/invalid frontmatter)")
print(f"{r['path'].name:<28}{r['trigger']:<16}{r['bytes']:>8}{r['tokens']:>8} {' / '.join(note)}")
print(f"\nalways_on total: {total:,} tokens / budget {RULES_BUDGET_TOKENS:,}")
if total <= RULES_BUDGET_TOKENS:
print("OK: すべて本文のまま入ります"); return
demoted, remaining = [], total
for r in sorted(active, key=lambda r: r["tokens"], reverse=True):
if remaining <= RULES_BUDGET_TOKENS: break
demoted.append(r); remaining -= r["tokens"]
print(f"over by {total - RULES_BUDGET_TOKENS:,}; demoted to pointers (largest first):")
for r in demoted:
print(f" - {r['path'].relative_to(root)}: {r['desc'] or '(description なし)'}")
print(f"inline after demotion: {remaining:,} tokens")
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else ".")pip install tiktoken を済ませ、ワークスペースの root を渡して python3 rules_budget_probe.py . と実行します。
なぜこう書いたかを二つだけ書き残します。取り込みの展開をサイズ判定の前に置いているのは、ドキュメントが「展開後のバイト数」で 24,000 を判定すると明記しているからです。trigger を四つの値と照合しているのは、綴りを誤ったルールがエラーにならず黙って捨てられるからで、これは後で実際に引っかかりました。
トークンの数え方は cl100k_base の近似で、Gemini の実カウントとは前後します。境界ぎりぎりの判断には向きませんが、「越えているか、どれくらい越えているか」を知るには十分でした。
数えた結果
私の Lab 運用ルール集は、要点をまとめた AGENTS.md と、参照表・共通手順・サイト固有の値・文体の決まり・チェックリストの 5 本で構成されています。全部を always_on として置いた場合の結果が次の表です。
| ファイル | 役割 | バイト | トークン(近似) |
|---|---|---|---|
| AGENTS.md | 運用の要点 | 15,392 | 5,685 |
| content-core.md | 共通手順と検査 | 21,993 | 7,988 |
| reference.md | 参照表 | 19,944 | 7,114 |
| voice.md | 文体の決まり | 12,280 | 4,593 |
| site.md | サイト固有の値 | 5,166 | 1,830 |
| checklist.md | 執筆チェックリスト | 3,623 | 1,403 |
| always_on の合計 | 28,613 | ||
合計は 28,613 トークンで、枠を 8,613 越えておりました。試算では大きい順に content-core.md(7,988)と reference.md(7,114)がポインタに降ろされ、本文のまま残るのは 13,511 トークン分です。
ここで手が止まりました。降ろされる 2 本のうち content-core.md は、私がいちばん「常に読んでいてほしい」と思っていた手順書だったのです。一方で、3,623 バイトのチェックリストは何もしなくても本文のまま残ります。
枠は重要度を見ません。大きさだけを見ます。私が「重要だから詳しく書いた」文書ほど大きくなり、大きいものから順に降ろされる——分厚くした理由と、降ろされる理由が同じでした。
直感と逆だった三つのこと
試算を何度か回すあいだに、ドキュメントを読んだだけでは分からなかったことが三つありました。
一つ目は、上で書いた「大きい順」です。減らすなら、いちばん大切な文書を短くするのがいちばん効きます。
二つ目は、トークンが足りていてもバイトで切られる場合があることです。AGENTS.md の末尾に @[参照](./reference.md) を一行足して参照表を取り込む形にすると、展開後は 35,231 バイトで 24,000 を越え、切り詰めの対象になりました。トークンは 12,769 で 20,000 の枠には余裕があるのに、です。取り込みは便利ですが、取り込んだ分もそのファイルのバイト数に乗ります。
三つ目は、trigger: alwaysOn と書いた試しのファイルが、警告もなく一覧から消えたことです。ドキュメントに「黙って捨てられる」と書いてあるとおりで、私の道具でも最初は alwaysOn をそのまま表示してしまい、照合を足して初めて INVALID と出るようになりました。ルールが効いていない気がするときは、.antigravityignore が効いていないときに疑う4か所と同じで、まず綴りを疑うのが早いのだと思います。
落とされる前に、自分で降ろす
結果を見て、私は 2 本を自分で model_decision に変えました。content-core.md と reference.md の frontmatter を書き換え、description を「いつ開くべきか」が分かる一文に直しただけです。
同じ道具で数え直すと、always_on の合計は 13,511 トークンになり、枠まで 6,489 の余裕が生まれました。エージェントから見える形は、枠に降ろされた場合とほとんど同じです。違うのは、どれを降ろすかを枠ではなく私が決めた、という一点だけなのです。
枠に落とされる前に、自分で降ろす順番を決めておく。 この線引きだけは、ルール集を増やすたびに守るようにしています。
残す側にも基準を置きました。短い禁止事項と文体の決まりは always_on に残し、長い手順と参照表は description を丁寧に書いて model_decision に降ろします。手順は必要な場面が決まっているので、エージェントが開くべきときに開けば足りるのです。
同じ 2.17.0 では、リポジトリ単位の設定が .gemini/config.json に移り、旧 .agents/settings.json は読まれなくなりました。設定の置き場所が動いた直後は、無人実行のエージェントが設定ファイルを書き換えたら戻す、小さな番人スクリプトの組み方で書いた見張りの対象も、新しい場所へ向け直しておくと安心です。
まず一本だけ数えてみる
更新を入れる前に、上の道具をワークスペースの root で一度だけ走らせてみていただければと思います。合計が 20,000 を越えていたら、いちばん大きい一本の description を書き直して model_decision に降ろす——それだけで、枠に選ばれる代わりに自分で選んだことになります。
私も、いちばん大切だと思っていた手順書から降ろしました。降ろしてみると、エージェントは必要なときにちゃんとそれを開いてくれるのだと、いまは感じています。