Хуки — маршрутизация и надёжность
Выбор модели, триаж, отказоустойчивость и стоимость. Список всех хуков — 06-hooks-reference.
6.5 escalate
Детерминированная эскалация на контакт человека, когда агент не может помочь:
kind: escalate
name: escalate
message: |
Если это не то, что вы искали — напишите: 📞 +7 ... ✉️ a@b.c
off_topic_keywords:
- iphone
- доставка
suppress_on_off_topic: true
empty_answer_patterns:
- "не нашёл"
- "нет информации"
min_answer_chars: 30
remind_next_turn: true
| Поле | Описание |
|---|---|
message |
контакт, показываемый пользователю (поддерживается {{question}}) |
off_topic_keywords |
вопросы с любым из слов считаются вне домена |
suppress_on_off_topic |
true = отказаться сразу (пропустить LLM); false = впрыснуть контакт подсказкой |
empty_answer_patterns |
финальные ответы с этими подстроками — «бесполезные» |
min_answer_chars |
ответы короче — «бесполезные» |
remind_next_turn |
впрыснуть контакт в контекст перед следующим сообщением |
Живой оператор (operator:)
Если задана секция operator, вместо статического контакта хук открывает
реальную эскалацию через ядро (EscalationService). Пока оператор ведёт
диалог, агент на паузе: сообщения пользователя пересылаются оператору, а в ответ
уходит hold_message. Если эскалацию открыть не удалось (нет каналов/сессии) —
fallback на обычный message.
operator:
enabled: true
on_off_topic: true
on_empty_answer: true
connecting_message: "Соединяю вас с оператором…"
unavailable_message: "Оператор недоступен.\n{contact}" # {contact} = message
hold_message: "Передал ваше сообщение оператору."
timeout_secs: 600
timeout_message: "Оператор не отвечает, продолжаю помогать я."
release_commands: ["/release", "/agent"] # команды возврата агенту
| Поле | Описание |
|---|---|
enabled |
включить handoff (по умолчанию false) |
on_off_topic |
эскалировать off-topic вопросы (по умолчанию true) |
on_empty_answer |
эскалировать «бесполезные» ответы (по умолчанию true) |
connecting_message |
показать пользователю при открытии эскалации |
unavailable_message |
fallback-контакт, если handoff не открылся ({contact}) |
hold_message |
ответ пользователю, пока говорит оператор |
timeout_secs |
простой оператора до возврата управления агенту |
timeout_message |
сообщение пользователю при возврате по таймауту |
release_commands |
команды оператора для возврата управления агенту |
context_messages |
сколько последних сообщений диалога приложить к карточке оператора (0 = без истории, по умолчанию 12) |
Каналы оператора задаются в config/server.yaml → approval.operator_notify
(Яндекс Мессенджер / Telegram / webhook; общий список с одобрениями, секреты
через token_env). Настройка каналов и команды оператора (/accept,
/release) — в справочнике инструментов
и одобрениях.
6.15 loop_guard
Разрывает зацикливание вызовов инструментов на before_tool_call: ведёт
скользящее окно последних вызовов процесса (имя инструмента + канонические
аргументы) и отклоняет вызов (deny), если тот же вызов повторился больше
max_identical раз в окне. Идея — смежная с tool_cache, но наоборот: кэш
отдаёт результат повтора, а этот хук останавливает цикл A,B,A,B,…, где
аргументы формально меняются, но агент не продвигается. Детерминированно, без
LLM; состояние in-memory на процесс.
kind: loop_guard
name: loop_guard
max_identical: 3 # сколько одинаковых вызовов допустимо (0 → 1)
window: 8 # окно последних вызовов на процесс
tools: [] # allowlist; пусто = все инструменты
exempt_tools: [read_overflow] # не считать (напр. постраничное чтение)
deny_reason: "Повторяющийся вызов {{tool}} остановлен ({{count}}/{{max}})."
| Поле | Описание |
|---|---|
max_identical |
порог повторов; при превышении — deny с причиной |
window |
размер скользящего окна (значение ниже max_identical поднимается до него) |
tools |
allowlist инструментов; пусто = все |
exempt_tools |
инструменты, которые не считаются вовсе |
deny_reason |
шаблон причины: {{tool}}, {{count}}, {{max}} |
Аргументы сравниваются канонически (ключи объектов сортируются), поэтому
{"a":1,"b":2} и {"b":2,"a":1} — один и тот же вызов. Отклонённый вызов не
попадает в окно, поэтому защита срабатывает, пока агент не сменит подход;
состояние процесса удаляется на on_terminate. Отказ возвращается модели как
failed-результат инструмента — та может переформулировать вызов.
6.17 circuit_breaker
Декларативный конфиг нативного circuit breaker провайдеров
(agent_os_core::circuit_breaker) для агента, к которому хук привязан.
Сложная часть — учёт ошибок провайдера и обход упавшего провайдера в цепочке
failover — живёт в ядре; хук лишь несёт политику и применяет её к своему
процессу. Дополняет (и переопределяет) глобальный дефолт из server.yaml
provider.circuit_breaker.
kind: circuit_breaker
name: circuit_breaker
failure_threshold: 5 # подряд ошибок до размыкания (0 = выключить)
cooldown_secs: 30 # пауза до пробного запроса
half_open_max: 1 # успешных проб для замыкания
| Поле | Описание |
|---|---|
failure_threshold |
ошибок подряд до open; 0 — выключить breaker для агента |
cooldown_secs |
сколько держать open до half-open пробы |
half_open_max |
успешных проб подряд для возврата в closed |
Применяется идемпотентно на before_inference (поэтому поздний grant_hook
тоже работает); сбрасывается на on_terminate. Ключ брейкера — имя
model-config провайдера (default, strong, …). Состояние видно в
GET /v1/circuit-breakers (+ сброс POST /v1/circuit-breakers/:name/reset) и в
/metrics (agentos_circuit_breaker_open, agentos_circuit_breaker_state).
6.19 model_router
Классификаторная маршрутизация модели на before_user_message: классифицирует
сообщение и пинит на ход model_config (провайдер/эндпоинт/pricing) плюс опц.
model/temperature/max_tokens. Бэкенд из конфига: onnx | llm | hybrid
(ONNX-классификатор → при низком скоре/недоступности LLM-классификатор со
строгой схемой {"label": ...}). Решение кэшируется по хэшу сообщения и
применяется один раз на ход (детерминированно в пределах хода).
kind: model_router
name: model_router
backend: hybrid # onnx | llm | hybrid
onnx_model: topic-clf # ONNX classifier id (onnx/hybrid)
min_confidence: 0.5 # ниже → LLM fallback (hybrid)
llm_provider: cheap # провайдер для LLM-классификатора (опц.)
max_chars: 2000
cache_ttl_secs: 3600
labels:
code: { model_config: strong }
chat: { model_config: cheap, temperature: 0.7 }
| Поле | Описание |
|---|---|
backend |
onnx / llm / hybrid |
onnx_model |
id локального классификатора (для onnx/hybrid) |
min_confidence |
порог скора ONNX; ниже — LLM (в hybrid) |
llm_provider |
именованный провайдер для LLM-классификатора |
max_chars / cache_ttl_secs |
обрезка сообщения / TTL кэша решений |
labels |
label → { model_config, model, temperature, max_tokens } |
Для backend: onnx/hybrid в config/server.yaml → security.onnx.models
уже зарегистрированы langid (язык/скрипт) и topic-clf (многоязычные темы,
19 категорий); id указывается в onnx_model. Live-проверка ONNX-ветки:
AGENT_OS_ONNX_LIVE=1 cargo test -p agent_web_bot --features onnx --test model_router_onnx_live.
Событие model_routed (label, source, model_config, score) пишется в
аудит (events.db). Список правил, ручной тестер и статистика — на
админ-странице /model-router (+ JSON GET /v1/model-router,
POST /v1/model-router/test); статистика читается из events.db (переживает
рестарт), а при отсутствии стора — из памяти ядра.
6.20 intent_router
Классификаторный триаж входящего сообщения на before_user_message:
классифицирует намерение/тему («нужен человек») и применяет действие из
конфига — вместо обычного хода или вместе с ним. Бэкенд как у model_router:
onnx | llm | hybrid, решение кэшируется по хэшу сообщения.
kind: intent_router
name: intent_router
backend: hybrid # onnx | llm | hybrid
onnx_model: topic-clf
min_confidence: 0.5
max_chars: 2000
cache_ttl_secs: 3600
labels:
greeting: { reply: "Здравствуйте! Чем помочь?" }
support: { inject_system: "Это обращение в поддержку — уточни детали." }
human: { escalate: { reason: user_requested, message: "Соединяю с оператором…" } }
| Действие | Эффект |
|---|---|
reply |
короткое замыкание: готовый ответ вместо LLM |
inject_system / inject_user |
впрыснуть сообщение перед сообщением пользователя |
notice |
текст статуса пользователю (LLM его не видит) |
escalate |
открыть handoff оператору (EscalationService) и ответить message |
escalate.reason: off_topic | empty_answer | user_requested | other;
опционально timeout_secs / timeout_message / context_messages. Приоритет:
escalate → reply → inject. Открывает оператора, если есть session id.
6.22 llm_cache
Нативный кэш инференса (crate::llm_cache). Кэширует точный запрос
(provider, model, messages, max_tokens, temperature) и на повторе отдаёт
сохранённый ответ, не вызывая провайдера. В отличие от faq_cache
(вопрос пользователя → ответ), этот кэш ловит повторы внутри цикла: ретрай,
rework, повторный идентичный промпт, tool-free субагент и скриптовый
llm(...).
Кэш живёт в ядре, потому что его надо смотреть на трёх путях, у которых нет общей hook-точки:
- цикл хода агента (токен
before_inference); LlmService::llm— скриптовый/пользовательскийllm(...)(там хуки намеренно не вызываются, чтобы не было рекурсии);HookServices::ask— tool-free дети-судьи отmemory/faq_cache/condense/compact_results/language_guard(они стартуют сhooks: []).
Хук llm_cache только несёт политику и ставит кэш на процесс (как
circuit_breaker); lookup/store делает ядро. Дочерние процессы наследуют
кэш родителя, поэтому ask(...)-судьи попадают в него.
kind: llm_cache
name: llm_cache
db_path: data/llm_cache.db
ttl_secs: 3600
scope: template # template | global | agent | session | user
ignore_temperature: true # не включать temperature в ключ
cache_with_tools: false # по умолчанию кэшируются только requests без tools
max_entries: 10000
min_response_chars: 0
max_response_chars: 100000
| Поле | Описание |
|---|---|
db_path |
путь к DuckDB-файлу (data/llm_cache.db по умолчанию) |
ttl_secs |
TTL записи в секундах; 0 — не истекает |
scope |
изоляция ключа: template (по умолчанию) / global / agent / session / user |
ignore_temperature |
не включать temperature в ключ |
cache_with_tools |
кэшировать и запросы с tools (сохраняется только ответ без tool_calls); по умолчанию false |
min_response_chars / max_response_chars |
не кэшировать слишком короткие/длинные ответы (max 0 — без лимита) |
max_entries |
вытеснение самых старых записей сверх лимита (0 — без лимита) |
Политика по умолчанию — только tool-free. Запрос с непустым request.tools
не кэшируется (ответ с вызовами инструментов недетерминирован и не должен
реплеиться). Для скриптов llm(...) tools пуст всегда, у судей-детей — тоже.
Установка идемпотентна на on_spawn (чтобы llm(...) и судьи первого хода
уже видели кэш) и на before_inference (чтобы хук, привязанный в рантайме,
вступал в силу сразу); на on_terminate кэш процесса снимается.