朝の棚卸しスクリプトが、いつもより静かでした。
私は個人開発で、数本のアプリの運用をひとつの Managed Agents セッションに任せております。夜間にクロール状況・課金・問い合わせを見て、朝に要約を置いておく。そのために、プロジェクトごとのメモリストアへ「前回の判断」「触ってはいけない設定」を書き溜めております。
毎朝、そのメモリを一覧して差分を眺めるのが習慣でした。ところがある朝、一覧が拾ってくる件数が減っていました。前日は全プロジェクト合わせて 68 件だったものが、その朝は 39 件。ほぼ半分です。エラーは出ていません。例外も、警告も、何も。ただ、いくつかのプロジェクトのメモリだけが結果から消えていました。
原因は、前夜に上げたベータヘッダーひとつでした。agent-memory-2026-07-22。
メモリストアの一覧を、なぜ自前で叩くのか
Managed Agents のメモリストアは、セッションのコンテナに /mnt/memory/ としてマウントされ、エージェントは通常のファイルツールで読み書きします。ここまでは、エージェントに任せておけば済む話です。
一覧 API を人間側から叩くのは、別の目的のためです。エージェントが書いたものをこちらで読み返し、誤った記憶があれば訂正し、区切りごとにエクスポートして棚卸しをしております。私の場合は毎朝の差分確認で、memories.list を path_prefix で絞り込みながらプロジェクト単位に巡回しておりました。
メモリは 1 件あたり 100 kB(およそ 25,000 トークン)が上限で、公式にも「大きな数本ではなく、小さく焦点を絞った多数のファイルに分けよ」と書かれています。私はこれに従い、/projects/alpha/decisions/2026-07.md のように、プロジェクト名を第1セグメントに置く木構造で運用しておりました。この設計が、今回の変更でちょうど急所を突かれた形になります。
agent-memory-2026-07-22 で変わった3点
ベータヘッダーを agent-memory-2026-07-22 に上げると、memories.list の挙動が3点変わります。順に見ていきます。
順序固定と depth 制限は、読めば身構えられます。困るのは3つ目です。挙動が「エラーではなく、静かに違う結果」に振れるからです。
直感に反した発見 — 部分一致に頼っていた自分に気づく
私の棚卸しスクリプトは、プロジェクトを跨いで拾うために、こう書いておりました。
# 変更前。これで /projects/alpha/... も /projects/alpha_archive/... も拾えていた
memories = client.beta.memory_stores.memories.list(
memory_store_id = store_id,
path_prefix = "/projects/alpha" , # ← 末尾スラッシュが無い
view = "basic" ,
betas = [ "managed-agents-2026-04-01" ],
)
変更前の path_prefix は部分文字列一致でした。"/projects/alpha" は "/projects/alpha/decisions/2026-07.md" の接頭辞であると同時に、"/projects/alpha_archive/old.md" の接頭辞でもあります。私は当時それを「プロジェクトと、そのアーカイブをまとめて拾える便利な挙動」として、半ば無自覚に頼っておりました。
agent-memory-2026-07-22 では、path_prefix は末尾スラッシュが必須になり、パスセグメント単位で一致します。すると次のことが起きます。
第一に、末尾スラッシュの無い "/projects/alpha" は、もはや意図した範囲を拾いません。セグメント境界に合わないためです。第二に、正しく "/projects/alpha/" と書いた場合、それは /projects/alpha_archive/ を含みません 。alpha と alpha_archive は別セグメントだからです。
つまり、以前は「1回の呼び出しで本体とアーカイブを両方拾えていた」ものが、変更後は「本体しか返らない」に変わります。件数が半減して見えた正体は、これでした。エラーが出なかったのは、path_prefix の指定自体は文法的に正しく、ただ一致範囲だけが音もなく狭まったからです。
ここが今回いちばん学びになった点です。破壊的変更のうち最も危ういのは、例外を投げるものではなく、黙って結果が変わるもの です。前者は必ず気づけますが、後者は監視していなければ見過ごします。
破壊的変更に耐えるアクセス層を書き直す
対処方針は3つに絞りました。プレフィックスを必ず正規化すること、返却順に一切依存しないこと、そして木を自分で明示的に歩くことです。これを踏まえて、棚卸し層を次のように書き直しました。実際に毎朝走っているコードから、鍵などを伏せて抜き出したものです。
from anthropic import Anthropic
client = Anthropic() # ANTHROPIC_API_KEY は環境変数から
BETAS = [ "managed-agents-2026-04-01" , "agent-memory-2026-07-22" ]
def normalize_prefix (prefix: str ) -> str :
"""path_prefix は必ず末尾スラッシュで終える。ルートは "/" に統一。"""
if not prefix.startswith( "/" ):
prefix = "/" + prefix
if not prefix.endswith( "/" ):
prefix = prefix + "/"
return prefix
def list_all_memories (store_id: str , prefix: str = "/" ) -> list[ dict ]:
"""
prefix 配下のメモリを全件返す。
- 返却順に依存しない(呼び出し側で必ずソートする)
- depth は 0/1/省略のみ許されるため、木は自分で再帰的に歩く
"""
prefix = normalize_prefix(prefix)
collected: list[ dict ] = []
cursor: str | None = None
while True :
page = client.beta.memory_stores.memories.list(
memory_store_id = store_id,
path_prefix = prefix,
depth = 1 , # 直下の子だけを取り、サブツリーは再帰で辿る
view = "basic" , # メタデータのみ。本文は必要な時だけ retrieve
after_id = cursor,
betas = BETAS ,
)
for item in page.data:
if item.type == "memory_prefix" :
# ディレクトリ相当のノード。1 段掘り下げる
collected.extend(list_all_memories(store_id, item.path))
else :
collected.append({ "id" : item.id, "path" : item.path})
if not getattr (page, "has_more" , False ):
break
cursor = page.last_id
# サーバ順は保証されないので、比較・差分のために自前で安定ソートする
collected.sort( key =lambda m: m[ "path" ])
return collected
ここでの要点を、コードの外側で補足しておきます。
まず depth=1 に固定し、memory_prefix(ディレクトリ相当のノード)に当たったら自分で再帰する形にしました。以前は大きな depth を渡して一気に取っていましたが、その値はもう 400 になります。木を自分で歩けば、深さの制限に縛られず、しかも各段で件数を把握できます。
次に、順序を一切あてにしていません。取得後に path で安定ソートしています。前日との差分は「順番」ではなく「集合」として比較すべきで、そう組んでおけばサーバ順が変わっても壊れません。
そして本文は view="basic" で取りません。棚卸しに要るのはパスと ID だけです。中身が要る時だけ、対象を絞って memories.retrieve します。100 kB ×多数を毎朝ダウンロードするのは、時間の面でも料金の面でも無駄でした。
「本体とアーカイブをまとめて」を、意図として書き直す
以前の部分一致に頼っていた「本体もアーカイブも拾う」挙動は、便利ではありましたが、意図が暗黙でした。移行を機に、これを明示的な列挙へ書き直しました。
def list_project_including_archive (store_id: str , project: str ) -> list[ dict ]:
"""プロジェクト本体とアーカイブを、意図的に別プレフィックスとして合算する。"""
live = list_all_memories(store_id, f "/projects/ { project } /" )
archive = list_all_memories(store_id, f "/projects/ { project } _archive/" )
return live + archive
書いてみて、以前のコードがいかに危うかったかがよく分かりました。alpha と名の付くプロジェクトが増えれば、部分一致は alpha2 や alpha_experimental まで巻き込んでいたはずです。セグメント一致は不便になったのではなく、私の曖昧な意図を明示させてくれたのだと、今は受け止めております。
安全な訂正 — content_sha256 プリコンディション
棚卸しの目的の半分は「誤った記憶の訂正」です。ここは仕様変更とは別に、最初から気をつけるべき点があります。エージェントと人間が同じメモリを触るため、上書き競合が起こり得ることです。
memories.update には楽観的並行制御があります。読んだ時の content_sha256 を渡し、保存側のハッシュがまだ一致する時だけ適用させます。
def safe_correct (store_id: str , memory_id: str , new_content: str ) -> bool :
current = client.beta.memory_stores.memories.retrieve(
memory_store_id = store_id, memory_id = memory_id, betas = BETAS ,
)
try :
client.beta.memory_stores.memories.update(
memory_store_id = store_id,
memory_id = memory_id,
content = new_content,
precondition = {
"type" : "content_sha256" ,
"content_sha256" : current.content_sha256,
},
betas = BETAS ,
)
return True
except Exception :
# ハッシュ不一致 = 誰か(多くはエージェント自身)が先に書いた。
# 黙って上書きせず、再読込して判断し直す
return False
夜間にエージェントが同じファイルへ追記していることは、実際にありました。プリコンディション無しで訂正していた頃は、その追記をたまに踏み潰していたはずです。すべての変更は不変の memory version(memver_...)として残るため、事後に気づけはしますが、踏まないに越したことはありません。
移行前に、影響範囲を手元で数える
半減に気づけたのは、前日の件数を手元に残していたからでした。裏を返せば、残していなければ気づけていません。
同じ思いをしないための手当ては、思ったより簡単でした。メモリの path 一覧さえ手元にあれば、API を叩かずに影響範囲は数えられます。旧来の部分一致と新しいセグメント一致を、同じパス集合へ並べて適用するだけです。
"""prefix_audit.py — 旧 substring 一致と新 segment 一致の差を、API を叩かずに数える。"""
import json, sys
def substring_match (paths, prefix): # 変更前の挙動
return [p for p in paths if p.startswith(prefix)]
def segment_match (paths, prefix): # 変更後の挙動
if not prefix.endswith( "/" ):
return [] # 末尾スラッシュ無しは一致しない
return [p for p in paths if p.startswith(prefix)]
def prefix_nodes (paths):
"""depth=1 で再帰する際に踏む、ディレクトリ相当ノードの数。"""
nodes = set ()
for p in paths:
parts = p.strip( "/" ).split( "/" )[: - 1 ]
for i in range ( len (parts)):
nodes.add( "/" + "/" .join(parts[: i + 1 ]) + "/" )
return nodes
if __name__ == "__main__" :
paths = json.load( open (sys.argv[ 1 ])) # memories.list から集めた path 一覧
print ( f "total memories: { len (paths) } " )
n = len (prefix_nodes(paths))
print ( f "prefix nodes : { n } -> depth=1 recursion needs { n + 1 } list calls \n " )
print ( f " { 'path_prefix' :<28 }{ 'substring' :>10 }{ 'as-written' :>12 }{ 'normalized' :>12 }{ 'lost' :>7 } " )
for pf in sys.argv[ 2 :]:
old = len (substring_match(paths, pf))
raw = len (segment_match(paths, pf))
new = len (segment_match(paths, pf if pf.endswith( "/" ) else pf + "/" ))
print ( f " { pf :<28 }{ old :>10 }{ raw :>12 }{ new :>12 }{ old - new :>7 } " )
私の運用と同じ形の木(プロジェクト6本・メモリ68件)を再現して流すと、こう出ます。
total memories: 68
prefix nodes : 15 -> depth=1 recursion needs 16 list calls
path_prefix substring as-written normalized lost
/projects/alpha 34 0 20 14
/projects/alpha/ 20 20 20 0
/projects/beta 16 0 16 0
/projects/ 68 68 68 0
列の意味は3つです。substring が変更前の一致件数、as-written が末尾スラッシュを補わずそのまま送った場合、normalized が正規化して送った場合。
並べてみると、壊れ方が2種類あることが見えてきます。
/projects/alpha は 34 件から 20 件へ、41% 目減りします。差の 14 件は alpha_archive と alpha2 で、私の意図が暗黙だった箇所です。便利な挙動だと思っていたものは、便利なのではなく、単に私が範囲を決めきっていなかっただけなのかもしれません。一方 /projects/beta は substring も normalized も 16 件で、件数だけ見れば無傷。ところが as-written は 0 です。末尾スラッシュを忘れたまま本番へ出せば、無傷どころか一件も返りません。
lost が 0 の行こそ危ない 、というのが数えてみて分かったことでした。差分の出ない箇所は「影響なし」と判断され、手が入らないまま残るためです。
もうひとつ、出力の2行目。木を歩くノードが 15 個あるため、depth=1 の再帰では list 呼び出しが 1 回から 16 回、つまり 16 倍に増えます。毎朝1度の棚卸しなら誤差の範囲ですが、分単位で回すなら効いてきます。私は浅い段だけを毎回歩き、深い段は上の段の件数が動いた時にだけ辿る形にしております。なおこの 16 という見積もりには誤りがありました。次節で実際に走らせて訂正します。
実測 — 15回の再帰は何秒になり、素朴な並列化はどこで詰まるか
前節で「16 倍に増える」と書きましたが、倍率だけでは判断できません。1 回あたりが 5 ms なら 16 倍でも 80 ms です。手元で測ることにしました。
memories.list と同じ形の応答を返す最小のサーバをローカルに立て、1 呼び出しあたり 45 ms の遅延を注入します。木は前節の監査出力とまったく同じ形(メモリ 68 件・prefix ノード 15)に揃えました。Python 3.10.12、3 回実行の中央値です。
走らせてまず出てきたのは、自分の監査スクリプトの誤りでした。
prefix nodes : 15 -> depth=1 recursion needs 16 list calls
実際の走査 : list calls = 15
prefix_nodes() はルートの /projects/ をすでに集合に含めています。そこへ n + 1 としたため、ルートを二重に数えていました。1 回の差ですが、見積もりの根拠が間違っていたことに変わりはありません。前節の「16 回」は 15 回が正しい値です。
そのうえで、逐次の depth-first 再帰にかかった時間がこちらです。
逐次 depth-first items=68 698.6 ms
15 回 × 45 ms = 675 ms。ほぼ理論値どおりで、時間のほとんどは待っているだけの往復です。ならば並列化すれば縮むはずだと考え、ThreadPoolExecutor を渡して再帰の中で ex.map を呼ぶ形にしました。ここで詰まりました。
プール内で再帰 workers=2 5秒経っても返らない(デッドロック)
プール内で再帰 workers=4 5秒経っても返らない(デッドロック)
プール内で再帰 workers=8 完了 items=68 286.0 ms
親のタスクがワーカーを1つ掴んだまま、子の完了を待ちます。ワーカーが 4 つなら、ルート 1 つとプロジェクト 3 つで埋まり、4 つ目のプロジェクトが永久に順番待ちになります。エラーは出ません。ただ返ってこないだけです。workers=8 で通ったのは、木の形がたまたま収まっただけで、プロジェクトが 1 本増えれば同じように止まります。
再帰と共有プールの組み合わせが原因ですから、再帰をやめて段ごとに広げる形へ変えました。
from concurrent.futures import ThreadPoolExecutor
def fetch_level (store_id: str , prefix: str ) -> list[ dict ]:
"""prefix 直下の 1 段だけを取る。ページングは全て消化して返す。"""
items, cursor = [], None
while True :
page = client.beta.memory_stores.memories.list(
memory_store_id = store_id, path_prefix = prefix,
depth = 1 , view = "basic" , after_id = cursor, betas = BETAS ,
)
items += [{ "path" : i.path, "type" : i.type} for i in page.data]
if not getattr (page, "has_more" , False ):
return items
cursor = page.last_id
def list_level_parallel (store_id: str , prefix: str = "/" , workers: int = 8 ) -> list[ str ]:
"""木を段ごとに下る。プールの中で再帰しないため、ワーカー数に関わらず詰まらない。"""
out: list[ str ] = []
frontier = [normalize_prefix(prefix)]
with ThreadPoolExecutor( max_workers = workers) as ex:
while frontier:
next_level: list[ str ] = []
for page in ex.map( lambda p: fetch_level(store_id, p), frontier):
for item in page:
if item[ "type" ] == "memory_prefix" :
next_level.append(item[ "path" ])
else :
out.append(item[ "path" ])
frontier = next_level
return sorted (out)
同じ木で測り直した結果です。
ワーカーを 8 から 16 へ倍にしても、147 ms 台から動きません。この木は /projects/ → プロジェクト → サブディレクトリの 3 段で、段ごとに待つ以上 3 × 45 ms = 135 ms より速くはなりません。実測の 147.8 ms は、その下限のすぐ上にいます。
つまり並列化で効くのは木の幅 であって、深さ ではありません。棚卸しの所要時間は、メモリの件数ではなく木の段数でおおよそ決まります。プロジェクト名を第1セグメントに置き、その下を 2 段に収めていた設計は、意図せずこの下限を低く保っていたことになります。深い階層を切りたくなった時に、コストの形として思い出せる指標です。
45 ms という値は私が注入したもので、実際の API の応答時間ではありません。ここで見たかったのは絶対値ではなく、倍率の付き方と天井の位置です。ご自身の環境で判断される際は、1 呼び出しの実測値を測ってから、この表の形に当てはめていただければと思います。
呼び出し箇所は grep では拾いきれません
ここまでで、何がどれだけ変わるかは数えられるようになりました。残るのは「どこを直すか」です。私はこの段で一度、手を抜いて痛い目を見ております。
最初は grep で済ませようとしました。末尾スラッシュの無い path_prefix を探すだけなら、正規表現ひとつで足りると思ったのです。
grep -rn 'path_prefix="[^"]*[^/]"' app
3 ファイル・5 呼び出しの手元の木に掛けて、これが拾ったのは 1 件だけでした。
app/daily_audit.py:6: memory_store_id=store_id, path_prefix="/projects/alpha", view="basic")
取りこぼしたのは、文字列リテラルで書かれていない呼び出しです。path_prefix=f"/projects/{name}" は f-string ですから、上の正規表現の "[^"]*[^/]" には掛かりません。辞書から引いて渡している箇所も同じです。運用が長くなるほど、呼び出しは固定文字列から変数渡しへ寄っていきます。つまり grep が拾える箇所ほど、直しやすくて危険が少ない という、探す側にとって都合の悪い偏りがあるのです。
構文木で見れば、この偏りは消えます。Python の ast で .list(...) の呼び出しを歩き、path_prefix の実引数が「固定文字列」「f-string」「それ以外」のどれかで分類しました。
"""prefix_callsites.py — memories.list の path_prefix を ast で列挙し、
末尾スラッシュを補えていない呼び出しを洗い出す。"""
import ast, pathlib, sys
def prefix_arg (call):
for kw in call.keywords:
if kw.arg == "path_prefix" :
return kw.value
return None
def literal_of (node):
"""定数なら文字列、f-string なら固定の末尾を返す。判定不能は None。"""
if isinstance (node, ast.Constant) and isinstance (node.value, str ):
return node.value, "literal"
if isinstance (node, ast.JoinedStr):
tail = node.values[ - 1 ]
if isinstance (tail, ast.Constant) and isinstance (tail.value, str ):
return tail.value, "fstring"
return "" , "fstring" # 末尾が式 = スラッシュは付いていない
return None , "dynamic"
def scan (path):
tree = ast.parse(path.read_text(), filename = str (path))
for node in ast.walk(tree):
if not isinstance (node, ast.Call):
continue
if not ( isinstance (node.func, ast.Attribute) and node.func.attr == "list" ):
continue
arg = prefix_arg(node)
if arg is None :
continue
value, kind = literal_of(arg)
if kind == "dynamic" :
verdict = "REVIEW" # 静的には決まらない。人が見る
elif value.endswith( "/" ):
verdict = "ok"
else :
verdict = "MISSING /"
yield path.name, node.lineno, kind, verdict
if __name__ == "__main__" :
rows = [r for f in sorted (pathlib.Path(sys.argv[ 1 ]).rglob( "*.py" )) for r in scan(f)]
print ( f " { 'file' :<18 }{ 'line' :>5 } { 'kind' :<8 }{ 'verdict' } " )
for f, ln, kind, verdict in rows:
print ( f " { f :<18 }{ ln :>5 } { kind :<8 }{ verdict } " )
bad = [r for r in rows if r[ 3 ] != "ok" ]
print ( f " \n call sites: { len (rows) } needs attention: { len (bad) } " )
同じ 3 ファイルに掛けた結果です。Python 3.10.12 で実行しました。
file line kind verdict
daily_audit.py 5 literal MISSING /
daily_audit.py 9 fstring MISSING /
daily_audit.py 13 fstring ok
exporter.py 6 dynamic REVIEW
exporter.py 10 literal ok
call sites: 5 needs attention: 3
grep が 1 件、構文木が 3 件。差の 2 件はどちらも f-string と辞書引きで、私が実際に本番へ書いていた形でした。いま思えば、grep で済ませようとした時点で、探しやすい場所だけを探していたのだと思います。
REVIEW の行だけは補足が要ります。exporter.py の 6 行目は辞書から値を引いて渡しており、その辞書の中身はたまたま両方とも末尾スラッシュ付きで、壊れてはおりません。静的には決まらない、というだけです。ここを MISSING と断じないのは、直す必要のない箇所まで赤くすると、次の週には誰もこの出力を見なくなるからです。機械に断定させるのは決められる範囲だけにとどめ、決められないものは決められないと出しております。 この線引きは、他のチェックスクリプトを書くときにも守るようにしております。
ひとつ細かい点を。line はメソッド呼び出しが始まる行で、grep が返す行番号——実引数が書かれている行——とは 1 行ずれます。上の例でも構文木は 5 行目、grep は 6 行目を指しています。両者の出力を機械で突き合わせようとして一度混乱しましたので、書き添えておきます。
状況別の指針
移行にあたって迷いやすい判断を、状況別に整理しておきます。
最後にひとつ。ベータヘッダーを上げた直後は、件数の総量を1度スナップショットしておくことをお勧めします。私が半減に気づけたのは、たまたま前日の件数を手元に残していたからでした。音を立てずに変わるものは、こちらから数えに行くしか捕まえようがないのだと感じています。
メモリの仕様変更は、長期運用ほど効いてきます。今回の path_prefix の件も、木構造を素直に設計していた運用ほど深く刺さりました。設計思想の背景をもう少し掘りたい方は、Claude エージェントのメモリ運用でつまずいた点と対処 も合わせてご覧いただければと思います。
私自身、まだ手探りの部分が多い領域です。同じように件数の変化に戸惑った方の、確認の手がかりになれば嬉しく思います。お読みいただき、ありがとうございました。