hermes dynamic threshold.spec

canonical
No value
aliases
No value
tags
hermes/spec
description
Hermes 컨텍스트 압축 트리거를 실측 바닥 기반으로 동적 산출하는 context engine 플러그인 스펙. hermes-agent git repo를 수정하지 않는다.
links
No value
status
No value
project
true
area
false
resource
false
title
hermes dynamic threshold.spec
created
2026-08-10T15:23:55
updated
2026-08-10T15:24:42

Hermes 동적 압축 threshold (context engine 플러그인)

문제정의

WHY: 압축 트리거(compression.threshold)는 컨텍스트 창의 고정 비율인데, 압축 후 착지점은 비율이 아니라 압축 불가능한 바닥(시스템 프롬프트 + 툴 스키마 + rolling summary + protect_first_n + protect_last_n)이 결정한다. 두 값이 서로 독립이라 사용자가 threshold를 낮게 잡으면 트리거 − 바닥 여유가 붕괴하고 압축이 매 1~2턴마다 재발한다.

실측 (세션 hermes-b4cea60738fe, deepseek/deepseek-v4-flash-0731, profile default):

항목 창 대비
context_length 1,048,576 100%
threshold_tokens (threshold 0.3) 314,572 30%
압축 후 실측 last_prompt_tokens 256,857 24.5%
실가용 여유 ~100K ~10%p

target_ratio를 낮춰도 해결되지 않는다. tail_token_budget = threshold_tokens × target_ratio = 62,914로 이미 바닥보다 작아 binding constraint가 아니기 때문이다.

WHERE: agent/context_compressor.py의 threshold 산출 경로.

WHAT: 상류에 이미 동일 증상에 대한 방어가 있다. _SMALL_CTX_THRESHOLD_PERCENT = 0.75 (:821)의 주석은 정확히 이 문제를 서술한다 — "the incompressible floor eats most of the reclaimed headroom, so compaction re-fires every 1-2 turns". 다만 context_length < 512_000 조건이 붙어 있고, "512K 이상이면 기본값 50%로 여유가 충분하다"는 가정에 의존한다. 1M 창에 0.3을 명시 설정한 경우 그 가정이 깨진다.

이 스펙은 정적 플로어 대신 실측 바닥에 상대적인 threshold를 산출한다. 단, 구현 위치가 핵심 제약이다.

제약: hermes-agent git repo를 수정할 수 없다

~/.hermes/hermes-agentNousResearch/hermes-agent 클론이고 hermes updategit stashpullgit stash apply 흐름이다 (hermes_cli/update_cmd.py). 충돌 경로가 명시적으로 존재한다.

update_cmd.py:1307  "✗ Update pulled new code, but restoring local changes hit conflicts."

agent/context_compressor.py는 상류 hot file이므로 직접 패치 시 업데이트마다 충돌한다. 따라서 repo 밖 확장점을 사용한다.

agent/agent_init.py:2430 부근의 context engine 선택 순서:

  1. config context.engine
  2. hermes-agent/plugins/context_engine/<name>/repo 내부, 사용 금지
  3. 일반 플러그인 시스템 (user-installed) ← 채택
  4. 내장 ContextCompressor

3번의 사용자 플러그인 디렉터리는 get_hermes_home()/plugins = ~/.hermes/plugins/ (hermes_cli/plugins.py:1526)로 repo 밖이다. ctx.register_context_engine() (hermes_cli/plugins.py:664)로 등록하고, ContextCompressor(ContextEngine) (agent/context_compressor.py:1577)를 상속해 최소 지점만 오버라이드한다.

예상 개발기간, 소요시간

IN-SCOPE

OUT-SCOPE

DEPENDENCY

RISK

TIME-ESTIMATED

5hr

설계

재산출 공식

floor        := 압축 직후 provider가 보고한 실제 prompt 토큰
dynamic      := ceil(floor × gap_multiplier)
lower        := configured_threshold_percent × effective_window
upper        := ceiling_percent × context_length
threshold    := clamp(max(lower, dynamic), lower, upper)

현재 실측치 대입: floor 256,857 → dynamic 513,714 → lower 786,432(threshold 0.75)이 더 크므로 786,432 채택. 바닥이 400K까지 커지면 dynamic 800,000이 lower를 넘어 threshold가 따라 올라간다. upper는 891,289.

오버라이드 지점을 threshold_tokens 프로퍼티로 잡지 않는 이유

update_model() (:2335)은 self.threshold_tokens = ...대입하고, 이어서 tail_token_budget을 그 값에서 파생시킨다. getter를 순수 계산식으로 오버라이드하면 이 대입들과 충돌한다. 따라서 기존 setter를 그대로 쓰고, update_from_response()에서 super() 반환 후 재대입한다.

바닥 관측 지점

record_completed_compaction() (:2091)이 _verify_compaction_cleared_threshold = True로 arming하고, update_from_response() (:2741)가 소비하면서 False로 되돌린다. 서브클래스는 super() 호출 전에 이 플래그를 읽어야 한다.

def update_from_response(self, usage):
    just_compacted = self._verify_compaction_cleared_threshold
    super().update_from_response(usage)
    if just_compacted and self.last_prompt_tokens > 0:
        self._observed_floor = self.last_prompt_tokens
        self._retune()

파일 배치

~/.hermes/plugins/adaptive-threshold/
  plugin.yaml          # name, version, description, author, kind: standalone
  __init__.py          # AdaptiveThresholdEngine + register(ctx)
  tests/
# ~/.hermes/config.yaml
context:
  engine: adaptive-threshold
plugins:
  enabled:
    - adaptive-threshold
compression:
  threshold: 0.75          # 동적 산출의 하한
  adaptive:
    gap_multiplier: 2.0
    ceiling_percent: 0.85

모델 및 설정 주입

관여하는 모델은 둘이고, 주입 경로가 서로 다르다.

압축 대상 모델 (main agent model). register(ctx)는 플러그인 discovery 시점에 호출되며 이때 모델은 아직 미정이다. model=""로 생성하고, host가 agent_init.py:2518에서 update_model(model, context_length, base_url, api_key, provider, api_mode)로 실제 값을 주입한다. update_model()context_length 대입 → threshold_percent 재해석 → threshold_tokens 재계산 → tail_token_budget 재파생까지 수행하므로, 생성 시점의 빈 모델이 뒤에 남지 않는다. _resolve_context_length()는 lazy 프로퍼티이고 context_length setter가 _resolved_context_length를 직접 채우므로, 빈 모델로 창 크기를 조회하는 일은 발생하지 않는다.

요약용 auxiliary 모델. 플러그인이 설정할 필요가 없다. self.summary_model = summary_model_override or "" (:2665)이고, 빈 값이면 요약 호출 시점에 _resolve_task_provider_model (:4210)이 auxiliary.compression.*에서 해석한다. 현 프로필 기준 openrouter / deepseek/deepseek-v4-flash. 따라서 summary_model_override=None을 유지한다.

설정 파리티 — 외부 엔진에 전달되지 않는 값들

이 스펙의 가장 큰 함정이다. host는 외부 엔진에 compression.*거의 전달하지 않는다 (agent_init.py:2492 주석). 내장 경로(agent_init.py:2528)는 ContextCompressor(...)에 15개 인자를 넘기지만, 외부 엔진은 update_model()model_thresholds 대입만 받는다. 플러그인이 직접 읽지 않으면 사용자 설정이 조용히 기본값으로 되돌아간다.

__init__ 파라미터 config 키 외부 엔진 전달 미조치 시 값
model update_model()
base_url / api_key / provider / api_mode update_model()
config_context_length update_model(context_length=)
model_thresholds compression.model_thresholds ✅ 직접 대입 (:2516)
threshold_percent compression.threshold 0.50
protect_last_n compression.protect_last_n 20 (설정 40 유실)
summary_target_ratio compression.target_ratio 0.20 (설정 0.10 유실)
protect_first_n compression.protect_first_n 3
abort_on_summary_failure compression.abort_on_summary_failure False
threshold_tokens_cap compression.threshold_tokens None
proactive_prune_tokens 외 2 compression.proactive_prune_* 0 / 8000 / 4096
min_tail_user_messages compression.min_tail_user_messages 1
max_tokens model.max_tokens None
quiet_mode False
_micro_compact_* compression.micro_compact* hasattr 대입 (:2559)

max_tokens는 별도 주의가 필요하다. update_model()에서 max_tokens=None은 "미지정 → 기존 값 유지" 의미이고 (:2331), host의 호출은 이 인자를 아예 넘기지 않는다. 따라서 __init__에서 설정한 값이 세션 내내 유지된다. model.max_tokens를 config에서 읽어 넘기면 되지만, caller가 런타임에 직접 max_tokens를 준 경우 (agent_init.py:857)는 플러그인이 알 수 없다. 현 프로필은 model.max_tokens 미설정이므로 None이 정답이고 내장 경로와 일치한다.

설정 로드 방식

번들 플러그인들이 쓰는 패턴을 그대로 따른다 — 함수 내부 지연 import (plugins/image_gen/openrouter/__init__.py:77, plugins/memory/byterover/__init__.py:72).

def register(ctx):
    from hermes_cli.config import load_config, cfg_get
    cfg = load_config()
    ctx.register_context_engine(AdaptiveThresholdEngine(
        model="",
        threshold_percent=cfg_get(cfg, "compression", "threshold", default=0.50),
        protect_last_n=cfg_get(cfg, "compression", "protect_last_n", default=20),
        summary_target_ratio=cfg_get(cfg, "compression", "target_ratio", default=0.20),
        max_tokens=cfg_get(cfg, "model", "max_tokens", default=None),
        # ... 위 표의 ❌ 행 전부
    ))

load_config()가 활성 프로필을 알아서 해석하므로 프로필별 분기는 불필요하다. 등록 시점 1회 로드이며, 결과는 plain dict이라 copy.deepcopy 제약에 걸리지 않는다.

마스터리스트 (평가지표 체크리스트)

항목명 설명 검증 방법 ⌛️🏃✅❌
repo 무결성 hermes-agent 내 추적 파일이 하나도 변경되지 않음 cd ~/.hermes/hermes-agent && git status --porcelain 이 빈 출력 ⌛️
업데이트 내성 hermes update 후에도 플러그인이 그대로 로드됨 업데이트 실행 → hermes plugins listadaptive-threshold 존재, 충돌 메시지 없음 ⌛️
엔진 선택 내장 compressor 대신 플러그인 엔진이 활성화됨 로그에 Using context engine: adaptive-threshold ⌛️
deepcopy 안전성 에이전트별 copy.deepcopy가 성공 로그에 could not be safely copied 경고 부재 + 위 엔진 선택 로그 동시 확인 ⌛️
config 무시 내성 compression.adaptive.* 미지 키가 host 로드를 깨지 않음 hermes config show 정상 출력, 스키마 경고 없음 ⌛️
설정 파리티 내장 compressor와 동일한 compression.* 유효값 신규 세션에서 엔진 속성 덤프 → protect_last_n == 40, summary_target_ratio == 0.10, max_tokens is None ⌛️
요약 모델 해석 auxiliary 요약이 auxiliary.compression.*로 라우팅됨 압축 1회 유발 후 로그에서 요약 호출 provider/model이 openrouter / deepseek-v4-flash인지 확인 ⌛️
하한 보장 바닥 관측 전에는 설정값과 동일한 threshold 신규 세션 첫 턴 threshold_tokens == 786,432 ⌛️
동적 상승 바닥이 커지면 threshold가 따라 상승 단위 테스트: floor 400,000 주입 → threshold 800,000 ⌛️
상한 클램프 상한을 절대 넘지 않음 단위 테스트: floor 900,000 주입 → threshold == 891,289 ⌛️
하한 클램프 하한 아래로 내려가지 않음 단위 테스트: floor 10,000 주입 → threshold == 786,432 ⌛️
모델 전환 재보정 /model 전환 후 새 창 기준으로 재산출 단위 테스트: update_model(context_length=272_000) 후 상·하한 재계산 확인 ⌛️
압축 빈도 개선 동일 작업량에서 압축 횟수 감소 실세션 비교: 압축 전후 compression_count / 턴 수 비율 ⌛️
폴백 안전성 플러그인 로드 실패 시 에이전트가 계속 동작 __init__.py를 의도적으로 깨뜨린 뒤 세션 시작 → 내장 compressor로 정상 진행 ⌛️

Usecase Scenarios

UC001 압축 직후 바닥 관측 및 재산출

[액터] AdaptiveThresholdEngine

[전제조건] 세션이 활성 상태이고, 직전 턴에서 압축 경계가 기록되어 _verify_compaction_cleared_thresholdTrue다.

[시나리오]

  1. provider 응답이 도착해 update_from_response(usage)가 호출된다.
  2. 엔진이 super() 호출 전에 _verify_compaction_cleared_threshold를 읽어 just_compacted로 보관한다.
  3. super().update_from_response(usage)last_prompt_tokens를 갱신하고 플래그를 소비한다.
  4. just_compacted가 참이면 last_prompt_tokens_observed_floor로 기록한다.
  5. 재산출 공식으로 새 threshold를 계산해 self.threshold_tokens에 대입한다.

[사후조건] threshold_tokens[lower, upper] 범위 안에 있고, floor × gap_multiplier 이상이다.

[예외흐름] usageprompt_tokens가 없거나 0 이하면 재산출을 건너뛰고 직전 값을 유지한다.

UC002 바닥 관측 이력이 없는 세션

[액터] AdaptiveThresholdEngine

[전제조건] 세션 시작 직후, 압축이 한 번도 발생하지 않았다.

[시나리오]

  1. 엔진이 _observed_floor 부재를 확인한다.
  2. lower(= 설정 compression.threshold 기반)를 그대로 사용한다.

[사후조건] 동작이 내장 compressor와 동일하다.

[예외흐름] 없음.

UC003 상한 도달

[액터] AdaptiveThresholdEngine

[전제조건] 툴 스키마 증가로 바닥이 창의 45%를 초과했다.

[시나리오]

  1. dynamicupper를 초과한다.
  2. 클램프가 upper로 잘라낸다.
  3. 압축이 threshold를 계속 못 넘기면 기존 _ineffective_compression_count breaker가 정상적으로 트립한다.

[사후조건] threshold가 ceiling_percent × context_length를 넘지 않고, 압축 불가 상태가 사용자에게 경고로 노출된다.

[예외흐름] breaker가 트립하면 자동 압축이 차단되며, 이는 기존 host 동작이므로 플러그인이 개입하지 않는다.

UC004 모델 전환

[액터] 사용자, AdaptiveThresholdEngine

[전제조건] 1M 창 모델로 세션 진행 중, 바닥이 관측된 상태.

[시나리오]

  1. 사용자가 /model로 272K 창 모델로 전환한다.
  2. host가 update_model(model, context_length=272_000, ...)를 호출한다.
  3. super()가 threshold_percent·threshold_tokens·tail_token_budget을 새 창 기준으로 재계산한다.
  4. 엔진이 _observed_floor를 무효화하고 lower로 되돌린다.

[사후조건] 이전 창에서 관측한 바닥이 새 창의 threshold를 오염시키지 않는다.

[예외흐름] 512K 미만 창이므로 host의 _effective_threshold_percent 플로어(75%)가 적용된다. 플러그인은 이를 덮어쓰지 않고 그 결과를 lower로 채택한다.

UC005 플러그인 로드 실패

[액터] host (agent_init)

[전제조건] context.engine: adaptive-threshold이지만 플러그인 import 또는 deepcopy가 실패한다.

[시나리오]

  1. load_context_engine()get_plugin_context_engine()이 모두 None 또는 복사 불가를 반환한다.
  2. host가 경고를 남기고 내장 ContextCompressor를 구성한다.

[사후조건] 세션이 정상 시작되고 압축은 compression.threshold 정적 값으로 동작한다.

[예외흐름] 이 경로는 조용한 성능 저하가 되므로, 마스터리스트의 deepcopy 안전성 항목으로 명시 검증한다.

참고자료

관련 회의록 링크