18 KiB
44062a63 — BaseAgentAdapter 아키텍처 설계 Rev.2
Job: 44062a63 · Role: Planner · Supersedes: 744ac67a (Rev.1)
응답 대상: 챌린지 c52bb834 (agy, [CHALLENGE: RAISED])
Base: 245abe6
1. 판정 요약
본 이의 1건과 보충 제언 2건 모두 채택한다. 그리고 셋 중 둘은 agy 가 말한 것보다 나쁘다.
| # | 항목 | 판정 | 실측 |
|---|---|---|---|
| C-1 | candidate_uuids 서명에 cwd/epoch/claimed_uuids 누락 |
채택 — 증상은 예측보다 위험 | agy 는 [] 를 예측했으나 실제는 타 워크스페이스 대화가 유효 후보로 반환된다 |
| M-1 | PYTHONPATH 부트스트랩 부족 |
우려 채택 · 제안 기각 | run_loop.sh 의 맨 python3 -c 5곳에서 ModuleNotFoundError 재현. 단 제안한 __init__.py 내 sys.path.insert 는 순환이라 실행 자체가 불가능 |
| M-2 | ready_tokens 어댑터 이관 |
채택 — 효과가 예측보다 큼 | "2~3곳"이 아니라 wait_for_tui_ready 의 25줄 case 블록 하나가 데이터 조회 1줄로 바뀐다 |
정정부터. Rev.1 §4.2 의 candidate_uuids(ws_key, home, claude_dir, iso_root="") 는 내가
claude 의 디렉터리 구조만 보고 서명을 뽑은 결과다. agy·hermes·cline 은 절대경로 cwd 로
스코프하는데 그 인자가 아예 없었다. agy 가 정확히 짚었다.
측정 결과: Rev.2 어댑터 4종 전부 discover() 정확. 변이 6건 전부 검출. 전체 회귀 162 passed, 0건.
2. C-1 — 채택. 다만 실패 양상이 예측과 다르다
2.1 agy 의 예측 vs 실제
agy 는 "lc_data.get(ws_key) 조회 실패 → 항상 [] 반환"이라고 봤다.
그런데 Rev.1 프로토타입의 agy 어댑터는 last_conversations.json 을 아예 보지 않는다.
conversations 디렉터리를 통째로 glob 한다. 실행해 봤다:
agy .candidate_uuids -> ['agy-mine', 'agy-foreign']
hermes.candidate_uuids -> ['herm-mine', 'herm-foreign', 'herm-ancient']
기대: agy -> ['agy-mine'] (agy-foreign 은 /work/other 소속)
hermes -> ['herm-mine'] (herm-foreign 은 타 cwd, herm-ancient 는 세션 생성 이전)
[] 는 서비스 거부다. 지금 나오는 값은 격리 위반이다. 후자가 훨씬 나쁘다.
b4a1d094 이후 이 저장소가 계속 방어해 온 바로 그 부류의 결함이다.
2.2 verify_artifact 도 막아 주지 않는다
len(valid_candidates)==1 게이트가 걸러 줄 거라 기대할 수도 있지만, 검증 단계를 실측했다:
agy agy-foreign -> True
hermes herm-foreign -> True
hermes herm-ancient -> True
전부 통과한다. 그러면 두 결말뿐이다 — 후보가 1개면 남의 대화를 고정하고, 2개면 b107cf34 에서 없앤 영구 교착으로 되돌아간다. 둘 다 받아들일 수 없다.
여기서 agy 가 언급하지 않은 두 번째 결함이 나온다. verify_artifact(path, uuid, cwd) 는
cwd 를 이미 인자로 받고 있는데 agy·hermes 분기가 그걸 쓰지 않는다. C-1 은 발견 단계만
지적했지만 검증 단계도 같은 병을 앓고 있었다.
2.3 hermes 는 파일 mtime 으로 epoch 을 걸 수 없다
agy 의 권고안은 epoch 을 인자로 넘기라고만 한다. 그런데 hermes 는 모든 세션이 하나의
state.db 를 공유한다. 파일 mtime 은 후보 전체에 대해 같은 값이므로 mtime 기반 필터는
"전부 통과" 아니면 "전부 탈락" 두 가지 답만 낼 수 있다.
hermes 는 sessions.started_at 을 갖고 있으므로 그걸 써야 한다. 이건 어댑터별 오버라이드
지점이고, 평평한 인자 목록만으로는 드러나지 않는다.
미검증 항목: hermes 는 이 머신에 설치돼 있지 않다(
command not found).sessions(id, cwd, started_at)스키마는lib.sh:1454의 실제 쿼리와tests/conftest.py의 mock 정의에서 역산한 것이다. 실 CLI 대조는 구현자 몫이다.
3. C-1 설계 — 평평한 인자 대신 컨텍스트 객체
agy 의 권고안은 인자 7개짜리 서명이다. 방향은 맞지만 형태를 바꾼다.
@dataclass(frozen=True)
class DiscoveryContext:
cwd: str # /Users/x/proj -- agy, hermes, cline
ws_key: str = "" # -Users-x-proj -- claude
home: str = ""
claude_dir: str = ""
iso_root: str = ""
epoch: float = 0.0 # 0 이면 필터 비활성
claimed: frozenset = frozenset()
이유: 이 서명은 두 번의 리뷰에서 두 번 바뀌었다(Rev.1 → cwd 추가 → epoch/claimed 추가).
위치 인자 목록은 바뀔 때마다 어댑터 4개 + 모든 호출부를 함께 고쳐야 한다.
세 번째 변경이 없으리라 가정할 근거가 없다.
cwd 와 ws_key 를 둘 다 담는 것이 핵심이다. 둘은 교환 가능하지 않다 —
claude 는 ws_key 로 디렉터리를 찾고, agy(last_conversations.json)·hermes(sessions.cwd)·
cline(세션 json 의 cwd)은 절대경로로 찾는다. 하나만 넘기면 어느 쪽이든 반이 깨진다.
3.1 필터는 어댑터가 아니라 기반 클래스에 둔다
def discover(self, ctx) -> list:
out = []
for uuid in self._raw_candidates(ctx):
if uuid in ctx.claimed:
continue
if ctx.epoch and not self._passes_epoch(uuid, ctx):
continue
out.append(uuid)
return out
@abstractmethod
def _raw_candidates(self, ctx) -> list: ... # cwd 스코프만 책임진다
def _passes_epoch(self, uuid, ctx) -> bool: # 기본: 아티팩트 mtime
...
agy 의 권고는 "epoch 필터링과 claimed 배제를 어댑터 내부에 캡슐화"였다.
어댑터 4개가 각자 구현하면 잊어버릴 기회가 4번 생긴다. 방금 그 방식으로
agy·hermes 두 개가 cwd 를 잊은 것을 봤다. 그래서 필터는 기반 클래스가 갖고,
어댑터는 스코프된 원시 후보만 내놓는다. hermes 만 _passes_epoch 를 오버라이드한다(§2.3).
3.2 agy 는 두 번째 방어선이 없다 — 그리고 그건 HEAD 도 마찬가지다
Rev.2 를 돌리면 agy 만 검증에서 foreign 을 못 막는다:
agy verify_artifact(foreign) -> True
claude verify_artifact(foreign) -> False
cline verify_artifact(foreign) -> False
hermes verify_artifact(foreign) -> False
내 설계 탓인지 확인하려고 HEAD 의 셸 구현을 직접 호출했다:
HEAD verify_session_uuid(agy, agy-mine) = True
HEAD verify_session_uuid(agy, agy-foreign) = True ← 동일
agy 의 .db 에는 cwd 가 기록되지 않는다. 캐시가 유일한 스코프 수단이고,
HEAD 규칙은 "캐시가 인정하거나, 형제 세션이 점유하지 않았으면 통과"다. 어댑터도 그 규칙을 그대로 옮겼다.
따라서 agy 에 대해서는 _raw_candidates 의 cwd 스코핑이 유일한 방어선이다.
캐시에 이 cwd 항목이 없으면 [] 를 반환하도록 명시적으로 정했다 —
[] 는 고정을 지연시키지만, 전량 반환은 남의 대화를 고정한다.
3.3 최종 인터페이스
class BaseAgentAdapter(ABC):
name: str = ""
own_key: str = ""
supports_assigned_id = False
ready_tokens: tuple = () # M-2
def auth_ok(self, run) -> bool: ...
def spawn_spec(self, binary, session_uuid) -> SpawnSpec: ...
def resume_spec(self, binary, uuid, materialized) -> SpawnSpec: ...
def artifact_path(self, uuid, ctx) -> str: ...
def artifact_exists(self, uuid, ctx) -> bool # 구체 구현
def verify_artifact(self, uuid, ctx) -> bool: ... # cwd 를 반드시 쓸 것
def discover(self, ctx) -> list # 구체 구현 (템플릿)
def _raw_candidates(self, ctx) -> list: ... # 추상
def _passes_epoch(self, uuid, ctx) -> bool # 오버라이드 가능
artifact_path / verify_artifact 도 ctx 를 받도록 통일했다. Rev.1 의
(uuid, ws_key, home, claude_dir, iso_root) 와 (path, uuid, cwd) 두 가지 관례가
공존하던 것이 애초에 cwd 를 흘린 원인이다.
4. M-1 — 우려는 옳고, 제안한 해법은 동작하지 않는다
4.1 우려: 실재한다
Rev.1 은 PYTHONPATH 를 env_python / atomic_dump_yaml 의 env 목록에만 얹었다.
그런데 run_loop.sh 는 맨 python3 -c 를 5곳(179, 210, 223, 250, 277) 쓴다.
그리고 Rev.1 §5 는 하필 그중 resolve_agent_type(223)을 registry.agent_of_row 로
교체하라고 했다. 재현:
$ source .agents/skills/lib.sh; python3 -c "import mam_agents"
ModuleNotFoundError: No module named 'mam_agents'
Rev.1 설계 그대로 M1 을 구현했다면 run_loop.sh 가 그 자리에서 죽는다.
4.2 제안: 순환이라 성립하지 않는다
mam_agents/__init__.py 안에서 sys.path.insert 를 하라는 제안은 실행될 수 없다.
__init__.py 가 돌려면 패키지가 이미 import 돼야 하고, import 되려면 경로가 이미 잡혀 있어야 한다.
$ python3 -c "import mam_agents" # sys.path 에서 skills 제거 후
ModuleNotFoundError: No module named 'mam_agents'
-> __init__.py never runs, so it cannot add its own directory to sys.path
4.3 채택하는 해법: lib.sh source 시점 1회 export
_mam_export_pythonpath() {
local d; d="$(mam_skills_dir)"
case ":${PYTHONPATH:-}:" in
*":$d:"*) ;;
*) export PYTHONPATH="$d${PYTHONPATH:+:$PYTHONPATH}" ;;
esac
}
_mam_export_pythonpath
lib.sh 를 source 하는 모든 스크립트의 모든 파이썬 호출이 한 번에 덮인다.
run_loop.sh:12 가 lib.sh 를 source 하므로 5곳 전부 포함된다. 검증:
$ source .agents/skills/lib.sh; python3 -c "from mam_agents import registry; print(registry.names())"
import OK: ['agy', 'claude', 'cline', 'hermes']
herdr shim 은 의도적으로 제외된다 — shim 은 lib.sh 를 source 하지 않는 별도 생성 스크립트이고, Rev.1 §3.1 에서 그 안의 python3 9곳이 에이전트 지식을 0건 쓴다는 것을 이미 측정했다.
표준 라이브러리 섀도잉 위험 점검: .agents/skills/ 바로 아래에 최상위 .py 파일은 0개다
(mam_agents/ 패키지와 스킬 디렉터리뿐). export 후에도 stdlib import 정상:
$ source .agents/skills/lib.sh; python3 -c "import json, os, sqlite3, glob, re; print('stdlib OK')"
stdlib OK
남는 부작용 하나: herdr 가 띄우는 에이전트 CLI 들이 이
PYTHONPATH를 상속한다. 최상위 모듈이 없어 섀도잉은 불가능하지만, 구현자는mam_agents라는 이름이 어느 에이전트 CLI 의 내부 모듈과 겹치지 않는지 한 번 확인하는 편이 좋다.
5. M-2 — 채택. 효과가 제언보다 크다
agy 는 "5번째 에이전트 추가 시 셸 수정 2~3곳 감소"로 추정했다. 실제로 세어 보니
_MAM_READY_TOKENS_CLAUDE 는 claude 전용 변수 하나이고, 나머지 세 에이전트의 준비 토큰은
wait_for_tui_ready 안에 인라인으로 박혀 있다(lib.sh:1811-1835). 그 case 블록이 25줄이다.
claude Anthropic|Assistant|Chat|Welcome
agy Antigravity
hermes Hermes
cline Cline|history|Chat|What can I do|slash commands
브리지가 MAM_READY_TOKENS 를 ERE alternation 으로 내보내면 25줄 case 가
grep -E -q "$MAM_READY_TOKENS" 한 줄이 된다. 새 에이전트는 셸을 0줄 건드린다.
행동 변경 주의. claude 의 ready_tokens 에서
projects를 뺐다. b107cf34 §2.7 에서 그 토큰이 cwd 경로에 우연히 매칭돼 trust 다이얼로그가 떠 있는 상태에서 "준비 완료"로 오판하는 것을 측정했기 때문이다. 이건 개선이지만 리팩터에 섞어 넣을 성질이 아니다. 별도 커밋으로 분리하고 자체 검증을 붙일 것을 권한다.
6. 변경 요약 (Rev.1 대비)
| ID | 파일 | 내용 |
|---|---|---|
| R-1 | base.py |
DiscoveryContext 도입, discover() 템플릿 메서드, _raw_candidates() 추상화, _passes_epoch() 훅, ready_tokens 속성 |
| R-2 | adapters/agy.py |
last_conversations.json[cwd] 스코핑, 캐시 없으면 [], 검증에 형제 점유 규칙 |
| R-3 | adapters/hermes.py |
WHERE cwd=? 복원, verify_artifact 에 cwd 대조, _passes_epoch 를 started_at 으로 오버라이드 |
| R-4 | adapters/cline.py |
세션 json 의 cwd 로 원시 후보 스코핑 |
| R-5 | adapters/claude.py |
ctx 서명 통일, ready_tokens(projects 제외) |
| R-6 | lib.sh |
PYTHONPATH 를 source 시점 1회 export (per-entry-point env 목록 방식 폐기) |
| R-7 | __main__.py |
브리지에 MAM_READY_TOKENS 추가 |
패키지 규모: Rev.1 374줄 → Rev.2 484줄. 증가분 110줄 대부분이 워크스페이스 스코핑과 필터 템플릿이다. Rev.1 이 그만큼 덜 하고 있었다는 뜻이다.
7. 검증
7.1 발견 정확도 — 어댑터 4종
워크스페이스 2개(/work/mine, /work/other), 세션 생성 epoch 1시간 전,
3개월 전 대화 1건, 형제가 점유한 id 1건을 심은 픽스처:
| 어댑터 | Rev.1 | Rev.2 | 기대 |
|---|---|---|---|
| claude | — | ['cl-mine'] |
✅ |
| agy | ['agy-mine', 'agy-foreign'] |
['agy-mine'] |
✅ |
| hermes | ['herm-mine', 'herm-foreign', 'herm-ancient'] |
['herm-mine'] |
✅ |
| cline | — | ['cli-mine'] |
✅ |
형제 점유 배제(전부 claimed 로 표시):
agy/claude/cline/hermes discover(all claimed) -> [] 4/4 OK
7.2 변이 — 6/6 검출
| 변이 | 되돌린 것 | 결과 |
|---|---|---|
| Q-1 | agy _raw_candidates → 플랫 glob (Rev.1 그대로) |
['agy-foreign', 'agy-mine'] WRONG |
| Q-2 | hermes WHERE cwd=? 제거 (Rev.1 그대로) |
herm-foreign 유입 |
| Q-3 | hermes _passes_epoch 오버라이드 제거 |
herm-ancient 유입 |
| Q-4 | 기반 클래스의 claimed 필터 제거 |
4종 전부 LEAKED |
| Q-5 | 기반 클래스의 epoch 필터 제거 |
claude·cline·hermes 에 ancient 유입 |
| Q-6 | cline cwd 스코핑 제거 | cli-foreign 유입 |
Q-1·Q-2 는 Rev.1 코드를 그대로 변이로 삼은 것이고 실제로 깨진다. Q-3 은 §2.3 의 hermes 특수성이 공허한 우려가 아님을 보인다.
7.3 회귀
baseline (HEAD 245abe6) 162 passed in 518.51s
Rev.1 프로토타입 162 passed in 521.54s
Rev.2 프로토타입 162 passed in 505.79s ← 회귀 0
R-6(source 시점 PYTHONPATH export)이 가장 위험했다. lib.sh 를 source 하는 모든
스크립트의 환경을 바꾸고 herdr 가 띄우는 프로세스까지 상속되기 때문이다. 회귀 0.
py_compile 통과. 어댑터는 표준 라이브러리만 사용(§Rev.1 3.2 제약 유지).
8. 남는 위험 (Rev.1 §9 갱신)
Rev.1 의 비용 항목 5가지(인터프리터 경계 · 브리지 호출 규율 · 배포/CI 등록 · 이행 중 이중 표현 · 간접화)는 그대로 유효하다. 아래는 갱신·추가분.
8.1 (갱신) 배포·CI 등록 — Rev.1 §8.2 의 deploy/remove.sh 한 줄과 §8.4 의 CI 경로 2줄은
Rev.2 에서도 그대로 필수다.
8.2 (신규) hermes 스키마 미검증 — §2.3. sessions(id, cwd, started_at) 은 기존 쿼리와
mock 에서 역산했다. hermes 미설치라 실 CLI 대조 불가. M4 착수 전 확인 필요.
8.3 (신규) agy 의 단일 방어선 — §3.2. agy 는 검증 단계에서 foreign 을 못 막는다(HEAD 동일).
캐시가 침묵하면 [] 를 반환하는 선택이 유일한 보호막이므로, 이 동작은 테스트로 고정해야 하고
"후보가 안 잡힌다"는 버그 리포트가 올라올 때 되돌리고 싶어질 지점이다. 되돌리면 격리가 깨진다.
8.4 (신규) projects 토큰 제거는 행동 변경 — §5. 리팩터와 분리할 것.
8.5 (신규) PYTHONPATH 상속 — §4.3. 에이전트 CLI 들이 상속한다. 섀도잉 위험은 측정상 없으나
이름 충돌 여부는 구현자가 확인.
9. 이행 순서 (Rev.1 §10 갱신)
| 단계 | 내용 | 변경점 |
|---|---|---|
| M0 | 패키지 골격 + source 시점 PYTHONPATH export(R-6) + deploy/remove.sh·install.sh·CI 등록 |
부트스트랩 방식 교체 |
| M1 | own_key / agent_of_row 이관 (프로토타입 완료, 34 → 29) |
변경 없음 |
| M2 | artifact_path + verify_artifact — ctx 서명으로 통일, 격리 경로 일원화 |
서명 변경 |
| M3 | spawn_spec / resume_spec / auth_ok |
변경 없음 |
| M4 | discover() — drift-C 4블록. hermes 스키마 확인이 선행(§8.2) |
선행 조건 추가 |
| M5 | stop_session.sh purge 경로 + exit key |
변경 없음 |
| M6 | (신규) ready_tokens — wait_for_tui_ready 25줄 case 제거 |
M-2 |
| M7 | (신규·별건) claude ready token 에서 projects 제거 + 자체 검증 |
§5 |
중단 기준은 그대로: M2 이후 팬아웃이 29 → 20 이하로 안 떨어지면 재검토.
10. 결론
이의 1건과 제언 2건 전부 채택했다. 그리고 셋 다 조사해 보니 지적된 것보다 컸다 —
C-1 은 서비스 거부가 아니라 격리 위반이었고, M-1 은 run_loop.sh 를 죽이는 문제였으며,
M-2 는 2~3곳이 아니라 25줄 블록이었다.
그대로 채택하지 않은 것 하나. agy 의 권고는 epoch/claimed 를 어댑터마다 캡슐화하라는 것인데,
어댑터 4개가 각자 구현하면 잊어버릴 기회가 4번 생긴다. 방금 그 방식으로 두 개가 cwd 를
잊은 것을 확인했다. 필터는 기반 클래스가 갖고, 어댑터는 스코프된 원시 후보만 낸다.
프로토타입 트리: scratchpad/ad2(Rev.2) · scratchpad/ad(Rev.1) · scratchpad/adbase(HEAD).
IMPROVEMENTS.md A-4 항목은 Creator 구현 시 본 Rev.2 기준으로 갱신이 필요하다 —
이번 작업에서는 저장소를 건드리지 않았다.
[AGREEMENT: REACHED]