ある朝、GitHub Actions のログを開いて固まりました。agy -p "..." を呼ぶステップが緑のチェックで「成功」しているのに、出力が一行も残っていないのです。手元のターミナルでまったく同じコマンドを叩くと、エージェントは饒舌に応答を返します。それなのに、CI のログには空白だけが残り、終了コードは 0。「成功したのに、何もしていない」という、いちばん気づきにくい壊れ方でした。
私はいくつかのサイトの更新を無人で回すために、ターミナルからエージェントを呼ぶ仕組みを個人開発の延長で組んでいます。そこに Antigravity CLI(コマンド名 agy)を組み込もうとして、この罠に半日ほど溶かしました。原因は私のスクリプトのバグではなく、agy の出力モードが「人が見ているターミナルかどうか」で挙動を変えることにありました。
この挙動は公式のチュートリアルにはほとんど書かれていません。けれど無人実行では致命的なので、再現のしくみと、私が最終的に落ち着いた堅牢な構成を順に共有します。
なぜ CI だと agy の出力が消えるのか
agy のような対話的エージェント CLI は、起動時に標準出力が TTY(端末)に繋がっているかどうかを判定します。人が対話しているとき(TTY のとき)は、ストリーミングで色付きの応答を流し、スピナーやプログレスを描画します。一方、出力がパイプやファイル、サブプロセスにリダイレクトされている「非TTY」のとき、CLI はこの端末向けレンダリングを無効化します。
問題は、現行バージョンの agy --print/-p が、非TTY で実行されると最終応答の標準出力を取りこぼすことがある点です。これは Antigravity CLI の課題として報告されているもので(後述)、端末描画と「機械可読な stdout」がまだ十分に分離されていないことに起因します。GitHub Actions のステップ、cron、subprocess.run()、$(agy -p ...) のコマンド置換 — これらはすべて非TTY なので、どれも同じ症状に当たります。
つまり「ローカルでは動くのに CI だと空になる」のは、環境変数でもネットワークでも権限でもなく、標準出力の接続先が端末でないこと が引き金です。ここを最初に疑えるかどうかで、調査時間が大きく変わります。agy の基本的な起動やスラッシュコマンドの読み解きはAntigravity CLI を触る:移行手順とスラッシュコマンドの整理 にまとめています。
最小再現:パイプを一枚かませるだけで空になる
手元で再現するのにサーバーは要りません。ターミナルで次を順に試すと、症状がそのまま見えます。
# (1) 直接実行 — 端末に繋がっているので応答が見える
agy -p "現在のディレクトリの README を3行で要約して"
# (2) パイプを一枚かませる — 非TTYになり、出力が空になることがある
agy -p "現在のディレクトリの README を3行で要約して" | cat
# (3) コマンド置換でも同じ — OUT が空のまま終了コードは 0
OUT = "$( agy -p "現在のディレクトリの README を3行で要約して")"
echo "captured length = ${ # OUT }" # → 0 になることがある
echo "exit code = $? " # → 0(成功扱い)
(1) では応答が見えるのに、(2)(3) では空になる。しかも $? は 0 を返すので、素朴なスクリプトは「成功した」と判断して次に進みます。これが「成功したのに何もしていない」の正体です。CI のステップは本質的に (2)(3) と同じ状況に置かれています。
補足:この非TTY時の stdout 取りこぼしは Antigravity CLI のリポジトリで Issue として追跡されています(agy --print / -p silently drops stdout when run with a non-TTY)。バージョンによって改善・変化する可能性があるため、自分の agy --version で挙動を必ず確認してください。
対処1:擬似TTYを与えて出力を取り戻す
いちばん確実なのは、CLI に「端末に繋がっている」と思わせることです。擬似端末(pseudo-TTY)を一枚噛ませれば、端末向けレンダリングが有効なまま出力を捕まえられます。OS によって道具が違うので、まず手元のどれが使えるか確認します。
# Linux(GitHub Actions の ubuntu ランナー含む): util-linux の script が最も確実
# -q 静音 / -e 子プロセスの終了コードを script の終了コードに伝播 / -c 実行コマンド
script -qec 'agy -p "READMEを3行で要約して"' /dev/null | tee agy_out.txt
# macOS(BSD script は引数の並びが違う点に注意)
script -q /dev/null agy -p "READMEを3行で要約して" | tee agy_out.txt
# expect が入っているなら unbuffer も使える(出力をそのまま中継)
unbuffer agy -p "READMEを3行で要約して" | tee agy_out.txt
ここで重要なのは script の -e です。これを付けないと、script 自身の終了コード(ほぼ常に成功)が返り、肝心の agy の失敗を取りこぼします。Linux の util-linux 版 script は -e をサポートしているので、CI では基本的にこの形を使います。BSD 系(macOS 標準)は引数順が異なり -e がないため、ローカル検証と CI で同じスクリプトを使い回すなら、OS 判定で分岐させておくのが安全です。
擬似TTYを噛ませると、応答に端末制御文字(ANSI エスケープや改行コード)が混ざることがあります。後段でパースするなら、取り込んだ直後に一度きれいにしておきます。
# ANSI エスケープと CR を除去してから保存する
script -qec 'agy -p "..."' /dev/null \
| sed -r 's/\x1B\[[0-9;]*[A-Za-z]//g' \
| tr -d '\r' \
> agy_clean.txt
私はこの「擬似TTY+即サニタイズ」を一つのシェル関数にまとめ、無人実行のすべての入口で必ず通すようにしています。出力を取りこぼさないことと、後段が安定することの両方を、入口で一度に担保できるからです。私自身、CI では擬似TTYを与える方式を第一の対処として推奨します。
対処2:--output-format json に頼らず、テキストを安全にパースする
「それなら最初から JSON で出力させればいい」と考えるのが自然です。実際、ドキュメントやサンプルには --output-format json という記述が見られます。ところが現行バージョンでは、--output-format を渡すと flags provided but not defined: -output-format というエラーで弾かれることが報告されており、構造化出力はまだ安定実装ではありません。
ここでの判断はシンプルです。まだ動かないフラグを前提に組まない 。構造化出力が安定するまでは、テキスト出力を「壊れても落ちない」前提でパースする方が、結果的に運用が安定します。私が使っているのは、(1) 出力が空でないこと、(2) 期待するマーカーを含むこと、の二段で検証する素朴な方法です。
#!/usr/bin/env bash
set -euo pipefail
# agy を擬似TTY経由で呼び、出力を変数に取り込む共通関数
run_agy () {
local prompt = " $1 "
# script の -e で agy の終了コードを伝播させる
script -qec "agy -p \" ${ prompt } \" " /dev/null \
| sed -r 's/\x1B\[[0-9;]*[A-Za-z]//g' \
| tr -d '\r'
}
OUT = "$( run_agy 'この変更内容を一行のコミットメッセージにして。先頭に COMMIT: を付けて' || true )"
# (1) 空チェック — 非TTY取りこぼしや無音失敗を確実に検出する
if [ -z "${ OUT // [[ : space : ]] / }" ]; then
echo "::error::agy returned empty output (likely non-TTY capture failure)" >&2
exit 1
fi
# (2) マーカー抽出 — 期待する形式が含まれるかで成否を判定する
MSG = "$( printf '%s\n' " $OUT " | grep -m1 '^COMMIT:' | sed 's/^COMMIT:[[:space:]]*//' || true )"
if [ -z " $MSG " ]; then
echo "::error::expected COMMIT: marker not found in agy output" >&2
printf '%s\n' " $OUT " >&2 # デバッグのため生出力を残す
exit 1
fi
echo "commit message = ${ MSG }"
ポイントは二つあります。プロンプト側で「先頭に COMMIT: を付けて」と出力フォーマットを自分で固定する こと。そして受け側で、その固定したマーカーの有無だけを成否判断に使うことです。モデルの自然文を正規表現でこじ開けようとすると、応答のゆらぎで簡単に壊れます。自分で決めた一行のマーカーだけを契約(contract)にすれば、文章がどう変わってもパイプラインは安定します。--output-format json が安定実装になったら、この grep の層を JSON パースに差し替えるだけで済む構造にしておくと、移行も滑らかです。
認証は OAuth ではなく API キーで渡す
非対話実行のもう一つの落とし穴が認証です。agy を普通に初回起動するとブラウザ経由の OAuth ログインを促されますが、CI にブラウザはありません。ここで agy login を素朴に呼ぶと、入力待ちでジョブがタイムアウトします。
無人環境では、対話ログインの代わりに API キーを環境変数で渡します。GEMINI_API_KEY(Google AI Studio のキー)または ANTIGRAVITY_API_KEY を設定しておくと、agy は対話ステップを踏まずに起動できます。GitHub Actions では必ず Secrets に格納し、ログに出さないようにします。
# .github/workflows/agy.yml の env 部分(抜粋)
env :
GEMINI_API_KEY : ${{ secrets.GEMINI_API_KEY }}
# キーは絶対にechoしない。マスクを明示しておくと安心
# スクリプト先頭での防御 — キー未設定なら即座に分かるように落とす
: "${ GEMINI_API_KEY :? GEMINI_API_KEY is required for headless agy }"
# 万一ログに出さないよう、値そのものは一切表示しない
echo "auth: GEMINI_API_KEY is set (length hidden)"
API キーを直接渡す方式は、OAuth トークンの失効に振り回されないという実務上の利点もあります。私の経験では、無人パイプラインの認証は「対話を一切挟まない経路」に寄せておくほど、夜間や休日の失敗が減ります。サンドボックス分離を使う場合は、-p(print)モードでも --sandbox が伝播するよう修正が入っているので、隔離実行を諦める必要はありません。ここも agy --help で自分のバージョンの扱いを確認してください。
終了コードと冪等性 — 「成功したのに何もしていない」を防ぐ
最初の事故の本質は、終了コードを過信したことでした。非TTY で出力が空でも agy が 0 を返すなら、終了コードだけを成否判断に使うパイプラインは必ず誤作動します。私が今守っているのは次の三つです。
第一に、終了コードと出力内容の両方を成功条件にする 。$? -eq 0 かつ「期待するマーカーが出力に含まれる」を満たして初めて成功とみなします。前章のスクリプトはこの二段ゲートになっています。
第二に、副作用を伴う処理は冪等にする 。エージェントの出力をもとにコミットやファイル生成を行うなら、同じ入力で二度走っても結果が増殖しないように設計します。たとえば生成物に内容ハッシュをキーとして持たせ、既存と一致したら何もしない、という形です。無人実行は再試行と相性が良い反面、再試行で重複を生むと後始末が一気に重くなります。冪等性の作り込みはAntigravity SDK で定期実行エージェントを冪等に組む でも掘り下げています。
第三に、タイムアウトと一回だけの再試行 を入れる。エージェントは時に長考します。timeout で上限を切り、空出力なら一度だけ短い待機を挟んで再実行する。それでも空なら失敗として人に通知する、という割り切りが運用を楽にします。
# 上限180秒・空出力なら1回だけ再試行する最小の堅牢化
attempt () { timeout 180 bash -c 'script -qec "agy -p \"$0\"" /dev/null' " $1 " \
| sed -r 's/\x1B\[[0-9;]*[A-Za-z]//g' | tr -d '\r' ; }
OUT = "$( attempt " $PROMPT " || true )"
if [ -z "${ OUT // [[ : space : ]] / }" ]; then
sleep 5
OUT = "$( attempt " $PROMPT " || true )"
fi
[ -n "${ OUT // [[ : space : ]] / }" ] || { echo "::error::agy empty after retry" ; exit 1 ; }
GitHub Actions に組み込む
ここまでの部品を最小のワークフローにまとめます。要点は、擬似TTY・空チェック・APIキー・タイムアウトをすべて一つのステップに閉じ込め、後段は「検証済みの出力ファイル」だけを読むことです。
name : agy-headless
on :
workflow_dispatch :
jobs :
run :
runs-on : ubuntu-latest
timeout-minutes : 10
env :
GEMINI_API_KEY : ${{ secrets.GEMINI_API_KEY }}
steps :
- uses : actions/checkout@v4
- name : Install Antigravity CLI (agy)
run : |
# 配布方法はバージョンにより異なるため、公式の最新手順に合わせること
# 例: 公式インストーラ or リリースバイナリの取得後、agy --version で疎通確認
agy --version
- name : Run agy headless and capture output
run : |
set -euo pipefail
: "${GEMINI_API_KEY:?missing key}"
# 擬似TTYで実行 → サニタイズ → ファイルへ
script -qec 'agy -p "変更点を一行で要約し、先頭にSUMMARY:を付けて"' /dev/null \
| sed -r 's/\x1B\[[0-9;]*[A-Za-z]//g' | tr -d '\r' > agy_out.txt
# 空チェック(非TTY取りこぼしの最終防衛線)
test -s agy_out.txt || { echo "::error::empty agy output"; exit 1; }
grep -q '^SUMMARY:' agy_out.txt || { echo "::error::no SUMMARY marker"; cat agy_out.txt; exit 1; }
- name : Use validated output
run : |
SUMMARY="$(sed -n 's/^SUMMARY:[[:space:]]*//p' agy_out.txt | head -1)"
echo "summary = $SUMMARY"
ubuntu-latest の script は util-linux 版なので -e/-c がそのまま使えます。runs-on を macOS に変える場合は、前述のとおり script の引数順が変わる点だけ調整してください。インストール手順はバージョンと配布形態で変わるので、ここは固定値を書かず、必ず公式の最新手順と agy --version での疎通確認に委ねるのが安全です。
おわりに
もし今あなたのパイプラインで agy を非対話で呼んでいるなら、まず一つだけ試してください。出力を受け取っている箇所を script -qec '...' /dev/null でくるみ、そのうえで「出力が空でないこと」を明示的なチェックにすることです。この二点だけで、「成功したのに何もしていない」という最も気づきにくい失敗の大半は防げます。
無人実行に組み込む前の最終チェックは、次の3点に絞ると迷いません。
出力受け取りを script -qec '...' /dev/null で擬似TTY化し、ANSI とCRをサニタイズしているか
終了コードだけでなく「出力が空でないこと」と「自分で決めたマーカーの有無」を成功条件にしているか
認証を API キーの環境変数に寄せ、タイムアウトと一回だけの再試行を入れているか
--output-format json が安定し、非TTY の stdout 取りこぼしが解消されれば、ここで書いた擬似TTY の工夫はいずれ不要になるはずです。それまでの過渡期を安全に渡るための実務メモとして、同じ罠にはまった方の役に立てば嬉しいです。