명령 하나를 바꾸는 일이 런타임 전체를 흔들 수 있다

에이전트 프로젝트를 만들다 보면 어느 순간 이런 유혹이 온다. 기존의 pi 명령을 내 런처인 drewgent로 감싸고, 모든 사용자가 하나의 명령만 쓰게 만들자는 생각이다. 보기에는 깔끔하다. 하지만 그 순간부터 세션 재개, 프로젝트 컨텍스트, 디버깅, 도움말, 심지어 사용자가 이미 익힌 습관까지 내 래퍼의 책임이 된다.

나는 Drewgent를 붙이면서 이 실수를 피하기로 했다. 인터랙티브 Pi의 표준 진입점은 bare pi로 남긴다. drewgent는 Pi를 대체하는 새 셸이 아니라, Drewgent의 설정과 CLI를 올리는 launcher다. 그리고 메시징 게이트웨이가 필요할 때만 Pi를 RPC 프로세스로 연결한다.

처음에는 하나로 합치고 싶었다

문제의 출발점은 합리적이었다. 내 컴퓨터에서는 대화형 작업도 하고, Discord 게이트웨이도 돌리고, 세션과 메모리도 관리한다. 그렇다면 진입점을 하나로 통일하면 운영이 쉬워질 것 같았다. drewgent 하나가 환경을 준비하고, Pi를 실행하고, 게이트웨이까지 선택하면 멋진 구조처럼 보인다.

그런데 “내가 실행하기 편한가”와 “기존 런타임의 계약을 보존하는가”는 다른 질문이었다. pi는 단순히 모델을 호출하는 바이너리가 아니다. 사용자가 직접 들어가는 인터랙티브 표면이고, 세션 파일과 재개 방식이 연결된 작업 공간이다. 여기에 새 래퍼가 끼어들면 편의 기능 하나를 추가하는 대신, 원래 있던 계약을 복제해야 한다.

새 런처를 만들 때 먼저 물어야 하는 질문은 “무엇을 더 할까?”가 아니라 “기존 사용자가 무엇을 잃게 될까?”였다.

내가 정한 경계: pi는 원본, drewgent는 부트스트랩

현재 구조에서 bin/drewgent가 하는 일은 의외로 작다. DREW_HOME을 정하고, 사용할 Python을 고르고, 프로젝트 루트를 PYTHONPATH에 넣은 다음 python -m drewgent_cli.main을 실행한다. 즉, Drewgent의 환경을 준비하는 진입점이지 Pi의 인터랙티브 프로토콜을 다시 구현하는 진입점은 아니다.

CORE_HOME=${DREW_HOME:-${DREWGENT_CORE:-"$HOME/.drewgent"}}
PYTHON=${DREWGENT_PYTHON:-"$CORE_HOME/.venv/bin/python3"}

export DREW_HOME="$CORE_HOME"
export PYTHONPATH="$CORE_HOME${PYTHONPATH:+:$PYTHONPATH}"
exec "$PYTHON" -m drewgent_cli.main "$@"

이 구분은 이름만 그럴듯하게 나눈 것이 아니다. Drewgent CLI의 기본 명령은 자체적으로 cmd_chat으로 들어가고, drewgent chat -q 같은 비대화형 모드도 별도로 가진다. 반면 Pi의 인터랙티브 세션을 쓰고 싶다면 그 세션의 소유자는 계속 Pi여야 한다. 래퍼가 Pi의 세션 형식과 명령을 흉내 내기 시작하는 순간, 두 개의 진실 공급원이 생긴다.

게이트웨이에서는 Pi를 복제하지 않고, Pi를 연결했다

메시징 게이트웨이에는 다른 문제가 있었다. Discord에서 온 대화를 Pi 세션으로 이어가고 싶지만, 매 메시지마다 프로세스를 새로 만들면 대화가 끊긴다. 여기서 내가 선택한 해법은 Drewgent가 Pi를 대신하는 것이 아니라, Pi가 제공하는 RPC 경계를 사용하는 것이었다.

argv = [self.config.pi_bin, "--mode", "rpc"]
argv.extend(["--session-dir", str(self.config.session_dir)])
argv.append("--no-approve")

P3-sensors/gateway/pi_runtime.py에서는 게이트웨이 세션 키마다 오래 살아 있는 pi --mode rpc 프로세스를 유지한다. 세션 파일은 별도의 Drewgent 대화 객체로 옮겨지지 않는다. Pi의 JSONL 세션이 원본으로 남고, 게이트웨이는 그 파일을 가리키는 매핑만 저장한다. 그래서 터미널에서 pi -r 또는 /resume으로 같은 세션을 이어갈 수 있다.

이 결정 덕분에 게이트웨이는 운반 계층이 된다. Discord 메시지를 RPC 요청으로 바꾸고, 응답을 다시 Discord로 전달한다. 세션의 의미와 파일의 생명주기를 새로 발명하지 않는다. 인터페이스는 추가하지만, 원본은 이동시키지 않는 것이 핵심이었다.

두 번째 실수는 기능보다 넓게 켜는 것이었다

새 런타임을 연결했다고 모든 트래픽을 그쪽으로 보내면 안 된다. 현재 게이트웨이의 Pi 경로는 PI_GATEWAY_ENABLED가 명시적으로 켜져야 하고, 기본값은 꺼져 있다. 켜더라도 우선 Discord에만 적용된다. 필요하면 부모 채널 allowlist로 더 좁힐 수 있다. Telegram이나 다른 어댑터의 기존 경로를 조용히 바꾸지 않도록 경계를 코드에 박아두었다.

if not (
    self._pi_gateway_enabled
    and source.platform_name == Platform.DISCORD.value
):
    return False

이건 보수적인 운영 취향이라서가 아니다. 문제가 생겼을 때 비교할 기준을 남겨야 하기 때문이다. 기존 AIAgent 경로를 rollback path로 보존하고, Pi 경로는 canary처럼 제한한다. 새 구조가 좋아 보인다는 이유만으로 전체 시스템의 관찰 가능성을 버리지 않는 방식이다.

첫 응답보다 중요한 것은 세션의 생명주기였다

처음 연결할 때 가장 쉽게 확인하는 것은 “메시지를 보내면 답이 오는가”다. 하지만 실제로 반복해서 쓸 수 있는 런타임인지 확인하려면 그 다음을 봐야 한다. 한 세션을 재사용하는가, 동시에 들어온 요청을 직렬화하는가, 중단할 수 있는가, 응답이 오지 않을 때 타임아웃하는가, 프로세스가 죽은 뒤 같은 세션 파일로 다시 열리는가.

그래서 관련 테스트도 첫 호출 하나에 머물지 않았다. 실제 작업에서 특히 중요했던 검증은 다음과 같다.

  • 같은 게이트웨이 키로 두 번 호출해도 Pi 프로세스와 세션 매핑을 재사용하는가.
  • 두 스레드가 같은 세션에 들어와도 프롬프트가 섞이지 않는가.
  • /stop에 해당하는 abort가 settled를 기다리는 프롬프트에 도달하는가.
  • 응답이 오지 않을 때 readline에 영원히 갇히지 않고 타임아웃하는가.
  • /resume으로 다른 세션을 붙일 때 원래 JSONL 파일을 지우지 않고 동시 사용을 막는가.

이 테스트들은 모두 통과했지만, 더 중요한 결과는 숫자가 아니었다. 내가 “Pi를 붙였다”고 말할 수 있게 된 기준이 첫 답변이 아니라 세션을 잃지 않고 실패에서 돌아오는가로 바뀌었다는 점이다.

이 패턴을 다른 프로젝트에 적용한다면

비슷한 구조를 만들고 있다면 런처부터 크게 설계하지 않는 편이 좋다. 먼저 다음 네 가지를 문서로 고정하면 된다.

  1. 표준 인터랙티브 진입점을 정한다. 사용자가 직접 들어가는 원본 명령과 자동화용 래퍼를 같은 것으로 취급하지 않는다.
  2. 래퍼의 책임을 부트스트랩으로 제한한다. 환경 변수, Python 경로, 설정 디렉터리를 준비하되 원본 런타임의 세션 계약을 복제하지 않는다.
  3. 통합은 프로토콜 경계 뒤에 둔다. 게이트웨이·메신저·에디터는 원본 세션을 소유하지 않고 RPC나 파일 매핑으로 연결한다.
  4. 기능 플래그와 rollback path를 남긴다. 새 경로를 기본값으로 바꾸기 전에 한 플랫폼·한 채널에서 생명주기를 검증한다.

특히 마지막 항목을 빼먹기 쉽다. “이제 작동하니 기본값으로 바꾸자”는 말은 구현 완료를 운영 전환으로 착각하게 만든다. 운영 전환은 별도의 결정이다. 실패했을 때 무엇으로 돌아갈지, 어떤 로그와 세션 파일을 확인할지 정해진 뒤에야 기본값을 바꿀 수 있다.

좋은 아키텍처는 명령을 늘리지 않는다

이번 작업에서 내가 얻은 결론은 간단하다. drewgent가 더 많은 일을 한다고 더 좋은 런처가 되는 것은 아니다. 사용자가 이미 신뢰하는 pi의 의미를 보존하고, Drewgent는 설정·게이트웨이·운영 경계를 맡는 편이 전체 시스템을 더 오래 유지하게 한다.

새 프로젝트에서도 같은 실수를 반복하지 않으려면, 먼저 “내 래퍼가 원본을 대체해야 하는가?”를 물어보자. 대체할 이유가 명확하지 않다면 그대로 연결하자. 원본은 원본으로 남겨두고, 통합 계층은 통합 계층으로 남겨두는 것. 내가 pi를 그대로 두기로 한 이유는 보수적이라서가 아니라, 그 경계가 이미 사용자와 세션을 지켜주고 있었기 때문이다.

직접 확인해볼 것

비슷한 런타임을 운영 중이라면 오늘은 래퍼 코드를 더 쓰기 전에 세 가지만 확인해보자. 터미널에서 쓰는 표준 명령이 무엇인지, 세션 파일의 실제 원본이 어디인지, 새 경로를 끄면 즉시 돌아갈 수 있는지다. 이 세 답이 문서와 코드에 모두 있다면 다음 기능을 추가해도 구조가 쉽게 흔들리지 않는다.

그리고 가능하다면 자신의 런처에 “원본 런타임을 대체하지 않는다”는 주석과 생명주기 테스트를 남겨두자. 몇 달 뒤 코드를 다시 보는 나 자신에게 가장 값싼 설계 문서가 된다.

근거 출처

  • 외부 정보(하베스터 수집): 사용하지 않음. 기사·트렌드·외부 통계에 기대지 않은 BUILD LOG다.
  • 상호작용(에이전트-드루 대화·작업): 작업 중 확인한 bin/drewgent, drewgent_cli/main.py, drewgent_cli/runtime_boundary.py, P3-sensors/gateway/pi_runtime.py, P3-sensors/gateway/run.py의 구현과 tests/gateway/test_pi_runtime.py의 focused test 결과를 바탕으로 작성했다.

적용할 때의 CTA: 자신의 프로젝트에서 표준 인터랙티브 명령과 래퍼의 책임을 먼저 분리해 문서화하고, 세션 재사용·중단·타임아웃·재개 테스트를 한 번씩 실행해보자. 기능을 하나 더 붙이는 것보다, 원본을 잃지 않는 경계를 확인하는 일이 먼저다.