diff --git a/my-deepagent/CHANGELOG.md b/my-deepagent/CHANGELOG.md index 33c7d7c..4d5eb4f 100644 --- a/my-deepagent/CHANGELOG.md +++ b/my-deepagent/CHANGELOG.md @@ -2,6 +2,35 @@ ## [Unreleased] +### Added +- **v0.3 PR #7 — MYDEEPAGENT.md instruction-file hierarchy**. Claude Code 의 + CLAUDE.md 글로벌/프로젝트 레이어링 등가. 세션 시작 시 다음 두 파일을 자동 + 로드해 시스템 프롬프트에 함께 inject: + - **Global** : `/MYDEEPAGENT.md` — 부팅 시 템플릿 자동 생성 + - **Project** : `/MYDEEPAGENT.md` — 존재할 때만 로드. 사용자 repo + 안에 자동 생성하지 않음 (invasive 행위 회피). + Memory / MEMORY.md / 개별 entry 보다 *먼저* 인젝트되어 deepagents + `MemoryMiddleware` 의 "later overrides earlier" 규칙에 따라 더 구체적인 + 맥락이 일반적인 지침을 덮을 수 있음. + - `instructions.py` (신규): + - `global_instructions_path(config)`, `project_instructions_path(repo_root)` + - `ensure_global_instructions_initialized(config)` — 글로벌 템플릿 1회 + 생성, idempotent. Korean-default 협업·코드 스타일 가이드 시드. + - `resolve_instruction_paths(config, repo_root)` — 존재하는 파일만 절대 + 경로로 글로벌→프로젝트 순서 반환. + - `cli/interactive.py`: + - `InteractiveSession.__init__`에서 `ensure_global_instructions_initialized` + 호출. + - `build_agent_if_needed`에서 `[*instruction_paths, *memory_paths]` 순서로 + memory_paths_override 구성. + - `tests/integration/test_instructions.py` (신규, 6 케이스): + - 글로벌 부트스트랩 + idempotency (수동 편집 보존) + - 프로젝트 파일은 절대 auto-create 안 함 + - 0/1/2 개 존재 시 `resolve_instruction_paths` 반환 순서 검증 + - global path 가 `data_dir` 아래에 위치 + - **integration**: `build_agent`가 결합된 [instructions, memory] 리스트를 + 그대로 `create_deep_agent(memory=...)` 로 전달 + ### Added - **v0.3 PR #6 — Sub-agent session linkage (`/agents` / `/spawn `)**. Claude Code의 sub-agent (task tool) 와 별개로, my-deepagent 만의 **persisted** diff --git a/my-deepagent/src/my_deepagent/cli/interactive.py b/my-deepagent/src/my_deepagent/cli/interactive.py index bd19143..30e6f23 100644 --- a/my-deepagent/src/my_deepagent/cli/interactive.py +++ b/my-deepagent/src/my_deepagent/cli/interactive.py @@ -36,6 +36,7 @@ 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 ..instructions import ensure_global_instructions_initialized, resolve_instruction_paths from ..memory import ( add_memory_entry, ensure_memory_initialized, @@ -167,6 +168,9 @@ class InteractiveSession: # 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) + # v0.3 PR #7: bootstrap global MYDEEPAGENT.md (project file is loaded + # if present but never auto-created — we don't write into the user's repo). + ensure_global_instructions_initialized(config) # v0.3 PR #4: user-scope skills directory bootstrap. Empty is normal — # users drop `/SKILL.md` directories under here to register skills. self.skills_dir: Path = user_skills_dir(config) @@ -263,6 +267,10 @@ class InteractiveSession: plan_mw = PlanModeMiddleware(is_active=lambda: self._plan_mode) # Re-glob memory paths every time the agent is rebuilt — `/remember` and # `/forget` call `clear_agent_cache()` so this picks up new/removed files. + # Order: instruction files (global → project) FIRST, then MEMORY.md + # index, then individual entries. Later files override earlier ones + # at the same path per `deepagents.MemoryMiddleware`. + instruction_paths = resolve_instruction_paths(self.config, self.repo_root) memory_paths = list_memory_paths(self.memory_dir) skill_sources = resolve_skill_sources(self.config) self._agent = build_agent( @@ -272,7 +280,7 @@ class InteractiveSession: middleware=[plan_mw, cost_mw, audit_mw], model_override=self._model_override, checkpointer=self.saver, - memory_paths_override=memory_paths, + memory_paths_override=[*instruction_paths, *memory_paths], skills_sources_override=skill_sources, ) return self._agent diff --git a/my-deepagent/src/my_deepagent/instructions.py b/my-deepagent/src/my_deepagent/instructions.py new file mode 100644 index 0000000..5e4fa67 --- /dev/null +++ b/my-deepagent/src/my_deepagent/instructions.py @@ -0,0 +1,98 @@ +"""MYDEEPAGENT.md instruction-file hierarchy (v0.3 PR #7). + +Two scopes (mirrors Claude Code's CLAUDE.md global/project layering): + +- **Global** : ``/MYDEEPAGENT.md`` + User-wide preferences that apply to every project. Bootstrapped with a + template on first session if missing. + +- **Project** : ``/MYDEEPAGENT.md`` + Repo-specific overrides. Picked up at session start when present; we do NOT + auto-create it (creating a file inside the user's repo would be invasive). + +Both files are passed to ``deepagents.MemoryMiddleware`` via the ``memory=`` +kwarg of ``create_deep_agent`` — same mechanism as auto-memory. Order in the +list: + + [global MYDEEPAGENT.md, project MYDEEPAGENT.md, MEMORY.md, ...entry .md] + +So later files (project + auto-memory) can override earlier ones at the same +filesystem path, matching the standard CLAUDE.md precedence. +""" + +from __future__ import annotations + +from pathlib import Path + +from .config import Config + +#: Filename for both global and project instruction files. +INSTRUCTION_FILENAME = "MYDEEPAGENT.md" + +#: Initial body written to the global file when it does not exist. +_GLOBAL_TEMPLATE = """# MYDEEPAGENT.md (global) + +이 파일은 모든 프로젝트에 공통으로 적용되는 사용자 선호를 정의합니다. +세션 시작 시 시스템 프롬프트에 자동으로 포함되어 모든 대화에 영향을 줍니다. + +프로젝트별 설정이 필요하면 해당 repo 루트에 같은 이름의 `MYDEEPAGENT.md` 파일을 +만들어 주세요 — 자동으로 함께 로드됩니다 (프로젝트가 글로벌을 덮어씁니다). + +## 협업 스타일 +- 한국어로 대화한다. 코드 안은 영어 유지. +- 작업 시작 전 번호 목록 계획을 만든다. +- 변경은 최소 범위로 한다 — 요청한 것만. + +## 코드 스타일 +- 새 파일을 만들기 전 기존 패턴을 먼저 읽는다. +- 주석은 "왜"가 자명하지 않을 때만 짧게 단다. +- TODO/FIXME/pass/NotImplementedError 를 최종 결과물에 남기지 않는다. + +## 잘 검토하기 +- 완료 선언 전에: 모든 항목 구현 / 정적 분석 통과 / 결과물 1회 이상 직접 읽음. +""" + + +def global_instructions_path(config: Config) -> Path: + """Return the absolute path of the global MYDEEPAGENT.md file.""" + return Path(config.data_dir) / INSTRUCTION_FILENAME + + +def project_instructions_path(repo_root: Path) -> Path: + """Return the absolute path of the project MYDEEPAGENT.md file (may not exist).""" + return Path(repo_root) / INSTRUCTION_FILENAME + + +def ensure_global_instructions_initialized(config: Config) -> Path: + """Create the global instructions file with a template if missing. + + Idempotent — repeated calls are no-ops once initialised. Returns the + absolute path. Bootstrap during REPL startup so users see the file the + first time they look in ````. + """ + p = global_instructions_path(config) + p.parent.mkdir(parents=True, exist_ok=True) + if not p.exists(): + p.write_text(_GLOBAL_TEMPLATE, encoding="utf-8") + return p + + +def resolve_instruction_paths(config: Config, repo_root: Path) -> list[str]: + """Return absolute paths to existing MYDEEPAGENT.md files, global-first. + + - Global is bootstrapped (always exists after a session has started) + - Project is included only if the file actually exists in the repo — + we never write into the user's repo automatically. + + The returned list is suitable for ``memory_paths_override`` passed to + :func:`session.build_agent` (the ``deepagents.MemoryMiddleware`` then + concatenates them in order — later files override earlier). + """ + paths: list[str] = [] + g = global_instructions_path(config) + if g.is_file(): + paths.append(str(g.resolve())) + p = project_instructions_path(repo_root) + if p.is_file(): + paths.append(str(p.resolve())) + return paths diff --git a/my-deepagent/tests/integration/test_instructions.py b/my-deepagent/tests/integration/test_instructions.py new file mode 100644 index 0000000..13d142b --- /dev/null +++ b/my-deepagent/tests/integration/test_instructions.py @@ -0,0 +1,161 @@ +"""v0.3 PR #7 — MYDEEPAGENT.md instruction-file hierarchy tests. + +Covers: +1. Global file is bootstrapped with template on first call (idempotent). +2. Project file is NEVER auto-created — present iff user wrote it. +3. `resolve_instruction_paths` orders global → project. +4. Resolution is empty if global hasn't been bootstrapped yet. +5. `build_agent` passes the combined list through to `deepagents.create_deep_agent(memory=...)`. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from my_deepagent.config import load_config +from my_deepagent.instructions import ( + INSTRUCTION_FILENAME, + ensure_global_instructions_initialized, + global_instructions_path, + project_instructions_path, + resolve_instruction_paths, +) + + +# --------------------------------------------------------------------------- +# Bootstrap (global only) +# --------------------------------------------------------------------------- + + +def test_ensure_global_instructions_creates_template(tmp_path: Path) -> None: + cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data") + p = ensure_global_instructions_initialized(cfg) + assert p.is_file() + assert p.name == INSTRUCTION_FILENAME + body = p.read_text(encoding="utf-8") + assert "MYDEEPAGENT.md (global)" in body + assert "한국어" in body # template is Korean by default + + +def test_ensure_global_instructions_idempotent(tmp_path: Path) -> None: + cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data") + p = ensure_global_instructions_initialized(cfg) + p.write_text("custom content", encoding="utf-8") + # Second call must not overwrite user-edited content. + p2 = ensure_global_instructions_initialized(cfg) + assert p2 == p + assert p.read_text(encoding="utf-8") == "custom content" + + +# --------------------------------------------------------------------------- +# Project file behaviour +# --------------------------------------------------------------------------- + + +def test_project_instructions_never_auto_created(tmp_path: Path) -> None: + cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data") + repo = tmp_path / "repo" + repo.mkdir() + # Bootstrap global — must not touch project file. + ensure_global_instructions_initialized(cfg) + assert not project_instructions_path(repo).exists() + + +# --------------------------------------------------------------------------- +# resolve_instruction_paths +# --------------------------------------------------------------------------- + + +def test_resolve_paths_includes_only_existing_files(tmp_path: Path) -> None: + cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data") + repo = tmp_path / "repo" + repo.mkdir() + + # No files exist → empty. + assert resolve_instruction_paths(cfg, repo) == [] + + # Only global. + g = ensure_global_instructions_initialized(cfg) + paths = resolve_instruction_paths(cfg, repo) + assert paths == [str(g.resolve())] + + # Add project — order becomes global, project. + proj_file = project_instructions_path(repo) + proj_file.write_text("# project-specific", encoding="utf-8") + paths = resolve_instruction_paths(cfg, repo) + assert paths == [str(g.resolve()), str(proj_file.resolve())] + + +def test_global_instructions_path_under_data_dir(tmp_path: Path) -> None: + cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data") + p = global_instructions_path(cfg) + assert p.parent == cfg.data_dir + assert p.name == INSTRUCTION_FILENAME + + +# --------------------------------------------------------------------------- +# Integration: instruction paths reach deepagents memory= kwarg +# --------------------------------------------------------------------------- + + +def test_build_agent_receives_combined_instruction_and_memory_paths( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """`build_agent(memory_paths_override=[instructions..., memory...])` passes + the union through to `create_deep_agent(memory=...)`. Mirrors what + InteractiveSession does at REPL bootstrap. + """ + 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) + + cfg = load_config( + workspace_root=tmp_path, + data_dir=tmp_path / "data", + openrouter_api_key="test-key", + ) + repo = tmp_path / "repo" + repo.mkdir() + + g = ensure_global_instructions_initialized(cfg) + proj_file = project_instructions_path(repo) + proj_file.write_text("# project rule", encoding="utf-8") + + # Simulate a project memory entry. + mem_entry = tmp_path / "MEM.md" + mem_entry.write_text("# memory entry", encoding="utf-8") + + 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", + ) + instruction_paths = resolve_instruction_paths(cfg, repo) + combined = [*instruction_paths, str(mem_entry.resolve())] + + _agent = session_mod.build_agent( + persona, + cfg, + root_dir=repo, + memory_paths_override=combined, + ) + assert "memory" in captured + # Global must come before project, project before mem entry — exact list match. + expected = [str(g.resolve()), str(proj_file.resolve()), str(mem_entry.resolve())] + assert captured["memory"] == expected