配信用の派生画像をそろえるエージェントを走らせたのは、9月3日の更新を当てた翌朝でした。返ってきたのは、いつもの派生画像と、頼んでいないストア掲載文の下書きでした。
定義ファイルは前日から一文字も触っておりません。変わったのは私の側ではなく、既定の側でした。1.1.25 から、Markdown で定義したカスタムエージェントが、周囲のスキル・ルール・サブエージェントを既定で引き継ぐようになったのです。
私は長いあいだ、エージェントの守備範囲を「書かないこと」で絞っておりました。ストア文言の担当には翻訳用語集だけを渡し、画像の担当には何も渡さない——渡していないのだから見えないはずだ、という前提です。その前提は、既定がひとつ動いた朝にきれいに外れました。
絞りたい範囲ほど、既定に預けず自分の手で書いておきます。 いまはこの線引きにしています。
書かなかった設定は、更新のたびに手を離れます
書かなかった設定は、既定値をそのまま借りている状態です。借り物である以上、貸し手の都合で中身が変わります。ここは責める話ではなく、借りているという事実を自分が忘れていただけなのだと思います。
やっかいなのは、この種の変化がエラーを出さないところです。エージェントは前より多くのものを見て、前より多くの仕事をします。動いてしまうので、気づく手がかりが「出力がなんとなく大きい」という感触しか残りません。個人開発で複数のアプリとサイトを並行して触っていると、その感触は忙しさに紛れます。
そこで、感触ではなく差分で気づけるようにしました。やることは単純で、更新を当てる前と後に、各エージェントが実際に何を見ているかを機械が読める形で書き出しておくだけです。
実効スコープを 1 枚の JSON に書き出します
定義ファイルのフロントマターと、周囲のスキル・ルール・サブエージェントの一覧を突き合わせて、「継承が有効なら足す、無効なら足さない」を再現します。私が使っているのは次の 90 行ほどのスクリプトです。
#!/usr/bin/env python3
"""agent_scope_snapshot.py — Markdown 定義エージェントの実効スコープを書き出す。
python3 agent_scope_snapshot.py --inherit-default off <config_dir> > before.json
python3 agent_scope_snapshot.py --inherit-default on <config_dir> > after.json
python3 agent_scope_snapshot.py --diff before.json after.json
"""
import argparse, json, sys
from pathlib import Path
POOLS = ("skills", "rules", "subagents")
def read_front_matter(path):
lines = path.read_text(encoding="utf-8").splitlines()
if not lines or lines[0].strip() != "---":
return {}
fm = {}
for line in lines[1:]:
if line.strip() == "---":
break
if ":" not in line:
continue
key, _, raw = line.partition(":")
val = raw.strip()
if val.startswith("[") and val.endswith("]"):
val = [v.strip().strip("'\"") for v in val[1:-1].split(",") if v.strip()]
elif val.lower() in ("true", "false"):
val = val.lower() == "true"
else:
val = val.strip("'\"")
fm[key.strip()] = val
return fm
def pool_names(root, pool):
d = root / pool
return sorted(p.stem for p in d.glob("*.md")) if d.is_dir() else []
def snapshot(root, inherit_default):
root = Path(root)
pools = {p: pool_names(root, p) for p in POOLS}
agents = {}
for path in sorted((root / "agents").glob("*.md")):
fm = read_front_matter(path)
inherit = fm.get("inheritCustomizations", inherit_default)
entry = {"inherit": bool(inherit), "declared": "inheritCustomizations" in fm}
for pool in POOLS:
explicit = fm.get(pool) or []
if isinstance(explicit, str):
explicit = [explicit]
entry[pool] = (sorted(set(explicit) | set(pools[pool])) if inherit
else sorted(set(explicit)))
agents[fm.get("name", path.stem)] = entry
return {"inherit_default": inherit_default, "agents": agents}
def diff(before, after):
rows = []
for name in sorted(set(before["agents"]) | set(after["agents"])):
b, a = before["agents"].get(name), after["agents"].get(name)
if b is None or a is None:
rows.append((name, "agent", "added" if b is None else "removed", ""))
continue
if b["inherit"] != a["inherit"]:
rows.append((name, "inherit", str(b["inherit"]), str(a["inherit"])))
for pool in POOLS:
for g in sorted(set(a[pool]) - set(b[pool])):
rows.append((name, pool, "", "+" + g))
for l in sorted(set(b[pool]) - set(a[pool])):
rows.append((name, pool, "-" + l, ""))
return rows
def main():
ap = argparse.ArgumentParser()
ap.add_argument("config_dir", nargs="?")
ap.add_argument("--inherit-default", choices=["on", "off"], default="on")
ap.add_argument("--diff", nargs=2, metavar=("BEFORE", "AFTER"))
args = ap.parse_args()
if args.diff:
before = json.loads(Path(args.diff[0]).read_text(encoding="utf-8"))
after = json.loads(Path(args.diff[1]).read_text(encoding="utf-8"))
rows = diff(before, after)
if not rows:
print("no change")
return 0
for name, field, was, now in rows:
print(f"{name}\t{field}\t{was}\t{now}")
return 1
if not args.config_dir:
ap.error("config_dir is required")
print(json.dumps(snapshot(args.config_dir, args.inherit_default == "on"),
ensure_ascii=False, indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
sys.exit(main())キーを並べ替えて出力しているのは、差分を取るときに順序の揺れで嘘の変化が出ないようにするためです。--diff は変化があれば終了コード 1 を返しますので、そのまま更新後の確認手順に組み込めます。
なぜフロントマターを自前で読んでいるかというと、この確認は「エージェント本体を動かさずに」できる必要があるからです。エージェントに聞けば教えてくれるかもしれませんが、聞いた時点でその答えは既定に影響された答えになります。定義ファイルと周囲のファイルだけを見て、外側から組み立て直すほうが確かなのです。
差分に出たのは、絞ったつもりの 1 本でした
手元の構成を小さく再現して試してみます。スキルが 4 本、ルールが 1 本、サブエージェントが 1 本ある場所に、エージェントを 2 本置きました。ストア掲載文の担当は inheritCustomizations: false と翻訳用語集だけを明記してあり、派生画像の担当は何も書いていません。
継承が無効だった頃と、既定で有効になった後を並べると、こうなりました。
$ python3 agent_scope_snapshot.py --inherit-default off /path/to/config > before.json
$ python3 agent_scope_snapshot.py --inherit-default on /path/to/config > after.json
$ python3 agent_scope_snapshot.py --diff before.json after.json
asset-derive inherit False True
asset-derive skills +image-pipeline
asset-derive skills +locale-glossary
asset-derive skills +review-reply
asset-derive skills +seo-audit
asset-derive rules +house-style
asset-derive subagents +translator
明示してあった store-copy は 1 行も出てきません。何も書いていなかった asset-derive だけが、スキル 4 本とルール 1 本とサブエージェント 1 本を一度に受け取っています。朝に届いた「頼んでいない下書き」の正体は、この locale-glossary と translator でした。
数が少ないうちは目で追えます。ただ、スキルが増えるほど差分は長くなりますので、私は最初に出た行だけを見るようにしています。inherit の行が出ているエージェントが、既定に預けていた本人だからです。
差分を読むときに見る 3 か所
| 見る場所 | 何がわかるか | 出たときの動き |
|---|---|---|
| inherit の行 | 既定に預けていたエージェントの一覧 | まずここに出た名前だけを明示に書き換えます |
| subagents の増分 | そのエージェントが他人を呼べるようになったか | 呼ばせたくない相手が入っていれば最優先で外します |
| rules の増分 | 出力の書式や口調を変える指示が増えたか | 出力が「なんとなく違う」原因はたいていここにあります |
私が最初に見落としていたのは 2 行目です。スキルが増えるのは、せいぜい余計な知識を持つだけだと考えておりました。ところがサブエージェントが増えると、そのエージェントは自分で他の担当を呼び始めます。仕事の量そのものが変わりますので、影響はスキル 1 本とは比べものになりません。
明示に書き換えるときの順番
一度に全部を書き換えたくなりますが、私は差分に出たものから順に、次の並びで進めています。
inheritの行に出たエージェントへ、inheritCustomizationsを明示で書き足します(広げたい場合でもtrueと明記します)- そのうえで、渡したいスキルとサブエージェントを名前で列挙します
- 書き換えるたびにスナップショットを取り直し、意図した差分だけが出ているかを確かめます
- 更新を当てる前後でスナップショットを取る手順を、自分の更新メモに 1 行足します
3 番目を挟むのが、遠回りに見えていちばん早い進み方でした。まとめて書き換えてから走らせると、直った理由と壊れた理由が同じ差分の中に混ざります。1 本ずつ確かめれば、その混ざりようがありません。
なお、継承を有効にするかどうかそのものの判断は、これとは別の話になります。何を渡すと取り違えが起きやすいかについては、description の重なりから継承の可否を決めた記録にまとめてありますので、判断の段階で迷われたときに覗いてみてください。
次にやること
今日は 1 本だけで構いません。いちばん狭く使っているエージェントを 1 つ選び、その定義ファイルに inheritCustomizations を明示で書き足してみてください。既定と同じ値であっても構いません。書いた瞬間から、そのエージェントは更新に左右されなくなります。
私はこの朝の一件から、定義ファイルの空欄を「まだ決めていない場所」ではなく「誰かに決めてもらっている場所」として読むようになりました。空欄が少ないほど、更新の朝が静かになります。
お読みいただきありがとうございました。同じように何かが勝手に広がった経験のある方に、この手順が届けば嬉しく思います。