受託で引き継いだサイトの仕様を調べていた午後のことでした。社内向けに公開されている古い技術資料のページを三つ並べて、エージェントに順番に読ませていました。一つめは通り、二つめも通り、三つめで会話がふつりと途切れました。
画面に残ったのは Agent execution terminated due to error の一行だけです。何を読もうとしたのか、どこで止まったのか、再試行すれば直るのか——手がかりは何もありませんでした。
最初のうち、私は自分の頼み方を疑っておりました。指示が長すぎたのか、同時に渡したファイルが多すぎたのか。プロンプトを短くして投げ直し、資料を一つずつに分け、それでも同じところで終わります。半日かけてようやく気づいたのは、落ちているのは私の指示ではなく、読ませた三つめのページのほうだということでした。
落ちたのは指示ではなく、ページのほうでした
原因は、そのページが文字コードを HTTP ヘッダで宣言していないことにありました。
いまも報告が続いている事象で、Antigravity CLI のリポジトリに issue #818 として上がっております。Content-Type に charset を持たず、HTML の <meta charset=iso-8859-1> だけで名乗っているようなページを ReadURL に読ませると、デコードの失敗が握りつぶされずにエージェントの実行そのものを終わらせてしまう、という内容です。9月15日時点で未解決のままで、日本語で書かれた説明も見当たりません。
ここが日本で作業している方に効いてきます。charset ヘッダを付けずに Shift_JIS や EUC-JP のまま配信されているページは、いまも珍しくありません。官公庁の古い告示、企業の製品仕様ページ、十年以上更新されていない技術資料。受託で古いサイトを引き継ぐと、まず間違いなくこの種のページに当たります。
つまり日本語圏で調べ物をしている方ほど、この落ち方に当たりやすいのです。にもかかわらず、画面には文字コードの「も」の字も出てきません。
落ちるページかどうかは、渡す前に分かります
疑い方さえ決めておけば、切り分けは一分で終わります。見るのは二箇所だけです。
まず HTTP ヘッダに charset があるかどうか。
# ヘッダだけ取得して Content-Type を見る
curl -sI https://example.com/old-spec.html | grep -i '^content-type'返ってくるのがこの形なら安全です。
content-type: text/html; charset=UTF-8
危ないのはこちらです。charset がどこにもありません。
content-type: text/html
charset が無いときは、本文の先頭だけ取って meta を確かめます。
# 先頭 2KB だけ取得して meta charset を探す
curl -s --range 0-2047 https://example.com/old-spec.html | grep -io 'charset=[a-z0-9_-]*'ここで charset=shift_jis や charset=euc-jp、あるいは charset=iso-8859-1 が出てきたら、そのページは渡す前にひと手間かけたほうが安全です。逆に何も出てこない場合——ヘッダにも meta にも宣言が無いページ——は、いちばん静かに落ちます。
ヘッダに charset がないとき、受け取る側は何を UTF-8 と見なすかを自分で決めなければなりません。HTTP の歴史的な既定では text/* は ISO-8859-1 として扱われます。日本語のバイト列を ISO-8859-1 として読みにいけば、当然どこかで破綻します。握りつぶされずに例外が上がると、その例外はエージェントの実行そのものを道連れにします。
先に取得して、UTF-8 に直してから渡します
見分けがついたら、次はどう渡すかです。私は「怪しいページはエージェントに直接読ませず、手元で UTF-8 に直してからファイルとして添付する」という形に落ち着きました。
下のスクリプトは、URL を一つ受け取って、ヘッダ・meta・日本語の主要な符号化方式の順に候補を試し、成功したものを UTF-8 で書き出します。標準ライブラリだけで動きます。
#!/usr/bin/env python3
"""URL の中身を UTF-8 に直して保存する。
エージェントへ直接 URL を渡す前のひと手間として使います。
出力されたファイルを添付すれば、デコードはこちら側で完了しています。
"""
import re
import sys
import urllib.request
# 日本語圏で実際に当たる順に並べています。
# cp932 は Shift_JIS の上位互換なので、Shift_JIS より先に置きます。
FALLBACKS = ["utf-8", "cp932", "euc-jp", "iso-2022-jp", "latin-1"]
def detect_declared(headers, head_bytes):
"""HTTP ヘッダと meta タグから宣言された charset を拾う。"""
ctype = headers.get("Content-Type", "")
m = re.search(r"charset=([\w-]+)", ctype, re.I)
if m:
return m.group(1).lower(), "header"
# meta は ASCII 範囲に収まるので latin-1 で安全に走査できます
text = head_bytes.decode("latin-1", errors="replace")
m = re.search(r'charset=["\']?([\w-]+)', text, re.I)
if m:
return m.group(1).lower(), "meta"
return None, "none"
def fetch_as_utf8(url, out_path):
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=30) as res:
raw = res.read()
declared, source = detect_declared(res.headers, raw[:2048])
print(f"declared={declared or '(なし)'} source={source} bytes={len(raw)}")
# 宣言があればそれを最優先で試し、失敗したら候補を順に当てます
candidates = ([declared] if declared else []) + FALLBACKS
for enc in candidates:
try:
# errors は指定しません。黙って壊れた文字を残さないためです
text = raw.decode(enc)
except (UnicodeDecodeError, LookupError):
continue
with open(out_path, "w", encoding="utf-8") as f:
f.write(text)
print(f"✅ {enc} でデコードし {out_path} へ保存しました")
return 0
print("❌ どの符号化方式でも読めませんでした。バイナリの可能性があります")
return 1
if __name__ == "__main__":
if len(sys.argv) != 3:
print("usage: fetch_utf8.py <URL> <出力ファイル>")
sys.exit(2)
sys.exit(fetch_as_utf8(sys.argv[1], sys.argv[2]))実行するとこのように出ます。
$ python3 fetch_utf8.py https://example.com/old-spec.html spec.txt
declared=shift_jis source=meta bytes=48213
✅ cp932 でデコードし spec.txt へ保存しました
一点だけ、意図して外している作法があります。decode() に errors="replace" を渡していないことです。
置換を許すとスクリプトは必ず成功します。そのかわり、読めなかった文字が U+FFFD の四角として本文に残ります。エージェントに渡す資料で、いちばん困るのは「落ちること」ではなく「壊れたまま通ること」でした。 落ちれば気づけますが、四角が混じった仕様書を読ませると、エージェントは欠けた箇所を推測で埋めてきます。そしてその推測は、こちらが疑わないかぎり最後まで通ってしまいます。
日本語ファイルをエージェントに編集させる場面でも同じ落とし穴があり、日本語を含むファイルをエージェントに編集させる前に、バイト列で確かめている3つの検査にそのときの運用を書いております。読み込みと書き出しで、疑うべきところが対称になっているのが面白いところです。
無人で回す処理では、渡す前に落とします
ここからは、定刻に無人で走らせている処理に組み込むときの話です。
私は Lab のサイト群と WordPress のブログを自分で回しており、その多くが決まった時刻に無人で動いております。壁紙アプリの素材を配信前に点検する処理も同じ枠に乗せており、個人開発でこれだけの数を抱えると、人が見ていない時間帯のほうが長くなってまいります。無人の処理で困るのは、失敗そのものよりも失敗が何の音も立てずに起きることでした。
ReadURL でエージェントごと落ちる構成は、まさにその形をしています。ログには終了だけが残り、何を読もうとして落ちたのかは残りません。
ですので、URL を扱う自動処理では、エージェントに渡す前段で弾く形にしました。先ほどのスクリプトを前置きに使うだけです。
#!/usr/bin/env bash
# 取得できたものだけをエージェントへ回す
set -u
URL="$1"
OUT="/tmp/fetched_$(date +%s).txt"
if python3 fetch_utf8.py "$URL" "$OUT"; then
agy -p "$OUT を読んで、仕様の変更点を箇条書きで3点にまとめてください"
else
# 落ちた事実と URL を残してから終えます
echo "$(date '+%F %T') SKIP decode-failed $URL" >> fetch_failures.log
exit 0
fi大事なのは exit 0 のほうです。デコードに失敗したページ一つのために、その日の処理全体を失敗扱いにする必要はありません。読めなかったという事実だけを残し、処理は先へ進めます。 この形にしてから、翌朝ログを見れば何が読めなかったかが分かるようになりました。
なお外部 URL の取得は、CLI 1.1.28 以降、あらかじめ許可していなければ既定で承認待ちになります。無人で走らせる構成では、落ちるより先に「止まる」ことがあり得ます。承認の境界をどこに引くかはAntigravity CLI と Claude Code の預け先を決める3つの問いに別途まとめております。
直るまでのあいだ、どう付き合うか
issue #818 は今も open のままで、修正版の予告も出ておりません。ラベルは付いているものの、開発側からの返信はまだありません。
ですので、しばらくは「こちらが先に気づく」しかないのだと思います。とはいえ、気づき方さえ決めておけば、消耗するのは最初の一回だけです。私の場合は次の三つに落ち着きました。
| 場面 | やること | 理由 |
|---|---|---|
| エージェントが理由を残さず終わった | 直前に渡した URL の Content-Type を見る | 一分で切り分けられ、外れても損が小さいため |
| 古いサイト・社内資料を読ませる | 先に取得して UTF-8 に直し、ファイルで添付する | デコードを自分の手元で完結させられるため |
| 無人の処理に URL 取得が入る | 前段で弾き、失敗した URL だけログに残す | 一つの失敗で全体を止めないため |
振り返ってみますと、半日を溶かした原因は文字コードそのものではありませんでした。エラーが見えないとき、私は自分の側だけを疑いすぎる——そこが本当のつまずきどころだったのだと思います。いまは、手がかりが何も残っていない終わり方をしたときこそ、自分の指示ではなく「渡したもの」を先に見るようにしております。
今日のところは、いつも読ませている社内資料や古い技術ページを一つ選んで、curl -sI で Content-Type を覗いてみていただければと思います。charset が書かれていなければ、それが次にあなたの手を止めるページです。
同じところで半日を溶かす方が一人でも減れば嬉しく思います。お読みいただきありがとうございました。