mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
dc0a51fc81
Protect the machine-managed onboarding-name block in the user `AGENTS.md` from being overwritten by agent file edits. --- The onboarding flow writes the user's preferred name into the user `AGENTS.md` inside a block delimited by `deepagents:onboarding-name` HTML-comment markers. `MemoryMiddleware` strips HTML comments before injecting memory, so the model never sees those markers — yet the same prompt tells it to `edit_file` that file to persist learnings, leaving nothing to stop it from rewriting the managed block. This adds `ManagedMemoryGuardMiddleware`, a deterministic `wrap_tool_call` guard wired into the agent's memory stack. When `write_file`/`edit_file` targets the guarded user `AGENTS.md` and changes (or drops) the managed block, the model's other edits are kept but the block is restored to its prior content via the existing `_upsert_onboarding_name_memory`, and an error is returned so the model learns the region is machine-managed. Edits that leave the block untouched, reads, and writes to other files pass through unchanged. <details> <summary>Scenario where this applies</summary> A user has completed onboarding, so their preferred name is stored in the machine-managed block inside their user `AGENTS.md`. Later, the agent tries to persist a new memory by calling `edit_file` on that file. `ManagedMemoryGuardMiddleware` intercepts the tool call to ensure the onboarding-name block is not changed. If the agent's edit touches or removes that block, the middleware preserves the agent's other edits, restores the onboarding-name block to its previous content, and returns an error telling the agent not to modify the machine-managed region. </details> Made by [Open SWE](https://openswe.vercel.app) --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
224 lines
6.9 KiB
Python
224 lines
6.9 KiB
Python
"""First-run onboarding state for the interactive TUI."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from typing import TYPE_CHECKING
|
|
|
|
from deepagents_code._env_vars import DEBUG_ONBOARDING, is_env_truthy
|
|
from deepagents_code.model_config import DEFAULT_STATE_DIR
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
ONBOARDING_MARKER_FILENAME = "onboarding_complete"
|
|
"""Marker filename under `~/.deepagents/.state` after onboarding has completed."""
|
|
|
|
ONBOARDING_NAME_MEMORY_START = "<!-- deepagents:onboarding-name:start -->"
|
|
"""Start marker for the managed onboarding name memory block."""
|
|
|
|
ONBOARDING_NAME_MEMORY_END = "<!-- deepagents:onboarding-name:end -->"
|
|
"""End marker for the managed onboarding name memory block."""
|
|
|
|
|
|
def onboarding_marker_path(state_dir: Path | None = None) -> Path:
|
|
"""Return the first-run onboarding marker path.
|
|
|
|
Args:
|
|
state_dir: Optional state directory override for tests.
|
|
|
|
Returns:
|
|
Path to the onboarding completion marker.
|
|
"""
|
|
return (state_dir or DEFAULT_STATE_DIR) / ONBOARDING_MARKER_FILENAME
|
|
|
|
|
|
def has_completed_onboarding(state_dir: Path | None = None) -> bool:
|
|
"""Return whether the user has completed onboarding.
|
|
|
|
Args:
|
|
state_dir: Optional state directory override for tests.
|
|
|
|
Returns:
|
|
`True` when the onboarding marker exists, otherwise `False`.
|
|
"""
|
|
try:
|
|
return onboarding_marker_path(state_dir).exists()
|
|
except OSError:
|
|
logger.warning("Could not inspect onboarding marker", exc_info=True)
|
|
return False
|
|
|
|
|
|
def mark_onboarding_complete(state_dir: Path | None = None) -> bool:
|
|
"""Persist that onboarding has completed.
|
|
|
|
Args:
|
|
state_dir: Optional state directory override for tests.
|
|
|
|
Returns:
|
|
`True` when the marker was written, otherwise `False`.
|
|
"""
|
|
path = onboarding_marker_path(state_dir)
|
|
try:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text("1\n", encoding="utf-8")
|
|
except OSError:
|
|
logger.warning("Could not write onboarding marker at %s", path, exc_info=True)
|
|
return False
|
|
return True
|
|
|
|
|
|
def write_onboarding_name_memory(
|
|
name: str,
|
|
assistant_id: str,
|
|
*,
|
|
memory_path: Path | None = None,
|
|
) -> bool:
|
|
"""Persist the optional onboarding name into user agent memory.
|
|
|
|
Empty or whitespace-only names are skipped (no file is written).
|
|
|
|
Args:
|
|
name: Submitted user name.
|
|
assistant_id: Agent identifier whose user memory should be updated.
|
|
memory_path: Optional memory file override for tests.
|
|
|
|
Returns:
|
|
`True` when memory was written, otherwise `False`.
|
|
"""
|
|
clean = _normalize_memory_name(name)
|
|
if not clean:
|
|
return False
|
|
|
|
if memory_path is None:
|
|
from deepagents_code.config import settings
|
|
|
|
path = settings.get_user_agent_md_path(assistant_id)
|
|
else:
|
|
path = memory_path
|
|
|
|
block = _onboarding_name_memory_block(clean)
|
|
try:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
existing = path.read_text(encoding="utf-8")
|
|
except FileNotFoundError:
|
|
existing = ""
|
|
except UnicodeDecodeError:
|
|
# Existing memory file is not valid UTF-8. Overwriting would clobber
|
|
# whatever the user has there, so abort and let them resolve it.
|
|
logger.warning(
|
|
"Existing memory file %s is not valid UTF-8; skipping onboarding "
|
|
"name memory write to avoid clobbering user content",
|
|
path,
|
|
exc_info=True,
|
|
)
|
|
return False
|
|
path.write_text(
|
|
_upsert_onboarding_name_memory(existing, block),
|
|
encoding="utf-8",
|
|
)
|
|
except OSError:
|
|
logger.warning(
|
|
"Could not write onboarding name memory at %s",
|
|
path,
|
|
exc_info=True,
|
|
)
|
|
return False
|
|
return True
|
|
|
|
|
|
def _normalize_memory_name(name: str) -> str:
|
|
"""Normalize whitespace in a name before writing it to memory.
|
|
|
|
Returns:
|
|
Name with leading/trailing whitespace stripped and internal runs
|
|
collapsed to single spaces.
|
|
"""
|
|
return " ".join(name.split())
|
|
|
|
|
|
def _onboarding_name_memory_block(name: str) -> str:
|
|
"""Return the managed memory block for an onboarding name."""
|
|
quoted = json.dumps(name)
|
|
return (
|
|
f"{ONBOARDING_NAME_MEMORY_START}\n"
|
|
f"- The user's preferred name is {quoted}.\n"
|
|
f"{ONBOARDING_NAME_MEMORY_END}"
|
|
)
|
|
|
|
|
|
def _upsert_onboarding_name_memory(existing: str, block: str) -> str:
|
|
"""Insert or replace the managed onboarding name memory block.
|
|
|
|
Returns:
|
|
Updated memory file content.
|
|
"""
|
|
start = existing.find(ONBOARDING_NAME_MEMORY_START)
|
|
end = existing.find(ONBOARDING_NAME_MEMORY_END)
|
|
if start != -1 and end != -1 and start < end:
|
|
end += len(ONBOARDING_NAME_MEMORY_END)
|
|
prefix = existing[:start].rstrip()
|
|
suffix = existing[end:].strip()
|
|
parts = [part for part in (prefix, block, suffix) if part]
|
|
return "\n\n".join(parts).rstrip() + "\n"
|
|
|
|
base = existing.rstrip()
|
|
if not base:
|
|
return f"## User Preferences\n\n{block}\n"
|
|
if "## User Preferences" in base:
|
|
return f"{base}\n\n{block}\n"
|
|
return f"{base}\n\n## User Preferences\n\n{block}\n"
|
|
|
|
|
|
def extract_onboarding_name_block(text: str) -> str | None:
|
|
"""Return the managed onboarding name block (markers included) if present.
|
|
|
|
Args:
|
|
text: Memory file content to inspect.
|
|
|
|
Returns:
|
|
The substring from the start marker through the end marker, or `None`
|
|
when a well-formed block is absent.
|
|
"""
|
|
start = text.find(ONBOARDING_NAME_MEMORY_START)
|
|
end = text.find(ONBOARDING_NAME_MEMORY_END)
|
|
if start == -1 or end == -1 or start >= end:
|
|
return None
|
|
return text[start : end + len(ONBOARDING_NAME_MEMORY_END)]
|
|
|
|
|
|
def strip_onboarding_name_markers(text: str) -> str:
|
|
"""Remove every onboarding-name marker occurrence from `text`.
|
|
|
|
A partial edit can leave a lone start or end marker behind. Stripping all
|
|
marker strings before re-inserting the managed block keeps re-insertion from
|
|
producing orphaned markers that would confuse `extract_onboarding_name_block`.
|
|
|
|
Args:
|
|
text: Memory file content to sanitize.
|
|
|
|
Returns:
|
|
`text` with all start and end marker strings removed.
|
|
"""
|
|
return text.replace(ONBOARDING_NAME_MEMORY_START, "").replace(
|
|
ONBOARDING_NAME_MEMORY_END, ""
|
|
)
|
|
|
|
|
|
def should_run_onboarding(state_dir: Path | None = None) -> bool:
|
|
"""Return whether onboarding should open at interactive startup.
|
|
|
|
Args:
|
|
state_dir: Optional state directory override for tests.
|
|
|
|
Returns:
|
|
`True` when the debug override is enabled or no completion marker exists.
|
|
"""
|
|
if is_env_truthy(DEBUG_ONBOARDING):
|
|
return True
|
|
return not has_completed_onboarding(state_dir)
|