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 の Memory tool(memory_20250818)でセッションをまたいで記憶させる実装手順 — API側は何も保存しない・パストラバーサル・指示がないと読みに来ない、3つのハマりどころ【2026】

0
Posted at

はじめに / 対象と前提

Claude に「前回の会話で決めたこと」を覚えさせたい。会話履歴を全部積み直すのは重いし、コンテキストが溢れる。そこで Anthropic が用意している Memory tool(memory_20250818)を Python から動かしてみた。

この記事は、Claude API のツール呼び出し(tool use)を一度は実装したことがある人向け。「Memory tool を宣言したのに何も保存されない」「/memories/../ で変なパスが飛んできた」あたりで詰まった人に刺さるはず。

環境は以下。

  • Python 3.13
  • anthropic Python SDK 1.x
  • モデル:claude-opus-5

TL;DR

  • Memory tool は クライアント側ツール。API は 1 バイトも保存しない。ファイルの読み書きは自分で実装する
  • ツールは {"type": "memory_20250818", "name": "memory"} と宣言するだけ。input_schema は書かない
  • Claude から view / create / str_replace / insert / delete / rename の 6 コマンドが飛んでくるので、/memories 配下のファイル操作に変換して tool_result で返す
  • ハマりどころは「保存されない(自分で実装する必要がある)」「パストラバーサル」「system prompt で指示しないと読みに来ない」の 3 つ

手順 / 動かし方

1. まずは宣言だけして何が飛んでくるか見る

import anthropic

client = anthropic.Anthropic()

res = client.messages.create(
    model="claude-opus-5",
    max_tokens=4096,
    tools=[{"type": "memory_20250818", "name": "memory"}],
    messages=[{"role": "user", "content": "自分の好きな言語は Python。覚えておいて。"}],
)
for block in res.content:
    print(block.type, getattr(block, "input", None))

実行結果(抜粋):

tool_use {'command': 'view', 'path': '/memories'}

いきなり create ではなく、まず /memories ディレクトリの view が来る。Claude は「今何が保存されているか」を確認してから書き込む動きをする。この時点で stop_reasontool_use なので、こちらが結果を返さないと会話は進まない。

2. 6 コマンドをローカルファイルシステムに繋ぐ

最小実装はこれ。/memories を実際のディレクトリ(ここでは ./memory_store)にマッピングする。

from pathlib import Path

ROOT = Path("./memory_store").resolve()
ROOT.mkdir(exist_ok=True)

def resolve(virtual: str) -> Path:
    if not virtual.startswith("/memories"):
        raise ValueError(f"path must start with /memories: {virtual}")
    real = (ROOT / virtual[len("/memories"):].lstrip("/")).resolve()
    if ROOT != real and ROOT not in real.parents:
        raise ValueError(f"path escapes memory root: {virtual}")
    return real

def handle_memory(inp: dict) -> str:
    cmd = inp["command"]
    if cmd == "view":
        p = resolve(inp["path"])
        if p.is_dir():
            files = sorted(str(f.relative_to(ROOT)) for f in p.rglob("*") if f.is_file())
            return "Directory: /memories\n" + "\n".join(f"- /memories/{f}" for f in files)
        lines = p.read_text().splitlines()
        start, end = inp.get("view_range", [1, len(lines)])
        return "\n".join(f"{i}: {l}" for i, l in enumerate(lines[start-1:end], start))
    if cmd == "create":
        p = resolve(inp["path"]); p.parent.mkdir(parents=True, exist_ok=True)
        p.write_text(inp["file_text"]); return f"File created: {inp['path']}"
    if cmd == "str_replace":
        p = resolve(inp["path"]); s = p.read_text()
        if s.count(inp["old_str"]) != 1:
            raise ValueError("old_str must appear exactly once")
        p.write_text(s.replace(inp["old_str"], inp["new_str"])); return "Edited"
    if cmd == "insert":
        p = resolve(inp["path"]); lines = p.read_text().splitlines()
        lines[inp["insert_line"]:inp["insert_line"]] = inp["insert_text"].splitlines()
        p.write_text("\n".join(lines) + "\n"); return "Inserted"
    if cmd == "delete":
        p = resolve(inp["path"]); p.unlink() if p.is_file() else __import__("shutil").rmtree(p)
        return f"Deleted: {inp['path']}"
    if cmd == "rename":
        resolve(inp["old_path"]).rename(resolve(inp["new_path"])); return "Renamed"
    raise ValueError(f"unknown command: {cmd}")

3. ループを回して動作確認

SYSTEM = (
    "あなたには /memories ディレクトリの memory ツールがある。"
    "会話の最初に必ず /memories を view して過去の記録を確認し、"
    "ユーザーの好みや決定事項は /memories/notes.md に記録すること。"
)

def chat(user_text: str) -> str:
    messages = [{"role": "user", "content": user_text}]
    while True:
        res = client.messages.create(
            model="claude-opus-5", max_tokens=4096, system=SYSTEM,
            tools=[{"type": "memory_20250818", "name": "memory"}],
            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 b in res.content:
            if b.type != "tool_use":
                continue
            try:
                out, err = handle_memory(b.input), False
            except Exception as e:
                out, err = str(e), True
            results.append({"type": "tool_result", "tool_use_id": b.id,
                            "content": out, "is_error": err})
        messages.append({"role": "user", "content": results})

print(chat("自分の好きな言語は Python。覚えておいて。"))
# --- プロセスを再起動してから ---
print(chat("自分の好きな言語って何だっけ?"))

2 回目の呼び出しで、Claude は view /memoriesview /memories/notes.md を経て「Python です」と答えた。会話履歴を渡していないのに答えられている、つまりファイル経由で記憶が引き継がれたことが確認できた。

memory_store/notes.md の中身:

# ユーザーの好み
- 好きな言語: Python

ハマりどころ

1. 宣言しただけでは何も保存されない

自分が最初に勘違いした点。「Anthropic 側にストレージがあって、memory_20250818 を付けたら勝手に永続化される」と思っていた。

実際は web_search のようなサーバー側ツールではなく、bash や text_editor と同じクライアント側ツールtool_use が返ってきたら自分でファイルを触り、tool_result を返さない限り何も起きない。ステップ 1 の結果で stop_reasontool_use のまま止まるのがその証拠。

ちなみに、以前は context-management-2025-06-27 のベータヘッダーが必須だったが、今の SDK では通常の client.messages.create で動く。古い記事のまま betas=[...] を付けても害はないが、不要。

2. パストラバーサルは自分で防ぐ

Claude が /memories/../../.env のようなパスを返してくる可能性はゼロではない(プロンプトインジェクションで誘導されるケースを想定する)。API 側では検証されないので、resolve()必ず /memories プレフィックスと、実パスがルート配下に収まっているかの 2 段チェック を入れる。

上のコードで ROOT not in real.parents を見ているのがそれ。Path.resolve() を通してから比較しないと、シンボリックリンクや .. を素通しする。

あとメモリファイルに API キーやパスワードを書かせない。system prompt で「秘密情報は記録しない」と明示しておくと安全側に倒れる。

3. system prompt で指示しないと読みに来ない

ツールを宣言しただけだと、Claude は「覚えて」と言われたときは create するが、次のセッションで 自発的に view しに来ないことがある。特に質問が短いと、メモリを見ずに「分かりません」と返す。

対策は上の SYSTEM のように「会話の最初に /memories を view しろ」「何をどのファイルに書け」を明示すること。書き込み先のファイル名を固定しておくと、セッションごとに notes.mduser_preferences.md が乱立するのも防げる。

補足:SDK のヘルパーを使う

自前ループが面倒なら、SDK に BetaAbstractMemoryTool という抽象クラスがある。6 コマンド分のメソッドを実装して tool_runner に渡せば、ループもディスパッチも SDK がやってくれる。

from anthropic.lib.tools import BetaAbstractMemoryTool

class LocalMemory(BetaAbstractMemoryTool):
    def view(self, command): ...
    def create(self, command): ...
    def str_replace(self, command): ...
    def insert(self, command): ...
    def delete(self, command): ...
    def rename(self, command): ...

runner = client.beta.messages.tool_runner(
    model="claude-opus-5", max_tokens=4096,
    tools=[LocalMemory()],
    messages=[{"role": "user", "content": "自分の好きな言語って何だっけ?"}],
)
for message in runner:
    print(message)

ただしパスの検証は結局自分で書くことになる。SDK が肩代わりしてくれるのはループ部分だけ、と理解しておくとよい。

まとめ

  • Memory tool は クライアント側ツール。API は何も保存しない、ファイル操作は自分で書く
  • 6 コマンド(view / create / str_replace / insert / delete / rename)を /memories 配下の実ファイルに変換して tool_result で返す
  • パストラバーサル対策は必須resolve() 後にルート配下かを検証する
  • system prompt で「最初に view しろ」「どのファイルに書け」を明示しないと、次のセッションで読みに来ない
  • ループが面倒なら BetaAbstractMemoryTool + tool_runner
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?