Доступ плагина к LLM

ctx.llm — штатный способ сделать LLM-вызов из плагина. Чат-completion, структурированная экстракция, синхронный и асинхронный режимы, с изображениями и без — один интерфейс, один шлюз доверия, одни учётные данные хоста.

Плагины обращаются к нему, когда нужно задействовать модель вне основного разговора агента. Хук, который переформулирует ошибку инструмента в понятную для нетехнического пользователя фразу. Шлюз-адаптер, который переводит входящее сообщение перед постановкой в очередь. Slash-команда, кратко излагающая длинную вставку. Планировщик, который оценивает активность за прошедший день и пишет одну строку в доску статусов. Предфильтр, решающий — стоит ли вообще будить агента ради этого сообщения.

Всё это — задачи, в которых агенту не место. Один LLM-вызов, типизированный ответ — и работа сделана.

Минимально возможный вызов

result = ctx.llm.complete(messages=[{"role": "user", "content": "ping"}])
return result.text

Вот и весь API — одна строка. Никаких ключей, никакой конфигурации провайдера, никакой инициализации SDK. Плагин работает с тем провайдером и моделью, которые активны у пользователя прямо сейчас — сменит провайдера, и плагин последует за ним автоматически.

Полноценный пример чат-вызова

result = ctx.llm.complete(
    messages=[
        {"role": "system", "content": "Rewrite errors as one short sentence a non-engineer can act on."},
        {"role": "user",   "content": traceback_text},
    ],
    max_tokens=64,
    purpose="hooks.error-rewrite",
)
return result.text

purpose — произвольная строка для аудита: она попадает в agent.log и в result.audit, чтобы операторы видели, какой плагин сделал какой вызов. Необязательна, но рекомендуется для всего, что вызывается часто.

Структурированный вывод

Когда плагину нужен типизированный ответ, переключайся на structured-режим:

result = ctx.llm.complete_structured(
    instructions="Score this support reply for urgency (0–1) and pick a category.",
    input=[{"type": "text", "text": message_body}],
    json_schema=TRIAGE_SCHEMA,
    purpose="support.triage",
    temperature=0.0,
    max_tokens=128,
)

if result.parsed["urgency"] > 0.8:
    await dispatch_to_oncall(result.parsed["category"], message_body)

Хост запрашивает у провайдера JSON-вывод, при необходимости парсит его локально как запасной вариант, валидирует по схеме (если установлен jsonschema) и возвращает Python-объект в result.parsed. Если модель не смогла вернуть валидный JSON, result.parsed равен None, а result.text содержит сырой ответ.

Что даёт этот интерфейс

  • Один вызов, четыре формы. complete() — для чата, complete_structured() — для типизированного JSON, acomplete() и acomplete_structured() — для asyncio. Одинаковые аргументы, одинаковые объекты результата.
  • Учётные данные на стороне хоста. OAuth-токены, процессы обновления, пул учётных данных, per-task aux-переопределения — все механизмы авторизации Hermes работают здесь. Плагин не видит ни одного токена; хост атрибутирует вызов через result.audit.
  • Ограниченность. Один синхронный или асинхронный вызов. Никакого стриминга, никаких циклов инструментов, никакого состояния разговора. Передал входные данные — получил результат.
  • Fail-closed доверие. Плагин без явной конфигурации не может сам выбрать провайдера, модель, агента или сохранённые учётные данные. По умолчанию действует правило: «используй то, что использует пользователь». Операторы включают конкретные переопределения для каждого плагина в config.yaml.

Быстрый старт

Два полных плагина ниже — один чатовый, один структурированный. Оба упакованы в одну функцию register(ctx) и работают с любой активной у пользователя моделью без дополнительной конфигурации.

Чат-completion — /tldr

def register(ctx):
    ctx.register_command(
        name="tldr",
        handler=lambda raw: _tldr(ctx, raw),
        description="Summarise the supplied text in one paragraph.",
        args_hint="<text>",
    )


def _tldr(ctx, raw_args: str) -> str:
    text = raw_args.strip()
    if not text:
        return "Usage: /tldr <text to summarise>"
    result = ctx.llm.complete(
        messages=[
            {"role": "system",
             "content": "Summarise the user's text in one tight paragraph. No preamble."},
            {"role": "user", "content": text},
        ],
        max_tokens=256,
        temperature=0.3,
        purpose="tldr",
    )
    return result.text

result.text — ответ модели; result.usage — счётчики токенов; result.provider и result.model несут атрибуцию.

Структурированная экстракция — /paste-to-tasks

def register(ctx):
    ctx.register_command(
        name="paste-to-tasks",
        handler=lambda raw: _paste_to_tasks(ctx, raw),
        description="Turn freeform meeting notes into structured tasks.",
        args_hint="<text>",
    )


_TASKS_SCHEMA = {
    "type": "object",
    "properties": {
        "tasks": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "owner":  {"type": "string"},
                    "action": {"type": "string"},
                    "due":    {"type": "string", "description": "ISO date or empty"},
                },
                "required": ["action"],
            },
        },
    },
    "required": ["tasks"],
}


def _paste_to_tasks(ctx, raw_args: str) -> str:
    if not raw_args.strip():
        return "Usage: /paste-to-tasks <meeting notes>"
    result = ctx.llm.complete_structured(
        instructions=(
            "Extract concrete action items from these meeting notes. "
            "One task per actionable line. If no owner is named, leave 'owner' blank."
        ),
        input=[{"type": "text", "text": raw_args}],
        json_schema=_TASKS_SCHEMA,
        schema_name="meeting.tasks",
        purpose="paste-to-tasks",
        temperature=0.0,
        max_tokens=512,
    )
    if result.parsed is None:
        return f"Couldn't parse a response. Raw output:\n{result.text}"
    lines = [f"- [{t.get('owner') or '?'}] {t['action']}" for t in result.parsed["tasks"]]
    return "\n".join(lines) or "(no tasks found)"

Третий рабочий пример — с входными изображениями — лежит в репозитории hermes-example-plugins (сопутствующий репозиторий с эталонными плагинами, в hermes-agent не входит). Пример асинхронного интерфейса (acomplete() / acomplete_structured() с asyncio.gather()) смотри в plugin-llm-async-example в том же репозитории.

Что и когда использовать

ЗадачаМетод
Произвольный текстовый ответ (перевод, резюме, рерайт, генерация)complete()
Многоходовой промпт (system + few-shot примеры + user)complete()
Типизированный словарь с валидацией по схемеcomplete_structured()
Изображение + текст на входе с типизированным словарём на выходеcomplete_structured()
Тот же вызов из асинхронного кода (шлюз-адаптеры, async-хуки)acomplete() / acomplete_structured()

Всё остальное — выбор провайдера, разрешение модели, авторизация, резервная цепочка, тайм-аут, маршрутизация vision — одинаково во всех четырёх вариантах.

API-интерфейс

ctx.llm — экземпляр agent.plugin_llm.PluginLlm.

complete()

result = ctx.llm.complete(
    messages=[{"role": "user", "content": "Hi"}],
    provider=None,         # optional, gated — Hermes provider id (e.g. "openrouter")
    model=None,            # optional, gated — whatever string that provider expects
    temperature=None,
    max_tokens=None,
    timeout=None,          # seconds
    agent_id=None,         # optional, gated
    profile=None,          # optional, gated — explicit auth-profile name
    purpose="optional-audit-string",
)
# → PluginLlmCompleteResult(text, provider, model, agent_id, usage, audit)

Обычный чат-completion. messages — стандартный формат OpenAI: список словарей {"role": "...", "content": "..."}. Многоходовые промпты (system + few-shot пары user/assistant + финальный user) работают точно так же, как в OpenAI SDK.

provider= и model= независимы друг от друга и следуют той же структуре, что и основная конфигурация хоста (model.provider + model.model). Укажи только model=, чтобы остаться на активном провайдере пользователя, но взять другую модель. Укажи оба аргумента, чтобы полностью сменить провайдера. Любой из этих аргументов без явного разрешения оператора вызовет PluginLlmTrustError.

complete_structured()

result = ctx.llm.complete_structured(
    instructions="What you want extracted.",
    input=[
        {"type": "text",  "text": "..."},
        {"type": "image", "data": b"...", "mime_type": "image/png"},
        {"type": "image", "url":  "https://..."},
    ],
    json_schema={...},     # optional — triggers parsed result + validation
    json_mode=False,       # set True without a schema to ask for JSON anyway
    schema_name=None,      # optional human-readable schema name
    system_prompt=None,
    provider=None,         # optional, gated
    model=None,            # optional, gated
    temperature=None,
    max_tokens=None,
    timeout=None,
    agent_id=None,
    profile=None,
    purpose=None,
)
# → PluginLlmStructuredResult(text, provider, model, agent_id,
#                             usage, parsed, content_type, audit)

Входные данные — типизированные блоки текста или изображений (сырые байты автоматически кодируются в base64 как data: URL). При указании json_schema или json_mode=True хост запрашивает JSON-вывод через response_format, при необходимости парсит его локально и валидирует по схеме (если установлен jsonschema).

  • result.content_type == "json"result.parsed содержит Python-объект, соответствующий схеме.
  • result.content_type == "text" — парсинг или валидация не удались; смотри result.text для сырого ответа модели.

Асинхронный режим

result = await ctx.llm.acomplete(messages=...)
result = await ctx.llm.acomplete_structured(instructions=..., input=...)

Те же аргументы и типы результатов, что у синхронных аналогов. Вызывай из шлюз-адаптеров, async-хуков или любого кода плагина, уже выполняющегося в asyncio-петле.

Атрибуты результата

@dataclass
class PluginLlmCompleteResult:
    text: str                    # the assistant's response
    provider: str                # e.g. "openrouter", "anthropic"
    model: str                   # whatever the provider returned for this call
    agent_id: str                # whose model/auth was used
    usage: PluginLlmUsage        # tokens + cache + cost estimate
    audit: Dict[str, Any]        # plugin_id, purpose, profile

@dataclass
class PluginLlmStructuredResult(PluginLlmCompleteResult):
    parsed: Optional[Any]        # JSON object when content_type == "json"
    content_type: str            # "json" or "text"
    # audit also carries schema_name when supplied

usage содержит input_tokens, output_tokens, total_tokens, cache_read_tokens, cache_write_tokens и cost_usd — когда провайдер возвращает эти поля.

Шлюз доверия

По умолчанию поведение — fail-closed. Без блока конфигурации plugins.entries плагин может:

  • вызывать любой из четырёх методов против активного провайдера и модели пользователя,
  • задавать аргументы формирования запроса (temperature, max_tokens, timeout, system_prompt, purpose, messages, instructions, input, json_schema),

…и всё. Аргументы provider=, model=, agent_id= и profile= вызывают PluginLlmTrustError до тех пор, пока оператор явно их не разрешит.

Большинству плагинов этот раздел не нужен. Плагин, который вызывает ctx.llm.complete(messages=...) без переопределений, работает с тем, что активно у пользователя, и не требует никакой конфигурации. Блок ниже актуален только тогда, когда плагин намеренно хочет зафиксироваться на другой модели или провайдере.

plugins:
  entries:
    my-plugin:
      llm:
        # Allow this plugin to choose a different Hermes provider
        # (must be one Hermes already knows about — same names as
        # `hermes model` and config.yaml model.provider).
        allow_provider_override: true

        # Optionally restrict which providers. Use ["*"] for any.
        allowed_providers:
          - openrouter
          - anthropic

        # Allow this plugin to ask for a specific model.
        allow_model_override: true

        # Optionally restrict which models. Use ["*"] for any.
        # Models are matched literally against whatever string the
        # plugin sends — Hermes does not look anything up.
        allowed_models:
          - openai/gpt-4o-mini
          - anthropic/claude-3-5-haiku

        # Allow cross-agent calls (rare).
        allow_agent_id_override: false

        # Allow the plugin to request a specific stored auth profile
        # (e.g. a different OAuth account on the same provider).
        allow_profile_override: false

Идентификатор плагина — это поле name: в манифесте для «плоских» плагинов или ключ, производный от пути, для вложенных (image_gen/openai, memory/honcho и т. п.).

Что именно контролирует шлюз

ПереопределениеПо умолчаниюКлюч конфигурации
provider=запрещеноallow_provider_override: true
↳ allowlistallowed_providers: [...]
model=запрещеноallow_model_override: true
↳ allowlistallowed_models: [...]
agent_id=запрещеноallow_agent_id_override: true
profile=запрещеноallow_profile_override: true

Каждое переопределение контролируется отдельно. Разрешение allow_model_override не даёт allow_provider_override: плагин с правом выбора модели всё равно привязан к активному провайдеру пользователя, пока не получит отдельное разрешение на провайдер.

Что шлюзу контролировать не нужно

  • Аргументы формирования запроса — temperature, max_tokens, timeout, system_prompt, purpose, messages, instructions, input, json_schema, schema_name, json_mode — разрешены всегда: они не управляют ни учётными данными, ни маршрутами.
  • Запрет по умолчанию не делает плагин бесполезным — он просто работает против активного провайдера и модели. О plugins.entries оператору стоит задуматься только ради плагинов, которым нужна более тонкая маршрутизация.

Что принадлежит хосту

Полный перечень того, что ctx.llm делает за плагин:

  • Разрешение провайдера. Читает model.provider + model.model из конфигурации пользователя (или из явных переопределений, если они разрешены).
  • Авторизация. Извлекает API-ключи, OAuth-токены или refresh-токены из ~/.hermes/auth.json / переменных окружения, включая пул учётных данных при его наличии. Плагин их не видит.
  • Маршрутизация vision. Если на входе есть изображения, а активная текстовая модель пользователя их не поддерживает, хост автоматически переключается на настроенную vision-модель.
  • Резервная цепочка. При 5xx или 429 от основного провайдера запрос проходит через обычный механизм резервного переключения Hermes, прежде чем вернуть плагину ошибку.
  • Тайм-аут. Учитывает твой аргумент timeout=, с фолбэком на auxiliary.<task>.timeout в конфиге или на глобальный aux-дефолт.
  • JSON-форматирование. Отправляет response_format провайдеру при запросе JSON, затем повторно парсит локально, если провайдер вернул ответ в блоке кода.
  • Валидация схемы. Проверяет по json_schema при наличии jsonschema; без него пишет debug-строку и пропускает строгую валидацию.
  • Журнал аудита. Каждый вызов пишет одну INFO-строку в agent.log с идентификатором плагина, провайдером/моделью, purpose и суммарным числом токенов.

Что принадлежит плагину

  • Форма запроса. messages — для чата, instructions + input — для структурированного вывода. Плагин строит промпт; хост его выполняет.
  • Схема. Любая структура, которую нужно получить на выходе. Хост не выводит её за тебя.
  • Обработка ошибок. complete_structured() поднимает ValueError при пустых входных данных и при ошибке валидации схемы. PluginLlmTrustError срабатывает, когда шлюз доверия отклоняет переопределение. Всё остальное (5xx провайдера, отсутствие учётных данных, тайм-аут) — то, что поднимает auxiliary_client.call_llm().
  • Стоимость. Каждый вызов расходует платный провайдер пользователя. Не зацикливай complete() на каждое сообщение шлюза без учёта расхода токенов.

Место в экосистеме плагинов

Остальные методы ctx.* расширяют существующие подсистемы Hermes:

| ctx.register_tool | добавляет инструмент, который агент может вызвать | | ctx.register_platform | подключает новый шлюз-адаптер | | ctx.register_image_gen_provider | заменяет бэкенд генерации изображений | | ctx.register_memory_provider | заменяет бэкенд памяти | | ctx.register_context_engine | заменяет компрессор контекста | | ctx.register_hook | следит за событием жизненного цикла |

ctx.llm — первый интерфейс, который позволяет плагину запустить ту же модель, с которой разговаривает пользователь, вне основного потока, не регистрируя ничего из перечисленного выше. Это его единственная задача. Нужно зарегистрировать инструмент для агента — бери register_tool. Нужно реагировать на событие жизненного цикла — бери register_hook. Нужен собственный вызов модели, структурированный или нет — ctx.llm.

Справочник

ESC