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

15 KiB
Raw Blame History

Skills 系统 (skills.rs + tools/skill.rs)

参考 Claude Code 的两层 Skill 加载架构 — Layer 1 在每轮系统提示词中列出可用 skill 名称(~20 tokens/skillLayer 2 在 LLM 调用 load_skill 工具时注入完整内容(~2000 tokens/skill

整体数据流

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 正文:

---
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 += 1last_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)

// 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

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 之后):

Box::new(LoadSkillTool::new(skill_registry)),

共享范围

所有 Agent 组件共享同一个 Arc<RwLock<SkillRegistry>> 实例:

  • 主 AgentAgentRuntime
  • 子代理(SubAgentRunner
  • 团队成员(teammate.rs
  • 后台任务 Agentbackground.rs

与 Claude Code 参考设计的对应关系

Claude Code 概念 AstroResearch 实现
src/skills/ 目录 + SKILL.md 完全相同
YAML frontmattername, description, context, allowed-tools... 相同,增加 versionagenteffortpaths 字段
<system-reminder> Layer 1 注入 build_reminder() → 结构化 XML 块
SkillTool Layer 2 按需加载 LoadSkillToolAgentTool 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 调用实现