Хуки — маршрутизация и надёжность

Выбор модели, триаж, отказоустойчивость и стоимость. Список всех хуков — 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.yamlapproval.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.yamlsecurity.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. Приоритет: escalatereplyinject. Открывает оператора, если есть session id.


6.22 llm_cache

Нативный кэш инференса (crate::llm_cache). Кэширует точный запрос (provider, model, messages, max_tokens, temperature) и на повторе отдаёт сохранённый ответ, не вызывая провайдера. В отличие от faq_cache (вопрос пользователя → ответ), этот кэш ловит повторы внутри цикла: ретрай, rework, повторный идентичный промпт, tool-free субагент и скриптовый llm(...).

Кэш живёт в ядре, потому что его надо смотреть на трёх путях, у которых нет общей hook-точки:

Хук 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 кэш процесса снимается.