AstroResearch/docs/architecture/agent/skills.md
Asfmq f6df9d8136 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 等变量说明
2026-06-18 01:21:02 +08:00

340 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Skills 系统 (`skills.rs` + `tools/skill.rs`)
参考 Claude Code 的两层 Skill 加载架构 — **Layer 1** 在每轮系统提示词中列出可用 skill 名称(~20 tokens/skill**Layer 2** 在 LLM 调用 `load_skill` 工具时注入完整内容(~2000 tokens/skill
## 整体数据流
```mermaid
sequenceDiagram
participant FS as skills/*/SKILL.md
participant SR as SkillRegistry<br/>(Arc&lt;RwLock&lt;&gt;&gt;)
participant SP as SystemPrompt
participant LLM as LLM
participant Tool as LoadSkillTool
participant Sub as SubAgentRunner
Note over SR: 启动阶段 (main.rs)
SR->>FS: discover_skill_dirs() 扫描目录
FS-->>SR: parse_skill_file() 解析 YAML + Markdown
SR->>SR: refresh() 完整重载,按 usage_score 排序
SR->>SR: start_watcher() 启动 notify 文件监听
Note over SR: Layer 1 — System Reminder (每轮请求)
SP->>SR: build_reminder()
SR-->>SP: &lt;system-reminder&gt; XML 块
SP->>LLM: 注入 system prompt "skills" section
Note over SR: Layer 2 — 按需加载
LLM->>Tool: load_skill(skill_name)
Tool->>SR: get_skill(name)
SR-->>Tool: Skill { meta + body }
alt context = "fork"
Tool->>Sub: 启动子代理 (max_steps ≤ 10)
Sub-->>Tool: 子代理执行结果
else context = "inline" (默认)
Tool->>Tool: substitute_variables(body)
end
Tool-->>LLM: 格式化技能内容
Tool->>SR: record_usage(name)
```
## 代码结构
| 文件 | 行数 | 职责 |
|---|---|---|
| `src/agent/skills.rs` | 847 | SkillRegistry 缓存、文件解析、热更新、条件激活、系统提示构建 |
| `src/agent/tools/skill.rs` | 222 | LoadSkillTool — Layer 2 按需加载的 AgentTool 实现 |
| `src/agent/runtime/mod.rs` | ~1182 | 将 `build_reminder()` 注入 SystemPrompt section 3 |
| `src/agent/runtime/system_prompt.rs` | ~64 | 静态 system prompt 中引导 LLM 使用 load_skill |
| `src/main.rs` | ~151-163 | 启动时初始化 SkillRegistry、refresh、启动文件监听器 |
| `skills/{name}/SKILL.md` | — | 实际 skill 定义文件(当前 3 个) |
## SKILL.md 格式
每个 skill 为 `skills/{name}/SKILL.md`,包含 YAML frontmatter + Markdown 正文:
```markdown
---
name: methodology
description: 系统性文献综述方法论——如何高效地完成学术文献调研
context: inline
allowed-tools:
- read_file
- search_papers
model: sonnet
argument-hint: "<research question>"
when_to_use: 当用户请求文献综述或调研时
disable-model-invocation: false
user-invocable: true
version: "1.0"
paths:
- "*.rs"
- "*.toml"
agent: code-reviewer
effort: high
---
# Skill 正文 (Markdown)
详细指引...
```
### Frontmatter 字段全量说明
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `name` | `string` | 目录名 | Skill 唯一标识 |
| `description` | `string` | "(无描述)" | 一句话描述,出现在 Layer 1 列表;**缺少时产生 warning** |
| `context` | `"inline"` / `"fork"` | `inline` | 执行模式;**非法值时产生 warning** |
| `allowed-tools` | `string[]` | `[]`(无限制) | 工具白名单fork 模式下建议仅使用这些工具 |
| `model` | `"haiku"` / `"sonnet"` / `"opus"` / `"inherit"` | — | 推荐的执行模型 |
| `argument-hint` | `string` | — | 参数提示(如 `<research question>`),已定义但 LoadSkillTool 尚未使用 |
| `when_to_use` | `string` | — | 触发场景描述,**Layer 1 reminder 中直接拼接到 description 后** |
| `disable-model-invocation` | `bool` | `false` | `true` 时 LLM 不能通过 load_skill 工具自动调用;同时控制条件激活的初始状态 |
| `user-invocable` | `bool` | `true` | `false` 时不出现在 Layer 1 reminder 中,用户无法手动调用 |
| `version` | `string` | — | 版本号 |
| `paths` | `string[]` | `[]`(始终激活) | 条件激活的 glob 模式,非空时 skill 仅在匹配文件路径后激活 |
| `agent` | `string` | — | fork 模式下游的 agent 类型(如 `code-reviewer`),已定义但 LoadSkillTool 尚未使用 |
| `effort` | `string` | — | fork 模式下的 effort 级别,已定义但 LoadSkillTool 尚未使用 |
### 当前项目 Skill 清单
| Skill | Context | 状态 | 说明 |
|---|---|---|---|
| `methodology` | inline | ✅ 完整 | 系统性文献综述 6 步流程:范围界定 → 按引用筛选 → 逐篇深读 → 补充检索 → 交叉验证 → 输出综述 |
| `plotting` | fork | 🚧 占位 | 科研绘图规范matplotlib/seaborn/plotly白名单 bash+save_note |
| `presentation` | fork | 🚧 占位 | 学术 PPT 生成Python-pptx/Beamer/Marp白名单 bash+save_note |
## SkillRegistry 核心实现 (`skills.rs`)
### 数据结构
```
SkillFrontmatter — serde_yaml 解析的 YAML frontmatter含 validate() 校验方法
├──▶ SkillMeta — Layer 1 摘要name, description, context, allowed_tools,
│ when_to_use, disable_model_invocation, user_invocable, paths
└──▶ Skill — Layer 2 完整对象meta + body + skill_dir
└──▶ SkillRegistry — 缓存容器 + 生命周期管理
├── skills: Vec<Skill>
├── last_scan_mtime: Option<SystemTime>
└── usage_stats: HashMap<String, SkillUsageStat>
```
### 关键方法
| 方法 | 返回值 | 说明 |
|---|---|---|
| `new(skills_dir)` | `Self` | 创建空注册表,调用 `refresh()` 触发初始扫描 |
| `refresh()` | `()` | **完整重载**(非增量):扫描目录 → 解析 → 按 `usage_score` 降序排序 |
| `needs_refresh()` | `bool` | 检查目录 mtime 是否变化(首次或 mtime > last_scan_mtime
| `build_reminder()` | `Option<String>` | 构建 Layer 1 XML `<system-reminder>` 块,仅包含 `user_invocable=true && disable_model_invocation=false` 的 skill |
| `build_tool_description()` | `String` | 动态生成 load_skill 工具的 description列出所有 `disable_model_invocation=false` 的 skill |
| `get_skill(name)` | `Option<&Skill>` | O(n) 按名称查找完整 Skill |
| `list_skills()` | `Vec<SkillMeta>` | 获取所有 skill 的元信息列表 |
| `record_usage(name)` | `()` | 记录调用:`invoke_count += 1``last_used_at = now` |
| `usage_score(name)` | `f64` | **指数衰减评分**`ln(1 + count) × 0.5^(age_hours / 168)`7 天半衰期) |
| `start_watcher(arc)` | `JoinHandle<()>` | 启动文件监听线程(见 4.6.5 |
| `activate_conditional_for_paths(paths)` | `Vec<String>` | 激活匹配指定文件路径的条件 skill见 4.6.6 |
| `matching_skills_for_paths(paths)` | `Vec<SkillMeta>` | 查询匹配指定文件路径的所有 skill只读不改变状态 |
### usage_score 算法
```
usage_score(name) = ln(1 + invoke_count) × 0.5^(age_hours / 168)
其中:
invoke_count — 从 record_usage() 累积
age_hours — 自 last_used_at 起的小时数(无记录时为 0.1
168 — 7 天(半衰期),即每过 7 天权重衰减 50%
```
这是一个**指数衰减 + 对数压缩**的评分:频率越高、越近使用,评分越高。`refresh()` 按此评分**降序**排列 skills使热技能优先出现在 remind 列表中。
## LoadSkillTool (`tools/skill.rs`)
实现 `AgentTool` traittool name = `"load_skill"`,参数:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `skill_name` | `string` | ✅ | 要加载的技能名称 |
| `max_steps` | `integer` | 否 | fork 模式下子代理最大步数,默认 5上限 10 |
### 执行流程
```
execute(args, ctx)
├─ 1. 从 SkillRegistry 缓存读取 SkillRwLock::read
│ ├─ 命中 → 2
│ └─ 未命中 → 返回错误(含可用 skill 列表提示)
├─ 2. record_usage() 更新调用统计RwLock::write
├─ 3. substitute_variables(body, skill_dir, session_id)
│ 替换 ${SKILL_DIR} → skill 目录绝对路径
│ 替换 ${SESSION_ID} → 当前会话 ID无则清空
├─ 4. 判断 context 模式
│ ┌─ context = "fork" ──────────────────────────────────────
│ │ • 构建子代理 system_prompt含 skill 正文 + base_dir
│ │ • 调用 SubAgentRunner::run(system_prompt, task, max_steps)
│ │ • 返回格式:[子代理执行结果 - 技能: xxx]
│ │ • metadata: { context: "fork", execution_mode: "subagent", ... }
│ │
│ └─ context = "inline" (默认) ─────────────────────────────
│ • 格式化输出:# 技能xxx (描述)\n\nBase directory: ...\n\nbody
│ • 附加 allowed_tools 白名单提示
│ • metadata: { context: "inline", execution_mode: "inline", ... }
└─ 5. 返回 ToolOutput
```
### 变量替换 (`substitute_variables`)
| 变量 | 替换目标 | 无值行为 |
|---|---|---|
| `${SKILL_DIR}` | skill 所在目录的绝对路径(如 `/app/skills/methodology` | 保持原样(`to_str()` 返回 None 时) |
| `${SESSION_ID}` | 当前 Agent 会话 ID | 清空为空字符串 |
> **已知问题**:当前 session_id 获取逻辑通过检查 `config.database_url.contains("session")` 来决定是否为 "current",这是一个脆弱的 hack应改为从 `ToolContext` 直接读取 `ctx.session_id`。
## 热重载 (Hot Reload)
`SkillRegistry::start_watcher()` 使用 **`notify` crate** 实现事件驱动的文件监听:
```
┌──────────────────────────────────────────────────────────┐
│ notify 文件监听线程 │
│ │
│ watcher.watch(skills_dir, RecursiveMode::Recursive) │
│ │ │
│ ▼ │
│ 仅过滤 SKILL.md 文件变更事件 │
│ │ │
│ ▼ 发送 () 到 mpsc channel │
│ ┌─────────────────┐ │
│ │ 300ms debounce │ ← rx.recv_timeout(300ms) │
│ │ 合并连续变更 │ 第一个事件后等待 300ms │
│ └────────┬────────┘ 期间有新事件则重置计时器 │
│ │ │
│ ▼ │
│ registry.write().refresh() 完整重载 │
└──────────────────────────────────────────────────────────┘
```
| 属性 | 说明 |
|---|---|
| 实现方式 | `notify::recommended_watcher` 事件驱动(非定时轮询) |
| 监听范围 | `RecursiveMode::Recursive`(递归监听子目录) |
| 过滤条件 | 仅处理文件名 == `SKILL.md` 的事件 |
| 防抖窗口 | 300ms — 快速连续的变更合并为一次刷新 |
| 刷新策略 | **完整重载**(始终重新扫描整个目录),非增量 |
| 测试模式 | `#[cfg(test)]` 下为空实现(不启动线程) |
## 条件 Skill 激活 (Paths-based Activation)
部分 skill 通过 `paths` frontmatter 声明 glob 模式,初始状态 `disable_model_invocation = true`,仅在 Agent 访问匹配文件时激活。
**激活流程(`activate_conditional_for_paths`**
```
当 Agent 通过 Read/Grep/Glob 访问文件时:
for each skill where paths is not empty AND disable_model_invocation == true:
if any(file_path matches any(pattern in skill.paths)):
skill.disable_model_invocation = false // 激活
log: "条件 skill '{name}' 已激活"
return newly_activated_skill_names
```
**匹配查询(`matching_skills_for_paths`** 只读方法,返回匹配的 skill 列表而不改变激活状态,可用于向 LLM 提示当前上下文相关的 skill。
**Glob 匹配(`glob_match_simple`**
| 通配符 | 匹配 | 示例 |
|---|---|---|
| `*` | 任意非 `/` 字符序列 | `"*.rs"``main.rs` |
| `**` | 任意字符(含 `/` | `"**/test/*"``src/test/foo` |
| `?` | 单个非 `/` 字符 | `"file_?.rs"``file_a.rs` |
使用 `glob::Pattern` crate降级方案为简单字符串包含匹配。
## 系统集成
### 启动初始化 (`main.rs:151-163`)
```rust
// 1. 创建注册表
let skill_registry = Arc::new(RwLock::new(SkillRegistry::new(config.skills_dir.clone())));
// 2. 初始刷新
if let Ok(mut reg) = skill_registry.write() {
reg.refresh();
}
// 3. 启动文件监听(热更新)
let _watcher_handle = SkillRegistry::start_watcher(skill_registry.clone());
// 4. 注入 AppState
let app_state = Arc::new(AppState {
skill_registry, // Arc<RwLock<SkillRegistry>>
// ... 其他字段
});
```
### System Prompt 注入 (`runtime/mod.rs:1178-1187`)
每轮 LLM 请求构建 system prompt 时,从 SkillRegistry 读取 reminder 并注入为 "skills" section
```rust
if let Some(skills) = self.app_state.skill_registry
.read().ok()
.and_then(|r| r.build_reminder())
{
sp.add_section("skills", skills);
}
```
### System Prompt 静态指引 (`system_prompt.rs:64`)
```
7. 对于复杂任务(如文献综述),调用 load_skill 获取方法论指引,再用 todo_write 制定计划。
```
### 工具注册 (`tools/mod.rs:254`)
LoadSkillTool 在所有工具注册表中作为第 18 个工具注册(紧跟 CompressTool 之后):
```rust
Box::new(LoadSkillTool::new(skill_registry)),
```
### 共享范围
所有 Agent 组件共享同一个 `Arc<RwLock<SkillRegistry>>` 实例:
- 主 Agent`AgentRuntime`
- 子代理(`SubAgentRunner`
- 团队成员(`teammate.rs`
- 后台任务 Agent`background.rs`
## 与 Claude Code 参考设计的对应关系
| Claude Code 概念 | AstroResearch 实现 |
|---|---|
| `src/skills/` 目录 + `SKILL.md` | 完全相同 |
| YAML frontmattername, description, context, allowed-tools... | 相同,增加 `version`、`agent`、`effort`、`paths` 字段 |
| `<system-reminder>` Layer 1 注入 | `build_reminder()` → 结构化 XML 块 |
| `SkillTool` Layer 2 按需加载 | `LoadSkillTool`AgentTool trait 实现) |
| inline 模式(注入指令内容) | ✅ 实现 |
| fork 模式(子代理隔离执行) | ✅ 实现SubAgentRunner |
| 热重载(目录监控) | ✅ `notify` crate + 300ms debounce |
| 调用统计 | ✅ 指数衰减评分 |
| 条件 skillpaths glob | ✅ `activate_conditional_for_paths()` |
| 变量替换 | ✅ `${SKILL_DIR}`, `${SESSION_ID}` |
| `Skill` 工具接口 + `skill` slash command | LoadSkillTooltool 形式),前端的 `/skill-name` 通过 tool 调用实现 |
---