钩子 — 安全与质量

输出护栏与 DLP:检查最终回答并保护数据。所有钩子的列表见 06-hooks-reference。


6.9 link_check

输出护栏:检查最终回答中的链接,并标记那些无法被来源证实(grounded)或已失效(live)的链接。接地依据 RAG 代理的归档(与 rag/archive_tool 相同的 archive::open_cached);结构性检查(绝对 http(s)、公共主机)始终执行。捕获模型编造的 URL——这是代理的主要失败点,而 RAG 指令要求引用来源。

kind: link_check
name: link_guard
mode: grounded              # grounded | well_formed | live | both
archive_path: data/ngu      # источник заземления (пусто = только структура)
check_bare_urls: true       # «голые» http(s):// вне markdown
check_html_anchors: false   # HTML <a href>
max_links: 20               # больше — нейтрально
on_invalid: notice          # notice | annotate | unlink | rework
notice_template: "⚠ {{count}} ссылк(и) не подтверждены источниками"
annotate_template: "{{title}} ⚠"
rework_template: ""         # для on_invalid: rework; {{links}}, {{count}}
rework_clear_stream: true   # false = не очищать уже отданный стрим (видимая самоправка / клиент без replace)
# live-режим (обычно только на администраторском глобальном хуке):
allow_hosts: []             # пусто = любой; иначе leaf/example.com + поддомены
timeout_ms: 2000
cache_ttl_secs: 86400       # 0 = без кэша
max_body_bytes: 0           # 0 = HEAD; >0 = GET (тело не читается)
字段 描述
mode grounded — 依据归档;well_formed — 仅结构;live — 结构 + HTTP 存活;both = grounded + live
archive_path 归档目录;为空/缺失 → grounded 检查关闭(中性)
check_bare_urls / check_html_anchors 扫描哪些形式的链接
max_links 链接过多 — 钩子中性(不检查)
on_invalid notice — 通知(默认);annotate — 链接文本后缀;unlink — 链接 → 文本;rework — 拒绝回答并强制该轮继续(见 §6.1 rework)
notice_template / annotate_template / rework_template {{count}} / {{title}} / {{links}}
rework_clear_stream 在 rework 时清除已发出的回答(replace)。false — 保留流(可见的自我修正或无 replace 的客户端)
allow_hosts live 的主机 allowlist(空 = 任意公共主机);列表外的主机不查询(不标记)
timeout_ms / cache_ttl_secs / max_body_bytes 超时、存活缓存 TTL、方法(HEAD/GET)

「有效」= 已接地(URL 存在于归档中;允许尾部 / 和 #…)、结构安全,并且在 live 下存活。live 不跟随重定向(3xx = 存活)——以此避免通过重定向的 SSRF;401/403/405/406/429 视为可达(反机器人),超时 — 「未知」(不标记),而 404/410 和网络错误 — 视为失效。检查并行进行,并按 URL 缓存在内存中。任何错误(无归档、无匹配、链接过多)时钩子中性——该轮不会中断。

annotate / unlink 会修改回答,并依赖重发契约(replace);仅在能够替换已显示回答的客户端上安全。默认 — notice。

rework 不修改回答,而是拒绝它:内核清除已发出的流(best-effort,replace: true)并注入一条带有未证实链接列表的 correction 消息,之后模型重新回答——代理在同一轮内修正链接。上限 MAX_REWORK_ROUNDS(2)保证终止:如果模型无法完成,该轮以最后一个回答结束(降级,不循环)。


6.12 format_guard

在 after_assistant_message 上确定性地整理最终回答(不使用 LLM):使链接可点击并移除「裸」URL。钩子就地修改回答,内核重发修正后的版本;如果无需修改 — 中性。

kind: format_guard
name: format_guard
linkify_bare_urls: true   # https://… → [https://…](https://…)
dedupe_links: true        # дубли ссылок (по target) схлопываются
one_link_per_line: true   # каждая ссылка с новой строки
stream: true              # форматировать и поток токенов (on_token)
notice: ""                # уведомление в UI при изменении (пусто = тихо)
字段 描述
linkify_bare_urls 将裸 http(s)-URL 包裹为 markdown 链接(已格式化的不动)
dedupe_links 按 target 的重复项替换为其文本
one_link_per_line 如果一行中有多个链接 — 分行排列
stream 格式化 on_token:URL 被缓冲并包裹,客户端立即看到修正后的文本
notice 如果回答被修改,显示 UI 通知

流。 after_assistant_message 修改已保存/已发出的回答,内核重新发送完整的 final_answer(重发)——对流式客户端来说这是多余的一帧。在 stream: true 下,链接直接在流中格式化(on_token):安全字符立即输出,而 URL 缓冲到终止符,然后作为 [url](url) 插入。after_assistant_message 仍是权威的最终处理(去重、one-per-line、非流式通道);重复应用是幂等的。


6.13 guard

无状态的统一安全膜。用通用引擎 agent_os_core::safety::scanner 在模型的四个边界上扫描文本,并按实体类别应用动作:

边界 点 覆盖内容
Ingress(进入模型) before_inference messages + tool 模式(+ rag/memory 注入)
Egress(面向用户) after_assistant_message 最终回答
Tool-args(来自模型) before_tool_call 调用参数
Tool-result(进入模型) after_tool_result 工具结果

动作:audit_only · notice · redact(不可逆掩码 [REDACTED])· deny(拒绝)· suppress · rework(重写回答)。tokenize 在 guard 中不可用——请使用 pseudonymize。当类别混合时,最严格的动作胜出。

kind: guard
name: dlp
direction: both            # input | output | tool | both (input+output) | all
on_input: redact           # audit_only | notice | redact | deny
on_output: rework          # audit_only | notice | redact | suppress | deny | rework
# on_tool_args: redact     # по умолчанию = on_input
# on_tool_result: redact
entities: [email, phone, card, iban, passport, inn, snils, api_key, pem]
actions:                   # переопределение действия на класс
  api_key: deny
allow: ["support@company.com"]
patterns:                  # свой regex на класс (напр. person)
  person: '\b[А-ЯЁ][а-яё]+\s[А-ЯЁ][а-яё]+\b'
detector: deterministic    # deterministic | llm (принимается; в v1 = deterministic)
audit_only: false          # true = только считать, не менять
notice_template: "⚠ Скрыто {{count}} чувствительных данных"
rework_template: "Убери эти данные и ответь заново: {{kinds}}"
rework_clear_stream: true
字段 描述
direction 哪些边界处于活动状态
on_input / on_output / on_tool_args / on_tool_result 边界的默认动作
entities 实体类别;空 = 所有内置类别
actions 针对特定类别覆盖动作
allow 不标记的值/子串
patterns 针对类别的额外 regex(person、org、……)
ner_model 此钩子的 ONNX-NER 模型 id(per-agent);空 = 按代理模板选择器 → default
audit_only 计数但不修改文本
notice_template {{count}} — 触发次数
rework_template {{kinds}}、{{count}} — 给模型的修正文本
Fail-open。 扫描出错时保持文本原样。值不会被记录——只记录类型和计数。

NER。 detector: ner / hybrid 通过内核的通用 ONNX 服务,用本地 ONNX 模型(类别在 ner_classes 中,例如 [person, org, address])补充规则;没有该服务时,钩子仍依赖规则。模型选择——ner_model(per-agent hook YAML),否则按 agent 模板选择器(templates),然后是 default。


6.14 pseudonymize

在同一个扫描器之上进行代理值的可逆替换。模型只能看到 [TYPE_n],真实值本地存储在 DuckDB-vault 中,并在输出时展开。

kind: pseudonymize
name: pii_vault
scope: session             # session | user | template
entities: [person, email, phone, address, org, passport]
detokenize_output: true    # разворачивать ответ пользователю (on_token)
detokenize_tool_args: true # side-effect'ы идут с реальными значениями
tokenize_tool_results: true
fail_closed: true          # не отправлять, если суррогат не удалось поставить
vault_path: data/pii_vault.db
placeholder_format: "[{kind}_{n}]"
output_dlp: true           # страховка: маскировать сырое PII в ответе
patterns: {}               # свой regex на класс (person/org требуют его)
allow: []

Vault 表(data/pii_vault.db):

CREATE TABLE pii_map (
  scope_key   TEXT NOT NULL,
  placeholder TEXT NOT NULL,
  kind        TEXT NOT NULL,
  value       TEXT NOT NULL,
  created_at  BIGINT NOT NULL,
  last_used   BIGINT NOT NULL,
  PRIMARY KEY (scope_key, placeholder)
);
CREATE INDEX idx_pii_value ON pii_map(scope_key, kind, value);

假名化 ≠ 匿名化:数据仍然是个人数据,且可重新识别;vault 不会离开边界。


6.16 groundedness

after_assistant_message 上的输出护栏:将最终回答拆分为断言(句子),并针对可用的 evidence 逐一检查——断言中在 evidence 里找到的实义 token 占比不得低于 min_support。未确认的断言以通知形式显示;回答不会改变,也不会被抑制。它能抓住 link_check 漏掉的情况:引用真实,但断言是编造的。确定性,不使用 LLM 和网络。

Evidence(evidence)——RAG agent 的归档(对每个断言进行 BM25 搜索)以及会话中成功的工具结果(ngu_search、http_fetch、……——即 agent 实际获得的内容)。

kind: groundedness
name: grounding_guard
archive_path: data/ngu        # источник evidence (общий archive::open_cached)
evidence: auto                # auto | archive | tools | both (both == auto)
tool_results_limit: 32        # последних tool-результатов сессии в пул
max_evidence_chars: 200000    # потолок текста evidence из инструментов
min_support: 0.5              # доля токенов claim, найденных в evidence
min_claim_chars: 24           # короче — не claim
min_claim_tokens: 3           # меньше содержательных токенов — не claim
max_claims: 30                # больше — не проверять (нейтрально)
top_chunks: 3                 # сколько чанков архива брать на claim
skip_questions: true          # пропускать вопросительные предложения
on_invalid: notice            # notice | rework
rework_template: "Эти утверждения не подтверждаются источниками: {{claims}}. Убери их или подтверди и ответь заново."
rework_clear_stream: true     # для rework: очистить уже отданный стрим (replace)
notice_template: "⚠ {{count}} утверждени(й) не подтверждается источниками: {{claims}}"
max_notice_claims: 3          # сколько claim показать в notice
字段 描述
archive_path 归档目录(evidence);为空 → 归档来源关闭
evidence auto/both——归档 + 工具;archive——仅归档;tools——仅工具
tool_results_limit 考虑会话中最近多少个工具结果
max_evidence_chars 来自工具的证据文本总量上限
min_support claim 的 token 被 evidence 覆盖的阈值(0..1)
min_claim_chars / min_claim_tokens 过滤过短/非事实性句子
max_claims 更大——钩子不检查(中性)
top_chunks 每个断言取多少个归档分块
skip_questions 不将问题视为 claim
on_invalid notice——通知(默认);rework——拒绝回答并继续该轮次({{count}}、{{claims}},见 §6.1)
rework_template / rework_clear_stream correction 消息模板以及针对 rework 的流清理
notice_template {{count}}(数字)和 {{claims}}(前几个未确认项)
max_notice_claims 通知文本中的断言数量上限

URL 和 markdown 标记会在分词前从断言中剔除(markdown 链接 [текст](url) → текст)。归档只打开一次并缓存(与 archive::open_cached 共享 rag/link_check/archive_tool);工具结果取自 HookContext.tool_results(ToolResultLookup::recent_all,in-memory per-session,仅成功项)。如果没有任何来源可用,则为中性。改写和综合可能被标记为未确认——因此默认 notice;rework 请在自己的语料上校准阈值后再启用。与 link_check 一起使用(钩子相互独立)。


6.18 language_guard

确保最终回答以预期语言书写——用户的语言(target: auto)或指定语言。由三个点组成的混合方案:在用户消息上捕获语言(before_user_message)、可选的预防性指令(before_inference),以及最终回答的 guard(after_assistant_message)。

关键点:after_assistant_message 只在最终(tool-free)回答上触发,而那时用户消息已经不在了——因此必须提前捕获用户语言(in-memory 按进程,在 on_terminate/on_session_closed 时清理)。

检测。 基于脚本、确定性、不使用 LLM:字母按 Unicode 范围分类(Latin、Cyrillic、Greek、Arabic、Hebrew、Devanagari、Han、Hangul、Thai),取占主导的组。能区分 ru↔en,但不能区分 en/es/de——对于同一脚本的语言,会启用细化 tier(detector):

detector 做什么 代价
script 仅脚本(默认) 零
ngram whatlang trigram 检测器,进程内 零,无模型/网络
onnx 通过内核 ONNX 服务的微型 language-id 模型(onnx_model,默认 langid;自动加载) 本地推理
llm 子进程评判器 token

当 min_ratio 低于阈值时,响应被视为混合,不标记; 短响应(min_chars)不检查。

模型 langid(mini-BERT,约 25 MB int8,200 种语言)已注册在 config/server.yaml → security.onnx.models,并在首次使用时自动下载 (download: auto,仓库 onnx-community/language_detection-ONNX,固定修订版)。需要 带 onnx 特性的服务器构建。如果 ONNX 服务或模型不可用,该钩子保持中立——回合不会中断。

哪些语言算「ok」。 allowed(代码/脚本列表)比 target 更强(auto = 用户语言)。区分同一脚本内的语言 (en/es,ru/uk)只能借助 detector: ngram/onnx/llm;在 detector: script 下,允许同一文字家族的任意响应。

kind: language_guard
name: language_guard
target: auto            # auto | <код: ru, en, …> | <скрипт: cyrillic, latin, …>
allowed: []             # непусто = ответ обязан быть в одном из списка
min_chars: 40           # короче — не проверяем
min_ratio: 0.5          # доля доминирующего скрипта; ниже — смешанный текст
sample_chars: 400       # образец сообщения пользователя для tier'а
on_mismatch: notice     # notice | rework
notice_template: "⚠️ Ответ не на языке запроса (ожидался {{expected}}, получен {{detected}})"
rework_template: ""     # пусто = встроенный текст; {{expected}}, {{detected}}
rework_clear_stream: true
detector: script        # script | ngram | onnx | llm
onnx_model: langid      # id модели для detector: onnx (см. security.onnx.models)
model: ""               # дешёвая модель для detector: llm
min_confidence: 0.6
judge_instructions: ""  # свой prompt судьи (JSON-ответ)
inject_directive: false # превентивно на before_inference
directive_template: "Always answer in {{language}}."
字段 描述
target auto — 跟随用户;否则为代码(ru)或脚本(cyrillic)
allowed 语言/脚本的 allowlist;比 target 更强
min_chars / min_ratio 响应长度阈值和脚本检测置信度阈值
sample_chars 将用户消息的多少个字符交给 tier
on_mismatch notice(默认)或 rework(在同一回合内重写)
notice_template / rework_template {{expected}},{{detected}}
rework_clear_stream 在 rework 时是否清除已发出的流
detector script / ngram / onnx / llm(见表格)
onnx_model 用于 detector: onnx 的 language-id 模型 id(默认 langid)
model / min_confidence / judge_instructions LLM 评判器(detector: llm)
inject_directive / directive_template 「用……回答」指令({{language}})

结果。 匹配 / 无可检查 → 中立。不一致 → notice 或 rework(内核清除流并注入 system 修正,上限 MAX_REWORK_ROUNDS)。replace/suppress 不使用:无法 确定性地翻译文本(见 §6.1 rework)。指令是幂等的(标记)。


6.28 schema_guard

根据 JSON Schema 验证结构化输出,在不匹配时要么 要求模型修正(rework),要么标记(notice)。补充 流程的 output_schema(后者只检查 job_done-payload):该钩子 在每个最终响应上触发。

触发点:

kind: schema_guard
name: schema_guard
target: answer              # answer | tool_args
on_invalid: rework          # answer: notice | rework ; tool_args: deny | notice
schema:
  type: object
  required: [answer]
  properties:
    answer: { type: string }
# notice_template: "⚠ Схема не соблюдена: {{errors}}"
# rework_template: "Ответ не соответствует схеме: {{errors}}"
# tools: [structured_search]   # tool_args: ограничить список
# exempt_tools: []             # tool_args: исключить
字段 描述
target answer(默认)或 tool_args
schema 用于验证的 JSON Schema
on_invalid answer:notice(默认)/ rework;tool_args:deny(默认)/ notice
notice_template / rework_template {{errors}}(以及用于 notice 的 {{tool}})
tools / exempt_tools tool_args:allowlist / 工具排除项

对于 answer,JSON 甚至可以从 fenced 块(```json … ```)或 包含 JSON 对象/数组的文本中提取。验证器与 output_schema 相同(agent_os_core::json_schema)。