はじめに / 対象と前提
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 で担保する