Files
AstroResearch/src/agent/hooks/mod.rs
T
fmq 698d007f39 feat: Agent 安全纵深防御、Checkpoint 快照、会话 Rewind/Branch、自进化
Skill、流式执行优化与系统架构全面升级

  本次提交对标 Claude Code 与 Hermes-Agent 的工程细节,在安全、可靠性、
  会话管理、自我进化四个维度进行了系统性加固,变更总量 48 文件 / +12680 -2292 行。

  ═══════ 安全纵深防御 ═══════

  1. Hardline 硬阻止层 (src/agent/runtime/hardline.rs, +534 行)
     - 不可绕过的危险命令拦截(关重启、磁盘擦除、Fork 炸弹、rm -rf /、kill -1)
     - 反规避标准化管线: ANSI 序列剥离 → Unicode NFKC → shell 反斜杠还原 → 空字面量清理
     - 在 PermissionChecker 之前执行,YOLO/Bypass 模式下同样生效
     - 集成到 executor Phase 2,被拒绝工具直接注入错误结果

  2. Permission 优先级裁决器 (src/agent/runtime/permission.rs, +200 行)
     - 7 层正式优先级规则 (P0 Deny → P7 Allow),带冲突日志
     - explain() 方法支持审计追溯
     - Hook PermissionRequired 与 Checker 结果的正确叠加逻辑

  ═══════ Checkpoint 文件快照系统 ═══════

  3. git2 原生快照 (src/agent/runtime/checkpoint.rs, +920 行)
     - 基于 git2 bare repo,内容寻址自动去重
     - 文件变更操作前自动触发 (file_write/file_edit/run_bash)
     - 每目录每 turn 最多一次快照,防止同一轮重复
     - 支持 list/diff/restore API + pre-rollback 安全快照
     - 旧快照自动 prune(保留最近 N 个)+ 按目录隔离 ref
     - 排除规则自动过滤 node_modules/target/.git/*.pdf 等
     - 集成到 executor: 文件操作前 ckpt.ensure_checkpoint()

  ═══════ 错误恢复系统大升级 ═══════

  4. 21 种 FailoverReason 分类 (src/agent/runtime/error_recovery.rs, +1200 行)
     - 参考 Hermes-Agent error_classifier.py
     - 8 步分类管线: provider-specific → HTTP status → text pattern → error body → fallback
     - is_retryable / should_compress / should_failover / is_permanent 方法
     - Context Overflow 自动修复: 从错误消息提取 token 限制,自动下调预算
     - RecoveryStep::AdjustMaxTokens 实现 (参考 Claude Code 自动修复)
     - 向后兼容 ErrorKind 别名

  ═══════ 会话 Rewind / Branch / Retry 体系 ═══════

  5. 完整 undo 栈 (src/agent/runtime/session.rs, +800 行 + 2 迁移脚本)
     - Rewind (软删除): active=0 标记,审计 trail 保留,LLM 不可见
     - Restore (撤销回退): 冲突检测——回退后有新消息则拒绝,引导使用 Branch
     - Branch: 分叉会话,复制所有 active=1 消息到新会话
     - Retry: 硬删除最后一轮对话,返回原消息文本供前端重提交
     - 数据库: agent_messages.active 列 + agent_sessions.rewind_count + parent_session_id
     - API: 4 个新端点 (/branch, /retry, /rewind, /rewind/restore)
     - load_history_for_agent 全面使用 active=1 过滤

  ═══════ Hooks 系统模块化重构 ═══════

  6. 单文件 → 7 模块体系 (src/agent/hooks/)
     hooks.rs (994 行) 拆分为:
     - mod.rs    — 入口 + HookRegistry + SessionHookManager
     - types.rs  — 类型定义 (Context, TaggedContext, PermissionRequestAction 等)
     - traits.rs — AgentHook + AsyncAgentHook + 15 种生命周期事件
     - matcher.rs — 工具名/参数匹配 + session 作用域过滤
     - dispatch.rs — 并行调度引擎 (run_pre/post_tool_use 等)
     - registry.rs — 注册/注销/查询
     - builtins.rs — CancellationHook + MetricsHook + AuditLogHook + ContextDeduplicator

     关键改进:
     - run_pre_tool_use 并行执行所有匹配 hooks,聚合 Block/MutateInput/Continue
     - TaggedContext 带完整来源标记的上下文注入 (hook_name + event)
     - ContextDeduplicator 单 dispatch cycle 内内容哈希去重
     - AsyncAgentHook 支持 fire-and-forget 异步 hooks

  ═══════ Executor 并发执行升级 ═══════

  7. 三阶段管道重写 (src/agent/runtime/executor.rs, +600 行)
     - Phase 1: 死循环检测 + 参数解析 (不变)
     - Phase 2: Hardline 预检查 (新增) → PermissionChecker (改进)
     - Phase 3: ToolPartitioner 分区 → 逐批次执行 (重写)
       - 并行批次内 FuturesUnordered 并发
       - 串行批次确保非并发安全工具独占执行
       - Checkpoint 预触发集成
     - Hook 上下文注入: system-reminder 格式 + ContextDeduplicator 去重
     - Hook 阻塞错误详细记录

  ═══════ 流式执行真正的流式调度 ═══════

  8. StreamingExecutor 重写 (src/agent/runtime/streaming_executor.rs, ~400 行变更)
     - on_tool_use 中对并发安全工具立即 tokio::spawn,不等待 flush
     - executing_non_concurrent 标志阻塞后继工具直到独占工具完成
     - JoinHandle 管理替代自定义 cancel channel
     - completed_queue 按流顺序 yield
     - Sibling Abort 通过 broadcast channel + tokio::select! 竞速
     - ToolContext 实现 Clone (支持 per-task 上下文复制)

  ═══════ 自改进 Skill 系统 ═══════

  9. PatternDetector + SkillCreator + Curator (src/agent/skills/, +1500 行)
     - PatternDetector: 扫描 agent_messages 表,检测跨 session 重复工具调用模式
     - SkillCreator: 将高置信度模式自动生成 SKILL.md (YAML frontmatter + 工作流步骤)
     - SelfImprovePipeline: 一站式 模式检测 → 创建 → 质量审查
     - Curator: 分析 skill 使用统计,标记 stale/deprecated,建议清理
     - Skill frontmatter 新增 pinned 字段 (禁止 Curator 自动清理)

  ═══════ 基础设施优化 ═══════

  10. 系统提示词缓存 (src/agent/runtime/system_prompt.rs + mod.rs)
      - SystemPromptCache: 首次计算后永久复用,/clear 时失效
      - 新增 SAFETY / SYSTEM_CONTEXT / TOOL_USAGE 静态 section
      - 环境/tools/skills/memory 动态 section 通过 get_or_compute 缓存

  11. ToolRegistry schema 缓存 (src/agent/tools/mod.rs)
      - schema_cache + schema_generation 版本号
      - 工具变更/过滤器变更时自动失效
      - precompute_definitions() 预计算 (AgentRuntime 初始化时调用)

  12. 迭代摘要融合 (src/agent/compact.rs, +100 行)
      - 参考 Hermes context_compressor.py
      - CollapseLog 追踪压缩历史,支持溢出合并
      - extract_prior_summary: 提取已有摘要融入新压缩

  13. SubAgent 系统提示词模块化 (src/agent/tools/subagent.rs)
      - 复用 5 个标准 section + 子代理专有上下文 section
      - 独立 ToolRegistry 构建工具列表

  ═══════ 前端 — CSS 变量主题系统 ═══════

  14. 全新主题变量体系 (dashboard/src/index.css + App.tsx + 各面板)
      - CSS 自定义属性: --bg-card, --text-main, --text-muted, --border-precision
      - 语义化颜色: --accent-blueprint, --accent-star
      - 全面替换硬编码 Tailwind 颜色 (slate-xxx → var(--xxx))
      - 文献入库提示优化 ("核心知识节点" 替代 "向量块")
      - ReaderPanel 样式变量化
2026-06-22 20:29:37 +08:00

550 lines
18 KiB
Rust
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.
// src/agent/hooks/mod.rs
//
// Agent 生命周期 Hooks 系统。
//
// 参考 Claude Code 的 PreToolUse / PostToolUse / Stop hooks 设计,
// 提供可扩展的事件回调链,支持 10 种生命周期事件 + 工具匹配过滤 +
// 权限决策优先级 + 异步 fire-and-forget hook。
//
// 子模块结构:
// - types.rs — 所有数据类型定义(HookEvent、Contexts、Actions、Results
// - traits.rs — AgentHook + AsyncAgentHook traits
// - matcher.rs — ToolNamePattern / ToolMatchFilter 工具匹配器
// - registry.rs — HookRegistry 结构体 + 基础方法
// - dispatch.rs — HookRegistry 调度方法(所有 run_*
// - builtins.rs — 内置 HooksCancellationHook、MetricsHook、AuditLogHook
pub mod builtins;
pub mod dispatch;
pub mod matcher;
pub mod registry;
pub mod traits;
pub mod types;
// ── 重导出:外部代码通过 `crate::agent::hooks::*` 访问 ──
// 核心注册表
pub use registry::HookRegistry;
// 类型
pub use types::{
event_label, BlockingError, HookAction, HookEvent, MetricsData, PermissionDecision,
PermissionDenialSource, PermissionDeniedContext, PermissionRequestAction,
PermissionRequestContext, PostCompactContext, PostToolUseAction, PostToolUseContext,
PostToolUseFailureContext, PostToolUseResult, PreCompactContext, PreToolUseAction,
PreToolUseContext, PreToolUseResult, SessionStartContext, SessionStopContext,
StepCompleteContext, SubagentStartContext, SubagentStopContext, TaggedContext,
DEFAULT_HOOK_TIMEOUT,
};
// Traits
pub use traits::{AgentHook, AsyncAgentHook};
// Matcher
pub use matcher::{ToolMatchFilter, ToolNamePattern};
// Builtins
pub use builtins::{AuditLogHook, CancellationHook, ContextDeduplicator, MetricsHook};
// ── 测试 ──
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use serde_json::{json, Value};
use sqlx::SqlitePool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
struct TestHook {
name: String,
pre_called: std::sync::Mutex<bool>,
}
impl TestHook {
fn new(name: &str) -> Self {
TestHook {
name: name.to_string(),
pre_called: std::sync::Mutex::new(false),
}
}
}
#[async_trait]
impl AgentHook for TestHook {
fn name(&self) -> &str {
&self.name
}
async fn pre_tool_use(&self, _ctx: &PreToolUseContext) -> PreToolUseAction {
*self.pre_called.lock().unwrap() = true;
PreToolUseAction::Continue
}
}
#[tokio::test]
async fn test_hook_registry_runs_all_hooks() {
let mut registry = HookRegistry::new();
let hook1 = TestHook::new("test1");
let hook2 = TestHook::new("test2");
registry.add(Box::new(hook1));
registry.add(Box::new(hook2));
let ctx = PreToolUseContext {
session_id: "test".into(),
tool_name: "test_tool".into(),
tool_args: serde_json::json!({}),
step: 1,
};
let result = registry.run_pre_tool_use(&ctx).await;
assert!(!result.action.is_blocked());
}
#[tokio::test]
async fn test_blocking_hook_stops_chain() {
struct BlockingHook;
#[async_trait]
impl AgentHook for BlockingHook {
fn name(&self) -> &str {
"blocker"
}
async fn pre_tool_use(&self, _ctx: &PreToolUseContext) -> PreToolUseAction {
PreToolUseAction::Block {
reason: "test block".into(),
}
}
}
let mut registry = HookRegistry::new();
registry.add(Box::new(BlockingHook));
let ctx = PreToolUseContext {
session_id: "test".into(),
tool_name: "test_tool".into(),
tool_args: serde_json::json!({}),
step: 1,
};
let result = registry.run_pre_tool_use(&ctx).await;
assert!(result.action.is_blocked());
assert_eq!(result.action.block_reason(), Some("test block"));
}
#[tokio::test]
async fn test_mutate_input_accumulates_context() {
struct MutateHook;
#[async_trait]
impl AgentHook for MutateHook {
fn name(&self) -> &str {
"mutator"
}
async fn pre_tool_use(&self, _ctx: &PreToolUseContext) -> PreToolUseAction {
PreToolUseAction::MutateInput {
updated_args: serde_json::json!({"key": "modified"}),
additional_context: Some("injected context".to_string()),
}
}
}
let mut registry = HookRegistry::new();
registry.add(Box::new(MutateHook));
let ctx = PreToolUseContext {
session_id: "test".into(),
tool_name: "test_tool".into(),
tool_args: serde_json::json!({"key": "original"}),
step: 1,
};
let result = registry.run_pre_tool_use(&ctx).await;
assert_eq!(result.final_args, serde_json::json!({"key": "modified"}));
assert_eq!(
result.additional_context,
Some("injected context".to_string())
);
}
#[tokio::test]
async fn test_post_tool_use_mutate_output() {
struct MutateOutputHook;
#[async_trait]
impl AgentHook for MutateOutputHook {
fn name(&self) -> &str {
"output_mutator"
}
async fn post_tool_use(&self, _ctx: &PostToolUseContext) -> PostToolUseAction {
PostToolUseAction::MutateOutput {
updated_content: "modified output".to_string(),
additional_context: None,
}
}
}
let mut registry = HookRegistry::new();
registry.add(Box::new(MutateOutputHook));
let ctx = PostToolUseContext {
session_id: "test".into(),
agent_name: "lead".into(),
tool_name: "test_tool".into(),
tool_args: serde_json::json!({}),
output_content: "original output".into(),
is_error: false,
step: 1,
elapsed_ms: 100,
};
let result = registry.run_post_tool_use(&ctx).await;
assert_eq!(result.final_content, "modified output");
}
#[tokio::test]
async fn test_cancellation_hook_blocks_when_cancelled() {
let mut cancelled = HashSet::new();
cancelled.insert("test_session".to_string());
let cancelled_runs = Arc::new(std::sync::Mutex::new(cancelled));
let hook = CancellationHook::new(cancelled_runs);
let ctx = PreToolUseContext {
session_id: "test_session".into(),
tool_name: "search_papers".into(),
tool_args: serde_json::json!({}),
step: 1,
};
let action = hook.pre_tool_use(&ctx).await;
assert!(action.is_blocked());
}
#[tokio::test]
async fn test_cancellation_hook_allows_when_not_cancelled() {
let cancelled_runs = Arc::new(std::sync::Mutex::new(std::collections::HashSet::new()));
let hook = CancellationHook::new(cancelled_runs);
let ctx = PreToolUseContext {
session_id: "test_session".into(),
tool_name: "search_papers".into(),
tool_args: serde_json::json!({}),
step: 1,
};
let action = hook.pre_tool_use(&ctx).await;
assert!(!action.is_blocked());
}
#[tokio::test]
async fn test_metrics_hook_accumulates_counts() {
let hook = MetricsHook::new();
let ctx = PostToolUseContext {
session_id: "test".into(),
agent_name: "lead".into(),
tool_name: "search_papers".into(),
tool_args: serde_json::json!({}),
output_content: "result".into(),
is_error: false,
step: 1,
elapsed_ms: 100,
};
hook.post_tool_use(&ctx).await;
let ctx2 = PostToolUseContext {
session_id: "test".into(),
agent_name: "lead".into(),
tool_name: "search_papers".into(),
tool_args: serde_json::json!({}),
output_content: "result2".into(),
is_error: false,
step: 2,
elapsed_ms: 200,
};
hook.post_tool_use(&ctx2).await;
let snapshot = hook.snapshot().expect("snapshot should succeed in test");
assert_eq!(snapshot.tool_call_counts.get("search_papers"), Some(&2));
assert_eq!(snapshot.total_steps, 2);
}
#[tokio::test]
async fn test_session_start_hook_called() {
struct StartTrackingHook {
started: std::sync::Mutex<Vec<String>>,
}
#[async_trait]
impl AgentHook for StartTrackingHook {
fn name(&self) -> &str {
"start_tracker"
}
async fn on_session_start(&self, ctx: &SessionStartContext) {
self.started.lock().unwrap().push(ctx.session_id.clone());
}
}
let hook = StartTrackingHook {
started: std::sync::Mutex::new(Vec::new()),
};
let mut registry = HookRegistry::new();
registry.add(Box::new(hook));
let ctx = SessionStartContext {
session_id: "test_sid".into(),
turn_index: 1,
is_resume: false,
};
registry.run_on_session_start(&ctx).await;
}
#[tokio::test]
async fn test_new_lifecycle_events_called() {
struct LifecycleTracker {
subagent_start: std::sync::Mutex<bool>,
subagent_stop: std::sync::Mutex<bool>,
pre_compact: std::sync::Mutex<bool>,
post_compact: std::sync::Mutex<bool>,
}
#[async_trait]
impl AgentHook for LifecycleTracker {
fn name(&self) -> &str {
"lifecycle_tracker"
}
async fn on_subagent_start(&self, _ctx: &SubagentStartContext) {
*self.subagent_start.lock().unwrap() = true;
}
async fn on_subagent_stop(&self, _ctx: &SubagentStopContext) {
*self.subagent_stop.lock().unwrap() = true;
}
async fn on_pre_compact(&self, _ctx: &PreCompactContext) {
*self.pre_compact.lock().unwrap() = true;
}
async fn on_post_compact(&self, _ctx: &PostCompactContext) {
*self.post_compact.lock().unwrap() = true;
}
}
let tracker = LifecycleTracker {
subagent_start: std::sync::Mutex::new(false),
subagent_stop: std::sync::Mutex::new(false),
pre_compact: std::sync::Mutex::new(false),
post_compact: std::sync::Mutex::new(false),
};
let mut registry = HookRegistry::new();
registry.add(Box::new(tracker));
registry
.run_on_subagent_start(&SubagentStartContext {
parent_session_id: "s1".into(),
subagent_name: "sub".into(),
prompt: "test".into(),
})
.await;
registry
.run_on_subagent_stop(&SubagentStopContext {
parent_session_id: "s1".into(),
subagent_name: "sub".into(),
result_summary: "done".into(),
steps: 3,
is_error: false,
})
.await;
registry
.run_on_pre_compact(&PreCompactContext {
session_id: "s1".into(),
message_count: 50,
estimated_tokens: 10000,
})
.await;
registry
.run_on_post_compact(&PostCompactContext {
session_id: "s1".into(),
new_message_count: 10,
compression_method: "micro".into(),
})
.await;
// If no panic, all hooks were called successfully
}
#[tokio::test]
async fn test_match_filter_skips_irrelevant() {
use std::sync::atomic::{AtomicUsize, Ordering};
struct CountedHook {
name: String,
call_count: Arc<AtomicUsize>,
filter: ToolMatchFilter,
}
#[async_trait]
impl AgentHook for CountedHook {
fn name(&self) -> &str {
&self.name
}
fn match_filter(&self) -> ToolMatchFilter {
self.filter.clone()
}
async fn pre_tool_use(&self, _ctx: &PreToolUseContext) -> PreToolUseAction {
self.call_count.fetch_add(1, Ordering::SeqCst);
PreToolUseAction::Continue
}
async fn post_tool_use(&self, _ctx: &PostToolUseContext) -> PostToolUseAction {
self.call_count.fetch_add(1, Ordering::SeqCst);
PostToolUseAction::Continue
}
}
let search_count = Arc::new(AtomicUsize::new(0));
let all_count = Arc::new(AtomicUsize::new(0));
let search_only = CountedHook {
name: "search_only".into(),
call_count: search_count.clone(),
filter: ToolMatchFilter::exact("search_papers"),
};
let all_match = CountedHook {
name: "all_match".into(),
call_count: all_count.clone(),
filter: ToolMatchFilter::default(),
};
let mut registry = HookRegistry::new();
registry.add(Box::new(search_only));
registry.add(Box::new(all_match));
let ctx = PreToolUseContext {
session_id: "test".into(),
tool_name: "search_papers".into(),
tool_args: serde_json::json!({}),
step: 1,
};
registry.run_pre_tool_use(&ctx).await;
assert_eq!(search_count.load(Ordering::SeqCst), 1);
assert_eq!(all_count.load(Ordering::SeqCst), 1);
let ctx2 = PreToolUseContext {
session_id: "test".into(),
tool_name: "download_paper".into(),
tool_args: serde_json::json!({}),
step: 2,
};
registry.run_pre_tool_use(&ctx2).await;
assert_eq!(search_count.load(Ordering::SeqCst), 1);
assert_eq!(all_count.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_full_hook_pipeline_integration() {
use std::sync::atomic::Ordering;
struct IntegrationHook {
name: String,
filter: ToolMatchFilter,
post_calls: Arc<std::sync::atomic::AtomicUsize>,
}
#[async_trait]
impl AgentHook for IntegrationHook {
fn name(&self) -> &str {
&self.name
}
fn match_filter(&self) -> ToolMatchFilter {
self.filter.clone()
}
async fn post_tool_use(&self, ctx: &PostToolUseContext) -> PostToolUseAction {
self.post_calls
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if self.name == "warn_hook" {
PostToolUseAction::Warning {
message: "test_warning".into(),
truncate_output: false,
}
} else if self.name == "meta_hook" {
PostToolUseAction::Metadata {
key: "origin".into(),
value: serde_json::Value::String("integration_test".into()),
}
} else {
PostToolUseAction::MutateOutput {
updated_content: format!("[{}] {}", self.name, ctx.output_content),
additional_context: Some(format!("context_from_{}", self.name)),
}
}
}
async fn pre_tool_use(&self, _ctx: &PreToolUseContext) -> PreToolUseAction {
PreToolUseAction::Continue
}
}
let mut registry = HookRegistry::new();
let file_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let search_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
registry.add(Box::new(IntegrationHook {
name: "file_hook".into(),
filter: ToolMatchFilter::prefix("file_"),
post_calls: file_count.clone(),
}));
registry.add(Box::new(IntegrationHook {
name: "search_hook".into(),
filter: ToolMatchFilter::exact("search_papers"),
post_calls: search_count.clone(),
}));
registry.add(Box::new(IntegrationHook {
name: "warn_hook".into(),
filter: ToolMatchFilter::default(),
post_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
}));
registry.add(Box::new(IntegrationHook {
name: "meta_hook".into(),
filter: ToolMatchFilter::default(),
post_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
}));
let ctx = PostToolUseContext {
session_id: "test".into(),
agent_name: "lead".into(),
tool_name: "file_write".into(),
tool_args: serde_json::json!({"path": "/tmp/test.txt"}),
output_content: "file content".into(),
is_error: false,
step: 1,
elapsed_ms: 100,
};
let result = registry.run_post_tool_use(&ctx).await;
assert_eq!(file_count.load(Ordering::SeqCst), 1);
assert_eq!(search_count.load(Ordering::SeqCst), 0);
assert!(result.final_content.contains("[file_hook]"));
assert!(!result.tagged_contexts.is_empty());
assert!(result
.tagged_contexts
.iter()
.any(|tc| tc.hook_name == "file_hook"));
assert!(result.warnings.contains(&"test_warning".to_string()));
assert_eq!(
result.metadata.get("origin").and_then(|v| v.as_str()),
Some("integration_test")
);
let ctx2 = PostToolUseContext {
session_id: "test".into(),
agent_name: "lead".into(),
tool_name: "search_papers".into(),
tool_args: serde_json::json!({"query": "quasars"}),
output_content: "search results".into(),
is_error: false,
step: 2,
elapsed_ms: 200,
};
let result2 = registry.run_post_tool_use(&ctx2).await;
assert_eq!(search_count.load(Ordering::SeqCst), 1);
assert_eq!(file_count.load(Ordering::SeqCst), 1);
assert!(result2.final_content.contains("[search_hook]"));
}
}