Hermes Agent 소스코드 해부 — 하네스·멀티에이전트·닫힌 학습 루프
왜 이 저장소를 읽는가 — 3축 지도와 실측 규모
핵심 요점
- 실측 결과 브리핑의 12개 파일 LOC와 8개 디렉토리 수치는 전부 정확히 일치했으나, 목록에 빠진 `hermes_cli/` 208,396줄이 `agent/` 134k보다 큰 최대 파이썬 디렉토리였다.
- ⚠️ 발표자료 표지의 "~12k LOC"는 HEAD 5292074에서 성립하지 않는다 — `run_agent.py` 단일 파일이 8,240줄이고, 테스트(671,092줄)를 뺀 파이썬 코어만 852,755줄이다.
- `max_iterations` 기본값 90 vs 500은 문서 드리프트가 아니라 **진입점 차이**였다 — 라이브러리 직접 생성은 90(`run_agent.py:446`), CLI·게이트웨이·크론은 500(`config_defaults.py:32`·`gateway/run.py:1909`·`cron/scheduler.py:3763`), TUI 일부는 25, 서브에이전트는 50. 단 라이브러리 문서 2곳(`python-library.md:312`·`AGENTS.md:367`)이 500이라 쓴 것은 실제 오류다.
- 85만 줄을 다 읽을 필요는 없다 — 설계 판단은 하네스(conversation_loop→iteration_budget→prompt_caching→context_compressor) / 멀티에이전트(delegate_tool) / 메모리·학습(memory_manager→background_review→curator) 3축에 응축돼 있다.
- 격리 정책은 `tools/delegate_tool.py:128`의 `MAX_DEPTH = 1` 한 줄로 못 박혀 있다 — 손자 에이전트는 명시적 옵트인 없이는 거부되는 flat-by-default 설계다.
- 참조 가치는 캐시·예산·격리·펜싱이 각각 독립 모듈로 분리돼 있다는 점이다. 특히 `IterationBudget`이 `execute_code` 턴을 refund 하는 것은 예산이 곧 도구 사용 인센티브 설계임을 보여준다.
왜 이 저장소를 읽는가 — 3축 지도와 실측 규모
Hermes Agent는 Nous Research가 MIT로 공개한 에이전트 하네스다(LICENSE:1-3, Copyright (c) 2025 Nous Research). README가 스스로를 규정하는 문구는 "self-improving AI agent ... the only agent with a built-in learning loop"인데, 마케팅 표현을 걷어내고 보면 이 저장소가 실제로 담고 있는 건 프로덕션 에이전트를 굴리려면 어차피 다 만들어야 하는 배관이다. 프롬프트 캐시 배치, 반복 예산, 서브에이전트 격리, 도구 승인 펜싱, 컨텍스트 압축, 세션 영속화. 남이 이미 한 번 짜본 것들이다.
이 글이 읽은 커밋은 HEAD 5292074 (2026-08-08) 하나다. 로컬 클론은 단일 커밋 스냅샷이라 히스토리 기반 변경 속도는 측정하지 못했다 — 아래 수치는 전부 이 트리에서 wc -l로 다시 잰 값이다.
실측 규모 — 표지의 "~12k LOC"를 정정한다
⚠️ 발표자료 대비 변경: 2026년 6월 사내 발표자료 표지의 "~12k LOC"는 현재 HEAD에서 성립하지 않는다. run_agent.py 단일 파일이 이미 8,240줄이다.
| 파일 | 실측 LOC | 역할 |
|---|---|---|
hermes_state.py |
10,399 | SQLite 세션 스토어(WAL + FTS5) |
run_agent.py |
8,240 | AIAgent 본체 — 하네스의 심장 |
agent/conversation_loop.py |
7,596 | 한 턴을 끝까지 미는 루프 |
agent/context_compressor.py |
7,202 | 자동 컨텍스트 압축 |
tools/delegate_tool.py |
4,342 | 서브에이전트 스폰 |
agent/curator.py |
2,019 | 스킬 유지보수 백그라운드 작업 |
agent/memory_manager.py |
1,241 | 메모리 프로바이더 단일 통합점 |
tools/memory_tool.py |
1,240 | 메모리 도구 표면 |
agent/background_review.py |
1,081 | 턴 후 자가 리뷰 포크 |
agent/system_prompt.py |
693 | 시스템 프롬프트 조립 |
agent/prompt_caching.py |
394 | Anthropic 캐시 브레이크포인트 배치 |
agent/iteration_budget.py |
62 | 반복 예산 카운터 |
브리핑에 적힌 12개 파일 수치는 전부 정확히 일치했다. 디렉토리 단위도 마찬가지다.
| 디렉토리 | 파이썬 LOC | 파일 수 |
|---|---|---|
hermes_cli/ |
208,396 | 267 |
agent/ |
134,128 | 186 |
plugins/ |
127,804 | 200 |
tools/ |
123,864 | 132 |
gateway/ |
101,739 | 88 |
| 루트(비재귀) | 53,062 | — |
tui_gateway/ |
25,914 | 22 |
skills/ |
18,293 | 69 |
cron/ |
11,417 | 13 |
여기서 브리핑 목록에 빠진 항목이 하나 있다: hermes_cli/ 208k는 agent/보다 크고, 이 저장소에서 가장 큰 파이썬 디렉토리다(web_server.py 17,951 / main.py 12,620 / kanban_db.py 10,378). 루트 53k도 cli.py 한 파일이 18,612줄로 3분의 1을 먹는다.
전체 파이썬은 1,523,847줄, 이 중 tests/가 671,092줄이다. 테스트를 뺀 파이썬 코어가 852,755줄 — 대략 85만 LOC. 여기에 TS/JS로 ui-tui/ 91,638줄, web/ 51,627줄이 더 붙는다.
발표자료를 깎아내릴 일은 아니다. 요점은 이런 저장소의 규모 스냅샷은 발표 슬라이드에 박아두면 안 된다는 것이다. 낡는 게 기본값이고, 낡았다는 사실을 알아채기도 어렵다.
그 낡음은 저장소 내부에서도 벌어지고 있다. max_iterations의 실제 기본값을 재보면 이렇다.
def __init__(
self,
...
model: str = "",
max_iterations: int = 90, # Default tool-calling iterations (shared with subagents)
run_agent.py:435-446
그런데 같은 트리의 agent/iteration_budget.py:5,21, AGENTS.md:367, website/docs/guides/python-library.md:312는 이 값을 500이라고 적는다. 처음엔 단순 문서 드리프트로 보였는데, 호출부를 따라가 보니 아니었다 — 진입점마다 기본값이 다르다.
| 진입점 | 기본 반복 한도 | 근거 |
|---|---|---|
AIAgent(...) 직접 생성(라이브러리) |
90 | run_agent.py:446 |
| CLI / TUI | 500 | agent.max_turns 기본 500(hermes_cli/config_defaults.py:32)을 max_iterations로 전달(hermes_cli/cli_agent_setup_mixin.py:494) |
| 게이트웨이 | 500 | HERMES_MAX_ITERATIONS 폴백 500(gateway/run.py:1909) |
| 크론 | 500 | cron/scheduler.py:3763 |
| TUI 일부 경로 | 25 | _cfg_max_turns(cfg, 25)(tui_gateway/server.py:6062) |
| 서브에이전트 | 50 | DEFAULT_MAX_ITERATIONS(tools/delegate_tool.py:819) |
즉 90과 500 둘 다 "맞다". 90은 라이브러리로 임포트할 때, 500은 사람이 실제로 쓰는 CLI·게이트웨이·크론 경로에서다. 다만 라이브러리 파라미터 표(python-library.md:312)와 생성자 시그니처를 그대로 옮겨 적은 AGENTS.md:367이 500이라고 쓴 것은 진짜 오류다 — 그 두 문서가 설명하는 대상은 정확히 90인 경로다.
배울 건 그래서 "문서가 낡았다"가 아니다. "우리 에이전트 기본 반복 한도가 몇이냐"는 질문에 코드 상수 하나로 답할 수 없는 구조가 됐다는 것이다. 기본값이 진입점마다 갈리면 그 값은 더 이상 상수가 아니라 배선의 산물이고, 읽는 사람은 생성자가 아니라 호출부를 봐야 한다. 진입점별 실효값 표는 뒤의 '반복 예산' 섹션에서 더 파고든다.
최상위 구조 — 한 줄씩
| 경로 | 무엇 |
|---|---|
run_agent.py |
AIAgent 클래스. 모델 호출·도구 디스패치·재시도·폴백의 진입점 |
agent/ |
하네스 내부 부품. 루프·압축·캐시·예산·메모리·큐레이터·프로바이더 어댑터 |
tools/ |
에이전트가 부르는 도구 구현 + 승인 게이트(approval.py 4,553줄) |
hermes_state.py |
SQLite 영속화. WAL + FTS5, 압축 시 parent_session_id 체인으로 세션 분할 |
gateway/ |
멀티플랫폼 게이트웨이(Telegram/Discord/Slack…) 단일 프로세스 |
plugins/ |
플랫폼 어댑터·메모리 백엔드·관측 등 외부 확장 |
skills/ |
번들 스킬 71개(SKILL.md 기준), 14개 카테고리 |
cron/ |
내장 스케줄러 — 무인 반복 작업 |
tui_gateway/, ui-tui/, web/ |
터미널 UI 서버와 프런트엔드 |
3축으로 읽는다
전체를 다 읽는 건 무의미하다. 85만 줄 중 설계 판단이 응축된 곳은 좁고, 세 축으로 갈라진다.
| 축 | 질문 | 진입점 |
|---|---|---|
| 하네스 | 한 턴을 어떻게 안전하게 끝까지 미나 | conversation_loop.py → iteration_budget.py → prompt_caching.py → context_compressor.py |
| 멀티에이전트 | 자식을 어떻게 격리하고 어디서 멈추나 | tools/delegate_tool.py(MAX_DEPTH = 1) → agent/delegation_context.py |
| 메모리·학습 | 무엇을 언제 기억으로 승격하나 | memory_manager.py → background_review.py → curator.py → skills/ |
이 축 구분이 자의적이지 않다는 건 코드 배치가 증명한다. 예산 축은 62줄짜리 파일 하나로 완결돼 있고(순수 카운터 + 락), 격리 축은 delegate_tool.py 상단에 정책 상수 한 줄로 못 박혀 있다.
MAX_DEPTH = 1 # flat by default: parent (0) -> child (1); grandchild rejected unless max_spawn_depth raised.
tools/delegate_tool.py:128
깊이 1이 기본이라는 선택 — 손자 에이전트는 명시적 옵트인 없이는 거부된다. 재귀 위임을 허용해본 사람이면 왜 이렇게 잠갔는지 안다.
왜 남의 하네스 코드를 읽나
에이전트를 만들면 누구나 같은 네 가지 벽에 부딪힌다. Hermes는 그 넷을 각각 독립 모듈로 분리해뒀다 — 이게 이 저장소의 참조 가치다.
- 캐시:
prompt_caching.py는 394줄 순수 함수 뭉치다. "정적 시스템 접두부 + 시스템 끝 + 마지막 비시스템 메시지 2개 = 브레이크포인트 4개, 접두부 없으면 시스템 1 + 메시지 3으로 폴백"이라는 배치가 클래스 상태 없이 구현돼 있다(agent/prompt_caching.py:1-10). 세션 간 접두부 재사용과 세션 내 캐시를 동시에 노리는 레이아웃이다. - 예산:
IterationBudget은consume/refund만 있다. 흥미로운 건execute_code(프로그램적 도구 호출) 턴을 환불한다는 점 — 도구를 코드로 묶어 부르면 예산을 안 쓴다. 예산이 곧 인센티브 설계라는 얘기다. - 격리: 자식은 부모 히스토리 없이 시작하고 자기
task_id, 자기 터미널 세션을 받는다(tools/delegate_tool.py:1-14). 부모 예산과 자식 예산이 별도라 총합이 부모 상한을 넘을 수 있다는 것도 문서에 명시돼 있다. - 펜싱:
tools/approval.py4,553줄 +write_approval.py+skills_guard.py. 승인 로직이 4천 줄이라는 사실 자체가 정보다 — 도구 게이팅은 "if 한 줄"로 끝나지 않는다.
우리 쪽 판단에 그대로 옮길 수 있는 건 상수값이 아니라 경계선이다. 예산을 어디서 세는지, 캐시 마커를 누가 붙이고 누가 떼는지(strip_anthropic_cache_control이 따로 있다), 자식이 무엇을 상속받지 않는지. 이후 섹션들은 이 세 축을 하나씩 파고든다.
참고 출처
↗ run_agent.py:435-446 — AIAgent.__init__, max_iterations 기본값 90↗ agent/iteration_budget.py:1-11 — 부모 500 / 자식 50 이라 적힌 docstring (코드와 불일치)↗ tools/delegate_tool.py:128 — MAX_DEPTH = 1 (flat by default)↗ agent/prompt_caching.py:1-10 — 4 브레이크포인트 레이아웃과 5m/1h TTL↗ hermes_state.py:1-12 — WAL + FTS5, parent_session_id 세션 분할↗ agent/conversation_loop.py:1-12 — run_conversation 추출 경위↗ agent/background_review.py:1-14 — 턴 후 포크 자가 리뷰↗ agent/curator.py:1-12 — 비활성 트리거 스킬 유지보수↗ README.md — self-improving agent / learning loop 규정↗ LICENSE — MIT, Copyright (c) 2025 Nous Research전체 구조 한눈에 보기
핵심 요점
- 가로 띠(학습 루프)가 그림을 위아래로 가른다 — 위는 사용자를 기다리게 하는 경로, 아래는 아닌 경로다.
- 발표자료 대비 정정 5건: 저장소 규모, 위임 깊이 상한 삭제, 자식 타임아웃 해제, 캐시 레이아웃 기본값, delegate_tool.py 규모.
- 일치 5건: 자식 예산 50, MAX_DEPTH 1, 동시 3, 메모리 상한 2200/1375, 큐레이터 7일·2h·30일·90일. (부모 반복 상한 90은 진입점별로 갈려 정정 항목으로 옮겼다.)
- 메모리·학습 축은 반년째 안정적인 반면 위임 축만 크게 움직였다 — 서브에이전트 운영에서 실제로 데인 흔적이다.
- 파일:라인 참조 209건을 HEAD 5292074 기준으로 기계 검증했다.
전체 구조 한눈에 보기
발표자료 마지막 장의 블록다이어그램을 이 글의 지도로 삼는다. 아래 다섯 덩어리가 곧 이후 섹션들의 순서다.
읽는 순서
한 번의 사용자 메시지가 이 그림을 왼쪽 위에서 오른쪽 아래로 관통한다.
- ① 하네스 —
run_conversation()한 번이 한 턴이다. 시스템 프롬프트를 stable/volatile 2계층으로 조립해 prefix 캐시를 살린 채,LLM ⇄ 도구 실행루프를IterationBudget이 허용하는 만큼 돈다. 컨텍스트가 임계치를 넘으면 중간 턴을 요약해 접는다. - ② 멀티에이전트 — 루프 안에서
delegate_task가 불리면 부모 히스토리가 없는 자식들이 스레드풀에서 병렬로 돈다. 부모에게 돌아오는 건 요약뿐이다. - ③ 메모리 — 턴 직전
prefetch, 턴 직후sync. 회상된 내용은 펜스로 감싸 "참조 데이터"로 강등된 채 주입된다. - ④ 영속·검색 — 모든 세션이 SQLite 에 쌓이고, FTS5 두 인덱스(기본 + 트라이그램)가 과거 세션을 되찾아온다.
- ⑤ 학습 루프 — 여기까지가 끝난 뒤, 별도 스레드에서 보조 모델이 "이번 턴에 기억할 게 있었나"를 판단한다. 사용자는 이 단계를 기다리지 않는다.
가로 띠(⑤)가 위아래를 갈라놓은 배치가 핵심이다. 위는 사용자를 기다리게 하는 경로, 아래는 아닌 경로. Hermes 의 자기개선은 전부 이 선 아래에 있다.
발표자료 대비 정정 — 무엇이 달라졌나
이 아래 다이어그램은 2026-06-04 사내 발표자료(파일 메타데이터 기준 작성일)의 마지막 장을 그대로 재현한 것이고, 이 글은 그것을 약 두 달 뒤 소스(HEAD 5292074, 2026-08-08)로 재검증한 결과다. 현재 값을 채운 갱신 다이어그램은 글 마지막 섹션에 있다. 대부분은 그대로였지만, 위임 제어 파라미터 쪽이 통째로 바뀌어 있었다.
| 항목 | 발표자료 | 현재 소스 | 판정 |
|---|---|---|---|
| 저장소 규모 | ~12k LOC |
run_agent.py 한 파일이 8,240줄 · 테스트 뺀 파이썬 코어 852,755줄 |
⚠️ 정정 |
| 위임 깊이 상한 | _MAX_SPAWN_DEPTH_CAP = 3, [1,3] 클램프 |
상한 삭제 — 하한 1만 남고 "no upper ceiling" | ⚠️ 정정 |
| 자식 타임아웃 | DEFAULT_CHILD_TIMEOUT = 600초 |
None(무제한) — 하트비트 staleness 감시로 교체 |
⚠️ 정정 |
| 캐시 레이아웃 | system_and_3 (시스템 + 마지막 3개) |
기본은 정적 접두부 + 시스템 끝 + 마지막 2개. system_and_3 은 접두부가 없을 때의 폴백 |
⚠️ 정정 |
delegate_tool.py 규모 |
~2.8k LOC |
4,342줄 | ⚠️ 정정 |
| 부모 반복 상한 | max_iterations = 90 |
90은 라이브러리 직접 생성 경로만 — CLI·게이트웨이·크론은 500, TUI 일부는 25 | ⚠️ 정정(진입점별로 다름) |
| 자식 반복 예산 | 50 | DEFAULT_MAX_ITERATIONS = 50 |
✅ |
| 위임 깊이 기본 | MAX_DEPTH = 1 (평면) |
동일 | ✅ |
| 동시 자식 수 | 3 | _DEFAULT_MAX_CONCURRENT_CHILDREN = 3 |
✅ |
MEMORY.md / USER.md 상한 |
2,200자 / 1,375자 | 동일 (tools/memory_tool.py:165) |
✅ |
| nudge 간격 | 메모리 10턴 / 스킬 10반복 | 동일 (agent/agent_init.py:1686,1786) |
✅ |
| 큐레이터 주기 | 7일 · 최소 2h 유휴 · 30일 stale · 90일 archive | 동일 (agent/curator.py:70-73) |
✅ |
메모리·학습 축은 반년째 안정적이고, 위임 축만 심하게 움직였다. 서브에이전트 운영에서 실제로 데인 흔적이 소스 주석에 남아 있기 때문인데(고정 타임아웃이 정상 작업을 죽였다는 자백), 그 이야기는 뒤의 '역할·깊이·동시성' 섹션에서 자세히 다룬다.
이 글에 적힌 파일·라인 참조는 전부 HEAD
5292074기준으로 실제 파일에 존재하고 범위 안에 있는지 기계적으로 검증했다(209건). 다만 저장소는 빠르게 움직이므로, 읽는 시점에 라인 번호는 밀려 있을 수 있다.
하네스 — run_conversation() 한 턴의 생명주기
핵심 요점
- 실제 while 조건은 `(api_call_count < agent.max_iterations and agent.iteration_budget.remaining > 0) or agent._budget_grace_call` (conversation_loop.py:1558)이지만, `_budget_grace_call`은 비테스트 소스 어디에서도 True로 설정되지 않아 죽은 항이다 — 유예 호출은 turn_finalizer.py:127-141로 이동했다.
- max_iterations 기본값은 진입점별로 갈린다 — 라이브러리 직접 생성 90(`run_agent.py:446`), CLI·게이트웨이·크론 500, TUI 일부 25. `iteration_budget.py:21` docstring의 500은 CLI 실효값을 파라미터 기본값처럼 서술해 오해를 부른다.
- 압축은 루프 뒤 독립 단계가 아니다 — 프롤로그 preflight + 루프 내 압력 게이트/오버플로 재시도 세 곳에서만 발화하고, 루프 종료 후에는 돌지 않는다.
- 게이트웨이는 메시지마다 새 AIAgent를 만들지 않는다 — `_agent_cache`(gateway/run.py:6123)로 캐시하고 1시간 유휴 축출(`_AGENT_CACHE_IDLE_TTL_SECS = 3600.0`). 재수화가 필요한 이유는 '항상 새 객체'가 아니라 '새 객체일 수도 있어서'다.
- 턴 시작 리셋은 build_turn_context에 집중: task_id UUID(454), turn_id(456), 재시도 카운터 6종(471-479), 도구 가드레일(482), `_vision_supported = True`(487), 새 IterationBudget(506). `_turns_since_memory`/`_iters_since_skill`은 명시적으로 리셋 제외(504).
- agent_init.py:590의 '공유 예산, 자식이 상속' 주석은 현실과 다르다 — delegate_tool.py:1655는 `iteration_budget=None`을 넘기고 turn_context.py:506은 매 턴 예산 객체를 무조건 교체한다.
하네스 — run_conversation() 한 턴의 생명주기
사용자 메시지 하나가 들어오면 AIAgent.run_conversation()이 정확히 한 번 돈다. 그 안에서 API가 90번 호출되든 도구가 40개 실행되든, 밖에서 보면 여전히 "한 턴"이다. 그래서 이 함수는 하네스에서 유일하게 "상태의 경계"가 되는 지점이고, 리셋해야 할 것과 이어받아야 할 것을 가르는 선이 전부 여기 그어져 있다.
먼저 발표자료의 5단계 도식부터 정정한다. 현재 소스에서 물리적 단계는 셋이다.
파일 맵 — 한 턴이 지나가는 코드
| 단계 | 진입점 | 줄 수 | 하는 일 |
|---|---|---|---|
| 프롤로그 | agent/turn_context.py: build_turn_context() |
1281 | 카운터 리셋, todo/nudge 재수화, 시스템 프롬프트 복원, preflight 압축, 메모리 prefetch, 크래시 대비 영속화 |
| 메인 루프 | agent/conversation_loop.py: run_conversation() |
7596 | while 루프, API 호출, 도구 실행, 루프 내 압축/재시도 |
| 종료자 | agent/turn_finalizer.py: finalize_turn() |
785 | 예산 소진 요약, trajectory 저장, 세션 영속화, 백그라운드 리뷰 |
run_agent.py(8240줄)의 run_conversation(7832행)은 사실상 포워더다. relay 리스 획득과 task/turn 컨텍스트만 세우고 agent.conversation_loop.run_conversation으로 넘긴다. ⚠️ 발표자료 대비 변경: "컨텍스트 압축"은 루프 뒤에 오는 독립 단계가 아니다. 압축은 (1) 프롤로그의 preflight, (2) 루프 안 pre-API 압력 게이트(conversation_loop.py:2173 부근), (3) 오버플로/413 재시도 핸들러 세 곳에서 발화하고, 루프가 끝난 뒤에는 한 번도 돌지 않는다. 압축을 "정리 단계"로 이해하면 실패 모드를 못 읽는다. 압축은 호출 직전의 방어지 사후 청소가 아니다.
while 조건식 — 세 항 중 하나는 이미 죽어 있다
while (api_call_count < agent.max_iterations and agent.iteration_budget.remaining > 0) or agent._budget_grace_call:
agent/conversation_loop.py:1558
max_iterations 기본값은 확인된다 — max_iterations: int = 90, # Default tool-calling iterations (shared with subagents) (run_agent.py:446). 그런데 나머지 두 항이 문제다.
⚠️ 발표자료 대비 변경 ①: _budget_grace_call은 저장소 전체에서 True로 설정되는 곳이 없다. agent_init.py:900에서 False로 초기화되고, conversation_loop.py:1588에서 다시 False로 소비될 뿐이다(비테스트 소스 기준 grep 3건). 즉 or 항은 항상 거짓이고, "한 번의 유예 호출"은 죽은 코드다. 저장소 자체 문서인 AGENTS.md:392가 이 조건식을 그대로 인용하며 "with ... a one-turn grace call"이라고 설명하는데, 그 유예는 이제 루프가 아니라 종료자에 있다(아래).
⚠️ 발표자료 대비 변경 ②: iteration_budget.remaining > 0도 부모 턴에서는 사실상 구속력이 없다. 예산은 매 턴 새로 만들어지고, 환불(refund)은 사용량을 줄이기만 하기 때문이다.
# NOTE: _turns_since_memory and _iters_since_skill are NOT reset here.
agent.iteration_budget = IterationBudget(agent.max_iterations)
agent/turn_context.py:504-506
환불 지점은 6곳(conversation_loop.py 2082·2245·5851·5862·5901·6663). 이 중 5곳은 api_call_count -= 1과 짝을 이루지만, 6663은 다르다 — 어떤 반복이 execute_code만 호출했다면 예산만 돌려주고 api_call_count는 그대로 둔다. 결과적으로 used ≤ api_call_count가 항상 성립하고, api_call_count < 90이면 remaining > 0은 자동으로 참이다. 두 카운터는 중복이 아니라 역할이 다르다: api_call_count는 되돌릴 수 없는 시도 벽, iteration_budget은 환불 가능한 비용계.
여기에 하나 더. agent_init.py:590의 주석은 "Shared iteration budget — parent creates, children inherit"라고 말하지만, delegate_tool.py:1655는 iteration_budget=None, # fresh budget per subagent를 넘기고, turn_context.py:506은 매 턴 예산 객체를 무조건 교체한다. 외부에서 공유 예산을 주입해도 첫 턴 시작과 동시에 파괴된다. iteration_budget.py:20-26의 클래스 docstring이 오히려 현실과 맞는다("Each agent ... gets its own"). 같은 파일 21행이 max_iterations 기본값을 500이라 적고 cli.py:14539도 getattr(..., 500)으로 폴백하는데, 라이브러리 경로의 실제 기본은 90이고, CLI·게이트웨이·크론 경로는 500이다(진입점별 실효값은 '반복 예산' 섹션 표 참조). 주석 세 개가 서로 다른 시대를 가리키고 있다 — 하네스에서 상수는 코드에서 직접 읽어야 한다는 흔한 교훈의 교과서적 사례.
턴 시작에 리셋되는 것 / 되지 않는 것
| 항목 | 위치 | 동작 |
|---|---|---|
task_id (UUID) |
turn_context.py:454 |
task_id or str(uuid.uuid4()) — VM/샌드박스 격리 키 |
turn_id |
turn_context.py:456-462 |
{session}:{task}:{uuid8}, relay가 미리 예약한 값 우선 |
| 재시도 카운터 6종 | turn_context.py:471-479 |
invalid_tool / invalid_json / empty_content / scratchpad / codex / thinking_prefill |
| 도구 가드레일 | turn_context.py:482 |
_tool_guardrails.reset_for_turn() |
| vision 플래그 | turn_context.py:487 |
_vision_supported = True (턴마다 낙관적 재시도) |
| 반복 예산 | turn_context.py:506 |
새 IterationBudget |
| 검증 nudge 카운터 | turn_context.py:1145-1146 |
0으로 |
| 리셋 안 함 | turn_context.py:504 |
_turns_since_memory, _iters_since_skill |
마지막 줄이 설계 판단이다. 세션 수명 카운터를 턴 경계에서 지우면 주기적 트리거가 영원히 발화하지 않는다.
재수화 — "새 에이전트"가 아니라 "캐시된 에이전트"
# Hydrate per-session nudge counters from persisted history (issue #22357).
if conversation_history and agent._user_turn_count == 0:
prior_user_turns = sum(1 for m in conversation_history if m.get("role") == "user")
if prior_user_turns > 0:
agent._user_turn_count = prior_user_turns
if agent._memory_nudge_interval > 0 and agent._turns_since_memory == 0:
agent._turns_since_memory = prior_user_turns % agent._memory_nudge_interval
agent/turn_context.py:549-557 (todo 재수화는 545-547)
⚠️ 발표자료 대비 변경 ③: "게이트웨이가 메시지마다 새 AIAgent를 만든다"는 서술은 현재 소스와 맞지 않는다. 게이트웨이는 _agent_cache(OrderedDict, gateway/run.py:6123)로 에이전트를 턴 사이에 캐시한다. conversation_loop.py:1428의 주석도 "The gateway caches agents across user turns"라고 명시한다. 새 에이전트는 캐시 미스, 1시간 유휴 축출(_AGENT_CACHE_IDLE_TTL_SECS = 3600.0, gateway/run.py:81), 설정 시그니처 불일치, 프로세스 재시작에서만 생긴다(tests/run_agent/test_memory_nudge_counter_hydration.py docstring).
차이는 사소하지 않다. 재수화가 필요한 이유는 "매번 새 객체라서"가 아니라 "새 객체일 수도 아닐 수도 있어서"다. 그래서 하네스는 두 방향을 동시에 감당해야 한다 — 캐시된 객체는 이전 턴 잔여물을 지워야 하고(1428행의 _last_compaction_in_place = False, 1485행 _incremental_persistence_failed = False, _delivered_interim_texts = set()), 새 객체는 기록에서 상태를 복원해야 한다. 두 코드가 같은 프롤로그에 나란히 있는 게 정답이다.
예산 소진은 루프가 아니라 종료자가 처리한다
elif final_response is None and budget_fallback_eligible:
# Budget exhausted — ask the model for a summary via one extra
# API call with tools stripped.
_turn_exit_reason = f"max_iterations_reached({api_call_count}/{agent.max_iterations})"
final_response = agent._handle_max_iterations(messages, api_call_count)
agent/turn_finalizer.py:127-141
죽은 _budget_grace_call의 자리를 이게 대신한다. 도구를 벗긴 단발 호출로 요약을 받아낸다(chat_completion_helpers.py:2292). 종료자는 그 밖에 trajectory 저장(:250), _persist_session(:410), 그리고 조건부 백그라운드 리뷰(:740-752)를 수행한다. 리뷰는 final_response가 있고 인터럽트가 없을 때만 데몬 스레드로 포크되며, cron 경로는 skip_background_review로 통째로 끈다 — 이벤트당 ~30K 토큰짜리 두 번째 에이전트라서다.
루프가 끝난 이유는 _turn_exit_reason 문자열 하나로 압축된다(19개 대입 지점). 이건 로그용 장식이 아니다. cron/scheduler.py:4222가 max_iterations_reached(로 시작하는지 검사해 워커 실패 회로를 돌린다. 종료 사유를 자유 문자열로 두되 접두어를 계약으로 삼는 절충인데, 실무에서는 이런 게 조용히 깨진다. 접두어를 바꾸면 스케줄러가 말없이 오판한다.
그래서 턴은 왜 재진입 가능해야 하는가
run_conversation은 같은 객체 위에서 몇 번이고 다시 실행된다. 이게 하네스 설계의 핵심 제약이다.
- 에이전트 객체 ≠ 대화. 진실은
messages와 SessionDB에 있고, 객체는 캐시일 뿐이다. 그래서 루프 내부에서_persist_session이 20곳 넘게 불린다(1567·2358·2874·3077 …). 턴이 중간에 죽어도 다음 턴이 기록에서 이어받는다. - 모든 가변 상태는 소유자가 명시돼야 한다. 턴 소유(재시도 카운터·가드레일·vision)는 프롤로그에서 리셋, 세션 소유(nudge·skill 카운터)는 명시적으로 리셋 제외, 프로세스 소유는 아예 건드리지 않는다.
turn_context.py:504의 한 줄짜리 NOTE가 이 계약을 지킨다. - 경계 리셋을 빠뜨리면 증상이 캐시 히트에서만 나타난다. 로컬 CLI(항상 새 프로세스)에서는 재현되지 않고 게이트웨이(캐시된 에이전트)에서만 터진다. Hermes가 프롤로그를 별도 모듈로 뽑아낸 실질적 이유가 이거다 — 470줄짜리 직선 코드 속에 흩어진 리셋은 리뷰로 검증되지 않는다.
턴을 함수가 아니라 트랜잭션으로 설계하라. 시작에 무엇을 열고, 끝에 무엇을 커밋하며, 중간에 죽으면 무엇이 남는지가 명시돼야 한다. Hermes의 3단 분해는 그 세 질문에 각각 파일 하나씩을 배정한 결과다.
참고 출처
↗ agent/conversation_loop.py:1558 (메인 while 조건식)↗ agent/conversation_loop.py:6661-6663 (execute_code 전용 반복 환불)↗ run_agent.py:446 (max_iterations: int = 90, shared with subagents)↗ agent/turn_context.py:454-506 (task_id/turn_id/재시도 카운터/가드레일/vision/예산 리셋)↗ agent/turn_context.py:545-557 (todo·nudge 카운터 재수화)↗ agent/turn_finalizer.py:127-141 (예산 소진 시 도구 제거 요약 호출)↗ agent/turn_finalizer.py:740-752 (백그라운드 메모리/스킬 리뷰 스폰 조건)↗ agent/iteration_budget.py:17-59 (IterationBudget: consume/refund/remaining)↗ agent/agent_init.py:590-591, 899-900 (예산 주입 주석·grace 플래그 초기화)↗ gateway/run.py:81, 6123 (_AGENT_CACHE_IDLE_TTL_SECS = 3600.0, _agent_cache)↗ tests/run_agent/test_memory_nudge_counter_hydration.py:1-13 (재수화가 필요한 4가지 시나리오)↗ AGENTS.md:390-400 (저장소 자체 문서의 루프 의사코드 — grace call 서술)↗ cron/scheduler.py:4222 (turn_exit_reason 접두어를 계약으로 소비)반복 예산(IterationBudget) — 폭주를 구조로 막기
핵심 요점
- `IterationBudget`은 62줄·상태 3개(`max_total`/`_used`/`Lock`)뿐이며 `used`/`remaining` 게터까지 락을 잡는다 — 게이트웨이 관측 스레드가 `get_activity_summary()`로 같은 값을 읽기 때문(회귀 테스트 `tests/run_agent/test_iteration_budget_race.py` 존재).
- 예산은 세션이 아니라 **턴마다** 새로 생성된다(`agent/turn_context.py:506`). 자식은 `iteration_budget=None`으로 항상 새 예산을 받으므로(`tools/delegate_tool.py:1655`) 부모+자식 총 반복에는 상한이 없다. `agent_init.py:590`의 "children inherit" 주석은 사문이다.
- `execute_code` 전용 턴 환불은 실재한다(`conversation_loop.py:6661-6663`, 집합 비교라 다른 도구가 섞이면 미환불). 그러나 이 지점만 `api_call_count -= 1`을 하지 않고, 루프 조건이 `api_call_count < max_iterations`와 AND라서 **환불이 실제 반복 횟수를 1회도 늘리지 못한다** — 효과는 회계·표시뿐이다.
- ⚠️ 발표자료 대비: `_budget_grace_call`은 저장소 전체에서 단 한 번도 `True`로 설정되지 않는 사문 플래그다. 실제 유예는 루프 밖 `turn_finalizer.py:141` → `handle_max_iterations()`의 **도구 제거 단발 요약 호출** 1회로 구현돼 있다.
- ⚠️ 발표자료 대비: 부모 기본값 90은 `run_agent.py:446` 생성자에만 해당하고, 게이트웨이·크론·TUI 경로는 500, TUI 일부는 25다. 같은 파일 독스트링(`iteration_budget.py:5,21`)조차 500이라고 잘못 적혀 있다. 자식 50(`delegate_tool.py:819`)만 일관된다.
- 자식 cap은 모델이 `delegate_task(max_iterations=...)`로 제안해도 무시되고 config 값이 이긴다(`delegate_tool.py:3182-3193`) — 예산은 모델과 협상하지 않는다는 설계.
반복 예산(IterationBudget) — 폭주를 구조로 막기
agent/iteration_budget.py는 62줄이다. 파일 하나를 통째로 읽는 데 30초도 안 걸리는데, 이 안에 "에이전트 루프를 무엇으로 세고, 무엇을 세지 않을 것인가"라는 판단이 전부 들어 있다. 그리고 실제로 읽어 보면 주석이 말하는 설계와 코드가 하는 일이 세 군데에서 갈라진다.
62줄 전문 — 락 하나에 카운터 하나
def consume(self) -> bool:
"""Try to consume one iteration. Returns True if allowed."""
with self._lock:
if self._used >= self.max_total:
return False
self._used += 1
return True
def refund(self) -> None:
"""Give back one iteration (e.g. for execute_code turns)."""
with self._lock:
if self._used > 0:
self._used -= 1
agent/iteration_budget.py:37-49
상태는 max_total, _used, threading.Lock 셋뿐이다. used/remaining도 프로퍼티지만 읽기까지 락을 잡는다(:51-59). 단순한 int 읽기에 락을 거는 건 과해 보이지만, 이건 회귀 방지 코드다 — tests/run_agent/test_iteration_budget_race.py의 주석이 "Before the fix, used returned _used directly without holding the lock"라고 못 박고, 10스레드 × 200회 consume/refund를 돌린다. 왜 멀티스레드인가? 루프 스레드가 예산을 깎는 동안 게이트웨이/TUI 스레드가 get_activity_summary()에서 같은 값을 읽어 화면에 뿌리기 때문이다(run_agent.py:4081-4082의 budget_used/budget_max).
교훈 1: 반복 예산은 종료 조건이자 관측 API다. 값을 읽는 쪽이 루프 밖 스레드라면 게터도 임계구역이다.
예산은 세션이 아니라 "턴"마다 새로 태어난다
agent_init.py:590의 주석은 "Shared iteration budget — parent creates, children inherit"라고 말한다. 소스는 그렇지 않다.
| 지점 | 실제 동작 |
|---|---|
agent/agent_init.py:590 |
iteration_budget or IterationBudget(max_iterations) — 주입 없으면 자기 것 생성 |
agent/turn_context.py:506 |
agent.iteration_budget = IterationBudget(agent.max_iterations) — 매 턴 통째로 교체 |
tools/delegate_tool.py:1655 |
iteration_budget=None, # fresh budget per subagent |
즉 이 저장소에서 예산을 공유해서 넘기는 호출자는 한 곳도 없다. 생성자 파라미터(run_agent.py:502)는 열려 있지만 아무도 쓰지 않는다. 결과적으로 예산 스코프는 "에이전트 1개 × 사용자 턴 1회"이고, 자식은 부모 예산을 건드리지 않으므로 부모+자식 총 반복은 부모 cap을 얼마든지 넘는다(모듈 독스트링 :22-24도 이 점은 정직하게 인정한다).
⚠️ 발표자료 대비 변경: "부모 90"은 생성자 기본값일 뿐
| 소스 | 값 |
|---|---|
run_agent.py:446 AIAgent(max_iterations=...) |
90 |
agent/iteration_budget.py:5,21 독스트링 |
500 (같은 파일이 90과 모순) |
cli-config.yaml.example:822 agent.max_turns |
500 |
gateway/run.py:1909 HERMES_MAX_ITERATIONS 폴백 |
500 |
cron/scheduler.py:3763 |
500 |
tui_gateway/server.py:6062 / :6539 |
25 / 500 |
tools/delegate_tool.py:819 DEFAULT_MAX_ITERATIONS |
50 |
자식 50은 정확하다. 부모 90은 직접 AIAgent(...)를 만들 때만 유효하고, 게이트웨이·크론·TUI 경로는 전부 500이다. 실무 함의: "우리 에이전트 기본 반복 한도가 몇이냐"는 질문에 코드 상수 하나로 답할 수 없다. 진입점마다 다르다. 참고로 자식 cap은 모델이 delegate_task(max_iterations=...)로 제안해도 무시되고 config 값이 이긴다(delegate_tool.py:3182-3193) — 예산은 모델이 협상할 수 있는 대상이 아니라는 판단이다.
execute_code 환불은 실재한다 — 그런데 루프를 늘리지는 못한다
주장 검증부터. refund() 호출은 전부 agent/conversation_loop.py에 6곳이다.
| 라인 | 사유 | api_call_count -= 1 동반 |
|---|---|---|
| 2082 | Ollama 런타임 컨텍스트 부족 — 프로바이더 도달 전 이탈 | ✅ |
| 2245 | 프리플라이트 압축 후 이번 반복 폐기 | ✅ |
| 5851 / 5862 | 사용자 정정으로 요청 취소 → 같은 반복 재구성 | ✅ |
| 5901 | content-filter 스톨 → 폴백 프로바이더로 재발행 | ✅ |
| 6663 | 호출된 도구가 execute_code 뿐인 턴 |
❌ 없음 |
_tc_names = {tc.function.name for tc in assistant_message.tool_calls}
if _tc_names == {"execute_code"}:
agent.iteration_budget.refund()
agent/conversation_loop.py:6661-6663
집합 비교라서 execute_code와 다른 도구가 섞이면 환불되지 않는다. 여기까지는 발표자료대로다. 그런데 루프 조건을 보면 이야기가 달라진다.
while (api_call_count < agent.max_iterations and agent.iteration_budget.remaining > 0) or agent._budget_grace_call:
agent/conversation_loop.py:1558
api_call_count는 턴 시작 시 0(:1495), 매 반복 +1(:1581) 뒤에 consume()(:1589)이 온다. 따라서 used ≤ api_call_count가 항상 성립하고, 환불은 이 격차를 벌리기만 한다. remaining = max_total − used ≥ max_iterations − api_call_count이므로 api_call_count < max_iterations인 동안 remaining > 0은 자동으로 참이다. 즉 execute_code 환불(=api_call_count를 되돌리지 않는 유일한 환불)은 이번 턴의 실제 반복 횟수를 단 1회도 늘리지 못한다. 1590행의 _turn_exit_reason = "budget_exhausted" 역시 현재 배선에서는 도달 불가다(예산을 공유 주입하는 호출자가 생기면 그때 살아난다). 남는 효과는 회계와 표시 — budget_used가 "값싼 RPC 턴을 뺀 진짜 소비"를 보여준다.
교훈 2: 환불은 회계가 아니라 종료 조건까지 되돌려야 효과가 있다. 다른 5곳은 그래서 api_call_count를 같이 깎는다.
grace call은 코드에 있고, 발동하지는 않는다
_budget_grace_call을 저장소 전체(바이너리 포함)에서 grep하면 7히트뿐이다: 초기화 False(agent_init.py:900), 루프 조건(:1558), 소비 후 False 리셋(:1587-1588), 문서(AGENTS.md:396), 테스트 1개. 어디서도 True로 설정되지 않는다. 같은 초기화 블록의 _budget_exhausted_injected도 마찬가지로 초기화만 있고 소비처가 없다. 두 플래그를 설명하는 agent_init.py:897-899의 주석("inject ONE message, allow one final API call")은 현재 구현과 맞지 않는 사문이다.
실제 유예는 루프 바깥에 있다. turn_finalizer.py:94-141이 api_call_count >= max_iterations or remaining <= 0으로 소진을 판정하고, 최종 응답이 비어 있으면 agent._handle_max_iterations() → chat_completion_helpers.py:2292로 도구를 제거한 단발 요약 호출 1회를 쏜다. 그리고 종료 사유를 max_iterations_reached(n/max)로 남기며, kanban 워커라면 timed_out으로 실패 회로까지 돌린다.
교훈 3: 유예 호출은 "한 번 더 일하게 해주는 것"이 아니라 "도구 없이 마무리 발화만 시키는 것"이어야 한다. 도구를 남겨 두면 유예가 새 폭주의 시작점이 된다. Hermes는 루프 안의 유예 플래그를 사실상 폐기하고 루프 밖 도구 제거 호출로 옮겼는데, 이 배치가 더 안전하다.
우리 하네스에 넣는다면 — 결정 기준 4개
- 차감 단위는 "프로바이더 왕복"이지 "도구 호출"이 아니다. 한 번의 어시스턴트 응답에 도구가 5개 붙어도 LLM 비용은 1회다. Hermes는 반복(=API 호출) 하나를 1로 세고, 도구 개수는 세지 않는다.
- 환불 기준은 "모델이 진전을 만들 기회를 가졌는가". 컨텍스트 초과로 요청이 나가지 못했거나(2082), 사용자 정정으로 취소됐거나(5851), 압축/폴백 때문에 같은 반복을 다시 돈 경우(2245·5901)는 모델 탓이 아니므로 환불. 반대로 도구가 실패했더라도 모델이 응답을 받았다면 차감이 맞다.
- 환불하면 종료 조건도 같이 되돌려라. 안 그러면 대시보드 숫자만 예뻐지고 실제 한도는 그대로다(위
execute_code사례). - 스코프를 먼저 못 박아라. 턴 단위인지 세션 단위인지, 자식이 독립인지 공유인지. Hermes는 턴 단위 + 자식 독립이라 트리 전체 상한이 존재하지 않는다 — 비용 상한이 필요하면 예산 객체가 아니라
max_spawn_depth·동시성 같은 다른 축으로 막아야 한다.
참고 출처
↗ agent/iteration_budget.py:17-59 (클래스 전문: consume/refund/used/remaining + Lock)↗ agent/conversation_loop.py:1558,1587-1592 (루프 조건과 grace call 소비 지점)↗ agent/conversation_loop.py:6661-6663 (execute_code 전용 턴 환불)↗ agent/turn_context.py:506 (턴마다 IterationBudget 재생성)↗ agent/agent_init.py:590,897-900 (예산 생성 + 사문화된 grace 주석/플래그)↗ agent/turn_finalizer.py:94-141 (소진 판정과 도구 제거 요약 호출)↗ agent/chat_completion_helpers.py:2292 (handle_max_iterations — 실제 유예 구현)↗ tools/delegate_tool.py:819,1655,3182-3193 (DEFAULT_MAX_ITERATIONS=50, fresh budget per subagent, config 우선)↗ run_agent.py:446,502,4081-4082 (max_iterations=90 기본값, 예산 주입 파라미터, budget_used 관측)↗ tests/run_agent/test_iteration_budget_race.py:1-50 (락 회귀 테스트)↗ cli-config.yaml.example:822,1305 (agent.max_turns=500, delegation.max_iterations=50)↗ gateway/run.py:1905-1911,10919-10924 (HERMES_MAX_ITERATIONS 기본 500)캐시를 1급 제약으로 — 2계층 시스템 프롬프트와 system_and_3
핵심 요점
- ⚠️ 발표자료 대비 변경 — 빌더는 stable/context/volatile 3계층이다(system_prompt.py:562-566). '2계층'은 와이어 기준으로만 맞다: `_cached_system_prompt_static`에 `stable`만 보관해 시스템 메시지를 [정적 프리픽스, 나머지] 2블록으로 자르고, context+volatile은 한 덩어리로 나간다.
- ⚠️ 발표자료 대비 변경 — 스킬 인덱스는 stable이 아니라 volatile 맨 앞에 있다(system_prompt.py:507-521). 배치 기준은 변경 빈도가 아니라 '바뀌면 뒤의 무엇을 같이 죽이나'라는 폭발 반경이다. 같은 이유로 타임스탬프는 분이 아닌 날짜 정밀도다.
- ⚠️ 발표자료 대비 변경 — `system_and_3`는 기본값이 아니라 폴백으로 강등됐다(prompt_caching.py:3-6). 기본은 정적 프리픽스 + 시스템 끝 + 마지막 비시스템 2. 게다가 `system_and_3`는 식별자가 아니라 저장소 전체에서 bedrock_adapter.py:1081 주석 한 곳에만 나오는 문자열이며, 분기는 `static_system_prefix` 유효성으로 암묵적으로 갈린다.
- 마커 상한 4개가 레이아웃을 통째로 결정했다(`remaining = 4 - breakpoints_used`, prompt_caching.py:383). 정적 프리픽스 분할은 공짜가 아니라 대화 쪽 브레이크포인트 2개를 팔아 세션 간 재사용을 사는 거래다. 툴 캐시 레이아웃은 시스템 suffix 마커를 포기하고 그 예산을 `tools[-1]`에 쓴다.
- '입력 토큰 약 75% 절감'은 agent_init.py:855 주석에만 존재하며 저장소에 측정 근거가 없다. 캐시 히트가 원가의 0.10x이므로 절감 상한은 90%이고, 75%를 내려면 입력의 약 83%가 히트여야 한다 — 낙관적 상한에 가깝다. 계측 필드(`cache_read_tokens`/`cache_write_tokens`)는 usage_pricing.py:34-60에 이미 있다.
- 캐시 최적화가 정확성을 인질로 잡지 않게 한 세 가지 장치: 분할은 요청 로컬(deep copy)로만 수행해 저장 트랜스크립트를 재작성하지 않고, 정적 프리픽스는 영속화 대신 재구성하되 `startswith` 게이트 실패 시 조용히 레거시 레이아웃으로 폴백하며, `cache_ttl`의 알 수 없는 값은 disable이 아니라 기본 TTL 유지로 처리한다.
캐시를 1급 제약으로 — 2계층 시스템 프롬프트와 system_and_3
에이전트 하네스에서 프롬프트 캐시는 "나중에 붙이는 최적화"가 되기 어렵다. 캐시 히트는 프리픽스가 바이트 단위로 동일할 때만 나오고, 그 조건은 프롬프트를 조립하는 코드 전체에 제약을 건다. Hermes는 이 제약을 사후 패치가 아니라 프롬프트 빌더의 자료구조 자체에 박아넣었다. 그리고 그 구조는 발표자료 시점 이후로 꽤 움직였다.
⚠️ 발표자료 대비 변경 ① — 2계층이 아니라 3계층이다
build_system_prompt_parts()는 dict 하나를 돌려주는데, 키가 셋이다.
return {
"stable": "\n\n".join(p.strip() for p in stable_parts if p and p.strip()),
"context": "\n\n".join(p.strip() for p in context_parts if p and p.strip()),
"volatile": "\n\n".join(p.strip() for p in volatile_parts if p and p.strip()),
}
agent/system_prompt.py:562-566
| 계층 | 실제 내용 (소스 확인) | 무효화 주기 |
|---|---|---|
stable |
SOUL.md 또는 DEFAULT_AGENT_IDENTITY(196/201), 헬프·완료·병렬툴콜 가이던스(204/213/224), 도구 가이드(245), computer-use·Nous 구독 블록(258/262), 모델별 운영 가이던스(285-297), alibaba 모델명 워크어라운드(336), 환경 힌트(348), 코딩 오퍼레이팅 브리프(366) |
세션을 넘어 재사용 |
context |
코딩 워크스페이스 스냅샷, 환경 프로브 라인, 활성 프로파일 힌트, 플랫폼 힌트, 호출자 system_message, AGENTS.md/.cursorrules 등 컨텍스트 파일 |
cwd·설정 단위 |
volatile |
스킬 인덱스(521), 메모리 스냅샷(527), USER.md 프로필(532), 외부 메모리 프로바이더 블록(539), 타임스탬프/세션/모델/프로바이더/플랫폼 라인(560) |
리빌드마다 |
그럼 발표자료의 "2계층"은 틀렸나. 절반만 그렇다. 와이어로 나갈 때 시스템 메시지는 정확히 2블록으로 접힌다 — build_system_prompt()가 agent._cached_system_prompt_static = parts["stable"]만 따로 보관하고(system_prompt.py:588), 캐시 데코레이터는 이 문자열로 [정적 프리픽스, 나머지 전부]를 자른다. 즉 context와 volatile은 와이어에서 한 덩어리다. 빌더는 3계층, 프로토콜은 2블록. 배울 점은 이 분리 자체다. 계층을 나누는 단위(무효화 주기)와 캐시 마커를 찍는 단위(프로바이더 예산)는 별개로 두고, 후자를 전자에서 유도해야 나중에 프로바이더가 바뀌어도 빌더를 안 건드린다.
⚠️ 발표자료 대비 변경 ② — 스킬 인덱스는 stable이 아니라 volatile이다
발표자료는 스킬 매니페스트를 불변 계층에 넣었지만, 소스는 명시적으로 반대로 갔다. 주석이 이유를 직접 말한다.
# Skills are runtime-mutable: the agent adds and patches them across a
# session (SKILLS_GUIDANCE tells it to patch a skill the moment it goes
# stale). ... With the index in the stable band, a rebuild
# that picked up a skill change would bust the cached prefix from the index
# down, taking the whole scaffold with it. Render it at the FRONT of the
# volatile band instead, ahead of the turn-varying memory/timestamp tail
agent/system_prompt.py:507-516
핵심은 "얼마나 자주 바뀌나"가 아니라 **"바뀌면 뒤의 무엇을 같이 죽이나"**다. 스킬 인덱스는 하루에 한 번 바뀔 수도 있지만, stable 대역 한복판에 있으면 그 한 번이 정체성부터 도구 가이드까지 전부를 재프리필시킨다. 반대로 volatile 맨 앞에 두면 손실은 자기 자신부터 아래로 한정된다. 계층 배치의 기준은 변경 빈도가 아니라 폭발 반경이다.
같은 논리가 타임스탬프에도 적용됐다. Conversation started: %A, %B %d, %Y — 분 단위가 아니라 날짜 단위다(system_prompt.py:545-553). 분 정밀도면 리빌드마다 프롬프트가 달라져 캐시가 매번 죽는다. 시각이 진짜 필요하면 도구로 물어보게 하고, 프롬프트에서는 정밀도를 깎았다.
⚠️ 발표자료 대비 변경 ③ — system_and_3는 기본값이 아니라 폴백이다
이게 가장 큰 차이다. 모듈 docstring이 첫 줄부터 정정한다.
The default layout uses 4 cache_control breakpoints: the static system
prefix, the end of the system prompt, and the last 2 non-system messages.
When a static system prefix is unavailable, it falls back to one system
breakpoint plus the last 3 messages. All markers use the same TTL (5m or 1h).
agent/prompt_caching.py:3-6
| 레이아웃 | 마커 배치 | 발동 조건 |
|---|---|---|
| 기본 (정적 프리픽스 분할) | 정적 프리픽스 + 시스템 끝 + 마지막 비시스템 2 | static_system_prefix가 시스템 프롬프트의 실제 프리픽스일 때 |
system_and_3 (레거시) |
시스템 전체 1 + 마지막 3 | 프리픽스가 없거나 startswith 불일치 |
| 툴 캐시 | 정적 프리픽스 + tools[-1] + 완료 트랜잭션 종점 2 |
api_mode == "anthropic_messages" AND 호스트가 api.anthropic.com |
| Bedrock | cachePoint 블록, converse_messages[-2]에 체크포인트 |
별도 경로 |
주의할 점: system_and_3는 식별자가 아니다. 저장소 전체를 grep하면 이 문자열은 agent/bedrock_adapter.py:1081 주석 한 곳에만 나온다. 레이아웃 선택 enum도, 설정 키도 없다. 분기는 전부 static_system_prefix가 유효한가라는 데이터 조건에서 암묵적으로 갈린다. 발표자료가 이름 붙인 "레이아웃"은 사실 코드에 이름이 없는 폴백 경로다.
마커 예산 산술은 노골적이다.
remaining = 4 - breakpoints_used
non_sys = [
i
for i in range(len(messages))
if messages[i].get("role") != "system"
and _can_carry_marker(messages[i], native_anthropic=native_anthropic)
]
agent/prompt_caching.py:383-389
4는 Anthropic의 cache_control 마커 상한이고, 이 하드 리밋이 레이아웃을 통째로 결정했다. 시스템에 2개를 쓰면 대화에 2개만 남는다. 그래서 정적 프리픽스 분할은 "공짜 최적화"가 아니라 대화 쪽 브레이크포인트 2개를 팔아서 세션 간 재사용을 사는 거래다. 툴 캐시 레이아웃은 여기서 한 발 더 나가, 시스템 suffix 마커를 포기하고 그 예산을 tools[-1]에 쓴다(mark_suffix=False, prompt_caching.py:331-339).
예산을 낭비하지 않기 위한 방어들
4개뿐이라 "찍었는데 무시당하는" 마커가 곧 손실이다. _can_carry_marker()는 프로바이더가 실제로 존중하는 자리인지 먼저 판정한다 — OpenRouter 계열(envelope 레이아웃)에서는 content 파트 안의 마커만 유효하므로, 순수 tool_calls인 빈 assistant 턴은 후보에서 아예 제외된다(prompt_caching.py:72-93). _completed_transaction_endpoint_indexes()는 완료된 툴 실행의 끝만 고른다. 진행 중인 트랜잭션에 찍으면 다음 턴에 그 뒤로 결과가 붙으면서 마커 위치가 밀려 캐시가 깨지기 때문이다. 테스트가 이 불변식을 직접 검증한다(tests/agent/test_prompt_caching.py:88 — 턴 전후로 마커 인덱스가 공유되는지).
빈 블록 가드도 실전 흔적이다. 프롬프트가 정확히 정적 프리픽스와 같으면 [프리픽스, ""]로 자르지 않고 통째로 한 블록에 찍는다 — 빈 text 블록은 네이티브 Anthropic에서 HTTP 400이다(prompt_caching.py:154-158).
TTL과 끄는 법
| 설정 키 | 값 | 효과 |
|---|---|---|
prompt_caching.cache_ttl |
"5m" (기본) |
마커는 {"type":"ephemeral"} — ttl 키 없음 |
"1h" |
{"type":"ephemeral","ttl":"1h"}. 쓰기 2x vs 5m의 1.25x |
|
falsy(false/null/off/none…) |
캐싱 전면 off. OAuth 구독 사용자·자체 마커를 주입하는 프록시용 | |
그 외("2h", 정수) |
disable이 아님 — 기본 TTL로 캐싱 유지 |
마지막 행이 중요하다. cache_ttl_means_disabled()의 docstring이 "Unknown values are NOT a disable"라고 못 박는다(agent_runtime_helpers.py:1957-1961). 오타로 캐시가 조용히 꺼지는 사고를 막는 fail-safe 방향 선택이다.
"입력 토큰 약 75% 절감"의 출처
찾았다. 그런데 벤치마크가 아니라 주석 한 줄이다.
# Anthropic prompt caching: auto-enabled for Claude models on native
# Anthropic, OpenRouter, and third-party gateways that speak the
# Anthropic protocol (``api_mode == 'anthropic_messages'``). Reduces
# input costs by ~75% on multi-turn conversations. Uses four breakpoints:
agent/agent_init.py:852-855
README·docs·테스트 어디에도 이 수치를 뒷받침하는 측정치는 없다. 설계상 기대치이며 저장소에서 수치 근거는 확인되지 않았다. 다만 산술적 상한은 따져볼 수 있다. 캐시 히트는 입력 원가의 0.10x로 과금되고(usage_pricing.py:109-110, 실제 엔트리 cache_read=0.10 / cache_write=1.25), 따라서 절감률 상한은 90%다. 절감 75%를 달성하려면 전체 입력 토큰의 약 83%가 히트여야 한다(0.9 × 0.83 ≈ 0.75). 도구 스키마 수십 개 + SOUL.md + 컨텍스트 파일이 시스템 쪽을 채우는 코딩 에이전트 루프라면 도달 가능한 범위지만, 쓰기 1.25x와 매 미스마다의 재기록을 감안하면 낙관적 상한에 가깝다. 사내에서 이 숫자를 인용하려면 cache_read_tokens/cache_write_tokens를 직접 계측하는 게 맞다 — 필드는 이미 usage_pricing.py:34-60에 있다.
실무 교훈
프롬프트를 문자열로 다루면 이 설계는 아예 표현이 안 된다. 필요한 건 세그먼트의 배열이고, 각 세그먼트에 (내용, 무효화 주기, 폭발 반경) 세 축이 붙는다. Hermes가 실제로 한 일은 셋이다. ① 조립 시점에 계층을 나누되 저장은 여전히 문자열 하나로 해서 세션 영속화와 비-Anthropic 전송을 안 건드린다(_apply_system_cache_markers docstring, prompt_caching.py:114-117). ② 분할은 요청 로컬로만 수행한다 — build_prompt_cache_plan()은 deep copy 위에서 작업하고 원본 트랜스크립트를 절대 재작성하지 않는다. ③ 정적 프리픽스는 영속화하지 않고 필요할 때 재구성하되, 반드시 startswith 게이트를 통과해야만 쓴다. 실패하면 조용히 레거시 레이아웃으로 떨어지고 저장된 바이트는 그대로 둔다(reconstruct_static_prefix, system_prompt.py:611-661). 캐시 최적화가 정확성을 인질로 잡지 않게 만드는 설계다. 하네스를 직접 만든다면 ③번을 가장 먼저 베끼는 걸 권한다.
참고 출처
↗ agent/prompt_caching.py:3-6 — 기본 레이아웃은 4 브레이크포인트, system_and_3는 폴백↗ agent/prompt_caching.py:383-389 — `remaining = 4 - breakpoints_used` 마커 예산 산술↗ agent/system_prompt.py:562-566 — stable/context/volatile 3계층 반환↗ agent/system_prompt.py:507-521 — 스킬 인덱스를 volatile 맨 앞에 두는 이유(주석)↗ agent/agent_init.py:852-855 — '~75% 입력 비용 절감' 주석(측정 근거 없음)↗ agent/agent_init.py:863-885 — prompt_caching.cache_ttl 5m/1h 및 비활성화 처리↗ agent/agent_runtime_helpers.py:1932-1961 — 툴 캐시 게이트(api.anthropic.com) 및 cache_ttl_means_disabled↗ agent/bedrock_adapter.py:1078-1085 — 저장소에서 'system_and_3' 문자열이 등장하는 유일한 위치↗ agent/usage_pricing.py:34-60, 105-152 — cache_read 0.10x / cache_write 1.25x 과금 엔트리↗ tests/agent/test_prompt_caching.py:88-129 — marker_count == 4 및 트랜잭션 종점 공유 검증↗ Anthropic — Prompt caching (cache_control 마커 최대 4개, 5m/1h TTL, 히트 0.1x 과금)컨텍스트 압축 — head/tail 보호와 "참조 전용" 프레이밍
핵심 요점
- ContextEngine ABC의 추상 메서드는 name/update_from_response/should_compress/compress 넷뿐이고 나머지는 옵셔널 훅이다. `context.engine` config로 한 번에 하나만 활성화되며 플러그인 엔진은 절대 자동 활성화되지 않는다 — LCM 등 대안 엔진은 docstring 예시로만 존재하고 `plugins/context_engine/`에는 `__init__.py`밖에 없다.
- ⚠️ ABC 클래스 기본값(threshold 0.75 / protect_last_n 6)과 내장 ContextCompressor 기본값(0.50 / 20)이 다르다. 어느 쪽을 인용했는지에 따라 발표자료 숫자가 틀린다.
- ⚠️ protect_first_n(기본 3)은 첫 압축 이후 0으로 감쇠한다(#11996). "첫 3개는 항상 보존"이 아니라 "첫 압축까지만" — 초기 턴 화석화를 막기 위한 의도적 설계.
- ⚠️ tail은 토큰 예산(`threshold_tokens × target_ratio`, 기본 실효 10%)으로 자르고, 메시지 개수 하한은 `_MAX_TAIL_MESSAGE_FLOOR = 8`로 상한이 걸린다 — protect_last_n=20을 설정해도 예산이 마르면 8개까지만 보장된다. docstring(5353행)은 예산 식을 `context_length × ratio`로 잘못 적어놨다.
- SUMMARY_PREFIX("[CONTEXT COMPACTION — REFERENCE ONLY] ...", 총 35줄)는 요약의 신뢰 등급을 '지시'에서 '참조 데이터'로 강등하는 장치다. 과거 5개 버전이 바이트 단위로 동결돼 회귀 테스트로 핀되어 있고, 그중 하나는 프레이밍이 너무 강해 도구 사용까지 억제돼 프로덕션에서 7턴 연속 나레이션만 한 사고 기록을 담고 있다.
- 보조 모델 요약(auxiliary 태스크 `compression`, max_tokens 의도적 미지정), 도구 출력 사전 프루닝(Phase 1 + 무-LLM `prune_tool_results_only`), Resolved/Pending-as-STALE 추적, `_previous_summary` 기반 누적 갱신 — 스펙에 적힌 네 가지 모두 소스에서 실제 구현이 확인된다.
컨텍스트 압축 — head/tail 보호와 "참조 전용" 프레이밍
엔진은 갈아끼우되, 한 번에 하나만
agent/context_engine.py(489줄)는 ContextEngine ABC 하나만 정의한다. 추상 메서드는 셋 — name(property), update_from_response(usage), should_compress(prompt_tokens=None), compress(messages, current_tokens, focus_topic, force, memory_context). 나머지는 전부 기본 구현이 있는 옵셔널 훅이고, 그중 select_context() / on_turn_complete() 두 개가 설계상 가장 중요하다. 소스의 주석이 이유를 직접 밝힌다: 이 훅이 없으면 매 턴 메시지 리스트에 접근해야 하는 엔진이 should_compress()를 무조건 True로 만들어 compress()를 콜백처럼 악용하게 되고, "선택(selection)"과 "축소(compression)"가 뒤섞인다(context_engine.py:236-241).
선택은 config 주도다. context.engine 키를 읽고, 아니면 plugins/context_engine/<name>/, 그다음 일반 플러그인 시스템, 마지막이 내장 ContextCompressor — 4단계(agent/agent_init.py:2396-2455). 중요한 건 플러그인 엔진은 절대 자동 활성화되지 않는다는 점이다. _engine_name != "compressor" 일 때만 탐색에 들어가고, 못 찾으면 경고 후 내장으로 폴백한다. 그리고 실제로 저장소에서 LCM 등 대안 엔진 구현은 존재하지 않는다: plugins/context_engine/에는 __init__.py 한 파일뿐이고, lcm_grep/lcm_describe/lcm_expand는 docstring 안의 가상 예시로만 등장한다(context_engine.py:418).
⚠️ 발표자료 대비 주의할 함정 — ABC의 클래스 기본값과 내장 구현의 기본값이 다르다. 어느 쪽 숫자를 인용했는지에 따라 발표자료가 틀린 값을 말하게 된다.
| 심볼 | ABC 기본값 (context_engine.py:121-123) | ContextCompressor (context_compressor.py:2386-2389) | config 예시 |
|---|---|---|---|
threshold_percent |
0.75 | 0.50 | compression.threshold: 0.50 |
protect_first_n |
3 | 3 | 3 |
protect_last_n |
6 | 20 | 20 |
summary_target_ratio |
— | 0.20 | target_ratio: 0.20 |
ABC 값은 "플러그인 작성자가 아무것도 안 했을 때"의 값이고, 내장 압축기는 자기 값을 따로 갖는다. 512K 미만 컨텍스트 모델은 _SMALL_CTX_THRESHOLD_PERCENT = 0.75로 상향-only 바닥이 걸린다(context_compressor.py:5385-5390).
head 는 영원하지 않다 — protect_first_n 의 감쇠
가장 반직관적인 설계는 head 보호가 한 번만 유효하다는 것이다.
if self.compression_count >= 1 or self._previous_summary:
return 0
agent/context_compressor.py:4986-4987 (_effective_protect_first_n)
이유는 주석에 있다(#11996): protect_first_n을 매 압축마다 적용하면 초기 턴이 화석화돼 자식 세션마다 재복사되고 영원히 요약되지 않는다. 첫 압축이 끝나는 순간 그 턴들은 이미 핸드오프 요약 안에 들어갔으므로 다시 지킬 이유가 없다. 시스템 프롬프트는 _protect_head_size()가 별도로 항상 보호한다. 재시작 후에는 head 근처에 핸드오프 요약이 있는지 탐침해서 감쇠 상태를 추론한다. ⚠️ 초기 발표자료가 "첫 3개 메시지는 항상 보존"이라고 썼다면 지금 소스와 어긋난다 — 정확히는 "첫 압축까지만".
교훈: 보호 규칙은 정적 상수가 아니라 세션 수명에 대한 함수다. "항상 지킨다"는 규칙은 롤링 압축이 도는 장기 세션에서 head를 무한히 키운다.
tail 은 개수가 아니라 토큰 예산으로 자른다
_find_tail_cut_by_tokens()는 끝에서 뒤로 걸으며 토큰을 누적한다. 예산은 tail_token_budget인데, 실제 계산식은 threshold_tokens * summary_target_ratio다(context_compressor.py:1698-1701). ⚠️ 같은 파일 5353-5355의 docstring은 이걸 summary_target_ratio * context_length라고 적어놨다 — threshold_percent(0.50) 배만큼 어긋나는 문서-코드 불일치다. 기본값으로 계산하면 컨텍스트의 20%가 아니라 10%(=0.50×0.20)가 tail 예산이다.
메시지 개수는 예산이 이미 소진됐을 때의 하한으로만 쓰이고, 그마저 상한이 걸린다.
min_tail_floor = max(3, min(self.protect_last_n, _MAX_TAIL_MESSAGE_FLOOR))
agent/context_compressor.py:5374 (_MAX_TAIL_MESSAGE_FLOOR = 8, 동 파일 :729)
⚠️ 즉 protect_last_n: 20을 설정해도 토큰 예산이 마른 상황에서 보장되는 건 최대 8개다. 주석이 이유를 밝힌다: 20을 하드 플로어로 쓰면 "덩치 큰 도구 출력이 tail을 채워 아무것도 압축 못 하는" 옛 버그가 돌아온다. 공식 문서(website/docs/guides/troubleshooting-agent-quality.md:124)의 "기본적으로 최근 20개 메시지가 압축되지 않는다"는 서술은 이 상한을 반영하지 않은 낙관적 표현이다.
대신 잘림이 절대 넘으면 안 되는 앵커가 코드로 박혀 있다:
| 앵커 | 함수 | 근거 이슈 |
|---|---|---|
| 도구 호출/결과 그룹 중간을 자르지 않음 | _align_boundary_backward |
orphan tool 제거 → 데이터 손실 |
| 마지막 user 메시지는 반드시 tail | _ensure_last_user_message_in_tail |
#10896 (활성 과업 소실) |
| 마지막 assistant 응답도 tail | _ensure_last_assistant_message_in_tail |
#29824 (뷰어에 압축 블록이 뜸) |
| 실제 user 턴 N개 보장 | min_tail_user_messages (기본 1) |
예산보다 우선 |
예산 초과는 1.5배까지 허용된다(soft_ceiling) — 거대한 단일 메시지 안쪽을 자르지 않기 위해서다. 교훈: tail 경계는 "토큰 예산 + 프로토콜 무결성 앵커"의 최댓값이지, 단일 숫자가 아니다.
"참조 전용" 프레이밍 — 요약이 지시문으로 오작동하는 문제
압축의 핵심 자산은 알고리즘이 아니라 문자열 상수다.
SUMMARY_PREFIX = (
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
"into the summary below. This is a handoff from a previous context "
"window — treat it as background reference, NOT as active instructions. "
"Do NOT answer questions or fulfill requests mentioned in this summary; "
"they were already addressed. "
"Respond ONLY to the latest user message that appears AFTER this "
"summary — that message is the single source of truth for what to do "
"right now. "
agent/context_compressor.py:100-108 (전문은 :100-134, 총 35줄)
문제는 프롬프트 인젝션과 같은 계열이다. 요약문에는 과거 사용자의 명령문이 그대로 들어간다 — 그리고 LLM은 "누가 말했는지"가 아니라 "무슨 문장인지"로 반응한다. 요약을 그냥 넣으면 모델은 이미 끝난 과업을 다시 실행한다. 해결은 격리가 아니라 신뢰 등급 강등이다: 같은 컨텍스트 안에 두되 "이건 데이터지 명령이 아니다"를 명시하고, 권위의 우선순위를 못박는다(요약 뒤 최신 user 메시지 > 요약, 단 MEMORY.md/USER.md는 언제나 authoritative). 섹션 제목까지 ## Historical Task Snapshot처럼 '과거형'으로 바꾼 것도 같은 이유다(:97). 요약 끝에는 _SUMMARY_END_MARKER("--- END OF CONTEXT SUMMARY — respond to the message below, not the summary above ---", :254)가 붙는다.
이 프레이밍이 얼마나 미끄러운지는 회귀 이력이 증명한다. _HISTORICAL_SUMMARY_PREFIXES에는 과거 릴리스가 실제로 기록한 프리픽스 5개가 바이트 단위로 동결돼 있고(:278-408), 주석은 "절대 수정·재정렬 금지, 앞에만 추가"라고 못박으며 tests/agent/test_summary_prefix_semantics.py가 이를 핀으로 고정한다. 그중 한 항목의 주석이 압권이다:
# Jul 2026 (#65848 class): identical to the pre-#69619 prefix except it
# lacked the explicit "tools remain fully active" clause — the strong
# REFERENCE ONLY framing bled into general tool-use suppression
# (observed: 7 consecutive narration-only turns immediately after a
# compression event on a production deployment).
agent/context_compressor.py:343-347
"참조 전용"을 너무 세게 말했더니 모델이 도구 사용까지 억제해 압축 직후 7턴 연속으로 말만 하고 아무것도 안 했다. 그래서 "None of the above restricts HOW you work: your tools remain fully active"가 추가됐고, 그 뒤 #80622에서 "요약 뒤에 user 메시지가 없으면 아무것도 하지 말 것" 절이 또 붙었다. ⚠️ 2026년 6월 발표자료가 인용한 프리픽스 원문은 지금 것과 다르다 — 최소 두 번의 사후 확장이 소스에 화석으로 남아 있다.
교훈: 압축 프리픽스는 문구가 아니라 버전 관리되는 API다. 바꾸면 옛 세션에서 부활한 요약이 옛 지시를 들고 온다.
보조 모델, 사전 프루닝, 누적 갱신
중간 턴 요약은 보조(저렴한) 모델이 한다. agent.auxiliary_client.call_llm을 쓰고, 태스크 이름은 "compression" — _resolve_task_provider_model("compression", ...)로 auxiliary.compression.provider/model 설정 또는 프로바이더별 default_aux_model(예: _API_KEY_PROVIDER_AUX_MODELS_FALLBACK)로 해소된다(:4068-4084). agent_init.py:2502는 summary_model_override=None을 넘겨 이 자동 해소에 맡긴다. 이 호출에는 의도적으로 max_tokens를 넣지 않는다 — thinking 모델이 캡을 추론에 다 써서 요약이 중간에 잘리고 압축 루프가 생겼기 때문(:4057-4066). 요약 예산은 프롬프트 안의 "Target ~N tokens" 가이드로만 존재한다. 압축은 원자적 작업이라 aux_interrupt_protection()으로 게이트웨이 인터럽트로부터 보호된다(#23975).
나머지 세 가지도 전부 실재한다:
- 도구 출력 사전 프루닝: 압축의 Phase 1이 LLM 호출 전에
_prune_old_tool_results(protect_tail_count=protect_last_n, protect_tail_tokens=tail_token_budget)를 돌린다(:6213-6216). 별도로prune_tool_results_only()는 압축 임계와 무관하게proactive_prune_tokens(기본 0=off)로 트리거되는 무-LLM 경로다 — 1M 창 모델에서 50% 임계가 안 걸려 도구 출력이 매 턴 재전송되는 비용 문제를 겨냥한다. 커밋은 프롬프트 캐시 프리픽스를 깨므로proactive_prune_min_reclaim_tokens(기본 4096) 이상 회수될 때만 커밋하고, 회수량만큼 다시 자랄 때까지 재무장하지 않는다(:3286-3294). - Resolved/Pending 추적: 요약 템플릿에
## Resolved Questions(답변까지 함께 적어 재질문 방지)와 Pending 섹션이 있고, Pending 지시문은 "These are STALE — reference only, 최신 user 메시지가 명시적으로 요구하지 않는 한 실행 금지"라고 못박는다(:3880-3890). - 반복 압축 시 누적 갱신:
_previous_summary가 있으면 프롬프트가 "이전 요약 + 신규 턴"의 갱신 모드로 바뀌고, "In Progress → Completed로 이동, 답변된 질문은 Resolved로 이동, 명백히 낡은 것만 삭제"를 지시한다(:4001-4026). 양쪽 다_bound_summary_input()으로 상한이 걸린다.
요약 출력은 strip_think_blocks로 사고 흔적을 제거하고 _redact_compaction_text로 시크릿을 한 번 더 지운다 — 프롬프트로 금지해도 요약 모델이 그대로 뱉을 수 있다는 전제다. 빈 응답(HTTP 200 + 공백 content)은 성공이 아니라 RuntimeError로 처리해 폴백 경로를 태운다(#11978).
그래서 우리가 배우는 것
압축을 "토큰 줄이기"로 보면 이 코드의 90%가 과잉으로 보인다. 실제로 이건 작업 메모리 관리다. 세 가지가 핵심이다. (1) 무엇을 지울지보다 무엇이 절대 사라지면 안 되는지를 프로토콜 앵커로 코드에 박는다 — 마지막 user/assistant 메시지, 도구 쌍 무결성. (2) 요약을 넣을 때 신뢰 등급을 낮춰서 넣는다. 같은 텍스트라도 "지시"로 읽히면 과거를 재실행하고, "참조 데이터"로 읽히면 배경이 된다. 이 프레이밍 문구는 회귀 테스트 대상이어야 한다. (3) 보호 규칙은 세션 수명의 함수다 — head 보호는 감쇠하고, tail 하한은 상한을 갖는다.
참고 출처
↗ agent/context_engine.py:89-130 (ContextEngine ABC, 클래스 기본값 threshold 0.75 / protect_first_n 3 / protect_last_n 6)↗ agent/context_engine.py:215-279 (select_context — 선택과 압축의 분리)↗ agent/context_compressor.py:100-134 (SUMMARY_PREFIX 원문)↗ agent/context_compressor.py:278-408 (_HISTORICAL_SUMMARY_PREFIXES — 동결된 과거 프리픽스 5개, 343-347행 도구 억제 사고 주석)↗ agent/context_compressor.py:5346-5440 (_find_tail_cut_by_tokens), :729 (_MAX_TAIL_MESSAGE_FLOOR = 8), :1696-1701 (tail_token_budget)↗ agent/context_compressor.py:4967-5025 (_effective_protect_first_n 감쇠 / _protect_head_size)↗ agent/context_compressor.py:3255-3300 (prune_tool_results_only), :6213-6216 (Phase 1 사전 프루닝)↗ agent/context_compressor.py:3880-3890 (Resolved/Pending 지시문), :4001-4026 (반복 압축 누적 갱신 프롬프트), :4055-4160 (보조 모델 호출)↗ agent/agent_init.py:2396-2502 (context.engine 4단계 해소, summary_model_override=None)↗ cli-config.yaml.example:425-545 (compression 블록: threshold 0.50 / target_ratio 0.20 / protect_last_n 20 / protect_first_n 3 / proactive_prune_*)↗ website/docs/developer-guide/context-compression-and-caching.md (공식 문서 — 본문에서 지적한 protect_last_n 서술 불일치의 출처)멀티에이전트 — delegate_task와 컨텍스트 격리
핵심 요점
- 부모 컨텍스트에 '요약만' 들어간다는 docstring 주장은 절반만 참 — entry dict(delegate_tool.py:2619,2635)는 tool_trace를 함께 직렬화한다. 단 내용이 아니라 argument_keys·args_bytes·result_bytes·ok/error 같은 메타데이터뿐이고 URL 크레덴셜은 제거된다.
- _build_child_agent은 messages 인자 자체를 넘기지 않고(goal이 첫 유저 메시지) skip_context_files/skip_memory=True, clarify_callback=None, ephemeral_system_prompt=goal+context+workspace로 자식을 만든다. 단 prefill_messages는 부모 값을 그대로 상속(:1628)해 격리의 유일한 누수 통로다.
- 툴 차단은 두 겹 — DELEGATE_BLOCKED_TOOLS 5종을 _strip_blocked_tools가 툴셋 이름으로 거르고, hermes-cli 같은 혼합 번들에 숨은 차단 툴은 _blocked_toolsets_for_role이 만든 deny 툴셋을 disabled_toolsets로 넘겨 composite 확장 '이후'에 뺀다.
- 요약 예산은 min(DEFAULT_MAX_SUMMARY_CHARS=24000, 부모 잔여 헤드룸×_SUMMARY_HEADROOM_FRACTION=0.5 ÷ 배치크기, 하한 _MIN_SUMMARY_CHARS=2000). 초과분은 head 75%/tail 25%로 자르고 전문은 cache/delegation에 흘려 read_file offset 푸터로 지연 로딩시킨다.
- issue/PR #9126의 원인은 개별 요약 크기가 아니라 팬아웃 배수 — N개 요약이 동시에 들어와 부모가 넘치고→압축 호출→429→재시도의 죽음의 나선이 돌았다. 그래서 예산을 배치 크기로 '나눈다'.
- 위임은 토큰이 아니라 부모 컨텍스트 예산을 아낀다 — 자식 비용은 _finalize_child_results가 부모 session_estimated_cost_usd에 합산하고(:3000) 반복 예산도 iteration_budget=None으로 별도다. ⚠️ MAX_DEPTH 실측값은 1(평면)이며 본문 주석의 'default 2'는 낡은 기술이다.
멀티에이전트 — delegate_task와 컨텍스트 격리
격리의 실제 경계는 "요약만"이 아니다
모듈 docstring은 격리 계약을 이렇게 선언한다.
Each child gets:
- A fresh conversation (no parent history)
- Its own task_id (own terminal session, file ops cache)
- The parent's toolsets, with child-only blocked tools stripped
- A focused system prompt built from the delegated goal + context
The parent's context only sees the delegation call and the summary result,
never the child's intermediate tool calls or reasoning.
tools/delegate_tool.py:10-17
앞의 네 줄은 소스대로다. 마지막 문장은 절반만 맞다. 자식 결과 dict를 조립하는 지점을 보면 summary 외에 tool_trace가 같이 실려 모델에게 직렬화된다(entry 리터럴 delegate_tool.py:2619, "tool_trace": tool_trace 는 :2635). 다만 이 trace는 내용이 아니라 회계 장부다. _summarize_tool_arguments는 인자 이름 목록(argument_keys)과 부수효과 대상(path/url/cwd 등 15개 키)만 남기고, URL은 urlsplit 후 재조립해 user:password@ 크레덴셜을 버린다(:409-457). 결과는 result_bytes + ok|error 뿐이다.
정확한 표현은 이렇다 — 부모는 자식이 "무슨 도구를 몇 바이트어치 썼는지"는 보지만 "그 도구가 뭘 반환했는지"는 못 본다. 이건 설계 실수가 아니라 의도다. 부모가 자식 요약을 검증하려면 "정말 파일을 썼나"를 판단할 최소 신호가 필요한데, 그걸 전문(全文) 없이 주는 방식이다. 툴 스키마 설명이 못을 박는다: "Child summaries are SELF-REPORTS, not verified facts"(:4090). 서브에이전트를 붙일 때 우리가 베껴야 할 건 격리 자체가 아니라 이 비대칭적 노출 설계다.
⚠️ 발표자료 대비 확인 필요: "자식의 도구 호출이 부모에 전혀 안 보인다"고 소개했다면 현재 소스와 어긋난다. 메타데이터 레벨로는 보인다.
_build_child_agent — 주입되는 것과 잘려나가는 것
AIAgent 생성자 호출부(:1620-1656)가 계약의 실체다.
enabled_toolsets=child_toolsets,
disabled_toolsets=child_disabled_toolsets,
quiet_mode=True,
ephemeral_system_prompt=child_prompt,
platform="subagent",
skip_context_files=True,
skip_memory=True,
clarify_callback=None,
tools/delegate_tool.py:1630-1638
| 항목 | 자식이 받는 값 | 설계 의도 |
|---|---|---|
| 대화 히스토리 | 인자 자체가 없음 — run_conversation(user_message=goal, ...)(:2325-2331) |
부모 문맥 0. goal이 곧 첫 유저 메시지 |
| 시스템 프롬프트 | ephemeral_system_prompt = goal + context + workspace(:900-978) |
trajectory에 저장 안 되는 일회성 |
| 프로젝트 컨텍스트 파일 | skip_context_files=True |
AGENTS.md류 재주입 금지 |
| 메모리 | skip_memory=True + memory 툴 차단 |
공유 MEMORY.md 오염 방지 |
| 사용자 질의 | clarify_callback=None |
자식은 사람에게 물을 수 없다 |
| task_id | child_task_id = _subagent_id(:2272) |
터미널 세션·file_state 캐시 분리 |
| cwd | 부모 값을 시드만 하고 이후 cd는 격리(:2283) |
자식의 cd가 부모로 역류하지 않음 |
| 반복 예산 | iteration_budget=None + max_iterations 기본 50 |
부모와 합산되지 않는 독립 예산 |
| 세션 DB | session_db 공유, parent_session_id 설정 |
관측은 되되 세션 피커에선 제외(_delegate_from) |
prefill_messages |
부모 값을 그대로 상속(:1628) |
유일하게 부모 대화 조각이 흐를 수 있는 통로 |
마지막 줄이 실무적으로 중요하다. "부모 히스토리 없음"은 messages에 대해서만 참이고, 부모가 prefill을 쓰고 있으면 그건 자식에게도 간다. 격리를 감사할 때는 "히스토리를 안 넘겼다"가 아니라 **"프롬프트 조립에 참여하는 모든 입력을 열거했다"**를 기준으로 삼아야 한다.
툴 차단은 두 겹이다. DELEGATE_BLOCKED_TOOLS = {delegate_task, clarify, memory, send_message, cronjob}(:49-57)를 _strip_blocked_tools가 툴셋 이름 단위로 걷어내고(:1005), 그것만으로는 hermes-cli 같은 혼합 번들 안에 숨은 차단 툴을 못 잡으므로 _blocked_toolsets_for_role이 만든 단일-툴 deny 툴셋을 disabled_toolsets로 따로 넘겨 composite 확장 이후에 빼게 한다(:1026-1044). 여기에 kanban은 항상 추가된다. 툴 게이팅을 이름 필터 한 겹으로 끝내면 번들 확장 시점에 조용히 새는데, 이 파일은 그 함정을 두 단계로 막았다.
요약 예산 — #9126의 죽음의 나선
부모 컨텍스트를 실제로 지키는 건 격리가 아니라 반환값 크기 제한이다. 상수는 셋이다.
| 상수 | 값 | 역할 |
|---|---|---|
DEFAULT_MAX_SUMMARY_CHARS |
24000 | 정적 상한(0이면 해제), delegation.max_summary_chars로 덮어씀 |
_SUMMARY_HEADROOM_FRACTION |
0.5 | 부모 잔여 헤드룸 중 배치 전체가 먹을 수 있는 비율 |
_MIN_SUMMARY_CHARS |
2000 | 부모가 이미 꽉 차도 보장되는 하한 |
delegate_tool.py:823-831
batch_token_budget = int(headroom_tokens * _SUMMARY_HEADROOM_FRACTION)
per_summary_tokens = batch_token_budget // max(1, n_summaries)
per_summary_chars = per_summary_tokens * 4 # ~4 chars/token
return max(_MIN_SUMMARY_CHARS, per_summary_chars)
tools/delegate_tool.py:2012-2015
핵심은 headroom_tokens = context_length - used_tokens - compressor.max_tokens, 즉 컨텍스트 총량이 아니라 압축기 출력분까지 제외한 실제 입력 여유를 분모로 쓴다는 점이다. 그리고 최종 cap은 min(정적 상한, 동적 예산)(:2053). 초과분은 버리지 않고 **head 75% / tail 25%**로 자른 뒤(:1939) 전문을 cache/delegation/에 흘리고, 푸터에 read_file path=... offset=N을 계산해 붙인다(:1926-1979). 잘린 요약이 "정보 손실"이 아니라 "지연 로딩"이 되는 구조다. tail을 남기는 이유도 명시돼 있다 — 결론·변경 파일·이슈는 요약 끝에 오기 때문이다.
주석이 밝힌 유래가 이 장치의 진짜 교훈이다.
This addresses issue/PR #9126: batch fan-out returned N full summaries verbatim,
blowing the parent context and (on rate-limited providers) triggering a
compression/429 death spiral.
tools/delegate_tool.py:2031-2033
N개 자식을 병렬로 띄우면 요약도 N개가 동시에 부모 컨텍스트로 들어온다. 부모가 넘치면 압축이 돌고, 압축은 또 한 번의 API 호출이며, 레이트리밋 사업자에서는 그 호출이 429를 받고, 재시도가 다시 압축을 부른다. 개별 요약 크기가 아니라 팬아웃 배수가 사고의 원인이라 per-summary 상한 하나로는 못 막는다 — 그래서 예산을 batch_token_budget ÷ n_summaries로 나눈다. 서브에이전트를 병렬로 굴리는 하네스를 만든다면, 반환 경로의 예산은 반드시 배치 크기의 함수여야 한다.
위임은 컨텍스트를 아끼지 토큰을 아끼지 않는다
"위임하면 공짜로 추론이 늘어난다"는 흔한 오해를 이 소스는 스스로 반박한다. _finalize_child_results는 자식 비용을 부모 세션 비용에 합산하고(session_estimated_cost_usd = current + children_cost_total, :3000), 각 결과에 cost_usd를 붙여 모델에게도 보여준다(:2657). 반복 예산도 iteration_budget=None이라 부모 50 + 자식 N×50이 될 수 있다(:1650-1653, :1655). 절약되는 건 단 하나, 부모의 컨텍스트 윈도우 예산이다. 자식의 30번의 grep과 실패한 빌드 로그는 부모 프롬프트에 한 줄도 남지 않는다.
깊이 기본값도 이 회계관을 따른다. MAX_DEPTH = 1(:128) — 부모(0) → 자식(1)의 평면 구조가 기본이고, 손자는 delegation.max_spawn_depth를 명시적으로 올려야 생긴다. 상한은 없지만 주석은 "각 레벨이 API 비용을 곱한다"고 못 박는다(:700-736).
⚠️ 소스 내부 불일치:
delegate_task본문 주석은 여전히default 2 for parity with the original MAX_DEPTH constant(:3167-3168)라고 적혀 있지만 실제MAX_DEPTH는 1이다. 발표자료가 "기본 2단계"라고 소개했다면 그건 이 낡은 주석과 같은 세대의 정보다. 실측값은 1.
서브에이전트를 도입할지 판정하는 기준
소스와 툴 설명(:4071-4105)에서 뽑은 판정선이다.
- 부모 컨텍스트를 오염시킬 중간 산출물이 많을 때만 이득. 최종 답이 짧고 과정이 긴 작업(코드 탐색, 리서치, 로그 뒤지기)이 정확히 그 형태다. 반대로 과정이 짧으면 위임 오버헤드(에이전트 생성·별도 프롬프트·요약 왕복)가 순손해다.
- 기계적 다단계 작업은 위임이 아니라 코드 실행으로. 툴 설명이 명시적으로
execute_code로 보낸다. 추론이 필요 없는 반복은 LLM 루프를 하나 더 띄울 이유가 없다. - 사람에게 물어야 끝나는 일은 위임 금지.
clarify_callback=None이라 자식은 구조적으로 질문할 수 없고, 승인 콜백도 기본이_subagent_auto_deny(:104-115)라 위험 명령은 조용히 거부된다. 애매한 요구사항을 위임하면 자식은 추측으로 완주하고 그걸 성공으로 보고한다. - 외부 부수효과가 있는 일은 위임하되 검증은 부모가. 요약은 자기보고다. 업로드·발행·원격 쓰기는 URL/ID/절대경로 같은 검증 가능한 핸들을 받아 부모가 직접 확인해야 한다.
- 세션을 넘어 살아남아야 하는 일은 위임이 아니다.
/stop,/new, 프로세스 종료가 실행 중인 자식을 버린다 — 그런 작업은 cronjob이나 백그라운드 터미널로 보낸다.
여기에 소스에서 하나 더 추가할 만하다. 자식이 부모가 이미 읽은 파일을 수정하면 file_state.writes_since로 감지해 요약 뒤에 "re-read before editing" 노트를 붙인다(:2696-2723). 컨텍스트를 격리하면 부모가 들고 있는 파일 스냅샷이 조용히 낡는다 — 격리형 서브에이전트를 만들 거라면 이 스테일 감지는 선택이 아니라 필수 부속품이다.
참고 출처
↗ tools/delegate_tool.py:10-17 (모듈 docstring — 격리 계약)↗ tools/delegate_tool.py:823-831 (DEFAULT_MAX_SUMMARY_CHARS / _SUMMARY_HEADROOM_FRACTION / _MIN_SUMMARY_CHARS)↗ tools/delegate_tool.py:1305-1720 (_build_child_agent — 주입/제거 항목)↗ tools/delegate_tool.py:2021-2073 (_apply_summary_budget — issue/PR #9126 압축·429 나선)↗ tools/delegate_tool.py:2619-2660 (자식 결과 entry — summary + tool_trace + cost_usd)↗ tools/delegate_tool.py:4059-4105 (_build_top_level_description — 위임 판정 기준)↗ NousResearch/hermes-agent (MIT, HEAD 5292074, 2026-08-08)역할·깊이·동시성 — 위임 트리를 제어하는 파라미터
핵심 요점
- ⚠️ 발표자료의 `_MAX_SPAWN_DEPTH_CAP = 3`은 현재 소스에 없다(리포 전체 grep 0건). 남은 건 `_MIN_SPAWN_DEPTH = 1` 하한뿐이고, 주석이 "No upper ceiling on spawn depth"라고 명시한다 — 깊이는 안전이 아니라 비용 문제이므로 상한을 코드가 정하지 않고 에러 메시지로 비용을 경고한다.
- ⚠️ `DEFAULT_CHILD_TIMEOUT`이 600초에서 `None`(무제한)으로 바뀌었다. 주석이 이유를 자백한다 — 깊은 리뷰·리서치 팬아웃·느린 추론 모델 같은 정상 작업이 중간에 죽고 있었다. 고정 타임아웃은 '느린 것'과 '멈춘 것'을 구분하지 못한다.
- 대체 장치는 30초 주기 하트비트 staleness 모니터. 유휴(`current_tool` 없음, 15사이클=450초)와 도구 실행 중(40사이클=1200초)을 다른 임계값으로 판정하고, 모델 응답 대기는 `direct_api_call`의 15초 티커가 `last_activity_ts`를 갱신하므로 유휴로 분류되지 않는다 — 신호 주기(15s)가 감시 주기(30s)보다 짧아야 한다는 계약.
- 모니터는 자식을 죽이지 않는다. 부모 활동 갱신을 멈춰 기존 게이트웨이 inactivity 타임아웃이 발화하게 둔다 — 판정자와 집행자를 분리해 종료 경로를 하나로 유지.
- `_DEFAULT_MAX_CONCURRENT_CHILDREN = 3`도 하한 1·상한 없음. >10 경고를 `_HIGH_CONCURRENCY_WARNED`로 프로세스당 1회만 발화하는데, 게터가 `get_definitions()` 스키마 재생성마다 호출되기 때문 — 경고 빈도는 조건이 아니라 호출 지점이 정한다.
- 중첩 위임은 `role='orchestrator'`가 `delegation` 툴셋을 무조건 재부착해 부여하며, 모델에게는 `toolsets` 인자 자체가 없다. 자식은 부모 툴셋을 상속·교집합할 뿐이라 권한 상승 경로가 모델 출력 표면에서 제거돼 있다. 기본값(max_spawn=1)에선 orchestrator가 항상 leaf로 강등된다.
- 리포지토리 자체 문서도 드리프트했다: `cli-config.yaml.example:1308`은 아직 "range: 1-3", `delegate_tool.py:3167` 주석은 "default 2 for parity with the original MAX_DEPTH constant"라고 하지만 `MAX_DEPTH = 1`이다. 발표자료의 클램프 기술은 당시 문서를 정확히 옮긴 것이었다.
역할·깊이·동시성 — 위임 트리를 제어하는 파라미터
delegate_task는 자식 에이전트를 띄우는 도구지만, 실제 설계 판단은 도구 본체가 아니라 그 위에 얹힌 게터 함수 여섯 개에 들어 있다. 상수를 직접 참조하지 않고 _get_*()로 감싼 이유는 단순하다 — 값이 config → env → 기본값 순서로 해석되고, 잘못된 값은 예외 대신 경고 후 조용한 강등(silent degrade) 으로 처리되기 때문이다. 위임은 실패 지점이 원격이라, 여기서 던지면 부모까지 죽는다.
제어 파라미터 한눈에
_load_config()가 읽는 delegation.* 네임스페이스 기준이다.
| 키 | 의미 | 현재 기본값 | 발표자료 표기 | 비고 |
|---|---|---|---|---|
max_spawn_depth |
위임 트리 깊이 상한 | 1 (MAX_DEPTH) |
1, [1,3] 클램프 |
⚠️ 상한 삭제. 하한 _MIN_SPAWN_DEPTH = 1만 남음 |
max_concurrent_children |
배치 병렬 자식 수 + background 동시 실행 | 3 | 3 | 하한 1, 상한 없음. >10이면 1회 경고 |
child_timeout_seconds |
자식 벽시계 캡 | 0 = 무제한 (DEFAULT_CHILD_TIMEOUT = None) |
600초 | ⚠️ 기본 해제. 양수 지정 시 하한 30초 |
max_iterations |
자식 1인당 도구 호출 턴 예산 | 50 | 50 | 모델이 넘긴 값은 무시, config가 authoritative |
orchestrator_enabled |
orchestrator 역할 킬스위치 | true | true | false면 조용히 leaf로 강등 |
max_async_children |
(구) background 전용 캡 | 폐기 | — | max_concurrent_children에 통합, 1회 deprecation 경고 |
inherit_mcp_toolsets |
좁힌 자식에 부모 MCP 툴셋 유지 | true | — | false면 엄격 교집합 |
subagent_auto_approve |
위험 명령 승인 처리 | false (auto-deny) | — | 부모 TUI가 stdin을 쥐고 있어 블로킹이 곧 데드락 |
max_concurrent_children, child_timeout_seconds는 DELEGATION_* env 폴백이 있지만 max_spawn_depth에는 없다. 깊이는 셸에서 실수로 켜지면 안 되는 값이라는 판단으로 읽힌다.
깊이: 상한이 사라졌다 ⚠️ 발표자료 대비 변경
발표자료에 있던 _MAX_SPAWN_DEPTH_CAP = 3은 현재 소스에 존재하지 않는다(리포 전체 grep 0건). 남은 건 하한뿐이다.
MAX_DEPTH = 1 # flat by default: parent (0) -> child (1); grandchild rejected unless max_spawn_depth raised.
_MIN_SPAWN_DEPTH = 1
# No upper ceiling on spawn depth — like max_concurrent_children, depth has a
# floor of 1 and no ceiling. Deeper trees multiply API cost, so the default
# stays flat (MAX_DEPTH = 1); raising the config knob is an explicit opt-in.
tools/delegate_tool.py:128-134
설계 의도가 주석에 그대로 있다. 깊이는 안전 문제가 아니라 비용 문제라는 것. 한 레벨 늘 때마다 API 비용이 곱해지므로 기본은 평면으로 두고, 올리는 건 명시적 opt-in으로 만든다. 하지만 "얼마까지가 안전한가"는 운영자만 아는 값이라 상한을 코드가 정하지 않는다. 대신 깊이 초과 시 반환하는 에러 문구가 "no hard ceiling, but each level multiplies API cost"라고 비용을 직접 언급한다(:3171-3178). 가드레일을 숫자로 박는 대신 에러 메시지로 교육한다 — 정책이 사람마다 다른 파라미터를 다루는 한 가지 방법이다.
타임아웃: 스톱워치를 진행 신호 감시로 바꾸다
이 섹션의 백미. 발표자료의 DEFAULT_CHILD_TIMEOUT = 600은 이제 None이고, 소스 주석이 이유를 자백한다.
# No default wall-clock cap on child agents: legitimate heavy subagent work
# (deep reviews, research fan-outs, slow reasoning models) was being killed
# mid-task. Errors should come from what the child actually does; stuck-child
# detection lives in the heartbeat staleness monitor below.
DEFAULT_CHILD_TIMEOUT: Optional[float] = None
tools/delegate_tool.py:832-837
고정 타임아웃의 근본 문제는 느린 것과 멈춘 것을 구분하지 못한다는 점이다. 깊은 코드리뷰, 대규모 리서치 팬아웃, 느린 추론 모델은 정상 작동 중에도 600초를 넘긴다. 그래서 판정 기준을 "얼마나 걸렸나"에서 "진행 신호가 오는가"로 바꿨다.
교체된 장치는 30초 주기 하트비트 루프다. 매 사이클마다 자식의 get_activity_summary()에서 세 신호를 뽑고, 하나라도 전진하면 카운터를 0으로 되돌린다.
| 신호 | 의미 |
|---|---|
api_call_count 증가 |
턴이 진행됨 |
current_tool 변경 |
다른 도구로 넘어감 |
last_activity_ts 전진 |
스트림 청크 / 모델 대기 중 갱신 |
임계값은 자식 상태에 따라 다르다. current_tool이 없으면(턴 사이 유휴) _HEARTBEAT_STALE_CYCLES_IDLE = 15 → 450초, 도구 실행 중이면 _HEARTBEAT_STALE_CYCLES_IN_TOOL = 40 → 1200초. 긴 터미널 명령이나 대용량 파일 읽기는 원래 오래 걸리니 관대하게, 아무것도 안 잡고 멈춰 있으면 빡빡하게.
핵심 디테일은 모델 응답 대기가 유휴로 분류되지 않는다는 것이다. 비스트리밍 API 호출은 요청이 열려 있는 동안 별도 스레드가 _DIRECT_API_ACTIVITY_HEARTBEAT_SECONDS = 15.0 주기로 _touch_activity()를 때린다(agent/chat_completion_helpers.py:609, 755-757). 15초 티커가 30초 샘플러보다 빠르므로 감시자는 매 사이클 last_activity_ts 전진을 반드시 관측한다 — 느린 로컬 GGUF나 긴 prefill 모델이 한 번의 completion 때문에 죽지 않는 건 이 주기 관계가 보장한다. 감시 간격보다 신호 간격이 짧아야 한다는, 흔히 놓치는 계약이다.
마지막으로 이 모니터는 자식을 죽이지 않는다. staleness 판정이 서면 break로 부모 활동 갱신을 멈출 뿐이고, 그러면 게이트웨이의 기존 inactivity 타임아웃이 알아서 발화한다(:2183-2190). 판정자와 집행자를 분리해 종료 경로를 하나로 유지한 것 — 하네스를 만든다면 훔칠 만한 패턴이다.
동시성: 상한 없음, 경고는 한 번
_DEFAULT_MAX_CONCURRENT_CHILDREN = 3. 여기도 하한 1만 강제하고 상한이 없다. 10을 넘기면 "각 자식이 독립적으로 토큰을 쓰므로 비용이 선형으로 곱해진다"는 경고를 띄우는데, _HIGH_CONCURRENCY_WARNED 전역 플래그로 프로세스당 1회만 발화한다. 이유가 재미있다 — 이 게터는 get_definitions() 스키마 재생성 때마다(도구 설명 문자열에 현재 한도를 박기 때문에) 호출되므로, 플래그가 없으면 delegate_task를 한 번도 안 써도 매 턴 로그가 도배된다(:122-127). 경고의 발화 빈도는 조건이 아니라 호출 지점이 정한다는 교훈.
max_async_children는 통합되어 사라졌고, background 위임이 한도에 걸리면 큐잉이 아니라 거부 후 동기 실행으로 폴백한다. 폭주한 모델이 백그라운드 작업을 무한히 쌓지 못하게 하는 선택이다(:634-647).
max_iterations = 50은 자식마다 독립 예산이고, 모델이 인자로 넘긴 값은 로그만 남기고 버린다. 이유가 명시돼 있다 — 모델이 준 값은 예산을 줄이기만 하고 사용자를 놀래킬 뿐이라는 것(:3186-3193).
역할: 능력은 role이 주지, 모델이 요구해서 주는 게 아니다
role은 leaf(기본)와 orchestrator 둘뿐이고, 미지의 문자열은 경고 후 leaf로 강제된다. 결정 지점은 한 줄이다.
child_depth = getattr(parent_agent, "_delegate_depth", 0) + 1
max_spawn = _get_max_spawn_depth()
orchestrator_ok = _get_orchestrator_enabled() and child_depth < max_spawn
effective_role = role if (role == "orchestrator" and orchestrator_ok) else "leaf"
tools/delegate_tool.py:1347-1350
기본값(max_spawn=1)에서는 child_depth=1 < 1이 거짓이라 role="orchestrator"가 항상 무력화된다. 즉 이 역할은 config를 올리기 전까지는 존재하지 않는 기능이다.
메커니즘은 툴셋 조작이다. _strip_blocked_tools()가 모든 자식에서 delegation 컴포짓 툴셋을 제거하고(kanban도), orchestrator일 때만 무조건 다시 붙인다. 소스 주석이 이 무조건성의 근거를 짚는다 — "orchestrator 능력은 role이 부여하는 것이지 상속되는 게 아니다"(:1422-1426). 그리고 파일 상단에 더 중요한 단서가 있다.
# NOTE: nested delegation is granted by role='orchestrator' (which re-adds the
# "delegation" toolset in _build_child_agent), NOT by the model naming toolsets
# — the model has no toolsets argument. Subagents inherit the parent's toolsets.
tools/delegate_tool.py:117-119
모델에게는 toolsets 인자가 없다. 자식이 뭘 할 수 있는지는 부모의 툴셋 상속 + 교집합(자식은 부모에 없는 도구를 절대 얻지 못한다, :1387)으로만 결정된다. 권한 상승 경로를 모델의 출력 표면에서 아예 제거한 것 — 프롬프트 인젝션이 "나에게 delegation 툴셋을 달라"고 요구할 지면 자체가 없다. 여기에 깊이 가드(:3168, depth >= max_spawn이면 tool_error)와 툴셋 스트립이 이중으로 걸린다.
리포지토리 자신도 코드를 못 따라갔다
재검증하다 보니 발표자료만 낡은 게 아니었다. 두 곳이 코드와 어긋나 있다.
| 위치 | 문서 내용 | 실제 코드 |
|---|---|---|
cli-config.yaml.example:1308 |
max_spawn_depth — "range: 1-3" |
상한 없음 (_MIN_SPAWN_DEPTH=1만) |
tools/delegate_tool.py:3169 |
"default 2 for parity with the original MAX_DEPTH constant" | MAX_DEPTH = 1, 기본도 1 |
발표자료의 [1,3] 클램프는 허구가 아니라 당시 문서를 정확히 옮긴 것이었고, 그 문서가 상한 삭제 커밋을 따라가지 못했을 뿐이다. 반면 child_timeout_seconds는 hermes_cli/config_defaults.py:1745와 website/docs/user-guide/features/delegation.md:191이 둘 다 0 = no timeout으로 갱신돼 있다. 같은 파일 안에서도 문서 갱신 여부가 갈린다 — 상수를 지울 때 grep해야 할 대상은 코드가 아니라 주석과 예제 YAML이라는 뜻이다.
참고 출처
↗ tools/delegate_tool.py:128-134 — MAX_DEPTH, _MIN_SPAWN_DEPTH, 상한 없음 주석↗ tools/delegate_tool.py:2119-2128 — DEFAULT_CHILD_TIMEOUT = None 및 사유 주석↗ tools/delegate_tool.py:2138-2154 — _HEARTBEAT_INTERVAL / _HEARTBEAT_STALE_CYCLES_IDLE=15 / _IN_TOOL=40↗ tools/delegate_tool.py:2132-2190 — 하트비트 루프, 3신호 staleness 판정, break 후 게이트웨이 타임아웃 위임↗ tools/delegate_tool.py:587-625 — 중첩 위임 NOTE, _DEFAULT_MAX_CONCURRENT_CHILDREN=3, _HIGH_CONCURRENCY_WARNED↗ tools/delegate_tool.py:657-747 — _get_child_timeout / _get_max_spawn_depth / _get_orchestrator_enabled↗ tools/delegate_tool.py:1347-1350, 1422-1426 — effective_role 결정과 delegation 툴셋 재부착↗ tools/delegate_tool.py:3167-3193 — 깊이 가드 tool_error, DEFAULT_MAX_ITERATIONS=50, 모델 제공 max_iterations 무시↗ agent/chat_completion_helpers.py:609, 750-760 — _DIRECT_API_ACTIVITY_HEARTBEAT_SECONDS=15.0 활동 티커↗ cli-config.yaml.example:1304-1312 — delegation 블록 (max_spawn_depth 'range: 1-3' 스테일 표기)↗ hermes_cli/config_defaults.py:1745-1755 — child_timeout_seconds: 0, max_concurrent_children: 3↗ website/docs/user-guide/features/delegation.md:191 — child_timeout_seconds 0 = no timeout 문서안전장치 — 자식 도구 블록리스트와 승인 콜백
핵심 요점
- ⚠️ 발표자료 대비 최대 변경: execute_code는 더 이상 차단되지 않는다. tests/tools/test_delegate.py:198-200이 'code_execution is deliberately NOT denied — children keep execute_code for programmatic tool calling (Teknium, Jul 2026)'라는 주석과 함께 회귀 테스트로 못 박고 있다. 반대로 cronjob이 블록리스트에 새로 추가됐다(부모 명의로 부모 수명을 넘어 도는 부작용 차단). 실측 블록리스트는 delegate_task/clarify/memory/send_message/cronjob 5종(tools/delegate_tool.py:49-57).
- ⚠️ 배치 폴링은 as_completed()가 아니라 concurrent.futures.wait(pending, timeout=0.5, return_when=FIRST_COMPLETED)다(:3472-3475). as_completed는 주석에서 '자식이 멈추면 부모가 영원히 블록된다'는 기각 사유로만 등장한다 — 폴링 타임아웃은 성능이 아니라 제어권 반환 주기다.
- threading.local()은 자식 스레드로 상속되지 않으므로 승인 콜백은 ThreadPoolExecutor(initializer=..., initargs=...)로 워커마다 주입해야 한다(:2299-2307). 주목할 점은 이 initializer가 배치 팬아웃 풀(:3404)이 아니라 자식 1개짜리 내부 풀에 걸려 있다는 것 — 실제 run_conversation이 도는 최내곽 스레드가 유일한 주입 지점이다. 같은 프로세스에서 contextvars는 copy_context()로 수동 전파하고 threading.local은 initializer로 채우는, 전파 방식이 다른 두 종류의 스레드별 상태가 공존한다.
- 기본은 fail-closed다. delegation.subagent_auto_approve=false(기본)면 _subagent_auto_deny가 'deny'를 반환해 자식이 복구 가능한 거부를 받고, true일 때만 _subagent_auto_approve가 'once'를 반환한다(cron/batch 옵트인). 방어는 다층이라 initializer를 놓친 경로도 approval.py:2776-2795의 prompt_toolkit fail-closed 가드가 받고, YOLO 모드는 스킬의 os.environ 조작을 통한 인젝션 우회를 막기 위해 import 시점에 동결된다(approval.py:36).
- set_spawn_paused()는 실행 중 자식은 살려두고 신규 스폰만 막는 킬스위치로, 폭주 트리에서 진행 중 작업을 잃지 않고 출혈만 멈추는 올바른 세분성이다. TUI /agents 오버레이와 게이트웨이 RPC 3종(delegation.status / delegation.pause / subagent.interrupt, tui_gateway/methods_session.py:3004·3024·3032)이 같은 API를 공유해 관측면과 제어면이 어긋나지 않는다.
- 권한 제거는 시점 이벤트가 아니라 에이전트에 저장되는 상태여야 한다. _blocked_toolsets_for_role은 hermes-cli 같은 혼합 번들을 통째로 못 빼기 때문에 deny 목록을 AIAgent에 넘겨 composite 확장 이후에 이름을 빼게 하고, 그 차단이 이후 registry/MCP 갱신에도 살아남게 한다. 플러그인 API는 blocked_tools를 아예 거부하고 allowed_toolsets도 부모 부분집합만 허용해(agent/subagent_lifecycle.py:514-517, 534-540) 호출자에게 좁히는 자유도만 준다.
- 문서는 코드보다 늦게 썩는다. _strip_blocked_tools의 독스트링은 아직 code_execution을 벗긴다고 적혀 있지만 실제 _COMPOSITE_BLOCKED_TOOLSETS는 {'delegation'} 하나뿐이고, kanban은 DELEGATE_BLOCKED_TOOLS를 거치지 않고 하드코딩으로 추가돼 같은 함수가 주장하는 lockstep을 깬다. 안전 설계를 읽을 때 신뢰 순서는 테스트 > 코드 > 주석이다.
안전장치 — 자식 도구 블록리스트와 승인 콜백
위임(delegation)은 권한 확대 사고다. 부모가 쓸 수 있는 도구를 자식이 그대로 물려받으면, 사용자가 승인한 적 없는 행위가 프롬프트 인젝션 한 줄로 실행된다. Hermes의 답은 단순하다 — 자식에게 무엇을 줄지가 아니라 무엇을 빼앗을지를 먼저 정의한다.
실측 블록리스트는 5종이다
DELEGATE_BLOCKED_TOOLS = frozenset(
[
"delegate_task", # no recursive delegation
"clarify", # no user interaction
"memory", # no writes to shared MEMORY.md
"send_message", # no cross-platform side effects
"cronjob", # no scheduling more work in the parent's name
]
)
tools/delegate_tool.py:49-57
주석이 차단 이유를 직접 달고 있다. delegate_task는 재귀 팬아웃(비용 폭발), clarify는 자식이 stdin을 잡는 순간 부모 TUI와 교착, memory는 공유 MEMORY.md에 대한 동시 쓰기, send_message는 되돌릴 수 없는 크로스 플랫폼 부작용이다.
여기서 발표자료와 두 군데가 어긋난다.
| 도구 | 발표자료 | 현재 소스(5292074) |
|---|---|---|
| delegate_task / clarify / memory / send_message | 차단 | 차단 (일치) |
cronjob |
목록에 없음 | ⚠️ 차단됨 — 신규 추가 |
execute_code |
차단(단계적 추론 유도) | ⚠️ 차단 안 됨 — 반전 |
cronjob 차단은 논리가 깔끔하다. 자식이 크론을 걸면 그 작업은 부모 명의로, 부모가 종료된 뒤에도 계속 돈다. 수명이 부모를 넘어서는 부작용은 위임 경계를 무의미하게 만든다.
⚠️ execute_code를 남긴 것은 의도적 번복이다
execute_code는 블록리스트에 없다. 추측이 아니라 테스트가 그 반대를 못 박고 있다.
# code_execution is deliberately NOT denied — children keep
# execute_code for programmatic tool calling (Teknium, Jul 2026).
self.assertNotIn("code_execution", disabled)
tests/tools/test_delegate.py:198-200
날짜와 이름까지 붙은 회귀 테스트다. 즉 "스크립트 대신 단계적 추론을 유도한다"는 발표자료의 설명은 한때 맞았으나 2026년 7월에 뒤집혔다. 자식이 프로그래밍 방식으로 도구를 호출하는 편(코드 한 번 = 도구 호출 N번)이 반복 추론보다 토큰·지연 모두 유리하다는 판단이다.
이 번복의 흔적이 소스에 그대로 남아 있다는 점이 더 흥미롭다. _strip_blocked_tools의 독스트링은 아직 "composite/scenario 툴셋(delegation, code_execution)"을 벗긴다고 적혀 있지만, 정작 코드의 집합은 delegation 하나뿐이다.
_COMPOSITE_BLOCKED_TOOLSETS = frozenset({"delegation"})
blocked_toolset_names = {
name
for name, defn in TOOLSETS.items()
if name in _COMPOSITE_BLOCKED_TOOLSETS
or all(t in DELEGATE_BLOCKED_TOOLS for t in defn.get("tools", []))
}
blocked_toolset_names.add("kanban")
tools/delegate_tool.py:1015-1022
교훈: 정책을 바꿀 때 코드·테스트는 갱신되고 독스트링은 남는다. 안전 설계 문서를 읽을 때 신뢰 순서는 테스트 > 코드 > 주석이다. 덧붙여 kanban은 DELEGATE_BLOCKED_TOOLS를 거치지 않고 하드코딩으로 추가된다 — "블록리스트와 strip 집합을 lockstep으로 유지한다"는 같은 함수의 주장에 난 구멍이다.
도구 이름을 툴셋 이름으로 번역하는 두 함수
블록리스트는 도구 단위인데 에이전트에 전달되는 건 툴셋 단위다. 그래서 두 단계로 나뉜다.
| 함수 | 역할 | 왜 필요한가 |
|---|---|---|
_strip_blocked_tools |
전부 차단된 툴셋을 통째로 제거 | 자식 툴셋 목록에서 선제 제거 |
_blocked_toolsets_for_role |
단일 도구 deny 툴셋 목록 반환 | hermes-cli 같은 혼합 번들은 유용한 도구가 섞여 있어 통째로 못 뺀다. deny 목록을 AIAgent에 넘겨 composite 확장 이후에 이름을 빼게 한다 |
핵심은 후자의 독스트링이다 — 차단이 "나중의 registry/MCP 갱신에도 살아남는다"(tools/delegate_tool.py:1031-1034). 한 번 필터링하고 끝내면 MCP 서버가 런타임에 도구를 재등록할 때 차단이 조용히 풀린다. 권한 제거는 시점 이벤트가 아니라 에이전트에 저장되는 상태여야 한다.
역할 분기도 한 줄뿐이다. role == "orchestrator"일 때만 delegate_task를 deny 목록에서 뺀다(:1037-1038).
ThreadPoolExecutor(initializer=...) — 스레드로컬은 상속되지 않는다
이 섹션에서 가장 실무적인 파이썬 함정이다. 승인 콜백은 스레드로컬에 산다.
_callback_tls = threading.local()
tools/terminal_tool.py:260
threading.local()은 자식 스레드로 상속되지 않는다. 워커 스레드에서 위험 명령 승인이 필요해지면 콜백이 None이고, input() 폴백이 prompt_toolkit이 이미 점유한 stdin을 읽으려 들면서 부모 TUI와 교착한다. 해법은 풀 생성 시 주입이다.
_timeout_executor = DaemonThreadPoolExecutor(
max_workers=1,
initializer=_set_subagent_approval_cb,
initargs=(_get_subagent_approval_callback(),),
)
tools/delegate_tool.py:2299-2307
initializer는 워커 스레드가 뜰 때마다 그 스레드 안에서 실행되므로, 스레드로컬 슬롯을 채울 수 있는 유일한 지점이다.
주목할 배치가 하나 더 있다. 이 initializer는 배치 팬아웃 풀이 아니라 자식 1개짜리 내부 풀에 걸려 있다. 배치 풀(tools/delegate_tool.py:3404)에는 initializer가 없다. 즉 모든 자식은 스레드 2단 깊이로 실행되고, 실제 run_conversation이 도는 최내곽 스레드에만 콜백이 심긴다. 플러그인 경로(agent/subagent_lifecycle.py의 _EXECUTOR, 역시 initializer 없음)도 결국 _run_child_lifecycle → _run_single_child를 거치므로 같은 지점에서 보호받는다.
또 하나의 대비: 배치 제출은 contextvars.copy_context()를 명시적으로 넘긴다(:3407-3409). 같은 프로세스 안에 "스레드별 상태"가 두 종류 있고, contextvars는 수동 복사로, threading.local은 initializer로 — 전파 방식이 서로 다르다. 하나만 처리하면 나머지가 조용히 샌다.
기본값은 거부다
콜백 선택은 설정 한 줄로 갈린다.
delegation.subagent_auto_approve |
설치되는 콜백 | 반환 | 용도 |
|---|---|---|---|
false (기본) |
_subagent_auto_deny |
"deny" |
대화형 세션 |
true |
_subagent_auto_approve |
"once" |
cron/batch 옵트인 |
둘 다 logger.warning으로 감사 로그를 남긴다. deny를 반환하는 이유가 중요하다 — 예외로 자식을 죽이지 않고 자식이 복구할 수 있는 거부를 돌려준다(:77-88).
방어는 한 겹이 아니다. initializer를 빠뜨린 경로가 있어도 approval.py가 fail-closed로 받는다. prompt_toolkit이 살아 있는데 콜백이 없으면 즉시 거부한다(tools/approval.py:2776-2795, issue #15216). YOLO 모드 역시 import 시점에 동결되는데, 이유가 명시돼 있다 — 프로세스 안 스킬이 os.environ을 바꿔 승인을 통째로 우회하는 프롬프트 인젝션 경로를 막기 위해서다(tools/approval.py:36).
킬스위치와 게이트웨이 RPC
set_spawn_paused()는 모듈 전역 플래그다. 실행 중 자식은 건드리지 않고 신규 스폰만 막는다(tools/delegate_tool.py:155-165). 폭주 트리를 발견했을 때 진행 중인 작업을 잃지 않고 출혈을 멈추는, 올바른 세분성이다. 강제 지점은 delegate_task 진입 직후 한 곳이다(:3149).
| RPC | 핸들러 | 동작 |
|---|---|---|
delegation.status |
tui_gateway/methods_session.py:3004 |
active 목록 + paused + depth/concurrency 캡 |
delegation.pause |
:3024 |
set_spawn_paused(paused) |
subagent.interrupt |
:3032 |
id로 개별 자식 중단 |
TUI의 /agents 오버레이가 이 세 RPC를 그대로 호출한다(p = pause 토글). 관측면과 제어면이 같은 API를 쓰기 때문에 "보이는 것"과 "끌 수 있는 것"이 어긋나지 않는다.
⚠️ 배치 실행 — as_completed()는 쓰지 않는다
자식은 전부 메인 스레드에서 먼저 만든다(:3313-3370). 이유는 스레드 안전성이다. 자식 구성이 부모의 해석된 도구 목록을 건드리므로, 락 아래에서 저장·복원해야 자식 툴셋이 부모로 새지 않는다.
그리고 발표자료의 as_completed()는 소스에 없다. 그 이름은 주석에서 기각 사유로만 등장한다.
from concurrent.futures import wait as _cf_wait, FIRST_COMPLETED
done, pending = _cf_wait(
pending, timeout=0.5, return_when=FIRST_COMPLETED
)
tools/delegate_tool.py:3472-3475
주석(:3421-3426)이 이유를 설명한다 — 자식 하나가 멈추면 부모가 영원히 블록되므로, 0.5초 타임아웃으로 깨어나 parent_agent._interrupt_requested를 확인한다. 인터럽트 시 끝난 것만 수거하고 나머지는 status: "interrupted"로 채워 버린다.
교훈: 인터럽트 가능한 시스템에서 무기한 블로킹 조인은 금지다. 폴링 루프의 타임아웃은 성능이 아니라 제어권 반환 주기다.
최소권한을 기본 설계로
플러그인 API가 이 철학을 가장 선명하게 드러낸다. SubagentLaunchRequest에 blocked_tools 필드가 있지만 넘기면 거부된다.
if request.blocked_tools:
raise SubagentLifecycleError(
"Per-tool blocking is not supported; use allowed_toolsets. Hermes always blocks unsafe child tools."
)
agent/subagent_lifecycle.py:514-517
allowed_toolsets도 부모의 enabled_toolsets 부분집합이어야 하고, 아니면 "Requested toolsets would broaden parent permissions"로 거절된다(:534-540). 즉 호출자에게 허용된 자유도는 좁히는 방향뿐이다. 안전 정책을 호출자가 조립하게 두면 언젠가 누군가 빈 리스트를 넘긴다. 정책은 중앙에 고정하고, API는 축소만 노출하는 편이 낫다.
참고 출처
↗ tools/delegate_tool.py:49-57 — DELEGATE_BLOCKED_TOOLS 실측 5종↗ tools/delegate_tool.py:2299-2307 — ThreadPoolExecutor(initializer=...) 승인 콜백 주입↗ tools/delegate_tool.py:3472-3475 — wait(timeout=0.5, FIRST_COMPLETED) 인터럽트 폴링↗ tests/tools/test_delegate.py:198-200 — execute_code는 의도적으로 차단하지 않는다↗ tools/approval.py:2776-2795 — prompt_toolkit fail-closed 승인 가드 (issue #15216)↗ tools/approval.py:36 — _YOLO_MODE_FROZEN, import 시점 동결로 인젝션 우회 차단↗ tools/terminal_tool.py:260,280-287 — threading.local 기반 승인 콜백 저장소↗ agent/subagent_lifecycle.py:514-517 — blocked_tools 거부, allowed_toolsets 부분집합 강제↗ tui_gateway/methods_session.py:3004,3024,3032 — delegation.status / delegation.pause / subagent.interrupt RPC↗ cli-config.yaml.example:1312 — delegation.subagent_auto_approve 기본값 false↗ Python 표준 라이브러리 — concurrent.futures.ThreadPoolExecutor(initializer, initargs)↗ Python 표준 라이브러리 — contextvars.copy_context()또 다른 협업 패턴 — Mixture-of-Agents와 Kanban 보드
핵심 요점
- MoA는 도구가 아니라 가상 프로바이더다 — tools/mixture_of_agents_tool.py는 존재하지 않고, base_url="moa://local"인 provider로 모델처럼 선택한다. 비용 N배 기능의 결정권을 모델이 아닌 사용자에게 고정하는 설계.
- 논문(arXiv:2406.04692)의 다층 구조가 없다 — 단일 레이어(reference N 병렬 → aggregator 1)이며, reference는 초안이 아니라 '조언'만 하고 aggregator가 실제 acting model이다. 레이어 대신 fanout 케이던스로 갱신 빈도를 조절한다.
- ⚠️ 기본 fanout이 per_iteration → user_turn으로 뒤집혔다(2026년 7월). 툴 루프가 긴 작업에서 advisor 비용이 수십 배 차이나는 지점이라 2026년 6월 자료는 이 부분이 틀리다.
- 위임은 작업 분할, MoA는 같은 작업의 앙상블. 실측 기본값: reference=openai-codex:gpt-5.5 + openrouter:deepseek/deepseek-v4-pro, aggregator=openrouter:anthropic/claude-opus-4.8, 병렬 상한 8, aggregator 선택은 동적 로직 없이 프리셋 고정.
- Kanban은 메시지 패싱 없는 blackboard — WAL + BEGIN IMMEDIATE + CAS로 조율하고 패배자는 0 rows를 보고 그냥 지나간다(재시도 루프·분산락 없음). swarm 계층조차 루트 태스크의 JSON 코멘트를 칠판으로 쓴다.
- ⚠️ plugins/kanban/은 대시보드 껍데기(파일 5개)이고 진짜 엔진은 hermes_cli/kanban_db.py(10,378줄)다. systemd 유닛은 DEPRECATED — dispatcher는 kanban.dispatch_in_gateway=true로 게이트웨이 내장이 기본. worktree도 필수가 아니라 scratch/worktree/dir 중 하나다.
또 다른 협업 패턴 — Mixture-of-Agents와 Kanban 보드
delegate_task가 유일한 협업 프리미티브는 아니다. 저장소에는 성격이 전혀 다른 두 개가 더 있다. 하나는 같은 작업을 여러 모델에게 동시에 묻는 MoA, 다른 하나는 여러 프로세스가 하나의 SQLite 보드를 공유하는 Kanban이다. 둘 다 발표자료 시점과 구조가 꽤 달라져 있어서, 먼저 그 차이부터 짚는다.
⚠️ 발표자료 대비 변경 ①: MoA는 도구가 아니다
tools/mixture_of_agents_tool.py는 존재하지 않는다. tools/ 아래에 moa/mixture 이름을 가진 파일은 하나도 없고, 도구 레지스트리에도 등록돼 있지 않다. 대신 MoA는 가상 프로바이더로 구현돼 있다.
if requested_provider == "moa":
"provider": "moa",
"base_url": "moa://local",
"api_key": "moa-virtual-provider",
"source": "moa-virtual-provider",
hermes_cli/runtime_provider.py:1705-1711
즉 /model review --provider moa로 모델을 고르듯이 프리셋을 고른다. 모듈 첫 줄이 의도를 명시한다: "The slash command is deliberately not a model tool." (agent/moa_loop.py:3). 이유는 설계상 중요하다 — 도구였다면 모델이 스스로 MoA를 부를 수 있고, 그러면 앙상블 비용이 모델 재량에 놓인다. 프로바이더로 두면 비용을 결정하는 주체가 사용자로 고정된다. 우리가 배울 점: 비용이 N배로 튀는 기능은 도구가 아니라 런타임 설정으로 노출하는 게 안전하다.
⚠️ 발표자료 대비 변경 ②: 레이어가 없다 — 대신 fanout 케이던스
원 논문(arXiv:2406.04692)의 MoA는 여러 레이어를 쌓는다. 레이어 n의 proposer들이 레이어 n-1의 출력을 보고 다시 초안을 쓴다. Hermes에는 round/layer 개념이 아예 없다. 소스 전체에서 그런 키를 찾을 수 없다. 구현은 단일 레이어다: reference N개 병렬 → aggregator 1개.
더 큰 차이는 reference의 역할이다. 논문의 proposer는 답안 초안을 쓰지만, Hermes의 reference는 초안을 쓰지 않고 조언만 한다.
_REFERENCE_SYSTEM_PROMPT = (
"You are a reference advisor in a Mixture of Agents (MoA) process. You are "
"NOT the acting agent and you do NOT execute anything: you cannot call "
"tools, run commands, browse, or access files, repositories, or URLs, and "
"you should not try to or apologize for being unable to. ..."
agent/moa_loop.py:253-257
그리고 aggregator가 곧 acting model이다. 도구를 호출하고 사용자에게 보이는 답을 쓰는 건 aggregator다. reference 출력은 aggregator 프롬프트 맨 끝에 붙는다 — 마지막 user 메시지에 병합하면 매 iteration마다 프리픽스가 달라져 KV 캐시가 전부 무효화되기 때문이다(_attach_reference_guidance, agent/moa_loop.py:1411-1435). 에이전트 루프에 앙상블을 얹을 때 가장 먼저 깨지는 게 프롬프트 캐시라는 점, 기억해둘 만하다.
논문의 "라운드"를 대체하는 건 fanout 케이던스다. 레이어를 쌓는 대신 툴 루프의 어느 iteration에서 조언을 갱신할지를 고른다.
fanout |
조언 갱신 시점 | 비용 |
|---|---|---|
user_turn (기본) |
사용자 턴당 1회 | reference 1세트 / 턴 |
every_n:<N> (N≥2) |
첫 iteration + 매 N번째 | 1/N 배 |
per_iteration |
매 툴 iteration | 툴 호출 수 × reference 수 |
⚠️ 발표자료 대비 변경 ③: 기본 케이던스가 뒤집혔다
문서가 직접 밝힌다 — "Prior to July 2026 the default cadence was per_iteration. The default is now user_turn" (website/docs/user-guide/features/mixture-of-agents.md:170-174). 2026년 6월 자료가 per_iteration을 기본으로 설명했다면 지금은 틀리다. 툴 루프가 20번 도는 작업에서 이 한 줄이 advisor 비용을 20배 가른다.
실측한 기본값·상수:
| 항목 | 값 | 위치 |
|---|---|---|
| 기본 reference 모델 | openai-codex:gpt-5.5, openrouter:deepseek/deepseek-v4-pro |
moa_config.py:14-17 |
| 기본 aggregator | openrouter:anthropic/claude-opus-4.8 |
moa_config.py:19-22 |
| aggregator 선택 로직 | 없음 — 프리셋 고정값 | moa_loop.py:1895 |
| 병렬 워커 상한 | _MAX_REFERENCE_WORKERS = 8 |
moa_loop.py:180 |
| reference 타임아웃 | 프리셋 미지정 시 auxiliary.moa_reference.timeout = 900초 |
config_defaults.py:1088 |
reference_max_tokens |
None(무제한). aggregator에는 절대 적용 안 함 |
moa_loop.py:1216-1222 |
| 트레이스 | moa.save_traces=False, JSONL 사이드채널 |
moa_trace.py:1-21 |
aggregator 선택이 "로직"이 아니라 정적 설정이라는 점은 명시해둔다 — 품질 기반 동적 선택 같은 건 없다.
MoA를 언제 쓰나 — 위임과의 대비
핵심 대비는 한 줄이다. 위임은 작업을 쪼개고, MoA는 같은 작업을 겹쳐 본다. delegate_task의 자식은 부모 히스토리 없는 새 대화에서 다른 일을 하고 요약만 돌려준다(MAX_DEPTH = 1, _DEFAULT_MAX_CONCURRENT_CHILDREN = 3 — tools/delegate_tool.py:128,121). MoA의 reference들은 같은 대화 상태를 받아 각자 판단을 내놓는다.
비용은 정직하게 N+1배다(정확히는 reference N회 + aggregator 1회, 케이던스에 곱해짐). 그래서 판단 기준이 필요하다:
- 쓸 때 — 정답이 하나인데 틀리면 비싼 문제. 어려운 추론·수학·아키텍처 결정·미묘한 버그의 근본원인. 실패 비용이 토큰 비용보다 훨씬 큰 구간.
- 쓰지 말 때 — 결과를 기계적으로 검증할 수 있는 작업(테스트가 판정해주는 코딩), 병렬로 쪼개면 그만인 작업, 긴 툴 루프. 툴 루프가 길면
user_turn으로도 조언이 금세 낡는다. - 중간 지대 —
reference_max_tokens: 600+every_n:3. 문서는 advisor 출력 캡만으로 샘플 작업 벽시계 시간 ~44% 단축을 주장한다(턴 지연이 출력 토큰과 상관 ~0.88).
재귀 방지도 걸려 있다. MoA 프리셋을 MoA 슬롯에 넣는 건 저장 시점에 거부되고(moa_config.py:206) 런타임에서도 스킵된다(moa_loop.py:848) — 앙상블의 앙상블은 비용이 곱셈으로 폭발하니까.
Kanban: 보드가 곧 공유 상태
Kanban은 정반대 축이다. 메시지 패싱이 없다. 모든 조율이 SQLite 한 파일을 통해 일어난다.
Concurrency strategy: WAL mode + ``BEGIN IMMEDIATE`` for write
transactions + compare-and-swap (CAS) updates on ``tasks.status`` and
``tasks.claim_lock``. SQLite serializes writers via its WAL lock, so at
most one claimer can win any given task. Losers observe zero affected
rows and move on -- no retry loops, no distributed-lock machinery.
hermes_cli/kanban_db.py:63-68
이게 blackboard 패턴의 교과서적 이득이다. 워커 간 프로토콜이 없으니 워커가 죽어도 프로토콜이 깨지지 않는다 — 행이 남아 있고 다음 dispatcher tick이 회수한다. 스키마는 의도적으로 작다: tasks, task_links, task_comments, task_events. swarm 계층조차 새 저장소를 만들지 않고 루트 태스크의 JSON 코멘트를 칠판으로 쓴다(BLACKBOARD_PREFIX = "[swarm:blackboard] ", hermes_cli/kanban_swarm.py:26). 모듈 주석이 원칙을 명시한다: "intentionally does not introduce a second scheduler."
worktree는 필수가 아니다. VALID_WORKSPACE_KINDS = {"scratch", "worktree", "dir"} (kanban_db.py:135) — 코딩 작업만 worktree를 쓰고, 리서치·운영 워크로드는 scratch로 돈다. worktree 모드에서는 <repo>/.worktrees/<task-id>에 브랜치 wt/<task-id>로 materialize되며, 형제 태스크가 같은 체크아웃을 공유하지 않도록 자식마다 경로를 비워 새로 만든다(kanban_db.py:6184-6191).
⚠️ 발표자료 대비 변경 ④: plugins/kanban/은 껍데기다
디렉터리를 열면 파일이 5개뿐이다.
| 경로 | 실체 |
|---|---|
plugins/kanban/dashboard/ |
대시보드 플러그인 (manifest + 번들 + 얇은 API 래퍼) |
plugins/kanban/systemd/*.service |
DEPRECATED — 헤더에 명시 |
hermes_cli/kanban_db.py |
진짜 엔진, 10,378줄 |
hermes_cli/kanban.py |
CLI + dispatcher (3,236줄) |
tools/kanban_tools.py |
모델용 도구 12종 (2,250줄) |
gateway/kanban_watchers.py |
게이트웨이 내장 dispatcher |
agent/kanban_stop.py |
턴 종료 가드 (108줄) |
systemd 유닛 주석: "the kanban dispatcher now runs inside the gateway by default (config key: kanban.dispatch_in_gateway, default true)... Running this unit AND a gateway with dispatch_in_gateway=true is NOT supported." 발표자료가 독립 데몬을 그렸다면 지금은 게이트웨이 내장이 기본이다.
agent/kanban_stop.py는 작지만 이 패턴의 급소를 보여준다. 워커는 반드시 kanban_complete 또는 kanban_block으로 끝나야 하는데, 모델이 "이제 리포트를 쓰겠습니다"라고 서술하고 툴 없이 종료하면 rc=0 → dispatcher의 protocol_violation이 된다. 그래서 합성 nudge를 최대 2회(_DEFAULT_MAX_ATTEMPTS = 2) 주입한다. 공유 상태 패턴에서는 "상태를 갱신하지 않고 끝내기"가 가장 흔한 고장 모드라는 얘기다.
두 패턴의 경계도 코드로 못박혀 있다. delegate_task 자식은 보드를 변경할 수 없다:
return tool_error(
f"{tool_name} refused: delegate_task child agents are not Kanban "
"run owners. Return findings to the parent agent; the dispatcher "
"worker or an explicitly configured Kanban orchestrator must perform "
"board mutations."
)
tools/kanban_tools.py:94-99
자식은 부모와 같은 프로세스에서 돌아 HERMES_KANBAN_* 환경변수를 상속하므로, 환경변수만으로는 소유권 증명이 안 된다. 소유권을 프로세스 경계가 아니라 컨텍스트 플래그로 판정하는 이 선택이 인프로세스 서브에이전트를 쓰는 모든 하네스에 그대로 적용된다.
결정표
| 기준 | delegate_task |
MoA | Kanban |
|---|---|---|---|
| 무엇을 나누나 | 작업을 분할 | 같은 작업의 관점을 앙상블 | 시간과 프로세스를 분할 |
| 실행 단위 | 인프로세스 자식 에이전트 | 모델 호출 N+1회 | 독립 OS 프로세스 |
| 상태 | 부모 컨텍스트(압축되면 소실) | 없음(턴 단위 휘발) | SQLite 행(영구) |
| 재개 | 불가 — 실패하면 실패 | 부분 실패 시 성공분만 사용 | block→unblock, 크래시→reclaim |
| 사람 개입 | 불가 | 불가 | 언제든 코멘트/unblock |
| 비용 축 | 자식 수 (기본 동시 3, 깊이 1) | reference 수 × fanout 케이던스 | 워커 수 (max_in_progress) |
| 지연 | 자식 중 최장 | 가장 느린 advisor + aggregator | 비동기 (tick 60초) |
| 고르는 순간 | 부모가 답 하나를 받고 계속 진행 | 한 번에 틀리면 비싼 판단 | 며칠 걸리거나 사람이 끼거나 재시도가 필요 |
| 한계 | MAX_DEPTH = 1 |
재귀 MoA 금지 | 단일 호스트 전용 |
셋은 배타적이지 않다. Kanban 워커가 내부에서 delegate_task를 부를 수 있고, 그 워커의 모델이 MoA 프리셋일 수도 있다. 다만 층위를 섞기 전에 비용 축이 곱해진다는 것만 기억하면 된다 — 워커 5 × reference 2 × per_iteration 20 iteration은 200회 호출이다.
참고 출처
↗ hermes_cli/moa_config.py:14-24 (기본 reference/aggregator 슬롯, 재귀 MoA 거부)↗ agent/moa_loop.py:1-7, 180, 253-283, 1411-1435 (도구 아님 선언, 병렬 상한, reference 시스템 프롬프트, KV캐시 보존 주입)↗ agent/moa_trace.py:1-21 (save_traces 사이드채널 JSONL)↗ hermes_cli/runtime_provider.py:1705-1711 (moa 가상 프로바이더 base_url)↗ hermes_cli/config_defaults.py:1088, 1806-1828, 2308-2374 (moa/auxiliary/kanban 기본값)↗ hermes_cli/kanban_db.py:63-68, 135, 6488-6603, 6184-6191 (CAS 조율 전략, workspace kinds, worktree materialize)↗ hermes_cli/kanban_swarm.py:1-26 (blackboard = 루트 태스크 JSON 코멘트, 두 번째 스케줄러 없음)↗ tools/kanban_tools.py:85-135 (delegate_task 자식의 보드 변경 거부, 워커/오케스트레이터 도구 게이트)↗ agent/kanban_stop.py:20-22, 68-101 (터미널 툴 강제 nudge, 최대 2회)↗ plugins/kanban/systemd/hermes-kanban-dispatcher.service (DEPRECATED — 게이트웨이 내장 dispatch로 이관)↗ tools/delegate_tool.py:121-128 (MAX_DEPTH=1, 기본 동시 자식 3)↗ website/docs/user-guide/features/mixture-of-agents.md:100-175 (fanout 기본값 변경 고지, reference_max_tokens 효과)↗ Mixture-of-Agents Enhances Large Language Model Capabilities (arXiv:2406.04692)다층 메모리 — 큐레이트 파일·Provider·동결 스냅샷·펜싱
핵심 요점
- MEMORY.md 2,200자 / USER.md 1,375자 상한은 발표자료와 일치 — 토큰이 아닌 문자 기준이라 토크나이저가 바뀌어도 예산과 사용량 게이지가 흔들리지 않는다(≈800/500토큰, 2.75 chars/token 가정)
- ⚠️ 발표자료 정정 3건: `read` 액션은 존재하지 않고(문서도 명시), 대신 원자적 `operations` 배치 shape이 도입됐으며, 외부 Provider는 4종이 아니라 8종이다(byterover/hindsight/holographic/honcho/mem0/openviking/retaindb/supermemory)
- 동결 스냅샷은 캐시↔최신성의 명시적 거래 — 세션 중 쓰기는 디스크에 즉시 반영되지만 시스템 프롬프트는 불변, 갱신은 다음 세션. 게다가 프롬프트의 volatile 밴드에 배치해 재프리필 범위까지 최소화한다
- replace/remove가 ID 대신 고유 부분문자열을 쓰는 건 LLM 친화 API의 정석 — 모델이 이미 보고 있는 텍스트라 환각 여지가 없고, 모호하면 후보 미리보기를 실어 자기수정을 유도한다
- 무결성 가드 4겹(파일 락·atomic rename·외부 드리프트→.bak 후 거부·읽기실패 센티널)의 공통 원칙: '실제로 본 적 없는 상태에서 파일을 재작성하지 않는다'
- ⚠️ 컨텍스트 펜싱 문구가 'informational background data'→'authoritative reference data'로 강화됐고(구 문구는 스크러버 정규식에만 잔존), 스트리밍 펜스 제거기는 think_scrubber.py가 아니라 memory_manager.py의 StreamingContextScrubber다
- 부산물 발견: `memory.flush_min_turns: 6`은 설정 예제에만 있고 코드 리더가 0곳인 죽은 키 — 설정 예제 파일은 코드와 함께 늙지 않는다
다층 메모리 — 큐레이트 파일·Provider·동결 스냅샷·펜싱
에이전트 하네스에서 "메모리"라는 단어는 보통 하나의 벡터DB를 가리킨다. Hermes는 그 반대를 택했다. 다섯 개의 저장 계층이 각각 다른 질문에 답하도록 잘려 있고, 어느 계층에 무엇을 넣을지는 도구 스키마 설명문에 못 박혀 있다.
다섯 레이어 — "무엇을 기억하는가"로 자른다
| # | 레이어 | 기억하는 것 | 정본 |
|---|---|---|---|
| ① | 큐레이트 파일 | 사용자 선호·환경 사실·관례 (선언적, 영구) | tools/memory_tool.py (1,240줄) |
| ② | 외부 Provider | 지식그래프·의미검색·자동 사실추출 | agent/memory_manager.py (1,241줄) + plugins/memory/ |
| ③ | FTS5 세션검색 | "그때 뭐라고 했지" — 과거 대화 원문 | hermes_state_search.py, tools/session_search_tool.py |
| ④ | 컨텍스트 압축 | 이번 세션의 중간 턴 요약 | agent/context_compressor.py |
| ⑤ | 학습 루프 | 저장할 게 있는지 주기적으로 되묻기 | turn_context.py + turn_finalizer.py |
경계는 문서가 아니라 스키마 문구로 강제된다. memory 도구 설명은 "작업 진행상황·완료 로그·임시 TODO는 저장하지 마라(그건 session_search용) / 재사용 가능한 절차는 메모리가 아니라 skill로"라고 직접 지시한다(tools/memory_tool.py:1172-1174). 배울 점: 계층 경계를 아키텍처 다이어그램에 그리는 것과 모델이 실제로 지키는 것은 다르다. 경계는 프롬프트에 써야 지켜진다.
문자수 상한과 § — 모델 독립 예산
ENTRY_DELIMITER = "\n§\n"
...
def __init__(self, memory_char_limit: int = 2200, user_char_limit: int = 1375):
tools/memory_tool.py:67, 165
발표자료의 2,200자 / 1,375자는 현재 소스와 정확히 일치한다(agent/agent_init.py:1705-1706도 같은 값). 토큰이 아니라 문자수인 이유는 설정 파일에 그대로 적혀 있다 — "~2.75 chars per token, model-independent", 즉 2200 ≈ 800토큰 / 1375 ≈ 500토큰(cli-config.yaml.example:691-693). 토크나이저가 바뀌어도 예산이 흔들리지 않고, 무엇보다 모델에게 보여줄 사용량 표시가 결정적이 된다. 시스템 프롬프트 블록 헤더에는 [63% — 1,392/2,200 chars] 같은 게이지가 박힌다(_render_block, :731-747).
구분자는 § 단독이 아니라 \n§\n이다. _parse_entries가 전체 구분자로 split하므로 본문에 §를 포함한 엔트리도 쪼개지지 않는다(:774-781).
단일 도구 + action — ID가 없는 API
replace/remove는 엔트리 ID를 받지 않는다. 고유 부분문자열만 받는다.
matches = [(i, e) for i, e in enumerate(entries) if old_text in e]
if len(matches) > 1:
unique_texts = {e for _, e in matches}
if len(unique_texts) > 1:
return {"success": False,
"error": f"Multiple entries matched '{old_text}'. Be more specific.",
"matches": self._previews([e for _, e in matches])}
tools/memory_tool.py:471-489
LLM에게 ID를 쥐여주면 환각한다. 부분문자열은 모델이 이미 컨텍스트에서 보고 있는 텍스트라 환각할 여지가 적고, 모호하면 후보 미리보기를 돌려주며 재시도를 유도한다. 실패 경로가 전부 자기수정 가능한 형태다 — old_text가 빠진 호출에는 "필수입니다" 대신 현재 엔트리 목록 전체를 실어 보낸다(_missing_old_text_error, :1015-1044).
⚠️ 발표자료 대비 변경 3건:
read액션은 없다. enum은["add", "replace", "remove"]뿐이고(:1181), 문서도 "There is noreadaction — memory content is automatically injected into the system prompt at session start"라고 명시한다(website/docs/user-guide/features/memory.md:67). 발표자료의 4-액션 설명은 오류다.operations배치 shape이 새로 들어왔다. 여러 op을 원자적으로 적용하되 최종 상태만 예산 검사한다. 중간 오버플로는 무시되므로, "add 하나만으론 넘치는" 상황에서도 remove+add를 한 호출에 담아 해결된다(apply_batch,:562-669). 이전엔 이게 다중 턴 consolidate-then-retry였고, 그때마다 대화 전체가 재전송됐다.- 성공 응답은 의도적으로 terminal이다(
"done": True, 엔트리 목록 비첨부). 주석에 관측 기록이 남아 있다 — "observed thrash: the correct batch on call 1, then 5 redundant repeats"(:713-718). 여기에 더해 한 턴 3회 실패하면 재시도 지시를 떼고 종료시킨다(_MAX_CONSOLIDATION_FAILURES_PER_TURN = 3,:163).
동결 스냅샷 — 캐시를 위해 최신성을 판다
load_from_disk() 시점에 _system_prompt_snapshot이 한 번 구워지고, 세션 내내 바뀌지 않는다.
def format_for_system_prompt(self, target: str) -> Optional[str]:
"""Return the frozen snapshot for system prompt injection.
This returns the state captured at load_from_disk() time, NOT the live
state. Mid-session writes do not affect this."""
block = self._system_prompt_snapshot.get(target, "")
return block if block else None
tools/memory_tool.py:682-693
거래는 명확하다. 세션 중 쓰기는 디스크에 즉시 반영되고(내구성 확보) 도구 응답은 라이브 상태를 보여주지만, 시스템 프롬프트는 안 움직인다 → prefix 캐시가 세션 전체에서 살아남는다. 대가는 한 세션의 지연: 방금 저장한 사실을 모델이 프롬프트에서 보는 건 다음 세션부터다.
주목할 디테일은 배치 위치다. 메모리 블록은 시스템 프롬프트의 volatile 밴드에 들어간다(agent/system_prompt.py:523-532). 앞쪽 안정 구간은 그대로 두고, 바뀔 수 있는 것들만 뒤에 몰아 longest-prefix 캐시의 재프리필 범위를 최소화한다. 스냅샷을 얼리는 것만으로는 부족하고, 어디에 얼리느냐까지 설계여야 한다는 뜻이다.
로드 시점에 각 엔트리는 주입 패턴 스캔을 통과해야 한다. 걸리면 스냅샷에서만 [BLOCKED: …] 플레이스홀더로 치환되고 라이브 엔트리는 원문을 유지한다 — 조용히 지우면 공격이 사용자에게 안 보이기 때문이다(_sanitize_entries_for_snapshot, :242-276). 스캔은 디스크 바이트에서 결정적이므로 스냅샷 안정성(=캐시 불변식)이 깨지지 않는다.
무결성 — 세 겹의 "쓰지 않는" 가드
| 가드 | 트리거 | 동작 |
|---|---|---|
| 파일 락 | 모든 mutation | 별도 .lock 파일에 fcntl.flock / Windows는 msvcrt.locking, 둘 다 없으면 no-op (:278-313) |
| 원자적 교체 | 저장 | atomic_write_text(..., tmp_prefix=".mem_") — temp+fsync+rename (:874, utils.py:139) |
| 외부 드리프트 | replace/remove/batch | 라운드트립 불일치 또는 단일 엔트리가 전체 상한 초과 → .bak.<ts> 백업 후 거부 (:807-861) |
| 읽기 실패 | 모든 mutation | 파일이 존재하는데 못 읽으면 _READ_FAILED 센티널로 중단 (:322-361) |
드리프트 가드의 두 번째 신호가 영리하다. 도구는 스토어 전체를 상한으로 예산하므로, 어떤 단일 엔트리도 상한을 넘을 수 없다. 넘는 엔트리가 보이면 외부 writer(패치 도구, 셸 append, 수동 편집, 형제 세션)가 자유 텍스트를 밀어넣은 것이다. 읽기 실패 가드는 더 아프게 배운 흔적이다 — 읽기 실패를 []로 처리하면 add 한 번이 파일 전체를 엔트리 하나로 재작성해 메모리를 통째로 날린다. add만 드리프트 검사를 건너뛰지만(append는 안전하니까), 읽기 실패 검사는 건너뛰지 않는다.
MemoryManager — 턴 흐름과 훅
prefetch_all(user_msg) → <memory-context> 펜스로 감싸 user 메시지 API 사본에만 append
→ LLM 턴 → sync_all(user, assistant) [백그라운드]
→ queue_prefetch_all(user) [백그라운드, 다음 턴 워밍]
sync_all과 queue_prefetch_all은 단일 워커 ThreadPoolExecutor로 나간다. 이유가 주석에 실측으로 남아 있다 — 잘못 설정된 Hindsight 데몬이 ~298초 블로킹했고, 인라인 실행이던 시절엔 사용자가 답을 다 본 뒤에도 CLI/TUI/게이트웨이가 몇 분간 "running"으로 남아 후속 메시지가 인터럽트를 유발했다(agent/memory_manager.py:646-661). 워커가 하나인 건 스레드 억제가 아니라 순서 보장이다 — 턴 N의 쓰기가 N+1보다 먼저 착지한다. 전경 prefetch는 별도 데몬 스레드 + join(8.0s) 타임아웃이고, 이전 prefetch가 아직 살아 있으면 그 턴은 통째로 건너뛴다(_EXTERNAL_PREFETCH_TIMEOUT_S = 8.0, :47, :547-595).
훅은 on_turn_start / on_session_end / on_session_switch / on_pre_compress / on_memory_write / on_delegation 여섯 개. 이 중 commit_session_boundary_async가 설계적으로 흥미롭다. /new 시 on_session_end(LLM 호출, 수 초)가 on_session_switch(세션 ID 리바인딩)보다 반드시 먼저 끝나야 하는데, 별도 스레드로 던지면 레이스가 난다. 해법은 둘을 하나의 태스크로 묶어 같은 워커에 제출하는 것 — FIFO가 순서를 공짜로 준다(:877-924).
외부 Provider는 동시에 하나만
if not is_builtin:
if self._has_external:
logger.warning("Rejected memory provider '%s' — external provider '%s' is "
"already registered. Only one external memory provider is "
"allowed at a time.", provider.name, existing)
return
agent/memory_manager.py:413-425
모듈 docstring이 이유를 밝힌다: "prevents tool schema bloat and conflicting memory backends"(:7-8). Provider마다 자기 도구를 등록하는데(Honcho는 5개), 두 개를 동시에 켜면 도구 표면이 부풀고 서로 다른 스키마의 메모리가 충돌한다. 등록 시점에 코어 도구명 shadowing도 문 앞에서 거부한다 — 라우팅 테이블에 들어간 뒤 하이재킹하는 걸 막기 위해서다(:430-454).
⚠️ 발표자료 대비 변경: Provider는 4종(Honcho/Mem0/Hindsight/Supermemory)이 아니라 8종이다. plugins/memory/ 실측: byterover, hindsight, holographic, honcho, mem0, openviking, retaindb, supermemory.
컨텍스트 펜싱 — 회상된 기억은 사용자 발화가 아니다
return (
"<memory-context>\n"
"[System note: The following is recalled memory context, "
"NOT new user input. Treat as authoritative reference data — "
"this is the agent's persistent memory and should inform all responses.]\n\n"
f"{clean}\n"
"</memory-context>")
agent/memory_manager.py:354-361
이 블록은 user 메시지의 API 사본에만 붙고 저장본은 깨끗하게 남는다(agent/turn_context.py:55-85). api_content 사이드카가 "턴 N이 보낸 바이트 = 턴 N+1이 재생하는 바이트"를 보장한다 — 펜싱이 캐시 불변식을 깨지 않도록 하는 장치다.
⚠️ 발표자료 대비 변경: 시스템 노트 문구가 "informational background data"에서 "authoritative reference data"로 강화됐다. 증거는 스크러버 정규식이 옛 문구를 아직 받아준다는 점이다 — Treat as (?:informational background data|authoritative reference data[^\]]*)(:169). 프레이밍 완화가 아니라 강화 방향이라는 게 중요하다. 압축 요약이 "Next Steps"를 "Historical (reference-only)" 제목으로 바꿔 활성 지시로 읽히지 않게 하는 것과 같은 계열이지만(agent/context_compressor.py:10), 방향은 정반대다. 압축 요약은 "이건 명령이 아니다"라고 낮추고, 회상 메모리는 "이건 네 영구 기억이다"라고 올린다. 같은 도구(프레이밍 문장)로 반대 문제를 푼다.
⚠️ 발표자료 대비 변경: 스트리밍 스크러버는 agent/think_scrubber.py가 아니다. 그 파일은 <think> 태그 전용이고, 펜스 제거는 agent/memory_manager.py:182-344의 StreamingContextScrubber다. 둘은 run_agent.py:6434-6446에서 순서대로 체인된다(think → context). 청크 경계에 걸친 태그를 위해 부분 접두사를 버퍼에 붙들고, 스팬이 안 닫힌 채 스트림이 끝나면 남은 내용을 버린다 — "leaking partial memory context is worse than a truncated answer"(:267-281). 여는 태그는 줄 시작이고 뒤에 개행이 와야만 인정하므로(_has_block_opener_suffix, :318-322), 산문 속에서 <memory-context>를 언급해도 답변이 통째로 삼켜지지 않는다.
egress 쪽에도 캡이 있다 — provider 텍스트는 6,000자로 잘리고(앞 4,000 + 뒤 1,500 + 마커) 그 전에 redact_sensitive_text(force=True, redact_url_credentials=True)를 통과한다(agent/context_engine.py:33-52).
학습 루프와, 죽어 있는 설정 키 하나
nudge_interval 턴마다(기본 10) 플래그가 서고, 응답을 사용자에게 전달한 뒤 백그라운드 리뷰 에이전트를 fork해 메모리를 정리시킨다 — "so it never competes with the user's task for model attention"(agent/turn_finalizer.py:735-753). cron 세션에선 억제된다(리뷰 fork 하나가 ~30K 토큰).
| 키 | 기본값 | 읽는 곳 |
|---|---|---|
memory.memory_enabled |
False |
agent_init.py:1699 |
memory.user_profile_enabled |
False |
agent_init.py:1700 |
memory.nudge_interval |
10 |
agent_init.py:1701 |
memory.memory_char_limit |
2200 |
agent_init.py:1705 |
memory.user_char_limit |
1375 |
agent_init.py:1706 |
memory.provider |
"" |
agent_init.py:1719 |
memory.flush_min_turns |
6 |
없음 — 코드에 리더가 0곳 |
마지막 줄이 이번 재검증의 부산물이다. flush_min_turns: 6은 cli-config.yaml.example:702에만 존재하고 저장소 어디에서도 읽히지 않는다. 세션 경계 flush는 cli.py:8095의 _launch_session_boundary_memory_flush로 다시 구현됐는데 이 게이트를 참조하지 않는다. 배울 점: 설정 예제 파일은 코드와 함께 늙지 않는다. 자기 하네스에서도 "설정 키 → 리더" grep을 CI에 넣을 값어치가 있다.
참고 출처
↗ tools/memory_tool.py:67,165 — ENTRY_DELIMITER "\n§\n" 및 2200/1375 상한↗ tools/memory_tool.py:682-693 — format_for_system_prompt (동결 스냅샷)↗ tools/memory_tool.py:807-861 — _detect_external_drift (라운드트립 + 엔트리 크기 2신호)↗ tools/memory_tool.py:1176-1217 — MEMORY_SCHEMA (action enum = add/replace/remove, operations 배치)↗ agent/memory_manager.py:354-361 — build_memory_context_block (<memory-context> 펜스)↗ agent/memory_manager.py:182-344 — StreamingContextScrubber (청크 경계 펜스 제거)↗ agent/memory_manager.py:404-426, 646-661, 877-924 — 외부 Provider 1개 제한 / 단일워커 sync / 세션경계 원자 커밋↗ agent/memory_provider.py:81-341 — MemoryProvider ABC 훅 계약↗ agent/agent_init.py:1683-1730 — 메모리 설정 리더 및 기본값↗ agent/system_prompt.py:523-532 — 메모리 블록의 volatile 밴드 주입↗ agent/turn_context.py:55-85, 594-606 — API 사본 펜스 주입 / 메모리 nudge↗ agent/context_engine.py:33-52 — MEMORY_CONTEXT_MAX_CHARS 6,000 + redact↗ website/docs/user-guide/features/memory.md:61-67 — "There is no read action"↗ cli-config.yaml.example:684-702 — memory 설정 블록 (flush_min_turns 포함)교차 세션 회상 — SQLite FTS5와 CJK 트라이그램
핵심 요점
- 인덱스는 2종이 아니라 3종이다 — unicode61(messages_fts), trigram(messages_fts_trigram), 그리고 로더블 확장으로 붙는 cjk_unicode61 바이그램 인덱스(messages_fts_cjk, PR #65544). 세 번째는 발표자료 이후 추가된 것으로 보이며, 트라이그램이 못 하는 1~2자 CJK 어휘(일본/项目)를 인덱스 속도로 처리한다.
- 질의는 두 인덱스를 병합하지 않는다 — cjk → trigram → LIKE 순 우선순위 폴백이고, 앞 단계가 성공하면 뒤를 건너뛴다(_trigram_succeeded). 각 폴백 조건이 인덱스의 물리적 한계(2자 이상 런만 바이그램, 3자 이상만 트라이그램, tool 행은 인덱스에 없음)에서 그대로 파생된다.
- 로컬 SQLite 3.51 실측: unicode61에서 '캘리브' 질의는 '캘리브레이션'에 0건, trigram은 1건. 반면 2자 '일본'은 trigram도 0건 — 이 두 줄이 트라이그램 인덱스와 CJK 바이그램 인덱스가 각각 존재하는 이유를 그대로 보여준다. 소스 주석의 'unicode61이 CJK를 개별 글자로 쪼갠다'는 설명은 실측과 어긋난다(런 전체가 한 토큰).
- ⚠️ session_search에 LLM 요약 경로가 없다 — 모듈 docstring이 'no summary LLM path'를 명시하고 도구 스키마도 'No LLM calls'라고 적는다. 컨텍스트 예산은 요약이 아니라 구조(±5 윈도우 + 앞뒤 bookend 3개)와 글자수 절단(윈도우 4000자 / bookend 1200자)으로 관리한다.
- FTS5 감지는 temp 가상테이블 프로브 한 방이고, 에러를 등급으로 나눈다 — 'no such module: fts5'는 검색 전체 비활성화 + 트리거 드롭(쓰기 경로 보존), 'no such tokenizer: trigram|cjk_unicode61'은 해당 인덱스만 비활성화. 안내 문구는 'managed uv guarantees FTS5'.
- v23부터 인덱스가 external-content + role<>'tool' 뷰 기반이다. 근거는 실측 — 트라이그램은 원문의 약 2.6배로 부풀고 도구 행이 메시지 바이트의 약 90%라서, 옛 inline 방식에서는 25GB DB 중 18.9GB가 인덱스였다. 마이그레이션은 자동이 아니라 `hermes sessions optimize-storage` opt-in.
교차 세션 회상 — SQLite FTS5와 CJK 트라이그램
하나의 state.db가 CLI와 게이트웨이를 잇는다
Hermes의 모든 세션 메시지는 ~/.hermes/state.db 한 파일에 들어간다(hermes_state.py:333, DEFAULT_DB_PATH). CLI(cli.py:4593), 에이전트 루프(run_agent.py:617), 게이트웨이(gateway/run.py:6199, AsyncSessionDB(SessionDB())), 미러·셧다운 플러시까지 전부 인자 없는 SessionDB()를 열어 같은 파일을 잡는다. 파일 헤더가 밝히는 설계 근거는 짧다 — "WAL mode for concurrent readers + one writer (gateway multi-platform)"(hermes_state.py:10).
여기서 배울 점은 스키마가 아니라 경계 선택이다. 하네스마다 세션 로그를 JSONL로 흘려두고 검색은 나중에 붙이는 게 흔한데, Hermes는 반대로 검색 가능한 저장소를 먼저 정하고 모든 프로세스를 그 위로 모았다. 그 대가로 SQLite 잠금·손상·NFS 같은 문제가 전부 이 파일 하나로 집중되고(hermes_state.py:693의 NFS/SMB/FUSE 힌트), 실제로 hermes_state.py가 10,399줄까지 부푼 이유의 상당 부분이 그 방어 코드다.
인덱스는 2개가 아니라 3개다 — ⚠️ 발표자료 대비 변경
발표자료가 다뤘을 "기본 인덱스 + 트라이그램 인덱스" 2종 구성은 현재 소스에서 3종이다.
| 테이블 | 토크나이저 | 콘텐츠 소스 | tool 행 | 최소 질의 길이 | 가용 조건 |
|---|---|---|---|---|---|
messages_fts |
unicode61(기본) | content='messages' |
포함 | 토큰 단위 | FTS5만 있으면 |
messages_fts_trigram |
trigram |
messages_fts_trigram_src 뷰 |
제외 | CJK 3자/토큰 | SQLite ≥ 3.34 |
messages_fts_cjk |
cjk_unicode61 |
messages_fts_cjk_src 뷰 |
제외 | CJK 2자 | libfts5_cjk.so 로드 시 |
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts_trigram USING fts5(
content, tool_name, tool_calls,
content='messages_fts_trigram_src',
content_rowid='id',
tokenize='trigram'
);
hermes_state_common.py:485-492
두 가지가 눈에 띈다. 첫째, v23부터 인덱스가 external-content로 바뀌었다. 이전(v11~v22, LEGACY_FTS_TRIGRAM_SQL, hermes_state_common.py:589)에는 각 가상 테이블이 content || tool_name || tool_calls 사본을 따로 들고 있었다. 둘째, role <> 'tool' 뷰로 도구 행을 인덱스에서 빼버렸다. 근거가 수치로 남아 있다 — 도구 행은 메시지 바이트의 약 90%(base64, 파일 덤프)이고 트라이그램 증폭률은 원문의 약 2.6배라서, 둘이 합쳐 "무거운 설치에서 state.db의 약 75%(관측: 25GB 중 18.9GB)"였다(hermes_state_schema.py:901-908).
교훈은 이렇다. 에이전트 트랜스크립트에서 인덱싱 비용은 사람이 쓴 글이 아니라 기계 출력이 결정한다. 도구 결과는 "저장하되 부분문자열 인덱싱은 하지 않는다"가 합리적 타협이고, Hermes는 그걸 뷰 하나로 구현했다. 대신 마이그레이션은 자동이 아니다 — hermes sessions optimize-storage를 사용자가 직접 부르는 opt-in이고, 이유도 명시돼 있다(VACUUM 중 일시적으로 파일 크기 2배, 25GB DB에서 1~2시간).
왜 트라이그램을 따로 두나 — 직접 측정
unicode61은 공백/구두점으로 토큰을 자른다. 한국어처럼 어절 안에서 부분 일치를 원하는 언어에선 이게 치명적이다. 로컬 SQLite 3.51.0으로 직접 확인했다.
문서 "어제 캘리브레이션 세션에서 gainV 를 고쳤다" |
messages_fts(unicode61) |
trigram |
|---|---|---|
캘리브 |
0건 | 1건 |
리브레 |
0건 | 1건 |
캘리브레이션 |
1건 | 1건 |
일본(2자) |
0건 | 0건 |
즉 "캘리브"로는 "캘리브레이션"이 절대 안 걸린다. 트라이그램은 겹치는 3글자(캘리브/리브레/브레이/레이션)를 색인하므로 걸린다. 부수적으로: 소스 주석은 unicode61이 CJK를 "개별 글자로 쪼갠다"고 설명하는데(hermes_state_search.py:1576-1578), 실측은 그 반대였다 — 大别山项目조차 大别山项目的进展에 0건이다. 런(run) 전체가 한 토큰이 되어 부분 일치가 아예 없다. 주석의 진단은 틀렸지만 결론(unicode61로 CJK 부분문자열은 불가)은 맞다.
그리고 위 표의 마지막 줄이 세 번째 인덱스의 존재 이유다. 트라이그램은 3자 미만 토큰에서 트라이그램을 하나도 못 만들어 무조건 0건이고(_trigram_eligible_tokens, hermes_state_search.py:1288-1301), 일본·구글·项目 같은 2자 어휘는 LIKE 전체 스캔으로 떨어져 "멀티-GB 설치에서 질의당 3~6초 CPU"였다(hermes_state.py:1866-1869). cjk_unicode61은 unicode61을 감싸 CJK 런을 겹치는 바이그램으로 재발행하는 ~250줄짜리 로더블 확장(native/fts5_cjk/fts5_cjk.c, 252줄, PR #65544)이다.
병합이 아니라 우선순위 폴백이다
"두 인덱스를 합쳐 질의"하지 않는다. _search_messages_impl은 하나가 성공하면 나머지를 건너뛴다(_trigram_succeeded 플래그, hermes_state_search.py:1603 이하).
| 순서 | 조건 | 경로 |
|---|---|---|
| 0 | 질의에 CJK 없음 | messages_fts |
| 1 | cjk 인덱스 사용가능 ∧ role='tool' 필터 아님 ∧ 1자 단독 CJK 런 없음 |
messages_fts_cjk |
| 2 | 위 실패 ∧ CJK ≥3자 ∧ 모든 토큰 ≥3 CJK자 ∧ trigram 사용가능 | messages_fts_trigram |
| 3 | 그 외 전부 | LIKE %...% 전체 스캔 |
예외 조건이 전부 인덱스의 물리적 한계에서 나온 게 좋다. _has_lone_cjk_run(hermes_state_search.py:1268-1285)은 "바이그램 인덱스는 2자 이상 런만 담으므로 고립된 1자 항은 못 찾는다"를 그대로 코드로 옮긴 것이고, role_filter=['tool']이 LIKE로 가는 건 도구 행이 두 부분문자열 인덱스에 아예 없기 때문이다. 실무 팁 하나: HERMES_SEARCH_SLOW_MS(기본 1000ms) 이상 걸린 질의는 어느 경로를 탔는지 한 줄 로그로 남는다(fts_cjk / fts5 / trigram / like_scan, config_defaults.py:2779-2784). 라우팅이 있는 검색기를 만든다면 경로 이름을 반드시 로그에 찍어라 — 없으면 회귀를 못 잡는다.
FTS5 부재 감지 — 세 단계로 쪼갠다
감지는 프로브 한 방이다.
def _sqlite_supports_fts5(self, cursor) -> bool:
try:
cursor.execute("CREATE VIRTUAL TABLE temp._hermes_fts5_probe USING fts5(x)")
cursor.execute("DROP TABLE temp._hermes_fts5_probe")
return True
except sqlite3.OperationalError as exc:
if not self._is_fts5_unavailable_error(exc):
raise
self._warn_fts5_unavailable(exc)
return False
hermes_state_schema.py:106-116
핵심은 에러를 등급으로 나눈 것이다. no such module: fts5면 검색 전체를 끄고 경고를 띄운다 — "full-text session search disabled. Run hermes update to rebuild the venv with a current Python (managed uv guarantees FTS5)"(hermes_state.py:2909-2915). 반면 no such tokenizer: trigram / no such tokenizer: cjk_unicode61은 그 인덱스 하나만 끈다(_is_trigram_unavailable_error, hermes_state.py:2851-2862) — 경고도 WARNING이 아니라 INFO다. 그리고 FTS5가 아예 없으면 트리거를 드롭해 쓰기 경로를 살린다(hermes_state_schema.py:688-697). cjk도 같다: 토크나이저를 못 여는 프로세스는 fts_cjk_stale 표식을 남기고 트리거를 지운 뒤, 능력 있는 호스트에서 재빌드하라고 안내한다(hermes_state.py:2955-2976).
여기서 가장 값진 건 external-content 인덱스의 위험을 명시적으로 다룬 대목이다. 인덱스가 갖고 있지도 않은 rowid에 'delete' 연산을 보내면 FTS5 인덱스가 깨진다. 그래서 백필 중에는 모든 트리거가 fts_rebuild_high_water / fts_rebuild_progress 마커 쌍의 조건절로 게이팅된다(그리고 cjk는 전용 마커 쌍을 따로 쓴다). 부분 백필 상태를 트리거 WHEN 절로 표현한 이 패턴은, 재개 가능한 인덱스 재구축이 필요한 어떤 시스템에도 그대로 훔쳐 쓸 만하다.
session_search — LLM 요약은 없다 ⚠️ 발표자료 대비 변경
발표자료가 "매칭 세션을 LLM 요약으로 압축해 컨텍스트에 넣는다"고 설명했다면, 그 경로는 지금 소스에 없다. 모듈 docstring이 이력까지 남겨놨다: PR #20238이 fast/summary 이중 모드를 심었고, PR #26419가 앵커 드릴다운·bookend를 더했으며, 현재 모듈은 "no mode parameter, no summary LLM path, and explicit scroll support"로 합쳤다(tools/session_search_tool.py:25-29). 도구 스키마 설명문에도 "No LLM calls — every shape returns actual messages from the DB"가 박혀 있다.
컨텍스트 예산은 요약이 아니라 구조와 절단으로 잡는다.
| 장치 | 값 | 위치 |
|---|---|---|
| FTS 스캔 폭 | 300행 (dedup 전) | _DISCOVER_SCAN_LIMIT, :56 |
| 반환 세션 수 | 기본 3 | session_search(limit=3), :851 |
| 매치 주변 윈도우 | ±5 | get_anchored_view(..., window=5, bookend=3), :795 |
| 세션 앞/뒤 bookend | 각 3개 | 같은 곳 |
| 윈도우 메시지 절단 | 4,000자 | _shape_message, :822 |
| bookend 절단 | 1,200자 | :818, :824 |
| 압축 요약 행 제외 | [CONTEXT COMPACTION / [CONTEXT SUMMARY]: |
_COMPACTION_PREFIXES, :77 |
설계 의도는 분명하다. 요약을 쓰면 LLM 호출·지연·요약 오류가 검색 경로에 들어온다. 대신 Hermes는 "목표(bookend_start) + 히트 주변(±5) + 결말(bookend_end)"이라는 모양으로 세션 한 건을 재구성하고, 절단한 곳엔 content_truncated / original_content_chars를 붙여 모델이 더 필요하면 SCROLL 모드로 재앵커하게 한다. 압축 요약 행을 bookend에서 걸러내는 규칙(#43175)도 같은 맥락 — 안 그러면 예전 압축 페이로드가 새 세션에 통째로 재유입된다. 크론 세션을 제외가 아니라 강등하는 _order_for_recall(:233, #19434) 역시 "BM25만 쓰면 자동화 로그가 사람 세션을 굶긴다"는 실측에서 나왔다.
실무 주의 두 가지
크기. 트라이그램은 원문의 약 2.6배다. 도구 출력이 많은 워크로드라면 인덱스가 DB의 대부분을 먹는다. Hermes의 답은 (a) 도구 행을 부분문자열 인덱스에서 제외, (b) external-content로 사본 제거, (c) 마이그레이션은 opt-in이다. 직접 하네스를 만든다면 최소한 (a)는 1일차부터 하라.
프라이버시. 이 DB는 원문 전체를 평문으로 보관하고, 인덱스는 그걸 3배로 복제한다. session_search(profile=...)는 다른 프로파일의 state.db를 read-only로 열고(:298-320), _locate_session_db는 세션 id 하나를 찾으려고 모든 프로파일의 state.db를 순회한다(:343-383). 프로파일 분리는 보안 경계가 아니라 편의 분리다. 저장소에서 확인되지 않은 것: 인덱싱 전 비밀값 마스킹이나 세션 단위 암호화 장치는 찾지 못했다. .env·토큰을 붙여넣은 대화가 있다면 그건 그대로 state.db와 인덱스 안에 남는다고 가정하는 게 안전하다.
참고 출처
↗ hermes_state_common.py:415-421 (messages_fts, unicode61 external-content DDL)↗ hermes_state_common.py:479-492 (FTS_TRIGRAM_SQL — tool 행 제외 뷰 + tokenize='trigram')↗ hermes_state.py:1866-1913 (CJK 바이그램 인덱스 근거 주석 + FTS_CJK_TABLE_SQL, tokenize='cjk_unicode61')↗ hermes_state.py:2835-2916 (_is_fts5_unavailable_error / _is_trigram_unavailable_error / _warn_fts5_unavailable)↗ hermes_state_schema.py:106-116 (_sqlite_supports_fts5 프로브) 및 :688-697 (FTS5 부재 시 트리거 드롭)↗ hermes_state_schema.py:901-910 (트라이그램 2.6배 증폭 · 25GB 중 18.9GB · opt-in 마이그레이션 근거)↗ hermes_state_search.py:1575-1800 (_search_messages_impl 라우팅: cjk → trigram → LIKE 폴백)↗ hermes_state_search.py:1268-1301 (_has_lone_cjk_run / _trigram_eligible_tokens)↗ tools/session_search_tool.py:1-30 (모듈 docstring — 'no summary LLM path', 'No LLM calls anywhere')↗ tools/session_search_tool.py:250-295, 690-830 (_shape_message 절단 · _discover 윈도우/bookend)↗ native/fts5_cjk/README.md (cjk_unicode61 빌드/설치, sessions.cjk_fts 토글, PR #65544)↗ hermes_cli/config_defaults.py:2771-2784 (sessions.cjk_fts 기본 True, search_slow_ms 기본 1000)↗ SQLite FTS5 — trigram tokenizer (SQLite >= 3.34) 공식 문서닫힌 학습 루프 — nudge → background review → curator
핵심 요점
- nudge 기본값은 발표자료대로 각 10 (agent_init.py:1686/:1786)이지만 세는 단위가 다르다 — 메모리는 사용자 턴, 스킬은 툴 루프 이터레이션이고 둘 다 해당 툴이 실제 쓰이면 0으로 리셋된다. 게이트웨이는 prior_user_turns % interval 로 카운터를 재수화해(#22357) 짧은 세션에서 트리거가 영원히 안 서는 문제를 막는다.
- ⚠️ 발표자료 대비 변경: background review 는 기본적으로 '저렴한 보조 모델'이 아니라 **메인 모델**로 돈다. 비용 절감의 주체는 싼 모델이 아니라 부모와 동일한 프롬프트 캐시 프리픽스 재사용이며, 다른 모델로 라우팅했을 때만 다이제스트(tail=24) 재생으로 전환한다.
- ⚠️ 발표자료 대비 변경: 툴 화이트리스트는 memory·skill_manage 2개가 아니라 skills 툴셋 3개(skills_list/skill_view/skill_manage) + memory(프로파일이 memory_enabled 일 때만 조건부, #54937)다.
- 포크가 부모 session_id 를 공유하는 대가로 _persist_disabled=True·compression_enabled=False 를 건다 — 없으면 리뷰의 하네스 프롬프트가 사용자 세션 DB 에 박혀 다음 턴에 에이전트가 '큐레이터가 되어버리는' 사고가 난다.
- curator 상수 7일·2시간·30일→90일은 전부 실측 일치. 단 DEFAULT_CONSOLIDATE=False 라 LLM 패스는 옵트인이고, min_idle_hours 게이트는 실제 호출부 두 곳(cli.py:15183, gateway/run.py:26765)이 모두 float('inf')를 넘겨 사실상 발동하지 않는다.
- '자동 삭제 없음'은 사실이지만 큐레이터 툴 목록의 action=delete 는 의미가 archive 다(curator.py:532). 복구는 archive 상태 + 실행 전 스킬 스냅샷(DEFAULT_KEEP=5) + rollback() 의 이중 구조.
- learning_graph.py 는 LLM·임베딩 없이 어휘 겹침(스킬명 일치 +6, 3글자 토큰 교집합, 상위 4개)으로 메모리↔스킬 엣지를 만들고 isolated_pct 같은 밀도 지표를 결정적으로 찍게 해둔다 — 자동 학습 루프의 오염을 감지하는 유일한 무료 계측기.
닫힌 학습 루프 — nudge → background review → curator
Hermes 의 자기개선은 하나의 거대한 "학습 모듈"이 아니라, 시간 스케일이 다른 세 장치를 이어붙인 파이프라인이다. 턴 단위로 트리거를 세고(nudge), 턴 직후에 포크된 에이전트가 판단하고(background review), 주 단위로 그 산출물을 정리한다(curator). 이 글은 2026년 6월 발표자료를 현재 HEAD(5292074)로 재검증한 것이고, 어긋난 지점을 먼저 밝힌다.
① Nudge — "10턴/10반복"은 맞았다. 세는 단위가 다를 뿐
| 항목 | 심볼 | 기본값 | 세는 단위 | 리셋 조건 | 설정 키 |
|---|---|---|---|---|---|
| 메모리 | _memory_nudge_interval |
10 | 사용자 턴 | memory 툴 호출 시 |
memory.nudge_interval |
| 스킬 | _skill_nudge_interval |
10 | 툴 루프 이터레이션 | skill_manage 호출 시 |
skills.creation_nudge_interval |
발표자료의 "각 10턴/10반복"은 실측과 일치한다(agent/agent_init.py:1686, :1786). 다만 스킬 쪽은 턴이 아니라 툴 호출 이터레이션이라, 툴을 많이 쓰는 한 턴만으로도 트리거될 수 있다. 판정 위치도 다르다 — 메모리는 루프 진입 전(turn_context.py:599), 스킬은 루프 종료 후(turn_finalizer.py:721). 그리고 둘 다 카운터는 해당 툴이 실제로 쓰이면 0으로 돌아간다(tool_executor.py:604-607). 즉 nudge 는 "주기적 알림"이 아니라 "한동안 저장 행위가 없었다"는 결핍 신호다.
게이트웨이가 카운터를 재수화하는 이유는 여기서 나온다. 프로세스 재시작마다 _turns_since_memory 가 0이면, 세션이 짧게 끊기는 배포(게이트웨이·텔레그램)에서는 임계값에 영원히 도달하지 못한다.
if conversation_history and agent._user_turn_count == 0:
prior_user_turns = sum(1 for m in conversation_history if m.get("role") == "user")
if prior_user_turns > 0:
agent._user_turn_count = prior_user_turns
if agent._memory_nudge_interval > 0 and agent._turns_since_memory == 0:
agent._turns_since_memory = prior_user_turns % agent._memory_nudge_interval
agent/turn_context.py:549-557 (issue #22357)
배운 것: 인메모리 카운터로 정책을 거는 순간, 그 카운터의 지속성이 정책의 일부가 된다. 나머지(%)로 복원하는 게 정확하진 않지만 "절대 안 터짐"보다 낫다는 판단이다.
② Background Review — 응답을 보낸 뒤, 데몬 스레드에서 자기 자신을 포크
트리거가 서면 turn_finalizer.py:740-752 가 final_response 가 있고, 인터럽트되지 않았고, skip_background_review (cron 세션용 스위치)가 꺼져 있을 때만 포크를 띄운다. 스레드는 name="bg-review", daemon=True (run_agent.py:1828-1831). 포크된 AIAgent 는 max_iterations=16, quiet_mode=True, 부모의 session_id 를 공유한다.
세션 ID 를 공유하는 대가로 붙은 방어가 이 파일의 백미다.
review_agent._persist_disabled = True
review_agent._session_db = None
review_agent._session_json_enabled = False
agent/background_review.py:828-830
주석은 이걸 "curator-takeover 근본 원인"이라 부른다. 이걸 안 걸면 리뷰의 하네스 턴("Review the conversation above and update the skill library…")이 사용자의 진짜 세션 DB에 user 메시지로 박히고, 다음 턴에 에이전트가 그걸 상주 지시로 읽어 "큐레이터가 되어버린다". 압축도 막는다(compression_enabled = False) — 포크가 압축 레이스에서 이기면 부모를 게이트웨이가 모르는 새 자식으로 회전시켜 형제 세션이 둘 생긴다(#38727).
⚠️ 발표자료 대비 변경 ①: 리뷰는 "저렴한 보조 모델"로 돌지 않는다.
기본값은 메인 모델이다. 이유가 명시돼 있다 — 전체 대화가 이미 프롬프트 캐시에 warm 하므로 재생이 싼 캐시 리드가 된다. 다른(싼) 모델로 라우팅하면 캐시가 무조건 cold 이므로, 그때만 전체가 아닌 다이제스트를 재생한다(_digest_history, tail=24, 오래된 턴은 합성 user 메시지 하나로 접음). "같은 모델 → 전문 재생, 다른 모델 → 다이제스트. 그게 정책의 전부다"라고 소스가 직접 쓴다(agent/background_review.py:34-44). 즉 **비용 최적화의 주체는 '싼 모델'이 아니라 '캐시 프리픽스 재사용'**이다. 그래서 포크는_skip_mcp_refresh = True로 MCP 지연 연결까지 막아tools[]를 부모와 바이트 동일하게 유지한다.
⚠️ 발표자료 대비 변경 ②: 화이트리스트는
memory·skill_manage둘이 아니다.
review_toolsets = ["skills"]
if review_agent._memory_enabled or review_agent._user_profile_enabled:
review_toolsets.insert(0, "memory")
review_whitelist = {t["function"]["name"] for t in get_tool_definitions(
enabled_toolsets=review_toolsets, quiet_mode=True)}
set_thread_tool_whitelist(review_whitelist, deny_msg_fmt=(
"Background review denied non-whitelisted tool: {tool_name}. ..."))
agent/background_review.py:893-907
skills 툴셋은 skills_list, skill_view, skill_manage 세 개다(toolsets.py:195-199). 읽기 툴이 같이 열려야 "기존 스킬을 패치할지 새로 만들지"를 판단할 수 있으니 당연한데, 발표자료의 2개 요약은 부정확하다. 더 중요한 건 memory 가 조건부라는 점 — 프로파일이 memory_enabled: false 인데 하드코딩된 ["memory","skills"] 로 열어 메모리 비활성 프로파일을 오염시킨 버그(#54937 layer 2)의 수정이다. 화이트리스트는 스레드 스코프이고, 위험 명령 승인 콜백도 무조건 deny 로 고정된다(부모 TUI 의 input() 데드락 회피, #15216).
③ Curator — 상수는 전부 맞았다. 게이트 하나가 비어 있을 뿐
DEFAULT_INTERVAL_HOURS = 24 * 7 # 7 days
DEFAULT_MIN_IDLE_HOURS = 2
DEFAULT_STALE_AFTER_DAYS = 30
DEFAULT_ARCHIVE_AFTER_DAYS = 90
DEFAULT_CONSOLIDATE = False
agent/curator.py:70-78
7일·2시간·30일→90일은 발표자료와 정확히 일치한다. is_enabled() 기본값도 True. 다만 두 가지가 추가로 드러난다.
첫째, DEFAULT_CONSOLIDATE = False — LLM 이 도는 "엄브렐라 통합" 패스는 옵트인이다. 기본 상태에서 curator 가 하는 일은 LLM 없는 결정적 프룬뿐이고, 보조 모델 비용은 0이다.
둘째, min_idle_hours 는 사실상 강제되지 않는다. 게이트는 호출자가 측정값을 줄 때만 적용되는데(curator.py:2011-2015), 실제 호출부는 두 곳뿐이고 둘 다 float("inf") 를 넘긴다 — CLI 시작 시(cli.py:15183, "CLI startup = fully idle")와 게이트웨이 하우스키핑 틱(gateway/run.py:26765). 즉 현재 배포 경로에서 유휴 판정은 "인터랙티브 루프 진입 전"이라는 구조적 사실로 대체돼 있고, 2시간은 서드파티 호출자를 위한 계약으로만 남아 있다. 자기개선 루프를 옮겨 심을 때 게이트의 존재와 게이트의 발동을 구분해서 봐야 한다는 사례다.
자동 전이 로직에는 세 겹의 예외가 있다(curator.py:331-370): pinned 스킵, cron 잡이 참조 중인 스킬 스킵(스케줄러는 실제 발화 때만 사용 카운트를 올리므로 90일보다 드물게 도는 잡의 스킬이 밑에서 빠져나간다), 그리고 use_count == 0 인 스킬의 유예 바닥("사용 0은 낡음의 증거가 아니라 증거의 부재"). 다시 쓰이면 stale → active 로 되돌아온다.
"자동 삭제 없음"도 사실이다. 다만 함정이 있다 — 큐레이터 프롬프트가 모델에게 주는 툴 목록에 skill_manage action=delete 가 있고, 그 설명이 "archive a skill" 이다(curator.py:532). 이름은 delete, 의미는 archive. 게다가 실행 전 curator_backup.snapshot_skills(reason="pre-curator-run") 로 스킬 트리 스냅샷을 뜨고 최근 5개를 보관한다(DEFAULT_KEEP = 5, curator_backup.py:58), rollback() 도 있다. 즉 복구는 archive 상태 + 스냅샷 이중이다.
learning_graph — 발표자료에 없던 산출물 계층
agent/learning_graph.py 는 이 루프의 결과를 사람이 볼 수 있는 그래프로 만든다. 노드는 base 가 아니면서 created_by == "agent" 이거나 use_count > 0 인 스킬 + MEMORY.md/USER.md 를 \n§\n 로 쪼갠 메모리 청크다. 엣지는 두 종류 — 프론트매터의 related_skills(양끝 노드가 다 있을 때만), 그리고 메모리↔스킬의 어휘 겹침이다.
score = 0
if skill_name_lower in text:
score += 6
score += len(tokens & text_tokens)
...
for _, skill_name in scored[:4]:
edges.append((mem_id, skill_name))
agent/learning_graph.py:236-244
임베딩도 LLM 도 없다. 3글자 이상 토큰 교집합 + 스킬명 완전일치 가중 6점, 상위 4개만. python -m agent.learning_graph 로 엣지 밀도(isolated_pct 포함)를 찍어볼 수 있게 해둔 게 핵심이다 — 자동 학습 루프에서 제일 먼저 썩는 건 "고아 노드 비율"이고, 그걸 결정적으로 측정 가능하게 만들어 뒀다.
한편 사용자가 명시적으로 거는 /learn 은 포크가 아니라 라이브 에이전트의 평범한 턴으로 돈다(agent/learn_prompt.py:20-27). 자동 학습은 격리, 수동 학습은 인라인 — 신뢰 수준에 따라 격리 강도를 나눈 것이다.
위험: 잘못된 교훈은 지워지지 않는다
자동 학습 루프의 진짜 실패 모드는 "아무것도 안 배움"이 아니라 틀린 교훈의 영속화다. 그리고 리뷰 프롬프트는 "아무 것도 안 하는 패스는 중립이 아니라 놓친 학습 기회"라며 적극성을 명시적으로 압박한다. 이건 오염 압력을 스스로 올리는 설계다. 그 압력에 대한 방어가 프롬프트 안에 열거돼 있다(background_review.py:272-296):
| 위험 | 소스의 방어 |
|---|---|
| 환경 의존 실패를 규칙화 | "missing binaries·command not found 는 저장 금지 — 사용자가 고칠 수 있다" |
| 툴에 대한 부정 단언 | "'browser tools do not work' 류는 몇 달간 스스로를 향해 인용하는 거부로 굳는다" |
| 미해결 실패를 워크플로로 포장 | "검증 안 된 실패 시퀀스를 'reliable workflow' 로 쓰지 말 것" |
| 남의 스킬 오염 | bundled/hub/external_dir/pinned/user-owned 는 쓰기 거부, hermes curator adopt 권고 |
| 메모리 무한 증식 | memory_char_limit=2200, user_char_limit=1375 (agent_init.py:1705-1707) |
| 잘못된 정리 | archive-only + 실행 전 스냅샷(keep 5) + rollback + --dry-run |
| 무성의 자동 실행 | 리뷰 행동을 💾 Self-improvement review: … 로 사용자에게 요약 출력 |
빈 곳도 분명하다. 방어의 다수가 프롬프트 텍스트이지 코드 강제가 아니다. 화이트리스트·_persist_disabled·pinned 스킵은 코드지만, "환경 의존 실패를 저장하지 마라"는 모델이 지켜야 한다. 그리고 메모리 쪽에는 스킬의 stale/archive 에 해당하는 시간 기반 감쇠가 없다 — curator 는 스킬만 정비한다. MEMORY.md 는 문자 상한이 유일한 브레이크다.
실무 교훈 — 자기개선 루프 도입 시 최소 안전 요건
- 쓰기 경로를 실행 경로에서 물리적으로 분리한다. 별 스레드 + 툴 화이트리스트 + 승인 콜백 강제 deny. 프롬프트로 "메모리만 써"라고 하지 말 것.
- 세션/영속 상태를 공유하면 반드시 쓰기를 봉인한다.
_persist_disabled가 없으면 하네스 프롬프트가 사용자 히스토리에 섞여 다음 턴의 지시가 된다. - 트리거 카운터는 재수화한다. 안 하면 짧은 세션에서 학습이 영원히 안 돈다 — 그리고 이 실패는 조용하다.
- 삭제를 만들지 말고 archive + 스냅샷을 만든다. 그리고 dry-run 을 1급 기능으로.
- 핀/외부 소유/스케줄러 참조는 자동 전이 대상에서 뺀다. "사용 0"을 낡음으로 읽지 말 것.
- 산출물의 건강 지표를 결정적으로 측정 가능하게 둔다. 고아 노드 비율·엣지 밀도처럼 LLM 없이 찍히는 숫자가 있어야 오염을 알아챈다.
- 게이트가 있다는 것과 게이트가 발동한다는 건 다르다. curator 의
min_idle_hours처럼, 호출부가 전부inf를 넘기면 그 안전장치는 문서에만 존재한다.
참고 출처
↗ agent/background_review.py:34-44 (aux 라우팅 정책 — 기본은 메인 모델 + warm 캐시)↗ agent/background_review.py:893-907 (스레드 스코프 툴 화이트리스트)↗ agent/curator.py:70-78 (DEFAULT_INTERVAL_HOURS / MIN_IDLE / STALE / ARCHIVE / CONSOLIDATE)↗ agent/curator.py:331-370, 2001-2019 (자동 전이 예외 3종과 유휴 게이트)↗ agent/agent_init.py:1686, 1786 (_memory_nudge_interval / _skill_nudge_interval = 10)↗ agent/turn_context.py:549-557 (nudge 카운터 재수화, issue #22357)↗ agent/turn_finalizer.py:719-752 (스킬 트리거 판정 + 응답 이후 포크 게이트)↗ agent/learning_graph.py:227-245 (메모리↔스킬 어휘 겹침 엣지)↗ agent/learn_prompt.py:1-27 (/learn 은 포크가 아닌 라이브 턴)↗ agent/curator_backup.py:58, 217-321 (DEFAULT_KEEP=5, 스냅샷·프룬)↗ toolsets.py:195-199 (skills 툴셋 = skills_list/skill_view/skill_manage)↗ run_agent.py:1799-1832 (bg-review 데몬 스레드 구성)↗ cli.py:15181-15189 / gateway/run.py:26756-26769 (maybe_run_curator 호출부 — 둘 다 idle=inf)↗ agent/tool_executor.py:604-607 (memory / skill_manage 사용 시 카운터 리셋)관통하는 설계 원칙과 읽기 시작점
핵심 요점
- 실측 재검증: 코어 834,291 LOC(스킬·테스트 제외), 전체 Python 1,523,847줄 중 671,092줄이 tests/ — "85만 LOC"는 맞지만 절반이 테스트라는 맥락이 빠지면 오해를 부른다
- 캐시는 추상화가 아니라 Anthropic cache_control 포맷 + 비대상 프로바이더에서 strip 하는 구조다(plan_cache_sections_for_destination). 4개 브레이크포인트, 프리픽스 부재 시 1+3 폴백
- 예산은 상수가 아니라 객체 — IterationBudget(부모 500=CLI·게이트웨이 / 90=라이브러리 직접 생성, 서브에이전트 각 50, execute_code는 refund)와 3계층 툴 결과 예산(100k/200k/1.5k, read_file은 inf로 핀)
- 신뢰 경계는 위조 가능성을 전제한다 — 32자 이상만 <untrusted_tool_result>로 감싸고 구분자 토큰을 대소문자 무시로 defang, "이미 감쌌나" 빠른 경로는 의도적으로 배제
- 자식 타임아웃 기본값은 None이고 대신 하트비트 감시(30s 주기, 유휴 450s / 툴 내 1200s)로 wedge를 판정 — 느린 로컬 모델을 죽이지 않으면서 고착은 잡는 절충
- 플러그인 ABC(MemoryProvider 357줄, ContextEngine 489줄)는 열려 있지만 config 키로 항상 하나만 활성 — 툴 스키마 부풀림과 백엔드 충돌을 막는 규율
관통하는 설계 원칙과 읽기 시작점
앞 섹션들이 각각 다룬 것들을 겹쳐 보면 반복되는 패턴이 있다. 새 사실을 더 캐기보다, 그 패턴을 뽑아내고 "우리 하네스에 뭘 옮길 수 있나"까지 밀어붙이는 게 이 섹션의 일이다.
여섯 가지 관통 원칙
| 원칙 | 저장소에서의 근거 | 일반화된 교훈 |
|---|---|---|
| 캐시를 1급 제약으로 | agent/prompt_caching.py(394줄)가 정적 시스템 프리픽스·시스템 끝·최근 비시스템 메시지 2개에 4개 브레이크포인트를 배치. 프리픽스가 없으면 시스템 1 + 최근 3으로 폴백 |
프롬프트를 "문자열"이 아니라 캐시 세그먼트의 열로 취급하라. 어디까지가 불변인지 코드가 알아야 한다 |
| 컨텍스트 격리 | delegate_task의 자식은 부모 히스토리 없이 시작하고, 부모는 호출과 요약만 본다. MAX_DEPTH = 1(flat) |
서브에이전트의 값어치는 병렬성이 아니라 부모 컨텍스트를 안 더럽히는 것이다 |
| 신뢰 경계의 명시화 | _maybe_wrap_untrusted가 web/browser/MCP 결과를 <untrusted_tool_result>로 감싸고, 안에 박힌 구분자 토큰을 하이픈으로 defang |
외부 텍스트는 데이터라고 모델에게 말해줘야 데이터다. 그리고 그 울타리 자체가 위조 가능하다는 걸 전제하라 |
| 예산과 회수 | IterationBudget(62줄): 부모 500(CLI·게이트웨이 경로 / 라이브러리 직접 생성은 90), 서브에이전트 각각 50. execute_code 반복은 refund()로 되돌려준다 |
예산은 숫자가 아니라 객체여야 한다. 환불·스레드 안전·상속 규칙이 붙는 순간 상수로는 못 버틴다 |
| 비동기 자기개선 | background_review.py가 턴마다 에이전트를 포크해 "이번 턴에서 저장할 스킬/메모리가 있나"를 묻고, curator.py는 유휴 시점에 스킬을 정리한다 |
학습 루프를 메인 대화 밖으로 빼라. 두 모듈 다 "메인 세션의 프롬프트 캐시를 건드리지 않는다"를 불변식으로 명시한다 |
| 교체 가능성 | MemoryProvider(357줄)·ContextEngine(489줄) ABC + 플러그인 디스커버리. 모델 프로바이더 34종, 메모리 백엔드 8종 |
확장점은 ABC로 열되 한 번에 하나만 활성으로 닫아라. 여러 개를 동시에 켜면 툴 스키마가 부풀고 충돌한다 |
세 번째와 네 번째가 특히 실전적이다. 신뢰 경계 쪽 상수는 이렇게 좁게 잡혀 있다.
_UNTRUSTED_TOOL_NAMES = frozenset({"web_extract", "web_search"})
_UNTRUSTED_TOOL_PREFIXES = ("browser_", "mcp_")
_UNTRUSTED_WRAP_MIN_CHARS = 32
# 어떤 대소문자로 와도 구분자로 읽히므로 대소문자 무시로 매칭
_DELIMITER_TOKEN_RE = re.compile(r"untrusted_tool_result", re.IGNORECASE)
agent/tool_dispatch_helpers.py:584-599
"이미 감싸져 있으면 건너뛴다"는 빠른 경로를 일부러 넣지 않았다는 주석이 압권이다. 그 체크는 공격자가 위조할 수 있으니 중복 래핑이 안전하다는 판단이다.
예산 쪽은 고정 타임아웃을 버리고 진행 신호를 본다.
DEFAULT_CHILD_TIMEOUT: Optional[float] = None
_HEARTBEAT_INTERVAL = 30
_HEARTBEAT_STALE_CYCLES_IDLE = 15 # 15 * 30s = 450s 턴 사이 유휴 → stale
_HEARTBEAT_STALE_CYCLES_IN_TOOL = 40 # 40 * 30s = 1200s 같은 툴에 고착 → stale
tools/delegate_tool.py:837-855
자식 타임아웃 기본값이 None이다. 대신 "툴 안에 있나 / 턴 사이인가"를 구분해 임계를 다르게 준다. 느린 로컬 GGUF의 긴 prefill을 죽이지 않으면서 진짜 wedge는 잡겠다는 것.
핵심 파일 맵
⚠️ 발표자료 대비 확인: "85만 LOC"는 실측과 맞는다 — 스킬·테스트를 뺀 코어가 834,291줄(HEAD 5292074). 다만 전체 Python 트리는 1,523,847줄이고 그중 **671,092줄이 tests/**다. 규모를 말할 때 이 절반은 테스트라는 걸 같이 말해야 오해가 없다. 디렉터리별로는 hermes_cli 208,396 / agent 134,128 / plugins 127,804 / tools 123,864 / gateway 101,739줄.
| 순서 | 파일 | 역할 | LOC |
|---|---|---|---|
| 1 | README.md / AGENTS.md |
개요·기여 규약 | 264 / 1,474 |
| 2 | cli-config.yaml.example |
설정 키의 사실상 명세서 | 1,735 |
| 3 | agent/conversation_loop.py |
턴 루프의 심장 | 7,596 |
| 4 | agent/iteration_budget.py |
반복 예산(입문용으로 최적) | 62 |
| 5 | agent/prompt_caching.py |
캐시 브레이크포인트 배치 | 394 |
| 6 | agent/agent_runtime_helpers.py |
캐시 정책 판정(anthropic_prompt_cache_policy) |
4,077 |
| 7 | agent/anthropic_adapter.py |
네이티브 와이어 변환 | 3,177 |
| 8 | agent/tool_dispatch_helpers.py |
신뢰 경계 래핑 | 732 |
| 9 | agent/tool_executor.py |
툴 실행 | 2,410 |
| 10 | tools/budget_config.py / tools/tool_result_storage.py |
3계층 결과 예산 | 78 / 254 |
| 11 | tools/delegate_tool.py |
서브에이전트 아키텍처 | 4,342 |
| 12 | agent/context_compressor.py |
기본 압축 엔진 | 7,202 |
| 13 | agent/context_engine.py / plugins/context_engine/__init__.py |
압축 교체점 | 489 / 285 |
| 14 | agent/memory_provider.py / plugins/memory/__init__.py |
메모리 교체점 | 357 / 461 |
| 15 | agent/memory_manager.py |
내장 메모리 + 외부 1개 제한 | 1,241 |
| 16 | agent/background_review.py / agent/curator.py |
비동기 자기개선 | 1,081 / 2,019 |
| 17 | hermes_state_common.py / hermes_state_search.py |
스키마 / FTS·트라이그램 검색 | 614 / 2,305 |
| 18 | agent/auxiliary_client.py |
보조 모델 경로(가장 큰 단일 파일) | 10,044 |
| 19 | run_agent.py / cli.py |
배선과 UI(마지막에 볼 것) | 8,240 / 18,612 |
읽기 경로 3단계
(a) 하루짜리 훑기 — 1 → 2 → 4 → 5 → 11(모듈 독스트링과 상수 블록만) → 13·14의 ABC 독스트링. 파일 본문을 다 읽지 말고 각 모듈 최상단 독스트링만 읽어라. 이 저장소는 독스트링에 설계 근거와 실패 사례 번호를 적어두는 관습이 있어서, 그것만 모아도 지도가 된다.
(b) 하네스만 깊게 — 3 → 9 → 8 → 10 → 11 → 6 → 7. 턴 루프에서 시작해 툴 실행 → 신뢰 경계 → 결과 예산 → 위임 → 캐시 정책 → 와이어 어댑터 순. 여기서 배울 게 가장 많고, 벤더 종속도 여기 몰려 있다.
(c) 메모리·학습만 깊게 — 15 → 14 → 12 → 13 → 16 → 17. MemoryManager의 "외부 프로바이더 1개" 규칙부터 보고, 압축 엔진을 지나 자기개선 루프로, 마지막에 SQLite 스키마·검색으로 내려가라.
그대로 베끼면 안 되는 것
- 규모. 코어 83만 줄에
hermes_cli하나가 20만 줄이다. 원칙은 훔치되 구조는 훔칠 수 없다. 특히agent/가 파일 186개인데 그중 여럿이 3,000줄을 넘는다 — 모듈 경계가 원칙대로 그어져 있지는 않다. - ABC의 추상화 비용.
ContextEngine은 라이프사이클 훅이 6단계,MemoryProvider는 필수 7개 + 선택 훅 8개다. 백엔드를 2개 이상 실제로 바꿔 끼울 계획이 없다면 이 표면적은 순손실이다. - 벤더 결합. 캐시 층 전체가 Anthropic
cache_control모양이다.plan_cache_sections_for_destination은 비대상 프로바이더로 갈 때 마커를 떼어내는 방식으로 대응한다. 즉 추상화가 아니라 "Anthropic 포맷 + strip"이다. 옮길 때는 이 결합을 알고 옮겨야 한다. - 자동 학습 루프의 운영 부담. 턴마다 에이전트를 포크해 리뷰를 돌리는 건 토큰과 동시성 비용이다. 큐레이터가 "절대 자동 삭제하지 않고 아카이브만 한다"는 불변식을 둔 이유도, 이 루프가 조용히 망가지면 되돌릴 수 없기 때문이다.
이번 주에 훔쳐갈 것
- 프롬프트를 캐시 세그먼트로 쪼개기 — 정적 프리픽스를 분리하면 새 세션도 프리픽스 캐시를 재사용한다(
prompt_caching.py모듈 독스트링). - 반복 예산을 객체로 — 62줄이면 끝난다. 부모/자식 독립 예산 +
refund()만 있어도 무한 루프 진단이 쉬워진다. - 고정 타임아웃 → 하트비트 감시 — 기본 타임아웃을 끄고 "유휴 450s / 툴 내 1200s"처럼 상태별 임계를 둬라.
- 툴 결과에 3계층 예산 — 결과당 100,000자, 턴당 200,000자, 인라인 프리뷰 1,500자.
read_file은 무한대로 핀해 persist→read→persist 루프를 막는다(tools/budget_config.py:11-20). - 외부 텍스트에 신뢰 등급 펜스 — 32자 이상만 감싸고, 구분자 토큰은 대소문자 무시로 defang. "이미 감쌌나" 체크는 넣지 마라.
- CJK 트라이그램 인덱스 — 기본 unicode61 토크나이저는 한글/한자를 글자 단위로 쪼개 구문 검색을 깬다. FTS5
trigram테이블을 별도로 두되, 백필 중에는fts_cjk_stale플래그로 읽기를 막아 반쪽 인덱스가 조용히 오답을 내지 않게 한다(hermes_state_common.py:465-548). - 플러그인은 열되 하나만 켜기 — 메모리 프로바이더도 컨텍스트 엔진도 config 키 하나로 단일 선택. 툴 스키마 부풀림을 막는 가장 싼 방법이다.
참고 출처
↗ agent/prompt_caching.py:1-11 (4 브레이크포인트 배치 전략)↗ agent/agent_runtime_helpers.py:2009-2114 (plan_cache_sections_for_destination / anthropic_prompt_cache_policy)↗ agent/iteration_budget.py:17-42 (부모 500 / 서브에이전트 50, refund)↗ tools/delegate_tool.py:128, 837-855 (MAX_DEPTH=1, 하트비트 staleness 임계)↗ agent/tool_dispatch_helpers.py:584-699 (untrusted_tool_result 래핑과 구분자 defang)↗ tools/budget_config.py:11-20 (3계층 결과 예산, read_file=inf 핀)↗ plugins/memory/__init__.py:12-13 및 plugins/context_engine/__init__.py:8-10 (한 번에 하나만 활성)↗ agent/context_engine.py:1-26 / agent/memory_provider.py:1-33 (ABC 라이프사이클)↗ agent/background_review.py:1-18 / agent/curator.py:1-21 (비동기 자기개선, 캐시 불간섭·자동삭제 금지)↗ hermes_state_common.py:465-548 (CJK 트라이그램 FTS와 fts_cjk_stale 페일클로즈)갱신 다이어그램 — 두 달 뒤의 같은 구조
핵심 요점
- 발표자료는 파일 메타데이터 기준 2026-06-04 작성 — 이 글이 읽은 HEAD 5292074(2026-08-08)와 약 두 달 차이다.
- 움직인 축은 전부 '운영하다 데인 곳'이다: 자식 타임아웃(정상 작업이 죽었다), 깊이 상한(안전→비용 재분류), 요약 예산(팬아웃 배수로 부모가 넘쳤다 · PR #9126), FTS5 인덱스(트라이그램만으론 CJK를 못 덮었다).
- 안 움직인 축은 처음 경계를 잘 그은 곳이다: 문자수 기준 메모리 상한은 토크나이저와 무관하고, 학습 루프는 응답 이후·별도 스레드라 사용자 경로와 충돌 지점이 없다.
- 6월 자료의 `[1,3]` 깊이 클램프는 허구가 아니라 당시 공식 문서를 정확히 옮긴 것이었다 — 저장소 자신도 `cli-config.yaml.example:1308`에 아직 'range: 1-3'을 남겨두고 있다.
- 상수를 지울 때 grep할 대상은 코드가 아니라 주석·예제 YAML·발표 슬라이드다.
갱신 다이어그램 — 두 달 뒤의 같은 구조
앞의 블록다이어그램은 2026-06-04 발표자료를 그대로 재현한 것이다(파일 메타데이터 기준 작성일). 이 글이 읽은 커밋은 그로부터 약 두 달 뒤인 5292074(2026-08-08)이고, 그 사이 소스가 움직였다. 같은 레이아웃에 현재 값을 채우고 변경점을 표시한 판을 마지막에 둔다.
두 판을 겹쳐 보면 드러나는 것
두 다이어그램의 차이가 무작위가 아니다. 움직인 축과 안 움직인 축이 선명하게 갈린다.
| 움직였다 ⚠️ | 안 움직였다 ✓ | |
|---|---|---|
| 무엇 | 위임 제어(깊이 상한·자식 타임아웃·orchestrator 게이트), 캐시 브레이크포인트 레이아웃, FTS5 검색 인덱스, MoA 기본 fanout | 메모리 상한(2,200/1,375자), Provider 동시 1개 규율, 학습 루프 3단계(nudge→review→curator) |
| 성격 | 실제로 운영하다 데인 지점 | 처음 설계가 맞았던 지점 |
이 대비가 이 글에서 가장 실용적인 결론이다. 바뀐 항목들은 전부 "돌려보니 아팠던 곳"이다.
- 자식 타임아웃 600초 → 무제한 + 하트비트 감시: 소스 주석이 이유를 자백한다 — 깊은 리뷰·리서치 팬아웃·느린 추론 모델이 정상 작동 중에 죽고 있었다. 고정 스톱워치는 "느린 것"과 "멈춘 것"을 구분하지 못한다.
- 깊이 상한 3 삭제: 안전 문제가 아니라 비용 문제라는 재분류. 그래서 코드가 숫자를 정하지 않고, 깊이 초과 에러 문구로 "각 레벨이 API 비용을 곱한다"고 교육한다.
- 요약 예산이 배치 크기로 나뉘게 됨: 개별 요약이 아니라 팬아웃 배수가 부모 컨텍스트를 넘치게 해 압축→429→재시도의 죽음의 나선을 돌렸다(issue/PR #9126).
- FTS5 인덱스 3종화: CJK 부분일치를 트라이그램 하나로 못 덮어서
cjk_unicode61이 추가됐고, 병합이 아니라 우선순위 폴백으로 라우팅된다.
반대로 메모리 쪽은 문자수 기준 상한이라 모델·토크나이저가 바뀌어도 흔들릴 이유가 없었고, 학습 루프는 애초에 "응답 이후·별도 스레드·보조 모델"이라 사용자 경로와 충돌할 지점이 없었다. 경계를 잘 그은 축은 두 달 동안 손댈 일이 없었다는 뜻이다.
그래서 스냅샷을 문서에 박지 말라
이 글 자체가 그 교훈의 실물이다. 6월 자료는 당시 소스와 정확했고, 심지어 [1,3] 깊이 클램프처럼 지금 보면 틀린 값도 당시 공식 문서를 정확히 옮긴 것이었다. 문서가 상한 삭제 커밋을 따라가지 못했을 뿐이다. 실제로 저장소 자신도 못 따라갔다 — cli-config.yaml.example:1308은 아직 "range: 1-3"이라 적고 있다.
상수를 지울 때 grep해야 할 대상은 코드가 아니라 주석과 예제 YAML과 발표 슬라이드다.
두 달에 한 번 이런 재검증을 돌리는 게 현실적인 대안이다. 비용도 크지 않다 — 이 글의 209건 파일·라인 참조는 저장소를 클론해 기계적으로 검증했고, 어긋난 항목만 사람이 읽었다.