Доступ плагина к 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 |
| ↳ allowlist | — | allowed_providers: [...] |
model= | запрещено | allow_model_override: true |
| ↳ allowlist | — | allowed_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.
Справочник
- Реализация:
agent/plugin_llm.py - Тесты:
tests/agent/test_plugin_llm.py - Эталонные плагины (сопутствующий репозиторий):
plugin-llm-example— синхронная структурированная экстракция с изображениямиplugin-llm-async-example— асинхронный режим сasyncio.gather()
- Вспомогательный клиент (движок под капотом): см. Среда выполнения провайдера.