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:
@@ -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/>历史压缩为 <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 工具 + 写入门控 |
|
||||
@@ -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 Key(PDF 配图托管) |
|
||||
| `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`(程序自动设置) |
|
||||
@@ -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<HashSet<String>>"]
|
||||
Registry --> MH["MetricsHook<br/>Arc<Mutex<MetricsData>> (共享)"]
|
||||
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 调用对应方法,不收集返回值。
|
||||
|
||||
## 内置 Hooks(3 个)
|
||||
|
||||
| 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 拦截 |
|
||||
|
||||
---
|
||||
@@ -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}.md(frontmatter + 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 实现,完整执行流程 |
|
||||
|
||||
---
|
||||
|
||||
@@ -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`)
|
||||
|
||||
---
|
||||
|
||||
@@ -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<PermissionChecker>"]
|
||||
PC2 -->|"check(tool_name, Some(&args))"| SubExec["三态检查<br/>Deny→注入错误 | Ask→自动拒绝 | Allow→执行"]
|
||||
end
|
||||
|
||||
subgraph "execute_parallel() Phase 2.5"
|
||||
PC3["permission_checker: Option<&PermissionChecker>"]
|
||||
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.5(PreToolUse 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→Allowed,DontAsk 将 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 覆盖 bug,executor 触发 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 权限配置 |
|
||||
@@ -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<RwLock<>>)
|
||||
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: <system-reminder> 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` trait,tool name = `"load_skill"`,参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `skill_name` | `string` | ✅ | 要加载的技能名称 |
|
||||
| `max_steps` | `integer` | 否 | fork 模式下子代理最大步数,默认 5,上限 10 |
|
||||
|
||||
### 执行流程
|
||||
|
||||
```
|
||||
execute(args, ctx)
|
||||
│
|
||||
├─ 1. 从 SkillRegistry 缓存读取 Skill(RwLock::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 frontmatter(name, 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 |
|
||||
| 调用统计 | ✅ 指数衰减评分 |
|
||||
| 条件 skill(paths glob) | ✅ `activate_conditional_for_paths()` |
|
||||
| 变量替换 | ✅ `${SKILL_DIR}`, `${SESSION_ID}` |
|
||||
| `Skill` 工具接口 + `skill` slash command | LoadSkillTool(tool 形式),前端的 `/skill-name` 通过 tool 调用实现 |
|
||||
|
||||
---
|
||||
|
||||
@@ -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<AppState><br/>• config: AgentConfig<br/>• tool_registry: ToolRegistry (与父代理共享)<br/>• hook_registry: Option<Arc<HookRegistry>><br/>• permission_checker: Arc<PermissionChecker><br/>• progress_tx: Option<UnboundedSender><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) |
|
||||
|
||||
---
|
||||
|
||||
@@ -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 命中率。动态 section(tools、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 预生成工具描述摘要
|
||||
@@ -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 字段引入 |
|
||||
@@ -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<name, TeamMemberHandle>"]
|
||||
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()` 清理队友和收件箱文件
|
||||
@@ -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 — 大输出磁盘持久化
|
||||
```
|
||||
Reference in New Issue
Block a user