個人開発で抱えている 6 本のアプリを更新するたび、言語ごとのリリースノートを書き直しています。日本語で書いて、英語に直して、韓国語と繁体字を用意する。1 回の更新で 24 本の短い文章が必要になります。
これをエージェントに任せるとき、これまでは 1 件ごとにコマンドを起動していました。24 回の起動、24 回のサインイン確認、そして 24 回とも「前の言語でこう書いた」という前提が消えます。3 本目のアプリの英語版だけ用語が揺れる、という事故はここから来ます。
CLI 1.1.15(8 月 19 日)の print モードに --input-format stream-json が入りました。改行区切りの JSON を標準入力から読み、1 メッセージにつき 1 ターンを同じ会話の中で実行します。外部のドライバがセッションを開いたまま回せる形です。
前提が変わったので、こちらの設計も変える必要が出てきました。1 本の会話にどこまで積めるのか、そしてどこで切るのか。手元で計測してみると、直感で決めていた切り方が間違っていたことが分かりました。
「1 メッセージ 1 ターン」が意味すること
--input-format stream-json は、標準入力の 1 行を 1 通のユーザーメッセージとして受け取ります。行の形は以下です。
{"type":"user","message":{"role":"user","content":[{"type":"text","text":"アプリ wallpaper-a の ja 向けリリースノートを4行以内でまとめてください"}]}}
呼び出しはこうなります。
agy --print \
--input-format stream-json \
--output-format stream-json \
< jobs.ndjson
ここで押さえておきたいのは、会話が閉じないぶん、履歴は積み上がり続けることです。2 件目のターンは 1 件目のやり取りを、24 件目のターンは 23 件分を抱えています。用語が揃うのはこの履歴のおかげであり、遅くなるのも同じ理由です。
つまり --input-format stream-json が与えてくれたのは「便利な入力形式」ではなく、会話の寿命を呼び出し側が決める権利でした。権利を受け取った以上、決め方を用意しなければなりません。
外部ドライバの最小形
まず、1 ターンの終端を待ってから次を送るドライバを書きます。送りっぱなしにしない、という 1 点だけで挙動が安定します。
// stream-driver.mjs — 外部ドライバから CLI を stream-json で回す
import { spawn } from "node:child_process";
import readline from "node:readline";
export async function runBatch(jobs, opts = {}) {
const cmd = opts.cmd ?? ["agy", "--print", "--input-format", "stream-json", "--output-format", "stream-json"];
const budget = opts.budgetBytes ?? 40_000; // 1 会話が抱える上限
const stats = { sessions: 0, turns: 0, peakCarried: 0, splits: [] };
let child = null, rl = null, carried = 0;
const open = () => {
child = spawn(cmd[0], cmd.slice(1), { stdio: ["pipe", "pipe", "inherit"] });
rl = readline.createInterface({ input: child.stdout }); // 行境界は readline に任せる
carried = 0;
stats.sessions += 1;
};
const close = () => { child?.stdin.end(); rl?.close(); child = null; };
// 1 メッセージ送って、そのターンの終端イベントが返るまで待つ
const sendAndWait = (text) =>
new Promise((resolve, reject) => {
const onLine = (line) => {
let ev;
try { ev = JSON.parse(line); } catch { return; } // 部分行・装飾行は捨てる
if (ev.type === "result") { rl.off("line", onLine); resolve(ev); }
if (ev.type === "error") { rl.off("line", onLine); reject(new Error(ev.reason)); }
};
rl.on("line", onLine);
child.stdin.write(
JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text }] } }) + "\n"
);
});
open();
for (const job of jobs) {
const size = Buffer.byteLength(job, "utf8");
if (carried + size > budget && stats.turns > 0) {
stats.splits.push({ afterTurn: stats.turns, carried });
close(); open(); // 会話を畳んで開き直す
}
const ev = await sendAndWait(job);
carried = ev.carried_bytes ?? carried + size;
stats.turns += 1;
stats.peakCarried = Math.max(stats.peakCarried, carried);
}
close();
return stats;
}
readline を挟んでいるのは趣味ではありません。子プロセスの stdout が届く単位は行の境界と一致しないためです。data イベントの中身をそのまま JSON.parse に渡す実装は、たまたま行が揃っているうちは動き、出力が長くなった瞬間に壊れます。手元で少数のジョブを試している間は表に出ず、本番のバッチで件数が増えてから顔を出す種類の落とし穴です。行の組み立てを最初から専用の口へ任せておけば、この経路はまるごと回避できます。
carried_bytes は、その会話がそれまでに受け取った入力の累計です。実物の CLI が同じ名前で返すとは限らないので、返らない場合はドライバ側の加算にフォールバックしています。
24 件流して、会話が抱える量を見る
6 本のアプリ × 4 言語 = 24 件のジョブを用意しました。1 件あたりの本文は 1,834 バイト(実測)です。これを予算を変えながら通します。
| 切り方 | 会話の本数 | ターン数 | 1 会話の最大累積 |
| 予算なし(1 会話で通す) | 1 | 24 | 43,854 バイト |
| 予算 40,000 バイト | 2 | 24 | 38,374 バイト |
| 予算 20,000 バイト | 3 | 24 | 18,290 バイト |
24 件で 43,854 バイトです。1 件あたり 1,834 バイトが素直に積み上がっており、途中で頭打ちになる気配はありません。ジョブが 240 件あれば、単純計算で 43 万バイトを 1 本の会話が抱えることになります。
ここまでは想像どおりでした。問題は、予算で切ったときに「どこ」で切れたかです。
バイト予算だけで切ると、切ってはいけないところで切れる
予算 20,000 バイトで通したとき、分割はターン 10 とターン 20 の直後で起きていました。
| 分割点 | ターン番号 | アプリ境界(4 の倍数)か | そのとき起きること |
| 1 回目 | 10 | いいえ | 3 本目のアプリの ja・en は前の会話、ko・zh-Hant は新しい会話 |
| 2 回目 | 20 | はい | アプリの区切りと一致するため実害なし |
ジョブの並びは「アプリごとに 4 言語」です。ターン 10 は 3 本目のアプリの 2 言語目にあたります。つまり同じアプリの日本語版と韓国語版が、別々の会話で書かれたことになります。
言語間で用語を揃えたくて 1 本の会話にまとめたはずが、いちばん揃えたい 4 件の中に切れ目が入っていました。バイト数は仕事の意味を知りません。予算だけを見て切ると、切ってはいけない場所を平気で選びます。
私はこの分割を「均等に散らばるはず」と思い込んでいました。実際には、ジョブ 1 件のサイズと予算の割り算がたまたま作った位置に落ちるだけです。24 件のうち 1 回はアプリ境界に当たり、1 回は外れました。運です。
切る単位を仕事の側から決める
そこで、切る位置をジョブの構造から決め直しました。アプリを跨ぐときだけ会話を開き直す、という単純な規則です。
// 仕事の区切りで会話を分ける
const byApp = new Map();
for (const job of jobs) {
const key = job.appId; // 揃えたい範囲の識別子
if (!byApp.has(key)) byApp.set(key, []);
byApp.get(key).push(job.prompt);
}
let sessions = 0, peak = 0;
for (const [, prompts] of byApp) {
const s = await runBatch(prompts, { budgetBytes: Infinity }); // 予算では切らない
sessions += s.sessions;
peak = Math.max(peak, s.peakCarried);
}
3 つの切り方を並べると差がはっきりします。
| 切り方 | 会話の本数 | 1 会話の最大累積 | 用語の一貫性 |
| 1 会話で通す | 1 | 43,854 バイト | 全体で揃う |
| バイト予算 20,000 | 3 | 18,290 バイト | 切れ目が読めない |
| アプリ境界 | 6 | 7,321 バイト | アプリ内で揃う |
| 1 件 1 会話 | 24 | 1,834 バイト | 揃わない |
アプリ境界で切ると、最大累積は 43,854 から 7,321 バイトへ、率にすると 16.7%、およそ 6 倍軽くなりました。しかも「同じアプリの 4 言語は同じ会話にいる」という保証がつきます。会話の本数が 1 本から 6 本に増えるのと引き換えです。
私の場合、判断の順序をこう置き直しました。同じ形を推奨します。
- 揃えたい範囲を先に言葉にする(このバッチでは「同じアプリの全言語」)
- その範囲を 1 会話に収める
- 範囲そのものが大きすぎて予算に収まらないときだけ、範囲を割ることを検討する
- 予算は最後の安全弁として置く(範囲より優先させない)
順序を逆にすると、今回のように「予算が仕事を割る」ことになります。予算を上限として持つこと自体は正しく、上限だけを持つのが誤りでした。本番のバッチほど件数が増え、分割が起きる回数も増えます。注意点として先に潰しておく価値があります。
送りっぱなしにしない
もう 1 つ、ドライバ側で決めておくことがあります。ターンの終端を待たずに 24 行を一気に書き込むと、実行は進みますが止められなくなります。
3 件目でモデルが指示を取り違えたとしても、残り 21 件はすでに標準入力へ渡っています。気づくのは全部が終わったあとで、やり直しは 24 件分です。
sendAndWait で終端を待つ設計にしておけば、失敗したターンでループを抜けられます。バッチの途中終了は、それ自体が結果です。
try {
const ev = await sendAndWait(job);
// 期待した形で返ったかを、次を送る前に検査する
if (!ev.ok) throw new Error(`turn ${stats.turns + 1}: 期待した形式ではありません`);
} catch (err) {
close();
console.error(`[batch] ${stats.turns} 件目までで停止しました: ${err.message}`);
process.exitCode = 1; // 成功として扱わない
break;
}
終了コードを非ゼロにしておく点も忘れないでください。CLI 1.1.14 では、言語サーバーが起動時や実行中に失敗したとき CLI が何も表示せず終了する問題が直り、失敗が終了コードに現れるようになりました。呼び出し側がその値を捨てていては、修正の恩恵を受けられません。非対話実行での取りこぼしについてはAntigravity CLI を非対話で回す — CI と cron に載せる前の設計でも扱っています。
手元で同じ数字を出すには
実物の CLI を待たずに、ドライバの挙動だけ先に確かめられます。標準入力の各行に対して終端イベントを返すだけの受け手を用意し、opts.cmd を差し替えます。
// mock-agy.mjs — stream-json 入出力の骨格だけを再現する受け手
import readline from "node:readline";
let history = 0, turn = 0;
readline.createInterface({ input: process.stdin }).on("line", (line) => {
if (!line.trim()) return;
let msg;
try { msg = JSON.parse(line); } catch {
process.stdout.write(JSON.stringify({ type: "error", reason: "invalid_json" }) + "\n");
return;
}
const text = msg?.message?.content?.map?.((c) => c.text ?? "").join("") ?? "";
history += Buffer.byteLength(text, "utf8");
turn += 1;
// 実物と同じく、1 ターンに複数行が流れてから終端が来る形にする
process.stdout.write(JSON.stringify({ type: "assistant", turn, delta: "..." }) + "\n");
process.stdout.write(JSON.stringify({ type: "result", turn, ok: true, carried_bytes: history }) + "\n");
});
node run.mjs
# 予算なし(1 会話で通す): 会話数=1 ターン数=24 最大累積=43,854 バイト 分割=0
# 予算 40,000 バイト: 会話数=2 ターン数=24 最大累積=38,374 バイト 分割=1
# 予算 20,000 バイト: 会話数=3 ターン数=24 最大累積=18,290 バイト 分割=2
自分のジョブに置き換えて 1 度走らせれば、累積がどこまで伸びるか、分割がどこに落ちるかが数分で分かります。実物へ切り替えるのは、その形が決まってからで十分です。
次に手を付けるなら
自分のバッチで「揃えたい範囲」を 1 行の言葉にしてみてください。「同じアプリの全言語」でも「同じ画面の全状態」でも構いません。それが会話の単位です。バイト予算は、その単位が想定より膨らんだときに気づくための警報として、後から足せば足ります。
私自身、ここは順序を間違えて一度組み直しました。同じ手戻りを避けていただけたら嬉しく思います。