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 agent turn loop (token
before_inference); LlmService::llm— scripted/userllm(...)(hooks are deliberately not called there, to avoid recursion);HookServices::ask— tool-free child judges frommemory/faq_cache/condense/compact_results/language_guard(they start withhooks: []).
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.