Claude Media
text editor toolを自前実装する手順 — max_charactersとundo_edit廃止

text editor toolを自前実装する手順 — max_charactersとundo_edit廃止

text_editor_20250728をスキーマ不要のツールとして宣言し、view・create・str_replace・insertを自前で処理する手順です。max_charactersの扱いとundo_edit廃止の履歴も載せます。

text editor toolは何を自前で実装するのか

text editor toolは、Claudeにファイルの閲覧と編集をさせるためのAnthropic定義のツールです。ツール名は str_replace_based_edit_tool で、タイプは text_editor_20250728 を指定します。Claude 4以降のモデル向けのタイプです。

このツールはクライアントツールです。Claudeが tool_use ブロックで「このファイルを開く」「この文字列を置換する」と要求するだけで、ファイルには触れません。実際の読み書きは、呼び出し側のコードが担います。

スキーマ不要のツール(schema-less tool)という点が、普通のツール定義との違いです。input_schema を書く必要はなく、スキーマはモデルに組み込まれていて変更できません。自前で用意するのは次の2つだけです。

  • リクエストに載せるツール宣言(タイプ・名前・任意の max_characters)
  • tool_use の入力を受けて、ファイルを操作して結果を返すハンドラ

同じクライアントツールの実装でも、シェル実行はBashツールをAPIで実装するセンチネル行とタイムアウト処理、画面操作はComputer Useツールを自前実装する最小構成にまとめています。本記事はファイル編集の側です。

ツール宣言とmax_characters

宣言は次の形です。max_characters は省略できます。

{
  "type": "text_editor_20250728",
  "name": "str_replace_based_edit_tool",
  "max_characters": 10000
}

max_characters は、大きなファイルを view で開いたときの切り詰めを制御するパラメータです。text_editor_20250728 以降のバージョンでしか使えません。古いバージョンのタイプに付けても効きません。

注意したいのは、切り詰めを実行するのが実装側だという点です。view を処理する手順には「ツール設定に max_characters を指定していたら、ファイルの内容をその長さに切り詰める」と書かれています。宣言に数字を書いただけでは何も起きません。ハンドラが自分で text[:max_characters] のように削り、削った結果を tool_result に入れます。

切り詰めた末尾に目印を付けるかどうかは、仕様に規定がありません。目印を付けるなら、Claudeが「続きがある」と判断できる文言と、view_range で続きを読めることを伝える一文が役に立ちます。この点は設計の判断で、仕様の指定ではありません。

追加で消費される入力トークン

ツールを宣言すると、リクエストごとに固定の入力トークンが加算されます。

対象モデル追加の入力トークン
Claude 4.7以降のモデルとClaude Mythos Preview追加の入力トークン974
Claude 4.6以前のモデル追加の入力トークン745

料金体系は他のツールと同じで、通常の入力・出力トークン単価に従います。

4つのコマンドと入力パラメータ

text_editor_20250728 が受け付けるコマンドは view / str_replace / create / insert の4つです。ハンドラは input.command で振り分けます。

コマンド主なパラメータ役割
view主なパラメータpath、任意で view_range役割ファイルの内容、またはディレクトリの一覧を返す
str_replace主なパラメータpath、old_str、new_str役割old_str を new_str に置換する
create主なパラメータpath、file_text役割新しいファイルを作る
insert主なパラメータpath、insert_line、insert_text役割指定行の後ろにテキストを挿入する

細部の仕様は4点です。

  • view_range は2つの整数の配列で、行番号は1始まりです。終了行に -1 を指定すると末尾までを意味します
  • view_range はファイルにだけ効き、ディレクトリには効きません
  • str_replace の old_str は空白とインデントまで完全一致が必要です
  • insert_line は「その行の後ろに挿入する」行番号で、0 ならファイルの先頭に入ります

Pythonでハンドラを書く

次のコードは、上の仕様を満たす最小のハンドラの一例です。match で振り分ける骨組みと、バックアップ・一意マッチの考え方は公式の実装例にあります。以下はそれを組み合わせた実装例です。作業ディレクトリの外に出るパスを拒否する処理と、view の行番号付き出力は、骨組みにない追加の設計です。

import os
import shutil
 
ROOT = os.path.realpath("workspace")
MAX_CHARS = 10000
 
 
def resolve(path):
    full = os.path.realpath(os.path.join(ROOT, path))
    if os.path.commonpath([ROOT, full]) != ROOT:
        raise PermissionError("Permission denied: outside workspace")
    return full
 
 
def backup(full):
    if os.path.exists(full):
        shutil.copyfile(full, full + ".backup")
 
 
def view(full, view_range):
    if os.path.isdir(full):
        return "\n".join(sorted(os.listdir(full)))
    with open(full, encoding="utf-8") as f:
        lines = f.read().splitlines()
    start, end = (view_range or [1, -1])
    end = len(lines) if end == -1 else end
    body = "\n".join(
        f"{n}: {line}"
        for n, line in enumerate(lines, 1)
        if start <= n <= end
    )
    return body[:MAX_CHARS]

続きは書き込み系の3コマンドです。

def str_replace(full, old, new):
    with open(full, encoding="utf-8") as f:
        text = f.read()
    count = text.count(old)
    if count == 0:
        raise ValueError("No match found for replacement.")
    if count > 1:
        raise ValueError(f"Found {count} matches. Add more context.")
    backup(full)
    with open(full, "w", encoding="utf-8") as f:
        f.write(text.replace(old, new))
    return "Successfully replaced text"
 
 
def create(full, file_text):
    backup(full)
    os.makedirs(os.path.dirname(full), exist_ok=True)
    with open(full, "w", encoding="utf-8") as f:
        f.write(file_text)
    return "File created"
 
 
def insert(full, line_no, text):
    with open(full, encoding="utf-8") as f:
        lines = f.read().splitlines(keepends=True)
    backup(full)
    lines.insert(line_no, text if text.endswith("\n") else text + "\n")
    with open(full, "w", encoding="utf-8") as f:
        f.writelines(lines)
    return "Text inserted"
 
 
def handle_editor_tool(params):
    command = params.get("command", "")
    full = resolve(params.get("path", ""))
    match command:
        case "view":
            return view(full, params.get("view_range"))
        case "str_replace":
            return str_replace(full, params["old_str"], params["new_str"])
        case "create":
            return create(full, params["file_text"])
        case "insert":
            return insert(full, params["insert_line"], params["insert_text"])
    raise ValueError(f"Unknown command: {command}")

str_replace が置換の前に出現回数を数えているのは、公式が注意点に挙げる「置換は必ず1箇所に一致させる」を満たすためです。複数一致を返したときの挙動と対処は、text_editorのstr_replaceで複数マッチエラーを防ぐ実装で個別に掘り下げています。

会話ループとエラーの返し方

ハンドラを呼ぶ側は、stop_reason が tool_use の間ループを回します。tool_use ブロックごとにハンドラを実行し、結果を tool_result として次の user メッセージに入れて返します。

import anthropic
 
client = anthropic.Anthropic()
tools = [{
    "type": "text_editor_20250728",
    "name": "str_replace_based_edit_tool",
    "max_characters": MAX_CHARS,
}]
messages = [{"role": "user",
             "content": "primes.py の構文エラーを直してください"}]
 
while True:
    resp = client.messages.create(
        model="claude-opus-5-5", max_tokens=1024,
        tools=tools, messages=messages,
    )
    messages.append({"role": "assistant", "content": resp.content})
    if resp.stop_reason != "tool_use":
        break
    results = []
    for block in resp.content:
        if block.type != "tool_use":
            continue
        try:
            out, is_error = handle_editor_tool(block.input), False
        except Exception 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,
        })
    messages.append({"role": "user", "content": results})

tool_result には tool_use_id と content を入れ、失敗したときは任意項目の is_error を true にします。想定されるエラーは4種類です。

状況返すメッセージの例
ファイルが存在しない返すメッセージの例Error: File not found
置換対象が複数に一致返すメッセージの例Error: Found 3 matches for replacement text. Please provide more context to make a unique match.
置換対象に一致なし返すメッセージの例Error: No match found for replacement. Please check your text and try again.
権限エラー返すメッセージの例Error: Permission denied. Cannot write to file.

いずれも is_error: true を付けて返します。文言そのものは例示で、Claudeが読んで次の手を決められる内容であれば構いません。ループでは、例外を一括で is_error に変換しています。ハンドラ側は失敗したら例外を投げるだけで済みます。

1往復分の入出力を見る

壊れた primes.py を直す場面で、ハンドラが受け取る入力と返す中身を1往復分並べます。最初にClaudeが返すのは、ファイルを開く要求です。stop_reason は tool_use になります。

{
  "type": "tool_use",
  "id": "toolu_01AbCdEfGhIjKlMnOpQrStU",
  "name": "str_replace_based_edit_tool",
  "input": {"command": "view", "path": "primes.py"}
}

ハンドラは行番号付きの本文を content に入れて返します。先頭の数行はこうなります。

1: def is_prime(n):
2:     "Check if a number is prime."
3:     if n <= 1:

行番号は必須ではありませんが、あると次の str_replace や insert で位置を指定しやすくなります。本文を読んだClaudeは、for 行のコロン抜けに気づいて置換を要求します。

{
  "type": "tool_use",
  "id": "toolu_01PqRsTuVwXyZAbCdEfGh",
  "name": "str_replace_based_edit_tool",
  "input": {
    "command": "str_replace",
    "path": "primes.py",
    "old_str": "    for num in range(2, limit + 1)",
    "new_str": "    for num in range(2, limit + 1):"
  }
}

old_str は行頭の空白まで含めて、ファイルの該当部分と一致させています。ハンドラが Successfully replaced text を返せば、Claudeは結果を踏まえて次の応答を返します。tool_use_id は要求ごとに変わるので、返す tool_result には必ず対応するIDを入れます。

undo_editはなぜ消えたのか

undo_edit は、直前の編集を取り消すコマンドでした。現行の text_editor_20250728 には存在しません。バージョンごとの履歴は次のとおりです。

あゆみ

text editor toolのバージョン履歴

  1. 2024年10月22日text_editor_20241022

    Claude Sonnet 3.5と同時に公開された最初のバージョンです。view / create / str_replace / insert / undo_edit の5コマンドを持ちます。

  2. 2025年3月13日text_editor_20250124

    単独のドキュメントとして整理されたバージョンです。Claude Sonnet 3.7に最適化されていますが、機能は前のバージョンと同じです。

  3. 2025年4月29日text_editor_20250429

    Claude 4向けのバージョンです。undo_edit コマンドを削除し、他の機能は維持しました。ツール名も str_replace_based_edit_tool という、str_replace 中心の構造を表す名前に変わっています。

  4. 2025年7月28日text_editor_20250728

    不具合の修正を含む更新版です。任意の max_characters が加わり、それ以外は text_editor_20250429 と同一です。

変更ログに理由の説明はありません。分かっているのは「text_editor_20250429 から消え、text_editor_20250728 も同じ」という事実までです。

実装上の要点は2つあります。

  1. text_editor_20250728 を使うなら、undo_edit のハンドラは不要です。Claudeはこのコマンドを呼びません
  2. 取り消しが必要なのはアプリケーションの都合なので、自前の仕組みで持ちます

後者の手がかりが、注意点にも挙がるバックアップです。編集の前にファイルのコピー(上のコードでは .backup)を作っておけば、利用者向けの「元に戻す」操作をアプリケーションから提供できます。上の例は直前の1世代しか残さないので、複数回さかのぼりたい場合は世代管理やGitのコミットに置き換えます。

バージョンの選び方

ツール一覧では、text_editor_20250728 がClaude 4以降のモデル向け、text_editor_20250124 が以前のモデル向けとされています。つまり、使うバージョンは対象モデルで決まります。text_editor_20250124 は undo_edit を持ち、max_characters は使えません。切り替えるのはタイプだけではありません。ツール名も変わり、text_editor_20250124 では str_replace_editor、text_editor_20250728 では str_replace_based_edit_tool です。宣言の名前と食い違えば、tool_use の name も食い違います。複数世代のモデルをまたいで同じ基盤を動かす場合は、モデルごとにタイプと名前を切り替え、undo_edit と max_characters の有無をハンドラ側で吸収する必要があります。text_editor_20241022 の名前は、参照した資料に載っていません。

実装前に確認したい点

実装時の注意は次の4点です。

  1. セキュリティ。ツールはローカルのファイルシステムに触れるので、適切な対策を入れる
  2. バックアップ。重要なファイルは編集の前にコピーを取る
  3. 入力の検証。意図しない変更を防ぐため、受け取った入力を検証する
  4. 一意なマッチ。置換が必ず1箇所に一致するようにする

このうち1と3を実装に落とすと、前掲の resolve のようなパスの検証になります。Claudeが返す path は、モデルの出力であって信頼できる入力ではありません。../ を含むパスや、シンボリックリンク経由で作業ディレクトリの外を指すパスは、realpath で正規化してから範囲を確かめます。

プロンプトの側にも工夫の余地があります。ベストプラクティスでは、「コードを直して」ではなく「primes.pyに構文エラーがあって動きません。直してください」のように、対象のファイルと症状を具体的に書くよう勧めています。ファイルパスを明示すれば、Claudeが無駄な view を重ねずに済みます。

試すときの流れ

動作確認は、壊れたファイルを1つ用意するのが手早い方法です。

手順

動作確認の進め方

  1. 1

    作業ディレクトリを用意する

    workspace/primes.py に、for 行の末尾のコロンを抜いたコードを置きます。

  2. 2

    ループを実行する

    上のスクリプトを実行し、Claudeが view で中身を確認してから str_replace を呼ぶことを確かめます。

  3. 3

    ログを見る

    各 tool_use の input と、返した tool_result の content を出力して、max_characters での切り詰めや is_error の動きを追います。

自前実装のまとめ

text_editor_20250728 の自前実装は、ツール宣言1つとコマンド4つのハンドラに分解できます。input_schema は書かず、max_characters の切り詰めは実装側の仕事で、取り消し機能は仕様の外にあります。実装の分かれ目は、パスの検証、一意マッチの確認、失敗時の is_error の3つです。複数世代のモデルに対応するときだけ、undo_edit と max_characters の差を吸収する層が増えます。

この記事を共有:XはてブLinkedIn