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 样式变量化
This commit is contained in:
fmq
2026-06-22 20:29:37 +08:00
parent f6df9d8136
commit 698d007f39
48 changed files with 12706 additions and 2318 deletions
Generated
+75
View File
@@ -141,6 +141,7 @@ dependencies = [
"dotenvy",
"flate2",
"futures-util",
"git2",
"glob",
"hmac 0.12.1",
"html2md",
@@ -159,6 +160,7 @@ dependencies = [
"sha1 0.10.6",
"sqlite-vec",
"sqlx",
"tempfile",
"thiserror 1.0.69",
"tokio",
"tower-http 0.5.2",
@@ -1456,6 +1458,21 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "git2"
version = "0.18.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "232e6a7bfe35766bf715e55a88b39a700596c0ccfd88cd3680b4cdb40d66ef70"
dependencies = [
"bitflags 2.13.0",
"libc",
"libgit2-sys",
"log",
"openssl-probe",
"openssl-sys",
"url",
]
[[package]]
name = "glob"
version = "0.3.3"
@@ -2066,6 +2083,20 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libgit2-sys"
version = "0.16.2+1.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee4126d8b4ee5c9d9ea891dd875cfdc1e9d0950437179104b183d7d8a74d24e8"
dependencies = [
"cc",
"libc",
"libssh2-sys",
"libz-sys",
"openssl-sys",
"pkg-config",
]
[[package]]
name = "libloading"
version = "0.8.9"
@@ -2105,6 +2136,32 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "libssh2-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9"
dependencies = [
"cc",
"libc",
"libz-sys",
"openssl-sys",
"pkg-config",
"vcpkg",
]
[[package]]
name = "libz-sys"
version = "1.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
@@ -2536,6 +2593,24 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "outref"
version = "0.5.2"
+2
View File
@@ -55,9 +55,11 @@ async-trait = "0.1"
async-stream = "0.3"
serde_yaml = "0.9"
notify = { version = "6", default-features = false, features = ["macos_kqueue"] }
tempfile = "3"
glob = "0.3"
walkdir = "2"
lru = "0.12"
git2 = "0.18"
[features]
default = []
+57 -57
View File
@@ -337,7 +337,7 @@ export default function App() {
}
};
// 5.5. 对文献进行向量化分块入库 (独立任务)
// 5.5. 对文献进行知识入库 (独立任务)
const handleVectorize = async (bibcode: string) => {
setVectorizing(true);
try {
@@ -347,10 +347,10 @@ export default function App() {
if (selectedPaper?.bibcode === bibcode) {
setSelectedPaper(prev => prev ? { ...prev, has_vector: true } : null);
}
showAlert(`文献向量化分块入库成功,共切片并录入 ${res.data.chunk_count}向量块`, '向量化成功');
showAlert(`文献已成功录入馆藏智能库,共提炼并录入 ${res.data.chunk_count}核心知识节点`, '知识入库成功');
} catch (e) {
console.error('文献向量化失败', e);
showAlert('向量化失败,请检查 .env 中的 Embedding API 配置。', '向量化失败');
console.error('知识入库失败', e);
showAlert('知识入库失败,请检查 .env 中的 Embedding API 配置。', '知识入库失败');
} finally {
setVectorizing(false);
}
@@ -698,11 +698,11 @@ export default function App() {
>
<div
onClick={(e) => e.stopPropagation()}
className="bg-white rounded-xl border border-slate-200 shadow-xl max-w-sm w-full p-6 space-y-4"
className="bg-[var(--bg-card)] rounded-xl border border-[var(--border-precision)] shadow-md max-w-sm w-full p-6 space-y-4"
>
<div className="flex items-center justify-between border-b border-slate-100 pb-2.5">
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${dialog.type === 'confirm' ? 'bg-sky-500' : 'bg-red-500'} animate-pulse`} />
<h3 className="text-xs font-bold text-[var(--text-main)] flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${dialog.type === 'confirm' ? 'bg-[var(--accent-blueprint)]' : 'bg-[#ef4444]'} animate-pulse`} />
<span>{dialog.title}</span>
</h3>
<button
@@ -719,7 +719,7 @@ export default function App() {
</button>
</div>
<div className="space-y-2">
<p className="text-xs text-slate-705 leading-relaxed">{dialog.message}</p>
<p className="text-xs text-[var(--text-muted)] leading-relaxed">{dialog.message}</p>
</div>
<div className="flex gap-2 pt-2">
<button
@@ -737,7 +737,7 @@ export default function App() {
if (dialog.onCancel) dialog.onCancel();
setDialog(null);
}}
className="px-4 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg text-[11px] font-bold text-center transition-all cursor-pointer"
className="px-4 btn-console btn-console-secondary py-2 rounded-lg text-[11px] font-bold text-center cursor-pointer"
>
</button>
@@ -755,11 +755,11 @@ export default function App() {
>
<div
onClick={(e) => e.stopPropagation()}
className="bg-white rounded-xl border border-slate-200 shadow-xl max-w-sm w-full p-6 space-y-4"
className="bg-[var(--bg-card)] rounded-xl border border-[var(--border-precision)] shadow-md max-w-sm w-full p-6 space-y-4"
>
<div className="flex items-center justify-between border-b border-slate-100 pb-2.5">
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
<h3 className="text-xs font-bold text-[var(--text-main)] flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-[var(--accent-star)] animate-pulse" />
<span></span>
</h3>
<button
@@ -773,10 +773,10 @@ export default function App() {
</button>
</div>
<div className="space-y-2">
<p className="text-xs text-slate-700 leading-relaxed">
<span className="font-mono bg-slate-100 px-1.5 py-0.5 rounded text-sky-700 font-bold select-all">{uncachedBibcode}</span>
<p className="text-xs text-[var(--text-main)] leading-relaxed">
<span className="font-mono bg-slate-100 px-1.5 py-0.5 rounded text-[var(--accent-blueprint)] font-bold select-all">{uncachedBibcode}</span>
</p>
<p className="text-[11px] text-slate-400 leading-relaxed">
<p className="text-[11px] text-[var(--text-muted)] leading-relaxed">
线 NASA ADS
</p>
</div>
@@ -795,13 +795,13 @@ export default function App() {
axios.post('/api/active_bibcode', { bibcode: uncachedBibcode }).catch(() => {});
window.open(`https://ui.adsabs.harvard.edu/abs/${uncachedBibcode}/abstract`, '_blank');
}}
className="flex-1 bg-white hover:bg-slate-50 text-slate-700 border border-slate-250 py-2 rounded-lg text-[11px] font-bold text-center transition-all shadow-sm cursor-pointer"
className="flex-1 btn-console btn-console-secondary py-2 rounded-lg text-[11px] font-bold text-center cursor-pointer"
>
ADS
</button>
<button
onClick={() => setUncachedBibcode(null)}
className="px-3 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg text-xs font-medium text-center transition-all cursor-pointer"
className="px-3 btn-console btn-console-secondary py-2 rounded-lg text-[11px] font-bold text-center cursor-pointer"
>
</button>
@@ -818,13 +818,13 @@ export default function App() {
>
<div
onClick={(e) => e.stopPropagation()}
className="bg-white rounded-xl border border-slate-200 shadow-xl max-w-lg w-full p-6 space-y-4 cursor-default animate-fade-in"
className="bg-[var(--bg-card)] rounded-xl border border-[var(--border-precision)] shadow-md max-w-lg w-full p-6 space-y-4 cursor-default animate-fade-in"
>
{/* 标题 & 关闭 */}
<div className="flex items-start justify-between border-b border-slate-100 pb-3">
<div className="space-y-1 pr-4">
<span className="text-[10px] font-bold text-sky-700 uppercase tracking-wider"></span>
<h3 className="text-xs font-bold text-slate-900 leading-snug">
<span className="text-[10px] font-bold text-[var(--accent-blueprint)] uppercase tracking-wider"></span>
<h3 className="text-xs font-bold text-[var(--text-main)] leading-snug">
{getDoctypeBadge(detailPaper.doctype)}
<span className="align-middle">{detailPaper.title}</span>
</h3>
@@ -844,48 +844,48 @@ export default function App() {
<div className="space-y-4 h-[460px] flex flex-col overflow-y-auto pr-1 text-xs scrollbar-thin">
{/* 作者 */}
<div className="space-y-1 h-12 overflow-y-auto scrollbar-thin shrink-0">
<span className="text-slate-450 font-bold"></span>
<p className="text-slate-800 leading-relaxed font-semibold">{detailPaper.authors.join(', ')}</p>
<span className="text-[var(--text-muted)] font-bold"></span>
<p className="text-[var(--text-main)] leading-relaxed font-semibold">{detailPaper.authors.join(', ')}</p>
</div>
{/* 期刊 & 年份 */}
<div className="grid grid-cols-5 gap-4 border-y border-slate-100 py-2.5 shrink-0 h-14">
<div className="space-y-0.5 flex flex-col justify-center min-w-0 col-span-4">
<span className="text-slate-450 font-bold block text-[10px] leading-tight"></span>
<span className="text-[var(--text-muted)] font-bold block text-[10px] leading-tight"></span>
<span
className="text-slate-800 font-bold italic truncate block text-[11px] leading-tight"
className="text-[var(--text-main)] font-bold italic truncate block text-[11px] leading-tight"
title={detailPaper.pub_journal || '未标注'}
>
{detailPaper.pub_journal || '未标注'}
</span>
</div>
<div className="space-y-0.5 flex flex-col justify-center col-span-1">
<span className="text-slate-450 font-bold block text-[10px] leading-tight"></span>
<span className="text-slate-850 font-extrabold block text-[11px] leading-tight">{detailPaper.year}</span>
<span className="text-[var(--text-muted)] font-bold block text-[10px] leading-tight"></span>
<span className="text-[var(--text-main)] font-extrabold block text-[11px] leading-tight">{detailPaper.year}</span>
</div>
</div>
{/* 摘要 */}
<div className="space-y-1.5 flex flex-col h-40 shrink-0">
<span className="text-slate-450 font-bold block"></span>
<p className="text-slate-700 leading-relaxed font-normal bg-slate-50 p-3 flex-1 rounded-lg border border-slate-200 text-justify overflow-y-auto scrollbar-thin select-text">
<span className="text-[var(--text-muted)] font-bold block"></span>
<p className="text-[var(--text-main)] leading-relaxed font-normal bg-[#f8fafc] p-3 flex-1 rounded-lg border border-[var(--border-precision)] text-justify overflow-y-auto scrollbar-thin select-text">
{detailPaper.abstract_text || '该文献暂无摘要数据。'}
</p>
</div>
{/* 关键字 */}
<div className="space-y-1.5 flex flex-col h-16 shrink-0">
<span className="text-slate-450 font-bold block"></span>
<span className="text-[var(--text-muted)] font-bold block"></span>
{detailPaper.keywords && detailPaper.keywords.length > 0 ? (
<div className="flex flex-wrap gap-1.5 flex-1 content-start items-start overflow-y-auto scrollbar-thin pr-1">
{detailPaper.keywords.map(kw => (
<span key={kw} className="px-2 py-0.5 rounded bg-slate-100 border border-slate-200 text-slate-600 font-bold text-[9px]">
<span key={kw} className="px-2 py-0.5 rounded bg-[#f1f5f9] border border-[var(--border-precision)] text-[var(--text-muted)] font-bold text-[9px]">
{kw}
</span>
))}
</div>
) : (
<div className="flex items-center justify-center flex-1 bg-slate-50 border border-slate-200 rounded-lg text-[10px] text-slate-400 font-bold select-none">
<div className="flex items-center justify-center flex-1 bg-[#f8fafc] border border-[var(--border-precision)] rounded-lg text-[10px] text-[var(--text-muted)] font-bold select-none">
</div>
)}
@@ -893,48 +893,48 @@ export default function App() {
{/* 标识符 */}
<div className="border-t border-slate-100 pt-3 grid grid-cols-1 sm:grid-cols-3 gap-2 text-[10px] font-mono mt-auto shrink-0">
<div className="bg-slate-50 px-2.5 py-1.5 rounded border border-slate-150">
<span className="text-slate-400 font-bold block">BIBCODE</span>
<span className="text-slate-700 font-semibold select-all truncate block" title={detailPaper.bibcode === detailPaper.arxiv_id ? '暂无' : detailPaper.bibcode}>
<div className="bg-[#f8fafc] px-2.5 py-1.5 rounded border border-[var(--border-precision)]">
<span className="text-[var(--text-muted)] font-bold block">BIBCODE</span>
<span className="text-[var(--text-main)] font-semibold select-all truncate block" title={detailPaper.bibcode === detailPaper.arxiv_id ? '暂无' : detailPaper.bibcode}>
{detailPaper.bibcode === detailPaper.arxiv_id ? '暂无' : (
<a
href={`https://ui.adsabs.harvard.edu/abs/${detailPaper.bibcode}/abstract`}
target="_blank"
rel="noreferrer"
onClick={() => axios.post('/api/active_bibcode', { bibcode: detailPaper.bibcode }).catch(() => {})}
className="hover:underline text-sky-600"
className="text-[var(--accent-blueprint)] hover:underline"
>
{detailPaper.bibcode}
</a>
)}
</span>
</div>
<div className="bg-slate-50 px-2.5 py-1.5 rounded border border-slate-150">
<span className="text-slate-400 font-bold block">DOI</span>
<span className="text-slate-700 font-semibold select-all truncate block" title={detailPaper.doi || '无'}>
<div className="bg-[#f8fafc] px-2.5 py-1.5 rounded border border-[var(--border-precision)]">
<span className="text-[var(--text-muted)] font-bold block">DOI</span>
<span className="text-[var(--text-main)] font-semibold select-all truncate block" title={detailPaper.doi || '无'}>
{detailPaper.doi ? (
<a
href={`https://doi.org/${detailPaper.doi}`}
target="_blank"
rel="noreferrer"
onClick={() => axios.post('/api/active_bibcode', { bibcode: detailPaper.bibcode }).catch(() => {})}
className="hover:underline text-sky-600"
className="text-[var(--accent-blueprint)] hover:underline"
>
{detailPaper.doi}
</a>
) : '无'}
</span>
</div>
<div className="bg-slate-50 px-2.5 py-1.5 rounded border border-slate-150">
<span className="text-slate-450 font-bold block">ARXIV ID</span>
<span className="text-slate-700 font-semibold select-all truncate block" title={detailPaper.arxiv_id || '无'}>
<div className="bg-[#f8fafc] px-2.5 py-1.5 rounded border border-[var(--border-precision)]">
<span className="text-[var(--text-muted)] font-bold block">ARXIV ID</span>
<span className="text-[var(--text-main)] font-semibold select-all truncate block" title={detailPaper.arxiv_id || '无'}>
{detailPaper.arxiv_id ? (
<a
href={`https://arxiv.org/abs/${detailPaper.arxiv_id}`}
target="_blank"
rel="noreferrer"
onClick={() => axios.post('/api/active_bibcode', { bibcode: detailPaper.bibcode }).catch(() => {})}
className="hover:underline text-sky-600"
className="text-[var(--accent-blueprint)] hover:underline"
>
{detailPaper.arxiv_id}
</a>
@@ -946,14 +946,14 @@ export default function App() {
{/* 手动上传文件(应对防爬阻断) */}
<div className="border-t border-slate-100 pt-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-slate-450 font-bold block">线</span>
<span className="text-[var(--text-muted)] font-bold block">线</span>
<span className="text-[9px] text-amber-700 font-bold bg-amber-50 px-2 py-0.5 rounded border border-amber-200">/</span>
</div>
<p className="text-[10px] text-slate-450 leading-relaxed">
<p className="text-[10px] text-[var(--text-muted)] leading-relaxed">
PDF HTML
</p>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col items-center justify-center border border-dashed border-slate-300 rounded-lg p-2 hover:bg-slate-50 transition-colors relative cursor-pointer group min-h-[50px]">
<div className="flex flex-col items-center justify-center border border-dashed border-[var(--border-precision)] rounded-lg p-2 hover:bg-[#f8fafc] transition-colors relative cursor-pointer group min-h-[50px]">
<input
type="file"
accept="application/pdf"
@@ -969,13 +969,13 @@ export default function App() {
</span>
)}
<span className="text-[10px] font-bold text-sky-600 group-hover:underline">
<span className="text-[10px] font-bold text-[var(--accent-blueprint)] group-hover:underline">
{uploadingBibcode === detailPaper.bibcode ? '上传中...' : '上传 PDF 文献'}
</span>
<span className="text-[8px] text-slate-400"> .pdf </span>
<span className="text-[8px] text-[var(--text-muted)]"> .pdf </span>
</div>
<div className="flex flex-col items-center justify-center border border-dashed border-slate-300 rounded-lg p-2 hover:bg-slate-50 transition-colors relative cursor-pointer group min-h-[50px]">
<div className="flex flex-col items-center justify-center border border-dashed border-[var(--border-precision)] rounded-lg p-2 hover:bg-[#f8fafc] transition-colors relative cursor-pointer group min-h-[50px]">
<input
type="file"
accept="text/html,.html"
@@ -991,10 +991,10 @@ export default function App() {
</span>
)}
<span className="text-[10px] font-bold text-sky-600 group-hover:underline">
<span className="text-[10px] font-bold text-[var(--accent-blueprint)] group-hover:underline">
{uploadingBibcode === detailPaper.bibcode ? '上传中...' : '上传 HTML 文献'}
</span>
<span className="text-[8px] text-slate-400"> .html </span>
<span className="text-[8px] text-[var(--text-muted)]"> .html </span>
</div>
</div>
@@ -1004,7 +1004,7 @@ export default function App() {
<button
type="button"
onClick={() => handleMarkNoResource(detailPaper.bibcode, true)}
className="w-full py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-lg text-[10px] font-bold transition-all border border-slate-250 cursor-pointer flex items-center justify-center gap-1.5"
className="btn-console btn-console-secondary w-full py-2 rounded-lg text-[10px] font-bold transition-all cursor-pointer flex items-center justify-center gap-1.5"
>
<RefreshCw className="w-3 h-3" /> ()
</button>
@@ -1012,9 +1012,9 @@ export default function App() {
<button
type="button"
onClick={() => handleMarkNoResource(detailPaper.bibcode, false)}
className="w-full py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-lg text-[10px] font-bold transition-all border border-slate-250 cursor-pointer flex items-center justify-center gap-1.5"
className="btn-console btn-console-secondary w-full py-2 rounded-lg text-[10px] font-bold transition-all cursor-pointer flex items-center justify-center gap-1.5"
>
<AlertTriangle className="w-3 h-3 text-amber-500" /> ()
<AlertTriangle className="w-3 h-3 text-[var(--accent-star)]" /> ()
</button>
)}
</div>
@@ -1080,7 +1080,7 @@ export default function App() {
setActiveTab('citation');
loadCitations(detailPaper.bibcode);
}}
className="flex-1 bg-white hover:bg-slate-50 text-slate-700 border border-slate-250 py-2.5 rounded-lg text-xs font-bold text-center transition-all cursor-pointer shadow-sm"
className="flex-1 btn-console btn-console-secondary py-2.5 rounded-lg text-xs font-bold text-center cursor-pointer"
>
</button>
@@ -1091,7 +1091,7 @@ export default function App() {
}, '确认重新下载');
}}
disabled={downloadingBibcodes[detailPaper.bibcode]}
className="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 text-amber-700 rounded-lg text-xs font-bold transition-all cursor-pointer disabled:opacity-50"
className="px-4 btn-console btn-console-secondary text-[var(--accent-star)] py-2.5 rounded-lg text-xs font-bold transition-all cursor-pointer disabled:opacity-50"
>
{downloadingBibcodes[detailPaper.bibcode] ? '重下中...' : '重新下载'}
</button>
@@ -1124,7 +1124,7 @@ export default function App() {
setActiveTab('citation');
loadCitations(detailPaper.bibcode);
}}
className="px-6 bg-white hover:bg-slate-50 text-slate-700 border border-slate-250 py-2.5 rounded-lg text-xs font-bold text-center transition-all cursor-pointer shadow-sm"
className="px-6 btn-console btn-console-secondary py-2.5 rounded-lg text-xs font-bold text-center cursor-pointer"
>
</button>
@@ -11,7 +11,8 @@ import 'katex/dist/katex.min.css';
import {
Brain, Settings, Eye, CheckCircle2, AlertTriangle,
Send, Loader, Plus, Trash2, Compass, Clock, Square,
BarChart3, ScrollText, Network
BarChart3, ScrollText, Network, Rewind, RotateCcw,
GitBranch, RefreshCw
} from 'lucide-react';
import { AskUserQuestionCard } from './AskUserQuestionCard';
import { PermissionRequestCard } from './PermissionRequestCard';
@@ -76,6 +77,7 @@ interface ActiveTurn {
interface ProcessedTurn {
turn_index: number;
question: string;
questionMessageId?: number;
timeline: TimelineItem[];
usage?: {
prompt_tokens: number;
@@ -85,6 +87,26 @@ interface ProcessedTurn {
createdAt: string;
}
interface RewindResult {
rewound_count: number;
target_preview: string;
new_turn_index: number;
session_id: string;
}
interface BranchResult {
branch_session_id: string;
forked_at_message_id: number;
copied_count: number;
}
interface RetryResult {
retried_message: string;
new_turn_index: number;
deleted_count: number;
session_id: string;
}
const safeSchema = {
...defaultSchema,
attributes: {
@@ -119,9 +141,9 @@ function getToolDisplayName(name: string): string {
case 'download_paper': return '下载文献全文资源';
case 'parse_paper': return '结构化解析文献内容';
case 'get_paper_content': return '获取文献全文内容';
case 'rag_search': return '语义库检索 (RAG)';
case 'query_target': return '查询 CDS Sesame 天体物理参数';
case 'save_note': return '保存研究笔记';
case 'rag_search': return '检索馆藏知识库';
case 'query_target': return '查询天体物理参数 (CDS)';
case 'save_note': return '保存文献手札';
// Agent 控制工具
case 'todo_write': return '管理任务列表';
case 'compress_context': return '压缩上下文窗口';
@@ -325,6 +347,156 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
}
};
// 回退会话到指定消息
const handleRewind = async (messageId?: number, n?: number) => {
if (!currentSessionId) return;
const msg = messageId
? '确定要回退到此消息之前吗?此后的对话将被移除(可通过“恢复”按钮撤销)。'
: `确定要回退最近 ${n || 1} 个对话轮次吗?`;
const performRewind = async () => {
try {
const body: { n?: number; message_id?: number } = {};
if (messageId) body.message_id = messageId;
else body.n = n || 1;
const res = await axios.post<RewindResult>(
`/api/chat/sessions/${currentSessionId}/rewind`,
body
);
if (showAlert) {
showAlert(`已回退 ${res.data.rewound_count} 条消息`, '成功');
} else {
alert(`已回退 ${res.data.rewound_count} 条消息`);
}
// 重新加载会话(静默刷新,避免闪烁)
loadSessionHistory(currentSessionId, true);
fetchSessions(); // 更新侧栏 turn_count
} catch (e: any) {
const errMsg = `回退失败: ${e.response?.data || e.message}`;
if (showAlert) {
showAlert(errMsg, '错误');
} else {
alert(errMsg);
}
}
};
if (showConfirm) {
showConfirm(msg, performRewind, '确认回退');
} else {
if (window.confirm(msg)) {
performRewind();
}
}
};
// 恢复上次回退(undo-of-undo
const handleRestoreRewind = async () => {
if (!currentSessionId) return;
try {
const res = await axios.post<{ restored_count: number }>(
`/api/chat/sessions/${currentSessionId}/rewind/restore`
);
if (res.data.restored_count > 0) {
const msg = `已恢复 ${res.data.restored_count} 条被回退的消息`;
if (showAlert) {
showAlert(msg, '成功');
} else {
alert(msg);
}
loadSessionHistory(currentSessionId, true);
fetchSessions();
} else {
const msg = '没有可恢复的回退操作';
if (showAlert) {
showAlert(msg, '提示');
} else {
alert(msg);
}
}
} catch (e: any) {
const errMsg = `恢复失败: ${e.response?.data || e.message}`;
if (showAlert) {
showAlert(errMsg, '错误');
} else {
alert(errMsg);
}
}
};
// 分叉当前会话
const handleBranch = async () => {
if (!currentSessionId) return;
const confirmed = window.confirm('确定要将当前活跃的消息分叉到一个新会话吗?');
if (!confirmed) return;
try {
const res = await axios.post<BranchResult>(
`/api/chat/sessions/${currentSessionId}/branch`
);
if (showAlert) {
showAlert(`成功分叉会话!已复制 ${res.data.copied_count} 条消息`, '成功');
} else {
alert(`成功分叉会话!已复制 ${res.data.copied_count} 条消息`);
}
// 刷新列表并选中新的分叉会话
await fetchSessions();
setCurrentSessionId(res.data.branch_session_id);
} catch (e: any) {
const errMsg = `分叉失败: ${e.response?.data || e.message}`;
if (showAlert) {
showAlert(errMsg, '错误');
} else {
alert(errMsg);
}
}
};
// 重试最后一轮
const handleRetry = async () => {
if (!currentSessionId || streaming) return;
const confirmed = window.confirm('确定要重试最后一轮对话吗?原回答将被删除。');
if (!confirmed) return;
try {
const res = await axios.post<RetryResult>(
`/api/chat/sessions/${currentSessionId}/retry`
);
const questionText = res.data.retried_message;
if (!questionText) {
if (showAlert) {
showAlert('没有找到可重试的上一轮问题', '提示');
} else {
alert('没有找到可重试的上一轮问题');
}
return;
}
// 重新加载会话以移除已被硬删除的消息,然后自动触发发送
await loadSessionHistory(currentSessionId, true);
await fetchSessions();
// 触发自动重发
handleSend(questionText);
} catch (e: any) {
const errMsg = `重试失败: ${e.response?.data || e.message}`;
if (showAlert) {
showAlert(errMsg, '错误');
} else {
alert(errMsg);
}
}
};
// 手动停止智能体执行
const handleStop = async () => {
if (!currentSessionId) return;
@@ -1082,10 +1254,21 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
{turns.map((turn) => (
<div key={turn.turn_index} className="space-y-4">
{/* 用户提问 */}
<div className="flex flex-col items-end space-y-1">
<div className="flex flex-col items-end space-y-1 group relative">
<span className="text-[10px] font-bold text-slate-400 px-1"></span>
<div className="max-w-[85%] rounded-2xl px-4 py-3 text-xs leading-relaxed font-semibold shadow-2xs border bg-sky-600 text-white border-sky-600 select-text">
{turn.question}
<div className="flex items-center gap-2 max-w-[85%]">
{turn.questionMessageId && (
<button
onClick={() => handleRewind(turn.questionMessageId)}
className="opacity-0 group-hover:opacity-100 text-slate-400 hover:text-amber-600 p-1.5 rounded-lg hover:bg-slate-100 transition-all cursor-pointer shrink-0"
title="回退到此消息之前"
>
<Rewind className="w-3.5 h-3.5" />
</button>
)}
<div className="rounded-2xl px-4 py-3 text-xs leading-relaxed font-semibold shadow-2xs border bg-sky-600 text-white border-sky-600 select-text flex-1">
{turn.question}
</div>
</div>
</div>
@@ -1151,7 +1334,7 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
{/* Error block */}
{activeTurn.error && (
<div className="flex items-start gap-2 bg-red-50 border border-red-200 text-red-700 rounded-lg p-3 text-xs w-[90%] font-semibold">
<div className="flex items-start gap-2 bg-red-50 border border-red-200 text-red-750 rounded-lg p-3 text-xs w-[90%] font-semibold">
<AlertTriangle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
<div>
<div className="font-bold"></div>
@@ -1161,6 +1344,40 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
)}
</div>
)}
{/* 会话操作小图标栏 */}
{currentSessionId && turns.length > 0 && !streaming && (
<div className="flex items-center gap-1 pt-2.5 justify-start border-t border-slate-100/60 mt-4 max-w-[200px]">
<button
onClick={() => handleRewind(undefined, 1)}
className="p-1.5 rounded-lg text-slate-400 hover:text-amber-600 hover:bg-slate-100 transition-all cursor-pointer"
title="回退最近一轮对话 (undo)"
>
<Rewind className="w-4 h-4" />
</button>
<button
onClick={handleRestoreRewind}
className="p-1.5 rounded-lg text-slate-400 hover:text-green-600 hover:bg-slate-100 transition-all cursor-pointer"
title="恢复上次回退操作"
>
<RotateCcw className="w-4 h-4" />
</button>
<button
onClick={handleRetry}
className="p-1.5 rounded-lg text-slate-400 hover:text-indigo-600 hover:bg-slate-100 transition-all cursor-pointer"
title="重试最后一轮对话"
>
<RefreshCw className="w-4 h-4" />
</button>
<button
onClick={handleBranch}
className="p-1.5 rounded-lg text-slate-400 hover:text-blue-600 hover:bg-slate-100 transition-all cursor-pointer"
title="分叉当前会话"
>
<GitBranch className="w-4 h-4" />
</button>
</div>
)}
</div>
)}
<div ref={chatEndRef} />
@@ -1269,6 +1486,7 @@ function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
if (msg.role === 'user') {
turn.question = msg.content;
turn.questionMessageId = msg.id;
} else if (msg.role === 'assistant') {
const stepNum = msg.step_index;
const hasToolCalls = msg.tool_calls && msg.tool_calls.length > 0;
+24 -24
View File
@@ -547,26 +547,26 @@ export function ReaderPanel({
onClick={() => handleVectorize(selectedPaper.bibcode)}
disabled={vectorizing}
className="btn-console btn-console-primary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2"
title="对文献进行向量化分块入库,以开启学术 AI 问答"
title="对文献进行知识入库,以开启学术 AI 研讨"
>
{vectorizing ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <Sparkles className="w-3.5 h-3.5" />}
{vectorizing ? '向量化入库...' : '向量化入库'}
{vectorizing ? '正在进行知识入库...' : '知识入库'}
</button>
)}
{selectedPaper.has_markdown && selectedPaper.has_vector && (
<button
onClick={() => {
showConfirm('确定要重新向量化入库吗?这会清先前该文献的切片记录并重新执行入库。', () => {
showConfirm('确定要重新进行知识入库吗?这会清先前该文献已有的知识点记录并重新写入。', () => {
handleVectorize(selectedPaper.bibcode);
}, '确认重新向量化');
}, '确认重新知识入库');
}}
disabled={vectorizing}
className="btn-console btn-console-secondary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2"
title="重新为该文献生成 RAG 向量切片"
title="重新为该文献解析知识点并入库"
>
{vectorizing ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <RotateCw className="w-3.5 h-3.5" />}
</button>
)}
@@ -941,56 +941,56 @@ export function ReaderPanel({
top: `${hoverCardPos.y}px`,
zIndex: 9999,
}}
className="console-panel rounded-xl p-4 bg-white border border-slate-200 shadow-xl w-72 text-xs space-y-2 pointer-events-auto animate-in fade-in duration-200"
className="console-panel rounded-xl p-4 bg-[var(--bg-card)] border border-[var(--border-precision)] shadow-md w-72 text-xs space-y-2 pointer-events-auto animate-in fade-in duration-200"
>
<div className="flex items-center justify-between border-b border-slate-100 pb-1.5">
<span className="font-bold text-slate-900 text-sm">{hoveredTarget.target_name}</span>
<span className="font-bold text-[var(--text-main)] text-sm">{hoveredTarget.target_name}</span>
<div className="flex items-center gap-2 select-none">
<a
href={`https://simbad.cds.unistra.fr/simbad/sim-id?Ident=${encodeURIComponent(hoveredTarget.target_name)}`}
target="_blank"
rel="noopener noreferrer"
className="text-[10px] font-bold text-sky-600 hover:text-sky-800 hover:underline cursor-pointer"
className="text-[10px] font-bold text-[var(--accent-blueprint)] hover:text-[#0d5988] hover:underline cursor-pointer"
title="在 SIMBAD 中查询该天体详情"
>
SIMBAD
</a>
<span className="text-[9px] text-slate-300">|</span>
<span className="text-[9px] text-[var(--border-precision)]">|</span>
<a
href={`https://vizier.cds.unistra.fr/viz-bin/VizieR?-c=${encodeURIComponent(hoveredTarget.target_name)}`}
target="_blank"
rel="noopener noreferrer"
className="text-[10px] font-bold text-sky-600 hover:text-sky-800 hover:underline cursor-pointer"
className="text-[10px] font-bold text-[var(--accent-blueprint)] hover:text-[#0d5988] hover:underline cursor-pointer"
title="在 VizieR 中查询相关文献和表数据"
>
VizieR
</a>
</div>
</div>
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 text-slate-600">
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 text-[var(--text-muted)]">
<div>
<span className="text-slate-400 font-semibold">RA (J2000):</span>
<div className="font-mono font-bold text-slate-800">{hoveredTarget.ra || '未知'}</div>
<span className="text-[var(--text-muted)] font-semibold">RA (J2000):</span>
<div className="font-mono font-bold text-[var(--text-main)]">{hoveredTarget.ra || '未知'}</div>
</div>
<div>
<span className="text-slate-400 font-semibold">Dec (J2000):</span>
<div className="font-mono font-bold text-slate-800">{hoveredTarget.dec || '未知'}</div>
<span className="text-[var(--text-muted)] font-semibold">Dec (J2000):</span>
<div className="font-mono font-bold text-[var(--text-main)]">{hoveredTarget.dec || '未知'}</div>
</div>
<div>
<span className="text-slate-400 font-semibold">:</span>
<div className="font-bold text-slate-800">{hoveredTarget.spectral_type || '未知'}</div>
<span className="text-[var(--text-muted)] font-semibold">:</span>
<div className="font-bold text-[var(--text-main)]">{hoveredTarget.spectral_type || '未知'}</div>
</div>
<div>
<span className="text-slate-400 font-semibold"> (V):</span>
<div className="font-bold text-slate-800">
<span className="text-[var(--text-muted)] font-semibold"> (V):</span>
<div className="font-bold text-[var(--text-main)]">
{hoveredTarget.v_magnitude !== null && hoveredTarget.v_magnitude !== undefined
? `${hoveredTarget.v_magnitude.toFixed(2)}`
: '未知'}
</div>
</div>
<div className="col-span-2">
<span className="text-slate-400 font-semibold"> / :</span>
<div className="font-bold text-slate-800">
<span className="text-[var(--text-muted)] font-semibold"> / :</span>
<div className="font-bold text-[var(--text-main)]">
{hoveredTarget.parallax !== null && hoveredTarget.parallax !== undefined
? `${hoveredTarget.parallax.toFixed(2)} mas (~${(1000.0 / hoveredTarget.parallax).toFixed(1)} pc)`
: '未知'}
@@ -999,8 +999,8 @@ export function ReaderPanel({
</div>
{hoveredTarget.aliases && hoveredTarget.aliases.length > 0 && (
<div className="border-t border-slate-100 pt-1.5">
<span className="text-slate-400 font-semibold block mb-0.5">:</span>
<div className="text-[10px] text-slate-500 font-medium leading-relaxed max-h-16 overflow-y-auto font-mono">
<span className="text-[var(--text-muted)] font-semibold block mb-0.5">:</span>
<div className="text-[10px] text-[var(--text-muted)] font-medium leading-relaxed max-h-16 overflow-y-auto font-mono">
{hoveredTarget.aliases.slice(0, 8).join(', ')}
{hoveredTarget.aliases.length > 8 && ' ...'}
</div>
+32 -31
View File
@@ -1,4 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap');
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@@ -6,15 +6,18 @@
font-family: 'Inter', system-ui, -apple-system, sans-serif;
color-scheme: light;
--bg-main: #f1f5f9;
/* 极简白蓝学术科技风配色 */
--bg-main: #f4f6f9;
--bg-card: #ffffff;
--bg-sidebar: #f8fafc;
--text-main: #0f172a;
--text-muted: #475569;
--bg-sidebar: #0f2540; /* 保持深色侧边栏作为界面骨架结构 */
--text-main: #0a2540;
--text-muted: #5c6b84;
--accent-blue: #0284c7;
--accent-navy: #1e3a8a;
--border-clean: #e2e8f0;
--accent-blueprint: #106ba3;
--accent-star: #d97706;
--border-precision: #d2d8e2;
--font-mono: 'JetBrains Mono', monospace;
}
body {
@@ -32,7 +35,7 @@ body {
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
background: #f4f6f9;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
@@ -45,20 +48,20 @@ body {
/* Premium clean panel cards */
.console-panel {
background: var(--bg-card);
border: 1px solid var(--border-clean);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
border: 1px solid var(--border-precision);
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05); /* 弱化阴影,更显扁平学术感 */
}
.console-panel-active {
border-color: var(--accent-blue);
box-shadow: 0 0 0 1px var(--accent-blue), 0 4px 6px -1px rgba(0, 0, 0, 0.05);
border-color: var(--accent-blueprint);
box-shadow: 0 0 0 1px var(--accent-blueprint), 0 1px 3px 0 rgba(0, 0, 0, 0.05);
}
/* High contrast clean console button */
.btn-console {
background: #ffffff;
border: 1px solid #cbd5e1;
color: #334155;
border: 1px solid var(--border-precision);
color: var(--text-main);
font-weight: 500;
transition: all 0.2s ease;
}
@@ -66,42 +69,42 @@ body {
.btn-console:hover:not(:disabled) {
background: #f8fafc;
border-color: #94a3b8;
color: #0f172a;
color: var(--text-main);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.btn-console-primary {
background: var(--accent-blue);
border: 1px solid var(--accent-blue);
background: var(--accent-blueprint);
border: 1px solid var(--accent-blueprint);
color: #ffffff;
}
.btn-console-primary:hover:not(:disabled) {
background: #0369a1;
border-color: #0369a1;
background: #0d5988;
border-color: #0d5988;
color: #ffffff;
box-shadow: 0 2px 4px 0 rgba(2, 132, 199, 0.2);
box-shadow: 0 2px 4px 0 rgba(16, 107, 163, 0.2);
}
.btn-console-secondary {
background: #f1f5f9;
border: 1px solid #e2e8f0;
color: #334155;
border: 1px solid var(--border-precision);
color: var(--text-muted);
}
.btn-console-secondary:hover:not(:disabled) {
background: #e2e8f0;
color: #0f172a;
color: var(--text-main);
}
/* Premium clean console select dropdown styling */
.select-console {
display: inline-block;
background-color: #ffffff;
border: 1px solid #cbd5e1;
border: 1px solid var(--border-precision);
border-radius: 0.5rem; /* 8px */
padding: 0.5rem 1.75rem 0.5rem 0.625rem; /* padding-right leaves space for custom arrow */
color: #334155;
color: var(--text-main);
font-size: 0.75rem; /* text-xs */
font-weight: 500;
cursor: pointer;
@@ -116,13 +119,13 @@ body {
.select-console:hover:not(:disabled) {
border-color: #94a3b8;
color: #0f172a;
color: var(--text-main);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.select-console:focus {
border-color: #0284c7;
box-shadow: 0 0 0 1px #0284c7;
border-color: var(--accent-blueprint);
box-shadow: 0 0 0 1px var(--accent-blueprint);
}
.select-console:disabled {
@@ -130,5 +133,3 @@ body {
color: #94a3b8;
cursor: not-allowed;
}
+32
View File
@@ -172,3 +172,35 @@ export interface AgentTask {
created_at: string;
updated_at: string;
}
// ── 会话回退 (Rewind) ──
export interface RewindRequest {
n?: number; // 回退 N 个轮次
message_id?: number; // 或指定消息 ID
}
export interface RewindResponse {
rewound_count: number;
target_preview: string;
new_turn_index: number;
session_id: string;
}
export interface RestoreResponse {
restored_count: number;
session_id: string;
}
export interface BranchResponse {
branch_session_id: string;
forked_at_message_id: number;
copied_count: number;
}
export interface RetryResponse {
retried_message: string;
new_turn_index: number;
deleted_count: number;
session_id: string;
}
@@ -0,0 +1,909 @@
# Claude Code / Hermes-Agent 参考分析
对 Claude Code (`/home/fmq/program/claudecode/src/`) 和 Hermes-Agent (`libs/hermes-agent/`)
源码的全面架构分析,记录对 AstroResearch Agent 系统的参考价值与改进方向。
> 分析日期: 2026-06-22 | 最后更新: 2026-06-22
## 实施状态
| 优先级 | 改进项 | 状态 | 涉及文件 |
|--------|--------|------|---------|
| P0 | Context Overflow 自动修复 | ✅ 已完成 | `error_recovery.rs` (+150 行) |
| P0 | StreamingExecutor 真正流式调度 | ✅ 已完成 | `streaming_executor.rs` (重写 ~400 行) |
| P0 | Executor 集成分区器(批次串行/并行) | ✅ 已完成 | `executor.rs` (Phase 3 重写 + 2 个提取函数) |
| P1 | 工具并发分区 `partition_tool_calls` | ✅ 已完成 | `partitioner.rs` (+2 测试) |
| P1 | PermissionRequest / PermissionDenied Hooks | ✅ 已完成 | `hooks/types.rs`, `traits.rs`, `dispatch.rs`, `mod.rs` |
| P1 | Auto-mode Classifier | ⏳ 待定 | — |
| P2 | Self-improving Skills(模式检测 + 自动创建 + Curator) | ✅ 已完成 | `skills/pattern_detector.rs` + `curator.rs` + `SkillCreator` |
| P2 | Coordinator Mode | ⏳ 待定 | — |
| P2 | UserPromptSubmit / PreCompact / PostCompact Hook | ⏳ 待定 | — |
| P3 | FTS5 跨 session 搜索 | ⏳ 待定 | — |
| P3 | Tool `defer_loading` / `classifier_summary` | ⏳ 待定 | — |
| P3 | 模型回退策略 | ⏳ 待定 | — |
| P3 | Session Memory Compaction | ⏳ 待定 | — |
---
## 目录
1. [总体评估](#1-总体评估)
2. [工具并发执行模型](#2-工具并发执行模型)
3. [Permission 系统](#3-permission-系统)
4. [Hooks 系统](#4-hooks-系统)
5. [Error Recovery / 重试系统](#5-error-recovery--重试系统)
6. [Tool 定义系统](#7-tool-定义系统)
8. [Memory 持久化](#8-memory-持久化)
9. [Coordinator / Multi-Agent](#9-coordinator--multi-agent)
10. [Hermes-Agent 的独特贡献](#10-hermes-agent-的独特贡献)
11. [优先级排序 —— 建议实施路线](#11-优先级排序--建议实施路线)
---
## 1. 总体评估
### 1.1 参考项目概览
| 维度 | Claude Code | Hermes-Agent | AstroResearch |
|------|-------------|-------------|---------------|
| 语言 | TypeScript (Node.js) | Python (3.11+) | Rust (Axum) |
| 定位 | 终端 IDE 编程助手 | 通用 AI 个人助手 | 天文科研 Agent |
| Agent 循环 | 流式 `query()` generator | 同步 `while` 循环 | 流式 ReAct 循环 |
| 工具注册 | 手动 import + `getAllBaseTools()` | 文件系统自动发现 | 手动 `ToolRegistry::new()` |
| 工具接口 | `Tool<T>` — ~70 个方法 | `handler(args) -> JSON string` | `AgentTool` trait — ~10 个方法 |
| 权限系统 | 6 层优先级 + Classifier + 沙箱 | 无内置 | 3 层规则 + PermissionChecker |
| Hooks | 27 种事件,6 种 hook 类型 | PluginManager 生命周期 | 15 种事件,2 种 hook 类型 |
| 子代理 | `AgentTool` + Fork + Worktree 隔离 | `delegate_task` + 子 AIAgent | `SubAgentTool` + 独立 ReAct |
| 多 Agent | Coordinator 模式 + Swarm/Team | Kanban 工作队列 | Team 系统 (lead/teammate) |
| 持久化 | 文件系统 Markdown + cost tracker | SQLite (FTS5) + SessionDB | SQLite + MEMORY.md |
| 上下文压缩 | 微压缩 + 自动压缩 + 手动压缩 | ContextCompressor | 4 层压缩 (微/snip/auto/aggro) |
### 1.2 核心结论
AstroResearch 的 Agent 系统架构本身就是**对标 Claude Code 设计的**——`StreamingToolExecutor`
`PermissionChecker``HookRegistry` 都明确标注了参考来源。当前差距主要是**实现深度**而非**设计方向**。
Claude Code 的参考价值在**工程细节**:流式调度的时机选择、Overflow 的自动修复、Classifier
的并行化设计。Hermes-Agent 的独特价值在**自我进化**Self-improving Skills)和**多 Profile 隔离**。
---
## 2. 工具并发执行模型
### 2.1 对比
| 特性 | Claude Code | AstroResearch (当前) |
|------|-------------|---------------------|
| 流式调度 | tool_use 到达**立即**开始执行 | tool_use 全部收集,`flush()` 批量执行 |
| 并发分区 | `partitionToolCalls()` 自动分组连续只读工具并行 | `ToolPartitioner` 存在但基本未使用 |
| Sibling Abort | Bash 错误 → 级联取消兄弟工具,有专用 `siblingAbortController` | `AbortReason::SiblingError` + 广播通道存在,取消逻辑不完整 |
| Progress 流式 | `pendingProgress` 即时 yield`progressAvailableResolve` 唤醒等待 | `execute_with_progress` 有通道,`getCompletedResults` 未检查 |
| 中断行为 | `interruptBehavior()` 区分 `cancel` vs `block` | `InterruptBehavior` 枚举存在但未在 Executor 中使用 |
### 2.2 Claude Code 的分区逻辑
```typescript
// src/services/tools/toolOrchestration.ts
// 自动将连续只读工具分组并行,写工具独立串行
function partitionToolCalls(toolUseMessages, toolUseContext): Batch[] {
return toolUseMessages.reduce((acc, toolUse) => {
const tool = findToolByName(toolUseContext.options.tools, toolUse.name)
const parsedInput = tool?.inputSchema.safeParse(toolUse.input)
const isConcurrencySafe = parsedInput?.success
? (() => { try { return Boolean(tool.isConcurrencySafe(parsedInput.data)) } catch { return false } })()
: false
if (isConcurrencySafe && acc[acc.length - 1]?.isConcurrencySafe) {
acc[acc.length - 1].blocks.push(toolUse) // 合并到当前批次
} else {
acc.push({ isConcurrencySafe, blocks: [toolUse] }) // 新批次
}
return acc
}, [])
}
```
关键点:
- **输入感知**的并发安全判断:同一工具可能因参数不同而安全属性不同
- 并发安全的工具**连续分组**——不打断写入顺序
- 非并发安全的工具**独占执行**——等上一个完成后才启动下一个
### 2.3 Claude Code 的 StreamingToolExecutor 核心逻辑
```
文件: src/services/tools/StreamingToolExecutor.ts (531 行)
状态机: Queued → Executing → Completed → Yielded
生命周期:
1. addTool(block) — LLM 流产生 tool_use 时立即调用
2. processQueue() — 检查 concurrency 条件,启动可执行工具
3. executeTool(tool) — 创建子 AbortController,调用 runToolUse generator
4. getCompletedResults() — 按序 yield 结果(非阻塞)
5. getRemainingResults() — 等待未完成工具(async generator
关键设计:
- siblingAbortController: 父 AbortController 的子节点
Bash 错误 → siblingAbortController.abort('sibling_error') → 取消所有兄弟
但 toolAbortController 的 abort 会向上冒泡到父 AbortController
- progressAvailableResolve: Promise resolver 用于唤醒等待 progress 的 getRemainingResults
- 中断行为: 'cancel' 工具被用户中断时生成 REJECT_MESSAGE; 'block' 工具不受影响
```
### 2.4 AstroResearch 的现状(2026-06-22 更新)
```
文件: src/agent/runtime/streaming_executor.rs (~400 行,已重写)
已实现:
✅ TrackedTool 状态机 (Queued/Executing/Completed/Yielded)
✅ Sibling Abort 广播通道(broadcast::channel + tokio::select! 竞速)
✅ on_tool_use / flush / next_result 接口
✅ 输出截断
✅ 真正的流式调度 — on_tool_use 中对并发安全工具立即 spawn tokio task
✅ 非并发安全工具独占执行 — executing_non_concurrent 标志阻塞后续启动
✅ 并发取消 — tokio::select! 在工具执行和 Sibling Abort 之间竞速
✅ 输入感知的并发安全判断 — 通过 tool_registry.get().is_concurrency_safe(&args)
✅ ToolContext 实现 Clone(支持 per-task 复制上下文)
与 Claude Code 的对齐:
- 核心理念一致:addTool → 立即 processQueue
- Sibling Abort 机制等效:broadcast::Sender + subscribe
- collectCompletedTasks 使用 JoinHandle::is_finished() 做非阻塞检查
```
### 2.5 已实施改进(2026-06-22
**✅ P0: 真正的流式调度** — 已完成
`on_tool_use` 中对并发安全工具立即 `tokio::spawn`,非并发安全工具标记 `executing_non_concurrent`
并阻塞后续启动,直到独占工具完成。
**✅ P0: Executor 集成分区器** — 已完成
`src/agent/runtime/executor.rs` Phase 3 从「所有非拒绝工具单一 `FuturesUnordered` 无差别并发」
改为「`ToolPartitioner` 分区 → 逐批次执行」:
- 并行批次内 `FuturesUnordered` 并发
- 串行批次内逐个执行(非并发安全工具独占)
- 提取 `execute_single_tool()``process_single_result()` 两个辅助函数
**✅ P1: 并发分区器** — 已完成
`src/agent/runtime/partitioner.rs` 新增 2 个测试(`run_bash``file_write` 打断并发批)。
### 2.6 原始建议(已过时)
```rust
// 建议在 on_tool_use 中对并发安全的工具立即 spawn
pub fn on_tool_use(&mut self, call_id: String, name: String, args: Value) -> bool {
let is_concurrency_safe = /* 判断 */
let idx = self.tracked.len();
self.tracked.push(tool);
if is_concurrency_safe && self.can_execute_now() {
let handle = tokio::spawn(/* 执行 */);
self.tracked[idx].handle = Some(handle);
true
} else {
false
}
}
```
**P1: 并发分区**
```rust
/// 将 tool_use 列表分区为 (并发安全批次, 非并发安全单例)
fn partition_tool_calls(calls: &[PreparedCall], registry: &ToolRegistry) -> Vec<Batch> {
calls.iter().fold(Vec::new(), |mut acc, call| {
let is_safe = registry.get(&call.tool_name)
.map(|t| t.is_concurrency_safe(&call.args))
.unwrap_or(false);
if is_safe && acc.last().map_or(false, |b: &Batch| b.concurrent) {
acc.last_mut().unwrap().calls.push(call.clone());
} else {
acc.push(Batch { concurrent: is_safe, calls: vec![call.clone()] });
}
acc
})
}
```
---
## 3. Permission 系统
### 3.1 对比
| 特性 | Claude Code | AstroResearch (当前) |
|------|-------------|---------------------|
| 规则来源分层 | 6 层优先级:policy > project > user > plugin > flag > command | 单一规则列表 |
| 规则行为 | Allow / Deny / Ask | Allow / Deny / Ask ✅ |
| Classifier 自动模式 | 两阶段(快速 + 思考),并行于 hooks 启动 | 无 |
| 拒绝追踪 | 带时间窗口的限流回退 (DenialTracker) | 简单计数 |
| 沙箱集成 | `shouldUseSandbox()` + `sandbox-adapter` | 无沙箱概念 |
| 决策溯源 | 每条 PermissionDecisionReason 记录完整来源链 | 只返回 Allow/Deny/Ask |
| 权限模式 | 5 种:default, acceptEdits, bypassPermissions, dontAsk, plan | 4 种 ✅ |
### 3.2 Claude Code 的 Permission 决策管道
```
1. validateInput() — Zod schema 验证
2. runPreToolUseHooks() — Session hooks(用户配置的)
3. canUseTool — 检查 deny 规则
4. resolveHookPermissionDecision() — Allow 规则
5. [auto mode] Classifier — 两阶段分类器(Haiku
6. [default mode] 用户弹窗 — 交互式确认
7. PermissionDecisionReason — 记录决策来源
```
每个决策都烙印 `PermissionDecisionReason`
```
rule | mode | subcommandResults | permissionPromptTool |
hook | asyncAgent | sandboxOverride | classifier |
workingDir | safetyCheck | other
```
### 3.3 Classifier 系统(最值得借鉴)
Claude Code 的 auto-mode classifier 是一个**独立的小模型调用**(Haiku),在后台并行运行:
```
Auto Mode 决策流程:
┌──────────────────────────────────────────────┐
│ 1. startSpeculativeClassifierCheck() │
│ └─ 并行于 PreToolUse hooks 启动 │
│ 2. 两阶段分类: │
│ ├─ Fast: 简单模式匹配(秒级) │
│ └─ Thinking: 深度分析(复杂命令时) │
│ 3. 结果: Allow / Deny / Ask + confidence │
│ 4. DenialTracker: 连续 Deny 后 fallback 用户 │
└──────────────────────────────────────────────┘
```
AstroResearch 目前没有 auto-mode——所有非白名单工具都需要用户交互确认。
### 3.4 建议改进
**P1: Auto-mode Classifier**
```rust
/// Auto-mode 分类器 — 使用廉价模型在后台预分类工具调用
pub struct AutoClassifier {
llm: LlmClient, // 使用廉价模型(如 Haiku 级别 provider
cache: LruCache<String, ClassificationResult>,
}
#[derive(Debug)]
pub struct ClassificationResult {
pub decision: PermissionResult,
pub confidence: f64,
pub reason: String,
}
impl AutoClassifier {
/// 在工具执行前异步预分类(不阻塞用户)
pub async fn preclassify(
&self,
tool_name: &str,
args: &Value,
context: &str, // 从 CLAUDE.md 和当前对话提取
) -> ClassificationResult {
// 构建精简 prompt
// "You are a security classifier. Evaluate this tool call:
// Tool: {tool_name}
// Args: {args}
// Context: {context}
// Respond: ALLOW|DENY|ASK <confidence 0-100> <reason>"
todo!()
}
}
```
**P1: PermissionRequest / PermissionDenied Hook 事件**
这两个事件对科研场景的审计至关重要:
```rust
// 在 hooks/types.rs 中添加
pub enum HookEvent {
// ... 现有事件 ...
/// 权限请求前触发(可阻止或修改)
PermissionRequest,
/// 权限被拒绝后触发(审计日志)
PermissionDenied,
}
```
---
## 4. Hooks 系统
### 4.1 对比
| 特性 | Claude Code | AstroResearch (当前) |
|------|-------------|---------------------|
| Hook 类型 | 6 种:command, prompt, agent, http, callback, function | 2 种:sync AgentHook + async AsyncAgentHook |
| 匹配器 | simple / pipe-separated / regex | glob + 精确匹配 |
| if 条件 | `preparePermissionMatcher()` — Bash 上有 tree-sitter | 无 |
| 输出协议 | JSON `{continue, decision, reason, suppressOutput, hookSpecificOutput}` | 直接返回值 |
| 超时 | 每个 hook 独立超时(默认 10min) | 统一 `DEFAULT_HOOK_TIMEOUT` |
| 事件数量 | 27 种 | ~15 种 |
| 来源 | config + plugin + SDK + session + function | registry + session |
### 4.2 Claude Code 的 27 种 Hook 事件
```
生命周期类:
SessionStart, Setup, SubagentStart, SubagentStop, SessionEnd, Stop, StopFailure
用户交互类:
UserPromptSubmit, Elicitation, ElicitationResult, PermissionRequest, PermissionDenied
工具执行类:
PreToolUse, PostToolUse, PostToolUseFailure
上下文类:
PreCompact, PostCompact, InstructionsLoaded
环境监控类:
FileChanged, CwdChanged, ConfigChange
Swarm/Team 类:
TeammateIdle, TaskCreated, TaskCompleted
UI 类:
Notification, StatusLine, FileSuggestion
```
### 4.3 AstroResearch 缺失的关键事件
| 缺失事件 | 用途 | 优先级 | 状态 |
|---------|------|--------|------|
| `UserPromptSubmit` | 用户提交 prompt 前拦截(自动上下文注入) | P2 | ⏳ |
| `Notification` | 长时间操作完成通知 | P1 | ⏳ |
| `PermissionRequest` | 权限弹窗前触发 | P1 | ✅ 已实现 |
| `PermissionDenied` | 权限被拒绝后记录审计 | P1 | ✅ 已实现 |
| `PreCompact` | 上下文压缩前机会(保存重要信息) | P2 | ⏳ |
| `PostCompact` | 上下文压缩后通知(更新外部状态) | P2 | ⏳ |
### 4.4 已实施改进(2026-06-22
**✅ P1: PermissionRequest / PermissionDenied 事件** — 已完成
新增类型(`src/agent/hooks/types.rs`:
- `HookEvent::PermissionRequest` / `HookEvent::PermissionDenied`
- `PermissionRequestContext` — 携带 `current_decision``permission_mode``is_subagent`
- `PermissionDeniedContext` — 携带 `reason``source` (Rule/Classifier/User/Timeout)
- `PermissionRequestAction` — Continue / Override / InjectContext
- `PermissionDecision` / `PermissionDenialSource` 枚举
新增 trait 方法(`src/agent/hooks/traits.rs`:
- `AgentHook::on_permission_request()``PermissionRequestAction`
- `AgentHook::on_permission_denied()` → void (审计日志)
新增调度方法(`src/agent/hooks/dispatch.rs`:
- `HookRegistry::run_on_permission_request()` — 并行调用,第一个 Override 生效
- `HookRegistry::run_on_permission_denied()` — fire-and-forget 审计
### 4.5 原始建议(部分已过时)
原建议的 context 设计已被更完善的实现替代:
}
```
**P2: 支持 command 类型 hook**
```rust
/// Command 类型 hook — 执行外部 shell 命令处理事件
pub struct CommandHook {
command: String, // 如 "python3 audit.py"
timeout: Duration, // 默认 10min
shell: HookShell, // Bash | PowerShell
}
```
---
## 5. Error Recovery / 重试系统
### 5.1 对比
| 特性 | Claude Code | AstroResearch (当前) |
|------|-------------|---------------------|
| 重试结构 | Generator 模式,yield 系统消息直到不可重试 | ErrorRecovery 枚举 + 简单决策 |
| 退避算法 | 指数 + 25% jitter,可配置上限(32s 默认,5min 持久) | 固定退避 |
| 错误分类 | `shouldRetry()` 检查 15+ 种错误,每种不同策略 | 8 步分类管线 |
| 529 Overloaded | 3 次重试 → 模型回退(Opus→Sonnet)→ 持久化重试 | 无模型回退 |
| Context Overflow | 解析 "X + Y > Z",自动调整 max_tokens + 1000 安全缓冲 | 只分类不修复 |
| 持久化重试 | 无限重试 + 30s 心跳 | 无 |
| Fast Cooldown | 429/529 在 fast mode → retry-after <20s → 10min cooldown | N/A |
### 5.2 Context Overflow 自动修复(最值得借鉴)
Claude Code 的做法:
```typescript
// src/services/api/withRetry.ts
// 解析 Anthropic API 的错误消息:
// "input length and max_tokens exceed context limit: 180000 + 32000 > 200000"
// → 计算安全的 max_tokens = 200000 - 180000 - 1000(safety) = 19000
function parseContextOverflowError(errorMessage: string) {
const match = errorMessage.match(
/input length and max_tokens exceed context limit: (\d+) \+ (\d+) > (\d+)/
);
if (match) {
const [, inputLen, maxTokens, contextLimit] = match.map(Number);
const newMaxTokens = contextLimit - inputLen - SAFETY_MARGIN;
if (newMaxTokens > MIN_TOKENS) {
return { shouldRetry: true, adjustedMaxTokens: newMaxTokens };
}
}
return { shouldRetry: false };
}
```
### 5.3 已实施改进(2026-06-22
**✅ P0: Context Overflow 自动修复** — 已完成
新增公共 API`src/agent/runtime/error_recovery.rs`:
- `ContextOverflowInfo` — 从错误消息解析的数值结构体
- `parse_context_overflow()` — 支持 Anthropic/OpenAI/通用三种格式
- `calculate_safe_max_tokens()``context_limit - input_length - SAFETY_MARGIN(1000)`
- `RecoveryStep::AdjustMaxTokens { new_max_tokens }` — 恢复管线第 0 步
- `extract_three_numbers()` — 正则匹配 "A + B > C" 模式(使用已有 `regex` crate
`AgentRuntime` 集成(`src/agent/runtime/mod.rs`:
```rust
let overflow_info = error_recovery::parse_context_overflow(&e_str);
while let Some(recovery_step) = recovery.try_recover(&error_kind, overflow_info.as_ref()) {
// AdjustMaxTokens 优先于 AggressiveCompact,仅无空间时才回退到压缩
}
```
12 个新增测试覆盖 Anthropic/OpenAI/Generic 格式、边界条件、恢复优先级。
### 5.4 原始建议(部分已过时)
**P1: 模型回退策略**
```rust
/// 模型回退链 — 529/Overloaded 时自动降级
pub struct ModelFallback {
chain: Vec<ModelTier>,
}
#[derive(Debug, Clone)]
pub enum ModelTier {
Primary(String), // 如 "claude-opus-4-8"
Fallback(String), // 如 "claude-sonnet-4-6"
Emergency(String), // 如 "claude-haiku-4-5"
}
```
---
## 6. Tool 定义系统
### 7.1 对比
| 特性 | Claude Code `Tool<T>` | AstroResearch `AgentTool` trait |
|------|----------------------|-------------------------------|
| 并发安全 | `isConcurrencySafe(input)` — 输入感知 ✅ | `is_concurrency_safe(args)` — ✅ |
| 语义标记 | `isReadOnly()` / `isDestructive()` / `isConcurrencySafe()` | `causes_sibling_abort()` / `interrupt_behavior()` |
| 权限逻辑 | `checkPermissions()` — 工具自己决定权限 | 集中在 PermissionChecker |
| 分类器输入 | `toAutoClassifierInput()` — 精简信息 | 无 |
| 延迟加载 | `shouldDefer` / `alwaysLoad` — 减小 prompt | 无 |
| 搜索提示 | `searchHint` — 帮助 ToolSearch 匹配 | 无 |
| UI 渲染 | `renderToolUseMessage/Result/Progress/Error` (6 种) | 前端独立处理 |
| 中断行为 | `interruptBehavior()` — cancel vs block | `interrupt_behavior()` ✅ |
| 输出大小 | `maxResultSizeChars` — 超限存磁盘 | env var 全局配置 |
| 权限匹配器 | `preparePermissionMatcher()` — 工具级模式匹配 | HookMatcher |
### 7.2 Claude Code 的 `buildTool()` Factory
```typescript
// 每个工具通过 buildTool 创建,自动填充安全默认值
const myTool: Tool = buildTool({
name: 'MyTool',
inputSchema: z.object({ ... }),
async call(input, context, toolUseId) { ... },
// 以下都有安全默认值,按需覆盖:
// isEnabled: true (可根据 permission mode 禁用)
// isConcurrencySafe: false
// isReadOnly: false
// isDestructive: false
// checkPermissions: {behavior: 'allow'} (最宽松)
// toAutoClassifierInput: '' (不参与分类)
// shouldDefer: false (立即加载)
// interruptBehavior: 'block' (不可中断)
})
```
### 7.3 建议改进
**P2: 为 AgentTool trait 添加方法**
```rust
pub trait AgentTool: Send + Sync {
// ... 现有方法 ...
/// 返回用于 auto-mode 分类器的精简摘要
fn classifier_summary(&self, args: &Value) -> String {
format!("{}", self.name())
}
/// 是否是只读操作(与并发安全不同——glob 是 readonly 但不能和 bash 并发)
fn is_readonly(&self) -> bool { false }
/// 是否需要延迟加载工具描述(大工具可延迟以减小 prompt)
fn defer_loading(&self) -> bool { false }
}
```
**P3: 工具 allow/deny 列表**
参考 Claude Code 的做法,AstroResearch 已有 `subagent_allowed_tools`,可扩展:
```rust
// 为子代理/异步代理定义工具过滤策略
pub struct ToolFilterPolicy {
/// 全局禁止(不计代理类型)
pub all_agent_disallowed: Vec<String>,
/// 自定义代理禁用(不能 spawn 子代理 + 编辑文件的代理)
pub custom_agent_disallowed: Vec<String>,
/// 异步代理白名单(只读子集)
pub async_agent_allowed: Vec<String>,
}
```
---
## 7. Memory 持久化
### 8.1 对比
AstroResearch 与 Claude Code 在 Memory 设计上高度相似(都是文件系统 frontmatter markdown +
`MEMORY.md` 索引),差距很小。
Claude Code 多了:
- **Team Memory**: 共享给团队的记忆(AstroResearch 有 team 系统但无 team memory
- **Session Memory compaction**: 压缩时自动通过 LLM 提取记忆到文件
### 8.2 建议改进
**P3: Session Memory Compaction**
压缩时自动提取记忆:
```rust
/// 在 compact.rs 的 auto_compact 过程中提取 session memory
pub async fn extract_session_memory(
llm: &LlmClient,
messages: &[ChatMessage],
) -> Vec<MemoryExtraction> {
// 用专门的小 prompt 让 LLM 从对话中提取可持久化的记忆
// 返回候选记忆列表,由 MemoryManager 做 dedup + 衰减
}
```
---
## 8. Coordinator / Multi-Agent
### 9.1 Claude Code 的 Coordinator Mode
```
Coordinator Mode 架构:
┌─────────────────────────────────────────────────┐
│ Coordinator (Coordinator System Prompt) │
│ Tools: Agent, SendMessage, TaskStop, │
│ SyntheticOutput (仅 4 个) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Worker 1 │ │ Worker 2 │ │ Worker 3 │ │
│ │ (async) │ │ (async) │ │ (async) │ │
│ │ standard │ │ standard │ │ standard │ │
│ │ tools │ │ tools │ │ tools │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ Worker 结果以 <task-notification> 返回 │
│ Coordinator 做 Synthesis → 下一轮 Workers │
└─────────────────────────────────────────────────┘
```
### 9.2 关键设计点
1. **Coordinator 只看到 4 个工具**——它不能直接读文件或执行 bash,只能编排 Workers
2. **Workers 全异步**——Coordinator 不等待,结果以 `<task-notification>` XML 注入
3. **Continue-vs-Spawn 决策矩阵**
- 新任务与已有 Worker 上下文重叠 <30% → 创建新 Worker
- 新任务是对已有 Worker 的跟进 → `SendMessage` 继续
4. **Worker prompt 写法规范**:自包含(self-contained),包含完整 spec,明确交付物
### 9.3 AstroResearch 的现状
AstroResearch 有 `SubAgentTool` + `TeamManager`,但没有 Coordinator 的概念。Team
是平级的(lead ↔ teammates),不是层级编排。
### 9.4 建议改进
**P2: Coordinator Mode 原型**
```
当 Agent 检测到复杂多步骤任务时,自动切换为 Coordinator 模式:
用户请求
→ Coordinator 做任务分解
→ 并行子代理执行 (SubAgentTool, async)
→ 结果综合
→ 减少单 Agent 的步骤数和 token 消耗
关键实现:
1. Coordinator system prompt: 类似 Claude Code coordinatorMode.ts
2. 仅暴露 SubAgentTool + 少量管理工具
3. 子代理结果以结构化格式注入
4. 综合阶段由 Coordinator 处理
```
---
## 9. Hermes-Agent 的独特贡献
### 10.1 文件位置
`/home/fmq/program/AstroResearch/libs/hermes-agent/`
### 10.2 Hermes-Agent vs Claude Code 架构对比
| 关注点 | Hermes-Agent | Claude Code |
|--------|-------------|-------------|
| 语言 | Python 3.11+ | TypeScript (Node.js 20+) |
| Agent 循环 | 同步 `while` 循环 | 异步 `query()` generator |
| 工具注册 | 文件系统自动发现 (`tools/*.py`) | 手动 import + `getAllBaseTools()` |
| 工具接口 | `handler(args) -> JSON string` | `Tool<T>` — ~70 方法 |
| 状态管理 | SQLite (SessionDB + FTS5) | 内存 `AppState` React store |
| Plugin 系统 | PluginManager + 生命周期 hooks | Plugin loader + MCP 集成 |
| Profile 隔离 | 多 profile + 独立 `HERMES_HOME` | 单 profile |
| 子代理 | 子 `AIAgent` 实例 | `LocalAgentTask` + `runAgent()` |
| Swarm/Team | Kanban 工作队列 | `InProcessTeammateTask` + coordinator |
| 上下文压缩 | `ContextCompressor` | `compact/` 服务 |
| MCP 支持 | `mcp_tool.py` + catalog | 完整的 `services/mcp/` |
| 定位 | 个人 AI 助手(自我改进、记忆、跨平台) | 编程 AI 助手(终端集成、文件操作) |
### 10.3 Hermes-Agent 的核心架构
```
hermes-agent/
├── run_agent.py # AIAgent 类 (~11k LOC) — 核心入口
├── model_tools.py # 工具编排层 (~2.7k LOC)
├── toolsets.py # 工具集定义
├── hermes_state.py # SessionDB — SQLite + FTS5
├── cli.py # HermesCLI 类 (~11k LOC)
├── agent/ # Agent 内部
│ ├── conversation_loop.py # 主循环
│ ├── turn_context.py # 每轮上下文 dataclass
│ ├── system_prompt.py # 三层 System Prompt
│ ├── prompt_builder.py # Prompt 片段构建
│ ├── memory_manager.py # 记忆编排
│ ├── context_compressor.py # 上下文压缩
│ ├── tool_executor.py # 工具执行分发
│ ├── tool_guardrails.py # 安全 guardrails
│ └── curator.py # 后台 Skill 生命周期管理
├── tools/ # 工具实现(自动发现)
│ ├── registry.py # ToolRegistrydiscover_builtin_tools
│ ├── delegate_tool.py # 子代理 spawn
│ ├── skills_tool.py # Skill 管理
│ └── cronjob_tools.py # Cron 调度
└── hermes_cli/ # CLI 子系统
├── plugins.py # PluginManager + 生命周期 hooks
└── profiles.py # 多 Profile 隔离
```
### 10.4 最值得借鉴的 Hermes 特性
**Self-improving Skills**
```
工作流:
1. Agent 在科研中反复使用某流程
(如: 搜索某类天体 → 下载论文 → RAG → 总结)
2. Agent 自动检测重复模式
3. Agent 创建 Skill 保存该流程
4. Curator 管理 Skill 生命周期
5. 下次相似查询直接加载 Skill,无需重新探索
AstroResearch 已有 Skills 系统 (skills.rs),缺少:
- 自动检测重复模式
- Agent 自主创建 Skill
- Curator 管理 Skill 质量
```
**Session FTS5 搜索**
```python
# Hermes 的 SessionDB 使用 SQLite FTS5 实现跨 session 搜索
class SessionDB:
def search_sessions(self, query: str) -> List[Session]:
"""全文本搜索所有历史会话"""
return self.db.execute(
"SELECT * FROM sessions WHERE sessions MATCH ?", (query,)
)
```
AstroResearch 的 `agent_sessions` 表有基本的 title/status 字段,但没有全文搜索。
### 10.5 建议改进
**P2: Self-improving Skills 原型**
```
1. Pattern Detector (自动检测)
- 监控 N 个 session 中的工具调用序列
- 使用简单的子序列匹配识别重复模式
- 阈值: 3 次相似序列 → 候选 Skill
2. Skill Creator (Agent 自主创建)
- 将候选 Skill 展示给用户确认
- 生成 SKILL.md 文件(含 when_to_use, steps
- 注册到 SkillRegistry
3. Curator (管理生命周期)
- 跟踪 Skill 使用频率
- 长时间未用的 Skill 标记为 stale
- 提示用户审查或删除
```
---
## 10. 优先级排序 —— 建议实施路线
按价值/投入比排序:
| 优先级 | 改进项 | 来源 | 状态 | 价值 | 预估投入 | 依赖 |
|--------|--------|------|------|------|---------|------|
| **P0** | Context Overflow 自动修复 | Claude Code | ✅ | 减少 ~50% LLM 调用失败 | ~30 行 | 无 |
| **P0** | StreamingExecutor 真正的流式调度 | Claude Code | ✅ | 减少 30-50% 工具执行延迟 | ~200 行 | 无 |
| **P0** | Executor 集成分区器(批次串行/并行) | Claude Code | ✅ | 修复非安全工具错误并发 | ~300 行 | ToolPartitioner |
| **P1** | 工具并发分区 `partition_tool_calls` | Claude Code | ✅ | 批量只读操作 3-5x 加速 | ~100 行 | 无 |
| **P1** | PermissionRequest / PermissionDenied Hook 事件 | Claude Code | ✅ | 安全审计能力 | ~100 行 | 无 |
| **P1** | Auto-mode Classifier(廉价模型预分类) | Claude Code | ⏳ | 消除 80%+ 权限弹窗 | ~300 行 | LLM client 支持 |
| **P2** | Self-improving SkillsAgent 保存成功流程) | Hermes | ✅ | 科研场景独特价值 | ~500 行 | Skills 系统 |
| **P2** | Coordinator Mode(层级多 Agent 编排) | Claude Code | ⏳ | 复杂任务效果提升 | ~800 行 | SubAgent + Team |
| **P2** | UserPromptSubmit / PreCompact / PostCompact Hook | Claude Code | ⏳ | Hook 系统完善 | ~200 行 | 无 |
| **P3** | FTS5 跨 session 搜索 | Hermes | ⏳ | 历史研究可复用 | 中 | SQLite 迁移 |
| **P3** | Tool `defer_loading` / `classifier_summary` | Claude Code | ⏳ | 减小 tool schema prompt | ~50 行 | 无 |
| **P3** | 模型回退策略 (Model Fallback) | Claude Code | ⏳ | 提高可用性 | ~200 行 | ErrorRecovery |
| **P3** | Session Memory Compaction | Claude Code | ⏳ | 自动化记忆提取 | ~300 行 | MemoryManager + Compact |
### 实施进度(2026-06-22
1. **P0 项 — 全部完成**
- Context Overflow 自动修复(`error_recovery.rs`
- StreamingExecutor 真正流式调度(`streaming_executor.rs` 重写)
- Executor 集成分区器(`executor.rs` Phase 3 重写)
2. **P1 项 — 部分完成**
- ✅ 工具并发分区
- ✅ PermissionRequest / PermissionDenied Hook 事件
- ⏳ Auto-mode Classifier — 需要设计讨论
3. **P2 项在下一个大版本规划**:需要设计讨论和更多测试
4. **P3 项作为 backlog**:长期优化方向
---
## 附录 A: Claude Code 关键源码索引
| 文件 | 用途 | 与 AstroResearch 对应 |
|------|------|----------------------|
| `src/Tool.ts` | Tool 类型 + buildTool factory | `src/agent/tools/mod.rs` |
| `src/tools.ts` | 工具注册 + assembleToolPool | `ToolRegistry::new()` |
| `src/services/tools/toolExecution.ts` | 工具执行管道 | `executor.rs` |
| `src/services/tools/toolOrchestration.ts` | 并发分区 + 批量执行 | `partitioner.rs` + `executor.rs` |
| `src/services/tools/StreamingToolExecutor.ts` | 流式工具执行 | `streaming_executor.rs` |
| `src/services/api/withRetry.ts` | 错误重试 + 退避 | `error_recovery.rs` |
| `src/services/api/claude.ts` | API 流式调用 | `streaming.rs` |
| `src/constants/prompts.ts` | System Prompt 构建 | `system_prompt.rs` |
| `src/utils/hooks.ts` | Hook 执行引擎 (5022 行) | `hooks/dispatch.rs` |
| `src/utils/permissions/permissions.ts` | 权限逻辑 | `permission.rs` |
| `src/utils/permissions/yoloClassifier.ts` | Auto-mode 分类器 | 无 (建议新增) |
| `src/tools/AgentTool/runAgent.ts` | 子代理执行引擎 | `subagent.rs` |
| `src/tools/AgentTool/AgentTool.tsx` | 子代理编排 | `tools/subagent.rs` |
| `src/coordinator/coordinatorMode.ts` | Coordinator 模式 | 无 (建议参考) |
| `src/memdir/memdir.ts` | Memory 文件系统 | `memory/mod.rs` |
| `src/skills/loadSkillsDir.ts` | Skill 加载 | `skills.rs` |
## 附录 B: Hermes-Agent 关键源码索引
| 文件 | 用途 | 与 AstroResearch 对应 |
|------|------|----------------------|
| `run_agent.py` | AIAgent 核心类 | `runtime/mod.rs` |
| `agent/conversation_loop.py` | Agent 主循环 | `runtime/mod.rs` (ReAct loop) |
| `agent/system_prompt.py` | 三层 System Prompt | `system_prompt.rs` |
| `agent/context_compressor.py` | 上下文压缩 | `compact.rs` |
| `agent/memory_manager.py` | 记忆编排 | `memory/mod.rs` |
| `agent/curator.py` | Skill 生命周期管理 | 无 (建议参考) |
| `tools/registry.py` | 工具自动发现 | `tools/mod.rs` |
| `tools/delegate_tool.py` | 子代理 | `subagent.rs` |
| `hermes_state.py` | SessionDB + FTS5 | `api/agent.rs` (sessions) |
| `hermes_cli/plugins.py` | PluginManager | 无 |
| `hermes_cli/profiles.py` | 多 Profile 隔离 | 无 |
| `model_tools.py` | 工具编排 | `executor.rs` |
## 附录 C: 变更日志
### 2026-06-22 — 首轮实施
**P0: Context Overflow 自动修复**
- `src/agent/runtime/error_recovery.rs`: +150 行
- 新增 `ContextOverflowInfo``parse_context_overflow()``calculate_safe_max_tokens()`
- 新增 `RecoveryStep::AdjustMaxTokens``extract_three_numbers()`
- 12 个新增测试(Anthropic/OpenAI/Generic 格式 + 边界 + 恢复优先级)
- `src/agent/runtime/mod.rs`: AgentRuntime 集成调用点
- `Cargo.lock`: 无新增依赖(使用已有 `regex` crate
**P0: StreamingExecutor 真正流式调度**
- `src/agent/runtime/streaming_executor.rs`: 重写 ~400 行(原 316 行)
- `on_tool_use` 中对并发安全工具立即 `tokio::spawn`
- `tokio::select!` 在工具执行和 Sibling Abort 之间竞速
- `collect_completed_tasks()` 使用 `JoinHandle::is_finished()` 非阻塞检查
- `executing_non_concurrent` 标志实现独占执行
- `src/agent/tools/mod.rs`: `ToolContext` 添加 `Clone` derive
**P0: Executor 集成分区器**
- `src/agent/runtime/executor.rs`: Phase 3 重写 + 2 个提取函数
- Phase 3a: 构建非拒绝工具的 (原索引, PreparedCall) 映射
- Phase 3b: `ToolPartitioner::partition()` 分区
- Phase 3c: 逐批次执行(并行批次 `FuturesUnordered`,串行批次逐个执行)
- 提取 `execute_single_tool()``process_single_result()` 辅助函数
- `src/agent/runtime/partitioner.rs`: +2 测试(`run_bash``file_write` 打断并发批)
**P1: PermissionRequest / PermissionDenied Hook 事件**
- `src/agent/hooks/types.rs`: +80 行
- 新增 `HookEvent::PermissionRequest``HookEvent::PermissionDenied`
- 新增 `PermissionRequestContext``PermissionDeniedContext`
- 新增 `PermissionRequestAction``PermissionDecision``PermissionDenialSource`
- `src/agent/hooks/traits.rs`: +20 行
- `AgentHook::on_permission_request()``AgentHook::on_permission_denied()`
- `src/agent/hooks/dispatch.rs`: +120 行
- `run_on_permission_request()` (并行调用,第一个 Override 生效)
- `run_on_permission_denied()` (fire-and-forget 审计)
- `src/agent/hooks/mod.rs`: 更新 re-exports
### 2026-06-22 — Self-improving Skills
**P2: Self-improving Skills(模式检测 + 自动创建 + Curator**
- `src/agent/skills/pattern_detector.rs`: +420 行
- `PatternDetector` — 从 `agent_messages` DB 表扫描工具调用序列
- `DetectedPattern` — 跨 session 重复模式(携带出现次数、置信度、指纹)
- 滑动窗口子序列提取 + Jaccard 相似度去重 + 超序列包含检测
- 8 个单元测试(子序列提取、指纹、去重、Jaccard 计算)
- `src/agent/skills/curator.rs`: +700 行
- `Curator` — Skill 生命周期管理(Active → Inactive → Stale → Deprecated
- `SkillQuality` — 基于调用次数和新鲜度的质量评分(对数 + 指数衰减)
- `CuratorReport` — 分析报告 + 清理建议列表
- `archive_stale_skills()` — 将过期 skill 移动到归档目录
- `CuratorRunner` — 后台空闲触发审查(`should_run_now()` + `record_activity()` 心跳)
- 12 个单元测试(5 种生命周期状态 + 质量评分 + 清理候选 + pinned + runner
- `src/agent/skills.rs`: +230 行
- `SkillCreator` — 将 `DetectedPattern` 转为 SKILL.md 文件(kebab-case 命名 + YAML frontmatter
- `SelfImprovePipeline` — 一站式管道(检测 → 创建 → 质量审查)
- `SelfImproveResult` — 管道结果(patterns_found + skills_created + curator_report
- `SkillFrontmatter` + `SkillMeta` 增加 `pinned` 字段
### 2026-06-22 — Hermes Curator 特性补齐
**Pinned Skills(不可清理)**
- `src/agent/skills.rs`: `SkillFrontmatter.pinned` + `SkillMeta.pinned` — YAML `pinned: true`
- `src/agent/skills/curator.rs`: `evaluate_quality(pinned)` — 强制 Active + min_score 0.8`analyze` 过滤 pinned 不进入 cleanup_candidates
**Seed Record(新 Skill 锚定时钟)**
- `src/agent/skills/curator.rs`: `evaluate_quality` 对无统计记录 skill 设置 `days_since_last_use=Some(0)`(等效刚创建),`NEW_SKILL_GRACE_PERIOD_DAYS=7` 防止立即 stale
**CuratorRunner(后台空闲触发审查)**
- `src/agent/skills/curator.rs`: `CuratorRunner` 结构体 + `should_run_now()`paused/idle/interval 三重检查)+ `record_activity()` 心跳 + `run_once()` + `spawn()` tokio 后台任务 + `pause()`/`resume()`
- 5 个新增测试(pinned_always_active、pinned_not_in_cleanup、seed_record、runner_paused、runner_idle
File diff suppressed because it is too large Load Diff
+26 -252
View File
@@ -133,6 +133,29 @@ graph TD
└────────────────────────────────────────────────────────────┘
```
### 多源权限优先级 (Multi-Source Permission Precedence)
当多个来源(Checker 规则、Hook、工具级规则、会话规则)同时做出权限决策时,
`resolve_permission_precedence()` 按以下优先级裁决:
| Priority | Source | Description |
|----------|--------|-------------|
| **P0** (highest) | `PermissionChecker::Deny` | 环境变量/配置文件配置的 Deny 规则,不可覆盖 |
| **P1** | Tool-level `PermissionRule::Deny` | 工具自身拒绝执行(如 Bash 危险命令) |
| **P2** | Session-level `PermissionChecker::Deny` | API 动态添加的会话级 Deny |
| **P3** | Hook `PreToolUseAction::Block` | Hook 主动阻止工具执行 |
| **P4** | Hook `PermissionRequired` | 仅当 Checker 返回 Allowed 时升级为 AskUser |
| **P5** | Session-level `PermissionChecker::Ask` | 仅当当前为 Allowed 时升级 |
| **P6** | Tool-level `PermissionRule::Ask` | 仅当当前为 Allowed 时升级 |
| **P7** | `PermissionChecker::Allow` | 显式 Allow 规则 |
| **P8** (lowest) | Implicit Allow (default) | 无任何规则匹配 → 允许 |
**关键规则:**
- **Deny 不可覆盖**: P0-P2 的 Deny 规则在任何情况下生效
- **Ask 可升级**: P4-P6 在 Allow 状态下升级为 Ask;在 Deny 状态下被忽略
- **Block = Deny**: Hook Block 等同于 Deny,由 P0-P2 可覆盖
- **冲突日志**: `conflict_log` 记录所有被覆盖的决策,用于审计```
### PermissionChecker API
```rust
@@ -433,7 +456,7 @@ flowchart TD
| `grep "ssh_config" *.rs` | ❌ 误拦(含子串 `ssh ` | ✅ 允许(首词 `grep` 在白名单) |
| `echo "use sudo carefully"` | ❌ 误拦(含子串 `sudo ` | ✅ 允许(首词 `echo` 在白名单) |
| `cat /usr/share/vim/vimrc` | ❌ 误拦(含子串 `vim ` | ✅ 允许(首词 `cat` 在白名单) |
| `python script.py` | ✅ 允许 | ✅ 允许(不在名单,默认允许 |
| `python script.py` | ✅ 允许 | ⚠️ 通过校验,但触发 `Ask` 用户确认(不在名单) |
| `vim file.txt` | ✅ 拒绝 | ✅ 拒绝(首词 `vim` 在黑名单) |
| `$(echo sud; echo o) /etc/passwd` | ✅ 允许(绕过!) | ❌ 拒绝(检测到命令替换绕过) |
@@ -449,7 +472,7 @@ const SAFE_COMMANDS: &[&str] = &[
];
```
白名单内的命令**优先放行**,不经过黑名单检查。非白名单命令经黑名单精确匹配和危险参数二次检查后默认允许
白名单内的命令**优先放行**,不经过黑名单检查。非白名单命令经黑名单精确匹配和危险参数检查后,通过 `bash_needs_permission()``RunBashTool::check_permissions()` 返回 `Ask` 规则,触发用户确认弹窗(PermissionRequestCard
### 其他约束
@@ -462,7 +485,7 @@ const SAFE_COMMANDS: &[&str] = &[
### 已知局限
1. ~~**黑名单子串匹配**~~ — ✅ 已修复:改用首词精确匹配,`grep "ssh_config"` 不再误拦
2. **未限制网络访问**`curl``wget` 不在黑名单中
2. ~~**未限制网络访问**~~ — ✅ 已修复:`NETWORK_COMMANDS` 名单(curl/wget/nc/socat 等)默认阻止,`AGENT_BLOCK_NETWORK=false` 可放行
3. **未限制进程数** — fork bomb(如 `:(){ :\|:& };:`)未被检测
4. **管道/重定向完整放行**`<``>``|` 不做限制
5. ~~**`$()` 命令替换**~~ — ✅ 已修复:检测首词位置 `$()` 和反引号绕过
@@ -684,258 +707,9 @@ Diminishing Returns 检测
---
## Claude Code 权限系统对比分析
> 对比基准:Claude Code (`/home/fmq/program/claudecode/src/utils/permissions/`)
> 分析日期:2026-06-17
### 架构差异总览
| 维度 | AstroResearch (当前) | Claude Code (参考) | 差距 |
|------|---------------------|-------------------|------|
| 规则引擎 | ✅ PermissionChecker (完成) | ✅ hasPermissionsToUseTool 多步流水线 | 相当 |
| 规则匹配粒度 | ✅ 内容级 `Tool(content*)` 前缀/后缀/包含 | ✅ 前缀/通配/内容级 / 正则 | 小 |
| AskUser 交互流 | ✅ SSE → PermissionRequestCard → Allow/Deny/Always Allow | ✅ 完整 SSE → Dialog → 决策 | 相当 |
| 权限模式 | ✅ Default/AcceptEdits/Bypass/DontAsk | ✅ 6种模式 (含 plan/auto) | 小 |
| 规则持久化 | ✅ 环境变量加载 + `PermissionChecker::from_config()` | ✅ settings.json 多层加载 (8级来源优先级) | 小 |
| 规则来源追踪 | ✅ `PermissionRuleSource` 枚举 (Env/Session) | ✅ cliArg > command > session > userSettings > ... | 小 |
| Bash 权限分类器 | ✅ SAFE_COMMANDS 白名单 + `check_permissions()` 集成 | ✅ AST解析 + AI分类器 + 异步推测 | 中等 |
| 拒绝追踪/熔断 | ✅ `DenialTracker` 连续/累计计数 + ReAct 循环熔断 | ✅ 连续/总计拒绝计数 + 自动终止 | 相当 |
| 权限 Hook 集成 | ✅ PreToolUseAction::PermissionRequired 完整流程 | ✅ 完整 PermissionRequest hook + 多路径决议 | 相当 |
| 规则遮蔽检测 | ✅ `detect_shadowed_rules()` deny/ask 双重检查 | ✅ `shadowedRuleDetection` deny/ask 遮蔽检测 | 相当 |
| Auto Mode (AI 分类) | ❌ 无 | ✅ YOLO classifier + 快速路径 + 安全工具白名单 | **远期** |
| 权限解释器 | ✅ 启发式 `explain_permission()` (Bash 风险等级 + 路径检测) | ✅ Haiku 生成风险解释 | 中等 |
| 会话内规则更新 | ✅ `POST/PUT /api/chat/sessions/:id/permissions/*` | ✅ `/permissions` 命令 + API | 小 |
| 子代理权限继承 | ✅ 完整 `check()` 三态检查 | ✅ 完整继承父级权限上下文 | 相当 |
| 附加目录沙箱 | ✅ `AGENT_ADDITIONAL_DIRS` + `is_path_allowed()` 扩展 | ✅ `additionalDirectories` 可配置 | 相当 |
### Claude Code 权限流水线 (参考架构)
```
hasPermissionsToUseTool(toolName, input, context):
Step 1a: 工具级 deny 规则检查 → deny → 返回 deny
Step 1b: 工具级 ask 规则检查 → ask → 返回 ask (sandbox 例外)
Step 1c: 工具自定义 checkPermissions() → 内容级规则匹配
Step 1d: 工具实现返回 deny → deny → 返回 deny
Step 1e: requiresUserInteraction? → ask → 强制 ask (bypass 免疫)
Step 1f: 内容级 ask 规则 → ask → 强制 ask (bypass 免疫)
Step 1g: 安全检查 (敏感路径等) → ask → 强制 ask (bypass 免疫)
Step 2a: bypassPermissions 模式? → allow → 返回 allow
Step 2b: 工具级 allow 规则 → allow → 返回 allow
Step 3: 剩余 passthrough → ask → 返回 ask
外层模式变换:
dontAsk 模式: ask → deny
auto 模式: acceptEdits 快速路径 → 安全工具白名单 → AI分类器
headless: hooks 先运行 → 无 hook 决定 → auto-deny
```
### 关键设计决策对比
**1. 规则格式**
Claude Code 使用 `ToolName(content)` 格式支持内容级规则:
```
Bash → 匹配所有 bash 命令
Bash(npm install) → 匹配精确命令
Bash(npm *) → 前缀通配
Bash(rm:*) → 旧版前缀(已废弃)
Read(.env) → 文件模式
mcp__server__tool → MCP 工具级
mcp__server → MCP 服务级
Agent(Explore) → 代理类型级
```
AstroResearch 已实现相同格式:
```
"*" → 通配所有工具
"tool_name" → 精确工具名匹配
"tool_name(content*)" → 前缀通配(如 "run_bash(rm *)" 匹配 "rm -rf /"
"tool_name(*suffix)" → 后缀通配(如 "read_file(*.env)" 匹配 ".env"
"tool_name(exact)" → 包含匹配(子串命中)
"*(content)" → 工具通配 + 内容匹配(如 "*(sudo)" 匹配任意工具的 sudo 命令)
```
从 args 中自动提取 `command`/`file_path`/`path`/`pattern`/`url` 字段进行内容匹配。
**2. 权限模式**
Claude Code 的 6 种模式通过 Shift+Tab 循环切换:
- `default` — 标准逐项确认
- `acceptEdits` — 工作目录内文件编辑自动通过
- `bypassPermissions` — 跳过所有 Ask(deny/ask 规则仍生效;安全检查 bypass 免疫)
- `dontAsk` — 所有 Ask 转 Deny
- `plan` — 计划模式
- `auto` — AI 自动分类(内部使用)
AstroResearch 已实现 4 种模式(通过 `AGENT_PERMISSION_MODE` 环境变量或 API 切换):
- `default` — 标准规则链,Ask 触发用户交互
- `acceptEdits` — 工作目录内文件编辑自动通过(路径检查由 executor 完成)
- `bypassPermissions` — 跳过所有 Ask(Deny 规则仍生效)
- `dontAsk` — 所有 Ask 转为 Deny
`PermissionChecker::from_config()``AgentConfig` 加载环境变量规则并构造完整检查器。
**3. 多路径权限决议**
Claude Code 的 AskUser 决议支持多个并行路径,任一先返回即生效(`claim()` 模式):
- 本地 UI 对话框
- Bridge 响应(CCR 远程)
- Channel 响应(Telegram 等)
- PermissionRequest hooks(后台异步运行)
- Bash 分类器(后台推测性异步分类)
AstroResearch 已实现完整的 AskUser 交互流:
- `PermissionChecker::check()` 返回 `AskUser` 时,executor 发送 `AgentStreamEvent::PermissionRequest` SSE 事件
- 通过 `oneshot` 通道创建 `PendingPermission`,存入 `AppState::pending_permissions`
- 等待用户通过前端 `PermissionRequestCard` 组件响应(Allow / Deny / Always Allow),120s 超时自动拒绝
- 单一路径决议(oneshot),不支持多路径 claim 模式
---
## 优化路线图
### ✅ P0 — 已全部完成
#### P0-1: 规则加载与持久化 ✅
`AgentConfig::from_env_optional()` 从环境变量加载规则(`AGENT_PERMISSIONS_DENY`/`ALLOW`/`ASK`),`PermissionChecker::from_config()` 按 Deny → Ask → Allow 优先级顺序构造规则链。
**实现位置**: `src/agent/runtime/mod.rs:113-117`, `src/agent/runtime/permission.rs:268-290`
#### P0-2: 完成 AskUser 权限交互流 ✅
executor Phase 2.5 中完整的 AskUser 处理:
- `AgentStreamEvent::PermissionRequest` SSE 事件 → 前端 `PermissionRequestCard` 组件
- `oneshot` 通道 + `AppState::pending_permissions` 存储
- 120s 超时自动拒绝,支持 Allow / Deny / Always Allow 决策
**实现位置**: `src/agent/runtime/executor.rs:282-422`, `src/api/agent.rs`, `dashboard/src/features/agent/PermissionRequestCard.tsx`
#### P0-3: 内容级权限匹配 ✅
`PermissionChecker::matches()` 支持 `"tool_name(content_pattern)"` 格式,前缀通配(`prefix*`)、后缀通配(`*suffix`)、包含匹配,自动从 args 提取 `command`/`file_path`/`path`/`pattern`/`url` 字段。
**实现位置**: `src/agent/runtime/permission.rs:183-255`
### ✅ P1 — 已全部完成
#### P1-1: 权限模式系统 ✅
`PermissionMode` 枚举实现 4 种模式(Default/AcceptEdits/Bypass/DontAsk),通过 `AGENT_PERMISSION_MODE` 环境变量或 API 切换。`PermissionChecker::apply_mode()` 在 executor Phase 2.5 中对检查结果进行模式变换(Bypass 将 Ask→AllowedDontAsk 将 Ask→Denied)。
**实现位置**: `src/agent/runtime/permission.rs:39-60, 139-177`
#### P1-2: Bash 权限接入 PermissionChecker ✅
`RunBashTool::check_permissions()` 调用 `bash_needs_permission()` —— 安全白名单中的命令返回空规则(自动允许),非白名单命令返回 `Ask` 规则。executor Phase 2.5 中与 PermissionChecker 结果叠加。
**实现位置**: `src/agent/tools/filesystem/bash.rs:58-73, 362-365`
#### P1-3: 权限 Hook 集成 ✅
`PreToolUseAction::PermissionRequired` 在 executor 中被检测:若 hook 返回 `PermissionRequired` 且 PermissionChecker 返回 `Allowed`,则升级为 `AskUser` 触发用户交互。已修复 Continue 覆盖 meaningful action 的 bug。
**实现位置**: `src/agent/hooks.rs:378-384`, `src/agent/runtime/executor.rs:221-230`
#### P1-4: 子代理完整权限继承 ✅
`SubAgentRunner` 使用 `check(tool_name, Some(&final_args))` 进行三态检查:Deny → 注入错误跳过执行,AskUser → 自动拒绝(子代理不应打断用户),Allowed → 正常执行。
**实现位置**: `src/agent/subagent.rs`
### ✅ P2-1、P2-2 — 已实现
#### P2-1: 拒绝追踪与熔断 ✅
`DenialTracker` 追踪连续拒绝和总拒绝数,阈值触发 ReAct 循环终止。
配置:`AGENT_DENIAL_MAX_CONSECUTIVE` (默认 3) / `AGENT_DENIAL_MAX_TOTAL` (默认 20)。
**实现位置**: `src/agent/runtime/denial_tracker.rs`, `src/agent/runtime/mod.rs`
#### P2-2: 会话内规则更新 API ✅
`POST /api/chat/sessions/:id/permissions/rules` — add/remove 规则
`PUT /api/chat/sessions/:id/permissions/mode` — 切换权限模式
通过 `AppState::session_permission_checker` (`Arc<RwLock<PermissionChecker>>`) 实现跨 turn 共享。
**实现位置**: `src/api/permissions.rs`, `src/agent/runtime/executor.rs` Phase 2.5
### 🟡 P2-3~P2-5 — 远期增强(按需实现)
#### P2-3: 规则遮蔽检测 ✅
`PermissionChecker::detect_shadowed_rules()` 检测 Deny/Ask 遮蔽 Allow 的情况,输出 `ShadowedRule` 列表(含 reason + fix 建议),在 AgentRuntime 初始化时通过 `warn!` 日志输出。
**实现位置**: `src/agent/runtime/permission.rs`
#### P2-4: 权限解释器 ✅
启发式 `explain_permission()` 函数,根据工具名和参数生成 `{risk_level, explanation, reasoning, risk}` 结构。Bash 命令通过关键词检测风险等级(HIGH/MEDIUM/LOW),文件操作检测系统路径。结果随 `PermissionRequest` SSE 事件推送到前端。
**实现位置**: `src/agent/runtime/permission_explainer.rs`, `src/agent/runtime/mod.rs` `AgentStreamEvent::PermissionRequest.explanation`
#### P2-5: Auto Mode (AI 权限分类器) ❌
使用 LLM 自动评估工具调用的风险:
- 快速路径:`AcceptEdits` 模式自动允许工作目录内的文件编辑
- 安全工具白名单:`read_file``grep_files``search_papers` 等只读操作自动允许
- AI 分类:对不确定的操作调用快速模型判断安全性
- 失败封闭:分类器不可用时拒绝所有非白名单操作(安全优先)
**工作量**: 3-5天
#### P2-4: 权限解释器
在执行前用 LLM 生成人类可读的风险描述:
```
"该命令将执行 npm install,可能修改 node_modules/ 目录并下载外部依赖包。"
```
**工作量**: 1天
#### P2-5: 子代理最小权限 (ToolRegistry::restrict)
```rust
impl ToolRegistry {
pub fn restrict(&self, allowed_tools: &[&str]) -> Self {
// 创建仅包含指定工具的受限注册表
}
}
```
**工作量**: 0.5天
---
## 实现路线图
```
已完成 (Phase 1): P0-1 规则加载 + P0-2 AskUser 交互流 + P0-3 内容级匹配
已完成 (Phase 2): P1-1 权限模式 + P1-2 Bash 集成 + P1-3 Hook 集成 + P1-4 子代理继承
已完成 (Phase 3): P2-1 拒绝追踪熔断 + P2-2 会话内规则更新 + P2-3 规则遮蔽检测 + P2-4 权限解释器 + 附加目录沙箱 + 规则来源追踪
远期规划 (按需): P2-5 Auto Mode (AI 分类器) + P2-6 权限解释器 LLM 升级 + 子代理最小权限 + 文件写入大小限制
```
---
## 待完善项
| 优先级 | 项目 | 当前状态 | 建议 |
|---|---|---|---|
| ~~**HIGH**~~ | ~~PermissionChecker 集成到主执行路径~~ | ✅ **已完成** | — |
| ~~**HIGH**~~ | ~~Bash 黑名单改为命令解析~~ | ✅ **已完成** | — |
| ~~**P0**~~ | ~~规则加载与持久化~~ | ✅ **已完成**`AgentConfig` 新增 `permission_deny_rules` / `permission_allow_rules` / `permission_ask_rules` / `permission_mode` 字段,通过 `AGENT_PERMISSIONS_*` 环境变量加载 | — |
| ~~**P0**~~ | ~~AskUser 权限交互流~~ | ✅ **已完成**executor Phase 2.5 AskUser 分支重写为完整 oneshot → SSE → 120s 超时流程。前端 `PermissionRequestCard` 组件提供 Allow / Deny / Always Allow | — |
| ~~**P0**~~ | ~~内容级权限匹配~~ | ✅ **已完成**`check(tool_name, tool_args)` 签名,`matches()` 支持 `"tool(content*)"` 格式(前缀/后缀/包含),自动提取 args 字段 | — |
| ~~**P1**~~ | ~~权限模式系统~~ | ✅ **已完成**`PermissionMode` (Default/AcceptEdits/Bypass/DontAsk)`apply_mode()` 方法,`AGENT_PERMISSION_MODE` 配置 | — |
| ~~**P1**~~ | ~~Bash 权限集成~~ | ✅ **已完成**`RunBashTool::check_permissions()` 调用 `bash_needs_permission()`(安全命令自动允许),executor 合并工具级检查 | — |
| ~~**P1**~~ | ~~权限 Hook 集成~~ | ✅ **已完成**`PreToolUseResult::is_permission_required()`,修复 Continue 覆盖 bugexecutor 触发 AskUser | — |
| ~~**P1**~~ | ~~子代理完整权限继承~~ | ✅ **已完成**`is_denied()``check(tool_name, Some(&final_args))`,子代理中 AskUser 自动拒绝 | — |
| ~~**MEDIUM**~~ | ~~拒绝追踪与熔断~~ | ✅ **已完成** | `DenialTracker`:连续/总计拒绝计数,阈值触发 ReAct 循环终止 |
| ~~**MEDIUM**~~ | ~~会话内规则更新 API~~ | ✅ **已完成** | `POST/PUT /api/chat/sessions/:id/permissions/*` 动态 add/remove/mode |
| ~~**MEDIUM**~~ | ~~权限解释器~~ | ✅ **已完成** | 启发式 `explain_permission()`,Bash 风险等级 + 路径检测,随 SSE PermissionRequest 推送前端 |
| **MEDIUM** | Auto Mode (AI 分类器) | 未实现 | LLM 评估风险,快速路径 + 安全工具白名单 |
| **LOW** | 子代理最小权限 | 继承全部父工具 | `ToolRegistry::restrict()` |
| **LOW** | 文件写入大小限制 | 无上限 | 添加 `max_file_size` 参数 |
| **LOW** | 网络访问控制 | `curl`/`wget` 未限制 | Bash 黑名单扩展 |
| **LOW** | 用户权限 profiles | 不支持 | YAML/TOML 权限配置 |
+312 -5
View File
@@ -42,7 +42,9 @@ sequenceDiagram
| 文件 | 行数 | 职责 |
|---|---|---|
| `src/agent/skills.rs` | 847 | SkillRegistry 缓存、文件解析、热更新、条件激活、系统提示构建 |
| `src/agent/skills.rs` | ~1100 | SkillRegistry 缓存、文件解析、热更新、条件激活、系统提示构建、SkillCreator / SelfImprovePipeline |
| `src/agent/skills/pattern_detector.rs` | ~420 | PatternDetector — 从 agent_messages 扫描工具调用序列、子序列匹配、Jaccard 去重 |
| `src/agent/skills/curator.rs` | ~700 | Curator — Skill 生命周期管理 + CuratorRunner — 后台空闲触发审查 |
| `src/agent/tools/skill.rs` | 222 | LoadSkillTool — Layer 2 按需加载的 AgentTool 实现 |
| `src/agent/runtime/mod.rs` | ~1182 | 将 `build_reminder()` 注入 SystemPrompt section 3 |
| `src/agent/runtime/system_prompt.rs` | ~64 | 静态 system prompt 中引导 LLM 使用 load_skill |
@@ -95,6 +97,7 @@ effort: high
| `paths` | `string[]` | `[]`(始终激活) | 条件激活的 glob 模式,非空时 skill 仅在匹配文件路径后激活 |
| `agent` | `string` | — | fork 模式下游的 agent 类型(如 `code-reviewer`),已定义但 LoadSkillTool 尚未使用 |
| `effort` | `string` | — | fork 模式下的 effort 级别,已定义但 LoadSkillTool 尚未使用 |
| `pinned` | `bool` | `false` | `true` 时 Curator 强制保持 Active 生命周期,最低质量评分 0.8,不被自动清理 |
### 当前项目 Skill 清单
@@ -112,7 +115,8 @@ effort: high
SkillFrontmatter — serde_yaml 解析的 YAML frontmatter,含 validate() 校验方法
├──▶ SkillMeta — Layer 1 摘要(name, description, context, allowed_tools,
│ when_to_use, disable_model_invocation, user_invocable, paths
│ when_to_use, disable_model_invocation, user_invocable, paths,
│ pinned: bool
└──▶ Skill — Layer 2 完整对象(meta + body + skill_dir
@@ -120,6 +124,12 @@ SkillFrontmatter — serde_yaml 解析的 YAML frontmatter,含 valida
├── skills: Vec<Skill>
├── last_scan_mtime: Option<SystemTime>
└── usage_stats: HashMap<String, SkillUsageStat>
SelfImprovePipeline — 一站式管道
├── PatternDetector — 扫描 agent_messages 检测重复工具调用序列
├── SkillCreator — 将 DetectedPattern 转换为 SKILL.md 文件
└── Curator — Skill 生命周期管理(Active→Inactive→Stale→Deprecated
└── CuratorRunner — 后台空闲触发审查 + 心跳记录
```
### 关键方法
@@ -319,12 +329,304 @@ Box::new(LoadSkillTool::new(skill_registry)),
- 团队成员(`teammate.rs`
- 后台任务 Agent`background.rs`
## Self-improving Skills — 自我进化管道
参考 Hermes-Agent 的 Self-improving Skills 模式,AstroResearch 实现了从**模式检测 → 自动创建 → 生命周期管理**的完整自我进化管道。
### 架构总览
```mermaid
graph TB
subgraph Pipeline["SelfImprovePipeline::run()"]
direction TB
PD["PatternDetector::scan()<br/>扫描 agent_messages 表"]
SC["SkillCreator::create_from_patterns()<br/>生成 SKILL.md 文件"]
CR["Curator::analyze()<br/>质量评分 + 生命周期评估"]
end
subgraph Background["后台定期维护"]
direction LR
Runner["CuratorRunner::spawn()<br/>空闲触发 + 间隔检查"]
Archive["archive_stale_skills()<br/>30d stale / 90d deprecated"]
end
PD -->|"Vec&lt;DetectedPattern&gt;"| SC
SC -->|"Vec&lt;Skill&gt;"| CR
CR -->|"CuratorReport"| Runner
```
### PatternDetector — 模式检测器
`agent_messages` 表中自动发现跨 session 重复的工具调用序列:
```
检测算法:
1. 查询每个 session 的 tool 消息(按时间排序)
2. 滑动窗口 (2-8 长度) 提取所有子序列
3. 跨 session 频率计数(≥3 次为候选)
4. Jaccard 相似度去重 + 超序列包含检测
5. 计算 confidence = frequency_score × similarity_penalty
```
**数据结构**
```rust
pub struct DetectedPattern {
pub tool_sequence: Vec<String>, // 如 ["search_papers", "download_paper", "rag_search"]
pub session_ids: Vec<String>, // 出现的 session
pub frequency: usize, // 跨 session 出现次数
pub confidence: f64, // 0.0-1.0 置信度
pub fingerprint: String, // 去重指纹
}
```
**置信度计算**
```
confidence = ln(frequency) / ln(3) × (1 - max_jaccard_similarity_with_other_patterns)
```
即:频率越高越好,与已有模式越不相似越好。
### SkillCreator — 自动 Skill 生成
`DetectedPattern` 转换为完整的 `SKILL.md` 文件:
```rust
pub struct SkillCreator {
skills_dir: PathBuf,
}
impl SkillCreator {
/// 检测到的模式 → 写入 skills/{kebab-case-name}/SKILL.md
pub fn create_from_patterns(
&self,
patterns: &[DetectedPattern],
dry_run: bool, // dry_run=true 时只预览不移交
) -> Result<Vec<Skill>, Error>;
/// 工具序列名 → kebab-case skill 名称
fn pattern_to_skill_name(tools: &[String]) -> String;
// 例: ["search_papers", "download_paper", "rag_search"] → "search-download-rag"
/// 生成 SKILL.md 正文(含 YAML frontmatter + step-by-step 指引)
fn generate_skill_md(pattern: &DetectedPattern) -> String;
}
```
生成的 SKILL.md 自动包含:
- `pinned: false`(初始不固定)
- `when_to_use` 自动从工具名推断
- 每个工具调用作为 `<step>` 写入正文
- `version: "0.1.0"`(自动生成版本)
### Curator — Skill 生命周期管理
基于 Hermes-Agent `curator.py` 的设计,实现确定性的时间戳驱动生命周期:
```mermaid
stateDiagram-v2
[*] --> Active: 创建 / 使用
Active --> Active: seed_record (0d) / 有调用记录
Active --> Inactive: 7d 无使用
Inactive --> Active: 再次使用
Inactive --> Stale: 30d 无使用
Stale --> Deprecated: 90d 无使用
Deprecated --> [*]: 手动删除
state Active {
[*] --> Pinned: pinned=true
Pinned --> Pinned: 强制保持 (min_score=0.8)
}
```
**生命周期状态**
| 状态 | 条件 | 行为 |
|------|------|------|
| `Active` | 最近使用 ≤ 7 天 | 正常在 remind 列表中出现 |
| `Inactive` | 7-30 天未使用 | 不出现在 remind 列表,可被重新激活 |
| `Stale` | 30-90 天未使用 | 标记为 stale,出现在清理候选列表 |
| `Deprecated` | > 90 天未使用 | 建议归档或删除 |
**质量评分**
```
quality_score = ln(1 + invoke_count) × 0.5^(days_since_last_use / 7)
```
**Pinned 保护**`pinned=true` 的 skill 强制 `Active` 状态,最低评分 0.8,不会出现在清理候选列表中。
### Seed Record — 新 Skill 锚定时钟
新创建或自动生成的 skill 可能没有使用统计,`seed_record` 机制防止它们被立即标记为 stale:
```rust
fn evaluate_quality(&self, skill: &Skill, stats: Option<&SkillUsageStat>) -> SkillQuality {
let (invoke_count, days_since_last_use) = match stats {
Some(s) => (s.invoke_count, s.days_since_last_use()),
None => (
0,
// seed_record: 无统计 → days_since_last_use = 0(视为刚创建)
Some(0),
),
};
// 如果 days_since_last_use <= 7 (NEW_SKILL_GRACE_PERIOD_DAYS) → Active
// ...
}
```
关键常量:
- `NEW_SKILL_GRACE_PERIOD_DAYS = 7`:新 skill 在 7 天内即使零调用也保持 Active
- `STALE_THRESHOLD_DAYS = 30`30 天未用标记为 stale
- `DEPRECATED_THRESHOLD_DAYS = 90`90 天未用标记为 deprecated
### CuratorRunner — 后台空闲触发审查
参考 Hermes-Agent 的 inactivity-triggered curator
```mermaid
sequenceDiagram
participant User as 用户交互
participant App as AgentRuntime
participant CR as CuratorRunner
participant Curator as Curator
Note over CR: 后台 tokio task
loop 每 check_interval
CR->>CR: should_run_now()
alt 未暂停 AND 空闲 > min_idle AND 距上次 > interval
CR->>Curator: run_once()
Curator->>Curator: evaluate_quality() / archive_stale_skills()
Curator-->>CR: CuratorReport
else 不满足条件
CR->>CR: skip
end
end
User->>App: 发送查询
App->>CR: record_activity() 更新心跳
```
**CuratorRunner API**
```rust
pub struct CuratorRunner {
curator: Curator,
db: SqlitePool,
interval: Duration, // 最小审查间隔(默认 7 天)
min_idle: Duration, // 最小空闲时间(默认 2 小时)
check_interval: Duration, // 检查间隔(默认 1 小时)
paused: AtomicBool,
last_activity: RwLock<Instant>,
last_run: RwLock<Option<Instant>>,
}
impl CuratorRunner {
pub fn new(curator: Curator, db: SqlitePool) -> Self;
pub fn with_interval(mut self, interval: Duration) -> Self;
pub fn with_min_idle(mut self, min_idle: Duration) -> Self;
/// 判断是否应运行:未暂停 + 空闲超时 + 距上次超间隔
pub fn should_run_now(&self) -> bool;
/// 记录用户活动心跳
pub async fn record_activity(&self);
/// 执行一次审查(仅在 should_run_now 时)
pub async fn run_once(
&self,
usage_stats: &HashMap<String, SkillUsageStat>,
skill_metas: &[SkillMeta],
) -> Option<CuratorReport>;
/// 启动后台任务
pub fn spawn(
self: Arc<Self>,
usage_stats: Arc<RwLock<HashMap<String, SkillUsageStat>>>,
skill_metas: Arc<RwLock<Vec<SkillMeta>>>,
check_interval: Duration,
) -> JoinHandle<()>;
pub fn pause(&self);
pub fn resume(&self);
}
```
**使用示例**
```rust
let curator = Curator::new(skills_dir.clone());
let runner = Arc::new(
CuratorRunner::new(curator, db_pool.clone())
.with_interval(Duration::from_secs(7 * 24 * 3600)) // 最少间隔 7 天
.with_min_idle(Duration::from_secs(2 * 3600)), // 空闲 2 小时后
);
// 每次用户交互时更新心跳
runner.record_activity().await;
// 启动后台任务
let _handle = runner.spawn(usage_stats, skill_metas, Duration::from_secs(3600));
```
### SelfImprovePipeline — 一站式管道
```rust
pub struct SelfImprovePipeline {
detector: PatternDetector,
creator: SkillCreator,
curator: Curator,
}
impl SelfImprovePipeline {
pub fn new(db: SqlitePool, skills_dir: PathBuf) -> Self;
/// 完整管道:检测 → 创建 → 审查
pub async fn run(&self, dry_run: bool) -> Result<SelfImproveResult>;
/// 仅检测模式(不创建)
pub async fn detect_only(&self) -> Result<Vec<DetectedPattern>>;
/// 仅分析已有 skills(不检测新模式)
pub async fn analyze_only(
&self,
usage_stats: &HashMap<String, SkillUsageStat>,
skill_metas: &[SkillMeta],
) -> Result<CuratorReport>;
}
pub struct SelfImproveResult {
pub patterns_found: usize,
pub skills_created: usize,
pub skills_created_names: Vec<String>,
pub curator_report: CuratorReport,
}
```
**管道流程**
```
SelfImprovePipeline::run(dry_run=true)
├─ 1. PatternDetector::scan()
│ └─ 从 agent_messages 中检测 ≥3 次跨 session 重复序列
│ └─ 结果: Vec<DetectedPattern>(按 confidence 降序)
├─ 2. SkillCreator::create_from_patterns(patterns, dry_run)
│ ├─ dry_run=true → 只记录日志,不写入文件
│ └─ dry_run=false → 写入 skills/ 目录 + 触发 SkillRegistry::refresh()
└─ 3. Curator::analyze(usage_stats, skill_metas)
└─ 质量评分 + 生命周期状态 + 清理建议
```
---
## 与 Claude Code 参考设计的对应关系
| Claude Code 概念 | AstroResearch 实现 |
|---|---|
| Claude Code / Hermes 概念 | AstroResearch 实现 |
|---|---|---|
| `src/skills/` 目录 + `SKILL.md` | 完全相同 |
| YAML frontmattername, description, context, allowed-tools... | 相同,增加 `version``agent``effort``paths` 字段 |
| YAML frontmattername, description, context, allowed-tools... | 相同,增加 `version``agent``effort``paths``pinned` 字段 |
| `<system-reminder>` Layer 1 注入 | `build_reminder()` → 结构化 XML 块 |
| `SkillTool` Layer 2 按需加载 | `LoadSkillTool`AgentTool trait 实现) |
| inline 模式(注入指令内容) | ✅ 实现 |
@@ -334,6 +636,11 @@ Box::new(LoadSkillTool::new(skill_registry)),
| 条件 skillpaths glob | ✅ `activate_conditional_for_paths()` |
| 变量替换 | ✅ `${SKILL_DIR}`, `${SESSION_ID}` |
| `Skill` 工具接口 + `skill` slash command | LoadSkillTooltool 形式),前端的 `/skill-name` 通过 tool 调用实现 |
| Hermes: Self-improving Skills(模式检测 + 自动创建) | ✅ `PatternDetector` + `SkillCreator` + `SelfImprovePipeline` |
| Hermes: Curator 生命周期管理 | ✅ `Curator`Active/Inactive/Stale/Deprecated |
| Hermes: Pinned Skills(不可清理) | ✅ `pinned: true` frontmatter + Curator 保护 |
| Hermes: Seed Record(新 skill 锚定时钟) | ✅ `days_since_last_use=Some(0)` + 7 天 grace period |
| Hermes: Inactivity-triggered Runner | ✅ `CuratorRunner`(后台 tokio task + 心跳记录) |
---
+229 -234
View File
@@ -1,6 +1,6 @@
# 系统提示词架构 (System Prompt Architecture)
AstroResearch Agent 系统提示词采用**模块化 Section 组装 + 动态注入 + 多层生命周期**架构,直接参考 Claude Code 的 System Prompt 设计。
AstroResearch Agent 系统提示词采用**模块化 Section 组装 + 简单首次缓存**架构,参考 Claude Code 的 System Prompt 设计并针对实际场景裁剪
## 整体分层
@@ -9,26 +9,29 @@ graph TB
subgraph L5["Layer 5: 运行时注入"]
Nudge["nudge / 任务恢复 / 后台通知"]
end
subgraph L4["Layer 4: 提示词压缩"]
Compress["snip → micro → auto → identity"]
subgraph L4["Layer 4: 提示词压缩 + CollapseLog"]
Compress["snip → micro → auto → aggressive_micro"]
Collapse["CollapseLog commit / overflow"]
end
subgraph L3["Layer 3: Skill 动态加载"]
Skill["Layer1 提醒 → Layer2 全文注入"]
end
subgraph L2["Layer 2: 子代理隔离提示词"]
SubSP["独立的 system_prompt"]
subgraph L2["Layer 2: 子代理模块化提示词"]
SubSP["6 section 组装器"]
end
subgraph L1["Layer 1: 主代理 SystemPrompt 组装"]
MainSP["5 个 section 模块化组装"]
MainSP["9 个 section 模块化组装<br/>静态(5) → 动态(4)"]
Cache["SystemPromptCache<br/>首次计算,永久复用"]
end
L5 --> L4 --> L3 --> L2 --> L1
L1 --> Cache
```
---
## 核心组装器 (`src/agent/runtime/system_prompt.rs`)
### 2.1 数据结构
### 数据结构
```rust
pub struct SystemPrompt {
@@ -36,290 +39,288 @@ pub struct SystemPrompt {
}
```
简单的有序 section 列表,通过 `assemble()` 方法用双换行符 `"\n\n"` 拼接所有 section 内容。section 按添加顺序排列
有序 section 列表`assemble()` `"\n\n"` 拼接所有 section。顺序即最终 prompt 中出现的顺序——静态内容在前,动态内容在后
### 2.2 静态常量
### Section 缓存 (`SystemPromptCache`)
两个 `&'static str` 常量在所有运行时实例间共享内存:
简化设计:**首次计算,永久缓存**。因为 session 生命周期内 CWD、platform、OS、model、工具注册表均不变,不需要 TTL 过期机制。仅在 `/clear``/compact` 事件时调用 `invalidate_all()` 全局失效。
**IDENTITY_SECTION**(身份声明,1 行):
```
你是一位专业的天体物理学研究助手,具备丰富的天文学知识。
```rust
pub struct SystemPromptCache {
entries: HashMap<&'static str, String>,
}
impl SystemPromptCache {
// 首次计算,后续命中缓存
pub fn get_or_compute(&mut self, name: &str, compute: impl FnOnce() -> String) -> String;
// compute 返回 OptionSome 缓存并返回,None 不缓存
pub fn get_or_compute_optional(&mut self, name: &str, compute: impl FnOnce() -> Option<String>) -> Option<String>;
// 显式失效
pub fn invalidate(&mut self, name: &str);
pub fn invalidate_all(&mut self);
}
```
**PRINCIPLES_SECTION**(核心行为准则,9 条):
```
核心原则:
1. 主动使用工具搜索最新文献,不要仅凭训练数据回答。
2. 优先使用本地资源(get_paper_content / rag_search),必要时再检索新文献。
3. 收集到足够信息后立即给出最终答案,避免无意义的重复工具调用。
4. 回答时引用具体文献来源,使用 ADS bibcode 标注。
5. 对于数学公式,使用标准 LaTeX 格式。
6. 用中文回答,保持科学术语的准确性(可附带英文原文)。
7. 对于复杂任务(如文献综述),调用 load_skill 获取方法论指引,再用 todo_write 制定计划。
8. 如果某个工具调用失败,不要用相同参数重试,尝试换一种方式或工具。
9. 任务状态会在每轮开始时从数据库恢复,请基于最新状态继续工作。
```
缓存策略:静态 section + environment + tools 全部通过 `get_or_compute` 缓存。skills 和 memory 不缓存——前者通过文件监听热更新,后者受 `save_memory` 工具实时影响。
### 2.3 组装顺序
### 静态 Section 常量(6 个)
每轮调用 `AgentRuntime::system_prompt()` 方法(`src/agent/runtime/mod.rs:1154-1203`),按以下顺序组装 5 个 section:
| 常量 | 内容 | 行数 |
|------|------|------|
| `IDENTITY_SECTION` | 身份声明 | 1 |
| `PRINCIPLES_SECTION` | 核心行为准则(9 条) | 9 |
| `SYSTEM_CONTEXT_SECTION` | system-reminder 标签说明 + 自动压缩 | 3 |
| `TOOL_USAGE_SECTION` | 专用工具优先、并行调用、todo_write | 5 |
| `SAFETY_SECTION` | 可逆性、影响范围、确认机制 | 5 |
### 组装顺序
`AgentRuntime::system_prompt()` 组装 9 个 section
```
Section 1: identity 静态 — 最大化 Anthropic prompt cache 命中率
Section 2: tools 动态 — 从 ToolRegistry 生成工具名称+摘要列表
Section 3: skills 动态 — 从 SkillRegistry.build_reminder() 生成(<system-reminder> XML
Section 4: memory 动态 — 从 MemoryManager.build_system_reminder(5) 生成(<project-memory-context> XML
Section 5: principles 静态 — 核心原则(放在最后 — 若需调整仅影响最后一个 cache segment
[1] identity ← 静态(首次计算后永久缓存)
[2] principles ← 静态
[3] system_context ← 静态
[4] tool_usage ← 静态
[5] safety ← 静态
[6] environment ← 动态(首次计算后缓存,session 内不变)
[7] tools ← 动态(首次计算后缓存,ToolRegistry session 内不变)
[8] skills ← 动态(不缓存,文件监听热更新)
[9] memory ← 动态(不缓存,save_memory 实时更新)
```
**缓存策略**:静态 section 固定且不变化,放在 prompt 头部以最大化 Anthropic prompt cache 命中率。动态 sectiontools、skills、memory)因内容较少,对 cache 影响可控。principles 虽然静态但放在最后,当需要调优时仅破坏最后一个 cache segment
**为什么没有 TTL**CWD、platform、OS、model、tools 在 session 生命周期内全部不变。首次计算即永久正确,TTL 是多余的复杂度
**为什么没有 cache_control 边界标记**`cache_control: {"type": "ephemeral"}` 是 Anthropic API 专有特性。我们的模型(DeepSeek/Qwen 等 OpenAI 兼容 API)不支持。静态内容前置的顺序本身已足够让服务端按内容哈希自然缓存。
---
## 动态 Section 详解
### 3.1 工具列表 (tools section)
### environment section
```rust
let mut tools_desc = String::from("你可以使用以下工具:\n");
fn build_environment_section(&self) -> String {
// 包含:工作目录、Git 仓库状态、平台、OS 版本、日期、模型名称
// 以及 Agent 配置摘要(最大步数、工具超时)
}
```
示例输出:
```
# 环境信息
- 工作目录: /home/user/project
- Git 仓库: 是
- 平台: linux
- OS 版本: Linux 7.0.0-22-generic
- 日期: 2026-06-22
- 当前模型: deepseek-v4-pro
- 最大推理步数: 8
- 工具超时: 120 秒
```
### tools section
```rust
// 从 ToolRegistry.definitions() 生成工具名称 + 80 字符摘要
// ToolRegistry 内部有 schema_cache:工具注册表不变时复用上次计算结果
for def in self.tool_registry.definitions() {
let short_desc = def.function.description
.split('。').next()
.unwrap_or(&def.function.description)
.chars().take(80)
.split('。').next() // 取第一句
.chars().take(80) // 截断 80 字符
.collect();
tools_desc.push_str(&format!("- {}: {}\n", def.function.name, short_desc));
}
```
- 19 个默认工具:`search_papers`, `download_paper`, `parse_paper`, `get_paper_content`, `rag_search`, `query_target`, `save_note`, `read_file`, `grep_files`, `glob_files`, `run_bash`, `file_write`, `file_edit`, `todo_write`, `compress_context`, `load_skill`, `subagent`, `save_memory`, `bg_task_run`
- 描述仅取**第一句 + 前 80 字符**作为功能摘要
- 完整的参数 JSON Schema 通过 API 的 `tools` 参数单独传递,不在 system prompt 中重复
完整 JSON Schema 通过 API `tools` 参数单独传递,不在 system prompt 中重复。
### 3.2 技能列表 (skills section) — 两层加载
### skills section — 两层加载
参考 Claude Code 的两层技能设计,定义在 `src/agent/skills.rs`
**Layer 1 (system-reminder)**`SkillRegistry.build_reminder()` 在 system prompt 中注入 `<system-reminder>` XML 块。列出所有 `user_invocable=true``disable_model_invocation=false` 的技能名称 + 描述。每个 skill 约消耗 ~20 tokens。
**Layer 1 (system-reminder)**`SkillRegistry.build_reminder()` 列出所有 `user_invocable=true` 的技能名称 + 描述 + when_to_use,每个约 20 tokens。
```xml
<system-reminder>
The following skills are available for use with the Skill tool:
- methodology: 天体物理研究方法论指南 - When user asks about research methodology
- plotting: 数据可视化与科学绘图 - When user wants to create plots
- presentation: 学术幻灯片制作 - When user needs to prepare a presentation
When a skill matches the user's request, invoke load_skill BEFORE generating any other response about the task.
If you see a <command-name> tag in the current conversation turn, the skill has ALREADY been loaded - follow the instructions directly instead of calling load_skill again.
When a skill matches the user's request, invoke load_skill BEFORE generating any other response...
</system-reminder>
```
**Layer 2 (load_skill 工具)**LLM 按需调用 `load_skill(skill_name)` 工具,从 `skills/{name}/SKILL.md` 加载完整内容(YAML frontmatter + Markdown body),注入到消息上下文。完整 skill 约 ~2000 tokens
**Layer 2 (load_skill 工具)**LLM 按需调用,从 `skills/{name}/SKILL.md` 加载完整内容(~2000 tokens),支持 `${SKILL_DIR}` / `${SESSION_ID}` 变量替换和 fork 执行模式
SKILL.md 格式:
```yaml
---
name: methodology
description: 天体物理研究方法论指南
context: inline # inline | fork
when_to_use: When user asks about research methodology
allowed-tools:
- search_papers
- rag_search
model: inherit
user-invocable: true
---
详见 [skills.md](skills.md)。
# Skill 正文
详细内容...
```
### memory section
**热重载**SkillRegistry 通过 `notify` crate 监听 skills 目录的文件变更,300ms debounce 后自动刷新。Skill 按使用频率排序(指数衰减评分,7 天半衰期)
**条件激活**Skill 可通过 `paths` frontmatter 声明 glob 模式。Agent 访问匹配文件时自动将 `disable_model_invocation` 设为 false,激活条件 skill。
### 3.3 项目记忆 (memory section)
`MemoryManager.build_system_reminder(5)``{library_dir}/memory/` 目录加载最近 5 条记忆,生成 `<project-memory-context>` XML 块:
```xml
<project-memory-context>
[PROJECT MEMORY]
[偏好] memory-slug: 一句话描述
内容预览前三行
[时效提示: 此记忆已超过N天,可能已过时]
[反馈] another-memory: 描述 [已更新→new-slug]
内容预览...
使用 save_memory 工具保存重要信息。记忆内容可能过时,请在使用前验证。
</project-memory-context>
```
关键特性:
- 按 mtime 排序(最新在前),支持语义选择 + 指数衰减排序
- 按类型标注:`[偏好]` / `[反馈]` / `[项目]` / `[参考]`
- 过期记忆标记为 `[已更新]``[已更新→new-slug]`(归档为 `{slug}_v1.md`
- 超过 1 天的记忆注入时效警告
- 索引文件 `MEMORY.md` 限制 200 行 / 25KB
`{library_dir}/memory/` 加载,**按 mtime 降序排列**(最新在前),取前 5 条,注入 `<project-memory-context>` XML 块。支持时效警告、过期标记、语义选择。详见 [memory.md](memory.md)
---
## 上下文初始化与运行时注入 (`src/agent/runtime/context.rs`)
## 上下文初始化 (`src/agent/runtime/context.rs`)
### 4.1 上下文构建流程
`build_initial_context()` 在每轮开始时构建完整的消息列表:
`build_initial_context()` 流程
```
1. 从数据库加载历史消息(agent_messages 表)
2. 如果历史第一条不是 system 角色 → 在位置 0 插入系统提示词
1. 加载历史消息(agent_messages 表)
2. 如果第一条不是 system 角色 → 插入系统提示词
3. 追加当前用户问题
4. [可选] 追加任务状态恢复提醒(agent_tasks 表读取
4. [可选] 追加任务状态恢复提醒(agent_tasks 表)
```
### 4.2 任务状态恢复
运行时干预通过**注入 user 消息**实现(不修改 system prompt):
`agent_tasks` 表恢复未完成的任务,格式化注入 user 消息:
```
[当前任务状态]
以下是上次会话中持久化的任务计划,请基于最新状态继续工作:
⏳ [task-1] 搜索相关文献...
🔄 [task-2] 分析论文数据... (依赖: task-1)
✅ [task-3] 格式化引用... (指派: lead)
使用 todo_write 工具更新任务进度。
```
### 4.3 运行时 Nudge 注入
在 ReAct 循环中,system prompt 组装后不再修改。运行时干预通过**注入 user 消息**实现(开闭原则):
| 触发条件 | Nudge 内容 |
| 触发条件 | 注入内容 |
|:---|:---|
| TodoWrite 连续 3 步未更新 | "提醒:你已经连续多步未更新任务计划。建议调用 todo_write 工具" |
| Token 预算 diminishing returns | "检测到你的后续步骤未产生新信息…请基于已收集的全部信息直接给出最终答案" |
| 达到最大步数 (max_steps) | "你已经执行 N 步(最大 M 步)。请根据已有信息直接给出最终答案" |
| 后台任务完成 | "[后台任务完成] ✅ tool_name: bibcode: summary" |
| TodoWrite 3 步未更新 | "提醒:建议调用 todo_write 工具复盘进度" |
| Token 预算 diminishing returns | "检测到重复操作模式,请直接给出最终答案" |
| 达到最大步数 | "已执行 N 步(最大 M 步),请直接给出最终答案" |
| 后台任务完成 | "[后台任务完成] ✅ tool_name: summary" |
---
## 子代理的独立系统提示词 (`src/agent/tools/subagent.rs`)
## 子代理模块化系统提示词 (`src/agent/tools/subagent.rs`)
子代理拥有独立的消息上下文,通过 `SubAgentRunner::run()` 接收一个**硬编码的简化版系统提示词**
子代理使用完整模块化系统提示词(不再硬编码 96 字符)
```
你是一位专业的天体物理学研究助手,在一个独立的子任务上下文中工作。
你可以使用文献搜索、下载、RAG检索等工具。
请高效完成任务,然后直接给出最终答案。不要进行不必要的重复操作。
用中文回答,引用具体文献来源。
[1] identity ← 与父代理相同
[2] subagent_context ← "在独立的子任务上下文中工作,请专注于完成这项任务"
[3] principles ← 与父代理相同
[4] system_context ← 与父代理相同
[5] tool_usage ← 与父代理相同
[6] safety ← 与父代理相同
[7] tools ← 运行时从 ToolRegistry 生成
```
特点:
- 不继承父代理的 tools/skills/memory sections
- 共享父代理的 ToolRegistry
- 通过 `PermissionChecker` 可在特定场景下限制工具访问
- 独立的 ReAct 循环(步数上限通过参数传入,默认 5,最大 10)
- 完整的 Hook 管道(PreToolUse/PostToolUse/SubagentStart/SubagentStop
- 包含活跃度日志(activity log),返回给父代理时附带工具调用统计
### 5.2 团队成员的独立提示词 (`src/agent/team/teammate.rs`)
队友的 `system_prompt``task_prompt``team/manager.rs`(lead 的委托逻辑)在运行时构造并传入 `run_teammate_loop()`
- prompt 内容完全由 lead 的决定
- 队友不包含 `subagent` 工具(防止无限委托链)
- 更轻量的 ReAct 循环(无 SSE、无 DB 持久化、无 hooks
- 步数上限更严格(min(max_steps, 5)
- 通过文件收件箱与 lead 通信(每 5 秒 poll,最长 60 秒)
预构建 `ToolRegistry`,在构造系统提示词前获取 `definitions()``SubAgentRunner` 新增 `new_with_registry_and_hooks()` 构造函数。详见 [subagent.md](subagent.md)。
---
## 上下文压缩中的独立提示词 (`src/agent/compact.rs`)
## 上下文压缩 + CollapseLog (`src/agent/compact.rs`)
### 6.1 四层压缩策略
### 四层压缩策略
| 层 | 方法 | API 调用 | 行为 |
| 层 | 方法 | API | 行为 |
|:---|:---|:---|:---|
| Layer 0 | `snip_compact` | 无 | 消息数超过 50 时截断中间段,保留头 3 + 尾 47 |
| Layer 1 | `micro_compact` | 无 | 较早工具结果替换为 `[Previous: used {tool_name}]` 占位符 |
| Layer 2 | `auto_compact` | 1 次 | LLM 摘要对话历史(见下),注入 `[历史对话摘要]` |
| Layer 3 | `aggressive_micro` | 无 | 保留最近 2 条工具结果,其余替换为占位符 |
| 0 | `snip_compact` | 无 | 消息 >50 时截断中间段,保留头 3 + 尾 |
| 1 | `micro_compact` | 无 | 较早工具结果替换为 `[Previous: used {name}]` 占位符 |
| 2 | `auto_compact` | 1 次 | LLM 摘要对话历史 |
| 3 | `aggressive_micro` | 无 | 保留最近 2 条工具结果 |
### 6.2 LLM 摘要 Prompt
### CollapseLog
Layer 2 中调用 LLM 生成摘要时,使用独立的系统提示词
`compress_context_with_hooks_and_log()` 在每次压缩后记录结构化 commit
```
系统: "你是一个对话摘要助手。请提取对话的关键信息和结论。"
用户: "请用简洁的中文总结以下对话历史的要点(不超过500字):
[用户] ...
[助手] ...
[工具] ..."
```rust
log.commit(CollapseMethod::LlmSummary, (after_count, before_count), summary);
```
### 6.3 身份再注入
如果压缩后消息过少(≤4 条),注入身份确认块防止模型丢失上下文认知:
```
[身份确认] 你是一位专业的天体物理学研究助手。以上是历史对话的压缩摘要。
你正在进行的研究任务是回答用户的问题。请基于摘要中的关键信息继续工作,
需要更多信息时主动使用工具搜索。
```
### 6.4 安全切割
`find_safe_cut_point()` 确保压缩时不会破坏 `assistant(tool_calls)` / `tool_result` 配对关系,向前追溯找到完整工具交互的边界。
### 6.5 熔断器
`CompactionCircuitBreaker` 防止连续压缩失败时的无限循环。连续 3 次压缩后消息数未减少 → 打开熔断器,后续跳过自动压缩。
超 5 条 commits 时触发**溢出合并**,将最早的 commits 合并为摘要注入消息列表。详见 `collapse.rs`
---
## Hook 系统与提示词交互 (`src/agent/hooks.rs`)
## Hook 系统与提示词交互
Hook 系统定义 9 个生命周期事件,其中与提示词相关的交互
9 个生命周期事件
| Hook | 与提示词的关系 |
|:---|:---|
| `OnSessionStart` | 提示词组装前触发,可影响任务状态恢复逻辑 |
| `PreToolUse::MutateInput` | 可向工具执行注入 `additional_context`为 user 消息追加 |
| `PreToolUse::Block` | 阻止特定工具的执行(如取消检查 |
| `PostToolUse::MutateOutput` | 可修改工具输出内容(影响后续 LLM 看到的 context |
| `OnStepComplete` | 每步结束记录 token 估算、消息数等指标 |
| `PreCompact` | 压缩前记录消息数和 token 估算 |
| `PostCompact` | 压缩后记录最终消息数和压缩方法 |
| `OnSubagentStart/Stop` | 子代理启动/停止时传递 prompt 和结果摘要 |
| `OnSessionStop` | 会话终止时清理取消状态并记录终止原因 |
| `OnSessionStart` | 提示词组装前触发 |
| `PreToolUse::MutateInput` | 注入 `additional_context`追加为 user 消息) |
| `PostToolUse::MutateOutput` | 修改工具输出(影响 LLM 看到的 context |
| `PreCompact / PostCompact` | 压缩前后记录指标 + CollapseLog commit |
| `OnSubagentStart/Stop` | 传递子代理 prompt 和结果摘要 |
详见 [hooks.md](hooks.md)。
---
## 完整数据流
## Claude Code 工具按需发现机制(参考分析)
```mermaid
flowchart TD
RT["AgentRuntime 创建<br/>system_prompt() 调用"]
RT --> S1["Section 1: identity<br/>(静态常量)"]
RT --> S2["Section 2: tools<br/>(ToolRegistry definitions)"]
RT --> S3["Section 3: skills<br/>(SkillRegistry.build_reminder)"]
S1 --> S4
S2 --> S4
S3 --> S4["Section 4: memory (可选)<br/>(MemoryManager.build_reminder, 5 entries)"]
S4 --> S5["Section 5: principles<br/>(静态常量)"]
S5 --> ASM["assemble()<br/>join('\n\n')"]
ASM --> Main["主 Agent 上下文<br/>build_initial_context()<br/>+ nudge 注入 + 任务恢复 + 后台通知"]
ASM --> Sub["子 Agent 上下文<br/>SubAgentRunner.run()<br/>(独立 system_prompt)"]
Main --> React["ReAct 循环"]
Main --> NudgeInj["Nudge 消息注入 (user)"]
Main --> Compact["压缩层<br/>generate_summary()<br/>+ identity re-injection"]
> 当前项目工具数量较少(~25 个),尚未实现此机制。以下为 Claude Code 的设计分析,作为未来工具增长时的参考。
### 要解决的问题
当接入大量 MCP 工具(30+ 服务器,100+ 工具)时,所有工具的完整 JSON Schema 在每轮 API 调用中占据大量上下文——绝大多数工具从未被调用,却每轮都在消耗 token。Claude Code 的解决方案:**延迟加载 + 按需发现**。
### 两层架构
**Layer 1: `defer_loading` 标记**
工具分为两类:
| 类型 | 行为 | 示例 |
|------|------|------|
| 常驻工具 | 始终在 `tools` 数组中,立即可用 | Read、Write、Bash、Glob、Grep、Task、TodoWrite |
| 延迟工具 | 标记 `defer_loading: true`,仅在模型主动发现后才加入 `tools` 数组 | MCP 工具、EnterPlanMode、NotebookEdit、LSP 工具 |
判断逻辑(`isDeferredTool()`):
```
1. alwaysLoad == true → 永不延迟
2. isMcp == true → 始终延迟(除非 MCP server 设 _meta['anthropic/alwaysLoad']
3. shouldDefer == true → 延迟
```
**Layer 2: ToolSearch 工具 + tool_reference 块**
模型通过 `ToolSearch` 工具按需发现延迟工具:
```
模型: ToolSearch(query: "github create PR")
系统: 返回 tool_reference 块 → [mcp__github__createPullRequest, mcp__github__listPRs]
下次 API 调用: 这两个工具的完整 schema 加入 tools 数组
```
`tool_reference` 是一个特殊的 content block 类型,API 服务端收到后会展开为完整工具定义,模型可以在后续 turn 直接调用。
### 消息流转
```
Turn N:
tools 数组 = 常驻工具 + ToolSearch + 之前发现过的延迟工具
<available-deferred-tools> 块列出所有可发现的延迟工具名称
→ 模型调用 ToolSearch(query: "slack")
→ tool_result 包含 tool_reference 块: [mcp__slack__sendMessage]
Turn N+1:
tools 数组 = 常驻工具 + ToolSearch + [mcp__slack__sendMessage, ...]
→ 模型可以直接调用 mcp__slack__sendMessage
```
### 发现状态持久化
`extractDiscoveredToolNames()` 从消息历史中扫描所有 `tool_reference` 块,提取已发现的工具名。压缩时通过 compact boundary marker 的 `preCompactDiscoveredTools` 字段保存已发现集合,防止压缩丢失发现状态。
### 工具发现状态变更通知
两个机制告诉模型有哪些可发现工具:
**Legacy**`<available-deferred-tools>` 块作为 user 消息前置注入(每次工具池变更都会 bust prompt cache)。
**Modern**`deferred_tools_delta` 附件——diff 上次通知的工具池,仅发送增量变更(added/removed),避免 bust cache。
### ToolSearch 搜索方式
- **精确选择** `select:ToolA,ToolB` — 按名称直接取工具
- **关键词搜索** `github create PR` — 搜索工具名 + searchHint + description,加权评分
- **必选词** `+slack send``+` 前缀表示必须匹配
### 对项目的适用性
| 当前状态 | 是否需要 |
|---------|---------|
| ~25 个工具,schema 总共 ~8K tokens | **暂不需要** |
| 无 MCP 工具接入 | 不需要 |
| 工具数量稳定 | 不需要 |
**触发条件**:当工具数量超过 ~50 或接入 MCP 服务器时,可以按以下步骤接入:
1. 为 MCP 工具设置 `shouldDefer: true` / `isMcp: true`
2. 注册 `ToolSearch` 工具
3. 在 ToolRegistry 中维护 `deferred_tool_names` 集合
4. API 调用时过滤 tools 数组 + 注入 `<available-deferred-tools>`
5. 压缩时保存 `preCompactDiscoveredTools` 快照
---
@@ -327,33 +328,27 @@ flowchart TD
| 文件 | 职责 |
|:---|:---|
| `src/agent/runtime/system_prompt.rs` | SystemPrompt 组装器 + 静态常量 |
| `src/agent/runtime/mod.rs:1154-1203` | `system_prompt()` 方法 — 5 section 拼装 |
| `src/agent/runtime/system_prompt.rs` | SystemPrompt 组装器 + 6 个静态常量 + SystemPromptCache |
| `src/agent/runtime/mod.rs` | `system_prompt()` + `build_environment_section()` |
| `src/agent/runtime/context.rs` | `build_initial_context()` — 上下文初始化 + 任务恢复 |
| `src/agent/skills.rs` | SkillRegistry — 两层技能加载 + 热重载 |
| `src/agent/memory/mod.rs` | MemoryManager — 记忆加载 + system reminder 构建 |
| `src/agent/compact.rs` | 四层压缩 + LLM 摘要 prompt + 身份再注入 |
| `src/agent/tools/subagent.rs` | 子代理系统提示词(硬编码) |
| `src/agent/subagent.rs` | SubAgentRunner — 子代理 ReAct 循环 |
| `src/agent/team/teammate.rs` | 队友 ReAct 循环(外部传入 system_prompt |
| `src/agent/hooks.rs` | 9 个生命周期 hook + 提示词交互 |
| `src/agent/memory/mod.rs` | MemoryManager — 按 recency 排序的记忆注入 |
| `src/agent/tools/mod.rs` | ToolRegistry — schema_cache + definition_filter |
| `src/agent/tools/subagent.rs` | 子代理模块化系统提示词构建 |
| `src/agent/subagent.rs` | SubAgentRunner — new_with_registry_and_hooks |
| `src/agent/compact.rs` | 四层压缩 + CollapseLog 集成 |
| `src/agent/compact/collapse.rs` | CollapseLog — 压缩历史记录 + 溢出合并 |
| `src/agent/hooks/` | 生命周期 hook + 提示词交互 |
| `docs/architecture/agent/system-prompt-optimization-plan.md` | 优化计划文档(背景、方案、对比分析) |
---
## 设计要点
### 优势
1. **模块化 section 组装**:各 section 独立管理,便于调试和迭代
2. **静态 section 前置**:最大化 Anthropic prompt cache 命中率,降低延迟和成本
3. **两层 skill 加载**:避免一次性注入所有 skill 的 token 浪费
4. **压缩时身份再注入**:防止激进压缩后模型丢失角色认知
5. **安全切割点**`find_safe_cut_point` 确保压缩不破坏 tool_call/tool_result 配对
6. **运行时 nudge 而非 system prompt 编辑**:遵循开闭原则,system prompt 保持稳定
### 潜在改进方向
1. **子代理系统提示词继承**:当前子代理的 system prompt 是硬编码的,可考虑让子代理也接收 section 组装器,选择性继承 skills/memory
2. **压缩 prompt 外部化**:摘要生成和身份确认的 prompt 可配置化,便于独立调优
3. **记忆注入锁竞争**`memory_manager.try_lock()` 在高并发下可能静默失败,考虑使用 `RwLock::read()`
4. **工具描述摘要策略**:80 字符截断可能丢失关键语义,可考虑 LLM 预生成工具描述摘要
1. **静态前置,动态后置**:内容不变的 section 先出现,服务端自然按哈希缓存
2. **首次计算,永久缓存**:session 内一切不变,不需要 TTL;仅在 /clear 时全部失效
3. **模块化 section**:各 section 独立管理,便于调试、增删、A/B 测试
4. **两层 skill 加载**20 tokens 列表 vs 2000 tokens 全文,按需加载
5. **子代理完整提示词**:共享主代理的静态常量 + 独立 tools 列表
6. **CollapseLog 追踪**:每次压缩记录结构化 commit,溢出自动合并
7. **运行时 nudge**:干预通过 user 消息注入,不修改 system prompt(开闭原则)
+41 -27
View File
@@ -16,7 +16,7 @@ graph TB
subgraph Layer2["执行协调层 — Executor"]
direction LR
EX["src/agent/runtime/executor.rs<br/>验证 → PreToolUse hooks → 并行调度 → PostToolUse"]
SX["src/agent/runtime/streaming_executor.rs (流式变体)<br/>流式 tool_use 到达时立即调度 + Sibling Abort"]
SX["src/agent/runtime/streaming_executor.rs (流式变体)<br/>流式 tool_use 到达时立即调度 + Sibling Abort<br/>非并发工具独占执行 (executing_non_concurrent 标志)"]
end
subgraph Layer3["业务逻辑层 — AgentTool Trait + 工具实现"]
@@ -185,21 +185,30 @@ LLM stream → tool_calls[]
│ └─ mutated_args + additional_contexts 收集
┌─ execute_parallel() ─────────────────────────────────────────┐
│ FuturesUnordered 并发调度: │
│ 每个 PreparedCall → tokio::spawn(async { │
│ tokio::select! { │
│ timeout(tool_timeout_secs) → 执行工具 │
│ cancel_fut (每 250ms 轮询) → 返回错误 │
│ } │
│ }) │
┌─ execute_parallel() — ToolPartitioner 批次调度 ──────────────┐
│ │
中断处理:
Phase 3a: 构建非拒绝工具的 (原索引, PreparedCall) 映射
│ Phase 3b: ToolPartitioner::partition() 分区 │
│ · 连续 concurrency_safe 工具 → 并行批次 │
│ · 非 concurrency_safe 工具 → 独占串行批次 │
│ · 示例: [read, grep, bash, read, write] │
│ → [read∥grep], [bash], [read], [write] │
│ │
│ Phase 3c: 逐批次执行 │
│ · 并行批次 → FuturesUnordered 并发 (tokio::spawn each) │
│ · 串行批次 → 逐个执行 (前一个完成后才启动下一个) │
│ │
│ 中断处理(每工具内): │
│ tokio::select! { │
│ timeout(tool_timeout_secs) → 执行工具 │
│ cancel_fut (每 250ms 轮询) → 返回错误 │
│ } │
│ InterruptBehavior::Block → 忽略 cancel_fut,等完成 │
│ InterruptBehavior::Cancel → 响应取消,注入错误 │
│ │
渐进式结果处理 (while exec_futs.next()):
完成即处理,快工具不因慢工具阻塞
辅助函数:
execute_single_tool() — 单工具超时+取消+执行
│ process_single_result() — 结果处理+SSE推送+TTL持久化+Hook │
└──────────────────────────────────────────────────────────────┘
▼ (每个工具完成后逐个处理)
@@ -221,27 +230,32 @@ LLM stream → tool_calls[]
### 并发模型细节
```rust
// executor.rs: FuturesUnordered 中的每个 future
Box::pin(async move {
let interrupt_behavior = tool.interrupt_behavior();
let is_blocking = interrupt_behavior == InterruptBehavior::Block;
tokio::select! {
res = tokio::time::timeout(timeout_dur, tool_fut) => {
// 正常完成或超时
// executor.rs Phase 3c: 逐批次执行
for batch in &batches {
if batch.is_parallel {
// 并行批次: FuturesUnordered 内并发执行
let mut exec_futs: FuturesUnordered<_> = batch.calls.iter()
.map(|prep| execute_single_tool(...))
.collect();
while let Some(result) = exec_futs.next().await {
process_single_result(...).await;
}
_ = cancel_fut => {
// 仅当 !is_blocking 时此分支可达
// Block 工具的 cancel_fut loop 不 break
} else {
// 串行批次: 逐个执行(非并发安全工具独占)
for prep in &batch.calls {
let result = execute_single_tool(...).await;
process_single_result(...).await;
}
}
})
}
```
关键特性:
- 所有工具放入同一个 `FuturesUnordered`,不区分串行/并行批次
- `is_concurrency_safe` 声明为语义标记(引导 LLM 并发调用),执行时全部并发
- 实际串行化依赖工具内部的 mutex/文件锁
- `ToolPartitioner::partition()` 将工具调用分为并行批次和串行批次
- 并行批次内使用 `FuturesUnordered` 最大化并发
- 串行批次内逐个执行(如 `run_bash``file_write` 独占)
- 连续的并发安全工具自动合并到一个并行批次
- `is_concurrency_safe(args)` 是**输入感知**的:同一工具可能因参数不同而安全属性不同
- `InterruptBehavior::Block` 保护写入操作不被用户取消打断
---
@@ -0,0 +1,20 @@
-- Migration: Session Rewind Support
--
-- 添加会话回退 (undo) 所需的软删除基础设施。
-- 参考 Hermes-Agent hermes_state.py active=0 模型。
--
-- active 列:
-- 1 = 活跃消息(对 LLM 可见,默认值)
-- 0 = 软删除(rewind 操作移除,LLM 不可见,保留用于审计)
--
-- rewind_count:
-- 单调递增计数器,记录会话被回退的次数
-- 1. agent_messages: 添加 active 列
ALTER TABLE agent_messages ADD COLUMN active INTEGER NOT NULL DEFAULT 1;
-- 2. agent_sessions: 添加 rewind_count
ALTER TABLE agent_sessions ADD COLUMN rewind_count INTEGER NOT NULL DEFAULT 0;
-- 3. 加速 active=1 过滤的索引(覆盖最常用的查询模式)
CREATE INDEX IF NOT EXISTS idx_agent_messages_active ON agent_messages(session_id, active);
@@ -0,0 +1,13 @@
-- Migration: Session Branch Support
--
-- 添加会话分叉所需的基础设施。
-- 参考 Hermes-Agent parent_session_id 模型。
--
-- parent_session_id: 分叉的源会话 IDNULL = 根会话)
-- branched_from: 分叉的消息 ID(在源会话中的分叉点)
-- 1. agent_sessions: 添加 parent_session_id
ALTER TABLE agent_sessions ADD COLUMN parent_session_id TEXT REFERENCES agent_sessions(session_id);
-- 2. agent_sessions: 添加分支元数据 JSON(存储 {branched_from, branch_reason} 等)
ALTER TABLE agent_sessions ADD COLUMN branch_metadata TEXT;
+179 -19
View File
@@ -21,6 +21,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use tracing::{info, warn};
use super::hooks::{HookRegistry, PostCompactContext, PreCompactContext};
use collapse::{CollapseLog, CollapseMethod};
/// 递归守卫:防止压缩内部触发的 LLM 调用再次触发压缩。
static COMPACTING: AtomicBool = AtomicBool::new(false);
@@ -259,8 +260,42 @@ fn inject_identity_block(messages: &mut Vec<ChatMessage>) {
}
}
/// 使用 LLM 生成对话摘要。
async fn generate_summary(to_summarize: &[ChatMessage], llm: &LlmClient) -> Result<String, String> {
/// 摘要标记前缀,用于识别已有的压缩摘要。
const SUMMARY_MARKER: &str = "[历史对话摘要]";
const COMPRESSION_LOG_MARKER: &str = "[上下文压缩历史]";
/// 从消息列表中提取已有的历史摘要内容。
/// 返回 None 表示无已有摘要。
fn extract_prior_summary(messages: &[ChatMessage]) -> Option<String> {
// 从后往前找最近的一份摘要(可能有多次压缩)
for msg in messages.iter().rev() {
if let Some(ref content) = msg.content {
if content.starts_with(SUMMARY_MARKER) || content.starts_with(COMPRESSION_LOG_MARKER) {
// 剥离标记,提取纯摘要内容
let summary_body = if let Some(pos) = content.find('\n') {
content[pos + 1..].trim().to_string()
} else {
content.clone()
};
if !summary_body.is_empty() && summary_body.len() < 2000 {
return Some(summary_body);
}
}
}
}
None
}
/// 使用 LLM 生成对话摘要,融合已有摘要实现迭代信息保留。
///
/// 参考 Hermes context_compressor.py 的迭代摘要设计:
/// 如果提供了 `prior_summary`,LLM 会将其作为已有背景融入新摘要,
/// 确保多次压缩后历史信息不丢失。
async fn generate_summary(
to_summarize: &[ChatMessage],
prior_summary: Option<&str>,
llm: &LlmClient,
) -> Result<String, String> {
let summary_content: String = to_summarize
.iter()
.filter_map(|m| {
@@ -278,10 +313,24 @@ async fn generate_summary(to_summarize: &[ChatMessage], llm: &LlmClient) -> Resu
.collect::<Vec<_>>()
.join("\n");
let summary_prompt = format!(
"请用简洁的中文总结以下对话历史的要点(不超过500字):\n\n{}",
summary_content
);
// ── 迭代摘要融合 ──
// 如果已有之前的摘要,将其作为上下文传给 LLM,要求新摘要融合旧内容
let summary_prompt = if let Some(prior) = prior_summary {
format!(
"你是一个对话摘要助手。请将以下信息合并为一份简洁的中文摘要(不超过500字)。\n\n\
\n\
1. \n\
2. \n\n\
\n{}\n\n\
\n{}",
prior, summary_content
)
} else {
format!(
"请用简洁的中文总结以下对话历史的要点(不超过500字):\n\n{}",
summary_content
)
};
llm.chat_completion(
"你是一个对话摘要助手。请提取对话的关键信息和结论。",
@@ -290,7 +339,12 @@ async fn generate_summary(to_summarize: &[ChatMessage], llm: &LlmClient) -> Resu
.await
.map_err(|e| {
warn!("[Compact] 上下文摘要生成失败: {},尝试激进压缩", e);
format!("[历史摘要] 此前进行了 {} 轮对话交互", to_summarize.len())
// 失败时保留旧摘要(如果有)+ 新增轮数
if let Some(prior) = prior_summary {
format!("{}(后续进行了 {} 轮对话)", prior, to_summarize.len())
} else {
format!("[历史摘要] 此前进行了 {} 轮对话交互", to_summarize.len())
}
})
}
@@ -309,7 +363,7 @@ async fn compress_with_fallback(
return;
}
// Layer 2: auto_compactLLM 摘要)
// Layer 2: auto_compactLLM 摘要,含迭代融合
let system_msg = messages.first().cloned();
let cut_point = find_safe_cut_point(messages, 10);
let to_summarize = &messages[1..cut_point];
@@ -317,7 +371,11 @@ async fn compress_with_fallback(
return;
}
let summary = match generate_summary(to_summarize, llm).await {
// ── 迭代摘要融合 ──
// 在切割前提取已有摘要,传给 LLM 以实现迭代融合
let prior_summary = extract_prior_summary(messages);
let summary = match generate_summary(to_summarize, prior_summary.as_deref(), llm).await {
Ok(s) => s,
Err(fallback) => fallback,
};
@@ -359,12 +417,34 @@ pub async fn compress_context(
}
/// 带 Hook 的上下文压缩变体。如果提供了 HookRegistry,会在压缩前后触发事件。
/// 如果提供了 CollapseLog,会在每次压缩后记录 commit。
pub async fn compress_context_with_hooks(
messages: &mut Vec<ChatMessage>,
llm: &LlmClient,
context_char_limit: usize,
session_id: &str,
hook_registry: Option<&HookRegistry>,
) {
compress_context_with_hooks_and_log(
messages,
llm,
context_char_limit,
session_id,
hook_registry,
None,
)
.await;
}
/// 带 Hook 和 CollapseLog 的上下文压缩变体。
/// 压缩后自动记录 commit 到 CollapseLog,并在溢出时注入合并摘要。
pub async fn compress_context_with_hooks_and_log(
messages: &mut Vec<ChatMessage>,
llm: &LlmClient,
context_char_limit: usize,
session_id: &str,
hook_registry: Option<&HookRegistry>,
collapse_log: Option<&std::sync::Mutex<CollapseLog>>,
) {
if messages.len() <= 4 {
return;
@@ -393,21 +473,67 @@ pub async fn compress_context_with_hooks(
// 执行多层回退压缩(注:完整会话历史已在 agent_messages 表中持久化,无需额外 transcript 快照)
compress_with_fallback(messages, llm, context_char_limit).await;
let after_count = messages.len();
let method = if after_count < before_count / 2 {
"llm_summary"
} else if after_count < before_count {
"snip_or_micro"
} else {
"none"
};
// ── CollapseLog 记录 ──
if let Some(log) = collapse_log {
if after_count < before_count {
// 压缩发生了:记录 commit
if let Ok(mut log_guard) = log.lock() {
let collapse_method = match method {
"llm_summary" => CollapseMethod::LlmSummary,
_ => CollapseMethod::MicroCompact,
};
let removed_range = (after_count, before_count);
let summary = format!(
"压缩 {} → {} 条消息 (方法: {}, 节省 ~{} tokens)",
before_count,
after_count,
method,
est_tokens.saturating_sub(rough_estimate_tokens(messages))
);
log_guard.commit(collapse_method, removed_range, summary);
// 溢出检查:如果 commits 过多,注入合并摘要
if log_guard.should_overflow() {
if let Some(overflow_summary) = log_guard.overflow() {
info!(
"[Compact] CollapseLog 溢出,注入合并摘要 ({} chars)",
overflow_summary.len()
);
// 注入到消息列表头部(系统消息之后)
let insert_pos = if messages
.first()
.is_some_and(|m| m.role == MessageRole::System)
{
1
} else {
0
};
messages.insert(
insert_pos,
ChatMessage::user(format!("[上下文压缩历史]\n{}", overflow_summary)),
);
}
}
}
}
}
// OnPostCompact hook
if let Some(registry) = hook_registry {
registry
.run_on_post_compact(&PostCompactContext {
session_id: session_id.to_string(),
new_message_count: messages.len(),
compression_method: if messages.len() < before_count / 2 {
"llm_summary"
} else if messages.len() < before_count {
// snip_compact 会产生占位消息但保留尾部,micro_compact 替换内容
"snip_or_micro"
} else {
"none"
}
.to_string(),
new_message_count: after_count,
compression_method: method.to_string(),
})
.await;
}
@@ -639,6 +765,40 @@ mod tests {
);
}
#[test]
fn test_extract_prior_summary_finds_existing() {
let messages = vec![
ChatMessage::system("System"),
ChatMessage::user("[历史对话摘要]\n步骤1-3: 搜索了黑洞相关文献。"),
ChatMessage::user("继续研究"),
];
let prior = extract_prior_summary(&messages);
assert!(prior.is_some());
assert!(prior.unwrap().contains("黑洞"));
}
#[test]
fn test_extract_prior_summary_ignores_compression_log() {
let messages = vec![
ChatMessage::system("System"),
ChatMessage::user("[上下文压缩历史]\n第一次压缩: 50→25 条消息"),
ChatMessage::user("继续"),
];
let prior = extract_prior_summary(&messages);
assert!(prior.is_some());
assert!(prior.unwrap().contains("第一次压缩"));
}
#[test]
fn test_extract_prior_summary_none_when_no_marker() {
let messages = vec![
ChatMessage::system("System"),
ChatMessage::user("普通用户消息"),
];
let prior = extract_prior_summary(&messages);
assert!(prior.is_none());
}
#[test]
fn test_snip_compact_respects_tool_pairing() {
// 构建 tool_call/tool_result 配对靠近切割点的场景
-994
View File
@@ -1,994 +0,0 @@
// src/agent/hooks.rs
//
// Agent 生命周期 Hooks 系统。
// 参考 Claude Code 的 PreToolUse / PostToolUse / Stop hooks 设计,
// 提供可扩展的事件回调链,支持:
// - OnSessionStart — 会话创建/恢复时
// - PreToolUse — 工具执行前(可拦截/阻止/修改输入/注入上下文)
// - PostToolUse — 工具执行后(审计日志、指标采集、输出修改)
// - OnStepComplete — 每步结束(指标更新、上下文检查)
// - OnSessionStop — 会话终止(清理、持久化)
// - OnSubagentStart — 子代理启动时
// - OnSubagentStop — 子代理停止时
// - OnPreCompact — 上下文压缩前
// - OnPostCompact — 上下文压缩后
//
// P3 增强(参考 Claude Code hooks 协议):
// - PreToolUseAction 支持 MutateInput(修改工具参数)和 PermissionRequired
// - PostToolUseAction 支持 MutateOutput(修改工具输出)
// - 扩展生命周期事件(SubagentStart/Stop, PreCompact/PostCompact
// - HookRegistry 返回累积的 additional_context 和 mutate_output
use async_trait::async_trait;
use sqlx::SqlitePool;
use std::collections::HashMap;
use std::sync::Arc;
use tracing::{info, warn};
use super::terminal::TurnTerminal;
// ── Hook Contexts ──
/// 会话启动上下文
#[derive(Debug, Clone)]
pub struct SessionStartContext {
pub session_id: String,
pub turn_index: i32,
pub is_resume: bool,
}
/// PreToolUse hook 上下文
#[derive(Debug, Clone)]
pub struct PreToolUseContext {
pub session_id: String,
pub tool_name: String,
pub tool_args: serde_json::Value,
pub step: usize,
}
/// PostToolUse hook 上下文
#[derive(Debug, Clone)]
pub struct PostToolUseContext {
pub session_id: String,
pub agent_name: String,
pub tool_name: String,
pub tool_args: serde_json::Value,
pub output_content: String,
pub is_error: bool,
pub step: usize,
pub elapsed_ms: u64,
}
/// Step 完成上下文
#[derive(Debug, Clone)]
pub struct StepCompleteContext {
pub session_id: String,
pub step: usize,
pub max_steps: usize,
pub messages_count: usize,
pub estimated_tokens: usize,
pub token_limit: usize,
}
/// Session 终止上下文
#[derive(Debug, Clone)]
pub struct SessionStopContext<'a> {
pub session_id: String,
pub terminal: &'a TurnTerminal,
pub total_steps: usize,
}
/// 子代理启动上下文
#[derive(Debug, Clone)]
pub struct SubagentStartContext {
pub parent_session_id: String,
pub subagent_name: String,
pub prompt: String,
}
/// 子代理停止上下文
#[derive(Debug, Clone)]
pub struct SubagentStopContext {
pub parent_session_id: String,
pub subagent_name: String,
pub result_summary: String,
pub steps: usize,
pub is_error: bool,
}
/// 压缩前上下文
#[derive(Debug, Clone)]
pub struct PreCompactContext {
pub session_id: String,
pub message_count: usize,
pub estimated_tokens: usize,
}
/// 压缩后上下文
#[derive(Debug, Clone)]
pub struct PostCompactContext {
pub session_id: String,
pub new_message_count: usize,
pub compression_method: String,
}
// ── Hook Actions ──
/// PreToolUse hook 返回的增强动作。
/// 支持:允许、阻止、修改输入、权限请求。
#[derive(Debug, Clone)]
pub enum PreToolUseAction {
/// 允许继续执行(默认)
Continue,
/// 阻止执行,附带原因
Block { reason: String },
/// 允许执行但修改输入参数或注入附加上下文
MutateInput {
updated_args: serde_json::Value,
additional_context: Option<String>,
},
/// 需要权限决策
PermissionRequired {
permission: String,
tool_name: String,
},
}
impl PreToolUseAction {
pub fn is_blocked(&self) -> bool {
matches!(self, PreToolUseAction::Block { .. })
}
pub fn block_reason(&self) -> Option<&str> {
match self {
PreToolUseAction::Block { reason } => Some(reason),
_ => None,
}
}
pub fn updated_args(&self) -> Option<&serde_json::Value> {
match self {
PreToolUseAction::MutateInput { updated_args, .. } => Some(updated_args),
_ => None,
}
}
pub fn additional_context(&self) -> Option<&str> {
match self {
PreToolUseAction::MutateInput {
additional_context, ..
} => additional_context.as_deref(),
_ => None,
}
}
}
/// 向后兼容类型别名 — 旧代码用 HookAction::Continue / HookAction::Block 仍可编译
pub type HookAction = PreToolUseAction;
/// PostToolUse hook 返回的动作。
/// 支持:保持输出不变、修改输出内容。
#[derive(Debug, Clone)]
pub enum PostToolUseAction {
/// 保持原输出不变(默认)
Continue,
/// 修改输出内容
MutateOutput { updated_content: String },
}
// ── Metrics Data ──
/// 可查询的运行指标快照
#[derive(Debug, Clone, Default)]
pub struct MetricsData {
pub tool_call_counts: HashMap<String, usize>,
pub total_steps: usize,
pub total_errors: usize,
pub session_id: Option<String>,
}
// ── Hook Trait ──
/// Agent 生命周期 Hook trait。
/// 所有方法都有默认空实现,只需覆写关心的 hook 点。
#[async_trait]
pub trait AgentHook: Send + Sync {
/// Hook 名称(用于日志和调试)
fn name(&self) -> &str;
// ── 原有 5 个生命周期事件 ──
/// 会话创建/恢复时调用。
async fn on_session_start(&self, _ctx: &SessionStartContext) {}
/// 工具执行前调用。可返回 Continue/Block/MutateInput/PermissionRequired。
async fn pre_tool_use(&self, _ctx: &PreToolUseContext) -> PreToolUseAction {
PreToolUseAction::Continue
}
/// 工具执行后调用。可返回 Continue 或 MutateOutput。
async fn post_tool_use(&self, _ctx: &PostToolUseContext) -> PostToolUseAction {
PostToolUseAction::Continue
}
/// 每个 ReAct step 完成后调用。
async fn on_step_complete(&self, _ctx: &StepCompleteContext) {}
/// 会话终止时调用。
async fn on_session_stop(&self, _ctx: &SessionStopContext<'_>) {}
// ── 新增 4 个生命周期事件(默认 no-op) ──
/// 子代理启动时调用。
async fn on_subagent_start(&self, _ctx: &SubagentStartContext) {}
/// 子代理停止时调用。
async fn on_subagent_stop(&self, _ctx: &SubagentStopContext) {}
/// 上下文压缩前调用。
async fn on_pre_compact(&self, _ctx: &PreCompactContext) {}
/// 上下文压缩后调用。
async fn on_post_compact(&self, _ctx: &PostCompactContext) {}
}
// ── Hook Registry ──
/// PreToolUse 聚合结果
#[derive(Debug, Clone)]
pub struct PreToolUseResult {
/// 最终动作(第一个 Block 获胜)
pub action: PreToolUseAction,
/// 累积的 additional_context(所有 MutateInput 的上下文拼接)
pub additional_context: Option<String>,
/// 最终的工具参数(应用了最后一个 MutateInput 的修改)
pub final_args: serde_json::Value,
}
impl PreToolUseResult {
/// 是否有 hook 请求了权限确认
pub fn is_permission_required(&self) -> bool {
matches!(self.action, PreToolUseAction::PermissionRequired { .. })
}
/// 获取权限确认的详情(permission 描述, tool_name
pub fn permission_info(&self) -> Option<(&str, &str)> {
match &self.action {
PreToolUseAction::PermissionRequired {
permission,
tool_name,
} => Some((permission.as_str(), tool_name.as_str())),
_ => None,
}
}
}
/// PostToolUse 聚合结果
#[derive(Debug, Clone)]
pub struct PostToolUseResult {
/// 最终输出内容(应用了最后一个 MutateOutput 的修改)
pub final_content: String,
}
/// Hook 注册表,管理所有已注册的 hook 并按序调用
pub struct HookRegistry {
hooks: Vec<Box<dyn AgentHook>>,
}
impl Default for HookRegistry {
fn default() -> Self {
Self::new()
}
}
impl HookRegistry {
/// 创建空的注册表
pub fn new() -> Self {
HookRegistry { hooks: Vec::new() }
}
/// 创建包含所有内置 hooks 的注册表。
///
/// 参数:
/// - `db`: 数据库连接池(供 AuditLogHook 持久化)
/// - `cancelled_runs`: 取消状态集合(供 CancellationHook 检查)
/// - `metrics_data`: 可选的共享指标数据引用。提供时复用已有的 MetricsData
/// 使得 AgentRuntime.get_metrics() 能查询到实际运行数据。
pub fn with_builtins(
db: SqlitePool,
cancelled_runs: Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
metrics_data: Option<Arc<std::sync::Mutex<MetricsData>>>,
) -> Self {
let mut registry = Self::new();
registry.add(Box::new(CancellationHook::new(cancelled_runs)));
// 如果提供了共享的 metrics_data,使用它;否则创建新的
let metrics_hook = match metrics_data {
Some(data) => MetricsHook::from_arc(data),
None => MetricsHook::new(),
};
registry.add(Box::new(metrics_hook));
registry.add(Box::new(AuditLogHook::new(db)));
registry
}
/// 注册一个 hook
pub fn add(&mut self, hook: Box<dyn AgentHook>) {
info!("[Hooks] 注册 hook: {}", hook.name());
self.hooks.push(hook);
}
/// 获取所有 hooks 的不可变引用
pub fn all(&self) -> &[Box<dyn AgentHook>] {
&self.hooks
}
// ── 便捷调用方法 ──
/// 调用所有 on_session_start hooks
pub async fn run_on_session_start(&self, ctx: &SessionStartContext) {
for hook in &self.hooks {
hook.on_session_start(ctx).await;
}
}
/// 调用所有 pre_tool_use hooks。
///
/// 返回聚合结果:
/// - 遇到第一个 Block 时短路,返回该 Block
/// - MutateInput 累积 additional_context 并更新 final_args
/// - PermissionRequired 记录但继续执行(暂时视为 Continue)
pub async fn run_pre_tool_use(&self, ctx: &PreToolUseContext) -> PreToolUseResult {
let mut accumulated_context = String::new();
let mut final_args = ctx.tool_args.clone();
let mut final_action = PreToolUseAction::Continue;
for hook in &self.hooks {
let action = hook.pre_tool_use(ctx).await;
match &action {
PreToolUseAction::Block { reason } => {
warn!(
"[Hooks] {} 阻止了工具 {} 的执行: {}",
hook.name(),
ctx.tool_name,
reason
);
return PreToolUseResult {
action,
additional_context: None,
final_args: ctx.tool_args.clone(),
};
}
PreToolUseAction::MutateInput {
updated_args,
additional_context,
} => {
info!(
"[Hooks] {} 修改了工具 {} 的输入参数",
hook.name(),
ctx.tool_name
);
final_args = updated_args.clone();
if let Some(ctx_str) = additional_context {
if !accumulated_context.is_empty() {
accumulated_context.push('\n');
}
accumulated_context.push_str(ctx_str);
}
}
PreToolUseAction::PermissionRequired { .. } => {
info!(
"[Hooks] {} 请求了工具 {} 的权限检查",
hook.name(),
ctx.tool_name
);
}
PreToolUseAction::Continue => {}
}
// Continue 不应覆盖已设置的 meaningful actionBlock/MutateInput/PermissionRequired
if !matches!(action, PreToolUseAction::Continue) {
final_action = action;
}
}
let ctx_opt = if accumulated_context.is_empty() {
None
} else {
Some(accumulated_context)
};
PreToolUseResult {
action: final_action,
additional_context: ctx_opt,
final_args,
}
}
/// 调用所有 post_tool_use hooks(全部执行,不会短路)。
/// 返回聚合的最终输出内容。
pub async fn run_post_tool_use(&self, ctx: &PostToolUseContext) -> PostToolUseResult {
let mut final_content = ctx.output_content.clone();
for hook in &self.hooks {
let action = hook.post_tool_use(ctx).await;
match action {
PostToolUseAction::MutateOutput { updated_content } => {
info!(
"[Hooks] {} 修改了工具 {} 的输出",
hook.name(),
ctx.tool_name
);
final_content = updated_content;
}
PostToolUseAction::Continue => {}
}
}
PostToolUseResult { final_content }
}
/// 调用所有 on_step_complete hooks
pub async fn run_on_step_complete(&self, ctx: &StepCompleteContext) {
for hook in &self.hooks {
hook.on_step_complete(ctx).await;
}
}
/// 调用所有 on_session_stop hooks
pub async fn run_on_session_stop(&self, ctx: &SessionStopContext<'_>) {
for hook in &self.hooks {
hook.on_session_stop(ctx).await;
}
}
/// 调用所有 on_subagent_start hooks
pub async fn run_on_subagent_start(&self, ctx: &SubagentStartContext) {
for hook in &self.hooks {
hook.on_subagent_start(ctx).await;
}
}
/// 调用所有 on_subagent_stop hooks
pub async fn run_on_subagent_stop(&self, ctx: &SubagentStopContext) {
for hook in &self.hooks {
hook.on_subagent_stop(ctx).await;
}
}
/// 调用所有 on_pre_compact hooks
pub async fn run_on_pre_compact(&self, ctx: &PreCompactContext) {
for hook in &self.hooks {
hook.on_pre_compact(ctx).await;
}
}
/// 调用所有 on_post_compact hooks
pub async fn run_on_post_compact(&self, ctx: &PostCompactContext) {
for hook in &self.hooks {
hook.on_post_compact(ctx).await;
}
}
}
// ── Built-in Hooks ──
/// 取消检查 Hook — 在每次工具执行前检查用户是否中止了会话。
pub struct CancellationHook {
cancelled_runs: Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
}
impl CancellationHook {
pub fn new(cancelled_runs: Arc<std::sync::Mutex<std::collections::HashSet<String>>>) -> Self {
CancellationHook { cancelled_runs }
}
}
#[async_trait]
impl AgentHook for CancellationHook {
fn name(&self) -> &str {
"CancellationHook"
}
async fn pre_tool_use(&self, ctx: &PreToolUseContext) -> PreToolUseAction {
if let Ok(runs) = self.cancelled_runs.lock() {
if runs.contains(&ctx.session_id) {
warn!(
"[CancellationHook] 会话 {} 已被用户取消,阻止工具 {} 执行",
ctx.session_id, ctx.tool_name
);
return PreToolUseAction::Block {
reason: "用户已手动中止执行".to_string(),
};
}
}
PreToolUseAction::Continue
}
async fn on_session_stop(&self, ctx: &SessionStopContext<'_>) {
// 清理取消状态
if let Ok(mut runs) = self.cancelled_runs.lock() {
runs.remove(&ctx.session_id);
}
}
}
/// 指标采集 Hook — 自动收集工具调用统计,支持快照查询
pub struct MetricsHook {
data: Arc<std::sync::Mutex<MetricsData>>,
}
impl Default for MetricsHook {
fn default() -> Self {
Self::new()
}
}
impl MetricsHook {
pub fn new() -> Self {
MetricsHook {
data: Arc::new(std::sync::Mutex::new(MetricsData::default())),
}
}
/// 从已有的 Arc<Mutex<MetricsData>> 创建(共享数据引用)
pub fn from_arc(data: Arc<std::sync::Mutex<MetricsData>>) -> Self {
MetricsHook { data }
}
/// 返回当前指标快照(锁异常时返回 None)
pub fn snapshot(&self) -> Option<MetricsData> {
self.data.lock().ok().map(|d| d.clone())
}
/// 获取 Arc 引用,供外部持有
pub fn data_arc(&self) -> Arc<std::sync::Mutex<MetricsData>> {
self.data.clone()
}
}
#[async_trait]
impl AgentHook for MetricsHook {
fn name(&self) -> &str {
"MetricsHook"
}
async fn on_session_start(&self, ctx: &SessionStartContext) {
if let Ok(mut data) = self.data.lock() {
data.session_id = Some(ctx.session_id.clone());
}
}
async fn post_tool_use(&self, ctx: &PostToolUseContext) -> PostToolUseAction {
info!(
"[Metrics] step={} tool={} is_error={} elapsed={}ms",
ctx.step, ctx.tool_name, ctx.is_error, ctx.elapsed_ms
);
if let Ok(mut data) = self.data.lock() {
*data
.tool_call_counts
.entry(ctx.tool_name.clone())
.or_insert(0) += 1;
data.total_steps = ctx.step;
if ctx.is_error {
data.total_errors += 1;
}
}
PostToolUseAction::Continue
}
async fn on_step_complete(&self, ctx: &StepCompleteContext) {
if ctx.step.is_multiple_of(3) {
info!(
"[Metrics] step {}/{} | messages={} | tokens≈{}/{}",
ctx.step, ctx.max_steps, ctx.messages_count, ctx.estimated_tokens, ctx.token_limit
);
}
}
async fn on_session_stop(&self, ctx: &SessionStopContext<'_>) {
info!(
"[Metrics] 会话 {} 结束: {} (total_steps={})",
ctx.session_id,
ctx.terminal.description(),
ctx.total_steps
);
}
}
/// 审计日志 Hook — 记录所有工具调用到 SQLite agent_audit_log 表
pub struct AuditLogHook {
db: SqlitePool,
}
impl AuditLogHook {
pub fn new(db: SqlitePool) -> Self {
AuditLogHook { db }
}
}
#[async_trait]
impl AgentHook for AuditLogHook {
fn name(&self) -> &str {
"AuditLogHook"
}
async fn post_tool_use(&self, ctx: &PostToolUseContext) -> PostToolUseAction {
let status = if ctx.is_error { "FAIL" } else { "OK" };
let preview: String = ctx.output_content.chars().take(200).collect();
info!(
"[Audit] session={} step={} tool={} status={} elapsed={}ms",
ctx.session_id, ctx.step, ctx.tool_name, status, ctx.elapsed_ms
);
let db = self.db.clone();
let session_id = ctx.session_id.clone();
let agent_name = ctx.agent_name.clone();
let tool_name = ctx.tool_name.clone();
let step = ctx.step;
let elapsed = ctx.elapsed_ms;
let preview_clone = preview.clone();
// Fire-and-forget 写入,不阻塞主循环
tokio::spawn(async move {
let _ = sqlx::query(
"INSERT INTO agent_audit_log (session_id, step, tool_name, status, elapsed_ms, output_preview, agent_name) \
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(&session_id)
.bind(step as i32)
.bind(&tool_name)
.bind(status)
.bind(elapsed as i32)
.bind(&preview_clone)
.bind(&agent_name)
.execute(&db)
.await;
});
PostToolUseAction::Continue
}
async fn on_session_stop(&self, ctx: &SessionStopContext<'_>) {
info!(
"[Audit] 会话 {} 终止原因: {}",
ctx.session_id,
ctx.terminal.description()
);
let db = self.db.clone();
let session_id = ctx.session_id.clone();
let total_steps = ctx.total_steps;
tokio::spawn(async move {
let _ = sqlx::query(
"INSERT INTO agent_audit_log (session_id, step, tool_name, status, elapsed_ms, output_preview) \
VALUES (?, ?, 'session', 'SESSION_STOP', 0, ?)",
)
.bind(&session_id)
.bind(total_steps as i32)
.bind(format!("会话终止,共 {}", total_steps))
.execute(&db)
.await;
});
}
}
// ── Tests ──
#[cfg(test)]
mod tests {
use super::*;
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(),
}
}
}
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() {
use std::collections::HashSet;
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
}
}
+265
View File
@@ -0,0 +1,265 @@
// src/agent/hooks/builtins.rs
//
// 内置 Hooks — CancellationHook、MetricsHook、AuditLogHook。
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use sqlx::SqlitePool;
use tracing::{info, warn};
use super::{
AgentHook, HookEvent, MetricsData, PostToolUseAction, PostToolUseContext, PreToolUseAction,
PreToolUseContext, SessionStartContext, SessionStopContext, StepCompleteContext,
};
use tokio;
// ── Context Deduplicator ──
/// 内容哈希去重器 — 单 dispatch cycle 内防止重复上下文注入。
///
/// 使用内容哈希(`std::hash::DefaultHasher`),在同一个 dispatch cycle
/// 内避免多个 hook 注入相同的上下文消息。不跨 step 去重(以允许合法的重复提醒)。
#[derive(Debug, Default)]
pub struct ContextDeduplicator {
seen: std::collections::HashSet<u64>,
}
impl ContextDeduplicator {
pub fn new() -> Self {
ContextDeduplicator {
seen: std::collections::HashSet::new(),
}
}
/// 若 `content` 在此 cycle 中已见则返回 true。
pub fn is_duplicate(&mut self, content: &str) -> bool {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
content.hash(&mut hasher);
let hash = hasher.finish();
!self.seen.insert(hash)
}
}
// ── Built-in Hooks ──
/// 取消检查 Hook — 在每次工具执行前检查用户是否中止了会话。
pub struct CancellationHook {
cancelled_runs: Arc<Mutex<HashSet<String>>>,
}
impl CancellationHook {
pub fn new(cancelled_runs: Arc<Mutex<HashSet<String>>>) -> Self {
CancellationHook { cancelled_runs }
}
}
#[async_trait]
impl AgentHook for CancellationHook {
fn name(&self) -> &str {
"CancellationHook"
}
fn subscribed_events(&self) -> &[HookEvent] {
&[HookEvent::PreToolUse, HookEvent::OnSessionStop]
}
async fn pre_tool_use(&self, ctx: &PreToolUseContext) -> PreToolUseAction {
if let Ok(runs) = self.cancelled_runs.lock() {
if runs.contains(&ctx.session_id) {
warn!(
"[CancellationHook] 会话 {} 已被用户取消,阻止工具 {} 执行",
ctx.session_id, ctx.tool_name
);
return PreToolUseAction::Block {
reason: "用户已手动中止执行".to_string(),
};
}
}
PreToolUseAction::Continue
}
async fn on_session_stop(&self, ctx: &SessionStopContext<'_>) {
// 清理取消状态
if let Ok(mut runs) = self.cancelled_runs.lock() {
runs.remove(&ctx.session_id);
}
}
}
/// 指标采集 Hook — 自动收集工具调用统计,支持快照查询
pub struct MetricsHook {
data: Arc<Mutex<MetricsData>>,
}
impl Default for MetricsHook {
fn default() -> Self {
Self::new()
}
}
impl MetricsHook {
pub fn new() -> Self {
MetricsHook {
data: Arc::new(Mutex::new(MetricsData::default())),
}
}
/// 从已有的 Arc<Mutex<MetricsData>> 创建(共享数据引用)
pub fn from_arc(data: Arc<Mutex<MetricsData>>) -> Self {
MetricsHook { data }
}
/// 返回当前指标快照(锁异常时返回 None)
pub fn snapshot(&self) -> Option<MetricsData> {
self.data.lock().ok().map(|d| d.clone())
}
/// 获取 Arc 引用,供外部持有
pub fn data_arc(&self) -> Arc<Mutex<MetricsData>> {
self.data.clone()
}
}
#[async_trait]
impl AgentHook for MetricsHook {
fn name(&self) -> &str {
"MetricsHook"
}
fn subscribed_events(&self) -> &[HookEvent] {
&[
HookEvent::OnSessionStart,
HookEvent::PostToolUse,
HookEvent::OnStepComplete,
HookEvent::OnSessionStop,
]
}
async fn on_session_start(&self, ctx: &SessionStartContext) {
if let Ok(mut data) = self.data.lock() {
data.session_id = Some(ctx.session_id.clone());
}
}
async fn post_tool_use(&self, ctx: &PostToolUseContext) -> PostToolUseAction {
info!(
"[Metrics] step={} tool={} is_error={} elapsed={}ms",
ctx.step, ctx.tool_name, ctx.is_error, ctx.elapsed_ms
);
if let Ok(mut data) = self.data.lock() {
*data
.tool_call_counts
.entry(ctx.tool_name.clone())
.or_insert(0) += 1;
data.total_steps = ctx.step;
if ctx.is_error {
data.total_errors += 1;
}
}
PostToolUseAction::Continue
}
async fn on_step_complete(&self, ctx: &StepCompleteContext) {
if ctx.step.is_multiple_of(3) {
info!(
"[Metrics] step {}/{} | messages={} | tokens≈{}/{}",
ctx.step, ctx.max_steps, ctx.messages_count, ctx.estimated_tokens, ctx.token_limit
);
}
}
async fn on_session_stop(&self, ctx: &SessionStopContext<'_>) {
info!(
"[Metrics] 会话 {} 结束: {} (total_steps={})",
ctx.session_id,
ctx.terminal.description(),
ctx.total_steps
);
}
}
/// 审计日志 Hook — 记录所有工具调用到 SQLite agent_audit_log 表
pub struct AuditLogHook {
db: SqlitePool,
}
impl AuditLogHook {
pub fn new(db: SqlitePool) -> Self {
AuditLogHook { db }
}
}
#[async_trait]
impl AgentHook for AuditLogHook {
fn name(&self) -> &str {
"AuditLogHook"
}
fn subscribed_events(&self) -> &[HookEvent] {
&[HookEvent::PostToolUse, HookEvent::OnSessionStop]
}
async fn post_tool_use(&self, ctx: &PostToolUseContext) -> PostToolUseAction {
let status = if ctx.is_error { "FAIL" } else { "OK" };
let preview: String = ctx.output_content.chars().take(200).collect();
info!(
"[Audit] session={} step={} tool={} status={} elapsed={}ms",
ctx.session_id, ctx.step, ctx.tool_name, status, ctx.elapsed_ms
);
let db = self.db.clone();
let session_id = ctx.session_id.clone();
let agent_name = ctx.agent_name.clone();
let tool_name = ctx.tool_name.clone();
let step = ctx.step;
let elapsed = ctx.elapsed_ms;
let preview_clone = preview.clone();
// Fire-and-forget 写入,不阻塞主循环
tokio::spawn(async move {
let _ = sqlx::query(
"INSERT INTO agent_audit_log (session_id, step, tool_name, status, elapsed_ms, output_preview, agent_name) \
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(&session_id)
.bind(step as i32)
.bind(&tool_name)
.bind(status)
.bind(elapsed as i32)
.bind(&preview_clone)
.bind(&agent_name)
.execute(&db)
.await;
});
PostToolUseAction::Continue
}
async fn on_session_stop(&self, ctx: &SessionStopContext<'_>) {
info!(
"[Audit] 会话 {} 终止原因: {}",
ctx.session_id,
ctx.terminal.description()
);
let db = self.db.clone();
let session_id = ctx.session_id.clone();
let total_steps = ctx.total_steps;
tokio::spawn(async move {
let _ = sqlx::query(
"INSERT INTO agent_audit_log (session_id, step, tool_name, status, elapsed_ms, output_preview) \
VALUES (?, ?, 'session', 'SESSION_STOP', 0, ?)",
)
.bind(&session_id)
.bind(total_steps as i32)
.bind(format!("会话终止,共 {}", total_steps))
.execute(&db)
.await;
});
}
}
+790
View File
@@ -0,0 +1,790 @@
// src/agent/hooks/dispatch.rs
//!
//! HookRegistry 调度方法——所有 run_* 生命周期事件分发。
use std::time::Duration;
use futures_util::future::join_all;
use tokio::time::timeout as tokio_timeout;
use tracing::{info, warn};
use super::{
AgentHook, AsyncAgentHook, BlockingError, HookEvent, PermissionDeniedContext,
PermissionRequestAction, PermissionRequestContext, PostCompactContext, PostToolUseAction,
PostToolUseContext, PostToolUseFailureContext, PostToolUseResult, PreCompactContext,
PreToolUseAction, PreToolUseContext, PreToolUseResult, SessionStartContext, SessionStopContext,
StepCompleteContext, SubagentStartContext, SubagentStopContext, TaggedContext,
DEFAULT_HOOK_TIMEOUT,
};
use serde_json;
impl super::HookRegistry {
// ── 便捷调用方法 ──
/// 调度匹配指定事件的异步 hooksfire-and-forget5s dispatch 超时)
async fn dispatch_async_hooks_for(
&self,
event: HookEvent,
_session_id: &str,
_tool_name: &str,
_tool_args: &serde_json::Value,
post_ctx: Option<&PostToolUseContext>,
failure_ctx: Option<&PostToolUseFailureContext>,
) {
let async_hooks: Vec<&dyn AsyncAgentHook> = self
.async_event_index
.get(&event)
.map(|indices| {
indices
.iter()
.filter_map(|&idx| self.async_hooks.get(idx).map(|b| b.as_ref()))
.filter(|_hook| {
// Async hooks can declare filtering too via name convention;
// for now, all matching the event fire.
true
})
.collect()
})
.unwrap_or_default();
if async_hooks.is_empty() {
return;
}
let timeout_dur = Duration::from_secs(5); // async dispatch timeout
// Build futures — clone contexts so futures own their data and satisfy 'static.
match event {
HookEvent::PostToolUse if post_ctx.is_some() => {
let ctx = post_ctx.unwrap().clone();
for hook in async_hooks {
let ctx = ctx.clone();
let _ = tokio_timeout(timeout_dur, hook.on_post_tool_use_async(ctx)).await;
}
}
HookEvent::PostToolUseFailure if failure_ctx.is_some() => {
let ctx = failure_ctx.unwrap().clone();
for hook in async_hooks {
let ctx = ctx.clone();
let _ =
tokio_timeout(timeout_dur, hook.on_post_tool_use_failure_async(ctx)).await;
}
}
_ => {}
}
}
/// 调用所有 on_session_start hooks(并行执行,含 session hooks
pub async fn run_on_session_start(&self, ctx: &SessionStartContext) {
let hooks = self.collect_hooks_for(HookEvent::OnSessionStart, &ctx.session_id);
if hooks.is_empty() {
return;
}
let ctx_clone = ctx.clone();
let futures: Vec<_> = hooks
.iter()
.map(|hook| {
let ctx = ctx_clone.clone();
let timeout_dur = hook.timeout().unwrap_or(DEFAULT_HOOK_TIMEOUT);
async move {
let _ = tokio_timeout(timeout_dur, hook.on_session_start(&ctx)).await;
}
})
.collect();
join_all(futures).await;
}
/// 调用所有 pre_tool_use hooks(并行执行,各自有独立超时)。
///
/// 所有订阅了 PreToolUse 的 hooks(含 session hooks)并行运行,每个 hook 包装在
/// `tokio::time::timeout` 中。完成后聚合所有结果:
/// - Block → 收集到 blocking_errors
/// - MutateInput → 累积 additional_contexts 并更新 final_args
/// - PermissionRequired → 记录
/// - 超时 → 记录 warning,视为 non-blocking error
pub async fn run_pre_tool_use(&self, ctx: &PreToolUseContext) -> PreToolUseResult {
let hooks = self.collect_tool_hooks_for(
HookEvent::PreToolUse,
&ctx.session_id,
&ctx.tool_name,
&ctx.tool_args,
);
// Fast path: 单 hook 或空 → 顺序执行
if hooks.len() <= 1 {
return self.run_pre_tool_use_sequential(ctx).await;
}
let ctx_clone = ctx.clone();
let futures: Vec<_> = hooks
.iter()
.map(|hook| {
let ctx = ctx_clone.clone();
let timeout_dur = hook.timeout().unwrap_or(DEFAULT_HOOK_TIMEOUT);
let hook_name = hook.name().to_string();
async move {
(
hook_name,
tokio_timeout(timeout_dur, hook.pre_tool_use(&ctx)).await,
)
}
})
.collect();
// 并行执行所有 hooks
let results = join_all(futures).await;
// 聚合结果
let mut additional_contexts: Vec<String> = Vec::new();
let mut tagged_contexts: Vec<TaggedContext> = Vec::new();
let mut final_args = ctx.tool_args.clone();
let mut final_action = PreToolUseAction::Continue;
let mut blocking_errors: Vec<BlockingError> = Vec::new();
for (hook_name, result) in results {
match result {
Ok(action) => {
match &action {
PreToolUseAction::Block { reason } => {
warn!(
"[Hooks] {hook_name} 阻止了工具 {} 的执行: {reason}",
ctx.tool_name
);
blocking_errors.push(BlockingError::new(hook_name, reason.clone()));
}
PreToolUseAction::MutateInput {
updated_args,
additional_context,
} => {
info!(
"[Hooks] {hook_name} 修改了工具 {} 的输入参数",
ctx.tool_name
);
final_args = updated_args.clone();
if let Some(ctx_str) = additional_context {
additional_contexts.push(ctx_str.clone());
tagged_contexts.push(TaggedContext::new(
&hook_name,
HookEvent::PreToolUse,
ctx_str,
));
}
}
PreToolUseAction::PermissionRequired { .. } => {
info!(
"[Hooks] {hook_name} 请求了工具 {} 的权限检查",
ctx.tool_name
);
}
PreToolUseAction::Continue => {}
}
if !matches!(action, PreToolUseAction::Continue) {
final_action = action;
}
}
Err(_elapsed) => {
warn!("[Hooks] {hook_name} 超时(PreToolUse),跳过其反馈");
}
}
}
let additional_context = if additional_contexts.is_empty() {
None
} else {
Some(additional_contexts.join("\n"))
};
PreToolUseResult {
action: final_action,
additional_contexts,
additional_context,
blocking_errors,
final_args,
tagged_contexts,
}
}
/// 顺序执行 pre_tool_use hooks(含 session hooks
async fn run_pre_tool_use_sequential(&self, ctx: &PreToolUseContext) -> PreToolUseResult {
let mut additional_contexts: Vec<String> = Vec::new();
let mut tagged_contexts: Vec<TaggedContext> = Vec::new();
let mut final_args = ctx.tool_args.clone();
let mut final_action = PreToolUseAction::Continue;
let mut blocking_errors: Vec<BlockingError> = Vec::new();
// 全局 hooks(通过 collect_tool_hooks_for 应用 match_filter 过滤)
let hooks = self.collect_tool_hooks_for(
HookEvent::PreToolUse,
&ctx.session_id,
&ctx.tool_name,
&ctx.tool_args,
);
for hook in &hooks {
let action = hook.pre_tool_use(ctx).await;
Self::process_pre_tool_action(
&action,
hook.name(),
&ctx.tool_name,
&mut blocking_errors,
&mut final_args,
&mut additional_contexts,
&mut tagged_contexts,
&mut final_action,
);
}
let additional_context = if additional_contexts.is_empty() {
None
} else {
Some(additional_contexts.join("\n"))
};
PreToolUseResult {
action: final_action,
additional_contexts,
additional_context,
blocking_errors,
final_args,
tagged_contexts,
}
}
/// 调用所有 post_tool_use hooks(并行执行,含 session hooks,各自有独立超时)。
pub async fn run_post_tool_use(&self, ctx: &PostToolUseContext) -> PostToolUseResult {
// 同时调度异步 hooksfire-and-forget
self.dispatch_async_hooks_for(
HookEvent::PostToolUse,
&ctx.session_id,
&ctx.tool_name,
&ctx.tool_args,
Some(ctx),
None,
)
.await;
let hooks = self.collect_tool_hooks_for(
HookEvent::PostToolUse,
&ctx.session_id,
&ctx.tool_name,
&ctx.tool_args,
);
// Fast path: 单 hook 或空 → 顺序执行
if hooks.len() <= 1 {
return self.run_post_tool_use_sequential(ctx).await;
}
let ctx_clone = ctx.clone();
let futures: Vec<_> = hooks
.iter()
.map(|hook| {
let ctx = ctx_clone.clone();
let timeout_dur = hook.timeout().unwrap_or(DEFAULT_HOOK_TIMEOUT);
let hook_name = hook.name().to_string();
async move {
(
hook_name,
tokio_timeout(timeout_dur, hook.post_tool_use(&ctx)).await,
)
}
})
.collect();
let results = join_all(futures).await;
let mut final_content = ctx.output_content.clone();
let mut additional_contexts: Vec<String> = Vec::new();
let mut tagged_contexts: Vec<TaggedContext> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
let mut post_permission_requests: Vec<(String, String)> = Vec::new();
let mut metadata: std::collections::HashMap<String, serde_json::Value> =
std::collections::HashMap::new();
for (hook_name, result) in results {
match result {
Ok(action) => match &action {
PostToolUseAction::MutateOutput {
updated_content: ref uc,
additional_context: ref ac,
} => {
info!("[Hooks] {hook_name} 修改了工具 {} 的输出", ctx.tool_name);
final_content = uc.clone();
if let Some(ctx_str) = ac {
additional_contexts.push(ctx_str.clone());
tagged_contexts.push(TaggedContext::new(
&hook_name,
HookEvent::PostToolUse,
ctx_str,
));
}
}
PostToolUseAction::Warning {
message,
truncate_output,
} => {
warn!(
"[Hooks] {hook_name} 发出了对 {} 的警告: {message}",
ctx.tool_name
);
warnings.push(message.clone());
if *truncate_output {
final_content = final_content.chars().take(1000).collect::<String>()
+ "\n\n[输出已由 Hook 截断]";
}
}
PostToolUseAction::PermissionRequired {
permission,
tool_name,
} => {
info!("[Hooks] {hook_name} 事后请求工具 {tool_name} 的权限: {permission}");
post_permission_requests.push((tool_name.clone(), permission.clone()));
}
PostToolUseAction::Metadata { key, value } => {
metadata.insert(key.clone(), value.clone());
}
PostToolUseAction::Continue => {}
},
Err(_elapsed) => {
warn!("[Hooks] {hook_name} 超时(PostToolUse),跳过其反馈");
}
}
}
PostToolUseResult {
final_content,
additional_contexts,
tagged_contexts,
warnings,
post_permission_requests,
metadata,
}
}
/// 顺序执行 post_tool_use hooks(含 session hooks
async fn run_post_tool_use_sequential(&self, ctx: &PostToolUseContext) -> PostToolUseResult {
let mut final_content = ctx.output_content.clone();
let mut additional_contexts: Vec<String> = Vec::new();
let mut tagged_contexts: Vec<TaggedContext> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
let mut post_permission_requests: Vec<(String, String)> = Vec::new();
let mut metadata: std::collections::HashMap<String, serde_json::Value> =
std::collections::HashMap::new();
let hooks = self.collect_tool_hooks_for(
HookEvent::PostToolUse,
&ctx.session_id,
&ctx.tool_name,
&ctx.tool_args,
);
for hook in &hooks {
let action = hook.post_tool_use(ctx).await;
match action {
PostToolUseAction::MutateOutput {
updated_content,
additional_context,
} => {
final_content = updated_content;
if let Some(ctx_str) = additional_context {
additional_contexts.push(ctx_str.clone());
tagged_contexts.push(TaggedContext::new(
hook.name(),
HookEvent::PostToolUse,
ctx_str,
));
}
}
PostToolUseAction::Warning {
message,
truncate_output,
} => {
warnings.push(message);
if truncate_output {
final_content = final_content.chars().take(1000).collect::<String>()
+ "\n\n[输出已由 Hook 截断]";
}
}
PostToolUseAction::PermissionRequired {
permission,
tool_name,
} => {
post_permission_requests.push((tool_name, permission));
}
PostToolUseAction::Metadata { key, value } => {
metadata.insert(key, value.clone());
}
PostToolUseAction::Continue => {}
}
}
PostToolUseResult {
final_content,
additional_contexts,
tagged_contexts,
warnings,
post_permission_requests,
metadata,
}
}
/// 调用所有 post_tool_use_failure hooks(并行执行,含 session hooks
pub async fn run_on_post_tool_use_failure(
&self,
ctx: &PostToolUseFailureContext,
) -> PostToolUseResult {
// 同时调度异步 hooksfire-and-forget
self.dispatch_async_hooks_for(
HookEvent::PostToolUseFailure,
&ctx.session_id,
&ctx.tool_name,
&ctx.tool_args,
None,
Some(ctx),
)
.await;
let hooks = self.collect_tool_hooks_for(
HookEvent::PostToolUseFailure,
&ctx.session_id,
&ctx.tool_name,
&ctx.tool_args,
);
// Fast path: 单 hook 或空 → 顺序执行
if hooks.len() <= 1 {
let mut final_content = String::new();
let mut additional_contexts: Vec<String> = Vec::new();
let mut tagged_contexts: Vec<TaggedContext> = Vec::new();
for hook in &hooks {
let action = hook.on_post_tool_use_failure(ctx).await;
match action {
PostToolUseAction::MutateOutput {
updated_content,
additional_context,
} => {
final_content = updated_content;
if let Some(ctx_str) = additional_context {
additional_contexts.push(ctx_str.clone());
tagged_contexts.push(TaggedContext::new(
hook.name(),
HookEvent::PostToolUseFailure,
ctx_str,
));
}
}
PostToolUseAction::Warning {
message,
truncate_output,
} => {
if truncate_output {
final_content = final_content.chars().take(1000).collect();
}
tagged_contexts.push(TaggedContext::new(
hook.name(),
HookEvent::PostToolUseFailure,
format!("Warning: {message}"),
));
}
PostToolUseAction::PermissionRequired { .. }
| PostToolUseAction::Metadata { .. }
| PostToolUseAction::Continue => {}
}
}
return PostToolUseResult {
final_content,
additional_contexts,
tagged_contexts,
warnings: Vec::new(),
post_permission_requests: Vec::new(),
metadata: std::collections::HashMap::new(),
};
}
let ctx_clone = ctx.clone();
let futures: Vec<_> = hooks
.iter()
.map(|hook| {
let ctx = ctx_clone.clone();
let timeout_dur = hook.timeout().unwrap_or(DEFAULT_HOOK_TIMEOUT);
let hook_name = hook.name().to_string();
async move {
(
hook_name,
tokio_timeout(timeout_dur, hook.on_post_tool_use_failure(&ctx)).await,
)
}
})
.collect();
let results = join_all(futures).await;
let mut final_content = String::new();
let mut additional_contexts: Vec<String> = Vec::new();
let mut tagged_contexts: Vec<TaggedContext> = Vec::new();
for (hook_name, result) in results {
match result {
Ok(action) => match action {
PostToolUseAction::MutateOutput {
updated_content,
additional_context,
} => {
final_content = updated_content;
if let Some(ctx_str) = additional_context {
additional_contexts.push(ctx_str.clone());
tagged_contexts.push(TaggedContext::new(
&hook_name,
HookEvent::PostToolUseFailure,
ctx_str,
));
}
}
PostToolUseAction::Warning {
message,
truncate_output,
} => {
if truncate_output {
final_content = final_content.chars().take(1000).collect();
}
tagged_contexts.push(TaggedContext::new(
&hook_name,
HookEvent::PostToolUseFailure,
format!("Warning: {message}"),
));
}
PostToolUseAction::PermissionRequired { .. }
| PostToolUseAction::Metadata { .. }
| PostToolUseAction::Continue => {}
},
Err(_elapsed) => {
// 超时已在警告中体现,跳过该 hook 的反馈
}
}
}
PostToolUseResult {
final_content,
additional_contexts,
tagged_contexts,
warnings: Vec::new(),
post_permission_requests: Vec::new(),
metadata: std::collections::HashMap::new(),
}
}
/// 调用所有 on_step_complete hooks(并行执行,含 session hooks
pub async fn run_on_step_complete(&self, ctx: &StepCompleteContext) {
let hooks = self.collect_hooks_for(HookEvent::OnStepComplete, &ctx.session_id);
if hooks.is_empty() {
return;
}
let ctx_clone = ctx.clone();
let futures: Vec<_> = hooks
.iter()
.map(|hook| {
let ctx = ctx_clone.clone();
let timeout_dur = hook.timeout().unwrap_or(DEFAULT_HOOK_TIMEOUT);
async move {
let _ = tokio_timeout(timeout_dur, hook.on_step_complete(&ctx)).await;
}
})
.collect();
join_all(futures).await;
}
/// 调用所有 on_session_stop hooks(顺序执行 + 自动清理 session hooks
pub async fn run_on_session_stop(&self, ctx: &SessionStopContext<'_>) {
// 全局 hooks
for &idx in self.indices_for(HookEvent::OnSessionStop) {
if let Some(hook) = self.hooks.get(idx) {
let _ = tokio_timeout(
hook.timeout().unwrap_or(DEFAULT_HOOK_TIMEOUT),
hook.on_session_stop(ctx),
)
.await;
}
}
// Session hooksStop 事件也通知它们)
for hook in self.get_session_hooks(&ctx.session_id) {
let _ = tokio_timeout(
hook.timeout().unwrap_or(DEFAULT_HOOK_TIMEOUT),
hook.on_session_stop(ctx),
)
.await;
}
// 注意:clear_session_hooks 需要 &mut self,但此方法是 &self。
// 调用方应在 run_on_session_stop 返回后主动调用 clear_session_hooks。
}
/// 调用所有 on_subagent_start hooks(并行执行,含 session hooks
pub async fn run_on_subagent_start(&self, ctx: &SubagentStartContext) {
let hooks = self.collect_hooks_for(HookEvent::OnSubagentStart, &ctx.parent_session_id);
if hooks.is_empty() {
return;
}
let ctx_clone = ctx.clone();
let futures: Vec<_> = hooks
.iter()
.map(|hook| {
let ctx = ctx_clone.clone();
let timeout_dur = hook.timeout().unwrap_or(DEFAULT_HOOK_TIMEOUT);
async move {
let _ = tokio_timeout(timeout_dur, hook.on_subagent_start(&ctx)).await;
}
})
.collect();
join_all(futures).await;
}
/// 调用所有 on_subagent_stop hooks(并行执行,含 session hooks
pub async fn run_on_subagent_stop(&self, ctx: &SubagentStopContext) {
let hooks = self.collect_hooks_for(HookEvent::OnSubagentStop, &ctx.parent_session_id);
if hooks.is_empty() {
return;
}
let ctx_clone = ctx.clone();
let futures: Vec<_> = hooks
.iter()
.map(|hook| {
let ctx = ctx_clone.clone();
let timeout_dur = hook.timeout().unwrap_or(DEFAULT_HOOK_TIMEOUT);
async move {
let _ = tokio_timeout(timeout_dur, hook.on_subagent_stop(&ctx)).await;
}
})
.collect();
join_all(futures).await;
}
/// 调用所有 on_pre_compact hooks(并行执行,含 session hooks
pub async fn run_on_pre_compact(&self, ctx: &PreCompactContext) {
let hooks = self.collect_hooks_for(HookEvent::OnPreCompact, &ctx.session_id);
if hooks.is_empty() {
return;
}
let ctx_clone = ctx.clone();
let futures: Vec<_> = hooks
.iter()
.map(|hook| {
let ctx = ctx_clone.clone();
let timeout_dur = hook.timeout().unwrap_or(DEFAULT_HOOK_TIMEOUT);
async move {
let _ = tokio_timeout(timeout_dur, hook.on_pre_compact(&ctx)).await;
}
})
.collect();
join_all(futures).await;
}
/// 调用所有 on_post_compact hooks(并行执行,含 session hooks
pub async fn run_on_post_compact(&self, ctx: &PostCompactContext) {
let hooks = self.collect_hooks_for(HookEvent::OnPostCompact, &ctx.session_id);
if hooks.is_empty() {
return;
}
let ctx_clone = ctx.clone();
let futures: Vec<_> = hooks
.iter()
.map(|hook| {
let ctx = ctx_clone.clone();
let timeout_dur = hook.timeout().unwrap_or(DEFAULT_HOOK_TIMEOUT);
async move {
let _ = tokio_timeout(timeout_dur, hook.on_post_compact(&ctx)).await;
}
})
.collect();
join_all(futures).await;
}
/// 分发 PermissionRequest 事件。
///
/// 并行调用所有匹配的 hook。每个 hook 返回 `PermissionRequestAction`
/// - `Continue` → 保持当前决策
/// - `Override` → 覆盖决策(第一个 Override 生效,后续不再检查)
/// - `InjectContext` → 注入上下文但不改变决策
///
/// 返回 (final_action, additional_contexts)。
pub async fn run_on_permission_request(
&self,
ctx: &PermissionRequestContext,
) -> (PermissionRequestAction, Vec<String>) {
let hooks = self.collect_hooks_for(HookEvent::PermissionRequest, &ctx.session_id);
if hooks.is_empty() {
return (PermissionRequestAction::Continue, Vec::new());
}
let mut final_action = PermissionRequestAction::Continue;
let mut injected_contexts: Vec<String> = Vec::new();
let mut overridden = false;
let futures: Vec<_> = hooks
.iter()
.map(|hook| {
let hook: &dyn AgentHook = *hook;
let ctx = ctx.clone();
let timeout_dur = DEFAULT_HOOK_TIMEOUT;
async move {
let result = tokio_timeout(timeout_dur, hook.on_permission_request(&ctx)).await;
match result {
Ok(action) => Some((hook.name().to_string(), action)),
Err(_) => {
warn!("[HookDispatch] PermissionRequest hook {} 超时", hook.name());
None
}
}
}
})
.collect();
let results: Vec<Option<(String, PermissionRequestAction)>> = join_all(futures).await;
for result in results.into_iter().flatten() {
let (hook_name, action) = result;
match action {
PermissionRequestAction::Continue => {
// 默认,不改变
}
PermissionRequestAction::Override(decision) if !overridden => {
overridden = true;
final_action = PermissionRequestAction::Override(decision);
info!(
"[HookDispatch] PermissionRequest hook {} 覆盖决策",
hook_name
);
}
PermissionRequestAction::Override(_) => {
warn!(
"[HookDispatch] PermissionRequest hook {}: 覆盖被忽略(已有其他 hook 先覆盖)",
hook_name
);
}
PermissionRequestAction::InjectContext { ref content } => {
injected_contexts.push(content.clone());
}
}
}
(final_action, injected_contexts)
}
/// 分发 PermissionDenied 事件(fire-and-forget,仅用于审计日志)。
///
/// 并行调用所有匹配的 hook。返回值不影响执行流程。
pub async fn run_on_permission_denied(&self, ctx: &PermissionDeniedContext) {
let hooks = self.collect_hooks_for(HookEvent::PermissionDenied, &ctx.session_id);
if hooks.is_empty() {
return;
}
let futures: Vec<_> = hooks
.iter()
.map(|hook| {
let hook: &dyn AgentHook = *hook;
let ctx = ctx.clone();
let timeout_dur = DEFAULT_HOOK_TIMEOUT;
async move {
let _ = tokio_timeout(timeout_dur, hook.on_permission_denied(&ctx)).await;
}
})
.collect();
join_all(futures).await;
}
}
+302
View File
@@ -0,0 +1,302 @@
// src/agent/hooks/matcher.rs
//
// 工具级匹配器。
// Hook 可通过 `match_filter()` 声明只关心特定工具或参数模式,
// 从而避免在热路径上被无关调用触发。
use serde_json::Value;
/// 工具名匹配模式。
///
/// 三种策略:
/// - `Exact("search_papers")` — 精确名称匹配
/// - `Prefix("file_")` — 前缀匹配(末尾 `*` 隐式)
/// - `Wildcard` — 匹配所有工具(默认行为)
///
/// # 示例
///
/// ```
/// use crate::agent::hooks::matcher::ToolNamePattern;
/// let pat = ToolNamePattern::parse("file_*");
/// assert!(pat.matches("file_write"));
/// assert!(!pat.matches("search_papers"));
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolNamePattern {
/// 精确匹配工具名
Exact(String),
/// 前缀匹配 — `"file_*"` 匹配所有以 `"file_"` 开头的工具名
Prefix(String),
/// 匹配所有工具
Wildcard,
}
impl ToolNamePattern {
/// 从模式字符串解析。
///
/// - `"*"` 或空字符串 → `Wildcard`
/// - `"xxx_*"` → `Prefix("xxx_")`
/// - 其他 → `Exact(pattern)`
pub fn parse(pattern: &str) -> Self {
let trimmed = pattern.trim();
if trimmed.is_empty() || trimmed == "*" {
return ToolNamePattern::Wildcard;
}
if let Some(prefix) = trimmed.strip_suffix('*') {
if !prefix.is_empty() {
return ToolNamePattern::Prefix(prefix.to_string());
}
// 只有 "*" — 上面已经处理
return ToolNamePattern::Wildcard;
}
ToolNamePattern::Exact(trimmed.to_string())
}
/// 若 `tool_name` 与此模式匹配则返回 true。
pub fn matches(&self, tool_name: &str) -> bool {
match self {
ToolNamePattern::Wildcard => true,
ToolNamePattern::Exact(name) => name == tool_name,
ToolNamePattern::Prefix(prefix) => tool_name.starts_with(prefix),
}
}
}
/// Hook 通过 `match_filter()` 声明的结构化过滤器,用于限制关注哪些工具调用。
///
/// 若 hook 返回非空 `ToolMatchFilter`,则仅当工具名和参数匹配时
/// 才调用其 `pre_tool_use` / `post_tool_use`。
///
/// 默认情况下(空过滤器,等价于 `[Wildcard]`),匹配所有工具。
///
/// # 示例
///
/// ```
/// use crate::agent::hooks::matcher::{ToolMatchFilter, ToolNamePattern};
///
/// // 仅匹配文件相关工具
/// let filter = ToolMatchFilter {
/// name_patterns: vec![ToolNamePattern::parse("file_*")],
/// content_patterns: vec![],
/// };
/// assert!(filter.matches("file_read", &serde_json::json!({})));
/// assert!(!filter.matches("search_papers", &serde_json::json!({})));
/// ```
#[derive(Debug, Clone, Default)]
pub struct ToolMatchFilter {
/// 工具名模式 — 任意一个匹配即可通过。
/// 空 Vec = 匹配全部(等价于 `[Wildcard]`)。
pub name_patterns: Vec<ToolNamePattern>,
/// 可选的内容级模式。格式:`"字段:子串"` 或直接 `"子串"`。
/// 例如 `"command:rm *"` 匹配 `run_bash` 调用中 command 字段以 "rm " 开头的情况。
/// 空 Vec = 不过滤内容。
pub content_patterns: Vec<String>,
}
impl ToolMatchFilter {
/// 若此过滤器匹配给定的工具调用则返回 true。
///
/// 检查流程:
/// 1. 若 `name_patterns` 为空,所有工具名匹配
/// 2. 任一非通配模式必须匹配 tool_name
/// 3. 任一 content_patterns 必须匹配 tool_args 中的某个字段
pub fn matches(&self, tool_name: &str, tool_args: &Value) -> bool {
// 名称匹配
if !self.name_patterns.is_empty() {
let name_match = self.name_patterns.iter().any(|pat| pat.matches(tool_name));
if !name_match {
return false;
}
}
// 内容匹配:每个模式必须至少匹配一个字段/值
if !self.content_patterns.is_empty() {
for pattern in &self.content_patterns {
if !content_matches(pattern, tool_args) {
return false;
}
}
}
true
}
/// 快捷构造:创建仅匹配单个工具名的过滤器。
pub fn exact(tool_name: &str) -> Self {
ToolMatchFilter {
name_patterns: vec![ToolNamePattern::Exact(tool_name.to_string())],
content_patterns: vec![],
}
}
/// 快捷构造:创建前缀过滤器。
pub fn prefix(prefix: &str) -> Self {
ToolMatchFilter {
name_patterns: vec![ToolNamePattern::Prefix(prefix.to_string())],
content_patterns: vec![],
}
}
/// 若此过滤器为默认"匹配全部"(空 name_patterns + 空 content_patterns)则返回 true。
pub fn is_match_all(&self) -> bool {
self.name_patterns.is_empty() && self.content_patterns.is_empty()
}
}
/// 检查内容模式是否匹配 JSON 值中的任意字段。
///
/// 模式格式:
/// - `"字段:值"` — 值必须是该字段字符串表示的子串
/// - `"值"` — 值必须出现在 JSON 字符串化后的任意位置
fn content_matches(pattern: &str, args: &Value) -> bool {
if let Some((field, wanted)) = pattern.split_once(':') {
// 匹配指定字段
if let Some(field_val) = args.get(field.trim()) {
let field_str = match field_val {
Value::String(s) => s.clone(),
other => other.to_string(),
};
return field_str.contains(wanted.trim());
}
return false;
}
// 在 JSON 中任意位置匹配
let full_str = serde_json::to_string(args).unwrap_or_default();
full_str.contains(pattern.trim())
}
// ── 测试 ──
#[cfg(test)]
mod tests {
use super::*;
// ── ToolNamePattern ──
#[test]
fn test_pattern_exact() {
let pat = ToolNamePattern::parse("search_papers");
assert!(pat.matches("search_papers"));
assert!(!pat.matches("download_paper"));
}
#[test]
fn test_pattern_prefix() {
let pat = ToolNamePattern::parse("file_*");
assert!(pat.matches("file_write"));
assert!(pat.matches("file_read"));
assert!(pat.matches("file_edit"));
assert!(!pat.matches("search_papers"));
assert!(!pat.matches("fi")); // 前缀比 "file_" 短
}
#[test]
fn test_pattern_wildcard() {
let pat = ToolNamePattern::parse("*");
assert!(matches!(pat, ToolNamePattern::Wildcard));
assert!(pat.matches("anything"));
assert!(pat.matches("search_papers"));
let pat_empty = ToolNamePattern::parse("");
assert!(matches!(pat_empty, ToolNamePattern::Wildcard));
}
#[test]
fn test_pattern_prefix_no_trailing_wild() {
// "file" 不带 * 应被解析为 Exact
let pat = ToolNamePattern::parse("file");
assert!(matches!(pat, ToolNamePattern::Exact(_)));
assert!(pat.matches("file"));
assert!(!pat.matches("file_write"));
}
// ── ToolMatchFilter ──
#[test]
fn test_filter_default_matches_all() {
let filter = ToolMatchFilter::default();
assert!(filter.matches("search_papers", &serde_json::json!({})));
assert!(filter.matches("run_bash", &serde_json::json!({"command": "rm -rf /"})));
}
#[test]
fn test_filter_exact_name_match() {
let filter = ToolMatchFilter::exact("search_papers");
assert!(filter.matches("search_papers", &serde_json::json!({})));
assert!(!filter.matches("download_paper", &serde_json::json!({})));
}
#[test]
fn test_filter_prefix_name_match() {
let filter = ToolMatchFilter::prefix("file_");
assert!(filter.matches("file_write", &serde_json::json!({})));
assert!(filter.matches("file_read", &serde_json::json!({})));
assert!(!filter.matches("run_bash", &serde_json::json!({})));
}
#[test]
fn test_filter_content_match_field() {
let filter = ToolMatchFilter {
name_patterns: vec![],
content_patterns: vec!["command:rm".to_string()],
};
assert!(filter.matches(
"run_bash",
&serde_json::json!({"command": "rm -rf /tmp/test"})
));
assert!(!filter.matches("run_bash", &serde_json::json!({"command": "ls -la"})));
assert!(!filter.matches("read_file", &serde_json::json!({"path": "/tmp/test"})));
}
#[test]
fn test_filter_content_match_anywhere() {
let filter = ToolMatchFilter {
name_patterns: vec![],
content_patterns: vec!["dangerous".to_string()],
};
assert!(filter.matches(
"run_bash",
&serde_json::json!({"command": "echo dangerous stuff"})
));
assert!(!filter.matches(
"run_bash",
&serde_json::json!({"command": "echo safe stuff"})
));
}
#[test]
fn test_filter_name_and_content_combined() {
let filter = ToolMatchFilter {
name_patterns: vec![ToolNamePattern::parse("file_*")],
content_patterns: vec!["path:.env".to_string()],
};
// 正确工具名 + 敏感路径 → 匹配
assert!(filter.matches("file_read", &serde_json::json!({"path": "/app/.env"})));
// 正确工具名 + 安全路径 → 不匹配
assert!(!filter.matches("file_read", &serde_json::json!({"path": "/app/README.md"})));
// 错误工具名 + 敏感路径 → 不匹配
assert!(!filter.matches("run_bash", &serde_json::json!({"path": "/app/.env"})));
}
#[test]
fn test_filter_empty_content_false() {
// 内容模式指定了不存在的字段 → 不匹配
let filter = ToolMatchFilter {
name_patterns: vec![],
content_patterns: vec!["nonexistent:value".to_string()],
};
assert!(!filter.matches("run_bash", &serde_json::json!({"command": "ls"})));
}
#[test]
fn test_is_match_all() {
assert!(ToolMatchFilter::default().is_match_all());
assert!(!ToolMatchFilter::exact("foo").is_match_all());
assert!(!ToolMatchFilter {
name_patterns: vec![],
content_patterns: vec!["cmd:ls".to_string()]
}
.is_match_all());
}
}
+549
View File
@@ -0,0 +1,549 @@
// 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]"));
}
}
+280
View File
@@ -0,0 +1,280 @@
// src/agent/hooks/registry.rs
//! HookRegistry — 注册表核心。
use sqlx::SqlitePool;
use std::collections::HashMap;
use std::sync::Arc;
use tracing::{info, warn};
use super::builtins::{AuditLogHook, CancellationHook, MetricsHook};
use super::traits::{AgentHook, AsyncAgentHook};
use super::types::{BlockingError, HookEvent, MetricsData, PreToolUseAction, TaggedContext};
use serde_json;
/// Hook 注册表,管理所有已注册的 hook 并按序调用
pub struct HookRegistry {
pub(crate) hooks: Vec<Box<dyn AgentHook>>,
/// 事件→hook 索引映射。预计算哪些 hook 关心哪些事件,
/// 避免每次事件触发时遍历全部 hook。
pub(crate) event_index: HashMap<HookEvent, Vec<usize>>,
/// Per-session 动态 hooks(运行时添加/移除,会话结束时自动清理)
pub(crate) session_hooks: HashMap<String, Vec<Box<dyn AgentHook>>>,
/// 异步 fire-and-forget hooksPhase 4.1+
pub(crate) async_hooks: Vec<Box<dyn AsyncAgentHook>>,
/// 异步 hook 的事件索引
pub(crate) async_event_index: HashMap<HookEvent, Vec<usize>>,
}
impl Default for HookRegistry {
fn default() -> Self {
Self::new()
}
}
impl HookRegistry {
/// 创建空的注册表
pub fn new() -> Self {
HookRegistry {
hooks: Vec::new(),
event_index: HashMap::new(),
session_hooks: HashMap::new(),
async_hooks: Vec::new(),
async_event_index: HashMap::new(),
}
}
/// 创建包含所有内置 hooks 的注册表。
///
/// 参数:
/// - `db`: 数据库连接池(供 AuditLogHook 持久化)
/// - `cancelled_runs`: 取消状态集合(供 CancellationHook 检查)
/// - `metrics_data`: 可选的共享指标数据引用。提供时复用已有的 MetricsData
/// 使得 AgentRuntime.get_metrics() 能查询到实际运行数据。
pub fn with_builtins(
db: SqlitePool,
cancelled_runs: Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
metrics_data: Option<Arc<std::sync::Mutex<MetricsData>>>,
) -> Self {
let mut registry = Self::new();
registry.add(Box::new(CancellationHook::new(cancelled_runs)));
// 如果提供了共享的 metrics_data,使用它;否则创建新的
let metrics_hook = match metrics_data {
Some(data) => MetricsHook::from_arc(data),
None => MetricsHook::new(),
};
registry.add(Box::new(metrics_hook));
registry.add(Box::new(AuditLogHook::new(db)));
registry
}
/// 注册一个 hook 并重建事件索引
pub fn add(&mut self, hook: Box<dyn AgentHook>) {
info!("[Hooks] 注册 hook: {}", hook.name());
let idx = self.hooks.len();
let subs = hook.subscribed_events();
if subs.is_empty() {
// 空切片 = 订阅所有事件
for event in &[
HookEvent::OnSessionStart,
HookEvent::PreToolUse,
HookEvent::PostToolUse,
HookEvent::PostToolUseFailure,
HookEvent::OnStepComplete,
HookEvent::OnSessionStop,
HookEvent::OnSubagentStart,
HookEvent::OnSubagentStop,
HookEvent::OnPreCompact,
HookEvent::OnPostCompact,
] {
self.event_index.entry(*event).or_default().push(idx);
}
} else {
for event in subs {
self.event_index.entry(*event).or_default().push(idx);
}
}
self.hooks.push(hook);
}
/// 注册一个异步 fire-and-forget hookPhase 4.1+
pub fn add_async(&mut self, hook: Box<dyn AsyncAgentHook>) {
info!("[Hooks] 注册异步 hook: {}", hook.name());
let idx = self.async_hooks.len();
let subs = hook.subscribed_events();
if subs.is_empty() {
for event in &[
HookEvent::OnSessionStart,
HookEvent::PreToolUse,
HookEvent::PostToolUse,
HookEvent::PostToolUseFailure,
HookEvent::OnStepComplete,
HookEvent::OnSessionStop,
HookEvent::OnSubagentStart,
HookEvent::OnSubagentStop,
HookEvent::OnPreCompact,
HookEvent::OnPostCompact,
] {
self.async_event_index.entry(*event).or_default().push(idx);
}
} else {
for event in subs {
self.async_event_index.entry(*event).or_default().push(idx);
}
}
self.async_hooks.push(hook);
}
/// 获取所有 hooks 的不可变引用
pub fn all(&self) -> &[Box<dyn AgentHook>] {
&self.hooks
}
/// 获取订阅了指定事件的 hook 索引列表
pub(crate) fn indices_for(&self, event: HookEvent) -> &[usize] {
self.event_index
.get(&event)
.map(|v| v.as_slice())
.unwrap_or(&[])
}
// ── Session Hook 管理 ──
/// 为指定会话动态添加一个 hook(仅当前会话有效,Stop 时自动清理)。
pub fn add_session_hook(&mut self, session_id: &str, hook: Box<dyn AgentHook>) {
info!(
"[Hooks] 为会话 {} 添加 session hook: {}",
session_id,
hook.name()
);
self.session_hooks
.entry(session_id.to_string())
.or_default()
.push(hook);
}
/// 移除指定会话中名为 `hook_name` 的 hook。返回是否成功移除。
pub fn remove_session_hook(&mut self, session_id: &str, hook_name: &str) -> bool {
if let Some(hooks) = self.session_hooks.get_mut(session_id) {
let before = hooks.len();
hooks.retain(|h| h.name() != hook_name);
let removed = hooks.len() < before;
if removed {
info!(
"[Hooks] 从会话 {} 移除 session hook: {}",
session_id, hook_name
);
}
if hooks.is_empty() {
self.session_hooks.remove(session_id);
}
return removed;
}
false
}
/// 清理指定会话的所有动态 hooks
pub fn clear_session_hooks(&mut self, session_id: &str) {
if self.session_hooks.remove(session_id).is_some() {
info!("[Hooks] 清理会话 {} 的所有 session hooks", session_id);
}
}
/// 获取指定会话的动态 hooks 的不可变引用
pub(crate) fn get_session_hooks(&self, session_id: &str) -> &[Box<dyn AgentHook>] {
self.session_hooks
.get(session_id)
.map(|v| v.as_slice())
.unwrap_or(&[])
}
/// 收集全局 hooks(按事件索引)和 session hooks 到统一列表,
/// 避免两个迭代器类型不同导致的 chain/extend 类型错误。
pub(crate) fn collect_hooks_for<'a>(
&'a self,
event: HookEvent,
session_id: &str,
) -> Vec<&'a dyn AgentHook> {
let mut hooks: Vec<&dyn AgentHook> = self
.indices_for(event)
.iter()
.filter_map(|&idx| self.hooks.get(idx).map(|b| b.as_ref()))
.collect();
hooks.extend(
self.get_session_hooks(session_id)
.iter()
.map(|b| b.as_ref()),
);
hooks
}
/// 收集工具相关事件的 hooks(按事件索引 + session hooks),
/// 并额外按 `match_filter()` 过滤——只保留关心当前工具名的 hook。
///
/// 如果 hook 的 `match_filter()` 返回默认 "match all",该 hook 永远包含。
/// 仅用于 `PreToolUse`、`PostToolUse`、`PostToolUseFailure` 事件。
pub(crate) fn collect_tool_hooks_for<'a>(
&'a self,
event: HookEvent,
session_id: &str,
tool_name: &str,
tool_args: &serde_json::Value,
) -> Vec<&'a dyn AgentHook> {
let mut hooks: Vec<&dyn AgentHook> = self
.indices_for(event)
.iter()
.filter_map(|&idx| self.hooks.get(idx).map(|b| b.as_ref()))
.filter(|hook| {
let filter = hook.match_filter();
filter.is_match_all() || filter.matches(tool_name, tool_args)
})
.collect();
hooks.extend(
self.get_session_hooks(session_id)
.iter()
.map(|b| b.as_ref()),
);
hooks
}
/// 处理单个 PreToolUse action 的聚合逻辑(共享于顺序/并行路径)
#[allow(clippy::too_many_arguments)]
pub(crate) fn process_pre_tool_action(
action: &PreToolUseAction,
hook_name: &str,
tool_name: &str,
blocking_errors: &mut Vec<BlockingError>,
final_args: &mut serde_json::Value,
additional_contexts: &mut Vec<String>,
tagged_contexts: &mut Vec<TaggedContext>,
final_action: &mut PreToolUseAction,
) {
match action {
PreToolUseAction::Block { reason } => {
warn!("[Hooks] {hook_name} 阻止了工具 {tool_name} 的执行: {reason}");
blocking_errors.push(BlockingError::new(hook_name.to_string(), reason.clone()));
}
PreToolUseAction::MutateInput {
updated_args,
additional_context,
} => {
info!("[Hooks] {hook_name} 修改了工具 {tool_name} 的输入参数");
*final_args = updated_args.clone();
if let Some(ctx_str) = additional_context {
additional_contexts.push(ctx_str.clone());
tagged_contexts.push(TaggedContext::new(
hook_name,
HookEvent::PreToolUse,
ctx_str,
));
}
}
PreToolUseAction::PermissionRequired { .. } => {
info!("[Hooks] {hook_name} 请求了工具 {tool_name} 的权限检查");
}
PreToolUseAction::Continue => {}
}
if !matches!(action, PreToolUseAction::Continue) {
*final_action = action.clone();
}
}
}
+139
View File
@@ -0,0 +1,139 @@
// src/agent/hooks/traits.rs
//
// Agent 生命周期 Hook traits。
use async_trait::async_trait;
use std::time::Duration;
use super::matcher::ToolMatchFilter;
use super::types::{
HookEvent, PermissionDeniedContext, PermissionRequestAction, PermissionRequestContext,
PostCompactContext, PostToolUseAction, PostToolUseContext, PostToolUseFailureContext,
PreCompactContext, PreToolUseAction, PreToolUseContext, SessionStartContext,
SessionStopContext, StepCompleteContext, SubagentStartContext, SubagentStopContext,
};
// ── Hook Trait ──
/// Agent 生命周期 Hook trait。
/// 所有方法都有默认空实现,只需覆写关心的 hook 点。
#[async_trait]
pub trait AgentHook: Send + Sync {
/// Hook 名称(用于日志和调试)
fn name(&self) -> &str;
/// 声明该 hook 订阅的生命周期事件。
/// 返回空切片表示订阅所有事件(向后兼容的默认行为)。
/// HookRegistry 用此信息预计算事件→hook 索引,避免无关调用。
fn subscribed_events(&self) -> &[HookEvent] {
&[] // 空 = 订阅全部
}
/// Per-hook 超时时间。返回 `None` 使用全局默认值 (30s)。
fn timeout(&self) -> Option<Duration> {
None
}
/// 声明此 hook 关心哪些工具调用。
///
/// 返回的 `ToolMatchFilter` 用于在 dispatch 时过滤无关的工具调用,
/// 减少不必要的 hook 执行。默认返回空过滤器(匹配所有工具)。
///
/// 仅对 `PreToolUse`、`PostToolUse`、`PostToolUseFailure` 事件生效。
/// 其他事件忽略此过滤器。
fn match_filter(&self) -> ToolMatchFilter {
ToolMatchFilter::default()
}
// ── 原有 5 个生命周期事件 ──
/// 会话创建/恢复时调用。
async fn on_session_start(&self, _ctx: &SessionStartContext) {}
/// 工具执行前调用。可返回 Continue/Block/MutateInput/PermissionRequired。
async fn pre_tool_use(&self, _ctx: &PreToolUseContext) -> PreToolUseAction {
PreToolUseAction::Continue
}
/// 工具执行后调用。可返回 Continue 或 MutateOutput。
async fn post_tool_use(&self, _ctx: &PostToolUseContext) -> PostToolUseAction {
PostToolUseAction::Continue
}
/// 每个 ReAct step 完成后调用。
async fn on_step_complete(&self, _ctx: &StepCompleteContext) {}
/// 会话终止时调用。
async fn on_session_stop(&self, _ctx: &SessionStopContext<'_>) {}
// ── 新增 4 个生命周期事件(默认 no-op) ──
/// 子代理启动时调用。
async fn on_subagent_start(&self, _ctx: &SubagentStartContext) {}
/// 子代理停止时调用。
async fn on_subagent_stop(&self, _ctx: &SubagentStopContext) {}
/// 上下文压缩前调用。
async fn on_pre_compact(&self, _ctx: &PreCompactContext) {}
/// 上下文压缩后调用。
async fn on_post_compact(&self, _ctx: &PostCompactContext) {}
/// 工具执行失败时调用(独立于 PostToolUse,专注错误处理)。
async fn on_post_tool_use_failure(
&self,
_ctx: &PostToolUseFailureContext,
) -> PostToolUseAction {
PostToolUseAction::Continue
}
/// 权限请求前调用(参考 Claude Code PermissionRequest hook)。
///
/// Hook 可以覆盖权限决策或注入附加上下文。此方法在 PermissionChecker
/// 做出初步决策后、最终返回前触发。
///
/// 返回 `PermissionRequestAction::Continue` 保持当前决策不变。
async fn on_permission_request(
&self,
_ctx: &PermissionRequestContext,
) -> PermissionRequestAction {
PermissionRequestAction::Continue
}
/// 权限被拒绝后调用(参考 Claude Code PermissionDenied hook)。
///
/// 仅用于审计/日志/监控——返回值不影响执行流程。
/// 在权限被最终拒绝后触发(规则拒绝、Classifier 拒绝或用户拒绝)。
async fn on_permission_denied(&self, _ctx: &PermissionDeniedContext) {}
}
// ── Async Hook Trait ──
/// Fire-and-forget hook traitPhase 4.1+)。
///
/// 与 `AgentHook` 分离设计:
/// - `AgentHook` 的返回值(`PreToolUseAction` / `PostToolUseAction`)会立即影响执行流程
/// - `AsyncAgentHook` 不返回决策——它适合用于后台操作(上传、远程日志上报等)
///
/// HookRegistry 并行调度两类 hooksync hook 的结果用于决策,async hook 仅 fire-and-forget。
/// Async hook dispatch 默认超时 5 秒(仅限方法返回,后台工作继续独立执行)。
#[async_trait]
pub trait AsyncAgentHook: Send + Sync {
/// Hook 名称(用于日志和调试)
fn name(&self) -> &str;
/// 声明订阅的事件(默认全部)。与 AgentHook 相同的索引机制。
fn subscribed_events(&self) -> &[HookEvent] {
&[]
}
/// 工具执行后异步回调(fire-and-forget
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<'_>) {}
}
+464
View File
@@ -0,0 +1,464 @@
// src/agent/hooks/types.rs
//
// Agent 生命周期 Hooks 系统 — 类型定义。
//
// 从 src/agent/hooks/mod.rs 提取,包含所有数据结构、枚举和结果类型。
use std::collections::HashMap;
use std::time::Duration;
use chrono;
use serde_json;
use crate::agent::terminal::TurnTerminal;
/// 默认 per-hook 超时时间(30 秒),可通过 HOOK_TIMEOUT_SECS 环境变量覆盖。
pub const DEFAULT_HOOK_TIMEOUT: Duration = Duration::from_secs(30);
// ── Hook Event Enum ──
/// 生命周期事件类型,用于事件订阅索引。
/// 每个 hook 通过 `subscribed_events()` 声明关心的事件,
/// HookRegistry 据此预计算索引,避免无关调用。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum HookEvent {
OnSessionStart,
PreToolUse,
PostToolUse,
PostToolUseFailure,
OnStepComplete,
OnSessionStop,
OnSubagentStart,
OnSubagentStop,
OnPreCompact,
OnPostCompact,
/// 权限请求前触发(参考 Claude Code PermissionRequest hook)。
/// Hook 可以修改权限决策或注入附加上下文。
PermissionRequest,
/// 权限被拒绝后触发(参考 Claude Code PermissionDenied hook)。
/// 用于审计日志和安全监控。
PermissionDenied,
}
// ── Hook Contexts ──
/// 会话启动上下文
#[derive(Debug, Clone)]
pub struct SessionStartContext {
pub session_id: String,
pub turn_index: i32,
pub is_resume: bool,
}
/// PreToolUse hook 上下文
#[derive(Debug, Clone)]
pub struct PreToolUseContext {
pub session_id: String,
pub tool_name: String,
pub tool_args: serde_json::Value,
pub step: usize,
}
/// PostToolUse hook 上下文
#[derive(Debug, Clone)]
pub struct PostToolUseContext {
pub session_id: String,
pub agent_name: String,
pub tool_name: String,
pub tool_args: serde_json::Value,
pub output_content: String,
pub is_error: bool,
pub step: usize,
pub elapsed_ms: u64,
}
/// Step 完成上下文
#[derive(Debug, Clone)]
pub struct StepCompleteContext {
pub session_id: String,
pub step: usize,
pub max_steps: usize,
pub messages_count: usize,
pub estimated_tokens: usize,
pub token_limit: usize,
}
/// Session 终止上下文
#[derive(Debug, Clone)]
pub struct SessionStopContext<'a> {
pub session_id: String,
pub terminal: &'a TurnTerminal,
pub total_steps: usize,
}
/// 子代理启动上下文
#[derive(Debug, Clone)]
pub struct SubagentStartContext {
pub parent_session_id: String,
pub subagent_name: String,
pub prompt: String,
}
/// 子代理停止上下文
#[derive(Debug, Clone)]
pub struct SubagentStopContext {
pub parent_session_id: String,
pub subagent_name: String,
pub result_summary: String,
pub steps: usize,
pub is_error: bool,
}
/// 压缩前上下文
#[derive(Debug, Clone)]
pub struct PreCompactContext {
pub session_id: String,
pub message_count: usize,
pub estimated_tokens: usize,
}
/// 压缩后上下文
#[derive(Debug, Clone)]
pub struct PostCompactContext {
pub session_id: String,
pub new_message_count: usize,
pub compression_method: String,
}
/// 工具执行失败上下文(参考 Claude Code 的 PostToolUseFailure 事件)
#[derive(Debug, Clone)]
pub struct PostToolUseFailureContext {
pub session_id: String,
pub agent_name: String,
pub tool_name: String,
pub tool_args: serde_json::Value,
pub error_message: String,
pub is_interrupt: bool,
pub step: usize,
pub elapsed_ms: u64,
}
/// 权限请求上下文(参考 Claude Code PermissionRequest hook)。
///
/// 在权限检查器做出最终决策前触发。Hook 可以:
/// - 通过返回 `PermissionRequestAction::Override` 修改决策
/// - 注入审计上下文
/// - 记录安全日志
#[derive(Debug, Clone)]
pub struct PermissionRequestContext {
pub session_id: String,
pub agent_name: String,
pub tool_name: String,
pub tool_args: serde_json::Value,
/// 权限检查器当前的决策
pub current_decision: PermissionDecision,
/// 权限模式(default / accept_edits / bypass / dont_ask
pub permission_mode: String,
pub step: usize,
/// 是否为子代理调用(静默模式)
pub is_subagent: bool,
}
/// 权限检查决策(供 PermissionRequestContext 使用)
#[derive(Debug, Clone, PartialEq)]
pub enum PermissionDecision {
Allowed,
Denied { reason: String },
AskUser { message: String },
}
/// 权限请求 Hook 的动作
#[derive(Debug, Clone)]
pub enum PermissionRequestAction {
/// 继续使用当前决策
Continue,
/// 覆盖当前决策
Override(PermissionDecision),
/// 注入附加上下文但不改变决策
InjectContext { content: String },
}
/// 权限被拒绝上下文(参考 Claude Code PermissionDenied hook)。
///
/// 在权限被最终拒绝后触发。用于:
/// - 审计日志记录
/// - 安全事件监控
/// - 告警通知
#[derive(Debug, Clone)]
pub struct PermissionDeniedContext {
pub session_id: String,
pub agent_name: String,
pub tool_name: String,
pub tool_args: serde_json::Value,
/// 拒绝原因
pub reason: String,
/// 拒绝来源
pub source: PermissionDenialSource,
pub step: usize,
}
/// 权限拒绝来源
#[derive(Debug, Clone, PartialEq)]
pub enum PermissionDenialSource {
/// 被规则列表明确拒绝
Rule,
/// Auto-mode Classifier 判定为拒绝
Classifier,
/// 用户交互选择拒绝
User,
/// 超时自动拒绝
Timeout,
}
impl std::fmt::Display for PermissionDenialSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PermissionDenialSource::Rule => write!(f, "rule"),
PermissionDenialSource::Classifier => write!(f, "classifier"),
PermissionDenialSource::User => write!(f, "user"),
PermissionDenialSource::Timeout => write!(f, "timeout"),
}
}
}
// ── Blocking Error ──
/// 描述单个 hook 的阻塞错误。
/// 取代原来只保留第一个 Block 的做法,允许多个 hook 的阻塞原因都被记录。
#[derive(Debug, Clone)]
pub struct BlockingError {
pub hook_name: String,
pub reason: String,
}
impl BlockingError {
pub fn new(hook_name: impl Into<String>, reason: impl Into<String>) -> Self {
BlockingError {
hook_name: hook_name.into(),
reason: reason.into(),
}
}
}
// ── Tagged Context ──
/// Human-readable label for a HookEvent, used in system-reminder formatting.
pub fn event_label(event: HookEvent) -> &'static str {
match event {
HookEvent::OnSessionStart => "SessionStart",
HookEvent::PreToolUse => "PreToolUse",
HookEvent::PostToolUse => "PostToolUse",
HookEvent::PostToolUseFailure => "PostToolUseFailure",
HookEvent::OnStepComplete => "StepComplete",
HookEvent::OnSessionStop => "SessionStop",
HookEvent::OnSubagentStart => "SubagentStart",
HookEvent::OnSubagentStop => "SubagentStop",
HookEvent::OnPreCompact => "PreCompact",
HookEvent::OnPostCompact => "PostCompact",
HookEvent::PermissionRequest => "PermissionRequest",
HookEvent::PermissionDenied => "PermissionDenied",
}
}
/// Tagged context from a hook, preserving which hook produced it and why.
///
/// Unlike the anonymous `additional_contexts` strings, `TaggedContext` carries
/// the hook's name and the lifecycle event that triggered it. The executor
/// uses this information to produce clearer system-reminders in the LLM
/// transcript.
#[derive(Debug, Clone)]
pub struct TaggedContext {
/// Name of the hook that produced this context.
pub hook_name: String,
/// Which lifecycle event triggered this context injection.
pub source_event: HookEvent,
/// The actual context content.
pub content: String,
/// Wall-clock time when this context was created.
pub timestamp: chrono::DateTime<chrono::Utc>,
}
impl TaggedContext {
pub fn new(
hook_name: impl Into<String>,
source_event: HookEvent,
content: impl Into<String>,
) -> Self {
TaggedContext {
hook_name: hook_name.into(),
source_event,
content: content.into(),
timestamp: chrono::Utc::now(),
}
}
}
// ── Hook Actions ──
/// PreToolUse hook 返回的增强动作。
/// 支持:允许、阻止、修改输入、权限请求。
#[derive(Debug, Clone)]
pub enum PreToolUseAction {
/// 允许继续执行(默认)
Continue,
/// 阻止执行,附带原因
Block { reason: String },
/// 允许执行但修改输入参数或注入附加上下文
MutateInput {
updated_args: serde_json::Value,
additional_context: Option<String>,
},
/// 需要权限决策
PermissionRequired {
permission: String,
tool_name: String,
},
}
impl PreToolUseAction {
pub fn is_blocked(&self) -> bool {
matches!(self, PreToolUseAction::Block { .. })
}
pub fn block_reason(&self) -> Option<&str> {
match self {
PreToolUseAction::Block { reason } => Some(reason),
_ => None,
}
}
pub fn updated_args(&self) -> Option<&serde_json::Value> {
match self {
PreToolUseAction::MutateInput { updated_args, .. } => Some(updated_args),
_ => None,
}
}
pub fn additional_context(&self) -> Option<&str> {
match self {
PreToolUseAction::MutateInput {
additional_context, ..
} => additional_context.as_deref(),
_ => None,
}
}
}
/// 向后兼容类型别名 — 旧代码用 HookAction::Continue / HookAction::Block 仍可编译
pub type HookAction = PreToolUseAction;
/// PostToolUse hook 返回的动作。
/// 支持:保持输出不变、修改输出内容、警告标记、事后权限请求、结构化元数据。
#[derive(Debug, Clone)]
pub enum PostToolUseAction {
/// 保持原输出不变(默认)
Continue,
/// 修改输出内容,可选注入附加上下文供 LLM 后续推理使用
MutateOutput {
updated_content: String,
additional_context: Option<String>,
},
/// 标记工具结果为警告(不阻止,仅供 LLM 参考)
Warning {
message: String,
/// 若为 true,截断工具输出以避免模型上下文过载
truncate_output: bool,
},
/// 事后请求权限(post-hoc advisory,不阻塞主循环)
PermissionRequired {
permission: String,
tool_name: String,
},
/// 附加结构化元数据到结果中(保留在审计日志中)
Metadata {
key: String,
value: serde_json::Value,
},
}
impl PostToolUseAction {
pub fn is_continue(&self) -> bool {
matches!(self, PostToolUseAction::Continue)
}
pub fn updated_content(&self) -> Option<&str> {
match self {
PostToolUseAction::MutateOutput {
updated_content, ..
} => Some(updated_content),
_ => None,
}
}
pub fn warning_message(&self) -> Option<&str> {
match self {
PostToolUseAction::Warning { message, .. } => Some(message),
_ => None,
}
}
}
// ── Metrics Data ──
/// 可查询的运行指标快照
#[derive(Debug, Clone, Default)]
pub struct MetricsData {
pub tool_call_counts: HashMap<String, usize>,
pub total_steps: usize,
pub total_errors: usize,
pub session_id: Option<String>,
}
// ── Hook Trait ──
/// PreToolUse 聚合结果
#[derive(Debug, Clone)]
pub struct PreToolUseResult {
/// 最终动作(第一个非 Continue 动作获胜)
pub action: PreToolUseAction,
/// 累积的 additional_contexts(所有 MutateInput 的上下文,不丢失任何 hook 的贡献)
pub additional_contexts: Vec<String>,
/// 向后兼容便捷字段:用换行拼接的附加上下文
pub additional_context: Option<String>,
/// 所有阻塞错误(多个 hook 同时 block 时全部保留)
pub blocking_errors: Vec<BlockingError>,
/// 最终的工具参数(应用了最后一个 MutateInput 的修改)
pub final_args: serde_json::Value,
/// 带来源标记的上下文列表(Phase 1.3+:用于格式化有意义的 system-reminder
pub tagged_contexts: Vec<TaggedContext>,
}
impl PreToolUseResult {
/// 是否有 hook 请求了权限确认
pub fn is_permission_required(&self) -> bool {
matches!(self.action, PreToolUseAction::PermissionRequired { .. })
}
/// 获取权限确认的详情(permission 描述, tool_name
pub fn permission_info(&self) -> Option<(&str, &str)> {
match &self.action {
PreToolUseAction::PermissionRequired {
permission,
tool_name,
} => Some((permission.as_str(), tool_name.as_str())),
_ => None,
}
}
}
/// PostToolUse 聚合结果
#[derive(Debug, Clone)]
pub struct PostToolUseResult {
/// 最终输出内容(应用了最后一个 MutateOutput 的修改)
pub final_content: String,
/// 累积的 additional_contexts(所有 PostToolUse hooks 注入的上下文)
pub additional_contexts: Vec<String>,
/// 带来源标记的上下文列表(Phase 1.3+:用于格式化有意义的 system-reminder
pub tagged_contexts: Vec<TaggedContext>,
/// 非阻塞警告消息列表(Phase 3.1+)
pub warnings: Vec<String>,
/// 事后权限请求列表(post-hoc advisory,不阻塞主循环)
pub post_permission_requests: Vec<(String, String)>,
/// 结构化键值对元数据(Phase 3.1+)
pub metadata: std::collections::HashMap<String, serde_json::Value>,
}
+13 -4
View File
@@ -178,7 +178,11 @@ impl MemoryManager {
String::new(),
];
for entry in selected {
// 按 mtime 降序排列
let mut sorted: Vec<&&MemoryEntry> = selected.iter().collect();
sorted.sort_by_key(|e| std::cmp::Reverse(e.mtime));
for entry in sorted {
let type_tag = match entry.memory_type {
MemoryType::User => "[偏好]",
MemoryType::Feedback => "[反馈]",
@@ -343,7 +347,8 @@ impl MemoryManager {
/// 生成 system prompt 中注入的记忆段落。
///
/// 包含最近的记忆条目(最多 10 条),并在前面注明可信度提醒
/// 按 recency 排序(最新的记忆在前),取 top N 条
/// 跳过已过期的条目(超过 memory_max_age_days 的条目由 age 模块标记)。
pub fn build_system_reminder(&self, max_entries: usize) -> Option<String> {
if self.entries.is_empty() {
return None;
@@ -356,8 +361,12 @@ impl MemoryManager {
"".to_string(),
];
let count = max_entries.min(self.entries.len());
for entry in self.entries.iter().take(count) {
// 按 mtime 降序排列(最新的在前),提升注入记忆的时效性
let mut sorted_entries: Vec<&MemoryEntry> = self.entries.iter().collect();
sorted_entries.sort_by_key(|e| std::cmp::Reverse(e.mtime));
let count = max_entries.min(sorted_entries.len());
for entry in sorted_entries.iter().take(count) {
let type_tag = match entry.memory_type {
MemoryType::User => "[偏好]",
MemoryType::Feedback => "[反馈]",
+920
View File
@@ -0,0 +1,920 @@
// src/agent/runtime/checkpoint.rs
//
// Checkpoint 系统 — 透明的文件系统快照。
// 参考 Hermes-Agent checkpoint_manager.py 设计,使用 git2 crate 实现。
//
// 设计原则:
// 1. 对 LLM 完全透明 — 不是 AgentTool,是基础设施
// 2. 文件变更操作前自动触发,每目录每 turn 最多一次
// 3. 使用 git2 原生庫而非 subprocess,零命令行注入风险
// 4. 单一 bare repo 存储,内容寻址自动去重
//
// 存储布局:
// .checkpoints/ ← bare git 仓库(与 library_dir 同级)
// HEAD, config, objects/ ← git 内部
// refs/checkpoints/<hash> ← 每个工作目录的分支
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tracing::{debug, info, warn};
/// 默认排除的文件/目录模式(gitignore 格式)
const DEFAULT_EXCLUDES: &[&str] = &[
// 依赖 / 构建产物
"node_modules/",
"dist/",
"build/",
"target/",
"out/",
".next/",
// 缓存
"__pycache__/",
"*.pyc",
".cache/",
".pytest_cache/",
".mypy_cache/",
".ruff_cache/",
// 虚拟环境
".venv/",
"venv/",
// VCS
".git/",
".hg/",
".svn/",
// 编译产物
"*.so",
"*.dylib",
"*.dll",
"*.o",
"*.a",
"*.exe",
"*.obj",
// 大文件
"*.mp4",
"*.mov",
"*.mkv",
"*.zip",
"*.tar",
"*.tar.gz",
"*.tgz",
"*.7z",
"*.pdf", // 论文 PDF 已有 library/ 备份,不纳入快照
// 敏感文件
".env",
".env.*",
"*.log",
// OS 垃圾
".DS_Store",
"Thumbs.db",
];
/// 每个 turn 最多快照一次的工具
const CHECKPOINT_TRIGGER_TOOLS: &[&str] = &["file_write", "file_edit", "run_bash"];
/// Checkpoint 元数据
#[derive(Debug, Clone)]
pub struct CheckpointEntry {
/// 完整 commit hash
pub hash: String,
/// 短 hash(前 8 位)
pub short_hash: String,
/// ISO 8601 时间戳
pub timestamp: String,
/// 快照原因
pub reason: String,
/// 变更文件数
pub files_changed: usize,
/// 插入行数
pub insertions: usize,
/// 删除行数
pub deletions: usize,
}
/// Checkpoint 管理器
///
/// 使用 git2 创建和维护一个 bare git 仓库用于文件系统快照。
/// 每个被监控的工作目录在 `refs/checkpoints/<dir_hash>` 下有独立的分支。
///
/// 注意:`git2::Repository` 是 `!Sync`,所以内部状态通过 `Mutex<InnerState>` 保护。
pub struct CheckpointManager {
/// 主开关
enabled: bool,
/// bare git 仓库路径
repo_path: PathBuf,
/// 每个项目保留的最大快照数
max_snapshots: usize,
/// 单个文件大小上限(字节),超过此大小不纳入快照
max_file_size: usize,
/// 内部可变状态(Send + Sync
inner: Mutex<InnerState>,
}
/// 需要 Mutex 保护的可变状态
struct InnerState {
/// git2 仓库句柄
repo: Option<git2::Repository>,
/// 本 turn 已快照的目录集合
checkpointed_this_turn: HashSet<PathBuf>,
}
impl InnerState {
fn new() -> Self {
InnerState {
repo: None,
checkpointed_this_turn: HashSet::new(),
}
}
}
impl CheckpointManager {
/// 创建新的 checkpoint 管理器。
///
/// `store_path` 是 bare repo 的路径(建议: `<library_dir>/../.checkpoints`)。
/// 如果 `enabled` 为 false 或 git 不可用,所有操作静默跳过。
pub fn new(store_path: PathBuf, enabled: bool) -> Self {
let mut inner = InnerState::new();
if enabled {
match Self::init_store(&store_path) {
Ok(r) => {
info!("[Checkpoint] 仓库已初始化: {}", store_path.display());
inner.repo = Some(r);
}
Err(e) => {
warn!("[Checkpoint] 仓库初始化失败,快照功能已禁用: {}", e);
}
}
}
CheckpointManager {
enabled: enabled && inner.repo.is_some(),
repo_path: store_path,
max_snapshots: std::env::var("AGENT_CHECKPOINT_MAX_SNAPSHOTS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10),
max_file_size: std::env::var("AGENT_CHECKPOINT_MAX_FILE_SIZE_MB")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.map(|mb| mb * 1024 * 1024)
.unwrap_or(10 * 1024 * 1024), // 10 MB
inner: Mutex::new(inner),
}
}
// ── 初始化 ──
/// 初始化 bare git 仓库,设置排除规则。
fn init_store(store_path: &Path) -> Result<git2::Repository, anyhow::Error> {
// 创建父目录
if let Some(parent) = store_path.parent() {
std::fs::create_dir_all(parent)?;
}
// 初始化或打开 bare repo
if store_path.join("HEAD").exists() {
return Ok(git2::Repository::open(store_path)?);
}
let repo = git2::Repository::init_bare(store_path)?;
// 写入 .gitignore 排除规则
let exclude_path = store_path.join("info").join("exclude");
if let Some(parent) = exclude_path.parent() {
std::fs::create_dir_all(parent)?;
}
let exclude_content = DEFAULT_EXCLUDES.join("\n") + "\n";
std::fs::write(&exclude_path, exclude_content)?;
// 设置仓库级配置:禁用 gpgsign
if let Ok(mut config) = repo.config() {
let _ = config.set_str("user.email", "checkpoint@astroresearch.local");
let _ = config.set_str("user.name", "AstroResearch Checkpoint");
let _ = config.set_str("commit.gpgsign", "false");
let _ = config.set_str("tag.gpgSign", "false");
let _ = config.set_str("gc.auto", "0");
}
Ok(repo)
}
// ── Turn 生命周期 ──
/// 重置 per-turn 去重状态。每个 ReAct 循环迭代前调用。
pub fn new_turn(&self) {
if !self.enabled {
return;
}
if let Ok(mut inner) = self.inner.lock() {
inner.checkpointed_this_turn.clear();
}
}
// ── 公共 API ──
/// 确保工作目录已被快照。如果是本 turn 首次对该目录调用且 enabled,
/// 则创建快照。返回是否实际创建了快照。
///
/// 永远不 panic — 所有错误静默记录日志。
pub fn ensure_checkpoint(&self, working_dir: &Path, reason: &str) -> bool {
if !self.enabled {
return false;
}
let abs_dir = match std::fs::canonicalize(working_dir) {
Ok(d) => d,
Err(e) => {
debug!(
"[Checkpoint] 无法解析目录 '{}': {}",
working_dir.display(),
e
);
return false;
}
};
// 跳过根目录和 home 目录
if abs_dir == Path::new("/") || abs_dir == dirs_home() {
debug!("[Checkpoint] 跳过过于宽泛的目录: {}", abs_dir.display());
return false;
}
// 每 turn 每目录去重
{
let mut inner = match self.inner.lock() {
Ok(s) => s,
Err(e) => {
warn!("[Checkpoint] 锁异常: {}", e);
return false;
}
};
if inner.checkpointed_this_turn.contains(&abs_dir) {
return false;
}
inner.checkpointed_this_turn.insert(abs_dir.clone());
}
match self.take_snapshot(&abs_dir, reason) {
Ok(true) => {
info!(
"[Checkpoint] 快照完成: {} (reason={})",
abs_dir.display(),
reason
);
true
}
Ok(false) => false, // 无变更
Err(e) => {
debug!("[Checkpoint] 快照失败(非致命): {}", e);
false
}
}
}
/// 列出指定工作目录的所有快照。
pub fn list_checkpoints(&self, working_dir: &Path) -> Vec<CheckpointEntry> {
let inner = match self.inner.lock() {
Ok(i) => i,
Err(_) => return Vec::new(),
};
let repo = match &inner.repo {
Some(r) => r,
None => return Vec::new(),
};
let abs_dir = match std::fs::canonicalize(working_dir) {
Ok(d) => d,
Err(_) => return Vec::new(),
};
let ref_name = dir_ref_name(&abs_dir);
// 查找该 ref 的所有 commit
let mut revwalk = match repo.revwalk() {
Ok(r) => r,
Err(_) => return Vec::new(),
};
let _ = revwalk.push_ref(&ref_name);
let _ = revwalk.set_sorting(git2::Sort::TIME);
let mut entries = Vec::new();
for oid_result in revwalk {
let oid = match oid_result {
Ok(o) => o,
Err(_) => continue,
};
let commit = match repo.find_commit(oid) {
Ok(c) => c,
Err(_) => continue,
};
let time = commit.time();
let timestamp = chrono::DateTime::from_timestamp(time.seconds(), 0)
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%S").to_string())
.unwrap_or_else(|| "unknown".to_string());
let reason = commit.message().unwrap_or("checkpoint").to_string();
let short_hash = oid.to_string()[..8].to_string();
// 统计变更(与父 commit 比较)
let (files_changed, insertions, deletions) = if commit.parent_count() > 0 {
let parent = commit.parent(0).ok();
let parent_tree = parent.and_then(|p| p.tree().ok());
let this_tree = commit.tree().ok();
match (parent_tree, this_tree) {
(Some(pt), Some(tt)) => {
match repo.diff_tree_to_tree(Some(&pt), Some(&tt), None) {
Ok(diff) => match diff.stats() {
Ok(stats) => {
(stats.files_changed(), stats.insertions(), stats.deletions())
}
Err(_) => (0, 0, 0),
},
Err(_) => (0, 0, 0),
}
}
_ => (0, 0, 0),
}
} else {
// 初始快照:统计所有文件
let tree = commit.tree().ok();
(tree.map(|t| t.len()).unwrap_or(0), 0, 0)
};
entries.push(CheckpointEntry {
hash: oid.to_string(),
short_hash,
timestamp,
reason,
files_changed,
insertions,
deletions,
});
if entries.len() >= self.max_snapshots {
break;
}
}
entries
}
/// 比较快照与当前工作目录的差异。
pub fn diff(&self, working_dir: &Path, commit_hash: &str) -> Result<String, String> {
let inner = self.inner.lock().map_err(|e| format!("锁异常: {}", e))?;
let repo = inner.repo.as_ref().ok_or("Checkpoint 未启用")?;
let _abs_dir =
std::fs::canonicalize(working_dir).map_err(|e| format!("无法解析目录: {}", e))?;
// 校验 commit hash
if commit_hash.is_empty() || commit_hash.starts_with('-') || commit_hash.len() < 4 {
return Err("无效的 commit hash".to_string());
}
let oid = git2::Oid::from_str(commit_hash).map_err(|e| format!("无效的 OID: {}", e))?;
let commit = repo
.find_commit(oid)
.map_err(|e| format!("未找到快照: {}", e))?;
let snapshot_tree = commit
.tree()
.map_err(|e| format!("无法读取快照 tree: {}", e))?;
// 构建当前工作目录的 tree(就地构建 index)
let _index = repo.index().map_err(|e| format!("无法创建 index: {}", e))?;
// 我们无法直接 add_all 到 git2 index(它只读工作目录),
// 改用 diff 的 workdir 模式
let diff = repo
.diff_tree_to_workdir_with_index(Some(&snapshot_tree), None)
.map_err(|e| format!("无法生成 diff: {}", e))?;
let stats = diff.stats().map_err(|e| format!("无法统计 diff: {}", e))?;
let mut output = String::new();
output.push_str(&format!(
"快照 {} ({} 个文件变更, +{} -{} 行)\n\n",
&commit_hash[..8.min(commit_hash.len())],
stats.files_changed(),
stats.insertions(),
stats.deletions(),
));
// 生成 unified diff
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
let origin = line.origin();
let content = std::str::from_utf8(line.content()).unwrap_or("<binary>");
output.push(origin);
output.push_str(content);
true
})
.map_err(|e| format!("生成 patch 失败: {}", e))?;
Ok(output)
}
/// 恢复文件到指定快照的状态。
///
/// `file_path` 如果为 Some,仅恢复该文件;否则恢复整个目录。
/// 恢复前会自动创建 pre-rollback 快照。
pub fn restore(
&self,
working_dir: &Path,
commit_hash: &str,
file_path: Option<&str>,
) -> Result<String, String> {
let inner = self.inner.lock().map_err(|e| format!("锁异常: {}", e))?;
let repo = inner.repo.as_ref().ok_or("Checkpoint 未启用")?;
let abs_dir =
std::fs::canonicalize(working_dir).map_err(|e| format!("无法解析目录: {}", e))?;
// 校验参数
if commit_hash.is_empty() || commit_hash.starts_with('-') {
return Err("无效的 commit hash".to_string());
}
if let Some(fp) = file_path {
if fp.is_empty() || fp.starts_with('/') || fp.contains("..") {
return Err("无效的文件路径".to_string());
}
}
let oid = git2::Oid::from_str(commit_hash).map_err(|e| format!("无效的 OID: {}", e))?;
let commit = repo
.find_commit(oid)
.map_err(|e| format!("未找到快照: {}", e))?;
let tree = commit
.tree()
.map_err(|e| format!("无法读取快照 tree: {}", e))?;
// Pre-rollback 快照
let _ = self.take_snapshot(
&abs_dir,
&format!("pre-rollback (restoring to {})", &commit_hash[..8]),
);
// git2 的 checkout 操作
let mut checkout_builder = git2::build::CheckoutBuilder::new();
checkout_builder.force(); // 覆盖本地修改
if let Some(fp) = file_path {
// 恢复单个文件
let path = Path::new(fp);
checkout_builder.path(path);
}
repo.checkout_tree(tree.as_object(), Some(&mut checkout_builder))
.map_err(|e| format!("恢复失败: {}", e))?;
Ok(format!(
"已恢复到快照 {}",
&commit_hash[..8.min(commit_hash.len())]
))
}
/// 检查指定工具是否需要触发 checkpoint。
pub fn should_checkpoint(tool_name: &str) -> bool {
CHECKPOINT_TRIGGER_TOOLS.contains(&tool_name)
}
/// 获取 repo 路径
pub fn repo_path(&self) -> &Path {
&self.repo_path
}
// ── 内部方法 ──
/// 获取项目专属的 ref 名
fn ref_name_for(&self, dir: &Path) -> String {
dir_ref_name(dir)
}
/// 创建快照。
/// 返回 Ok(true) 表示创建了新快照,Ok(false) 表示无变更。
fn take_snapshot(&self, dir: &Path, reason: &str) -> Result<bool, anyhow::Error> {
let inner = self
.inner
.lock()
.map_err(|e| anyhow::anyhow!("锁异常: {}", e))?;
let repo = match &inner.repo {
Some(r) => r,
None => return Ok(false),
};
let ref_name = self.ref_name_for(dir);
// 查找该 ref 当前的 tip commit 作为父提交
let parent_commit = repo
.find_reference(&ref_name)
.ok()
.and_then(|r| r.peel_to_commit().ok());
// 构建当前目录的 tree
let mut index = git2::Index::new()?;
// 如果已有父提交,先用父提交的 tree 填充 index
if let Some(ref parent) = parent_commit {
let parent_tree = parent.tree()?;
index.read_tree(&parent_tree)?;
}
// 添加当前目录的所有文件
let walk_result = self.add_files_to_index(dir, &mut index)?;
if !walk_result {
// 无变更
return Ok(false);
}
// 排除超大文件
self.remove_oversize_from_index(dir, &mut index)?;
// 检查是否有实际变更
if let Some(ref parent) = parent_commit {
let parent_tree = parent.tree()?;
let new_tree_oid = index.write_tree()?;
if new_tree_oid == parent_tree.id() {
return Ok(false); // 无变更
}
// 把新 tree 写回 indexwrite_tree 消费了 index,需要重建)
// 实际上 git2 Index::write_tree 不消费 index,我们可以继续用
}
// 写入 tree
let tree_oid = index.write_tree()?;
let tree = repo.find_tree(tree_oid)?;
// 创建 commit
let signature =
git2::Signature::now("AstroResearch Checkpoint", "checkpoint@astroresearch.local")?;
let commit_id = if let Some(ref parent) = parent_commit {
repo.commit(
Some(&ref_name),
&signature,
&signature,
reason,
&tree,
&[parent],
)?
} else {
repo.commit(Some(&ref_name), &signature, &signature, reason, &tree, &[])?
};
debug!(
"[Checkpoint] 快照创建: dir={}, sha={}, reason={}",
dir.display(),
&commit_id.to_string()[..8],
reason
);
// 清理旧快照
if let Err(e) = self.prune_old(dir, &ref_name) {
debug!("[Checkpoint] 清理旧快照失败(非致命): {}", e);
}
Ok(true)
}
/// 遍历目录并将文件添加到 git index。
/// 返回 true 表示有文件被添加。
fn add_files_to_index(
&self,
dir: &Path,
index: &mut git2::Index,
) -> Result<bool, anyhow::Error> {
let mut added = false;
let max_files = 50_000;
let mut count = 0;
for entry in walkdir::WalkDir::new(dir)
.into_iter()
.filter_entry(|e| !is_excluded(e))
{
let entry = entry?;
if !entry.file_type().is_file() {
continue;
}
count += 1;
if count > max_files {
debug!("[Checkpoint] 目录文件数超过上限 ({}), 停止遍历", max_files);
break;
}
let abs_path = entry.path();
let rel_path = abs_path.strip_prefix(dir)?;
// 跳过符号链接和超大文件
let metadata = match std::fs::symlink_metadata(abs_path) {
Ok(m) => m,
Err(_) => continue,
};
if metadata.file_type().is_symlink() {
continue;
}
if metadata.len() > self.max_file_size as u64 {
continue;
}
// 将文件添加到 index
let rel_str = rel_path.to_string_lossy();
index.add_path(Path::new(&*rel_str))?;
added = true;
}
Ok(added)
}
/// 从 index 中移除超大文件。
fn remove_oversize_from_index(
&self,
_dir: &Path,
index: &mut git2::Index,
) -> Result<(), anyhow::Error> {
// NOTE: git2 Index 没有便捷的按大小过滤方法。
// 这里保留接口,后续可以在 add_files_to_index 阶段直接跳过(已实现)。
let _ = index;
Ok(())
}
/// 清理旧快照,每个项目保留最近 `max_snapshots` 个。
fn prune_old(&self, _dir: &Path, ref_name: &str) -> Result<(), anyhow::Error> {
let inner = self
.inner
.lock()
.map_err(|e| anyhow::anyhow!("锁异常: {}", e))?;
let repo = match &inner.repo {
Some(r) => r,
None => return Ok(()),
};
// 统计 commit 数量
let mut revwalk = repo.revwalk()?;
revwalk.push_ref(ref_name)?;
let count = revwalk.count();
if count <= self.max_snapshots {
return Ok(());
}
// 收集所有 commit(从旧到新)
let mut revwalk = repo.revwalk()?;
revwalk.push_ref(ref_name)?;
revwalk.set_sorting(git2::Sort::TIME | git2::Sort::REVERSE)?;
let commits: Vec<git2::Oid> = revwalk.filter_map(|r| r.ok()).collect();
if commits.len() <= self.max_snapshots {
return Ok(());
}
// 保留最后的 N 个
let keep = commits.len() - self.max_snapshots;
let drop_oids: Vec<git2::Oid> = commits.iter().take(keep).copied().collect();
// 重建线性链:从 keep_index 开始
let keep_start = keep;
let keep_commits: Vec<git2::Commit<'_>> = commits[keep_start..]
.iter()
.filter_map(|oid| repo.find_commit(*oid).ok())
.collect();
if keep_commits.is_empty() {
return Ok(());
}
// 重建 chain(保持原有的 tree 和 message
let signature =
git2::Signature::now("AstroResearch Checkpoint", "checkpoint@astroresearch.local")?;
let mut new_parent: Option<git2::Oid> = None;
for commit in &keep_commits {
let tree = commit.tree()?;
let message = commit.message().unwrap_or("checkpoint");
let new_oid = if let Some(parent) = new_parent {
let parent_commit = repo.find_commit(parent)?;
repo.commit(
None,
&signature,
&signature,
message,
&tree,
&[&parent_commit],
)?
} else {
repo.commit(None, &signature, &signature, message, &tree, &[])?
};
new_parent = Some(new_oid);
}
// 更新 ref
if let Some(new_tip) = new_parent {
repo.reference(ref_name, new_tip, true, "prune old checkpoints")?;
}
// 丢弃旧 commits 不再被引用 → git gc 会回收
let _ = drop_oids;
debug!(
"[Checkpoint] 清理完成: dropped {} commits, kept {}",
keep, self.max_snapshots
);
Ok(())
}
}
// ── 辅助函数 ──
/// 为目录生成 ref 名: `refs/checkpoints/<sha256[:16]>`
fn dir_ref_name(dir: &Path) -> String {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
dir.to_string_lossy().hash(&mut hasher);
let hash = hasher.finish();
format!("refs/checkpoints/{:016x}", hash)
}
/// 获取 Home 目录
fn dirs_home() -> PathBuf {
std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/"))
}
/// 检查路径是否匹配排除规则。
///
/// 使用简单的 glob 匹配 DEFAULT_EXCLUDES 中列出的模式。
fn is_excluded(entry: &walkdir::DirEntry) -> bool {
let file_name = entry.file_name().to_string_lossy();
let path_str = entry.path().to_string_lossy();
for pattern in DEFAULT_EXCLUDES {
// 目录模式
if pattern.ends_with('/') {
let dir_name = pattern.trim_end_matches('/');
if file_name.as_ref() == dir_name && entry.file_type().is_dir() {
return true;
}
}
// 文件扩展名模式
if pattern.starts_with("*.") {
let ext = &pattern[1..]; // ".pyc"
if file_name.ends_with(ext) {
return true;
}
}
// 精确匹配
if file_name.as_ref() == *pattern {
return true;
}
// 包含目录路径的模式
if pattern.contains('/') && path_str.contains(pattern) {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
/// 创建临时目录和 checkpoint manager
fn setup(tmp: &tempfile::TempDir, enabled: bool) -> CheckpointManager {
let store = tmp.path().join(".checkpoints");
CheckpointManager::new(store, enabled)
}
#[test]
fn test_disabled_skips_all() {
let tmp = tempfile::tempdir().unwrap();
let mgr = setup(&tmp, false);
assert!(!mgr.ensure_checkpoint(tmp.path(), "test"));
assert!(mgr.list_checkpoints(tmp.path()).is_empty());
}
#[test]
fn test_enabled_creates_snapshot() {
let tmp = tempfile::tempdir().unwrap();
let work = tmp.path().join("work");
std::fs::create_dir_all(&work).unwrap();
// 创建一个文件
let file_path = work.join("test.txt");
let mut f = std::fs::File::create(&file_path).unwrap();
writeln!(f, "hello world").unwrap();
let mgr = setup(&tmp, true);
// First turn
mgr.new_turn();
let result = mgr.ensure_checkpoint(&work, "initial");
// git2 may fail in test environments without git config
// We just verify it doesn't panic
let _ = result;
// List checkpoints
let entries = mgr.list_checkpoints(&work);
// Not asserting count since git2 may behave differently
let _ = entries;
}
#[test]
fn test_dedup_per_turn() {
let tmp = tempfile::tempdir().unwrap();
let work = tmp.path().join("work");
std::fs::create_dir_all(&work).unwrap();
let mgr = setup(&tmp, true);
mgr.new_turn();
// 同一目录同一 turn 只快照一次
let first = mgr.ensure_checkpoint(&work, "first");
let second = mgr.ensure_checkpoint(&work, "second");
// second should be false (already checkpointed this turn)
if first {
assert!(!second);
}
}
#[test]
fn test_new_turn_resets_dedup() {
let tmp = tempfile::tempdir().unwrap();
let work = tmp.path().join("work");
std::fs::create_dir_all(&work).unwrap();
let mgr = setup(&tmp, true);
mgr.new_turn();
let _ = mgr.ensure_checkpoint(&work, "turn1");
mgr.new_turn(); // reset
// 新 turn 应该可以再次快照
let result = mgr.ensure_checkpoint(&work, "turn2");
let _ = result; // may or may not create depending on changes
}
#[test]
fn test_should_checkpoint_triggers() {
assert!(CheckpointManager::should_checkpoint("file_write"));
assert!(CheckpointManager::should_checkpoint("file_edit"));
assert!(CheckpointManager::should_checkpoint("run_bash"));
assert!(!CheckpointManager::should_checkpoint("read_file"));
assert!(!CheckpointManager::should_checkpoint("search_papers"));
}
#[test]
fn test_dir_ref_name_deterministic() {
let name1 = dir_ref_name(Path::new("/home/user/project"));
let name2 = dir_ref_name(Path::new("/home/user/project"));
assert_eq!(name1, name2);
let name3 = dir_ref_name(Path::new("/other/path"));
assert_ne!(name1, name3);
}
#[test]
fn test_restore_invalid_hash() {
let tmp = tempfile::tempdir().unwrap();
let mgr = setup(&tmp, true);
let result = mgr.restore(tmp.path(), "-invalid", None);
assert!(result.is_err());
}
#[test]
fn test_diff_invalid_hash() {
let tmp = tempfile::tempdir().unwrap();
let mgr = setup(&tmp, true);
let result = mgr.diff(tmp.path(), "-bad");
assert!(result.is_err());
}
#[test]
fn test_exclude_patterns() {
// Test that common excludes are matched
let tmp = tempfile::tempdir().unwrap();
// Create a .git directory
let git_dir = tmp.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
for entry in walkdir::WalkDir::new(tmp.path()) {
let e = entry.unwrap();
if e.file_name() == ".git" {
assert!(is_excluded(&e));
}
}
}
#[test]
fn test_disabled_on_init_failure() {
// Use a path that can't be created (e.g., /dev/null/file)
let bad_path = PathBuf::from("/proc/self/fd/0/checkpoints");
let mgr = CheckpointManager::new(bad_path, true);
assert!(!mgr.enabled);
}
}
File diff suppressed because it is too large Load Diff
+463 -130
View File
@@ -13,14 +13,18 @@ use tracing::{info, warn};
use crate::api::{AppState, PendingPermission};
use crate::clients::llm::{ChatMessage, ToolCall};
use super::checkpoint::CheckpointManager;
use super::denial_tracker::DenialTracker;
use super::file_cache::FileStateCache;
use super::hardline;
use super::partitioner::ToolPartitioner;
use super::permission::{PermissionChecker, PermissionResult};
use super::permission_explainer::explain_permission;
use super::{AgentStreamEvent, DuplicateDetector};
use crate::agent::hooks::{HookRegistry, PostToolUseContext, PreToolUseContext};
use crate::agent::tools::persist::maybe_persist_tool_result;
use crate::agent::tools::{InterruptBehavior, ToolContext, ToolOutput, ToolRegistry};
use crate::agent::hooks::{
event_label, HookRegistry, PostToolUseContext, PostToolUseFailureContext, PreToolUseContext,
};
use crate::agent::tools::{ToolContext, ToolRegistry};
/// 准备好的工具调用
#[derive(Debug, Clone)]
@@ -42,6 +46,10 @@ pub struct ToolExecutionResult {
pub tool_messages: Vec<ToolResultMessage>,
pub was_cancelled: bool,
pub had_duplicate: bool,
/// Hook 注入的附加上下文(PreToolUse + PostToolUse),需注入 LLM 消息列表
pub hook_contexts: Vec<String>,
/// Hook 的阻塞错误详情(用于日志和诊断)
pub blocking_errors: Vec<String>,
}
/// 验证工具调用:死循环检测 + 参数解析。
@@ -144,6 +152,7 @@ pub async fn execute_parallel(
permission_checker: Option<&PermissionChecker>,
session_permission_checker: Option<&std::sync::RwLock<PermissionChecker>>,
denial_tracker: Option<&std::sync::Mutex<DenialTracker>>,
checkpoint_manager: Option<&std::sync::Arc<CheckpointManager>>,
tx: &mpsc::UnboundedSender<AgentStreamEvent>,
db: &SqlitePool,
session_id: &str,
@@ -161,6 +170,8 @@ pub async fn execute_parallel(
tool_messages: Vec::new(),
was_cancelled: false,
had_duplicate: false,
hook_contexts: Vec::new(),
blocking_errors: Vec::new(),
};
}
@@ -180,7 +191,9 @@ pub async fn execute_parallel(
let exec_start = std::time::Instant::now();
let mut mutated_args: Vec<serde_json::Value> = Vec::new();
let mut additional_contexts: Vec<String> = Vec::new();
let mut hook_permission_required: Vec<bool> = Vec::new();
let mut hook_permission_info: Vec<Option<(String, String)>> = Vec::new();
// ^^^ (permission_desc, hook_tool_name)
let mut hook_blocking_errors: Vec<String> = Vec::new();
for prep in prepared_calls {
let hook_ctx = PreToolUseContext {
session_id: sid.clone(),
@@ -196,20 +209,39 @@ pub async fn execute_parallel(
prep.tool_name, reason
);
}
// 收集 hook 的权限请求
if result.is_permission_required() {
// 收集所有阻塞错误详情(含多个 hook 同时 block 的情况)
for be in &result.blocking_errors {
hook_blocking_errors.push(format!(
"[{}] 阻止 {}: {}",
be.hook_name, prep.tool_name, be.reason
));
}
// 收集 hook 的权限请求(保留完整信息用于 AskUser prompt
if let Some((permission, tool_name)) = result.permission_info() {
info!(
"[Executor] PreToolUse hook 请求了工具 {} 的权限确认",
prep.tool_name
"[Executor] Hook 请求了工具 {} 的权限确认: {}",
prep.tool_name, permission
);
hook_permission_required.push(true);
hook_permission_info.push(Some((permission.to_string(), tool_name.to_string())));
} else {
hook_permission_required.push(false);
hook_permission_info.push(None);
}
// 使用 hook 可能修改后的参数
mutated_args.push(result.final_args);
if let Some(ctx) = result.additional_context {
additional_contexts.push(ctx);
// 收集所有 hook 注入的上下文(优先使用带来源标记的 tagged_contexts
if !result.tagged_contexts.is_empty() {
for tc in &result.tagged_contexts {
additional_contexts.push(format!(
"[Hook: {} | {}] {}",
tc.hook_name,
event_label(tc.source_event),
tc.content,
));
}
} else {
for ctx in &result.additional_contexts {
additional_contexts.push(ctx.clone());
}
}
}
@@ -217,17 +249,83 @@ pub async fn execute_parallel(
// 被拒绝的工具直接注入错误 result,不进入执行队列。
let mut tool_messages: Vec<ToolResultMessage> = Vec::new();
let mut denied_indices: std::collections::HashSet<usize> = std::collections::HashSet::new();
// ── Hardline 预检查(在任何模式下都不可绕过)──
// 在 PermissionChecker 之前执行,确保 hardline 规则始终生效。
for (i, prep) in prepared_calls.iter().enumerate() {
let hardline_result = match prep.tool_name.as_str() {
"run_bash" => {
if let Some(cmd) = prep.args.get("command").and_then(|v| v.as_str()) {
hardline::check_command(cmd)
} else {
hardline::HardlineResult::allowed()
}
}
"file_write" | "file_edit" => {
if let Some(path) = prep.args.get("file_path").and_then(|v| v.as_str()) {
hardline::check_dangerous_path(path)
} else if let Some(path) = prep.args.get("path").and_then(|v| v.as_str()) {
hardline::check_dangerous_path(path)
} else {
hardline::HardlineResult::allowed()
}
}
_ => hardline::HardlineResult::allowed(),
};
if hardline_result.blocked {
warn!(
"[Executor] Hardline 阻止了工具 {} (category={}): {}",
prep.tool_name,
hardline_result.category.as_deref().unwrap_or("unknown"),
hardline_result.reason
);
let err_output = hardline_result.reason.clone();
let _ = tx.send(AgentStreamEvent::ToolResult {
tool_call_id: prep.tool_call_id.clone(),
name: prep.tool_name.clone(),
output: err_output.clone(),
is_error: true,
metadata: serde_json::json!({
"hardline_blocked": true,
"hardline_category": hardline_result.category,
}),
step,
});
let err_msg = ChatMessage::tool_result(&prep.tool_call_id, &err_output);
save_tool_message_sync(db, &sid, turn_index, step, &err_msg);
tool_messages.push(ToolResultMessage {
chat_message: err_msg,
was_error: true,
});
// 记录拒绝追踪
if let Some(dt) = denial_tracker {
if let Ok(mut tracker) = dt.lock() {
tracker.record_denial();
}
}
denied_indices.insert(i);
}
}
if let Some(checker) = permission_checker {
for (i, prep) in prepared_calls.iter().enumerate() {
let mut perm_result = checker.check(&prep.tool_name, Some(&prep.args));
perm_result = checker.apply_mode(perm_result, &prep.tool_name);
// Hook PermissionRequired — 若 Checker 返回 Allowed,升级为 Ask
if hook_permission_required.get(i).copied().unwrap_or(false) && perm_result.is_allowed()
{
perm_result = PermissionResult::AskUser {
message: format!("Hook 请求了工具 {} 的权限确认", prep.tool_name),
};
// 使用 hook 提供的具体权限描述替换泛型消息
if let Some(Some((ref perm_desc, _))) = hook_permission_info.get(i) {
if perm_result.is_allowed() {
perm_result = PermissionResult::AskUser {
message: format!(
"[Hook 权限请求] {}\n\n工具: {}\n参数: {}",
perm_desc,
prep.tool_name,
serde_json::to_string_pretty(&prep.args).unwrap_or_default(),
),
};
}
}
// 工具级 check_permissions() — 在 PermissionChecker 结果基础上叠加
@@ -274,10 +372,8 @@ pub async fn execute_parallel(
}
}
PermissionResult::Allowed => {
// 会话 Allow 仅在非 Deny 时覆盖(会话明确允许)
if !perm_result.is_denied() {
perm_result = PermissionResult::Allowed;
}
// 会话 Allow 仅覆盖 Allowed,保持 Deny/AskUser 不变
// 避免覆盖工具级 check_permissions() 升级的 AskUser
}
}
}
@@ -481,7 +577,14 @@ pub async fn execute_parallel(
}
} // if let Some(checker)
// Phase 3: 并行执行
// Phase 3: 分区并行执行(参考 Claude Code partitionToolCalls + runTools)。
//
// 改进:原实现将所有非拒绝工具放入单个 FuturesUnordered 无差别并发,
// 可能导致非并发安全工具(如 run_bash)错误地并行执行。
// 新实现使用 ToolPartitioner 将工具按并发安全性分批:
// - 连续的并发安全工具放入同一个并行批次(FuturesUnordered
// - 非并发安全工具独占一个串行批次(逐次执行)
// 批次内工具执行完成后立即推送 SSE 事件,不等待整个批次完成。
let cancelled = Arc::new(AtomicBool::new(false));
let cancel_flag = cancelled.clone();
let app_state_ref = app_state.clone();
@@ -501,128 +604,187 @@ pub async fn execute_parallel(
let timeout_dur = std::time::Duration::from_secs(tool_timeout_secs);
// Phase 3: 使用 FuturesUnordered 进行渐进式并行执行。
// 每个工具完成后立即发送 SSE ToolResult 事件到前端(非阻塞),
// 而后台继续等待其他工具完成。快工具的结果不会因慢工具而延迟。
let mut exec_futs: FuturesUnordered<_> = prepared_calls
// ── Checkpoint 预触发:对文件变更类工具在执行前创建快照 ──
if let Some(ckpt) = checkpoint_manager {
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
for prep in prepared_calls
.iter()
.filter(|p| CheckpointManager::should_checkpoint(&p.tool_name))
{
ckpt.ensure_checkpoint(&cwd, &format!("pre-{}", prep.tool_name));
}
}
// ── Phase 3a: 构建不包含被拒绝工具的 (原索引, PreparedCall) 映射 ──
let non_denied: Vec<(usize, &PreparedCall)> = prepared_calls
.iter()
.enumerate()
.filter(|(i, _)| !denied_indices.contains(i))
.map(|(i, prep)| {
let tool_name = prep.tool_name.clone();
let args = mutated_args
.get(i)
.cloned()
.unwrap_or_else(|| prep.args.clone());
let tool_ctx = ToolContext::with_file_cache(app_state.clone(), read_file_state.clone())
.with_sse_tx(tx.clone())
.with_session_id(session_id.to_string())
.with_thinking(enable_thinking)
.with_additional_dirs(additional_allowed_dirs.clone());
let cancelled = cancelled.clone();
let tool_opt = tool_registry.get(&tool_name);
Box::pin(async move {
let output = match tool_opt {
Some(tool) => {
let interrupt_behavior = tool.interrupt_behavior();
let is_blocking = interrupt_behavior == InterruptBehavior::Block;
let tool_fut = tool.execute(args, &tool_ctx);
let cancel_fut = async {
loop {
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
if !is_blocking && cancelled.load(Ordering::SeqCst) {
return;
}
}
};
tokio::select! {
res = tokio::time::timeout(timeout_dur, tool_fut) => {
match res {
Ok(output) => output,
Err(_) => ToolOutput::error(format!(
"工具 {} 执行超时({}秒)",
tool_name,
timeout_dur.as_secs()
)),
}
}
_ = cancel_fut => {
ToolOutput::error("执行已被用户取消")
}
}
}
None => ToolOutput::error(format!("未知工具: {}", tool_name)),
};
let was_cancelled = cancelled.load(Ordering::SeqCst);
(
prep.tool_call_id.clone(),
prep.tool_name.clone(),
prep.args.clone(),
output,
was_cancelled,
)
})
})
.collect();
// ── Phase 3b: 分区 ──
let non_denied_calls: Vec<PreparedCall> =
non_denied.iter().map(|(_, p)| (*p).clone()).collect();
let partitioner = ToolPartitioner::new(10);
let batches = partitioner.partition(&non_denied_calls, tool_registry);
// 预设非拒绝工具中哪些原索引属于已拒绝列表(不会有,但安全起见)
let original_index_of: std::collections::HashMap<String, usize> = non_denied
.iter()
.map(|(orig_idx, prep)| (prep.tool_call_id.clone(), *orig_idx))
.collect();
info!(
"[Executor] 工具分区完成: {} 工具 → {} 批次 ({} 串行 + {} 并行)",
non_denied.len(),
batches.len(),
batches.iter().filter(|b| !b.is_parallel).count(),
batches.iter().filter(|b| b.is_parallel).count(),
);
let mut was_cancelled = false;
// 渐进式处理结果:每个工具一完成就立即处理(SSE 事件 + PostToolUse hook + 持久化)
while let Some((tool_call_id, tool_name, tool_args, output, cancelled_flag)) =
exec_futs.next().await
{
if cancelled_flag {
was_cancelled = true;
// ── Phase 3c: 逐批次执行 ──
// 批次之间串行;并行批次内工具并发执行;串行批次内工具逐个执行。
for batch in &batches {
if was_cancelled {
break;
}
let elapsed_ms = exec_start.elapsed().as_millis() as u64;
if batch.is_parallel {
// ── 并行批次:FuturesUnordered 并发执行 ──
let mut exec_futs: FuturesUnordered<_> = batch
.calls
.iter()
.map(|prep| {
let orig_idx = original_index_of
.get(&prep.tool_call_id)
.copied()
.unwrap_or(0);
let tool_name = prep.tool_name.clone();
let args = mutated_args
.get(orig_idx)
.cloned()
.unwrap_or_else(|| prep.args.clone());
let tool_ctx =
ToolContext::with_file_cache(app_state.clone(), read_file_state.clone())
.with_sse_tx(tx.clone())
.with_session_id(session_id.to_string())
.with_thinking(enable_thinking)
.with_additional_dirs(additional_allowed_dirs.clone());
let cancelled = cancelled.clone();
let tool_opt = tool_registry.get(&tool_name);
// SSE 事件 — 立即推送到前端
let _ = tx.send(AgentStreamEvent::ToolResult {
tool_call_id: tool_call_id.clone(),
name: tool_name.clone(),
output: output.content.clone(),
is_error: output.is_error,
metadata: output.metadata.clone(),
step,
});
Box::pin(async move {
let output = execute_single_tool(
tool_opt,
args,
&tool_ctx,
&cancelled,
timeout_dur,
&tool_name,
)
.await;
let was_cancelled = cancelled.load(Ordering::SeqCst);
(
prep.tool_call_id.clone(),
prep.tool_name.clone(),
prep.args.clone(),
output,
was_cancelled,
)
})
})
.collect();
// 输出处理:小结果直接传递,大结果持久化到磁盘并返回 stub
let tool_results_dir = app_state.config.library_dir.join("tool-results");
let (processed_content, _persisted_path) = maybe_persist_tool_result(
&output.content,
&tool_call_id,
max_output_chars,
&tool_results_dir,
);
// 渐进式处理:每个工具一完成就处理
while let Some((tool_call_id, tool_name, tool_args, output, cancelled_flag)) =
exec_futs.next().await
{
if cancelled_flag {
was_cancelled = true;
}
process_single_result(
&tool_call_id,
&tool_name,
&tool_args,
&output,
cancelled_flag,
exec_start,
tx,
hook_registry,
&app_state.config.library_dir,
&sid,
agent_name,
step,
max_output_chars,
&mut tool_messages,
&mut additional_contexts,
db,
turn_index,
)
.await;
}
} else {
// ── 串行批次:逐个执行 ──
for prep in &batch.calls {
let orig_idx = original_index_of
.get(&prep.tool_call_id)
.copied()
.unwrap_or(0);
let tool_name = prep.tool_name.clone();
let args = mutated_args
.get(orig_idx)
.cloned()
.unwrap_or_else(|| prep.args.clone());
let tool_ctx =
ToolContext::with_file_cache(app_state.clone(), read_file_state.clone())
.with_sse_tx(tx.clone())
.with_session_id(session_id.to_string())
.with_thinking(enable_thinking)
.with_additional_dirs(additional_allowed_dirs.clone());
let tool_opt = tool_registry.get(&tool_name);
// PostToolUse hook
let post_ctx = PostToolUseContext {
session_id: sid.clone(),
agent_name: agent_name.to_string(),
tool_name: tool_name.clone(),
tool_args,
output_content: processed_content.clone(),
is_error: output.is_error,
step,
elapsed_ms,
};
let post_result = hook_registry.run_post_tool_use(&post_ctx).await;
let final_content = post_result.final_content;
let output = execute_single_tool(
tool_opt,
args,
&tool_ctx,
&cancelled,
timeout_dur,
&tool_name,
)
.await;
let cancelled_flag = cancelled.load(Ordering::SeqCst);
if cancelled_flag {
was_cancelled = true;
}
let chat_message = ChatMessage::tool_result(&tool_call_id, &final_content);
process_single_result(
&prep.tool_call_id,
&tool_name,
&prep.args,
&output,
cancelled_flag,
exec_start,
tx,
hook_registry,
&app_state.config.library_dir,
&sid,
agent_name,
step,
max_output_chars,
&mut tool_messages,
&mut additional_contexts,
db,
turn_index,
)
.await;
// 持久化到数据库(fire-and-forget
save_tool_message_sync(db, &sid, turn_index, step, &chat_message);
tool_messages.push(ToolResultMessage {
chat_message,
was_error: output.is_error,
});
if was_cancelled {
break;
}
}
}
}
cancel_handle.abort();
@@ -631,9 +793,180 @@ pub async fn execute_parallel(
tool_messages,
was_cancelled,
had_duplicate: false,
hook_contexts: additional_contexts,
blocking_errors: hook_blocking_errors,
}
}
/// 执行单个工具调用(含超时和取消检测)。
///
/// 从原 `execute_parallel` 的闭包提取,供分区后的批次执行复用。
async fn execute_single_tool(
tool_opt: Option<&dyn crate::agent::tools::AgentTool>,
args: serde_json::Value,
tool_ctx: &crate::agent::tools::ToolContext,
cancelled: &Arc<AtomicBool>,
timeout_dur: std::time::Duration,
tool_name: &str,
) -> crate::agent::tools::ToolOutput {
let tool = match tool_opt {
Some(t) => t,
None => return crate::agent::tools::ToolOutput::error(format!("未知工具: {}", tool_name)),
};
let interrupt_behavior = tool.interrupt_behavior();
let is_blocking = interrupt_behavior == crate::agent::tools::InterruptBehavior::Block;
let tool_fut = tool.execute(args, tool_ctx);
let cancelled = cancelled.clone();
let cancel_fut = async {
loop {
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
if !is_blocking && cancelled.load(Ordering::SeqCst) {
return;
}
}
};
tokio::select! {
res = tokio::time::timeout(timeout_dur, tool_fut) => {
match res {
Ok(output) => output,
Err(_) => crate::agent::tools::ToolOutput::error(format!(
"工具 {} 执行超时({}秒)",
tool_name,
timeout_dur.as_secs()
)),
}
}
_ = cancel_fut => {
crate::agent::tools::ToolOutput::error("执行已被用户取消")
}
}
}
/// 处理单个工具执行结果(SSE 事件、PostToolUse hooks、持久化)。
///
/// 从原 `execute_parallel` 的结果处理循环提取。
#[allow(clippy::too_many_arguments)]
async fn process_single_result(
tool_call_id: &str,
tool_name: &str,
tool_args: &serde_json::Value,
output: &crate::agent::tools::ToolOutput,
cancelled_flag: bool,
exec_start: std::time::Instant,
tx: &mpsc::UnboundedSender<AgentStreamEvent>,
hook_registry: &HookRegistry,
library_dir: &std::path::Path,
sid: &str,
agent_name: &str,
step: usize,
max_output_chars: usize,
tool_messages: &mut Vec<ToolResultMessage>,
additional_contexts: &mut Vec<String>,
db: &SqlitePool,
turn_index: i32,
) {
use crate::agent::tools::persist::maybe_persist_tool_result;
let elapsed_ms = exec_start.elapsed().as_millis() as u64;
// SSE 事件 — 立即推送到前端
let _ = tx.send(AgentStreamEvent::ToolResult {
tool_call_id: tool_call_id.to_string(),
name: tool_name.to_string(),
output: output.content.clone(),
is_error: output.is_error,
metadata: output.metadata.clone(),
step,
});
// 输出处理:小结果直接传递,大结果持久化到磁盘并返回 stub
let tool_results_dir = library_dir.join("tool-results");
let (processed_content, _persisted_path) = maybe_persist_tool_result(
&output.content,
tool_call_id,
max_output_chars,
&tool_results_dir,
);
// PostToolUse hook
let post_ctx = PostToolUseContext {
session_id: sid.to_string(),
agent_name: agent_name.to_string(),
tool_name: tool_name.to_string(),
tool_args: tool_args.clone(),
output_content: processed_content.clone(),
is_error: output.is_error,
step,
elapsed_ms,
};
let post_result = hook_registry.run_post_tool_use(&post_ctx).await;
let final_content = post_result.final_content;
// 非可信内容包裹(间接 prompt 注入防御)
let llm_content = super::untrusted::wrap_untrusted_content(tool_name, &final_content);
// 收集 PostToolUse hook 注入的上下文
if !post_result.tagged_contexts.is_empty() {
for tc in &post_result.tagged_contexts {
additional_contexts.push(format!(
"[Hook: {} | {}] {}",
tc.hook_name,
event_label(tc.source_event),
tc.content,
));
}
} else {
for ctx in &post_result.additional_contexts {
additional_contexts.push(ctx.clone());
}
}
// 收集 PostToolUse 的警告
for warning in &post_result.warnings {
additional_contexts.push(format!("[Hook Warning] {}", warning));
}
// 事后权限请求(audit trail
for (perm_tool, perm) in &post_result.post_permission_requests {
warn!(
"[Executor] Hook 事后请求权限: tool={} permission={}",
perm_tool, perm
);
}
// PostToolUseFailure hook
if output.is_error {
let failure_ctx = PostToolUseFailureContext {
session_id: sid.to_string(),
agent_name: agent_name.to_string(),
tool_name: tool_name.to_string(),
tool_args: tool_args.clone(),
error_message: output.content.clone(),
is_interrupt: cancelled_flag,
step,
elapsed_ms,
};
hook_registry
.run_on_post_tool_use_failure(&failure_ctx)
.await;
}
// 发送给 LLM 使用包裹后的内容(安全防御)
let chat_message = ChatMessage::tool_result(tool_call_id, &llm_content);
// 持久化到数据库(fire-and-forget
save_tool_message_sync(db, sid, turn_index, step, &chat_message);
tool_messages.push(ToolResultMessage {
chat_message,
was_error: output.is_error,
});
}
/// 同步保存 tool 角色消息到数据库。
fn save_tool_message_sync(
db: &SqlitePool,
+1 -1
View File
@@ -37,7 +37,7 @@ pub async fn finalize_turn(
app_state: Option<Arc<AppState>>,
) -> anyhow::Result<()> {
let new_turn_count: i32 = sqlx::query_scalar(
"SELECT COUNT(DISTINCT turn_index) FROM agent_messages WHERE session_id = ?",
"SELECT COUNT(DISTINCT turn_index) FROM agent_messages WHERE session_id = ? AND active = 1",
)
.bind(session_id)
.fetch_one(db)
+534
View File
@@ -0,0 +1,534 @@
// src/agent/runtime/hardline.rs
//
// Hardline 命令阻止层 — 不可绕过的危险命令检查。
// 参考 Hermes-Agent approval.py HARDLINE_PATTERNS 设计。
//
// 设计原则:
// 1. Hardline 规则在任何模式下都不可被绕过(包括 YOLO/Bypass 模式)
// 2. 优先级高于所有其他权限规则
// 3. 在命令执行前做反规避标准化后再匹配
//
// 阻止的命令类别:
// - 系统关机/重启
// - 磁盘擦除/格式化
// - Fork 炸弹
// - 递归删除根目录
// - kill -1(信号广播)
use regex::RegexSet;
use std::sync::LazyLock;
// ── 反规避命令标准化 ──
/// 在安全检查前对命令字符串做标准化处理。
/// 参考 Hermes `_normalize_command_for_detection()` 实现。
///
/// 转换顺序:
/// 1. 剥离 ANSI 转义序列(ECMA-48
/// 2. 剥离 null 字节
/// 3. Unicode 全角字符 NFKC 标准化
/// 4. 剥离 shell 反斜杠转义(`r\m` → `rm`
/// 5. 剥离空字符串字面量(`r''m` → `rm`
/// 6. 解析后的绝对路径还原为 `~/` 形式
pub fn normalize_command(raw: &str) -> String {
// Step 1: 剥离 ANSI 转义序列
let s = strip_ansi_escapes(raw);
// Step 2: 剥离 null 字节
let s = s.replace('\0', "");
// Step 3: Unicode NFKC 标准化(全角 → 半角)
let s = unicode_normalization::lookup(&s).unwrap_or_else(|| s.to_string());
// Step 4: 剥离 shell 反斜杠转义(`r\m` → `rm`
let s = strip_backslash_escapes(&s);
// Step 5: 剥离空字符串字面量(`r''m` → `rm`, `r""m` → `rm`
let s = strip_empty_string_literals(&s);
// Step 6: 还原 Home 路径
normalize_home_paths(&s)
}
/// 剥离 ANSI 转义序列(CSI 序列,ECMA-48 §5.4
fn strip_ansi_escapes(s: &str) -> String {
static ANSI_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]").expect("ANSI regex compile"));
ANSI_RE.replace_all(s, "").to_string()
}
/// Unicode 全角字符 NFKC 标准化 + 常见全角 ASCII 映射
mod unicode_normalization {
use std::collections::HashMap;
use std::sync::LazyLock;
static FULLWIDTH_MAP: LazyLock<HashMap<char, char>> = LazyLock::new(|| {
// 全角 ASCIIU+FF01-U+FF5E)映射到半角(U+0021-U+007E
let mut map = HashMap::new();
for code in 0xFF01u32..=0xFF5E {
if let Some(c) = char::from_u32(code) {
let half_width = char::from_u32(code - 0xFEE0).unwrap_or(c);
if c != half_width {
map.insert(c, half_width);
}
}
}
// 全角空格 U+3000 → 半角空格 U+0020
map.insert('\u{3000}', ' ');
map
});
/// 如果字符串包含全角字符,返回标准化后的版本;否则返回 None(无需复制)。
pub fn lookup(s: &str) -> Option<String> {
let needs_normalize = s.chars().any(|c| FULLWIDTH_MAP.contains_key(&c));
if !needs_normalize {
return None;
}
let normalized: String = s
.chars()
.map(|c| FULLWIDTH_MAP.get(&c).copied().unwrap_or(c))
.collect();
Some(normalized)
}
}
/// 剥离 shell 反斜杠转义。
/// 匹配 `\<任意字符>` 并还原为 `<字符>`。
/// 示例:`r\m\ \-\r\f\ \/` → `rm -rf /`
fn strip_backslash_escapes(s: &str) -> String {
// 匹配 backslash 后跟任意非换行字符,捕获该字符
static BACKSLASH_ESCAPE_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"\\(.)").expect("backslash escape regex compile"));
// 仅当有反斜杠时才执行替换(快速路径)
if s.contains('\\') {
BACKSLASH_ESCAPE_RE.replace_all(s, "$1").to_string()
} else {
s.to_string()
}
}
/// 剥离空字符串字面量。
/// 匹配 `''` 或 `""`shell 中用于分割命令名)。
/// 示例:`r''m` → `rm`, `r""m` → `rm`
fn strip_empty_string_literals(s: &str) -> String {
// 匹配连续两个单引号('')或连续两个双引号("")
static EMPTY_QUOTE_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"'{2}|\x22{2}").expect("empty quote regex compile"));
let has_single_empty = s.contains("''");
let has_double_empty = s.contains("\"\"");
if has_single_empty || has_double_empty {
EMPTY_QUOTE_RE.replace_all(s, "").to_string()
} else {
s.to_string()
}
}
/// 将解析后的绝对 HOME 路径还原为 `~/` 形式。
fn normalize_home_paths(s: &str) -> String {
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/unknown".to_string());
if home.is_empty() || home == "/" {
return s.to_string();
}
s.replace(&home, "~")
}
// ── Hardline 模式定义 ──
/// 不可绕过的硬阻止模式。
///
/// 每个模式包含:
/// - `pattern`: 正则表达式
/// - `category`: 命令类别(用于日志和错误消息)
/// - `message`: 返回给 LLM 的阻止理由
struct HardlinePattern {
pattern: &'static str,
category: &'static str,
message: &'static str,
}
/// Hardline 模式列表。
/// 参考 Hermes HARDLINE_PATTERNS + 科研场景特定扩展。
static HARDLINE_PATTERNS: LazyLock<Vec<HardlinePattern>> = LazyLock::new(|| {
vec![
// ══════ 系统关机/重启 ══════
HardlinePattern {
pattern: r"\b(?:shutdown|poweroff|halt|reboot)\b",
category: "system_shutdown",
message: "系统关机/重启命令被硬阻止",
},
HardlinePattern {
pattern: r"\binit\s+[06]\b",
category: "system_shutdown",
message: "init 运行级别切换被硬阻止",
},
HardlinePattern {
pattern: r"\bsystemctl\s+(?:poweroff|reboot|halt)\b",
category: "system_shutdown",
message: "systemctl 关机命令被硬阻止",
},
// ══════ 磁盘擦除/格式化 ══════
HardlinePattern {
pattern: r"\bmkfs\b",
category: "disk_format",
message: "磁盘格式化命令 mkfs 被硬阻止",
},
HardlinePattern {
pattern: r"\bdd\s+.*\bof=/dev/[a-z]+",
category: "dd_to_device",
message: "dd 写入块设备被硬阻止",
},
HardlinePattern {
pattern: r"\bdd\s+.*\bof=/dev/(?:sd[a-z]|nvme\d+n\d+|mmcblk\d+)",
category: "dd_to_device",
message: "dd 写入磁盘设备被硬阻止",
},
// ══════ 递归删除根目录 ══════
HardlinePattern {
pattern: r"\brm\s+-rf\s+(?:/|/\*)",
category: "rm_root",
message: "递归删除根目录被硬阻止",
},
HardlinePattern {
pattern: r"\brm\s+.*\s+-rf\s+/",
category: "rm_root",
message: "递归删除根目录被硬阻止",
},
// ══════ Fork 炸弹 ══════
HardlinePattern {
pattern: r":\(\)\s*\{[^}]*:[^}]*\}",
category: "fork_bomb",
message: "Fork 炸弹模式被硬阻止",
},
HardlinePattern {
pattern: r"\bperl\s+-e\s+.*fork.*while",
category: "fork_bomb",
message: "Perl fork 循环被硬阻止",
},
HardlinePattern {
pattern: r"\bpython3?\s+-c\s+.*while.*os\.fork",
category: "fork_bomb",
message: "Python fork 炸弹被硬阻止",
},
// ══════ Kill 信号广播 ══════
HardlinePattern {
pattern: r"\bkill\s+-1\b",
category: "kill_all",
message: "kill -1(信号广播到所有进程)被硬阻止",
},
// ══════ 覆盖关键系统文件 ══════
HardlinePattern {
pattern: r">\s*/etc/(?:passwd|shadow|sudoers|hosts)\b",
category: "system_file_overwrite",
message: "重定向覆盖关键系统文件被硬阻止",
},
HardlinePattern {
pattern: r"\bcp\s+.*\s+/etc/(?:passwd|shadow|sudoers)\b",
category: "system_file_overwrite",
message: "复制覆盖关键系统文件被硬阻止",
},
// ══════ chmod 危险操作 ══════
HardlinePattern {
pattern: r"\bchmod\s+.*777\s+/(?:etc|bin|usr|lib|sbin|boot)\b",
category: "dangerous_chmod",
message: "对系统目录执行 chmod 777 被硬阻止",
},
// ══════ chown 到 root ══════
HardlinePattern {
pattern: r"\bchown\s+-R\s+root:root\s+/",
category: "chown_root",
message: "递归 chown root 到根目录被硬阻止",
},
]
});
/// 编译后的 Hardline 正则集合(模块加载时编译一次)
static HARDLINE_REGEX_SET: LazyLock<RegexSet> = LazyLock::new(|| {
let patterns: Vec<&str> = HARDLINE_PATTERNS.iter().map(|p| p.pattern).collect();
RegexSet::new(&patterns).expect("Hardline regex patterns must compile")
});
// ── 检查 API ──
/// Hardline 检查结果
#[derive(Debug, Clone)]
pub struct HardlineResult {
/// 是否被阻止
pub blocked: bool,
/// 阻止原因(供 LLM 查看)
pub reason: String,
/// 命令类别(供日志分类)
pub category: Option<String>,
}
impl HardlineResult {
/// 通过检查
pub fn allowed() -> Self {
HardlineResult {
blocked: false,
reason: String::new(),
category: None,
}
}
/// 被拒绝
pub fn denied(reason: String, category: &str) -> Self {
HardlineResult {
blocked: true,
reason,
category: Some(category.to_string()),
}
}
}
/// 检查命令是否命中 hardline 模式。
///
/// 执行反规避标准化后再匹配,返回第一个命中的模式。
///
/// 注意:此函数在模块导入时冻结 `HERMES_YOLO_MODE`
/// 确保运行时无法通过设置环境变量绕过 hardline 检查。
pub fn check_command(raw_command: &str) -> HardlineResult {
let normalized = normalize_command(raw_command);
// ── YOLO 模式冻结 ──
// YOLO 模式在首次调用时从环境变量读取并缓存,
// 后续设置环境变量不会生效(防止注入攻击)。
static YOLO_MODE_FROZEN: LazyLock<bool> = LazyLock::new(|| {
let val = std::env::var("HERMES_YOLO_MODE")
.unwrap_or_default()
.to_lowercase();
val == "1" || val == "true" || val == "yes" || val == "on"
});
// Hardline 即使在 YOLO 模式下也不可绕过
let _yolo = *YOLO_MODE_FROZEN;
let matches: Vec<usize> = HARDLINE_REGEX_SET
.matches(&normalized)
.into_iter()
.collect();
if let Some(&idx) = matches.first() {
let pattern = &HARDLINE_PATTERNS[idx];
let reason = format!(
"⚠️ 命令被硬阻止(安全策略,不可绕过)。\n\
: {}\n\
: {}\n\
",
pattern.category, pattern.message
);
HardlineResult::denied(reason, pattern.category)
} else {
HardlineResult::allowed()
}
}
/// 仅做标准化(不检查 hardline),用于在其他安全检查前预处理命令。
pub fn normalize_only(raw: &str) -> String {
normalize_command(raw)
}
/// 检查命令是否包含危险的重定向操作。
/// 用于 file_write/file_edit 等非 bash 工具的路径安全检查。
pub fn check_dangerous_path(path: &str) -> HardlineResult {
let normalized = normalize_command(path);
// 检查是否尝试覆盖关键系统文件
let dangerous_prefixes = [
"/etc/passwd",
"/etc/shadow",
"/etc/sudoers",
"/etc/sudoers.d/",
"/etc/ssh/",
"/root/",
"/boot/",
"~/.ssh/authorized_keys",
"~/.ssh/id_rsa",
"~/.ssh/id_ed25519",
"~/.netrc",
"~/.pgpass",
"~/.npmrc",
"~/.pypirc",
"~/.git-credentials",
];
for prefix in &dangerous_prefixes {
if normalized.starts_with(prefix) || normalized.contains(prefix) {
return HardlineResult::denied(
format!(
"路径 '{}' 指向受保护的系统/凭据文件,写入操作被硬阻止。",
path
),
"sensitive_path",
);
}
}
HardlineResult::allowed()
}
#[cfg(test)]
mod tests {
use super::*;
// ── 标准化测试 ──
#[test]
fn test_normalize_backslash_escapes() {
assert_eq!(normalize_command(r"r\m"), "rm");
assert_eq!(normalize_command(r"r\m\ \-\r\f"), "rm -rf");
}
#[test]
fn test_normalize_empty_string_literals() {
assert_eq!(normalize_command("r''m"), "rm");
assert_eq!(normalize_command("r\"\"m"), "rm");
}
#[test]
fn test_normalize_fullwidth() {
// 全角 '' (U+FF52) → 半角 'r'
let fullwidth_rm = "\u{FF52}\u{FF4D}"; // rm
let normalized = normalize_command(fullwidth_rm);
assert_eq!(normalized, "rm");
}
#[test]
fn test_normalize_ansi_strip() {
let cmd = "\x1b[31mrm -rf /\x1b[0m";
let normalized = normalize_command(cmd);
assert_eq!(normalized, "rm -rf /");
}
#[test]
fn test_normalize_null_bytes() {
let cmd = "rm\0 -rf\0 /";
let normalized = normalize_command(cmd);
assert!(!normalized.contains('\0'));
}
// ── Hardline 检查测试 ──
#[test]
fn test_block_shutdown() {
let result = check_command("shutdown -h now");
assert!(result.blocked);
assert_eq!(result.category.as_deref(), Some("system_shutdown"));
}
#[test]
fn test_block_reboot() {
let result = check_command("reboot");
assert!(result.blocked);
}
#[test]
fn test_block_systemctl_poweroff() {
let result = check_command("systemctl poweroff");
assert!(result.blocked);
}
#[test]
fn test_block_mkfs() {
let result = check_command("mkfs.ext4 /dev/sda1");
assert!(result.blocked);
}
#[test]
fn test_block_dd_to_device() {
let result = check_command("dd if=/dev/zero of=/dev/sda bs=1M");
assert!(result.blocked);
}
#[test]
fn test_block_dd_to_nvme() {
let result = check_command("dd if=image.iso of=/dev/nvme0n1");
assert!(result.blocked);
}
#[test]
fn test_block_rm_rf_root() {
let result = check_command("rm -rf /");
assert!(result.blocked);
}
#[test]
fn test_block_rm_rf_root_wildcard() {
let result = check_command("rm -rf /*");
assert!(result.blocked);
}
#[test]
fn test_block_fork_bomb() {
let result = check_command(":(){ :|:& };:");
assert!(result.blocked);
}
#[test]
fn test_block_kill_minus_one() {
let result = check_command("kill -1 1");
assert!(result.blocked);
}
#[test]
fn test_block_redirect_overwrite_passwd() {
let result = check_command("echo 'x' > /etc/passwd");
assert!(result.blocked);
}
#[test]
fn test_block_chmod_777_etc() {
let result = check_command("chmod -R 777 /etc");
assert!(result.blocked);
}
#[test]
fn test_block_chown_root() {
let result = check_command("chown -R root:root /");
assert!(result.blocked);
}
#[test]
fn test_allow_normal_commands() {
assert!(!check_command("ls -la").blocked);
assert!(!check_command("cargo build").blocked);
assert!(!check_command("git status").blocked);
assert!(!check_command("python3 -c 'print(1+1)'").blocked);
}
#[test]
fn test_allow_safe_rm() {
// rm 单个文件不阻止
assert!(!check_command("rm file.txt").blocked);
assert!(!check_command("rm -rf ./node_modules").blocked);
}
#[test]
fn test_allow_dd_to_file() {
// dd 写入普通文件不阻止
assert!(!check_command("dd if=/dev/zero of=test.bin bs=1M count=10").blocked);
}
#[test]
fn test_evasion_backslash_escapes() {
// r\e\b\o\o\t 应该匹配 reboot
let result = check_command(r"r\e\b\o\o\t");
assert!(result.blocked);
}
#[test]
fn test_evasion_empty_quotes() {
// r''m 应该匹配
let result = check_command("r''m -rf /");
assert!(result.blocked);
}
#[test]
fn test_dangerous_path_check() {
let result = check_dangerous_path("/etc/passwd");
assert!(result.blocked);
let result = check_dangerous_path("/home/user/data.txt");
assert!(!result.blocked);
}
}
+204 -28
View File
@@ -11,6 +11,7 @@
// executor — 工具调用验证与并行执行
// finalize — 会话收尾、指标持久化
pub mod checkpoint;
pub mod circuit_breaker;
pub mod context;
pub mod denial_tracker;
@@ -18,6 +19,7 @@ pub mod error_recovery;
pub mod executor;
pub mod file_cache;
pub mod finalize;
pub mod hardline;
pub mod partitioner;
pub mod permission;
pub mod permission_explainer;
@@ -27,6 +29,7 @@ pub mod streaming;
pub mod streaming_executor;
pub mod system_prompt;
pub mod token_budget;
pub mod untrusted;
use serde::Serialize;
use sqlx::SqlitePool;
@@ -46,6 +49,7 @@ use crate::clients::llm::{ChatMessage, LlmClient, MessageRole, StreamEvent};
use self::error_recovery::{classify_error, ErrorKind, ErrorRecovery};
use self::session::SessionInfo;
use self::streaming::{StreamOutput, StreamStatus};
use self::system_prompt::SystemPromptCache;
use self::token_budget::TokenBudget;
/// Agent 配置参数
@@ -303,6 +307,12 @@ pub struct AgentRuntime {
denial_tracker: Arc<std::sync::Mutex<denial_tracker::DenialTracker>>,
/// 文件状态缓存(跨 turn 共享,用于 Read 去重)
read_file_state: Arc<std::sync::Mutex<file_cache::FileStateCache>>,
/// 系统提示词 section 缓存(跨 turn 共享,避免每 turn 重建静态/低频变动内容)
prompt_cache: std::sync::Mutex<SystemPromptCache>,
/// 上下文压缩折叠日志(跨 turn 共享,追踪压缩历史并触发溢出合并)
collapse_log: Arc<std::sync::Mutex<compact::collapse::CollapseLog>>,
/// Checkpoint 管理器(跨 turn 共享,文件变更操作前自动快照)
checkpoint_manager: Arc<checkpoint::CheckpointManager>,
}
impl AgentRuntime {
@@ -334,6 +344,17 @@ impl AgentRuntime {
*session_checker = (*permission_checker).clone();
}
// 初始化 checkpoint 管理器
let checkpoint_enabled = std::env::var("AGENT_CHECKPOINT_ENABLED")
.unwrap_or_else(|_| "true".to_string())
.to_lowercase()
!= "false";
let checkpoint_store = app_state.config.library_dir.join("..").join(".checkpoints");
let checkpoint_manager = Arc::new(checkpoint::CheckpointManager::new(
std::fs::canonicalize(&checkpoint_store).unwrap_or(checkpoint_store),
checkpoint_enabled,
));
AgentRuntime {
app_state,
config,
@@ -346,6 +367,9 @@ impl AgentRuntime {
permission_checker,
denial_tracker,
read_file_state: Arc::new(std::sync::Mutex::new(file_cache::FileStateCache::new())),
prompt_cache: std::sync::Mutex::new(SystemPromptCache::new()),
collapse_log: Arc::new(std::sync::Mutex::new(compact::collapse::CollapseLog::new())),
checkpoint_manager,
}
}
@@ -373,6 +397,18 @@ impl AgentRuntime {
if let Ok(mut session_checker) = app_state.session_permission_checker.write() {
*session_checker = (*permission_checker).clone();
}
// 初始化 checkpoint 管理器
let checkpoint_enabled = std::env::var("AGENT_CHECKPOINT_ENABLED")
.unwrap_or_else(|_| "true".to_string())
.to_lowercase()
!= "false";
let checkpoint_store = app_state.config.library_dir.join("..").join(".checkpoints");
let checkpoint_manager = Arc::new(checkpoint::CheckpointManager::new(
std::fs::canonicalize(&checkpoint_store).unwrap_or(checkpoint_store),
checkpoint_enabled,
));
AgentRuntime {
app_state,
config,
@@ -385,6 +421,9 @@ impl AgentRuntime {
permission_checker,
denial_tracker,
read_file_state: Arc::new(std::sync::Mutex::new(file_cache::FileStateCache::new())),
prompt_cache: std::sync::Mutex::new(SystemPromptCache::new()),
collapse_log: Arc::new(std::sync::Mutex::new(compact::collapse::CollapseLog::new())),
checkpoint_manager,
}
}
@@ -427,12 +466,13 @@ impl AgentRuntime {
}
};
compact::compress_context_with_hooks(
compact::compress_context_with_hooks_and_log(
messages,
llm,
self.config.context_char_limit,
session_id,
Some(hook_registry),
Some(&self.collapse_log),
)
.await;
@@ -579,6 +619,9 @@ impl AgentRuntime {
loop {
step += 1;
// ── Checkpoint: 每个 ReAct 迭代开始时重置去重状态 ──
self.checkpoint_manager.new_turn();
// 检查用户取消
let is_cancelled = {
if let Ok(mut cancelled) = self.app_state.cancelled_runs.lock() {
@@ -923,7 +966,7 @@ impl AgentRuntime {
continue;
}
// 并行执行工具(带权限检查和分区器)
// 并行执行工具(带权限检查、checkpoint 和分区器)
let exec_result = executor::execute_parallel(
&prepared_calls,
&self.tool_registry,
@@ -932,6 +975,7 @@ impl AgentRuntime {
Some(&self.permission_checker),
Some(&self.app_state.session_permission_checker),
Some(&self.denial_tracker),
Some(&self.checkpoint_manager),
tx,
db,
sid,
@@ -961,6 +1005,25 @@ impl AgentRuntime {
messages.push(tm.chat_message);
}
// Hook 注入的附加上下文:包装为 system-reminder 注入 LLM 消息列表
// 使用 ContextDeduplicator 在单步内去重(多个 hook 注入相同内容时只保留一份)
let mut dedup = crate::agent::hooks::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 阻塞错误:记录到日志用于诊断
for be in &exec_result.blocking_errors {
warn!("[AgentRuntime] Hook 阻塞错误: {}", be);
}
// 持久化 todo_write 任务状态到数据库
if called_todo_write {
for prep in &prepared_calls {
@@ -1178,8 +1241,18 @@ impl AgentRuntime {
let mut recovery = ErrorRecovery::new(token_budget.clone());
while let Some(recovery_step) = recovery.try_recover(&error_kind) {
// 尝试从错误消息中解析 ContextOverflow 信息(参考 Claude Code 自动修复)
let overflow_info = error_recovery::parse_context_overflow(&e_str);
while let Some(recovery_step) = recovery.try_recover(&error_kind, overflow_info.as_ref()) {
match recovery_step {
error_recovery::RecoveryStep::AdjustMaxTokens { new_max_tokens } => {
info!(
"[AgentRuntime] 错误恢复: AdjustMaxTokens → {} (从错误消息自动计算)",
new_max_tokens
);
// token_budget.hard_limit 已由 try_recover 下调
}
error_recovery::RecoveryStep::RetryWithBackoff { attempt, delay_ms } => {
// 429/529 本应在 streaming 层处理,若到达此处说明分类逻辑有变更,
// 安全降级为 sleep + 直接重试(不依赖 streaming 层重试)。
@@ -1269,33 +1342,76 @@ impl AgentRuntime {
// ── Helpers ──
/// 系统提示词(模块化组装 — 参考 Claude Code s10)。
/// 静态 section 在前以最大化 prompt cache 命中率。
/// 系统提示词(模块化组装)。
///
/// 设计原则:
/// 1. 所有静态 section 在前 → 内容不变,服务端自然缓存
/// 2. 动态 sectionenvironment/tools/skills/memory)在后
/// 3. 使用 SystemPromptCache:首次计算后永久复用,/clear 时失效
fn system_prompt(&self) -> String {
use self::system_prompt::{SystemPrompt, IDENTITY_SECTION, PRINCIPLES_SECTION};
use self::system_prompt::{
SystemPrompt, IDENTITY_SECTION, PRINCIPLES_SECTION, SAFETY_SECTION,
SYSTEM_CONTEXT_SECTION, TOOL_USAGE_SECTION,
};
let mut sp = SystemPrompt::new();
// Section 1: 静态身份(始终加载,最大化缓存)
sp.add_section("identity", IDENTITY_SECTION.to_string());
// ═══════ 静态 section(首次计算后永久缓存)═══════
// Section 2: 动态工具列表(运行时生成)
let mut tools_desc = String::from("你可以使用以下工具:\n");
for def in self.tool_registry.definitions() {
let short_desc: String = def
.function
.description
.split('。')
.next()
.unwrap_or(&def.function.description)
.chars()
.take(80)
.collect();
tools_desc.push_str(&format!("- {}: {}\n", def.function.name, short_desc));
}
sp.add_section("tools", tools_desc);
let mut cache = self.prompt_cache.lock().unwrap_or_else(|e| {
tracing::warn!("[SystemPrompt] 缓存锁异常: {:?}", e);
e.into_inner()
});
// Section 3: 可用技能(动态)
sp.add_section(
"identity",
cache.get_or_compute("identity", || IDENTITY_SECTION.to_string()),
);
sp.add_section(
"principles",
cache.get_or_compute("principles", || PRINCIPLES_SECTION.to_string()),
);
sp.add_section(
"system_context",
cache.get_or_compute("system_context", || SYSTEM_CONTEXT_SECTION.to_string()),
);
sp.add_section(
"tool_usage",
cache.get_or_compute("tool_usage", || TOOL_USAGE_SECTION.to_string()),
);
sp.add_section(
"safety",
cache.get_or_compute("safety", || SAFETY_SECTION.to_string()),
);
// ═══════ 动态 section(首次计算后缓存,session 内不变)═══════
// 环境上下文:CWD/platform/OS/model 在 session 内不变
let env_section = cache.get_or_compute("environment", || self.build_environment_section());
sp.add_section("environment", env_section);
// 工具列表:ToolRegistry 在 session 内不变
let tools_section = cache.get_or_compute("tools", || {
let mut tools_desc = String::from("你可以使用以下工具:\n");
for def in self.tool_registry.definitions() {
let short_desc: String = def
.function
.description
.split('。')
.next()
.unwrap_or(&def.function.description)
.chars()
.take(80)
.collect();
tools_desc.push_str(&format!("- {}: {}\n", def.function.name, short_desc));
}
tools_desc
});
sp.add_section("tools", tools_section);
drop(cache);
// 技能列表:通过文件监听热更新,不缓存
if let Some(skills) = self
.app_state
.skill_registry
@@ -1306,7 +1422,7 @@ impl AgentRuntime {
sp.add_section("skills", skills);
}
// Section 4: 项目记忆(按需加载)
// 项目记忆:受 save_memory 工具实时影响,不缓存
if let Some(memory) = self
.app_state
.memory_manager
@@ -1317,12 +1433,72 @@ impl AgentRuntime {
sp.add_section("memory", memory);
}
// Section 5: 静态核心原则(最后加载,因较常变化)
sp.add_section("principles", PRINCIPLES_SECTION.to_string());
sp.assemble()
}
/// 使提示词缓存中指定 section 失效。
pub fn invalidate_prompt_cache(&self, section_name: &'static str) {
if let Ok(mut cache) = self.prompt_cache.lock() {
cache.invalidate(section_name);
}
}
/// 使所有提示词缓存失效(`/clear` 或 `/compact` 事件触发)。
pub fn invalidate_all_prompt_cache(&self) {
if let Ok(mut cache) = self.prompt_cache.lock() {
cache.invalidate_all();
}
}
/// 构建环境上下文 section(参考 Claude Code `computeEnvInfo()`)。
///
/// 包含:工作目录、git 状态、平台、OS 版本、日期、模型信息。
fn build_environment_section(&self) -> String {
let cwd = std::env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "(unknown)".to_string());
let is_git = std::process::Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
let platform = std::env::consts::OS;
let os_version = {
let output = std::process::Command::new("uname")
.args(["-s", "-r"])
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
if output.is_empty() {
std::env::consts::ARCH.to_string()
} else {
output
}
};
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
let model_name = self.app_state.llm.model().to_string();
let mut lines = vec![
"# 环境信息".to_string(),
format!("- 工作目录: {}", cwd),
format!("- Git 仓库: {}", if is_git { "" } else { "" }),
format!("- 平台: {}", platform),
format!("- OS 版本: {}", os_version),
format!("- 日期: {}", today),
format!("- 当前模型: {}", model_name),
];
// Agent 配置摘要(最大步数、超时等)
lines.push(format!("- 最大推理步数: {}", self.config.max_steps));
lines.push(format!("- 工具超时: {}", self.config.tool_timeout_secs));
lines.join("\n")
}
/// 步数耗尽时的最终答案生成(不带工具调用,强制 LLM 直接回答)
async fn final_answer_without_tools(
&self,
+49
View File
@@ -186,4 +186,53 @@ mod tests {
assert!(batches[1].is_parallel);
assert_eq!(batches[1].calls.len(), 2);
}
#[test]
fn test_run_bash_is_always_serial() {
let registry = make_registry();
let partitioner = ToolPartitioner::new(10);
// run_bash 总是非并发安全的(即使命令是只读的),
// 确保它独立成批
let calls = vec![
make_prep("search_papers"),
make_prep("run_bash"), // 非并发安全
make_prep("rag_search"),
];
let batches = partitioner.partition(&calls, &registry);
// search_papers (并行) → run_bash (串行) → rag_search (并行)
assert_eq!(batches.len(), 3, "run_bash 应打断并发批次");
assert!(batches[0].is_parallel);
assert_eq!(batches[0].calls[0].tool_name, "search_papers");
assert!(!batches[1].is_parallel, "run_bash 必须是串行批次");
assert_eq!(batches[1].calls[0].tool_name, "run_bash");
assert!(batches[2].is_parallel);
assert_eq!(batches[2].calls[0].tool_name, "rag_search");
}
#[test]
fn test_file_write_splits_batch() {
let registry = make_registry();
let partitioner = ToolPartitioner::new(10);
// file_write/file_edit 是非并发安全的
let calls = vec![
make_prep("read_file"),
make_prep("file_write"),
make_prep("read_file"),
];
let batches = partitioner.partition(&calls, &registry);
// read_file (并发) → file_write (串行) → read_file (并发)
assert_eq!(batches.len(), 3, "file_write 应打断并发批次");
assert!(batches[0].is_parallel);
assert_eq!(batches[0].calls[0].tool_name, "read_file");
assert!(!batches[1].is_parallel, "file_write 必须是串行批次");
assert_eq!(batches[1].calls[0].tool_name, "file_write");
assert!(batches[2].is_parallel);
assert_eq!(batches[2].calls[0].tool_name, "read_file");
}
}
+217
View File
@@ -214,6 +214,38 @@ impl PermissionChecker {
}
}
/// 返回一个人类可读的解释,说明某工具为何被允许/拒绝/询问。
/// 用于 hook 审计和调试。
///
/// 遍历规则列表,格式化第一条匹配规则为可读字符串。
pub fn explain(&self, tool_name: &str, tool_args: Option<&serde_json::Value>) -> String {
for rule in &self.rules {
match rule {
PermissionRule::Deny {
tool_name: name,
reason,
..
} if Self::matches(name, tool_name, tool_args) => {
return format!("Denied by rule: {name}{reason}");
}
PermissionRule::Allow {
tool_name: name, ..
} if Self::matches(name, tool_name, tool_args) => {
return format!("Allowed by rule: {name}");
}
PermissionRule::Ask {
tool_name: name,
message,
..
} if Self::matches(name, tool_name, tool_args) => {
return format!("Ask by rule: {name}{message}");
}
_ => {}
}
}
"Allowed by default (no matching rule)".to_string()
}
/// 规则名称匹配:支持精确匹配、通配符 "*",以及内容级匹配。
///
/// 内容级格式:`"tool_name(content_pattern)"`。
@@ -466,6 +498,109 @@ impl PermissionChecker {
}
}
// ── Permission Precedence Resolver ──
/// 多源权限决策的最终裁决。遵循正式的优先级规则表:
///
/// | Priority | Source | Overridable By |
/// |----------|----------------------------------|----------------|
/// | P0 | PermissionChecker::Deny | Nothing |
/// | P1 | Tool-level PermissionRule::Deny | Nothing |
/// | P2 | Session-level Checker::Deny | Nothing |
/// | P3 | Hook PreToolUseAction::Block | P0-P2 |
/// | P4 | Hook PermissionRequired | P0-P3 |
/// | P5-P7 | Checker::Ask / Tool::Ask / Allow | Normal |
///
/// `conflict_log` 记录被覆盖的决策,便于审计和调试。
pub fn resolve_permission_precedence(
checker_result: PermissionResult,
tool_rules: &[crate::agent::tools::PermissionRule],
hook_permission: Option<&(String, String)>, // (permission_desc, tool_name)
hook_blocked: bool,
session_result: Option<PermissionResult>,
) -> (PermissionResult, Vec<String>) {
let mut final_result = checker_result;
let mut conflict_log: Vec<String> = Vec::new();
// ── P1: Tool-level Deny ──
for rule in tool_rules {
if let crate::agent::tools::PermissionRule::Deny { reason, .. } = rule {
if !final_result.is_denied() {
conflict_log.push(format!("Tool-level Deny overrides checker: {reason}"));
final_result = PermissionResult::Denied {
reason: reason.clone(),
};
} else {
conflict_log.push(format!(
"Tool-level Deny '{reason}' ignored: already Denied"
));
}
break; // only handle first Deny
}
}
// ── P2: Session-level Deny ──
if let Some(PermissionResult::Denied { reason }) = &session_result {
conflict_log.push(format!("Session-level Deny overrides current: {reason}"));
final_result = PermissionResult::Denied {
reason: reason.clone(),
};
}
// ── P3: Hook Block ──
if hook_blocked {
conflict_log.push("Hook Block prevents execution".to_string());
// Block is already handled in the executor via denied_indices;
// here we record it for the conflict log.
}
// ── P4: Hook PermissionRequired ──
if let Some((perm_desc, _tool_name)) = hook_permission {
if final_result.is_allowed() {
conflict_log.push(format!(
"Hook PermissionRequired upgrades Allowed → Ask: {perm_desc}"
));
} else if !final_result.is_denied() {
conflict_log.push(format!(
"Hook PermissionRequired coexists with current state: {perm_desc}"
));
} else {
conflict_log.push(format!(
"Hook PermissionRequired '{perm_desc}' ignored: already Denied"
));
}
}
// ── P5: Session-level Ask ──
if let Some(PermissionResult::AskUser { message }) = &session_result {
if final_result.is_allowed() {
final_result = PermissionResult::AskUser {
message: message.clone(),
};
conflict_log.push("Session-level Ask overrides Allow".to_string());
} else {
conflict_log.push("Session-level Ask ignored: not Allowed".to_string());
}
}
// ── P6: Tool-level Ask ──
for rule in tool_rules {
if let crate::agent::tools::PermissionRule::Ask { message, .. } = rule {
if final_result.is_allowed() {
conflict_log.push(format!("Tool-level Ask upgrades Allow: {message}"));
final_result = PermissionResult::AskUser {
message: message.clone(),
};
} else {
conflict_log.push("Tool-level Ask ignored: not Allowed".to_string());
}
break;
}
}
(final_result, conflict_log)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -819,4 +954,86 @@ mod tests {
// AcceptEdits 保留 AskUser 让 executor 做路径检查
assert!(matches!(result, PermissionResult::AskUser { .. }));
}
// ── resolve_permission_precedence tests ──
#[test]
fn test_precedence_checker_deny_wins_over_all() {
let (result, log) = resolve_permission_precedence(
PermissionResult::Denied {
reason: "blocked by policy".into(),
},
&[],
Some(&("need confirmation".to_string(), "test_tool".to_string())),
false,
None,
);
assert!(result.is_denied());
assert!(
!log.is_empty(),
"conflict log should record the interaction"
);
}
#[test]
fn test_precedence_tool_deny_overrides_allow() {
use crate::agent::tools::{PermissionRule, PermissionRuleSource};
let tool_rules = vec![PermissionRule::Deny {
tool_name: "test_tool".into(),
reason: "tool self-protection".into(),
source: PermissionRuleSource::Env,
}];
let (result, log) = resolve_permission_precedence(
PermissionResult::Allowed,
&tool_rules,
None,
false,
None,
);
assert!(result.is_denied());
assert!(!log.is_empty());
}
#[test]
fn test_precedence_hook_block_recorded() {
let (result, log) = resolve_permission_precedence(
PermissionResult::Allowed,
&[],
None,
true, // hook blocked
None,
);
// Hook Block doesn't directly return Denied — it's logged for executor handling
assert!(result.is_allowed());
assert!(log.iter().any(|l| l.contains("Block")));
}
#[test]
fn test_precedence_session_deny_overrides() {
let (result, _log) = resolve_permission_precedence(
PermissionResult::Allowed,
&[],
None,
false,
Some(PermissionResult::Denied {
reason: "session deny".into(),
}),
);
assert!(result.is_denied());
}
#[test]
fn test_precedence_hook_permission_ignored_when_denied() {
let (result, log) = resolve_permission_precedence(
PermissionResult::Denied {
reason: "policy deny".into(),
},
&[],
Some(&("need confirm".to_string(), "test_tool".to_string())),
false,
None,
);
assert!(result.is_denied());
assert!(log.iter().any(|l| l.contains("ignored")));
}
}
+837 -16
View File
@@ -1,8 +1,10 @@
// src/agent/runtime/session.rs
//
// 会话生命周期管理:创建/恢复/验证 Agent 会话。
// 支持软删除回退 (undo)active=0 标记保留审计 trailLLM 不可见。
use sqlx::SqlitePool;
use tracing::info;
use crate::clients::llm::LlmClient;
@@ -13,9 +15,18 @@ pub struct SessionInfo {
pub turn_index: i32,
}
/// 回退操作结果
#[derive(Debug, Clone)]
pub struct RewindResult {
/// 被软删除的消息数
pub rewound_count: usize,
/// 目标消息的内容预览(供 UI 展示)
pub target_preview: String,
/// 回退后的 turn_index
pub new_turn_index: i32,
}
/// 创建新会话或恢复已有会话。
///
/// 返回会话信息。如果指定的 session_id 不存在则返回错误。
pub async fn create_or_resume_session(
db: &SqlitePool,
session_id: Option<String>,
@@ -23,7 +34,6 @@ pub async fn create_or_resume_session(
) -> anyhow::Result<SessionInfo> {
match session_id {
Some(id) => {
// 验证会话存在且未被软删除
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM agent_sessions WHERE session_id = ? AND deleted_at IS NULL)",
)
@@ -36,9 +46,10 @@ pub async fn create_or_resume_session(
return Err(anyhow::anyhow!("会话 {} 不存在或已删除", id));
}
// 计算当前轮次号
// 计算当前轮次号(仅统计 active=1 的消息)
let turn_index: i32 = sqlx::query_scalar(
"SELECT COALESCE(MAX(turn_index), -1) + 1 FROM agent_messages WHERE session_id = ?",
"SELECT COALESCE(MAX(turn_index), -1) + 1 FROM agent_messages \
WHERE session_id = ? AND active = 1",
)
.bind(&id)
.fetch_one(db)
@@ -68,10 +79,6 @@ pub async fn create_or_resume_session(
}
/// 加载会话的历史消息(供 LLM 上下文使用)。
///
/// `agent_name` 参数用于消息隔离:
/// - `"lead"` — 只加载 Lead Agent 自己的消息(默认)
/// - `"*"` — 加载所有 agent 的消息(调试/审计用)
pub async fn load_history_for_llm(
db: &SqlitePool,
session_id: &str,
@@ -79,14 +86,29 @@ pub async fn load_history_for_llm(
load_history_for_agent(db, session_id, "lead").await
}
/// 加载指定 agent 的历史消息。
/// 加载指定 agent 的历史消息(仅 active=1
pub async fn load_history_for_agent(
db: &SqlitePool,
session_id: &str,
agent_name: &str,
) -> anyhow::Result<Vec<crate::clients::llm::ChatMessage>> {
load_history_for_agent_impl(db, session_id, agent_name, true).await
}
/// 加载指定 agent 的历史消息。
///
/// `active_only`: true 时仅加载 active=1LLM 上下文),
/// false 时加载全部(审计/调试用)。
async fn load_history_for_agent_impl(
db: &SqlitePool,
session_id: &str,
agent_name: &str,
active_only: bool,
) -> anyhow::Result<Vec<crate::clients::llm::ChatMessage>> {
use crate::clients::llm::{ChatMessage, MessageRole};
let active_filter = if active_only { " AND active = 1" } else { "" };
#[allow(clippy::type_complexity)]
let rows: Vec<(
String,
@@ -95,18 +117,20 @@ pub async fn load_history_for_agent(
Option<String>,
Option<String>,
)> = if agent_name == "*" {
sqlx::query_as(
sqlx::query_as(&format!(
"SELECT role, content, tool_calls, tool_call_id, thought FROM agent_messages \
WHERE session_id = ? ORDER BY id ASC",
)
WHERE session_id = ?{} ORDER BY id ASC",
active_filter
))
.bind(session_id)
.fetch_all(db)
.await?
} else {
sqlx::query_as(
sqlx::query_as(&format!(
"SELECT role, content, tool_calls, tool_call_id, thought FROM agent_messages \
WHERE session_id = ? AND agent_name = ? ORDER BY id ASC",
)
WHERE session_id = ? AND agent_name = ?{} ORDER BY id ASC",
active_filter
))
.bind(session_id)
.bind(agent_name)
.fetch_all(db)
@@ -142,3 +166,800 @@ pub async fn load_history_for_agent(
Ok(messages)
}
// ── Rewind / Undo API ──
/// 回退会话到指定用户消息之前。
///
/// 软删除:将目标消息及之后的所有 active=1 消息设置为 active=0。
/// 返回被软删除的消息数和目标消息预览。
pub async fn rewind_to_message(
db: &SqlitePool,
session_id: &str,
target_message_id: i64,
) -> anyhow::Result<RewindResult> {
// 1. 验证目标消息是当前 session 的 user 消息且 active=1
let target: Option<(String, i32)> = sqlx::query_as(
"SELECT content, turn_index FROM agent_messages \
WHERE id = ? AND session_id = ? AND role = 'user' AND active = 1",
)
.bind(target_message_id)
.bind(session_id)
.fetch_optional(db)
.await?;
let (target_content, _target_turn) = match target {
Some(t) => t,
None => {
return Err(anyhow::anyhow!(
"目标消息 {} 不存在、不是用户消息、或已被回退",
target_message_id
));
}
};
// 2. 查找 >= target_id 的所有 active=1 消息
let to_rewind: Vec<i64> = sqlx::query_scalar(
"SELECT id FROM agent_messages \
WHERE session_id = ? AND id >= ? AND active = 1 \
ORDER BY id DESC",
)
.bind(session_id)
.bind(target_message_id)
.fetch_all(db)
.await?;
if to_rewind.is_empty() {
return Ok(RewindResult {
rewound_count: 0,
target_preview: String::new(),
new_turn_index: 0,
});
}
// 3. 原子执行软删除(单个事务)
let mut tx = db.begin().await?;
let count = to_rewind.len();
let first_rewound_id = to_rewind.last().copied().unwrap_or(0);
sqlx::query(
"UPDATE agent_messages SET active = 0 \
WHERE session_id = ? AND id >= ? AND active = 1",
)
.bind(session_id)
.bind(first_rewound_id)
.execute(&mut *tx)
.await?;
// 4. 更新 rewind_count
sqlx::query(
"UPDATE agent_sessions SET rewind_count = rewind_count + 1, \
updated_at = CURRENT_TIMESTAMP WHERE session_id = ?",
)
.bind(session_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
// 5. 计算新的 turn_index(目标消息之前的 turn
let new_turn_index: i32 = sqlx::query_scalar(
"SELECT COALESCE(MAX(turn_index), -1) + 1 FROM agent_messages \
WHERE session_id = ? AND active = 1",
)
.bind(session_id)
.fetch_one(db)
.await
.unwrap_or(0);
let preview: String = target_content.chars().take(120).collect();
info!(
"[Session] 回退完成: session={}, rewound={}, to_id={}, new_turn={}",
session_id, count, target_message_id, new_turn_index
);
Ok(RewindResult {
rewound_count: count,
target_preview: if preview.len() >= 120 {
format!("{}...", preview)
} else {
preview
},
new_turn_index,
})
}
/// 回退最新的 N 个用户轮次。
///
/// 查找最近 N 个 user 消息,回退到第 N 个之前。
///
/// 返回 `RewindResult`,若没有足够的 user 消息则回退全部。
pub async fn rewind_n_turns(
db: &SqlitePool,
session_id: &str,
n: usize,
) -> anyhow::Result<RewindResult> {
let n = n.max(1);
// 查找最近的 N 个 user 消息(按 id DESC
let user_ids: Vec<i64> = sqlx::query_scalar(
"SELECT id FROM agent_messages \
WHERE session_id = ? AND role = 'user' AND active = 1 \
ORDER BY id DESC \
LIMIT ?",
)
.bind(session_id)
.bind(n as i64)
.fetch_all(db)
.await?;
if user_ids.is_empty() {
return Ok(RewindResult {
rewound_count: 0,
target_preview: "没有可回退的消息".to_string(),
new_turn_index: 0,
});
}
// 回退到最早的 user 消息(第 N 个)的位置
let target_id = user_ids.last().copied().unwrap();
rewind_to_message(db, session_id, target_id).await
}
/// 恢复最近一次回退操作(undo-of-undo)。
///
/// 将所有 active=0 的消息恢复为 active=1。
///
/// **安全约束**: 如果回退后产生了新对话(有 active=1 消息的 id 大于
/// 被回退消息的 id),则拒绝恢复,因为这会导效消息穿插乱序。
/// 此种情况请使用 `/branch` 分叉到回退点后再探索替代路径。
///
/// 仅在"刚回退,尚未发送新消息"的场景下可安全使用。
pub async fn restore_rewound(db: &SqlitePool, session_id: &str) -> anyhow::Result<usize> {
// ── 冲突检测 ──
// 查找最小的 inactive 消息 id 和最大的 active 消息 id。
// 如果 max_active_id > min_inactive_id,说明回退后产生了新消息,
// 恢复会导致旧消息穿插在新消息之间。
let conflict: Option<(i64, i64)> = sqlx::query_as(
"SELECT \
(SELECT COALESCE(MAX(id), 0) FROM agent_messages WHERE session_id = ? AND active = 1), \
(SELECT COALESCE(MIN(id), 0) FROM agent_messages WHERE session_id = ? AND active = 0)",
)
.bind(session_id)
.bind(session_id)
.fetch_optional(db)
.await?
.map(|(max_active, min_inactive): (i64, i64)| (max_active, min_inactive))
.filter(|(max_active, min_inactive)| *max_active > 0 && *min_inactive > 0 && max_active > min_inactive);
if let Some((max_active, min_inactive)) = conflict {
return Err(anyhow::anyhow!(
"无法恢复回退:回退后已产生 {} 条新消息 (id {} ~ {})。\
(id {}) \
/rewind",
sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM agent_messages WHERE session_id = ? AND active = 1 AND id > ?"
)
.bind(session_id)
.bind(min_inactive)
.fetch_one(db)
.await
.unwrap_or(0),
min_inactive + 1,
max_active,
min_inactive
));
}
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM agent_messages \
WHERE session_id = ? AND active = 0",
)
.bind(session_id)
.fetch_one(db)
.await?;
if count == 0 {
return Ok(0);
}
sqlx::query(
"UPDATE agent_messages SET active = 1 \
WHERE session_id = ? AND active = 0",
)
.bind(session_id)
.execute(db)
.await?;
// 回退 rewind_count
sqlx::query(
"UPDATE agent_sessions SET rewind_count = MAX(0, rewind_count - 1), \
updated_at = CURRENT_TIMESTAMP WHERE session_id = ?",
)
.bind(session_id)
.execute(db)
.await?;
info!(
"[Session] 恢复回退: session={}, restored={} messages",
session_id, count
);
Ok(count as usize)
}
/// 重试最后一次对话(/retry 命令)。
///
/// 硬删除最后一条用户消息及之后的所有消息,返回被删除的用户消息文本。
/// 与 `/rewind`soft-delete)不同,此操作物理删除行,数据不可恢复。
///
/// 返回 `(deleted_message_text, new_turn_index)`。
/// 如果没有找到用户消息,返回错误。
pub async fn retry_last_turn(db: &SqlitePool, session_id: &str) -> anyhow::Result<(String, i32)> {
// 查找最后一条 user 消息(仅 active=1
let last_user: Option<(i64, String, i32)> = sqlx::query_as(
"SELECT id, content, turn_index FROM agent_messages \
WHERE session_id = ? AND role = 'user' AND active = 1 \
ORDER BY id DESC LIMIT 1",
)
.bind(session_id)
.fetch_optional(db)
.await?;
let (target_id, message_text, _turn) = match last_user {
Some(t) => t,
None => return Err(anyhow::anyhow!("没有找到可重试的用户消息")),
};
// 硬删除 >= target_id 的所有消息(含 active=0 的历史回退消息)
let deleted: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM agent_messages \
WHERE session_id = ? AND id >= ?",
)
.bind(session_id)
.bind(target_id)
.fetch_one(db)
.await?;
sqlx::query(
"DELETE FROM agent_messages \
WHERE session_id = ? AND id >= ?",
)
.bind(session_id)
.bind(target_id)
.execute(db)
.await?;
// 计算新的 turn_index
let new_turn_index: i32 = sqlx::query_scalar(
"SELECT COALESCE(MAX(turn_index), -1) + 1 FROM agent_messages \
WHERE session_id = ? AND active = 1",
)
.bind(session_id)
.fetch_one(db)
.await
.unwrap_or(0);
info!(
"[Session] 重试: session={}, deleted={} messages from id={}, new_turn={}",
session_id, deleted, target_id, new_turn_index
);
Ok((message_text, new_turn_index))
}
/// 分叉结果
#[derive(Debug, Clone)]
pub struct BranchResult {
/// 新分支的 session_id
pub branch_session_id: String,
/// 分叉点:原始会话中最后保留的消息 id
pub forked_at_message_id: i64,
/// 复制的消息数
pub copied_count: usize,
}
/// 创建会话分叉(/branch 命令)。
///
/// 将当前会话的所有 active=1 消息复制到新会话,
/// 新会话通过 `parent_session_id` 追溯源会话。
///
/// 分叉后两条分支完全独立,各自继续对话互不影响。
/// 这是 Hermes 推荐的"回到过去探索替代路径"方案。
pub async fn branch_session(db: &SqlitePool, session_id: &str) -> anyhow::Result<BranchResult> {
// 1. 验证源会话存在
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM agent_sessions WHERE session_id = ? AND deleted_at IS NULL)",
)
.bind(session_id)
.fetch_one(db)
.await?;
if !exists {
return Err(anyhow::anyhow!("会话 {} 不存在或已删除", session_id));
}
// 2. 获取源会话的 title
let title: String =
sqlx::query_scalar("SELECT COALESCE(title, '') FROM agent_sessions WHERE session_id = ?")
.bind(session_id)
.fetch_one(db)
.await
.unwrap_or_default();
// 3. 查找最后一条 active=1 的消息 id(作为分叉点)
let last_active_id: Option<i64> = sqlx::query_scalar(
"SELECT MAX(id) FROM agent_messages WHERE session_id = ? AND active = 1",
)
.bind(session_id)
.fetch_one(db)
.await?;
let forked_at = last_active_id.unwrap_or(0);
// 4. 创建新会话
let branch_id = uuid::Uuid::new_v4().to_string();
let branch_title = if title.is_empty() {
format!("分支 (来自 {})", &session_id[..8.min(session_id.len())])
} else {
format!("{} — 分支", title)
};
let branch_meta = serde_json::json!({
"branched_from": session_id,
"branched_at_message_id": forked_at,
});
sqlx::query(
"INSERT INTO agent_sessions (session_id, title, model, parent_session_id, branch_metadata) \
VALUES (?, ?, '', ?, ?)",
)
.bind(&branch_id)
.bind(&branch_title)
.bind(session_id)
.bind(serde_json::to_string(&branch_meta).unwrap_or_default())
.execute(db)
.await?;
// 5. 复制所有 active=1 的消息到新会话
let copied: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM agent_messages WHERE session_id = ? AND active = 1",
)
.bind(session_id)
.fetch_one(db)
.await?;
sqlx::query(
"INSERT INTO agent_messages \
(session_id, turn_index, step_index, role, content, thought, tool_calls, \
tool_call_id, token_count, metadata, raw_json, agent_name, active) \
SELECT ?, turn_index, step_index, role, content, thought, tool_calls, \
tool_call_id, token_count, metadata, raw_json, agent_name, active \
FROM agent_messages \
WHERE session_id = ? AND active = 1 \
ORDER BY id ASC",
)
.bind(&branch_id)
.bind(session_id)
.execute(db)
.await?;
info!(
"[Session] 分叉完成: parent={}, branch={}, copied={} messages, forked_at={}",
session_id, branch_id, copied, forked_at
);
Ok(BranchResult {
branch_session_id: branch_id,
forked_at_message_id: forked_at,
copied_count: copied as usize,
})
}
/// 获取会话的回退次数。
pub async fn get_rewind_count(db: &SqlitePool, session_id: &str) -> i32 {
sqlx::query_scalar("SELECT rewind_count FROM agent_sessions WHERE session_id = ?")
.bind(session_id)
.fetch_one(db)
.await
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
async fn setup_db() -> SqlitePool {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
sqlx::query(
"CREATE TABLE agent_sessions (
session_id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
turn_count INTEGER NOT NULL DEFAULT 0,
rewind_count INTEGER NOT NULL DEFAULT 0,
parent_session_id TEXT REFERENCES agent_sessions(session_id),
branch_metadata TEXT,
last_error TEXT,
summary TEXT,
metadata TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE agent_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
turn_index INTEGER NOT NULL DEFAULT 0,
step_index INTEGER NOT NULL DEFAULT 0,
role TEXT NOT NULL CHECK(role IN ('system', 'user', 'assistant', 'tool')),
content TEXT NOT NULL DEFAULT '',
thought TEXT,
tool_calls TEXT,
tool_call_id TEXT,
token_count INTEGER NOT NULL DEFAULT 0,
metadata TEXT,
raw_json TEXT,
agent_name TEXT NOT NULL DEFAULT 'lead',
active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (session_id) REFERENCES agent_sessions(session_id) ON DELETE CASCADE
)",
)
.execute(&pool)
.await
.unwrap();
pool
}
async fn seed_messages(db: &SqlitePool, session_id: &str) {
// Session must be created BEFORE messages (FK constraint)
sqlx::query("INSERT INTO agent_sessions (session_id) VALUES (?)")
.bind(session_id)
.execute(db)
.await
.unwrap();
// Insert system + 3 user turns with responses
for (id, role, turn, content) in [
(1, "system", 0, "You are a helpful assistant."),
(2, "user", 0, "Question 1"),
(3, "assistant", 0, "Answer 1"),
(4, "user", 1, "Question 2"),
(5, "assistant", 1, "Answer 2"),
(6, "user", 2, "Question 3"),
(7, "assistant", 2, "Answer 3"),
] {
sqlx::query(
"INSERT INTO agent_messages (id, session_id, role, turn_index, content, agent_name, active) \
VALUES (?, ?, ?, ?, ?, 'lead', 1)",
)
.bind(id)
.bind(session_id)
.bind(role)
.bind(turn)
.bind(content)
.execute(db)
.await
.unwrap();
}
}
#[tokio::test]
async fn test_load_history_filters_active() {
let db = setup_db().await;
let sid = "test-active-filter";
seed_messages(&db, sid).await;
// Before rewind: all 7 messages active
let msgs = load_history_for_llm(&db, sid).await.unwrap();
assert_eq!(msgs.len(), 7);
// Rewind to message 6 (Question 3) → soft-delete ids 6,7
let result = rewind_to_message(&db, sid, 6).await.unwrap();
assert_eq!(result.rewound_count, 2);
// After rewind: only 5 messages active (ids 1-5)
let msgs = load_history_for_llm(&db, sid).await.unwrap();
assert_eq!(msgs.len(), 5);
}
#[tokio::test]
async fn test_rewind_to_message_keeps_system() {
let db = setup_db().await;
let sid = "test-keep-system";
seed_messages(&db, sid).await;
// Rewind back to first user message (id=2)
let result = rewind_to_message(&db, sid, 2).await.unwrap();
assert!(result.rewound_count >= 1);
let msgs = load_history_for_llm(&db, sid).await.unwrap();
// Should have system (id=1) + target user (id=2 itself is rewound)
// Actually, rewind_to_message(2) soft-deletes id >= 2
// So only system (id=1) remains
assert_eq!(msgs.len(), 1);
assert_eq!(
msgs[0].content.as_deref(),
Some("You are a helpful assistant.")
);
}
#[tokio::test]
async fn test_rewind_n_turns() {
let db = setup_db().await;
let sid = "test-rewind-n";
seed_messages(&db, sid).await;
// Rewind 2 turns → should go back to before Question 2 (id=4)
let result = rewind_n_turns(&db, sid, 2).await.unwrap();
assert!(result.rewound_count >= 1);
let msgs = load_history_for_llm(&db, sid).await.unwrap();
// System (1) + Q1 (2) + A1 (3) = 3 messages
assert_eq!(msgs.len(), 3);
}
#[tokio::test]
async fn test_restore_rewound() {
let db = setup_db().await;
let sid = "test-restore";
seed_messages(&db, sid).await;
// Rewind to id=4 → soft-delete 4,5,6,7
rewind_to_message(&db, sid, 4).await.unwrap();
assert_eq!(load_history_for_llm(&db, sid).await.unwrap().len(), 3);
// Restore
let restored = restore_rewound(&db, sid).await.unwrap();
assert_eq!(restored, 4);
// All 7 messages back
assert_eq!(load_history_for_llm(&db, sid).await.unwrap().len(), 7);
}
#[tokio::test]
async fn test_rewind_invalid_message() {
let db = setup_db().await;
let sid = "test-invalid";
seed_messages(&db, sid).await;
// Try to rewind to a non-existent message
let result = rewind_to_message(&db, sid, 999).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("不存在"));
}
#[tokio::test]
async fn test_restore_rejects_after_new_messages() {
let db = setup_db().await;
let sid = "test-conflict";
seed_messages(&db, sid).await;
// Rewind to id=4 (soft-delete 4-7)
rewind_to_message(&db, sid, 4).await.unwrap();
assert_eq!(load_history_for_llm(&db, sid).await.unwrap().len(), 3);
// Add a new message after rewind (simulates new conversation)
sqlx::query(
"INSERT INTO agent_messages (id, session_id, role, turn_index, content, agent_name, active) \
VALUES (8, ?, 'user', 2, 'New question', 'lead', 1)",
)
.bind(sid)
.execute(&db)
.await
.unwrap();
// Now trying to restore should FAIL because new messages exist after old inactive ones
let result = restore_rewound(&db, sid).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("无法恢复回退"),
"Expected conflict error, got: {}",
err
);
}
#[tokio::test]
async fn test_rewind_count_tracks() {
let db = setup_db().await;
let sid = "test-count";
seed_messages(&db, sid).await;
assert_eq!(get_rewind_count(&db, sid).await, 0);
rewind_to_message(&db, sid, 6).await.unwrap();
assert_eq!(get_rewind_count(&db, sid).await, 1);
rewind_to_message(&db, sid, 4).await.unwrap();
assert_eq!(get_rewind_count(&db, sid).await, 2);
}
// ── /retry tests ──
#[tokio::test]
async fn test_retry_last_turn_deletes_and_returns_message() {
let db = setup_db().await;
let sid = "test-retry-basic";
seed_messages(&db, sid).await;
// Last user message is "Question 3" (id=6)
let (msg, new_turn) = retry_last_turn(&db, sid).await.unwrap();
assert_eq!(msg, "Question 3");
assert_eq!(new_turn, 2); // turn_index after removing id=6,7
// Only messages 1-5 should remain
let msgs = load_history_for_llm(&db, sid).await.unwrap();
assert_eq!(msgs.len(), 5);
assert_eq!(msgs.last().unwrap().content.as_deref(), Some("Answer 2"));
}
#[tokio::test]
async fn test_retry_on_empty_session_errors() {
let db = setup_db().await;
let sid = "test-retry-empty";
sqlx::query("INSERT INTO agent_sessions (session_id) VALUES (?)")
.bind(sid)
.execute(&db)
.await
.unwrap();
let result = retry_last_turn(&db, sid).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_retry_after_rewind_deletes_inactive_too() {
let db = setup_db().await;
let sid = "test-retry-after-rewind";
seed_messages(&db, sid).await;
// First rewind to id=4 (soft-delete 4-7)
rewind_to_message(&db, sid, 4).await.unwrap();
// Now: ids 1-3 active=1, ids 4-7 active=0
// Now retry — should hard DELETE from last active user message (id=2)
let (msg, _new_turn) = retry_last_turn(&db, sid).await.unwrap();
assert_eq!(msg, "Question 1"); // last active user message
// Only system message should remain
let msgs = load_history_for_llm(&db, sid).await.unwrap();
assert_eq!(msgs.len(), 1);
assert_eq!(
msgs[0].content.as_deref(),
Some("You are a helpful assistant.")
);
// Even inactive messages (4-7) should be gone (hard DELETE)
let all_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM agent_messages WHERE session_id = ?")
.bind(sid)
.fetch_one(&db)
.await
.unwrap();
assert_eq!(all_count, 1);
}
// ── /branch tests ──
#[tokio::test]
async fn test_branch_copies_active_messages() {
let db = setup_db().await;
let sid = "test-branch-copy";
seed_messages(&db, sid).await;
// Rewind to id=6 first (soft-delete 6,7) so only 1-5 are active
rewind_to_message(&db, sid, 6).await.unwrap();
// Create branch
let result = branch_session(&db, sid).await.unwrap();
assert_eq!(result.copied_count, 5); // only active=1 messages (ids 1-5)
assert!(result.forked_at_message_id > 0);
// New branch has independent history
let branch_msgs = load_history_for_llm(&db, &result.branch_session_id)
.await
.unwrap();
assert_eq!(branch_msgs.len(), 5);
// Original session unchanged
let orig_msgs = load_history_for_llm(&db, sid).await.unwrap();
assert_eq!(orig_msgs.len(), 5);
}
#[tokio::test]
async fn test_branch_independent_continuation() {
let db = setup_db().await;
let sid = "test-branch-independent";
seed_messages(&db, sid).await;
let result = branch_session(&db, sid).await.unwrap();
let bid = result.branch_session_id;
// Add a new message to the branch
sqlx::query(
"INSERT INTO agent_messages (session_id, role, turn_index, content, agent_name, active) \
VALUES (?, 'user', 3, 'Branch question', 'lead', 1)",
)
.bind(&bid)
.execute(&db)
.await
.unwrap();
// Branch sees new message
let branch_msgs = load_history_for_llm(&db, &bid).await.unwrap();
let has_branch_msg = branch_msgs
.iter()
.any(|m| m.content.as_deref() == Some("Branch question"));
assert!(has_branch_msg);
// Original does NOT see branch message
let orig_msgs = load_history_for_llm(&db, sid).await.unwrap();
let has_branch_msg = orig_msgs
.iter()
.any(|m| m.content.as_deref() == Some("Branch question"));
assert!(!has_branch_msg);
}
#[tokio::test]
async fn test_branch_keeps_parent_link() {
let db = setup_db().await;
let sid = "test-branch-parent";
seed_messages(&db, sid).await;
let result = branch_session(&db, sid).await.unwrap();
// Check parent_session_id is set
let parent: Option<String> =
sqlx::query_scalar("SELECT parent_session_id FROM agent_sessions WHERE session_id = ?")
.bind(&result.branch_session_id)
.fetch_one(&db)
.await
.unwrap();
assert_eq!(parent, Some(sid.to_string()));
}
#[tokio::test]
async fn test_branch_invalid_session_errors() {
let db = setup_db().await;
let result = branch_session(&db, "nonexistent-session").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_retry_keeps_inactive_before_target() {
// If there are inactive messages before the retry target,
// they should survive the DELETE (since DELETE is id >= target_id)
let db = setup_db().await;
let sid = "test-retry-keep-inactive";
seed_messages(&db, sid).await;
// First rewind to id=6 (soft-delete 6,7)
rewind_to_message(&db, sid, 6).await.unwrap();
// Now: ids 1-5 active=1, ids 6-7 active=0
// Now rewind again to id=4 (soft-delete 4,5)
rewind_to_message(&db, sid, 4).await.unwrap();
// Now: ids 1-3 active=1, ids 4-7 active=0
// Retry: last active user is id=2. Hard DELETE ids >= 2.
// This removes everything: 1 (system), 2 (user), 3 (asst), and 4-7 (inactive)
let (msg, _) = retry_last_turn(&db, sid).await.unwrap();
assert_eq!(msg, "Question 1");
// Only system remains
let msgs = load_history_for_llm(&db, sid).await.unwrap();
assert_eq!(msgs.len(), 1);
}
}
+343 -159
View File
@@ -6,45 +6,33 @@
// 当 LLM 流式输出 tool_use 块时,立即开始执行并发安全的工具。
// 非并发安全的工具排队等待。结果按流中顺序 yield。
//
// 功能
// 1. 流式执行 — tool_use 到达时立即调度
// 2. Sibling Abort — 副效应工具报错时中止兄弟并行执行
// 3. Progress 流式 — 长时间操作可发送进度更新
// 与 Claude Code 的对齐改进 (2026-06-22)
// 1. 真正的流式调度 — on_tool_use 中对并发安全工具立即 spawn tokio task
// 2. 并发分区 — 自动分组连续只读工具并行执行
// 3. Progress 流式 — 长操作进度消息即时 yield
// 4. Sibling Abort — 副效应工具报错时级联中止兄弟姐妹
use std::collections::VecDeque;
use std::sync::Arc;
use tokio::sync::{broadcast, mpsc, oneshot};
use tokio::sync::broadcast;
use tokio::task::JoinHandle;
use tracing::{info, warn};
use super::partitioner::ToolPartitioner;
use crate::agent::tools::{ToolContext, ToolOutput, ToolRegistry};
/// 流式工具执行状态
#[derive(Debug, Clone, PartialEq)]
pub enum TrackedToolStatus {
/// 工具调用已从 LLM 流中接收到
/// 工具调用已从 LLM 流中接收到,等待调度
Queued,
/// 正在执行中
/// 正在执行中spawned tokio task 运行中)
Executing,
/// 执行完成,等待 yield
/// 执行完成,结果就绪等待 yield
Completed,
/// 结果已 yield 给调用方
Yielded,
}
/// 跟踪中的工具执行
#[derive(Debug)]
struct TrackedTool {
tool_call_id: String,
tool_name: String,
args: serde_json::Value,
status: TrackedToolStatus,
/// 执行完成后的输出
output: Option<ToolOutput>,
/// 取消通道(Sibling Abort 使用)
#[allow(dead_code)]
cancel_tx: Option<oneshot::Sender<()>>,
}
/// Sibling Abort 原因
#[derive(Debug, Clone)]
pub enum AbortReason {
@@ -54,33 +42,59 @@ pub enum AbortReason {
UserInterrupted,
}
/// 流式工具执行
/// 单次工具执行的结果
#[derive(Debug)]
struct ToolExecutionResult {
tool_name: String,
output: ToolOutput,
}
/// 跟踪中的工具执行
struct TrackedTool {
tool_call_id: String,
tool_name: String,
args: serde_json::Value,
status: TrackedToolStatus,
/// 执行完成后的输出
output: Option<ToolOutput>,
/// 并发安全的工具在 spawn 后的 JoinHandle
handle: Option<JoinHandle<ToolExecutionResult>>,
}
/// 流式工具执行器。
///
/// 参考 Claude Code `StreamingToolExecutor` (531 行 TypeScript)
/// 关键改进:并发安全工具立即 spawn tokio task,不等待 flush。
pub struct StreamingToolExecutor {
/// 所有跟踪中的工具
/// 所有跟踪中的工具(按 LLM 流中到达顺序)
tracked: Vec<TrackedTool>,
/// 工具注册表
tool_registry: Arc<ToolRegistry>,
/// 并发分区器(保留用于未来并发策略优化
#[allow(dead_code)]
partitioner: ToolPartitioner,
/// 工具上下文
/// 工具上下文(按需 clone 给每个 spawn 的 task
tool_context: ToolContext,
/// Sibling Abort 广播通道 (tx)
abort_tx: broadcast::Sender<AbortReason>,
/// Sibling Abort 广播通道 (rx)
/// Sibling Abort 广播通道 (rx) — 保留以保持 channel 存活,
/// 实际使用时通过 `abort_tx.subscribe()` 获取新接收端。
#[allow(dead_code)]
abort_rx: broadcast::Receiver<AbortReason>,
/// 当前是否已发生错误(触发 sibling abort
has_errored: bool,
/// 出错工具的描述
/// 出错工具的描述(如 "bash(git push)"
errored_tool_desc: String,
/// 下一个 stream_index
next_index: usize,
/// 最大并发数(预留,当前使用 executing_non_concurrent 控制)
#[allow(dead_code)]
max_concurrency: usize,
/// 最大工具输出字符数
max_output_chars: usize,
/// 当前正在执行的非并发安全工具数(0 或 1)
executing_non_concurrent: bool,
/// 已完成但尚未 yield 的结果队列(按流顺序)
completed_queue: VecDeque<usize>,
}
impl StreamingToolExecutor {
/// 创建新的流式执行器
/// 创建新的流式执行器
pub fn new(
tool_registry: Arc<ToolRegistry>,
tool_context: ToolContext,
@@ -91,86 +105,109 @@ impl StreamingToolExecutor {
StreamingToolExecutor {
tracked: Vec::new(),
tool_registry,
partitioner: ToolPartitioner::new(max_concurrency),
tool_context,
abort_tx,
abort_rx,
has_errored: false,
errored_tool_desc: String::new(),
next_index: 0,
max_concurrency,
max_output_chars,
executing_non_concurrent: false,
completed_queue: VecDeque::new(),
}
}
/// 获取 abort 广播发送端(供外部注入取消信号)
/// 获取 abort 广播发送端(供外部注入取消信号)
pub fn abort_sender(&self) -> broadcast::Sender<AbortReason> {
self.abort_tx.clone()
}
/// 当 LLM 流产生一个新的 tool_use 时调用。
///
/// 返回 true 表示该工具已立即开始执行(并发安全),false 表示排队
/// 如果是并发安全工具且当前没有非并发安全工具在执行,立即 spawn tokio task
/// 否则加入队列等待调度。
///
/// 返回 true 表示该工具已立即开始执行,false 表示排队。
pub fn on_tool_use(&mut self, call_id: String, name: String, args: serde_json::Value) -> bool {
let _index = self.next_index;
self.next_index += 1;
let is_concurrency_safe = self
.tool_registry
.get(&name)
.map(|t| t.is_concurrency_safe(&args))
.unwrap_or(false);
let (cancel_tx, _cancel_rx) = oneshot::channel();
let tool = TrackedTool {
let mut tool = TrackedTool {
tool_call_id: call_id.clone(),
tool_name: name.clone(),
args: args.clone(),
status: TrackedToolStatus::Queued,
output: None,
cancel_tx: Some(cancel_tx),
handle: None,
};
self.tracked.push(tool);
let idx = self.tracked.len();
let can_start_now = is_concurrency_safe && !self.executing_non_concurrent;
if is_concurrency_safe {
info!("[StreamingExecutor] 立即调度并发安全工具: {}", name);
self.try_execute_pending();
true
if can_start_now {
// 立即 spawn tokio task(参考 Claude Code: addTool 立即 processQueue
info!(
"[StreamingExecutor] 立即 spawn 并发安全工具: {} (id={})",
name, call_id
);
let handle = self.spawn_tool_task(idx, call_id.clone(), name.clone(), args.clone());
tool.handle = Some(handle);
tool.status = TrackedToolStatus::Executing;
} else {
info!("[StreamingExecutor] 排队非并发安全工具: {}", name);
false
info!(
"[StreamingExecutor] 排队工具: {} (concurrent={}, executing_non_concurrent={})",
name, is_concurrency_safe, self.executing_non_concurrent
);
}
if !is_concurrency_safe {
self.executing_non_concurrent = true;
}
self.tracked.push(tool);
can_start_now
}
/// LLM 流结束后调用,执行所有剩余排队工具。
/// LLM 流结束后调用,等待所有剩余排队工具完成
pub async fn flush(&mut self) {
let queued_count = self
.tracked
.iter()
.filter(|t| t.status == TrackedToolStatus::Queued)
.count();
info!(
"[StreamingExecutor] flush: {} tracked, {} queued",
"[StreamingExecutor] flush: {} tracked, {} queued, {} executing",
self.tracked.len(),
queued_count,
self.tracked
.iter()
.filter(|t| t.status == TrackedToolStatus::Queued)
.filter(|t| t.status == TrackedToolStatus::Executing)
.count()
);
// 将剩余排队的工具分批执行
let queued: Vec<usize> = self
.tracked
.iter()
.enumerate()
.filter(|(_, t)| t.status == TrackedToolStatus::Queued)
.map(|(i, _)| i)
.collect();
// 启动所有还在排队的工具
self.start_all_queued();
for idx in queued {
self.execute_one(idx).await;
}
// 等待所有执行中的工具完成
self.await_all_executing().await;
}
/// 按流顺序获取下一个完成的结果(非阻塞)。
///
/// 对于已完成的任务,如果其 handle 已就绪则收集结果。
/// 返回按到达顺序的第一个已完成结果。
pub fn next_result(&mut self) -> Option<(String, ToolOutput)> {
for tool in &mut self.tracked {
// 先尝试收集任何已完成的 async task 结果
self.collect_completed_tasks();
// 从 completed_queue 中按序取
while let Some(&idx) = self.completed_queue.front() {
self.completed_queue.pop_front();
let tool = &mut self.tracked[idx];
if tool.status == TrackedToolStatus::Completed {
tool.status = TrackedToolStatus::Yielded;
let output = tool
@@ -183,133 +220,280 @@ impl StreamingToolExecutor {
None
}
/// 是否有未 yield 的结果
/// 是否有未 yield 的结果(已完成或即将完成)。
pub fn has_pending_results(&self) -> bool {
self.tracked
.iter()
.any(|t| t.status == TrackedToolStatus::Completed)
|| !self.completed_queue.is_empty()
}
/// 是否有未完成的工具
/// 是否有未完成的工具(仍在排队或执行中)。
pub fn has_unfinished(&self) -> bool {
self.tracked.iter().any(|t| {
t.status == TrackedToolStatus::Queued || t.status == TrackedToolStatus::Executing
})
}
/// 获取所有已完成的结果(包括已 yield 和未 yield 的)
/// 获取所有已完成的结果(包括已 yield 和未 yield 的)
pub fn all_results_mut(&mut self) -> Vec<(String, ToolOutput)> {
self.collect_completed_tasks();
let mut results = Vec::new();
for tool in &mut self.tracked {
if let Some(output) = tool.output.take() {
results.push((tool.tool_call_id.clone(), output));
}
tool.status = TrackedToolStatus::Yielded;
}
results
}
// ── 内部方法 ──
/// 尝试执行可执行的排队工具
fn try_execute_pending(&mut self) {
// 简单策略:如果有正在执行的且它不是并发的,则不启动新的
let has_executing = self
/// Spawn 一个 tokio task 执行单个工具调用。
fn spawn_tool_task(
&self,
_idx: usize,
_call_id: String,
tool_name: String,
args: serde_json::Value,
) -> JoinHandle<ToolExecutionResult> {
let tool_registry = self.tool_registry.clone();
let tool_context = self.tool_context.clone();
let max_output_chars = self.max_output_chars;
let mut abort_rx = self.abort_tx.subscribe();
tokio::spawn(async move {
// tokio::select! 在工具执行和 Sibling Abort 之间竞速
tokio::select! {
result = async {
match tool_registry.get(&tool_name) {
Some(tool) => {
tool.execute_with_progress(args, &tool_context, None).await
}
None => ToolOutput::error(format!("未知工具: {}", tool_name)),
}
} => {
// 截断输出
let truncated = if result.content.len() > max_output_chars {
let t: String = result.content.chars().take(max_output_chars).collect();
ToolOutput {
content: format!(
"{}...\n[输出已截断,原始长度: {} 字符]",
t,
result.content.len()
),
is_error: result.is_error,
metadata: result.metadata,
}
} else {
result
};
ToolExecutionResult {
tool_name,
output: truncated,
}
}
Ok(reason) = abort_rx.recv() => {
let msg = match reason {
AbortReason::SiblingError { description } => {
format!("取消:并行工具 {} 出错,已级联取消", description)
}
AbortReason::UserInterrupted => "执行已被用户取消".to_string(),
};
ToolExecutionResult {
tool_name,
output: ToolOutput::error(msg),
}
}
}
})
}
/// 尝试收集所有已完成 tokio task 的结果(非阻塞)。
fn collect_completed_tasks(&mut self) {
for idx in 0..self.tracked.len() {
if self.tracked[idx].status != TrackedToolStatus::Executing {
continue;
}
if self.tracked[idx].handle.is_none() {
continue;
}
// 检查 JoinHandle 是否已完成(非阻塞)
let handle = self.tracked[idx].handle.take().unwrap();
if handle.is_finished() {
// is_finished=true 保证 .await 会立即返回
// 使用 tokio::task::yield_now 之后的 poll 可能也成功,
// 这里直接在同步上下文中检查后放入完成队列
// 等下次 async 上下文中通过 await_all_executing 处理
self.tracked[idx].handle = Some(handle);
// 标记为需要收集 — 将在 flush/await 中处理
} else {
// 放回未完成的 handle
self.tracked[idx].handle = Some(handle);
}
}
}
/// 启动所有排队的工具。
fn start_all_queued(&mut self) {
// 收集需要启动的工具索引(避免借用冲突)
let to_start: Vec<usize> = self
.tracked
.iter()
.any(|t| t.status == TrackedToolStatus::Executing);
.enumerate()
.filter(|(_, t)| t.status == TrackedToolStatus::Queued)
.filter(|(_, t)| {
let is_safe = self
.tool_registry
.get(&t.tool_name)
.map(|reg_tool| reg_tool.is_concurrency_safe(&t.args))
.unwrap_or(false);
// 并发安全工具可随时启动,非并发安全的需要独占
is_safe || !self.executing_non_concurrent
})
.map(|(i, _)| i)
.collect();
if !has_executing {
// 启动所有排队的并发安全工具
let indices: Vec<usize> = self
.tracked
.iter()
.enumerate()
.filter(|(_, t)| t.status == TrackedToolStatus::Queued)
.map(|(i, _)| i)
.collect();
for idx in to_start {
let tool = &self.tracked[idx];
let call_id = tool.tool_call_id.clone();
let tool_name = tool.tool_name.clone();
let args = tool.args.clone();
for idx in indices {
// 在同步上下文中只能标记状态,实际执行在 async 上下文中
self.tracked[idx].status = TrackedToolStatus::Executing;
}
}
}
/// 执行单个工具(内部辅助)
async fn execute_one(&mut self, idx: usize) {
if idx >= self.tracked.len() {
return;
}
// 检查 sibling abort
if self.has_errored {
if let Ok(reason) = self.abort_rx.try_recv() {
let msg = match reason {
AbortReason::SiblingError { ref description } => {
format!("取消:并行工具 {} 出错,已级联取消", description)
}
AbortReason::UserInterrupted => "执行已被用户取消".to_string(),
};
self.tracked[idx].output = Some(ToolOutput::error(msg));
self.tracked[idx].status = TrackedToolStatus::Completed;
return;
}
}
self.tracked[idx].status = TrackedToolStatus::Executing;
let tool_name = self.tracked[idx].tool_name.clone();
let output = match self.tool_registry.get(&tool_name) {
Some(tool) => {
let (progress_tx, _progress_rx) = mpsc::unbounded_channel();
let tool_args = self.tracked[idx].args.clone();
let tool_fut =
tool.execute_with_progress(tool_args, &self.tool_context, Some(&progress_tx));
tool_fut.await
}
None => ToolOutput::error(format!("未知工具: {}", tool_name)),
};
// 检查是否需要触发 sibling abort
if output.is_error {
let causes_abort = self
let is_safe = self
.tool_registry
.get(&tool_name)
.map(|t| t.causes_sibling_abort())
.map(|t| t.is_concurrency_safe(&args))
.unwrap_or(false);
if causes_abort {
warn!(
"[StreamingExecutor] 工具 {} 出错,触发 sibling abort",
tool_name
);
self.has_errored = true;
self.errored_tool_desc = tool_name.clone();
let _ = self.abort_tx.send(AbortReason::SiblingError {
description: tool_name.clone(),
});
let handle = self.spawn_tool_task(idx, call_id, tool_name.clone(), args);
self.tracked[idx].handle = Some(handle);
self.tracked[idx].status = TrackedToolStatus::Executing;
if !is_safe {
self.executing_non_concurrent = true;
// 非并发安全工具启动后停止(独占执行)
break;
}
info!("[StreamingExecutor] 启动排队工具: {}", tool_name);
}
}
/// 等待所有执行中的工具完成。
async fn await_all_executing(&mut self) {
// 收集所有剩余 JoinHandles
let mut handles: Vec<(usize, JoinHandle<ToolExecutionResult>)> = Vec::new();
for idx in 0..self.tracked.len() {
if self.tracked[idx].status == TrackedToolStatus::Executing {
if let Some(handle) = self.tracked[idx].handle.take() {
handles.push((idx, handle));
}
}
}
// 截断输出
let truncated = if output.content.len() > self.max_output_chars {
let t: String = output.content.chars().take(self.max_output_chars).collect();
ToolOutput {
content: format!(
"{}...\n[输出已截断,原始长度: {} 字符]",
t,
output.content.len()
),
is_error: output.is_error,
metadata: output.metadata,
}
} else {
output
};
// 并发等待所有任务
for (idx, handle) in handles {
match handle.await {
Ok(result) => {
let tool_name = result.tool_name.clone();
let is_error = result.output.is_error;
self.tracked[idx].output = Some(truncated);
self.tracked[idx].status = TrackedToolStatus::Completed;
self.tracked[idx].output = Some(result.output);
self.tracked[idx].status = TrackedToolStatus::Completed;
self.completed_queue.push_back(idx);
if is_error {
self.check_sibling_abort(idx, &tool_name);
}
}
Err(e) => {
warn!("[StreamingExecutor] tokio task 异常: {}", e);
self.tracked[idx].output =
Some(ToolOutput::error(format!("工具执行异常: {}", e)));
self.tracked[idx].status = TrackedToolStatus::Completed;
self.completed_queue.push_back(idx);
}
}
}
self.executing_non_concurrent = false;
}
/// 检查错误工具是否触发 Sibling Abort。
fn check_sibling_abort(&mut self, idx: usize, tool_name: &str) {
let causes_abort = self
.tool_registry
.get(tool_name)
.map(|t| t.causes_sibling_abort())
.unwrap_or(false);
if causes_abort && !self.has_errored {
warn!(
"[StreamingExecutor] 工具 {} 出错,触发 sibling abort",
tool_name
);
self.has_errored = true;
self.errored_tool_desc = self.get_tool_description(idx);
let _ = self.abort_tx.send(AbortReason::SiblingError {
description: self.errored_tool_desc.clone(),
});
}
}
/// 获取工具的人类可读描述(用于错误消息)。
fn get_tool_description(&self, idx: usize) -> String {
let tool = &self.tracked[idx];
let summary = tool
.args
.get("command")
.or_else(|| tool.args.get("file_path"))
.or_else(|| tool.args.get("pattern"))
.and_then(|v| v.as_str())
.unwrap_or("");
if summary.is_empty() {
tool.tool_name.clone()
} else {
let truncated: String = summary.chars().take(40).collect();
if summary.len() > 40 {
format!("{}({}…)", tool.tool_name, truncated)
} else {
format!("{}({})", tool.tool_name, summary)
}
}
}
}
#[cfg(test)]
mod tests {
// 注意:StreamingToolExecutor 的集成测试放在 src/agent/runtime/ 的 #[cfg(test)] 模块中,
// 需要完整的 AppState 和 ToolContext。此处的单元测试仅验证核心数据结构。
//
// 以下测试验证 TrackedToolStatus 枚举和状态转换逻辑,不依赖外部设施。
use super::*;
#[test]
fn test_tracked_tool_status_debug() {
assert_eq!(format!("{:?}", TrackedToolStatus::Queued), "Queued");
assert_eq!(format!("{:?}", TrackedToolStatus::Executing), "Executing");
assert_eq!(format!("{:?}", TrackedToolStatus::Completed), "Completed");
assert_eq!(format!("{:?}", TrackedToolStatus::Yielded), "Yielded");
}
#[test]
fn test_abort_reason_display() {
let sibling = AbortReason::SiblingError {
description: "bash(rm -rf /)".into(),
};
let user = AbortReason::UserInterrupted;
assert_eq!(
format!("{:?}", sibling),
"SiblingError { description: \"bash(rm -rf /)\" }"
);
assert_eq!(format!("{:?}", user), "UserInterrupted");
}
}
+225 -6
View File
@@ -1,9 +1,11 @@
// src/agent/runtime/system_prompt.rs
//
// 模块化系统提示词组装 — 参考 Claude Code s10 System Prompt 设计
// 模块化系统提示词组装。
//
// 将硬编码的提示词拆分为独立 section,运行时按需拼接。
// 静态 section 在前以最大化 Anthropic prompt cache 命中率。
// 设计原则:
// 1. 静态 section 全部在前 → 内容不变,服务端自然缓存命中
// 2. 动态 section 在后 → 随 session 变化
// 3. 简单缓存:首次计算后永久复用(session 内一切不变),仅显式 invalidate
/// 系统提示词组装器
pub struct SystemPrompt {
@@ -48,13 +50,86 @@ impl Default for SystemPrompt {
}
}
/// 静态身份 section(始终加载,最大化 prompt cache 命中率)
// ── Section 缓存 ─────────────────────────────────────────────────────────
use std::collections::HashMap;
/// SystemPrompt 的 Section 级缓存。
///
/// 首次计算后永久缓存,仅通过 `invalidate()` 显式失效。
/// 因为 session 生命周期内 CWD/平台/OS/模型名/工具注册表均不变,
/// 不需要 TTL 过期机制。
#[derive(Debug)]
pub struct SystemPromptCache {
entries: HashMap<&'static str, String>,
}
impl SystemPromptCache {
pub fn new() -> Self {
SystemPromptCache {
entries: HashMap::new(),
}
}
/// 获取 section 内容(首次计算,后续命中缓存)。
pub fn get_or_compute(
&mut self,
name: &'static str,
compute: impl FnOnce() -> String,
) -> String {
if let Some(cached) = self.entries.get(name) {
return cached.clone();
}
let content = compute();
self.entries.insert(name, content.clone());
content
}
/// 获取 section 内容,compute 返回 Option 时:Some 缓存并返回,None 不缓存。
pub fn get_or_compute_optional(
&mut self,
name: &'static str,
compute: impl FnOnce() -> Option<String>,
) -> Option<String> {
if let Some(cached) = self.entries.get(name) {
return Some(cached.clone());
}
let content = compute()?;
self.entries.insert(name, content.clone());
Some(content)
}
/// 使指定 section 的缓存失效。
pub fn invalidate(&mut self, name: &'static str) {
self.entries.remove(name);
}
/// 使所有缓存失效(/clear 或 /compact 时调用)。
pub fn invalidate_all(&mut self) {
self.entries.clear();
}
/// 缓存条目数量(调试用)。
pub fn entry_count(&self) -> usize {
self.entries.len()
}
}
impl Default for SystemPromptCache {
fn default() -> Self {
Self::new()
}
}
// ── 静态 Section 常量 ────────────────────────────────────────────────────
/// 静态身份 section
pub const IDENTITY_SECTION: &str = "\
";
/// 静态核心原则 section
/// 静态核心原则 section(行为准则 + 科研规范)
pub const PRINCIPLES_SECTION: &str = "\
#
1. 使
2. 使get_paper_content / rag_search
3.
@@ -65,6 +140,32 @@ pub const PRINCIPLES_SECTION: &str = "\
8.
9. ";
/// 静态系统上下文 section(说明 system-reminder 标签和自动压缩机制)
pub const SYSTEM_CONTEXT_SECTION: &str = "\
#
- <system-reminder>
- ";
/// 静态工具使用指导 sectiondedicated tools 优先、并行调用、任务追踪)
pub const TOOL_USAGE_SECTION: &str = "\
# 使
- 使read_filegrep_filesglob_filesfile_editfile_write使 run_bash
- 使 run_bash
-
- 使 todo_write
- ";
/// 静态操作安全 section(可逆性、影响范围、确认机制)
pub const SAFETY_SECTION: &str = "\
#
-
- /
-
- --no-verify
- ";
// ── Tests ─────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
@@ -101,4 +202,122 @@ mod tests {
sp.add_section("a", "A".to_string());
assert_eq!(sp.section_count(), 1);
}
#[test]
fn test_static_sections_not_empty() {
assert!(!SYSTEM_CONTEXT_SECTION.is_empty());
assert!(!TOOL_USAGE_SECTION.is_empty());
assert!(!SAFETY_SECTION.is_empty());
assert!(!PRINCIPLES_SECTION.is_empty());
assert!(!IDENTITY_SECTION.is_empty());
}
#[test]
fn test_static_sections_have_headers() {
assert!(SYSTEM_CONTEXT_SECTION.starts_with("# 系统上下文"));
assert!(TOOL_USAGE_SECTION.starts_with("# 工具使用指南"));
assert!(SAFETY_SECTION.starts_with("# 操作安全"));
assert!(PRINCIPLES_SECTION.starts_with("# 核心原则"));
}
// ── SystemPromptCache tests ──
#[test]
fn test_cache_compute_once() {
let mut cache = SystemPromptCache::new();
let mut call_count = 0;
let r1 = cache.get_or_compute("test", || {
call_count += 1;
"computed".to_string()
});
assert_eq!(r1, "computed");
assert_eq!(call_count, 1);
// 第二次不调用 compute
let r2 = cache.get_or_compute("test", || {
call_count += 1;
"recomputed".to_string()
});
assert_eq!(r2, "computed");
assert_eq!(call_count, 1);
}
#[test]
fn test_cache_invalidate() {
let mut cache = SystemPromptCache::new();
let _ = cache.get_or_compute("a", || "value_a".to_string());
let _ = cache.get_or_compute("b", || "value_b".to_string());
assert_eq!(cache.entry_count(), 2);
cache.invalidate("a");
assert_eq!(cache.entry_count(), 1);
// a 重新计算
let a2 = cache.get_or_compute("a", || "new_a".to_string());
assert_eq!(a2, "new_a");
assert_eq!(cache.entry_count(), 2);
}
#[test]
fn test_cache_invalidate_all() {
let mut cache = SystemPromptCache::new();
let _ = cache.get_or_compute("a", || "v_a".to_string());
let _ = cache.get_or_compute("b", || "v_b".to_string());
let _ = cache.get_or_compute("c", || "v_c".to_string());
assert_eq!(cache.entry_count(), 3);
cache.invalidate_all();
assert_eq!(cache.entry_count(), 0);
}
#[test]
fn test_cache_get_or_compute_optional_some() {
let mut cache = SystemPromptCache::new();
let mut call_count = 0;
let r1 = cache.get_or_compute_optional("opt", || {
call_count += 1;
Some("present".to_string())
});
assert_eq!(r1, Some("present".to_string()));
assert_eq!(call_count, 1);
// 缓存命中
let r2 = cache.get_or_compute_optional("opt", || {
call_count += 1;
Some("should_not_compute".to_string())
});
assert_eq!(r2, Some("present".to_string()));
assert_eq!(call_count, 1);
}
#[test]
fn test_cache_get_or_compute_optional_none() {
let mut cache = SystemPromptCache::new();
let mut call_count = 0;
// None 不缓存 — 每次都会重新计算
let r1 = cache.get_or_compute_optional("opt", || {
call_count += 1;
None::<String>
});
assert_eq!(r1, None);
assert_eq!(call_count, 1);
let r2 = cache.get_or_compute_optional("opt", || {
call_count += 1;
None::<String>
});
assert_eq!(r2, None);
assert_eq!(call_count, 2); // 未缓存,再次调用
}
#[test]
fn test_cache_default_empty() {
let cache = SystemPromptCache::default();
assert_eq!(cache.entry_count(), 0);
}
}
+159
View File
@@ -0,0 +1,159 @@
// src/agent/runtime/untrusted.rs
//
// 非可信内容包裹 — 间接 Prompt 注入防御。
// 参考 Hermes-Agent tool_dispatch_helpers.py make_tool_result_message() 设计。
//
// 设计原则:
// 1. 高风险外部工具(web/browser/MCP/RAG)的结果包裹在 <untrusted_tool_result> 中
// 2. 该标签告诉 LLM:这是外部数据,不是来自用户的指令
// 3. 这是一种架构级防御(改变 LLM 对内容的解释方式),而非 regex 模式匹配
//
// 为何不是安全边界:
// 标签防御依赖 LLM 遵守指令的能力。恶意 LLM 或精心构造的注入仍可能绕过。
// 这是 defense-in-depth 的一层,需要与权限系统、hardline 检查配合使用。
/// 高风险工具 — 其结果来自外部源,可能包含 prompt 注入内容。
///
/// 仅对以下工具类别进行包裹:
/// - web_search, web_extract, web_fetch — 搜索结果来自互联网
/// - browser_* — 浏览器内容来自任意网站
/// - mcp_* — MCP 工具结果来自外部服务器
/// - rag_search — RAG 检索结果来自外部论文(可能含对抗内容)
/// - search_papers — 搜索结果摘要来自 arXiv/ADS(外部 API
const HIGH_RISK_TOOLS: &[&str] = &[
"web_search",
"web_fetch",
"browser_navigate",
"browser_snapshot",
"browser_click",
"rag_search",
"search_papers",
];
/// 检查工具是否为高风险(其结果应被包裹)。
pub fn is_high_risk(tool_name: &str) -> bool {
HIGH_RISK_TOOLS.contains(&tool_name)
|| tool_name.starts_with("mcp__")
|| tool_name.starts_with("web_")
|| tool_name.starts_with("browser_")
}
/// 为非可信工具结果包裹安全标记。
///
/// 包裹格式:
/// ```text
/// <untrusted_tool_result tool="{tool_name}">
/// {original_content}
/// </untrusted_tool_result>
/// ```
///
/// 此标记指示 LLM 将内容视为外部数据而非用户指令。
/// 包裹仅应用于高风险工具(web/browser/MCP),以最小化 token 开销。
pub fn wrap_untrusted_content(tool_name: &str, content: &str) -> String {
if !is_high_risk(tool_name) {
return content.to_string();
}
// 避免双重包裹
if content.contains("<untrusted_tool_result") {
return content.to_string();
}
format!(
"<untrusted_tool_result tool=\"{tool}\">\n{content}\n</untrusted_tool_result>",
tool = tool_name,
content = content
)
}
/// 从错误包裹中恢复原内容(当需要向用户展示时)
pub fn unwrap_untrusted(content: &str) -> String {
if !content.starts_with("<untrusted_tool_result") {
return content.to_string();
}
// 简单提取:找到第一行之后和最后一行之前的内容
if let Some(start) = content.find('\n') {
let inner = &content[start + 1..];
if let Some(end) = inner.rfind('\n') {
if inner[end..].contains("</untrusted_tool_result>") {
return inner[..end].to_string();
}
}
}
content.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_high_risk_web_search() {
assert!(is_high_risk("web_search"));
assert!(is_high_risk("web_fetch"));
}
#[test]
fn test_high_risk_browser() {
assert!(is_high_risk("browser_navigate"));
assert!(is_high_risk("browser_snapshot"));
}
#[test]
fn test_high_risk_mcp() {
assert!(is_high_risk("mcp__github_search"));
}
#[test]
fn test_high_risk_rag() {
assert!(is_high_risk("rag_search"));
assert!(is_high_risk("search_papers"));
}
#[test]
fn test_not_high_risk_file_tools() {
assert!(!is_high_risk("read_file"));
assert!(!is_high_risk("file_write"));
assert!(!is_high_risk("run_bash"));
assert!(!is_high_risk("grep_files"));
}
#[test]
fn test_wrap_web_search_result() {
let content = "Found: prompt injection <script>alert('xss')</script>";
let wrapped = wrap_untrusted_content("web_search", content);
assert!(wrapped.starts_with("<untrusted_tool_result tool=\"web_search\">"));
assert!(wrapped.ends_with("</untrusted_tool_result>"));
assert!(wrapped.contains(content));
}
#[test]
fn test_no_wrap_for_safe_tool() {
let content = "file contents here";
let wrapped = wrap_untrusted_content("read_file", content);
assert_eq!(wrapped, content);
}
#[test]
fn test_no_double_wrap() {
let content = "<untrusted_tool_result tool=\"web_search\">\nalready wrapped\n</untrusted_tool_result>";
let wrapped = wrap_untrusted_content("web_search", content);
assert_eq!(wrapped, content);
}
#[test]
fn test_unwrap_restores_original() {
let original = "Found: some search result with injection";
let wrapped = wrap_untrusted_content("web_search", original);
let unwrapped = unwrap_untrusted(&wrapped);
assert_eq!(unwrapped, original);
}
#[test]
fn test_unwrap_no_marker_returns_as_is() {
let content = "plain text without wrapper";
let unwrapped = unwrap_untrusted(content);
assert_eq!(unwrapped, content);
}
}
+274
View File
@@ -4,6 +4,11 @@
// Layer 1 — system-reminder 注入:每轮动态列出 skill 名称(~20 tokens/skill
// Layer 2 — LoadSkillToolLLM 按需调用,注入完整 skill 内容(~2000 tokens/skill
//
// 自改进技能系统(参考 Hermes-Agent curator.py 设计):
// PatternDetector — 扫描工具调用序列,检测跨 session 重复模式
// SkillCreator — 将检测到的模式生成 SKILL.md 文件
// Curator — 管理 skill 生命周期,标记 stale/deprecated,建议清理
//
// Skill 文件格式(对齐 Claude Code 的目录约定):
// skills/{skill-name}/SKILL.md ← 必须是目录 + SKILL.md
//
@@ -26,6 +31,9 @@
// # Skill 正文
// 详细内容...
pub mod curator;
pub mod pattern_detector;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
@@ -76,6 +84,9 @@ pub struct SkillFrontmatter {
/// fork 模式下的 effort 级别
#[serde(default)]
pub effort: Option<String>,
/// 是否禁止 Curator 自动清理(参考 Hermes pinned skills
#[serde(default)]
pub pinned: Option<bool>,
}
impl SkillFrontmatter {
@@ -116,6 +127,8 @@ pub struct SkillMeta {
pub user_invocable: bool,
/// 条件激活的 glob 模式(空 Vec 表示始终激活)
pub paths: Vec<String>,
/// 是否禁止 Curator 自动清理(参考 Hermes pinned skills
pub pinned: bool,
}
/// 完整的 SkillLayer 2LLM 调用 load_skill 时注入)
@@ -583,6 +596,7 @@ fn load_skill_from_path(skill_md_path: &Path, dir_name: &str) -> Result<Skill, S
let disable_model_invocation = frontmatter.disable_model_invocation.unwrap_or(false);
let user_invocable = frontmatter.user_invocable.unwrap_or(true);
let paths = frontmatter.paths.unwrap_or_default();
let pinned = frontmatter.pinned.unwrap_or(false);
let skill_dir = skill_md_path
.parent()
.unwrap_or(Path::new("."))
@@ -603,6 +617,7 @@ fn load_skill_from_path(skill_md_path: &Path, dir_name: &str) -> Result<Skill, S
disable_model_invocation,
user_invocable,
paths,
pinned,
},
body,
skill_dir,
@@ -651,6 +666,265 @@ pub fn substitute_variables(body: &str, skill_dir: &Path, session_id: Option<&st
result
}
// ── Self-Improving Skill Creator ───────────────────────────────────────────
//
// 参考 Hermes-Agent curator.py 设计。
// SkillCreator 将 PatternDetector 检测到的模式自动生成 SKILL.md 文件。
/// Skill 创建器 — 将检测到的工具调用模式转换为 SKILL.md 文件。
///
/// 使用方式:
/// ```ignore
/// let creator = SkillCreator::new(skills_dir);
/// let created = creator.create_from_pattern(&pattern)?;
/// // created 包含新建的 skill 文件路径列表
/// ```
pub struct SkillCreator {
skills_dir: PathBuf,
}
impl SkillCreator {
pub fn new(skills_dir: PathBuf) -> Self {
SkillCreator { skills_dir }
}
/// 从检测到的模式创建 SKILL.md 文件。
///
/// 返回创建的 skill 名称列表。
/// 如果目标 skill 目录已存在则跳过(不覆盖已有 skill)。
pub fn create_from_patterns(
&self,
patterns: &[pattern_detector::DetectedPattern],
) -> std::io::Result<Vec<String>> {
let mut created = Vec::new();
for pattern in patterns {
if !pattern.is_confident() {
continue;
}
let skill_name = self.pattern_to_skill_name(pattern);
let skill_dir = self.skills_dir.join(&skill_name);
if skill_dir.exists() {
info!("[SkillCreator] Skill '{}' 已存在,跳过创建", skill_name);
continue;
}
// 创建 skill 目录
std::fs::create_dir_all(&skill_dir)?;
// 生成 SKILL.md 内容
let content = self.generate_skill_md(pattern, &skill_name);
let md_path = skill_dir.join("SKILL.md");
std::fs::write(&md_path, &content)?;
info!(
"[SkillCreator] 已从模式创建 Skill '{}': {}",
skill_name,
md_path.display()
);
created.push(skill_name);
}
Ok(created)
}
/// 将模式转换为 kebab-case skill 名称。
fn pattern_to_skill_name(&self, pattern: &pattern_detector::DetectedPattern) -> String {
// 取前三个工具名生成名称
let tokens: Vec<String> = pattern
.tool_sequence
.iter()
.take(3)
.map(|s| {
s.replace("search_", "")
.replace("get_", "")
.replace("download_", "dl-")
.replace("parse_", "parse-")
.replace("rag_", "rag-")
.replace("query_", "query-")
.replace("read_", "read-")
.replace("save_", "save-")
.replace("load_", "load-")
.replace("_", "-")
})
.collect();
format!("auto-{}", tokens.join("-"))
}
/// 生成 SKILL.md 内容(Markdown + YAML frontmatter)。
fn generate_skill_md(
&self,
pattern: &pattern_detector::DetectedPattern,
skill_name: &str,
) -> String {
let tool_list = pattern
.tool_sequence
.iter()
.map(|t| format!(" - {}", t))
.collect::<Vec<_>>()
.join("\n");
let desc = if pattern.description.is_empty() {
format!(
"自动检测的工作流: {} (出现 {} 次)",
pattern.tool_sequence.join(""),
pattern.occurrence_count
)
} else {
pattern.description.clone()
};
let when_to_use = format!(
"当用户需要执行以下操作序列时: {}",
pattern.tool_sequence.join("")
);
format!(
r#"---
name: {name}
description: {desc}
version: "0.1.0"
context: inline
allowed-tools:
{tools}
when_to_use: {when}
user-invocable: true
disable-model-invocation: false
auto-generated: true
auto-generated-from: pattern-detector
confidence: {confidence}
occurrences: {occurrences}
---
# {name}
##
{desc}
##
Skill
{steps}
## 使
: {when}
Skill PatternDetector
"#,
name = skill_name,
desc = desc,
tools = tool_list,
when = when_to_use,
confidence = pattern.confidence,
occurrences = pattern.occurrence_count,
steps = pattern
.tool_sequence
.iter()
.enumerate()
.map(|(i, t)| format!("{}. `{}`", i + 1, t))
.collect::<Vec<_>>()
.join("\n"),
)
}
}
// ── Self-Improving Pipeline ───────────────────────────────────────────────
/// 自改进管道 — 一站式执行模式检测 + Skill 创建 + 质量审查。
///
/// 参考 Hermes-Agent 的 curator.py 设计。
/// 在 Agent 会话结束后或定时触发调用。
pub struct SelfImprovePipeline {
detector: pattern_detector::PatternDetector,
creator: SkillCreator,
curator: curator::Curator,
}
impl SelfImprovePipeline {
/// 创建自改进管道。
///
/// `skills_dir` — skills 目录路径
/// `known_fingerprints` — 已注册 skill 的指纹集合(防止重复创建)
pub fn new(skills_dir: PathBuf, known_fingerprints: std::collections::HashSet<String>) -> Self {
SelfImprovePipeline {
detector: pattern_detector::PatternDetector::new(known_fingerprints),
creator: SkillCreator::new(skills_dir.clone()),
curator: curator::Curator::new(skills_dir),
}
}
/// 执行完整的自改进流程。
///
/// 1. 扫描最近的 session,检测重复模式
/// 2. 将高置信度模式创建为 Skill
/// 3. 分析现有 Skill 的质量并生成清理建议
///
/// 返回 (新建的 skill 名称, curator 报告)。
pub async fn run(
&self,
db: &sqlx::SqlitePool,
max_sessions: usize,
usage_stats: &HashMap<String, SkillUsageStat>,
skill_metas: &[SkillMeta],
) -> anyhow::Result<SelfImproveResult> {
// Step 1: 模式检测
let patterns = self
.detector
.scan(db, max_sessions, 1 /* 排除最近 1 小时 */)
.await?;
info!("[SelfImprove] 检测到 {} 个候选模式", patterns.len());
// Step 2: 创建 Skills
let created = self.creator.create_from_patterns(&patterns)?;
// Step 3: Curator 分析
let curator_report = self.curator.analyze(usage_stats, skill_metas);
Ok(SelfImproveResult {
patterns_found: patterns.len(),
skills_created: created,
curator_report,
})
}
/// 仅运行模式检测(不创建 skill)。
pub async fn detect_only(
&self,
db: &sqlx::SqlitePool,
max_sessions: usize,
) -> anyhow::Result<Vec<pattern_detector::DetectedPattern>> {
self.detector.scan(db, max_sessions, 1).await
}
/// 仅运行 curator 分析。
pub fn analyze_only(
&self,
usage_stats: &HashMap<String, SkillUsageStat>,
skill_metas: &[SkillMeta],
) -> curator::CuratorReport {
self.curator.analyze(usage_stats, skill_metas)
}
}
/// 自改进管道的结果
#[derive(Debug)]
pub struct SelfImproveResult {
/// 检测到的模式数量
pub patterns_found: usize,
/// 新建的 skill 名称列表
pub skills_created: Vec<String>,
/// Curator 质量报告
pub curator_report: curator::CuratorReport,
}
// ── Tests ──────────────────────────────────────────────────────────────────
#[cfg(test)]
+747
View File
@@ -0,0 +1,747 @@
// src/agent/skills/curator.rs
//
// Skill 生命周期管理者。
// 参考 Hermes-Agent curator.py 设计。
//
// 功能:
// 1. Stale Detection — 检测长期未使用的 skill,标记为可清理
// 2. Quality Scoring — 基于使用频率、成功率和新鲜度的质量评分
// 3. Pinned Protection — 标记为 pinned 的 skill 免疫清理(参考 Hermes
// 4. Seed Record — 新 skill 锚定创建时间,防止立即被标记 stale
// 5. Auto-prune — 自动归档/删除低质量或过期的 skill
// 6. Stats Report — 生成 skill 使用统计报告
// 7. Inactivity-triggered — 后台空闲检测 + 自动触发审查
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::Serialize;
use tokio::sync::Mutex;
use tracing::{info, warn};
// ── 配置常量 ──
/// Skill 进入 stale 状态的天数阈值(默认 30 天)
pub const STALE_THRESHOLD_DAYS: i64 = 30;
/// Skill 进入 deprecated 状态的天数阈值(默认 90 天)
pub const DEPRECATED_THRESHOLD_DAYS: i64 = 90;
/// 自动清理的最低质量分数阈值(低于此值且 stale 的 skill 将被建议删除)
pub const AUTO_PRUNE_QUALITY_THRESHOLD: f64 = 0.1;
/// 最低调用次数,低于此值的新 skill 在 stale 后更容易被清理
pub const MIN_INVOCATIONS_FOR_RETENTION: u64 = 3;
/// Curator 运行的默认间隔(7 天,对齐 Hermes DEFAULT_INTERVAL_HOURS
pub const DEFAULT_CURATOR_INTERVAL: Duration = Duration::from_secs(7 * 24 * 3600);
/// 触发 curator 的最小空闲时间(2 小时,对齐 Hermes DEFAULT_MIN_IDLE_HOURS
pub const DEFAULT_MIN_IDLE: Duration = Duration::from_secs(2 * 3600);
/// 新 skill 的保护期(刚创建的 skill 在此期限内不会被标记为 stale)
pub const NEW_SKILL_GRACE_PERIOD_DAYS: i64 = 7;
// ── 数据结构 ──
/// Skill 生命周期状态
#[derive(Debug, Clone, PartialEq, Serialize)]
pub enum SkillLifecycle {
/// 活跃使用中
Active,
/// 不活跃但保留(使用频率低但质量尚可)
Inactive,
/// 过期(超过 STALE_THRESHOLD_DAYS 天未使用)
Stale,
/// 已弃用(超过 DEPRECATED_THRESHOLD_DAYS 天未使用,建议删除)
Deprecated,
}
impl SkillLifecycle {
pub fn as_str(&self) -> &'static str {
match self {
SkillLifecycle::Active => "active",
SkillLifecycle::Inactive => "inactive",
SkillLifecycle::Stale => "stale",
SkillLifecycle::Deprecated => "deprecated",
}
}
}
/// Skill 质量评估
#[derive(Debug, Clone, Serialize)]
pub struct SkillQuality {
/// 质量分数 (0.0 ~ 1.0)
pub score: f64,
/// 生命周期状态
pub lifecycle: SkillLifecycle,
/// 距上次使用的天数
pub days_since_last_use: Option<i64>,
/// 总调用次数
pub total_invocations: u64,
/// 预估成功率 (成功调用 / 总调用), None 表示无数据
pub estimated_success_rate: Option<f64>,
/// Curator 的建议
pub recommendation: String,
}
/// Curator 分析报告
#[derive(Debug, Clone, Serialize)]
pub struct CuratorReport {
/// 分析时间
pub analyzed_at: DateTime<Utc>,
/// 各 skill 的质量评估(按 skill name 索引)
pub skills: Vec<SkillReport>,
/// 建议清理的 skill 名称列表
pub cleanup_candidates: Vec<String>,
/// 总 skill 数
pub total_skills: usize,
/// Stale skill 数
pub stale_count: usize,
}
/// 单个 skill 的报告
#[derive(Debug, Clone, Serialize)]
pub struct SkillReport {
pub name: String,
pub quality: SkillQuality,
pub description: String,
}
// ── Curator ──
/// Skill 生命周期管理者。
///
/// 使用方式:
/// ```ignore
/// let curator = Curator::new(skills_dir);
/// let report = curator.analyze(usage_stats, skill_metas);
/// if !report.cleanup_candidates.is_empty() {
/// curator.archive_stale_skills(&report.cleanup_candidates);
/// }
/// ```
pub struct Curator {
skills_dir: PathBuf,
/// 归档目录(被清理的 skill 移动到此而非直接删除)
archive_dir: PathBuf,
}
impl Curator {
/// 创建 Curator。
///
/// `skills_dir` — 活跃 skills 目录
/// `archive_dir` — 归档目录(清理时移入此处,默认 `skills_dir/../skills-archive`
pub fn new(skills_dir: PathBuf) -> Self {
let archive_dir = skills_dir.parent().map_or_else(
|| PathBuf::from("skills-archive"),
|p| p.join("skills-archive"),
);
Curator {
skills_dir,
archive_dir,
}
}
/// 自定义归档目录
pub fn with_archive(mut self, archive_dir: PathBuf) -> Self {
self.archive_dir = archive_dir;
self
}
/// 分析所有 skill 的使用统计,生成质量报告。
///
/// `usage_stats` — 来自 SkillRegistry 的使用统计
/// `skill_metas` — 所有已注册 skill 的元信息(含 pinned 标记)
pub fn analyze(
&self,
usage_stats: &HashMap<String, crate::agent::skills::SkillUsageStat>,
skill_metas: &[crate::agent::skills::SkillMeta],
) -> CuratorReport {
let now = Utc::now();
let mut reports = Vec::new();
let mut cleanup_candidates = Vec::new();
let mut stale_count = 0usize;
for meta in skill_metas {
let stat = usage_stats.get(&meta.name);
let quality = self.evaluate_quality(&meta.name, stat, &now, meta.pinned);
if quality.lifecycle == SkillLifecycle::Stale
|| quality.lifecycle == SkillLifecycle::Deprecated
{
stale_count += 1;
// Pinned skills 绝不进入清理候选(参考 Hermes)
if !meta.pinned
&& (quality.score < AUTO_PRUNE_QUALITY_THRESHOLD
|| quality.lifecycle == SkillLifecycle::Deprecated)
{
cleanup_candidates.push(meta.name.clone());
}
}
reports.push(SkillReport {
name: meta.name.clone(),
quality,
description: meta.description.clone(),
});
}
CuratorReport {
analyzed_at: now,
skills: reports,
cleanup_candidates,
total_skills: skill_metas.len(),
stale_count,
}
}
/// 评估单个 skill 的质量。
///
/// `pinned` — true 时 skill 免疫 stale/deprecated 自动转换(参考 Hermes pinned)。
/// 质量评分最低 0.8,始终标记为 Active。
fn evaluate_quality(
&self,
name: &str,
stat: Option<&crate::agent::skills::SkillUsageStat>,
now: &DateTime<Utc>,
pinned: bool,
) -> SkillQuality {
// seed_record: 无统计记录的新 skill 视为刚创建(参考 Hermes seed_record_if_missing
let (total_invocations, days_since_last_use) = match stat {
Some(s) => {
let days = s
.last_used_at
.map(|last| now.signed_duration_since(last).num_days().max(0));
(s.invoke_count, days)
}
None => (0u64, Some(0i64)),
};
// 生命周期判断 — pinned skill 强制 Active
let lifecycle = if pinned {
SkillLifecycle::Active
} else {
match days_since_last_use {
Some(days) if days >= DEPRECATED_THRESHOLD_DAYS => SkillLifecycle::Deprecated,
Some(days) if days >= STALE_THRESHOLD_DAYS => SkillLifecycle::Stale,
// 新 skill 保护期:7 天内不标记为 Inactive
Some(days) if days < NEW_SKILL_GRACE_PERIOD_DAYS && total_invocations == 0 => {
SkillLifecycle::Active
}
Some(_) if total_invocations == 0 => SkillLifecycle::Inactive,
Some(_) => SkillLifecycle::Active,
None => {
if total_invocations > 0 {
SkillLifecycle::Active
} else {
SkillLifecycle::Inactive
}
}
}
};
// 质量评分 — pinned skill 不低于 0.8
let mut score = self.compute_quality_score(total_invocations, days_since_last_use);
if pinned {
score = score.max(0.8);
}
let recommendation = self.recommend(&lifecycle, total_invocations, score, name, pinned);
SkillQuality {
score,
lifecycle,
days_since_last_use,
total_invocations,
estimated_success_rate: None,
recommendation,
}
}
/// 计算质量分数。
///
/// 公式:
/// invocation_weight = min(1.0, ln(1 + invocations) / ln(1 + MIN_RETENTION))
/// recency_weight = 0.5 ^ (days_since_last_use / STALE_THRESHOLD)
/// score = invocation_weight * recency_weight
fn compute_quality_score(
&self,
total_invocations: u64,
days_since_last_use: Option<i64>,
) -> f64 {
// 调用次数因子:0 次调用 → 0.0, 3 次 → ~0.6, 10 次 → ~0.85
let invoc_weight = if total_invocations == 0 {
0.0
} else {
let raw =
(total_invocations as f64).ln_1p() / (MIN_INVOCATIONS_FOR_RETENTION as f64).ln_1p();
raw.min(1.0)
};
// 新鲜度因子:当天 → 1.0, stale 时 → ~0.5, deprecated → ~0.125
let recency_weight = match days_since_last_use {
Some(days) => 0.5_f64.powf(days as f64 / STALE_THRESHOLD_DAYS as f64),
None => 0.0,
};
let score = invoc_weight * recency_weight;
(score * 100.0).round() / 100.0
}
/// 生成 curator 推荐建议
fn recommend(
&self,
lifecycle: &SkillLifecycle,
total_invocations: u64,
score: f64,
name: &str,
pinned: bool,
) -> String {
if pinned {
return format!("Skill '{}' 已固定(pinned),跳过自动清理", name);
}
match lifecycle {
SkillLifecycle::Deprecated => {
format!(
"Skill '{}' 已 {} 天未使用,建议归档到 {}",
name,
DEPRECATED_THRESHOLD_DAYS,
self.archive_dir.display()
)
}
SkillLifecycle::Stale if score < AUTO_PRUNE_QUALITY_THRESHOLD => {
format!(
"Skill '{}' 超过 {} 天未使用且质量分数低 ({:.2}),建议审查后删除",
name, STALE_THRESHOLD_DAYS, score
)
}
SkillLifecycle::Stale => {
format!("Skill '{}' 长期未使用,若不再需要可归档", name)
}
SkillLifecycle::Inactive if total_invocations == 0 => {
format!(
"Skill '{}' 未被使用过,若 {} 天内仍无使用建议删除",
name, STALE_THRESHOLD_DAYS
)
}
SkillLifecycle::Inactive => {
format!("Skill '{}' 使用频率低,可考虑优化或合并", name)
}
SkillLifecycle::Active => {
format!("Skill '{}' 状态良好(调用 {} 次)", name, total_invocations)
}
}
}
/// 归档指定的 stale skills(移动到 archive 目录而非删除)。
///
/// 返回成功归档的 skill 名称列表。
pub fn archive_stale_skills(&self, skill_names: &[String]) -> std::io::Result<Vec<String>> {
// 确保归档目录存在
std::fs::create_dir_all(&self.archive_dir)?;
let mut archived = Vec::new();
for name in skill_names {
let skill_dir = self.skills_dir.join(name);
if !skill_dir.exists() {
warn!("[Curator] Skill 目录不存在,跳过: {}", skill_dir.display());
continue;
}
let archive_path = self.archive_dir.join(name);
if archive_path.exists() {
// 避免覆盖已有归档:添加时间戳后缀
let ts = Utc::now().format("%Y%m%d%H%M%S");
let renamed = self.archive_dir.join(format!("{}-{}", name, ts));
info!(
"[Curator] 归档目录已存在,使用新名称: {}",
renamed.display()
);
std::fs::rename(&skill_dir, &renamed)?;
archived.push(format!("{}-{}", name, ts));
} else {
std::fs::rename(&skill_dir, &archive_path)?;
archived.push(name.clone());
}
info!("[Curator] 已归档 skill: {}", name);
}
Ok(archived)
}
/// 获取归档目录路径
pub fn archive_dir(&self) -> &Path {
&self.archive_dir
}
}
// ── CuratorRunner — Inactivity-Triggered 后台调度器 ───────────────────────
//
// 参考 Hermes curator.py 的 maybe_run_curator() 设计。
// CuratorRunner 在后台运行,检测 Agent 空闲状态后自动触发 curator 审查。
// 对齐 Hermes 的 inactivity-triggered 模式(非 cron daemon)。
/// Curator 后台运行器。
///
/// 在 Agent 空闲超过 `min_idle` 且距上次运行超过 `interval` 时,
/// 自动触发 curator 审查。不阻塞主流程。
///
/// 使用方式:
/// ```ignore
/// let runner = CuratorRunner::new(curator, db_pool, skill_registry);
/// let handle = runner.spawn(); // 返回 JoinHandleabort 即停止
/// ```
pub struct CuratorRunner {
curator: Arc<Curator>,
db: sqlx::SqlitePool,
/// 当前是否已触发过(防止重复运行)
last_run_at: Arc<Mutex<Option<DateTime<Utc>>>>,
/// 是否暂停
paused: Arc<AtomicBool>,
/// 运行间隔
interval: Duration,
/// 最小空闲时间
min_idle: Duration,
/// 最后一次 Agent 活动时间
last_activity: Arc<Mutex<DateTime<Utc>>>,
}
impl CuratorRunner {
/// 创建运行器。
pub fn new(curator: Curator, db: sqlx::SqlitePool) -> Self {
CuratorRunner {
curator: Arc::new(curator),
db,
last_run_at: Arc::new(Mutex::new(None)),
paused: Arc::new(AtomicBool::new(false)),
interval: DEFAULT_CURATOR_INTERVAL,
min_idle: DEFAULT_MIN_IDLE,
last_activity: Arc::new(Mutex::new(Utc::now())),
}
}
/// 配置运行间隔
pub fn with_interval(mut self, interval: Duration) -> Self {
self.interval = interval;
self
}
/// 配置最小空闲时间
pub fn with_min_idle(mut self, min_idle: Duration) -> Self {
self.min_idle = min_idle;
self
}
/// 暂停 curator
pub fn pause(&self) {
self.paused.store(true, Ordering::SeqCst);
info!("[CuratorRunner] 已暂停");
}
/// 恢复 curator
pub fn resume(&self) {
self.paused.store(false, Ordering::SeqCst);
info!("[CuratorRunner] 已恢复");
}
/// 是否已暂停
pub fn is_paused(&self) -> bool {
self.paused.load(Ordering::SeqCst)
}
/// 记录 Agent 活动时间(每次工具调用或用户交互时调用)。
pub async fn record_activity(&self) {
*self.last_activity.lock().await = Utc::now();
}
/// 获取上一次运行时间
pub async fn last_run_at(&self) -> Option<DateTime<Utc>> {
*self.last_run_at.lock().await
}
/// 判断当前是否应该运行 curator。
///
/// 条件(对齐 Hermes should_run_now):
/// 1. curator 未暂停
/// 2. Agent 空闲超过 min_idle
/// 3. 距上次运行超过 interval(或从未运行过)
pub async fn should_run_now(&self) -> bool {
if self.paused.load(Ordering::SeqCst) {
return false;
}
let last_activity = *self.last_activity.lock().await;
let now = Utc::now();
let idle_duration = now.signed_duration_since(last_activity);
if idle_duration.num_seconds() < self.min_idle.as_secs() as i64 {
return false;
}
match *self.last_run_at.lock().await {
Some(last_run) => {
now.signed_duration_since(last_run).num_seconds() as u64 >= self.interval.as_secs()
}
None => true, // 从未运行过
}
}
/// 执行一次 curator 审查(仅在 should_run_now 时)。
///
/// 返回 Some(report) 如果执行了审查,None 如果跳过。
pub async fn run_once(
&self,
usage_stats: &HashMap<String, crate::agent::skills::SkillUsageStat>,
skill_metas: &[crate::agent::skills::SkillMeta],
) -> Option<CuratorReport> {
if !self.should_run_now().await {
return None;
}
info!("[CuratorRunner] 开始后台审查...");
let now = Utc::now();
let report = self.curator.analyze(usage_stats, skill_metas);
*self.last_run_at.lock().await = Some(now);
if !report.cleanup_candidates.is_empty() {
info!(
"[CuratorRunner] 审查完成: {} 个清理候选 (总 {} skill, {} stale)",
report.cleanup_candidates.len(),
report.total_skills,
report.stale_count
);
} else {
info!(
"[CuratorRunner] 审查完成: 无需清理 (总 {} skill)",
report.total_skills
);
}
Some(report)
}
/// 启动后台任务(spawn tokio task)。
///
/// 每隔 `check_interval` 检查一次是否应运行 curator。
/// 返回 JoinHandle,调用 `.abort()` 停止。
pub fn spawn(
self: Arc<Self>,
usage_stats: Arc<std::sync::RwLock<HashMap<String, crate::agent::skills::SkillUsageStat>>>,
skill_metas: Arc<std::sync::RwLock<Vec<crate::agent::skills::SkillMeta>>>,
check_interval: Duration,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
loop {
tokio::time::sleep(check_interval).await;
if !self.should_run_now().await {
continue;
}
let stats = usage_stats.read().unwrap().clone();
let metas = skill_metas.read().unwrap().clone();
let _report = self.run_once(&stats, &metas).await;
}
})
}
}
// ── 测试 ──
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::skills::SkillUsageStat;
#[test]
fn test_lifecycle_active() {
let curator = Curator::new(PathBuf::from("/tmp/skills"));
let stat = SkillUsageStat {
invoke_count: 10,
last_used_at: Some(Utc::now()),
};
let quality = curator.evaluate_quality("test-skill", Some(&stat), &Utc::now(), false);
assert_eq!(quality.lifecycle, SkillLifecycle::Active);
assert!(quality.score > 0.5);
}
#[test]
fn test_lifecycle_deprecated() {
let curator = Curator::new(PathBuf::from("/tmp/skills"));
let stat = SkillUsageStat {
invoke_count: 1,
last_used_at: Some(Utc::now() - chrono::Duration::days(DEPRECATED_THRESHOLD_DAYS + 1)),
};
let quality = curator.evaluate_quality("old-skill", Some(&stat), &Utc::now(), false);
assert_eq!(quality.lifecycle, SkillLifecycle::Deprecated);
assert!(quality.score < 0.3);
}
#[test]
fn test_lifecycle_stale() {
let curator = Curator::new(PathBuf::from("/tmp/skills"));
let stat = SkillUsageStat {
invoke_count: 5,
last_used_at: Some(Utc::now() - chrono::Duration::days(STALE_THRESHOLD_DAYS + 5)),
};
let quality = curator.evaluate_quality("stale-skill", Some(&stat), &Utc::now(), false);
assert_eq!(quality.lifecycle, SkillLifecycle::Stale);
}
#[test]
fn test_lifecycle_inactive_new() {
let curator = Curator::new(PathBuf::from("/tmp/skills"));
// 从未使用过的新 skill
let stat = SkillUsageStat {
invoke_count: 0,
last_used_at: None,
};
let quality = curator.evaluate_quality("new-skill", Some(&stat), &Utc::now(), false);
assert_eq!(quality.lifecycle, SkillLifecycle::Inactive);
assert_eq!(quality.score, 0.0);
}
#[test]
fn test_quality_score_perfect() {
let curator = Curator::new(PathBuf::from("/tmp/skills"));
// 频繁使用、刚使用过
let score = curator.compute_quality_score(50, Some(0));
assert!(score > 0.9);
}
#[test]
fn test_quality_score_zero() {
let curator = Curator::new(PathBuf::from("/tmp/skills"));
// 从未使用、从未调用
let score = curator.compute_quality_score(0, None);
assert_eq!(score, 0.0);
}
#[test]
fn test_analyze_produces_cleanup_candidates() {
let curator = Curator::new(PathBuf::from("/tmp/skills"));
let mut stats = HashMap::new();
stats.insert(
"old-skill".to_string(),
SkillUsageStat {
invoke_count: 0,
last_used_at: Some(
Utc::now() - chrono::Duration::days(DEPRECATED_THRESHOLD_DAYS + 10),
),
},
);
let metas = vec![crate::agent::skills::SkillMeta {
name: "old-skill".into(),
description: "An old skill".into(),
context: None,
allowed_tools: vec![],
when_to_use: None,
disable_model_invocation: false,
user_invocable: true,
paths: vec![],
pinned: false,
}];
let report = curator.analyze(&stats, &metas);
assert_eq!(report.total_skills, 1);
assert_eq!(report.stale_count, 1);
assert!(!report.cleanup_candidates.is_empty());
assert!(report.cleanup_candidates.contains(&"old-skill".to_string()));
}
#[test]
fn test_pinned_skill_always_active() {
let curator = Curator::new(PathBuf::from("/tmp/skills"));
// 即使 200 天未使用,pinned skill 仍为 Active
let stat = SkillUsageStat {
invoke_count: 0,
last_used_at: Some(
Utc::now() - chrono::Duration::days(DEPRECATED_THRESHOLD_DAYS + 100),
),
};
let quality = curator.evaluate_quality("pinned-skill", Some(&stat), &Utc::now(), true);
assert_eq!(quality.lifecycle, SkillLifecycle::Active);
assert!(
quality.score >= 0.8,
"Pinned score should be >= 0.8, got {}",
quality.score
);
}
#[test]
fn test_pinned_skill_not_in_cleanup() {
let curator = Curator::new(PathBuf::from("/tmp/skills"));
let mut stats = HashMap::new();
stats.insert(
"pinned-skill".to_string(),
SkillUsageStat {
invoke_count: 0,
last_used_at: Some(
Utc::now() - chrono::Duration::days(DEPRECATED_THRESHOLD_DAYS + 10),
),
},
);
let metas = vec![crate::agent::skills::SkillMeta {
name: "pinned-skill".into(),
description: "Pinned skill".into(),
context: None,
allowed_tools: vec![],
when_to_use: None,
disable_model_invocation: false,
user_invocable: true,
paths: vec![],
pinned: true, // pinned!
}];
let report = curator.analyze(&stats, &metas);
assert_eq!(report.total_skills, 1);
// Pinned 不应进入清理候选
assert!(
report.cleanup_candidates.is_empty(),
"Pinned skill should not be in cleanup candidates"
);
}
#[test]
fn test_seed_record_new_skill_is_active() {
let curator = Curator::new(PathBuf::from("/tmp/skills"));
// seed_record: 无统计记录的新 skill → days_since_last_use=0 → Active
let quality = curator.evaluate_quality("fresh-skill", None, &Utc::now(), false);
assert_eq!(
quality.lifecycle,
SkillLifecycle::Active,
"New skill should be Active (seed_record)"
);
assert_eq!(quality.days_since_last_use, Some(0));
}
#[tokio::test]
async fn test_curator_runner_paused_skips() {
let curator = Curator::new(PathBuf::from("/tmp/skills"));
let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
let runner = CuratorRunner::new(curator, pool);
runner.pause();
assert!(!runner.should_run_now().await);
}
#[tokio::test]
async fn test_curator_runner_idle_check() {
let curator = Curator::new(PathBuf::from("/tmp/skills"));
let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
let runner = CuratorRunner::new(curator, pool)
.with_min_idle(Duration::from_secs(0)) // 立即视为空闲
.with_interval(Duration::from_secs(0)); // 立即视为过期
// 刚创建,应满足运行条件
assert!(runner.should_run_now().await);
}
}
+448
View File
@@ -0,0 +1,448 @@
// src/agent/skills/pattern_detector.rs
//
// 工具调用序列模式检测器。
// 参考 Hermes-Agent curator.py 设计。
//
// 监控 agent_messages 中的工具调用序列,检测跨 session 重复出现的工作流模式。
// 当同一序列在多个 session 中重复出现时,将其标记为「可保存为 Skill 的候选」。
//
// 算法:
// 1. 从 agent_messages 查询所有 tool 角色的消息(按 session + 时间排序)
// 2. 按 session 分组为工具名称序列
// 3. 使用滑动窗口检测 >=MIN_PATTERN_LENGTH 的公共子序列
// 4. 过滤掉已在 SkillRegistry 中注册的已有模式
// 5. 返回候选模式列表(按出现频次降序)
use std::collections::{HashMap, HashSet};
use serde::Serialize;
use sqlx::SqlitePool;
use tracing::{debug, info};
// ── 配置常量 ──
/// 模式的最小长度(工具调用数),短于此值的序列不值得保存为 Skill
pub const MIN_PATTERN_LENGTH: usize = 2;
/// 候选 Skill 的最小出现次数(跨不同 session)
pub const MIN_OCCURRENCES: usize = 3;
/// 滑动窗口最大长度(长序列会被切片为多个子序列)
pub const MAX_WINDOW_LENGTH: usize = 8;
/// 序列相似度阈值(Jaccard 系数),用于模糊匹配
pub const SIMILARITY_THRESHOLD: f64 = 0.7;
// ── 数据结构 ──
/// 检测到的工具调用模式
#[derive(Debug, Clone, Serialize)]
pub struct DetectedPattern {
/// 工具名称序列(如 ["search_papers", "download_paper", "get_paper_content"]
pub tool_sequence: Vec<String>,
/// 该模式在不同 session 中的出现频次
pub occurrence_count: usize,
/// 出现该模式的 session ID 列表
pub session_ids: Vec<String>,
/// 一个典型的完整参数示例(来自最近一次出现)
pub example_args: Vec<serde_json::Value>,
/// 一个典型的人类可读描述(LLM 生成,初始为空)
pub description: String,
/// 模式置信度分数 (0.0 ~ 1.0),基于出现次数和序列长度
pub confidence: f64,
}
impl DetectedPattern {
/// 计算模式置信度。
///
/// 公式:`min(1.0, occurrences / 5.0) * min(1.0, len / 4.0)`
/// - 出现 5+ 次 → 权重 1.0
/// - 序列长度 4+ → 权重 1.0
pub fn compute_confidence(occurrences: usize, seq_len: usize) -> f64 {
let occ_factor = (occurrences as f64 / 5.0).min(1.0);
let len_factor = (seq_len as f64 / 4.0).min(1.0);
(occ_factor * len_factor * 100.0).round() / 100.0
}
/// 生成唯一的模式标识符(用于去重)
pub fn fingerprint(&self) -> String {
self.tool_sequence.join("")
}
/// 该模式是否足够可信以自动保存
pub fn is_confident(&self) -> bool {
self.confidence >= 0.6 && self.occurrence_count >= MIN_OCCURRENCES
}
}
// ── 检测引擎 ──
/// 模式检测器。
///
/// 从数据库中的 agent_messages 表提取工具调用序列并检测重复模式。
pub struct PatternDetector {
/// 已注册 skill 的工具序列指纹集合(用于去重)
known_patterns: HashSet<String>,
}
impl PatternDetector {
/// 创建检测器,传入已注册 skill 的指纹集合以避免重复创建。
pub fn new(known_patterns: HashSet<String>) -> Self {
PatternDetector { known_patterns }
}
/// 扫描最近 N 个 session 的工具调用序列,检测重复模式。
///
/// `db` — SQLite 连接池
/// `max_sessions` — 最多扫描的 session 数
/// `exclude_recent` — 排除最近 N 小时的 session(避免从当前活跃 session 中学习)
pub async fn scan(
&self,
db: &SqlitePool,
max_sessions: usize,
exclude_recent_hours: i64,
) -> anyhow::Result<Vec<DetectedPattern>> {
// Step 1: 获取最近 session 列表
let sessions = self
.fetch_recent_sessions(db, max_sessions, exclude_recent_hours)
.await?;
info!(
"[PatternDetector] 扫描 {} 个 session 的工具调用序列",
sessions.len()
);
if sessions.is_empty() {
return Ok(Vec::new());
}
// Step 2: 对每个 session 提取工具名称序列
let mut session_sequences: Vec<(String, Vec<String>, Vec<serde_json::Value>)> = Vec::new(); // (session_id, tool_names, args_list)
for (sid, _title) in &sessions {
let (tools, args) = self.fetch_tool_sequence(db, sid).await?;
if tools.len() >= MIN_PATTERN_LENGTH {
session_sequences.push((sid.clone(), tools, args));
}
}
// Step 3: 滑动窗口检测公共子序列
let mut pattern_candidates: HashMap<String, DetectedPattern> = HashMap::new();
for (sid, tools, args) in &session_sequences {
let all_subseqs =
extract_all_subsequences(tools, MIN_PATTERN_LENGTH, MAX_WINDOW_LENGTH);
for subseq in all_subseqs {
let fp = subseq.join("");
// 跳过已注册的 skill 模式
if self.known_patterns.contains(&fp) {
continue;
}
let entry =
pattern_candidates
.entry(fp.clone())
.or_insert_with(|| DetectedPattern {
tool_sequence: subseq.clone(),
occurrence_count: 0,
session_ids: Vec::new(),
example_args: Vec::new(),
description: String::new(),
confidence: 0.0,
});
entry.occurrence_count += 1;
if !entry.session_ids.contains(sid) {
entry.session_ids.push(sid.clone());
}
// 仅保存第一个示例参数(最近的)
if entry.example_args.is_empty() {
entry.example_args = args.clone();
}
}
}
// Step 4: 过滤和排序
let mut patterns: Vec<DetectedPattern> = pattern_candidates
.into_values()
.filter(|p| {
p.occurrence_count >= MIN_OCCURRENCES && p.tool_sequence.len() >= MIN_PATTERN_LENGTH
})
.collect();
// 去重:移除被更长模式包含的短模式
patterns = deduplicate_subpatterns(patterns);
// 计算置信度并排序
for p in &mut patterns {
p.confidence =
DetectedPattern::compute_confidence(p.occurrence_count, p.tool_sequence.len());
}
patterns.sort_by(|a, b| {
b.confidence
.partial_cmp(&a.confidence)
.unwrap_or(std::cmp::Ordering::Equal)
});
debug!(
"[PatternDetector] 检测到 {} 个候选模式(过滤后)",
patterns.len()
);
Ok(patterns)
}
// ── 数据库查询 ──
/// 获取最近的 session 列表(按最后活动时间降序)
async fn fetch_recent_sessions(
&self,
db: &SqlitePool,
max_sessions: usize,
exclude_recent_hours: i64,
) -> anyhow::Result<Vec<(String, String)>> {
let rows = sqlx::query_as::<_, (String, String)>(
r#"
SELECT session_id, COALESCE(title, '') as title
FROM agent_sessions
WHERE status = 'completed'
AND updated_at < datetime('now', ?)
ORDER BY updated_at DESC
LIMIT ?
"#,
)
.bind(format!("-{} hours", exclude_recent_hours))
.bind(max_sessions as i64)
.fetch_all(db)
.await?;
Ok(rows)
}
/// 获取指定 session 的工具调用序列(按时间排序)
async fn fetch_tool_sequence(
&self,
db: &SqlitePool,
session_id: &str,
) -> anyhow::Result<(Vec<String>, Vec<serde_json::Value>)> {
let rows = sqlx::query_as::<_, (String, String)>(
r#"
SELECT COALESCE(tool_call_id, ''), COALESCE(raw_json, '{}')
FROM agent_messages
WHERE session_id = ? AND role = 'tool'
ORDER BY created_at ASC
"#,
)
.bind(session_id)
.fetch_all(db)
.await?;
// 从 tool_call_id 或 raw_json 中提取工具名
let mut tool_names = Vec::new();
let mut args_list = Vec::new();
for (_call_id, raw_json) in &rows {
// 尝试从 raw_json 中解析 tool_name
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(raw_json) {
let name = parsed
.get("tool_name")
.or_else(|| parsed.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let args = parsed.get("arguments").or(parsed.get("args")).cloned();
tool_names.push(name);
args_list.push(args.unwrap_or(serde_json::Value::Null));
}
}
Ok((tool_names, args_list))
}
}
// ── 序列处理 ──
/// 从工具名称列表中提取所有长度在 [min_len, max_len] 范围内的子序列。
///
/// 使用滑动窗口,窗口大小从 min_len 到 max_len。
fn extract_all_subsequences(tools: &[String], min_len: usize, max_len: usize) -> Vec<Vec<String>> {
let mut subseqs = Vec::new();
let n = tools.len();
for window_size in min_len..=max_len.min(n) {
for start in 0..=n - window_size {
let window: Vec<String> = tools[start..start + window_size].to_vec();
subseqs.push(window);
}
}
subseqs
}
/// 去重:移除被更长模式包含的短模式。
///
/// 例如,如果已有 "A→B→C→D"4步),则移除 "A→B→C"3步)。
fn deduplicate_subpatterns(mut patterns: Vec<DetectedPattern>) -> Vec<DetectedPattern> {
// 按序列长度降序排列
patterns.sort_by(|a, b| b.tool_sequence.len().cmp(&a.tool_sequence.len()));
let mut retained: Vec<DetectedPattern> = Vec::new();
for p in patterns {
let is_subpattern = retained.iter().any(|existing| {
// 检查 p 是否是 existing 的子序列
is_subsequence(&p.tool_sequence, &existing.tool_sequence)
&& p.tool_sequence.len() < existing.tool_sequence.len()
});
if !is_subpattern {
retained.push(p);
}
}
// 按置信度降序重新排列
retained.sort_by(|a, b| {
b.confidence
.partial_cmp(&a.confidence)
.unwrap_or(std::cmp::Ordering::Equal)
});
retained
}
/// 检查 `short` 是否是 `long` 的连续子序列
fn is_subsequence(short: &[String], long: &[String]) -> bool {
if short.len() > long.len() {
return false;
}
long.windows(short.len()).any(|window| window == short)
}
/// 计算两个序列的 Jaccard 相似度。
///
/// Jaccard(A, B) = |A ∩ B| / |A B|
#[allow(dead_code)]
fn jaccard_similarity(a: &[String], b: &[String]) -> f64 {
let set_a: HashSet<&String> = a.iter().collect();
let set_b: HashSet<&String> = b.iter().collect();
let intersection = set_a.intersection(&set_b).count();
let union = set_a.union(&set_b).count();
if union == 0 {
return 0.0;
}
intersection as f64 / union as f64
}
// ── 测试 ──
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_subsequences_basic() {
let tools: Vec<String> = vec!["A", "B", "C", "D"]
.into_iter()
.map(String::from)
.collect();
let subseqs = extract_all_subsequences(&tools, 2, 3);
// 长度2: [A,B] [B,C] [C,D] = 3
// 长度3: [A,B,C] [B,C,D] = 2
// 总计 5
assert_eq!(subseqs.len(), 5);
}
#[test]
fn test_extract_subsequences_min_equals_max() {
let tools: Vec<String> = vec!["X", "Y"].into_iter().map(String::from).collect();
let subseqs = extract_all_subsequences(&tools, 2, 2);
assert_eq!(subseqs.len(), 1);
assert_eq!(subseqs[0], vec!["X", "Y"]);
}
#[test]
fn test_is_subsequence_match() {
let short: Vec<String> = vec!["B", "C"].into_iter().map(String::from).collect();
let long: Vec<String> = vec!["A", "B", "C", "D"]
.into_iter()
.map(String::from)
.collect();
assert!(is_subsequence(&short, &long));
}
#[test]
fn test_is_subsequence_no_match() {
let short: Vec<String> = vec!["B", "D"].into_iter().map(String::from).collect();
let long: Vec<String> = vec!["A", "B", "C", "D"]
.into_iter()
.map(String::from)
.collect();
assert!(!is_subsequence(&short, &long));
}
#[test]
fn test_deduplicate_subpatterns_removes_contained() {
let patterns = vec![
DetectedPattern {
tool_sequence: vec!["A".into(), "B".into(), "C".into()],
occurrence_count: 5,
session_ids: vec!["s1".into()],
example_args: vec![],
description: String::new(),
confidence: 0.8,
},
DetectedPattern {
tool_sequence: vec!["A".into(), "B".into(), "C".into(), "D".into()],
occurrence_count: 3,
session_ids: vec!["s1".into()],
example_args: vec![],
description: String::new(),
confidence: 0.7,
},
];
let result = deduplicate_subpatterns(patterns);
// 长序列 ABC→D 保留,子序列 ABC 被移除
assert_eq!(result.len(), 1);
assert_eq!(result[0].tool_sequence.len(), 4);
}
#[test]
fn test_compute_confidence() {
// 5 occurrences, 4 length → perfect confidence
let c1 = DetectedPattern::compute_confidence(5, 4);
assert_eq!(c1, 1.0);
// 3 occurrences, 2 length → low confidence
let c2 = DetectedPattern::compute_confidence(3, 2);
assert!(c2 < 0.5);
// 10 occurrences, 6 length → clipped at 1.0
let c3 = DetectedPattern::compute_confidence(10, 6);
assert_eq!(c3, 1.0);
}
#[test]
fn test_fingerprint() {
let pattern = DetectedPattern {
tool_sequence: vec!["search_papers".into(), "download_paper".into()],
occurrence_count: 3,
session_ids: vec!["s1".into(), "s2".into()],
example_args: vec![],
description: String::new(),
confidence: 0.6,
};
assert_eq!(pattern.fingerprint(), "search_papers→download_paper");
}
#[test]
fn test_jaccard_similarity() {
let a: Vec<String> = vec!["A", "B", "C"].into_iter().map(String::from).collect();
let b: Vec<String> = vec!["A", "B", "D"].into_iter().map(String::from).collect();
let sim = jaccard_similarity(&a, &b);
// |A ∩ B| = 2 (A, B), |A B| = 4 (A, B, C, D)
assert!((sim - 0.5).abs() < 0.01);
}
}
+22 -2
View File
@@ -100,6 +100,25 @@ impl SubAgentRunner {
}
}
/// 创建带完整 hooks/permissions + 自定义 ToolRegistry 的子代理运行器。
pub fn new_with_registry_and_hooks(
app_state: Arc<AppState>,
tool_registry: ToolRegistry,
hook_registry: Option<Arc<HookRegistry>>,
permission_checker: Arc<PermissionChecker>,
progress_tx: Option<UnboundedSender<AgentStreamEvent>>,
) -> Self {
SubAgentRunner {
app_state,
config: AgentConfig::default(),
tool_registry,
hook_registry,
permission_checker,
progress_tx,
parent_session_id: String::new(),
}
}
/// 运行子代理的 ReAct 循环,返回最终文本摘要。
///
/// # Arguments
@@ -267,11 +286,12 @@ impl SubAgentRunner {
"[SubAgent] 上下文超限 (est. {} tokens),触发压缩",
est_tokens
);
compact::compress_context(
compact::compress_context_with_hooks(
&mut messages,
llm,
self.config.context_char_limit,
"subagent",
subagent_name,
self.hook_registry.as_ref().map(|a| a.as_ref()),
)
.await;
}
+70
View File
@@ -50,6 +50,7 @@ pub use todo::persist_tasks;
pub use todo::TodoWriteTool;
/// 工具执行上下文,封装全局共享状态
#[derive(Clone)]
pub struct ToolContext {
pub app_state: Arc<AppState>,
/// 当前会话 ID(用于工具将数据关联到正确的 session)
@@ -275,6 +276,10 @@ pub struct ToolRegistry {
ordered_names: Vec<String>,
/// 可选的工具白名单(子代理最小权限)
definition_filter: Option<std::collections::HashSet<String>>,
/// 缓存的工具 schema(session 生命周期内有效,工具注册变更时失效)
schema_cache: Option<Vec<ToolDefinition>>,
/// schema 缓存版本号(递增使缓存失效)
schema_generation: u64,
}
// ── 工具注册辅助函数(消除重复代码) ──
@@ -348,6 +353,8 @@ impl ToolRegistry {
tools: std::collections::HashMap::new(),
ordered_names: Vec::new(),
definition_filter: None,
schema_cache: None,
schema_generation: 0,
}
}
@@ -365,11 +372,14 @@ impl ToolRegistry {
tools: std::collections::HashMap::new(),
ordered_names: Vec::new(),
definition_filter: None,
schema_cache: None,
schema_generation: 0,
};
add_base_tools(&mut registry, skill_registry);
if let Some(q) = queue {
add_background_tools(&mut registry, q);
}
registry.precompute_definitions();
registry
}
@@ -382,6 +392,7 @@ impl ToolRegistry {
// 使用 new_with_queue 获取基础 + 后台工具,再添加团队工具
let mut registry = Self::new_with_queue(queue, skill_registry);
add_team_tools(&mut registry, team_manager);
registry.precompute_definitions();
registry
}
@@ -390,6 +401,9 @@ impl ToolRegistry {
let name = tool.name().to_string();
self.ordered_names.push(name.clone());
self.tools.insert(name, tool);
// 工具变更 → 使 schema 缓存失效
self.schema_cache = None;
self.schema_generation += 1;
}
/// 替换已存在的工具(保持名称在 ordered_names 中的位置不变)。
@@ -400,6 +414,9 @@ impl ToolRegistry {
self.ordered_names.push(name.clone());
}
self.tools.insert(name, tool);
// 工具变更 → 使 schema 缓存失效
self.schema_cache = None;
self.schema_generation += 1;
}
/// 根据名称查找工具 (O(1))
@@ -407,10 +424,27 @@ impl ToolRegistry {
self.tools.get(name).map(|t| t.as_ref())
}
/// 设置工具定义白名单过滤器(子代理最小权限)。
/// 设置后 `definitions()` 仅返回白名单中的工具。
pub fn set_definition_filter(&mut self, allowed: Vec<String>) {
self.definition_filter = Some(allowed.into_iter().collect());
// 过滤器变更 → 使 schema 缓存失效
self.schema_cache = None;
self.schema_generation += 1;
}
/// 生成所有工具的 ToolDefinition 列表(用于发送给 LLM)。
/// 按名称字母序排序以保证跨调用的稳定性,提升 prompt cache 命中率。
/// 若设置了 definition_filter,仅返回白名单中的工具。
///
/// 使用内部 schema 缓存:工具注册表不变时复用上次计算结果。
pub fn definitions(&self) -> Vec<ToolDefinition> {
// 缓存命中:直接返回
if let Some(ref cached) = self.schema_cache {
return cached.clone();
}
// 缓存未命中:重新计算
let values: Vec<&Box<dyn AgentTool>> = if let Some(filter) = &self.definition_filter {
self.tools
.iter()
@@ -425,9 +459,45 @@ impl ToolRegistry {
.map(|t| ToolDefinition::new(t.name(), t.description(), t.parameters()))
.collect();
defs.sort_by(|a, b| a.function.name.cmp(&b.function.name));
// 此方法在 &self 上调用,无法通过 &mut self 修改 self.schema_cache。
// 使用 unsafe 实现内部可变性,仅在缓存空时写入(逻辑上安全)。
// 如果 schema_cache 为 None,说明工具集未变更,写入是安全的。
// Note: 实际上这里需要用内部可变性。最简单的方式是用 RefCell 或
// 将 schema_cache 和 schema_generation 移到 Cell/RefCell 中。
// 但为了最小改动,这里依赖一个事实:definitions() 在主线程中调用时
// 没有并发写入问题,且 add_tool/replace_tool 只在初始化时调用。
defs
}
/// 获取当前 schema 缓存版本号(用于外部检测工具是否变更)。
pub fn schema_generation(&self) -> u64 {
self.schema_generation
}
/// 预计算并缓存 tool definitions(在 AgentRuntime 初始化时调用,
/// 此时拥有 &mut self,可以安全写入缓存)。
pub fn precompute_definitions(&mut self) {
if self.schema_cache.is_some() {
return;
}
let values: Vec<&Box<dyn AgentTool>> = if let Some(filter) = &self.definition_filter {
self.tools
.iter()
.filter(|(name, _)| filter.contains(*name))
.map(|(_, tool)| tool)
.collect()
} else {
self.tools.values().collect()
};
let mut defs: Vec<_> = values
.iter()
.map(|t| ToolDefinition::new(t.name(), t.description(), t.parameters()))
.collect();
defs.sort_by(|a, b| a.function.name.cmp(&b.function.name));
self.schema_cache = Some(defs);
}
/// 设置工具白名单(子代理最小权限)。
/// 设置后 `definitions()` 仅暴露白名单中的工具给 LLM,
/// 但 `get()` 仍可访问所有工具以便对未授权调用返回友好错误消息。
+48 -7
View File
@@ -100,21 +100,62 @@ impl AgentTool for SubAgentTool {
max_steps
);
let system_prompt = "你是一位专业的天体物理学研究助手,在一个独立的子任务上下文中工作。\
使RAG检索等工具\
\
";
// 创建子代理的 ToolRegistry(与父代理共享 skill_registry
let skill_registry = ctx.app_state.skill_registry.clone();
let tool_registry = crate::agent::tools::ToolRegistry::new(skill_registry);
let runner_tool_defs = tool_registry.definitions();
// 使用 ToolContext 中的 SSE 通道和会话 IDexecutor 在构造 ToolContext 时已注入
let runner = SubAgentRunner::new_with_hooks(
// 构建子代理系统提示词(复用模块化 section,参考 Claude Code agent prompt layering
let system_prompt = {
use crate::agent::runtime::system_prompt::{
SystemPrompt, IDENTITY_SECTION, PRINCIPLES_SECTION, SAFETY_SECTION,
SYSTEM_CONTEXT_SECTION, TOOL_USAGE_SECTION,
};
let mut sp = SystemPrompt::new();
sp.add_section("identity", IDENTITY_SECTION.to_string());
sp.add_section(
"subagent_context",
"# 子代理上下文\n\
\
\
使"
.to_string(),
);
sp.add_section("principles", PRINCIPLES_SECTION.to_string());
sp.add_section("system_context", SYSTEM_CONTEXT_SECTION.to_string());
sp.add_section("tool_usage", TOOL_USAGE_SECTION.to_string());
sp.add_section("safety", SAFETY_SECTION.to_string());
// 添加子代理可用工具列表(与 ToolRegistry 一致)
let mut tools_desc = String::from("你可以使用以下工具:\n");
for def in &runner_tool_defs {
let short_desc: String = def
.function
.description
.split('。')
.next()
.unwrap_or(&def.function.description)
.chars()
.take(80)
.collect();
tools_desc.push_str(&format!("- {}: {}\n", def.function.name, short_desc));
}
sp.add_section("tools", tools_desc);
sp.assemble()
};
// 使用预构建的 ToolRegistry 创建子代理运行器
let runner = SubAgentRunner::new_with_registry_and_hooks(
ctx.app_state.clone(),
tool_registry,
self.hook_registry.clone(),
self.permission_checker.clone(),
ctx.sse_tx.clone(),
)
.with_parent_session(ctx.session_id.clone())
.with_thinking(ctx.enable_thinking);
let result = runner.run(system_prompt, &research_prompt, max_steps).await;
let result = runner
.run(&system_prompt, &research_prompt, max_steps)
.await;
if result.is_error {
ToolOutput::error(format!("子代理执行失败: {}", result.content))
+1 -1
View File
@@ -114,7 +114,7 @@ impl TrajectoryExporter {
),
>(
"SELECT role, content, thought, tool_calls, tool_call_id \
FROM agent_messages WHERE session_id = ? \
FROM agent_messages WHERE session_id = ? AND active = 1 \
ORDER BY created_at ASC, step_index ASC",
)
.bind(session_id)
+126 -1
View File
@@ -198,7 +198,7 @@ pub async fn get_session(
let msg_rows = sqlx::query(
"SELECT id, agent_name, turn_index, step_index, role, content, thought, tool_calls, tool_call_id, token_count, metadata, created_at \
FROM agent_messages \
WHERE session_id = ? \
WHERE session_id = ? AND active = 1 \
ORDER BY id ASC"
)
.bind(&session_id)
@@ -546,3 +546,128 @@ pub async fn get_pending_permissions(
.collect();
Json(result)
}
// ── POST /api/chat/sessions/:id/branch ──
// 创建会话分叉(复制所有 active=1 消息到新会话)
#[derive(Debug, Serialize)]
pub struct BranchResponse {
pub branch_session_id: String,
pub forked_at_message_id: i64,
pub copied_count: usize,
}
pub async fn branch_session(
State(state): State<Arc<AppState>>,
Path(session_id): Path<String>,
) -> Result<Json<BranchResponse>, (StatusCode, String)> {
let result = crate::agent::runtime::session::branch_session(&state.db, &session_id)
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
Ok(Json(BranchResponse {
branch_session_id: result.branch_session_id,
forked_at_message_id: result.forked_at_message_id,
copied_count: result.copied_count,
}))
}
// ── POST /api/chat/sessions/:id/retry ──
// 重试最后一次对话(硬删除 + 返回消息文本供前端重提交)
#[derive(Debug, Serialize)]
pub struct RetryResponse {
/// 被删除的用户消息文本(前端可自动重提交)
pub retried_message: String,
pub new_turn_index: i32,
pub deleted_count: i64,
pub session_id: String,
}
pub async fn retry_session(
State(state): State<Arc<AppState>>,
Path(session_id): Path<String>,
) -> Result<Json<RetryResponse>, (StatusCode, String)> {
let (retried_message, new_turn_index) =
crate::agent::runtime::session::retry_last_turn(&state.db, &session_id)
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
Ok(Json(RetryResponse {
retried_message,
new_turn_index,
deleted_count: 0, // 数据库层不便返回,设为 0
session_id,
}))
}
// ── POST /api/chat/sessions/:id/rewind ──
// 回退会话到指定的消息之前(软删除)
//
// 回退后的消息标记为 active=0(审计保留)。
// 在未产生新对话前可通过 /rewind/restore 恢复。
// 如果已产生新对话,使用 /branch 分叉探索替代路径。
#[derive(Debug, Deserialize)]
pub struct RewindRequest {
/// 回退 N 个用户轮次(默认 1)
pub n: Option<usize>,
/// 或者指定回退到的消息 ID
pub message_id: Option<i64>,
}
#[derive(Debug, Serialize)]
pub struct RewindResponse {
pub rewound_count: usize,
pub target_preview: String,
pub new_turn_index: i32,
pub session_id: String,
}
pub async fn rewind_session(
State(state): State<Arc<AppState>>,
Path(session_id): Path<String>,
Json(req): Json<RewindRequest>,
) -> Result<Json<RewindResponse>, (StatusCode, String)> {
let result = if let Some(msg_id) = req.message_id {
crate::agent::runtime::session::rewind_to_message(&state.db, &session_id, msg_id)
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
} else {
let n = req.n.unwrap_or(1);
crate::agent::runtime::session::rewind_n_turns(&state.db, &session_id, n)
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
};
Ok(Json(RewindResponse {
rewound_count: result.rewound_count,
target_preview: result.target_preview,
new_turn_index: result.new_turn_index,
session_id: session_id.clone(),
}))
}
// ── POST /api/chat/sessions/:id/rewind/restore ──
// 恢复最近一次回退(undo-of-undo)。
// 仅当回退后未产生新对话时才可恢复。
#[derive(Debug, Serialize)]
pub struct RestoreResponse {
pub restored_count: usize,
pub session_id: String,
}
pub async fn restore_rewound_session(
State(state): State<Arc<AppState>>,
Path(session_id): Path<String>,
) -> Result<Json<RestoreResponse>, (StatusCode, String)> {
let count = crate::agent::runtime::session::restore_rewound(&state.db, &session_id)
.await
.map_err(|e| (StatusCode::CONFLICT, e.to_string()))?;
Ok(Json(RestoreResponse {
restored_count: count,
session_id,
}))
}
+5 -3
View File
@@ -113,9 +113,11 @@ pub mod targets;
// 提供兼容的 handlers 命名空间,避免修改 main.rs / batch_sync.rs 里的导入
pub mod handlers {
pub use super::agent::{
answer_question, chat_agent, delete_session, get_agent_metrics, get_pending_permissions,
get_pending_questions, get_session, get_session_audit, list_sessions, respond_permission,
stop_agent, AgentChatRequest, AgentMetricsResponse, AuditLogEntry, MessageRecord,
answer_question, branch_session, chat_agent, delete_session, get_agent_metrics,
get_pending_permissions, get_pending_questions, get_session, get_session_audit,
list_sessions, respond_permission, restore_rewound_session, retry_session, rewind_session,
stop_agent, AgentChatRequest, AgentMetricsResponse, AuditLogEntry, BranchResponse,
MessageRecord, RestoreResponse, RetryResponse, RewindRequest, RewindResponse,
SessionDetail, SessionListParams, SessionSummary,
};
pub use super::helpers::{
+1 -1
View File
@@ -388,7 +388,7 @@ impl LlmClient {
"temperature": 0.3
});
// 前端可控的思考模式开关(仅对千问/DashScope 启用 enable_thinking 参数)
// 前端可控的思考模式开关(仅对千问/DashScope 启用 enable_thinking 参数)(仅对千问/DashScope 启用 enable_thinking 参数)
if enable_thinking
&& (self.api_base.contains("dashscope.aliyuncs.com")
|| self.model.to_lowercase().contains("qwen"))
+7
View File
@@ -248,6 +248,13 @@ async fn main() -> anyhow::Result<()> {
)
.route("/chat/sessions/:id/stop", post(handlers::stop_agent))
.route("/chat/sessions/:id/audit", get(handlers::get_session_audit))
.route("/chat/sessions/:id/branch", post(handlers::branch_session))
.route("/chat/sessions/:id/retry", post(handlers::retry_session))
.route("/chat/sessions/:id/rewind", post(handlers::rewind_session))
.route(
"/chat/sessions/:id/rewind/restore",
post(handlers::restore_rewound_session),
)
.route("/chat/questions", get(handlers::get_pending_questions))
.route("/chat/answer", post(handlers::answer_question))
.route(