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

33 KiB
Raw Permalink Blame History

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 行)

设计哲学

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
      析构自动清理

架构总览

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

类型系统

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

// 仅匹配文件相关工具且参数路径包含 .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() 匹配所有工具
  • 仅对 PreToolUsePostToolUsePostToolUseFailure 生效

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 分离设计,适合后台操作(远程上报、大文件处理等):

#[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 注入相同上下文消息:

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


PermissionRequest / PermissionDenied — 权限决策钩子

这两个专用事件在权限决策管线中提供 Hook 级别的可编程控制点,参考 Claude Code 的 PermissionRequest / PermissionDenied 事件设计。

PermissionRequest — 权限请求前

触发时机resolve_permission_precedence() 之后、最终权限决策生效之前。

调度策略:并行调用所有匹配的 hook第一个返回 Override 的生效(后续 hook 仍执行但结果忽略),其他 hook 返回 Continue 表示不干预。

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 权限请求超时自动拒绝

使用示例

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);
    }
}


生命周期事件全景

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 调度机制

事件索引 + 工具过滤

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 行为

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 注入格式

// 带来源标记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

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_idPostToolUse(累加计数)、OnStepComplete(每 3 步输出摘要)、OnSessionStop(输出终止原因)。

AuditLogHook

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

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

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

// 为当前会话临时添加
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 可以检查/记录用户输入

测试覆盖

测试 覆盖路径
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 四种来源标记