0
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?

Claude API の Text Editor ツール(text_editor_20250728)でファイルを編集させる実装手順 — name 違いで 400・str_replace の複数一致・undo_edit が無い、3つのハマりどころ【2026】

0
Posted at

はじめに / 対象と前提

Claude API には、Anthropic があらかじめスキーマを定義している Text Editor ツール がある。Claude が「ファイルを見る」「新規作成する」「文字列を置き換える」「行を挿入する」といった操作をツール呼び出しとして返してくるので、実際のファイル操作は自分のコードで実行して結果を返す、というクライアントツールだ。

自分は「指定ディレクトリ内の設定ファイルやコードを Claude に直させる小さな編集エージェント」を作るときにこれを使った。仕組みは単純だが、ツール定義の name、str_replace の一致判定、undo_edit の扱いで3回ハマったので、動くコードとセットでまとめておく。

想定読者

  • Claude API の tool use(tool_use / tool_result の往復)を一度は書いたことがある
  • 自前のエージェントに「ファイル編集」を安全に持たせたい

検証環境

  • Python 3.13
  • anthropic(Python SDK)2026年10月時点の最新版
  • ツールバージョン:text_editor_20250728(Claude 4 系以降のモデル向け)

TL;DR

  • ツール定義は {"type": "text_editor_20250728", "name": "str_replace_based_edit_tool"} の 2行だけ。name は固定値で、好きな名前を付けると 400 になる
  • str_replace は 「old_str がファイル内でちょうど1回だけ現れる」ときだけ置換 し、0件・複数件は is_error: true で理由を返す。ここをサボると別の箇所が書き換わる
  • Claude 4 系向けのバージョンには undo_edit が無い。取り消しが必要なら、編集前のバックアップは自分で取る

手順 / 動かし方

1. ツールを定義して投げる

Text Editor はスキーマ定義済みなので、input_schema は書かない。type と name を渡すだけでいい。

import anthropic

client = anthropic.Anthropic()  # ANTHROPIC_API_KEY を環境変数から読む

TOOLS = [
    {
        "type": "text_editor_20250728",
        "name": "str_replace_based_edit_tool",
        # 任意: view で返す文字数の上限。巨大ファイル対策
        "max_characters": 20000,
    }
]

messages = [
    {
        "role": "user",
        "content": "config/app.toml の timeout を 30 から 60 に変えて。変更前に中身を確認すること。",
    }
]

response = client.messages.create(
    model="claude-sonnet-5-5",
    max_tokens=4096,
    tools=TOOLS,
    messages=messages,
)
print(response.stop_reason)
for block in response.content:
    print(block.type, getattr(block, "input", None))

返ってくる content の例(抜粋)。指示どおりなら、まず view で中身を見に来る:

# 出力例
tool_use
text None
tool_use {'command': 'view', 'path': 'config/app.toml'}

input に入ってくる command は view / create / str_replace / insert の4種類。これを自分でディスパッチする。

2. コマンドのハンドラを書く

ポイントは3つ。

  • 作業ディレクトリの外は触らせない(../ や絶対パスを弾く)
  • 失敗は例外で落とさず、理由を文字列で返して Claude に再試行させる
  • view は行番号付きで返す(Claude が insert_line を指定するときの手がかりになる)
from pathlib import Path

ROOT = Path("./workspace").resolve()
BACKUPS: dict[Path, list[str]] = {}  # undo 用の自前バックアップ


class ToolError(Exception):
    pass


def safe_path(p: str) -> Path:
    target = (ROOT / p.lstrip("/")).resolve()
    if not target.is_relative_to(ROOT):
        raise ToolError(f"作業ディレクトリ外へのアクセスは禁止: {p}")
    return target


def backup(path: Path) -> None:
    if path.exists():
        BACKUPS.setdefault(path, []).append(path.read_text(encoding="utf-8"))


def handle(inp: dict) -> str:
    cmd = inp["command"]
    path = safe_path(inp["path"])

    if cmd == "view":
        if path.is_dir():
            return "\n".join(sorted(str(c.relative_to(ROOT)) for c in path.iterdir()))
        if not path.exists():
            raise ToolError(f"ファイルが存在しない: {inp['path']}")
        lines = path.read_text(encoding="utf-8").splitlines()
        start, end = inp.get("view_range") or [1, len(lines)]
        if end == -1:
            end = len(lines)
        return "\n".join(f"{i}\t{lines[i - 1]}" for i in range(start, end + 1))

    if cmd == "create":
        if path.exists():
            raise ToolError("既に存在する。上書きではなく str_replace を使うこと")
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(inp["file_text"], encoding="utf-8")
        return f"作成した: {inp['path']}"

    if cmd == "str_replace":
        text = path.read_text(encoding="utf-8")
        old, new = inp["old_str"], inp.get("new_str", "")
        count = text.count(old)
        if count == 0:
            raise ToolError("old_str が見つからない。空白・インデントも含めて view の内容と完全一致させること")
        if count > 1:
            raise ToolError(f"old_str が {count} 箇所に一致した。前後の行を含めて一意になるよう広げること")
        backup(path)
        path.write_text(text.replace(old, new, 1), encoding="utf-8")
        return "置換した"

    if cmd == "insert":
        lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
        n = inp["insert_line"]  # 0 なら先頭、N なら N 行目の後ろ
        if not 0 <= n <= len(lines):
            raise ToolError(f"insert_line は 0〜{len(lines)} の範囲で指定すること")
        new_text = inp.get("new_str", inp.get("insert_text", ""))
        if not new_text.endswith("\n"):
            new_text += "\n"
        backup(path)
        lines.insert(n, new_text)
        path.write_text("".join(lines), encoding="utf-8")
        return f"{n} 行目の後に挿入した"

    raise ToolError(f"未対応のコマンド: {cmd}")

3. tool_use ⇔ tool_result のループを回す

def run(messages: list) -> str:
    while True:
        res = client.messages.create(
            model="claude-sonnet-5-5", max_tokens=4096, tools=TOOLS, messages=messages
        )
        messages.append({"role": "assistant", "content": res.content})
        if res.stop_reason != "tool_use":
            return "".join(b.text for b in res.content if b.type == "text")

        results = []
        for block in res.content:
            if block.type != "tool_use":
                continue
            try:
                out, is_error = handle(block.input), False
            except (ToolError, OSError, KeyError) as e:
                out, is_error = f"Error: {e}", True
            results.append(
                {"type": "tool_result", "tool_use_id": block.id, "content": out, "is_error": is_error}
            )
        # 並列で複数 tool_use が来ても、tool_result は1つの user メッセージにまとめる
        messages.append({"role": "user", "content": results})

典型的なツール呼び出しの流れはこうなる(例):

view        config/app.toml
str_replace old_str='timeout = 30' new_str='timeout = 60'
-> 最終応答: timeout を 30 から 60 に変更しました。

4. 動作確認

API を叩かずにハンドラ単体を確認しておくと、ハマりどころの切り分けが速い。

ROOT.mkdir(exist_ok=True)
(ROOT / "a.txt").write_text("x = 1\ny = 1\nx = 1\n", encoding="utf-8")

print(handle({"command": "view", "path": "a.txt"}))
try:
    handle({"command": "str_replace", "path": "a.txt", "old_str": "x = 1", "new_str": "x = 2"})
except ToolError as e:
    print("ERR:", e)
print(handle({"command": "str_replace", "path": "a.txt", "old_str": "y = 1\nx = 1", "new_str": "y = 1\nx = 2"}))
try:
    handle({"command": "view", "path": "../etc/passwd"})
except ToolError as e:
    print("ERR:", e)
1	x = 1
2	y = 1
3	x = 1
ERR: old_str が 2 箇所に一致した。前後の行を含めて一意になるよう広げること
置換した
ERR: 作業ディレクトリ外へのアクセスは禁止: ../etc/passwd

複数一致を弾き、前後を広げた old_str なら通る。パストラバーサルも止まる。

ハマりどころ

ハマり1:name を自分で付けたら 400

最初、他の自作ツールと揃えて "name": "file_editor" のように付けたら、リクエスト自体が 400(invalid_request_error)で弾かれた。

  • 原因:Anthropic 定義済みツールは type と name の組み合わせが決まっている。text_editor_20250728 の name は str_replace_based_edit_tool 固定
  • 回避策:name は変えない。古い記事やサンプルにある text_editor_20250124 + str_replace_editor は Claude 3.7 Sonnet 時代の組み合わせで、新しいモデルにそのまま持ってくると噛み合わない。type と name はセットで書き換える

ディスパッチ側で「どのツールの呼び出しか」を判定するときも、block.name == "str_replace_based_edit_tool" で見ることになる。

ハマり2:str_replace で意図しない箇所が書き換わる

最初の実装は text.replace(old, new) だった。設定ファイルに enabled = true が3箇所あり、Claude が「2つ目のセクションだけ変えたい」つもりで old_str="enabled = true" を送ってきたとき、3箇所全部が書き換わった。replace(old, new, 1) にしても今度は1つ目が書き換わるだけで、意図とはズレる。

  • 原因:一致件数のチェックをしていなかった。Claude 側は「ちょうど1回だけ一致する前提」で old_str を作ってくる
  • 回避策:text.count(old) を数え、0件・2件以上は is_error: true で理由を返す。メッセージに「前後の行を含めて一意にしろ」と書いておくと、次のターンで Claude がセクション見出しごと old_str に含めて再送してくる

0件のケースもよく起きる。多いのはタブとスペースの違いと、view の行番号(12\t)まで old_str に含めてしまう パターン。エラー文に「view の内容と完全一致させること」と書いておけば、ほぼ自力で直してくる。

ハマり3:undo_edit を実装して待っていても来ない

古いドキュメントを見て undo_edit のハンドラを書いたが、一度も呼ばれなかった。

  • 原因:Claude 4 系向けの text_editor_20250429 / text_editor_20250728 では undo_edit コマンドが提供されていない。使えるのは view / create / str_replace / insert のみ
  • 回避策:取り消しが必要なら 自前でバックアップを取る。上のコードでは str_replace と insert の直前に BACKUPS へ積んでいる。人間側の「やっぱり戻して」に応えるのはアプリ側の責務と割り切った
def undo(p: str) -> None:
    path = safe_path(p)
    if not BACKUPS.get(path):
        raise ToolError("戻せる履歴がない")
    path.write_text(BACKUPS[path].pop(), encoding="utf-8")

本番では git 管理下の作業ツリーで動かし、ループ終了後に git diff を人間がレビューする運用のほうが確実だった。

背景・補足

  • なぜ自前定義ツールではなく Text Editor を使うのか:モデルはこのツールの使い方で学習されているので、view → str_replace の手順や、エラー時の再試行が安定する。自分で edit_file(path, content) のような全文上書きツールを作ったときより、差分だけを送ってくるので出力トークンも少ない
  • max_characters:text_editor_20250728 から使えるパラメータで、view の返却を切り詰める。大きいログファイルを丸ごと読まれてコンテキストを食い潰すのを防げる。ハンドラ側でも行数制限を入れておくと二重に安全
  • insert の引数名:ドキュメントの版によって挿入テキストのキー名の記載が揺れていたので、上のコードでは new_str と insert_text の両方を受けるようにしている
  • セキュリティ:Claude が返すパスは信用しない。resolve() してから is_relative_to(ROOT) で判定するのが最低ライン。シンボリックリンク経由の脱出も resolve() で潰せる

まとめ

  • Text Editor ツールは type と name の2つを渡すだけ。name は str_replace_based_edit_tool 固定で、type とセットで扱う
  • 実際のファイル操作は自分のコードの責任。作業ディレクトリ外のパスは必ず弾く
  • str_replace は「ちょうど1回一致」のときだけ実行し、それ以外は is_error で理由を返すと Claude が自力で直してくる
  • Claude 4 系向けには undo_edit が無い。取り消しは自前バックアップか git で担保する
0
0
0

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
0
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?