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?

deepagentsのファイルシステムバックエンド、混ぜると更新時刻が9時間ズレた

0
Posted at

LangChain の deepagents は、エージェントが読み書きする「ファイルシステム」を差し替え可能なバックエンドとして抽象化しています。同じ write_file / read_file / ls / glob / grep ツールを与えたまま、保存先だけをスレッド内メモリ・実ファイル・永続ストア・LangSmith Context Hub に切り替えられる設計です。

保存先を選べるのは便利ですが、選択肢が 6 つあると「どれを選ぶと後で困るのか」は触ってみるまで分かりません。対象読者は、LangGraph / deepagents でエージェントの永続コンテキストをどこに置くか決めかねている Python 開発者です。

6種のバックエンドを1枚で

筆者が deepagents 0.7.5 を実際にインストールして触った結果を、先に表でまとめます。「単体で動くか」は LangGraph のグラフ実行の外側(素の Python スクリプト)から直接叩けるか を指します。

バックエンド 保存先 単体で動くか 認証情報 主な用途
StateBackend LangGraph の state(スレッド境界) RuntimeError 不要 中間結果のスクラッチパッド
FilesystemBackend root_dir 配下の実ファイル 不要 ローカル開発・CI
LocalShellBackend 実ファイル+ホストでのシェル実行 不要 ローカルのコーディング補助
StoreBackend LangGraph BaseStore(スレッド横断) 不要(InMemoryStore) 実行をまたぐ長期記憶
ContextHubBackend LangSmith Context Hub のリポジトリ ✗ HTTP 400 LANGSMITH_API_KEY バージョン履歴付きの永続化
CompositeBackend パスごとに別バックエンドへ委譲 委譲先に依存 上記の組み合わせ

実測環境は Python 3.11.15・deepagents 0.7.5・langchain 1.3.14・langgraph 1.2.10 で、pip install deepagents の素の依存解決に任せた組み合わせです。LangSmith の API キーは持っていないため、ContextHubBackend は「キーなしで何が起きるか」までを実機で確認し、正常系の挙動は公式ドキュメントの記述として扱います。

deepagents.backends の公開シンボルは実際に次の顔ぶれでした。

>>> import deepagents.backends as B
>>> [n for n in dir(B) if not n.startswith('_')]
['BackendProtocol', 'CompositeBackend', 'ContextHubBackend', 'DEFAULT_EXECUTE_TIMEOUT',
 'FilesystemBackend', 'LangSmithSandbox', 'LocalShellBackend', 'NamespaceFactory',
 'StateBackend', 'StoreBackend', 'composite', 'context_hub', 'filesystem', 'langsmith',
 'local_shell', 'protocol', 'sandbox', 'state', 'store', 'utils']

BackendProtocol が要求するメソッドは同期・非同期の対で 18 個(ls / read / write / edit / delete / glob / grep / upload_files / download_files と、それぞれの a 接頭辞版)です。自前バックエンドを書く場合はこの 18 個が実装範囲になります。

混ぜた瞬間に壊れるのは modified_at だった

CompositeBackend は「デフォルトの保存先」と「パスプレフィックスごとのルート」を宣言するだけで、エージェント側のツール定義を変えずに保存先を混在させられます。ドキュメントどおり、/large_tool_results/ のような内部データはスレッド内の StateBackend に、/memories/ は永続バックエンドに振る、という構成が想定されています。

筆者が FilesystemBackend(デフォルト)と StoreBackend/memories/ ルート)を混ぜて glob を撃ったところ、同一スクリプト内で 135 マイクロ秒差で書き込んだ 2 ファイルの modified_at9 時間ズレて 返ってきました。

comp = CompositeBackend(default=FilesystemBackend(root_dir=fs_root),
                        routes={"/memories/": StoreBackend(namespace=lambda rt: ("u1",),
                                                           store=InMemoryStore())})
comp.write("/report.md", "# title\nTODO: fix later\n")
comp.write("/memories/note.md", "# memo\nTODO: remember\n")
print(comp.glob("**/*.md"))

実際に返ってきた値です。

GlobResult(error=None, matches=[
  {'path': '/memories/note.md', 'is_dir': False, 'size': 22,
   'modified_at': '2026-08-07T07:13:59.400405+00:00'},
  {'path': '/report.md', 'is_dir': False, 'size': 24,
   'modified_at': '2026-08-07T16:13:59.400270'}
], truncated=False)

StoreBackend は UTC のタイムゾーン付き ISO8601(+00:00)を返し、FilesystemBackend はタイムゾーン情報のないローカル時刻(JST)を返しています。実行環境が JST だったため差はちょうど 9 時間で、書き込み順は /report.md が先なのに、文字列としてソートすると /memories/note.md の方が古く見えます。

これは CompositeBackend の集約処理が各バックエンドの返す辞書をそのまま連結する設計だからで、ls / glob の結果を「更新順に並べる」「N 日以内のファイルだけ読む」といった後処理をエージェント側やツール側で書くと、混在構成では静かに壊れます。datetime.fromisoformat() に食わせた場合も、naive と aware の比較で TypeError が出ます。

回避策は単純で、modified_at を跨バックエンドで比較しないことです。どうしても必要なら、読み出した直後に dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt のような正規化を挟むか、そもそも CompositeBackend の全ルートを同種のバックエンドで揃えます。ls('/') が返すルートプレフィックスの疑似ディレクトリは、そもそも modified_at が空文字列でした。

{'path': '/memories/', 'is_dir': True, 'size': 0, 'modified_at': ''}

一方で grep は素直でした。両バックエンドを横断して行番号付きで拾い、パスは元のプレフィックス付きで返ります。

GrepResult(error=None, matches=[
  {'path': '/report.md', 'line': 2, 'text': 'TODO: fix later'},
  {'path': '/memories/note.md', 'line': 2, 'text': 'TODO: remember'}
], truncated=False)

物理的な分離も確認できました。/notes.txt はデフォルト側の実ディレクトリにだけ、/memories/m.txt はルート先の実ディレクトリにだけ現れ、取り違えはありませんでした。

virtual_mode の防御は3通りに分かれる

FilesystemBackend(root_dir=..., virtual_mode=True) は「..~・root 外の絶対パスをブロックする」と説明されています。実際に root 外へ置いた secret.txt を狙って読ませてみると、ブロックの仕方が 3 通りに分かれました

与えたパス virtual_mode=True virtual_mode=False
/inside.txt(root 配下) 読める File '/inside.txt' not found
../outside/secret.txt ValueError: Path traversal not allowed(例外) not found(エラー値)
/tmp/outside-xxxx/secret.txt(root 外の絶対パス) not found(エラー値) error=None で読める
~/.ssh/id_rsa not found not found
/etc/passwd not found error=None で読める

.. を含むパスだけが ValueError例外 で飛び、root 外の絶対パスは例外ではなく「見つからない」という戻り値になります。後者は絶対パスを root 相対として解決し直しているため封じ込めは成立していますが、呼び出し側から見ると「traversal を試みられた」ことがエラー種別として区別できません。ツール実行を監査ログに残す運用なら、例外の捕捉だけでは攻撃の痕跡が半分しか残らない構造です。

virtual_mode=False/etc/passwderror=None で読めました。同時に /inside.txt(root 配下のファイル)は読めなくなります。パスの意味論が「root 相対」からホストの絶対パスに切り替わるためで、片方で書いたコードはもう片方では動きません。「セキュリティのために virtual_mode=True を推奨」というより、両者はパス解決の仕様そのものが別物という理解が正確でした。

ドキュメントには root_dir は絶対パスでなければならないと書かれていますが、FilesystemBackend(root_dir=".") はコンストラクタでも実使用でもエラーになりませんでした。カレントディレクトリ基準で普通に ls が通ります。

[relative root] ls('/') -> LsResult(error=None, entries=[
  {'path': '/t1_filesystem.py', ...}, {'path': '/t2_virtual.py', ...} ...])

エージェントの起動 cwd に依存する挙動が黙って通るということなので、絶対パスで渡す運用ルールは自分側で持つ必要があります。実際のシグネチャは FilesystemBackend(root_dir=None, virtual_mode=True, max_file_size_mb=10) で、ガイドページの説明文には現れない max_file_size_mb(既定 10MB の読み込み上限)も持っています。API リファレンス側には記載があるため、引数の全量を知りたいときはガイドではなくリファレンスか inspect.signature() を見るのが確実でした。

StateBackend はスクリプトから触れない

StateBackend() はインスタンス化はできますが、write / ls を呼ぶと即座に落ちます。

RuntimeError: StateBackend must be used inside a LangGraph graph execution
(e.g. via create_deep_agent). It cannot read or write state outside of a graph context.
To pre-populate files, pass them on invoke: agent.i...

メッセージが「グラフ実行の内側で使え」「事前投入は invoke で渡せ」と対処まで書いてくれるので迷いません。ただし設計上の含意は大きく、StateBackend を前提にしたエージェントのファイル操作は、エージェントを起動せずに単体テストできません。バックエンド層だけを pytest で検証したいなら FilesystemBackend(root_dir=tmp_path)StoreBackend(store=InMemoryStore()) に差し替える前提で設計しておくのが実用的です。

LocalShellBackend は逆に、グラフの外から execute までそのまま通りました。

lsb = LocalShellBackend(root_dir=root, virtual_mode=True)
lsb.execute("echo BACKEND_SHELL_OK")
# ExecuteResponse(output='BACKEND_SHELL_OK\n', exit_code=0, truncated=False)

シグネチャは LocalShellBackend(root_dir=None, *, virtual_mode=True, timeout=120, max_output_bytes=100000, env=None, inherit_env=False) で、inherit_env の既定が False です。呼び出し元のシェルの環境変数(API キーを含む)がそのままエージェントのコマンドへ渡らない側に倒れているので、既定のまま使うのが安全でした。実際に子プロセス側で環境変数を数えると 1 件だけで、PATH は未設定でした。

printenv PATH     -> exit_code=1(出力なし)
printenv | wc -l  -> '1\n'
python3 --version -> 'Python 3.11.15\n'
git --version     -> 'git version 2.43.0\n'

PATH が空でも python3git は解決されます。PATH 未設定時にシェルが既定の探索パスへフォールバックするためで、「環境変数は渡っていないのにコマンドは動く」という状態です。特定バージョンの実行ファイルを使わせたいなら、フォールバックに任せず env={"PATH": "..."} で明示するのが確実でした。

対照的に StoreBackendInMemoryStore を渡せばグラフの外で完結しました。namespaceRuntime を受け取るコーラブルを要求しますが、素のスクリプトから呼んでも write / ls が通ります。

stb = StoreBackend(namespace=lambda rt: ("u1",), store=InMemoryStore())
stb.write("/m.txt", "memo")      # WriteResult(error=None, path='/m.txt')
stb.ls("/")                      # entries=[{'path': '/m.txt', 'size': 4, ...}]

マルチユーザー運用では namespace にユーザー識別子を返させることが必須(既定の共有領域にならないよう明示する)で、LangSmith 上にデプロイする場合は store を省略すればプラットフォーム側が供給する、と公式ドキュメントは説明しています。

なお read の戻り値は content フィールドではなく file_data の辞書に入っていました。ツールの外側で戻り値を直接扱うときに引っかかる点です。

ReadResult(error=None, file_data={'content': 'line1\nline2\n', 'encoding': 'utf-8'},
           total_lines=2, start_line=1, end_line=2, next_offset=None,
           no_lines_requested=False)

ContextHubBackend はキーなしでも初期化が通る

LangSmith Context Hub をバックエンドにする ContextHubBackend は、Context Hub の紹介記事にあるとおり、エージェントのスキル・メモリを Hub のコミットとして保存し、書き込みごとにバージョン履歴を得る仕組みです。deepagents 0.6.0(2026-05-12)で追加されました。

LANGSMITH_API_KEY を設定していない環境で試すと、コンストラクタは成功します。Hub のツリーを遅延ロードする設計のため、失敗するのは最初の I/O のときです。

os.environ.get("LANGSMITH_API_KEY")   # None
chb = ContextHubBackend("my-agent")   # init OK
chb.ls("/")

返ってきたのは例外ではなく、エラーを包んだ LsResult でした。

LsResult(error='Hub unavailable: Failed to GET /v1/platform/hub/repos/-/my-agent/directories
in LangSmith API. HTTPError(\'400 Client Error ...\', \'{"error":"no repo owner specified"}\')')

興味深いのは、返ってきたのが 401/403 ではなく 400 の no repo owner specified だったことです。ContextHubBackend("my-agent") の名前だけ指定した形式が URL 上で repos/-/my-agent に展開され、オーナー欠落としてリクエスト検証で先に弾かれます。つまりキーの有無より先に識別子の形式で落ちるため、認証エラーが見えないまま「Hub unavailable」だけが返る ことになります。owner/name 形式で渡すのが安全です。

また ls は戻り値に包む一方で、内部の langsmith.utils.LangSmithError のスタックトレースは標準エラーに出ました。エージェントのログでは「戻り値はエラーなしの体裁なのに stderr だけ真っ赤」という見え方になり得るので、Hub 経路を採る場合は戻り値の error フィールドを必ず確認する運用が要ります。

束ねて分かったこと

6 種を並べて触った結論として、選定の分岐点は「永続性」ではなく 「グラフの外でテストできるか」と「認証情報を要求するか」 の 2 軸でした。

  • 単体テスト可能・認証不要: FilesystemBackend / StoreBackendInMemoryStore)/ LocalShellBackend
  • グラフ内限定・認証不要: StateBackend
  • 認証必須: ContextHubBackend

エージェントのファイル操作にリグレッションテストを敷きたいなら、実装は BackendProtocol に対して書き、テストでは FilesystemBackend(root_dir=tmp_path) を挿す構成が最も摩擦が小さいです。そして CompositeBackend で異種を混ぜるときは、modified_at の表現がバックエンドごとに違うことを前提に、時刻での並べ替え・絞り込みをバックエンド境界を越えて行わないようにします。9 時間ズレは、実際に混ぜてみるまで気づけませんでした。

検証は Python 3.11.15・deepagents 0.7.5 の組み合わせで行っており、ContextHubBackend の正常系(Hub コミットとしての書き込み・バージョン履歴)と各種サンドボックスバックエンドは未検証です。

関連記事

参考リンク

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?