Создание плагина движка контекста
Плагины движка контекста заменяют встроенный ContextCompressor другой стратегией управления контекстом диалога. Например, движок Lossless Context Management (LCM) строит граф знаний (DAG) вместо сжатия с потерями.
Принцип работы
Управление контекстом агента построено на абстрактном классе ContextEngine (agent/context_engine.py). Реализация по умолчанию — встроенный ContextCompressor. Плагины должны реализовать тот же интерфейс.
Одновременно активен только один движок контекста. Выбор задаётся через конфигурацию:
# config.yaml
context:
engine: "compressor" # встроенный по умолчанию
engine: "lcm" # активирует плагинный движок с именем "lcm"
Сами по себе плагинные движки никогда не включаются — пользователь должен явно задать context.engine с именем плагина.
Структура каталогов
Каждый движок контекста размещается в plugins/context_engine/<name>/:
plugins/context_engine/lcm/
├── __init__.py # экспортирует подкласс ContextEngine
├── plugin.yaml # метаданные (name, description, version)
└── ... # прочие модули вашего движка
Абстрактный класс ContextEngine
Ваш движок должен реализовать следующие обязательные методы:
from agent.context_engine import ContextEngine
class LCMEngine(ContextEngine):
@property
def name(self) -> str:
"""Краткий идентификатор, например 'lcm'. Должен совпадать со значением в config.yaml."""
return "lcm"
def update_from_response(self, usage: dict) -> None:
"""Вызывается после каждого обращения к LLM со словарём usage.
Обновите self.last_prompt_tokens, self.last_completion_tokens,
self.last_total_tokens из ответа.
"""
def should_compress(self, prompt_tokens: int = None) -> bool:
"""Возвращает True, если в этом ходу нужно запустить сжатие."""
def compress(self, messages: list, current_tokens: int = None,
focus_topic: str = None) -> list:
"""Сжимает список сообщений и возвращает новый (возможно, укороченный) список.
Возвращаемый список должен быть валидной последовательностью сообщений формата OpenAI.
``focus_topic`` — опциональная тема из ручной команды
``/compress <focus>``; движки с поддержкой управляемого сжатия должны
приоритизировать сохранение связанной с ней информации, остальные могут игнорировать.
"""
Атрибуты класса, которые движок обязан поддерживать
Агент читает их напрямую для отображения и логирования:
last_prompt_tokens: int = 0
last_completion_tokens: int = 0
last_total_tokens: int = 0
threshold_tokens: int = 0 # порог, при котором срабатывает сжатие
context_length: int = 0 # полное контекстное окно модели
compression_count: int = 0 # сколько раз был вызван compress()
Опциональные методы
В абстрактном классе для них есть разумные реализации по умолчанию. Переопределяйте по необходимости:
| Метод | По умолчанию | Когда переопределять |
|---|---|---|
on_session_start(session_id, **kwargs) | Пустая операция | Нужно загрузить сохранённое состояние (DAG, БД) |
on_session_end(session_id, messages) | Пустая операция | Нужно сбросить состояние, закрыть соединения |
on_session_reset() | Сбрасывает счётчики токенов | Есть состояние уровня сессии для очистки |
update_model(model, context_length, ...) | Обновляет context_length и порог | Нужно пересчитать бюджеты при смене модели |
get_tool_schemas() | Возвращает [] | Движок предоставляет инструменты для вызова агентом (например, lcm_grep) |
handle_tool_call(name, args, **kwargs) | Возвращает JSON с ошибкой | Реализуете собственные обработчики инструментов |
should_compress_preflight(messages) | Возвращает False | Можно дать дешёвую оценку до вызова API |
get_status() | Стандартный словарь токенов и порога | Есть нестандартные метрики для отображения |
select_context(request_messages, *, conversation_messages, incoming_message, budget_tokens) | Возвращает None (пустая операция) | Вы сами выбираете или маршрутизируете контекст этого запроса (retrieval, маршрутизация по темам) — см. ниже |
on_turn_complete(messages, usage=None, **kwargs) | Пустая операция | Вы принимаете, индексируете или наблюдаете завершённый ход — см. ниже |
clone_for_agent() | copy.deepcopy(self) | Движок хранит некопируемое состояние (блокировки, соединения SQLite/БД) — см. Через общую систему плагинов |
Выбор контекста и наблюдение за ходом
compress() отвечает на вопрос «контекст слишком длинный → сделай короче». Ортогональную ось выбора и наблюдения закрывают два опциональных хука, по умолчанию ничего не делающих, — теперь движку не нужно принудительно возвращать True из should_compress() и злоупотреблять compress() как колбэком на каждый ход:
def select_context(self, request_messages, *, conversation_messages=None,
incoming_message=None, budget_tokens=0):
"""Choose/replace the context for THIS request, before dispatch.
Return a new message list to use for this one provider call (retrieval,
topic routing, role/branch switching), or None to leave it unchanged.
Request-only: the persisted conversation history is never mutated.
"""
def on_turn_complete(self, messages, usage=None, **kwargs):
"""Observe a finished turn after the assistant/tool loop completes.
Receives a shallow copy of the finalized transcript plus the turn's
canonical usage dict (or None if no provider response was reached), so the
engine can ingest/index/summarize for the next select_context(). The return
value is ignored.
"""
Контракт:
- По умолчанию пустая операция, отказ в безопасную сторону. Оба хука по умолчанию возвращают
None. Отсутствующий хук, исключение или некорректное возвращаемое значение оставляют запрос нетронутым — так сломанный движок никогда не хуже отсутствующего. Хост к тому же сверяет функцию по идентичности с дефолтной реализацией ABC и пропускает вызов целиком, поэтому движки, которые хуки не реализуют (включая встроенный компрессор), не платят вообще ничего на каждый запрос. select_context()живёт в границах запроса. Возвращённый список подменяет сообщения ровно для одного вызова провайдера; сохранённая история не переписывается.None,[], не-список и список с элементами не-словарями — всё это уводит к неизменённому запросу.- Порядок и стабильность кэша. Хук отрабатывает до управления кэшем промпта и всех санитайзеров запроса, так что (а) подмена проходит ту же валидацию, что и любой запрос, и (б) пустая по умолчанию реализация оставляет запрос побайтово прежним — для движков без реализации поведение кэша промпта не меняется. Движок, подменяющий список, меняет только собственный префикс кэша. Вычисляется на каждый запрос к провайдеру (перезапускается при повторах).
on_turn_complete()— только наблюдение постфактум; считайтеmessagesдоступными лишь для чтения. Покрытие по мере возможности: хук срабатывает на штатном шве завершения хода. Часть аномальных ранних выходов из цикла (например, блокировка по политике контента или терминальный сбой провайдера) сохраняет состояние и возвращает управление в обход завершения, поэтому такие случаи хук сейчас не порождают — относитесь к нему как к наблюдению за завершёнными ходами по возможности, а не как к гарантированному колбэку на каждый ранний выход. Свести все терминальные пути к одному шву завершения — отдельная последующая работа.
Стабильная идентичность сообщения: message_uid
У каждого сообщения, которое хост сохранил, есть message_uid: 32-символьный hex-идентификатор
(uuid4().hex), который создаётся один раз при первой вставке строки и хранится в
messages.message_uid. Именно его нужно использовать, когда движок ведёт собственное
состояние по сообщениям (дословное хранилище, DAG сводок, эмбеддинги отдельных сообщений)
и должен узнавать уже виденное сообщение. Физический id строки (_row_id) для этого не
годится: он выдаётся заново при каждом копировании и присутствует только на части путей
восстановления.
Что гарантирует хост:
- Присутствует на каждой поверхности движка, как только строка существует: во входном
списке
compress(), в клонахon_turn_complete(), вconversation_historyуpost_llm_call, в сообщенияхon_session_end()и в каждой восстановленной истории (CLI, TUI, ACP, шлюз, принятие надёжного снимка при сжатии). В отличие от_row_id, восстанавливается безусловно. Единственное исключение — строка, записанная до появления колонки: в большом хранилище при обновлении такие идентификаторы создаются в течение нескольких следующих открытий, так что старая строка может ненадолго прийти без него. Отсутствие uid считайте признаком «идентичности ещё нет», но не ошибкой. - Сохраняется при любом копировании хостом одного и того же логического сообщения:
поколения сжатия на месте и их клоны параллельного хвоста, копии передачи в дочернюю
сессию при ротации и клоны чужого хвоста, повторные выдачи
replace_messages, перемотка, экспорт/импорт и копии/branch/ ветки в Desktop (скопированные строки дочерней сессии сохраняют uid родителя). - Сохраняется при переписывании содержимого той же строки: переопределение сохранения,
построчная перезапись санитайзером, дозаполнение прерванного потока. Считайте
(message_uid, content)версией сообщения; никогда не отказывайте по принципу fail-closed из-за смены содержимого под известным uid. - При слиянии сохраняется uid первой составляющей, остальные записываются. Каждый раз,
когда хост сворачивает одно надёжное сообщение в другое (слияния подряд идущих
пользовательских и подряд идущих ассистентских сообщений при починке чередования,
пересказ компрессором выполняемой задачи на носитель сводки, реальный пользовательский
якорь, свёрнутый в замыкающий служебный ход, слияние соседних пользовательских
сообщений при микроуплотнении), составное сообщение получает uid той составляющей,
чей текст идёт первым, а остальные записывает в
_absorbed_message_uids(в порядке текста). Список сохраняется в строке «выжившего» (messages.absorbed_message_uids) и восстанавливается вместе с ней, так что после перезапуска движок по-прежнему видит, чтоA\n\nB— это свёрткаAиBхостом, а не новое сообщение. Слияния, сделанные при восстановлении истории (repair_alternation=True), записывают такое свидетельство так же. Строка, которую хост отбрасывает, а не сворачивает (предварительный кандидат проверки, вытесненный финальным ответом, или ход ассистента, чей текст стоит рядом с мультимодальным содержимым, — такие никогда не склеиваются), не записывается: свидетельство называет только текст, который продолжает жить в составном сообщении. - Строки, созданные движком, сохраняют заданный им uid. Если вывод вашего
compress()заранее проставляетmessage_uidна носителе сводки (или на любой строке, которую он выдаёт), хост записывает это значение при фиксации — и на месте, и при ротации, — а каждое последующее восстановление и копирование его возвращает; строкам без uid он создаётся при вставке. Так движок может узнавать собственные строки по uid. - Uid называет одно логическое сообщение, а не одну строку. Копии сообщения делят его по замыслу, а путь хоста, который заново дописывает отредактированное или слитое сообщение рядом с ещё активной прежней строкой (переопределение сохранения на восстановленном списке, слитый «выживший», сброшенный новой строкой), оставляет две активные строки с одним uid. Не предполагайте уникальность по строке в активном наборе; привязывайте своё состояние к uid и считайте более позднюю строку текущей версией.
- У вызовов инструментов тоже есть идентификаторы на каждое вхождение. Id вызовов
инструментов у провайдера повторяются (Hermes создаёт детерминированные id
call_<12hex>для одинаковых вызовов, а модели переиспользуют id), поэтому сообщение ассистента несёт_tool_call_uids— карту{tool_call_id: uid}для своихtool_calls, а каждое сообщение с результатом инструмента несёт соответствующий_tool_call_uid. Вызовы, повторяющие id провайдера внутри одного ответа, делят один uid (его несёт каждый результат, так что ни один вызов не выглядит безответным). Когда свёртка оставляет в одном сообщении вызовы из двух ходов с общим id, этот id отображается в список uid — по одному на каждое вхождение в порядкеtool_calls. Обращённый к провайдеруidвнутриtool_callsостаётся нетронутым. Оба поля создаются при первой вставке строки ассистента, ставятся на результат при его сбросе (в той же пачке или из живого списка, если результат попадает в более поздний сброс) и при восстановлении (из предшествующей строки ассистента), сохраняются (messages.tool_call_uids,messages.tool_call_uid) и переживают те же пути копирования и перезаписи, что иmessage_uid. У результата, чей вызов был сохранён без uid, его нет. - Никогда не уходит по сети.
message_uid,_absorbed_message_uids,_tool_call_uidsи_tool_call_uidвходят вPERSISTENCE_ONLY_MESSAGE_FIELDS: они вырезаются из каждой исходящей копии для провайдера, а оценщик токенов их игнорирует. Движкам, которые их не читают, они не мешают. - Отсутствует только до появления строки. У пользовательского сообщения текущего хода
нет uid во время предварительного
compress(), который выполняется до сброса в начале хода; тот же объект словаря получает его при этом сбросе. Хранилища, обновлённые со старой схемы, один раз дозаполняют uid во всех существующих строках (схема v31), а триггер вставки создаёт его для любой строки, которую потом запишет в хранилище v31 более старая сборка (живые словари той сборки остаются без него до восстановления).
Когда эти хуки нужны — и когда НЕТ
- Реализуйте
select_context(), только если движку нужно именно подменять контекст запроса — выбор с опорой на retrieval, маршрутизация по темам и веткам, смена роли. Это единственный глагол, способный поменять состав сообщений в запросе: хук плагинаpre_llm_callпо документированному замыслу умеет только вставлять (дописывает к сообщению пользователя и никогда не переписывает список — ради сохранности префикса кэша промпта). Не нужна подмена — не реализуйте. - Если плагину нужно только наблюдение и приём данных постфактум (индексация, синхронизация памяти, аналитика), делайте провайдер памяти (
sync_turn()— см. Плагины провайдеров памяти), а не движок контекста. Движок контекста берёт на себя политику уплотнения сессии; провайдер памяти наблюдает за ходами, ничем не владея.on_turn_complete()существует как зеркало-наблюдатель для движков, которым и так нуженselect_context(), — чтобы один и тот же компонент учился на ходе, который только что маршрутизировал, — а не как универсальный колбэк на каждый ход. - Влияние настоящего
select_context()на кэш промпта. Непустая выборка естественным образом меняет префикс кэша на тех ходах, где меняется сам выбор: префикс такого запроса больше не совпадает с закэшированным у провайдера, и эти ходы кэш переписывают, а не читают. Движкам стоит возвращать стабильную выборку, пока ничего не изменилось (тот же объект или равный список), и перекраивать контекст только когда решение о маршрутизации реально другое; выборка, тасующаяся от хода к ходу, молча теряет переиспользование кэша каждый ход.
Инструменты движка
Движок контекста может предоставлять инструменты, которые агент вызывает напрямую. Возвращайте схемы из get_tool_schemas() и обрабатывайте вызовы в handle_tool_call():
def get_tool_schemas(self):
return [{
"name": "lcm_grep",
"description": "Поиск по графу знаний контекста",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Поисковый запрос"}
},
"required": ["query"],
},
}]
def handle_tool_call(self, name, args, **kwargs):
if name == "lcm_grep":
results = self._search_dag(args["query"])
return json.dumps({"results": results})
return json.dumps({"error": f"Unknown tool: {name}"})
Инструменты движка добавляются в набор инструментов агента при запуске, и вызовы к ним направляются автоматически — отдельно регистрировать их не нужно.
Регистрация
Через каталог (рекомендуется)
Разместите движок в plugins/context_engine/<name>/ (встроенный) или в ~/.hermes/plugins/<name>/ (установленный пользователем; $HERMES_HOME/plugins/<name>/). В __init__.py должен экспортироваться подкласс ContextEngine или функция register(ctx), которая вызывает ctx.register_context_engine(...). Активирует движок настройка context.engine: <name> — пользовательскому движку запись в plugins.enabled не нужна. При совпадении имён побеждает встроенный.
Через общую систему плагинов
Обычный плагин тоже может зарегистрировать движок контекста:
def register(ctx):
engine = LCMEngine(context_length=200000)
ctx.register_context_engine(engine)
Зарегистрировать можно только один движок. Если зарегистрировать свой движок попробует второй плагин, такая попытка отклоняется с предупреждением.
Зарегистрированный экземпляр общий для всего процесса, но каждому AIAgent (родителю, субагентам, сессиям
шлюза) нужен собственный движок, чтобы update_model() дочернего агента не менял бюджет родителя.
Поэтому Hermes при каждой инициализации агента вызывает engine.clone_for_agent() у зарегистрированного экземпляра.
По умолчанию это copy.deepcopy(self). Переопределите метод, если движок хранит состояние, которое нельзя
глубоко скопировать (блокировки, соединения SQLite или HTTP), и возвращайте свежий движок, который делит с исходным
долговременный бэкенд, а копирует только изменяемые поля бюджета. Если клонирование выбрасывает исключение, агент
откатывается на встроенный компрессор и пишет в лог Context engine 'X' could not be safely copied for this agent.
def clone_for_agent(self):
clone = LCMEngine(db_path=self.db_path) # открывает собственное соединение
clone.threshold_percent = self.threshold_percent
return clone
Жизненный цикл
1. Создание экземпляра движка (загрузка плагина или автообнаружение из каталога)
2. on_session_start() — начало диалога
3. update_from_response() — после каждого вызова API
4. should_compress() — проверка на каждом ходу
5. compress() — вызывается, когда should_compress() вернул True
6. on_session_end() — граница сессии (выход из CLI, /reset, остановка шлюза)
on_session_reset() вызывается при /new или /reset — очищает состояние уровня сессии без полной остановки.
Конфигурация
Пользователь выбирает движок через hermes plugins → Provider Plugins → Context Engine либо правкой config.yaml:
context:
engine: "lcm" # должно совпадать со свойством name вашего движка
Блок compression в конфигурации (compression.threshold, compression.protect_last_n и т. д.) относится только к встроенному ContextCompressor — с одним явным исключением: compression.model_thresholds (переопределения порога по моделям) входит в контракт движка контекста. Разрешённую карту хост присваивает в engine.model_thresholds до первого вызова update_model(), а update_model() из базового класса её применяет (самое длинное совпадение подстроки, с откатом на порог, настроенный у движка). Движки, переопределяющие update_model(), владеют собственной политикой уплотнения и могут карту учитывать или игнорировать — from agent.context_compressor import resolve_model_threshold, если хотите переиспользовать ту же логику разрешения. Во всём остальном: нужны свои параметры — задайте собственный формат и читайте их из config.yaml при инициализации.
Тестирование
from agent.context_engine import ContextEngine
def test_engine_satisfies_abc():
engine = YourEngine(context_length=200000)
assert isinstance(engine, ContextEngine)
assert engine.name == "your-name"
def test_compress_returns_valid_messages():
engine = YourEngine(context_length=200000)
msgs = [{"role": "user", "content": "hello"}]
result = engine.compress(msgs)
assert isinstance(result, list)
assert all("role" in m for m in result)
Полный набор тестов контракта абстрактного класса — в tests/agent/test_context_engine.py.
Потокобезопасность
Когда compression.context_timeout_seconds > 0 (по умолчанию), Hermes выполняет
весь проход сжатия — включая compress() вашего движка и пограничные
колбэки, а также on_pre_compress / on_session_switch любого провайдера
памяти — в пуле демон-потоков с таймаутом на стороне хоста. Поэтому ваш
движок должен исходить из следующего:
- Вызовы могут приходить из произвольного потока пула. Не полагайтесь на
привязку к потоку или на состояние
threading.local, общее с потоком диалога. - Список сообщений, который вы получаете, — приватный глубокий снимок; изменять его на месте допустимо (устаревший контракт), но изменение становится видимым только если проход зафиксирован. После таймаута хоста ваша ещё выполняющаяся работа отбрасывается — никогда не публикуйте во внешнее/постоянное состояние в обход фиксации.
- Проходы для разных сессий могут выполняться параллельно на соседних потоках пула; единственный экземпляр движка/провайдера, общий для нескольких сессий, должен быть потокобезопасным.
См. также
- Сжатие и кэширование контекста — как работает встроенный compressor
- Плагины провайдера памяти — аналогичная система выбора одного плагина для памяти
- Плагины — обзор общей системы плагинов