feat(my-deepagent): v0.3 PR #7 — MYDEEPAGENT.md global+project hierarchy
Claude Code 의 CLAUDE.md 글로벌/프로젝트 레이어링 등가. 세션 시작 시 두
파일을 자동 로드해 시스템 프롬프트에 inject:
- Global: <config.data_dir>/MYDEEPAGENT.md (템플릿 자동 생성, idempotent)
- Project: <repo>/MYDEEPAGENT.md (있을 때만 로드, auto-create 안 함)
순서는 [global → project → MEMORY.md → entry .md] 라서 후순위 파일이
deepagents `MemoryMiddleware`의 "later overrides earlier" 규칙에 따라
더 구체적인 맥락으로 일반 지침을 덮을 수 있음.
데이터·라이브러리:
- `instructions.py` (신규):
- `global_instructions_path(config)`, `project_instructions_path(repo_root)`
- `ensure_global_instructions_initialized(config)` — 글로벌 템플릿 1회 생성.
Korean-default 협업·코드 스타일 가이드 시드. Idempotent (사용자 편집 보존).
- `resolve_instruction_paths(config, repo_root)` — 존재하는 파일만 절대 경로로
글로벌 → 프로젝트 순서 반환.
REPL 통합 (`cli/interactive.py`):
- `InteractiveSession.__init__`에서 `ensure_global_instructions_initialized`
호출.
- `build_agent_if_needed`에서 `[*instructions, *memory]` 순서로
`memory_paths_override` 구성 → deepagents memory= kwarg 까지 전파.
테스트 (`tests/integration/test_instructions.py`, 6 케이스):
- 글로벌 부트스트랩 + idempotency (수동 편집 보존)
- 프로젝트 파일은 auto-create 안 함
- 0/1/2 개 존재 시 `resolve_instruction_paths` 반환 순서 검증
- global path 가 data_dir 아래에 위치
- **integration**: `build_agent`가 결합 리스트를 `create_deep_agent(memory=...)`
로 그대로 전달
게이트:
- ruff check / format --check / mypy: PASS
- pytest -q --ignore=tests/integration/test_e2e_workflow.py
--ignore=tests/integration/test_openrouter_smoke.py: 671 passed (6 신규 포함)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,35 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **v0.3 PR #7 — MYDEEPAGENT.md instruction-file hierarchy**. Claude Code 의
|
||||||
|
CLAUDE.md 글로벌/프로젝트 레이어링 등가. 세션 시작 시 다음 두 파일을 자동
|
||||||
|
로드해 시스템 프롬프트에 함께 inject:
|
||||||
|
- **Global** : `<config.data_dir>/MYDEEPAGENT.md` — 부팅 시 템플릿 자동 생성
|
||||||
|
- **Project** : `<repo>/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
|
### Added
|
||||||
- **v0.3 PR #6 — Sub-agent session linkage (`/agents` / `/spawn <persona>`)**.
|
- **v0.3 PR #6 — Sub-agent session linkage (`/agents` / `/spawn <persona>`)**.
|
||||||
Claude Code의 sub-agent (task tool) 와 별개로, my-deepagent 만의 **persisted**
|
Claude Code의 sub-agent (task tool) 와 별개로, my-deepagent 만의 **persisted**
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from ..budget import make_budget_tracker_from_config
|
|||||||
from ..compaction import compact_session, should_compact
|
from ..compaction import compact_session, should_compact
|
||||||
from ..config import Config, load_config
|
from ..config import Config, load_config
|
||||||
from ..governance import require_consent
|
from ..governance import require_consent
|
||||||
|
from ..instructions import ensure_global_instructions_initialized, resolve_instruction_paths
|
||||||
from ..memory import (
|
from ..memory import (
|
||||||
add_memory_entry,
|
add_memory_entry,
|
||||||
ensure_memory_initialized,
|
ensure_memory_initialized,
|
||||||
@@ -167,6 +168,9 @@ class InteractiveSession:
|
|||||||
# the same repo across sessions hits the same memory.
|
# the same repo across sessions hits the same memory.
|
||||||
self.memory_dir: Path = project_memory_dir(config, project_key)
|
self.memory_dir: Path = project_memory_dir(config, project_key)
|
||||||
ensure_memory_initialized(self.memory_dir)
|
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 —
|
# v0.3 PR #4: user-scope skills directory bootstrap. Empty is normal —
|
||||||
# users drop `<name>/SKILL.md` directories under here to register skills.
|
# users drop `<name>/SKILL.md` directories under here to register skills.
|
||||||
self.skills_dir: Path = user_skills_dir(config)
|
self.skills_dir: Path = user_skills_dir(config)
|
||||||
@@ -263,6 +267,10 @@ class InteractiveSession:
|
|||||||
plan_mw = PlanModeMiddleware(is_active=lambda: self._plan_mode)
|
plan_mw = PlanModeMiddleware(is_active=lambda: self._plan_mode)
|
||||||
# Re-glob memory paths every time the agent is rebuilt — `/remember` and
|
# Re-glob memory paths every time the agent is rebuilt — `/remember` and
|
||||||
# `/forget` call `clear_agent_cache()` so this picks up new/removed files.
|
# `/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)
|
memory_paths = list_memory_paths(self.memory_dir)
|
||||||
skill_sources = resolve_skill_sources(self.config)
|
skill_sources = resolve_skill_sources(self.config)
|
||||||
self._agent = build_agent(
|
self._agent = build_agent(
|
||||||
@@ -272,7 +280,7 @@ class InteractiveSession:
|
|||||||
middleware=[plan_mw, cost_mw, audit_mw],
|
middleware=[plan_mw, cost_mw, audit_mw],
|
||||||
model_override=self._model_override,
|
model_override=self._model_override,
|
||||||
checkpointer=self.saver,
|
checkpointer=self.saver,
|
||||||
memory_paths_override=memory_paths,
|
memory_paths_override=[*instruction_paths, *memory_paths],
|
||||||
skills_sources_override=skill_sources,
|
skills_sources_override=skill_sources,
|
||||||
)
|
)
|
||||||
return self._agent
|
return self._agent
|
||||||
|
|||||||
98
my-deepagent/src/my_deepagent/instructions.py
Normal file
98
my-deepagent/src/my_deepagent/instructions.py
Normal file
@@ -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** : ``<config.data_dir>/MYDEEPAGENT.md``
|
||||||
|
User-wide preferences that apply to every project. Bootstrapped with a
|
||||||
|
template on first session if missing.
|
||||||
|
|
||||||
|
- **Project** : ``<repo>/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 ``<data_dir>``.
|
||||||
|
"""
|
||||||
|
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
|
||||||
161
my-deepagent/tests/integration/test_instructions.py
Normal file
161
my-deepagent/tests/integration/test_instructions.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user