feat(my-deepagent): v0.3 PR #3 — auto-memory (project-scoped MEMORY.md + /remember/forget)
Claude Code의 auto-memory + `/remember`/`/forget` 슬래시 등가. 사전 검증 false-positive 였던 deepagents `memory=` kwarg 동작을 확정 (실제로 `MemoryMiddleware` 가 sources 리스트를 매 ainvoke 마다 backend 로 download 해서 system prompt 에 `<agent_memory>` 블록 으로 inject). 핵심 동작: - 세션 시작 시 `<config.data_dir>/projects/<project_key>/memory/` 디렉터리 부트스트랩 + `MEMORY.md` (index) 자동 생성 (idempotent). `project_key` = `sha256(realpath(repo_path))[:16]` 라서 같은 repo 는 세션 간 동일 memory. - 매 agent 재빌드 시 `list_memory_paths(memory_dir)`로 현재 `*.md` 목록을 다시 읽어 deepagents `memory=` kwarg 로 전달. index 파일이 항상 첫 번째 → ToC 역할. - `/remember <text>`: `<slug>.md` 파일 생성 + index 에 pointer 한 줄 append + `clear_agent_cache()` 로 다음 턴에 새 파일 반영. - `/forget <slug>`: 파일 삭제 + index 라인 prune + cache flush. - `/memory`: 현재 디렉터리의 entry 목록 표시. 데이터·라이브러리: - `memory.py` (신규): `project_memory_dir` / `ensure_memory_initialized` / `list_memory_paths` / `add_memory_entry` (슬러그 충돌 시 `-2`/`-3` suffix) / `remove_memory_entry` (index 자체는 삭제 거부) / `memory_entries_summary` / `_slugify`. - `session.py`: `build_agent(..., memory_paths_override=...)` 신규 kwarg. `persona.memory_files`와 합쳐 deepagents `memory=` 로 전달 (empty 이면 kwarg 자체 생략). `_resolve_memory_paths` 헬퍼 추출 (C901 회피). - `cli/interactive.py`: `InteractiveSession` 시그니처에 `project_key: str` 추가. `_register_memory_slash` 신규. 테스트 (`tests/integration/test_memory.py`, 22 케이스): - Bootstrap idempotency - add/remove 정상/실패 (slug 충돌, 없는 항목, index 보호, 빈 입력 거부) - list 순서 (index 우선), 누락된 디렉터리 처리 - project_key 격리, empty key 거부 - `_slugify` 영문/유니코드 fallback/max_len - **integration**: `build_agent(..., memory_paths_override=...)`가 실제로 `create_deep_agent(memory=...)` 까지 전달되는지 monkeypatch 로 검증. Plan §사전검증 #5 false-positive 해소. 게이트: - ruff check / format --check / mypy: PASS - pytest -q --ignore=tests/integration/test_e2e_workflow.py --ignore=tests/integration/test_openrouter_smoke.py: 633 passed (22 신규 포함) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,47 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **v0.3 PR #3 — auto-memory (project-scoped `MEMORY.md` + entry files)**.
|
||||
Claude Code의 auto-memory + `/remember`/`/forget` 슬래시 등가. 세션이 시작될
|
||||
때 `<config.data_dir>/projects/<project_key>/memory/` 디렉터리를 부트스트랩
|
||||
(idempotent) 하고, 그 안의 모든 `*.md` 파일을 deepagents `memory=` kwarg로
|
||||
전달. 같은 repo 경로(= 같은 `project_key`)는 세션 간 동일 memory를 본다.
|
||||
- `memory.py` (신규):
|
||||
- `project_memory_dir(config, project_key)` — `<data_dir>/projects/<key>/memory`
|
||||
- `ensure_memory_initialized(dir)` — `MEMORY.md` (index) 생성, idempotent
|
||||
- `list_memory_paths(dir)` — 모든 `*.md` 정렬, index 파일 맨 앞 배치
|
||||
(deepagents가 순서대로 concat하므로 index가 시스템 프롬프트의 ToC 역할)
|
||||
- `add_memory_entry(dir, content, name=...)` — `<slug>.md` 작성 + index에
|
||||
pointer 한 줄 append. 슬러그 충돌 시 `-2`, `-3` suffix. 빈 콘텐츠 거부.
|
||||
- `remove_memory_entry(dir, slug_or_filename)` — 파일 삭제 + index 라인 prune.
|
||||
`MEMORY.md` 자체는 삭제 거부.
|
||||
- `memory_entries_summary(dir)` — `[(name, char_count), ...]` index 제외.
|
||||
- `session.py`:
|
||||
- `build_agent(..., memory_paths_override: list[str] | None = None)` 신규
|
||||
kwarg. `persona.memory_files`와 합쳐 deepagents `memory=` kwarg로 전달
|
||||
(없으면 kwarg 자체를 생략 → `MemoryMiddleware` 미생성).
|
||||
- 복잡도 제어를 위해 `_resolve_memory_paths` 헬퍼 추출 (C901 회피).
|
||||
- `cli/interactive.py`:
|
||||
- `InteractiveSession(...)` 시그니처에 `project_key: str` 추가, `__init__`
|
||||
에서 `project_memory_dir(...)` + `ensure_memory_initialized(...)` 호출.
|
||||
- `build_agent_if_needed`가 매 재빌드 시 `list_memory_paths(memory_dir)`로
|
||||
현재 디렉터리 상태를 다시 읽어 deepagents에 전달. `/remember`/`/forget`이
|
||||
`clear_agent_cache()`를 호출하면 다음 턴에 새 파일 목록 반영.
|
||||
- `_register_memory_slash`: `/remember <text>`, `/forget <slug>`, `/memory`
|
||||
슬래시 등록. `/memory`는 현재 저장된 항목 목록 표시.
|
||||
- `tests/integration/test_memory.py` (신규, 22 케이스):
|
||||
- Bootstrap idempotency, index 자동 생성
|
||||
- add_memory_entry: 파일·index 동시 작성, 충돌 처리, 빈 입력 거부, name override
|
||||
- remove_memory_entry: slug/filename 매칭, 없는 항목, index 자체 보호
|
||||
- list_memory_paths: index 우선, 누락된 디렉터리는 빈 리스트
|
||||
- memory_entries_summary: index 제외, 누락된 디렉터리
|
||||
- project_memory_dir: project_key 격리, empty key 거부
|
||||
- _slugify: 영문, 유니코드 fallback, max_len 잘라내기
|
||||
- **integration**: `build_agent(..., memory_paths_override=[...])`가 실제로
|
||||
`create_deep_agent(memory=...)` 까지 전달되는지 monkeypatch로 검증
|
||||
(false-positive 였던 plan §사전검증 #5 해소)
|
||||
|
||||
### Added
|
||||
- **v0.3 PR #2 — context compaction (auto + manual `/compact`)**.
|
||||
Claude Code의 auto-compact + `/compact` 슬래시 등가. 세션 누적 토큰이
|
||||
|
||||
@@ -36,6 +36,14 @@ from ..budget import make_budget_tracker_from_config
|
||||
from ..compaction import compact_session, should_compact
|
||||
from ..config import Config, load_config
|
||||
from ..governance import require_consent
|
||||
from ..memory import (
|
||||
add_memory_entry,
|
||||
ensure_memory_initialized,
|
||||
list_memory_paths,
|
||||
memory_entries_summary,
|
||||
project_memory_dir,
|
||||
remove_memory_entry,
|
||||
)
|
||||
from ..middleware.audit import AuditToolMiddleware
|
||||
from ..middleware.cost import CostMiddleware
|
||||
from ..monitoring.pricing import ModelPrice, PricingCache
|
||||
@@ -129,6 +137,7 @@ class InteractiveSession:
|
||||
repo_root: Path,
|
||||
session_id: UUID,
|
||||
saver: Any,
|
||||
project_key: str,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.personas = personas
|
||||
@@ -137,12 +146,18 @@ class InteractiveSession:
|
||||
self.repo_root = repo_root
|
||||
self.session_id = session_id
|
||||
self.saver = saver
|
||||
self.project_key = project_key
|
||||
self._model_override: str | None = None
|
||||
self._persona = self._default_persona()
|
||||
self._agent: Any | None = None
|
||||
# thread_suffix bumps on /model and compaction; LangGraph thread_id =
|
||||
# f"{session_id}:{suffix}" so model switches start fresh deepagents state.
|
||||
self._thread_suffix: int = 0
|
||||
# v0.3 PR #3: per-project memory dir bootstrap. Idempotent so resumes
|
||||
# are cheap. Path is determined entirely by config + project_key —
|
||||
# the same repo across sessions hits the same memory.
|
||||
self.memory_dir: Path = project_memory_dir(config, project_key)
|
||||
ensure_memory_initialized(self.memory_dir)
|
||||
|
||||
@property
|
||||
def thread_id(self) -> str:
|
||||
@@ -205,6 +220,9 @@ class InteractiveSession:
|
||||
interactive_session_id=self.session_id,
|
||||
file_recorder=make_audit_recorder(self.config.state_dir),
|
||||
)
|
||||
# Re-glob memory paths every time the agent is rebuilt — `/remember` and
|
||||
# `/forget` call `clear_agent_cache()` so this picks up new/removed files.
|
||||
memory_paths = list_memory_paths(self.memory_dir)
|
||||
self._agent = build_agent(
|
||||
self._persona,
|
||||
self.config,
|
||||
@@ -212,6 +230,7 @@ class InteractiveSession:
|
||||
middleware=[cost_mw, audit_mw],
|
||||
model_override=self._model_override,
|
||||
checkpointer=self.saver,
|
||||
memory_paths_override=memory_paths,
|
||||
)
|
||||
return self._agent
|
||||
|
||||
@@ -482,11 +501,62 @@ def _register_compaction_slash(reg: SlashRegistry, sess: InteractiveSession) ->
|
||||
reg.register("compact", _compact, help="manually compact the conversation history")
|
||||
|
||||
|
||||
def _register_memory_slash(reg: SlashRegistry, sess: InteractiveSession) -> None:
|
||||
"""Register /remember, /forget, /memory slash handlers (v0.3 PR #3)."""
|
||||
|
||||
async def _remember(cmd: SlashParsed) -> bool:
|
||||
# /remember <text> — strip the leading "remember" word from raw to
|
||||
# preserve original whitespace inside the entry body.
|
||||
text = cmd.raw[len("remember") :].strip() if cmd.raw.lower().startswith("remember") else ""
|
||||
if not text:
|
||||
_CONSOLE.print(
|
||||
"[yellow]usage:[/] /remember <text> — saves a memory file for this project."
|
||||
)
|
||||
return False
|
||||
try:
|
||||
path = add_memory_entry(sess.memory_dir, text)
|
||||
except ValueError as e:
|
||||
_CONSOLE.print(f"[red]{e}[/]")
|
||||
return False
|
||||
# Force rebuild on next turn so MemoryMiddleware picks up the new file.
|
||||
sess.clear_agent_cache()
|
||||
_CONSOLE.print(f"[green]remembered →[/] {path.name} (new thread, memory reloaded)")
|
||||
return False
|
||||
|
||||
async def _forget(cmd: SlashParsed) -> bool:
|
||||
if not cmd.args:
|
||||
_CONSOLE.print("[yellow]usage:[/] /forget <slug> — remove a memory file.")
|
||||
return False
|
||||
slug = cmd.args[0]
|
||||
removed = remove_memory_entry(sess.memory_dir, slug)
|
||||
if not removed:
|
||||
_CONSOLE.print(f"[yellow]no memory file found for:[/] {slug}")
|
||||
return False
|
||||
sess.clear_agent_cache()
|
||||
_CONSOLE.print(f"[green]forgotten →[/] {slug} (new thread, memory reloaded)")
|
||||
return False
|
||||
|
||||
async def _memory(_: SlashParsed) -> bool:
|
||||
entries = memory_entries_summary(sess.memory_dir)
|
||||
_CONSOLE.print(f"[bold]project memory[/] ({sess.memory_dir})")
|
||||
if not entries:
|
||||
_CONSOLE.print(" [dim](no entries — try /remember <text>)[/]")
|
||||
return False
|
||||
for name, size in entries:
|
||||
_CONSOLE.print(f" - {name} [dim]({size} chars)[/]")
|
||||
return False
|
||||
|
||||
reg.register("remember", _remember, help="save a memory: /remember <text>")
|
||||
reg.register("forget", _forget, help="remove a memory: /forget <slug>")
|
||||
reg.register("memory", _memory, help="list memory entries for this project")
|
||||
|
||||
|
||||
def _register_slash(reg: SlashRegistry, sess: InteractiveSession) -> None:
|
||||
_register_navigation_slash(reg, sess)
|
||||
_register_persona_slash(reg, sess)
|
||||
_register_telemetry_slash(reg)
|
||||
_register_compaction_slash(reg, sess)
|
||||
_register_memory_slash(reg, sess)
|
||||
|
||||
|
||||
def _completer(personas: list[Persona], slash_names: list[str]) -> WordCompleter:
|
||||
@@ -667,10 +737,25 @@ async def _interactive_loop_async(
|
||||
session_id = uuid4()
|
||||
creating = True
|
||||
|
||||
# Per-project memory uses a sha256-hash of the realpath — same as the
|
||||
# session row's project_key column, computed once here and reused.
|
||||
from ..hash import sha256
|
||||
|
||||
project_key = sha256(str(Path.cwd().resolve()))[:16]
|
||||
|
||||
try:
|
||||
async with get_checkpointer_ctx(config.database_url) as saver:
|
||||
# Resolve initial persona (may be overridden below).
|
||||
sess = InteractiveSession(config, personas, db, pricing, Path.cwd(), session_id, saver)
|
||||
sess = InteractiveSession(
|
||||
config,
|
||||
personas,
|
||||
db,
|
||||
pricing,
|
||||
Path.cwd(),
|
||||
session_id,
|
||||
saver,
|
||||
project_key,
|
||||
)
|
||||
if persona_override:
|
||||
try:
|
||||
sess.set_persona(persona_override)
|
||||
|
||||
187
my-deepagent/src/my_deepagent/memory.py
Normal file
187
my-deepagent/src/my_deepagent/memory.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""Auto-memory (v0.3 PR #3) — project-scoped persistent context.
|
||||
|
||||
Layout::
|
||||
|
||||
<config.data_dir>/projects/<project_key>/memory/
|
||||
MEMORY.md # index — one line per entry: "- [Title](file.md) — hook"
|
||||
<slug>.md # individual memory entries (with optional frontmatter)
|
||||
|
||||
The deepagents `MemoryMiddleware` reads every path we pass via the `memory=`
|
||||
kwarg of `create_deep_agent` and injects them (concatenated) into the system
|
||||
prompt under an `<agent_memory>` block. The middleware re-reads files on
|
||||
every turn, so updates take effect on the next user message — no agent
|
||||
rebuild required.
|
||||
|
||||
`/remember <text>` appends a new entry file and updates the index. `/forget
|
||||
<slug>` deletes the entry file and prunes the index. Both are project-scoped
|
||||
(via `project_key`) so different repos keep separate memory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Config
|
||||
|
||||
#: Filename of the index file inside each project memory dir.
|
||||
INDEX_FILENAME = "MEMORY.md"
|
||||
|
||||
#: Slug character set — kept conservative for filesystem portability.
|
||||
_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
|
||||
|
||||
#: Initial index body when bootstrapping a fresh memory directory.
|
||||
_INITIAL_INDEX = """# Auto-memory
|
||||
|
||||
This file is an index of stored memories for this project. Each entry below
|
||||
points to a sibling `*.md` file. Entries are auto-managed by `/remember` and
|
||||
`/forget` slash commands — edit by hand if you need finer control.
|
||||
|
||||
## Entries
|
||||
"""
|
||||
|
||||
|
||||
def project_memory_dir(config: Config, project_key: str) -> Path:
|
||||
"""Return the absolute directory path for this project's memory."""
|
||||
if not project_key:
|
||||
raise ValueError("project_key must be non-empty")
|
||||
return Path(config.data_dir) / "projects" / project_key / "memory"
|
||||
|
||||
|
||||
def ensure_memory_initialized(memory_dir: Path) -> Path:
|
||||
"""Create the memory directory + initial MEMORY.md if missing.
|
||||
|
||||
Idempotent — repeated calls are no-ops once initialised. Returns the
|
||||
path to the index file so callers can pass it to deepagents.
|
||||
"""
|
||||
memory_dir.mkdir(parents=True, exist_ok=True)
|
||||
index_path = memory_dir / INDEX_FILENAME
|
||||
if not index_path.exists():
|
||||
index_path.write_text(_INITIAL_INDEX, encoding="utf-8")
|
||||
return index_path
|
||||
|
||||
|
||||
def list_memory_paths(memory_dir: Path) -> list[str]:
|
||||
"""Return absolute paths of all `*.md` files in the directory, sorted.
|
||||
|
||||
The index file appears first (deepagents concatenates in order, so the
|
||||
index serves as a table of contents inside the system prompt). Returns
|
||||
an empty list if the directory does not exist.
|
||||
"""
|
||||
if not memory_dir.is_dir():
|
||||
return []
|
||||
paths = sorted(p for p in memory_dir.glob("*.md") if p.is_file())
|
||||
# Move the index to the front if present.
|
||||
index_path = memory_dir / INDEX_FILENAME
|
||||
if index_path in paths:
|
||||
paths.remove(index_path)
|
||||
paths.insert(0, index_path)
|
||||
return [str(p.resolve()) for p in paths]
|
||||
|
||||
|
||||
def _slugify(text: str, *, max_len: int = 40) -> str:
|
||||
"""Lowercase + replace non-[a-z0-9_-] with `-`, trim to ``max_len``.
|
||||
|
||||
Empty result → fallback "entry". Used by `/remember` for auto-named
|
||||
files when the user does not pass an explicit slug.
|
||||
"""
|
||||
slug = _SLUG_RE.sub("-", text.lower().strip()).strip("-")
|
||||
slug = slug[:max_len].rstrip("-")
|
||||
return slug or "entry"
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def add_memory_entry(
|
||||
memory_dir: Path,
|
||||
content: str,
|
||||
*,
|
||||
name: str | None = None,
|
||||
) -> Path:
|
||||
"""Write a new memory file + append pointer to the index.
|
||||
|
||||
- ``name`` (optional): explicit slug. If omitted, derived from the first
|
||||
line of ``content`` via :func:`_slugify`.
|
||||
- File names collide → ``-2``, ``-3``, … suffix is appended until unique.
|
||||
|
||||
Returns the absolute path to the newly written file. Raises
|
||||
``ValueError`` for empty content.
|
||||
"""
|
||||
if not content or not content.strip():
|
||||
raise ValueError("memory content must be non-empty")
|
||||
|
||||
ensure_memory_initialized(memory_dir)
|
||||
|
||||
first_line = content.strip().splitlines()[0]
|
||||
slug_base = _slugify(name or first_line)
|
||||
candidate = memory_dir / f"{slug_base}.md"
|
||||
n = 2
|
||||
while candidate.exists():
|
||||
candidate = memory_dir / f"{slug_base}-{n}.md"
|
||||
n += 1
|
||||
|
||||
# File body: short frontmatter + content. The frontmatter is informational
|
||||
# for human readers; the deepagents middleware does not parse it.
|
||||
body = f"---\nslug: {candidate.stem}\ncreated: {_now_iso()}\n---\n\n{content.strip()}\n"
|
||||
candidate.write_text(body, encoding="utf-8")
|
||||
|
||||
# Append a one-line pointer to the index — first line of content is the
|
||||
# title, truncated to keep the index scannable.
|
||||
title = first_line.strip().lstrip("# ").strip()[:80] or candidate.stem
|
||||
pointer = f"- [{title}]({candidate.name}) — {_now_iso()}\n"
|
||||
index_path = memory_dir / INDEX_FILENAME
|
||||
with index_path.open("a", encoding="utf-8") as f:
|
||||
f.write(pointer)
|
||||
|
||||
return candidate
|
||||
|
||||
|
||||
def remove_memory_entry(memory_dir: Path, slug_or_filename: str) -> bool:
|
||||
"""Delete a memory entry by slug or filename. Returns True if removed.
|
||||
|
||||
Matching is exact against ``<slug>.md`` or ``<slug>`` (the extension is
|
||||
optional). Also prunes any matching pointer line from the index.
|
||||
"""
|
||||
if not slug_or_filename:
|
||||
return False
|
||||
|
||||
target_name = slug_or_filename if slug_or_filename.endswith(".md") else f"{slug_or_filename}.md"
|
||||
target_path = memory_dir / target_name
|
||||
if not target_path.is_file():
|
||||
return False
|
||||
if target_path.name == INDEX_FILENAME:
|
||||
# Refuse to delete the index itself — it's regenerated on next session.
|
||||
return False
|
||||
target_path.unlink()
|
||||
|
||||
# Prune pointer lines that mention this filename.
|
||||
index_path = memory_dir / INDEX_FILENAME
|
||||
if index_path.is_file():
|
||||
original = index_path.read_text(encoding="utf-8")
|
||||
pruned_lines = [
|
||||
line for line in original.splitlines(keepends=True) if f"({target_name})" not in line
|
||||
]
|
||||
index_path.write_text("".join(pruned_lines), encoding="utf-8")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def memory_entries_summary(memory_dir: Path) -> list[tuple[str, int]]:
|
||||
"""Return ``[(filename, char_count), ...]`` excluding the index, sorted by name.
|
||||
|
||||
Used by `/memory` slash to show the user what's currently stored.
|
||||
"""
|
||||
if not memory_dir.is_dir():
|
||||
return []
|
||||
out: list[tuple[str, int]] = []
|
||||
for p in sorted(memory_dir.glob("*.md")):
|
||||
if p.name == INDEX_FILENAME:
|
||||
continue
|
||||
try:
|
||||
out.append((p.name, len(p.read_text(encoding="utf-8"))))
|
||||
except OSError:
|
||||
continue
|
||||
return out
|
||||
@@ -205,6 +205,7 @@ def build_agent(
|
||||
run_id: UUID | None = None,
|
||||
phase_key: str | None = None,
|
||||
model_override: str | None = None,
|
||||
memory_paths_override: list[str] | None = None,
|
||||
) -> Any:
|
||||
"""Construct a deepagents CompiledStateGraph for the given persona.
|
||||
|
||||
@@ -269,7 +270,23 @@ def build_agent(
|
||||
kwargs["checkpointer"] = checkpointer
|
||||
if persona.skills:
|
||||
kwargs["skills"] = list(persona.skills)
|
||||
if persona.memory_files:
|
||||
kwargs["memory"] = list(persona.memory_files)
|
||||
memory_paths = _resolve_memory_paths(persona, memory_paths_override)
|
||||
if memory_paths:
|
||||
kwargs["memory"] = memory_paths
|
||||
|
||||
return create_deep_agent(**kwargs)
|
||||
|
||||
|
||||
def _resolve_memory_paths(persona: Persona, override: list[str] | None) -> list[str]:
|
||||
"""Combine persona-defined memory files with caller-supplied per-session paths.
|
||||
|
||||
v0.3 PR #3 — InteractiveSession passes project-scoped MEMORY.md + entries via
|
||||
``memory_paths_override``. Order matters: persona files first (static role
|
||||
context), then session files (dynamic per-project remembered facts) — later
|
||||
sources override earlier ones at the same source path
|
||||
(``deepagents.MemoryMiddleware``).
|
||||
"""
|
||||
combined: list[str] = list(persona.memory_files)
|
||||
if override:
|
||||
combined.extend(override)
|
||||
return combined
|
||||
|
||||
314
my-deepagent/tests/integration/test_memory.py
Normal file
314
my-deepagent/tests/integration/test_memory.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""v0.3 PR #3 — Auto-memory tests.
|
||||
|
||||
Covers:
|
||||
1. Bootstrap creates an empty MEMORY.md index on first call (idempotent).
|
||||
2. `add_memory_entry` writes a `<slug>.md` file + appends an index pointer line.
|
||||
3. Auto-slug derivation + collision handling (`-2`, `-3`, … suffix).
|
||||
4. `remove_memory_entry` deletes file + prunes index line (refuses to delete
|
||||
the index itself).
|
||||
5. `list_memory_paths` puts MEMORY.md first (deepagents reads in order, so the
|
||||
index becomes the table-of-contents header in the system prompt).
|
||||
6. `memory_entries_summary` skips the index, returns (name, size) pairs.
|
||||
7. ``project_memory_dir`` rejects empty project_key.
|
||||
8. Different `project_key`s isolate memory between repos.
|
||||
9. End-to-end via deepagents MemoryMiddleware: `build_agent(..., memory_paths_override=[...])`
|
||||
constructs an agent whose middleware stack contains a MemoryMiddleware with
|
||||
the right sources.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from my_deepagent.config import load_config
|
||||
from my_deepagent.memory import (
|
||||
INDEX_FILENAME,
|
||||
_slugify,
|
||||
add_memory_entry,
|
||||
ensure_memory_initialized,
|
||||
list_memory_paths,
|
||||
memory_entries_summary,
|
||||
project_memory_dir,
|
||||
remove_memory_entry,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory_dir(tmp_path: Path) -> Path:
|
||||
return tmp_path / "memory"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bootstrap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ensure_memory_initialized_creates_index(memory_dir: Path) -> None:
|
||||
assert not memory_dir.exists()
|
||||
index = ensure_memory_initialized(memory_dir)
|
||||
assert index.is_file()
|
||||
assert index.name == INDEX_FILENAME
|
||||
body = index.read_text(encoding="utf-8")
|
||||
assert "Auto-memory" in body
|
||||
assert "## Entries" in body
|
||||
|
||||
|
||||
def test_ensure_memory_initialized_is_idempotent(memory_dir: Path) -> None:
|
||||
ensure_memory_initialized(memory_dir)
|
||||
index = memory_dir / INDEX_FILENAME
|
||||
original_content = index.read_text(encoding="utf-8")
|
||||
# Re-running should not overwrite an existing index.
|
||||
ensure_memory_initialized(memory_dir)
|
||||
assert index.read_text(encoding="utf-8") == original_content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# add_memory_entry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_add_memory_entry_writes_file_and_updates_index(memory_dir: Path) -> None:
|
||||
path = add_memory_entry(memory_dir, "프로젝트 핵심: 위크닥 CLI MVP")
|
||||
assert path.is_file()
|
||||
body = path.read_text(encoding="utf-8")
|
||||
assert "프로젝트 핵심" in body
|
||||
assert body.startswith("---\nslug: ")
|
||||
|
||||
index = (memory_dir / INDEX_FILENAME).read_text(encoding="utf-8")
|
||||
assert path.name in index
|
||||
assert "프로젝트 핵심" in index
|
||||
|
||||
|
||||
def test_add_memory_entry_handles_slug_collision(memory_dir: Path) -> None:
|
||||
p1 = add_memory_entry(memory_dir, "Same first line")
|
||||
p2 = add_memory_entry(memory_dir, "Same first line\nsecond entry body")
|
||||
p3 = add_memory_entry(memory_dir, "Same first line\nthird entry body")
|
||||
assert p1.name != p2.name != p3.name
|
||||
# Auto-slugging should land on <slug>-2.md and <slug>-3.md.
|
||||
stems = sorted([p1.stem, p2.stem, p3.stem])
|
||||
assert stems[0] == "same-first-line"
|
||||
assert stems[1] == "same-first-line-2"
|
||||
assert stems[2] == "same-first-line-3"
|
||||
|
||||
|
||||
def test_add_memory_entry_rejects_empty_content(memory_dir: Path) -> None:
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
add_memory_entry(memory_dir, " \n \t ")
|
||||
|
||||
|
||||
def test_add_memory_entry_explicit_name_override(memory_dir: Path) -> None:
|
||||
p = add_memory_entry(memory_dir, "Random body text", name="My Custom Slug!!")
|
||||
assert p.stem == "my-custom-slug"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# remove_memory_entry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_remove_memory_entry_by_slug(memory_dir: Path) -> None:
|
||||
p = add_memory_entry(memory_dir, "to be forgotten")
|
||||
assert remove_memory_entry(memory_dir, p.stem) is True
|
||||
assert not p.exists()
|
||||
index_body = (memory_dir / INDEX_FILENAME).read_text(encoding="utf-8")
|
||||
assert p.name not in index_body
|
||||
|
||||
|
||||
def test_remove_memory_entry_by_filename(memory_dir: Path) -> None:
|
||||
p = add_memory_entry(memory_dir, "to be forgotten by full filename")
|
||||
assert remove_memory_entry(memory_dir, p.name) is True
|
||||
assert not p.exists()
|
||||
|
||||
|
||||
def test_remove_memory_entry_missing_returns_false(memory_dir: Path) -> None:
|
||||
ensure_memory_initialized(memory_dir)
|
||||
assert remove_memory_entry(memory_dir, "no-such-slug") is False
|
||||
|
||||
|
||||
def test_remove_memory_entry_refuses_to_delete_index(memory_dir: Path) -> None:
|
||||
ensure_memory_initialized(memory_dir)
|
||||
assert remove_memory_entry(memory_dir, INDEX_FILENAME) is False
|
||||
assert (memory_dir / INDEX_FILENAME).is_file()
|
||||
|
||||
|
||||
def test_remove_memory_entry_empty_input(memory_dir: Path) -> None:
|
||||
assert remove_memory_entry(memory_dir, "") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_memory_paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_memory_paths_puts_index_first(memory_dir: Path) -> None:
|
||||
ensure_memory_initialized(memory_dir)
|
||||
add_memory_entry(memory_dir, "alpha entry")
|
||||
add_memory_entry(memory_dir, "beta entry")
|
||||
paths = list_memory_paths(memory_dir)
|
||||
assert len(paths) == 3
|
||||
assert Path(paths[0]).name == INDEX_FILENAME
|
||||
# Remaining ordered lexicographically.
|
||||
rest = [Path(p).name for p in paths[1:]]
|
||||
assert rest == sorted(rest)
|
||||
|
||||
|
||||
def test_list_memory_paths_missing_dir_returns_empty(tmp_path: Path) -> None:
|
||||
assert list_memory_paths(tmp_path / "no-such-dir") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# memory_entries_summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_memory_entries_summary_skips_index(memory_dir: Path) -> None:
|
||||
ensure_memory_initialized(memory_dir)
|
||||
add_memory_entry(memory_dir, "first")
|
||||
add_memory_entry(memory_dir, "second")
|
||||
summary = memory_entries_summary(memory_dir)
|
||||
names = [n for (n, _sz) in summary]
|
||||
assert INDEX_FILENAME not in names
|
||||
assert len(summary) == 2
|
||||
for _name, size in summary:
|
||||
assert size > 0
|
||||
|
||||
|
||||
def test_memory_entries_summary_missing_dir(tmp_path: Path) -> None:
|
||||
assert memory_entries_summary(tmp_path / "no-such") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# project_memory_dir
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_project_memory_dir_uses_project_key(tmp_path: Path) -> None:
|
||||
cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data")
|
||||
d1 = project_memory_dir(cfg, "abcdef0123456789")
|
||||
d2 = project_memory_dir(cfg, "fedcba9876543210")
|
||||
assert d1 != d2
|
||||
assert d1.parent.name == "abcdef0123456789"
|
||||
assert d2.parent.name == "fedcba9876543210"
|
||||
|
||||
|
||||
def test_project_memory_dir_rejects_empty_key(tmp_path: Path) -> None:
|
||||
cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data")
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
project_memory_dir(cfg, "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slugify
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_slugify_basic() -> None:
|
||||
assert _slugify("Hello World!") == "hello-world"
|
||||
|
||||
|
||||
def test_slugify_unicode_falls_back() -> None:
|
||||
# Korean text becomes purely separator characters → fallback "entry".
|
||||
assert _slugify("프로젝트 설정") == "entry"
|
||||
|
||||
|
||||
def test_slugify_truncates_to_max_len() -> None:
|
||||
long = "a" * 100
|
||||
s = _slugify(long, max_len=20)
|
||||
assert len(s) <= 20
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: build_agent threads memory paths through to deepagents
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_agent_passes_memory_paths_to_deepagents(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Ensure `build_agent(..., memory_paths_override=[...])` causes
|
||||
`create_deep_agent` to receive the combined memory list.
|
||||
"""
|
||||
from my_deepagent import session as session_mod
|
||||
from my_deepagent.persona import Persona
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_create_deep_agent(**kwargs: Any) -> Any:
|
||||
captured.update(kwargs)
|
||||
return object() # opaque sentinel
|
||||
|
||||
monkeypatch.setattr(session_mod, "create_deep_agent", fake_create_deep_agent)
|
||||
|
||||
# Persona doubles as MemoryMiddleware source when populated; here we leave
|
||||
# persona.memory_files empty and rely entirely on the override.
|
||||
persona = Persona(
|
||||
name="test-persona",
|
||||
version=1,
|
||||
backend="openrouter",
|
||||
model="openrouter:deepseek/deepseek-chat",
|
||||
provider_origin="CN/DeepSeek",
|
||||
capabilities=("code_edit",),
|
||||
max_risk_level="high",
|
||||
system_prompt="System prompt for test persona (must be ≥10 chars)",
|
||||
deepagents_backend="state",
|
||||
)
|
||||
cfg = load_config(
|
||||
workspace_root=tmp_path,
|
||||
data_dir=tmp_path / "data",
|
||||
openrouter_api_key="test-key",
|
||||
)
|
||||
|
||||
# Build memory paths under tmp_path so the test is hermetic.
|
||||
memdir = tmp_path / "mem"
|
||||
ensure_memory_initialized(memdir)
|
||||
add_memory_entry(memdir, "do not commit secrets")
|
||||
paths = list_memory_paths(memdir)
|
||||
|
||||
_agent = session_mod.build_agent(
|
||||
persona,
|
||||
cfg,
|
||||
root_dir=tmp_path,
|
||||
memory_paths_override=paths,
|
||||
)
|
||||
assert "memory" in captured
|
||||
assert captured["memory"] == paths
|
||||
|
||||
|
||||
def test_build_agent_omits_memory_kwarg_when_no_paths(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Empty memory list → no `memory=` kwarg passed to deepagents (so the
|
||||
MemoryMiddleware is not constructed)."""
|
||||
from my_deepagent import session as session_mod
|
||||
from my_deepagent.persona import Persona
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_create_deep_agent(**kwargs: Any) -> Any:
|
||||
captured.update(kwargs)
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(session_mod, "create_deep_agent", fake_create_deep_agent)
|
||||
|
||||
persona = Persona(
|
||||
name="test-persona",
|
||||
version=1,
|
||||
backend="openrouter",
|
||||
model="openrouter:deepseek/deepseek-chat",
|
||||
provider_origin="CN/DeepSeek",
|
||||
capabilities=("code_edit",),
|
||||
max_risk_level="high",
|
||||
system_prompt="System prompt for test persona (must be ≥10 chars)",
|
||||
deepagents_backend="state",
|
||||
)
|
||||
cfg = load_config(
|
||||
workspace_root=tmp_path,
|
||||
data_dir=tmp_path / "data",
|
||||
openrouter_api_key="test-key",
|
||||
)
|
||||
|
||||
_agent = session_mod.build_agent(persona, cfg, root_dir=tmp_path)
|
||||
assert "memory" not in captured
|
||||
Reference in New Issue
Block a user