1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

エージェントの「失敗」283件を分類したら、本当の失敗は3分の1しかなかった

1
Posted at

3行で

  • 自分のセッションログ 9,906 件のツール呼び出しを調べたら、失敗フラグが立っていたのは 283 件(2.9%)だった。
  • その内訳は 統治 33.6% / 実失敗 34.3% / 「出力はあるのに exit≠0」21.2% / 不明 11.0%。失敗のうち実際に壊れていたのは3分の1。
  • しかも失敗フラグは両方向に嘘をつく。成功したコマンドが失敗と記録され、失敗したコマンドが成功と記録される。「エラー率」を運用指標にすると、直す価値のないものを直すことになる。

なぜ分類したか

エージェントの運用を改善しようとすると、まず「エラー率」を見たくなる。私も見た。2.9% という数字が出た。

そこで止まると何も分からない。2.9% を 1.5% にすることに意味があるのか、そもそもこの 283 件は「直せるもの」なのか。分母も分子も、中身を見るまでは何を数えているのか決まっていない。

だから 283 件を全部開いて、文面で分けた。

何を数えたか

Claude Code はセッションを ~/.claude/projects/**/*.jsonl に行区切り JSON で残す。ツールの結果は type: "tool_result" のブロックで、失敗すると is_error: true が立つ。

ひとつ厄介なのは、tool_result にツール名が入っていないことだ。名前は直前の tool_use ブロックにあり、tool_use_id で紐づく。だから走査中に id → 名前の対応を持ち歩く必要がある。

if blk.get("type") == "tool_use":
    pending[blk.get("id")] = blk.get("name")
elif blk.get("type") == "tool_result":
    name = pending.get(blk.get("tool_use_id"), "?")

分類の規則は全部正規表現で、記事末尾のスクリプトにそのまま入っている。実行するとこう出る。

ツール結果 9,906 件 / 失敗 283 件 = 2.9%

ツール別の失敗率(30件以上)
  Bash               222 /  6394 =   3.5%
  Read                25 /  1332 =   1.9%
  Edit                12 /  1239 =   1.0%
  Write                2 /   172 =   1.2%
  WebFetch             1 /   146 =   0.7%
  WebSearch            0 /   100 =   0.0%
  Grep                 1 /    99 =   1.0%
  StructuredOutput     4 /    88 =   4.5%
  ToolSearch           0 /    78 =   0.0%
  Agent                0 /    66 =   0.0%
  AskUserQuestion      0 /    40 =   0.0%
  Glob                 3 /    35 =   8.6%

失敗の内訳
  統治      95   33.6%
      cd+git 誘導             34
      承認要求                  14
      複合コマンド分解              13
      分類器が拒否                11
      スキーマ違反                 9
      ハーネスが制止                6
      worktree 隔離            4
      未読ファイル編集               3
      ユーザーが却下                1
  実失敗     97   34.3%
      対象が無い                 38
      実行時エラー                32
      タイムアウト                17
      外部API                 10
  見かけ     60   21.2%
      出力はあるが exit≠0         60
  不明      31   11.0%
      不明                    31

以下、上から見ていく。

統治 95件 — これは失敗ではなく、実行されていない

3分の1は「エージェントが何かを間違えた」ではなく、ハーネスが実行前に止めたものだった。コマンドは走っていない。だから直す対象は「コマンドの中身」ではなく「呼び方」になる。

最多は 34 件で、文面はこうだ。

This command changes directory before running git, which can execute
untrusted hooks from the target directory. Approve only if you trust it.

cd somewhere && git ... という形を止めている。理由は書いてあるとおりで、移動先の .git/hooks/ は移動先のものだからだ。信頼していないディレクトリへ cd してから git を叩くのは、そのディレクトリのフックに実行権を渡すのと同じになる。

これは git -C で消える。

# 止まる
cd /path/to/repo && git log --oneline -5

# 止まらない
git -C /path/to/repo log --oneline -5

私のコーパスで cd 始まりかつ git を含むコマンドは 1,798 件あった。対して git -C を使っていたのは 180 件。10 対 1 で悪いほうを書いている。

ただし正確に言うと、1,798 件のうち発火したのは 34 件(1.9%)だけだ。ゲートは cd+git を機械的に全部止めているわけではない。それでも、止まった 34 件は失敗全体の最多単一原因だった。1.9% しか発火しない条件が最多を占めるということは、それ以外の原因がいかに散らばっているかということでもある。

2番目以降も性質は同じだ。

  • 承認要求 14件 — 素で承認待ちになった。
  • 複合コマンド分解 13件&& や改行で繋いだ塊のうち、一部だけが承認対象だと判定された。This Bash command contains multiple operations. The following part requires approval: と、どの部分かまで示される。
  • 分類器が拒否 11件 — 自動承認モードの分類器がブロックした。
  • スキーマ違反 9件・worktree 隔離 4件・未読ファイル編集 3件 — 規約違反。Edit の前に Read していない、といった作法の話。

95 件すべてに共通するのは、もう一度同じことを、違う書き方で頼めば通ることだ。エラーではなく、通行許可の問題になる。

実失敗 97件 — 内訳は退屈で、だからこそ直せる

こちらが本物。最多は「対象が無い」38 件。

Read の失敗 25 件のうち 24 件(96%)が File does not exist だった。つまり Read はほぼ壊れない。壊れるのは、その前に「ファイルがそこにあるはずだ」と決めたところ。

Edit の 12 件も似た構図で、5 件が String to replace not found in file。ファイルは開けている。置換対象の文字列のほうが、思っていた形と違った。

実行時エラー 32 件は素直な TracebackSyntaxError。タイムアウト 17 件、外部 API 10 件(レート制限・401)。

ここには驚きがない。驚きがないことが重要で、実失敗はすべて「対象の状態についての思い込み」に還元される。存在すると思ったファイル、この形だと思った文字列、間に合うと思った処理。

見かけ 60件 — 出力はあるのに exit≠0

一番おもしろかったのがここ。エラーらしい語(error / fatal / no such / denied …)が本文に一切なく、正常な出力だけを返しているのに is_error が立っているものが 60 件あった。平均 578 文字の、ふつうの実行結果が入っている。

原因はシェルの終了コードの規則だ。; で連ねたコマンド列の終了コードは、最後のひとつだけで決まる。

$ bash -c 'ls d; echo "--- 次 ---"; grep -c PATTERN d/a.txt'; echo "exit=$?"
a.txt
b.txt
------
0
exit=1

ls は成功している。echo も成功している。最後の grep -c が「0 件でした」と正しく報告し、grep は「見つからなかった」を終了コード 1 で表す。それだけで、この呼び出し全体が失敗として記録される。

順番を入れ替えるだけで消える。

$ bash -c 'grep -c PATTERN d/a.txt; ls d'; echo "exit=$?"
0
a.txt
b.txt
exit=0

中身は同じ。やったことも同じ。記録だけが違う。

そして逆方向にも嘘をつく。パイプの終了コードも最後のひとつで決まるので、失敗が握り潰される

$ bash -c 'ls 存在しない | cat'; echo "exit=$?"
exit=0

ls は失敗している。cat が成功したので全体は成功。これは失敗として記録されない。つまり私の 283 件という数字は、こちら側では過小になっている。

拾いたければ pipefail を立てる。

$ bash -c 'set -o pipefail; ls 存在しない | cat'; echo "exit=$?"
exit=1

私のコーパスでは Bash コマンドの 88.2% が複合(改行・パイプ・&& のいずれかを含む)だった。複合が支配的な環境で、終了コードは最後の 1 コマンドの意見でしかない。この条件下の「エラー率」は、測っているつもりのものを測っていない。

それで「エラー率 2.9%」は何だったのか

分けたあとで元の数字に戻ると、こうなる。

見え方 件数 直し方
生の失敗フラグ 283 2.9%
うち統治(実行されていない) 95 1.0% 呼び方を変える
うち実失敗 97 1.0% 対象の確認を増やす
うち見かけ(出力はある) 60 0.6% 直す対象ではない
うち不明 31 0.3%

改善対象は 2.9% ではなく 1.0%。そして統治の 1.0% は、コードの品質ではなく git -C と書くかどうかで決まる。この2つは原因も対処も無関係なのに、ひとつの指標に混ぜられていた。

さらに、パイプで握り潰された失敗はこの表のどこにも出てこない。分子は水増しされ、同時に取りこぼしている。

エラー率を運用指標にするなら、分類してからでないと使えない。少なくとも「統治」を分母から抜き、pipefail を入れて分子を正しくしてからでないと、下げても上げても意味が読めない。

自分のログで確かめる

以下を classify_failures.py として保存して実行する。引数なしで ~/.claude/projects を全部読む。

#!/usr/bin/env python3
"""Claude Code のセッションログから、ツール呼び出しの失敗を分類する。

    python3 classify_failures.py [~/.claude/projects]
"""
import sys, os, glob, json, re, collections

GOVERNANCE = [
    ("cd+git 誘導",     r"changes directory before running git"),
    ("承認要求",         r"^This command requires approval"),
    ("複合コマンド分解", r"contains multiple operations"),
    ("分類器が拒否",     r"Permission for this action was denied"),
    ("ユーザーが却下",   r"doesn't want to proceed"),
    ("ハーネスが制止",   r"Blocked:"),
    ("未読ファイル編集", r"File has not been read yet"),
    ("worktree 隔離",   r"isolated in the worktree|EnterWorktree cannot"),
    ("スキーマ違反",     r"InputValidationError|does not match required schema"),
]
REAL = [
    ("対象が無い",   r"^File does not exist|String to replace not found|not a git repository|already checked out"),
    ("実行時エラー", r"Traceback \(most recent call last\)|SyntaxError|Exit code 128 fatal"),
    ("タイムアウト", r"Command timed out|timed out after \d+ second"),
    ("外部API",      r"rate limit exceeded|401 Unauthorized|channel_not_found|non-200"),
]
# エラーらしい語が本文に一切無いなら、出力は返っている=「見かけの失敗」
ERRISH = re.compile(r"(?i)error|fatal|no such|not found|denied|invalid|usage:|warning")


def texts(root):
    """(tool_name, is_error, body) を全セッションから取り出す。"""
    for path in glob.glob(os.path.join(root, "**", "*.jsonl"), recursive=True):
        pending = {}
        with open(path, encoding="utf-8", errors="replace") as fh:
            for line in fh:
                try:
                    ev = json.loads(line)
                except ValueError:
                    continue
                content = (ev.get("message") or {}).get("content")
                if not isinstance(content, list):
                    continue
                for blk in content:
                    if not isinstance(blk, dict):
                        continue
                    # tool_use_id からツール名を引けるようにしておく
                    if blk.get("type") == "tool_use":
                        pending[blk.get("id")] = blk.get("name")
                    elif blk.get("type") == "tool_result":
                        body = blk.get("content")
                        if isinstance(body, list):
                            body = " ".join(b.get("text", "") for b in body
                                            if isinstance(b, dict))
                        yield (pending.get(blk.get("tool_use_id"), "?"),
                               bool(blk.get("is_error")), str(body or ""))


def classify(body):
    for label, pat in GOVERNANCE:
        if re.search(pat, body):
            return "統治", label
    for label, pat in REAL:
        if re.search(pat, body):
            return "実失敗", label
    if not ERRISH.search(body[:200]):
        return "見かけ", "出力はあるが exit≠0"
    return "不明", "不明"


def main():
    root = os.path.expanduser(sys.argv[1] if len(sys.argv) > 1 else "~/.claude/projects")
    total = fails = 0
    by_tool, err_tool = collections.Counter(), collections.Counter()
    buckets, detail = collections.Counter(), collections.Counter()

    for tool, is_err, body in texts(root):
        total += 1
        by_tool[tool] += 1
        if not is_err:
            continue
        fails += 1
        err_tool[tool] += 1
        kind, label = classify(" ".join(body.split()))
        buckets[kind] += 1
        detail[(kind, label)] += 1

    if not total:
        print("ツール結果が見つからない。パスを確認する。")
        return 1

    print(f"ツール結果 {total:,} 件 / 失敗 {fails} 件 = {fails / total * 100:.1f}%\n")
    print("ツール別の失敗率(30件以上)")
    for tool, n in by_tool.most_common():
        if n >= 30:
            print(f"  {tool:<18}{err_tool[tool]:>4} /{n:>6} = {err_tool[tool] / n * 100:>5.1f}%")

    print("\n失敗の内訳")
    for kind in ("統治", "実失敗", "見かけ", "不明"):
        n = buckets[kind]
        if not n:
            continue
        print(f"  {kind:<6}{n:>4}  {n / fails * 100:>5.1f}%")
        for (k, label), m in detail.most_common():
            if k == kind:
                print(f"      {label:<20}{m:>4}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

正規表現は私のログから作ったので、そのままでは「不明」が多く出るかもしれない。その場合は推測で埋めずに、不明の文面を実際に開いて規則を足す。 私も最初の版で 50% が不明になり、開いてみて初めて承認ゲートが最大勢力だと分かった。

直す

計測の結果から出てくる手は3つだけだった。

1. cd X && gitgit -C X にする。 統治の最多原因が消える。ついでに cd は shell の状態を変えるので、次の呼び出しの前提も壊す。

2. 複合コマンドの末尾に「終了コードを決めるつもりのないコマンド」を置かない。 grep -cdiff は、正常な報告のために非ゼロを返す。最後に置くなら意図を明示する。

# 末尾の grep が exit を決めてしまう
ls d; grep -c PATTERN d/a.txt

# 意図を書く
ls d; grep -c PATTERN d/a.txt || true

3. パイプを使うなら set -o pipefail 入れないと失敗が消える。私のログでは複合が 88.2% なので、これは例外的な話ではない。

この記事の限界

  • 単一のコーパスである。私の 35 プロジェクト・9,906 ツール呼び出しから出た比率で、他人の作業配分ではまったく違う分布になる。特に統治の比率は、承認モードの設定で直接動く。
  • 分類は正規表現で、文面が変われば壊れる。ハーネスの警告文はバージョンで変わる。
  • 「見かけ」の判定は近似である。「エラーらしい語が本文にない」を根拠にしているので、エラーメッセージが日本語だけの場合などは取りこぼす。60 件は下限として読んでほしい。
  • パイプで握り潰された失敗は数えられていない。 数えるには過去のコマンドを再実行するしかなく、それは別の話になる。

まとめ

失敗フラグは、失敗の代理変数として弱い。

  • 3分の1は実行されていない(統治)。コードではなく呼び方の問題で、git -C ひとつで最多原因が消える。
  • 3分の1が本物で、その全部が「対象の状態についての思い込み」に還元される。
  • 2割は成功しているのに失敗と記録されたもの。; で繋いだ末尾のコマンドが決めている。
  • そして逆に、パイプの途中で死んだものは成功として記録されている

数える前に分ける。分けないと、直す価値のないものを直すことになる。


関連

失敗の3分の1は「統治」── 規約に止められて実行されていないものでした。止めるべきものを止めているのか、それとも書き方のせいで通らないだけなのかは、分類しないと分かりません。

止める場所の設計と、規約が効かなくなる4つのパターンをまとめた本があります。

AIコンテキスト設計ガイド ── 規約を配ったのに守られない理由(2,000円)

第3章「規約が効かなくなる4つのパターン」まで無料で読めます。
/compact の後もルートの CLAUDE.md は再注入されるが、サブディレクトリと paths: 付きは再注入されない ── 長いセッションで静かに落ちるのはどちらか、という章です。

1
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?