Fragments of verbose memory

冗長な記憶の断片 - Web技術のメモをほぼ毎日更新

Feb 19, 2026 - 日記

CLIの出力設計を間違えると、AIエージェントは何も読めない: agent-execが採用した分離戦略

CLIの出力設計を間違えると、AIエージェントは何も読めない: agent-execが採用した分離戦略 cover image

AIエージェント に長時間コマンドを実行させたい。でも、既存のCLIツールは「人間が端末で見る」前提で設計されているため、エージェントが出力をパースできません。

原因は単純です。多くのCLIが「結果」と「ログ」を標準出力(stdout)に混ぜて出力するからです。人間なら目で見分けられますが、エージェントには無理です。

agent-exec は、この問題を「stdout = JSON only、stderr = ログ」という厳格な分離で解決したRust 製ジョブランナーです。本記事では、なぜこの設計が重要なのか、どう実装されているのかを解説します。

問題: stdoutに混ざるログがパースを壊す

典型的なCLIツールの出力例を見てみましょう。

1
2
3
4
5
$ some-tool deploy --app myapp
Connecting to server...
Uploading files... (50%)
Uploading files... (100%)
{"status": "success", "deployment_id": "abc123"}

人間なら「最後の行がJSON結果だな」と判断できます。しかし、エージェントにとっては以下の問題があります:

  • どの行が結果か判別できない: 進捗ログとJSON結果が混在
  • JSONパーサーがエラーを吐く: 最初の行から読むと Connecting to server... でパース失敗
  • 最後の行を読む保証がない: 出力が途中で切れたら結果を取得できない

この問題は、CLIが「人間が端末で見る」前提で設計されているために起きます。

解決策: stdout/stderr分離の原則

agent-exec は、以下の「Output Contract」(出力契約)を定義しています:

  • stdout: JSON only — すべてのコマンドが正確に1つのJSONオブジェクトを出力
  • stderr: 診断ログ(RUST_LOGまたは-v/-vvフラグで制御)

この分離により、エージェントは以下のように確実にパースできます:

1
2
3
4
5
6
7
8
# 1. ジョブを開始(stdoutにJSON、stderrにログ)
JOB=$(agent-exec run echo "hello world" | jq -r .job_id)

# 2. 完了を待つ
agent-exec wait "$JOB"

# 3. 出力を取得
agent-exec tail "$JOB"

tailコマンドの出力例:

1
2
3
4
5
6
7
8
9
{
  "schema_version": "0.1",
  "ok": true,
  "type": "tail",
  "job_id": "01J...",
  "stdout_tail": "hello world",
  "stderr_tail": "",
  "truncated": false
}

エージェントは、stdoutから読んだ内容をそのままJSONパーサーに渡せます。ログのフィルタリングは不要です。

設計原則: Agentic CLI Designとの関係

この設計は、私が以前提唱した「Agentic CLI Design: CLIをAIエージェント向けプロトコルとして設計する7つの原則 」の原則1: Machine-readable(機械可読が主)を徹底したものです。

原則1の要件:

  • --json / --output json|yaml|text オプションがある
  • 標準出力(stdout)= 結果 / 標準エラー出力(stderr)= ログ・進捗 を厳守(混ぜない)
  • エラーも構造化(可能ならJSONで)
  • スキーマは安定(破壊的変更は schemaVersion 等で管理)

agent-execは、この原則を「オプション」ではなく「デフォルト」にしました。すべてのコマンド(runstatustailwaitkilllist)が、常にJSON出力を返します。

実装: Rustでどう実現しているか

agent-execはRustで実装されています。stdout/stderr分離を実現するための主要な実装パターンを見てみましょう。

パターン1: stdoutへのJSON出力

すべてのコマンドは、最後に1つのJSONオブジェクトをprintln!でstdoutに出力します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
use serde::Serialize;

#[derive(Serialize)]
struct RunResponse {
    schema_version: &'static str,
    ok: bool,
    #[serde(rename = "type")]
    response_type: &'static str,
    job_id: String,
}

fn main() {
    let response = RunResponse {
        schema_version: "0.1",
        ok: true,
        response_type: "run",
        job_id: "01J...".to_string(),
    };
    
    // stdoutに1回だけJSON出力
    println!("{}", serde_json::to_string(&response).unwrap());
}

重要なのは、stdoutへの出力は最後の1回だけという点です。途中の進捗やデバッグ情報は、すべてstderrに送ります。

パターン2: stderrへのログ出力

ログはeprintln!またはlogクレートを使ってstderrに出力します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
use log::{info, debug, error};

fn run_job(command: &str) -> Result<String, Error> {
    // デバッグログ(stderr)
    debug!("Starting job: {}", command);
    
    // 進捗ログ(stderr)
    info!("Job started with ID: {}", job_id);
    
    // エラーログ(stderr)
    if let Err(e) = execute(command) {
        error!("Job failed: {}", e);
        return Err(e);
    }
    
    Ok(job_id)
}

ログレベルはRUST_LOG環境変数または-v/-vvフラグで制御できます:

1
2
3
4
5
# デバッグログを表示
RUST_LOG=debug agent-exec run echo hello

# または
agent-exec -v run echo hello

パターン3: エラーもJSON化

エラーが発生した場合も、stdoutにJSON形式で出力します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "ok": false,
  "type": "run",
  "schema_version": "0.1",
  "error": {
    "code": "timeout",
    "message": "Job exceeded timeout of 5000ms",
    "retryAfterMs": null
  }
}

これにより、エージェントは成功・失敗を問わず、同じパーサーで結果を処理できます。

実例: 長時間ジョブの監視

agent-execの真価は、長時間実行されるコマンドをエージェントが監視する場面で発揮されます。

以下は、30秒かかるコマンドをバックグラウンドで実行し、状態を監視する例です:

1
2
3
4
5
# 1. ジョブを開始(即座にジョブIDを返す)
JOB=$(agent-exec run sleep 30 | jq -r .job_id)

# 2. 状態を確認
agent-exec status "$JOB"

statusコマンドの出力例:

1
2
3
4
5
6
7
8
{
  "schema_version": "0.1",
  "ok": true,
  "type": "status",
  "job_id": "01J...",
  "state": "running",
  "exit_code": null
}

エージェントは、この出力から"state": "running"を読み取り、「まだ実行中だから待つ」と判断できます。

完了後は以下のように変わります:

1
2
3
4
5
6
7
8
{
  "schema_version": "0.1",
  "ok": true,
  "type": "status",
  "job_id": "01J...",
  "state": "exited",
  "exit_code": 0
}

"state": "exited""exit_code": 0から、「正常終了した」と判断できます。

タイムアウトとシグナル制御

agent-execは、タイムアウト時の挙動も構造化されています。

1
2
3
4
5
# 5秒後にSIGTERM、さらに2秒後にSIGKILLを送る
agent-exec run \
  --timeout 5000 \
  --kill-after 2000 \
  sleep 60

この設計により、エージェントは以下のように段階的な終了を実現できます:

  1. --timeout 5000: 5秒後にSIGTERMを送信(プロセスに終了を要求)
  2. --kill-after 2000: SIGTERM送信後、さらに2秒待ってもプロセスが終了しない場合、SIGKILLを送信(強制終了)

この2段階の設計は、「Agentic CLI Design 」の原則3: Safe by default(安全側デフォルト)に基づいています。

他のツールとの比較

agent-execと同様の問題を解決するツールとして、以下があります:

ツール特徴stdout/stderr分離
agent-execJSON専用ジョブランナー✅ 完全分離
kubectlKubernetes CLI-o jsonオプション
AWS CLIAWS操作CLI--output jsonオプション
一般的なCLI人間向け出力❌ 混在

agent-execの特徴は、「オプション」ではなく「デフォルト」でJSON出力を提供する点です。エージェントは、フラグを指定せずに確実にパースできます。

まとめ: CLIはプロトコルである

agent-execが示したのは、「CLIは人間向けUIではなく、エージェント向けプロトコルである」という設計思想です。

重要なポイント:

  • stdout = 結果、stderr = ログを厳守する
  • すべての出力をJSON化し、エージェントがパースできるようにする
  • エラーも構造化し、成功・失敗を同じパーサーで処理できるようにする
  • タイムアウト・シグナル制御を提供し、エージェントが長時間ジョブを安全に監視できるようにする

この設計は、slack-rs (詳細は「Agentic CLI Designを実装する: slack-rsで学ぶ7原則の具体化 」参照)やxcom-rs (詳細は「X.com API従量課金時代のCLI設計: xcom-rsが実装したコストガードレールと冪等性 」参照)と同じ「Agentic CLI Design」の系譜に属します。

あなたのCLIツールは、エージェントが読めますか?

参考リンク