Hooks — routing and reliability

Model selection, triage, fault tolerance, and cost. A list of all hooks is in 06-hooks-reference.


6.5 escalate

Deterministic escalation to a human contact when the agent cannot help:

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
Field Description
message contact shown to the user ({{question}} supported)
off_topic_keywords questions containing any of the words are considered out of domain
suppress_on_off_topic true = refuse immediately (skip LLM); false = inject the contact via prompt
empty_answer_patterns final answers containing these substrings are "useless"
min_answer_chars answers shorter than this are "useless"
remind_next_turn inject the contact into the context before the next message

Live operator (operator:)

If the operator section is set, instead of a static contact the hook opens a real escalation via the core (EscalationService). While the operator is conducting the dialog, the agent is paused: user messages are forwarded to the operator, and hold_message is sent back. If the escalation could not be opened (no channels/session) — fallback to the regular 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"]                 # команды возврата агенту
Field Description
enabled enable handoff (default false)
on_off_topic escalate off-topic questions (default true)
on_empty_answer escalate "useless" answers (default true)
connecting_message shown to the user when the escalation opens
unavailable_message fallback contact if handoff did not open ({contact})
hold_message reply to the user while the operator is talking
timeout_secs operator idle time before control returns to the agent
timeout_message message to the user on return by timeout
release_commands operator commands to return control to the agent
context_messages how many recent dialog messages to attach to the operator card (0 = no history, default 12)

Operator channels are configured in config/server.yaml → approval.operator_notify (Yandex Messenger / Telegram / webhook; a shared list with approvals, secrets via token_env). Channel setup and operator commands (/accept, /release) are in the tool reference and approvals.


6.15 loop_guard

Breaks tool-call looping on before_tool_call: it maintains a sliding window of the process's recent calls (tool name + canonical arguments) and rejects a call (deny) if the same call has repeated more than max_identical times in the window. The idea is adjacent to tool_cache, but inverted: the cache returns the result of a repeat, whereas this hook stops a loop A,B,A,B,… where the arguments formally change but the agent makes no progress. Deterministic, without LLM; state is in-memory per process.

kind: loop_guard
name: loop_guard
max_identical: 3                 # сколько одинаковых вызовов допустимо (0 → 1)
window: 8                        # окно последних вызовов на процесс
tools: []                        # allowlist; пусто = все инструменты
exempt_tools: [read_overflow]    # не считать (напр. постраничное чтение)
deny_reason: "Повторяющийся вызов {{tool}} остановлен ({{count}}/{{max}})."
Field Description
max_identical repeat threshold; when exceeded — deny with a reason
window sliding window size (a value below max_identical is raised to it)
tools tool allowlist; empty = all
exempt_tools tools that are not counted at all
deny_reason reason template: {{tool}}, {{count}}, {{max}}

Arguments are compared canonically (object keys are sorted), so {"a":1,"b":2} and {"b":2,"a":1} are the same call. A rejected call does not enter the window, so the protection triggers until the agent changes its approach; the process state is deleted on on_terminate. The rejection is returned to the model as a failed tool result — which can then rephrase the call.


6.17 circuit_breaker

Declarative config for the native provider circuit breaker (agent_os_core::circuit_breaker) for the agent the hook is bound to. The complex part — accounting for provider errors and bypassing a failed provider in the failover chain — lives in the core; the hook merely carries the policy and applies it to its process. It complements (and overrides) the global default from server.yaml provider.circuit_breaker.

kind: circuit_breaker
name: circuit_breaker
failure_threshold: 5      # подряд ошибок до размыкания (0 = выключить)
cooldown_secs: 30         # пауза до пробного запроса
half_open_max: 1          # успешных проб для замыкания
Field Description
failure_threshold consecutive errors before open; 0 — disable the breaker for the agent
cooldown_secs how long to keep open before the half-open probe
half_open_max consecutive successful probes to return to closed

Applied idempotently on before_inference (so a late grant_hook also works); reset on on_terminate. The breaker key is the name of the provider's model-config (default, strong, …). The state is visible in GET /v1/circuit-breakers (+ reset POST /v1/circuit-breakers/:name/reset) and in /metrics (agentos_circuit_breaker_open, agentos_circuit_breaker_state).


6.19 model_router

Classifier-based model routing on before_user_message: it classifies the message and pins it for the turn to model_config (provider/endpoint/pricing) plus optional model/temperature/max_tokens. Backend from config: onnx | llm | hybrid (ONNX classifier → on low score/unavailability, an LLM classifier with a strict schema {"label": ...}). The decision is cached by message hash and applied once per turn (deterministically within the turn).

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 }
Field Description
backend onnx / llm / hybrid
onnx_model id of the local classifier (for onnx/hybrid)
min_confidence ONNX score threshold; below it — LLM (in hybrid)
llm_provider named provider for the LLM classifier
max_chars / cache_ttl_secs message truncation / decision cache TTL
labels label → { model_config, model, temperature, max_tokens }

For backend: onnx/hybrid in config/server.yaml → security.onnx.models, langid (language/script) and topic-clf (multilingual topics, 19 categories) are already registered; the id is specified in onnx_model. Live check of the ONNX branch: AGENT_OS_ONNX_LIVE=1 cargo test -p agent_web_bot --features onnx --test model_router_onnx_live. Event model_routed (label, source, model_config, score) is written to the audit (events.db). The rule list, manual tester, and statistics are on the admin page /model-router (+ JSON GET /v1/model-router, POST /v1/model-router/test); statistics are read from events.db (survives restart), and in the absence of a store — from the kernel memory.


6.20 intent_router

Classifier triage of an incoming message on before_user_message: classifies the intent/topic ("needs a human") and applies an action from the config — instead of the usual turn or alongside it. Backend same as model_router: onnx | llm | hybrid, the decision is cached by message hash.

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: "Соединяю с оператором…" } }
Action Effect
reply short circuit: a ready-made answer instead of the LLM
inject_system / inject_user inject a message before the user's message
notice status text to the user (the LLM does not see it)
escalate open handoff to an operator (EscalationService) and reply message

escalate.reason: off_topic | empty_answer | user_requested | other; optionally timeout_secs / timeout_message / context_messages. Priority: escalate → reply → inject. Opens an operator if a session id is present.


6.22 llm_cache

Native inference cache (crate::llm_cache). Caches the exact request (provider, model, messages, max_tokens, temperature) and on a repeat returns the saved response without calling the provider. Unlike faq_cache (user question → answer), this cache catches repeats within the loop: a retry, rework, a repeated identical prompt, a tool-free subagent, and a scripted llm(...).

The cache lives in the kernel, because it must be consulted on three paths that have no common hook point:

The llm_cache hook only carries the policy and installs the cache on the process (like circuit_breaker); lookup/store is done by the kernel. Child processes inherit the parent's cache, so ask(...) judges get into it.

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
Field Description
db_path path to the DuckDB file (data/llm_cache.db by default)
ttl_secs entry TTL in seconds; 0 — does not expire
scope key isolation: template (default) / global / agent / session / user
ignore_temperature do not include temperature in the key
cache_with_tools also cache requests with tools (only the response without tool_calls is saved); default false
min_response_chars / max_response_chars do not cache too short/long responses (max 0 — no limit)
max_entries evict the oldest entries beyond the limit (0 — no limit)

The default policy is tool-free only. A request with a non-empty request.tools is not cached (a response with tool calls is non-deterministic and must not be replayed). For scripts llm(...) tools is always empty, and for child judges too.

Installation is idempotent on on_spawn (so that llm(...) and first-turn judges already see the cache) and on before_inference (so that a hook bound at runtime takes effect immediately); on on_terminate the process cache is removed.