mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 09:45:24 -04:00
564e5a05bc
`/offload` now stores archived conversation history through the agent's backend. --- `/offload` previously summarized and saved conversation history in the client process. In server and sandbox modes, that process does not own the backend used by the agent: persistence could fail against a read-only filesystem, and even a successfully written archive would not be available to the agent through `read_file`. This changes Deep Agents Code to run the existing `compact_conversation` tool through the active agent instead. The command seeds a tool call into the thread, approves the expected human-in-the-loop interrupt because the user explicitly requested `/offload`, resumes the graph, and reads the persisted summarization event back from server state. The archive is therefore written through the agent's composite backend and remains readable by the agent in local, server, and sandbox runs. The old client-side `perform_offload` helper (and its `OffloadResult` / `OffloadThresholdNotMet` / `OffloadModelError` result types) is removed, as summarization and persistence now run through the agent. In local mode, the `conversation_history` backend now roots under `~/.deepagents` (via `_offload_fallback_root`, with a hardened private-temp fallback when the home directory is not writable) instead of a throwaway `tempfile.mkdtemp` directory, so offloaded history persists across sessions. The SDK's `compact_conversation` API is unchanged; this PR is confined to Deep Agents Code. Made by [Open SWE](https://openswe.vercel.app/agents/1cbc308e-b411-2a09-bd6b-541e606ca4c5) --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
147 lines
6.2 KiB
Python
147 lines
6.2 KiB
Python
"""Storage paths for offloaded conversation history."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import stat
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_EPHEMERAL_OFFLOAD_STORAGE = False
|
|
"""Whether the most recent `_offload_fallback_root` fell back to temp storage."""
|
|
|
|
|
|
def offload_storage_is_ephemeral() -> bool:
|
|
"""Return whether offload history is routed to non-persistent storage.
|
|
|
|
`True` when the persistent `~/.deepagents` location was unwritable and the
|
|
most recent `_offload_fallback_root` fell back to a temporary directory that
|
|
may not survive a restart. Only meaningful in local mode, where
|
|
`_offload_fallback_root` runs in the same process as the UI; in
|
|
server/sandbox mode persistence is owned by the server backend and this flag
|
|
stays `False` client-side.
|
|
|
|
Returns:
|
|
`True` if the local offload root is a temporary, non-persistent
|
|
directory; `False` when it is the persistent per-user location (or was
|
|
never resolved in this process).
|
|
"""
|
|
return _EPHEMERAL_OFFLOAD_STORAGE
|
|
|
|
|
|
def _offload_fallback_root() -> Path:
|
|
"""Return a writable base directory for offloaded conversation history.
|
|
|
|
Prefers the persistent per-user `~/.deepagents` directory so offloaded
|
|
history survives across sessions and is easy to locate; falls back to a
|
|
private temporary directory when the home directory cannot be resolved or
|
|
written. This is the live root for the local-mode `conversation_history`
|
|
backend in `agent.py`.
|
|
|
|
Archives always live in the `conversation_history` subdirectory of the
|
|
returned root. The `0o700` hardening therefore targets that subdirectory,
|
|
never the shared `~/.deepagents` config root -- which also houses
|
|
`config.toml`, `hooks.json`, `.env`, and `.state/`, whose permissions this
|
|
must not disturb. A temporary fallback root is created solely for offload,
|
|
so the whole directory is hardened in that case.
|
|
|
|
Note: the `S_ISDIR` check below (which uses `lstat`, deliberately not
|
|
following the link) guards the paths it is applied to -- the
|
|
`conversation_history` subdirectory and, in the fallback case, the temp
|
|
root -- not `~/.deepagents` itself, which is created with a plain `mkdir`.
|
|
So a `conversation_history` (or temp root) that is itself a symlink is
|
|
rejected, whereas a symlinked `~/.deepagents` pointing at a directory the
|
|
current user owns is followed transparently and archives persist normally.
|
|
(A dangling `~/.deepagents` symlink still falls through to temporary
|
|
storage, but via `mkdir` raising, not via this check.)
|
|
|
|
Returns:
|
|
A directory whose `conversation_history` subdirectory is private and
|
|
writable.
|
|
"""
|
|
|
|
def _harden(path: Path) -> None:
|
|
"""Create `path` if needed and restrict it to the current user.
|
|
|
|
Only ever called on directories owned by offload (a fresh temp dir or
|
|
the dedicated `conversation_history` subdirectory), never on the shared
|
|
`~/.deepagents` config root.
|
|
|
|
Raises:
|
|
OSError: If the path exists but is not a directory, or the
|
|
directory cannot be created or its mode changed (e.g. a
|
|
read-only mount).
|
|
PermissionError: If the existing directory is owned by another
|
|
local user.
|
|
"""
|
|
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
info = path.lstat()
|
|
if not stat.S_ISDIR(info.st_mode):
|
|
msg = f"Offload path is not a directory: {path}"
|
|
raise OSError(msg)
|
|
getuid = getattr(os, "getuid", None)
|
|
if getuid is not None and info.st_uid != getuid():
|
|
msg = f"Offload directory is owned by another user: {path}"
|
|
raise PermissionError(msg)
|
|
# `mkdir(mode=...)` does not tighten an existing directory. Archives
|
|
# contain conversation data, so the offload directory must remain
|
|
# inaccessible to other local accounts regardless of the process umask.
|
|
path.chmod(0o700)
|
|
|
|
def _probe_writable(path: Path) -> None:
|
|
# Creating the directory is insufficient when it already exists on a
|
|
# read-only mount. A temporary file proves archive writes can succeed.
|
|
with tempfile.NamedTemporaryFile(dir=path, prefix=".write-test-"):
|
|
pass
|
|
|
|
def _prepare_user_dir() -> Path:
|
|
base = Path.home() / ".deepagents"
|
|
# Ensure the shared config root exists and is usable, but leave its
|
|
# permissions untouched -- hardening belongs on the archive subdir only.
|
|
base.mkdir(parents=True, exist_ok=True)
|
|
archive_dir = base / "conversation_history"
|
|
_harden(archive_dir)
|
|
_probe_writable(archive_dir)
|
|
return base
|
|
|
|
def _prepare_temp_dir(path: Path) -> Path:
|
|
# A temp dir is created solely for offload and is not shared config, so
|
|
# hardening the whole directory (which protects its archive subdir) is
|
|
# both safe and necessary in world-writable temp locations.
|
|
_harden(path)
|
|
_probe_writable(path)
|
|
return path
|
|
|
|
global _EPHEMERAL_OFFLOAD_STORAGE # noqa: PLW0603
|
|
try:
|
|
root = _prepare_user_dir()
|
|
except (RuntimeError, OSError):
|
|
logger.warning(
|
|
"User data directory is not writable; falling back to temporary "
|
|
"offload storage, which may not persist across restarts",
|
|
exc_info=True,
|
|
)
|
|
else:
|
|
_EPHEMERAL_OFFLOAD_STORAGE = False
|
|
return root
|
|
# Only reached on the fallback path: every root produced below is temporary
|
|
# and may not survive a restart.
|
|
_EPHEMERAL_OFFLOAD_STORAGE = True
|
|
getuid = getattr(os, "getuid", None)
|
|
suffix = str(getuid()) if getuid is not None else str(os.getpid())
|
|
temp_root = Path(tempfile.gettempdir())
|
|
path = temp_root / f"deepagents-{suffix}"
|
|
try:
|
|
return _prepare_temp_dir(path)
|
|
except (OSError, RuntimeError):
|
|
logger.warning(
|
|
"Per-user temporary offload directory is unavailable; creating "
|
|
"a private unique directory",
|
|
exc_info=True,
|
|
)
|
|
unique = Path(tempfile.mkdtemp(prefix=f"deepagents-{suffix}-", dir=temp_root))
|
|
return _prepare_temp_dir(unique)
|