Создание плагина движка контекста

Плагины движка контекста заменяют встроенный 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, общее с потоком диалога.
  • Список сообщений, который вы получаете, — приватный глубокий снимок; изменять его на месте допустимо (устаревший контракт), но изменение становится видимым только если проход зафиксирован. После таймаута хоста ваша ещё выполняющаяся работа отбрасывается — никогда не публикуйте во внешнее/постоянное состояние в обход фиксации.
  • Проходы для разных сессий могут выполняться параллельно на соседних потоках пула; единственный экземпляр движка/провайдера, общий для нескольких сессий, должен быть потокобезопасным.

См. также

перевод и редактура:

обновлено

⌕ ESC