Antigravity 2.12.0 の引用を初めて使った夜、私は長い応答の中から関数を4行だけ選び、そのまま次のプロンプトの文脈として渡しました。文脈をうまく絞れたつもりでおりました。
返ってきた修正案は、私が渡していない定数を、それらしい名前で勝手に補ったものでした。同じやり取りを三度繰り返して、ようやく手が止まりました。
原因は難しいものではありませんでした。引用した4行が使っている名前の定義が、引用の外に残っていたのです。
最初にお伝えしたいのは、引用で削るべきものは分量ではない、ということです。
引用が運ぶもの、置いていくもの
2.12.0 で入った引用は、応答の一部を範囲選択して、そのまま次のプロンプトの文脈として渡せる機能です。これまでは手でコピーするか、応答の全体を参照させるかの二択でした。長い応答のうち一箇所だけを次の指示の根拠にしたいとき、その二択はどちらも据わりが悪いものでした。
ただ、実際に使ってみると、この機能が運ぶのは連続した行の並びだけです。その行が寄りかかっている定義までは、いっしょに付いてきません。
私が最初に引用したのは、次のような4行でした。
def run_agent(settings):
timeout = settings.get("timeout", AGENT_TIMEOUT_MS)
budget = min(timeout, AGENT_TIMEOUT_MS)
return dispatch(budget)見た目には自己完結しています。けれども AGENT_TIMEOUT_MS の値も、dispatch が何を返すのかも、この4行のどこにも書かれておりません。人間は「上のほうにあったはず」と補いながら読みますが、次のプロンプトに渡されるのは選択した範囲だけです。
ローカルモデルを併用している方には、ここがなおさら効きます。狭いコンテキストに収めたい一心で範囲を切り詰めると、削ってはいけない依存まで一緒に落ちてしまうのです。
取り残された名前を、渡す前に数える
同じ失敗を繰り返さないために、引用する範囲を決めた時点で確かめる小さなスクリプトを書きました。応答をテキストで保存しておき、引用したい行範囲を渡すと、その範囲が触れている名前のうち、定義が範囲の外にあるものを並べます。
#!/usr/bin/env python3
"""応答の一部だけを次のプロンプトへ引用したときに、
定義が引用の外に取り残される名前を洗い出します。
使い方: quote_check.py <response.md> <開始行> <終了行> ["追加の指示文"]
終了コード: 0 = 取り残しなし / 1 = 取り残しあり
"""
import re
import sys
# 定義とみなす行のパターン(Python / JS / TS / 定数)
DEF_PATTERNS = [
re.compile(r"^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)"),
re.compile(r"^\s*class\s+([A-Za-z_]\w*)"),
re.compile(r"^\s*(?:export\s+)?(?:const|let|var|function)\s+([A-Za-z_]\w*)"),
re.compile(r"^\s*([A-Z][A-Z0-9_]{2,})\s*[:=]"),
]
BACKTICK = re.compile(r"`([A-Za-z_][\w.]*)`")
BARE = re.compile(r"\b([A-Za-z_]\w{2,})\b")
STOPWORDS = {
"def", "class", "return", "import", "from", "for", "if", "else", "elif",
"None", "True", "False", "self", "and", "not", "get", "min", "max", "len",
"sorted", "list", "dict", "str", "int", "encoding", "utf",
}
def read_lines(path):
with open(path, encoding="utf-8") as fh:
return fh.read().splitlines()
def collect_defs(lines, offset=0):
"""定義された名前と、その行番号を集めます。"""
found = {}
for i, line in enumerate(lines, start=offset + 1):
for pat in DEF_PATTERNS:
m = pat.match(line)
if m and m.group(1) not in found:
found[m.group(1)] = i
return found
def collect_refs(text, known):
"""応答のどこかで定義されている名前のうち、この本文が触れているものだけ返します。"""
hits = set()
for tok in BACKTICK.findall(text):
hits.add(tok.split(".")[0])
for tok in BARE.findall(text):
if tok not in STOPWORDS:
hits.add(tok)
return {t for t in hits if t in known}
def report(path, start, end, followup=""):
lines = read_lines(path)
if not 1 <= start <= end <= len(lines):
print(f"行範囲が不正です(1..{len(lines)} の範囲で指定してください)")
return 2
quoted_lines = lines[start - 1:end]
quoted = "\n".join(quoted_lines)
whole = "\n".join(lines)
all_defs = collect_defs(lines)
in_quote = collect_defs(quoted_lines, offset=start - 1)
referenced = collect_refs(quoted + "\n" + followup, all_defs)
dangling = sorted(t for t in referenced if t not in in_quote)
ratio = len(quoted) * 100 // max(len(whole), 1)
print(f"応答全体 : {len(whole)} 文字 / {len(lines)} 行")
print(f"引用 L{start}-L{end} : {len(quoted)} 文字 / {end - start + 1} 行(全体の {ratio}%)")
print(f"引用が触れる名前 : {len(referenced)} 個")
print(f"引用内で定義 : {len(in_quote)} 個")
if dangling:
print(f"定義が引用の外 : {len(dangling)} 個")
for tok in dangling:
print(f" - {tok}(定義は L{all_defs[tok]})")
lo = min(start, min(all_defs[t] for t in dangling))
hi = max(end, max(all_defs[t] for t in dangling))
print(f"→ 引用を L{lo}-L{hi} まで広げると解消します")
return 1
print("定義が引用の外 : 0 個")
return 0
if __name__ == "__main__":
if len(sys.argv) < 4:
print(__doc__)
sys.exit(2)
tail = sys.argv[4] if len(sys.argv) > 4 else ""
sys.exit(report(sys.argv[1], int(sys.argv[2]), int(sys.argv[3]), tail))書き方の理由を二つだけ添えます。
ひとつめは、参照の抽出をバッククォート内の記法だけに頼らなかったことです。応答の中の説明文では dispatch のように囲まれていても、コードの本体では裸で現れます。囲みだけを見ていると、いちばん見落としたい依存を素通りしてしまいます。
ふたつめは、判定を「応答のどこかで定義されている名前」に限ったことです。外部ライブラリの関数まで拾い始めると出力が読めなくなります。ここで知りたいのは、同じ応答の中にあったのに引用から漏れたものだけなのです。
4行の引用で、定義が2つ残っていました
45行・1,039文字の応答を用意して、最初に私がやったとおり run_agent の4行だけを引用した状態で走らせました。
$ python3 quote_check.py response.md 33 36 "budget の決め方を直してください"
応答全体 : 1039 文字 / 45 行
引用 L33-L36 : 152 文字 / 4 行(全体の 14%)
引用が触れる名前 : 3 個
引用内で定義 : 1 個
定義が引用の外 : 2 個
- AGENT_TIMEOUT_MS(定義は L6)
- dispatch(定義は L38)
→ 引用を L6-L38 まで広げると解消します
$ echo $?
1全体の14%まで絞れていたのに、その中で使っている名前の三分の二は定義を置き去りにしておりました。返ってきた修正案が的外れだったのは、モデルが不出来だったからではなく、私が渡した材料が足りなかったからです。
一度広げただけでは足りませんでした
指示された L6-L38 へ素直に広げれば済むと思っていたのですが、そうはなりませんでした。
$ python3 quote_check.py response.md 6 38 "budget の決め方を直してください"
引用 L6-L38 : 807 文字 / 33 行(全体の 77%)
引用が触れる名前 : 8 個
引用内で定義 : 7 個
定義が引用の外 : 1 個
- CONFIG_ROOT(定義は L5)
→ 引用を L5-L38 まで広げると解消します範囲を広げると、新しく入ってきた行が別の名前に寄りかかります。依存はひと続きの鎖になっていて、一度たぐっただけでは端まで届きません。ここは私にとって意外でした。範囲を切り詰めることばかり考えていて、鎖の長さを見ていなかったのだと気づいたのは、二度目の実行を終えたあとでした。
そこで、収束するまで自動で広げる短いラッパーを足しました。
#!/bin/bash
# 使い方: widen.sh <response.md> <開始行> <終了行> "<追加の指示文>"
FILE="$1"; S="$2"; E="$3"; MSG="$4"
for i in 1 2 3 4 5; do
OUT=$(python3 quote_check.py "$FILE" "$S" "$E" "$MSG")
if ! grep -q '定義が引用の外 : [1-9]' <<< "$OUT"; then
echo "$i 回目で収束しました: L$S-L$E"
grep -E '^引用 L' <<< "$OUT"
exit 0
fi
RANGE=$(grep -o 'L[0-9]*-L[0-9]*' <<< "$OUT" | tail -1)
S="${RANGE%-*}"; S="${S#L}"
E="${RANGE#*-}"; E="${E#L}"
echo "$i 回目: L$S-L$E へ拡張"
done
echo "5 回で収束しませんでした"; exit 1実行するとこうなりました。
$ ./widen.sh response.md 33 36 "budget の決め方を直してください"
1 回目: L6-L38 へ拡張
2 回目: L5-L38 へ拡張
3 回目で収束しました: L5-L38
引用 L5-L38 : 838 文字 / 34 行(全体の 80%)14%で足りるはずだった引用が、最終的に80%になりました。数字だけ見ると引用の意味が薄れたように思えますが、私はむしろ助かったと感じております。80%を渡さないと通じない箇所だと先に分かったからです。
引用に向く箇所と、向かない箇所
同じ応答の中でも、範囲によって結果はまったく違いました。走査の打ち切り条件を扱っている部分は、ひと回りで収束しました。
| 引用した箇所 | 最初の範囲 | 収束後の範囲 | 拡張の回数 |
|---|---|---|---|
| タイムアウトの決定(run_agent) | 4行・全体の14% | 34行・全体の80% | 2回 |
| 走査の打ち切り(walk_guarded) | 7行・全体の19% | 8行・全体の21% | 1回 |
| 応答全体をそのまま | 45行・100% | 45行・100% | 0回 |
同じ応答の中で、片方は21%で足り、もう片方は80%を必要としました。この差は行数でも複雑さでもなく、その箇所がどれだけ他所の名前に寄りかかっているかで決まっているのだと感じています。
引用で削るのは分量ではなく、依存の外側です。
この線引きに立つと、判断が楽になりました。80%まで広がる箇所は、そもそも引用に向いておりません。応答をまるごと参照させるか、先にその部分を独立したファイルへ切り出してから渡すほうが、結局は速いのです。逆に21%で収まる箇所は、迷わず引用でよいと分かります。
引用と、応答の全体参照は、どちらが優れているという話ではありません。両方を残したまま、収束後の割合で使い分けています。
なお、応答ではなくファイルを部分的に読ませる場面では、view_file のページ範囲と解像度の下限のほうが道具として適しています。ルールファイルを @path/to/file で分割したときに何が実際に減るのかは、ルールを分割して実際に減るものに書き残しました。
いま私がしている手順
やることはひとつだけです。引用の範囲を選んだら、送信する前に quote_check.py を一度走らせてください。取り残しが 0 個なら、そのまま渡していただいて大丈夫です。
私自身、いまも三度に一度は取り残しを出しております。それでも、返信を読んでから気づくのと、送る前に気づくのとでは、消える時間がまるで違うのです。お読みくださり、ありがとうございました。