feat: Agent 思考模式前端可控、子代理全链路持久化、权限系统、工具 ID 追踪体系、前端面板与文档架构重构

- AgentConfig/LlmClient 新增 enable_thinking 参数,前端 SSE 请求传递 thinking
  开关,仅千问/DashScope 时启用
  - 完善权限系统,支持细粒度的权限控制和用户权限申请
  - delegate_research 工具重命名为 subagent,SubAgentTool/SubAgentRunner 重构
  - 子代理消息(system/user/assistant/tool)持久化到 agent_messages 表,带 agent_name 标识
  - 子代理活动日志(工具调用列表+思考摘要)注入返回结果,Hooks 获得正确 session_id 和 subagent_name
  - LLM 工具调用 ID 回退生成 UUID(llm.rs),ToolCall/ToolResult SSE 事件增加 id/tool_call_id 双字段
  - ToolContext 扩展 session_id/sse_tx/enable_thinking 字段,executor 统一注入而非构造函数传参
  - agent_messages 新增 metadata+raw_json 列,agent_sessions 暴露 summary 字段
  - 删除文件级 transcript 快照(compact.rs),改为依赖 DB 持久化
  - ResearchAgentPanel 重写:TimelineItem 类型替代 StreamStep,支持会话历史回放
  - 新增 AgentMetricsPanel/AskUserQuestionCard/AuditLogViewer 三个前端组件,types.ts 完整类型定义
  - docs/architecture/ 分层重组:概览/核心模块/核心工作流 + agent/ 子目录 11 篇专题文档
  - docs/api.md 补充 RAG/Target/Agent 接口,docs/development.md 新建开发指南
  - .env.example 完全重写,补充 FALLBACK_MODEL 等变量说明
This commit is contained in:
fmq
2026-06-18 01:21:02 +08:00
parent 49784739fa
commit f6df9d8136
60 changed files with 9913 additions and 1844 deletions
+378
View File
@@ -0,0 +1,378 @@
# Agent 上下文管理系统
AstroResearch Agent 的上下文管理是一个多层防御架构,涵盖从系统提示词组装、运行时预算监控、多级压缩、错误恢复到跨会话持久化的完整生命周期。
## 架构概览
```mermaid
graph TB
subgraph 构建阶段["构建阶段"]
SP["SystemPrompt<br/>模块化组装"]
HL["+ 历史加载"]
TR["+ 任务恢复"]
SL["+ 技能列表"]
PM["+ 项目记忆"]
end
subgraph 运行时监控["运行时监控"]
TB["TokenBudget<br/>三级渐进Nudge"]
DR["DimReturns"]
TD["Todo提醒"]
BN["后台通知"]
LD["死循环检测"]
end
subgraph 压缩阶段["压缩阶段"]
L0["snip (L0)"]
L1["micro (L1)"]
L2["auto (L2)"]
L3["aggro_micro (L3)"]
L4["identity (L4)"]
L0 --> L1 --> L2 --> L3 --> L4
end
subgraph 恢复阶段["恢复阶段"]
ER["ErrorRecovery<br/>5级恢复阶梯"]
B4["429指数退避"]
CB["熔断器保护"]
FC["file_cache<br/>恢复注入"]
end
subgraph 持久化层["持久化层"]
AM["agent_msgs"]
AT["agent_tasks"]
MM["MEMORY.md"]
TJ["trajectory"]
AL["audit_log"]
end
构建阶段 --> 运行时监控 --> 压缩阶段 --> 恢复阶段 --> 持久化层
```
## 1. 上下文构建 (`runtime/context.rs` + `runtime/session.rs`)
### 1.1 构建流程
每个 turn 开始时 `build_initial_context()` 按以下顺序构建 LLM 消息列表:
1.`agent_messages` 表加载历史消息(按 `agent_name='lead'` 隔离,排除子代理消息)
2. 若历史中无 System 消息,在最前面插入 System Prompt
3. 追加当前用户问题
4.`agent_tasks` 表恢复持久化任务状态,格式化为 `[当前任务状态]` 消息块
### 1.2 System Prompt 模块化组装 (`runtime/system_prompt.rs`)
静态与动态 section 分离,静态 section 在前以最大化 prompt cache 命中率:
| 顺序 | Section | 类型 | 内容 |
|:---|:---|:---|:---|
| 1 | `identity` | 静态 | "你是一位专业的天体物理学研究助手…" |
| 2 | `tools` | 动态 | 从 ToolRegistry 生成工具名称 + 一行描述(~20 tokens/tool |
| 3 | `skills` | 动态 | 从 SkillRegistry 构建 `<system-reminder>` 技能列表(~20 tokens/skill |
| 4 | `memory` | 动态 | 从 MemoryManager 加载最近 5 条项目记忆 |
| 5 | `principles` | 静态 | 9 条核心工作原则(主动搜索、引用来源、LaTeX 格式等) |
### 1.3 会话生命周期 (`session.rs`)
- `create_or_resume_session()`:新建会话生成 UUID,恢复会话验证存在性并计算 turn_index
- `load_history_for_agent()`:按 `session_id + agent_name` 加载,还原 role/content/tool_calls/tool_call_id/thought 字段
- 消息隔离:`agent_name="lead"` 仅加载主代理历史,`"*"` 加载全部(调试用)
---
## 2. 上下文运行时监控 (`runtime/mod.rs` ReAct 循环内)
每一步 LLM 调用前执行以下检查:
### 2.1 Token 预算管理 (`runtime/token_budget.rs`)
```
软限制: AGENT_TOKEN_SOFT_LIMIT (默认 32,000)
硬限制: AGENT_TOKEN_HARD_LIMIT (默认 40,000)
```
**三级渐进式 Nudge**(每步检查,按优先级仅注入一条):
| 级别 | 条件 | 图标 | 消息语义 |
|:---|:---|:---|:---|
| `near_soft` | `total_spent ≥ soft_limit × 80%` | 💡 | "Token 预算提示…请注意控制后续步骤的深度" |
| `over_soft` | `total_spent ≥ soft_limit` | 🟡 | "Token 预算警告…请尽快总结关键发现" |
| `over_hard` | `total_spent ≥ hard_limit` | 🔴 | "已耗尽…请立即总结并给出最终答案" |
**Diminishing Returns 检测**
- 条件:3+ 次延续 + 连续 2 次检查的 token 增量 < 500
- 触发后:强制终止工具调用,注入"请直接给出最终答案"消息,调用 `final_answer_without_tools()`
### 2.2 TodoWrite Nag 提醒
连续 3 步未调用 `todo_write` → 注入提醒消息,防止模型陷入无计划循环。
### 2.3 后台任务通知注入 (`background.rs`)
慢速操作(download_paper, parse_paper)通过 `bg_task_run` 异步执行。
每轮 LLM 调用前,`BgNotificationQueue::drain()` 收集已完成结果并注入:
```
[后台任务完成] ✅ download_paper: 2024A&A... (task_abc123): 下载成功...
```
### 2.4 死循环检测 (`DuplicateDetector`)
- 同一 `(tool_name, arguments)` 连续调用 ≥ 3 次 → 注入错误 tool_result 跳过
- 同时在 metrics 中记录 `duplicate_detections`
---
## 3. 上下文压缩系统 (`compact.rs`)
### 3.1 五层压缩策略
`compress_with_fallback()` 按顺序执行,每层后检查是否需要继续:
```mermaid
graph LR
L0["Layer 0: snip_compact<br/>零 API 调用<br/>消息超 MAX_MESSAGES 时截断"]
L1["Layer 1: micro_compact<br/>零 API 调用<br/>替换早期工具结果为占位符"]
L2["Layer 2: auto_compact<br/>LLM 摘要<br/>历史压缩为 &lt;500 字中文"]
L3["Layer 3: aggressive_micro<br/>零 API 调用<br/>激进占位符 keep_recent=2"]
L4["Layer 4: identity_inject<br/>零 API 调用<br/>注入身份确认块"]
L0 --> L1 --> L2 --> L3 --> L4
```
| 层 | 触发条件 | 算法 | API 调用 | 关键参数 |
|:---|:---|:---|:---|:---|
| **snip** (L0) | 消息数 > `MAX_MESSAGES` (默认 50) | `find_safe_cut_point` 切中间段 → 插入占位消息 | 否 | HEAD_KEEP=3, tail_keep=47 |
| **micro** (L1) | L0 后仍超 `context_char_limit × 1.5` | 工具结果 → `[Previous: used {tool_name}]` | 否 | keep_recent=8 |
| **auto** (L2) | L1 后仍超限 | LLM 生成中文摘要替换历史 | 是 | 摘要 ≤500 字 |
| **aggressive_micro** (L3) | L2 后仍超限 | 同 L1 但仅保留最近 2 条工具结果 | 否 | keep_recent=2 |
| **identity** (L4) | 压缩后消息 ≤4 条 | 注入 `[身份确认]` 块 | 否 | 天体物理学研究助手身份 |
### 3.2 压缩触发机制 (在 ReAct 循环中)
```mermaid
flowchart TD
A["estimated_tokens > token_budget.soft_limit?"]
A --> B["检查熔断器 can_attempt()"]
B -->|"Closed / HalfOpen"| C["执行 snapshot_compress_restore()"]
B -->|"Open"| D["跳过,记录警告"]
C --> E{"messages.len() < before_len ?"}
E -->|"是"| F["record_success()"]
E -->|"否"| G["record_failure()"]
```
Token 估算策略:
- 优先使用 API 返回的精确 `prompt_tokens`
- 辅以增量估算:新增消息数 × (content.len() + 4) 字符估算
- 首次无 API 数据时回退到 `rough_estimate_tokens`
### 3.3 手动压缩 (`compress_context` 工具触发)
- LLM 调用 `compress_context` 工具 → 设置 `pending_manual_compress = true`
- 下一轮循环中执行压缩(若刚已自动压缩则跳过)
- 手动压缩不受熔断器限制,成功后重置熔断器
### 3.4 安全切割点 (`find_safe_cut_point`)
确保不破坏 `assistant(tool_calls) ↔ tool_result` 配对的算法:
1. 计算候选切割点 `messages.len() - desired_keep`
2. 若切割点落在 `tool` 消息上 → 向前追溯到对应 `assistant(tool_calls)` 一并保留
3. 向前扫描孤立 `assistant(tool_calls)`(无 tool_result 配对)→ 切点前移
### 3.5 压缩熔断器 (`runtime/circuit_breaker.rs`)
防止无限自动压缩的三态熔断器,创建于 `AgentRuntime::new()`,跨 turn 共享:
```mermaid
stateDiagram-v2
[*] --> Closed: 初始状态
Closed --> Closed: record_success() → 重置计数
Closed --> Open: 连续失败 3 次
Open --> HalfOpen: 5 分钟后自动恢复
HalfOpen --> Closed: record_success()
HalfOpen --> Open: record_failure()
Closed --> Closed: reset() (手动压缩成功后)
```
| 参数 | 值 | 说明 |
|:---|:---|:---|
| `MAX_CONSECUTIVE_FAILURES` | 3 | 连续失败次数阈值 |
| `AUTO_RECOVERY_TIMEOUT_SECS` | 300 | 熔断后自动尝试恢复的等待时间 |
| 压缩成功判定 | `messages.len() < before_len` | 消息数减少即视为成功 |
### 3.6 递归守卫
`COMPACTING` AtomicBool — 压缩内部触发的 LLM 调用可能再次触发压缩,递归守卫通过 `compare_exchange` 防止嵌套压缩死循环。
---
## 4. 文件缓存与压缩集成 (`runtime/file_cache.rs`)
压缩前后的文件状态保护循环 (`snapshot_compress_restore`)
```
压缩前
├─ FileStateCache.to_snapshot() → 保存所有已读文件快照(按时间戳降序)
└─ FileStateCache.clear() → 清空缓存
压缩后
├─ restore_from_snapshot(max=5) → 恢复最近 5 个文件到 LRU 缓存
└─ build_restore_context(max=5) → 生成 "[压缩后恢复: {path}]" 块注入消息
```
| 参数 | 值 | 说明 |
|:---|:---|:---|
| `MAX_ENTRIES` | 100 | 缓存条目上限 |
| `MAX_CACHE_SIZE_BYTES` | 25 MB | 缓存内容总大小上限 |
| `POST_COMPACT_MAX_FILES_TO_RESTORE` | 5 | 压缩后恢复的文件数 |
| `POST_COMPACT_MAX_CHARS_PER_FILE` | 4,000 | 每文件恢复内容上限 |
| `FILE_UNCHANGED_STUB` | 静态字符串 | 文件未变时返回的占位消息 |
LRU 淘汰策略:容量超限时自动驱逐最久未使用的条目。
---
## 5. 错误恢复系统 (`runtime/error_recovery.rs`)
### 5.1 错误分类 (`classify_error`)
| ErrorKind | 检测关键词 |
|:---|:---|
| `RateLimited` | 429, rate limit, rate_limit, too many requests |
| `Overloaded` | 529, overloaded, overload, service overloaded |
| `PromptTooLong` | prompt_too_long, context length, 413, context_window_exceeded, input length |
| `TokenExhausted` | max_tokens, token limit, token_exhausted, maximum context length |
| `Timeout` | timeout, timed out, deadline exceeded, 408, 504 |
| `ModelError` | 默认归类,携带原始错误字符串 |
### 5.2 恢复阶梯
LLM 流式调用失败后的 5 级恢复:
| 步骤 | RecoveryStep | 操作 | 适用错误 |
|:---|:---|:---|:---|
| 1 | `AggressiveCompact` | snip + micro(keep_recent=2) | PromptTooLong, TokenExhausted |
| 2 | `ReactiveCompact` | LLM 摘要压缩 | 同上 |
| 3 | `EscalateTokens` | 提升 hard_limit → 64,000 | 同上 |
| 4 | `MultiTurn` | 注入分步恢复消息 | 同上 |
| 5 | `Surface` | 放弃恢复,暴露错误 | 不可恢复错误(ModelError 直接到此) |
每步有 `has_attempted` 守卫,防止无限循环。
### 5.3 429/529 退避重试(独立快速路径)
RateLimited/Overloaded 不走恢复阶梯,独立执行指数退避重试(最多 10 次):
- 延迟公式:`min(500 × 2^attempt, 32,000) + deterministic jitter`
- 支持 `retry_after` header 解析
- 529 连续 3 次过载 → 尝试切换到 `FALLBACK_MODEL` 环境变量指定的备用模型
- 重试期间检查用户取消信号
---
## 6. 子代理上下文隔离 (`subagent.rs`)
父代理通过 `delegate_research` 委托子任务给子代理,子代理拥有独立的上下文环境:
| 组件 | 隔离方式 |
|:---|:---|
| 消息上下文 | 全新 `[system_prompt, user_prompt]`,不含父代理中间工具调用 |
| 工具访问 | 共享 ToolRegistry(可配置受限 registry,如仅只读工具) |
| Hook 管道 | 完整的 PreToolUse/PostToolUse + PermissionChecker |
| 进度通知 | 通过 `progress_tx` 向父代理发送 Thought/ToolCall/ToolResult SSE 事件 |
| 最终返回 | 仅返回 `[子代理活动记录] + [子代理结论]` 文本摘要 |
| 消息持久化 | 以 `agent_name=sub_xxx` 写入父会话的 `agent_messages` 表 |
子代理也有独立的上下文压缩(同 `compact::compress_context`)、死循环检测(同 `DuplicateDetector` 逻辑)、强制终止(超 max_steps 时调用 `force_final_answer`)。
---
## 7. 跨会话持久化
### 7.1 数据库表
| 表 | 持久化内容 | 上下文用途 |
|:---|:---|:---|
| `agent_sessions` | session_id, title, model, turn_count, metadata | 会话生命周期管理 |
| `agent_messages` | role, content, thought, tool_calls(JSON), tool_call_id, token_count, metadata, raw_json(完整 ChatMessage), agent_name | 历史恢复 + 审计追溯 |
| `agent_tasks` | task_id, content, status, blocked_by, owner | 跨 turn 任务状态恢复 |
| `agent_audit_log` | session_id, step, tool_name, status, elapsed_ms, output_preview | 完整审计追踪 |
### 7.2 文件系统记忆 (`memory/`)
- Agent 可通过 `save_memory` 工具将重要信息写入 `~/.claude/projects/{project}/memory/`
- 四类记忆:`user`, `feedback`, `project`, `reference`
- 写入时门控:内容质量检查 + Jaccard 相似度去重(70% 阈值)
- 每 turn 结束时 fire-and-forget 自动提取候选记忆
- 下次会话的 system prompt 中自动加载最近 5 条记忆
### 7.3 Trajectory 导出
每 turn 结束时,`TrajectoryExporter::export()` 将完整会话轨迹导出到文件系统,用于调试和审计。
---
## 8. Lifecycle Hooks 与上下文事件
| Hook | 触发时机 | 上下文影响 |
|:---|:---|:---|
| `OnSessionStart` | 会话创建/恢复 | 通知生命周期开始 |
| `PreToolUse` | 工具执行前 | 可 MutateInput 注入上下文、Block 阻止 |
| `PostToolUse` | 工具执行后 | 可 MutateOutput 修改结果、审计日志写入 |
| `OnStepComplete` | 每步结束 | 日志消息数/预算使用率 |
| `OnPreCompact` | 压缩前 | 记录消息数/预估 tokens |
| `OnPostCompact` | 压缩后 | 记录新消息数/压缩方法 |
| `OnSubagentStart` | 子代理启动 | 通知子代理创建 |
| `OnSubagentStop` | 子代理停止 | 记录结果摘要 |
| `OnSessionStop` | 会话终止 | 清理取消状态、记录终止原因 |
---
## 9. SSE 流事件 — 前端上下文同步
Agent 内部上下文通过 SSE 事件实时同步到前端:
```
Session → Thought → (ToolCall ↔ ToolResult)* → TextDelta → Usage → Done
```
`tool_call.id` 贯穿全链路:LLM 生成 ID → 前端精确匹配 tool_call/tool_result 条目 → 审计日志关联。
---
## 10. 环境变量配置
| 变量 | 默认值 | 作用 |
|:---|:---|:---|
| `AGENT_MAX_STEPS` | 8 | ReAct 最大迭代步数 |
| `AGENT_TOOL_TIMEOUT_SECS` | 120 | 工具执行超时(秒) |
| `AGENT_MAX_TOOL_OUTPUT_CHARS` | 4000 | 工具输出截断长度(字符) |
| `AGENT_CONTEXT_CHAR_LIMIT` | 16000 | 上下文压缩触发点(字符估算) |
| `AGENT_TOKEN_SOFT_LIMIT` | 32000 | Token 预算软限制(触发 nudging |
| `AGENT_TOKEN_HARD_LIMIT` | 40000 | Token 预算硬限制(触发强制动作) |
| `AGENT_MAX_MESSAGES` | 50 | snip_compact 触发阈值(消息数) |
| `FALLBACK_MODEL` | — | 529 连续过载 3 次时的备用模型 |
---
## 11. 关键源文件索引
| 文件 | 职责 |
|:---|:---|
| `src/agent/compact.rs` | 五层压缩 + 安全切割 + 递归守卫 + 身份注入 |
| `src/agent/runtime/mod.rs` | ReAct 循环 + 压缩触发 + 后台通知 + todo nag |
| `src/agent/runtime/context.rs` | 初始上下文构建 + 任务状态恢复 |
| `src/agent/runtime/session.rs` | 会话 CRUD + 历史消息加载 |
| `src/agent/runtime/system_prompt.rs` | 模块化 System Prompt 组装 |
| `src/agent/runtime/token_budget.rs` | Token 预算 + 三级 Nudge + Dim Returns |
| `src/agent/runtime/circuit_breaker.rs` | 压缩熔断器 |
| `src/agent/runtime/error_recovery.rs` | 5 级恢复阶梯 + 错误分类 + 退避重试 |
| `src/agent/runtime/file_cache.rs` | 文件状态缓存 + 压缩快照/恢复 |
| `src/agent/runtime/streaming.rs` | 流式响应 + 取消竞速 |
| `src/agent/runtime/executor.rs` | 工具验证 + 并行执行 + Hook 集成 |
| `src/agent/runtime/finalize.rs` | 会话收尾 + 记忆提取 + 轨迹导出 |
| `src/agent/subagent.rs` | 子代理上下文隔离 + 独立 ReAct 循环 |
| `src/agent/background.rs` | 后台任务队列 + 通知注入 |
| `src/agent/hooks.rs` | 9 个生命周期事件 + 内置 3 Hook |
| `src/agent/skills.rs` | 两层技能加载 + 热重载 + 条件激活 |
| `src/agent/tools/memory.rs` | save_memory 工具 + 写入门控 |
+134
View File
@@ -0,0 +1,134 @@
# Agent 环境变量参考
Agent 系统的所有可配置参数,按子系统分类。
## 1. AgentConfig — ReAct 循环核心参数
`AgentConfig::from_env_optional()` 加载,所有变量可选,缺失时使用默认值。
| 变量 | 默认值 | 类型 | 说明 |
|:---|:---|:---|:---|
| `AGENT_MAX_STEPS` | 8 | usize | 单轮对话中最大 ReAct 迭代步数。到达后强制终止,注入"请根据已有信息直接给出最终答案"消息,调用 `final_answer_without_tools()` 不带工具生成最终回答。 |
| `AGENT_TOOL_TIMEOUT_SECS` | 120 | u64 | 单个工具调用的超时时间(秒)。`read_file` / `grep_files` 等快速工具通常 <1s`download_paper` 可能需要 30-60s。超时后返回 `ToolOutput::error`,不终止整个 turn。 |
| `AGENT_MAX_TOOL_OUTPUT_CHARS` | 4000 | usize | 工具输出截断字符数。超过此值的输出被截断并在尾部附加 `[已截断,原始 N 字符]`。大文件的完整内容可通过 `maybe_persist_tool_result()` 写入磁盘,返回文件路径指针。 |
| `AGENT_CONTEXT_CHAR_LIMIT` | 16000 | usize | 上下文字符估算上限。用于 `rough_estimate_tokens` 与 micro_compact 层的触发判断。仅在 LLM 不返回精确 token 计数时作为后备。 |
| `AGENT_TOKEN_SOFT_LIMIT` | 32000 | usize | Token 预算软限制。达到 80% 时注入 💡 "Token 预算提示";达到 100% 时注入 🟡 "Token 预算警告";同时作为自动压缩的触发阈值(`estimated_tokens > soft_limit`)。 |
| `AGENT_TOKEN_HARD_LIMIT` | 40000 | usize | Token 预算硬限制。达到时注入 🔴 "已耗尽" 消息,强制要求模型立即给出最终答案。error recovery escalate 步骤可临时提升此值到 64,000。 |
| `AGENT_MAX_MESSAGES` | 50 | usize | snip_compact (Layer 0) 的触发阈值。消息数超过此值时截断中间段:保留前 3 条 + 后 47 条,中间替换为含工具名称列表的占位消息。 |
**硬编码参数**(非环境变量,修改需改代码):
| 参数 | 值 | 位置 | 说明 |
|:---|:---|:---|:---|
| `duplicate_call_threshold` | 3 | `AgentConfig` | 工具连续以相同参数调用 3 次判定为死循环 |
| `HEAD_KEEP` | 3 | `compact.rs` | snip_compact 保留的头部消息数 |
| `MAX_CONSECUTIVE_FAILURES` | 3 | `circuit_breaker.rs` | 压缩熔断器触发阈值 |
| `AUTO_RECOVERY_TIMEOUT_SECS` | 300 | `circuit_breaker.rs` | 熔断器自动恢复等待时间 |
| `MAX_ENTRIES` | 100 | `file_cache.rs` | 文件状态缓存条目上限 |
| `MAX_CACHE_SIZE_BYTES` | 25 MB | `file_cache.rs` | 文件状态缓存内容总大小上限 |
| `POST_COMPACT_MAX_FILES_TO_RESTORE` | 5 | `file_cache.rs` | 压缩后恢复的文件数 |
| `POST_COMPACT_MAX_CHARS_PER_FILE` | 4,000 | `file_cache.rs` | 每文件恢复内容的字符上限 |
| `nag_after_steps` | 3 | `runtime/mod.rs` | TodoWrite 提醒间隔(步数) |
| `MAX_BACKOFF_RETRIES` | 10 | `runtime/mod.rs` | 429/529 退避重试最大次数 |
---
## 2. 错误恢复
| 变量 | 默认值 | 说明 |
|:---|:---|:---|
| `FALLBACK_MODEL` | — | 529 连续过载 3 次时尝试切换的备用模型名称。当前仅记录日志,因 `LlmClient` 的 model 不可变。需配合支持 model override 的客户端使用。 |
---
## 3. 自动记忆提取 (`memory/extraction.rs`)
`ExtractionConfig::from_env()` 加载。在每次会话结束时,可选地使用受限子代理分析对话内容并自动提取值得保留的记忆。
| 变量 | 默认值 | 说明 |
|:---|:---|:---|
| `EXTRACT_MEMORY_ENABLED` | false | 是否启用自动记忆提取。默认关闭以避免意外的 LLM 费用。提取在 turn 结束时 fire-and-forget 执行,不阻塞会话关闭。如果主代理已通过 `save_memory` 工具写入过记忆,则跳过提取。 |
| `EXTRACT_MEMORY_THROTTLE_TURNS` | 3 | 最小提取间隔(轮次)。避免每轮都消耗 LLM 调用进行提取。 |
| `EXTRACT_MEMORY_MAX_STEPS` | 3 | 提取子代理的最大 ReAct 步数。提取使用受限工具集(read_file, grep_files, glob_files, save_memory),步数较少以控制成本。 |
---
## 4. Agent 关联的系统级环境变量
以下变量在 `Config::from_env()` 中加载,直接影响 Agent 运行时。
### 4.1 核心 API
| 变量 | 默认值 | 说明 |
|:---|:---|:---|
| `LLM_API_KEY` | — | LLM API 密钥(OpenAI 兼容协议) |
| `LLM_API_BASE` | `https://api.openai.com/v1` | LLM API 基础地址 |
| `LLM_MODEL` | `gpt-4o-mini` | Agent 使用的对话模型 |
| `EMBEDDING_API_KEY` | 同 `LLM_API_KEY` | Embedding API 密钥 |
| `EMBEDDING_API_BASE` | 同 `LLM_API_BASE` | Embedding API 基础地址 |
| `EMBEDDING_MODEL` | `text-embedding-3-small` | RAG 使用的向量模型 |
| `EMBEDDING_DIM` | 1536 | 向量维度。与 `vec_paper_chunks` 表的维度校验相关,不匹配时自动重建。 |
| `ADS_API_KEY` | — | NASA ADS API Token`search_papers` 工具使用) |
### 4.2 本地路径与端口
| 变量 | 默认值 | 说明 |
|:---|:---|:---|
| `DATABASE_URL` | `sqlite://library/astro_research.db` | SQLite 数据库路径 |
| `LIBRARY_DIR` | `./library` | 文献下载/解析/缓存的根目录 |
| `SKILLS_DIR` | `./skills` | Agent Skills 目录,SkillRegistry 从此加载 `{name}/SKILL.md` |
| `PORT` | 8000 | Axum 服务监听端口 |
### 4.3 云存储与解析
| 变量 | 默认值 | 说明 |
|:---|:---|:---|
| `QINIU_AK` | — | 七牛云 Access KeyPDF 配图托管) |
| `QINIU_SK` | — | 七牛云 Secret Key |
| `QINIU_BUCKET` | — | 七牛云存储空间名 |
| `QINIU_DOMAIN` | — | 七牛云 CDN 域名 |
| `MINERU_API_URL` | — | MinerU PDF 解析远程 API 地址 |
| `MINERU_API_KEY` | — | MinerU API Token |
### 4.4 日志
| 变量 | 默认值 | 说明 |
|:---|:---|:---|
| `LOG_LEVEL` | `info,astroresearch=debug` | 日志级别:trace / debug / info / warn / error,可指定模块级别 |
| `LOG_FORMAT` | `pretty` | 日志格式:`pretty`(彩色控制台)或 `json`(结构化) |
| `LOG_OUTPUTS` | `stdout,file` | 日志输出目标,逗号分隔:`stdout`(控制台)、`file`(滚动文件) |
| `LOG_DIR` | `./logs` | 日志文件目录(仅 `LOG_OUTPUTS``file` 时生效),按天滚动 |
### 4.5 其他
| 变量 | 默认值 | 说明 |
|:---|:---|:---|
| `OBSCURA_ROTATE_PROFILE` | `true` | Obscura 浏览器 Profile 轮换,用于绕过 Cloudflare/WAF |
| `OBSCURA_ALLOW_PRIVATE_NETWORK` | — | Obscura 内网访问许可(程序自动设置,无需手动配置) |
---
## 5. 与模型 Context Window 的配置建议
推荐的配置比例(为多轮对话预留空间):
| 模型 Context | 建议 soft_limit | 建议 hard_limit | 说明 |
|:---|:---|:---|:---|
| 128K (GPT-4o) | 32,000 (25%) | 40,000 (31%) | 默认值适用 |
| 128K (Claude) | 32,000 (25%) | 64,000 (50%) | Claude 200K 则向上调整 |
| 200K (Claude) | 64,000 (32%) | 100,000 (50%) | 大 context 可更宽松 |
| 32K (旧模型) | 8,000 (25%) | 16,000 (50%) | 小 context 需更激进压缩 |
---
## 6. 关键源文件索引
| 文件 | 加载的变量 |
|:---|:---|
| `src/lib.rs` (Config) | `LLM_API_KEY`, `LLM_API_BASE`, `LLM_MODEL`, `EMBEDDING_*`, `ADS_API_KEY`, `DATABASE_URL`, `LIBRARY_DIR`, `SKILLS_DIR`, `PORT`, `QINIU_*`, `MINERU_*` |
| `src/main.rs` | `EMBEDDING_DIM` |
| `src/services/logging.rs` | `LOG_LEVEL`, `LOG_FORMAT`, `LOG_OUTPUTS`, `LOG_DIR` |
| `src/agent/runtime/mod.rs` (AgentConfig) | `AGENT_MAX_STEPS`, `AGENT_TOOL_TIMEOUT_SECS`, `AGENT_MAX_TOOL_OUTPUT_CHARS`, `AGENT_CONTEXT_CHAR_LIMIT`, `AGENT_TOKEN_SOFT_LIMIT`, `AGENT_TOKEN_HARD_LIMIT`, `AGENT_MAX_MESSAGES` |
| `src/agent/runtime/mod.rs` (call_llm_with_recovery) | `FALLBACK_MODEL` |
| `src/agent/memory/extraction.rs` (ExtractionConfig) | `EXTRACT_MEMORY_ENABLED`, `EXTRACT_MEMORY_THROTTLE_TURNS`, `EXTRACT_MEMORY_MAX_STEPS` |
| `src/services/download.rs` | `OBSCURA_ALLOW_PRIVATE_NETWORK`(程序自动设置) |
+274
View File
@@ -0,0 +1,274 @@
# Hooks 生命周期系统 (`hooks.rs`)
参考 Claude Code hooks 协议,提供 **9 种生命周期事件回调**,基于 **观察者模式 + 责任链模式** 实现。
核心思路:允许在 Agent 运行的各个关键节点插入自定义逻辑(取消检查、指标采集、审计日志等),而不污染核心 ReAct 循环代码。
## 架构总览
```mermaid
graph TB
subgraph Registry["HookRegistry"]
direction TB
Methods["聚合方法(遍历所有 hook 依次调用)<br/>run_on_session_start() | run_pre_tool_use()<br/>run_post_tool_use() | run_on_step_complete()<br/>run_on_session_stop() | run_on_subagent_start()<br/>run_on_subagent_stop() | run_on_pre_compact()<br/>run_on_post_compact()"]
end
Registry --> CH["CancellationHook<br/>Arc&lt;HashSet&lt;String&gt;&gt;"]
Registry --> MH["MetricsHook<br/>Arc&lt;Mutex&lt;MetricsData&gt;&gt; (共享)"]
Registry --> AH["AuditLogHook<br/>SqlitePool (fire-and-forget 写入)"]
```
## 生命周期事件(9 个)
| # | 事件 | 触发时机 | 返回值 | 调用位置 |
|---|------|---------|--------|---------|
| 1 | `OnSessionStart` | 会话创建/恢复 | 无 (fire-and-forget) | `AgentRuntime::run_turn()` |
| 2 | `PreToolUse` | 每个工具执行前 | `PreToolUseAction` (可拦截/修改参数) | `executor::execute_parallel()` |
| 3 | `PostToolUse` | 每个工具执行后 | `PostToolUseAction` (可修改输出) | `executor::execute_parallel()` |
| 4 | `OnStepComplete` | 每步 ReAct 结束 | 无 | `AgentRuntime::run_react_loop()` |
| 5 | `OnSessionStop` | 会话终止(任何原因) | 无 | `finalize::finalize_turn()` |
| 6 | `OnSubagentStart` | 子代理启动 | 无 | `SubAgentRunner::run()` |
| 7 | `OnSubagentStop` | 子代理停止 | 无 | `SubAgentRunner::run()` |
| 8 | `OnPreCompact` | 上下文压缩前 | 无 | `compact::compress_context_with_hooks()` |
| 9 | `OnPostCompact` | 上下文压缩后 | 无 | `compact::compress_context_with_hooks()` |
## 核心类型
### AgentHook trait
```rust
#[async_trait]
pub trait AgentHook: Send + Sync {
fn name(&self) -> &str;
async fn on_session_start(&self, _ctx: &SessionStartContext) {}
async fn pre_tool_use(&self, _ctx: &PreToolUseContext) -> PreToolUseAction { ... }
async fn post_tool_use(&self, _ctx: &PostToolUseContext) -> PostToolUseAction { ... }
async fn on_step_complete(&self, _ctx: &StepCompleteContext) {}
async fn on_session_stop(&self, _ctx: &SessionStopContext<'_>) {}
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) {}
}
```
所有 9 个方法都有默认空实现——hook 实现者只需覆写关心的 hook 点,遵循**接口隔离原则**。
### PreToolUseAction(工具执行前返回值)
```rust
pub enum PreToolUseAction {
Continue, // 允许执行(默认)
Block { reason: String }, // 阻止执行
MutateInput { // 修改参数 + 注入上下文
updated_args: serde_json::Value,
additional_context: Option<String>,
},
PermissionRequired { permission: String, tool_name: String }, // 需要权限决策 (Phase 2)
}
```
向后兼容:`pub type HookAction = PreToolUseAction;`
### PostToolUseAction(工具执行后返回值)
```rust
pub enum PostToolUseAction {
Continue, // 保持输出不变
MutateOutput { updated_content: String }, // 修改输出内容
}
```
### 聚合结果类型
```rust
pub struct PreToolUseResult {
pub action: PreToolUseAction, // 最终动作(第一个 Block 获胜)
pub additional_context: Option<String>, // 累积的附加上下文(所有 MutateInput 拼接)
pub final_args: serde_json::Value, // 最终参数(最后一个 MutateInput 获胜)
}
pub struct PostToolUseResult {
pub final_content: String, // 最终输出(最后一个 MutateOutput 获胜)
}
```
## HookRegistry 聚合逻辑
**PreToolUse 聚合(责任链 + 短路)**
```mermaid
flowchart TD
Start["run_pre_tool_use(ctx)"] --> Loop["遍历 hooks,依次调用 pre_tool_use()"]
Loop --> Check{"结果类型?"}
Check -->|"Block"| Short["立即短路返回<br/>不询问后续 hooks"]
Check -->|"MutateInput"| Mut["更新 final_args<br/>累积 additional_context (\\n 拼接)"]
Check -->|"PermissionRequired"| Log["记录日志但不阻止执行<br/>(Phase 2 预留)"]
Check -->|"Continue"| Next["继续下一个 hook"]
Mut --> Next
Log --> Next
Next --> Loop
Short --> Return["返回 PreToolUseResult"]
Next -->|"遍历完毕"| Return
```
关键设计:
- **第一个 Block 获胜** — 短路保护
- **最后一个 MutateInput 获胜** — 后覆盖前
- **additional_context 累积** — 多个 hook 的上下文用 `\n` 连接
**PostToolUse 聚合(全部执行,无短路)**
```mermaid
flowchart TD
Start2["run_post_tool_use(ctx)"] --> Loop2["遍历所有 hooks,依次调用 post_tool_use()"]
Loop2 --> MutOut{"MutateOutput ?"}
MutOut -->|"是"| Update["更新 final_content<br/>(最后的 MutateOutput 获胜)"]
MutOut -->|"Continue"| Next2["继续下一个 hook"]
Update --> Next2
Next2 --> Loop2
Next2 -->|"遍历完毕"| Return2["返回 PostToolUseResult { final_content }"]
```
**其余 7 个事件** 均为 fire-and-forget:遍历所有 hooks 调用对应方法,不收集返回值。
## 内置 Hooks3 个)
| Hook | 覆写的事件 | 职责 | 关键依赖 |
|------|-----------|------|---------|
| `CancellationHook` | `pre_tool_use`, `on_session_stop` | 每次工具执行前检查用户是否中止会话;会话停止时清理取消令牌 | `Arc<Mutex<HashSet<String>>>` (与 AppState 共享) |
| `MetricsHook` | `on_session_start`, `post_tool_use`, `on_step_complete`, `on_session_stop` | 采集运行指标:工具调用次数、步数、错误数、token 消耗;每 3 步输出摘要日志 | `Arc<Mutex<MetricsData>>` (**共享引用**API 通过 `AgentRuntime::get_metrics()` 实时查询) |
| `AuditLogHook` | `post_tool_use`, `on_session_stop` | 所有工具调用写入 `agent_audit_log` 表(工具名、状态、耗时、输出预览);会话终止写入 SESSION_STOP 标记 | `SqlitePool` (**fire-and-forget** 写入,不阻塞主循环) |
> **注意**:代码中**不存在** PermissionHook。权限检查由独立的 `PermissionChecker` (`src/agent/runtime/permission.rs`) 负责,该组件在工具执行前与 hooks 并行调用,不属于 hooks 体系。`PreToolUseAction::PermissionRequired` 变体预留于 Phase 2 完善。
## 数据流
```mermaid
sequenceDiagram
participant API as API Handler
participant RT as AgentRuntime
participant HR as HookRegistry
participant CH as CancellationHook
participant MH as MetricsHook
participant AH as AuditLogHook
participant EX as Executor
Note over API,EX: Phase 1 — 会话启动
API->>RT: run_turn(question)
RT->>HR: HookRegistry::with_builtins(db, cancelled_runs, metrics_data)
RT->>HR: run_on_session_start(ctx)
HR->>MH: 记录 session_id
Note over API,EX: Phase 2 — ReAct 循环
loop 每步 (最多 max_steps)
RT->>RT: LLM 流式调用
RT->>EX: execute_parallel(tool_calls, hook_registry)
par 每个工具调用
EX->>HR: run_pre_tool_use(pre_ctx)
HR->>CH: 检查取消状态
alt 已取消
CH-->>HR: Block { reason }
HR-->>EX: PreToolUseResult { action: Block }
EX-->>EX: 跳过该工具
else 未取消
CH-->>HR: Continue
HR-->>EX: PreToolUseResult { final_args, additional_context }
EX->>EX: 执行工具
EX->>HR: run_post_tool_use(post_ctx)
HR->>MH: 更新工具调用计数/错误数
HR->>AH: fire-and-forget INSERT agent_audit_log
HR-->>EX: PostToolUseResult { final_content }
end
end
EX-->>RT: ToolExecutionResult { tool_messages }
RT->>HR: run_on_step_complete(ctx)
HR->>MH: 每 3 步输出摘要日志
opt 上下文超限
RT->>RT: snapshot_compress_restore()
Note over RT: PreCompact / PostCompact hooks 触发
end
end
Note over API,EX: Phase 3 — 会话收尾
RT->>RT: finalize_turn()
RT->>HR: run_on_session_stop(ctx)
HR->>CH: 清理 cancelled_runs
HR->>MH: 输出会话结束摘要
HR->>AH: fire-and-forget SESSION_STOP 记录
RT-->>API: Done (SSE)
```
## HookRegistry 构建
`AgentRuntime::run_turn()` 在每次 turn 开始时构建 `HookRegistry`
```rust
let hook_registry = HookRegistry::with_builtins(
db.clone(), // → AuditLogHook
self.app_state.cancelled_runs.clone(), // → CancellationHook
Some(self.metrics_data.clone()), // → MetricsHook (共享引用)
);
```
`MetricsHook` 使用 `from_arc()` 复用 `AgentRuntime` 自身的 `metrics_data: Arc<Mutex<MetricsData>>`,确保 hook 内部采集的指标与 `AgentRuntime::get_metrics()` API 查询返回的是同一份数据。
## 子代理中的 Hooks
`SubAgentRunner` 拥有独立的 hook 管道,共享同一个 `HookRegistry` 实例:
- `on_subagent_start` / `on_subagent_stop` 在子代理生命周期的首尾触发
- 子代理的工具执行也经过 `run_pre_tool_use` / `run_post_tool_use`(通过 `subagent.rs:447-518`
- **已知不足**:子代理内部的上下文压缩 (`subagent.rs:270`) 直接调用 `compress_context` 而非 `compress_context_with_hooks`,导致 PreCompact/PostCompact 事件**不会**在子代理压缩时触发
## 扩展方式
添加自定义 hook 只需两步:
```rust
// 1. 实现 AgentHook trait
struct MyCustomHook;
#[async_trait]
impl AgentHook for MyCustomHook {
fn name(&self) -> &str { "MyCustomHook" }
async fn pre_tool_use(&self, ctx: &PreToolUseContext) -> PreToolUseAction {
// 自定义逻辑
PreToolUseAction::Continue
}
}
// 2. 注册到 HookRegistry
registry.add(Box::new(MyCustomHook));
```
## 测试覆盖
`hooks.rs` 包含 8 个单元测试(`#[cfg(test)] mod tests`),覆盖:
| 测试 | 验证点 |
|------|-------|
| `test_hook_registry_runs_all_hooks` | 注册表遍历调用所有 hook |
| `test_blocking_hook_stops_chain` | Block 短路机制 |
| `test_mutate_input_accumulates_context` | 参数修改 + 上下文累积 |
| `test_post_tool_use_mutate_output` | 输出修改 |
| `test_cancellation_hook_blocks_when_cancelled` | CancellationHook 阻止逻辑 |
| `test_cancellation_hook_allows_when_not_cancelled` | CancellationHook 放行逻辑 |
| `test_metrics_hook_accumulates_counts` | MetricsHook 累加正确性 |
| `test_session_start_hook_called` | OnSessionStart 调用 |
| `test_new_lifecycle_events_called` | OnSubagentStart/Stop, PreCompact/PostCompact 调用 |
## 已知改进项
| 问题 | 说明 |
|------|------|
| `PermissionRequired` 未实现 | 代码中存在此变体但被当作 `Continue` 处理,注释标明 "Permission 系统在 Phase 2 中完善" |
| 取消检查重复 | `CancellationHook::pre_tool_use``AgentRuntime::run_react_loop` 中的显式检查存在功能重叠 |
| 子代理压缩未走 hooks | `subagent.rs:270` 直接调用 `compress_context` 而非 `compress_context_with_hooks` |
| 缺少 `on_error` 事件 | `AgentHook` trait 没有错误生命周期事件,错误场景无法通过 hook 拦截 |
---
+432
View File
@@ -0,0 +1,432 @@
# 记忆系统 (`memory/`)
参考 Claude Code `memdir/` 设计,提供**完全基于文件系统**(非数据库)的项目级持久化记忆管理。核心代码位于 `src/agent/memory/`8 个文件)和 `src/agent/tools/memory.rs``save_memory` 工具)。
## 整体架构
```mermaid
graph TD
subgraph Storage["文件存储 ({library_dir}/memory/)"]
MEMORY_MD["MEMORY.md<br/>索引文件 (≤200行, ≤25KB)"]
Files["{slug}.md × N<br/>每个记忆一个 Markdown 文件"]
Archive["{slug}_v1.md<br/>旧版本归档(永不删除)"]
end
subgraph Manager["MemoryManager (mod.rs)"]
Load["reload()<br/>扫描目录 → 解析 frontmatter → 按 mtime 排序"]
Save["save_memory()<br/>写文件 → 归档旧版 → 更新索引 → reload"]
Reminder["build_system_reminder(N)<br/>构建注入 system prompt 的 XML 块"]
SelectRel["select_relevant_memories()<br/>LLM 语义选择 + 指数衰减排序"]
end
subgraph Tool["save_memory 工具 (tools/memory.rs)"]
Validate["参数校验<br/>slug 格式 + memory_type 枚举"]
QualityGate["写入时门控<br/>质量检查 + Jaccard 去重"]
Execute["执行写入<br/>MemoryManager.save_memory()"]
end
subgraph Pipeline["记忆生命周期"]
Extract["extraction.rs<br/>会话结束时自动提取(默认关闭)"]
Dedup["dedup.rs<br/>Jaccard 相似度去重 (≥70%) + 内容质量门控"]
Decay["decay.rs<br/>指数时间衰减 + Hebbian 激活层级"]
Age["age.rs<br/>时效标签 + freshness 警告"]
Guardrails["guardrails.rs<br/>WHAT_NOT_TO_SAVE + VERIFY_BEFORE_RECOMMENDING"]
end
subgraph Types["types.rs — 数据模型"]
MemoryEntry["MemoryEntry<br/>{ slug, name, description, memory_type, mtime, content, path, status }"]
MemoryType["MemoryType: User | Feedback | Project | Reference"]
MemoryStatus["MemoryStatus: Active | Historical { superseded_by }"]
end
Tool --> Manager
Manager --> Storage
Reminder -->|"try_lock() 非阻塞"| Runtime["AgentRuntime System Prompt"]
Extract --> Dedup --> Tool
Decay --> SelectRel
Guardrails --> Reminder
Guardrails --> Tool
```
## 存储结构
记忆**不使用 SQLite**,全部存储在文件系统 `{library_dir}/memory/` 目录下:
```
{library_dir}/memory/
├── MEMORY.md # 索引文件(最多 200 行 / 25KB)
├── user-prefs.md # 活跃记忆(YAML frontmatter + Markdown 内容)
├── project-goals.md
├── user-prefs_v1.md # 旧版本归档(原文件重命名,永不删除)
└── ...
```
**每个记忆文件**格式:
```markdown
---
name: user-role
description: 用户的科研角色和偏好
type: user
status: active
---
用户是天体物理学博士后,主要研究恒星演化与双星系统。
**Why:** 在对话开始时用户明确说明了研究领域。
**How to apply:** 默认使用天体物理学术语,优先推荐恒星演化相关文献。
```
**版本管理机制**:当更新已有 slug 时,旧文件重命名为 `{slug}_v1.md`,其 frontmatter 中 `status` 改为 `historical` 并记录 `superseded_by` 指向新版本。旧版本**永不删除**——保留完整的事实演进历史。`MemoryStatus` 枚举控制此生命周期:
```
Active ──(更新)──→ Historical { superseded_by: Some("new-slug") }
```
## 核心数据结构 (`types.rs`)
| 结构体/枚举 | 字段/变体 | 说明 |
|:---|:---|:---|
| `MemoryType` | `User` | 用户角色、偏好、知识背景 |
| | `Feedback` | 用户提供的修正或确认的方法论(含 Why 和 How to apply |
| | `Project` | 项目上下文、目标、约束(不可从代码推导的部分) |
| | `Reference` | 外部资源指针(URL、仪表盘、工单系统) |
| `MemoryStatus` | `Active` | 当前有效(默认值) |
| | `Historical { superseded_by }` | 已被更新版本取代,`superseded_by` 指向新 slug |
| `MemoryEntry` | `slug: String` | 文件名标识(不含 `.md` 扩展名) |
| | `name: String` | 记忆标题 |
| | `description: String` | 简短描述,用于相关性匹配 |
| | `memory_type: MemoryType` | 记忆类型 |
| | `mtime: u64` | 文件修改时间(Unix 时间戳),用于排序 |
| | `content: String` | 不含 frontmatter 的纯正文(Markdown |
| | `path: PathBuf` | 文件完整路径 |
| | `status: MemoryStatus` | 生命周期状态(默认 Active) |
`parse_frontmatter(raw)` 函数解析 YAML-like frontmatter,支持 `name``description``type`/`memory_type``status``superseded_by` 字段。缺失 `status` 时默认为 `Active`
## MemoryManager 核心方法 (`mod.rs`)
`MemoryManager` 在应用启动时创建(`main.rs`),存入 `AppState.memory_manager: Arc<tokio::sync::Mutex<MemoryManager>>`
| 方法 | 调用时机 | 行为 |
|:---|:---|:---|
| `new(library_dir)` | 应用启动 | `create_dir_all(memory/)``reload()` 扫描所有 `.md` 文件 → 解析 frontmatter → 按 mtime 降序排序 |
| `reload()` | 每次 `save_memory` 后 | 全量重新扫描目录,清空并重建 `entries: Vec<MemoryEntry>` |
| `entries()` | 查询 | 返回 `&[MemoryEntry]` 不可变引用 |
| `save_memory(slug, name, desc, type, content)` | `save_memory` 工具调用 | ① 检查是否已有活跃版本 → 归档为 `{slug}_v1.md` ② 写入新 YAML frontmatter + content ③ `update_index()` 更新 MEMORY.md ④ `reload()` 刷新内存状态 |
| `build_system_reminder(max)` | System prompt 组装 | 取最近 `max` 条记忆,构建 `<project-memory-context>` XML 块,包含类型标签、时效警告、验证提醒 |
| `build_system_reminder_from(selected)` | LLM 选择后 | 同上,但从指定的条目子集构建 |
| `select_relevant_memories(llm, ctx, max)` | 按需 | LLM 语义匹配 → 指数时间衰减重排序 → 回退到 recency 排序 |
| `mark_main_agent_wrote()` | `save_memory` 工具执行后 | 设置标志抑制本会话的自动提取 |
**关键设计:非阻塞加载**。System prompt 组装时使用 `try_lock()`(非 `lock()`)——如果 mutex 已被持有则直接跳过,不阻塞会话启动。
## `update_index()` 索引维护
`MEMORY.md` 索引文件受双重容量保护:
| 参数 | 值 | 触发行为 |
|:---|:---|:---|
| `MAX_ENTRYPOINT_LINES` | 200 | 行数满时移除最旧条目行 |
| `MAX_ENTRYPOINT_BYTES` | 25,000 (~25KB) | 字节数超限时在约 25KB 处截断,附加截断提示 |
索引格式(每行一条):
```markdown
- [记忆标题](slug.md) — 简短描述 (type: user)
```
写入时执行**原地更新**:若 `MEMORY.md` 中已存在同 slug 的行(通过 `](slug.md)` 标记匹配),则替换该行而非追加。
## `save_memory` 工具 (`tools/memory.rs`)
这是**唯一**暴露给 LLM 的记忆写入工具。
**参数 Schema**
| 参数 | 类型 | 必填 | 校验规则 |
|:---|:---|:---|:---|
| `slug` | string | 是 | kebab-case,禁止空格、`/``\` |
| `name` | string | 是 | 记忆标题 |
| `description` | string | 是 | 用于决定何时加载此记忆 |
| `memory_type` | enum | 是 | `user` / `feedback` / `project` / `reference` |
| `content` | string | 是 | Markdown 格式,最少有效信息量推荐 >10 字符 |
**工具配置**
| 配置项 | 值 | 原因 |
|:---|:---|:---|
| `is_concurrency_safe` | `false` | 写入操作不可与其他工具并发执行 |
| `interrupt_behavior` | `Block` | 写入磁盘不可中途中断 |
**完整执行流程**
```mermaid
sequenceDiagram
participant LLM as LLM
participant Tool as SaveMemoryTool
participant QG as 质量门控 (dedup.rs)
participant Mgr as MemoryManager
participant FS as 文件系统
LLM->>Tool: save_memory(slug, name, desc, type, content)
Tool->>Tool: ① 参数校验 (slug 非空 + 格式合法 + type 枚举有效)
Tool->>Mgr: ② lock().await 获取互斥锁
Tool->>QG: ③ check_content_quality(content)
QG-->>Tool: QualityCheck (Accept / TooShort / TransientState / VagueLanguage / CodePattern)
Tool->>QG: ④ find_duplicate_by_content(content, entries, 0.70)
QG-->>Tool: 重复的 slug 或 None
Tool->>QG: ⑤ slug_exists(memory_dir, slug) → 判断是新建还是更新
Tool->>Mgr: ⑥ save_memory(slug, name, desc, type, content)
Mgr->>FS: 若已有活跃版本 → 归档 {slug}_v1.md
Mgr->>FS: 写入新 {slug}.md (frontmatter + content)
Mgr->>FS: 更新 MEMORY.md 索引
Mgr->>Mgr: reload() 刷新内存状态
Tool->>Mgr: ⑦ mark_main_agent_wrote() 抑制自动提取
Tool->>QG: ⑧ build_manifest_preview(entries) 构建清单
Tool-->>LLM: success(message + manifest + quality warning + duplicate hint)
```
**返回消息结构**
1. 操作状态(✅ 已保存 / 🔄 已更新)
2. 质量警告(如有:⚠️ 过短 / 瞬时状态 / 模糊语言 / 代码模式)
3. 重复提示(如有:💡 检测到与 `{slug}` 内容 ≥70% 重叠)
4. 当前记忆清单(所有现有条目供 LLM 参考)
## 记忆注入机制
在 Agent 会话启动时,system prompt 按顺序组装各 section。记忆注入发生在 **Section 4**(位于 Tools、Skills 之后,Core Principles 之前):
```
① 静态身份声明
② 工具定义
③ Skills 提醒
④ <project-memory-context> ← 记忆(通过 try_lock 非阻塞加载,默认 5 条)
⑤ 核心原则
```
注入格式示例:
```xml
<project-memory-context>
[PROJECT MEMORY]
[偏好] 用户角色: 天体物理学博士后,研究恒星演化与双星系统
用户是天体物理学博士后,主要研究恒星演化与双星系统。
**Why:** 在对话开始...
<system-reminder>此记忆已过 3 天。记忆是某个时间点的快照,不是实时状态...</system-reminder>
[反馈] 测试策略: 优先使用单元测试而非集成测试
用户偏好单元测试,认为集成测试太慢...
使用 save_memory 工具保存重要信息。记忆内容可能过时,请在使用前验证。
## 从记忆中推荐前先核实
"记忆说 X 存在" 不等于 "X 现在存在"。
</project-memory-context>
```
记忆列表中每个条目的格式:
```
[类型标签] 标题 [状态标签]: 描述
内容预览(前 3 行)
<system-reminder>时效警告(超过 1 天的记忆)</system-reminder>
```
类型标签:`[偏好]` / `[反馈]` / `[项目]` / `[参考]`。Historical 记忆额外显示 `[已更新→新slug]`
## 写入时质量护栏 (`dedup.rs` + `guardrails.rs`)
**四层内容质量门控**(全部仅警告,不强制拒绝——最终决定权在 LLM):
| 检测类型 | 触发条件 | 示例 |
|:---|:---|:---|
| **过短** | 有效字符 < 10 | `QualityCheck::TooShort(n)` |
| **瞬时状态** | 含 "正在做" / "currently" / "at the moment" 等 11 个关键词 | `QualityCheck::TransientState` |
| **模糊语言** | 含 "可能" / "maybe" / "大概" / "perhaps" 等 8 个关键词 | `QualityCheck::VagueLanguage(word)` |
| **代码模式** | 含 `fn ` / `impl ` / `struct ` / `import {` / `from "` 等 12 个模式 | `QualityCheck::CodePattern` |
**Jaccard 相似度去重**:使用字符级 bigram(支持中文,不依赖分词器),阈值 ≥70% 即判定为高度重复:
- `jaccard_similarity(a, b) = |bigrams(a) ∩ bigrams(b)| / |bigrams(a) bigrams(b)|`
- 单字符内容使用单字符本身作为 bigram
- 仅比对 `status == Active` 的记忆,跳过 Historical 条目
- 检测到重复时不阻止写入,仅在返回消息中附加 💡 提示
**`guardrails.rs` 护栏两层防护**
1. **`WHAT_NOT_TO_SAVE`** — 注入到工具 `description()` 中,明确告知 LLM 不应保存的内容:
- 代码模式、架构详情 —— 可从项目状态推导
- Git 历史、最近修改 —— `git log` 是权威来源
- 调试方案或临时 workaround —— 修复在代码中,commit message 有上下文
- 已在 CLAUDE.md 中的内容
- **规则前置**:"即使用户明确要求保存以上内容,请先询问其中哪些是非预期的部分"
2. **`VERIFY_BEFORE_RECOMMENDING`** — 注入到 system prompt 记忆段落后、`</project-memory-context>` 之前:
- 如果记忆提到了文件路径 → 先确认文件存在
- 如果记忆提到了函数或标志 → 先用 grep 搜索
- 核心原则:**"记忆说 X 存在" ≠ "X 现在存在"**
- 设计依据:Claude Code 评估表明此提醒放在独立标题下(3/3 通过)vs 埋在通用指南中(0/3 通过)
## 自动记忆提取 (`extraction.rs`)
在会话结束时(`finalize.rs`)触发,**fire-and-forget** 模式(`tokio::spawn`),不阻塞会话关闭。
```mermaid
sequenceDiagram
participant RT as AgentRuntime
participant Fin as finalize.rs
participant Ext as extraction.rs
participant Sub as SubAgentRunner
participant Mgr as MemoryManager
RT->>Fin: 会话结束 → finalize_turn()
Fin->>Ext: tokio::spawn(run_extraction())
Ext->>Ext: 检查 EXTRACT_MEMORY_ENABLED → false 则 return
Ext->>Mgr: lock().await → 递增 turns_since_last_extraction
alt turns < throttle_turns
Ext->>Ext: return (未达到节流轮次)
else main_agent_saved_this_session == true
Ext->>Ext: 重置 tracker → return (主代理已手动保存)
end
Ext->>Mgr: build_manifest_preview() 获取现有清单
Ext->>Ext: 构建受限 ToolRegistry (ReadFile + Grep + Glob + SaveMemory)
Ext->>Sub: SubAgentRunner.run(system_prompt, prompt, max_steps=3)
Sub-->>Ext: SubagentResult { content, is_error }
Ext->>Ext: 记录日志 (成功摘要 / 错误截断)
```
**环境变量配置**
| 变量 | 默认值 | 说明 |
|:---|:---|:---|
| `EXTRACT_MEMORY_ENABLED` | `false` | 是否启用自动提取(默认关闭,避免意外 LLM 费用) |
| `EXTRACT_MEMORY_THROTTLE_TURNS` | `3` | 最小提取间隔(轮次),避免每轮都触发 |
| `EXTRACT_MEMORY_MAX_STEPS` | `3` | 子代理最大 ReAct 步数,限制提取成本 |
**跳过条件**(任一满足即跳过):
- `EXTRACT_MEMORY_ENABLED != true`
- `turns_since_last_extraction < throttle_turns`(节流未到)
- `main_agent_saved_this_session == true`(主代理已通过 `save_memory` 工具写入)
- 子代理工具集为受限集(仅 4 个只读工具 + `save_memory`),不能执行 bash、不能搜索论文
## 时效性与衰减系统
**指数时间衰减** (`decay.rs`)
```
score = e^(-λ × days_old)
λ = ln(2) / half_life_days
```
| 参数 | 默认值 | 说明 |
|:---|:---|:---|
| `DEFAULT_HALF_LIFE_DAYS` | 30 | 半衰期 30 天:第 0 天 score=1.0,第 30 天 score=0.5,第 60 天 score=0.25 |
| Historical 记忆固定分数 | 0.01 | 已更新记忆始终排在最后 |
**Hebbian 启发式激活层级**
| 层级 | 天数范围 | 权重乘数 | 说明 |
|:---|:---|:---|:---|
| **Hot** | ≤ 7 天 | 1.0 | 最近活跃,全额权重 |
| **Warm** | 8-30 天 | 0.7 | 中等时效,7 折权重 |
| **Cool** | > 30 天 | 0.3 | 较久远,3 折权重 |
**时效警告** (`age.rs`)
| 函数 | 输出 | 用途 |
|:---|:---|:---|
| `memory_age_label(mtime)` | "今天" / "昨天" / "N 天前" | 人类可读的年龄标签 |
| `memory_freshness_note(mtime)` | 超过 1 天时返回 `<system-reminder>` 警告 | 注入 system prompt 的 XML 标签,利用 system-reminder 对模型的强注意力引导 |
| `memory_freshness_text(mtime)` | 同上但纯文本 | 备选方案 |
设计洞察:**"LLM 对绝对日期 ('2026-01-15') 的时效感知弱,但对相对时间 ('47 天前') 的感知强"**——因此使用天数差而非 ISO 日期。
## 记忆选择器 (`selection.rs`)
当记忆数量超过 `max_entries`(默认 5)时,`select_relevant_memories()` 进行 LLM 语义选择:
1. 过滤掉 `SelectionContext.already_surfaced` 中已展示的条目
2. 若候选数 ≤ max_entries → 直接返回全部
3. 构建候选目录(slug + type + name + description
4. LLM 结构化 JSON 选择 → 支持两种响应格式:`["slug1", "slug2"]``[0, 1, 3]`
5. LLM 失败或 JSON 解析失败 → **回退到 recency 排序**(保证优雅降级)
6. 通过 `apply_decay_scoring()` 对选中结果进行指数衰减重排序
`SelectionContext` 结构:
| 字段 | 类型 | 默认值 | 说明 |
|:---|:---|:---|:---|
| `already_surfaced` | `Vec<usize>` | `[]` | 已展示过的条目索引,本轮不再选择 |
| `recent_tools` | `Vec<String>` | `[]` | 最近使用的工具,相关记忆降权 |
| `max_entries` | `usize` | `5` | 每次最多选择条数 |
## 与数据库的关系
**记忆完全使用文件系统,不使用 SQLite**。数据库表仅存储 Agent 会话状态:
| 数据库表 | 存储内容 | 与记忆的关联 |
|:---|:---|:---|
| `agent_sessions` | 会话元数据(title, model, turn_count | 无直接关联 |
| `agent_messages` | 消息历史(role, content, thought, token_count | 自动提取时子代理读取对话历史 |
| `agent_tasks` | DAG 任务看板 | 无关联 |
| `agent_audit_log` | 工具调用审计(tool_name, status, elapsed_ms | **每个 `save_memory` 调用被 `AuditLogHook` 记录到此表** |
## 完整数据流
```
应用启动
main.rs: MemoryManager::new(library_dir)
→ create_dir_all(memory/)
→ reload() 扫描 *.md → 解析 frontmatter → 按 mtime 排序
→ 存入 AppState.memory_manager
会话开始
runtime/mod.rs: build_system_prompt()
→ try_lock() memory_manager (非阻塞)
→ build_system_reminder(5)
→ 构建 <project-memory-context> XML 块注入 system prompt
会话中(LLM 主动保存)
LLM 调用 save_memory(slug, name, desc, type, content)
→ SaveMemoryTool.execute()
→ 参数校验(slug 格式 + type 枚举)
→ lock() memory_manager
→ check_content_quality(content) ← 四层质量门控(仅警告)
→ find_duplicate_by_content(0.70) ← Jaccard bigram 去重
→ mgr.save_memory()
→ 归档旧版 {slug}_v1.md(如存在)
→ 写入新 {slug}.mdfrontmatter + content
→ update_index() → 更新 MEMORY.md
→ reload()
→ mark_main_agent_wrote()
→ 返回: 操作状态 + 质量警告 + 重复提示 + manifest 清单
会话结束
finalize.rs: finalize_turn()
→ 更新 agent_sessions (turn_count, metrics)
→ 运行 OnSessionStop hooks(审计日志、指标)
→ 导出轨迹 JSONL
→ 如果 EXTRACT_MEMORY_ENABLED:
tokio::spawn(run_extraction())
→ 检查节流 + 主代理写入标志
→ 子代理分析对话 → 提取记忆 → save_memory
```
## 子模块文件索引
| 文件 | 职责 |
|:---|:---|
| `src/agent/memory/mod.rs` | `MemoryManager` 核心:构造、`reload``save_memory``build_system_reminder`、索引维护 |
| `src/agent/memory/types.rs` | `MemoryType`(4 变体)、`MemoryStatus`(Active/Historical)、`MemoryEntry`、frontmatter 解析器 |
| `src/agent/memory/selection.rs` | LLM 语义记忆选择器 + 指数衰减排序 + recency 回退 |
| `src/agent/memory/decay.rs` | 指数时间衰减 (`e^(-λt)`) + Hebbian Hot/Warm/Cool 激活层级 |
| `src/agent/memory/age.rs` | 时效标签生成 + `<system-reminder>` freshness 警告 |
| `src/agent/memory/guardrails.rs` | `WHAT_NOT_TO_SAVE` 排除规则 + `VERIFY_BEFORE_RECOMMENDING` 验证提醒 |
| `src/agent/memory/dedup.rs` | Jaccard 相似度去重 (bigram) + `check_content_quality` 四层门控 + manifest 构建 |
| `src/agent/memory/extraction.rs` | 自动提取:`ExtractionConfig`(env) + `ExtractionTracker` + `run_extraction` 子代理 |
| `src/agent/tools/memory.rs` | `save_memory` 工具:`AgentTool` trait 实现,完整执行流程 |
---
+174
View File
@@ -0,0 +1,174 @@
# Agent 架构概览
AstroResearch 内置了一个基于 **ReAct** (Thought → Action → Observation) 范式的科研智能体引擎 (`src/agent/`),参考 Claude Code 的分层设计。以下对各子系统的架构、数据流和内部逻辑进行完整说明。
### 整体架构
```mermaid
graph TD
subgraph API["API 层"]
SSE["SSE /api/chat/agent"]
Sessions["Session CRUD"]
Metrics["GET /api/chat/metrics"]
Audit["GET /api/chat/sessions/:id/audit"]
AskUser["问答 /api/chat/questions + /api/chat/answer"]
end
subgraph Runtime["AgentRuntime — ReAct 引擎"]
RunTurn["run_turn() 主入口"]
SP["SystemPrompt 组装器"]
CtxBuild["Context Builder 上下文构建"]
ReAct["ReAct 主循环"]
Streaming["streaming.rs 流式处理"]
Executor["executor.rs 并行执行"]
Finalize["finalize.rs 会话收尾"]
TokenBudget["token_budget.rs"]
CircuitBreaker["circuit_breaker.rs"]
end
subgraph Tools["工具系统 (tools/)"]
AgentTool["AgentTool trait"]
Registry["ToolRegistry"]
FS["filesystem/ (6 工具)"]
Astro["astro/ (7 工具)"]
AskUserT["ask_user"]
SubAgentT["subagent/delegate_research"]
TeamT["team/ (4 工具)"]
BG["background (2 工具)"]
end
subgraph CrossCutting["横切关注点"]
Hooks["HookRegistry (9 事件)"]
Skills["SkillRegistry (两层加载)"]
Memory["MemoryManager (项目记忆)"]
Permission["PermissionChecker"]
FileCache["FileStateCache (Read 去重)"]
end
subgraph DB["持久化"]
AgentSessions["agent_sessions"]
AgentMessages["agent_messages"]
AgentTasks["agent_tasks"]
AgentAudit["agent_audit_log"]
end
SSE --> RunTurn
RunTurn --> SP
RunTurn --> CtxBuild --> DB
RunTurn --> ReAct
ReAct --> Streaming --> Tools
ReAct --> Executor --> Tools
ReAct --> TokenBudget
ReAct --> CircuitBreaker
ReAct --> Finalize
Hooks -.-> ReAct
Hooks -.-> Tools
Skills -.-> Tools
Memory -.-> SP
Permission -.-> Tools
FileCache -.-> Tools
```
---
### ReAct 运行循环 (`runtime/`)
主循环由 `AgentRuntime::run_turn()` 驱动,分为 4 个阶段:
#### 完整生命周期
```mermaid
sequenceDiagram
participant FE as 前端 SSE
participant RT as AgentRuntime
participant DB as SQLite
participant LLM as LLM API
participant Tools as ToolRegistry
FE->>RT: POST /api/chat/agent { question, session_id? }
Note over RT: Phase 1 — 会话管理
RT->>DB: create_or_resume_session()
alt 新会话
DB-->>RT: session_id = uuid, turn_index = 0
else 恢复会话
DB-->>RT: 验证存在 + 计算 turn_index
end
RT->>RT: 触发 OnSessionStart hook
RT-->>FE: SSE session { session_id, title }
Note over RT: Phase 2 — 上下文构建
RT->>RT: build_initial_context()
RT->>RT: ① 组装 SystemPrompt (静态 section + 记忆注入)
RT->>DB: ② 加载历史消息 load_history_for_llm()
RT->>DB: ③ 恢复未完成任务 (agent_tasks)
RT->>RT: ④ 检查压缩/清理上下文
RT->>DB: ⑤ 保存用户消息
RT->>RT: ⑥ 运行 PreToolUse hooks 过滤
Note over RT: Phase 3 — ReAct 循环
loop 每步迭代 (step ≤ max_steps)
RT->>LLM: chat_stream(messages + tool_defs)
LLM-->>RT: ReasoningDelta / TextDelta / ToolCallsComplete
RT-->>FE: SSE thought / text_delta / tool_call
alt 无工具调用 → 最终答案
RT->>DB: 保存 assistant 消息
RT-->>FE: SSE text_delta → usage → done
Note over RT: break 循环
else 有工具调用
RT->>RT: 检查 token 预算 + 熔断器
RT->>RT: validate_and_prepare() — 去重 + 过滤
RT->>Tools: execute_parallel() — 并行执行
Tools-->>RT: (tool_call_id, name, output)
RT-->>FE: SSE tool_result { tool_call_id, name, output }
RT->>DB: 保存 tool 消息 + 审计日志
RT->>RT: 运行 PostToolUse hooks
RT->>RT: 检测压缩需求 (auto_compact)
end
RT->>RT: 检测循环终止条件
end
Note over RT: Phase 4 — 会话收尾
RT->>DB: 更新 turn_count + updated_at
RT->>DB: calculate_and_persist_metrics()
RT->>RT: 运行 OnSessionStop hook
RT-->>FE: SSE done
```
#### 并行工具执行模型
```mermaid
sequenceDiagram
participant ReAct as ReAct 循环
participant Val as validate_and_prepare
participant Exec as execute_parallel
participant T1 as Tool A
participant T2 as Tool B
participant FE as 前端 SSE
ReAct->>Val: LLM 返回 [tool_call_a, tool_call_b]
Val->>Val: 去重检测 + 权限验证
Val-->>ReAct: prepared_calls[] + has_duplicate 标志
ReAct->>FE: 发送 tool_call SSE (逐一)
ReAct->>Exec: 启动 execute_parallel()
par 并行执行
Exec->>T1: tool_a.execute(args_a)
Exec->>T2: tool_b.execute(args_b)
end
T1-->>Exec: ToolOutput { content, is_error }
Exec-->>FE: SSE tool_result (立即推送)
T2-->>Exec: ToolOutput { content, is_error }
Exec-->>FE: SSE tool_result (立即推送)
Exec-->>ReAct: Vec<(tool_call_id, name, args, output, cancelled)>
ReAct->>ReAct: PostToolUse hooks + 审计日志 + 持久化
```
- **并发上限**:由 `max_concurrent_tools` 环境变量控制,默认不限制
- **Sibling Abort**:仅 `causes_sibling_abort() = true` 的工具(如 `download_paper`)能在出错时中断兄弟任务
- **InterruptBehavior**`Block` 工具(如 `ask_user`)不可被用户取消;`Cancel` 工具可在取消信号时中断
- **超时控制**:每个工具有独立超时,默认 120s(`AGENT_TOOL_TIMEOUT_SECS`
---
+941
View File
@@ -0,0 +1,941 @@
# 权限系统 (Permission System)
Agent 权限系统采用**多层纵深防御**架构,从工具级 trait 约束到运行时的 Hook 拦截、路径沙箱、命令黑名单,形成 7 层安全防线。
---
## 整体架构
```mermaid
graph TD
subgraph L1["第一层:工具 Trait 约束"]
IB["InterruptBehavior<br/>(Cancel vs Block)"]
CS["is_concurrency_safe<br/>(并行安全声明)"]
CP["check_permissions<br/>(自定义 PermissionRule)"]
SA["causes_sibling_abort<br/>(兄弟中断)"]
end
subgraph L2["第二层:PermissionChecker 规则引擎"]
Rules["有序规则链<br/>Deny → Allow → Ask → Default Allow"]
WC["通配符 '*' 全匹配"]
end
subgraph L3["第三层:Hook 管道"]
PreH["PreToolUse<br/>Continue | Block | MutateInput | PermissionRequired"]
PostH["PostToolUse<br/>Continue | MutateOutput"]
Builtins["3 内置 Hook<br/>Cancellation | Metrics | AuditLog"]
end
subgraph L4["第四层:文件系统沙箱"]
PS["路径沙箱<br/>library_dir / skills_dir / cwd"]
PT["穿越防护<br/>拒绝 .. 和 ~"]
end
subgraph L5["第五层:Bash 命令安全"]
Blacklist["黑名单 21 条<br/>交互式/破坏性命令"]
Timeout["超时控制 60s (max 120s)"]
OutputLimit["输出截断 4000 字符"]
end
subgraph L6["第六层:子代理隔离"]
SilentCtx["Silent 上下文<br/>禁止 ask_user"]
FreshMsg["全新消息上下文<br/>不污染父代理"]
InheritPerm["继承 PermissionChecker<br/>+ HookRegistry"]
end
subgraph L7["第七层:运行时安全约束"]
MaxSteps["max_steps 上限"]
DupDetect["同质调用检测"]
TokenBudget["Token 预算 diminishing returns"]
Cancel["用户取消 (cancelled_runs)"]
end
AgentRuntime["AgentRuntime::run_turn()"] --> L1
L1 --> L2
L2 --> L3
L3 --> L4
L4 --> L5
L5 --> L6
L6 --> L7
```
---
## 第一层:工具级 Trait 约束
每个工具通过覆写 `AgentTool` trait 的 4 个安全方法声明自身行为边界。
定义位置:`src/agent/tools/mod.rs:179`
### 方法说明
| 方法 | 默认值 | 作用 |
|---|---|---|
| `interrupt_behavior()` | `Cancel` | 用户取消时的响应:`Cancel` 立即停止(只读工具),`Block` 等待完成(有副作用的写入工具) |
| `is_concurrency_safe(args)` | `false` | 是否可与其他工具并行执行。保守默认,只读工具须显式覆写为 `true` |
| `check_permissions(args)` | 空 `Vec` | 返回 `PermissionRule` 列表,由 PermissionChecker 运行时逐条匹配 |
| `causes_sibling_abort()` | `false` | 该工具失败时是否中止兄弟并行执行(下载/解析类工具可设为 `true` |
### 工具安全分类表
#### 只读并发安全工具
| 工具 | 域 | `is_concurrency_safe` | `interrupt_behavior` |
|---|---|---|---|
| `search_papers` | astro/search | `true` | `Cancel` |
| `get_paper_metadata` | astro/search | `true` | `Cancel` |
| `get_paper_content` | astro/paper | `true` | `Cancel` |
| `rag_search` | astro/rag | `true` | `Cancel` |
| `query_target` | astro/target | `true` | `Cancel` |
| `read_file` | filesystem | `true` | `Cancel` |
| `grep_files` | filesystem | `true` | `Cancel` |
| `glob_files` | filesystem | `true` | `Cancel` |
| `load_skill` | skill | `true` | `Cancel` |
#### 写入/IO 串行安全工具
| 工具 | 域 | `is_concurrency_safe` | `interrupt_behavior` |
|---|---|---|---|
| `download_paper` | astro/paper | `false` | `Cancel` |
| `parse_paper` | astro/paper | `false` | `Cancel` |
| `file_write` | filesystem | `false` | `Cancel` |
| `file_edit` | filesystem | `false` | `Cancel` |
| `save_note` | astro/note | `false` | `Cancel` |
| `save_memory` | memory | `false` | **`Block`** |
| `run_bash` | filesystem | `false` | **`Block`** |
| `ask_user` | ask_user | `false` | **`Block`** |
| `todo_write` | todo | `false` | `Cancel` |
| `compress_context` | compress | `false` | `Cancel` |
| `subagent` | subagent | `false` | **`Block`** |
> **设计原理**:`Block` 工具在用户取消时忽略中断信号,确保写操作完整提交后才停止。`save_memory` 和 `run_bash` 涉及文件系统修改,`ask_user` 依赖 oneshot 通道生命周期,`subagent` 开启了完整的子代理 ReAct 循环,中断可能导致数据不一致。
---
## 第二层:PermissionChecker 规则引擎
参考 Claude Code 的 PermissionChecker 设计,提供可编程的权限规则链。
定义位置:`src/agent/runtime/permission.rs`
### 权限判定规则
```
┌────────────────────────────────────────────────────────────┐
│ 规则匹配优先级 (first-match-wins) │
│ │
│ 1. Deny { tool_name, reason } — 不可覆盖的拒绝 │
│ 2. Allow { tool_name } — 显式允许 │
│ 3. Ask { tool_name, message } — 需要用户确认 │
│ 4. (default) — 无匹配 → Allow │
│ │
│ 通配符 "*" 匹配所有工具名 │
└────────────────────────────────────────────────────────────┘
```
### PermissionChecker API
```rust
// src/agent/runtime/permission.rs
pub struct PermissionChecker {
rules: Vec<PermissionRule>, // 有序规则列表,先添加的优先级更高
}
impl PermissionChecker {
pub fn new() -> Self; // 空检查器 = 默认允许所有
pub fn add_rule(&mut self, rule: PermissionRule); // 添加规则
pub fn check(&self, tool_name: &str) -> PermissionResult; // 检查单个工具
pub fn is_denied(&self, tool_name: &str) -> bool; // 快捷 deny 检查
fn matches(pattern: &str, tool_name: &str) -> bool {
pattern == "*" || pattern == tool_name // 通配符或精确匹配
}
}
pub enum PermissionResult {
Denied { reason: String },
Allowed,
AskUser { message: String },
}
```
### 使用示例
```rust
// 创建受限检查器:只允许只读操作
let mut checker = PermissionChecker::new();
checker.add_rule(PermissionRule::Deny {
tool_name: "run_bash".into(),
reason: "此会话中禁止执行命令".into(),
});
checker.add_rule(PermissionRule::Deny {
tool_name: "download_paper".into(),
reason: "禁止下载".into(),
});
checker.add_rule(PermissionRule::Ask {
tool_name: "file_write".into(),
message: "是否允许写入文件?".into(),
});
// 默认 Allow — 其余工具正常执行
assert!(checker.check("search_papers").is_allowed());
assert!(checker.check("run_bash").is_denied());
```
### 当前集成状态
```mermaid
graph LR
subgraph "AgentConfig::from_env_optional()"
PC0["解析 AGENT_PERMISSIONS_* 环境变量<br/>构造 PermissionChecker::from_config()"]
end
subgraph "AgentRuntime::run_turn()"
PC1["permission_checker: PermissionChecker<br/>(Deny → Ask → Allow 规则链 + 4种模式)"]
end
subgraph "SubAgentRunner"
PC2["permission_checker: Arc&lt;PermissionChecker&gt;"]
PC2 -->|"check(tool_name, Some(&args))"| SubExec["三态检查<br/>Deny→注入错误 | Ask→自动拒绝 | Allow→执行"]
end
subgraph "execute_parallel() Phase 2.5"
PC3["permission_checker: Option&lt;&PermissionChecker&gt;"]
PC3 -->|"check() + apply_mode() + 工具级叠加"| DenyCheck["━━ 三态处理 ━━<br/>Deny → 注入错误跳过执行<br/>AskUser → SSE PermissionRequest + oneshot 等待(120s超时)<br/>Allowed → 正常进入执行队列"]
end
PC0 -.->|"构建"| PC1
PC1 -.->|"传递给"| PC2
PC1 -.->|"传递给"| PC3
style PC3 fill:#ccffcc,stroke:#00aa00
```
> **✅ 完整已实现**`executor::execute_parallel()` 在 Phase 2.5PreToolUse hooks 之后、工具执行之前)执行完整的权限检查管道:
> 1. `PermissionChecker::check(tool_name, tool_args)` — 规则链匹配 + 内容级匹配
> 2. `PermissionChecker::apply_mode()` — 模式变换(Bypass/DontAsk/AcceptEdits
> 3. Hook `PermissionRequired` 升级 — hook 请求的权限确认为 AskUser
> 4. 工具级 `check_permissions()` 叠加 — 工具自定义规则在 Allow 时升级为 Ask
> 5. 最终三态分流:Deny → 注入错误 / AskUser → oneshot 交互(120s 超时) / Allowed → 正常执行
---
## 第三层:Hook 管道
Hook 系统提供了可编程的事件拦截点,参考 Claude Code 的 PreToolUse/PostToolUse/Stop hooks 设计。
定义位置:`src/agent/hooks.rs`
### PreToolUse 动作类型
```mermaid
graph TD
PreToolUse["PreToolUse hook"]
PreToolUse --> Continue["Continue<br/>正常执行"]
PreToolUse --> Block["Block { reason }<br/>阻止执行,第一个 Block 短路整个链"]
PreToolUse --> Mutate["MutateInput { updated_args, additional_context }<br/>修改参数 + 注入附加上下文"]
PreToolUse --> PermReq["PermissionRequired { permission, tool_name }<br/>请求权限决策(Phase 2 待完善)"]
```
### PostToolUse 动作类型
```mermaid
graph TD
PostToolUse["PostToolUse hook"]
PostToolUse --> Continue2["Continue<br/>保持输出不变"]
PostToolUse --> MutateOut["MutateOutput { updated_content }<br/>修改工具输出(如脱敏)"]
```
### Hook 链执行逻辑 (`run_pre_tool_use`)
```rust
// 遍历所有已注册 hook
for hook in &self.hooks {
let action = hook.pre_tool_use(ctx).await;
match action {
Block { reason } => {
// 第一个 Block 立即短路返回,不执行后续 hook
return PreToolUseResult { action, ... };
}
MutateInput { updated_args, additional_context } => {
// 累积 additional_context(多 hook 拼接)
// 更新 final_args(最后一个 MutateInput 的修改生效)
}
PermissionRequired { .. } => {
// 记录日志,但暂不阻塞(Phase 2 完善)
}
Continue => {} // 继续下一个 hook
}
}
```
### 3 个内置 Hook
```mermaid
classDiagram
class CancellationHook {
-cancelled_runs: Arc~Mutex~HashSet~String~~
+pre_tool_use() → Block | Continue
+on_session_stop() → 清理取消状态
}
class MetricsHook {
-data: Arc~Mutex~MetricsData~
+on_session_start() → 关联 session_id
+post_tool_use() → 累计工具调用/错误计数
+on_step_complete() → 每 3 步输出摘要日志
+on_session_stop() → 输出终止原因
+snapshot() → 返回可查询的指标快照
}
class AuditLogHook {
-db: SqlitePool
+post_tool_use() → fire-and-forget 写入 agent_audit_log
+on_session_stop() → 写入 SESSION_STOP 标记
}
class AgentHook {
<<interface>>
+name() &str
+on_session_start()
+pre_tool_use()
+post_tool_use()
+on_step_complete()
+on_session_stop()
+on_subagent_start()
+on_subagent_stop()
+on_pre_compact()
+on_post_compact()
}
AgentHook <|-- CancellationHook
AgentHook <|-- MetricsHook
AgentHook <|-- AuditLogHook
```
### 审计日志 (`agent_audit_log` 表)
`AuditLogHook` 在每次工具执行后通过 fire-and-forget`tokio::spawn`)写入审计记录:
| 字段 | 说明 |
|---|---|
| `session_id` | 会话 ID |
| `step` | ReAct 步数 |
| `tool_name` | 工具名称 |
| `status` | `"OK"``"FAIL"` |
| `elapsed_ms` | 执行耗时(毫秒) |
| `output_preview` | 输出内容前 200 字符 |
| `agent_name` | 代理身份(`"lead"` 或子代理名) |
会话终止时写入一条 `tool_name = 'session'``status = 'SESSION_STOP'` 的汇总记录。
---
## 第四层:文件系统路径沙箱
所有文件操作工具(`read_file``file_write``file_edit``glob_files``grep_files``run_bash``working_dir` 参数)共享的路径安全检查。
定义位置:`src/agent/tools/filesystem/security.rs`
### 允许的根目录
```rust
let allowed_roots = [
config.library_dir.canonicalize(), // 论文库目录
config.skills_dir.canonicalize(), // Agent Skills 目录
std::env::current_dir(), // 项目根目录
];
```
### 安全检查函数
```mermaid
flowchart TD
Input["用户提供的路径字符串"] --> PT{"has_path_traversal()<br/>包含 .. 或 ~ "}
PT -->|是| Reject1["❌ 拒绝"]
PT -->|否| Resolve["resolve_path()<br/>绝对路径直接用,相对路径基于 cwd 拼接"]
Resolve --> Canon["canonicalize()<br/>消除符号链接"]
Canon -->|失败| TryParent["尝试对父目录 canonicalize"]
TryParent -->|失败| Reject2["❌ 拒绝:无法解析"]
Canon -->|成功| Check{"is_path_allowed()<br/>在 allowed_roots 内?"}
TryParent -->|成功| Check
Check -->|是| Allow["✅ 允许"]
Check -->|否| Reject3["❌ 拒绝:无权访问"]
```
### 防护能力
| 攻击类型 | 防护方式 |
|---|---|
| 路径穿越 (`../../../etc/passwd`) | `has_path_traversal()` 拒绝含 `..` 的路径 |
| 家目录访问 (`~/`) | `has_path_traversal()` 拒绝含 `~` 的路径 |
| 符号链接逃逸 | `canonicalize()` 解析符号链接到真实路径后再检查 |
| 绝对路径越界 | `resolve_path()` 解析后再 `is_path_allowed()` |
### 覆盖的工具
| 工具 | 受保护的参数 |
|---|---|
| `read_file` | `file_path` |
| `file_write` | `file_path` |
| `file_edit` | `file_path` |
| `glob_files` | `pattern` (解析后) |
| `grep_files` | `path` |
| `run_bash` | `working_dir` |
---
## 第五层:Bash 命令安全校验
`run_bash` 工具在路径沙箱之上叠加了命令级安全校验。
定义位置:`src/agent/tools/filesystem/bash.rs`
### 校验流程
```mermaid
flowchart TD
Cmd["command 参数"] --> Trim["trim() 去空格"]
Trim --> Empty{"空命令 / bash / bash -c"}
Empty -->|是| Reject1["❌ 拒绝"]
Empty -->|否| Lower["to_lowercase()"]
Lower --> Blacklist{"黑名单子串匹配<br/>21 条模式)"}
Blacklist -->|命中| Reject2["❌ 拒绝:不允许执行 'X' 类命令"]
Blacklist -->|未命中| Execute["✅ 执行"]
```
### 黑名单(精确首词匹配,已修复子串误伤问题)
校验流程:
```mermaid
flowchart TD
Cmd["command 参数"] --> Trim["trim() 去空格"]
Trim --> Empty{"空命令 / bash / bash -c"}
Empty -->|是| Reject1["❌ 拒绝"]
Empty -->|否| Bypass{"命令替换绕过?<br/>$() 或反引号开头的首词"}
Bypass -->|是| Reject2["❌ 拒绝"]
Bypass -->|否| Extract["extract_first_command_word()<br/>提取首个命令单词"]
Extract --> Whitelist{"SAFE_COMMANDS 白名单?<br/>35 个安全命令)"}
Whitelist -->|是| Allow["✅ 直接允许"]
Whitelist -->|否| Blacklist{"DANGEROUS_COMMANDS 黑名单?<br/>33 个精确匹配)"}
Blacklist -->|是| Reject3["❌ 拒绝"]
Blacklist -->|否| ArgCheck{"DANGEROUS_ARG_PATTERNS<br/>5 个危险参数子串)"}
ArgCheck -->|命中| Reject4["❌ 拒绝"]
ArgCheck -->|未命中| Allow2["✅ 默认允许<br/>(路径沙箱 + 超时兜底)"]
```
**精确匹配 vs 子串匹配(修复前/后对比)**
| 命令 | 修复前(子串) | 修复后(首词精确) |
|---|---|---|
| `grep "ssh_config" *.rs` | ❌ 误拦(含子串 `ssh ` | ✅ 允许(首词 `grep` 在白名单) |
| `echo "use sudo carefully"` | ❌ 误拦(含子串 `sudo ` | ✅ 允许(首词 `echo` 在白名单) |
| `cat /usr/share/vim/vimrc` | ❌ 误拦(含子串 `vim ` | ✅ 允许(首词 `cat` 在白名单) |
| `python script.py` | ✅ 允许 | ✅ 允许(不在黑名单,默认允许) |
| `vim file.txt` | ✅ 拒绝 | ✅ 拒绝(首词 `vim` 在黑名单) |
| `$(echo sud; echo o) /etc/passwd` | ✅ 允许(绕过!) | ❌ 拒绝(检测到命令替换绕过) |
### 安全白名单(已启用)
```rust
const SAFE_COMMANDS: &[&str] = &[
"ls", "cat", "head", "tail", "find", "grep", "wc", "echo",
"pwd", "sort", "uniq", "cut", "tr", "awk", "sed", "jq",
"diff", "file", "stat", "du", "df", "env", "printenv",
"which", "basename", "dirname", "realpath", "readlink",
"xargs", "tee", "date", "sleep", "true", "false",
];
```
白名单内的命令**优先放行**,不经过黑名单检查。非白名单命令经过黑名单精确匹配和危险参数二次检查后默认允许。
### 其他约束
| 约束 | 值 | 说明 |
|---|---|---|
| 超时 | 默认 60s,最大 120s | `AGENT_TOOL_TIMEOUT_SECS` 环境变量 |
| 输出截断 | 4000 字符 | `truncate_content()` |
| 工作目录 | library_dir / skills_dir / cwd | 受路径沙箱约束 |
### 已知局限
1. ~~**黑名单子串匹配**~~ — ✅ 已修复:改用首词精确匹配,`grep "ssh_config"` 不再误拦
2. **未限制网络访问**`curl``wget` 不在黑名单中
3. **未限制进程数** — fork bomb(如 `:(){ :\|:& };:`)未被检测
4. **管道/重定向完整放行**`<``>``|` 不做限制
5. ~~**`$()` 命令替换**~~ — ✅ 已修复:检测首词位置 `$()` 和反引号绕过
6. ~~**白名单未被使用**~~ — ✅ 已修复:`SAFE_COMMANDS` 已集成到 `validate_bash_command()` 中,白名单命令优先放行
---
## 第六层:子代理隔离
子代理通过 `SubAgentRunner` 创建上下文隔离的执行环境,参考 Claude Code Subagents 设计。
定义位置:`src/agent/subagent.rs``src/agent/tools/subagent.rs`
### 隔离维度对比
| 维度 | 父代理 | 子代理 |
|---|---|---|
| 消息上下文 | 完整历史 + 所有中间工具调用 | **全新 messages**:仅 `[system_prompt, user_prompt]` |
| 工具注册表 | 完整 `ToolRegistry`19+ 工具) | 完整 `ToolRegistry`(共享同一个引用) |
| 工具上下文 | `ToolContext::new()` | `ToolContext::silent()`**`silent = true`** |
| Hook 管道 | 完整 `HookRegistry` | 继承父代理的 `HookRegistry` |
| PermissionChecker | 自己的实例 | 继承父代理的 `PermissionChecker` |
| 上下文压缩 | 多层压缩(micro/auto/manual | 独立的自动压缩 |
| 死循环检测 | `DuplicateDetector` per turn | 独立的 `(last_call, consecutive_count)` |
### Silent 模式
```rust
// src/agent/tools/mod.rs:114
pub fn silent(app_state: Arc<AppState>) -> Self {
ToolContext {
app_state,
session_id: String::new(),
silent: true, // ← 关键标志
read_file_state: ...,
sse_tx: None, // ← 无 SSE 通道
enable_thinking: false,
}
}
```
**Silent 模式效果**
- `ask_user` 工具检测到 `ctx.silent == true` 时**直接返回错误**
- 阻止子代理绕过父代理向终端用户提问
- 无 SSE 通道 → 子代理的工具调用进度不会单独推送到前端
### 子代理内部权限流程
```mermaid
sequenceDiagram
participant SA as SubAgentRunner
participant Hook as HookRegistry
participant PC as PermissionChecker
participant Tool as AgentTool
SA->>Hook: PreToolUseContext { tool_name, args }
Hook-->>SA: PreToolUseResult { action, final_args }
alt action = Block
SA-->>SA: 注入错误 tool_result,跳过执行
else action = Continue / MutateInput
SA->>PC: is_denied(tool_name)
alt 被拒绝
SA-->>SA: 注入错误 tool_result,跳过执行
else 允许
SA->>Tool: execute(final_args, silent_ctx)
Tool-->>SA: ToolOutput
SA->>Hook: PostToolUse hooks → 可能修改输出
end
end
```
> **⚠️ 当前局限**:子代理使用**完整的父代理 ToolRegistry**,不支持按子任务需求裁剪工具列表(如"仅搜索"模式只给只读工具)。未来可引入 `ToolRegistry::restrict()` 方法实现最小权限原则。
---
## 第七层:运行时安全约束
ReAct 循环中的多层终止条件,防止无限循环和资源耗尽。
定义位置:`src/agent/runtime/mod.rs:442`
### 终止条件矩阵
| 条件 | 触发阈值 | 行为 |
|---|---|---|
| **最大步数** | `step > max_steps` (默认 8) | 强制 LLM 生成最终答案(不带工具调用),注入提醒消息 |
| **同质调用** | 连续 3 次相同 `(tool_name, args)` | 注入错误 tool_result,跳过本轮该工具 |
| **Token diminishing returns** | 连续多步无新增信息 | 强制结束,注入"请基于已收集信息直接回答" |
| **用户取消** | `cancelled_runs` 含当前 `session_id` | 循环开始和工具执行中双重检查,发送 Error SSE 事件 |
| **压缩熔断** | `CompactionCircuitBreaker` 连续失败 | 跳过自动压缩,避免无限压缩循环 |
| **Token 硬限制** | `token_hard_limit` (默认 40000) | `TokenBudget` 触发强制动作 |
### 用户取消的双重检查
```mermaid
sequenceDiagram
participant User as 用户
participant API as API 层
participant State as cancelled_runs
participant Loop as ReAct 循环
participant Exec as 工具执行
User->>API: POST /api/chat/cancel
API->>State: insert(session_id)
Note over Loop: 每步迭代开始
Loop->>State: contains(session_id)?
State-->>Loop: true → break 循环
Note over Exec: 工具执行中 (每 250ms)
loop 取消轮询
Exec->>State: contains(session_id)?
State-->>Exec: true → interrupt
end
Note over Exec: InterruptBehavior 判断
alt interrupt_behavior = Cancel
Exec-->>Exec: 立即停止,返回 "执行已被用户取消"
else interrupt_behavior = Block
Exec-->>Exec: 忽略中断信号,等待完成
end
```
- 主循环在**每步开始**检查取消标志
- 工具执行器以 **250ms 间隔**轮询取消状态
- `Block` 工具(`run_bash``save_memory``ask_user``subagent`)忽略取消信号直到自然完成
### 取消状态生命周期
1. 用户通过 API 端点设置 `cancelled_runs.insert(session_id)`
2. `CancellationHook::pre_tool_use()` 检测到 → 返回 `Block`
3. ReAct 循环入口检测到 → `break` 跳出
4. 工具执行检测到 + `InterruptBehavior::Cancel` → 立即返回
5. `CancellationHook::on_session_stop()` → 清理 `cancelled_runs.remove(session_id)`
---
## 配套安全机制
### Background Task 安全
位置:`src/agent/tools/background.rs``src/agent/background.rs`
- `bg_task_run` 用于在后台执行慢速操作(下载、解析)
- 后台任务通过 `BgNotificationQueue` 注入结果,不直接访问 Agent 上下文
- 结果注入在下一次 LLM 调用前以 user 消息形式推送
### 工具输出持久化
位置:`src/agent/tools/persist.rs`
- 大型工具结果(超过 `max_tool_output_chars`)写入磁盘,消息中只包含 stub
- 写入路径:`{library_dir}/tool-results/{tool_call_id}.txt`
- 通过调用外部工具读取完整结果,避免上下文污染
### Token 预算管理
位置:`src/agent/runtime/token_budget.rs`
```
软限制 (token_soft_limit, 默认 32000)
↓ 触发渐进式 nudging 提醒 → 建议 LLM 总结/给出答案
硬限制 (token_hard_limit, 默认 40000)
↓ 触发强制动作 → 上下文压缩或强制结束
Diminishing Returns 检测
↓ 连续无新增信息 → 强制结束 + 直接回答
```
---
## 权限检查全链路
一次完整的工具调用穿越全部 7 层防线:
```
工具调用请求
├─ [L7] 循环入口:步数 / 取消 / diminishing returns 检查
├─ [L7] validate_and_prepare():死循环检测 + 参数解析
├─ [L3] PreToolUse hooks
│ ├── CancellationHook → 检查 cancelled_runs
│ ├── 自定义 Hook → Block? MutateInput?
│ └── 返回 final_args + additional_context
├─ [L2] PermissionChecker.check() ← ✅ 在 Phase 2.5 调用(PreToolUse hooks 之后、执行之前)
│ ├── Deny → 注入错误 result,跳过执行,不进入队列
│ ├── AskUser → 暂视为允许(Phase 2 确认交互待完善)
│ └── Allowed → 正常进入执行队列
├─ [L1] 分区器 (ToolPartitioner)
│ └── is_concurrency_safe() 判断 → 并行 or 串行批次
├─ 工具执行 (每工具独立 Future):
│ │
│ ├─ [L1] InterruptBehavior 判断 → Cancel 可中断 / Block 不可中断
│ │
│ ├─ [L4] 路径沙箱 (read_file / file_write / file_edit / glob / grep / bash)
│ │
│ ├─ [L5] Bash 黑名单 (run_bash):
│ │ ├── validate_bash_command() → 空命令 / 黑名单
│ │ ├── 工作目录路径沙箱检查
│ │ └── 超时控制 (60s default / 120s max)
│ │
│ ├─ [L6] ask_user Silent 模式检查 → 子代理中直接返回错误
│ │
│ └─ 超时控制 (AGENT_TOOL_TIMEOUT_SECS, default 120s)
└─ [L3] PostToolUse hooks
├── 输出截断 + 大结果持久化
├── MetricsHook → 累计指标
├── AuditLogHook → fire-and-forget 审计日志
└── MutateOutput → 输出修改
```
---
## 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 权限配置 |
+339
View File
@@ -0,0 +1,339 @@
# Skills 系统 (`skills.rs` + `tools/skill.rs`)
参考 Claude Code 的两层 Skill 加载架构 — **Layer 1** 在每轮系统提示词中列出可用 skill 名称(~20 tokens/skill),**Layer 2** 在 LLM 调用 `load_skill` 工具时注入完整内容(~2000 tokens/skill)。
## 整体数据流
```mermaid
sequenceDiagram
participant FS as skills/*/SKILL.md
participant SR as SkillRegistry<br/>(Arc&lt;RwLock&lt;&gt;&gt;)
participant SP as SystemPrompt
participant LLM as LLM
participant Tool as LoadSkillTool
participant Sub as SubAgentRunner
Note over SR: 启动阶段 (main.rs)
SR->>FS: discover_skill_dirs() 扫描目录
FS-->>SR: parse_skill_file() 解析 YAML + Markdown
SR->>SR: refresh() 完整重载,按 usage_score 排序
SR->>SR: start_watcher() 启动 notify 文件监听
Note over SR: Layer 1 — System Reminder (每轮请求)
SP->>SR: build_reminder()
SR-->>SP: &lt;system-reminder&gt; XML 块
SP->>LLM: 注入 system prompt "skills" section
Note over SR: Layer 2 — 按需加载
LLM->>Tool: load_skill(skill_name)
Tool->>SR: get_skill(name)
SR-->>Tool: Skill { meta + body }
alt context = "fork"
Tool->>Sub: 启动子代理 (max_steps ≤ 10)
Sub-->>Tool: 子代理执行结果
else context = "inline" (默认)
Tool->>Tool: substitute_variables(body)
end
Tool-->>LLM: 格式化技能内容
Tool->>SR: record_usage(name)
```
## 代码结构
| 文件 | 行数 | 职责 |
|---|---|---|
| `src/agent/skills.rs` | 847 | SkillRegistry 缓存、文件解析、热更新、条件激活、系统提示构建 |
| `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 |
| `src/main.rs` | ~151-163 | 启动时初始化 SkillRegistry、refresh、启动文件监听器 |
| `skills/{name}/SKILL.md` | — | 实际 skill 定义文件(当前 3 个) |
## SKILL.md 格式
每个 skill 为 `skills/{name}/SKILL.md`,包含 YAML frontmatter + Markdown 正文:
```markdown
---
name: methodology
description: 系统性文献综述方法论——如何高效地完成学术文献调研
context: inline
allowed-tools:
- read_file
- search_papers
model: sonnet
argument-hint: "<research question>"
when_to_use: 当用户请求文献综述或调研时
disable-model-invocation: false
user-invocable: true
version: "1.0"
paths:
- "*.rs"
- "*.toml"
agent: code-reviewer
effort: high
---
# Skill 正文 (Markdown)
详细指引...
```
### Frontmatter 字段全量说明
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `name` | `string` | 目录名 | Skill 唯一标识 |
| `description` | `string` | "(无描述)" | 一句话描述,出现在 Layer 1 列表;**缺少时产生 warning** |
| `context` | `"inline"` / `"fork"` | `inline` | 执行模式;**非法值时产生 warning** |
| `allowed-tools` | `string[]` | `[]`(无限制) | 工具白名单,fork 模式下建议仅使用这些工具 |
| `model` | `"haiku"` / `"sonnet"` / `"opus"` / `"inherit"` | — | 推荐的执行模型 |
| `argument-hint` | `string` | — | 参数提示(如 `<research question>`),已定义但 LoadSkillTool 尚未使用 |
| `when_to_use` | `string` | — | 触发场景描述,**Layer 1 reminder 中直接拼接到 description 后** |
| `disable-model-invocation` | `bool` | `false` | `true` 时 LLM 不能通过 load_skill 工具自动调用;同时控制条件激活的初始状态 |
| `user-invocable` | `bool` | `true` | `false` 时不出现在 Layer 1 reminder 中,用户无法手动调用 |
| `version` | `string` | — | 版本号 |
| `paths` | `string[]` | `[]`(始终激活) | 条件激活的 glob 模式,非空时 skill 仅在匹配文件路径后激活 |
| `agent` | `string` | — | fork 模式下游的 agent 类型(如 `code-reviewer`),已定义但 LoadSkillTool 尚未使用 |
| `effort` | `string` | — | fork 模式下的 effort 级别,已定义但 LoadSkillTool 尚未使用 |
### 当前项目 Skill 清单
| Skill | Context | 状态 | 说明 |
|---|---|---|---|
| `methodology` | inline | ✅ 完整 | 系统性文献综述 6 步流程:范围界定 → 按引用筛选 → 逐篇深读 → 补充检索 → 交叉验证 → 输出综述 |
| `plotting` | fork | 🚧 占位 | 科研绘图规范(matplotlib/seaborn/plotly),白名单 bash+save_note |
| `presentation` | fork | 🚧 占位 | 学术 PPT 生成(Python-pptx/Beamer/Marp),白名单 bash+save_note |
## SkillRegistry 核心实现 (`skills.rs`)
### 数据结构
```
SkillFrontmatter — serde_yaml 解析的 YAML frontmatter,含 validate() 校验方法
├──▶ SkillMeta — Layer 1 摘要(name, description, context, allowed_tools,
│ when_to_use, disable_model_invocation, user_invocable, paths
└──▶ Skill — Layer 2 完整对象(meta + body + skill_dir
└──▶ SkillRegistry — 缓存容器 + 生命周期管理
├── skills: Vec<Skill>
├── last_scan_mtime: Option<SystemTime>
└── usage_stats: HashMap<String, SkillUsageStat>
```
### 关键方法
| 方法 | 返回值 | 说明 |
|---|---|---|
| `new(skills_dir)` | `Self` | 创建空注册表,调用 `refresh()` 触发初始扫描 |
| `refresh()` | `()` | **完整重载**(非增量):扫描目录 → 解析 → 按 `usage_score` 降序排序 |
| `needs_refresh()` | `bool` | 检查目录 mtime 是否变化(首次或 mtime > last_scan_mtime
| `build_reminder()` | `Option<String>` | 构建 Layer 1 XML `<system-reminder>` 块,仅包含 `user_invocable=true && disable_model_invocation=false` 的 skill |
| `build_tool_description()` | `String` | 动态生成 load_skill 工具的 description(列出所有 `disable_model_invocation=false` 的 skill |
| `get_skill(name)` | `Option<&Skill>` | O(n) 按名称查找完整 Skill |
| `list_skills()` | `Vec<SkillMeta>` | 获取所有 skill 的元信息列表 |
| `record_usage(name)` | `()` | 记录调用:`invoke_count += 1``last_used_at = now` |
| `usage_score(name)` | `f64` | **指数衰减评分**`ln(1 + count) × 0.5^(age_hours / 168)`7 天半衰期) |
| `start_watcher(arc)` | `JoinHandle<()>` | 启动文件监听线程(见 4.6.5) |
| `activate_conditional_for_paths(paths)` | `Vec<String>` | 激活匹配指定文件路径的条件 skill(见 4.6.6 |
| `matching_skills_for_paths(paths)` | `Vec<SkillMeta>` | 查询匹配指定文件路径的所有 skill(只读,不改变状态) |
### usage_score 算法
```
usage_score(name) = ln(1 + invoke_count) × 0.5^(age_hours / 168)
其中:
invoke_count — 从 record_usage() 累积
age_hours — 自 last_used_at 起的小时数(无记录时为 0.1)
168 — 7 天(半衰期),即每过 7 天权重衰减 50%
```
这是一个**指数衰减 + 对数压缩**的评分:频率越高、越近使用,评分越高。`refresh()` 按此评分**降序**排列 skills,使热技能优先出现在 remind 列表中。
## LoadSkillTool (`tools/skill.rs`)
实现 `AgentTool` traittool name = `"load_skill"`,参数:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `skill_name` | `string` | ✅ | 要加载的技能名称 |
| `max_steps` | `integer` | 否 | fork 模式下子代理最大步数,默认 5,上限 10 |
### 执行流程
```
execute(args, ctx)
├─ 1. 从 SkillRegistry 缓存读取 SkillRwLock::read
│ ├─ 命中 → 2
│ └─ 未命中 → 返回错误(含可用 skill 列表提示)
├─ 2. record_usage() 更新调用统计(RwLock::write
├─ 3. substitute_variables(body, skill_dir, session_id)
│ 替换 ${SKILL_DIR} → skill 目录绝对路径
│ 替换 ${SESSION_ID} → 当前会话 ID(无则清空)
├─ 4. 判断 context 模式
│ ┌─ context = "fork" ──────────────────────────────────────
│ │ • 构建子代理 system_prompt(含 skill 正文 + base_dir
│ │ • 调用 SubAgentRunner::run(system_prompt, task, max_steps)
│ │ • 返回格式:[子代理执行结果 - 技能: xxx]
│ │ • metadata: { context: "fork", execution_mode: "subagent", ... }
│ │
│ └─ context = "inline" (默认) ─────────────────────────────
│ • 格式化输出:# 技能:xxx (描述)\n\nBase directory: ...\n\nbody
│ • 附加 allowed_tools 白名单提示
│ • metadata: { context: "inline", execution_mode: "inline", ... }
└─ 5. 返回 ToolOutput
```
### 变量替换 (`substitute_variables`)
| 变量 | 替换目标 | 无值行为 |
|---|---|---|
| `${SKILL_DIR}` | skill 所在目录的绝对路径(如 `/app/skills/methodology` | 保持原样(`to_str()` 返回 None 时) |
| `${SESSION_ID}` | 当前 Agent 会话 ID | 清空为空字符串 |
> **已知问题**:当前 session_id 获取逻辑通过检查 `config.database_url.contains("session")` 来决定是否为 "current",这是一个脆弱的 hack,应改为从 `ToolContext` 直接读取 `ctx.session_id`。
## 热重载 (Hot Reload)
`SkillRegistry::start_watcher()` 使用 **`notify` crate** 实现事件驱动的文件监听:
```
┌──────────────────────────────────────────────────────────┐
│ notify 文件监听线程 │
│ │
│ watcher.watch(skills_dir, RecursiveMode::Recursive) │
│ │ │
│ ▼ │
│ 仅过滤 SKILL.md 文件变更事件 │
│ │ │
│ ▼ 发送 () 到 mpsc channel │
│ ┌─────────────────┐ │
│ │ 300ms debounce │ ← rx.recv_timeout(300ms) │
│ │ 合并连续变更 │ 第一个事件后等待 300ms │
│ └────────┬────────┘ 期间有新事件则重置计时器 │
│ │ │
│ ▼ │
│ registry.write().refresh() 完整重载 │
└──────────────────────────────────────────────────────────┘
```
| 属性 | 说明 |
|---|---|
| 实现方式 | `notify::recommended_watcher` 事件驱动(非定时轮询) |
| 监听范围 | `RecursiveMode::Recursive`(递归监听子目录) |
| 过滤条件 | 仅处理文件名 == `SKILL.md` 的事件 |
| 防抖窗口 | 300ms — 快速连续的变更合并为一次刷新 |
| 刷新策略 | **完整重载**(始终重新扫描整个目录),非增量 |
| 测试模式 | `#[cfg(test)]` 下为空实现(不启动线程) |
## 条件 Skill 激活 (Paths-based Activation)
部分 skill 通过 `paths` frontmatter 声明 glob 模式,初始状态 `disable_model_invocation = true`,仅在 Agent 访问匹配文件时激活。
**激活流程(`activate_conditional_for_paths`):**
```
当 Agent 通过 Read/Grep/Glob 访问文件时:
for each skill where paths is not empty AND disable_model_invocation == true:
if any(file_path matches any(pattern in skill.paths)):
skill.disable_model_invocation = false // 激活
log: "条件 skill '{name}' 已激活"
return newly_activated_skill_names
```
**匹配查询(`matching_skills_for_paths`):** 只读方法,返回匹配的 skill 列表而不改变激活状态,可用于向 LLM 提示当前上下文相关的 skill。
**Glob 匹配(`glob_match_simple`):**
| 通配符 | 匹配 | 示例 |
|---|---|---|
| `*` | 任意非 `/` 字符序列 | `"*.rs"``main.rs` |
| `**` | 任意字符(含 `/` | `"**/test/*"``src/test/foo` |
| `?` | 单个非 `/` 字符 | `"file_?.rs"``file_a.rs` |
使用 `glob::Pattern` crate,降级方案为简单字符串包含匹配。
## 系统集成
### 启动初始化 (`main.rs:151-163`)
```rust
// 1. 创建注册表
let skill_registry = Arc::new(RwLock::new(SkillRegistry::new(config.skills_dir.clone())));
// 2. 初始刷新
if let Ok(mut reg) = skill_registry.write() {
reg.refresh();
}
// 3. 启动文件监听(热更新)
let _watcher_handle = SkillRegistry::start_watcher(skill_registry.clone());
// 4. 注入 AppState
let app_state = Arc::new(AppState {
skill_registry, // Arc<RwLock<SkillRegistry>>
// ... 其他字段
});
```
### System Prompt 注入 (`runtime/mod.rs:1178-1187`)
每轮 LLM 请求构建 system prompt 时,从 SkillRegistry 读取 reminder 并注入为 "skills" section
```rust
if let Some(skills) = self.app_state.skill_registry
.read().ok()
.and_then(|r| r.build_reminder())
{
sp.add_section("skills", skills);
}
```
### System Prompt 静态指引 (`system_prompt.rs:64`)
```
7. 对于复杂任务(如文献综述),调用 load_skill 获取方法论指引,再用 todo_write 制定计划。
```
### 工具注册 (`tools/mod.rs:254`)
LoadSkillTool 在所有工具注册表中作为第 18 个工具注册(紧跟 CompressTool 之后):
```rust
Box::new(LoadSkillTool::new(skill_registry)),
```
### 共享范围
所有 Agent 组件共享同一个 `Arc<RwLock<SkillRegistry>>` 实例:
- 主 Agent`AgentRuntime`
- 子代理(`SubAgentRunner`
- 团队成员(`teammate.rs`
- 后台任务 Agent`background.rs`
## 与 Claude Code 参考设计的对应关系
| Claude Code 概念 | AstroResearch 实现 |
|---|---|
| `src/skills/` 目录 + `SKILL.md` | 完全相同 |
| YAML frontmattername, description, context, allowed-tools... | 相同,增加 `version``agent``effort``paths` 字段 |
| `<system-reminder>` Layer 1 注入 | `build_reminder()` → 结构化 XML 块 |
| `SkillTool` Layer 2 按需加载 | `LoadSkillTool`AgentTool trait 实现) |
| inline 模式(注入指令内容) | ✅ 实现 |
| fork 模式(子代理隔离执行) | ✅ 实现(SubAgentRunner |
| 热重载(目录监控) | ✅ `notify` crate + 300ms debounce |
| 调用统计 | ✅ 指数衰减评分 |
| 条件 skillpaths glob | ✅ `activate_conditional_for_paths()` |
| 变量替换 | ✅ `${SKILL_DIR}`, `${SESSION_ID}` |
| `Skill` 工具接口 + `skill` slash command | LoadSkillTooltool 形式),前端的 `/skill-name` 通过 tool 调用实现 |
---
+364
View File
@@ -0,0 +1,364 @@
# 子代理系统 (`subagent.rs` + `tools/subagent.rs`)
参考 Claude Code s04 Subagents 设计,实现**上下文隔离的子代理运行器**。父代理通过 `subagent` 工具将独立子任务委托给子代理执行,子代理拥有全新的消息上下文和完整的 ReAct 循环,仅将最终文本摘要返回给父代理。
核心价值:**防止父代理上下文被中间工具调用污染**,同时让子代理具备完整的工具能力(文献搜索、下载、RAG 检索、笔记等)来独立完成子任务。
## 架构总览
```mermaid
graph TB
subgraph Tool["SubAgentTool (tools/subagent.rs)"]
direction TB
T_name["工具名: 'subagent' (对外语义: delegate_research)"]
T_params["参数: research_prompt (必需), max_steps (默认5, 最大10)"]
T_interrupt["InterruptBehavior::Block — 需等待写操作完成"]
T_role["职责: 参数解析 → 构造 SubAgentRunner → 结果包装"]
end
Tool -->|"调用"| Runner
subgraph Runner["SubAgentRunner (subagent.rs)"]
direction TB
R_fields["字段:<br/>• app_state: Arc&lt;AppState&gt;<br/>• config: AgentConfig<br/>• tool_registry: ToolRegistry (与父代理共享)<br/>• hook_registry: Option&lt;Arc&lt;HookRegistry&gt;&gt;<br/>• permission_checker: Arc&lt;PermissionChecker&gt;<br/>• progress_tx: Option&lt;UnboundedSender&gt;<br/>• parent_session_id: String"]
R_ctors["三种构造方式:<br/>① new() — 最简构造<br/>② new_with_hooks() — 完整构造<br/>③ new_with_registry() — 自定义工具集"]
R_chain["链式配置:<br/>• with_parent_session()<br/>• with_thinking()"]
end
```
## 完整执行流程
```mermaid
sequenceDiagram
participant Lead as 父代理 (AgentRuntime)
participant Tool as SubAgentTool
participant Runner as SubAgentRunner
participant LLM as LLM API
participant FE as 前端 SSE
participant DB as SQLite
Lead->>Tool: subagent(research_prompt, max_steps?)
Tool->>Tool: 参数校验 + 构造 system_prompt
Tool->>Runner: new_with_hooks(app_state, hooks, pchecker, sse_tx)
Tool->>Runner: .with_parent_session(sid).with_thinking(bool)
Tool->>Runner: run(system_prompt, research_prompt, max_steps)
Note over Runner: Phase 1 — 初始化
Runner->>Runner: 生成 subagent_name = "sub_<uuid8>"
Runner->>Runner: 触发 OnSubagentStart hook
Runner->>DB: 保存 system + user 消息 (fire-and-forget)
Note over Runner: Phase 2 — run_inner() ReAct 循环
Runner->>Runner: 构建全新 messages: [system, user]
loop 每步迭代 (step ≤ max_steps)
Runner->>Runner: 上下文压缩检查 (est_tokens > limit × 1.5)
Runner->>LLM: chat_stream(messages, tool_defs, thinking?)
LLM-->>Runner: ReasoningDelta / TextDelta / ToolCallsComplete
Runner->>FE: Thought "[子代理] ..." (通过 progress_tx)
alt 无工具调用 → 最终答案
Runner->>FE: Thought (子代理结论)
Runner-->>Tool: ToolOutput::success([子代理活动记录] + [子代理结论])
else 有工具调用
Runner->>Runner: 死循环检测 (连续相同调用 ≥ 3 次 → 拦截)
loop 每个 tool_call (串行执行)
Runner->>FE: ToolCall "[sub] tool_name" (通过 progress_tx)
Runner->>Runner: PreToolUse hook (Block → 跳过, MutateInput → 修改参数)
Runner->>Runner: 权限检查 (is_denied → 跳过)
Runner->>Runner: tool.execute(args, ctx) (timeout 保护)
Runner->>Runner: PostToolUse hook (MutateOutput → 修改输出)
Runner->>Runner: 输出截断 (max_tool_output_chars)
Runner->>FE: ToolResult "[sub] tool_name" (通过 progress_tx)
Runner->>DB: 保存 assistant + tool 消息 (fire-and-forget)
Runner->>Runner: messages.push(ChatMessage::tool_result)
end
Runner->>Runner: messages.push(ChatMessage::assistant)
end
end
Note over Runner: Phase 3 — 达到最大步数
Runner->>LLM: force_final_answer (空工具列表, 无工具调用)
LLM-->>Runner: 最终文本答案
Note over Runner: Phase 4 — 收尾
Runner->>DB: 保存最终 assistant 消息
Runner->>Runner: 触发 OnSubagentStop hook
Runner-->>Tool: ToolOutput { content, metadata }
Tool->>Tool: 包装: "[子代理研究结果]\n\n{content}"
Tool-->>Lead: ToolOutput
```
## 核心机制详解
### 1. 上下文隔离
子代理拥有**全新的 `messages` 向量**,仅包含 system prompt + user prompt,不包含父代理的任何中间工具调用。父代理只会收到最终的文本摘要,中间的工具调用细节(搜索了什么、下载了什么)不会污染父上下文。
这是子代理系统最核心的价值——与直接在父代理中执行相比,子代理消耗的父上下文 token 是 O(1) 而不是 O(steps)。
```
父代理 context: 子代理 context:
┌─────────────────────┐ ┌─────────────────────────┐
│ system prompt │ │ system prompt │
│ user: "分析星系演化" │ │ user: "搜索星系演化的文献" │
│ assistant: tool_calls│ │ assistant: tool_call │
│ tool_result: [大文本] │ │ tool_result: [搜索结果] │
│ ... (越来越多) │ │ assistant: "找到 5 篇..." │
│ │ └─────────────────────────┘
│ ← 子代理结果注入这里 │ ↓
│ (仅有摘要,无中间步骤)│ 只返回文本摘要给父代理
└─────────────────────┘
```
### 2. 工具执行模式 — 串行
与父代理 `executor.rs` 使用 `FuturesUnordered` 的**并行**执行不同,子代理采用简单的 **for 循环串行**执行:
```rust
// src/agent/subagent.rs:395 — 串行 for 循环
for tool_call in &tool_calls {
let output = tool.execute(final_args, &tool_ctx).await;
messages.push(ChatMessage::tool_result(&tool_call.id, &truncated));
}
```
| 特性 | 父代理 (executor.rs) | 子代理 (subagent.rs) |
|:---|:---|:---|
| 执行方式 | `FuturesUnordered` 并行 | `for` 循环串行 |
| 结果推送 | 渐进式(快工具不等待慢工具) | 逐个完成 |
| FileStateCache | 支持(避免重复读文件) | 不支持 |
| 大结果持久化到磁盘 | `maybe_persist_tool_result` | 无(仅截断) |
| Sibling Abort | 支持(出错时中断兄弟任务) | 不支持 |
**设计考量**:子代理的 `max_steps` 通常为 5,任务规模较小,并行收益有限。串行实现更简单、更可预测,且每次工具调用之前会检查 PreToolUse hook 和权限,串行执行保证了 hook 决策的时序正确性。
### 3. 死循环检测
子代理独立维护 `last_call: Option<(String, String)>` 追踪上次的工具调用(名称 + 参数)。当连续 3 次相同调用时触发拦截:
```rust
// src/agent/subagent.rs:416-418
let call_key = (tool_name.clone(), tool_args_str.clone());
if last_call.as_ref() == Some(&call_key) {
consecutive_count += 1;
if consecutive_count >= duplicate_threshold { // threshold = 3
// 注入错误消息,强制 LLM 停止并给出答案
let error_msg = ChatMessage::tool_result(
&tool_call.id,
format!("工具 {} 被连续重复调用。请停止并给出当前收集到的答案。", tool_name),
);
messages.push(error_msg);
continue; // 跳过本次执行
}
} else {
last_call = Some(call_key);
consecutive_count = 1;
}
```
这与父代理的 `DuplicateDetector` 功能相同,但实现更简洁——子代理的场景简单,不需要全局去重表。
### 4. 上下文压缩
子代理的上下文在每步开始前检查,使用简单的字节长度估算:
```rust
// src/agent/subagent.rs:261-265
let est_tokens: usize = messages
.iter()
.map(|m| m.content.as_ref().map_or(0, |c| c.len()) + 4)
.sum();
if est_tokens > self.config.context_char_limit * 3 / 2 {
compact::compress_context(&mut messages, llm, config.context_char_limit, "subagent").await;
}
```
- 触发阈值:`context_char_limit * 1.5`(默认 ~24000 字符)
- 压缩方式:调用 `compact::compress_context`LLM 摘要压缩)
- 标识来源:`"subagent"`,区别于 `"lead"` / `"teammate"` / `"background"`
### 5. force_final_answer — 达到最大步数时的兜底
```rust
// src/agent/subagent.rs:563-600
async fn force_final_answer(&self, llm: &LlmClient, messages: &[ChatMessage]) -> ToolOutput {
let mut final_messages = messages.to_vec();
final_messages.push(ChatMessage::user(
"请根据已收集的信息直接给出最终答案,不要再调用工具。",
));
let empty_tools: Vec<ToolDefinition> = Vec::new(); // 不提供任何工具
let mut stream_rx = llm.chat_stream(&final_messages, &empty_tools, ...).await?;
// 收集 TextDelta 直到 Done
}
```
- 追加一条 user 消息明确要求停止工具调用
- 传递**空的工具定义列表**,从 API 层面禁止工具调用
- 返回 `metadata: { "forced": true }` 标记这是强制答案
### 6. 数据库持久化
所有子代理消息通过 `save_subagent_message` 写入 `agent_messages` 表,使用 fire-and-forget 模式(`tokio::spawn`):
```rust
// src/agent/subagent.rs:190-235
fn save_subagent_message(&self, agent_name: &str, turn_index: i32, step_index: i32,
role: &str, content: &str, ...) {
let metadata = serde_json::json!({ "agent": agent_name, "is_subagent": true });
tokio::spawn(async move {
let _ = sqlx::query(
"INSERT INTO agent_messages (session_id, turn_index, step_index, role,
content, thought, tool_calls, tool_call_id, token_count, metadata, agent_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&session_id)
.bind(turn_index) // 始终为 0
.bind(step_index) // 当前 step 编号
.bind(&role_owned) // system / user / assistant / tool
.bind(&content_owned)
// ...
.bind(&metadata_str) // {"agent":"sub_xxx","is_subagent":true}
.bind(&agent) // subagent_name
.execute(&db).await;
});
}
```
持久化的消息类型:
- **system** — 子代理系统提示词
- **user** — 研究任务描述
- **assistant** — 每步 LLM 响应(含 tool_calls JSON
- **tool** — 每个工具的执行结果(截断后)
- **assistant** — 最终结果(step_index = max_steps + 1
所有消息带 `{"is_subagent": true}` 元数据标记,`agent_name` 字段存放 `sub_<uuid8>` 名称,`turn_index` 始终为 0。
**设计考量**fire-and-forget 意味着持久化失败不会阻塞子代理执行。代价是极端情况下(DB 连接断开)可能丢失审计数据。
## Hook 集成
子代理在以下时机触发 Hook 事件:
| 事件 | 触发位置 | 上下文数据 | 用途 |
|:---|:---|:---|:---|
| `OnSubagentStart` | `run()` Phase 1 | parent_session_id, subagent_name, prompt | 审计、指标初始化 |
| `PreToolUse` | 每个工具执行前 | session_id="subagent", tool_name, args | 取消检查、参数修改 |
| `PostToolUse` | 每个工具执行后 | session_id="subagent", output_content, is_error | 指标采集、输出修改 |
| `OnSubagentStop` | `run()` Phase 4 | result_summary (前200字符), steps, is_error | 审计记录、数据汇总 |
`PreToolUse``PostToolUse` 在子代理的工具执行循环中**直接内联实现**(`subagent.rs:444-521`),而非通过 `executor.rs` 的并行管道。这意味着:
- PreToolUse 的 Block 操作 → 注入错误 tool_result 消息,跳过执行
- PreToolUse 的 MutateInput → 替换 final_args
- PostToolUse 的 MutateOutput → 替换 final_output_content
- Permission check → `is_denied` 直接拒绝
**与父代理的区别**:父代理通过 `executor::execute_parallel()` 统一处理 hooks + 并发,子代理在自身的 for 循环中手动调用 hook 方法。两者享有相同的 `HookRegistry` 实例(通过 `SubAgentRunner::new_with_hooks` 注入)。
## SubAgentTool 工具层
```rust
// src/agent/tools/subagent.rs
impl AgentTool for SubAgentTool {
fn name(&self) -> &str { "subagent" }
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"research_prompt": {
"type": "string",
"description": "要委托给子代理执行的完整研究任务描述..."
},
"max_steps": {
"type": "integer",
"description": "子代理最大推理步数,默认5,最大10",
"default": 5
}
},
"required": ["research_prompt"]
})
}
// 子代理可能执行写操作,中断时应阻塞以完成
fn interrupt_behavior(&self) -> InterruptBehavior {
InterruptBehavior::Block
}
}
```
`execute` 方法流程:
1. 参数校验(`research_prompt` 必填,`max_steps` 取 min(参数, 10)
2. 构造子代理 `system_prompt`(天体物理学研究助手,中文回答)
3. 创建 `SubAgentRunner::new_with_hooks()` — 注入 hooks、permissions、SSE 通道、session_id
4. 调用 `runner.run(system_prompt, &research_prompt, max_steps)`
5. 错误 → `ToolOutput::error("子代理执行失败: ...")`
6. 成功 → 包装为 `"[子代理研究结果]\n\n{content}"`
## 与 Team Teammate 的对比
两者都实现了"将工作委托给独立的 ReAct 循环",但设计上有本质差异:
| 特性 | SubAgentRunner | Team Teammate |
|:---|:---|:---|
| 文件位置 | `src/agent/subagent.rs` | `src/agent/team/teammate.rs` |
| 触发方式 | LLM 调用 `subagent` 工具 | Lead 通过文件收件箱发送任务 |
| 工具集 | 完整 ToolRegistry(含 subagent | 排除 subagent(防无限委托链) |
| Hook 管道 | 完整(PreToolUse/PostToolUse/Start/Stop | 无 |
| SSE 进度 | 支持(通过 progress_tx 透传) | 无 |
| DB 持久化 | 完整(agent_messages + agent_audit_log | 无 |
| 运行模式 | 同步:父代理等待子代理完成 | 异步:循环轮询收件箱 |
| 生命周期 | 一次性:任务完成即销毁 | 持续:SPAWN → WORKING → IDLE → SHUTDOWN |
| 嵌套能力 | 可递归(无深度限制) | 不可(排除 subagent 工具) |
| 上下文压缩 | 支持(compact::compress_context | 支持(同) |
| 死循环检测 | 支持(阈值=3) | 无独立检测 |
| 最大步数 | 默认 5,最大 10 | `max_steps.min(5)` |
| 取消支持 | 无独立取消信号 | `AtomicBool` 取消标志 |
**使用场景区分**
- **SubAgent** — 同步的、一次性的、需要完整工具能力的子任务("综述近 5 年星系演化的文献")
- **Teammate** — 异步的、持续性的、角色分工的并行协作("你负责搜索,他负责解析")
## 工具注册
子代理工具 `SubAgentTool``ToolRegistry::add_base_tools()` 中注册,随后被替换为带 hooks 的实例:
```rust
// src/agent/runtime/mod.rs:264
// AgentRuntime 初始化时用带 hooks 的版本替换默认的 SubAgentTool
tool_registry.replace_tool(
crate::agent::tools::subagent::SubAgentTool::new_with_hooks(
hook_registry.clone(),
permission_checker.clone(),
),
);
```
参数透传链路:
```
LLM 调用 subagent(research_prompt, max_steps)
→ SubAgentTool.execute(args, ToolContext)
→ SubAgentRunner::new_with_hooks(app_state, hooks, pchecker, ctx.sse_tx)
.with_parent_session(ctx.session_id)
.with_thinking(ctx.enable_thinking)
.run(system_prompt, research_prompt, max_steps)
```
## 当前局限与改进方向
| # | 问题 | 影响 | 改进方向 |
|:---|:---|:---|:---|
| 1 | **工具串行执行** | 同一步多个工具调用无法并行,慢工具阻塞快工具 | 复用 `executor::execute_parallel`,或至少对 `is_concurrency_safe()` 工具并行 |
| 2 | **无嵌套深度限制** | 子代理可调用 subagent 创建子子代理,理论上无限递归 | 增加深度计数器,超过 2 层时移除 subagent 工具 |
| 3 | **Token 估算粗糙** | `content.len() + 4` 对中文极不准确(中文 1 字符 ≈ 1.5-2 token | 使用 `tiktoken-rs` 或 tokenizer 精确计数 |
| 4 | **fire-and-forget 持久化** | DB 写入失败静默忽略,可能丢失审计数据 | 至少记录 warn 日志;关键消息可改为 await |
| 5 | **force_final_answer 不调温度** | 达到 max_steps 时模型可能仍尝试输出工具调用格式 | 降低 temperature 或增加 stop 序列 |
| 6 | **subagent_name 不可控** | 自动生成的 UUID 片段不利于日志可读性 | 允许 LLM 传入 `name` 参数作为标识 |
| 7 | **无 FileStateCache** | 子代理反复读取同一文件时会重复 I/O | 传入 FileStateCache 或使用全局缓存 |
| 8 | **步骤内消息顺序丢失** | 同一步的多个 tool 消息共享相同 step_index | 引入子序号(如 step_index.sub_index |
---
+359
View File
@@ -0,0 +1,359 @@
# 系统提示词架构 (System Prompt Architecture)
AstroResearch 的 Agent 系统提示词采用**模块化 Section 组装 + 动态注入 + 多层生命周期**架构,直接参考 Claude Code 的 System Prompt 设计。
## 整体分层
```mermaid
graph TB
subgraph L5["Layer 5: 运行时注入"]
Nudge["nudge / 任务恢复 / 后台通知"]
end
subgraph L4["Layer 4: 提示词压缩"]
Compress["snip → micro → auto → identity"]
end
subgraph L3["Layer 3: Skill 动态加载"]
Skill["Layer1 提醒 → Layer2 全文注入"]
end
subgraph L2["Layer 2: 子代理隔离提示词"]
SubSP["独立的 system_prompt"]
end
subgraph L1["Layer 1: 主代理 SystemPrompt 组装"]
MainSP["5 个 section 模块化组装"]
end
L5 --> L4 --> L3 --> L2 --> L1
```
---
## 核心组装器 (`src/agent/runtime/system_prompt.rs`)
### 2.1 数据结构
```rust
pub struct SystemPrompt {
sections: Vec<(&'static str, String)>,
}
```
简单的有序 section 列表,通过 `assemble()` 方法用双换行符 `"\n\n"` 拼接所有 section 内容。section 按添加顺序排列。
### 2.2 静态常量
两个 `&'static str` 常量在所有运行时实例间共享内存:
**IDENTITY_SECTION**(身份声明,1 行):
```
你是一位专业的天体物理学研究助手,具备丰富的天文学知识。
```
**PRINCIPLES_SECTION**(核心行为准则,9 条):
```
核心原则:
1. 主动使用工具搜索最新文献,不要仅凭训练数据回答。
2. 优先使用本地资源(get_paper_content / rag_search),必要时再检索新文献。
3. 收集到足够信息后立即给出最终答案,避免无意义的重复工具调用。
4. 回答时引用具体文献来源,使用 ADS bibcode 标注。
5. 对于数学公式,使用标准 LaTeX 格式。
6. 用中文回答,保持科学术语的准确性(可附带英文原文)。
7. 对于复杂任务(如文献综述),调用 load_skill 获取方法论指引,再用 todo_write 制定计划。
8. 如果某个工具调用失败,不要用相同参数重试,尝试换一种方式或工具。
9. 任务状态会在每轮开始时从数据库恢复,请基于最新状态继续工作。
```
### 2.3 组装顺序
每轮调用 `AgentRuntime::system_prompt()` 方法(`src/agent/runtime/mod.rs:1154-1203`),按以下顺序组装 5 个 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
```
**缓存策略**:静态 section 固定且不变化,放在 prompt 头部以最大化 Anthropic prompt cache 命中率。动态 sectiontools、skills、memory)因内容较少,对 cache 影响可控。principles 虽然静态但放在最后,当需要调优时仅破坏最后一个 cache segment。
---
## 动态 Section 详解
### 3.1 工具列表 (tools section)
```rust
let mut tools_desc = String::from("你可以使用以下工具:\n");
for def in self.tool_registry.definitions() {
let short_desc = 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));
}
```
- 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 中重复
### 3.2 技能列表 (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。
```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.
</system-reminder>
```
**Layer 2 (load_skill 工具)**LLM 按需调用 `load_skill(skill_name)` 工具,从 `skills/{name}/SKILL.md` 加载完整内容(YAML frontmatter + Markdown body),注入到消息上下文。完整 skill 约 ~2000 tokens。
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
---
# Skill 正文
详细内容...
```
**热重载**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
---
## 上下文初始化与运行时注入 (`src/agent/runtime/context.rs`)
### 4.1 上下文构建流程
`build_initial_context()` 在每轮开始时构建完整的消息列表:
```
1. 从数据库加载历史消息(agent_messages 表)
2. 如果历史第一条不是 system 角色 → 在位置 0 插入系统提示词
3. 追加当前用户问题
4. [可选] 追加任务状态恢复提醒(从 agent_tasks 表读取)
```
### 4.2 任务状态恢复
`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" |
---
## 子代理的独立系统提示词 (`src/agent/tools/subagent.rs`)
子代理拥有独立的消息上下文,通过 `SubAgentRunner::run()` 接收一个**硬编码的简化版系统提示词**:
```
你是一位专业的天体物理学研究助手,在一个独立的子任务上下文中工作。
你可以使用文献搜索、下载、RAG检索等工具。
请高效完成任务,然后直接给出最终答案。不要进行不必要的重复操作。
用中文回答,引用具体文献来源。
```
特点:
- 不继承父代理的 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 秒)
---
## 上下文压缩中的独立提示词 (`src/agent/compact.rs`)
### 6.1 四层压缩策略
| 层 | 方法 | 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 条工具结果,其余替换为占位符 |
### 6.2 LLM 摘要 Prompt
Layer 2 中调用 LLM 生成摘要时,使用独立的系统提示词:
```
系统: "你是一个对话摘要助手。请提取对话的关键信息和结论。"
用户: "请用简洁的中文总结以下对话历史的要点(不超过500字):
[用户] ...
[助手] ...
[工具] ..."
```
### 6.3 身份再注入
如果压缩后消息过少(≤4 条),注入身份确认块防止模型丢失上下文认知:
```
[身份确认] 你是一位专业的天体物理学研究助手。以上是历史对话的压缩摘要。
你正在进行的研究任务是回答用户的问题。请基于摘要中的关键信息继续工作,
需要更多信息时主动使用工具搜索。
```
### 6.4 安全切割
`find_safe_cut_point()` 确保压缩时不会破坏 `assistant(tool_calls)` / `tool_result` 配对关系,向前追溯找到完整工具交互的边界。
### 6.5 熔断器
`CompactionCircuitBreaker` 防止连续压缩失败时的无限循环。连续 3 次压缩后消息数未减少 → 打开熔断器,后续跳过自动压缩。
---
## Hook 系统与提示词的交互 (`src/agent/hooks.rs`)
Hook 系统定义 9 个生命周期事件,其中与提示词相关的交互:
| Hook | 与提示词的关系 |
|:---|:---|
| `OnSessionStart` | 在提示词组装前触发,可影响任务状态恢复逻辑 |
| `PreToolUse::MutateInput` | 可向工具执行注入 `additional_context`(作为 user 消息追加) |
| `PreToolUse::Block` | 阻止特定工具的执行(如取消检查) |
| `PostToolUse::MutateOutput` | 可修改工具输出内容(影响后续 LLM 看到的 context |
| `OnStepComplete` | 每步结束记录 token 估算、消息数等指标 |
| `PreCompact` | 压缩前记录消息数和 token 估算 |
| `PostCompact` | 压缩后记录最终消息数和压缩方法 |
| `OnSubagentStart/Stop` | 子代理启动/停止时传递 prompt 和结果摘要 |
| `OnSessionStop` | 会话终止时清理取消状态并记录终止原因 |
---
## 完整数据流
```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"]
```
---
## 相关文件
| 文件 | 职责 |
|:---|:---|
| `src/agent/runtime/system_prompt.rs` | SystemPrompt 组装器 + 静态常量 |
| `src/agent/runtime/mod.rs:1154-1203` | `system_prompt()` 方法 — 5 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 + 提示词交互 |
---
## 设计要点
### 优势
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 预生成工具描述摘要
+233
View File
@@ -0,0 +1,233 @@
# 任务系统 (Task System)
任务系统为 Agent 提供 **规划 → 执行跟踪 → 状态持久化 → 跨 turn 恢复** 的完整闭环,参考 Claude Code s12 Task System 和 s17 Autonomous Agents 设计。
---
## 概览
```mermaid
flowchart TD
subgraph RT["AgentRuntime (run_react_loop)"]
direction TB
S1["1. LLM 调用"]
S2["2. 检测 todo_write 调用"]
S3["3. persist_tasks() 持久化"]
S4["4. 每 3 步 nag reminder"]
S5["5. context.rs 恢复任务状态"]
end
RT --> TodoWrite["TodoWriteTool<br/>(纯格式化, 并发安全, 无副作用)"]
RT --> Context["context.rs<br/>restore_tasks_from_db()<br/>(每 turn 开始时注入)"]
RT --> TaskBoard["TaskBoard<br/>(跨 session 共享看板)<br/>can_start() / claim_task() (原子)<br/>list_available_tasks()"]
TodoWrite --> Persist["persist_tasks<br/>INSERT OR REPLACE INTO<br/>agent_tasks"]
Persist --> DB["SQLite: agent_tasks<br/>(session_id, task_id UNIQUE)"]
```
---
## 数据库 Schema
```sql
-- migrations/20260616000000_agent_tasks.sql
CREATE TABLE agent_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
task_id TEXT NOT NULL, -- LLM 生成的任务唯一标识
content TEXT NOT NULL DEFAULT '', -- 任务描述
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending', 'in_progress', 'completed')),
blocked_by TEXT NOT NULL DEFAULT '[]', -- JSON 数组: ["task_1", "task_2"]
owner TEXT NOT NULL DEFAULT '', -- 归属 agent (lead / sub_xxx / teammate)
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (session_id) REFERENCES agent_sessions(session_id) ON DELETE CASCADE
);
-- 索引
CREATE UNIQUE INDEX idx_agent_tasks_session_task ON agent_tasks(session_id, task_id);
CREATE INDEX idx_agent_tasks_session ON agent_tasks(session_id);
CREATE INDEX idx_agent_tasks_status ON agent_tasks(session_id, status);
```
关键设计点:
- **(session_id, task_id) 联合唯一索引**:支持 `INSERT ... ON CONFLICT DO UPDATE` 的 upsert 语义
- **`blocked_by` 存 JSON 数组**:如 `["1", "2"]` 表示依赖任务 1 和 2 必须先完成,形成 DAG
- **`owner` 字段**`migrations/20260618000000_agent_identity.sql` 引入):支持多 Agent 场景下的任务归属
---
## 任务状态机
```mermaid
stateDiagram-v2
[*] --> pending: 初始状态
pending --> pending: LLM 标记 completed<br/>(回退)
pending --> in_progress: claim_task()<br/>或 LLM 标记 in_progress
in_progress --> completed: LLM 标记 completed
completed --> [*]: 终点状态
note right of in_progress: 同一时刻只能有一个
```
约束规则:
1. **最多一个 `in_progress`**TodoWriteTool 在格式化输出时检测并警告(`tools/todo.rs:115-118`
2. **DAG 依赖**TaskBoard::can_start() 检查所有 blockedBy 依赖是否为 completed`task_board.rs:33-63`
3. **原子认领**claim_task() 使用乐观并发控制,`WHERE owner IS NULL` 保证不被重复认领
4. **应用层验证**persist_tasks 只做自引用检测,不做完整的循环依赖检测(文档明确标注)
---
## 核心组件
### TodoWriteTool (`tools/todo.rs`)
LLM 通过 function calling 调用的工具,声明参数:
```json
{
"todos": [
{"id": "1", "content": "搜索相关文献", "status": "completed"},
{"id": "2", "content": "分析论文方法", "status": "in_progress", "blockedBy": ["1"]},
{"id": "3", "content": "撰写综述", "status": "pending", "blockedBy": ["2"]}
]
}
```
**设计原则 — 工具层与持久化层分离**
- `TodoWriteTool::execute()` 只做格式化和约束校验,不写数据库
- `is_concurrency_safe()` 返回 `true`(纯格式化,无副作用)
- 实际的 SQLite 持久化由 `AgentRuntime::run_react_loop()` 在检测到 `todo_write` 调用后统一完成(`runtime/mod.rs:845-854`
### persist_tasks() (`tools/todo.rs`)
```rust
pub async fn persist_tasks(
db: &SqlitePool,
session_id: &str,
todos: &[serde_json::Value],
owner: &str, // "lead" / "sub_xxx" / teammate name
) -> anyhow::Result<()>
```
- 使用 `INSERT ... ON CONFLICT(session_id, task_id) DO UPDATE SET ...` 实现 upsert
- 自动跳过任务对自身的引用(`blockedBy` 中包含自身 ID 时忽略并记录 warn)
- 作为公开 API 导出(`tools/mod.rs:49`),允许 teammate、外部调用者直接操作
### TaskBoard (`task_board.rs`)
共享任务看板,提供跨 Agent 的任务可见性:
| 方法 | 功能 | 并发策略 |
|:---|:---|:---|
| `can_start(session_id, task_id)` | 遍历 blockedBy,检查所有依赖是否 completed | 只读,天然安全 |
| `claim_task(session_id, task_id, claimant)` | 原子认领(status→in_progress, owner→claimant | `WHERE owner IS NULL OR owner = ''` |
| `list_available_tasks(limit)` | 列出所有可认领的 pending 任务,**跨 session** | 只读,附加 can_start 解析 |
认领 SQL 的原子性保证:
```sql
UPDATE agent_tasks SET owner = ?, status = 'in_progress'
WHERE session_id = ? AND task_id = ?
AND (owner IS NULL OR owner = '' OR status = 'pending')
```
`rows_affected() > 0` 表示认领成功,否则已被他人认领。
---
## ReAct 循环中的集成点
`AgentRuntime::run_react_loop()` 中有三个关键集成点:
### todo_write 检测与持久化 (`mod.rs:752-855`)
```rust
// 检测 todo_write 调用
let called_todo_write = tool_calls.iter().any(|tc| tc.function.name == "todo_write");
if called_todo_write {
steps_since_last_todo = 0; // 重置 nag 计数器
}
// 工具执行完成后持久化
if called_todo_write {
for prep in &prepared_calls {
if prep.tool_name == "todo_write" {
if let Some(todos) = prep.args.get("todos").and_then(|t| t.as_array()) {
let _ = persist_tasks(db, sid, &todos_vec, "lead").await;
}
}
}
}
```
### 进度催促 Nag Reminder (`mod.rs:589-596`)
每 3 步未调用 `todo_write`,自动向 LLM 注入提醒:
```
提醒:你已经连续多步未更新任务计划。建议调用 todo_write 工具复盘当前进度并规划后续步骤。
```
`nag_after_steps = 3``steps_since_last_todo` 在每次非 todo_write 调用后递增。
### 跨 Turn 任务恢复 (`context.rs:32-34`)
每个新 turn 开始时,`restore_tasks_from_db()` 从 agent_tasks 表查询当前 session 的所有任务,格式化后注入 LLM 上下文:
```
[当前任务状态]
以下是上次会话中持久化的任务计划,请基于最新状态继续工作:
✅ [1] 文献检索
✅ [2] 文献分析
🔄 [3] 撰写综述 (依赖: 1,2)
⏳ [4] 最终校对 (依赖: 3)
使用 todo_write 工具更新任务进度。
```
System prompt 中也包含提示(`system_prompt.rs:66`):
> 任务状态会在每轮开始时从数据库恢复,请基于最新状态继续工作。
---
## 子代理与任务隔离
子代理 (`SubAgentRunner`) 与父代理在 Task 层面保持隔离:
- 子代理拥有独立的 ToolRegistry 和 ReAct 循环(`subagent.rs:248-254`
- 子代理消息标记 `agent_name = "sub_xxx"`,父代理加载历史时只加载 `agent_name = "lead"` 的消息(`session.rs:78`
- 子代理不直接操作父代理的 agent_tasks,只返回文本摘要
- `agent_tasks.owner` 字段已为 teammate 场景预留
---
## 辅助机制:工具结果持久化 (`tools/persist.rs`)
与 Task 系统协同工作的工具输出持久化:
| 维度 | Task 系统 | Persist 系统 |
|:---|:---|:---|
| 回答的问题 | "要做什么" | "看到了什么" |
| 触发条件 | LLM 调用 todo_write | 工具输出超过 max_output_chars |
| 存储位置 | SQLite agent_tasks 表 | 磁盘文件 |
| 幂等保证 | ON CONFLICT DO UPDATE | 独占创建 (create_new) |
当工具输出超过 `AGENT_MAX_TOOL_OUTPUT_CHARS`(默认 4000)时,完整内容写入 `{library_dir}/tool-results/{tool_call_id}.txt`,返回 `<persisted-output>` XML 占位符,LLM 后续可通过 `read_file` 读取完整内容。
---
## 相关文件
| 文件 | 职责 |
|:---|:---|
| `src/agent/tools/todo.rs` | TodoWriteTool 定义 + persist_tasks() |
| `src/agent/task_board.rs` | TaskBoard — 跨 session 共享看板 |
| `src/agent/runtime/mod.rs` | ReAct 循环中的 nag/persist 集成 |
| `src/agent/runtime/context.rs` | restore_tasks_from_db — 跨 turn 恢复 |
| `src/agent/runtime/system_prompt.rs` | 系统提示词中的 Task 指引 |
| `src/agent/tools/persist.rs` | 工具输出磁盘持久化(辅助机制) |
| `src/agent/subagent.rs` | 子代理中的 Task 隔离 |
| `migrations/20260616000000_agent_tasks.sql` | agent_tasks 表 DDL |
| `migrations/20260618000000_agent_identity.sql` | owner 字段引入 |
+232
View File
@@ -0,0 +1,232 @@
# 多 Agent 团队 (`team/`)
基于**文件邮箱 (file inbox)** 的轻量级多 Agent 协作系统,参考 Claude Code Agent Teams 设计。
## 架构总览
```mermaid
graph LR
subgraph Team["src/agent/team/"]
direction TB
Mod["mod.rs (11 lines)<br/>模块声明与重导出"]
Config["config.rs (53 lines)<br/>TeamConfig, MemberConfig, MemberStatus"]
Inbox["inbox.rs (110 lines)<br/>文件邮箱 (append-only JSONL + drain)"]
Manager["manager.rs (205 lines)<br/>spawn/stop/send/broadcast/check_inbox/list"]
Teammate["teammate.rs (270 lines)<br/>队友 ReAct 循环<br/>(idle poll → react turn → report result)"]
end
Tools["src/agent/tools/team.rs (292 lines)<br/>4 个团队工具暴露给 Lead Agent"]
Manager --> Tools
Teammate --> Manager
Inbox --> Manager
Inbox --> Teammate
```
```mermaid
graph TD
subgraph Lead["Lead Agent (AgentRuntime)"]
LR["ReAct Loop"]
TR["ToolRegistry"]
end
subgraph TM["TeamManager"]
Config["TeamConfig<br/>session_id + members[]"]
Handles["HashMap&lt;name, TeamMemberHandle&gt;"]
end
subgraph InboxFS[".team/{session_id}/inbox/"]
L["lead.jsonl"]
S["searcher.jsonl"]
R["reader.jsonl"]
end
subgraph Teammates["Teammates (tokio::spawn)"]
T1["searcher<br/>ReAct loop<br/>max 5 steps"]
T2["reader<br/>ReAct loop<br/>max 5 steps"]
end
LR -->|"spawn_teammate"| TM
LR -->|"send_teammate_message"| S
LR -->|"check_team_inbox"| L
LR -->|"team_broadcast"| S
LR -->|"team_broadcast"| R
TM -->|"tokio::spawn"| T1
TM -->|"tokio::spawn"| T2
T1 -->|"append result"| L
T2 -->|"append result"| L
T1 -->|"drain inbox"| S
T2 -->|"drain inbox"| R
```
## 通信机制:文件邮箱
消息传递不通过 channel 或共享内存,而是通过 **append-only JSONL 文件**
```
.team/{session_id}/inbox/
├── lead.jsonl ← 队友将结果写入此处
├── searcher.jsonl ← Lead 将任务写入此处
└── reader.jsonl ← Lead 将任务写入此处
```
### 消息类型 (`TeamMessage`)
| 字段 | 类型 | 说明 |
|:---|:---|:---|
| `from` | `String` | 发送者名称 |
| `to` | `String` | 接收者名称 |
| `content` | `String` | 消息内容 |
| `msg_type` | `TeamMessageType` | `task` / `result` / `question` / `answer` / `status` |
| `timestamp` | `String` | ISO 8601 时间戳 (UTC) |
### 核心操作
- **`append_message(team_dir, agent_name, msg)`**:追加一行 JSON 到接收者文件,自动创建目录
- **`drain_inbox(team_dir, agent_name)`**:读取所有消息行 → 清空文件 → 返回 Vec(破坏性读取)
- **`has_pending(team_dir, agent_name)`**:检查文件是否存在且非空(用于快速轮询)
> **已知问题**`drain_inbox()` 在 `read_to_string` 和 `write("")` 之间存在 TOCTOU 竞态条件。若清空前有新消息追入,该消息会丢失。生产环境应使用 advisory file lock 或原子 rename 替代。
## 队友生命周期
```
┌──────────┐
│ SPAWNING │ ← Manager 创建句柄,tokio::spawn 启动循环
└────┬─────┘
┌──────────┐ 有新消息到达 ┌──────────┐
│ IDLE │ ─────────────────→ │ WORKING │
│ │ ←──────────────── │ │
│ 5s×12 │ 任务完成/超时 │ ReAct │
│ 轮询 │ │ max 5步 │
└────┬─────┘ └──────────┘
│ 收到取消信号
┌──────────┐
│ SHUTDOWN │ ← 发送告别 Status 消息给 Lead,退出循环
└──────────┘
```
### IDLE 阶段细节 (`teammate.rs:68-92`)
1. 设置状态为 `Idle`
2. `drain_inbox()` 检查是否有待处理消息
3. 若为空:以 **5 秒间隔轮询 `has_pending()`**,最长 60 秒(12 次 × 5 秒)
4. 在轮询中,若 `cancelled` 标志被设置或 `has_pending()` 返回 true,则提前退出
5. 超时无消息 → 回到步骤 2
6. 有新消息 → 进入 WORKING
### WORKING 阶段细节 (`teammate.rs:103-152`)
1. 设置状态为 `Working`
2. 将收件箱消息作为 `user` 角色消息注入到队友的对话历史中
3. 调用 `run_teammate_react_turn()`:最多 **5 步**的简化 ReAct 循环
4. 若返回结果 → 封装为 `TeamMessageType::Result` 发送到 `lead` 的收件箱
5. 若返回 `None`(错误/超时)→ 静默回到 IDLE(**不通知 Lead 出错**
## 队友 ReAct 循环 vs 主 Agent 循环
| 特性 | 主 Agent (Lead) | 队友 (Teammate) |
|:---|:---|:---|
| 最大步数 | 8(可配置) | 5(硬编码 `min(config.max_steps, 5)` |
| 流式输出 | SSE 实时推送到前端 | 无 SSE,静默消费 |
| 工具注册表 | 完整(含 subagent、team 工具) | 排除了 subagent 和 team 工具 |
| 上下文压缩 | 四层压缩 + CircuitBreaker | 简单 token 估算 + `compact::compress_context` |
| Hooks | PreToolUse / PostToolUse / Stop | 无 |
| 权限检查 | PermissionChecker | 无 |
| 数据库持久化 | agent_messages 表 | 无 |
| 后台通知 | BgNotificationQueue | 有 Queue 但未使用 |
| Skills | 两层加载 (list + load_skill) | 无,skill_registry 仅用于工具构造 |
队友的工具注册表构造 (`teammate.rs:40-41`)
```rust
let tool_registry =
ToolRegistry::new_with_queue(Some(queue.clone()), app_state.skill_registry.clone());
```
`ToolRegistry::new_with_queue()` 不注册 subagent 和 team 工具,防止无限委托链。
## TeamManager 设计
### 锁顺序约定 (CRITICAL)
存在两个 `tokio::sync::Mutex` 的嵌套获取:
1. `TeamManager.handles`(外层)
2. `TeamMemberHandle.status`(内层)
所有代码必须遵守 **handles → status** 的顺序,否则死锁。`list_members()` 作为正确顺序的参考实现。
### 主要操作
| 操作 | 说明 | 锁行为 |
|:---|:---|:---|
| `spawn(name, role)` | tokio::spawn 队友循环,注册句柄 | 获取 handles 锁 |
| `stop(name)` | 设置 cancelled 标志 | 获取 handles 锁 |
| `stop_all()` | 停止所有队友 | 获取 handles 锁 |
| `send_message(from, to, content, type)` | 追加消息到接收者收件箱文件 | 无锁(纯文件 I/O) |
| `broadcast(from, content)` | 向 config.members 中所有非发送者成员追加消息 | 无锁(纯文件 I/O) |
| `check_inbox(agent_name)` | drain_inbox 并返回消息 | 无锁(纯文件 I/O) |
| `list_members()` | 获取所有队友的 (name, role, status) | handles → 各 status(正确顺序) |
### 队友系统提示词 (`manager.rs:70-93`)
队友收到的是天体物理学研究助手角色设定 + 工具使用指引 + 通信协议说明。系统提示词始终通过 `build_teammate_system_prompt(role)` 生成,**不使用** `MemberConfig.system_prompt` 字段。
## 4 个团队工具
工具定义在 `src/agent/tools/team.rs`,均设置 `InterruptBehavior::Block`(中断时先完成副作用再停止):
| 工具名 | 参数 | 功能 | 接收者 |
|:---|:---|:---|:---|
| `spawn_teammate` | `name`, `role` | 启动一个后台队友 | — |
| `send_teammate_message` | `to`, `content` | 向指定队友发送消息 | 队友收件箱 |
| `team_broadcast` | `content` | 向所有队友广播消息 | 所有队友收件箱 |
| `check_team_inbox` | `agent_name?` (默认 "lead") | 读取并清空收件箱 | Lead 收件箱 |
典型协作流程:
```
1. Lead 调用 spawn_teammate(name="searcher", role="ADS文献搜索专家")
2. Lead 调用 send_teammate_message(to="searcher", content="搜索2024年暗物质间接探测综述")
3. Lead 继续自己的 ReAct 循环(可以同时发起另一个 spawn_teammate
4. 队友在后台执行 ReAct 循环(最多5步),完成后将结果写入 lead.jsonl
5. Lead 调用 check_team_inbox() 读取队友结果
6. Lead 综合队友结果,给出最终回答
```
## 与子代理 (`subagent`) 的对比
代码库中存在**两种委托机制**,适用不同场景:
| | 子代理 (`subagent`) | 团队 (`team`) |
|:---|:---|:---|
| 执行模式 | 同步:Lead 等待完成 | 异步:后台运行,Lead 主动轮询 |
| 并发度 | 每次调用 1 个 | 可同时运行多个队友 |
| 上下文 | 任务完成后丢弃 | 跨任务累积(直到压缩) |
| 通信方式 | 工具调用 → 返回摘要 | 文件邮箱:写入/轮询 |
| Hooks | PreToolUse / PostToolUse / SubagentStart / SubagentStop | 无 |
| 权限检查 | PermissionChecker | 无 |
| 进度透传 | SSE 事件到父代理 | 无 |
| DB 持久化 | agent_messages 表 | 无 |
| 适用场景 | "去完成这个子任务,给我摘要" | "留在后台,持续处理我分配的任务" |
## 当前状态:未接入
> **重要**`TeamManager` 和 4 个团队工具已完整实现,但当前**未接入 AgentRuntime**。
>
> `AgentRuntime::new()` 调用 `ToolRegistry::new_with_queue()`,而非 `ToolRegistry::new_with_team()`。团队工具通过独立的 `add_team_tools()` 函数接入,但该函数在当前代码路径中从未被调用。
>
> 实际可用的委托功能由 `subagent` 工具提供(通过 `SubAgentTool` 已接入)。
>
> 接入方法:在 `AgentRuntime::new()` 和 `with_config()` 中,创建 `TeamManager` 实例并用 `ToolRegistry::new_with_team()` 替代 `new_with_queue()`。
## 改进建议
1. **修复 drain_inbox 竞态条件**:使用 advisory file lock 或原子 rename
2. **队友错误传播**`run_teammate_react_turn()` 返回 `None` 时应通知 Lead
3. **消息关联 ID**:添加 `correlation_id` 字段,支持请求-响应匹配
4. **降低锁持有时间**:将 `send_message` / `broadcast` 的文件 I/O 移出锁临界区(当前已是无锁,但工具层仍持有 `team_manager` 锁)
5. **队友名称去重**:spawn 同名队友前检查是否已有活跃队友
6. **会话级清理**Agent 会话结束时调用 `stop_all()` 清理队友和收件箱文件
+425
View File
@@ -0,0 +1,425 @@
# 工具系统 (Tool System)
Agent 工具系统是 ReAct 循环中 **Action → Observation** 环节的执行引擎。每个工具遵循 `AgentTool` trait 向 LLM 暴露 JSON Schema 参数定义,并在 `execute` 中调用服务层完成实际业务操作。
---
## 三层架构
```mermaid
graph TB
subgraph Layer1["调度层 — AgentRuntime"]
direction LR
RT["src/agent/runtime/mod.rs<br/>ReAct 循环<br/>调度 LLM tool_calls → 工具执行 → 结果注入上下文"]
end
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"]
end
subgraph Layer3["业务逻辑层 — AgentTool Trait + 工具实现"]
direction LR
Tools["src/agent/tools/<br/>每个工具独立子模块,实现 AgentTool trait"]
end
Layer1 --> Layer2 --> Layer3
```
---
## AgentTool Trait
```mermaid
classDiagram
class AgentTool {
<<interface>>
+name() &str
+description() &str
+parameters() Value
+execute(args, ctx) ToolOutput
+interrupt_behavior() InterruptBehavior
+is_concurrency_safe(args) bool
+check_permissions(args) PermissionRule[]
+causes_sibling_abort() bool
+execute_with_progress(args, ctx, tx) ToolOutput
}
class InterruptBehavior {
<<enumeration>>
Cancel — 可安全中断(只读工具默认)
Block — 忽略中断信号直到完成(写入工具)
}
class PermissionRule {
<<enumeration>>
Deny — 不可覆盖的拒绝
Allow — 显式允许
Ask — 需用户确认
}
class ToolContext {
+Arc~AppState~ app_state
+String session_id
+bool silent
+Arc~Mutex~FileStateCache~~ read_file_state
+Option~UnboundedSender~ sse_tx
+bool enable_thinking
}
class ToolOutput {
+String content — 给 LLM 的截断文本
+bool is_error — 是否为错误
+Value metadata — 给前端的结构化数据
}
class ToolRegistry {
-HashMap~String, Box~AgentTool~~ tools
-Vec~String~ ordered_names
+empty() Self
+new(skill_registry) Self
+new_with_queue(queue, skill_registry) Self
+new_with_team(queue, team_manager, skill_registry) Self
+add_tool(tool)
+replace_tool(tool)
+get(name) Option~&dyn AgentTool~
+definitions() Vec~ToolDefinition~
}
AgentTool --> InterruptBehavior
AgentTool --> PermissionRule
AgentTool --> ToolContext
AgentTool --> ToolOutput
```
### 方法详解
| 方法 | 返回类型 | 默认值 | 说明 |
|:---|:---|:---|:---|
| `name()` | `&str` | **(必须)** | 工具名称,全小写下划线风格,与 LLM function calling `name` 一致 |
| `description()` | `&str` | **(必须)** | 告知 LLM 何时应调用该工具;作为 base description 注入 system prompt |
| `parameters()` | `Value` | **(必须)** | JSON Schema 格式的参数定义,直接序列化为 LLM tool definition |
| `execute(args, ctx)` | `ToolOutput` | **(必须)** | 核心执行逻辑,接收 LLM 传入的参数 + `ToolContext` 全局状态 |
| `interrupt_behavior()` | `InterruptBehavior` | `Cancel` | 控制用户取消时的行为:`Cancel`—响应取消信号立即返回错误;`Block`—忽略取消直到执行完成(保护有副作用的写入操作) |
| `is_concurrency_safe(args)` | `bool` | `false` | 该工具是否可以与其他工具并发执行。**只读工具**(`read_file``search_papers``rag_search` 等)覆写为 `true`;写入工具保持默认 `false` |
| `check_permissions()` | `Vec<PermissionRule>` | `[]` | 工具自定义权限规则:`Deny{tool, reason}` / `Allow{tool}` / `Ask{tool, message}`。与 `PermissionChecker` 管道协同工作 |
| `causes_sibling_abort()` | `bool` | `false` | 该工具错误时是否中止兄弟并行执行。用于 `download_paper``parse_paper` 等关键工具 |
| `execute_with_progress(args, ctx, tx)` | `ToolOutput` | 委托 `execute()` | 长时间操作可覆写,通过 `progress_tx` 发送进度更新到前端 |
---
## 工具清单(23 个)
### 文件系统工具(6 个)— `filesystem/`
| 工具 | 并发安全 | 中断行为 | 功能 |
|:---|:---|:---|:---|
| `read_file` | ✅ | Cancel | 读取文件,带 `FileStateCache` mtime 去重;相同 offset/limit 且文件未修改时返回 `FILE_UNCHANGED_STUB` 占位符 |
| `grep_files` | ✅ | Cancel | 正则搜索文件内容 |
| `glob_files` | ✅ | Cancel | 通配符匹配文件路径 |
| `run_bash` | ❌ | Cancel | Shell 命令执行,有独立超时和输出截断 |
| `file_write` | ❌ | Block | 创建/覆盖文件 |
| `file_edit` | ❌ | Block | 精确字符串替换(基于 `old_string` 匹配) |
**安全约束** (`filesystem/security.rs`)
- **路径沙箱**:只允许 `library_dir``skills_dir`、项目根目录三个根路径
- **路径穿越防护**:字符串级拒绝含 `..``~` 的路径
- `canonicalize()` 解析符号链接后再做前缀匹配,防止 symlink 绕过
### 天文科研工具(8 个)— `astro/`
| 工具 | 并发安全 | 功能 |
|:---|:---|:---|
| `search_papers` | ✅ | ADS + arXiv 跨库联合检索,自动合并去重,关联本地馆藏状态与引用关系 |
| `get_paper_metadata` | ✅ | 获取单篇文献完整元数据(作者、期刊、关键词、摘要、引用数、下载/解析状态) |
| `download_paper` | ❌ | PDF 下载,支持 Obscura 反爬绕过和多通道 fallback |
| `parse_paper` | ❌ | PDF/HTML → Markdown 解析(MinerU/ar5iv/IOP/A&A 等多引擎) |
| `get_paper_content` | ✅ | 读取已解析的论文全文 Markdown |
| `rag_search` | ✅ | 向量相似度检索 + LLM 答案生成(基于 `sqlite-vec` |
| `query_target` | ✅ | CDS Sesame 天体目标查询(IAU 名称解析 + 坐标/类型) |
| `save_note` | ❌ | 高亮批注持久化到数据库 |
### Agent 自管理工具(5 个)
| 工具 | 并发安全 | 中断行为 | 功能 |
|:---|:---|:---|:---|
| `todo_write` | ✅ | Cancel | 任务规划,支持 `pending/in_progress/completed` 状态 + `blockedBy` DAG 依赖;验证只能有一个 in_progress 任务 |
| `compress_context` | ✅ | Cancel | 设置手动压缩标志位,下一轮 LLM 调用前由 Runtime 执行压缩(纯幂等操作) |
| `load_skill` | ✅ | Cancel | 按需加载 SKILL.md 技能文件。支持 **inline** 模式(直接返回内容)和 **fork** 模式(启动子代理按技能指引执行任务) |
| `save_memory` | ❌ | Block | 跨会话记忆持久化。写入时门控:质量检查(过短/模糊/瞬时/代码模式)+ Jaccard 70% 去重 |
| `ask_user` | N/A | Block | 暂停 ReAct 循环向用户提问。oneshot 通道机制:创建问题 → SSE 推送前端 → 阻塞等待 → 5 分钟超时。子代理中不可用(silent 模式) |
### 高级编排工具(4 个)
| 工具 | 并发安全 | 中断行为 | 功能 |
|:---|:---|:---|:---|
| `subagent` | ❌ | Block | 上下文隔离的子代理。子代理拥有完整工具访问权,但仅最终摘要返回父代理。支持自定义 `max_steps`(默认5,最大10 |
| `bg_task_run` | ❌ | Block | 后台异步执行慢速操作(仅支持 `download_paper``parse_paper`)。结果通过 `BgNotificationQueue` 在下一轮前注入 |
| `bg_task_check` | ✅ | Cancel | 查询后台任务状态(可指定 task_id 或列出全部) |
| *团队 4 工具* | ❌ | Block | `spawn_teammate` / `send_teammate_message` / `team_broadcast` / `check_team_inbox` — 基于文件收件箱的多 Agent 协作 |
---
## 执行流水线
从 LLM 返回 `tool_calls` 到结果注入 `messages[]` 的完整数据流:
```
LLM stream → tool_calls[]
┌─ validate_and_prepare() ─────────────────────────────────────┐
│ 1. 死循环检测 (DuplicateDetector): │
│ 连续相同 (tool_name, args) ≥ threshold → 注入错误消息 │
│ 2. JSON 参数解析: 失败则注入 tool_result 错误 │
│ 3. 修复空 tool_call_id (UUID 前缀) │
└──────────────────────────────────────────────────────────────┘
▼ PreparedCall[]
├─ 发送 ToolCall SSE 事件 → 前端实时渲染
├─ PreToolUse hooks (顺序执行)
│ ├─ 可拦截 (Block) / 修改参数 (MutateInput) / 注入上下文 (AppendContext)
│ └─ mutated_args + additional_contexts 收集
┌─ execute_parallel() ─────────────────────────────────────────┐
│ FuturesUnordered 并发调度: │
│ 每个 PreparedCall → tokio::spawn(async { │
│ tokio::select! { │
│ timeout(tool_timeout_secs) → 执行工具 │
│ cancel_fut (每 250ms 轮询) → 返回错误 │
│ } │
│ }) │
│ │
│ 中断处理: │
│ InterruptBehavior::Block → 忽略 cancel_fut,等完成 │
│ InterruptBehavior::Cancel → 响应取消,注入错误 │
│ │
│ 渐进式结果处理 (while exec_futs.next()): │
│ 完成即处理,快工具不因慢工具阻塞 │
└──────────────────────────────────────────────────────────────┘
▼ (每个工具完成后逐个处理)
├─ 发送 ToolResult SSE 事件 (tool_call_id 精确匹配)
├─ maybe_persist_tool_result()
│ ├─ content ≤ max_chars → 直接传递
│ └─ content > max_chars → 写入 disk + 返回 <persisted-output> stub
│ 幂等写入 (create_new),同 tool_call_id 不重复写
├─ PostToolUse hooks → 审计日志 / 指标采集 / 输出修改
├─ 持久化到 agent_messages 表 (fire-and-forget)
└─ 推入 messages[] → 下一轮 LLM 调用
```
### 并发模型细节
```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) => {
// 正常完成或超时
}
_ = cancel_fut => {
// 仅当 !is_blocking 时此分支可达
// Block 工具的 cancel_fut loop 不 break
}
}
})
```
关键特性:
- 所有工具放入同一个 `FuturesUnordered`,不区分串行/并行批次
- `is_concurrency_safe` 声明为语义标记(引导 LLM 并发调用),执行时全部并发
- 实际串行化依赖工具内部的 mutex/文件锁
- `InterruptBehavior::Block` 保护写入操作不被用户取消打断
---
## ToolRegistry 注册流程
```mermaid
sequenceDiagram
participant RT as AgentRuntime::new()
participant TR as ToolRegistry
participant Tools as 工具实例
RT->>TR: new_with_queue(queue, skill_registry)
TR->>TR: add_base_tools()
Note over TR: 注册 19 个基础工具
TR->>Tools: read_file, grep_files, glob_files, run_bash
TR->>Tools: file_write, file_edit
TR->>Tools: search_papers, get_paper_metadata
TR->>Tools: download_paper, parse_paper, get_paper_content
TR->>Tools: rag_search, query_target, save_note
TR->>Tools: todo_write, compress_context, ask_user
TR->>Tools: load_skill(skill_registry)
TR->>Tools: subagent (默认实例)
opt queue.is_some()
TR->>TR: add_background_tools(queue)
TR->>Tools: bg_task_run + bg_task_check
end
RT->>TR: add_tool(SaveMemoryTool)
Note over TR: MemoryManager 需要共享状态,动态注入
RT->>TR: replace_tool(SubAgentTool::new_with_hooks(...))
Note over TR: 替换为带 PermissionChecker + HookRegistry 的增强版
TR->>TR: definitions()
Note over TR: HashMap 值收集 → 按 name 字母序排序
Note over TR: 排序保证跨调用稳定性 → 提升 prompt cache 命中率
```
### 注册表工厂方法
| 方法 | 工具数 | 用途 |
|:---|:---|:---|
| `ToolRegistry::empty()` | 0 | 受限场景(如记忆提取子代理只需只读 + save_memory |
| `ToolRegistry::new(skill_registry)` | 19 | 标准科研 Agent |
| `ToolRegistry::new_with_queue(queue, skill_registry)` | 21 | 带后台任务支持 |
| `ToolRegistry::new_with_team(queue, team_manager, skill_registry)` | 25 | 带团队协作 |
---
## 安全模型
### 多层权限管道
```
工具调用
├─ 1. AgentTool::check_permissions() ← 工具自身声明的权限规则
├─ 2. PermissionChecker::check() ← 集中式规则链
│ ├─ Deny rule (最高优先级,不可覆盖)
│ ├─ Allow rule (显式允许)
│ ├─ Ask rule (需用户确认)
│ └─ Default: Allowed
│ └─ 支持通配符 "*" 匹配所有工具
└─ 3. PreToolUse hooks ← Hook 可最终拦截 (Block)
```
### 文件系统安全
```rust
// 路径穿越检测
fn has_path_traversal(path_str: &str) -> bool {
path_str.contains("..") || path_str.contains('~')
}
// 路径沙箱
fn is_path_allowed(path: &Path, ctx: &ToolContext) -> bool {
let canonical = path.canonicalize()?; // 解析所有符号链接
// 检查是否在 library_dir / skills_dir / current_dir 下
allowed_roots.iter().any(|root| canonical.starts_with(root))
}
```
### SQL 注入防护
所有数据库操作使用参数化查询,无字符串拼接:
```rust
sqlx::query("INSERT INTO agent_messages (...) VALUES (?, ?, ...)")
.bind(value1)
.bind(value2)
// ...
```
---
## 关键集成点
### Hook 系统
| Hook | 触发时机 | 在工具系统中的用途 |
|:---|:---|:---|
| `PreToolUse` | 工具执行前 | 拦截/修改参数(`MutateInput`)、注入附加上下文(`AppendContext`)、权限确认(`PermissionRequired` |
| `PostToolUse` | 工具执行后 | 审计日志写入 `agent_audit_log`、指标采集(`MetricsData`)、输出修改(`MutateOutput` |
### SSE 事件流
```
ToolCall { id, name, arguments, step } ← 工具开始执行
(如有进度) ToolProgress { id, progress } ← execute_with_progress 发送
ToolResult { tool_call_id, name, output, ← 执行完成
is_error, metadata, step }
```
前端通过 `tool_call_id` 精确匹配 ToolCall/ToolResult,实现 Timeline 渲染。
### 上下文压缩
三层压缩与工具系统的交互:
| 层级 | 触发方式 | 实现 |
|:---|:---|:---|
| micro_compact | 自动(token 超限前) | 占位符替换,不涉及工具 |
| auto_compact | 自动(`estimated_tokens > soft_limit` | Runtime 检测 → `snapshot_compress_restore()` → 文件缓存快照 → LLM 摘要压缩 → 恢复最近文件 |
| manual_compact | `compress_context` 工具 | 设置 `pending_manual_compress` 标志位,下一轮 LLM 调用前执行。不受熔断器限制 |
压缩熔断器 (`CompactionCircuitBreaker`):连续失败多次后打开,阻止进一步压缩以防止无限循环。
### 工具输出持久化
```
execute() → ToolOutput { content }
├─ content.len() ≤ max_output_chars (默认 4000)
│ └─ 直接返回给 LLM
└─ content.len() > max_output_chars
└─ maybe_persist_tool_result()
├─ 写入 {library_dir}/tool-results/{tool_call_id}.txt
├─ 使用 create_new 保证幂等
└─ 返回 <persisted-output> stub(含 path + preview
LLM 可通过 read_file 读取完整内容
```
---
## 目录结构
```
src/agent/tools/
├── mod.rs # AgentTool trait + ToolRegistry + ToolContext + ToolOutput + 辅助函数
├── filesystem/ # 文件 I/O (6 工具)
│ ├── mod.rs # re-export
│ ├── read.rs # read_file — 带 FileStateCache mtime 去重
│ ├── grep.rs # grep_files — 正则搜索
│ ├── glob.rs # glob_files — 通配符匹配
│ ├── bash.rs # run_bash — Shell 命令执行(超时+截断)
│ ├── write.rs # file_write — 文件创建/覆盖
│ ├── edit.rs # file_edit — 精确字符串替换
│ └── security.rs # 共享安全验证(路径沙箱 + 穿越检测)
├── astro/ # 天文学工具 (8 工具)
│ ├── mod.rs # re-export
│ ├── search.rs # search_papers + get_paper_metadata
│ ├── paper.rs # download_paper + parse_paper + get_paper_content
│ ├── rag.rs # rag_search — 向量检索 + LLM 问答
│ ├── target.rs # query_target — CDS Sesame 查询
│ └── note.rs # save_note — 高亮批注
├── todo.rs # todo_write — 任务规划(DAG 依赖,持久化到 agent_tasks
├── compress.rs # compress_context — 手动上下文压缩标志位
├── skill.rs # load_skill — inline/fork 双模式技能加载
├── subagent.rs # subagent — 上下文隔离的子代理委派
├── ask_user.rs # ask_user — oneshot 通道用户交互
├── background.rs # bg_task_run + bg_task_check — 后台异步任务
├── team.rs # spawn/send/broadcast/check_inbox — 多 Agent 协作
├── memory.rs # save_memory — 带质量门控的跨会话记忆
└── persist.rs # maybe_persist_tool_result — 大输出磁盘持久化
```