diff --git a/my-deepagent/CHANGELOG.md b/my-deepagent/CHANGELOG.md index c3ebee8..c51288a 100644 --- a/my-deepagent/CHANGELOG.md +++ b/my-deepagent/CHANGELOG.md @@ -2,6 +2,47 @@ ## [Unreleased] +### Added +- **v0.3 PR #3 — auto-memory (project-scoped `MEMORY.md` + entry files)**. + Claude Code의 auto-memory + `/remember`/`/forget` 슬래시 등가. 세션이 시작될 + 때 `/projects//memory/` 디렉터리를 부트스트랩 + (idempotent) 하고, 그 안의 모든 `*.md` 파일을 deepagents `memory=` kwarg로 + 전달. 같은 repo 경로(= 같은 `project_key`)는 세션 간 동일 memory를 본다. + - `memory.py` (신규): + - `project_memory_dir(config, project_key)` — `/projects//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=...)` — `.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 `, `/forget `, `/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` 슬래시 등가. 세션 누적 토큰이 diff --git a/my-deepagent/src/my_deepagent/cli/interactive.py b/my-deepagent/src/my_deepagent/cli/interactive.py index c6eb482..232a180 100644 --- a/my-deepagent/src/my_deepagent/cli/interactive.py +++ b/my-deepagent/src/my_deepagent/cli/interactive.py @@ -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 — 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 — 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 — 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 )[/]") + 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 ") + reg.register("forget", _forget, help="remove a memory: /forget ") + 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) diff --git a/my-deepagent/src/my_deepagent/memory.py b/my-deepagent/src/my_deepagent/memory.py new file mode 100644 index 0000000..1e955ca --- /dev/null +++ b/my-deepagent/src/my_deepagent/memory.py @@ -0,0 +1,187 @@ +"""Auto-memory (v0.3 PR #3) — project-scoped persistent context. + +Layout:: + + /projects//memory/ + MEMORY.md # index — one line per entry: "- [Title](file.md) — hook" + .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 `` block. The middleware re-reads files on +every turn, so updates take effect on the next user message — no agent +rebuild required. + +`/remember ` appends a new entry file and updates the index. `/forget +` 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 ``.md`` or ```` (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 diff --git a/my-deepagent/src/my_deepagent/session.py b/my-deepagent/src/my_deepagent/session.py index fc16eed..73bfbee 100644 --- a/my-deepagent/src/my_deepagent/session.py +++ b/my-deepagent/src/my_deepagent/session.py @@ -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 diff --git a/my-deepagent/tests/integration/test_memory.py b/my-deepagent/tests/integration/test_memory.py new file mode 100644 index 0000000..2e7234e --- /dev/null +++ b/my-deepagent/tests/integration/test_memory.py @@ -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 `.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 -2.md and -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