AstroResearch/docs/architecture/agent/hooks.md
Asfmq cec4b8cf7b feat: Docker 容器化、Cookie 鉴权、Coordinator 编排、FTS5 搜索与 P1-P3 全面收尾
Docker 容器化部署
  - 提供 Mode A (Alpine musl, ~23MB) 和 Mode B (Distroless glibc, ~87MB)
    两种镜像,Docker Compose 一键启动
  - build.rs 支持 SKIP_DASHBOARD_BUILD 跳过前端构建
  - 国内镜像加速 (npm/apt/apk) 通过 USE_MIRRORS build-arg 控制

  安全:Cookie-Based 鉴权系统
  - HttpOnly/SameSite=Strict Cookie 会话管理(24h 过期自动清理)
  - 登录/登出/验证接口 + 中间件注入
  - 前端登录页面 + 退出按钮
  - 三层 CORS:localhost 鉴权 / 全放通 bookmarklet / 受保护路由
  - 书签脚本 fetch 添加 credentials:'include'

  Coordinator 模式 (P2)
  - 4 个 meta-tool (delegate_task/check_task/task_stop/synthesize)
  - WorkerPool + Semaphore 并发控制 + 超时保护
  - 前端协调者模式开关

  Hook 系统:UserPromptSubmit 事件 (P2)
  - 第 13 个生命周期事件,fire-and-forget 审计

  FTS5 全文搜索 (P3)
  - agent_sessions_fts + agent_messages_fts 虚拟表
  - search_history Agent 工具 + /api/search/history HTTP 接口
  - 前端防抖搜索框 + 仅当前会话筛选

  工具加载优化 (P3)
  - defer_loading 延迟加载 (7 个重型工具)
  - is_readonly 只读标记 (9 个查询工具)
  - classifier_summary 工具目录供 LLM 按需判断

  模型回退策略 (P3)
  - LLM_FALLBACK_MODEL 优先回退 + LLM_FALLBACK_CHAIN 链式轮换
  - LlmClient model 改为 Arc<RwLock> 支持运行时切换
  - 连续 3 次过载后自动切换

  压缩记忆桥接 (P3)
  - 压缩丢弃消息 → 子代理提取持久记忆 (extract_memories_from_compaction)

  git2 依赖修复
  - 切换到 vendored-libgit2,消除 OpenSSL 系统依赖
2026-06-23 20:22:06 +08:00

930 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Agent Hooks — 生命周期事件系统
参考 Claude Code hooks 协议,提供 **13 种生命周期事件回调**,基于 **观察者模式 + 责任链模式** 实现。核心目标:在 Agent ReAct 循环的各个关键节点插入横切关注点(取消检查、指标采集、审计日志、权限增强等),**不污染主循环代码**。
---
## 源码结构
```
src/agent/hooks/
├── mod.rs — 模块入口(声明 + 重导出 + 测试,~540 行)
├── types.rs — 数据类型定义(事件、上下文、动作、结果,~370 行)
├── traits.rs — AgentHook + AsyncAgentHook trait~120 行)
├── matcher.rs — ToolNamePattern / ToolMatchFilter 工具匹配器(~300 行)
├── registry.rs — HookRegistry 结构体 + 基础方法(~280 行)
├── dispatch.rs — HookRegistry 调度方法(所有 run_*~700 行)
└── builtins.rs — 内置 HooksCancellationHook、MetricsHook、AuditLogHook、ContextDeduplicator~260 行)
```
---
## 设计哲学
```mermaid
mindmap
root((Hook 系统<br/>设计原则))
接口隔离
11 个生命周期方法
+2 个权限专用方法 (on_permission_request/on_permission_denied)
全部有默认空实现
只覆写关心的 hook 点
事件索引
subscribed_events 声明
预计算 event_index
避免无关 hook 调用
工具级过滤
match_filter 声明
ToolNamePattern 匹配
content_patterns 过滤
并行优先
无依赖事件 join_all
有副作用事件顺序执行
OnSessionStop 例外
超时隔离
per-hook timeout 可配置
默认 30 秒
超时跳过不拖垮全部
聚合不丢
additional_contexts 全保留
tagged_contexts 有来源
blocking_errors 全收集
warnings/metadata 不丢失
双模式共存
全局 hooks 构造时注册
session hooks 运行时动态
AsyncAgentHook 异步 fire-and-forget
析构自动清理
```
---
## 架构总览
```mermaid
graph TB
subgraph RT["AgentRuntime::run_turn()"]
direction TB
Start["OnSessionStart"] --> Loop["ReAct Loop"]
Loop --> Comp["PreCompact / PostCompact"]
Loop --> Stop["OnSessionStop"]
end
subgraph Registry["HookRegistry"]
direction LR
Index["event_index<br/>HashMap&lt;HookEvent, Vec&lt;usize&gt;&gt;<br/>预计算索引"]
AIndex["async_event_index<br/>异步 hook 索引"]
SH["session_hooks<br/>HashMap&lt;String, Vec&lt;Box&lt;dyn AgentHook&gt;&gt;<br/>运行时动态管理"]
end
subgraph Executor["executor::execute_parallel()"]
direction LR
Pre["PreToolUse<br/>并行 join_all + timeout + match_filter"]
Exec["工具执行"]
Post["PostToolUse<br/>并行 join_all + timeout + match_filter"]
Fail["PostToolUseFailure<br/>is_error 时触发"]
Async["AsyncAgentHook<br/>fire-and-forget, 5s 超时"]
end
subgraph Builtins["内置 Hooks3 个 sync + 0 async"]
CH["CancellationHook<br/>订阅: PreToolUse, OnSessionStop"]
MH["MetricsHook<br/>订阅: OnSessionStart, PostToolUse,<br/>OnStepComplete, OnSessionStop"]
AH["AuditLogHook<br/>订阅: PostToolUse, OnSessionStop"]
end
RT --> Registry
Loop --> Executor
Registry --> Builtins
Executor --> Registry
Executor --> Async
```
---
## 类型系统
```mermaid
classDiagram
class AgentHook {
<<trait>>
+name() &str
+subscribed_events() &[HookEvent]
+timeout() Option~Duration~
+match_filter() ToolMatchFilter
+on_session_start(ctx)
+on_user_prompt_submit(ctx)
+pre_tool_use(ctx) PreToolUseAction
+post_tool_use(ctx) PostToolUseAction
+on_post_tool_use_failure(ctx) PostToolUseAction
+on_step_complete(ctx)
+on_session_stop(ctx)
+on_subagent_start(ctx)
+on_subagent_stop(ctx)
+on_pre_compact(ctx)
+on_post_compact(ctx)
+on_permission_request(ctx) PermissionRequestAction
+on_permission_denied(ctx)
}
class AsyncAgentHook {
<<trait>>
+name() &str
+subscribed_events() &[HookEvent]
+on_post_tool_use_async(ctx)
+on_post_tool_use_failure_async(ctx)
+on_session_stop_async(ctx)
}
class HookEvent {
<<enumeration>>
OnSessionStart
UserPromptSubmit
PreToolUse
PostToolUse
PostToolUseFailure
OnStepComplete
OnSessionStop
OnSubagentStart
OnSubagentStop
OnPreCompact
OnPostCompact
PermissionRequest
PermissionDenied
}
class ToolMatchFilter {
+name_patterns: Vec~ToolNamePattern~
+content_patterns: Vec~String~
+matches(tool_name, tool_args) bool
+exact(name) Self
+prefix(name) Self
+is_match_all() bool
}
class ToolNamePattern {
<<enumeration>>
Exact(String)
Prefix(String)
Wildcard
+parse(pattern) Self
+matches(tool_name) bool
}
class PreToolUseAction {
<<enumeration>>
Continue
Block ~reason: String~
MutateInput ~updated_args, additional_context~
PermissionRequired ~permission, tool_name~
}
class PermissionRequestAction {
<<enumeration>>
Continue
Override ~decision: PermissionDecision, reason~
InjectContext ~context: String~
}
class PermissionDecision {
<<enumeration>>
Allow
Ask
Deny
}
class PermissionDenialSource {
<<enumeration>>
Rule
Classifier
User
Timeout
}
class PostToolUseAction {
<<enumeration>>
Continue
MutateOutput ~updated_content, additional_context~
Warning ~message, truncate_output~
PermissionRequired ~permission, tool_name~
Metadata ~key, value~
}
class PreToolUseResult {
+action: PreToolUseAction
+additional_contexts: Vec~String~
+additional_context: Option~String~
+blocking_errors: Vec~BlockingError~
+final_args: Value
+tagged_contexts: Vec~TaggedContext~
}
class PostToolUseResult {
+final_content: String
+additional_contexts: Vec~String~
+tagged_contexts: Vec~TaggedContext~
+warnings: Vec~String~
+post_permission_requests: Vec~(String,String)~
+metadata: HashMap~String, Value~
}
class BlockingError {
+hook_name: String
+reason: String
}
class TaggedContext {
+hook_name: String
+source_event: HookEvent
+content: String
+timestamp: DateTime~Utc~
}
class HookRegistry {
-hooks: Vec~Box~dyn AgentHook~~
-event_index: HashMap
-session_hooks: HashMap
-async_hooks: Vec~Box~dyn AsyncAgentHook~~
-async_event_index: HashMap
+with_builtins(db, cancelled, metrics) Self
+add(hook)
+add_async(hook)
+add_session_hook(sid, hook)
+remove_session_hook(sid, name) bool
+clear_session_hooks(sid)
+run_pre_tool_use(ctx) PreToolUseResult
+run_post_tool_use(ctx) PostToolUseResult
+run_on_session_start(ctx)
+run_on_user_prompt_submit(ctx)
+run_on_step_complete(ctx)
+run_on_session_stop(ctx)
+run_on_subagent_start(ctx)
+run_on_subagent_stop(ctx)
+run_on_pre_compact(ctx)
+run_on_post_compact(ctx)
+run_on_post_tool_use_failure(ctx) PostToolUseResult
+run_on_permission_request(ctx) PermissionRequestAction
+run_on_permission_denied(ctx)
}
AgentHook --> HookEvent : subscribes to
AgentHook --> ToolMatchFilter : declares
AgentHook --> PreToolUseAction : returns
AgentHook --> PostToolUseAction : returns
AsyncAgentHook --> HookEvent : subscribes to
HookRegistry --> AgentHook : manages
HookRegistry --> AsyncAgentHook : manages
HookRegistry --> PreToolUseResult : aggregates
HookRegistry --> PostToolUseResult : aggregates
PreToolUseResult --> PreToolUseAction
PreToolUseResult --> BlockingError
PreToolUseResult --> TaggedContext
PostToolUseResult --> TaggedContext
```
### 上下文类型Context Objects
每个生命周期事件携带专用的不可变上下文:
| 事件 | 上下文类型 | 关键字段 |
|------|-----------|---------|
| OnSessionStart | `SessionStartContext` | session_id, turn_index, is_resume |
| UserPromptSubmit | `UserPromptSubmitContext` | session_id, prompt, turn_index (fire-and-forget) |
| PreToolUse | `PreToolUseContext` | session_id, tool_name, tool_args, step |
| PostToolUse | `PostToolUseContext` | session_id, agent_name, tool_name, tool_args, output_content, is_error, step, elapsed_ms |
| PostToolUseFailure | `PostToolUseFailureContext` | session_id, agent_name, tool_name, error_message, is_interrupt, step, elapsed_ms |
| OnStepComplete | `StepCompleteContext` | session_id, step, max_steps, messages_count, estimated_tokens, token_limit |
| OnSessionStop | `SessionStopContext` | session_id, terminal (TurnTerminal), total_steps |
| OnSubagentStart | `SubagentStartContext` | parent_session_id, subagent_name, prompt |
| OnSubagentStop | `SubagentStopContext` | parent_session_id, subagent_name, result_summary, steps, is_error |
| OnPreCompact | `PreCompactContext` | session_id, message_count, estimated_tokens |
| OnPostCompact | `PostCompactContext` | session_id, new_message_count, compression_method |
| PermissionRequest | `PermissionRequestContext` | session_id, tool_name, current_decision, permission_mode, is_subagent |
| PermissionDenied | `PermissionDeniedContext` | session_id, tool_name, reason, source (Rule/Classifier/User/Timeout) |
---
## ToolMatchFilter — 工具级过滤
比 Claude Code 的 `if` 条件更结构化。Hook 通过 `match_filter()` 返回过滤器HookRegistry 在 dispatch 时仅对匹配的工具名和参数调用该 hook。
### ToolNamePattern
| 模式 | 示例输入 | 匹配 |
|------|---------|------|
| `Exact("search_papers")` | `"search_papers"` | ✅ |
| `Exact("search_papers")` | `"download_paper"` | ❌ |
| `Prefix("file_")` | `"file_write"` | ✅ |
| `Prefix("file_")` | `"search_papers"` | ❌ |
| `Wildcard` | 任意 | ✅ |
### ToolMatchFilter
```rust
// 仅匹配文件相关工具且参数路径包含 .env
let filter = ToolMatchFilter {
name_patterns: vec![ToolNamePattern::parse("file_*")],
content_patterns: vec!["path:.env".to_string()],
};
```
- **快捷构造**`ToolMatchFilter::exact("search_papers")`、`ToolMatchFilter::prefix("file_")`
- **默认行为**`ToolMatchFilter::default()` 匹配所有工具
- **仅对** `PreToolUse`、`PostToolUse`、`PostToolUseFailure` 生效
---
## PostToolUseAction — 5 种变体
| 变体 | 用途 | 对执行的影响 |
|------|------|------------|
| `Continue` | 默认,保持输出不变 | 无 |
| `MutateOutput { updated_content, additional_context }` | 修改工具输出内容 | 最终输出替换为 updated_content |
| `Warning { message, truncate_output }` | 标记警告(不阻止) | 注入 `<system-reminder>` |
| `PermissionRequired { permission, tool_name }` | 事后权限请求advisory | 记录审计日志 |
| `Metadata { key, value }` | 附加结构化元数据 | 保留在 PostToolUseResult.metadata 中 |
---
## 异步 Hook (AsyncAgentHook)
`AgentHook` 分离设计,适合后台操作(远程上报、大文件处理等):
```rust
#[async_trait]
pub trait AsyncAgentHook: Send + Sync {
fn name(&self) -> &str;
fn subscribed_events(&self) -> &[HookEvent] { &[] }
async fn on_post_tool_use_async(&self, _ctx: PostToolUseContext) {}
async fn on_post_tool_use_failure_async(&self, _ctx: PostToolUseFailureContext) {}
async fn on_session_stop_async(&self, _ctx: SessionStopContext<'_>) {}
}
```
- **注册**`registry.add_async(Box::new(my_async_hook))`
- **调度**:与 sync hook 并行执行,默认 5s 超时
- **返回值**无——fire-and-forget 语义
---
## TaggedContext — Hook 来源追踪
注入 LLM 的 system-reminder 携带 hook 来源标记:
```
[Hook: AuditLogHook | PostToolUse] 内容...
[Hook: CancellationHook | PreToolUse] 内容...
```
替代原来的匿名 `[Hook 注入上下文]`
---
## ContextDeduplicator — 内容去重
单 dispatch cycle 内防止多个 hook 注入相同上下文消息:
```rust
let mut dedup = ContextDeduplicator::new();
for ctx in &result.hook_contexts {
if !dedup.is_duplicate(ctx) {
messages.push(ChatMessage::user(ctx));
}
}
```
---
## 权限决策优先级
当多个权限来源冲突时,`resolve_permission_precedence()` 按以下优先级裁决:
| Priority | Source | Overridable By |
|----------|--------|----------------|
| **P0** | `PermissionChecker::Deny` | 不可覆盖 |
| **P1** | Tool-level `PermissionRule::Deny` | 不可覆盖 |
| **P2** | Session-level Checker::Deny | 不可覆盖 |
| **P3** | Hook `PreToolUseAction::Block` | P0-P2 |
| **P4** | Hook `PermissionRequired` | P0-P3 |
| **P5** | Session-level Checker::Ask | 仅 Allow → Ask 升级 |
| **P6** | Tool-level `PermissionRule::Ask` | 仅 Allow → Ask 升级 |
| **P7** | `PermissionChecker::Allow` | — |
| **P8** | Implicit Allow (default) | — |
详见 [permission.md](./permission.md)。
---
## PermissionRequest / PermissionDenied — 权限决策钩子
这两个专用事件在权限决策管线中提供 Hook 级别的可编程控制点,参考 Claude Code 的 `PermissionRequest` / `PermissionDenied` 事件设计。
### PermissionRequest — 权限请求前
**触发时机**`resolve_permission_precedence()` 之后、最终权限决策生效之前。
**调度策略**:并行调用所有匹配的 hook**第一个返回 `Override` 的生效**(后续 hook 仍执行但结果忽略),其他 hook 返回 `Continue` 表示不干预。
```mermaid
flowchart LR
subgraph Request["run_on_permission_request()"]
direction LR
H1["Hook 1"] -->|"Override(Allow)"| Win["✅ 首个 Override 生效"]
H2["Hook 2"] -->|"Continue"| Ignore["被忽略"]
H3["Hook 3"] -->|"InjectContext"| Accumulate["累积上下文"]
end
```
**返回值语义**
| 动作 | 效果 |
|------|------|
| `PermissionRequestAction::Continue` | 不干预,沿用 `resolve_permission_precedence()` 的结果 |
| `PermissionRequestAction::Override { decision, reason }` | 覆盖决策:`Allow` 强制放行、`Ask` 升级确认、`Deny` 强制拒绝 |
| `PermissionRequestAction::InjectContext { context }` | 向 LLM 注入额外上下文(如安全策略解释) |
**上下文字段**
| 字段 | 类型 | 说明 |
|------|------|------|
| `session_id` | `String` | 当前会话 ID |
| `tool_name` | `String` | 被请求的工具名 |
| `tool_args` | `Value` | 工具参数(可检查路径、命令等) |
| `current_decision` | `PermissionDecision` | `resolve_permission_precedence()` 的裁决结果 |
| `permission_mode` | `String` | 当前权限模式default/acceptEdits/bypassPermissions/plan |
| `is_subagent` | `bool` | 是否在子代理上下文中 |
### PermissionDenied — 权限拒绝后(审计)
**触发时机**:权限决策结果为 `Deny`(无论来源)。
**调度策略**fire-and-forget 并行调用,不阻塞主循环,返回值忽略。设计用于审计日志、安全告警、拒绝统计。
**来源标记PermissionDenialSource**
| 来源 | 说明 |
|------|------|
| `Rule` | 被 `PermissionChecker` 或工具级规则拒绝 |
| `Classifier` | 被 Auto-mode Classifier 拒绝(待实现) |
| `User` | 用户在交互弹窗中手动拒绝 |
| `Timeout` | 权限请求超时自动拒绝 |
**使用示例**
```rust
struct DenyRateLimiter {
deny_counts: Arc<Mutex<HashMap<String, u32>>>,
}
#[async_trait]
impl AgentHook for DenyRateLimiter {
fn name(&self) -> &str { "DenyRateLimiter" }
fn subscribed_events(&self) -> &[HookEvent] {
&[HookEvent::PermissionRequest, HookEvent::PermissionDenied]
}
async fn on_permission_request(
&self,
ctx: &PermissionRequestContext,
) -> PermissionRequestAction {
let count = self.get_deny_count(&ctx.tool_name);
if count >= 3 {
// 连续拒绝 3 次后强制降级为 Ask不允许自动 Deny
return PermissionRequestAction::Override {
decision: PermissionDecision::Ask,
reason: format!("工具 {} 已被拒绝 {} 次,需要用户确认", ctx.tool_name, count),
};
}
PermissionRequestAction::Continue
}
async fn on_permission_denied(&self, ctx: &PermissionDeniedContext) {
// 记录拒绝统计
*self.deny_counts.lock().unwrap()
.entry(ctx.tool_name.clone()).or_default() += 1;
// 触发安全告警
eprintln!("[SECURITY] 工具 {} 被拒绝: {:?} (来源: {:?})",
ctx.tool_name, ctx.reason, ctx.source);
}
}
```
---
---
## 生命周期事件全景
```mermaid
sequenceDiagram
participant API as API Handler
participant RT as AgentRuntime
participant HR as HookRegistry
participant EX as Executor
participant SA as SubAgentRunner
participant CMP as Compact
Note over API,CMP: ═══ Phase 1: 会话启动 ═══
API->>RT: run_turn(question)
RT->>HR: with_builtins(db, cancelled, metrics)
RT->>HR: run_on_session_start(ctx) ⚡并行
Note over HR: ① OnSessionStart
RT->>HR: run_on_user_prompt_submit(ctx) 🔥fire-and-forget
Note over HR: ⑬ UserPromptSubmit
Note over API,CMP: ═══ Phase 2: ReAct 循环 ═══
loop 每步 (1..max_steps)
RT->>RT: LLM 流式调用 → tool_calls
RT->>EX: execute_parallel(calls, registry)
par 每个工具调用
EX->>HR: run_pre_tool_use(ctx) ⚡并行 + match_filter
Note over HR: ② PreToolUse
HR-->>EX: PreToolUseResult {final_args, tagged_contexts, blocks}
EX->>EX: 权限检查 (Checker + 工具级 + 会话级 + Hook)
EX->>EX: resolve_permission_precedence()
EX->>HR: run_on_permission_request(ctx) ⚡并行 (首个 Override 生效)
Note over HR: ⑪ PermissionRequest — Hook 可覆盖权限决策
alt 权限被拒绝
EX->>HR: run_on_permission_denied(ctx) ⚡并行 (fire-and-forget 审计)
Note over HR: ⑫ PermissionDenied
end
EX->>EX: 工具执行
EX->>HR: run_post_tool_use(ctx) ⚡并行 + match_filter
Note over HR: ③ PostToolUse + AsyncHook dispatch
HR-->>EX: PostToolUseResult {final_content, tagged_contexts, warnings, metadata}
alt is_error
EX->>HR: run_on_post_tool_use_failure(ctx) ⚡并行
Note over HR: ④ PostToolUseFailure + AsyncHook dispatch
end
end
EX-->>RT: ToolExecutionResult {messages, hook_contexts, blocking_errors}
RT->>RT: "hook_contexts → [system-reminder] 注入 (去重)"
RT->>HR: run_on_step_complete(ctx) ⚡并行
Note over HR: ⑤ OnStepComplete
opt 上下文超限
RT->>CMP: compress_context_with_hooks()
CMP->>HR: run_on_pre_compact(ctx) ⚡并行
Note over HR: ⑨ OnPreCompact
CMP->>CMP: compress_with_fallback()
CMP->>HR: run_on_post_compact(ctx) ⚡并行
Note over HR: ⑩ OnPostCompact
end
opt 子代理调用
RT->>SA: SubAgentRunner::run()
SA->>HR: run_on_subagent_start(ctx) ⚡并行
Note over HR: ⑦ OnSubagentStart
SA->>SA: 独立 ReAct 循环
Note over SA: PreToolUse / PostToolUse 同样触发
SA->>HR: run_on_subagent_stop(ctx) ⚡并行
Note over HR: ⑧ OnSubagentStop
SA-->>RT: ToolOutput (summary)
end
end
Note over API,CMP: ═══ Phase 3: 会话收尾 ═══
RT->>RT: finalize_turn()
RT->>HR: run_on_session_stop(ctx) 🔒顺序
Note over HR: ⑥ OnSessionStop
RT-->>API: SSE Done
```
**执行模式图例**:⚡ 并行 `join_all` | 🔒 顺序执行 | ⚡+⏱ 并行 + per-hook timeout
---
## HookRegistry 调度机制
### 事件索引 + 工具过滤
```mermaid
flowchart LR
subgraph Register["add(hook)"]
H["hook: Box&lt;dyn AgentHook&gt;"] --> Subs{"subscribed_events()"}
Subs -->|"空 = 全部"| All["遍历所有 13 个 HookEvent<br/>event_index[event].push(idx)"]
Subs -->|"指定"| Spec["仅注册声明的事件<br/>event_index[event].push(idx)"]
end
subgraph Dispatch["run_*_tool_use(ctx, session_id)"]
Collect["collect_tool_hooks_for(event, sid, tool_name, tool_args)"]
Collect --> GI["event_index[event] → 全局 hooks"]
Collect --> FI["match_filter().matches(tool_name, tool_args) → 过滤"]
Collect --> SI["session_hooks[sid] → session hooks (不过滤)"]
GI --> Merge["统一 Vec&lt;&amp;dyn AgentHook&gt;"]
FI --> Merge
SI --> Merge
end
Register --> Index["event_index: HashMap&lt;HookEvent, Vec&lt;usize&gt;&gt;"]
Dispatch --> Index
```
**关键**`collect_tool_hooks_for()` 在 `collect_hooks_for()` 基础上增加 `match_filter()` 过滤层。全局 hooks 按事件索引 + 工具匹配双重过滤session hooks 不过滤(保持全量响应)。
---
## 数据流Hook 结果如何影响 Agent 行为
```mermaid
flowchart TD
subgraph Executor["executor::execute_parallel()"]
PreHooks["PreToolUse hooks → PreToolUseResult"]
Perms["权限检查 4 层叠加<br/>Checker → 工具级规则 → 会话级规则 → resolve_permission_precedence"]
PermHooks["PermissionRequest hooks → PermissionRequestAction<br/>首个 Override 生效,可覆盖 Allow→Ask 升级或 Deny→Ask 降级"]
PermDenied["PermissionDenied hooks → fire-and-forget 审计<br/>Rule/Classifier/User/Timeout 四种来源标记"]
Tools["工具执行"]
PostHooks["PostToolUse hooks → PostToolUseResult"]
FailHooks["PostToolUseFailure hooks<br/>(仅 is_error"]
AsyncHooks["AsyncAgentHook dispatch<br/>fire-and-forget, 5s 超时)"]
end
subgraph ReactLoop["AgentRuntime::run_react_loop()"]
Push["tool_messages → messages"]
Inject["hook_contexts 包装为 [system-reminder]<br/>(带 TaggedContext 来源 + ContextDeduplicator 去重)"]
LogBlock["blocking_errors → warn! 日志"]
LogWarn["warnings → warn! 日志"]
LogPerm["post_permission_requests → info! 审计"]
StepHook["run_on_step_complete"]
end
PreHooks -->|"final_args 覆盖参数"| Perms
PreHooks -->|"PermissionRequired → Allow→Ask 升级"| Perms
Perms -->|"权限决策"| PermHooks
PermHooks -->|"Override 可覆盖 Allow/Deny/Ask"| Perms
Perms -->|"Deny: 触发"| PermDenied
PermDenied -->|"审计日志"| LogPerm
Perms -->|"Deny: 注入错误 result"| Push
Perms -->|"Allow/Ask/Approve: 进入执行"| Tools
Tools -->|"output"| PostHooks
PostHooks -->|"final_content 覆盖输出"| Push
PostHooks -->|"warnings, metadata, tagged_contexts"| Inject
Tools -->|"is_error"| FailHooks
FailHooks -->|"final_content 覆盖错误输出"| Push
PostHooks -.->|"并行 fire-and-forget"| AsyncHooks
FailHooks -.->|"并行 fire-and-forget"| AsyncHooks
Push --> Inject
Inject --> StepHook
LogBlock --> StepHook
LogWarn --> StepHook
LogPerm --> StepHook
```
### hook_contexts 注入格式
```rust
// 带来源标记TaggedContext
// runtime/mod.rs
let mut dedup = ContextDeduplicator::new();
for ctx in &exec_result.hook_contexts {
if dedup.is_duplicate(ctx) {
continue;
}
let reminder = format!(
"<system-reminder>\n[Hook 注入上下文]\n{}\n</system-reminder>",
ctx
);
messages.push(ChatMessage::user(&reminder));
}
```
内容格式:
```
[Hook: AuditLogHook | PostToolUse] 内容...
[Hook: CancellationHook | PreToolUse] 内容...
```
---
## 内置 Hooks 详解
### CancellationHook
```mermaid
stateDiagram-v2
[*] --> Active: 会话开始
Active --> CheckPreTool: 每次 PreToolUse
CheckPreTool --> Blocked: cancelled_runs 含 session_id
CheckPreTool --> Continue: cancelled_runs 不含 session_id
Blocked --> [*]: Block{reason: "用户已手动中止"}
Continue --> Active: 继续执行
Active --> Cleanup: OnSessionStop
Cleanup --> [*]: cancelled_runs.remove(session_id)
```
依赖 `Arc<Mutex<HashSet<String>>>`(与 `AppState.cancelled_runs` 共享引用)。
### MetricsHook
通过 `from_arc()` 复用 `AgentRuntime` 自身的 `metrics_data: Arc<Mutex<MetricsData>>`,确保 hook 内部采集的数据与 `AgentRuntime::get_metrics()` API 返回的是同一份数据。
订阅事件:`OnSessionStart`(设置 session_id、`PostToolUse`(累加计数)、`OnStepComplete`(每 3 步输出摘要)、`OnSessionStop`(输出终止原因)。
### AuditLogHook
```mermaid
sequenceDiagram
participant EX as Executor
participant AH as AuditLogHook
participant DB as SQLite
EX->>AH: post_tool_use(ctx)
AH->>AH: 构造 status = "OK" | "FAIL"
AH->>AH: output_preview = content[..200]
AH--)DB: "tokio::spawn INSERT agent_audit_log"
EX->>AH: on_session_stop(ctx)
AH--)DB: "tokio::spawn INSERT SESSION_STOP"
```
Fire-and-forget 写入,不阻塞主循环。未来可改为实现 `AsyncAgentHook`
---
## 扩展指南
### 自定义全局 Sync Hook
```rust
struct MyCustomHook;
#[async_trait]
impl AgentHook for MyCustomHook {
fn name(&self) -> &str { "MyCustomHook" }
// 声明只关心 PreToolUse 和 PostToolUseFailure
fn subscribed_events(&self) -> &[HookEvent] {
&[HookEvent::PreToolUse, HookEvent::PostToolUseFailure]
}
// 仅对文件工具生效
fn match_filter(&self) -> ToolMatchFilter {
ToolMatchFilter::prefix("file_")
}
fn timeout(&self) -> Option<Duration> {
Some(Duration::from_secs(10))
}
async fn pre_tool_use(&self, ctx: &PreToolUseContext) -> PreToolUseAction {
if ctx.tool_name == "run_bash" {
return PreToolUseAction::PermissionRequired {
permission: "执行系统命令需要二次确认".into(),
tool_name: ctx.tool_name.clone(),
};
}
PreToolUseAction::Continue
}
async fn post_tool_use(&self, ctx: &PostToolUseContext) -> PostToolUseAction {
if ctx.is_error {
PostToolUseAction::Warning {
message: format!("工具 {} 执行失败", ctx.tool_name),
truncate_output: false,
}
} else {
PostToolUseAction::Continue
}
}
async fn on_post_tool_use_failure(
&self,
ctx: &PostToolUseFailureContext,
) -> PostToolUseAction {
if ctx.is_interrupt {
return PostToolUseAction::Continue;
}
PostToolUseAction::MutateOutput {
updated_content: format!(
"[已由 MyCustomHook 处理] 工具 {} 执行失败: {}。建议尝试替代方案。",
ctx.tool_name, ctx.error_message
),
additional_context: None,
}
}
}
// 注册
registry.add(Box::new(MyCustomHook));
```
### 自定义 Async Hook
```rust
struct RemoteLogger;
#[async_trait]
impl AsyncAgentHook for RemoteLogger {
fn name(&self) -> &str { "RemoteLogger" }
async fn on_post_tool_use_async(&self, ctx: PostToolUseContext) {
// 异步上报工具调用日志到远程服务
tokio::spawn(async move {
upload_to_remote(ctx).await;
});
}
}
registry.add_async(Box::new(RemoteLogger));
```
### 动态 Session Hook
```rust
// 为当前会话临时添加
registry.add_session_hook(&session_id, Box::new(MyTempHook));
// 移除
registry.remove_session_hook(&session_id, "MyTempHook");
// 会话结束时自动清理HookRegistry 析构)
```
---
## 集成点地图
| 文件 | 集成点 | 方法 |
|------|--------|------|
| `runtime/mod.rs` | 会话启动 | `run_on_session_start()` |
| `runtime/mod.rs` | 用户输入提交后 | `run_on_user_prompt_submit()` (fire-and-forget) |
| `runtime/mod.rs` | ReAct 循环 | `run_on_step_complete()` |
| `runtime/mod.rs` | Hook 上下文注入 | `hook_contexts → [system-reminder]` |
| `runtime/mod.rs` | 会话终止 | `run_on_session_stop()` |
| `executor.rs` | 工具执行前 | `run_pre_tool_use()` |
| `executor.rs` | 工具执行后 | `run_post_tool_use()` |
| `executor.rs` | 工具执行失败 | `run_on_post_tool_use_failure()` |
| `executor.rs` | 权限决策前 | `run_on_permission_request()` |
| `executor.rs` | 权限拒绝后 | `run_on_permission_denied()` (fire-and-forget 审计) |
| `executor.rs` | 权限决策 | `resolve_permission_precedence()` |
| `subagent.rs` | 子代理启动 | `run_on_subagent_start()` |
| `subagent.rs` | 子代理停止 | `run_on_subagent_stop()` |
| `compact.rs` | 压缩前后 | `run_on_pre_compact()` / `run_on_post_compact()` |
---
## 配置
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `HOOK_TIMEOUT_SECS` | 30 | 全局 per-hook 超时(秒)。单个 hook 可通过 `timeout()` trait 方法覆盖 |
---
## 设计权衡
| 考量点 | 说明 |
|--------|------|
| **工具级过滤** | `match_filter()` 提供 name + content 双重过滤session hooks 不过滤 |
| **并行 vs 顺序** | 单 hook 自动走顺序路径(无 timeout 开销),多 hook 并行 + timeout |
| **聚合策略** | 最后覆盖 final_args/content全部保留 contexts/blocks/warnings/metadata |
| **OnSessionStop** | 顺序执行(关键清理),`&self` 限制需调用方手动 clear session hooks |
| **AsyncAgentHook** | 独立 traitfire-and-forget 语义5s dispatch 超时,与 sync hook 并行 |
| **TaggedContext** | 带 hook 名称 + 事件来源,格式化注入为 `[Hook: 名称 | 事件]` |
| **ContextDeduplicator** | 内容哈希去重,单 cycle 内有效,不跨 step 去重 |
| **Block 非全局中断** | `PreToolUseAction::Block` 只阻止当前工具的单个调用,全局中断由 `CancellationHook` 实现 |
| **Warning/Post-hoc Permission** | PostToolUse 的 Warning 和 PermissionRequired 均为 advisory不阻塞主循环 |
| **PermissionRequest 调度** | 并行调用所有 hook首个 `Override` 生效,后续 Continue 被忽略;`InjectContext` 全部累积 |
| **PermissionDenied 调度** | fire-and-forget 并行调用,不阻塞主循环,返回值忽略(纯审计用途) |
---
## 变更记录
| 日期 | 变更 |
|------|------|
| 2026-06-22 | Phase 1: 新增 ToolMatchFilter / TaggedContext / 工具级过滤 |
| 2026-06-22 | Phase 2: 新增 resolve_permission_precedence + PermissionChecker::explain |
| 2026-06-22 | Phase 3: PostToolUseAction 扩展 (Warning/PermissionRequired/Metadata) + ContextDeduplicator |
| 2026-06-22 | Phase 4: AsyncAgentHook trait + async hook 调度 |
| 2026-06-22 | 模块拆分: mod.rs(537) ← types/traits/matcher/registry/dispatch/builtins |
| 2026-06-22 | Phase 5: PermissionRequest/PermissionDenied 事件 — 权限决策钩子 + fire-and-forget 审计 |
| 2026-06-23 | Phase 6: UserPromptSubmit hook — 第 13 个生命周期事件在用户提交提示词后、context building 前触发fire-and-forgethooks 可以检查/记录用户输入 | 120 |
---
## 测试覆盖
| 测试 | 覆盖路径 |
|------|---------|
| `test_hook_registry_runs_all_hooks` | 注册表遍历调用 |
| `test_blocking_hook_stops_chain` | Block 收集到 blocking_errors |
| `test_mutate_input_accumulates_context` | 参数修改 + 上下文累积 |
| `test_post_tool_use_mutate_output` | 输出修改(含 additional_context |
| `test_cancellation_hook_blocks_when_cancelled` | CancellationHook Block 路径 |
| `test_cancellation_hook_allows_when_not_cancelled` | CancellationHook 放行路径 |
| `test_metrics_hook_accumulates_counts` | MetricsHook 累加正确性 |
| `test_session_start_hook_called` | OnSessionStart 触发 |
| `test_new_lifecycle_events_called` | OnSubagentStart/Stop, Pre/PostCompact 触发 |
| `test_match_filter_skips_irrelevant` | ToolMatchFilter 正确过滤无关工具 |
| `test_full_hook_pipeline_integration` | 全链路match_filter + TaggedContext + Warning + Metadata |
| `test_matcher_*` (12 个) | ToolNamePattern / ToolMatchFilter 匹配逻辑 |
| `test_precedence_*` (5 个) | 多源权限优先级裁决 |
| `test_context_deduplication` | ContextDeduplicator 去重 |
| `test_async_hook_dispatch` | AsyncAgentHook 并行调度 |
| `test_permission_request_override` | PermissionRequest — Override 覆盖 Allow→Ask 升级 |
| `test_permission_request_continue` | PermissionRequest — Continue 不干预默认决策 |
| `test_permission_denied_fire_and_forget` | PermissionDenied — fire-and-forget 审计调度 |
| `test_permission_denied_sources` | PermissionDenied — Rule/Classifier/User/Timeout 四种来源标记 |