# 6. Справочник хуков

🔵 Хуки перехватывают жизненный цикл агента. Это YAML-файлы в
`config/agents/<agent>/hooks/`, подключаются через `hook_files:` (или по имени в
`hooks:`). Поле `kind:` выбирает хук.

---

## 6.1 Точки жизненного цикла

| Точка | Сигнатура | Что делает |
|---|---|---|
| `before_user_message` | `(ctx, msg)` | впрыснуть, переписать, отбросить сообщение или ответить самому |
| `after_turn` | `(ctx, question, answer)` | после финального ответа; может выдать пост-ходовое уведомление |
| `after_loop` | `(ctx, reason)` | при остановке цикла работы (`job_done`, `terminated`, `blocked`, `budget_exhausted`, `max_turns`, …) |

### Исход `before_user_message`

Хук может вернуть карту с любым из полей (все опциональны):

| Поле | Эффект |
|---|---|
| `notice` | текст, стримящийся в чат-UI; **не** попадает в контекст LLM |
| `reply` | полный ответ — ход LLM пропускается |
| `suppress` | полностью отбросить сообщение пользователя |
| `inject` | дополнительные сообщения перед сообщением пользователя |
| `replace_message` | заменить сообщение пользователя |

Ошибка хука никогда не ломает ход — исключение логируется, хук возвращает
нейтральный результат.

---

## 6.2 Виды хуков

| kind | Назначение |
|---|---|
| `faq_cache` | кэш (вопрос → ответ) в DuckDB; отдавать повторы, пропуская LLM |
| `inject` | декларативно впрыскивать системные/пользовательские сообщения |
| `escalate` | эскалация на человека, когда агент не может помочь |
| `memory` | долговременная память на пользователя (факты + сводка) |
| `script_hook` | скриптовый хук на Rhai/JavaScript (см. `07-scripts`) |

---

## 6.3 `faq_cache`

Запоминает пары (вопрос, ответ) и отдаёт кэшированный ответ на повторные или
похожие вопросы:

1. На сообщении пользователя ищет в хранилище уже отвеченные вопросы
   (префильтр по пересечению токенов), затем спрашивает **судью** (дочерний
   агент-процесс — свой PID, провайдер ядра, бюджет токенов), похож ли
   кандидат.
2. При уверенном совпадении кэшированный ответ впрыскивается как подсказка
   (`short_circuit: false`) или возвращается напрямую, пропуская LLM
   (`short_circuit: true`).
3. После каждого хода новая пара записывается.

```yaml
kind: faq_cache
name: faq_cache
db_path: data/faq_cache_ngu.db    # запасной путь (в проде — messages.db)
max_candidates: 5
min_confidence: 0.7
short_circuit: true
hit_notice: "⚡ Отвечено из кэша FAQ"
hint_template: ""                 # подсказка при short_circuit: false
fallback_score: 0.6               # пересечение токенов, если судья недоступен
judge_instructions: |             # опциональный промпт судьи (JSON-ответ)
  You are a question similarity judge...
store:
  enabled: true
  min_question_chars: 8
  min_answer_chars: 8
```

| Поле | Описание |
|---|---|
| `db_path` | файл DuckDB (для standalone/тестов); в проде — общий message store в скоупе `faq:<template>` |
| `max_candidates` | сколько кандидатов отдаётся судье |
| `min_confidence` | мин. уверенность судьи (0..1) для принятия совпадения |
| `short_circuit` | `true` = отвечать напрямую (пропустить LLM) |
| `hit_notice` | уведомление при попадании в кэш |
| `judge_instructions` | кастомный промпт судьи |
| `fallback_score` | порог пересечения токенов без судьи |
| `store.*` | что записывать после хода |

---

## 6.4 `inject`

Чистое YAML-впрыскивание контекста на сообщениях пользователя:

```yaml
kind: inject
name: city_hint
first_n: 1                  # сработать только на первые N сообщений (на процесс)
notice: "Reminder: city context active"
messages:
  - role: system
    content: "Important: the user's city is Moscow."
  - role: user
    content: "Context reminder: {{question}}"
```

| Поле | Описание |
|---|---|
| `first_n` | сработать только на первые N сообщений (опущено = на каждое) |
| `notice` | опциональное уведомление в чат-UI |
| `messages` | список `{ role, content }` (роль по умолчанию `system`) |

`{{question}}` заменяется входящим сообщением. Роли: `system` | `user` |
`assistant`.

---

## 6.5 `escalate`

Детерминированная эскалация на контакт человека, когда агент не может помочь:

```yaml
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` | впрыснуть контакт в контекст перед следующим сообщением |

---

## 6.6 `memory`

Долговременная память на пользователя, переживающая сессии:

1. Перед сообщением вспоминает релевантные факты (префильтр по стеммингованному
   пересечению токенов — RU/EN словоформы совпадают) и просит **судью** отобрать
   полезные. Кандидаты несут `importance` и давность, предранжируются по
   релевантности → важности → давности. Отобранные факты + скользящая сводка
   впрыскиваются системным сообщением.
2. После хода **запоминатель** извлекает устойчивые факты и обновляет
   **скользящую сводку на пользователя** (не на сессию). Факты дедуплицируются,
   ограничиваются, могут помечаться `stale`. Новый факт, противоречащий старому,
   перечисляет старый в `supersedes` — тот автоматически выводится.
3. Запоминатель видит существующие факты каждый ход и может их исправлять;
   обратная связь пользователя (like/dislike) поднимает/опускает `importance`
   вспомненных фактов.

```yaml
kind: memory
name: user_memory
db_path: data/memory_ngu.db
max_candidates: 8
min_confidence: 0.6
notice: ""                    # опциональное уведомление о воспоминании ({{count}})
inject_template: ""           # шаблон: {{facts}}, {{summaries}}, {{memories}}
judge_instructions: ""        # промпт судьи релевантности
judge_model: ""               # более дешёвая модель для судьи
backfill: true                # false → пропускать кандидатов при слабом пересечении
max_memories_per_scope: 200
dedup_threshold: 0.8
store:
  enabled: true
  min_question_chars: 4
  min_answer_chars: 20
  extractor_instructions: ""  # промпт запоминателя
  memorizer_model: ""         # более дешёвая модель для запоминателя
  memorize_every: 1           # запускать запоминатель каждые N ходов
  max_episodes_per_scope: 50
  summary_max_chars: 2000
```

Память скоупится на пользователя (`user_id` с фронтенда), с откатом к сессии →
шаблону → имени процесса, если user id нет.

| Поле | Описание |
|---|---|
| `judge_model` / `store.memorizer_model` | запускать вспомогательный процесс на другой (дешевле) модели |
| `backfill` | `true` (по умолч.) добирает недавние несовпавшие факты, чтобы судья видел факты при языковых расхождениях |
| `store.memorize_every` | запускать запоминатель раз в N подходящих ходов на пользователя |
| `dedup_threshold` | порог стеммингованного пересечения, при котором новый факт сливается с существующим |

`inject_template` поддерживает три плейсхолдера: `{{facts}}` (факты списком),
`{{summaries}}` (сводки списком), `{{memories}}` (всё вместе, для обратной
совместимости).

Посмотреть, что агент помнит — админ-эндпоинт `GET /v1/memory/:scope` (факты с
флагами importance/stale, скользящая сводка, эпизоды).

---

## 6.7 `script_hook`

См. `07-scripts` (Rhai или JavaScript).

---

## 6.8 Подключение хуков к агенту

```yaml
# config/agents/<agent>/agent.yaml
hook_files:
  - hooks/faq_cache.yaml
  - hooks/memory.yaml
```

---

## 6.9 Тестирование хуков локально

```bash
hook_tool <hook.yaml> info                    # показать распарсенный конфиг
hook_tool <hook.yaml> user "Привет, мир"      # запустить before_user_message
hook_tool <hook.yaml> turn "question" "answer" # запустить after_turn
hook_tool <hook.yaml> loop "job_done"          # запустить after_loop

# флаги
--db <path>       # перекрыть db_path (для тестов — временный файл)
--live            # судья/запоминатель — реальный дочерний процесс (нужен ключ API)
--agent <name>    # имя процесса в контексте хука
--template <name> # имя шаблона
--user <id>       # user_id
--session <id>    # session_id
```
