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:
Asfmq 2026-06-18 01:21:02 +08:00
parent 49784739fa
commit f6df9d8136
60 changed files with 9913 additions and 1844 deletions

View File

@ -1,37 +1,131 @@
# AstroResearch Configuration Template
# Copy this to .env and fill in your details
# =============================================================================
# AstroResearch 环境变量模板
# 复制此文件为 .env 并填入你的配置值
# =============================================================================
# NASA ADS API Key (Get from ui.adsabs.harvard.edu)
# ─────────────────────────────────────────────────────────────────────────────
# 1. 核心 API 服务
# ─────────────────────────────────────────────────────────────────────────────
# NASA ADS API Token从 ui.adsabs.harvard.edu 申请)
ADS_API_KEY=your_ads_api_key_here
# LLM Translation Provider Settings (OpenAI-compatible endpoints)
# 大语言模型配置(兼容 OpenAI 接口协议)
LLM_API_KEY=your_llm_api_key_here
LLM_API_BASE=https://api.deepseek.com/v1
# Examples: deepseek-chat, gpt-4o-mini, gemini-1.5-flash
LLM_MODEL=deepseek-chat
LLM_API_BASE=https://api.openai.com/v1
# 可选模型示例deepseek-chat, qwen-flash, gpt-4o-mini
LLM_MODEL=gpt-4o-mini
# Embedding Model Settings (OpenAI-compatible endpoints, falls back to LLM keys if unspecified)
EMBEDDING_API_KEY=your_embedding_api_key_here
EMBEDDING_API_BASE=https://api.openai.com/v1
EMBEDDING_MODEL=text-embedding-3-small
# 备用模型(连续 3 次 529 过载后自动切换,可选)
# FALLBACK_MODEL=gpt-4o-mini
# 向量嵌入模型配置(未设置时默认回退到 LLM 的 API Key 和 Base
# EMBEDDING_API_KEY=your_embedding_api_key_here
# EMBEDDING_API_BASE=https://api.openai.com/v1
# EMBEDDING_MODEL=text-embedding-3-small
# 向量维度(需与所选模型输出维度一致,默认 1536
# EMBEDDING_DIM=1536
# ─────────────────────────────────────────────────────────────────────────────
# 2. 七牛云对象存储PDF 解析后的配图托管)
# ─────────────────────────────────────────────────────────────────────────────
# Qiniu Cloud Storage Config (For hosting PDF-extracted layout images)
QINIU_AK=your_qiniu_access_key_here
QINIU_SK=your_qiniu_secret_key_here
QINIU_BUCKET=your_bucket_name
QINIU_DOMAIN=http://your_cdn_domain.com
# MinerU PDF Layout Extractor Remote API (If not using HTML)
MINERU_API_URL=http://mineru.remote-api.com/api/v1/extract
MINERU_API_KEY=your_mineru_api_key
# ─────────────────────────────────────────────────────────────────────────────
# 3. 文档解析服务
# ─────────────────────────────────────────────────────────────────────────────
# Local Data Paths
# MinerU PDF 解析远程 API不使用 HTML 解析时可选)
# MINERU_API_URL=https://mineru.net/api/v4/extract/task
# MINERU_API_KEY=your_mineru_api_key_here
# ─────────────────────────────────────────────────────────────────────────────
# 4. 本地路径与端口
# ─────────────────────────────────────────────────────────────────────────────
# SQLite 数据库路径
DATABASE_URL=sqlite://library/astro_research.db
# 本地文献馆藏根目录
LIBRARY_DIR=./library
# Agent Skills 目录Markdown 知识模块)
SKILLS_DIR=./skills
# 后端服务监听端口
PORT=8000
DATABASE_URL=sqlite://astro_research.db
# Logging Configuration (Pretty console and rolling file logging)
# ─────────────────────────────────────────────────────────────────────────────
# 5. 日志配置
# ─────────────────────────────────────────────────────────────────────────────
# 日志级别trace / debug / info / warn / error可指定模块级别如 info,astroresearch=debug
LOG_LEVEL=info,astroresearch=debug
# 日志格式pretty彩色或 json结构化
LOG_FORMAT=pretty
# 日志输出目标stdout控制台、file文件逗号分隔可同时启用
LOG_OUTPUTS=stdout,file
# 日志文件目录(仅 LOG_OUTPUTS 含 file 时生效)
LOG_DIR=./logs
# ─────────────────────────────────────────────────────────────────────────────
# 6. Agent 运行时参数
# ─────────────────────────────────────────────────────────────────────────────
# 最大 ReAct 推理步数(默认 8
# AGENT_MAX_STEPS=8
# 单个工具执行超时秒数(默认 120
# AGENT_TOOL_TIMEOUT_SECS=120
# 工具输出截断字符数(默认 4000
# AGENT_MAX_TOOL_OUTPUT_CHARS=4000
# 上下文触发压缩的字符阈值(默认 16000
# AGENT_CONTEXT_CHAR_LIMIT=16000
# Token 软限制:触发渐进式提醒(默认 32000
# AGENT_TOKEN_SOFT_LIMIT=32000
# Token 硬限制:触发强制压缩或终止(默认 40000
# AGENT_TOKEN_HARD_LIMIT=40000
# 触发 snip 压缩的最大消息数(默认 50
# AGENT_MAX_MESSAGES=50
# ── 权限系统 ──
# Agent 工具权限规则(逗号分隔,支持内容级匹配 "ToolName(pattern)"
# 规则优先级: Deny > Ask > Allow先匹配先生效
#
# 格式: "tool_name" → 工具级规则
# "tool_name(content_pattern)" → 内容级规则
# 示例: "run_bash(rm *)" 匹配以 rm 开头的 bash 命令
# "read_file(*.env)" 匹配 .env 后缀的文件读取
# "file_write(/etc/*)" 匹配 /etc/ 下的文件写入
#
# 权限模式:
# default — 标准:逐项按规则决定(默认)
# accept_edits — 工作目录内的 file_write/file_edit 自动允许
# bypass — 跳过所有 Ask 检查Deny 规则仍生效)
# dont_ask — 将所有 Ask 转为 Deny
# AGENT_PERMISSION_MODE=default
#
# 拒绝规则(逗号分隔,不可覆盖)
# AGENT_PERMISSIONS_DENY=run_bash(rm *),run_bash(sudo *)
# 允许规则(逗号分隔,显式允许)
# AGENT_PERMISSIONS_ALLOW=read_file,grep_files,glob_files,search_papers
# 询问规则(逗号分隔,需用户确认)
# AGENT_PERMISSIONS_ASK=run_bash,file_write,file_edit,download_paper
# ─────────────────────────────────────────────────────────────────────────────
# 7. 自动记忆提取(实验性功能)
# ─────────────────────────────────────────────────────────────────────────────
# 是否启用自动记忆提取(默认 false
# EXTRACT_MEMORY_ENABLED=false
# 记忆提取节流间隔:每 N 个 turn 触发一次(默认 3
# EXTRACT_MEMORY_THROTTLE_TURNS=3
# 记忆提取子代理最大步数(默认 3
# EXTRACT_MEMORY_MAX_STEPS=3
# ─────────────────────────────────────────────────────────────────────────────
# 8. 其他
# ─────────────────────────────────────────────────────────────────────────────
# Obscura 浏览器 Profile 轮换(绕过 Cloudflare/WAF默认 true
# OBSCURA_ROTATE_PROFILE=true

View File

@ -68,14 +68,19 @@ src/
│ ├── targets.rs # Target query/associate/extract, RAG chat, figure chat
│ └── helpers.rs # Shared DB helpers, format conversion, path validation
├── agent/ # ReAct-based research agent (LLM-driven tool-use loop)
│ ├── tools/ # AgentTool trait, ToolRegistry, tool implementations per domain file
│ ├── runtime/ # ReAct loop engine, streaming, session management, context building
│ ├── compact/ # Context compression (micro/auto/manual layers)
│ ├── tools/ # AgentTool trait, ToolRegistry, 25+ tool implementations per domain file
│ ├── runtime/ # ReAct loop, streaming, session/context, token budget, error recovery,
│ │ # permission checker, file cache, system prompt assembly, circuit breaker
│ ├── compact/ # Context compression (micro/auto/manual layers + collapse)
│ ├── memory/ # Persistent memory manager: extraction, dedup, decay, age, guardrails
│ ├── hooks.rs # Lifecycle events (PreToolUse/PostToolUse/Stop/etc.)
│ ├── skills.rs # SkillRegistry: loads skill SKILL.md files from skills/ directory
│ ├── subagent.rs # Context-isolated sub-agent runner for delegate_research
│ ├── skills.rs # SkillRegistry: hot-loads SKILL.md files from skills/ directory
│ ├── subagent.rs # Context-isolated sub-agent runner (subagent tool)
│ ├── team/ # Multi-agent team: file-based inbox, lead/teammate coordination
│ ├── background.rs# BgNotificationQueue for async slow-task (download/parse) notifications
│ └── team/ # Multi-agent team: file-based inbox, lead/teammate coordination
│ ├── task_board.rs# Persistent task board (agent_tasks table)
│ ├── trajectory.rs# Session trajectory recording for audit/debug
│ └── terminal.rs # Escape sequence filter for ANSI-heavy tool outputs
├── clients/ # External API wrappers
│ ├── llm.rs # LlmClient (OpenAI-compatible chat + streaming), EmbeddingClient
│ ├── ads.rs # NASA ADS API
@ -106,6 +111,7 @@ All handlers access state via `Arc<AppState>`. Key fields:
- `llm: LlmClient` / `embedding: EmbeddingClient` — OpenAI-compatible LLM clients
- `ads: AdsClient` / `arxiv: ArxivClient` — academic search clients
- `skill_registry: Arc<RwLock<SkillRegistry>>` — hot-reloaded agent skills
- `memory_manager: Arc<MemoryManager>` — persistent agent memory (MEMORY.md + decay)
- `cancelled_runs: Arc<Mutex<HashSet<String>>>` — agent cancellation tokens
- `harvest_status` / `batch_status` — async batch operation status tracking
@ -114,14 +120,17 @@ All handlers access state via `Arc<AppState>`. Key fields:
The agent (`src/agent/`) implements a **ReAct** (Thought → Action → Observation) loop:
1. **`AgentRuntime`** (`runtime/mod.rs`) orchestrates the loop: session create/resume → context build → ReAct loop → finalize
2. **Streaming**: LLM response is streamed via SSE (`AgentStreamEvent`) — thought, tool_call, tool_result, text_delta, usage, error, done
3. **Tools**: Each tool implements `AgentTool` trait (name, description, JSON Schema parameters, execute). 19 tools in default registry including read_file, grep_files, glob_files, run_bash, file_write, file_edit, search_papers, download_paper, parse_paper, get_paper_content, rag_search, query_target, save_note, todo_write, compress_context, load_skill, delegate_research, plus optional background and team tools
4. **Parallel execution**: Same-turn tool calls execute concurrently via `executor::execute_parallel`
5. **Context compression**: Three layers — micro (placeholder replacement), auto (LLM summarization when over threshold), manual (compress_context tool). Protected by `CompactionCircuitBreaker`
6. **Skills** (`skills.rs`): Two-layer loading — system-reminder lists names (~20 tokens each), LLM calls `load_skill` to inject full SKILL.md content
7. **Sub-agents** (`subagent.rs`): `delegate_research` spawns a context-isolated sub-agent with its own ReAct loop, returning only the final summary
8. **Teams** (`team/`): File-based inbox directory per session for lead/teammate message passing
9. **Background tasks** (`background.rs`): Slow ops (download, parse) can run async; results inject via `BgNotificationQueue` before next LLM call
2. **Streaming**: LLM response is streamed via SSE (`AgentStreamEvent`) — thought, tool_call (with `id`), tool_result (with `tool_call_id`), text_delta, usage, error, done. Tool calls execute in parallel.
3. **Tools**: Each tool implements `AgentTool` trait (name, description, JSON Schema parameters, execute). Core tools: read_file, grep_files, glob_files, run_bash, file_write, file_edit, search_papers, download_paper, parse_paper, get_paper_content, rag_search, query_target, save_note, todo_write, compress_context, load_skill, subagent, ask_user, save_memory. Plus background tools (bg_task_run, bg_task_check) and team tools (spawn_teammate, send_teammate_message, team_broadcast, check_team_inbox).
4. **Thinking mode**: `enable_thinking` flag propagates from `AgentChatRequest``AgentConfig``ToolContext``LlmClient::chat_stream`. Only enabled for Qwen/DashScope backends; frontend-controlled via the `thinking` request field.
5. **Tool call ID tracking**: LLM may not return tool_call IDs — `LlmClient` generates UUID fallbacks. `ToolCall` and `ToolResult` SSE events carry matching IDs for precise frontend pairing.
6. **ToolContext** (`tools/mod.rs`): Injected into every tool execution — holds `app_state`, `session_id`, `sse_tx` (for intermediate events), `enable_thinking`, `read_file_state` (file cache for dedup), `silent` (sub-agents skip permission prompts).
7. **Context compression**: Four layers — micro (placeholder replacement), snip (old-message truncation), auto (LLM summarization), aggro_micro (aggressive placeholder). Protected by `CompactionCircuitBreaker`. Transcripts persisted in `agent_messages` table, not filesystem snapshots.
8. **Skills** (`skills.rs`): Two-layer loading — system-reminder lists names (~20 tokens each), LLM calls `load_skill` to inject full SKILL.md content
9. **Sub-agents** (`subagent.rs`): `subagent` tool spawns a context-isolated sub-agent with its own ReAct loop. Sub-agent messages (system/user/assistant/tool) are persisted to `agent_messages` with `agent_name` identifier. Returns final summary + activity log. SSE progress forwarded to parent via ToolContext.
10. **Memory** (`memory/`): File-based persistent memory (MEMORY.md). `MemoryManager` handles extraction from conversation, dedup, recency decay, age-based pruning, and guardrails. Tools: `save_memory`, `load_memory` (auto-injected in system prompt).
11. **Teams** (`team/`): File-based inbox directory per session for lead/teammate message passing
12. **Background tasks** (`background.rs`): Slow ops (download, parse) can run async; results inject via `BgNotificationQueue` before next LLM call
Environment variables for agent tuning: `AGENT_MAX_STEPS` (default 8), `AGENT_TOOL_TIMEOUT_SECS` (default 120), `AGENT_MAX_TOOL_OUTPUT_CHARS` (default 4000), `AGENT_CONTEXT_CHAR_LIMIT` (default 16000), `AGENT_TOKEN_SOFT_LIMIT` / `AGENT_TOKEN_HARD_LIMIT`.
@ -140,7 +149,7 @@ React 19 + TypeScript + Vite + Tailwind CSS 4. Features are organized by domain:
- `features/reader/` — Bilingual reader with highlight annotations (KaTeX for math)
- `features/citation/` — Canvas-based force-directed citation graph
- `features/sync/` — Batch sync control panel
- `features/agent/` — Agent chat interface (SSE event consumption)
- `features/agent/` — Agent chat: ResearchAgentPanel (timeline view with thought/tool_call/answer/subagent), AgentMetricsPanel (tool stats), AskUserQuestionCard (interactive Q&A), AuditLogViewer
- `features/settings/` — System configuration
Dependencies: `react-markdown` + `rehype-katex` + `remark-math` for Markdown/LaTeX rendering, `framer-motion` for animations, `lucide-react` for icons.

View File

@ -46,8 +46,8 @@ flate2 = "1.1.9"
zip = "8.6.0"
uuid = { version = "1.23.2", features = ["v4"] }
tracing-appender = "0.2.5"
obscura-browser = { path = "/home/fmq/program/AstroResearch/libs/obscura/crates/obscura-browser", optional = true }
obscura-net = { path = "/home/fmq/program/AstroResearch/libs/obscura/crates/obscura-net", optional = true }
obscura-browser = { path = "libs/obscura/crates/obscura-browser", optional = true }
obscura-net = { path = "libs/obscura/crates/obscura-net", optional = true }
libsqlite3-sys = { version = "0.27.0", features = ["bundled"] }
sqlite-vec = "0.1.9"
clap = { version = "4", features = ["derive"] }

View File

@ -85,15 +85,14 @@ cp .env.example .env
## 3. 技术文档结构 (Documentation Directory)
详细的技术和部署设计文档已集中整理至 `docs/` 目录:
- 🏗️ **[架构设计](docs/architecture.md)**:包含系统宏观流程图与序列图。
- 🌐 **[API 接口规范](docs/api.md)**:后端 Axum 路由及 HTTP 接口格式。
- 🗄️ **[数据库设计](docs/database.md)**SQLite 表结构、ER 图与索引优化。
- 🎨 **[视觉与交互设计](docs/design.md)**:高对比度浅色中文控制台、自研 Canvas 图谱引擎说明。
- 🛠️ **[排障指南](docs/troubleshooting.md)**:人机校验、解析失败等常见问题解法。
- 🚀 **[编译与部署指南](docs/deployment.md)**:单执行文件打包与发布流程。
- 🤝 **[参与贡献指南](docs/contributing.md)**:开发规范及单元测试。
- 🏗️ **[架构设计](docs/architecture.md)** — 系统宏观架构、Agent 子系统、Mermaid 流程图
- 🌐 **[API 接口规范](docs/api.md)** — 全部 Axum REST 端点与 SSE 事件
- 🗄️ **[数据库设计](docs/database.md)** — SQLite 表结构、ER 图、迁移历史
- 🛠️ **[开发指南](docs/development.md)** — 构建/测试/环境变量/项目结构
- 🎨 **[视觉设计](docs/design.md)** — UI 设计系统与交互体验
- 🚀 **[部署指南](docs/deployment.md)** — 生产构建与发布
- 🔧 **[排障指南](docs/troubleshooting.md)** — 常见问题与解决方案
- 🤝 **[参与贡献](docs/contributing.md)** — 代码规范与测试要求
---
@ -102,50 +101,38 @@ cp .env.example .env
```
AstroResearch/
├── src/
│ ├── main.rs # Axum 服务入口:路由注册、中间件、静态资源托管
│ ├── lib.rs # 库入口Config 配置结构体与环境变量加载
│ ├── api/ # API 层(模块化拆分)
│ │ ├── mod.rs # AppState / StandardPaper 定义 + handlers 兼容命名空间
│ │ ├── helpers.rs # 共享工具函数:格式转换、数据库读写、路径校验
│ │ ├── papers.rs # 文献相关:检索、下载、上传、解析、翻译、引文、导出
│ │ ├── notes.rs # 笔记 CRUD创建、查询、删除
│ │ └── sync.rs # 批量同步:元数据同步、资源同步、查询管理
│ ├── bin/
│ │ └── health_check.rs # 独立二进制:馆藏健康度诊断与修复工具
│ ├── clients/
│ │ ├── ads.rs # NASA ADS API 客户端
│ │ ├── arxiv.rs # arXiv Atom XML API 客户端
│ │ └── qiniu.rs # 七牛云对象存储客户端
│ └── services/
│ ├── batch/ # 批量同步引擎(模块化拆分)
│ │ ├── mod.rs # 公共导出
│ │ ├── meta.rs # 元数据大批量采集 (MetaSync)
│ │ └── asset.rs # 物理资源批量处理 (AssetSync)
│ ├── download.rs # 文献下载器:反爬伪装、多级回退、错误记录
│ ├── parser.rs # HTML/PDF → Markdown 解析器
│ ├── translation.rs # LLM 翻译器 + Trie 词典
│ ├── query_parser.rs # 高级检索语法解析
│ └── logging.rs # 日志系统:控制台美化 + 滚动文件
├── dashboard/
│ └── src/
│ ├── App.tsx # 全局状态管理与布局
│ ├── types.ts # TypeScript 类型定义
│ ├── components/
│ │ ├── CitationGalaxyCanvas.tsx # Canvas 力导向引文星系图
│ │ └── CustomSelect.tsx # 可复用下拉选择组件
│ └── features/
│ ├── search/SearchPanel.tsx # 跨源检索面板
│ ├── library/LibraryPanel.tsx # 馆藏管理面板
│ ├── reader/ReaderPanel.tsx # 双语对照阅读器
│ ├── citation/CitationPanel.tsx # 引文图谱面板
│ ├── sync/SyncPanel.tsx # 批量同步控制台
│ └── settings/SettingsPanel.tsx # 系统设置
├── migrations/ # SQLite 数据库迁移脚本
├── library/ # 本地文献物理存储目录
│ ├── PDF/ # 下载的 PDF 文件
│ ├── HTML/ # 下载的 HTML 文件
│ ├── Markdown/ # 解析后的 Markdown 文件
│ └── Translation/ # 翻译后的中文 Markdown 文件
├── docs/ # 技术文档
│ ├── main.rs # Axum 服务入口:路由、中间件、静态资源托管
│ ├── lib.rs # Config 配置加载
│ ├── api/ # HTTP handlers + AppState
│ │ ├── agent.rs # SSE 智能体对话、会话管理、指标、审计
│ │ ├── papers.rs # 文献检索/下载/解析/翻译/引文/导出
│ │ ├── notes.rs # 笔记 CRUD
│ │ ├── sync.rs # 批量同步
│ │ ├── targets.rs # 天体目标识别
│ │ └── helpers.rs # 共享工具函数
│ ├── agent/ # ReAct 智能体引擎 (参考 Claude Code 分层设计)
│ │ ├── runtime/ # ReAct 循环、流式执行、Token 管理、权限、熔断
│ │ ├── tools/ # 工具系统 (filesystem/ astro/ memory/ team/)
│ │ ├── compact/ # 三层上下文压缩
│ │ ├── memory/ # 项目记忆管理 (提取/去重/衰减/保活/护栏)
│ │ ├── team/ # 多 Agent 团队协作
│ │ ├── hooks.rs # 生命周期事件系统
│ │ ├── skills.rs # 技能注册表 (热加载)
│ │ ├── subagent.rs # 上下文隔离子代理
│ │ └── terminal.rs # 循环终止信号
│ ├── clients/ # 外部 API 客户端 (ADS, arXiv, LLM, Qiniu)
│ ├── services/ # 业务逻辑
│ │ ├── parser/ # HTML/PDF → Markdown 解析器 (A&A, IOP, ar5iv, MinerU)
│ │ ├── batch/ # 批量同步引擎
│ │ ├── download.rs # 文献下载器 (反爬伪装、多级回退)
│ │ ├── translation.rs # LLM 翻译 + Trie 天文词典
│ │ └── rag.rs # 向量检索增强生成
│ └── bin/ # 独立二进制 (cli, health_check, reparse)
├── dashboard/ # React 19 + Vite + TypeScript 前端
│ └── src/features/ # search/ library/ reader/ citation/ agent/ sync/ settings/
├── skills/ # Agent Skills (Markdown 知识模块)
├── migrations/ # SQLite 迁移脚本
├── library/ # 本地文献存储 (PDF/HTML/Markdown/Translation)
├── docs/ # 技术文档 (架构/API/数据库/开发/部署/排障)
└── dictionary.txt # 天文学双语名词词典
```

View File

@ -0,0 +1,271 @@
// dashboard/src/features/agent/AgentMetricsPanel.tsx
import { useState, useEffect } from 'react';
import axios from 'axios';
import { BarChart3, Activity, AlertTriangle, Zap, Brain, RefreshCw, Loader } from 'lucide-react';
import type { AgentMetricsResponse } from '../../types';
interface AgentMetricsPanelProps {
showAlert?: (message: string, title?: string) => void;
}
// 工具名到中文显示名的映射
const TOOL_LABELS: Record<string, string> = {
read_file: '读取文件',
grep_files: '搜索文件',
glob_files: '匹配文件',
run_bash: 'Shell 命令',
file_write: '写入文件',
file_edit: '编辑文件',
search_papers: '文献检索',
get_paper_metadata: '获取元数据',
download_paper: '下载文献',
parse_paper: '解析文献',
get_paper_content: '获取内容',
rag_search: 'RAG 检索',
query_target: '天体查询',
save_note: '保存笔记',
todo_write: '任务管理',
compress_context: '上下文压缩',
load_skill: '加载技能',
subagent: '派发子代理',
delegate_research: '子代理研究(旧)',
ask_user: '用户提问',
save_memory: '保存记忆',
load_memory: '读取记忆',
bg_task_run: '后台任务',
bg_task_check: '检查后台',
spawn_teammate: '创建队友',
send_teammate_message: '队友消息',
team_broadcast: '团队广播',
check_team_inbox: '收件箱检查',
};
// 工具调用的分类色
const CATEGORY_COLORS: Record<string, string> = {
read_file: 'bg-blue-100 text-blue-700 border-blue-200',
grep_files: 'bg-blue-100 text-blue-700 border-blue-200',
glob_files: 'bg-blue-100 text-blue-700 border-blue-200',
run_bash: 'bg-slate-200 text-slate-700 border-slate-300',
file_write: 'bg-blue-100 text-blue-700 border-blue-200',
file_edit: 'bg-blue-100 text-blue-700 border-blue-200',
search_papers: 'bg-emerald-100 text-emerald-700 border-emerald-200',
get_paper_metadata: 'bg-emerald-100 text-emerald-700 border-emerald-200',
download_paper: 'bg-emerald-100 text-emerald-700 border-emerald-200',
parse_paper: 'bg-emerald-100 text-emerald-700 border-emerald-200',
get_paper_content: 'bg-emerald-100 text-emerald-700 border-emerald-200',
rag_search: 'bg-violet-100 text-violet-700 border-violet-200',
query_target: 'bg-amber-100 text-amber-700 border-amber-200',
save_note: 'bg-teal-100 text-teal-700 border-teal-200',
todo_write: 'bg-orange-100 text-orange-700 border-orange-200',
compress_context: 'bg-rose-100 text-rose-700 border-rose-200',
load_skill: 'bg-indigo-100 text-indigo-700 border-indigo-200',
subagent: 'bg-purple-100 text-purple-700 border-purple-200',
delegate_research: 'bg-purple-100 text-purple-700 border-purple-200',
ask_user: 'bg-amber-100 text-amber-700 border-amber-200',
save_memory: 'bg-pink-100 text-pink-700 border-pink-200',
load_memory: 'bg-pink-100 text-pink-700 border-pink-200',
bg_task_run: 'bg-cyan-100 text-cyan-700 border-cyan-200',
bg_task_check: 'bg-cyan-100 text-cyan-700 border-cyan-200',
spawn_teammate: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-200',
send_teammate_message: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-200',
team_broadcast: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-200',
check_team_inbox: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-200',
};
export function AgentMetricsPanel({ showAlert }: AgentMetricsPanelProps) {
const [metrics, setMetrics] = useState<AgentMetricsResponse | null>(null);
const [loading, setLoading] = useState(false);
const fetchMetrics = async () => {
setLoading(true);
try {
const res = await axios.get<AgentMetricsResponse>('/api/chat/metrics');
setMetrics(res.data);
} catch (e) {
console.error('获取智能体指标失败:', e);
showAlert?.('获取智能体运行指标失败,请确认后端服务状态。', '指标加载出错');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchMetrics();
}, []);
// 提取工具调用排行取前10
const toolBreakdown = metrics?.tool_call_breakdown
? Object.entries(metrics.tool_call_breakdown)
.sort(([, a], [, b]) => b - a)
.slice(0, 15)
: [];
const maxToolCalls = toolBreakdown.length > 0 ? toolBreakdown[0][1] : 1;
return (
<div className="space-y-5">
{/* 头部 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-sky-600" />
<h3 className="text-xs font-extrabold text-slate-800 tracking-wide">
</h3>
</div>
<button
onClick={fetchMetrics}
disabled={loading}
className="p-1.5 rounded-lg bg-slate-100 hover:bg-slate-200 text-slate-500 hover:text-slate-700 transition-colors cursor-pointer disabled:opacity-50"
title="刷新指标"
>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{loading && !metrics ? (
<div className="flex items-center justify-center py-12 text-slate-400 gap-2">
<Loader className="w-4 h-4 animate-spin text-sky-600" />
<span className="text-xs font-bold">...</span>
</div>
) : metrics ? (
<>
{/* 概览卡片 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<MetricCard
icon={<Brain className="w-4 h-4" />}
label="总会话数"
value={metrics.total_sessions}
color="sky"
/>
<MetricCard
icon={<Zap className="w-4 h-4" />}
label="总工具调用"
value={metrics.total_tool_calls}
color="emerald"
/>
<MetricCard
icon={<Activity className="w-4 h-4" />}
label="平均步数/会话"
value={metrics.avg_steps_per_session.toFixed(1)}
color="violet"
/>
<MetricCard
icon={<AlertTriangle className="w-4 h-4" />}
label="错误率"
value={`${(metrics.error_rate * 100).toFixed(1)}%`}
color={metrics.error_rate > 0.1 ? 'rose' : 'emerald'}
/>
</div>
{/* 工具调用分布 */}
<div className="space-y-2.5">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
</span>
{toolBreakdown.length === 0 ? (
<p className="text-xs text-slate-400 italic py-4 text-center">
</p>
) : (
<div className="space-y-1.5">
{toolBreakdown.map(([name, count]) => {
const barWidth = Math.max((count / maxToolCalls) * 100, 2);
const colorClass = CATEGORY_COLORS[name] || 'bg-slate-100 text-slate-700 border-slate-200';
const label = TOOL_LABELS[name] || name;
return (
<div key={name} className="flex items-center gap-2.5">
<span className="text-[10px] text-slate-500 w-24 shrink-0 text-right font-medium truncate" title={label}>
{label}
</span>
<div className="flex-1 h-5 bg-slate-100 rounded-full overflow-hidden border border-slate-200">
<div
className={`h-full rounded-full transition-all duration-500 ${colorClass.split(' ')[0]}`}
style={{ width: `${barWidth}%` }}
/>
</div>
<span className="text-[10px] font-bold text-slate-600 w-8 text-right shrink-0">
{count}
</span>
</div>
);
})}
</div>
)}
</div>
{/* 工具分类统计 */}
<div className="border-t border-slate-200 pt-3">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider block mb-2">
</span>
<div className="flex flex-wrap gap-1.5">
{Object.entries(getCategoryCounts(metrics.tool_call_breakdown)).map(([category, count]) => (
<span
key={category}
className="px-2.5 py-1 rounded-lg text-[10px] font-bold border bg-slate-50 text-slate-600 border-slate-200"
>
{category}: {count}
</span>
))}
</div>
</div>
</>
) : null}
</div>
);
}
// 小指标卡片
function MetricCard({
icon,
label,
value,
color,
}: {
icon: React.ReactNode;
label: string;
value: string | number;
color: string;
}) {
const colorMap: Record<string, string> = {
sky: 'border-sky-200 bg-sky-50 text-sky-700',
emerald: 'border-emerald-200 bg-emerald-50 text-emerald-700',
violet: 'border-violet-200 bg-violet-50 text-violet-700',
rose: 'border-rose-200 bg-rose-50 text-rose-700',
amber: 'border-amber-200 bg-amber-50 text-amber-700',
};
return (
<div className={`rounded-xl border p-3.5 ${colorMap[color] || colorMap.sky} transition-all`}>
<div className="flex items-center gap-1.5 mb-1.5">
<span className="opacity-60">{icon}</span>
<span className="text-[10px] font-bold uppercase tracking-wider opacity-70">{label}</span>
</div>
<div className="text-lg font-extrabold tracking-tight">
{value}
</div>
</div>
);
}
// 按功能域分组统计
function getCategoryCounts(breakdown: Record<string, number>): Record<string, number> {
const categories: Record<string, string[]> = {
'文件系统': ['read_file', 'grep_files', 'glob_files', 'run_bash', 'file_write', 'file_edit'],
'文献科研': ['search_papers', 'get_paper_metadata', 'download_paper', 'parse_paper', 'get_paper_content'],
'RAG/天体': ['rag_search', 'query_target', 'save_note'],
'Agent控制': ['todo_write', 'compress_context', 'load_skill', 'subagent', 'delegate_research', 'ask_user'],
'记忆系统': ['save_memory', 'load_memory'],
'后台任务': ['bg_task_run', 'bg_task_check'],
'团队协作': ['spawn_teammate', 'send_teammate_message', 'team_broadcast', 'check_team_inbox'],
};
const result: Record<string, number> = {};
for (const [cat, tools] of Object.entries(categories)) {
const total = tools.reduce((sum, t) => sum + (breakdown[t] || 0), 0);
if (total > 0) {
result[cat] = total;
}
}
return result;
}

View File

@ -0,0 +1,293 @@
// dashboard/src/features/agent/AskUserQuestionCard.tsx
import { useState, useEffect } from 'react';
import axios from 'axios';
import { MessageCircle, Send, X, Loader, CheckSquare, Square } from 'lucide-react';
import type { PendingQuestion } from '../../types';
interface AskUserQuestionCardProps {
onAnswered?: () => void;
}
export function AskUserQuestionCard({ onAnswered }: AskUserQuestionCardProps) {
const [pendingQuestions, setPendingQuestions] = useState<PendingQuestion[]>([]);
const [answers, setAnswers] = useState<Record<string, string[]>>({});
const [freeText, setFreeText] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState<Record<string, boolean>>({});
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [error, setError] = useState<Record<string, string | null>>({});
// 轮询待回答问题
useEffect(() => {
let cancelled = false;
const poll = async () => {
try {
const res = await axios.get<PendingQuestion[]>('/api/chat/questions');
if (!cancelled) {
// 安全解析:确保返回的是数组
const data = Array.isArray(res.data) ? res.data : [];
setPendingQuestions(data);
// 自动展开新问题
setExpanded(prev => {
const next = { ...prev };
for (const q of data) {
if (q && q.question_id && !(q.question_id in next)) {
next[q.question_id] = true;
}
}
return next;
});
}
} catch (e) {
console.error('获取待回答问题失败:', e);
}
};
poll();
const interval = setInterval(poll, 3000); // 每3秒轮询
return () => {
cancelled = true;
clearInterval(interval);
};
}, []);
// 切换选项:使用 option.label 而非整个对象
const toggleOption = (questionId: string, optionLabel: string, multiSelect: boolean) => {
setAnswers(prev => {
const current = prev[questionId] || [];
if (multiSelect) {
return {
...prev,
[questionId]: current.includes(optionLabel)
? current.filter(o => o !== optionLabel)
: [...current, optionLabel],
};
} else {
return { ...prev, [questionId]: [optionLabel] };
}
});
};
const handleSubmit = async (questionId: string) => {
setSubmitting(prev => ({ ...prev, [questionId]: true }));
setError(prev => ({ ...prev, [questionId]: null }));
try {
await axios.post('/api/chat/answer', {
question_id: questionId,
answers: answers[questionId] || [],
free_text: freeText[questionId] || null,
});
// 移除已回答的问题
setPendingQuestions(prev => prev.filter(q => q.question_id !== questionId));
// 清理状态
setAnswers(prev => {
const next = { ...prev };
delete next[questionId];
return next;
});
setFreeText(prev => {
const next = { ...prev };
delete next[questionId];
return next;
});
setError(prev => {
const next = { ...prev };
delete next[questionId];
return next;
});
onAnswered?.();
} catch (e: any) {
console.error('提交答案失败:', e);
const msg = e.response?.status === 410
? '该问题已超时或已被回答'
: e.response?.status === 404
? '未找到该问题'
: '提交失败,请稍后重试';
setError(prev => ({ ...prev, [questionId]: msg }));
} finally {
setSubmitting(prev => ({ ...prev, [questionId]: false }));
}
};
const dismissQuestion = (questionId: string) => {
setPendingQuestions(prev => prev.filter(q => q.question_id !== questionId));
setExpanded(prev => ({ ...prev, [questionId]: false }));
};
if (pendingQuestions.length === 0) return null;
return (
<div className="space-y-3">
{pendingQuestions.map(q => {
// 防御:确保必填字段存在
if (!q || !q.question_id) return null;
const isExpanded = expanded[q.question_id] !== false;
const isSubmitting = submitting[q.question_id] || false;
const qError = error[q.question_id] || null;
const options = Array.isArray(q.options) ? q.options : [];
const multiSelect = q.multi_select === true;
return (
<div
key={q.question_id}
className="bg-amber-50 border-2 border-amber-300 rounded-xl shadow-lg overflow-hidden transition-all"
>
{/* 头部 — 显示 header 标签和问题摘要 */}
<button
onClick={() => setExpanded(prev => ({ ...prev, [q.question_id]: !prev[q.question_id] }))}
className="w-full flex items-center justify-between px-4 py-3 bg-amber-100/50 hover:bg-amber-100 transition-colors cursor-pointer"
>
<div className="flex items-center gap-2 text-left min-w-0">
<MessageCircle className="w-4 h-4 text-amber-600 shrink-0" />
<span className="px-1.5 py-0.5 rounded bg-amber-200 border border-amber-300 text-[10px] font-extrabold text-amber-800 shrink-0">
{q.header || '提问'}
</span>
{!isExpanded && (
<span className="text-[10px] text-amber-700 font-medium truncate">
{q.question || ''}
</span>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{!isExpanded && (
<span className="text-[9px] text-amber-500 font-bold px-1.5 py-0.5 bg-amber-100 rounded">
</span>
)}
<button
onClick={(e) => { e.stopPropagation(); dismissQuestion(q.question_id); }}
className="p-1 rounded-md text-amber-400 hover:text-amber-600 hover:bg-amber-200 transition-colors cursor-pointer"
title="忽略此问题"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</button>
{/* 内容 */}
{isExpanded && (
<div className="px-4 py-3 space-y-3">
{/* 问题文本 */}
<div className="space-y-1.5">
<span className="text-[10px] font-bold text-amber-700 uppercase tracking-wider">
</span>
<p className="text-xs text-slate-800 font-semibold leading-relaxed bg-white rounded-lg p-3 border border-amber-200">
{q.question || ''}
</p>
</div>
{/* 选项列表 — 每个选项是 {label, description} 对象 */}
{options.length > 0 && (
<div className="space-y-1.5">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
{multiSelect ? '可多选' : '请选择一项'}
</span>
<div className="space-y-1.5">
{options.map((option, idx) => {
// 防御:确保 option 是有效对象
const label = typeof option?.label === 'string' ? option.label : String(option);
const desc = typeof option?.description === 'string' ? option.description : '';
const selected = (answers[q.question_id] || []).includes(label);
return (
<button
key={idx}
onClick={() => toggleOption(q.question_id, label, multiSelect)}
disabled={isSubmitting}
className={`w-full text-left px-3 py-2.5 rounded-lg border text-xs font-medium transition-all cursor-pointer flex items-start gap-2.5 ${
selected
? 'bg-sky-50 border-sky-300 text-sky-800'
: 'bg-white border-slate-200 text-slate-700 hover:border-sky-200 hover:bg-sky-50/50'
} disabled:opacity-50`}
>
{/* 选择指示器 */}
{multiSelect ? (
selected
? <CheckSquare className="w-3.5 h-3.5 text-sky-600 shrink-0 mt-0.5" />
: <Square className="w-3.5 h-3.5 text-slate-400 shrink-0 mt-0.5" />
) : (
<div className={`w-3.5 h-3.5 rounded-full border-2 shrink-0 mt-0.5 ${
selected ? 'border-sky-600 bg-sky-600' : 'border-slate-300'
}`}>
{selected && (
<div className="w-full h-full flex items-center justify-center">
<div className="w-1.5 h-1.5 rounded-full bg-white" />
</div>
)}
</div>
)}
{/* label + description */}
<div className="min-w-0">
<div className="font-semibold text-slate-800">{label}</div>
{desc && (
<div className="text-[10px] text-slate-500 mt-0.5 leading-relaxed">
{desc}
</div>
)}
</div>
</button>
);
})}
</div>
</div>
)}
{/* 自由文本 */}
<div className="space-y-1.5">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
</span>
<textarea
value={freeText[q.question_id] || ''}
onChange={(e) => setFreeText(prev => ({ ...prev, [q.question_id]: e.target.value }))}
disabled={isSubmitting}
placeholder="输入您的补充说明..."
rows={2}
className="w-full bg-white border border-slate-200 rounded-lg px-3 py-2 text-xs text-slate-800 placeholder-slate-400 focus:outline-none focus:border-sky-300 focus:ring-1 focus:ring-sky-500/20 resize-none disabled:opacity-50"
/>
</div>
{/* 错误提示(按问题 ID 隔离) */}
{qError && (
<div className="text-[10px] text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2 font-medium">
{qError}
</div>
)}
{/* 提交按钮 */}
<div className="flex gap-2 pt-1">
<button
onClick={() => handleSubmit(q.question_id)}
disabled={isSubmitting}
className="flex-1 flex items-center justify-center gap-1.5 bg-amber-600 hover:bg-amber-700 text-white rounded-lg py-2.5 text-xs font-bold transition-colors cursor-pointer disabled:opacity-50"
>
{isSubmitting ? (
<>
<Loader className="w-3.5 h-3.5 animate-spin" />
<span>...</span>
</>
) : (
<>
<Send className="w-3.5 h-3.5" />
<span></span>
</>
)}
</button>
<button
onClick={() => dismissQuestion(q.question_id)}
disabled={isSubmitting}
className="px-4 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg text-xs font-bold transition-colors cursor-pointer disabled:opacity-50"
>
</button>
</div>
</div>
)}
</div>
);
})}
</div>
);
}

View File

@ -0,0 +1,227 @@
// dashboard/src/features/agent/AuditLogViewer.tsx
import { useState, useEffect } from 'react';
import axios from 'axios';
import { ScrollText, Clock, CheckCircle2, XCircle, AlertTriangle, Loader, ChevronDown, ChevronUp } from 'lucide-react';
import type { AuditLogEntry } from '../../types';
interface AuditLogViewerProps {
sessionId: string;
onClose?: () => void;
}
function getToolDisplayName(name: string | null): string {
if (!name) return '—';
const map: Record<string, string> = {
read_file: '读取文件',
grep_files: '搜索文件',
glob_files: '匹配文件',
run_bash: 'Shell 命令',
file_write: '写入文件',
file_edit: '编辑文件',
search_papers: '文献检索',
get_paper_metadata: '获取元数据',
download_paper: '下载文献',
parse_paper: '解析文献',
get_paper_content: '获取内容',
rag_search: 'RAG 检索',
query_target: '天体查询',
save_note: '保存笔记',
todo_write: '任务管理',
compress_context: '压缩上下文',
load_skill: '加载技能',
subagent: '派发子代理',
delegate_research: '子代理研究(旧)',
ask_user: '用户提问',
save_memory: '保存记忆',
load_memory: '读取记忆',
bg_task_run: '后台任务',
bg_task_check: '检查后台',
spawn_teammate: '创建队友',
send_teammate_message: '队友消息',
team_broadcast: '团队广播',
check_team_inbox: '收件箱检查',
};
return map[name] || name;
}
export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
const [entries, setEntries] = useState<AuditLogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedPreview, setExpandedPreview] = useState<Record<number, boolean>>({});
useEffect(() => {
if (!sessionId) return;
setLoading(true);
setError(null);
axios
.get<AuditLogEntry[]>(`/api/chat/sessions/${sessionId}/audit`)
.then(res => setEntries(res.data))
.catch(e => {
console.error('加载审计日志失败:', e);
setError('审计日志加载失败');
})
.finally(() => setLoading(false));
}, [sessionId]);
const okCount = entries.filter(e => e.status === 'OK').length;
const failCount = entries.filter(e => e.status === 'FAIL').length;
const totalElapsed = entries.reduce((sum, e) => sum + e.elapsed_ms, 0);
return (
<div className="space-y-4">
{/* 头部 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ScrollText className="w-4 h-4 text-sky-600" />
<h3 className="text-xs font-extrabold text-slate-800 tracking-wide">
</h3>
</div>
{onClose && (
<button
onClick={onClose}
className="text-[10px] font-bold text-slate-400 hover:text-slate-600 transition-colors cursor-pointer"
>
</button>
)}
</div>
{/* 汇总 */}
{entries.length > 0 && (
<div className="grid grid-cols-3 gap-2">
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-3 py-2 text-center">
<div className="text-xs font-extrabold text-emerald-700">{okCount}</div>
<div className="text-[9px] font-bold text-emerald-500"></div>
</div>
<div className="rounded-lg bg-rose-50 border border-rose-200 px-3 py-2 text-center">
<div className="text-xs font-extrabold text-rose-700">{failCount}</div>
<div className="text-[9px] font-bold text-rose-500"></div>
</div>
<div className="rounded-lg bg-slate-50 border border-slate-200 px-3 py-2 text-center">
<div className="text-xs font-extrabold text-slate-700">{(totalElapsed / 1000).toFixed(1)}s</div>
<div className="text-[9px] font-bold text-slate-500"></div>
</div>
</div>
)}
{/* 加载/错误状态 */}
{loading && (
<div className="flex items-center justify-center py-8 text-slate-400 gap-2">
<Loader className="w-4 h-4 animate-spin text-sky-600" />
<span className="text-xs font-bold">...</span>
</div>
)}
{error && (
<div className="flex items-center gap-2 text-rose-600 bg-rose-50 border border-rose-200 rounded-lg px-3 py-2 text-xs font-medium">
<AlertTriangle className="w-3.5 h-3.5" />
{error}
</div>
)}
{/* 日志表格 */}
{!loading && entries.length === 0 && !error && (
<p className="text-xs text-slate-400 italic py-8 text-center">
</p>
)}
{entries.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-[10px]">
<thead>
<tr className="border-b border-slate-200 text-left">
<th className="pb-2 pr-2 font-extrabold text-slate-400 uppercase tracking-wider w-10"></th>
<th className="pb-2 pr-2 font-extrabold text-slate-400 uppercase tracking-wider"></th>
<th className="pb-2 pr-2 font-extrabold text-slate-400 uppercase tracking-wider w-12"></th>
<th className="pb-2 pr-2 font-extrabold text-slate-400 uppercase tracking-wider w-16 text-right"></th>
<th className="pb-2 font-extrabold text-slate-400 uppercase tracking-wider"></th>
</tr>
</thead>
<tbody>
{entries.map(entry => {
const isExpanded = expandedPreview[entry.id] || false;
const hasPreview = entry.output_preview && entry.output_preview.length > 0;
return (
<tr
key={entry.id}
className={`border-b border-slate-100 ${
entry.status === 'FAIL' ? 'bg-rose-50/30' : ''
}`}
>
<td className="py-2 pr-2 font-mono text-slate-500 align-top">
#{entry.step}
</td>
<td className="py-2 pr-2 font-semibold text-slate-700 align-top">
{getToolDisplayName(entry.tool_name)}
{entry.tool_name && (
<span className="text-[9px] text-slate-400 font-mono block">
{entry.tool_name}
</span>
)}
</td>
<td className="py-2 pr-2 align-top">
{entry.status === 'OK' ? (
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700 font-bold text-[9px]">
<CheckCircle2 className="w-2.5 h-2.5" />
OK
</span>
) : entry.status === 'FAIL' ? (
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-rose-100 text-rose-700 font-bold text-[9px]">
<XCircle className="w-2.5 h-2.5" />
FAIL
</span>
) : (
<span className="text-slate-400 text-[9px]">{entry.status}</span>
)}
</td>
<td className="py-2 pr-2 text-right font-mono text-slate-500 align-top">
<span className="flex items-center gap-0.5 justify-end">
<Clock className="w-2.5 h-2.5" />
{entry.elapsed_ms >= 1000
? `${(entry.elapsed_ms / 1000).toFixed(1)}s`
: `${entry.elapsed_ms}ms`}
</span>
</td>
<td className="py-2 align-top">
{hasPreview ? (
<div>
{isExpanded ? (
<div className="space-y-1">
<pre className="font-mono text-[9px] text-slate-600 bg-slate-50 border border-slate-200 rounded p-2 max-h-32 overflow-y-auto whitespace-pre-wrap leading-relaxed">
{entry.output_preview}
</pre>
<button
onClick={() => setExpandedPreview(prev => ({ ...prev, [entry.id]: false }))}
className="text-[9px] font-bold text-sky-600 hover:underline cursor-pointer flex items-center gap-0.5"
>
<ChevronUp className="w-2.5 h-2.5" />
</button>
</div>
) : (
<button
onClick={() => setExpandedPreview(prev => ({ ...prev, [entry.id]: true }))}
className="text-left font-mono text-[9px] text-slate-500 hover:text-sky-600 transition-colors cursor-pointer flex items-center gap-0.5 max-w-[200px]"
>
<ChevronDown className="w-2.5 h-2.5 shrink-0" />
<span className="truncate">{(entry.output_preview ?? '').slice(0, 60)}{(entry.output_preview ?? '').length > 60 ? '...' : ''}</span>
</button>
)}
</div>
) : (
<span className="text-slate-400 italic text-[9px]"></span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,129 @@
// dashboard/src/features/agent/PermissionRequestCard.tsx
import { useState, useEffect } from 'react';
import axios from 'axios';
import { Shield, Check, X, Loader } from 'lucide-react';
import type { PendingPermissionRequest } from '../../types';
interface PermissionRequestCardProps {
sessionId: string;
}
/** 显示待处理的工具执行权限请求卡片 */
export function PermissionRequestCard({ sessionId }: PermissionRequestCardProps) {
const [pending, setPending] = useState<PendingPermissionRequest[]>([]);
const [submitting, setSubmitting] = useState<Record<string, boolean>>({});
const [responses, setResponses] = useState<Record<string, 'allow' | 'deny' | null>>({});
// 轮询待处理权限请求
useEffect(() => {
if (!sessionId) return;
let cancelled = false;
const poll = async () => {
try {
const res = await axios.get<PendingPermissionRequest[]>(
`/api/chat/sessions/${sessionId}/permissions`,
);
if (!cancelled) {
const data = Array.isArray(res.data) ? res.data : [];
setPending(data);
}
} catch {
// 静默失败,等待下次轮询
}
};
poll();
const interval = setInterval(poll, 2000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [sessionId]);
const respond = async (
toolCallId: string,
allowed: boolean,
allowAlways: boolean,
) => {
setSubmitting(prev => ({ ...prev, [toolCallId]: true }));
try {
await axios.post(
`/api/chat/sessions/${sessionId}/permissions/respond`,
{ tool_call_id: toolCallId, allowed, allow_always: allowAlways },
);
setResponses(prev => ({
...prev,
[toolCallId]: allowed ? 'allow' : 'deny',
}));
} catch (err) {
console.error('权限响应发送失败:', err);
} finally {
setSubmitting(prev => ({ ...prev, [toolCallId]: false }));
}
};
// 不显示已处理的请求
const activeRequests = pending.filter(p => !responses[p.tool_call_id]);
if (activeRequests.length === 0) return null;
return (
<div className="flex flex-col gap-3">
{activeRequests.map(req => (
<div
key={req.permission_id}
className="rounded-lg border border-amber-500/30 bg-amber-950/20 p-4"
>
<div className="mb-2 flex items-center gap-2">
<Shield className="h-5 w-5 text-amber-400" />
<span className="font-semibold text-amber-300"></span>
<code className="rounded bg-amber-900/40 px-1.5 py-0.5 text-xs text-amber-200">
{req.tool_name}
</code>
</div>
<p className="mb-3 text-sm text-amber-200/80">{req.message}</p>
{/* 显示工具参数的简化预览 */}
{Object.keys(req.arguments).length > 0 && (
<pre className="mb-3 max-h-24 overflow-auto rounded bg-black/30 p-2 text-xs text-amber-300/60">
{JSON.stringify(req.arguments, null, 2)}
</pre>
)}
<div className="flex gap-2">
<button
onClick={() => respond(req.tool_call_id, true, false)}
disabled={submitting[req.tool_call_id]}
className="flex items-center gap-1 rounded bg-emerald-600 px-3 py-1.5 text-sm text-white transition hover:bg-emerald-500 disabled:opacity-50"
>
{submitting[req.tool_call_id] ? (
<Loader className="h-4 w-4 animate-spin" />
) : (
<Check className="h-4 w-4" />
)}
Allow
</button>
<button
onClick={() => respond(req.tool_call_id, true, true)}
disabled={submitting[req.tool_call_id]}
className="flex items-center gap-1 rounded bg-emerald-700 px-3 py-1.5 text-sm text-emerald-200 transition hover:bg-emerald-600 disabled:opacity-50"
>
Always Allow
</button>
<button
onClick={() => respond(req.tool_call_id, false, false)}
disabled={submitting[req.tool_call_id]}
className="flex items-center gap-1 rounded bg-red-600 px-3 py-1.5 text-sm text-white transition hover:bg-red-500 disabled:opacity-50"
>
<X className="h-4 w-4" />
Deny
</button>
</div>
</div>
))}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -50,3 +50,125 @@ export interface SavedSyncQuery {
limit_count: number;
last_run: string;
}
// ── Agent 相关类型 ──
export interface SessionSummary {
session_id: string;
title: string;
model: string;
turn_count: number;
created_at: string;
updated_at: string;
}
export interface MessageRecord {
id: number;
agent_name: string;
turn_index: number;
step_index: number;
role: 'system' | 'user' | 'assistant' | 'tool';
content: string;
thought?: string | null;
tool_calls?: ToolCall[] | null;
tool_call_id?: string | null;
token_count: number;
metadata?: any | null;
created_at: string;
}
export interface ToolCall {
id: string;
type: string;
function: {
name: string;
arguments: string; // JSON string
};
}
export interface SessionDetail {
session: SessionSummary;
messages: MessageRecord[];
}
// ── Agent 指标 ──
export interface AgentMetricsResponse {
total_sessions: number;
total_tool_calls: number;
tool_call_breakdown: Record<string, number>;
avg_steps_per_session: number;
error_rate: number;
}
// ── 审计日志 ──
export interface AuditLogEntry {
id: number;
step: number;
tool_name: string | null;
status: string;
elapsed_ms: number;
output_preview: string | null;
created_at: string;
}
// ── 交互式问答 (ask_user 工具) ──
export interface UserOption {
label: string;
description: string;
}
export interface PendingQuestion {
question_id: string;
question: string;
header: string;
options: UserOption[];
multi_select: boolean;
}
export interface AnswerQuestionRequest {
question_id: string;
answers: string[];
free_text?: string;
}
// ── 权限请求 (Permission Checker) ──
export interface PendingPermissionRequest {
permission_id: string;
tool_call_id: string;
tool_name: string;
message: string;
arguments: Record<string, unknown>;
}
export interface PermissionResponseRequest {
tool_call_id: string;
allowed: boolean;
allow_always: boolean;
}
// ── 项目记忆 ──
export interface MemoryEntry {
slug: string;
name: string;
description: string;
memory_type: string;
content: string;
created_at: string;
updated_at: string;
}
// ── Agent 任务板 ──
export interface AgentTask {
id: string;
description: string;
status: 'pending' | 'in_progress' | 'completed' | 'cancelled';
dependencies: string[];
created_at: string;
updated_at: string;
}

View File

@ -1,738 +0,0 @@
# Agent 架构优化分析
> 对比 Claude Code 源码 (`/home/fmq/program/claudecode/src/`) 与 AstroResearch Agent (`src/agent/`)
> 基于 2026-06-16 的代码快照。
---
## 总体评估
我们的 Agent 已经实现了一个功能完整的 ReAct 研究引擎,涵盖了工具注册/调度、三层上下文压缩、生命周期 Hooks、
多 Agent 团队协作、子代理委托、后台任务、Skills 加载等关键子系统。与 Claude Code 的架构范式高度一致。
以下按**影响优先级**列出可优化领域。
---
## 一、CRITICALStreaming Tool Executor
### 现状
`executor.rs` 在 LLM 流式响应**完全结束后**才通过 `join_all` 并行执行工具。
```rust
// 当前流程LLM stream → 收集所有 tool_use blocks → join_all 执行
let results = futures_util::future::join_all(exec_futs).await;
```
### Claude Code 做法
`StreamingToolExecutor` 在模型**仍在生成** tool_use blocks 时就开始调度执行:
```
模型输出 tool_use(Read file A) → 立即开始读 A
模型输出 tool_use(Read file B) → 立即开始读 B并发安全
模型输出 tool_use(Bash cmd) → 排队等待(非并发安全)
模型输出结束 → 此时 A 和 B 可能已完成
```
### 优化方案
```rust
/// 流式工具执行器 —— 模型还在输出时就开始执行工具
pub struct StreamingToolExecutor {
tools: Vec<TrackedTool>,
tool_registry: Arc<ToolRegistry>,
tool_context: ToolContext,
max_concurrency: usize,
}
enum ToolStatus {
Queued,
Executing,
Completed,
Yielded,
}
struct TrackedTool {
id: String,
block: ToolCall,
status: ToolStatus,
is_concurrency_safe: bool,
handle: Option<JoinHandle<ToolOutput>>,
results: Option<Vec<ToolResultMessage>>,
pending_progress: Vec<ProgressMessage>,
}
impl StreamingToolExecutor {
/// 模型每输出一个 tool_use block 就调用此方法
pub fn add_tool(&mut self, block: ToolCall) {
let is_safe = self.tool_registry
.get(&block.name)
.map(|t| t.is_concurrency_safe(&block.args))
.unwrap_or(false);
self.tools.push(TrackedTool {
id: block.id.clone(),
block,
status: ToolStatus::Queued,
is_concurrency_safe: is_safe,
handle: None,
results: None,
pending_progress: vec![],
});
tokio::spawn(async { self.process_queue().await });
}
/// 非阻塞获取已完成的工具结果
pub fn get_completed_results(&mut self) -> Vec<ToolResultMessage> {
// 按顺序 yield 已完成的结果
// 非并发安全的工具保持顺序
// 进度消息立即 yield
}
/// 等待所有剩余工具完成
pub async fn get_remaining_results(&mut self) -> Vec<ToolResultMessage> {
// 等待 executing 的工具完成
// 然后 yield 所有结果
}
}
```
**预期收益**:大幅降低端到端延迟,尤其是当模型并行输出多个独立的 Read/Search 类工具调用时。
---
## 二、HIGH工具并发分区
### 现状
`executor.rs` 对所有工具调用一律使用 `join_all` 并行执行,不考虑工具的并发安全性。
### Claude Code 做法
`partitionToolCalls()` 将工具调用分区为:
1. **并发安全批次** — 连续的 `isConcurrencySafe=true` 工具(如 Read、Grep、WebSearch
2. **串行批次** — 单个 `isConcurrencySafe=false` 工具(如 Bash、Edit、Write
并发批次用 `all()` 并行执行max concurrency = 10串行批次逐个执行。
### 优化方案
```rust
/// 工具特征增加并发安全声明
#[async_trait]
pub trait AgentTool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn parameters(&self) -> serde_json::Value;
/// 工具是否可以与其他并发安全的工具同时执行
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true // 默认只读工具是并发安全的
}
/// 最大并发数(默认无限制)
fn max_concurrency(&self) -> Option<usize> {
None
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput;
}
/// 分区工具调用
fn partition_tool_calls(
calls: &[PreparedCall],
registry: &ToolRegistry,
) -> Vec<Batch> {
let mut batches: Vec<Batch> = vec![];
for call in calls {
let is_safe = registry.get(&call.tool_name)
.map(|t| t.is_concurrency_safe(&call.args))
.unwrap_or(false);
if is_safe && batches.last().map_or(false, |b| b.is_concurrency_safe) {
batches.last_mut().unwrap().calls.push(call.clone());
} else {
batches.push(Batch {
is_concurrency_safe: is_safe,
calls: vec![call.clone()],
});
}
}
batches
}
```
**预期收益**:避免 Bash/Write 等有副作用的工具与其他工具竞争导致的不确定性。
---
## 三、HIGHSibling Abort兄弟中止
### 现状
当一个工具执行出错时,其他并行执行的工具继续运行,浪费资源。
### Claude Code 做法
`StreamingToolExecutor` 中:
- 当 Bash 工具出错时,`siblingAbortController.abort("sibling_error")` 中止所有兄弟 Bash 执行
- Read/WebFetch 等独立工具的失败不影响其他工具
- 被中止的工具获得 synthetic error message
### 优化方案
```rust
/// 在并行执行时注入 sibling abort 信号
pub struct SiblingAbortController {
abort_sender: tokio::sync::broadcast::Sender<SiblingAbortReason>,
errored_tool_description: Arc<tokio::sync::Mutex<Option<String>>>,
}
enum SiblingAbortReason {
SiblingError { description: String },
UserInterrupted,
StreamingFallback,
}
impl SiblingAbortController {
/// 当工具出错时调用,如果是 Bash 类工具则通知所有兄弟
pub fn notify_error(&self, tool_name: &str, tool_desc: &str) {
if is_bash_like_tool(tool_name) {
let _ = self.abort_sender.send(SiblingAbortReason::SiblingError {
description: tool_desc.to_string(),
});
}
}
/// 每个工具执行前检查是否已被兄弟中止
pub fn check_aborted(&self, this_tool: &str) -> Option<String> {
// 如果是本工具报的错,不生成 synthetic error避免重复
}
}
```
**预期收益**:避免无效的后续执行,减少等待时间和 API 调用浪费。
---
## 四、HIGHError Recovery Ladder错误恢复阶梯
### 现状
`compact.rs` 只有**主动压缩**(在达到 token 限制前触发。如果压缩不够激进413 `prompt_too_long` 错误会直接暴露给用户。
### Claude Code 做法
`query.ts` 实现了多层恢复阶梯:
```
第1层Context Collapse drain便宜commit 已 staged 的 collapse
↓ 失败/不可用
第2层Reactive Compactfork agent 摘要整个对话)
↓ 失败
第3层Max Output Tokens Escalate临时提升到 64k token cap
↓ 再次命中
第4层Multi-turn Recovery注入 meta message继续对话
↓ 全部失败
最终Surface the error暴露给用户
```
每层都有 `hasAttempted` 守卫防止无限重试autocompact 有 circuit breaker连续 3 次失败后停止)。
### 优化方案
```rust
/// 错误恢复策略枚举
enum RecoveryStrategy {
/// 尝试更激进的 micro_compact
AggressiveMicroCompact,
/// LLM 摘要整个对话历史
ReactiveCompact,
/// 提升 max_tokens 上限
MaxTokensEscalate,
/// 注入 metacognitive 消息
MultiTurnRecovery,
/// 放弃,暴露错误给用户
Surface,
}
struct RecoveryState {
attempted_micro_compact: bool,
attempted_reactive_compact: bool,
attempted_max_tokens_escalation: bool,
autocompact_failure_count: u32,
}
const MAX_AUTOCOMPACT_FAILURES: u32 = 3;
impl RecoveryState {
fn next_strategy(&mut self, error: &ModelError) -> RecoveryStrategy {
match error {
ModelError::ContextOverflow(_) => {
if !self.attempted_micro_compact {
self.attempted_micro_compact = true;
return RecoveryStrategy::AggressiveMicroCompact;
}
if !self.attempted_reactive_compact
&& self.autocompact_failure_count < MAX_AUTOCOMPACT_FAILURES
{
self.attempted_reactive_compact = true;
return RecoveryStrategy::ReactiveCompact;
}
if !self.attempted_max_tokens_escalation {
self.attempted_max_tokens_escalation = true;
return RecoveryStrategy::MaxTokensEscalate;
}
RecoveryStrategy::MultiTurnRecovery
}
_ => RecoveryStrategy::Surface,
}
}
}
```
**预期收益**:显著提高长对话的鲁棒性,减少用户遇到的 "context too long" 错误。
---
## 五、MEDIUMTime-Based Microcompact
### 现状
我们的 micro_compact 只基于消息数量/大小触发,不考虑时间因素。
### Claude Code 做法
`microcompactMessages()` 首先检查 `evaluateTimeBasedTrigger()`
- 计算距上一条 assistant 消息的时间间隔
- 如果超过配置阈值(如 5 分钟),服务器的 prompt cache 已经过期
- 此时直接 content-clear 旧的 tool results保留最近 N 个)
- 因为 cache 已冷,修改消息内容不会有额外代价
### 优化方案
```rust
pub struct TimeBasedMCConfig {
pub enabled: bool,
/// 触发阈值(分钟)
pub gap_threshold_minutes: u64,
/// 保留最近 N 个工具结果
pub keep_recent: usize,
}
impl Default for TimeBasedMCConfig {
fn default() -> Self {
Self {
enabled: true,
gap_threshold_minutes: 5,
keep_recent: 4,
}
}
}
/// 检查时间触发是否应激活
fn evaluate_time_based_trigger(
messages: &[ChatMessage],
config: &TimeBasedMCConfig,
) -> Option<TimeBasedTrigger> {
let last_assistant = messages.iter()
.rev()
.find(|m| m.role == MessageRole::Assistant)?;
let elapsed = last_assistant.timestamp.elapsed().unwrap_or_default();
let gap_minutes = elapsed.as_secs() / 60;
if gap_minutes >= config.gap_threshold_minutes {
Some(TimeBasedTrigger { gap_minutes })
} else {
None
}
}
```
**预期收益**:在长时间闲置后自动清理过期上下文,防止用户回到对话时遇上 context overflow。
---
## 六、MEDIUM增强的 Hook 系统
### 现状
5 个生命周期事件,简单的 `HookAction::Continue/Block` 二元决策。
### Claude Code 做法
27 个 hook 事件4 种 hook 类型command/prompt/agent/http结构化 JSON 协议:
```json
{
"continue": true,
"decision": "approve",
"reason": "...",
"systemMessage": "...",
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": { ... },
"additionalContext": "..."
}
}
```
### 优化建议(按价值排序)
1. **PreToolUse 输入修改**Hook 可以修改工具参数后再执行(如自动修正 bibcode 格式)
2. **PostToolUse 输出修改**Hook 可以后处理工具结果(如自动翻译、格式化)
3. **additionalContext 注入**Hook 可以向 LLM 注入附加上下文
4. **SessionStart watchPaths**:启动时注册文件监控路径
5. **SubagentStart/Stop**:子代理生命周期事件
```rust
/// 增强的 PreToolUse 输出
pub struct PreToolUseHookOutput {
pub decision: HookDecision,
pub updated_input: Option<serde_json::Value>,
pub additional_context: Option<String>,
pub system_message: Option<String>,
}
pub enum HookDecision {
Allow,
Deny { reason: String },
Ask { reason: String },
}
/// 增强的 PostToolUse 输出
pub struct PostToolUseHookOutput {
pub updated_output: Option<String>,
pub additional_context: Option<String>,
}
```
---
## 七、MEDIUMPermission Pipeline权限管道
### 现状
工具没有权限系统。所有工具对 Agent 都同样可用。
### Claude Code 做法
多层权限评估管道:
```
Step 1: Deny rule 匹配 → deny不可覆盖
Step 2: Ask rule 匹配 → ask除非 sandbox override
Step 3: Tool.checkPermissions → 工具自身逻辑
Step 4: Safety checks → ask绕过免疫
Step 5: bypassPermissions 模式 → allow
Step 6: Allow rule 匹配 → allow
Step 7: 默认 → ask
```
权限规则格式:`ToolName(pattern:*)`,支持多源优先级链。
### 优化方案
```rust
/// 权限行为
pub enum PermissionBehavior {
Allow,
Deny,
Ask,
}
/// 权限规则来源(优先级从高到低)
pub enum RuleSource {
Policy, // 企业策略
UserSettings,
ProjectSettings,
LocalSettings,
CliArg,
Session,
}
/// 权限规则
pub struct PermissionRule {
pub source: RuleSource,
pub behavior: PermissionBehavior,
pub tool_pattern: String, // "search_papers" 或 "bash(git *)"
pub content_pattern: Option<String>,
}
/// 权限检查器
pub struct PermissionChecker {
rules: Vec<PermissionRule>,
}
impl PermissionChecker {
pub fn check(
&self,
tool_name: &str,
input: &serde_json::Value,
) -> PermissionDecision {
// 1. 检查 deny 规则(不可覆盖)
// 2. 检查 ask 规则
// 3. 工具自身 check_permissions
// 4. 安全检查
// 5. bypass 模式
// 6. allow 规则
// 7. 默认 ask
}
}
```
---
## 八、MEDIUMProgress Streaming进度流式传输
### 现状
工具执行期间没有任何进度反馈,直到执行完成才发送结果。
### Claude Code 做法
`StreamingToolExecutor` 支持工具的进度消息立即 yield即使工具还在执行中。进度消息类型为 `"progress"`,在 UI 中显示为短暂的状态更新。
### 优化方案
```rust
/// 为长时间运行的工具增加进度回调
#[async_trait]
pub trait AgentTool: Send + Sync {
// ... 现有方法 ...
/// 带进度回调的执行(默认委托给 execute
async fn execute_with_progress(
&self,
args: serde_json::Value,
ctx: &ToolContext,
progress: mpsc::UnboundedSender<ProgressUpdate>,
) -> ToolOutput {
let _ = progress; // 默认忽略
self.execute(args, ctx).await
}
}
/// 进度更新
pub struct ProgressUpdate {
pub tool_call_id: String,
pub message: String,
pub percentage: Option<u8>,
}
```
**适用工具**download_paper下载进度、parse_paper解析进度、search_papers搜索进度
---
## 九、MEDIUMToken Budget Management
### 现状
没有 token 预算跟踪。Agent 可以无限制地消耗 tokens。
### Claude Code 做法
- `budget.total` — 用户设定的 token 预算上限
- `budget.spent()` — 当前已消耗的 output tokens
- `budget.remaining()` — 剩余可用 tokens
- 硬上限:达到 total 后 `agent()` 调用会抛错
- 软上限:在接近限制时注入 nudge 消息提醒模型
### 优化方案
```rust
pub struct TokenBudget {
total: Option<u64>,
spent_output_tokens: u64,
spent_input_tokens: u64,
}
impl TokenBudget {
pub fn new(total: Option<u64>) -> Self { ... }
pub fn record_usage(&mut self, input: u64, output: u64) {
self.spent_input_tokens += input;
self.spent_output_tokens += output;
}
pub fn remaining(&self) -> Option<u64> {
self.total.map(|t| t.saturating_sub(self.spent_output_tokens))
}
/// 在接近限制时生成提醒消息
pub fn nudge_message(&self) -> Option<String> {
if let (Some(total), Some(rem)) = (self.total, self.remaining()) {
if rem < total / 10 {
Some(format!(
"注意token 预算已使用 {:.0}%,剩余约 {} tokens。请尽快给出最终答案。",
(self.spent_output_tokens as f64 / total as f64) * 100.0,
rem
))
} else {
None
}
} else {
None
}
}
}
```
---
## 十、LOW-MEDIUMInterrupt Behavior中断行为分类
### 现状
取消信号对所有工具一视同仁。
### Claude Code 做法
每个工具声明 `interruptBehavior()`
- `"cancel"` — 用户中断时立即取消(如 Read、Search
- `"block"` — 用户中断时继续执行完毕(如 Edit、Write防止文件损坏
### 优化方案
```rust
pub enum InterruptBehavior {
/// 用户中断时立即取消(默认,适合只读工具)
Cancel,
/// 用户中断时继续执行到完成(适合写入工具)
Block,
}
#[async_trait]
pub trait AgentTool: Send + Sync {
// ... 现有方法 ...
fn interrupt_behavior(&self) -> InterruptBehavior {
InterruptBehavior::Cancel // 默认安全取消
}
}
```
---
## 十一、LOWCircuit Breaker 模式
### 现状
compact 失败没有熔断机制,可能无限重试。
### Claude Code 做法
- `MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3`
- fallback model 调用遇到 `529 Overloaded` 时切换到备选模型
- 所有恢复策略都有 `hasAttempted` 守卫
### 优化方案
```rust
pub struct CircuitBreaker {
max_failures: u32,
failure_count: u32,
state: CircuitState,
}
enum CircuitState {
Closed, // 正常工作
Open, // 熔断,拒绝请求
HalfOpen, // 试探性恢复
}
impl CircuitBreaker {
pub fn check(&mut self) -> Result<(), CircuitOpenError> {
match self.state {
CircuitState::Open => Err(CircuitOpenError),
CircuitState::HalfOpen | CircuitState::Closed => Ok(()),
}
}
pub fn record_success(&mut self) {
self.failure_count = 0;
self.state = CircuitState::Closed;
}
pub fn record_failure(&mut self) {
self.failure_count += 1;
if self.failure_count >= self.max_failures {
self.state = CircuitState::Open;
}
}
}
```
---
## 十二、LOWContext Collapse / Projection System架构级
### 现状
压缩直接修改消息数组,原始上下文永久丢失。
### Claude Code 做法
`ContextCollapse` 采用 **commit log + projection** 模式:
1. 不再直接修改消息
2. 将旧的上下文段替换为摘要 + metadata
3. 摘要存储在独立的 collapse store 中
4. 每次查询循环入口通过 `projectView()` 重放 commit log 重建临时消息视图
5. Commit 是 staged先暂存再 committedon overflow
### 适用场景
我们的论文研究场景中Agent 可能会在同一个 session 中研究多篇论文。Context collapse 可以在切换论文时保留之前的研究摘要而不是完全丢弃。
### 优化方案
```rust
/// 上下文段
pub struct ContextSegment {
pub id: String,
pub summary: String,
pub original_message_count: usize,
pub original_token_estimate: usize,
pub created_at: chrono::DateTime<chrono::Utc>,
}
/// Collapse 存储
pub struct CollapseStore {
segments: Vec<ContextSegment>,
commit_log: Vec<CollapseCommit>,
}
impl CollapseStore {
/// Stage 一个 collapse还不提交
pub fn stage(&mut self, segment: ContextSegment) { ... }
/// Commit 所有 staged collapses
pub fn commit_staged(&mut self) -> usize { ... }
/// 重放 commit log生成当前消息视图
pub fn project_view(
&self,
recent_messages: &[ChatMessage],
) -> Vec<ChatMessage> { ... }
}
```
---
## 实施优先级建议
| 优先级 | 优化项 | 预计工作量 | 收益 |
|--------|--------|-----------|------|
| P0 | Streaming Tool Executor | 3-5 天 | 延迟大幅降低 |
| P1 | 工具并发分区 | 1-2 天 | 正确性提升 |
| P1 | Error Recovery Ladder | 2-3 天 | 鲁棒性大幅提升 |
| P1 | Sibling Abort | 1 天 | 资源浪费减少 |
| P2 | Time-Based Microcompact | 1 天 | 长会话体验 |
| P2 | Token Budget Management | 1-2 天 | 成本控制 |
| P2 | Progress Streaming | 1-2 天 | UX 提升 |
| P3 | 增强 Hook 系统 | 2-3 天 | 可扩展性 |
| P3 | Permission Pipeline | 2-3 天 | 安全性 |
| P3 | Interrupt Behavior | 0.5 天 | 可靠性 |
| P4 | Circuit Breaker | 1 天 | 稳定性 |
| P4 | Context Collapse | 5-7 天 | 长期架构 |
---
## 架构范式已对齐的部分
以下方面我们的实现已经与 Claude Code 的范式高度一致,无需大幅改动:
1. ✅ ReAct Loop 结构Thought → Act → Observe
2. ✅ Tool trait + Registry 模式(虽可细化但已完整)
3. ✅ 三层上下文压缩micro/auto/aggressive
4. ✅ 安全切割点(不切断 tool_call/tool_result 配对)
5. ✅ Transcript 持久化(压缩前保存)
6. ✅ 生命周期 Hooks有基础的 5 事件)
7. ✅ 重复调用检测DuplicateDetector
8. ✅ Skills 两层加载系统
9. ✅ 子代理委托SubAgentRunner
10. ✅ 后台任务系统BgNotificationQueue + mpsc
11. ✅ 多 Agent 团队TeamManager + file inbox
12. ✅ SSE 流式事件到前端
13. ✅ Todo/Task 持久化到 SQLite
14. ✅ Session 生命周期管理

View File

@ -517,6 +517,93 @@ export interface SavedSyncQuery {
---
### 2.8 RAG 问答与天体目标识别 (RAG & Target)
#### 2.8.1 RAG 文献问答
- **Endpoint**: `POST /api/chat/rag`
- **Description**: 基于本地向量库进行 RAG 检索增强生成,回答天文学相关问题。
- **Request Body**:
```json
{
"question": "什么是赫罗图?",
"limit": 5
}
```
- **Response Schema**:
```json
{
"answer": "...",
"sources": [{"bibcode": "...", "title": "...", "relevance": 0.95}]
}
```
#### 2.8.2 图表问答
- **Endpoint**: `POST /api/chat/figure`
- **Description**: 对文献中的图表进行问答分析。
#### 2.8.3 天体目标查询
- **Endpoint**: `GET /api/target/query`
- **Query Parameters**: `name` (string, required) — 天体名称。
- **Description**: 通过 CDS Sesame 服务查询天体坐标和基本信息。
#### 2.8.4 天体目标关联
- **Endpoint**: `POST /api/target/associate`
- **Description**: 将识别出的天体目标与文献 Bibcode 关联。
#### 2.8.5 天体目标提取
- **Endpoint**: `POST /api/target/extract`
- **Description**: 从文本中自动提取 IAU 标准天体名称。
#### 2.8.6 天体目标列表
- **Endpoint**: `GET /api/target/list`
- **Description**: 获取所有已提取的天体目标列表。
---
### 2.9 智能体模块 (Agent)
#### 2.9.1 SSE 流式对话
- **Endpoint**: `POST /api/chat/agent`
- **Description**: 创建或恢复智能体会话,通过 SSE (Server-Sent Events) 流式返回 ReAct 循环的思考/工具调用/结果。
- **Request Body**:
```json
{
"question": "搜索并下载最近关于暗物质晕的论文",
"session_id": null
}
```
- **SSE Event Types**: `thought`, `tool_call`, `tool_result`, `text_delta`, `usage`, `error`, `done`
#### 2.9.2 会话管理
- `GET /api/chat/sessions` — 列出所有会话(支持 `?limit=&offset=` 分页)。
- `GET /api/chat/sessions/:id` — 获取会话详情(含消息历史)。
- `DELETE /api/chat/sessions/:id` — 软删除一个会话。
- `POST /api/chat/sessions/:id/stop` — 终止正在运行的会话。
#### 2.9.3 Agent 运行指标
- **Endpoint**: `GET /api/chat/metrics`
- **Description**: 返回聚合的智能体运行指标。
- **Response Schema**:
```json
{
"total_sessions": 42,
"total_tool_calls": 318,
"tool_call_breakdown": {"read_file": 120, "search_papers": 85, "run_bash": 43},
"avg_steps_per_session": 7.6,
"error_rate": 0.03
}
```
#### 2.9.4 会话审计日志
- **Endpoint**: `GET /api/chat/sessions/:id/audit`
- **Description**: 返回指定会话的工具调用审计日志(工具名、状态、耗时、输出预览)。
#### 2.9.5 人机交互问答
- `GET /api/chat/questions` — Agent 发出的待处理问题列表。
- `POST /api/chat/answer` — 用户回答 Agent 提出的问题,推进任务执行。
---
## 3. 完整路由表 (Route Summary)
| 方法 | 路径 | 说明 |
@ -542,6 +629,21 @@ export interface SavedSyncQuery {
| `GET` | `/api/sync/asset/status` | 查询资源处理状态 |
| `GET` | `/api/sync/queries` | 获取已保存检索条件 |
| `DELETE` | `/api/sync/queries/:id` | 删除已保存检索条件 |
| `GET` | `/api/chat/sessions` | 列出所有智能体会话 |
| `GET` | `/api/chat/sessions/:id` | 获取会话详情与消息历史 |
| `DELETE` | `/api/chat/sessions/:id` | 软删除指定会话 |
| `POST` | `/api/chat/sessions/:id/stop` | 终止正在运行的会话 |
| `GET` | `/api/chat/metrics` | 聚合智能体运行指标 |
| `GET` | `/api/chat/sessions/:id/audit` | 获取会话工具调用审计日志 |
| `GET` | `/api/chat/questions` | Agent 发出的待处理问题列表 |
| `POST` | `/api/chat/answer` | 用户回答 Agent 提出的问题 |
| `POST` | `/api/chat/agent` | SSE 流式智能体对话 |
| `POST` | `/api/chat/rag` | RAG 文献问答 |
| `POST` | `/api/chat/figure` | 图表问答分析 |
| `GET` | `/api/target/query` | 天体目标查询 |
| `POST` | `/api/target/associate` | 天体目标关联文献 |
| `POST` | `/api/target/extract` | 提取文本中的天体目标 |
| `GET` | `/api/target/list` | 天体目标列表 |
| `GET` | `/api/active_bibcode` | 获取当前活跃文献 |
| `POST` | `/api/active_bibcode` | 设置当前活跃文献 |

View File

@ -2,306 +2,44 @@
AstroResearch 是一个集成了天文学文献检索、多通道下载(含防爬绕过与手动上传)、下载错误诊断、结构化解析、中英学术对比翻译、引文星系图谱以及馆藏健康度诊断的天文科研辅助系统。
## 1. 整体架构 (Overall Architecture)
AstroResearch 采用 **C/S (Client-Server)** 架构,由前端 React 单页应用和后端 Axum HTTP 服务构成,核心流程及层级如下:
```mermaid
graph TD
subgraph Frontend ["React 前端 (Port 5173 / 8000)"]
UI[仪表盘 UI / ReaderPanel]
Canvas[引文 Canvas 拓扑图]
API_Client[Axum API 客户端]
CustomSelect[CustomSelect 可复用组件]
end
subgraph Backend ["Rust Axum 后端 (Port 8000)"]
Router[Axum 路由与中间件]
subgraph API ["API 层 (模块化)"]
Helpers[helpers.rs 格式转换与数据库工具]
Papers[papers.rs 文献检索/下载/上传/解析/翻译/引文/导出]
Notes[notes.rs 笔记 CRUD]
Sync[sync.rs 批量同步控制]
end
subgraph Services ["服务层"]
Batch[batch/ 批量同步引擎]
BatchMeta[batch/meta.rs 元数据采集]
BatchAsset[batch/asset.rs 资源处理]
Parser[parser.rs HTML/PDF 解析]
Downloader[download.rs 多通道下载器]
Translator[translation.rs LLM 翻译器]
Logging[logging.rs 日志系统]
end
DB[("SQLite / astro_research.db")]
end
subgraph External [外部第三方服务]
ADS[NASA ADS API]
arXiv[arXiv Atom XML API]
MinerU[MinerU PDF 解析服务]
QiniuCDN[七牛云对象存储 CDN]
LLM[LLM API]
end
UI -->|用户操作| API_Client
API_Client -->|RESTful APIs| Router
Router --> API
Papers -->|查询/保存元数据| DB
Papers -->|文献下载| Downloader
Papers -->|文件上传| Papers
Papers -->|正文解析| Parser
Papers -->|学术翻译| Translator
Sync -->|批量操作| Batch
BatchMeta -->|元数据同步| ADS
BatchMeta -->|元数据同步| arXiv
BatchAsset -->|批量文件下载| Downloader
BatchAsset -->|批量正文解析| Parser
BatchAsset -->|批量翻译| Translator
Batch -->|写库记录| DB
Downloader -->|代理请求| ADS
Downloader -->|直连或 ar5iv| arXiv
Parser -->|图文降级解析| MinerU
Parser -->|托管插图| QiniuCDN
Translator -->|天文术语翻译| LLM
Canvas -->|引文网络请求| Papers
```
> **本文档已拆分为模块化子文档。** 以下为索引导航。各子文档位于 `docs/architecture/` 目录下。
---
## 2. 核心工作流 (Core Workflows)
## 主架构
### 2.1 文献下载流程 (Download Flow)
本流程实现了文献的多通道流式下载,支持多级回退、错误诊断记录以及安全反爬防线绕过:
```mermaid
sequenceDiagram
participant U as 用户 (React 前端)
participant H as 处理器 (papers.rs)
participant D as 下载器 (download.rs)
participant DB as 本地数据库 (SQLite)
U->>H: 1. 发起下载请求 (POST /api/download, 含 bibcode, force)
H->>DB: 2. 查询文献元数据 (获取 arxiv_id, doi 等)
alt force == true
H->>DB: 3. 重置本地下载路径字段为 NULL
end
H->>D: 4. 调度下载器执行物理拉取
alt 文献含有 arxiv_id (通道 AarXiv 直连优先)
D->>D: 5a. 去除版本号 (strip_arxiv_version, v2 -> 无版本)
D->>D: 5b. 随机延时 (maybe_delay: 500-2000ms) 并伪装 UA
D->>D: 5c. 下载 PDF 并校验文件头 (%PDF + %%EOF)
D->>D: 5d. 优先请求官方 HTML (arxiv.org/html/)
note over D: 若官方 HTML 返回 404/错误
D->>D: 5e. 自动降级回退请求 ar5iv HTML (ar5iv.labs.arxiv.org)
D->>D: 5f. 校验 HTML 内容 (detect_anti_bot 检测反爬)
else 无 arxiv_id (通道 BADS 路由回退)
D->>D: 6a. 跟踪 ADS Link Gateway 重定向路由
note over D: 若遇到 validate.perfdrive.com 拦截
D->>D: 6b. 自动解析并解码 ssc 参数提取直链
note over D: 若指向 IOPscience / Springer
D->>D: 6c. IOP 专属策略:预热主页写入 Cookie带 Referer 下载 PDF
D->>D: 6d. Springer 专属策略:使用 Chrome 头下载 HTML 页
note over D: 若网关均失败且存在 DOI
D->>D: 6e. CrossRef 兜底:请求 CrossRef API 获取 PDF URL 并直连下载
end
alt 下载成功
D-->>H: 7a. 返回下载好的本地物理 PDF & HTML 路径
H->>DB: 8a. 更新 pdf_path & html_path 记录
H-->>U: 9a. 返回最新文献状态 (is_downloaded: true)
else 下载失败
D-->>H: 7b. 返回失败原因
H->>DB: 8b. 以 error: 前缀记录诊断信息
H-->>U: 9b. 返回文献状态 (pdf_error / html_error 已填充)
end
```
#### 详细下载说明:
1. **指令接收与校验**:后端 `download_paper` 接口在 `force` 参数为 `true` 时,会强行擦除数据库中已下载的文件路径,启动无缓存的物理文件重新拉取。
2. **下载反爬伪装**:下载器 `Downloader` 请求时采用动态生成的 Firefox/Chrome 轮换 User-Agent并在每次 HTTP 访问前强制加入随机休眠机制500ms - 2000ms模拟人类自然阅读行为。
3. **内容完整性校验**
- 对 PDF 严格校验前四个字节(必须是 `%PDF`)以及尾部检索(必须包含 `%%EOF` 终止符),排查登录墙、错误页伪装成 PDF 导致下载坏文件的问题。
- 对 HTML 文本利用 `detect_anti_bot` 流水线过滤 "cloudflare"、"captcha"、"robot check" 等拦截特征。
4. **错误诊断记录**:下载失败时,系统会将具体的失败原因(如 "Cloudflare 拦截"、"404 Not Found" 等)以 `error:` 前缀存入数据库的 `pdf_path` / `html_path` 字段。前端通过 `pdf_error` / `html_error` 字段读取并向用户展示。
| 文档 | 内容 |
|:---|:---|
| [overview](architecture/overview.md) | 整体架构 — C/S 架构总览、Mermaid 图、各层级说明 |
| [core-workflows](architecture/core-workflows.md) | 核心工作流 — 文献下载流程、手动上传流程、文献解析流程、智能对照翻译流程 |
| [core-modules](architecture/core-modules.md) | 核心模块说明 — API 层、服务层、客户端层、独立工具、前端核心组件 |
---
### 2.2 手动上传流程 (Upload Flow)
## 智能体系统架构
当自动下载受防爬或人机验证阻碍时,用户可手动上传文献文件:
```mermaid
sequenceDiagram
participant U as 用户 (React 前端 / 浏览器书签)
participant H as 处理器 (papers.rs)
participant DB as 本地数据库 (SQLite)
participant FS as 本地文件系统
U->>H: 1. 上传文件 (POST /api/upload, Multipart: bibcode + type + file)
H->>H: 2. 解析 Multipart 字段
alt bibcode 未直接匹配数据库
H->>DB: 3a. 尝试通过 DOI 匹配
H->>DB: 3b. 尝试通过 arXiv ID 匹配(自动去除版本号)
end
H->>H: 4. 校验文件格式 (PDF 校验 %PDF 文件头)
H->>FS: 5. 写入物理文件 (library/PDF/ 或 library/HTML/)
H->>DB: 6. 更新 pdf_path / html_path清除 error: 诊断记录
H-->>U: 7. 返回更新后的文献元数据 (is_downloaded: true)
```
| 文档 | 内容 |
|:---|:---|
| [Agent 概览](architecture/agent/overview.md) | Agent 整体架构 + ReAct 运行循环(完整生命周期时序图、并行工具执行模型) |
| [System Prompt](architecture/agent/system-prompt.md) | 系统提示词组装 — 模块化 Section 拼装、Anthropic Prompt Cache 优化策略 |
| [上下文管理](architecture/agent/context.md) | 四层压缩策略Snip/Micro/Auto/Aggressive、Token 预算管理、压缩熔断器 |
| [工具系统](architecture/agent/tools.md) | AgentTool Trait 设计、ToolRegistry 注册流程、19 个工具的域组织 |
| [Skills 系统](architecture/agent/skills.md) | 两层加载架构、SKILL.md 格式规范、热重载机制 |
| [Hooks 系统](architecture/agent/hooks.md) | 9 种生命周期事件、4 个内置 Hook 实现 |
| [记忆系统](architecture/agent/memory.md) | 文件系统持久化、`save_memory` 工具、Jaccard 去重、指数衰减、自动提取 |
| [子代理系统](architecture/agent/subagent.md) | 上下文隔离子代理、`delegate_research` 工具、进度透传 |
| [多 Agent 团队](architecture/agent/team.md) | 基于文件 inbox 的轻量级多 Agent 协作、Teammate 生命周期 |
| [任务看板与持久化](architecture/agent/tasks.md) | DAG 任务依赖、审计日志、轨迹导出 |
| [环境变量参考](architecture/agent/env-vars.md) | Agent 调参环境变量、Token 预算配置、相关文档链接 |
---
### 2.3 文献解析流程 (Parse Flow)
## 其他文档
本流程负责将本地下载的 HTML 或 PDF 转换为高保真的 Markdown
```mermaid
sequenceDiagram
participant U as 用户 (React 前端)
participant H as 处理器 (papers.rs)
participant P as 解析器 (parser.rs)
participant M as MinerU (PDF解析服务)
participant Q as 七牛云 (对象存储)
participant DB as 本地数据库 (SQLite)
U->>H: 1. 发起解析请求 (POST /api/parse, 含 bibcode, force)
H->>DB: 2. 查询文献物理路径 (pdf_path, html_path, markdown_path)
alt force == false 且本地已存在 Markdown 物理缓存
H->>H: 3. 读取本地 Markdown 物理文件
H-->>U: 4. 直接返回缓存 Markdown流程结束
end
H->>P: 5. 触发结构化文献解析
alt 本地存在 HTML 文件
P->>P: 6a. 剥离广告/导航栏与尾页页脚噪声
P->>P: 6b. 公式保护:利用占位符隔离 MathJax/LaTeX 公式段
P->>P: 6c. 标签规范:还原 LaTeXML 特定 span 为标准 table/tr/td修正上下标
P->>P: 6d. 插图处理:把相对图像路径替换为绝对 CDN 外链地址
P->>P: 6e. 转换 GFM Markdown 并恢复 LaTeX 公式
P->>P: 6f. 后处理:清除冗余的 margin 空白与前导缩进
else 仅有 PDF 文件 (PDF 降级解析)
P->>M: 7a. 获取批量预签名上传 URL (POST /file-urls/batch/)
M-->>P: 7b. 返回预签名上传 URL 与 Batch ID
P->>M: 7c. 上传 PDF 二进制字节流 (PUT 至预签名 URL)
loop 轮询任务状态 (每 10s 一次,最多 45 次)
P->>M: 7d. 查询提取进度与结果 (GET /extract-results/batch/{id})
M-->>P: 7e. 返回处理状态 ("done"/"error"等)
end
P->>P: 7f. 下载解析结果的 ZIP 压缩包并解压提取
loop 遍历每一个提取的插图
P->>Q: 7g. 上传插图文件并获取七牛云 CDN 域名外链
end
P->>P: 7h. 在 Markdown 中重写插图链接为七牛云 CDN 绝对路径
end
P-->>H: 8. 返回清洗转换出的标准英文 Markdown 文本
H->>P: 9. 写入本地物理缓存 Markdown/ 目录
H->>DB: 10. 更新数据库 markdown_path 记录
H-->>U: 11. 返回标准 Markdown 内容渲染展示
```
---
### 2.4 智能对照翻译流程 (Translation Flow)
本流程实现了基于天文学专属词汇表的 LLM 专业对比翻译:
```mermaid
sequenceDiagram
participant U as 用户 (React 前端)
participant H as 处理器 (papers.rs)
participant T as 翻译器 (translation.rs)
participant D as 天文词典 (Trie 树)
participant L as 大模型 (LLM API)
participant DB as 本地数据库 (SQLite)
U->>H: 1. 请求文献对比翻译 (POST /api/translate, 含 bibcode, force)
H->>DB: 2. 查询文献路径及状态
alt force == false 且本地已存在翻译缓存文件
H->>H: 3. 读取本地 Translation/{bibcode}_zh.md 物理文件
H-->>U: 4. 直接返回缓存译文,流程结束
end
H->>H: 5. 读取对应的英文解析 Markdown 物理文件
H->>T: 6. 调度翻译器执行翻译工作流
T->>D: 7. 加载本地 dictionary.txt 并初始化 Trie 树结构
T->>D: 8. 执行英文 Markdown 文本分词匹配
D->>D: 9a. 进行前缀匹配检索
D->>D: 9b. 遵循"最长匹配优先"原则,过滤子词去重
D-->>T: 10. 返回该篇文献提取出的天文学名词对照 (Glossary)
loop 针对英文 Markdown 进行段落分块 (Token 长度控制)
T->>L: 11. 携带 Glossary + 英文原文段落发送 Prompt 请求
note over L: LLM 遵循系统 Prompt 约束:<br>1. 专业词汇严格对应 Glossary 译出<br>2. 严禁改变 LaTeX 公式及 Markdown 标签<br>3. 保持中英段落高度对齐
L-->>T: 12. 返回学术级双语对照翻译段落
end
T->>T: 13. 拼接所有段落,生成完整的对照 Markdown
T->>H: 14. 写入本地物理缓存 Translation/ 目录
H->>DB: 15. 更新数据库中的 translation_path 字段
H-->>U: 16. 返回翻译后 Markdown 渲染展示
```
---
## 3. 核心模块说明
### 3.1 API 层 (`src/api/`)
| 模块文件 | 职责 |
| 文档 | 内容 |
|:---|:---|
| **[mod.rs](../src/api/mod.rs)** | 定义全局共享状态 `AppState`(含 `active_bibcode` 追踪)和统一文献格式 `StandardPaper`(含 `pdf_error` / `html_error` 诊断字段),通过 `pub mod handlers` 保持向后兼容命名空间。 |
| **[helpers.rs](../src/api/helpers.rs)** | 共享工具函数:`convert_ads_doc_to_standard`、`convert_arxiv_to_standard`、`save_paper_to_db`、`get_paper_from_db`、`check_paper_paths_in_db`。负责数据库 CRUD 和 `error:` 前缀诊断信息的读取与解析。 |
| **[papers.rs](../src/api/papers.rs)** | 文献相关核心处理器:统一检索 (`search_papers`)、下载 (`download_paper`)、**手动上传 (`upload_paper_file`)**、**无资源标记 (`mark_no_resource`)**、解析 (`parse_paper`)、翻译 (`translate_paper`)、引文拓扑 (`get_citation_network`)、文献详情 (`get_paper_detail`)、馆藏列表 (`get_library`)、BibTeX 导出 (`export_citations`)、**活跃文献追踪 (`get/set_active_bibcode`)**。 |
| **[notes.rs](../src/api/notes.rs)** | 笔记 CRUD 处理器:创建 (`create_note`)、查询 (`get_notes`)、删除 (`delete_note`)。 |
| **[sync.rs](../src/api/sync.rs)** | 批量同步控制处理器:元数据同步启动/状态/计数、资源同步启动/停止/状态、检索条件管理。 |
### 3.2 服务层 (`src/services/`)
| 模块文件 | 职责 |
|:---|:---|
| **[batch/mod.rs](../src/services/batch/mod.rs)** | 批量同步引擎公共导出模块。 |
| **[batch/meta.rs](../src/services/batch/meta.rs)** | 元数据大批量采集引擎 (`MetaSync`):分页检索 ADS/arXiv 并增量入库。 |
| **[batch/asset.rs](../src/services/batch/asset.rs)** | 物理资源批量处理引擎 (`AssetSync`):后台异步执行下载/解析/翻译流水线,记录 `download_failed` / `parse_failed` 计数,保留最新 100 条日志。 |
| **[download.rs](../src/services/download.rs)** | 多通道下载器浏览器头伪装与请求延迟控制、ADS Link Gateway 重定向追踪与 `validate.perfdrive.com` 防护解码绕过、官方 `arxiv.org/html` 优先及 `ar5iv` 兜底、**下载失败时以 `error:` 前缀记录诊断信息至数据库**。 |
| **[parser.rs](../src/services/parser.rs)** | HTML 语法树向 GFM Markdown 逆向转换,使用占位符保护 LaTeX 公式;统一图表链接;集成 MinerU PDF 解析。 |
| **[translation.rs](../src/services/translation.rs)** | 基于本地天文双语词典的 Trie 树最长匹配分词,注入 Glossary 系统提示词让 LLM 实现学术级精细翻译。 |
| **[query_parser.rs](../src/services/query_parser.rs)** | 高级检索语法解析器将前端组合条件AND/OR/NOT + 字段限定)转换为 ADS API 查询语法。 |
| **[logging.rs](../src/services/logging.rs)** | 全局日志记录系统,基于 `tracing-subscriber` 实现控制台美化日志输出与基于时间的每日滚动日志文件写出,使用上海时区 (+08:00) 格式化时间。 |
### 3.3 客户端层 (`src/clients/`)
| 模块文件 | 职责 |
|:---|:---|
| **[ads.rs](../src/clients/ads.rs)** | NASA ADS API 客户端文献检索、元数据获取、BibTeX 导出。 |
| **[arxiv.rs](../src/clients/arxiv.rs)** | arXiv Atom XML API 客户端:解析 XML Feed 提取文献元数据。 |
| **[qiniu.rs](../src/clients/qiniu.rs)** | 七牛云对象存储客户端PDF 插图上传与 CDN 外链生成。 |
### 3.4 独立工具 (`src/bin/`)
| 文件 | 职责 |
|:---|:---|
| **[health_check.rs](../src/bin/health_check.rs)** | 馆藏健康度诊断与修复工具:检测损坏文件、丢失文件、`error:` 报错记录和孤立 Markdown`--fix` 模式自动清理并重置数据库状态。 |
### 3.5 前端核心组件 (`dashboard/src/`)
| 组件文件 | 职责 |
|:---|:---|
| **[App.tsx](../dashboard/src/App.tsx)** | 全局状态管理Tab 持久化、手动上传处理、无资源标记、活跃文献追踪、详情弹窗(含错误诊断和上传区)。 |
| **[components/CustomSelect.tsx](../dashboard/src/components/CustomSelect.tsx)** | 可复用下拉选择组件:统一视觉风格、点击外部关闭、选中高亮。 |
| **[components/CitationGalaxyCanvas.tsx](../dashboard/src/components/CitationGalaxyCanvas.tsx)** | 基于 HTML5 Canvas 的自研力导向引文星系图谱引擎:节点排斥力、中心引力、拖拽阻尼、双击多层级衍生。 |
| **[features/library/LibraryPanel.tsx](../dashboard/src/features/library/LibraryPanel.tsx)** | 馆藏管理面板:同步反馈、下载失败/无资源状态筛选、文献类型筛选13 种)、状态优先排序。 |
| **[features/search/SearchPanel.tsx](../dashboard/src/features/search/SearchPanel.tsx)** | 跨源检索面板高级组合条件、排序分页、下载失败状态提示、文献类型徽章16 种)。 |
| **[features/sync/SyncPanel.tsx](../dashboard/src/features/sync/SyncPanel.tsx)** | 批量同步控制台:乐观 UI 更新、容器内日志自动滚动。 |
| [API 文档](api.md) | 全部 REST API 端点说明 |
| [数据库设计](database.md) | SQLite 表结构与迁移 |
| [开发指南](development.md) | 开发环境搭建与工作流 |
| [部署指南](deployment.md) | 生产环境部署配置 |
| [故障排除](troubleshooting.md) | 常见问题诊断与修复 |

View File

@ -0,0 +1,378 @@
# Agent 上下文管理系统
AstroResearch Agent 的上下文管理是一个多层防御架构,涵盖从系统提示词组装、运行时预算监控、多级压缩、错误恢复到跨会话持久化的完整生命周期。
## 架构概览
```mermaid
graph TB
subgraph 构建阶段["构建阶段"]
SP["SystemPrompt<br/>模块化组装"]
HL["+ 历史加载"]
TR["+ 任务恢复"]
SL["+ 技能列表"]
PM["+ 项目记忆"]
end
subgraph 运行时监控["运行时监控"]
TB["TokenBudget<br/>三级渐进Nudge"]
DR["DimReturns"]
TD["Todo提醒"]
BN["后台通知"]
LD["死循环检测"]
end
subgraph 压缩阶段["压缩阶段"]
L0["snip (L0)"]
L1["micro (L1)"]
L2["auto (L2)"]
L3["aggro_micro (L3)"]
L4["identity (L4)"]
L0 --> L1 --> L2 --> L3 --> L4
end
subgraph 恢复阶段["恢复阶段"]
ER["ErrorRecovery<br/>5级恢复阶梯"]
B4["429指数退避"]
CB["熔断器保护"]
FC["file_cache<br/>恢复注入"]
end
subgraph 持久化层["持久化层"]
AM["agent_msgs"]
AT["agent_tasks"]
MM["MEMORY.md"]
TJ["trajectory"]
AL["audit_log"]
end
构建阶段 --> 运行时监控 --> 压缩阶段 --> 恢复阶段 --> 持久化层
```
## 1. 上下文构建 (`runtime/context.rs` + `runtime/session.rs`)
### 1.1 构建流程
每个 turn 开始时 `build_initial_context()` 按以下顺序构建 LLM 消息列表:
1. 从 `agent_messages` 表加载历史消息(按 `agent_name='lead'` 隔离,排除子代理消息)
2. 若历史中无 System 消息,在最前面插入 System Prompt
3. 追加当前用户问题
4. 从 `agent_tasks` 表恢复持久化任务状态,格式化为 `[当前任务状态]` 消息块
### 1.2 System Prompt 模块化组装 (`runtime/system_prompt.rs`)
静态与动态 section 分离,静态 section 在前以最大化 prompt cache 命中率:
| 顺序 | Section | 类型 | 内容 |
|:---|:---|:---|:---|
| 1 | `identity` | 静态 | "你是一位专业的天体物理学研究助手…" |
| 2 | `tools` | 动态 | 从 ToolRegistry 生成工具名称 + 一行描述(~20 tokens/tool |
| 3 | `skills` | 动态 | 从 SkillRegistry 构建 `<system-reminder>` 技能列表(~20 tokens/skill |
| 4 | `memory` | 动态 | 从 MemoryManager 加载最近 5 条项目记忆 |
| 5 | `principles` | 静态 | 9 条核心工作原则主动搜索、引用来源、LaTeX 格式等) |
### 1.3 会话生命周期 (`session.rs`)
- `create_or_resume_session()`:新建会话生成 UUID恢复会话验证存在性并计算 turn_index
- `load_history_for_agent()`:按 `session_id + agent_name` 加载,还原 role/content/tool_calls/tool_call_id/thought 字段
- 消息隔离:`agent_name="lead"` 仅加载主代理历史,`"*"` 加载全部(调试用)
---
## 2. 上下文运行时监控 (`runtime/mod.rs` ReAct 循环内)
每一步 LLM 调用前执行以下检查:
### 2.1 Token 预算管理 (`runtime/token_budget.rs`)
```
软限制: AGENT_TOKEN_SOFT_LIMIT (默认 32,000)
硬限制: AGENT_TOKEN_HARD_LIMIT (默认 40,000)
```
**三级渐进式 Nudge**(每步检查,按优先级仅注入一条):
| 级别 | 条件 | 图标 | 消息语义 |
|:---|:---|:---|:---|
| `near_soft` | `total_spent ≥ soft_limit × 80%` | 💡 | "Token 预算提示…请注意控制后续步骤的深度" |
| `over_soft` | `total_spent ≥ soft_limit` | 🟡 | "Token 预算警告…请尽快总结关键发现" |
| `over_hard` | `total_spent ≥ hard_limit` | 🔴 | "已耗尽…请立即总结并给出最终答案" |
**Diminishing Returns 检测**
- 条件3+ 次延续 + 连续 2 次检查的 token 增量 < 500
- 触发后:强制终止工具调用,注入"请直接给出最终答案"消息,调用 `final_answer_without_tools()`
### 2.2 TodoWrite Nag 提醒
连续 3 步未调用 `todo_write` → 注入提醒消息,防止模型陷入无计划循环。
### 2.3 后台任务通知注入 (`background.rs`)
慢速操作download_paper, parse_paper通过 `bg_task_run` 异步执行。
每轮 LLM 调用前,`BgNotificationQueue::drain()` 收集已完成结果并注入:
```
[后台任务完成] ✅ download_paper: 2024A&A... (task_abc123): 下载成功...
```
### 2.4 死循环检测 (`DuplicateDetector`)
- 同一 `(tool_name, arguments)` 连续调用 ≥ 3 次 → 注入错误 tool_result 跳过
- 同时在 metrics 中记录 `duplicate_detections`
---
## 3. 上下文压缩系统 (`compact.rs`)
### 3.1 五层压缩策略
`compress_with_fallback()` 按顺序执行,每层后检查是否需要继续:
```mermaid
graph LR
L0["Layer 0: snip_compact<br/>零 API 调用<br/>消息超 MAX_MESSAGES 时截断"]
L1["Layer 1: micro_compact<br/>零 API 调用<br/>替换早期工具结果为占位符"]
L2["Layer 2: auto_compact<br/>LLM 摘要<br/>历史压缩为 &lt;500 字中文"]
L3["Layer 3: aggressive_micro<br/>零 API 调用<br/>激进占位符 keep_recent=2"]
L4["Layer 4: identity_inject<br/>零 API 调用<br/>注入身份确认块"]
L0 --> L1 --> L2 --> L3 --> L4
```
| 层 | 触发条件 | 算法 | API 调用 | 关键参数 |
|:---|:---|:---|:---|:---|
| **snip** (L0) | 消息数 > `MAX_MESSAGES` (默认 50) | `find_safe_cut_point` 切中间段 → 插入占位消息 | 否 | HEAD_KEEP=3, tail_keep=47 |
| **micro** (L1) | L0 后仍超 `context_char_limit × 1.5` | 工具结果 → `[Previous: used {tool_name}]` | 否 | keep_recent=8 |
| **auto** (L2) | L1 后仍超限 | LLM 生成中文摘要替换历史 | 是 | 摘要 ≤500 字 |
| **aggressive_micro** (L3) | L2 后仍超限 | 同 L1 但仅保留最近 2 条工具结果 | 否 | keep_recent=2 |
| **identity** (L4) | 压缩后消息 ≤4 条 | 注入 `[身份确认]` 块 | 否 | 天体物理学研究助手身份 |
### 3.2 压缩触发机制 (在 ReAct 循环中)
```mermaid
flowchart TD
A["estimated_tokens > token_budget.soft_limit?"]
A --> B["检查熔断器 can_attempt()"]
B -->|"Closed / HalfOpen"| C["执行 snapshot_compress_restore()"]
B -->|"Open"| D["跳过,记录警告"]
C --> E{"messages.len() < before_len ?"}
E -->|"是"| F["record_success()"]
E -->|"否"| G["record_failure()"]
```
Token 估算策略:
- 优先使用 API 返回的精确 `prompt_tokens`
- 辅以增量估算:新增消息数 × (content.len() + 4) 字符估算
- 首次无 API 数据时回退到 `rough_estimate_tokens`
### 3.3 手动压缩 (`compress_context` 工具触发)
- LLM 调用 `compress_context` 工具 → 设置 `pending_manual_compress = true`
- 下一轮循环中执行压缩(若刚已自动压缩则跳过)
- 手动压缩不受熔断器限制,成功后重置熔断器
### 3.4 安全切割点 (`find_safe_cut_point`)
确保不破坏 `assistant(tool_calls) ↔ tool_result` 配对的算法:
1. 计算候选切割点 `messages.len() - desired_keep`
2. 若切割点落在 `tool` 消息上 → 向前追溯到对应 `assistant(tool_calls)` 一并保留
3. 向前扫描孤立 `assistant(tool_calls)`(无 tool_result 配对)→ 切点前移
### 3.5 压缩熔断器 (`runtime/circuit_breaker.rs`)
防止无限自动压缩的三态熔断器,创建于 `AgentRuntime::new()`,跨 turn 共享:
```mermaid
stateDiagram-v2
[*] --> Closed: 初始状态
Closed --> Closed: record_success() → 重置计数
Closed --> Open: 连续失败 3 次
Open --> HalfOpen: 5 分钟后自动恢复
HalfOpen --> Closed: record_success()
HalfOpen --> Open: record_failure()
Closed --> Closed: reset() (手动压缩成功后)
```
| 参数 | 值 | 说明 |
|:---|:---|:---|
| `MAX_CONSECUTIVE_FAILURES` | 3 | 连续失败次数阈值 |
| `AUTO_RECOVERY_TIMEOUT_SECS` | 300 | 熔断后自动尝试恢复的等待时间 |
| 压缩成功判定 | `messages.len() < before_len` | 消息数减少即视为成功 |
### 3.6 递归守卫
`COMPACTING` AtomicBool — 压缩内部触发的 LLM 调用可能再次触发压缩,递归守卫通过 `compare_exchange` 防止嵌套压缩死循环。
---
## 4. 文件缓存与压缩集成 (`runtime/file_cache.rs`)
压缩前后的文件状态保护循环 (`snapshot_compress_restore`)
```
压缩前
├─ FileStateCache.to_snapshot() → 保存所有已读文件快照(按时间戳降序)
└─ FileStateCache.clear() → 清空缓存
压缩后
├─ restore_from_snapshot(max=5) → 恢复最近 5 个文件到 LRU 缓存
└─ build_restore_context(max=5) → 生成 "[压缩后恢复: {path}]" 块注入消息
```
| 参数 | 值 | 说明 |
|:---|:---|:---|
| `MAX_ENTRIES` | 100 | 缓存条目上限 |
| `MAX_CACHE_SIZE_BYTES` | 25 MB | 缓存内容总大小上限 |
| `POST_COMPACT_MAX_FILES_TO_RESTORE` | 5 | 压缩后恢复的文件数 |
| `POST_COMPACT_MAX_CHARS_PER_FILE` | 4,000 | 每文件恢复内容上限 |
| `FILE_UNCHANGED_STUB` | 静态字符串 | 文件未变时返回的占位消息 |
LRU 淘汰策略:容量超限时自动驱逐最久未使用的条目。
---
## 5. 错误恢复系统 (`runtime/error_recovery.rs`)
### 5.1 错误分类 (`classify_error`)
| ErrorKind | 检测关键词 |
|:---|:---|
| `RateLimited` | 429, rate limit, rate_limit, too many requests |
| `Overloaded` | 529, overloaded, overload, service overloaded |
| `PromptTooLong` | prompt_too_long, context length, 413, context_window_exceeded, input length |
| `TokenExhausted` | max_tokens, token limit, token_exhausted, maximum context length |
| `Timeout` | timeout, timed out, deadline exceeded, 408, 504 |
| `ModelError` | 默认归类,携带原始错误字符串 |
### 5.2 恢复阶梯
LLM 流式调用失败后的 5 级恢复:
| 步骤 | RecoveryStep | 操作 | 适用错误 |
|:---|:---|:---|:---|
| 1 | `AggressiveCompact` | snip + micro(keep_recent=2) | PromptTooLong, TokenExhausted |
| 2 | `ReactiveCompact` | LLM 摘要压缩 | 同上 |
| 3 | `EscalateTokens` | 提升 hard_limit → 64,000 | 同上 |
| 4 | `MultiTurn` | 注入分步恢复消息 | 同上 |
| 5 | `Surface` | 放弃恢复,暴露错误 | 不可恢复错误ModelError 直接到此) |
每步有 `has_attempted` 守卫,防止无限循环。
### 5.3 429/529 退避重试(独立快速路径)
RateLimited/Overloaded 不走恢复阶梯,独立执行指数退避重试(最多 10 次):
- 延迟公式:`min(500 × 2^attempt, 32,000) + deterministic jitter`
- 支持 `retry_after` header 解析
- 529 连续 3 次过载 → 尝试切换到 `FALLBACK_MODEL` 环境变量指定的备用模型
- 重试期间检查用户取消信号
---
## 6. 子代理上下文隔离 (`subagent.rs`)
父代理通过 `delegate_research` 委托子任务给子代理,子代理拥有独立的上下文环境:
| 组件 | 隔离方式 |
|:---|:---|
| 消息上下文 | 全新 `[system_prompt, user_prompt]`,不含父代理中间工具调用 |
| 工具访问 | 共享 ToolRegistry可配置受限 registry如仅只读工具 |
| Hook 管道 | 完整的 PreToolUse/PostToolUse + PermissionChecker |
| 进度通知 | 通过 `progress_tx` 向父代理发送 Thought/ToolCall/ToolResult SSE 事件 |
| 最终返回 | 仅返回 `[子代理活动记录] + [子代理结论]` 文本摘要 |
| 消息持久化 | 以 `agent_name=sub_xxx` 写入父会话的 `agent_messages` 表 |
子代理也有独立的上下文压缩(同 `compact::compress_context`)、死循环检测(同 `DuplicateDetector` 逻辑)、强制终止(超 max_steps 时调用 `force_final_answer`)。
---
## 7. 跨会话持久化
### 7.1 数据库表
| 表 | 持久化内容 | 上下文用途 |
|:---|:---|:---|
| `agent_sessions` | session_id, title, model, turn_count, metadata | 会话生命周期管理 |
| `agent_messages` | role, content, thought, tool_calls(JSON), tool_call_id, token_count, metadata, raw_json(完整 ChatMessage), agent_name | 历史恢复 + 审计追溯 |
| `agent_tasks` | task_id, content, status, blocked_by, owner | 跨 turn 任务状态恢复 |
| `agent_audit_log` | session_id, step, tool_name, status, elapsed_ms, output_preview | 完整审计追踪 |
### 7.2 文件系统记忆 (`memory/`)
- Agent 可通过 `save_memory` 工具将重要信息写入 `~/.claude/projects/{project}/memory/`
- 四类记忆:`user`, `feedback`, `project`, `reference`
- 写入时门控:内容质量检查 + Jaccard 相似度去重70% 阈值)
- 每 turn 结束时 fire-and-forget 自动提取候选记忆
- 下次会话的 system prompt 中自动加载最近 5 条记忆
### 7.3 Trajectory 导出
每 turn 结束时,`TrajectoryExporter::export()` 将完整会话轨迹导出到文件系统,用于调试和审计。
---
## 8. Lifecycle Hooks 与上下文事件
| Hook | 触发时机 | 上下文影响 |
|:---|:---|:---|
| `OnSessionStart` | 会话创建/恢复 | 通知生命周期开始 |
| `PreToolUse` | 工具执行前 | 可 MutateInput 注入上下文、Block 阻止 |
| `PostToolUse` | 工具执行后 | 可 MutateOutput 修改结果、审计日志写入 |
| `OnStepComplete` | 每步结束 | 日志消息数/预算使用率 |
| `OnPreCompact` | 压缩前 | 记录消息数/预估 tokens |
| `OnPostCompact` | 压缩后 | 记录新消息数/压缩方法 |
| `OnSubagentStart` | 子代理启动 | 通知子代理创建 |
| `OnSubagentStop` | 子代理停止 | 记录结果摘要 |
| `OnSessionStop` | 会话终止 | 清理取消状态、记录终止原因 |
---
## 9. SSE 流事件 — 前端上下文同步
Agent 内部上下文通过 SSE 事件实时同步到前端:
```
Session → Thought → (ToolCall ↔ ToolResult)* → TextDelta → Usage → Done
```
`tool_call.id` 贯穿全链路LLM 生成 ID → 前端精确匹配 tool_call/tool_result 条目 → 审计日志关联。
---
## 10. 环境变量配置
| 变量 | 默认值 | 作用 |
|:---|:---|:---|
| `AGENT_MAX_STEPS` | 8 | ReAct 最大迭代步数 |
| `AGENT_TOOL_TIMEOUT_SECS` | 120 | 工具执行超时(秒) |
| `AGENT_MAX_TOOL_OUTPUT_CHARS` | 4000 | 工具输出截断长度(字符) |
| `AGENT_CONTEXT_CHAR_LIMIT` | 16000 | 上下文压缩触发点(字符估算) |
| `AGENT_TOKEN_SOFT_LIMIT` | 32000 | Token 预算软限制(触发 nudging |
| `AGENT_TOKEN_HARD_LIMIT` | 40000 | Token 预算硬限制(触发强制动作) |
| `AGENT_MAX_MESSAGES` | 50 | snip_compact 触发阈值(消息数) |
| `FALLBACK_MODEL` | — | 529 连续过载 3 次时的备用模型 |
---
## 11. 关键源文件索引
| 文件 | 职责 |
|:---|:---|
| `src/agent/compact.rs` | 五层压缩 + 安全切割 + 递归守卫 + 身份注入 |
| `src/agent/runtime/mod.rs` | ReAct 循环 + 压缩触发 + 后台通知 + todo nag |
| `src/agent/runtime/context.rs` | 初始上下文构建 + 任务状态恢复 |
| `src/agent/runtime/session.rs` | 会话 CRUD + 历史消息加载 |
| `src/agent/runtime/system_prompt.rs` | 模块化 System Prompt 组装 |
| `src/agent/runtime/token_budget.rs` | Token 预算 + 三级 Nudge + Dim Returns |
| `src/agent/runtime/circuit_breaker.rs` | 压缩熔断器 |
| `src/agent/runtime/error_recovery.rs` | 5 级恢复阶梯 + 错误分类 + 退避重试 |
| `src/agent/runtime/file_cache.rs` | 文件状态缓存 + 压缩快照/恢复 |
| `src/agent/runtime/streaming.rs` | 流式响应 + 取消竞速 |
| `src/agent/runtime/executor.rs` | 工具验证 + 并行执行 + Hook 集成 |
| `src/agent/runtime/finalize.rs` | 会话收尾 + 记忆提取 + 轨迹导出 |
| `src/agent/subagent.rs` | 子代理上下文隔离 + 独立 ReAct 循环 |
| `src/agent/background.rs` | 后台任务队列 + 通知注入 |
| `src/agent/hooks.rs` | 9 个生命周期事件 + 内置 3 Hook |
| `src/agent/skills.rs` | 两层技能加载 + 热重载 + 条件激活 |
| `src/agent/tools/memory.rs` | save_memory 工具 + 写入门控 |

View File

@ -0,0 +1,134 @@
# Agent 环境变量参考
Agent 系统的所有可配置参数,按子系统分类。
## 1. AgentConfig — ReAct 循环核心参数
`AgentConfig::from_env_optional()` 加载,所有变量可选,缺失时使用默认值。
| 变量 | 默认值 | 类型 | 说明 |
|:---|:---|:---|:---|
| `AGENT_MAX_STEPS` | 8 | usize | 单轮对话中最大 ReAct 迭代步数。到达后强制终止,注入"请根据已有信息直接给出最终答案"消息,调用 `final_answer_without_tools()` 不带工具生成最终回答。 |
| `AGENT_TOOL_TIMEOUT_SECS` | 120 | u64 | 单个工具调用的超时时间(秒)。`read_file` / `grep_files` 等快速工具通常 <1s`download_paper` 可能需要 30-60s超时后返回 `ToolOutput::error`不终止整个 turn |
| `AGENT_MAX_TOOL_OUTPUT_CHARS` | 4000 | usize | 工具输出截断字符数。超过此值的输出被截断并在尾部附加 `[已截断,原始 N 字符]`。大文件的完整内容可通过 `maybe_persist_tool_result()` 写入磁盘,返回文件路径指针。 |
| `AGENT_CONTEXT_CHAR_LIMIT` | 16000 | usize | 上下文字符估算上限。用于 `rough_estimate_tokens` 与 micro_compact 层的触发判断。仅在 LLM 不返回精确 token 计数时作为后备。 |
| `AGENT_TOKEN_SOFT_LIMIT` | 32000 | usize | Token 预算软限制。达到 80% 时注入 💡 "Token 预算提示";达到 100% 时注入 🟡 "Token 预算警告";同时作为自动压缩的触发阈值(`estimated_tokens > soft_limit`)。 |
| `AGENT_TOKEN_HARD_LIMIT` | 40000 | usize | Token 预算硬限制。达到时注入 🔴 "已耗尽" 消息强制要求模型立即给出最终答案。error recovery escalate 步骤可临时提升此值到 64,000。 |
| `AGENT_MAX_MESSAGES` | 50 | usize | snip_compact (Layer 0) 的触发阈值。消息数超过此值时截断中间段:保留前 3 条 + 后 47 条,中间替换为含工具名称列表的占位消息。 |
**硬编码参数**(非环境变量,修改需改代码):
| 参数 | 值 | 位置 | 说明 |
|:---|:---|:---|:---|
| `duplicate_call_threshold` | 3 | `AgentConfig` | 工具连续以相同参数调用 3 次判定为死循环 |
| `HEAD_KEEP` | 3 | `compact.rs` | snip_compact 保留的头部消息数 |
| `MAX_CONSECUTIVE_FAILURES` | 3 | `circuit_breaker.rs` | 压缩熔断器触发阈值 |
| `AUTO_RECOVERY_TIMEOUT_SECS` | 300 | `circuit_breaker.rs` | 熔断器自动恢复等待时间 |
| `MAX_ENTRIES` | 100 | `file_cache.rs` | 文件状态缓存条目上限 |
| `MAX_CACHE_SIZE_BYTES` | 25 MB | `file_cache.rs` | 文件状态缓存内容总大小上限 |
| `POST_COMPACT_MAX_FILES_TO_RESTORE` | 5 | `file_cache.rs` | 压缩后恢复的文件数 |
| `POST_COMPACT_MAX_CHARS_PER_FILE` | 4,000 | `file_cache.rs` | 每文件恢复内容的字符上限 |
| `nag_after_steps` | 3 | `runtime/mod.rs` | TodoWrite 提醒间隔(步数) |
| `MAX_BACKOFF_RETRIES` | 10 | `runtime/mod.rs` | 429/529 退避重试最大次数 |
---
## 2. 错误恢复
| 变量 | 默认值 | 说明 |
|:---|:---|:---|
| `FALLBACK_MODEL` | — | 529 连续过载 3 次时尝试切换的备用模型名称。当前仅记录日志,因 `LlmClient` 的 model 不可变。需配合支持 model override 的客户端使用。 |
---
## 3. 自动记忆提取 (`memory/extraction.rs`)
`ExtractionConfig::from_env()` 加载。在每次会话结束时,可选地使用受限子代理分析对话内容并自动提取值得保留的记忆。
| 变量 | 默认值 | 说明 |
|:---|:---|:---|
| `EXTRACT_MEMORY_ENABLED` | false | 是否启用自动记忆提取。默认关闭以避免意外的 LLM 费用。提取在 turn 结束时 fire-and-forget 执行,不阻塞会话关闭。如果主代理已通过 `save_memory` 工具写入过记忆,则跳过提取。 |
| `EXTRACT_MEMORY_THROTTLE_TURNS` | 3 | 最小提取间隔(轮次)。避免每轮都消耗 LLM 调用进行提取。 |
| `EXTRACT_MEMORY_MAX_STEPS` | 3 | 提取子代理的最大 ReAct 步数。提取使用受限工具集read_file, grep_files, glob_files, save_memory步数较少以控制成本。 |
---
## 4. Agent 关联的系统级环境变量
以下变量在 `Config::from_env()` 中加载,直接影响 Agent 运行时。
### 4.1 核心 API
| 变量 | 默认值 | 说明 |
|:---|:---|:---|
| `LLM_API_KEY` | — | LLM API 密钥OpenAI 兼容协议) |
| `LLM_API_BASE` | `https://api.openai.com/v1` | LLM API 基础地址 |
| `LLM_MODEL` | `gpt-4o-mini` | Agent 使用的对话模型 |
| `EMBEDDING_API_KEY` | 同 `LLM_API_KEY` | Embedding API 密钥 |
| `EMBEDDING_API_BASE` | 同 `LLM_API_BASE` | Embedding API 基础地址 |
| `EMBEDDING_MODEL` | `text-embedding-3-small` | RAG 使用的向量模型 |
| `EMBEDDING_DIM` | 1536 | 向量维度。与 `vec_paper_chunks` 表的维度校验相关,不匹配时自动重建。 |
| `ADS_API_KEY` | — | NASA ADS API Token`search_papers` 工具使用) |
### 4.2 本地路径与端口
| 变量 | 默认值 | 说明 |
|:---|:---|:---|
| `DATABASE_URL` | `sqlite://library/astro_research.db` | SQLite 数据库路径 |
| `LIBRARY_DIR` | `./library` | 文献下载/解析/缓存的根目录 |
| `SKILLS_DIR` | `./skills` | Agent Skills 目录SkillRegistry 从此加载 `{name}/SKILL.md` |
| `PORT` | 8000 | Axum 服务监听端口 |
### 4.3 云存储与解析
| 变量 | 默认值 | 说明 |
|:---|:---|:---|
| `QINIU_AK` | — | 七牛云 Access KeyPDF 配图托管) |
| `QINIU_SK` | — | 七牛云 Secret Key |
| `QINIU_BUCKET` | — | 七牛云存储空间名 |
| `QINIU_DOMAIN` | — | 七牛云 CDN 域名 |
| `MINERU_API_URL` | — | MinerU PDF 解析远程 API 地址 |
| `MINERU_API_KEY` | — | MinerU API Token |
### 4.4 日志
| 变量 | 默认值 | 说明 |
|:---|:---|:---|
| `LOG_LEVEL` | `info,astroresearch=debug` | 日志级别trace / debug / info / warn / error可指定模块级别 |
| `LOG_FORMAT` | `pretty` | 日志格式:`pretty`(彩色控制台)或 `json`(结构化) |
| `LOG_OUTPUTS` | `stdout,file` | 日志输出目标,逗号分隔:`stdout`(控制台)、`file`(滚动文件) |
| `LOG_DIR` | `./logs` | 日志文件目录(仅 `LOG_OUTPUTS``file` 时生效),按天滚动 |
### 4.5 其他
| 变量 | 默认值 | 说明 |
|:---|:---|:---|
| `OBSCURA_ROTATE_PROFILE` | `true` | Obscura 浏览器 Profile 轮换,用于绕过 Cloudflare/WAF |
| `OBSCURA_ALLOW_PRIVATE_NETWORK` | — | Obscura 内网访问许可(程序自动设置,无需手动配置) |
---
## 5. 与模型 Context Window 的配置建议
推荐的配置比例(为多轮对话预留空间):
| 模型 Context | 建议 soft_limit | 建议 hard_limit | 说明 |
|:---|:---|:---|:---|
| 128K (GPT-4o) | 32,000 (25%) | 40,000 (31%) | 默认值适用 |
| 128K (Claude) | 32,000 (25%) | 64,000 (50%) | Claude 200K 则向上调整 |
| 200K (Claude) | 64,000 (32%) | 100,000 (50%) | 大 context 可更宽松 |
| 32K (旧模型) | 8,000 (25%) | 16,000 (50%) | 小 context 需更激进压缩 |
---
## 6. 关键源文件索引
| 文件 | 加载的变量 |
|:---|:---|
| `src/lib.rs` (Config) | `LLM_API_KEY`, `LLM_API_BASE`, `LLM_MODEL`, `EMBEDDING_*`, `ADS_API_KEY`, `DATABASE_URL`, `LIBRARY_DIR`, `SKILLS_DIR`, `PORT`, `QINIU_*`, `MINERU_*` |
| `src/main.rs` | `EMBEDDING_DIM` |
| `src/services/logging.rs` | `LOG_LEVEL`, `LOG_FORMAT`, `LOG_OUTPUTS`, `LOG_DIR` |
| `src/agent/runtime/mod.rs` (AgentConfig) | `AGENT_MAX_STEPS`, `AGENT_TOOL_TIMEOUT_SECS`, `AGENT_MAX_TOOL_OUTPUT_CHARS`, `AGENT_CONTEXT_CHAR_LIMIT`, `AGENT_TOKEN_SOFT_LIMIT`, `AGENT_TOKEN_HARD_LIMIT`, `AGENT_MAX_MESSAGES` |
| `src/agent/runtime/mod.rs` (call_llm_with_recovery) | `FALLBACK_MODEL` |
| `src/agent/memory/extraction.rs` (ExtractionConfig) | `EXTRACT_MEMORY_ENABLED`, `EXTRACT_MEMORY_THROTTLE_TURNS`, `EXTRACT_MEMORY_MAX_STEPS` |
| `src/services/download.rs` | `OBSCURA_ALLOW_PRIVATE_NETWORK`(程序自动设置) |

View File

@ -0,0 +1,274 @@
# Hooks 生命周期系统 (`hooks.rs`)
参考 Claude Code hooks 协议,提供 **9 种生命周期事件回调**,基于 **观察者模式 + 责任链模式** 实现。
核心思路:允许在 Agent 运行的各个关键节点插入自定义逻辑(取消检查、指标采集、审计日志等),而不污染核心 ReAct 循环代码。
## 架构总览
```mermaid
graph TB
subgraph Registry["HookRegistry"]
direction TB
Methods["聚合方法(遍历所有 hook 依次调用)<br/>run_on_session_start() | run_pre_tool_use()<br/>run_post_tool_use() | run_on_step_complete()<br/>run_on_session_stop() | run_on_subagent_start()<br/>run_on_subagent_stop() | run_on_pre_compact()<br/>run_on_post_compact()"]
end
Registry --> CH["CancellationHook<br/>Arc&lt;HashSet&lt;String&gt;&gt;"]
Registry --> MH["MetricsHook<br/>Arc&lt;Mutex&lt;MetricsData&gt;&gt; (共享)"]
Registry --> AH["AuditLogHook<br/>SqlitePool (fire-and-forget 写入)"]
```
## 生命周期事件9 个)
| # | 事件 | 触发时机 | 返回值 | 调用位置 |
|---|------|---------|--------|---------|
| 1 | `OnSessionStart` | 会话创建/恢复 | 无 (fire-and-forget) | `AgentRuntime::run_turn()` |
| 2 | `PreToolUse` | 每个工具执行前 | `PreToolUseAction` (可拦截/修改参数) | `executor::execute_parallel()` |
| 3 | `PostToolUse` | 每个工具执行后 | `PostToolUseAction` (可修改输出) | `executor::execute_parallel()` |
| 4 | `OnStepComplete` | 每步 ReAct 结束 | 无 | `AgentRuntime::run_react_loop()` |
| 5 | `OnSessionStop` | 会话终止(任何原因) | 无 | `finalize::finalize_turn()` |
| 6 | `OnSubagentStart` | 子代理启动 | 无 | `SubAgentRunner::run()` |
| 7 | `OnSubagentStop` | 子代理停止 | 无 | `SubAgentRunner::run()` |
| 8 | `OnPreCompact` | 上下文压缩前 | 无 | `compact::compress_context_with_hooks()` |
| 9 | `OnPostCompact` | 上下文压缩后 | 无 | `compact::compress_context_with_hooks()` |
## 核心类型
### AgentHook trait
```rust
#[async_trait]
pub trait AgentHook: Send + Sync {
fn name(&self) -> &str;
async fn on_session_start(&self, _ctx: &SessionStartContext) {}
async fn pre_tool_use(&self, _ctx: &PreToolUseContext) -> PreToolUseAction { ... }
async fn post_tool_use(&self, _ctx: &PostToolUseContext) -> PostToolUseAction { ... }
async fn on_step_complete(&self, _ctx: &StepCompleteContext) {}
async fn on_session_stop(&self, _ctx: &SessionStopContext<'_>) {}
async fn on_subagent_start(&self, _ctx: &SubagentStartContext) {}
async fn on_subagent_stop(&self, _ctx: &SubagentStopContext) {}
async fn on_pre_compact(&self, _ctx: &PreCompactContext) {}
async fn on_post_compact(&self, _ctx: &PostCompactContext) {}
}
```
所有 9 个方法都有默认空实现——hook 实现者只需覆写关心的 hook 点,遵循**接口隔离原则**。
### PreToolUseAction工具执行前返回值
```rust
pub enum PreToolUseAction {
Continue, // 允许执行(默认)
Block { reason: String }, // 阻止执行
MutateInput { // 修改参数 + 注入上下文
updated_args: serde_json::Value,
additional_context: Option<String>,
},
PermissionRequired { permission: String, tool_name: String }, // 需要权限决策 (Phase 2)
}
```
向后兼容:`pub type HookAction = PreToolUseAction;`
### PostToolUseAction工具执行后返回值
```rust
pub enum PostToolUseAction {
Continue, // 保持输出不变
MutateOutput { updated_content: String }, // 修改输出内容
}
```
### 聚合结果类型
```rust
pub struct PreToolUseResult {
pub action: PreToolUseAction, // 最终动作(第一个 Block 获胜)
pub additional_context: Option<String>, // 累积的附加上下文(所有 MutateInput 拼接)
pub final_args: serde_json::Value, // 最终参数(最后一个 MutateInput 获胜)
}
pub struct PostToolUseResult {
pub final_content: String, // 最终输出(最后一个 MutateOutput 获胜)
}
```
## HookRegistry 聚合逻辑
**PreToolUse 聚合(责任链 + 短路)**
```mermaid
flowchart TD
Start["run_pre_tool_use(ctx)"] --> Loop["遍历 hooks依次调用 pre_tool_use()"]
Loop --> Check{"结果类型?"}
Check -->|"Block"| Short["立即短路返回<br/>不询问后续 hooks"]
Check -->|"MutateInput"| Mut["更新 final_args<br/>累积 additional_context (\\n 拼接)"]
Check -->|"PermissionRequired"| Log["记录日志但不阻止执行<br/>(Phase 2 预留)"]
Check -->|"Continue"| Next["继续下一个 hook"]
Mut --> Next
Log --> Next
Next --> Loop
Short --> Return["返回 PreToolUseResult"]
Next -->|"遍历完毕"| Return
```
关键设计:
- **第一个 Block 获胜** — 短路保护
- **最后一个 MutateInput 获胜** — 后覆盖前
- **additional_context 累积** — 多个 hook 的上下文用 `\n` 连接
**PostToolUse 聚合(全部执行,无短路)**
```mermaid
flowchart TD
Start2["run_post_tool_use(ctx)"] --> Loop2["遍历所有 hooks依次调用 post_tool_use()"]
Loop2 --> MutOut{"MutateOutput ?"}
MutOut -->|"是"| Update["更新 final_content<br/>(最后的 MutateOutput 获胜)"]
MutOut -->|"Continue"| Next2["继续下一个 hook"]
Update --> Next2
Next2 --> Loop2
Next2 -->|"遍历完毕"| Return2["返回 PostToolUseResult { final_content }"]
```
**其余 7 个事件** 均为 fire-and-forget遍历所有 hooks 调用对应方法,不收集返回值。
## 内置 Hooks3 个)
| Hook | 覆写的事件 | 职责 | 关键依赖 |
|------|-----------|------|---------|
| `CancellationHook` | `pre_tool_use`, `on_session_stop` | 每次工具执行前检查用户是否中止会话;会话停止时清理取消令牌 | `Arc<Mutex<HashSet<String>>>` (与 AppState 共享) |
| `MetricsHook` | `on_session_start`, `post_tool_use`, `on_step_complete`, `on_session_stop` | 采集运行指标工具调用次数、步数、错误数、token 消耗;每 3 步输出摘要日志 | `Arc<Mutex<MetricsData>>` (**共享引用**API 通过 `AgentRuntime::get_metrics()` 实时查询) |
| `AuditLogHook` | `post_tool_use`, `on_session_stop` | 所有工具调用写入 `agent_audit_log` 表(工具名、状态、耗时、输出预览);会话终止写入 SESSION_STOP 标记 | `SqlitePool` (**fire-and-forget** 写入,不阻塞主循环) |
> **注意**:代码中**不存在** PermissionHook。权限检查由独立的 `PermissionChecker` (`src/agent/runtime/permission.rs`) 负责,该组件在工具执行前与 hooks 并行调用,不属于 hooks 体系。`PreToolUseAction::PermissionRequired` 变体预留于 Phase 2 完善。
## 数据流
```mermaid
sequenceDiagram
participant API as API Handler
participant RT as AgentRuntime
participant HR as HookRegistry
participant CH as CancellationHook
participant MH as MetricsHook
participant AH as AuditLogHook
participant EX as Executor
Note over API,EX: Phase 1 — 会话启动
API->>RT: run_turn(question)
RT->>HR: HookRegistry::with_builtins(db, cancelled_runs, metrics_data)
RT->>HR: run_on_session_start(ctx)
HR->>MH: 记录 session_id
Note over API,EX: Phase 2 — ReAct 循环
loop 每步 (最多 max_steps)
RT->>RT: LLM 流式调用
RT->>EX: execute_parallel(tool_calls, hook_registry)
par 每个工具调用
EX->>HR: run_pre_tool_use(pre_ctx)
HR->>CH: 检查取消状态
alt 已取消
CH-->>HR: Block { reason }
HR-->>EX: PreToolUseResult { action: Block }
EX-->>EX: 跳过该工具
else 未取消
CH-->>HR: Continue
HR-->>EX: PreToolUseResult { final_args, additional_context }
EX->>EX: 执行工具
EX->>HR: run_post_tool_use(post_ctx)
HR->>MH: 更新工具调用计数/错误数
HR->>AH: fire-and-forget INSERT agent_audit_log
HR-->>EX: PostToolUseResult { final_content }
end
end
EX-->>RT: ToolExecutionResult { tool_messages }
RT->>HR: run_on_step_complete(ctx)
HR->>MH: 每 3 步输出摘要日志
opt 上下文超限
RT->>RT: snapshot_compress_restore()
Note over RT: PreCompact / PostCompact hooks 触发
end
end
Note over API,EX: Phase 3 — 会话收尾
RT->>RT: finalize_turn()
RT->>HR: run_on_session_stop(ctx)
HR->>CH: 清理 cancelled_runs
HR->>MH: 输出会话结束摘要
HR->>AH: fire-and-forget SESSION_STOP 记录
RT-->>API: Done (SSE)
```
## HookRegistry 构建
`AgentRuntime::run_turn()` 在每次 turn 开始时构建 `HookRegistry`
```rust
let hook_registry = HookRegistry::with_builtins(
db.clone(), // → AuditLogHook
self.app_state.cancelled_runs.clone(), // → CancellationHook
Some(self.metrics_data.clone()), // → MetricsHook (共享引用)
);
```
`MetricsHook` 使用 `from_arc()` 复用 `AgentRuntime` 自身的 `metrics_data: Arc<Mutex<MetricsData>>`,确保 hook 内部采集的指标与 `AgentRuntime::get_metrics()` API 查询返回的是同一份数据。
## 子代理中的 Hooks
`SubAgentRunner` 拥有独立的 hook 管道,共享同一个 `HookRegistry` 实例:
- `on_subagent_start` / `on_subagent_stop` 在子代理生命周期的首尾触发
- 子代理的工具执行也经过 `run_pre_tool_use` / `run_post_tool_use`(通过 `subagent.rs:447-518`
- **已知不足**:子代理内部的上下文压缩 (`subagent.rs:270`) 直接调用 `compress_context` 而非 `compress_context_with_hooks`,导致 PreCompact/PostCompact 事件**不会**在子代理压缩时触发
## 扩展方式
添加自定义 hook 只需两步:
```rust
// 1. 实现 AgentHook trait
struct MyCustomHook;
#[async_trait]
impl AgentHook for MyCustomHook {
fn name(&self) -> &str { "MyCustomHook" }
async fn pre_tool_use(&self, ctx: &PreToolUseContext) -> PreToolUseAction {
// 自定义逻辑
PreToolUseAction::Continue
}
}
// 2. 注册到 HookRegistry
registry.add(Box::new(MyCustomHook));
```
## 测试覆盖
`hooks.rs` 包含 8 个单元测试(`#[cfg(test)] mod tests`),覆盖:
| 测试 | 验证点 |
|------|-------|
| `test_hook_registry_runs_all_hooks` | 注册表遍历调用所有 hook |
| `test_blocking_hook_stops_chain` | Block 短路机制 |
| `test_mutate_input_accumulates_context` | 参数修改 + 上下文累积 |
| `test_post_tool_use_mutate_output` | 输出修改 |
| `test_cancellation_hook_blocks_when_cancelled` | CancellationHook 阻止逻辑 |
| `test_cancellation_hook_allows_when_not_cancelled` | CancellationHook 放行逻辑 |
| `test_metrics_hook_accumulates_counts` | MetricsHook 累加正确性 |
| `test_session_start_hook_called` | OnSessionStart 调用 |
| `test_new_lifecycle_events_called` | OnSubagentStart/Stop, PreCompact/PostCompact 调用 |
## 已知改进项
| 问题 | 说明 |
|------|------|
| `PermissionRequired` 未实现 | 代码中存在此变体但被当作 `Continue` 处理,注释标明 "Permission 系统在 Phase 2 中完善" |
| 取消检查重复 | `CancellationHook::pre_tool_use``AgentRuntime::run_react_loop` 中的显式检查存在功能重叠 |
| 子代理压缩未走 hooks | `subagent.rs:270` 直接调用 `compress_context` 而非 `compress_context_with_hooks` |
| 缺少 `on_error` 事件 | `AgentHook` trait 没有错误生命周期事件,错误场景无法通过 hook 拦截 |
---

View File

@ -0,0 +1,432 @@
# 记忆系统 (`memory/`)
参考 Claude Code `memdir/` 设计,提供**完全基于文件系统**(非数据库)的项目级持久化记忆管理。核心代码位于 `src/agent/memory/`8 个文件)和 `src/agent/tools/memory.rs``save_memory` 工具)。
## 整体架构
```mermaid
graph TD
subgraph Storage["文件存储 ({library_dir}/memory/)"]
MEMORY_MD["MEMORY.md<br/>索引文件 (≤200行, ≤25KB)"]
Files["{slug}.md × N<br/>每个记忆一个 Markdown 文件"]
Archive["{slug}_v1.md<br/>旧版本归档(永不删除)"]
end
subgraph Manager["MemoryManager (mod.rs)"]
Load["reload()<br/>扫描目录 → 解析 frontmatter → 按 mtime 排序"]
Save["save_memory()<br/>写文件 → 归档旧版 → 更新索引 → reload"]
Reminder["build_system_reminder(N)<br/>构建注入 system prompt 的 XML 块"]
SelectRel["select_relevant_memories()<br/>LLM 语义选择 + 指数衰减排序"]
end
subgraph Tool["save_memory 工具 (tools/memory.rs)"]
Validate["参数校验<br/>slug 格式 + memory_type 枚举"]
QualityGate["写入时门控<br/>质量检查 + Jaccard 去重"]
Execute["执行写入<br/>MemoryManager.save_memory()"]
end
subgraph Pipeline["记忆生命周期"]
Extract["extraction.rs<br/>会话结束时自动提取(默认关闭)"]
Dedup["dedup.rs<br/>Jaccard 相似度去重 (≥70%) + 内容质量门控"]
Decay["decay.rs<br/>指数时间衰减 + Hebbian 激活层级"]
Age["age.rs<br/>时效标签 + freshness 警告"]
Guardrails["guardrails.rs<br/>WHAT_NOT_TO_SAVE + VERIFY_BEFORE_RECOMMENDING"]
end
subgraph Types["types.rs — 数据模型"]
MemoryEntry["MemoryEntry<br/>{ slug, name, description, memory_type, mtime, content, path, status }"]
MemoryType["MemoryType: User | Feedback | Project | Reference"]
MemoryStatus["MemoryStatus: Active | Historical { superseded_by }"]
end
Tool --> Manager
Manager --> Storage
Reminder -->|"try_lock() 非阻塞"| Runtime["AgentRuntime System Prompt"]
Extract --> Dedup --> Tool
Decay --> SelectRel
Guardrails --> Reminder
Guardrails --> Tool
```
## 存储结构
记忆**不使用 SQLite**,全部存储在文件系统 `{library_dir}/memory/` 目录下:
```
{library_dir}/memory/
├── MEMORY.md # 索引文件(最多 200 行 / 25KB
├── user-prefs.md # 活跃记忆YAML frontmatter + Markdown 内容)
├── project-goals.md
├── user-prefs_v1.md # 旧版本归档(原文件重命名,永不删除)
└── ...
```
**每个记忆文件**格式:
```markdown
---
name: user-role
description: 用户的科研角色和偏好
type: user
status: active
---
用户是天体物理学博士后,主要研究恒星演化与双星系统。
**Why:** 在对话开始时用户明确说明了研究领域。
**How to apply:** 默认使用天体物理学术语,优先推荐恒星演化相关文献。
```
**版本管理机制**:当更新已有 slug 时,旧文件重命名为 `{slug}_v1.md`,其 frontmatter 中 `status` 改为 `historical` 并记录 `superseded_by` 指向新版本。旧版本**永不删除**——保留完整的事实演进历史。`MemoryStatus` 枚举控制此生命周期:
```
Active ──(更新)──→ Historical { superseded_by: Some("new-slug") }
```
## 核心数据结构 (`types.rs`)
| 结构体/枚举 | 字段/变体 | 说明 |
|:---|:---|:---|
| `MemoryType` | `User` | 用户角色、偏好、知识背景 |
| | `Feedback` | 用户提供的修正或确认的方法论(含 Why 和 How to apply |
| | `Project` | 项目上下文、目标、约束(不可从代码推导的部分) |
| | `Reference` | 外部资源指针URL、仪表盘、工单系统 |
| `MemoryStatus` | `Active` | 当前有效(默认值) |
| | `Historical { superseded_by }` | 已被更新版本取代,`superseded_by` 指向新 slug |
| `MemoryEntry` | `slug: String` | 文件名标识(不含 `.md` 扩展名) |
| | `name: String` | 记忆标题 |
| | `description: String` | 简短描述,用于相关性匹配 |
| | `memory_type: MemoryType` | 记忆类型 |
| | `mtime: u64` | 文件修改时间Unix 时间戳),用于排序 |
| | `content: String` | 不含 frontmatter 的纯正文Markdown |
| | `path: PathBuf` | 文件完整路径 |
| | `status: MemoryStatus` | 生命周期状态(默认 Active |
`parse_frontmatter(raw)` 函数解析 YAML-like frontmatter支持 `name`、`description`、`type`/`memory_type`、`status`、`superseded_by` 字段。缺失 `status` 时默认为 `Active`
## MemoryManager 核心方法 (`mod.rs`)
`MemoryManager` 在应用启动时创建(`main.rs`),存入 `AppState.memory_manager: Arc<tokio::sync::Mutex<MemoryManager>>`
| 方法 | 调用时机 | 行为 |
|:---|:---|:---|
| `new(library_dir)` | 应用启动 | `create_dir_all(memory/)``reload()` 扫描所有 `.md` 文件 → 解析 frontmatter → 按 mtime 降序排序 |
| `reload()` | 每次 `save_memory` 后 | 全量重新扫描目录,清空并重建 `entries: Vec<MemoryEntry>` |
| `entries()` | 查询 | 返回 `&[MemoryEntry]` 不可变引用 |
| `save_memory(slug, name, desc, type, content)` | `save_memory` 工具调用 | ① 检查是否已有活跃版本 → 归档为 `{slug}_v1.md` ② 写入新 YAML frontmatter + content ③ `update_index()` 更新 MEMORY.md ④ `reload()` 刷新内存状态 |
| `build_system_reminder(max)` | System prompt 组装 | 取最近 `max` 条记忆,构建 `<project-memory-context>` XML 块,包含类型标签、时效警告、验证提醒 |
| `build_system_reminder_from(selected)` | LLM 选择后 | 同上,但从指定的条目子集构建 |
| `select_relevant_memories(llm, ctx, max)` | 按需 | LLM 语义匹配 → 指数时间衰减重排序 → 回退到 recency 排序 |
| `mark_main_agent_wrote()` | `save_memory` 工具执行后 | 设置标志抑制本会话的自动提取 |
**关键设计:非阻塞加载**。System prompt 组装时使用 `try_lock()`(非 `lock()`)——如果 mutex 已被持有则直接跳过,不阻塞会话启动。
## `update_index()` 索引维护
`MEMORY.md` 索引文件受双重容量保护:
| 参数 | 值 | 触发行为 |
|:---|:---|:---|
| `MAX_ENTRYPOINT_LINES` | 200 | 行数满时移除最旧条目行 |
| `MAX_ENTRYPOINT_BYTES` | 25,000 (~25KB) | 字节数超限时在约 25KB 处截断,附加截断提示 |
索引格式(每行一条):
```markdown
- [记忆标题](slug.md) — 简短描述 (type: user)
```
写入时执行**原地更新**:若 `MEMORY.md` 中已存在同 slug 的行(通过 `](slug.md)` 标记匹配),则替换该行而非追加。
## `save_memory` 工具 (`tools/memory.rs`)
这是**唯一**暴露给 LLM 的记忆写入工具。
**参数 Schema**
| 参数 | 类型 | 必填 | 校验规则 |
|:---|:---|:---|:---|
| `slug` | string | 是 | kebab-case禁止空格、`/`、`\` |
| `name` | string | 是 | 记忆标题 |
| `description` | string | 是 | 用于决定何时加载此记忆 |
| `memory_type` | enum | 是 | `user` / `feedback` / `project` / `reference` |
| `content` | string | 是 | Markdown 格式,最少有效信息量推荐 >10 字符 |
**工具配置**
| 配置项 | 值 | 原因 |
|:---|:---|:---|
| `is_concurrency_safe` | `false` | 写入操作不可与其他工具并发执行 |
| `interrupt_behavior` | `Block` | 写入磁盘不可中途中断 |
**完整执行流程**
```mermaid
sequenceDiagram
participant LLM as LLM
participant Tool as SaveMemoryTool
participant QG as 质量门控 (dedup.rs)
participant Mgr as MemoryManager
participant FS as 文件系统
LLM->>Tool: save_memory(slug, name, desc, type, content)
Tool->>Tool: ① 参数校验 (slug 非空 + 格式合法 + type 枚举有效)
Tool->>Mgr: ② lock().await 获取互斥锁
Tool->>QG: ③ check_content_quality(content)
QG-->>Tool: QualityCheck (Accept / TooShort / TransientState / VagueLanguage / CodePattern)
Tool->>QG: ④ find_duplicate_by_content(content, entries, 0.70)
QG-->>Tool: 重复的 slug 或 None
Tool->>QG: ⑤ slug_exists(memory_dir, slug) → 判断是新建还是更新
Tool->>Mgr: ⑥ save_memory(slug, name, desc, type, content)
Mgr->>FS: 若已有活跃版本 → 归档 {slug}_v1.md
Mgr->>FS: 写入新 {slug}.md (frontmatter + content)
Mgr->>FS: 更新 MEMORY.md 索引
Mgr->>Mgr: reload() 刷新内存状态
Tool->>Mgr: ⑦ mark_main_agent_wrote() 抑制自动提取
Tool->>QG: ⑧ build_manifest_preview(entries) 构建清单
Tool-->>LLM: success(message + manifest + quality warning + duplicate hint)
```
**返回消息结构**
1. 操作状态(✅ 已保存 / 🔄 已更新)
2. 质量警告(如有:⚠️ 过短 / 瞬时状态 / 模糊语言 / 代码模式)
3. 重复提示(如有:💡 检测到与 `{slug}` 内容 ≥70% 重叠)
4. 当前记忆清单(所有现有条目供 LLM 参考)
## 记忆注入机制
在 Agent 会话启动时system prompt 按顺序组装各 section。记忆注入发生在 **Section 4**(位于 Tools、Skills 之后Core Principles 之前):
```
① 静态身份声明
② 工具定义
③ Skills 提醒
<project-memory-context> ← 记忆(通过 try_lock 非阻塞加载,默认 5 条)
⑤ 核心原则
```
注入格式示例:
```xml
<project-memory-context>
[PROJECT MEMORY]
[偏好] 用户角色: 天体物理学博士后,研究恒星演化与双星系统
用户是天体物理学博士后,主要研究恒星演化与双星系统。
**Why:** 在对话开始...
<system-reminder>此记忆已过 3 天。记忆是某个时间点的快照,不是实时状态...</system-reminder>
[反馈] 测试策略: 优先使用单元测试而非集成测试
用户偏好单元测试,认为集成测试太慢...
使用 save_memory 工具保存重要信息。记忆内容可能过时,请在使用前验证。
## 从记忆中推荐前先核实
"记忆说 X 存在" 不等于 "X 现在存在"。
</project-memory-context>
```
记忆列表中每个条目的格式:
```
[类型标签] 标题 [状态标签]: 描述
内容预览(前 3 行)
<system-reminder>时效警告(超过 1 天的记忆)</system-reminder>
```
类型标签:`[偏好]` / `[反馈]` / `[项目]` / `[参考]`。Historical 记忆额外显示 `[已更新→新slug]`
## 写入时质量护栏 (`dedup.rs` + `guardrails.rs`)
**四层内容质量门控**(全部仅警告,不强制拒绝——最终决定权在 LLM
| 检测类型 | 触发条件 | 示例 |
|:---|:---|:---|
| **过短** | 有效字符 < 10 | `QualityCheck::TooShort(n)` |
| **瞬时状态** | 含 "正在做" / "currently" / "at the moment" 等 11 个关键词 | `QualityCheck::TransientState` |
| **模糊语言** | 含 "可能" / "maybe" / "大概" / "perhaps" 等 8 个关键词 | `QualityCheck::VagueLanguage(word)` |
| **代码模式** | 含 `fn ` / `impl ` / `struct ` / `import {` / `from "` 等 12 个模式 | `QualityCheck::CodePattern` |
**Jaccard 相似度去重**:使用字符级 bigram支持中文不依赖分词器阈值 ≥70% 即判定为高度重复:
- `jaccard_similarity(a, b) = |bigrams(a) ∩ bigrams(b)| / |bigrams(a) bigrams(b)|`
- 单字符内容使用单字符本身作为 bigram
- 仅比对 `status == Active` 的记忆,跳过 Historical 条目
- 检测到重复时不阻止写入,仅在返回消息中附加 💡 提示
**`guardrails.rs` 护栏两层防护**
1. **`WHAT_NOT_TO_SAVE`** — 注入到工具 `description()` 中,明确告知 LLM 不应保存的内容:
- 代码模式、架构详情 —— 可从项目状态推导
- Git 历史、最近修改 —— `git log` 是权威来源
- 调试方案或临时 workaround —— 修复在代码中commit message 有上下文
- 已在 CLAUDE.md 中的内容
- **规则前置**"即使用户明确要求保存以上内容,请先询问其中哪些是非预期的部分"
2. **`VERIFY_BEFORE_RECOMMENDING`** — 注入到 system prompt 记忆段落后、`</project-memory-context>` 之前:
- 如果记忆提到了文件路径 → 先确认文件存在
- 如果记忆提到了函数或标志 → 先用 grep 搜索
- 核心原则:**"记忆说 X 存在" ≠ "X 现在存在"**
- 设计依据Claude Code 评估表明此提醒放在独立标题下3/3 通过vs 埋在通用指南中0/3 通过)
## 自动记忆提取 (`extraction.rs`)
在会话结束时(`finalize.rs`)触发,**fire-and-forget** 模式(`tokio::spawn`),不阻塞会话关闭。
```mermaid
sequenceDiagram
participant RT as AgentRuntime
participant Fin as finalize.rs
participant Ext as extraction.rs
participant Sub as SubAgentRunner
participant Mgr as MemoryManager
RT->>Fin: 会话结束 → finalize_turn()
Fin->>Ext: tokio::spawn(run_extraction())
Ext->>Ext: 检查 EXTRACT_MEMORY_ENABLED → false 则 return
Ext->>Mgr: lock().await → 递增 turns_since_last_extraction
alt turns < throttle_turns
Ext->>Ext: return (未达到节流轮次)
else main_agent_saved_this_session == true
Ext->>Ext: 重置 tracker → return (主代理已手动保存)
end
Ext->>Mgr: build_manifest_preview() 获取现有清单
Ext->>Ext: 构建受限 ToolRegistry (ReadFile + Grep + Glob + SaveMemory)
Ext->>Sub: SubAgentRunner.run(system_prompt, prompt, max_steps=3)
Sub-->>Ext: SubagentResult { content, is_error }
Ext->>Ext: 记录日志 (成功摘要 / 错误截断)
```
**环境变量配置**
| 变量 | 默认值 | 说明 |
|:---|:---|:---|
| `EXTRACT_MEMORY_ENABLED` | `false` | 是否启用自动提取(默认关闭,避免意外 LLM 费用) |
| `EXTRACT_MEMORY_THROTTLE_TURNS` | `3` | 最小提取间隔(轮次),避免每轮都触发 |
| `EXTRACT_MEMORY_MAX_STEPS` | `3` | 子代理最大 ReAct 步数,限制提取成本 |
**跳过条件**(任一满足即跳过):
- `EXTRACT_MEMORY_ENABLED != true`
- `turns_since_last_extraction < throttle_turns`(节流未到)
- `main_agent_saved_this_session == true`(主代理已通过 `save_memory` 工具写入)
- 子代理工具集为受限集(仅 4 个只读工具 + `save_memory`),不能执行 bash、不能搜索论文
## 时效性与衰减系统
**指数时间衰减** (`decay.rs`)
```
score = e^(-λ × days_old)
λ = ln(2) / half_life_days
```
| 参数 | 默认值 | 说明 |
|:---|:---|:---|
| `DEFAULT_HALF_LIFE_DAYS` | 30 | 半衰期 30 天:第 0 天 score=1.0,第 30 天 score=0.5,第 60 天 score=0.25 |
| Historical 记忆固定分数 | 0.01 | 已更新记忆始终排在最后 |
**Hebbian 启发式激活层级**
| 层级 | 天数范围 | 权重乘数 | 说明 |
|:---|:---|:---|:---|
| **Hot** | ≤ 7 天 | 1.0 | 最近活跃,全额权重 |
| **Warm** | 8-30 天 | 0.7 | 中等时效7 折权重 |
| **Cool** | > 30 天 | 0.3 | 较久远3 折权重 |
**时效警告** (`age.rs`)
| 函数 | 输出 | 用途 |
|:---|:---|:---|
| `memory_age_label(mtime)` | "今天" / "昨天" / "N 天前" | 人类可读的年龄标签 |
| `memory_freshness_note(mtime)` | 超过 1 天时返回 `<system-reminder>` 警告 | 注入 system prompt 的 XML 标签,利用 system-reminder 对模型的强注意力引导 |
| `memory_freshness_text(mtime)` | 同上但纯文本 | 备选方案 |
设计洞察:**"LLM 对绝对日期 ('2026-01-15') 的时效感知弱,但对相对时间 ('47 天前') 的感知强"**——因此使用天数差而非 ISO 日期。
## 记忆选择器 (`selection.rs`)
当记忆数量超过 `max_entries`(默认 5`select_relevant_memories()` 进行 LLM 语义选择:
1. 过滤掉 `SelectionContext.already_surfaced` 中已展示的条目
2. 若候选数 ≤ max_entries → 直接返回全部
3. 构建候选目录slug + type + name + description
4. LLM 结构化 JSON 选择 → 支持两种响应格式:`["slug1", "slug2"]` 和 `[0, 1, 3]`
5. LLM 失败或 JSON 解析失败 → **回退到 recency 排序**(保证优雅降级)
6. 通过 `apply_decay_scoring()` 对选中结果进行指数衰减重排序
`SelectionContext` 结构:
| 字段 | 类型 | 默认值 | 说明 |
|:---|:---|:---|:---|
| `already_surfaced` | `Vec<usize>` | `[]` | 已展示过的条目索引,本轮不再选择 |
| `recent_tools` | `Vec<String>` | `[]` | 最近使用的工具,相关记忆降权 |
| `max_entries` | `usize` | `5` | 每次最多选择条数 |
## 与数据库的关系
**记忆完全使用文件系统,不使用 SQLite**。数据库表仅存储 Agent 会话状态:
| 数据库表 | 存储内容 | 与记忆的关联 |
|:---|:---|:---|
| `agent_sessions` | 会话元数据title, model, turn_count | 无直接关联 |
| `agent_messages` | 消息历史role, content, thought, token_count | 自动提取时子代理读取对话历史 |
| `agent_tasks` | DAG 任务看板 | 无关联 |
| `agent_audit_log` | 工具调用审计tool_name, status, elapsed_ms | **每个 `save_memory` 调用被 `AuditLogHook` 记录到此表** |
## 完整数据流
```
应用启动
main.rs: MemoryManager::new(library_dir)
→ create_dir_all(memory/)
→ reload() 扫描 *.md → 解析 frontmatter → 按 mtime 排序
→ 存入 AppState.memory_manager
会话开始
runtime/mod.rs: build_system_prompt()
→ try_lock() memory_manager (非阻塞)
→ build_system_reminder(5)
→ 构建 <project-memory-context> XML 块注入 system prompt
会话中LLM 主动保存)
LLM 调用 save_memory(slug, name, desc, type, content)
→ SaveMemoryTool.execute()
→ 参数校验slug 格式 + type 枚举)
→ lock() memory_manager
→ check_content_quality(content) ← 四层质量门控(仅警告)
→ find_duplicate_by_content(0.70) ← Jaccard bigram 去重
→ mgr.save_memory()
→ 归档旧版 {slug}_v1.md如存在
→ 写入新 {slug}.mdfrontmatter + content
→ update_index() → 更新 MEMORY.md
→ reload()
→ mark_main_agent_wrote()
→ 返回: 操作状态 + 质量警告 + 重复提示 + manifest 清单
会话结束
finalize.rs: finalize_turn()
→ 更新 agent_sessions (turn_count, metrics)
→ 运行 OnSessionStop hooks审计日志、指标
→ 导出轨迹 JSONL
→ 如果 EXTRACT_MEMORY_ENABLED:
tokio::spawn(run_extraction())
→ 检查节流 + 主代理写入标志
→ 子代理分析对话 → 提取记忆 → save_memory
```
## 子模块文件索引
| 文件 | 职责 |
|:---|:---|
| `src/agent/memory/mod.rs` | `MemoryManager` 核心:构造、`reload`、`save_memory`、`build_system_reminder`、索引维护 |
| `src/agent/memory/types.rs` | `MemoryType`(4 变体)、`MemoryStatus`(Active/Historical)、`MemoryEntry`、frontmatter 解析器 |
| `src/agent/memory/selection.rs` | LLM 语义记忆选择器 + 指数衰减排序 + recency 回退 |
| `src/agent/memory/decay.rs` | 指数时间衰减 (`e^(-λt)`) + Hebbian Hot/Warm/Cool 激活层级 |
| `src/agent/memory/age.rs` | 时效标签生成 + `<system-reminder>` freshness 警告 |
| `src/agent/memory/guardrails.rs` | `WHAT_NOT_TO_SAVE` 排除规则 + `VERIFY_BEFORE_RECOMMENDING` 验证提醒 |
| `src/agent/memory/dedup.rs` | Jaccard 相似度去重 (bigram) + `check_content_quality` 四层门控 + manifest 构建 |
| `src/agent/memory/extraction.rs` | 自动提取:`ExtractionConfig`(env) + `ExtractionTracker` + `run_extraction` 子代理 |
| `src/agent/tools/memory.rs` | `save_memory` 工具:`AgentTool` trait 实现,完整执行流程 |
---

View File

@ -0,0 +1,174 @@
# Agent 架构概览
AstroResearch 内置了一个基于 **ReAct** (Thought → Action → Observation) 范式的科研智能体引擎 (`src/agent/`),参考 Claude Code 的分层设计。以下对各子系统的架构、数据流和内部逻辑进行完整说明。
### 整体架构
```mermaid
graph TD
subgraph API["API 层"]
SSE["SSE /api/chat/agent"]
Sessions["Session CRUD"]
Metrics["GET /api/chat/metrics"]
Audit["GET /api/chat/sessions/:id/audit"]
AskUser["问答 /api/chat/questions + /api/chat/answer"]
end
subgraph Runtime["AgentRuntime — ReAct 引擎"]
RunTurn["run_turn() 主入口"]
SP["SystemPrompt 组装器"]
CtxBuild["Context Builder 上下文构建"]
ReAct["ReAct 主循环"]
Streaming["streaming.rs 流式处理"]
Executor["executor.rs 并行执行"]
Finalize["finalize.rs 会话收尾"]
TokenBudget["token_budget.rs"]
CircuitBreaker["circuit_breaker.rs"]
end
subgraph Tools["工具系统 (tools/)"]
AgentTool["AgentTool trait"]
Registry["ToolRegistry"]
FS["filesystem/ (6 工具)"]
Astro["astro/ (7 工具)"]
AskUserT["ask_user"]
SubAgentT["subagent/delegate_research"]
TeamT["team/ (4 工具)"]
BG["background (2 工具)"]
end
subgraph CrossCutting["横切关注点"]
Hooks["HookRegistry (9 事件)"]
Skills["SkillRegistry (两层加载)"]
Memory["MemoryManager (项目记忆)"]
Permission["PermissionChecker"]
FileCache["FileStateCache (Read 去重)"]
end
subgraph DB["持久化"]
AgentSessions["agent_sessions"]
AgentMessages["agent_messages"]
AgentTasks["agent_tasks"]
AgentAudit["agent_audit_log"]
end
SSE --> RunTurn
RunTurn --> SP
RunTurn --> CtxBuild --> DB
RunTurn --> ReAct
ReAct --> Streaming --> Tools
ReAct --> Executor --> Tools
ReAct --> TokenBudget
ReAct --> CircuitBreaker
ReAct --> Finalize
Hooks -.-> ReAct
Hooks -.-> Tools
Skills -.-> Tools
Memory -.-> SP
Permission -.-> Tools
FileCache -.-> Tools
```
---
### ReAct 运行循环 (`runtime/`)
主循环由 `AgentRuntime::run_turn()` 驱动,分为 4 个阶段:
#### 完整生命周期
```mermaid
sequenceDiagram
participant FE as 前端 SSE
participant RT as AgentRuntime
participant DB as SQLite
participant LLM as LLM API
participant Tools as ToolRegistry
FE->>RT: POST /api/chat/agent { question, session_id? }
Note over RT: Phase 1 — 会话管理
RT->>DB: create_or_resume_session()
alt 新会话
DB-->>RT: session_id = uuid, turn_index = 0
else 恢复会话
DB-->>RT: 验证存在 + 计算 turn_index
end
RT->>RT: 触发 OnSessionStart hook
RT-->>FE: SSE session { session_id, title }
Note over RT: Phase 2 — 上下文构建
RT->>RT: build_initial_context()
RT->>RT: ① 组装 SystemPrompt (静态 section + 记忆注入)
RT->>DB: ② 加载历史消息 load_history_for_llm()
RT->>DB: ③ 恢复未完成任务 (agent_tasks)
RT->>RT: ④ 检查压缩/清理上下文
RT->>DB: ⑤ 保存用户消息
RT->>RT: ⑥ 运行 PreToolUse hooks 过滤
Note over RT: Phase 3 — ReAct 循环
loop 每步迭代 (step ≤ max_steps)
RT->>LLM: chat_stream(messages + tool_defs)
LLM-->>RT: ReasoningDelta / TextDelta / ToolCallsComplete
RT-->>FE: SSE thought / text_delta / tool_call
alt 无工具调用 → 最终答案
RT->>DB: 保存 assistant 消息
RT-->>FE: SSE text_delta → usage → done
Note over RT: break 循环
else 有工具调用
RT->>RT: 检查 token 预算 + 熔断器
RT->>RT: validate_and_prepare() — 去重 + 过滤
RT->>Tools: execute_parallel() — 并行执行
Tools-->>RT: (tool_call_id, name, output)
RT-->>FE: SSE tool_result { tool_call_id, name, output }
RT->>DB: 保存 tool 消息 + 审计日志
RT->>RT: 运行 PostToolUse hooks
RT->>RT: 检测压缩需求 (auto_compact)
end
RT->>RT: 检测循环终止条件
end
Note over RT: Phase 4 — 会话收尾
RT->>DB: 更新 turn_count + updated_at
RT->>DB: calculate_and_persist_metrics()
RT->>RT: 运行 OnSessionStop hook
RT-->>FE: SSE done
```
#### 并行工具执行模型
```mermaid
sequenceDiagram
participant ReAct as ReAct 循环
participant Val as validate_and_prepare
participant Exec as execute_parallel
participant T1 as Tool A
participant T2 as Tool B
participant FE as 前端 SSE
ReAct->>Val: LLM 返回 [tool_call_a, tool_call_b]
Val->>Val: 去重检测 + 权限验证
Val-->>ReAct: prepared_calls[] + has_duplicate 标志
ReAct->>FE: 发送 tool_call SSE (逐一)
ReAct->>Exec: 启动 execute_parallel()
par 并行执行
Exec->>T1: tool_a.execute(args_a)
Exec->>T2: tool_b.execute(args_b)
end
T1-->>Exec: ToolOutput { content, is_error }
Exec-->>FE: SSE tool_result (立即推送)
T2-->>Exec: ToolOutput { content, is_error }
Exec-->>FE: SSE tool_result (立即推送)
Exec-->>ReAct: Vec<(tool_call_id, name, args, output, cancelled)>
ReAct->>ReAct: PostToolUse hooks + 审计日志 + 持久化
```
- **并发上限**:由 `max_concurrent_tools` 环境变量控制,默认不限制
- **Sibling Abort**:仅 `causes_sibling_abort() = true` 的工具(如 `download_paper`)能在出错时中断兄弟任务
- **InterruptBehavior**`Block` 工具(如 `ask_user`)不可被用户取消;`Cancel` 工具可在取消信号时中断
- **超时控制**:每个工具有独立超时,默认 120s`AGENT_TOOL_TIMEOUT_SECS`
---

View File

@ -0,0 +1,941 @@
# 权限系统 (Permission System)
Agent 权限系统采用**多层纵深防御**架构,从工具级 trait 约束到运行时的 Hook 拦截、路径沙箱、命令黑名单,形成 7 层安全防线。
---
## 整体架构
```mermaid
graph TD
subgraph L1["第一层:工具 Trait 约束"]
IB["InterruptBehavior<br/>(Cancel vs Block)"]
CS["is_concurrency_safe<br/>(并行安全声明)"]
CP["check_permissions<br/>(自定义 PermissionRule)"]
SA["causes_sibling_abort<br/>(兄弟中断)"]
end
subgraph L2["第二层PermissionChecker 规则引擎"]
Rules["有序规则链<br/>Deny → Allow → Ask → Default Allow"]
WC["通配符 '*' 全匹配"]
end
subgraph L3["第三层Hook 管道"]
PreH["PreToolUse<br/>Continue | Block | MutateInput | PermissionRequired"]
PostH["PostToolUse<br/>Continue | MutateOutput"]
Builtins["3 内置 Hook<br/>Cancellation | Metrics | AuditLog"]
end
subgraph L4["第四层:文件系统沙箱"]
PS["路径沙箱<br/>library_dir / skills_dir / cwd"]
PT["穿越防护<br/>拒绝 .. 和 ~"]
end
subgraph L5["第五层Bash 命令安全"]
Blacklist["黑名单 21 条<br/>交互式/破坏性命令"]
Timeout["超时控制 60s (max 120s)"]
OutputLimit["输出截断 4000 字符"]
end
subgraph L6["第六层:子代理隔离"]
SilentCtx["Silent 上下文<br/>禁止 ask_user"]
FreshMsg["全新消息上下文<br/>不污染父代理"]
InheritPerm["继承 PermissionChecker<br/>+ HookRegistry"]
end
subgraph L7["第七层:运行时安全约束"]
MaxSteps["max_steps 上限"]
DupDetect["同质调用检测"]
TokenBudget["Token 预算 diminishing returns"]
Cancel["用户取消 (cancelled_runs)"]
end
AgentRuntime["AgentRuntime::run_turn()"] --> L1
L1 --> L2
L2 --> L3
L3 --> L4
L4 --> L5
L5 --> L6
L6 --> L7
```
---
## 第一层:工具级 Trait 约束
每个工具通过覆写 `AgentTool` trait 的 4 个安全方法声明自身行为边界。
定义位置:`src/agent/tools/mod.rs:179`
### 方法说明
| 方法 | 默认值 | 作用 |
|---|---|---|
| `interrupt_behavior()` | `Cancel` | 用户取消时的响应:`Cancel` 立即停止(只读工具),`Block` 等待完成(有副作用的写入工具) |
| `is_concurrency_safe(args)` | `false` | 是否可与其他工具并行执行。保守默认,只读工具须显式覆写为 `true` |
| `check_permissions(args)` | 空 `Vec` | 返回 `PermissionRule` 列表,由 PermissionChecker 运行时逐条匹配 |
| `causes_sibling_abort()` | `false` | 该工具失败时是否中止兄弟并行执行(下载/解析类工具可设为 `true` |
### 工具安全分类表
#### 只读并发安全工具
| 工具 | 域 | `is_concurrency_safe` | `interrupt_behavior` |
|---|---|---|---|
| `search_papers` | astro/search | `true` | `Cancel` |
| `get_paper_metadata` | astro/search | `true` | `Cancel` |
| `get_paper_content` | astro/paper | `true` | `Cancel` |
| `rag_search` | astro/rag | `true` | `Cancel` |
| `query_target` | astro/target | `true` | `Cancel` |
| `read_file` | filesystem | `true` | `Cancel` |
| `grep_files` | filesystem | `true` | `Cancel` |
| `glob_files` | filesystem | `true` | `Cancel` |
| `load_skill` | skill | `true` | `Cancel` |
#### 写入/IO 串行安全工具
| 工具 | 域 | `is_concurrency_safe` | `interrupt_behavior` |
|---|---|---|---|
| `download_paper` | astro/paper | `false` | `Cancel` |
| `parse_paper` | astro/paper | `false` | `Cancel` |
| `file_write` | filesystem | `false` | `Cancel` |
| `file_edit` | filesystem | `false` | `Cancel` |
| `save_note` | astro/note | `false` | `Cancel` |
| `save_memory` | memory | `false` | **`Block`** |
| `run_bash` | filesystem | `false` | **`Block`** |
| `ask_user` | ask_user | `false` | **`Block`** |
| `todo_write` | todo | `false` | `Cancel` |
| `compress_context` | compress | `false` | `Cancel` |
| `subagent` | subagent | `false` | **`Block`** |
> **设计原理**`Block` 工具在用户取消时忽略中断信号,确保写操作完整提交后才停止。`save_memory` 和 `run_bash` 涉及文件系统修改,`ask_user` 依赖 oneshot 通道生命周期,`subagent` 开启了完整的子代理 ReAct 循环,中断可能导致数据不一致。
---
## 第二层PermissionChecker 规则引擎
参考 Claude Code 的 PermissionChecker 设计,提供可编程的权限规则链。
定义位置:`src/agent/runtime/permission.rs`
### 权限判定规则
```
┌────────────────────────────────────────────────────────────┐
│ 规则匹配优先级 (first-match-wins) │
│ │
│ 1. Deny { tool_name, reason } — 不可覆盖的拒绝 │
│ 2. Allow { tool_name } — 显式允许 │
│ 3. Ask { tool_name, message } — 需要用户确认 │
│ 4. (default) — 无匹配 → Allow │
│ │
│ 通配符 "*" 匹配所有工具名 │
└────────────────────────────────────────────────────────────┘
```
### PermissionChecker API
```rust
// src/agent/runtime/permission.rs
pub struct PermissionChecker {
rules: Vec<PermissionRule>, // 有序规则列表,先添加的优先级更高
}
impl PermissionChecker {
pub fn new() -> Self; // 空检查器 = 默认允许所有
pub fn add_rule(&mut self, rule: PermissionRule); // 添加规则
pub fn check(&self, tool_name: &str) -> PermissionResult; // 检查单个工具
pub fn is_denied(&self, tool_name: &str) -> bool; // 快捷 deny 检查
fn matches(pattern: &str, tool_name: &str) -> bool {
pattern == "*" || pattern == tool_name // 通配符或精确匹配
}
}
pub enum PermissionResult {
Denied { reason: String },
Allowed,
AskUser { message: String },
}
```
### 使用示例
```rust
// 创建受限检查器:只允许只读操作
let mut checker = PermissionChecker::new();
checker.add_rule(PermissionRule::Deny {
tool_name: "run_bash".into(),
reason: "此会话中禁止执行命令".into(),
});
checker.add_rule(PermissionRule::Deny {
tool_name: "download_paper".into(),
reason: "禁止下载".into(),
});
checker.add_rule(PermissionRule::Ask {
tool_name: "file_write".into(),
message: "是否允许写入文件?".into(),
});
// 默认 Allow — 其余工具正常执行
assert!(checker.check("search_papers").is_allowed());
assert!(checker.check("run_bash").is_denied());
```
### 当前集成状态
```mermaid
graph LR
subgraph "AgentConfig::from_env_optional()"
PC0["解析 AGENT_PERMISSIONS_* 环境变量<br/>构造 PermissionChecker::from_config()"]
end
subgraph "AgentRuntime::run_turn()"
PC1["permission_checker: PermissionChecker<br/>(Deny → Ask → Allow 规则链 + 4种模式)"]
end
subgraph "SubAgentRunner"
PC2["permission_checker: Arc&lt;PermissionChecker&gt;"]
PC2 -->|"check(tool_name, Some(&args))"| SubExec["三态检查<br/>Deny→注入错误 | Ask→自动拒绝 | Allow→执行"]
end
subgraph "execute_parallel() Phase 2.5"
PC3["permission_checker: Option&lt;&PermissionChecker&gt;"]
PC3 -->|"check() + apply_mode() + 工具级叠加"| DenyCheck["━━ 三态处理 ━━<br/>Deny → 注入错误跳过执行<br/>AskUser → SSE PermissionRequest + oneshot 等待(120s超时)<br/>Allowed → 正常进入执行队列"]
end
PC0 -.->|"构建"| PC1
PC1 -.->|"传递给"| PC2
PC1 -.->|"传递给"| PC3
style PC3 fill:#ccffcc,stroke:#00aa00
```
> **✅ 完整已实现**`executor::execute_parallel()` 在 Phase 2.5PreToolUse hooks 之后、工具执行之前)执行完整的权限检查管道:
> 1. `PermissionChecker::check(tool_name, tool_args)` — 规则链匹配 + 内容级匹配
> 2. `PermissionChecker::apply_mode()` — 模式变换Bypass/DontAsk/AcceptEdits
> 3. Hook `PermissionRequired` 升级 — hook 请求的权限确认为 AskUser
> 4. 工具级 `check_permissions()` 叠加 — 工具自定义规则在 Allow 时升级为 Ask
> 5. 最终三态分流Deny → 注入错误 / AskUser → oneshot 交互(120s 超时) / Allowed → 正常执行
---
## 第三层Hook 管道
Hook 系统提供了可编程的事件拦截点,参考 Claude Code 的 PreToolUse/PostToolUse/Stop hooks 设计。
定义位置:`src/agent/hooks.rs`
### PreToolUse 动作类型
```mermaid
graph TD
PreToolUse["PreToolUse hook"]
PreToolUse --> Continue["Continue<br/>正常执行"]
PreToolUse --> Block["Block { reason }<br/>阻止执行,第一个 Block 短路整个链"]
PreToolUse --> Mutate["MutateInput { updated_args, additional_context }<br/>修改参数 + 注入附加上下文"]
PreToolUse --> PermReq["PermissionRequired { permission, tool_name }<br/>请求权限决策Phase 2 待完善)"]
```
### PostToolUse 动作类型
```mermaid
graph TD
PostToolUse["PostToolUse hook"]
PostToolUse --> Continue2["Continue<br/>保持输出不变"]
PostToolUse --> MutateOut["MutateOutput { updated_content }<br/>修改工具输出(如脱敏)"]
```
### Hook 链执行逻辑 (`run_pre_tool_use`)
```rust
// 遍历所有已注册 hook
for hook in &self.hooks {
let action = hook.pre_tool_use(ctx).await;
match action {
Block { reason } => {
// 第一个 Block 立即短路返回,不执行后续 hook
return PreToolUseResult { action, ... };
}
MutateInput { updated_args, additional_context } => {
// 累积 additional_context多 hook 拼接)
// 更新 final_args最后一个 MutateInput 的修改生效)
}
PermissionRequired { .. } => {
// 记录日志但暂不阻塞Phase 2 完善)
}
Continue => {} // 继续下一个 hook
}
}
```
### 3 个内置 Hook
```mermaid
classDiagram
class CancellationHook {
-cancelled_runs: Arc~Mutex~HashSet~String~~
+pre_tool_use() → Block | Continue
+on_session_stop() → 清理取消状态
}
class MetricsHook {
-data: Arc~Mutex~MetricsData~
+on_session_start() → 关联 session_id
+post_tool_use() → 累计工具调用/错误计数
+on_step_complete() → 每 3 步输出摘要日志
+on_session_stop() → 输出终止原因
+snapshot() → 返回可查询的指标快照
}
class AuditLogHook {
-db: SqlitePool
+post_tool_use() → fire-and-forget 写入 agent_audit_log
+on_session_stop() → 写入 SESSION_STOP 标记
}
class AgentHook {
<<interface>>
+name() &str
+on_session_start()
+pre_tool_use()
+post_tool_use()
+on_step_complete()
+on_session_stop()
+on_subagent_start()
+on_subagent_stop()
+on_pre_compact()
+on_post_compact()
}
AgentHook <|-- CancellationHook
AgentHook <|-- MetricsHook
AgentHook <|-- AuditLogHook
```
### 审计日志 (`agent_audit_log` 表)
`AuditLogHook` 在每次工具执行后通过 fire-and-forget`tokio::spawn`)写入审计记录:
| 字段 | 说明 |
|---|---|
| `session_id` | 会话 ID |
| `step` | ReAct 步数 |
| `tool_name` | 工具名称 |
| `status` | `"OK"``"FAIL"` |
| `elapsed_ms` | 执行耗时(毫秒) |
| `output_preview` | 输出内容前 200 字符 |
| `agent_name` | 代理身份(`"lead"` 或子代理名) |
会话终止时写入一条 `tool_name = 'session'`、`status = 'SESSION_STOP'` 的汇总记录。
---
## 第四层:文件系统路径沙箱
所有文件操作工具(`read_file`、`file_write`、`file_edit`、`glob_files`、`grep_files`、`run_bash` 的 `working_dir` 参数)共享的路径安全检查。
定义位置:`src/agent/tools/filesystem/security.rs`
### 允许的根目录
```rust
let allowed_roots = [
config.library_dir.canonicalize(), // 论文库目录
config.skills_dir.canonicalize(), // Agent Skills 目录
std::env::current_dir(), // 项目根目录
];
```
### 安全检查函数
```mermaid
flowchart TD
Input["用户提供的路径字符串"] --> PT{"has_path_traversal()<br/>包含 .. 或 ~ "}
PT -->|是| Reject1["❌ 拒绝"]
PT -->|否| Resolve["resolve_path()<br/>绝对路径直接用,相对路径基于 cwd 拼接"]
Resolve --> Canon["canonicalize()<br/>消除符号链接"]
Canon -->|失败| TryParent["尝试对父目录 canonicalize"]
TryParent -->|失败| Reject2["❌ 拒绝:无法解析"]
Canon -->|成功| Check{"is_path_allowed()<br/>在 allowed_roots 内?"}
TryParent -->|成功| Check
Check -->|是| Allow["✅ 允许"]
Check -->|否| Reject3["❌ 拒绝:无权访问"]
```
### 防护能力
| 攻击类型 | 防护方式 |
|---|---|
| 路径穿越 (`../../../etc/passwd`) | `has_path_traversal()` 拒绝含 `..` 的路径 |
| 家目录访问 (`~/`) | `has_path_traversal()` 拒绝含 `~` 的路径 |
| 符号链接逃逸 | `canonicalize()` 解析符号链接到真实路径后再检查 |
| 绝对路径越界 | `resolve_path()` 解析后再 `is_path_allowed()` |
### 覆盖的工具
| 工具 | 受保护的参数 |
|---|---|
| `read_file` | `file_path` |
| `file_write` | `file_path` |
| `file_edit` | `file_path` |
| `glob_files` | `pattern` (解析后) |
| `grep_files` | `path` |
| `run_bash` | `working_dir` |
---
## 第五层Bash 命令安全校验
`run_bash` 工具在路径沙箱之上叠加了命令级安全校验。
定义位置:`src/agent/tools/filesystem/bash.rs`
### 校验流程
```mermaid
flowchart TD
Cmd["command 参数"] --> Trim["trim() 去空格"]
Trim --> Empty{"空命令 / bash / bash -c"}
Empty -->|是| Reject1["❌ 拒绝"]
Empty -->|否| Lower["to_lowercase()"]
Lower --> Blacklist{"黑名单子串匹配<br/>21 条模式)"}
Blacklist -->|命中| Reject2["❌ 拒绝:不允许执行 'X' 类命令"]
Blacklist -->|未命中| Execute["✅ 执行"]
```
### 黑名单(精确首词匹配,已修复子串误伤问题)
校验流程:
```mermaid
flowchart TD
Cmd["command 参数"] --> Trim["trim() 去空格"]
Trim --> Empty{"空命令 / bash / bash -c"}
Empty -->|是| Reject1["❌ 拒绝"]
Empty -->|否| Bypass{"命令替换绕过?<br/>$() 或反引号开头的首词"}
Bypass -->|是| Reject2["❌ 拒绝"]
Bypass -->|否| Extract["extract_first_command_word()<br/>提取首个命令单词"]
Extract --> Whitelist{"SAFE_COMMANDS 白名单?<br/>35 个安全命令)"}
Whitelist -->|是| Allow["✅ 直接允许"]
Whitelist -->|否| Blacklist{"DANGEROUS_COMMANDS 黑名单?<br/>33 个精确匹配)"}
Blacklist -->|是| Reject3["❌ 拒绝"]
Blacklist -->|否| ArgCheck{"DANGEROUS_ARG_PATTERNS<br/>5 个危险参数子串)"}
ArgCheck -->|命中| Reject4["❌ 拒绝"]
ArgCheck -->|未命中| Allow2["✅ 默认允许<br/>(路径沙箱 + 超时兜底)"]
```
**精确匹配 vs 子串匹配(修复前/后对比)**
| 命令 | 修复前(子串) | 修复后(首词精确) |
|---|---|---|
| `grep "ssh_config" *.rs` | ❌ 误拦(含子串 `ssh ` | ✅ 允许(首词 `grep` 在白名单) |
| `echo "use sudo carefully"` | ❌ 误拦(含子串 `sudo ` | ✅ 允许(首词 `echo` 在白名单) |
| `cat /usr/share/vim/vimrc` | ❌ 误拦(含子串 `vim ` | ✅ 允许(首词 `cat` 在白名单) |
| `python script.py` | ✅ 允许 | ✅ 允许(不在黑名单,默认允许) |
| `vim file.txt` | ✅ 拒绝 | ✅ 拒绝(首词 `vim` 在黑名单) |
| `$(echo sud; echo o) /etc/passwd` | ✅ 允许(绕过!) | ❌ 拒绝(检测到命令替换绕过) |
### 安全白名单(已启用)
```rust
const SAFE_COMMANDS: &[&str] = &[
"ls", "cat", "head", "tail", "find", "grep", "wc", "echo",
"pwd", "sort", "uniq", "cut", "tr", "awk", "sed", "jq",
"diff", "file", "stat", "du", "df", "env", "printenv",
"which", "basename", "dirname", "realpath", "readlink",
"xargs", "tee", "date", "sleep", "true", "false",
];
```
白名单内的命令**优先放行**,不经过黑名单检查。非白名单命令经过黑名单精确匹配和危险参数二次检查后默认允许。
### 其他约束
| 约束 | 值 | 说明 |
|---|---|---|
| 超时 | 默认 60s最大 120s | `AGENT_TOOL_TIMEOUT_SECS` 环境变量 |
| 输出截断 | 4000 字符 | `truncate_content()` |
| 工作目录 | library_dir / skills_dir / cwd | 受路径沙箱约束 |
### 已知局限
1. ~~**黑名单子串匹配**~~ — ✅ 已修复:改用首词精确匹配,`grep "ssh_config"` 不再误拦
2. **未限制网络访问**`curl`、`wget` 不在黑名单中
3. **未限制进程数** — fork bomb`:(){ :\|:& };:`)未被检测
4. **管道/重定向完整放行**`<`、`>`、`|` 不做限制
5. ~~**`$()` 命令替换**~~ — ✅ 已修复:检测首词位置 `$()` 和反引号绕过
6. ~~**白名单未被使用**~~ — ✅ 已修复:`SAFE_COMMANDS` 已集成到 `validate_bash_command()` 中,白名单命令优先放行
---
## 第六层:子代理隔离
子代理通过 `SubAgentRunner` 创建上下文隔离的执行环境,参考 Claude Code Subagents 设计。
定义位置:`src/agent/subagent.rs`、`src/agent/tools/subagent.rs`
### 隔离维度对比
| 维度 | 父代理 | 子代理 |
|---|---|---|
| 消息上下文 | 完整历史 + 所有中间工具调用 | **全新 messages**:仅 `[system_prompt, user_prompt]` |
| 工具注册表 | 完整 `ToolRegistry`19+ 工具) | 完整 `ToolRegistry`(共享同一个引用) |
| 工具上下文 | `ToolContext::new()` | `ToolContext::silent()`**`silent = true`** |
| Hook 管道 | 完整 `HookRegistry` | 继承父代理的 `HookRegistry` |
| PermissionChecker | 自己的实例 | 继承父代理的 `PermissionChecker` |
| 上下文压缩 | 多层压缩micro/auto/manual | 独立的自动压缩 |
| 死循环检测 | `DuplicateDetector` per turn | 独立的 `(last_call, consecutive_count)` |
### Silent 模式
```rust
// src/agent/tools/mod.rs:114
pub fn silent(app_state: Arc<AppState>) -> Self {
ToolContext {
app_state,
session_id: String::new(),
silent: true, // ← 关键标志
read_file_state: ...,
sse_tx: None, // ← 无 SSE 通道
enable_thinking: false,
}
}
```
**Silent 模式效果**
- `ask_user` 工具检测到 `ctx.silent == true` 时**直接返回错误**
- 阻止子代理绕过父代理向终端用户提问
- 无 SSE 通道 → 子代理的工具调用进度不会单独推送到前端
### 子代理内部权限流程
```mermaid
sequenceDiagram
participant SA as SubAgentRunner
participant Hook as HookRegistry
participant PC as PermissionChecker
participant Tool as AgentTool
SA->>Hook: PreToolUseContext { tool_name, args }
Hook-->>SA: PreToolUseResult { action, final_args }
alt action = Block
SA-->>SA: 注入错误 tool_result跳过执行
else action = Continue / MutateInput
SA->>PC: is_denied(tool_name)
alt 被拒绝
SA-->>SA: 注入错误 tool_result跳过执行
else 允许
SA->>Tool: execute(final_args, silent_ctx)
Tool-->>SA: ToolOutput
SA->>Hook: PostToolUse hooks → 可能修改输出
end
end
```
> **⚠️ 当前局限**:子代理使用**完整的父代理 ToolRegistry**,不支持按子任务需求裁剪工具列表(如"仅搜索"模式只给只读工具)。未来可引入 `ToolRegistry::restrict()` 方法实现最小权限原则。
---
## 第七层:运行时安全约束
ReAct 循环中的多层终止条件,防止无限循环和资源耗尽。
定义位置:`src/agent/runtime/mod.rs:442`
### 终止条件矩阵
| 条件 | 触发阈值 | 行为 |
|---|---|---|
| **最大步数** | `step > max_steps` (默认 8) | 强制 LLM 生成最终答案(不带工具调用),注入提醒消息 |
| **同质调用** | 连续 3 次相同 `(tool_name, args)` | 注入错误 tool_result跳过本轮该工具 |
| **Token diminishing returns** | 连续多步无新增信息 | 强制结束,注入"请基于已收集信息直接回答" |
| **用户取消** | `cancelled_runs` 含当前 `session_id` | 循环开始和工具执行中双重检查,发送 Error SSE 事件 |
| **压缩熔断** | `CompactionCircuitBreaker` 连续失败 | 跳过自动压缩,避免无限压缩循环 |
| **Token 硬限制** | `token_hard_limit` (默认 40000) | `TokenBudget` 触发强制动作 |
### 用户取消的双重检查
```mermaid
sequenceDiagram
participant User as 用户
participant API as API 层
participant State as cancelled_runs
participant Loop as ReAct 循环
participant Exec as 工具执行
User->>API: POST /api/chat/cancel
API->>State: insert(session_id)
Note over Loop: 每步迭代开始
Loop->>State: contains(session_id)?
State-->>Loop: true → break 循环
Note over Exec: 工具执行中 (每 250ms)
loop 取消轮询
Exec->>State: contains(session_id)?
State-->>Exec: true → interrupt
end
Note over Exec: InterruptBehavior 判断
alt interrupt_behavior = Cancel
Exec-->>Exec: 立即停止,返回 "执行已被用户取消"
else interrupt_behavior = Block
Exec-->>Exec: 忽略中断信号,等待完成
end
```
- 主循环在**每步开始**检查取消标志
- 工具执行器以 **250ms 间隔**轮询取消状态
- `Block` 工具(`run_bash`、`save_memory`、`ask_user`、`subagent`)忽略取消信号直到自然完成
### 取消状态生命周期
1. 用户通过 API 端点设置 `cancelled_runs.insert(session_id)`
2. `CancellationHook::pre_tool_use()` 检测到 → 返回 `Block`
3. ReAct 循环入口检测到 → `break` 跳出
4. 工具执行检测到 + `InterruptBehavior::Cancel` → 立即返回
5. `CancellationHook::on_session_stop()` → 清理 `cancelled_runs.remove(session_id)`
---
## 配套安全机制
### Background Task 安全
位置:`src/agent/tools/background.rs`、`src/agent/background.rs`
- `bg_task_run` 用于在后台执行慢速操作(下载、解析)
- 后台任务通过 `BgNotificationQueue` 注入结果,不直接访问 Agent 上下文
- 结果注入在下一次 LLM 调用前以 user 消息形式推送
### 工具输出持久化
位置:`src/agent/tools/persist.rs`
- 大型工具结果(超过 `max_tool_output_chars`)写入磁盘,消息中只包含 stub
- 写入路径:`{library_dir}/tool-results/{tool_call_id}.txt`
- 通过调用外部工具读取完整结果,避免上下文污染
### Token 预算管理
位置:`src/agent/runtime/token_budget.rs`
```
软限制 (token_soft_limit, 默认 32000)
↓ 触发渐进式 nudging 提醒 → 建议 LLM 总结/给出答案
硬限制 (token_hard_limit, 默认 40000)
↓ 触发强制动作 → 上下文压缩或强制结束
Diminishing Returns 检测
↓ 连续无新增信息 → 强制结束 + 直接回答
```
---
## 权限检查全链路
一次完整的工具调用穿越全部 7 层防线:
```
工具调用请求
├─ [L7] 循环入口:步数 / 取消 / diminishing returns 检查
├─ [L7] validate_and_prepare():死循环检测 + 参数解析
├─ [L3] PreToolUse hooks
│ ├── CancellationHook → 检查 cancelled_runs
│ ├── 自定义 Hook → Block? MutateInput?
│ └── 返回 final_args + additional_context
├─ [L2] PermissionChecker.check() ← ✅ 在 Phase 2.5 调用PreToolUse hooks 之后、执行之前)
│ ├── Deny → 注入错误 result跳过执行不进入队列
│ ├── AskUser → 暂视为允许Phase 2 确认交互待完善)
│ └── Allowed → 正常进入执行队列
├─ [L1] 分区器 (ToolPartitioner)
│ └── is_concurrency_safe() 判断 → 并行 or 串行批次
├─ 工具执行 (每工具独立 Future)
│ │
│ ├─ [L1] InterruptBehavior 判断 → Cancel 可中断 / Block 不可中断
│ │
│ ├─ [L4] 路径沙箱 (read_file / file_write / file_edit / glob / grep / bash)
│ │
│ ├─ [L5] Bash 黑名单 (run_bash):
│ │ ├── validate_bash_command() → 空命令 / 黑名单
│ │ ├── 工作目录路径沙箱检查
│ │ └── 超时控制 (60s default / 120s max)
│ │
│ ├─ [L6] ask_user Silent 模式检查 → 子代理中直接返回错误
│ │
│ └─ 超时控制 (AGENT_TOOL_TIMEOUT_SECS, default 120s)
└─ [L3] PostToolUse hooks
├── 输出截断 + 大结果持久化
├── MetricsHook → 累计指标
├── AuditLogHook → fire-and-forget 审计日志
└── MutateOutput → 输出修改
```
---
## Claude Code 权限系统对比分析
> 对比基准Claude Code (`/home/fmq/program/claudecode/src/utils/permissions/`)
> 分析日期2026-06-17
### 架构差异总览
| 维度 | AstroResearch (当前) | Claude Code (参考) | 差距 |
|------|---------------------|-------------------|------|
| 规则引擎 | ✅ PermissionChecker (完成) | ✅ hasPermissionsToUseTool 多步流水线 | 相当 |
| 规则匹配粒度 | ✅ 内容级 `Tool(content*)` 前缀/后缀/包含 | ✅ 前缀/通配/内容级 / 正则 | 小 |
| AskUser 交互流 | ✅ SSE → PermissionRequestCard → Allow/Deny/Always Allow | ✅ 完整 SSE → Dialog → 决策 | 相当 |
| 权限模式 | ✅ Default/AcceptEdits/Bypass/DontAsk | ✅ 6种模式 (含 plan/auto) | 小 |
| 规则持久化 | ✅ 环境变量加载 + `PermissionChecker::from_config()` | ✅ settings.json 多层加载 (8级来源优先级) | 小 |
| 规则来源追踪 | ✅ `PermissionRuleSource` 枚举 (Env/Session) | ✅ cliArg > command > session > userSettings > ... | 小 |
| Bash 权限分类器 | ✅ SAFE_COMMANDS 白名单 + `check_permissions()` 集成 | ✅ AST解析 + AI分类器 + 异步推测 | 中等 |
| 拒绝追踪/熔断 | ✅ `DenialTracker` 连续/累计计数 + ReAct 循环熔断 | ✅ 连续/总计拒绝计数 + 自动终止 | 相当 |
| 权限 Hook 集成 | ✅ PreToolUseAction::PermissionRequired 完整流程 | ✅ 完整 PermissionRequest hook + 多路径决议 | 相当 |
| 规则遮蔽检测 | ✅ `detect_shadowed_rules()` deny/ask 双重检查 | ✅ `shadowedRuleDetection` deny/ask 遮蔽检测 | 相当 |
| Auto Mode (AI 分类) | ❌ 无 | ✅ YOLO classifier + 快速路径 + 安全工具白名单 | **远期** |
| 权限解释器 | ✅ 启发式 `explain_permission()` (Bash 风险等级 + 路径检测) | ✅ Haiku 生成风险解释 | 中等 |
| 会话内规则更新 | ✅ `POST/PUT /api/chat/sessions/:id/permissions/*` | ✅ `/permissions` 命令 + API | 小 |
| 子代理权限继承 | ✅ 完整 `check()` 三态检查 | ✅ 完整继承父级权限上下文 | 相当 |
| 附加目录沙箱 | ✅ `AGENT_ADDITIONAL_DIRS` + `is_path_allowed()` 扩展 | ✅ `additionalDirectories` 可配置 | 相当 |
### Claude Code 权限流水线 (参考架构)
```
hasPermissionsToUseTool(toolName, input, context):
Step 1a: 工具级 deny 规则检查 → deny → 返回 deny
Step 1b: 工具级 ask 规则检查 → ask → 返回 ask (sandbox 例外)
Step 1c: 工具自定义 checkPermissions() → 内容级规则匹配
Step 1d: 工具实现返回 deny → deny → 返回 deny
Step 1e: requiresUserInteraction? → ask → 强制 ask (bypass 免疫)
Step 1f: 内容级 ask 规则 → ask → 强制 ask (bypass 免疫)
Step 1g: 安全检查 (敏感路径等) → ask → 强制 ask (bypass 免疫)
Step 2a: bypassPermissions 模式? → allow → 返回 allow
Step 2b: 工具级 allow 规则 → allow → 返回 allow
Step 3: 剩余 passthrough → ask → 返回 ask
外层模式变换:
dontAsk 模式: ask → deny
auto 模式: acceptEdits 快速路径 → 安全工具白名单 → AI分类器
headless: hooks 先运行 → 无 hook 决定 → auto-deny
```
### 关键设计决策对比
**1. 规则格式**
Claude Code 使用 `ToolName(content)` 格式支持内容级规则:
```
Bash → 匹配所有 bash 命令
Bash(npm install) → 匹配精确命令
Bash(npm *) → 前缀通配
Bash(rm:*) → 旧版前缀(已废弃)
Read(.env) → 文件模式
mcp__server__tool → MCP 工具级
mcp__server → MCP 服务级
Agent(Explore) → 代理类型级
```
AstroResearch 已实现相同格式:
```
"*" → 通配所有工具
"tool_name" → 精确工具名匹配
"tool_name(content*)" → 前缀通配(如 "run_bash(rm *)" 匹配 "rm -rf /"
"tool_name(*suffix)" → 后缀通配(如 "read_file(*.env)" 匹配 ".env"
"tool_name(exact)" → 包含匹配(子串命中)
"*(content)" → 工具通配 + 内容匹配(如 "*(sudo)" 匹配任意工具的 sudo 命令)
```
从 args 中自动提取 `command`/`file_path`/`path`/`pattern`/`url` 字段进行内容匹配。
**2. 权限模式**
Claude Code 的 6 种模式通过 Shift+Tab 循环切换:
- `default` — 标准逐项确认
- `acceptEdits` — 工作目录内文件编辑自动通过
- `bypassPermissions` — 跳过所有 Askdeny/ask 规则仍生效;安全检查 bypass 免疫)
- `dontAsk` — 所有 Ask 转 Deny
- `plan` — 计划模式
- `auto` — AI 自动分类(内部使用)
AstroResearch 已实现 4 种模式(通过 `AGENT_PERMISSION_MODE` 环境变量或 API 切换):
- `default` — 标准规则链Ask 触发用户交互
- `acceptEdits` — 工作目录内文件编辑自动通过(路径检查由 executor 完成)
- `bypassPermissions` — 跳过所有 AskDeny 规则仍生效)
- `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 Allow120s 超时自动拒绝
- 单一路径决议oneshot不支持多路径 claim 模式
---
## 优化路线图
### ✅ P0 — 已全部完成
#### P0-1: 规则加载与持久化 ✅
`AgentConfig::from_env_optional()` 从环境变量加载规则(`AGENT_PERMISSIONS_DENY`/`ALLOW`/`ASK``PermissionChecker::from_config()` 按 Deny → Ask → Allow 优先级顺序构造规则链。
**实现位置**: `src/agent/runtime/mod.rs:113-117`, `src/agent/runtime/permission.rs:268-290`
#### P0-2: 完成 AskUser 权限交互流 ✅
executor Phase 2.5 中完整的 AskUser 处理:
- `AgentStreamEvent::PermissionRequest` SSE 事件 → 前端 `PermissionRequestCard` 组件
- `oneshot` 通道 + `AppState::pending_permissions` 存储
- 120s 超时自动拒绝,支持 Allow / Deny / Always Allow 决策
**实现位置**: `src/agent/runtime/executor.rs:282-422`, `src/api/agent.rs`, `dashboard/src/features/agent/PermissionRequestCard.tsx`
#### P0-3: 内容级权限匹配 ✅
`PermissionChecker::matches()` 支持 `"tool_name(content_pattern)"` 格式,前缀通配(`prefix*`)、后缀通配(`*suffix`)、包含匹配,自动从 args 提取 `command`/`file_path`/`path`/`pattern`/`url` 字段。
**实现位置**: `src/agent/runtime/permission.rs:183-255`
### ✅ P1 — 已全部完成
#### P1-1: 权限模式系统 ✅
`PermissionMode` 枚举实现 4 种模式Default/AcceptEdits/Bypass/DontAsk通过 `AGENT_PERMISSION_MODE` 环境变量或 API 切换。`PermissionChecker::apply_mode()` 在 executor Phase 2.5 中对检查结果进行模式变换Bypass 将 Ask→AllowedDontAsk 将 Ask→Denied
**实现位置**: `src/agent/runtime/permission.rs:39-60, 139-177`
#### P1-2: Bash 权限接入 PermissionChecker ✅
`RunBashTool::check_permissions()` 调用 `bash_needs_permission()` —— 安全白名单中的命令返回空规则(自动允许),非白名单命令返回 `Ask` 规则。executor Phase 2.5 中与 PermissionChecker 结果叠加。
**实现位置**: `src/agent/tools/filesystem/bash.rs:58-73, 362-365`
#### P1-3: 权限 Hook 集成 ✅
`PreToolUseAction::PermissionRequired` 在 executor 中被检测:若 hook 返回 `PermissionRequired` 且 PermissionChecker 返回 `Allowed`,则升级为 `AskUser` 触发用户交互。已修复 Continue 覆盖 meaningful action 的 bug。
**实现位置**: `src/agent/hooks.rs:378-384`, `src/agent/runtime/executor.rs:221-230`
#### P1-4: 子代理完整权限继承 ✅
`SubAgentRunner` 使用 `check(tool_name, Some(&final_args))` 进行三态检查Deny → 注入错误跳过执行AskUser → 自动拒绝子代理不应打断用户Allowed → 正常执行。
**实现位置**: `src/agent/subagent.rs`
### ✅ P2-1、P2-2 — 已实现
#### P2-1: 拒绝追踪与熔断 ✅
`DenialTracker` 追踪连续拒绝和总拒绝数,阈值触发 ReAct 循环终止。
配置:`AGENT_DENIAL_MAX_CONSECUTIVE` (默认 3) / `AGENT_DENIAL_MAX_TOTAL` (默认 20)。
**实现位置**: `src/agent/runtime/denial_tracker.rs`, `src/agent/runtime/mod.rs`
#### P2-2: 会话内规则更新 API ✅
`POST /api/chat/sessions/:id/permissions/rules` — add/remove 规则
`PUT /api/chat/sessions/:id/permissions/mode` — 切换权限模式
通过 `AppState::session_permission_checker` (`Arc<RwLock<PermissionChecker>>`) 实现跨 turn 共享。
**实现位置**: `src/api/permissions.rs`, `src/agent/runtime/executor.rs` Phase 2.5
### 🟡 P2-3~P2-5 — 远期增强(按需实现)
#### P2-3: 规则遮蔽检测 ✅
`PermissionChecker::detect_shadowed_rules()` 检测 Deny/Ask 遮蔽 Allow 的情况,输出 `ShadowedRule` 列表(含 reason + fix 建议),在 AgentRuntime 初始化时通过 `warn!` 日志输出。
**实现位置**: `src/agent/runtime/permission.rs`
#### P2-4: 权限解释器 ✅
启发式 `explain_permission()` 函数,根据工具名和参数生成 `{risk_level, explanation, reasoning, risk}` 结构。Bash 命令通过关键词检测风险等级HIGH/MEDIUM/LOW文件操作检测系统路径。结果随 `PermissionRequest` SSE 事件推送到前端。
**实现位置**: `src/agent/runtime/permission_explainer.rs`, `src/agent/runtime/mod.rs` `AgentStreamEvent::PermissionRequest.explanation`
#### P2-5: Auto Mode (AI 权限分类器) ❌
使用 LLM 自动评估工具调用的风险:
- 快速路径:`AcceptEdits` 模式自动允许工作目录内的文件编辑
- 安全工具白名单:`read_file`、`grep_files`、`search_papers` 等只读操作自动允许
- AI 分类:对不确定的操作调用快速模型判断安全性
- 失败封闭:分类器不可用时拒绝所有非白名单操作(安全优先)
**工作量**: 3-5天
#### P2-4: 权限解释器
在执行前用 LLM 生成人类可读的风险描述:
```
"该命令将执行 npm install可能修改 node_modules/ 目录并下载外部依赖包。"
```
**工作量**: 1天
#### P2-5: 子代理最小权限 (ToolRegistry::restrict)
```rust
impl ToolRegistry {
pub fn restrict(&self, allowed_tools: &[&str]) -> Self {
// 创建仅包含指定工具的受限注册表
}
}
```
**工作量**: 0.5天
---
## 实现路线图
```
已完成 (Phase 1): P0-1 规则加载 + P0-2 AskUser 交互流 + P0-3 内容级匹配
已完成 (Phase 2): P1-1 权限模式 + P1-2 Bash 集成 + P1-3 Hook 集成 + P1-4 子代理继承
已完成 (Phase 3): P2-1 拒绝追踪熔断 + P2-2 会话内规则更新 + P2-3 规则遮蔽检测 + P2-4 权限解释器 + 附加目录沙箱 + 规则来源追踪
远期规划 (按需): P2-5 Auto Mode (AI 分类器) + P2-6 权限解释器 LLM 升级 + 子代理最小权限 + 文件写入大小限制
```
---
## 待完善项
| 优先级 | 项目 | 当前状态 | 建议 |
|---|---|---|---|
| ~~**HIGH**~~ | ~~PermissionChecker 集成到主执行路径~~ | ✅ **已完成** | — |
| ~~**HIGH**~~ | ~~Bash 黑名单改为命令解析~~ | ✅ **已完成** | — |
| ~~**P0**~~ | ~~规则加载与持久化~~ | ✅ **已完成**`AgentConfig` 新增 `permission_deny_rules` / `permission_allow_rules` / `permission_ask_rules` / `permission_mode` 字段,通过 `AGENT_PERMISSIONS_*` 环境变量加载 | — |
| ~~**P0**~~ | ~~AskUser 权限交互流~~ | ✅ **已完成**executor Phase 2.5 AskUser 分支重写为完整 oneshot → SSE → 120s 超时流程。前端 `PermissionRequestCard` 组件提供 Allow / Deny / Always Allow | — |
| ~~**P0**~~ | ~~内容级权限匹配~~ | ✅ **已完成**`check(tool_name, tool_args)` 签名,`matches()` 支持 `"tool(content*)"` 格式(前缀/后缀/包含),自动提取 args 字段 | — |
| ~~**P1**~~ | ~~权限模式系统~~ | ✅ **已完成**`PermissionMode` (Default/AcceptEdits/Bypass/DontAsk)`apply_mode()` 方法,`AGENT_PERMISSION_MODE` 配置 | — |
| ~~**P1**~~ | ~~Bash 权限集成~~ | ✅ **已完成**`RunBashTool::check_permissions()` 调用 `bash_needs_permission()`安全命令自动允许executor 合并工具级检查 | — |
| ~~**P1**~~ | ~~权限 Hook 集成~~ | ✅ **已完成**`PreToolUseResult::is_permission_required()`,修复 Continue 覆盖 bugexecutor 触发 AskUser | — |
| ~~**P1**~~ | ~~子代理完整权限继承~~ | ✅ **已完成**`is_denied()` → `check(tool_name, Some(&final_args))`,子代理中 AskUser 自动拒绝 | — |
| ~~**MEDIUM**~~ | ~~拒绝追踪与熔断~~ | ✅ **已完成** | `DenialTracker`:连续/总计拒绝计数,阈值触发 ReAct 循环终止 |
| ~~**MEDIUM**~~ | ~~会话内规则更新 API~~ | ✅ **已完成** | `POST/PUT /api/chat/sessions/:id/permissions/*` 动态 add/remove/mode |
| ~~**MEDIUM**~~ | ~~权限解释器~~ | ✅ **已完成** | 启发式 `explain_permission()`Bash 风险等级 + 路径检测,随 SSE PermissionRequest 推送前端 |
| **MEDIUM** | Auto Mode (AI 分类器) | 未实现 | LLM 评估风险,快速路径 + 安全工具白名单 |
| **LOW** | 子代理最小权限 | 继承全部父工具 | `ToolRegistry::restrict()` |
| **LOW** | 文件写入大小限制 | 无上限 | 添加 `max_file_size` 参数 |
| **LOW** | 网络访问控制 | `curl`/`wget` 未限制 | Bash 黑名单扩展 |
| **LOW** | 用户权限 profiles | 不支持 | YAML/TOML 权限配置 |

View File

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

View File

@ -0,0 +1,364 @@
# 子代理系统 (`subagent.rs` + `tools/subagent.rs`)
参考 Claude Code s04 Subagents 设计,实现**上下文隔离的子代理运行器**。父代理通过 `subagent` 工具将独立子任务委托给子代理执行,子代理拥有全新的消息上下文和完整的 ReAct 循环,仅将最终文本摘要返回给父代理。
核心价值:**防止父代理上下文被中间工具调用污染**同时让子代理具备完整的工具能力文献搜索、下载、RAG 检索、笔记等)来独立完成子任务。
## 架构总览
```mermaid
graph TB
subgraph Tool["SubAgentTool (tools/subagent.rs)"]
direction TB
T_name["工具名: 'subagent' (对外语义: delegate_research)"]
T_params["参数: research_prompt (必需), max_steps (默认5, 最大10)"]
T_interrupt["InterruptBehavior::Block — 需等待写操作完成"]
T_role["职责: 参数解析 → 构造 SubAgentRunner → 结果包装"]
end
Tool -->|"调用"| Runner
subgraph Runner["SubAgentRunner (subagent.rs)"]
direction TB
R_fields["字段:<br/>• app_state: Arc&lt;AppState&gt;<br/>• config: AgentConfig<br/>• tool_registry: ToolRegistry (与父代理共享)<br/>• hook_registry: Option&lt;Arc&lt;HookRegistry&gt;&gt;<br/>• permission_checker: Arc&lt;PermissionChecker&gt;<br/>• progress_tx: Option&lt;UnboundedSender&gt;<br/>• parent_session_id: String"]
R_ctors["三种构造方式:<br/>① new() — 最简构造<br/>② new_with_hooks() — 完整构造<br/>③ new_with_registry() — 自定义工具集"]
R_chain["链式配置:<br/>• with_parent_session()<br/>• with_thinking()"]
end
```
## 完整执行流程
```mermaid
sequenceDiagram
participant Lead as 父代理 (AgentRuntime)
participant Tool as SubAgentTool
participant Runner as SubAgentRunner
participant LLM as LLM API
participant FE as 前端 SSE
participant DB as SQLite
Lead->>Tool: subagent(research_prompt, max_steps?)
Tool->>Tool: 参数校验 + 构造 system_prompt
Tool->>Runner: new_with_hooks(app_state, hooks, pchecker, sse_tx)
Tool->>Runner: .with_parent_session(sid).with_thinking(bool)
Tool->>Runner: run(system_prompt, research_prompt, max_steps)
Note over Runner: Phase 1 — 初始化
Runner->>Runner: 生成 subagent_name = "sub_<uuid8>"
Runner->>Runner: 触发 OnSubagentStart hook
Runner->>DB: 保存 system + user 消息 (fire-and-forget)
Note over Runner: Phase 2 — run_inner() ReAct 循环
Runner->>Runner: 构建全新 messages: [system, user]
loop 每步迭代 (step ≤ max_steps)
Runner->>Runner: 上下文压缩检查 (est_tokens > limit × 1.5)
Runner->>LLM: chat_stream(messages, tool_defs, thinking?)
LLM-->>Runner: ReasoningDelta / TextDelta / ToolCallsComplete
Runner->>FE: Thought "[子代理] ..." (通过 progress_tx)
alt 无工具调用 → 最终答案
Runner->>FE: Thought (子代理结论)
Runner-->>Tool: ToolOutput::success([子代理活动记录] + [子代理结论])
else 有工具调用
Runner->>Runner: 死循环检测 (连续相同调用 ≥ 3 次 → 拦截)
loop 每个 tool_call (串行执行)
Runner->>FE: ToolCall "[sub] tool_name" (通过 progress_tx)
Runner->>Runner: PreToolUse hook (Block → 跳过, MutateInput → 修改参数)
Runner->>Runner: 权限检查 (is_denied → 跳过)
Runner->>Runner: tool.execute(args, ctx) (timeout 保护)
Runner->>Runner: PostToolUse hook (MutateOutput → 修改输出)
Runner->>Runner: 输出截断 (max_tool_output_chars)
Runner->>FE: ToolResult "[sub] tool_name" (通过 progress_tx)
Runner->>DB: 保存 assistant + tool 消息 (fire-and-forget)
Runner->>Runner: messages.push(ChatMessage::tool_result)
end
Runner->>Runner: messages.push(ChatMessage::assistant)
end
end
Note over Runner: Phase 3 — 达到最大步数
Runner->>LLM: force_final_answer (空工具列表, 无工具调用)
LLM-->>Runner: 最终文本答案
Note over Runner: Phase 4 — 收尾
Runner->>DB: 保存最终 assistant 消息
Runner->>Runner: 触发 OnSubagentStop hook
Runner-->>Tool: ToolOutput { content, metadata }
Tool->>Tool: 包装: "[子代理研究结果]\n\n{content}"
Tool-->>Lead: ToolOutput
```
## 核心机制详解
### 1. 上下文隔离
子代理拥有**全新的 `messages` 向量**,仅包含 system prompt + user prompt不包含父代理的任何中间工具调用。父代理只会收到最终的文本摘要中间的工具调用细节搜索了什么、下载了什么不会污染父上下文。
这是子代理系统最核心的价值——与直接在父代理中执行相比,子代理消耗的父上下文 token 是 O(1) 而不是 O(steps)。
```
父代理 context: 子代理 context:
┌─────────────────────┐ ┌─────────────────────────┐
│ system prompt │ │ system prompt │
│ user: "分析星系演化" │ │ user: "搜索星系演化的文献" │
│ assistant: tool_calls│ │ assistant: tool_call │
│ tool_result: [大文本] │ │ tool_result: [搜索结果] │
│ ... (越来越多) │ │ assistant: "找到 5 篇..." │
│ │ └─────────────────────────┘
│ ← 子代理结果注入这里 │ ↓
│ (仅有摘要,无中间步骤)│ 只返回文本摘要给父代理
└─────────────────────┘
```
### 2. 工具执行模式 — 串行
与父代理 `executor.rs` 使用 `FuturesUnordered` 的**并行**执行不同,子代理采用简单的 **for 循环串行**执行:
```rust
// src/agent/subagent.rs:395 — 串行 for 循环
for tool_call in &tool_calls {
let output = tool.execute(final_args, &tool_ctx).await;
messages.push(ChatMessage::tool_result(&tool_call.id, &truncated));
}
```
| 特性 | 父代理 (executor.rs) | 子代理 (subagent.rs) |
|:---|:---|:---|
| 执行方式 | `FuturesUnordered` 并行 | `for` 循环串行 |
| 结果推送 | 渐进式(快工具不等待慢工具) | 逐个完成 |
| FileStateCache | 支持(避免重复读文件) | 不支持 |
| 大结果持久化到磁盘 | `maybe_persist_tool_result` | 无(仅截断) |
| Sibling Abort | 支持(出错时中断兄弟任务) | 不支持 |
**设计考量**:子代理的 `max_steps` 通常为 5任务规模较小并行收益有限。串行实现更简单、更可预测且每次工具调用之前会检查 PreToolUse hook 和权限,串行执行保证了 hook 决策的时序正确性。
### 3. 死循环检测
子代理独立维护 `last_call: Option<(String, String)>` 追踪上次的工具调用(名称 + 参数)。当连续 3 次相同调用时触发拦截:
```rust
// src/agent/subagent.rs:416-418
let call_key = (tool_name.clone(), tool_args_str.clone());
if last_call.as_ref() == Some(&call_key) {
consecutive_count += 1;
if consecutive_count >= duplicate_threshold { // threshold = 3
// 注入错误消息,强制 LLM 停止并给出答案
let error_msg = ChatMessage::tool_result(
&tool_call.id,
format!("工具 {} 被连续重复调用。请停止并给出当前收集到的答案。", tool_name),
);
messages.push(error_msg);
continue; // 跳过本次执行
}
} else {
last_call = Some(call_key);
consecutive_count = 1;
}
```
这与父代理的 `DuplicateDetector` 功能相同,但实现更简洁——子代理的场景简单,不需要全局去重表。
### 4. 上下文压缩
子代理的上下文在每步开始前检查,使用简单的字节长度估算:
```rust
// src/agent/subagent.rs:261-265
let est_tokens: usize = messages
.iter()
.map(|m| m.content.as_ref().map_or(0, |c| c.len()) + 4)
.sum();
if est_tokens > self.config.context_char_limit * 3 / 2 {
compact::compress_context(&mut messages, llm, config.context_char_limit, "subagent").await;
}
```
- 触发阈值:`context_char_limit * 1.5`(默认 ~24000 字符)
- 压缩方式:调用 `compact::compress_context`LLM 摘要压缩)
- 标识来源:`"subagent"`,区别于 `"lead"` / `"teammate"` / `"background"`
### 5. force_final_answer — 达到最大步数时的兜底
```rust
// src/agent/subagent.rs:563-600
async fn force_final_answer(&self, llm: &LlmClient, messages: &[ChatMessage]) -> ToolOutput {
let mut final_messages = messages.to_vec();
final_messages.push(ChatMessage::user(
"请根据已收集的信息直接给出最终答案,不要再调用工具。",
));
let empty_tools: Vec<ToolDefinition> = Vec::new(); // 不提供任何工具
let mut stream_rx = llm.chat_stream(&final_messages, &empty_tools, ...).await?;
// 收集 TextDelta 直到 Done
}
```
- 追加一条 user 消息明确要求停止工具调用
- 传递**空的工具定义列表**,从 API 层面禁止工具调用
- 返回 `metadata: { "forced": true }` 标记这是强制答案
### 6. 数据库持久化
所有子代理消息通过 `save_subagent_message` 写入 `agent_messages` 表,使用 fire-and-forget 模式(`tokio::spawn`
```rust
// src/agent/subagent.rs:190-235
fn save_subagent_message(&self, agent_name: &str, turn_index: i32, step_index: i32,
role: &str, content: &str, ...) {
let metadata = serde_json::json!({ "agent": agent_name, "is_subagent": true });
tokio::spawn(async move {
let _ = sqlx::query(
"INSERT INTO agent_messages (session_id, turn_index, step_index, role,
content, thought, tool_calls, tool_call_id, token_count, metadata, agent_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&session_id)
.bind(turn_index) // 始终为 0
.bind(step_index) // 当前 step 编号
.bind(&role_owned) // system / user / assistant / tool
.bind(&content_owned)
// ...
.bind(&metadata_str) // {"agent":"sub_xxx","is_subagent":true}
.bind(&agent) // subagent_name
.execute(&db).await;
});
}
```
持久化的消息类型:
- **system** — 子代理系统提示词
- **user** — 研究任务描述
- **assistant** — 每步 LLM 响应(含 tool_calls JSON
- **tool** — 每个工具的执行结果(截断后)
- **assistant** — 最终结果step_index = max_steps + 1
所有消息带 `{"is_subagent": true}` 元数据标记,`agent_name` 字段存放 `sub_<uuid8>` 名称,`turn_index` 始终为 0。
**设计考量**fire-and-forget 意味着持久化失败不会阻塞子代理执行。代价是极端情况下DB 连接断开)可能丢失审计数据。
## Hook 集成
子代理在以下时机触发 Hook 事件:
| 事件 | 触发位置 | 上下文数据 | 用途 |
|:---|:---|:---|:---|
| `OnSubagentStart` | `run()` Phase 1 | parent_session_id, subagent_name, prompt | 审计、指标初始化 |
| `PreToolUse` | 每个工具执行前 | session_id="subagent", tool_name, args | 取消检查、参数修改 |
| `PostToolUse` | 每个工具执行后 | session_id="subagent", output_content, is_error | 指标采集、输出修改 |
| `OnSubagentStop` | `run()` Phase 4 | result_summary (前200字符), steps, is_error | 审计记录、数据汇总 |
`PreToolUse``PostToolUse` 在子代理的工具执行循环中**直接内联实现**`subagent.rs:444-521`),而非通过 `executor.rs` 的并行管道。这意味着:
- PreToolUse 的 Block 操作 → 注入错误 tool_result 消息,跳过执行
- PreToolUse 的 MutateInput → 替换 final_args
- PostToolUse 的 MutateOutput → 替换 final_output_content
- Permission check → `is_denied` 直接拒绝
**与父代理的区别**:父代理通过 `executor::execute_parallel()` 统一处理 hooks + 并发,子代理在自身的 for 循环中手动调用 hook 方法。两者享有相同的 `HookRegistry` 实例(通过 `SubAgentRunner::new_with_hooks` 注入)。
## SubAgentTool 工具层
```rust
// src/agent/tools/subagent.rs
impl AgentTool for SubAgentTool {
fn name(&self) -> &str { "subagent" }
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"research_prompt": {
"type": "string",
"description": "要委托给子代理执行的完整研究任务描述..."
},
"max_steps": {
"type": "integer",
"description": "子代理最大推理步数默认5最大10",
"default": 5
}
},
"required": ["research_prompt"]
})
}
// 子代理可能执行写操作,中断时应阻塞以完成
fn interrupt_behavior(&self) -> InterruptBehavior {
InterruptBehavior::Block
}
}
```
`execute` 方法流程:
1. 参数校验(`research_prompt` 必填,`max_steps` 取 min(参数, 10)
2. 构造子代理 `system_prompt`(天体物理学研究助手,中文回答)
3. 创建 `SubAgentRunner::new_with_hooks()` — 注入 hooks、permissions、SSE 通道、session_id
4. 调用 `runner.run(system_prompt, &research_prompt, max_steps)`
5. 错误 → `ToolOutput::error("子代理执行失败: ...")`
6. 成功 → 包装为 `"[子代理研究结果]\n\n{content}"`
## 与 Team Teammate 的对比
两者都实现了"将工作委托给独立的 ReAct 循环",但设计上有本质差异:
| 特性 | SubAgentRunner | Team Teammate |
|:---|:---|:---|
| 文件位置 | `src/agent/subagent.rs` | `src/agent/team/teammate.rs` |
| 触发方式 | LLM 调用 `subagent` 工具 | Lead 通过文件收件箱发送任务 |
| 工具集 | 完整 ToolRegistry含 subagent | 排除 subagent防无限委托链 |
| Hook 管道 | 完整PreToolUse/PostToolUse/Start/Stop | 无 |
| SSE 进度 | 支持(通过 progress_tx 透传) | 无 |
| DB 持久化 | 完整agent_messages + agent_audit_log | 无 |
| 运行模式 | 同步:父代理等待子代理完成 | 异步:循环轮询收件箱 |
| 生命周期 | 一次性:任务完成即销毁 | 持续SPAWN → WORKING → IDLE → SHUTDOWN |
| 嵌套能力 | 可递归(无深度限制) | 不可(排除 subagent 工具) |
| 上下文压缩 | 支持compact::compress_context | 支持(同) |
| 死循环检测 | 支持(阈值=3 | 无独立检测 |
| 最大步数 | 默认 5最大 10 | `max_steps.min(5)` |
| 取消支持 | 无独立取消信号 | `AtomicBool` 取消标志 |
**使用场景区分**
- **SubAgent** — 同步的、一次性的、需要完整工具能力的子任务("综述近 5 年星系演化的文献"
- **Teammate** — 异步的、持续性的、角色分工的并行协作("你负责搜索,他负责解析"
## 工具注册
子代理工具 `SubAgentTool``ToolRegistry::add_base_tools()` 中注册,随后被替换为带 hooks 的实例:
```rust
// src/agent/runtime/mod.rs:264
// AgentRuntime 初始化时用带 hooks 的版本替换默认的 SubAgentTool
tool_registry.replace_tool(
crate::agent::tools::subagent::SubAgentTool::new_with_hooks(
hook_registry.clone(),
permission_checker.clone(),
),
);
```
参数透传链路:
```
LLM 调用 subagent(research_prompt, max_steps)
→ SubAgentTool.execute(args, ToolContext)
→ SubAgentRunner::new_with_hooks(app_state, hooks, pchecker, ctx.sse_tx)
.with_parent_session(ctx.session_id)
.with_thinking(ctx.enable_thinking)
.run(system_prompt, research_prompt, max_steps)
```
## 当前局限与改进方向
| # | 问题 | 影响 | 改进方向 |
|:---|:---|:---|:---|
| 1 | **工具串行执行** | 同一步多个工具调用无法并行,慢工具阻塞快工具 | 复用 `executor::execute_parallel`,或至少对 `is_concurrency_safe()` 工具并行 |
| 2 | **无嵌套深度限制** | 子代理可调用 subagent 创建子子代理,理论上无限递归 | 增加深度计数器,超过 2 层时移除 subagent 工具 |
| 3 | **Token 估算粗糙** | `content.len() + 4` 对中文极不准确(中文 1 字符 ≈ 1.5-2 token | 使用 `tiktoken-rs` 或 tokenizer 精确计数 |
| 4 | **fire-and-forget 持久化** | DB 写入失败静默忽略,可能丢失审计数据 | 至少记录 warn 日志;关键消息可改为 await |
| 5 | **force_final_answer 不调温度** | 达到 max_steps 时模型可能仍尝试输出工具调用格式 | 降低 temperature 或增加 stop 序列 |
| 6 | **subagent_name 不可控** | 自动生成的 UUID 片段不利于日志可读性 | 允许 LLM 传入 `name` 参数作为标识 |
| 7 | **无 FileStateCache** | 子代理反复读取同一文件时会重复 I/O | 传入 FileStateCache 或使用全局缓存 |
| 8 | **步骤内消息顺序丢失** | 同一步的多个 tool 消息共享相同 step_index | 引入子序号(如 step_index.sub_index |
---

View File

@ -0,0 +1,359 @@
# 系统提示词架构 (System Prompt Architecture)
AstroResearch 的 Agent 系统提示词采用**模块化 Section 组装 + 动态注入 + 多层生命周期**架构,直接参考 Claude Code 的 System Prompt 设计。
## 整体分层
```mermaid
graph TB
subgraph L5["Layer 5: 运行时注入"]
Nudge["nudge / 任务恢复 / 后台通知"]
end
subgraph L4["Layer 4: 提示词压缩"]
Compress["snip → micro → auto → identity"]
end
subgraph L3["Layer 3: Skill 动态加载"]
Skill["Layer1 提醒 → Layer2 全文注入"]
end
subgraph L2["Layer 2: 子代理隔离提示词"]
SubSP["独立的 system_prompt"]
end
subgraph L1["Layer 1: 主代理 SystemPrompt 组装"]
MainSP["5 个 section 模块化组装"]
end
L5 --> L4 --> L3 --> L2 --> L1
```
---
## 核心组装器 (`src/agent/runtime/system_prompt.rs`)
### 2.1 数据结构
```rust
pub struct SystemPrompt {
sections: Vec<(&'static str, String)>,
}
```
简单的有序 section 列表,通过 `assemble()` 方法用双换行符 `"\n\n"` 拼接所有 section 内容。section 按添加顺序排列。
### 2.2 静态常量
两个 `&'static str` 常量在所有运行时实例间共享内存:
**IDENTITY_SECTION**身份声明1 行):
```
你是一位专业的天体物理学研究助手,具备丰富的天文学知识。
```
**PRINCIPLES_SECTION**核心行为准则9 条):
```
核心原则:
1. 主动使用工具搜索最新文献,不要仅凭训练数据回答。
2. 优先使用本地资源get_paper_content / rag_search必要时再检索新文献。
3. 收集到足够信息后立即给出最终答案,避免无意义的重复工具调用。
4. 回答时引用具体文献来源,使用 ADS bibcode 标注。
5. 对于数学公式,使用标准 LaTeX 格式。
6. 用中文回答,保持科学术语的准确性(可附带英文原文)。
7. 对于复杂任务(如文献综述),调用 load_skill 获取方法论指引,再用 todo_write 制定计划。
8. 如果某个工具调用失败,不要用相同参数重试,尝试换一种方式或工具。
9. 任务状态会在每轮开始时从数据库恢复,请基于最新状态继续工作。
```
### 2.3 组装顺序
每轮调用 `AgentRuntime::system_prompt()` 方法(`src/agent/runtime/mod.rs:1154-1203`),按以下顺序组装 5 个 section
```
Section 1: identity 静态 — 最大化 Anthropic prompt cache 命中率
Section 2: tools 动态 — 从 ToolRegistry 生成工具名称+摘要列表
Section 3: skills 动态 — 从 SkillRegistry.build_reminder() 生成(<system-reminder> XML
Section 4: memory 动态 — 从 MemoryManager.build_system_reminder(5) 生成(<project-memory-context> XML
Section 5: principles 静态 — 核心原则(放在最后 — 若需调整仅影响最后一个 cache segment
```
**缓存策略**:静态 section 固定且不变化,放在 prompt 头部以最大化 Anthropic prompt cache 命中率。动态 sectiontools、skills、memory因内容较少对 cache 影响可控。principles 虽然静态但放在最后,当需要调优时仅破坏最后一个 cache segment。
---
## 动态 Section 详解
### 3.1 工具列表 (tools section)
```rust
let mut tools_desc = String::from("你可以使用以下工具:\n");
for def in self.tool_registry.definitions() {
let short_desc = def.function.description
.split('。').next()
.unwrap_or(&def.function.description)
.chars().take(80)
.collect();
tools_desc.push_str(&format!("- {}: {}\n", def.function.name, short_desc));
}
```
- 19 个默认工具:`search_papers`, `download_paper`, `parse_paper`, `get_paper_content`, `rag_search`, `query_target`, `save_note`, `read_file`, `grep_files`, `glob_files`, `run_bash`, `file_write`, `file_edit`, `todo_write`, `compress_context`, `load_skill`, `subagent`, `save_memory`, `bg_task_run`
- 描述仅取**第一句 + 前 80 字符**作为功能摘要
- 完整的参数 JSON Schema 通过 API 的 `tools` 参数单独传递,不在 system prompt 中重复
### 3.2 技能列表 (skills section) — 两层加载
参考 Claude Code 的两层技能设计,定义在 `src/agent/skills.rs`
**Layer 1 (system-reminder)**`SkillRegistry.build_reminder()` 在 system prompt 中注入 `<system-reminder>` XML 块。列出所有 `user_invocable=true``disable_model_invocation=false` 的技能名称 + 描述。每个 skill 约消耗 ~20 tokens。
```xml
<system-reminder>
The following skills are available for use with the Skill tool:
- methodology: 天体物理研究方法论指南 - When user asks about research methodology
- plotting: 数据可视化与科学绘图 - When user wants to create plots
- presentation: 学术幻灯片制作 - When user needs to prepare a presentation
When a skill matches the user's request, invoke load_skill BEFORE generating any other response about the task.
If you see a <command-name> tag in the current conversation turn, the skill has ALREADY been loaded - follow the instructions directly instead of calling load_skill again.
</system-reminder>
```
**Layer 2 (load_skill 工具)**LLM 按需调用 `load_skill(skill_name)` 工具,从 `skills/{name}/SKILL.md` 加载完整内容YAML frontmatter + Markdown body注入到消息上下文。完整 skill 约 ~2000 tokens。
SKILL.md 格式:
```yaml
---
name: methodology
description: 天体物理研究方法论指南
context: inline # inline | fork
when_to_use: When user asks about research methodology
allowed-tools:
- search_papers
- rag_search
model: inherit
user-invocable: true
---
# Skill 正文
详细内容...
```
**热重载**SkillRegistry 通过 `notify` crate 监听 skills 目录的文件变更300ms debounce 后自动刷新。Skill 按使用频率排序指数衰减评分7 天半衰期)。
**条件激活**Skill 可通过 `paths` frontmatter 声明 glob 模式。Agent 访问匹配文件时自动将 `disable_model_invocation` 设为 false激活条件 skill。
### 3.3 项目记忆 (memory section)
`MemoryManager.build_system_reminder(5)``{library_dir}/memory/` 目录加载最近 5 条记忆,生成 `<project-memory-context>` XML 块:
```xml
<project-memory-context>
[PROJECT MEMORY]
[偏好] memory-slug: 一句话描述
内容预览前三行
[时效提示: 此记忆已超过N天可能已过时]
[反馈] another-memory: 描述 [已更新→new-slug]
内容预览...
使用 save_memory 工具保存重要信息。记忆内容可能过时,请在使用前验证。
</project-memory-context>
```
关键特性:
- 按 mtime 排序(最新在前),支持语义选择 + 指数衰减排序
- 按类型标注:`[偏好]` / `[反馈]` / `[项目]` / `[参考]`
- 过期记忆标记为 `[已更新]``[已更新→new-slug]`(归档为 `{slug}_v1.md`
- 超过 1 天的记忆注入时效警告
- 索引文件 `MEMORY.md` 限制 200 行 / 25KB
---
## 上下文初始化与运行时注入 (`src/agent/runtime/context.rs`)
### 4.1 上下文构建流程
`build_initial_context()` 在每轮开始时构建完整的消息列表:
```
1. 从数据库加载历史消息agent_messages 表)
2. 如果历史第一条不是 system 角色 → 在位置 0 插入系统提示词
3. 追加当前用户问题
4. [可选] 追加任务状态恢复提醒(从 agent_tasks 表读取)
```
### 4.2 任务状态恢复
`agent_tasks` 表恢复未完成的任务,格式化注入 user 消息:
```
[当前任务状态]
以下是上次会话中持久化的任务计划,请基于最新状态继续工作:
⏳ [task-1] 搜索相关文献...
🔄 [task-2] 分析论文数据... (依赖: task-1)
✅ [task-3] 格式化引用... (指派: lead)
使用 todo_write 工具更新任务进度。
```
### 4.3 运行时 Nudge 注入
在 ReAct 循环中system prompt 组装后不再修改。运行时干预通过**注入 user 消息**实现(开闭原则):
| 触发条件 | Nudge 内容 |
|:---|:---|
| TodoWrite 连续 3 步未更新 | "提醒:你已经连续多步未更新任务计划。建议调用 todo_write 工具…" |
| Token 预算 diminishing returns | "检测到你的后续步骤未产生新信息…请基于已收集的全部信息直接给出最终答案" |
| 达到最大步数 (max_steps) | "你已经执行了 N 步(最大 M 步)。请根据已有信息直接给出最终答案" |
| 后台任务完成 | "[后台任务完成] ✅ tool_name: bibcode: summary" |
---
## 子代理的独立系统提示词 (`src/agent/tools/subagent.rs`)
子代理拥有独立的消息上下文,通过 `SubAgentRunner::run()` 接收一个**硬编码的简化版系统提示词**
```
你是一位专业的天体物理学研究助手,在一个独立的子任务上下文中工作。
你可以使用文献搜索、下载、RAG检索等工具。
请高效完成任务,然后直接给出最终答案。不要进行不必要的重复操作。
用中文回答,引用具体文献来源。
```
特点:
- 不继承父代理的 tools/skills/memory sections
- 共享父代理的 ToolRegistry
- 通过 `PermissionChecker` 可在特定场景下限制工具访问
- 独立的 ReAct 循环(步数上限通过参数传入,默认 5最大 10
- 完整的 Hook 管道PreToolUse/PostToolUse/SubagentStart/SubagentStop
- 包含活跃度日志activity log返回给父代理时附带工具调用统计
### 5.2 团队成员的独立提示词 (`src/agent/team/teammate.rs`)
队友的 `system_prompt``task_prompt``team/manager.rs`lead 的委托逻辑)在运行时构造并传入 `run_teammate_loop()`
- prompt 内容完全由 lead 的决定
- 队友不包含 `subagent` 工具(防止无限委托链)
- 更轻量的 ReAct 循环(无 SSE、无 DB 持久化、无 hooks
- 步数上限更严格min(max_steps, 5)
- 通过文件收件箱与 lead 通信(每 5 秒 poll最长 60 秒)
---
## 上下文压缩中的独立提示词 (`src/agent/compact.rs`)
### 6.1 四层压缩策略
| 层 | 方法 | API 调用 | 行为 |
|:---|:---|:---|:---|
| Layer 0 | `snip_compact` | 无 | 消息数超过 50 时截断中间段,保留头 3 + 尾 47 |
| Layer 1 | `micro_compact` | 无 | 将较早的工具结果替换为 `[Previous: used {tool_name}]` 占位符 |
| Layer 2 | `auto_compact` | 1 次 | LLM 摘要对话历史(见下),注入 `[历史对话摘要]` |
| Layer 3 | `aggressive_micro` | 无 | 保留最近 2 条工具结果,其余替换为占位符 |
### 6.2 LLM 摘要 Prompt
Layer 2 中调用 LLM 生成摘要时,使用独立的系统提示词:
```
系统: "你是一个对话摘要助手。请提取对话的关键信息和结论。"
用户: "请用简洁的中文总结以下对话历史的要点不超过500字
[用户] ...
[助手] ...
[工具] ..."
```
### 6.3 身份再注入
如果压缩后消息过少≤4 条),注入身份确认块防止模型丢失上下文认知:
```
[身份确认] 你是一位专业的天体物理学研究助手。以上是历史对话的压缩摘要。
你正在进行的研究任务是回答用户的问题。请基于摘要中的关键信息继续工作,
需要更多信息时主动使用工具搜索。
```
### 6.4 安全切割
`find_safe_cut_point()` 确保压缩时不会破坏 `assistant(tool_calls)` / `tool_result` 配对关系,向前追溯找到完整工具交互的边界。
### 6.5 熔断器
`CompactionCircuitBreaker` 防止连续压缩失败时的无限循环。连续 3 次压缩后消息数未减少 → 打开熔断器,后续跳过自动压缩。
---
## Hook 系统与提示词的交互 (`src/agent/hooks.rs`)
Hook 系统定义 9 个生命周期事件,其中与提示词相关的交互:
| Hook | 与提示词的关系 |
|:---|:---|
| `OnSessionStart` | 在提示词组装前触发,可影响任务状态恢复逻辑 |
| `PreToolUse::MutateInput` | 可向工具执行注入 `additional_context`(作为 user 消息追加) |
| `PreToolUse::Block` | 阻止特定工具的执行(如取消检查) |
| `PostToolUse::MutateOutput` | 可修改工具输出内容(影响后续 LLM 看到的 context |
| `OnStepComplete` | 每步结束记录 token 估算、消息数等指标 |
| `PreCompact` | 压缩前记录消息数和 token 估算 |
| `PostCompact` | 压缩后记录最终消息数和压缩方法 |
| `OnSubagentStart/Stop` | 子代理启动/停止时传递 prompt 和结果摘要 |
| `OnSessionStop` | 会话终止时清理取消状态并记录终止原因 |
---
## 完整数据流
```mermaid
flowchart TD
RT["AgentRuntime 创建<br/>system_prompt() 调用"]
RT --> S1["Section 1: identity<br/>(静态常量)"]
RT --> S2["Section 2: tools<br/>(ToolRegistry definitions)"]
RT --> S3["Section 3: skills<br/>(SkillRegistry.build_reminder)"]
S1 --> S4
S2 --> S4
S3 --> S4["Section 4: memory (可选)<br/>(MemoryManager.build_reminder, 5 entries)"]
S4 --> S5["Section 5: principles<br/>(静态常量)"]
S5 --> ASM["assemble()<br/>join('\n\n')"]
ASM --> Main["主 Agent 上下文<br/>build_initial_context()<br/>+ nudge 注入 + 任务恢复 + 后台通知"]
ASM --> Sub["子 Agent 上下文<br/>SubAgentRunner.run()<br/>(独立 system_prompt)"]
Main --> React["ReAct 循环"]
Main --> NudgeInj["Nudge 消息注入 (user)"]
Main --> Compact["压缩层<br/>generate_summary()<br/>+ identity re-injection"]
```
---
## 相关文件
| 文件 | 职责 |
|:---|:---|
| `src/agent/runtime/system_prompt.rs` | SystemPrompt 组装器 + 静态常量 |
| `src/agent/runtime/mod.rs:1154-1203` | `system_prompt()` 方法 — 5 section 拼装 |
| `src/agent/runtime/context.rs` | `build_initial_context()` — 上下文初始化 + 任务恢复 |
| `src/agent/skills.rs` | SkillRegistry — 两层技能加载 + 热重载 |
| `src/agent/memory/mod.rs` | MemoryManager — 记忆加载 + system reminder 构建 |
| `src/agent/compact.rs` | 四层压缩 + LLM 摘要 prompt + 身份再注入 |
| `src/agent/tools/subagent.rs` | 子代理系统提示词(硬编码) |
| `src/agent/subagent.rs` | SubAgentRunner — 子代理 ReAct 循环 |
| `src/agent/team/teammate.rs` | 队友 ReAct 循环(外部传入 system_prompt |
| `src/agent/hooks.rs` | 9 个生命周期 hook + 提示词交互 |
---
## 设计要点
### 优势
1. **模块化 section 组装**:各 section 独立管理,便于调试和迭代
2. **静态 section 前置**:最大化 Anthropic prompt cache 命中率,降低延迟和成本
3. **两层 skill 加载**:避免一次性注入所有 skill 的 token 浪费
4. **压缩时身份再注入**:防止激进压缩后模型丢失角色认知
5. **安全切割点**`find_safe_cut_point` 确保压缩不破坏 tool_call/tool_result 配对
6. **运行时 nudge 而非 system prompt 编辑**遵循开闭原则system prompt 保持稳定
### 潜在改进方向
1. **子代理系统提示词继承**:当前子代理的 system prompt 是硬编码的,可考虑让子代理也接收 section 组装器,选择性继承 skills/memory
2. **压缩 prompt 外部化**:摘要生成和身份确认的 prompt 可配置化,便于独立调优
3. **记忆注入锁竞争**`memory_manager.try_lock()` 在高并发下可能静默失败,考虑使用 `RwLock::read()`
4. **工具描述摘要策略**80 字符截断可能丢失关键语义,可考虑 LLM 预生成工具描述摘要

View File

@ -0,0 +1,233 @@
# 任务系统 (Task System)
任务系统为 Agent 提供 **规划 → 执行跟踪 → 状态持久化 → 跨 turn 恢复** 的完整闭环,参考 Claude Code s12 Task System 和 s17 Autonomous Agents 设计。
---
## 概览
```mermaid
flowchart TD
subgraph RT["AgentRuntime (run_react_loop)"]
direction TB
S1["1. LLM 调用"]
S2["2. 检测 todo_write 调用"]
S3["3. persist_tasks() 持久化"]
S4["4. 每 3 步 nag reminder"]
S5["5. context.rs 恢复任务状态"]
end
RT --> TodoWrite["TodoWriteTool<br/>(纯格式化, 并发安全, 无副作用)"]
RT --> Context["context.rs<br/>restore_tasks_from_db()<br/>(每 turn 开始时注入)"]
RT --> TaskBoard["TaskBoard<br/>(跨 session 共享看板)<br/>can_start() / claim_task() (原子)<br/>list_available_tasks()"]
TodoWrite --> Persist["persist_tasks<br/>INSERT OR REPLACE INTO<br/>agent_tasks"]
Persist --> DB["SQLite: agent_tasks<br/>(session_id, task_id UNIQUE)"]
```
---
## 数据库 Schema
```sql
-- migrations/20260616000000_agent_tasks.sql
CREATE TABLE agent_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
task_id TEXT NOT NULL, -- LLM 生成的任务唯一标识
content TEXT NOT NULL DEFAULT '', -- 任务描述
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending', 'in_progress', 'completed')),
blocked_by TEXT NOT NULL DEFAULT '[]', -- JSON 数组: ["task_1", "task_2"]
owner TEXT NOT NULL DEFAULT '', -- 归属 agent (lead / sub_xxx / teammate)
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (session_id) REFERENCES agent_sessions(session_id) ON DELETE CASCADE
);
-- 索引
CREATE UNIQUE INDEX idx_agent_tasks_session_task ON agent_tasks(session_id, task_id);
CREATE INDEX idx_agent_tasks_session ON agent_tasks(session_id);
CREATE INDEX idx_agent_tasks_status ON agent_tasks(session_id, status);
```
关键设计点:
- **(session_id, task_id) 联合唯一索引**:支持 `INSERT ... ON CONFLICT DO UPDATE` 的 upsert 语义
- **`blocked_by` 存 JSON 数组**:如 `["1", "2"]` 表示依赖任务 1 和 2 必须先完成,形成 DAG
- **`owner` 字段**`migrations/20260618000000_agent_identity.sql` 引入):支持多 Agent 场景下的任务归属
---
## 任务状态机
```mermaid
stateDiagram-v2
[*] --> pending: 初始状态
pending --> pending: LLM 标记 completed<br/>(回退)
pending --> in_progress: claim_task()<br/>或 LLM 标记 in_progress
in_progress --> completed: LLM 标记 completed
completed --> [*]: 终点状态
note right of in_progress: 同一时刻只能有一个
```
约束规则:
1. **最多一个 `in_progress`**TodoWriteTool 在格式化输出时检测并警告(`tools/todo.rs:115-118`
2. **DAG 依赖**TaskBoard::can_start() 检查所有 blockedBy 依赖是否为 completed`task_board.rs:33-63`
3. **原子认领**claim_task() 使用乐观并发控制,`WHERE owner IS NULL` 保证不被重复认领
4. **应用层验证**persist_tasks 只做自引用检测,不做完整的循环依赖检测(文档明确标注)
---
## 核心组件
### TodoWriteTool (`tools/todo.rs`)
LLM 通过 function calling 调用的工具,声明参数:
```json
{
"todos": [
{"id": "1", "content": "搜索相关文献", "status": "completed"},
{"id": "2", "content": "分析论文方法", "status": "in_progress", "blockedBy": ["1"]},
{"id": "3", "content": "撰写综述", "status": "pending", "blockedBy": ["2"]}
]
}
```
**设计原则 — 工具层与持久化层分离**
- `TodoWriteTool::execute()` 只做格式化和约束校验,不写数据库
- `is_concurrency_safe()` 返回 `true`(纯格式化,无副作用)
- 实际的 SQLite 持久化由 `AgentRuntime::run_react_loop()` 在检测到 `todo_write` 调用后统一完成(`runtime/mod.rs:845-854`
### persist_tasks() (`tools/todo.rs`)
```rust
pub async fn persist_tasks(
db: &SqlitePool,
session_id: &str,
todos: &[serde_json::Value],
owner: &str, // "lead" / "sub_xxx" / teammate name
) -> anyhow::Result<()>
```
- 使用 `INSERT ... ON CONFLICT(session_id, task_id) DO UPDATE SET ...` 实现 upsert
- 自动跳过任务对自身的引用(`blockedBy` 中包含自身 ID 时忽略并记录 warn
- 作为公开 API 导出(`tools/mod.rs:49`),允许 teammate、外部调用者直接操作
### TaskBoard (`task_board.rs`)
共享任务看板,提供跨 Agent 的任务可见性:
| 方法 | 功能 | 并发策略 |
|:---|:---|:---|
| `can_start(session_id, task_id)` | 遍历 blockedBy检查所有依赖是否 completed | 只读,天然安全 |
| `claim_task(session_id, task_id, claimant)` | 原子认领status→in_progress, owner→claimant | `WHERE owner IS NULL OR owner = ''` |
| `list_available_tasks(limit)` | 列出所有可认领的 pending 任务,**跨 session** | 只读,附加 can_start 解析 |
认领 SQL 的原子性保证:
```sql
UPDATE agent_tasks SET owner = ?, status = 'in_progress'
WHERE session_id = ? AND task_id = ?
AND (owner IS NULL OR owner = '' OR status = 'pending')
```
`rows_affected() > 0` 表示认领成功,否则已被他人认领。
---
## ReAct 循环中的集成点
`AgentRuntime::run_react_loop()` 中有三个关键集成点:
### todo_write 检测与持久化 (`mod.rs:752-855`)
```rust
// 检测 todo_write 调用
let called_todo_write = tool_calls.iter().any(|tc| tc.function.name == "todo_write");
if called_todo_write {
steps_since_last_todo = 0; // 重置 nag 计数器
}
// 工具执行完成后持久化
if called_todo_write {
for prep in &prepared_calls {
if prep.tool_name == "todo_write" {
if let Some(todos) = prep.args.get("todos").and_then(|t| t.as_array()) {
let _ = persist_tasks(db, sid, &todos_vec, "lead").await;
}
}
}
}
```
### 进度催促 Nag Reminder (`mod.rs:589-596`)
每 3 步未调用 `todo_write`,自动向 LLM 注入提醒:
```
提醒:你已经连续多步未更新任务计划。建议调用 todo_write 工具复盘当前进度并规划后续步骤。
```
`nag_after_steps = 3``steps_since_last_todo` 在每次非 todo_write 调用后递增。
### 跨 Turn 任务恢复 (`context.rs:32-34`)
每个新 turn 开始时,`restore_tasks_from_db()` 从 agent_tasks 表查询当前 session 的所有任务,格式化后注入 LLM 上下文:
```
[当前任务状态]
以下是上次会话中持久化的任务计划,请基于最新状态继续工作:
✅ [1] 文献检索
✅ [2] 文献分析
🔄 [3] 撰写综述 (依赖: 1,2)
⏳ [4] 最终校对 (依赖: 3)
使用 todo_write 工具更新任务进度。
```
System prompt 中也包含提示(`system_prompt.rs:66`
> 任务状态会在每轮开始时从数据库恢复,请基于最新状态继续工作。
---
## 子代理与任务隔离
子代理 (`SubAgentRunner`) 与父代理在 Task 层面保持隔离:
- 子代理拥有独立的 ToolRegistry 和 ReAct 循环(`subagent.rs:248-254`
- 子代理消息标记 `agent_name = "sub_xxx"`,父代理加载历史时只加载 `agent_name = "lead"` 的消息(`session.rs:78`
- 子代理不直接操作父代理的 agent_tasks只返回文本摘要
- `agent_tasks.owner` 字段已为 teammate 场景预留
---
## 辅助机制:工具结果持久化 (`tools/persist.rs`)
与 Task 系统协同工作的工具输出持久化:
| 维度 | Task 系统 | Persist 系统 |
|:---|:---|:---|
| 回答的问题 | "要做什么" | "看到了什么" |
| 触发条件 | LLM 调用 todo_write | 工具输出超过 max_output_chars |
| 存储位置 | SQLite agent_tasks 表 | 磁盘文件 |
| 幂等保证 | ON CONFLICT DO UPDATE | 独占创建 (create_new) |
当工具输出超过 `AGENT_MAX_TOOL_OUTPUT_CHARS`(默认 4000完整内容写入 `{library_dir}/tool-results/{tool_call_id}.txt`,返回 `<persisted-output>` XML 占位符LLM 后续可通过 `read_file` 读取完整内容。
---
## 相关文件
| 文件 | 职责 |
|:---|:---|
| `src/agent/tools/todo.rs` | TodoWriteTool 定义 + persist_tasks() |
| `src/agent/task_board.rs` | TaskBoard — 跨 session 共享看板 |
| `src/agent/runtime/mod.rs` | ReAct 循环中的 nag/persist 集成 |
| `src/agent/runtime/context.rs` | restore_tasks_from_db — 跨 turn 恢复 |
| `src/agent/runtime/system_prompt.rs` | 系统提示词中的 Task 指引 |
| `src/agent/tools/persist.rs` | 工具输出磁盘持久化(辅助机制) |
| `src/agent/subagent.rs` | 子代理中的 Task 隔离 |
| `migrations/20260616000000_agent_tasks.sql` | agent_tasks 表 DDL |
| `migrations/20260618000000_agent_identity.sql` | owner 字段引入 |

View File

@ -0,0 +1,232 @@
# 多 Agent 团队 (`team/`)
基于**文件邮箱 (file inbox)** 的轻量级多 Agent 协作系统,参考 Claude Code Agent Teams 设计。
## 架构总览
```mermaid
graph LR
subgraph Team["src/agent/team/"]
direction TB
Mod["mod.rs (11 lines)<br/>模块声明与重导出"]
Config["config.rs (53 lines)<br/>TeamConfig, MemberConfig, MemberStatus"]
Inbox["inbox.rs (110 lines)<br/>文件邮箱 (append-only JSONL + drain)"]
Manager["manager.rs (205 lines)<br/>spawn/stop/send/broadcast/check_inbox/list"]
Teammate["teammate.rs (270 lines)<br/>队友 ReAct 循环<br/>(idle poll → react turn → report result)"]
end
Tools["src/agent/tools/team.rs (292 lines)<br/>4 个团队工具暴露给 Lead Agent"]
Manager --> Tools
Teammate --> Manager
Inbox --> Manager
Inbox --> Teammate
```
```mermaid
graph TD
subgraph Lead["Lead Agent (AgentRuntime)"]
LR["ReAct Loop"]
TR["ToolRegistry"]
end
subgraph TM["TeamManager"]
Config["TeamConfig<br/>session_id + members[]"]
Handles["HashMap&lt;name, TeamMemberHandle&gt;"]
end
subgraph InboxFS[".team/{session_id}/inbox/"]
L["lead.jsonl"]
S["searcher.jsonl"]
R["reader.jsonl"]
end
subgraph Teammates["Teammates (tokio::spawn)"]
T1["searcher<br/>ReAct loop<br/>max 5 steps"]
T2["reader<br/>ReAct loop<br/>max 5 steps"]
end
LR -->|"spawn_teammate"| TM
LR -->|"send_teammate_message"| S
LR -->|"check_team_inbox"| L
LR -->|"team_broadcast"| S
LR -->|"team_broadcast"| R
TM -->|"tokio::spawn"| T1
TM -->|"tokio::spawn"| T2
T1 -->|"append result"| L
T2 -->|"append result"| L
T1 -->|"drain inbox"| S
T2 -->|"drain inbox"| R
```
## 通信机制:文件邮箱
消息传递不通过 channel 或共享内存,而是通过 **append-only JSONL 文件**
```
.team/{session_id}/inbox/
├── lead.jsonl ← 队友将结果写入此处
├── searcher.jsonl ← Lead 将任务写入此处
└── reader.jsonl ← Lead 将任务写入此处
```
### 消息类型 (`TeamMessage`)
| 字段 | 类型 | 说明 |
|:---|:---|:---|
| `from` | `String` | 发送者名称 |
| `to` | `String` | 接收者名称 |
| `content` | `String` | 消息内容 |
| `msg_type` | `TeamMessageType` | `task` / `result` / `question` / `answer` / `status` |
| `timestamp` | `String` | ISO 8601 时间戳 (UTC) |
### 核心操作
- **`append_message(team_dir, agent_name, msg)`**:追加一行 JSON 到接收者文件,自动创建目录
- **`drain_inbox(team_dir, agent_name)`**:读取所有消息行 → 清空文件 → 返回 Vec破坏性读取
- **`has_pending(team_dir, agent_name)`**:检查文件是否存在且非空(用于快速轮询)
> **已知问题**`drain_inbox()` 在 `read_to_string``write("")` 之间存在 TOCTOU 竞态条件。若清空前有新消息追入,该消息会丢失。生产环境应使用 advisory file lock 或原子 rename 替代。
## 队友生命周期
```
┌──────────┐
│ SPAWNING │ ← Manager 创建句柄tokio::spawn 启动循环
└────┬─────┘
┌──────────┐ 有新消息到达 ┌──────────┐
│ IDLE │ ─────────────────→ │ WORKING │
│ │ ←──────────────── │ │
│ 5s×12 │ 任务完成/超时 │ ReAct │
│ 轮询 │ │ max 5步 │
└────┬─────┘ └──────────┘
│ 收到取消信号
┌──────────┐
│ SHUTDOWN │ ← 发送告别 Status 消息给 Lead退出循环
└──────────┘
```
### IDLE 阶段细节 (`teammate.rs:68-92`)
1. 设置状态为 `Idle`
2. `drain_inbox()` 检查是否有待处理消息
3. 若为空:以 **5 秒间隔轮询 `has_pending()`**,最长 60 秒12 次 × 5 秒)
4. 在轮询中,若 `cancelled` 标志被设置或 `has_pending()` 返回 true则提前退出
5. 超时无消息 → 回到步骤 2
6. 有新消息 → 进入 WORKING
### WORKING 阶段细节 (`teammate.rs:103-152`)
1. 设置状态为 `Working`
2. 将收件箱消息作为 `user` 角色消息注入到队友的对话历史中
3. 调用 `run_teammate_react_turn()`:最多 **5 步**的简化 ReAct 循环
4. 若返回结果 → 封装为 `TeamMessageType::Result` 发送到 `lead` 的收件箱
5. 若返回 `None`(错误/超时)→ 静默回到 IDLE**不通知 Lead 出错**
## 队友 ReAct 循环 vs 主 Agent 循环
| 特性 | 主 Agent (Lead) | 队友 (Teammate) |
|:---|:---|:---|
| 最大步数 | 8可配置 | 5硬编码 `min(config.max_steps, 5)` |
| 流式输出 | SSE 实时推送到前端 | 无 SSE静默消费 |
| 工具注册表 | 完整(含 subagent、team 工具) | 排除了 subagent 和 team 工具 |
| 上下文压缩 | 四层压缩 + CircuitBreaker | 简单 token 估算 + `compact::compress_context` |
| Hooks | PreToolUse / PostToolUse / Stop | 无 |
| 权限检查 | PermissionChecker | 无 |
| 数据库持久化 | agent_messages 表 | 无 |
| 后台通知 | BgNotificationQueue | 有 Queue 但未使用 |
| Skills | 两层加载 (list + load_skill) | 无skill_registry 仅用于工具构造 |
队友的工具注册表构造 (`teammate.rs:40-41`)
```rust
let tool_registry =
ToolRegistry::new_with_queue(Some(queue.clone()), app_state.skill_registry.clone());
```
`ToolRegistry::new_with_queue()` 不注册 subagent 和 team 工具,防止无限委托链。
## TeamManager 设计
### 锁顺序约定 (CRITICAL)
存在两个 `tokio::sync::Mutex` 的嵌套获取:
1. `TeamManager.handles`(外层)
2. `TeamMemberHandle.status`(内层)
所有代码必须遵守 **handles → status** 的顺序,否则死锁。`list_members()` 作为正确顺序的参考实现。
### 主要操作
| 操作 | 说明 | 锁行为 |
|:---|:---|:---|
| `spawn(name, role)` | tokio::spawn 队友循环,注册句柄 | 获取 handles 锁 |
| `stop(name)` | 设置 cancelled 标志 | 获取 handles 锁 |
| `stop_all()` | 停止所有队友 | 获取 handles 锁 |
| `send_message(from, to, content, type)` | 追加消息到接收者收件箱文件 | 无锁(纯文件 I/O |
| `broadcast(from, content)` | 向 config.members 中所有非发送者成员追加消息 | 无锁(纯文件 I/O |
| `check_inbox(agent_name)` | drain_inbox 并返回消息 | 无锁(纯文件 I/O |
| `list_members()` | 获取所有队友的 (name, role, status) | handles → 各 status正确顺序 |
### 队友系统提示词 (`manager.rs:70-93`)
队友收到的是天体物理学研究助手角色设定 + 工具使用指引 + 通信协议说明。系统提示词始终通过 `build_teammate_system_prompt(role)` 生成,**不使用** `MemberConfig.system_prompt` 字段。
## 4 个团队工具
工具定义在 `src/agent/tools/team.rs`,均设置 `InterruptBehavior::Block`(中断时先完成副作用再停止):
| 工具名 | 参数 | 功能 | 接收者 |
|:---|:---|:---|:---|
| `spawn_teammate` | `name`, `role` | 启动一个后台队友 | — |
| `send_teammate_message` | `to`, `content` | 向指定队友发送消息 | 队友收件箱 |
| `team_broadcast` | `content` | 向所有队友广播消息 | 所有队友收件箱 |
| `check_team_inbox` | `agent_name?` (默认 "lead") | 读取并清空收件箱 | Lead 收件箱 |
典型协作流程:
```
1. Lead 调用 spawn_teammate(name="searcher", role="ADS文献搜索专家")
2. Lead 调用 send_teammate_message(to="searcher", content="搜索2024年暗物质间接探测综述")
3. Lead 继续自己的 ReAct 循环(可以同时发起另一个 spawn_teammate
4. 队友在后台执行 ReAct 循环最多5步完成后将结果写入 lead.jsonl
5. Lead 调用 check_team_inbox() 读取队友结果
6. Lead 综合队友结果,给出最终回答
```
## 与子代理 (`subagent`) 的对比
代码库中存在**两种委托机制**,适用不同场景:
| | 子代理 (`subagent`) | 团队 (`team`) |
|:---|:---|:---|
| 执行模式 | 同步Lead 等待完成 | 异步后台运行Lead 主动轮询 |
| 并发度 | 每次调用 1 个 | 可同时运行多个队友 |
| 上下文 | 任务完成后丢弃 | 跨任务累积(直到压缩) |
| 通信方式 | 工具调用 → 返回摘要 | 文件邮箱:写入/轮询 |
| Hooks | PreToolUse / PostToolUse / SubagentStart / SubagentStop | 无 |
| 权限检查 | PermissionChecker | 无 |
| 进度透传 | SSE 事件到父代理 | 无 |
| DB 持久化 | agent_messages 表 | 无 |
| 适用场景 | "去完成这个子任务,给我摘要" | "留在后台,持续处理我分配的任务" |
## 当前状态:未接入
> **重要**`TeamManager` 和 4 个团队工具已完整实现,但当前**未接入 AgentRuntime**。
>
> `AgentRuntime::new()` 调用 `ToolRegistry::new_with_queue()`,而非 `ToolRegistry::new_with_team()`。团队工具通过独立的 `add_team_tools()` 函数接入,但该函数在当前代码路径中从未被调用。
>
> 实际可用的委托功能由 `subagent` 工具提供(通过 `SubAgentTool` 已接入)。
>
> 接入方法:在 `AgentRuntime::new()``with_config()` 中,创建 `TeamManager` 实例并用 `ToolRegistry::new_with_team()` 替代 `new_with_queue()`
## 改进建议
1. **修复 drain_inbox 竞态条件**:使用 advisory file lock 或原子 rename
2. **队友错误传播**`run_teammate_react_turn()` 返回 `None` 时应通知 Lead
3. **消息关联 ID**:添加 `correlation_id` 字段,支持请求-响应匹配
4. **降低锁持有时间**:将 `send_message` / `broadcast` 的文件 I/O 移出锁临界区(当前已是无锁,但工具层仍持有 `team_manager` 锁)
5. **队友名称去重**spawn 同名队友前检查是否已有活跃队友
6. **会话级清理**Agent 会话结束时调用 `stop_all()` 清理队友和收件箱文件

View File

@ -0,0 +1,425 @@
# 工具系统 (Tool System)
Agent 工具系统是 ReAct 循环中 **Action → Observation** 环节的执行引擎。每个工具遵循 `AgentTool` trait 向 LLM 暴露 JSON Schema 参数定义,并在 `execute` 中调用服务层完成实际业务操作。
---
## 三层架构
```mermaid
graph TB
subgraph Layer1["调度层 — AgentRuntime"]
direction LR
RT["src/agent/runtime/mod.rs<br/>ReAct 循环<br/>调度 LLM tool_calls → 工具执行 → 结果注入上下文"]
end
subgraph Layer2["执行协调层 — Executor"]
direction LR
EX["src/agent/runtime/executor.rs<br/>验证 → PreToolUse hooks → 并行调度 → PostToolUse"]
SX["src/agent/runtime/streaming_executor.rs (流式变体)<br/>流式 tool_use 到达时立即调度 + Sibling Abort"]
end
subgraph Layer3["业务逻辑层 — AgentTool Trait + 工具实现"]
direction LR
Tools["src/agent/tools/<br/>每个工具独立子模块,实现 AgentTool trait"]
end
Layer1 --> Layer2 --> Layer3
```
---
## AgentTool Trait
```mermaid
classDiagram
class AgentTool {
<<interface>>
+name() &str
+description() &str
+parameters() Value
+execute(args, ctx) ToolOutput
+interrupt_behavior() InterruptBehavior
+is_concurrency_safe(args) bool
+check_permissions(args) PermissionRule[]
+causes_sibling_abort() bool
+execute_with_progress(args, ctx, tx) ToolOutput
}
class InterruptBehavior {
<<enumeration>>
Cancel — 可安全中断(只读工具默认)
Block — 忽略中断信号直到完成(写入工具)
}
class PermissionRule {
<<enumeration>>
Deny — 不可覆盖的拒绝
Allow — 显式允许
Ask — 需用户确认
}
class ToolContext {
+Arc~AppState~ app_state
+String session_id
+bool silent
+Arc~Mutex~FileStateCache~~ read_file_state
+Option~UnboundedSender~ sse_tx
+bool enable_thinking
}
class ToolOutput {
+String content — 给 LLM 的截断文本
+bool is_error — 是否为错误
+Value metadata — 给前端的结构化数据
}
class ToolRegistry {
-HashMap~String, Box~AgentTool~~ tools
-Vec~String~ ordered_names
+empty() Self
+new(skill_registry) Self
+new_with_queue(queue, skill_registry) Self
+new_with_team(queue, team_manager, skill_registry) Self
+add_tool(tool)
+replace_tool(tool)
+get(name) Option~&dyn AgentTool~
+definitions() Vec~ToolDefinition~
}
AgentTool --> InterruptBehavior
AgentTool --> PermissionRule
AgentTool --> ToolContext
AgentTool --> ToolOutput
```
### 方法详解
| 方法 | 返回类型 | 默认值 | 说明 |
|:---|:---|:---|:---|
| `name()` | `&str` | **(必须)** | 工具名称,全小写下划线风格,与 LLM function calling `name` 一致 |
| `description()` | `&str` | **(必须)** | 告知 LLM 何时应调用该工具;作为 base description 注入 system prompt |
| `parameters()` | `Value` | **(必须)** | JSON Schema 格式的参数定义,直接序列化为 LLM tool definition |
| `execute(args, ctx)` | `ToolOutput` | **(必须)** | 核心执行逻辑,接收 LLM 传入的参数 + `ToolContext` 全局状态 |
| `interrupt_behavior()` | `InterruptBehavior` | `Cancel` | 控制用户取消时的行为:`Cancel`—响应取消信号立即返回错误;`Block`—忽略取消直到执行完成(保护有副作用的写入操作) |
| `is_concurrency_safe(args)` | `bool` | `false` | 该工具是否可以与其他工具并发执行。**只读工具**`read_file`、`search_papers`、`rag_search` 等)覆写为 `true`;写入工具保持默认 `false` |
| `check_permissions()` | `Vec<PermissionRule>` | `[]` | 工具自定义权限规则:`Deny{tool, reason}` / `Allow{tool}` / `Ask{tool, message}`。与 `PermissionChecker` 管道协同工作 |
| `causes_sibling_abort()` | `bool` | `false` | 该工具错误时是否中止兄弟并行执行。用于 `download_paper`、`parse_paper` 等关键工具 |
| `execute_with_progress(args, ctx, tx)` | `ToolOutput` | 委托 `execute()` | 长时间操作可覆写,通过 `progress_tx` 发送进度更新到前端 |
---
## 工具清单23 个)
### 文件系统工具6 个)— `filesystem/`
| 工具 | 并发安全 | 中断行为 | 功能 |
|:---|:---|:---|:---|
| `read_file` | ✅ | Cancel | 读取文件,带 `FileStateCache` mtime 去重;相同 offset/limit 且文件未修改时返回 `FILE_UNCHANGED_STUB` 占位符 |
| `grep_files` | ✅ | Cancel | 正则搜索文件内容 |
| `glob_files` | ✅ | Cancel | 通配符匹配文件路径 |
| `run_bash` | ❌ | Cancel | Shell 命令执行,有独立超时和输出截断 |
| `file_write` | ❌ | Block | 创建/覆盖文件 |
| `file_edit` | ❌ | Block | 精确字符串替换(基于 `old_string` 匹配) |
**安全约束** (`filesystem/security.rs`)
- **路径沙箱**:只允许 `library_dir`、`skills_dir`、项目根目录三个根路径
- **路径穿越防护**:字符串级拒绝含 `..``~` 的路径
- `canonicalize()` 解析符号链接后再做前缀匹配,防止 symlink 绕过
### 天文科研工具8 个)— `astro/`
| 工具 | 并发安全 | 功能 |
|:---|:---|:---|
| `search_papers` | ✅ | ADS + arXiv 跨库联合检索,自动合并去重,关联本地馆藏状态与引用关系 |
| `get_paper_metadata` | ✅ | 获取单篇文献完整元数据(作者、期刊、关键词、摘要、引用数、下载/解析状态) |
| `download_paper` | ❌ | PDF 下载,支持 Obscura 反爬绕过和多通道 fallback |
| `parse_paper` | ❌ | PDF/HTML → Markdown 解析MinerU/ar5iv/IOP/A&A 等多引擎) |
| `get_paper_content` | ✅ | 读取已解析的论文全文 Markdown |
| `rag_search` | ✅ | 向量相似度检索 + LLM 答案生成(基于 `sqlite-vec` |
| `query_target` | ✅ | CDS Sesame 天体目标查询IAU 名称解析 + 坐标/类型) |
| `save_note` | ❌ | 高亮批注持久化到数据库 |
### Agent 自管理工具5 个)
| 工具 | 并发安全 | 中断行为 | 功能 |
|:---|:---|:---|:---|
| `todo_write` | ✅ | Cancel | 任务规划,支持 `pending/in_progress/completed` 状态 + `blockedBy` DAG 依赖;验证只能有一个 in_progress 任务 |
| `compress_context` | ✅ | Cancel | 设置手动压缩标志位,下一轮 LLM 调用前由 Runtime 执行压缩(纯幂等操作) |
| `load_skill` | ✅ | Cancel | 按需加载 SKILL.md 技能文件。支持 **inline** 模式(直接返回内容)和 **fork** 模式(启动子代理按技能指引执行任务) |
| `save_memory` | ❌ | Block | 跨会话记忆持久化。写入时门控:质量检查(过短/模糊/瞬时/代码模式)+ Jaccard 70% 去重 |
| `ask_user` | N/A | Block | 暂停 ReAct 循环向用户提问。oneshot 通道机制:创建问题 → SSE 推送前端 → 阻塞等待 → 5 分钟超时。子代理中不可用silent 模式) |
### 高级编排工具4 个)
| 工具 | 并发安全 | 中断行为 | 功能 |
|:---|:---|:---|:---|
| `subagent` | ❌ | Block | 上下文隔离的子代理。子代理拥有完整工具访问权,但仅最终摘要返回父代理。支持自定义 `max_steps`默认5最大10 |
| `bg_task_run` | ❌ | Block | 后台异步执行慢速操作(仅支持 `download_paper`、`parse_paper`)。结果通过 `BgNotificationQueue` 在下一轮前注入 |
| `bg_task_check` | ✅ | Cancel | 查询后台任务状态(可指定 task_id 或列出全部) |
| *团队 4 工具* | ❌ | Block | `spawn_teammate` / `send_teammate_message` / `team_broadcast` / `check_team_inbox` — 基于文件收件箱的多 Agent 协作 |
---
## 执行流水线
从 LLM 返回 `tool_calls` 到结果注入 `messages[]` 的完整数据流:
```
LLM stream → tool_calls[]
┌─ validate_and_prepare() ─────────────────────────────────────┐
│ 1. 死循环检测 (DuplicateDetector): │
│ 连续相同 (tool_name, args) ≥ threshold → 注入错误消息 │
│ 2. JSON 参数解析: 失败则注入 tool_result 错误 │
│ 3. 修复空 tool_call_id (UUID 前缀) │
└──────────────────────────────────────────────────────────────┘
▼ PreparedCall[]
├─ 发送 ToolCall SSE 事件 → 前端实时渲染
├─ PreToolUse hooks (顺序执行)
│ ├─ 可拦截 (Block) / 修改参数 (MutateInput) / 注入上下文 (AppendContext)
│ └─ mutated_args + additional_contexts 收集
┌─ execute_parallel() ─────────────────────────────────────────┐
│ FuturesUnordered 并发调度: │
│ 每个 PreparedCall → tokio::spawn(async { │
│ tokio::select! { │
│ timeout(tool_timeout_secs) → 执行工具 │
│ cancel_fut (每 250ms 轮询) → 返回错误 │
│ } │
│ }) │
│ │
│ 中断处理: │
│ InterruptBehavior::Block → 忽略 cancel_fut等完成 │
│ InterruptBehavior::Cancel → 响应取消,注入错误 │
│ │
│ 渐进式结果处理 (while exec_futs.next()): │
│ 完成即处理,快工具不因慢工具阻塞 │
└──────────────────────────────────────────────────────────────┘
▼ (每个工具完成后逐个处理)
├─ 发送 ToolResult SSE 事件 (tool_call_id 精确匹配)
├─ maybe_persist_tool_result()
│ ├─ content ≤ max_chars → 直接传递
│ └─ content > max_chars → 写入 disk + 返回 <persisted-output> stub
│ 幂等写入 (create_new),同 tool_call_id 不重复写
├─ PostToolUse hooks → 审计日志 / 指标采集 / 输出修改
├─ 持久化到 agent_messages 表 (fire-and-forget)
└─ 推入 messages[] → 下一轮 LLM 调用
```
### 并发模型细节
```rust
// executor.rs: FuturesUnordered 中的每个 future
Box::pin(async move {
let interrupt_behavior = tool.interrupt_behavior();
let is_blocking = interrupt_behavior == InterruptBehavior::Block;
tokio::select! {
res = tokio::time::timeout(timeout_dur, tool_fut) => {
// 正常完成或超时
}
_ = cancel_fut => {
// 仅当 !is_blocking 时此分支可达
// Block 工具的 cancel_fut loop 不 break
}
}
})
```
关键特性:
- 所有工具放入同一个 `FuturesUnordered`,不区分串行/并行批次
- `is_concurrency_safe` 声明为语义标记(引导 LLM 并发调用),执行时全部并发
- 实际串行化依赖工具内部的 mutex/文件锁
- `InterruptBehavior::Block` 保护写入操作不被用户取消打断
---
## ToolRegistry 注册流程
```mermaid
sequenceDiagram
participant RT as AgentRuntime::new()
participant TR as ToolRegistry
participant Tools as 工具实例
RT->>TR: new_with_queue(queue, skill_registry)
TR->>TR: add_base_tools()
Note over TR: 注册 19 个基础工具
TR->>Tools: read_file, grep_files, glob_files, run_bash
TR->>Tools: file_write, file_edit
TR->>Tools: search_papers, get_paper_metadata
TR->>Tools: download_paper, parse_paper, get_paper_content
TR->>Tools: rag_search, query_target, save_note
TR->>Tools: todo_write, compress_context, ask_user
TR->>Tools: load_skill(skill_registry)
TR->>Tools: subagent (默认实例)
opt queue.is_some()
TR->>TR: add_background_tools(queue)
TR->>Tools: bg_task_run + bg_task_check
end
RT->>TR: add_tool(SaveMemoryTool)
Note over TR: MemoryManager 需要共享状态,动态注入
RT->>TR: replace_tool(SubAgentTool::new_with_hooks(...))
Note over TR: 替换为带 PermissionChecker + HookRegistry 的增强版
TR->>TR: definitions()
Note over TR: HashMap 值收集 → 按 name 字母序排序
Note over TR: 排序保证跨调用稳定性 → 提升 prompt cache 命中率
```
### 注册表工厂方法
| 方法 | 工具数 | 用途 |
|:---|:---|:---|
| `ToolRegistry::empty()` | 0 | 受限场景(如记忆提取子代理只需只读 + save_memory |
| `ToolRegistry::new(skill_registry)` | 19 | 标准科研 Agent |
| `ToolRegistry::new_with_queue(queue, skill_registry)` | 21 | 带后台任务支持 |
| `ToolRegistry::new_with_team(queue, team_manager, skill_registry)` | 25 | 带团队协作 |
---
## 安全模型
### 多层权限管道
```
工具调用
├─ 1. AgentTool::check_permissions() ← 工具自身声明的权限规则
├─ 2. PermissionChecker::check() ← 集中式规则链
│ ├─ Deny rule (最高优先级,不可覆盖)
│ ├─ Allow rule (显式允许)
│ ├─ Ask rule (需用户确认)
│ └─ Default: Allowed
│ └─ 支持通配符 "*" 匹配所有工具
└─ 3. PreToolUse hooks ← Hook 可最终拦截 (Block)
```
### 文件系统安全
```rust
// 路径穿越检测
fn has_path_traversal(path_str: &str) -> bool {
path_str.contains("..") || path_str.contains('~')
}
// 路径沙箱
fn is_path_allowed(path: &Path, ctx: &ToolContext) -> bool {
let canonical = path.canonicalize()?; // 解析所有符号链接
// 检查是否在 library_dir / skills_dir / current_dir 下
allowed_roots.iter().any(|root| canonical.starts_with(root))
}
```
### SQL 注入防护
所有数据库操作使用参数化查询,无字符串拼接:
```rust
sqlx::query("INSERT INTO agent_messages (...) VALUES (?, ?, ...)")
.bind(value1)
.bind(value2)
// ...
```
---
## 关键集成点
### Hook 系统
| Hook | 触发时机 | 在工具系统中的用途 |
|:---|:---|:---|
| `PreToolUse` | 工具执行前 | 拦截/修改参数(`MutateInput`)、注入附加上下文(`AppendContext`)、权限确认(`PermissionRequired` |
| `PostToolUse` | 工具执行后 | 审计日志写入 `agent_audit_log`、指标采集(`MetricsData`)、输出修改(`MutateOutput` |
### SSE 事件流
```
ToolCall { id, name, arguments, step } ← 工具开始执行
(如有进度) ToolProgress { id, progress } ← execute_with_progress 发送
ToolResult { tool_call_id, name, output, ← 执行完成
is_error, metadata, step }
```
前端通过 `tool_call_id` 精确匹配 ToolCall/ToolResult实现 Timeline 渲染。
### 上下文压缩
三层压缩与工具系统的交互:
| 层级 | 触发方式 | 实现 |
|:---|:---|:---|
| micro_compact | 自动token 超限前) | 占位符替换,不涉及工具 |
| auto_compact | 自动(`estimated_tokens > soft_limit` | Runtime 检测 → `snapshot_compress_restore()` → 文件缓存快照 → LLM 摘要压缩 → 恢复最近文件 |
| manual_compact | `compress_context` 工具 | 设置 `pending_manual_compress` 标志位,下一轮 LLM 调用前执行。不受熔断器限制 |
压缩熔断器 (`CompactionCircuitBreaker`):连续失败多次后打开,阻止进一步压缩以防止无限循环。
### 工具输出持久化
```
execute() → ToolOutput { content }
├─ content.len() ≤ max_output_chars (默认 4000)
│ └─ 直接返回给 LLM
└─ content.len() > max_output_chars
└─ maybe_persist_tool_result()
├─ 写入 {library_dir}/tool-results/{tool_call_id}.txt
├─ 使用 create_new 保证幂等
└─ 返回 <persisted-output> stub含 path + preview
LLM 可通过 read_file 读取完整内容
```
---
## 目录结构
```
src/agent/tools/
├── mod.rs # AgentTool trait + ToolRegistry + ToolContext + ToolOutput + 辅助函数
├── filesystem/ # 文件 I/O (6 工具)
│ ├── mod.rs # re-export
│ ├── read.rs # read_file — 带 FileStateCache mtime 去重
│ ├── grep.rs # grep_files — 正则搜索
│ ├── glob.rs # glob_files — 通配符匹配
│ ├── bash.rs # run_bash — Shell 命令执行(超时+截断)
│ ├── write.rs # file_write — 文件创建/覆盖
│ ├── edit.rs # file_edit — 精确字符串替换
│ └── security.rs # 共享安全验证(路径沙箱 + 穿越检测)
├── astro/ # 天文学工具 (8 工具)
│ ├── mod.rs # re-export
│ ├── search.rs # search_papers + get_paper_metadata
│ ├── paper.rs # download_paper + parse_paper + get_paper_content
│ ├── rag.rs # rag_search — 向量检索 + LLM 问答
│ ├── target.rs # query_target — CDS Sesame 查询
│ └── note.rs # save_note — 高亮批注
├── todo.rs # todo_write — 任务规划DAG 依赖,持久化到 agent_tasks
├── compress.rs # compress_context — 手动上下文压缩标志位
├── skill.rs # load_skill — inline/fork 双模式技能加载
├── subagent.rs # subagent — 上下文隔离的子代理委派
├── ask_user.rs # ask_user — oneshot 通道用户交互
├── background.rs # bg_task_run + bg_task_check — 后台异步任务
├── team.rs # spawn/send/broadcast/check_inbox — 多 Agent 协作
├── memory.rs # save_memory — 带质量门控的跨会话记忆
└── persist.rs # maybe_persist_tool_result — 大输出磁盘持久化
```

View File

@ -0,0 +1,56 @@
# AstroResearch Architecture / 架构设计
AstroResearch 是一个集成了天文学文献检索、多通道下载(含防爬绕过与手动上传)、下载错误诊断、结构化解析、中英学术对比翻译、引文星系图谱以及馆藏健康度诊断的天文科研辅助系统。
## 3. 核心模块说明
### 3.1 API 层 (`src/api/`)
| 模块文件 | 职责 |
|:---|:---|
| **[mod.rs](../src/api/mod.rs)** | 定义全局共享状态 `AppState`(含 `active_bibcode` 追踪)和统一文献格式 `StandardPaper`(含 `pdf_error` / `html_error` 诊断字段),通过 `pub mod handlers` 保持向后兼容命名空间。 |
| **[helpers.rs](../src/api/helpers.rs)** | 共享工具函数:`convert_ads_doc_to_standard`、`convert_arxiv_to_standard`、`save_paper_to_db`、`get_paper_from_db`、`check_paper_paths_in_db`。负责数据库 CRUD 和 `error:` 前缀诊断信息的读取与解析。 |
| **[papers.rs](../src/api/papers.rs)** | 文献相关核心处理器:统一检索 (`search_papers`)、下载 (`download_paper`)、**手动上传 (`upload_paper_file`)**、**无资源标记 (`mark_no_resource`)**、解析 (`parse_paper`)、翻译 (`translate_paper`)、引文拓扑 (`get_citation_network`)、文献详情 (`get_paper_detail`)、馆藏列表 (`get_library`)、BibTeX 导出 (`export_citations`)、**活跃文献追踪 (`get/set_active_bibcode`)**。 |
| **[notes.rs](../src/api/notes.rs)** | 笔记 CRUD 处理器:创建 (`create_note`)、查询 (`get_notes`)、删除 (`delete_note`)。 |
| **[sync.rs](../src/api/sync.rs)** | 批量同步控制处理器:元数据同步启动/状态/计数、资源同步启动/停止/状态、检索条件管理。 |
### 3.2 服务层 (`src/services/`)
| 模块文件 | 职责 |
|:---|:---|
| **[batch/mod.rs](../src/services/batch/mod.rs)** | 批量同步引擎公共导出模块。 |
| **[batch/meta.rs](../src/services/batch/meta.rs)** | 元数据大批量采集引擎 (`MetaSync`):分页检索 ADS/arXiv 并增量入库。 |
| **[batch/asset.rs](../src/services/batch/asset.rs)** | 物理资源批量处理引擎 (`AssetSync`):后台异步执行下载/解析/翻译流水线,记录 `download_failed` / `parse_failed` 计数,保留最新 100 条日志。 |
| **[download.rs](../src/services/download.rs)** | 多通道下载器浏览器头伪装与请求延迟控制、ADS Link Gateway 重定向追踪与 `validate.perfdrive.com` 防护解码绕过、官方 `arxiv.org/html` 优先及 `ar5iv` 兜底、**下载失败时以 `error:` 前缀记录诊断信息至数据库**。 |
| **[parser.rs](../src/services/parser.rs)** | HTML 语法树向 GFM Markdown 逆向转换,使用占位符保护 LaTeX 公式;统一图表链接;集成 MinerU PDF 解析。 |
| **[translation.rs](../src/services/translation.rs)** | 基于本地天文双语词典的 Trie 树最长匹配分词,注入 Glossary 系统提示词让 LLM 实现学术级精细翻译。 |
| **[query_parser.rs](../src/services/query_parser.rs)** | 高级检索语法解析器将前端组合条件AND/OR/NOT + 字段限定)转换为 ADS API 查询语法。 |
| **[logging.rs](../src/services/logging.rs)** | 全局日志记录系统,基于 `tracing-subscriber` 实现控制台美化日志输出与基于时间的每日滚动日志文件写出,使用上海时区 (+08:00) 格式化时间。 |
### 3.3 客户端层 (`src/clients/`)
| 模块文件 | 职责 |
|:---|:---|
| **[ads.rs](../src/clients/ads.rs)** | NASA ADS API 客户端文献检索、元数据获取、BibTeX 导出。 |
| **[arxiv.rs](../src/clients/arxiv.rs)** | arXiv Atom XML API 客户端:解析 XML Feed 提取文献元数据。 |
| **[qiniu.rs](../src/clients/qiniu.rs)** | 七牛云对象存储客户端PDF 插图上传与 CDN 外链生成。 |
### 3.4 独立工具 (`src/bin/`)
| 文件 | 职责 |
|:---|:---|
| **[health_check.rs](../src/bin/health_check.rs)** | 馆藏健康度诊断与修复工具:检测损坏文件、丢失文件、`error:` 报错记录和孤立 Markdown`--fix` 模式自动清理并重置数据库状态。 |
### 3.5 前端核心组件 (`dashboard/src/`)
| 组件文件 | 职责 |
|:---|:---|
| **[App.tsx](../dashboard/src/App.tsx)** | 全局状态管理Tab 持久化、手动上传处理、无资源标记、活跃文献追踪、详情弹窗(含错误诊断和上传区)。 |
| **[components/CustomSelect.tsx](../dashboard/src/components/CustomSelect.tsx)** | 可复用下拉选择组件:统一视觉风格、点击外部关闭、选中高亮。 |
| **[components/CitationGalaxyCanvas.tsx](../dashboard/src/components/CitationGalaxyCanvas.tsx)** | 基于 HTML5 Canvas 的自研力导向引文星系图谱引擎:节点排斥力、中心引力、拖拽阻尼、双击多层级衍生。 |
| **[features/library/LibraryPanel.tsx](../dashboard/src/features/library/LibraryPanel.tsx)** | 馆藏管理面板:同步反馈、下载失败/无资源状态筛选、文献类型筛选13 种)、状态优先排序。 |
| **[features/search/SearchPanel.tsx](../dashboard/src/features/search/SearchPanel.tsx)** | 跨源检索面板高级组合条件、排序分页、下载失败状态提示、文献类型徽章16 种)。 |
| **[features/sync/SyncPanel.tsx](../dashboard/src/features/sync/SyncPanel.tsx)** | 批量同步控制台:乐观 UI 更新、容器内日志自动滚动。 |
---

View File

@ -0,0 +1,183 @@
# AstroResearch Architecture / 架构设计
AstroResearch 是一个集成了天文学文献检索、多通道下载(含防爬绕过与手动上传)、下载错误诊断、结构化解析、中英学术对比翻译、引文星系图谱以及馆藏健康度诊断的天文科研辅助系统。
## 2. 核心工作流 (Core Workflows)
### 2.1 文献下载流程 (Download Flow)
本流程实现了文献的多通道流式下载,支持多级回退、错误诊断记录以及安全反爬防线绕过:
```mermaid
sequenceDiagram
participant U as 用户 (React 前端)
participant H as 处理器 (papers.rs)
participant D as 下载器 (download.rs)
participant DB as 本地数据库 (SQLite)
U->>H: 1. 发起下载请求 (POST /api/download, 含 bibcode, force)
H->>DB: 2. 查询文献元数据 (获取 arxiv_id, doi 等)
alt force == true
H->>DB: 3. 重置本地下载路径字段为 NULL
end
H->>D: 4. 调度下载器执行物理拉取
alt 文献含有 arxiv_id (通道 AarXiv 直连优先)
D->>D: 5a. 去除版本号 (strip_arxiv_version, v2 -> 无版本)
D->>D: 5b. 随机延时 (maybe_delay: 500-2000ms) 并伪装 UA
D->>D: 5c. 下载 PDF 并校验文件头 (%PDF + %%EOF)
D->>D: 5d. 优先请求官方 HTML (arxiv.org/html/)
note over D: 若官方 HTML 返回 404/错误
D->>D: 5e. 自动降级回退请求 ar5iv HTML (ar5iv.labs.arxiv.org)
D->>D: 5f. 校验 HTML 内容 (detect_anti_bot 检测反爬)
else 无 arxiv_id (通道 BADS 路由回退)
D->>D: 6a. 跟踪 ADS Link Gateway 重定向路由
note over D: 若遇到 validate.perfdrive.com 拦截
D->>D: 6b. 自动解析并解码 ssc 参数提取直链
note over D: 若指向 IOPscience / Springer
D->>D: 6c. IOP 专属策略:预热主页写入 Cookie带 Referer 下载 PDF
D->>D: 6d. Springer 专属策略:使用 Chrome 头下载 HTML 页
note over D: 若网关均失败且存在 DOI
D->>D: 6e. CrossRef 兜底:请求 CrossRef API 获取 PDF URL 并直连下载
end
alt 下载成功
D-->>H: 7a. 返回下载好的本地物理 PDF & HTML 路径
H->>DB: 8a. 更新 pdf_path & html_path 记录
H-->>U: 9a. 返回最新文献状态 (is_downloaded: true)
else 下载失败
D-->>H: 7b. 返回失败原因
H->>DB: 8b. 以 error: 前缀记录诊断信息
H-->>U: 9b. 返回文献状态 (pdf_error / html_error 已填充)
end
```
#### 详细下载说明:
1. **指令接收与校验**:后端 `download_paper` 接口在 `force` 参数为 `true` 时,会强行擦除数据库中已下载的文件路径,启动无缓存的物理文件重新拉取。
2. **下载反爬伪装**:下载器 `Downloader` 请求时采用动态生成的 Firefox/Chrome 轮换 User-Agent并在每次 HTTP 访问前强制加入随机休眠机制500ms - 2000ms模拟人类自然阅读行为。
3. **内容完整性校验**
- 对 PDF 严格校验前四个字节(必须是 `%PDF`)以及尾部检索(必须包含 `%%EOF` 终止符),排查登录墙、错误页伪装成 PDF 导致下载坏文件的问题。
- 对 HTML 文本利用 `detect_anti_bot` 流水线过滤 "cloudflare"、"captcha"、"robot check" 等拦截特征。
4. **错误诊断记录**:下载失败时,系统会将具体的失败原因(如 "Cloudflare 拦截"、"404 Not Found" 等)以 `error:` 前缀存入数据库的 `pdf_path` / `html_path` 字段。前端通过 `pdf_error` / `html_error` 字段读取并向用户展示。
---
### 2.2 手动上传流程 (Upload Flow)
当自动下载受防爬或人机验证阻碍时,用户可手动上传文献文件:
```mermaid
sequenceDiagram
participant U as 用户 (React 前端 / 浏览器书签)
participant H as 处理器 (papers.rs)
participant DB as 本地数据库 (SQLite)
participant FS as 本地文件系统
U->>H: 1. 上传文件 (POST /api/upload, Multipart: bibcode + type + file)
H->>H: 2. 解析 Multipart 字段
alt bibcode 未直接匹配数据库
H->>DB: 3a. 尝试通过 DOI 匹配
H->>DB: 3b. 尝试通过 arXiv ID 匹配(自动去除版本号)
end
H->>H: 4. 校验文件格式 (PDF 校验 %PDF 文件头)
H->>FS: 5. 写入物理文件 (library/PDF/ 或 library/HTML/)
H->>DB: 6. 更新 pdf_path / html_path清除 error: 诊断记录
H-->>U: 7. 返回更新后的文献元数据 (is_downloaded: true)
```
---
### 2.3 文献解析流程 (Parse Flow)
本流程负责将本地下载的 HTML 或 PDF 转换为高保真的 Markdown
```mermaid
sequenceDiagram
participant U as 用户 (React 前端)
participant H as 处理器 (papers.rs)
participant P as 解析器 (parser.rs)
participant M as MinerU (PDF解析服务)
participant Q as 七牛云 (对象存储)
participant DB as 本地数据库 (SQLite)
U->>H: 1. 发起解析请求 (POST /api/parse, 含 bibcode, force)
H->>DB: 2. 查询文献物理路径 (pdf_path, html_path, markdown_path)
alt force == false 且本地已存在 Markdown 物理缓存
H->>H: 3. 读取本地 Markdown 物理文件
H-->>U: 4. 直接返回缓存 Markdown流程结束
end
H->>P: 5. 触发结构化文献解析
alt 本地存在 HTML 文件
P->>P: 6a. 剥离广告/导航栏与尾页页脚噪声
P->>P: 6b. 公式保护:利用占位符隔离 MathJax/LaTeX 公式段
P->>P: 6c. 标签规范:还原 LaTeXML 特定 span 为标准 table/tr/td修正上下标
P->>P: 6d. 插图处理:把相对图像路径替换为绝对 CDN 外链地址
P->>P: 6e. 转换 GFM Markdown 并恢复 LaTeX 公式
P->>P: 6f. 后处理:清除冗余的 margin 空白与前导缩进
else 仅有 PDF 文件 (PDF 降级解析)
P->>M: 7a. 获取批量预签名上传 URL (POST /file-urls/batch/)
M-->>P: 7b. 返回预签名上传 URL 与 Batch ID
P->>M: 7c. 上传 PDF 二进制字节流 (PUT 至预签名 URL)
loop 轮询任务状态 (每 10s 一次,最多 45 次)
P->>M: 7d. 查询提取进度与结果 (GET /extract-results/batch/{id})
M-->>P: 7e. 返回处理状态 ("done"/"error"等)
end
P->>P: 7f. 下载解析结果的 ZIP 压缩包并解压提取
loop 遍历每一个提取的插图
P->>Q: 7g. 上传插图文件并获取七牛云 CDN 域名外链
end
P->>P: 7h. 在 Markdown 中重写插图链接为七牛云 CDN 绝对路径
end
P-->>H: 8. 返回清洗转换出的标准英文 Markdown 文本
H->>P: 9. 写入本地物理缓存 Markdown/ 目录
H->>DB: 10. 更新数据库 markdown_path 记录
H-->>U: 11. 返回标准 Markdown 内容渲染展示
```
---
### 2.4 智能对照翻译流程 (Translation Flow)
本流程实现了基于天文学专属词汇表的 LLM 专业对比翻译:
```mermaid
sequenceDiagram
participant U as 用户 (React 前端)
participant H as 处理器 (papers.rs)
participant T as 翻译器 (translation.rs)
participant D as 天文词典 (Trie 树)
participant L as 大模型 (LLM API)
participant DB as 本地数据库 (SQLite)
U->>H: 1. 请求文献对比翻译 (POST /api/translate, 含 bibcode, force)
H->>DB: 2. 查询文献路径及状态
alt force == false 且本地已存在翻译缓存文件
H->>H: 3. 读取本地 Translation/{bibcode}_zh.md 物理文件
H-->>U: 4. 直接返回缓存译文,流程结束
end
H->>H: 5. 读取对应的英文解析 Markdown 物理文件
H->>T: 6. 调度翻译器执行翻译工作流
T->>D: 7. 加载本地 dictionary.txt 并初始化 Trie 树结构
T->>D: 8. 执行英文 Markdown 文本分词匹配
D->>D: 9a. 进行前缀匹配检索
D->>D: 9b. 遵循"最长匹配优先"原则,过滤子词去重
D-->>T: 10. 返回该篇文献提取出的天文学名词对照 (Glossary)
loop 针对英文 Markdown 进行段落分块 (Token 长度控制)
T->>L: 11. 携带 Glossary + 英文原文段落发送 Prompt 请求
note over L: LLM 遵循系统 Prompt 约束:<br>1. 专业词汇严格对应 Glossary 译出<br>2. 严禁改变 LaTeX 公式及 Markdown 标签<br>3. 保持中英段落高度对齐
L-->>T: 12. 返回学术级双语对照翻译段落
end
T->>T: 13. 拼接所有段落,生成完整的对照 Markdown
T->>H: 14. 写入本地物理缓存 Translation/ 目录
H->>DB: 15. 更新数据库中的 translation_path 字段
H-->>U: 16. 返回翻译后 Markdown 渲染展示
```
---

View File

@ -0,0 +1,79 @@
# AstroResearch Architecture / 架构设计
AstroResearch 是一个集成了天文学文献检索、多通道下载(含防爬绕过与手动上传)、下载错误诊断、结构化解析、中英学术对比翻译、引文星系图谱以及馆藏健康度诊断的天文科研辅助系统。
## 1. 整体架构 (Overall Architecture)
AstroResearch 采用 **C/S (Client-Server)** 架构,由前端 React 单页应用和后端 Axum HTTP 服务构成,核心流程及层级如下:
```mermaid
graph TD
subgraph Frontend ["React 前端 (Port 5173 / 8000)"]
UI[仪表盘 UI / ReaderPanel]
Canvas[引文 Canvas 拓扑图]
API_Client[Axum API 客户端]
CustomSelect[CustomSelect 可复用组件]
end
subgraph Backend ["Rust Axum 后端 (Port 8000)"]
Router[Axum 路由与中间件]
subgraph API ["API 层 (模块化)"]
Helpers[helpers.rs 格式转换与数据库工具]
Papers[papers.rs 文献检索/下载/上传/解析/翻译/引文/导出]
Notes[notes.rs 笔记 CRUD]
Sync[sync.rs 批量同步控制]
end
subgraph Services ["服务层"]
Batch[batch/ 批量同步引擎]
BatchMeta[batch/meta.rs 元数据采集]
BatchAsset[batch/asset.rs 资源处理]
Parser[parser.rs HTML/PDF 解析]
Downloader[download.rs 多通道下载器]
Translator[translation.rs LLM 翻译器]
Logging[logging.rs 日志系统]
end
DB[("SQLite / astro_research.db")]
end
subgraph External [外部第三方服务]
ADS[NASA ADS API]
arXiv[arXiv Atom XML API]
MinerU[MinerU PDF 解析服务]
QiniuCDN[七牛云对象存储 CDN]
LLM[LLM API]
end
UI -->|用户操作| API_Client
API_Client -->|RESTful APIs| Router
Router --> API
Papers -->|查询/保存元数据| DB
Papers -->|文献下载| Downloader
Papers -->|文件上传| Papers
Papers -->|正文解析| Parser
Papers -->|学术翻译| Translator
Sync -->|批量操作| Batch
BatchMeta -->|元数据同步| ADS
BatchMeta -->|元数据同步| arXiv
BatchAsset -->|批量文件下载| Downloader
BatchAsset -->|批量正文解析| Parser
BatchAsset -->|批量翻译| Translator
Batch -->|写库记录| DB
Downloader -->|代理请求| ADS
Downloader -->|直连或 ar5iv| arXiv
Parser -->|图文降级解析| MinerU
Parser -->|托管插图| QiniuCDN
Translator -->|天文术语翻译| LLM
Canvas -->|引文网络请求| Papers
```
---

View File

@ -1,54 +1,16 @@
# AstroResearch Contributing Guide / 参与贡献
我们欢迎社区共同参与 AstroResearch 的开发与优化。以下是关于本地开发调试、代码规范和测试的说明。
我们欢迎社区共同参与 AstroResearch 的开发与优化。
环境搭建和项目结构详见 **[开发指南](development.md)**。
---
## 1. 开发者本地环境搭建 (Developer Setup)
### 后端开发环境 (Rust)
1. 准备 Rust 工具链 (Edition 2021)。
2. 安装 SQLx CLI可选用于生成迁移文件
```bash
cargo install sqlx-cli --no-default-features --features sqlite
```
3. 启动开发模式下的 Rust 服务:
```bash
cargo run
```
### 前端开发环境 (React + TypeScript)
1. 进入 `dashboard` 目录,安装依赖:
```bash
cd dashboard
npm install
```
2. 启动开发服务器(支持 HMR 热更新及 API 请求代理转发):
```bash
npm run dev
```
## 1. 项目结构约定 (Project Structure Conventions)
---
## 2. 项目结构约定 (Project Structure Conventions)
### 后端模块化架构
后端代码已从单文件架构重构为模块化架构:
- **API 层** (`src/api/`):按职责拆分为 `papers.rs`(文献相关)、`notes.rs`(笔记相关)、`sync.rs`(同步相关)、`helpers.rs`(共享工具),通过 `mod.rs` 统一暴露 `AppState`、`StandardPaper` 和向后兼容的 `handlers` 命名空间。
- **服务层** (`src/services/`):批量同步引擎从单文件 `batch_sync.rs` 拆分为 `batch/mod.rs` + `meta.rs` + `asset.rs`,同时通过 `pub mod batch_sync` 保持路径兼容。
- **独立工具** (`src/bin/`)`health_check.rs` 作为独立二进制程序,可直接运行。
### 前端组件化架构
- **可复用组件** (`components/`)`CustomSelect` 替代所有原生 `<select>`,保持视觉一致性。
- **功能模块** (`features/`)按功能域划分search、library、reader、citation、sync、settings
- **类型定义** (`types.ts`):集中管理所有接口类型,与后端 `StandardPaper` 结构体保持同步。
---
## 3. 编码规范 (Coding Style Guidelines)
## 2. 编码规范 (Coding Style Guidelines)
### Rust 规范 (Backend)
- 遵循 Rust 官方标准样式,提交前必须执行 `cargo fmt``cargo clippy`
@ -66,7 +28,7 @@
---
## 4. 测试与验证 (Testing)
## 3. 测试与验证 (Testing)
### 运行后端单元测试
系统为各个下载、解析、词典分词、接口提取等模块设计了健全的测试。运行测试命令:
@ -89,7 +51,7 @@ cargo run --bin health_check
---
## 5. 数据库迁移 (Database Migrations)
## 4. 数据库迁移 (Database Migrations)
添加新的数据库字段或表时,需在 `migrations/` 目录下创建新的迁移脚本:
1. 文件命名格式:`YYYYMMDDHHMMSS_description.sql`

View File

@ -53,8 +53,48 @@ erDiagram
UNIQUE_query_source_limit "唯一去重约束"
}
AGENT_SESSIONS {
text session_id PK
text title
text model
integer turn_count
datetime deleted_at
}
AGENT_TASKS {
integer id PK
text session_id FK
text task_id
text content
text status
text blocked_by "JSON Array — DAG 依赖"
text owner "分配目标 agent 名称"
}
AGENT_AUDIT_LOG {
integer id PK
text session_id FK
integer step
text tool_name
text status "OK / FAIL / SESSION_STOP"
integer elapsed_ms
text output_preview
text agent_name
}
AGENT_TEAM_MEMBERS {
integer id PK
text session_id FK
text agent_name
text agent_role
text status "spawning / active / idle / shutdown"
}
PAPERS ||--o{ NOTES : "has"
PAPERS ||--o{ CITATIONS_REFERENCES : "cites / cited_by"
AGENT_SESSIONS ||--o{ AGENT_TASKS : "owns"
AGENT_SESSIONS ||--o{ AGENT_AUDIT_LOG : "records"
AGENT_SESSIONS ||--o{ AGENT_TEAM_MEMBERS : "members"
```
---
@ -87,6 +127,30 @@ erDiagram
存储用户保存的批量同步检索条件,支持快速重新同步。
- **唯一约束**`UNIQUE(query, source, limit_count)` 确保相同条件的检索不会重复保存。
### 2.5 agent_sessions 表 (Agent 会话)
存储 ReAct 智能体对话会话的元数据,支持软删除 (`deleted_at`)。
- 每条会话关联 `agent_messages` 表存储对话历史与工具调用记录。
- **索引**`idx_agent_sessions_created_at` (ORDER BY updated_at DESC)。
### 2.6 agent_tasks 表 (Agent 任务追踪)
持久化智能体的结构化待办任务,支持 DAG 依赖模式。
- `blocked_by`JSON 数组,前置任务 ID 列表。
- `status` 生命周期:`pending` → `in_progress``completed`
- `owner`:分配目标 agent 名称(多 Agent 团队协作路由)。
- **索引**`idx_agent_tasks_session`、`idx_agent_tasks_status`、`idx_agent_tasks_session_task` (UNIQUE)。
### 2.7 agent_audit_log 表 (Agent 工具审计)
记录所有工具调用的审计信息:工具名称、执行状态、耗时 (ms)、输出预览。
- `status``OK` / `FAIL` / `SESSION_STOP`
- `agent_name`:区分 lead/子代理/teammate 的调用来源。
- **用途**`GET /api/chat/metrics` 聚合指标、会话审计回溯。
### 2.8 agent_team_members 表 (多 Agent 团队)
管理多智能体团队中每个成员的生命周期。
- `status``spawning` → `active``idle``shutdown`
- `agent_role`:区分 team lead / teammate 等角色。
- **唯一约束**`UNIQUE(session_id, agent_name)`。
---
## 3. 数据库迁移说明
@ -98,6 +162,9 @@ erDiagram
| `20260608000001_notes.sql` | 添加 `notes` 笔记高亮表,并为关联建立级联删除。 |
| `20260608000002_add_doctype.sql` | 为 `papers` 表新增 `doctype` 文献类型字段。 |
| `20260608000003_sync_features.sql` | 添加 `sync_queries` 同步检索条件表,支持唯一去重。 |
| `20260616000000_agent_tasks.sql` | 智能体任务持久化表,支持 DAG 依赖与状态生命周期。 |
| `20260617000000_agent_audit_log.sql` | 智能体工具调用审计日志表。 |
| `20260618000000_agent_identity.sql` | Agent 身份隔离:消息/审计归属、`agent_team_members` 团队注册表。 |
---

View File

@ -79,11 +79,18 @@ cargo build --release --bin health_check
当遭遇 WAF 拦截时,主进程将自动通过异步子进程调用 `./bin/obscura` 进行抓取。
### 模式 B进程内集成模式 (In-Process Feature)
该模式将整个无头浏览器及 V8 运行引擎直接静态链接编译进单个二进制文件中。这免去了在服务器分发和配置外部可执行程序的步骤,提供了零配置”的部署体验。
该模式将整个无头浏览器及 V8 运行引擎直接静态链接编译进单个二进制文件中。这免去了在服务器分发和配置外部可执行程序的步骤,提供了零配置”的部署体验。
> [!WARNING]
> 由于需要静态链接 C++ 编写的 V8 引擎,**首次编译会额外多耗时 1 到 3 分钟**,且最终编译生成的**单体可执行文件体积会膨胀约 80MB**。
0. **获取 Obscura 依赖源码**(仅首次):
`libs/` 目录未被 Git 跟踪(已列入 `.gitignore``git clone` 后需要单独拉取 Obscura 浏览器内核源码:
```bash
mkdir -p libs
git clone https://github.com/h4ckf0r0day/obscura libs/obscura
```
1. **启用 Feature 编译**
在构建时指定 `--features obscura-inprocess` 特性标记:
```bash

107
docs/development.md Normal file
View File

@ -0,0 +1,107 @@
# AstroResearch Development Guide / 开发指南
## 快速开始
### 后端 (Rust)
```bash
# 前置Rust 1.75+,可选 sqlx-cli
cargo install sqlx-cli --no-default-features --features sqlite
# 启动开发模式
cargo run # 默认模式
cargo run --features obscura-inprocess # 进程内浏览器
# 运行 CLI / 工具
cargo run --bin astroresearch_cli
cargo run --bin health_check # 只读诊断
cargo run --bin health_check -- --fix # 自动修复
```
### 前端 (React + Vite)
```bash
cd dashboard
npm install
npm run dev # HMR 开发服务器 :5173/api → :8000
```
### 构建命令
```bash
# Rust
cargo build # Debug
cargo build --release # Release (速度优化)
cargo build --profile release-min # Release (体积优化, LTO)
# 前端
cd dashboard && npm run build # → dashboard/dist/
# Lint & Format
cargo clippy
cargo fmt
# 测试
cargo test # 全部测试
cargo test --lib # 单元测试
cargo test test_name # 指定测试
```
## 项目结构
```
src/
├── main.rs # Axum 服务入口、路由注册
├── lib.rs # Config 配置加载
├── api/ # HTTP handlers + AppState
├── agent/ # ReAct 智能体引擎
│ ├── runtime/ # ReAct 循环 + 流式执行 + Token 管理
│ ├── tools/ # 工具定义 (filesystem/ astro/ memory/ team/)
│ ├── compact/ # 上下文压缩
│ ├── memory/ # 项目记忆管理
│ ├── team/ # 多 Agent 团队
│ ├── hooks.rs # 生命周期事件
│ ├── skills.rs # 技能注册表
│ └── subagent.rs # 隔离子代理
├── clients/ # 外部 API 客户端 (ADS, arXiv, LLM, Qiniu)
├── services/ # 业务逻辑 (search, download, parser/, translation, rag, batch/)
└── bin/ # 独立二进制 (cli, health_check, reparse)
```
## 环境变量
参见 `.env.example`,关键变量:
| 变量 | 默认 | 说明 |
|:---|:---|:---|
| `DATABASE_URL` | `sqlite://library/astro_research.db` | SQLite 路径 |
| `ADS_API_KEY` | — | NASA ADS API Token |
| `LLM_API_KEY` / `LLM_API_BASE` / `LLM_MODEL` | OpenAI 默认值 | LLM 配置 |
| `LLM_MODEL` | `gpt-4o-mini` | Agent/Skills 继承模型 |
| `SKILLS_DIR` | `./skills` | Agent Skills 目录 |
| `PORT` | `8000` | 服务端口 |
Agent 调优参数:`AGENT_MAX_STEPS` (8)、`AGENT_TOOL_TIMEOUT_SECS` (120)、`AGENT_CONTEXT_CHAR_LIMIT` (16000)。
## 代码规范
- Rust: `cargo fmt` + `cargo clippy`,提交前必须通过
- 不可变性优先:使用 `let` 默认,必要时才 `let mut`
- 错误处理:应用层用 `anyhow`,库层用 `thiserror`
- 参数化 SQL`sqlx::query("...").bind(...)`,禁止字符串拼接
- 文件组织:按功能域拆分,单文件 200-400 行,上限 800 行
- Hook 检查:`PreToolUse` / `PostToolUse` / `Stop` 生命周期
## 测试
- 单元测试:`#[cfg(test)]` 模块内联在源文件中
- 集成测试:`tests/` 目录
- 目标覆盖率80%+
## 相关文档
- [架构设计](architecture.md)
- [API 接口](api.md)
- [数据库设计](database.md)
- [部署指南](deployment.md)
- [参与贡献](contributing.md)

@ -1 +0,0 @@
Subproject commit cd889d56596d62d561cf09301237ebf407fdd95a

29
profiles/readonly.toml Normal file
View File

@ -0,0 +1,29 @@
# 权限配置档案:只读模式
# 禁止所有 Shell 命令执行和文件写入操作。
# 加载方式AGENT_PERMISSION_PROFILE=readonly
[profile]
name = "readonly"
description = "只读访问 — 禁止 Shell 执行、文件写入、论文下载/解析"
[permissions]
deny = [
"run_bash",
"file_write",
"file_edit",
"download_paper",
"parse_paper",
"subagent",
]
allow = [
"read_file",
"grep_files",
"glob_files",
"search_papers",
"get_paper_metadata",
"get_paper_content",
"rag_search",
"query_target",
"load_skill",
]
mode = "default"

38
profiles/research.toml Normal file
View File

@ -0,0 +1,38 @@
# 权限配置档案:科研模式
# 允许文件操作和学术文献下载,禁止危险 Shell 命令。
# 加载方式AGENT_PERMISSION_PROFILE=research
[profile]
name = "research"
description = "科研模式 — 允许文件 I/O、文献下载禁止危险 Shell 命令"
[permissions]
deny = [
"run_bash(rm *)",
"run_bash(sudo)",
"run_bash(chmod)",
"run_bash(chown)",
"run_bash(mkfs)",
"run_bash(dd )",
"run_bash(> /)",
]
ask = [
"run_bash",
"file_write",
"file_edit",
]
allow = [
"read_file",
"grep_files",
"glob_files",
"search_papers",
"get_paper_metadata",
"get_paper_content",
"download_paper",
"parse_paper",
"rag_search",
"query_target",
"save_note",
"load_skill",
]
mode = "default"

View File

@ -17,7 +17,6 @@
pub mod collapse;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use tracing::{info, warn};
@ -28,11 +27,6 @@ static COMPACTING: AtomicBool = AtomicBool::new(false);
use crate::clients::llm::{ChatMessage, LlmClient, MessageRole};
/// 获取 transcripts 存储目录
fn transcripts_dir() -> PathBuf {
PathBuf::from(".transcripts")
}
/// 找到安全的上下文切割点,确保不会切断 tool_call / tool_result 配对。
/// 从末尾向前扫描,如果候选切割点的第一条要保留的消息是 tool 角色,
/// 则向前追溯到对应的 assistant(tool_calls) 消息一并保留。
@ -238,37 +232,6 @@ pub fn rough_estimate_tokens(messages: &[ChatMessage]) -> usize {
.sum()
}
/// 保存完整 transcript 到磁盘JSONL 格式)。
/// 在压缩前调用,确保不丢失任何对话历史。
async fn save_transcript(messages: &[ChatMessage], session_id: &str) {
let dir = transcripts_dir();
if let Err(e) = std::fs::create_dir_all(&dir) {
warn!("[Compact] 无法创建 transcripts 目录 {:?}: {}", dir, e);
return;
}
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
let filename = format!("{}_{}.jsonl", session_id, timestamp);
let path = dir.join(&filename);
let mut content = String::new();
for msg in messages {
if let Ok(json) = serde_json::to_string(msg) {
content.push_str(&json);
content.push('\n');
}
}
match std::fs::write(&path, &content) {
Ok(_) => info!(
"[Compact] Transcript 已保存: {} ({} 条消息)",
path.display(),
messages.len()
),
Err(e) => warn!("[Compact] Transcript 保存失败: {}", e),
}
}
/// 在压缩后注入身份确认块,防止模型丢失上下文认知。
/// 参考 Claude Code s11: identity re-injection after compression.
fn inject_identity_block(messages: &mut Vec<ChatMessage>) {
@ -427,10 +390,7 @@ pub async fn compress_context_with_hooks(
.await;
}
// 压缩前保存完整 transcript使用传入的 session_id 避免跨会话覆盖)
save_transcript(messages, session_id).await;
// 执行多层回退压缩
// 执行多层回退压缩(注:完整会话历史已在 agent_messages 表中持久化,无需额外 transcript 快照)
compress_with_fallback(messages, llm, context_char_limit).await;
// OnPostCompact hook

View File

@ -245,6 +245,24 @@ pub struct PreToolUseResult {
pub final_args: serde_json::Value,
}
impl PreToolUseResult {
/// 是否有 hook 请求了权限确认
pub fn is_permission_required(&self) -> bool {
matches!(self.action, PreToolUseAction::PermissionRequired { .. })
}
/// 获取权限确认的详情permission 描述, tool_name
pub fn permission_info(&self) -> Option<(&str, &str)> {
match &self.action {
PreToolUseAction::PermissionRequired {
permission,
tool_name,
} => Some((permission.as_str(), tool_name.as_str())),
_ => None,
}
}
}
/// PostToolUse 聚合结果
#[derive(Debug, Clone)]
pub struct PostToolUseResult {
@ -358,7 +376,6 @@ impl HookRegistry {
}
}
PreToolUseAction::PermissionRequired { .. } => {
// 暂时记录但继续执行Permission 系统在 Phase 2 中完善)
info!(
"[Hooks] {} 请求了工具 {} 的权限检查",
hook.name(),
@ -367,8 +384,11 @@ impl HookRegistry {
}
PreToolUseAction::Continue => {}
}
// Continue 不应覆盖已设置的 meaningful actionBlock/MutateInput/PermissionRequired
if !matches!(action, PreToolUseAction::Continue) {
final_action = action;
}
}
let ctx_opt = if accumulated_context.is_empty() {
None

View File

@ -55,8 +55,7 @@ impl MemoryType {
///
/// 事实永远不会被删除,只会从 Active 转换为 Historical。
/// supersedes 链保留了"理解如何演变"的完整历史。
#[derive(Debug, Clone, PartialEq)]
#[derive(Default)]
#[derive(Debug, Clone, PartialEq, Default)]
pub enum MemoryStatus {
/// 当前有效
#[default]
@ -96,7 +95,6 @@ impl MemoryStatus {
}
}
/// 记忆条目(从 .md 文件解析)
#[derive(Debug, Clone)]
pub struct MemoryEntry {

View File

@ -0,0 +1,179 @@
// src/agent/runtime/denial_tracker.rs
//
// 拒绝追踪与熔断器 — 参考 Claude Code denialTracking.ts。
//
// 追踪 Agent 执行过程中被权限规则拒绝的工具调用次数,
// 在连续拒绝或总拒绝数超过阈值时触发熔断,防止 Agent 反复尝试被禁操作。
//
// 设计:
// - consecutive_denials: 连续拒绝数(一次 allow 后重置)
// - total_denials: 总拒绝数(会话级累计)
// - should_terminate(): 任一达到阈值返回 true
/// 拒绝追踪器
#[derive(Debug, Clone)]
pub struct DenialTracker {
/// 连续拒绝计数(每次 allow 后重置)
consecutive_denials: usize,
/// 总拒绝计数(会话级累计)
total_denials: usize,
/// 连续拒绝上限
max_consecutive: usize,
/// 总拒绝上限
max_total: usize,
}
impl Default for DenialTracker {
fn default() -> Self {
Self {
consecutive_denials: 0,
total_denials: 0,
max_consecutive: 3,
max_total: 20,
}
}
}
impl DenialTracker {
/// 创建新追踪器
pub fn new(max_consecutive: usize, max_total: usize) -> Self {
Self {
consecutive_denials: 0,
total_denials: 0,
max_consecutive,
max_total,
}
}
/// 记录一次权限拒绝
pub fn record_denial(&mut self) {
self.consecutive_denials += 1;
self.total_denials += 1;
}
/// 记录一次权限允许(重置连续拒绝计数)
pub fn record_success(&mut self) {
if self.consecutive_denials > 0 {
self.consecutive_denials = 0;
}
}
/// 是否应该终止 Agent 循环(任一阈值达到)
pub fn should_terminate(&self) -> bool {
self.consecutive_denials >= self.max_consecutive || self.total_denials >= self.max_total
}
/// 生成终止原因消息
pub fn termination_reason(&self) -> String {
if self.consecutive_denials >= self.max_consecutive {
format!(
"连续被拒绝 {} 次(上限 {}),已终止 Agent 循环。请检查权限配置或调整任务。",
self.consecutive_denials, self.max_consecutive
)
} else {
format!(
"累计被拒绝 {} 次(上限 {}),已终止 Agent 循环。请检查权限配置或调整任务。",
self.total_denials, self.max_total
)
}
}
// ── 查询方法 ──
pub fn consecutive_denials(&self) -> usize {
self.consecutive_denials
}
pub fn total_denials(&self) -> usize {
self.total_denials
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_tracker_starts_at_zero() {
let tracker = DenialTracker::new(3, 20);
assert_eq!(tracker.consecutive_denials(), 0);
assert_eq!(tracker.total_denials(), 0);
assert!(!tracker.should_terminate());
}
#[test]
fn test_consecutive_denial_termination() {
let mut tracker = DenialTracker::new(3, 20);
assert!(!tracker.should_terminate());
tracker.record_denial();
assert_eq!(tracker.consecutive_denials(), 1);
assert!(!tracker.should_terminate());
tracker.record_denial();
assert_eq!(tracker.consecutive_denials(), 2);
assert!(!tracker.should_terminate());
tracker.record_denial();
assert_eq!(tracker.consecutive_denials(), 3);
assert!(tracker.should_terminate());
}
#[test]
fn test_total_denial_termination() {
let mut tracker = DenialTracker::new(3, 5);
// 每 2 次 deny 就来一次 success重置 consecutive但 total 会累积
for _ in 0..5 {
tracker.record_denial();
}
assert_eq!(tracker.total_denials(), 5);
assert!(tracker.should_terminate());
}
#[test]
fn test_success_resets_consecutive_only() {
let mut tracker = DenialTracker::new(3, 20);
tracker.record_denial();
tracker.record_denial();
assert_eq!(tracker.consecutive_denials(), 2);
assert_eq!(tracker.total_denials(), 2);
tracker.record_success();
assert_eq!(tracker.consecutive_denials(), 0);
assert_eq!(tracker.total_denials(), 2); // total 不重置
}
#[test]
fn test_success_noop_when_already_clean() {
let mut tracker = DenialTracker::new(3, 20);
tracker.record_success();
assert_eq!(tracker.consecutive_denials(), 0);
assert_eq!(tracker.total_denials(), 0);
}
#[test]
fn test_termination_reason_consecutive() {
let mut tracker = DenialTracker::new(3, 20);
tracker.record_denial();
tracker.record_denial();
tracker.record_denial();
assert!(tracker.should_terminate());
let reason = tracker.termination_reason();
assert!(reason.contains("连续被拒绝"));
assert!(reason.contains("3"));
}
#[test]
fn test_termination_reason_total() {
let mut tracker = DenialTracker::new(10, 2);
tracker.record_denial();
tracker.record_success(); // 重置 consecutive
tracker.record_denial();
// total = 2
assert!(tracker.should_terminate());
let reason = tracker.termination_reason();
assert!(reason.contains("累计被拒绝"));
}
}

View File

@ -7,14 +7,16 @@ use futures_util::StreamExt;
use sqlx::SqlitePool;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::sync::mpsc;
use tracing::warn;
use tokio::sync::{mpsc, oneshot};
use tracing::{info, warn};
use crate::api::AppState;
use crate::api::{AppState, PendingPermission};
use crate::clients::llm::{ChatMessage, ToolCall};
use super::denial_tracker::DenialTracker;
use super::file_cache::FileStateCache;
use super::permission::PermissionChecker;
use super::permission::{PermissionChecker, PermissionResult};
use super::permission_explainer::explain_permission;
use super::{AgentStreamEvent, DuplicateDetector};
use crate::agent::hooks::{HookRegistry, PostToolUseContext, PreToolUseContext};
use crate::agent::tools::persist::maybe_persist_tool_result;
@ -65,6 +67,13 @@ pub fn validate_and_prepare(
let tool_name = &tool_call.function.name;
let tool_args_str = &tool_call.function.arguments;
// 确保每个工具调用有唯一 IDLLM 可能不返回 id
let call_id = if tool_call.id.is_empty() {
format!("call_{}", &uuid::Uuid::new_v4().to_string()[..8])
} else {
tool_call.id.clone()
};
// 死循环检测
if duplicate_detector.record(tool_name, tool_args_str, duplicate_threshold) {
warn!(
@ -75,7 +84,7 @@ pub fn validate_and_prepare(
message: format!("检测到工具 {} 的重复调用,已自动终止循环。", tool_name),
});
let error_msg = ChatMessage::tool_result(
&tool_call.id,
&call_id,
format!(
"错误:工具 {} 被连续重复调用 {} 次,参数完全相同。\
",
@ -93,13 +102,14 @@ pub fn validate_and_prepare(
Err(e) => {
let error_output = format!("工具参数 JSON 解析失败: {}", e);
let _ = tx.send(AgentStreamEvent::ToolResult {
tool_call_id: call_id.clone(),
name: tool_name.clone(),
output: error_output.clone(),
is_error: true,
metadata: serde_json::json!({}),
step,
});
let tool_msg = ChatMessage::tool_result(&tool_call.id, &error_output);
let tool_msg = ChatMessage::tool_result(&call_id, &error_output);
save_tool_message_sync(db, session_id, turn_index, step, &tool_msg);
messages.push(tool_msg);
continue;
@ -107,7 +117,7 @@ pub fn validate_and_prepare(
};
prepared_calls.push(PreparedCall {
tool_call_id: tool_call.id.clone(),
tool_call_id: call_id.clone(),
tool_name: tool_name.clone(),
args,
});
@ -131,7 +141,9 @@ pub async fn execute_parallel(
tool_registry: &ToolRegistry,
app_state: Arc<AppState>,
hook_registry: &HookRegistry,
_permission_checker: Option<&PermissionChecker>,
permission_checker: Option<&PermissionChecker>,
session_permission_checker: Option<&std::sync::RwLock<PermissionChecker>>,
denial_tracker: Option<&std::sync::Mutex<DenialTracker>>,
tx: &mpsc::UnboundedSender<AgentStreamEvent>,
db: &SqlitePool,
session_id: &str,
@ -141,6 +153,8 @@ pub async fn execute_parallel(
tool_timeout_secs: u64,
max_output_chars: usize,
read_file_state: Arc<std::sync::Mutex<FileStateCache>>,
enable_thinking: bool,
additional_allowed_dirs: Vec<String>,
) -> ToolExecutionResult {
if prepared_calls.is_empty() {
return ToolExecutionResult {
@ -155,6 +169,7 @@ pub async fn execute_parallel(
// Phase 1: 发送 ToolCall SSE 事件
for prep in prepared_calls {
let _ = tx.send(AgentStreamEvent::ToolCall {
id: prep.tool_call_id.clone(),
name: prep.tool_name.clone(),
arguments: prep.args.clone(),
step,
@ -165,6 +180,7 @@ pub async fn execute_parallel(
let exec_start = std::time::Instant::now();
let mut mutated_args: Vec<serde_json::Value> = Vec::new();
let mut additional_contexts: Vec<String> = Vec::new();
let mut hook_permission_required: Vec<bool> = Vec::new();
for prep in prepared_calls {
let hook_ctx = PreToolUseContext {
session_id: sid.clone(),
@ -180,6 +196,16 @@ pub async fn execute_parallel(
prep.tool_name, reason
);
}
// 收集 hook 的权限请求
if result.is_permission_required() {
info!(
"[Executor] PreToolUse hook 请求了工具 {} 的权限确认",
prep.tool_name
);
hook_permission_required.push(true);
} else {
hook_permission_required.push(false);
}
// 使用 hook 可能修改后的参数
mutated_args.push(result.final_args);
if let Some(ctx) = result.additional_context {
@ -187,6 +213,274 @@ pub async fn execute_parallel(
}
}
// Phase 2.5: 权限检查 — PermissionChecker 规则引擎拦截被拒绝的工具。
// 被拒绝的工具直接注入错误 result不进入执行队列。
let mut tool_messages: Vec<ToolResultMessage> = Vec::new();
let mut denied_indices: std::collections::HashSet<usize> = std::collections::HashSet::new();
if let Some(checker) = permission_checker {
for (i, prep) in prepared_calls.iter().enumerate() {
let mut perm_result = checker.check(&prep.tool_name, Some(&prep.args));
perm_result = checker.apply_mode(perm_result, &prep.tool_name);
// Hook PermissionRequired — 若 Checker 返回 Allowed升级为 Ask
if hook_permission_required.get(i).copied().unwrap_or(false) && perm_result.is_allowed()
{
perm_result = PermissionResult::AskUser {
message: format!("Hook 请求了工具 {} 的权限确认", prep.tool_name),
};
}
// 工具级 check_permissions() — 在 PermissionChecker 结果基础上叠加
// PermissionChecker Deny/Ask 优先,工具级规则在 Allow 时可升级为 Ask
if let Some(tool) = tool_registry.get(&prep.tool_name) {
let tool_rules = tool.check_permissions(&prep.args);
for tool_rule in &tool_rules {
match tool_rule {
crate::agent::tools::PermissionRule::Deny { reason, .. } => {
// 工具级 Deny 仅在 PermissionChecker 未 Deny 时生效
if !perm_result.is_denied() {
perm_result = PermissionResult::Denied {
reason: reason.clone(),
};
}
}
crate::agent::tools::PermissionRule::Ask { message, .. } => {
// 工具级 Ask若 PermissionChecker 返回 Allowed升级为 Ask
if perm_result.is_allowed() {
perm_result = PermissionResult::AskUser {
message: message.clone(),
};
}
}
_ => {}
}
}
}
// 会话级权限检查API 动态添加的规则,优先级高于环境变量规则)
if let Some(session_checker) = session_permission_checker {
if let Ok(checker) = session_checker.read() {
let session_result = checker.check(&prep.tool_name, Some(&prep.args));
// 会话规则结果覆盖或升级
match session_result {
PermissionResult::Denied { reason } => {
// 会话 Deny 强制覆盖
perm_result = PermissionResult::Denied { reason };
}
PermissionResult::AskUser { message } => {
// 会话 Ask 在 Allow 时升级
if perm_result.is_allowed() {
perm_result = PermissionResult::AskUser { message };
}
}
PermissionResult::Allowed => {
// 会话 Allow 仅在非 Deny 时覆盖(会话明确允许)
if !perm_result.is_denied() {
perm_result = PermissionResult::Allowed;
}
}
}
}
}
match perm_result {
PermissionResult::Denied { reason } => {
warn!(
"[Executor] PermissionChecker 拒绝了工具 {}: {}",
prep.tool_name, reason
);
let err_output =
format!("工具 {} 被权限规则拒绝执行: {}", prep.tool_name, reason);
let _ = tx.send(AgentStreamEvent::ToolResult {
tool_call_id: prep.tool_call_id.clone(),
name: prep.tool_name.clone(),
output: err_output.clone(),
is_error: true,
metadata: serde_json::json!({}),
step,
});
let err_msg = ChatMessage::tool_result(&prep.tool_call_id, &err_output);
save_tool_message_sync(db, &sid, turn_index, step, &err_msg);
tool_messages.push(ToolResultMessage {
chat_message: err_msg,
was_error: true,
});
// 记录拒绝追踪
if let Some(dt) = denial_tracker {
if let Ok(mut tracker) = dt.lock() {
tracker.record_denial();
}
}
denied_indices.insert(i);
}
PermissionResult::AskUser { message } => {
info!(
"[Executor] PermissionChecker 请求用户确认工具 {}: {}",
prep.tool_name, message
);
// 生成权限风险解释
let permission_exp = explain_permission(&prep.tool_name, &prep.args);
let explanation_json = serde_json::to_value(&permission_exp).ok();
// 发送权限请求 SSE 事件
let _ = tx.send(AgentStreamEvent::PermissionRequest {
tool_call_id: prep.tool_call_id.clone(),
tool_name: prep.tool_name.clone(),
message: message.clone(),
arguments: prep.args.clone(),
explanation: explanation_json,
});
// 创建 oneshot 通道等待用户响应
let (resp_tx, resp_rx) = oneshot::channel();
let perm_id = uuid::Uuid::new_v4().to_string();
let tc_id = prep.tool_call_id.clone();
let t_name = prep.tool_name.clone();
// 存储待处理的权限请求
{
let mut perms = match app_state.pending_permissions.lock() {
Ok(p) => p,
Err(e) => {
warn!("[Executor] 权限系统内部错误: {}", e);
let err_output =
format!("权限系统内部错误,工具 {} 被拒绝", prep.tool_name);
let _ = tx.send(AgentStreamEvent::ToolResult {
tool_call_id: prep.tool_call_id.clone(),
name: prep.tool_name.clone(),
output: err_output.clone(),
is_error: true,
metadata: serde_json::json!({}),
step,
});
let err_msg =
ChatMessage::tool_result(&prep.tool_call_id, &err_output);
save_tool_message_sync(db, &sid, turn_index, step, &err_msg);
tool_messages.push(ToolResultMessage {
chat_message: err_msg,
was_error: true,
});
// 内部错误 → 记录拒绝追踪
if let Some(dt) = denial_tracker {
if let Ok(mut tracker) = dt.lock() {
tracker.record_denial();
}
}
denied_indices.insert(i);
continue;
}
};
perms.insert(
perm_id.clone(),
PendingPermission {
tool_call_id: tc_id.clone(),
tool_name: t_name.clone(),
message: message.clone(),
arguments: prep.args.clone(),
response_tx: resp_tx,
},
);
}
// 等待用户响应120 秒超时)
let timeout_dur = std::time::Duration::from_secs(120);
let perm_result = tokio::time::timeout(timeout_dur, resp_rx).await;
// 清理待处理的权限请求
if let Ok(mut perms) = app_state.pending_permissions.lock() {
perms.remove(&perm_id);
}
match perm_result {
Ok(Ok(response)) if response.allowed => {
info!("[Executor] 用户允许了工具 {} 的执行", prep.tool_name);
let _ = tx.send(AgentStreamEvent::PermissionResponse {
tool_call_id: prep.tool_call_id.clone(),
allowed: true,
});
// 用户允许 → 重置连续拒绝计数
if let Some(dt) = denial_tracker {
if let Ok(mut tracker) = dt.lock() {
tracker.record_success();
}
}
}
Ok(Ok(_response)) => {
// 用户拒绝
info!("[Executor] 用户拒绝了工具 {}", prep.tool_name);
let err_output = format!("用户拒绝了工具 {} 的执行", prep.tool_name);
let _ = tx.send(AgentStreamEvent::ToolResult {
tool_call_id: prep.tool_call_id.clone(),
name: prep.tool_name.clone(),
output: err_output.clone(),
is_error: true,
metadata: serde_json::json!({}),
step,
});
let err_msg = ChatMessage::tool_result(&prep.tool_call_id, &err_output);
save_tool_message_sync(db, &sid, turn_index, step, &err_msg);
tool_messages.push(ToolResultMessage {
chat_message: err_msg,
was_error: true,
});
// 用户拒绝 → 记录拒绝追踪
if let Some(dt) = denial_tracker {
if let Ok(mut tracker) = dt.lock() {
tracker.record_denial();
}
}
denied_indices.insert(i);
let _ = tx.send(AgentStreamEvent::PermissionResponse {
tool_call_id: prep.tool_call_id.clone(),
allowed: false,
});
}
_ => {
// 超时或通道关闭
warn!("[Executor] 权限请求超时或取消: {}", prep.tool_name);
let err_output =
format!("权限请求超时 (120s): {} 未获得用户确认", prep.tool_name);
let _ = tx.send(AgentStreamEvent::ToolResult {
tool_call_id: prep.tool_call_id.clone(),
name: prep.tool_name.clone(),
output: err_output.clone(),
is_error: true,
metadata: serde_json::json!({}),
step,
});
let err_msg = ChatMessage::tool_result(&prep.tool_call_id, &err_output);
save_tool_message_sync(db, &sid, turn_index, step, &err_msg);
tool_messages.push(ToolResultMessage {
chat_message: err_msg,
was_error: true,
});
// 超时 → 记录拒绝追踪
if let Some(dt) = denial_tracker {
if let Ok(mut tracker) = dt.lock() {
tracker.record_denial();
}
}
denied_indices.insert(i);
let _ = tx.send(AgentStreamEvent::PermissionResponse {
tool_call_id: prep.tool_call_id.clone(),
allowed: false,
});
}
}
}
PermissionResult::Allowed => {
// 工具被允许 → 重置连续拒绝计数
if let Some(dt) = denial_tracker {
if let Ok(mut tracker) = dt.lock() {
tracker.record_success();
}
}
}
}
}
} // if let Some(checker)
// Phase 3: 并行执行
let cancelled = Arc::new(AtomicBool::new(false));
let cancel_flag = cancelled.clone();
@ -213,13 +507,18 @@ pub async fn execute_parallel(
let mut exec_futs: FuturesUnordered<_> = prepared_calls
.iter()
.enumerate()
.filter(|(i, _)| !denied_indices.contains(i))
.map(|(i, prep)| {
let tool_name = prep.tool_name.clone();
let args = mutated_args
.get(i)
.cloned()
.unwrap_or_else(|| prep.args.clone());
let tool_ctx = ToolContext::with_file_cache(app_state.clone(), read_file_state.clone());
let tool_ctx = ToolContext::with_file_cache(app_state.clone(), read_file_state.clone())
.with_sse_tx(tx.clone())
.with_session_id(session_id.to_string())
.with_thinking(enable_thinking)
.with_additional_dirs(additional_allowed_dirs.clone());
let cancelled = cancelled.clone();
let tool_opt = tool_registry.get(&tool_name);
@ -270,7 +569,6 @@ pub async fn execute_parallel(
})
.collect();
let mut tool_messages: Vec<ToolResultMessage> = Vec::new();
let mut was_cancelled = false;
// 渐进式处理结果每个工具一完成就立即处理SSE 事件 + PostToolUse hook + 持久化)
@ -285,6 +583,7 @@ pub async fn execute_parallel(
// SSE 事件 — 立即推送到前端
let _ = tx.send(AgentStreamEvent::ToolResult {
tool_call_id: tool_call_id.clone(),
name: tool_name.clone(),
output: output.content.clone(),
is_error: output.is_error,
@ -347,12 +646,16 @@ fn save_tool_message_sync(
let session_id = session_id.to_string();
let content = msg.content.as_deref().unwrap_or("").to_string();
let tool_call_id = msg.tool_call_id.clone();
// 提前序列化,避免闭包内的生命周期问题
let metadata_str =
serde_json::to_string(&serde_json::json!({ "role": "tool" })).unwrap_or_default();
let raw_json = serde_json::to_string(&msg).unwrap_or_default();
// fire-and-forget: tool 消息保存失败不影响主流程
tokio::spawn(async move {
let token_count = content.len() as i32 / 4;
if let Err(e) = sqlx::query(
"INSERT INTO agent_messages (session_id, turn_index, step_index, role, content, tool_call_id, token_count, agent_name) \
VALUES (?, ?, ?, 'tool', ?, ?, ?, ?)",
"INSERT INTO agent_messages (session_id, turn_index, step_index, role, content, tool_call_id, token_count, metadata, raw_json, agent_name) \
VALUES (?, ?, ?, 'tool', ?, ?, ?, ?, ?, ?)",
)
.bind(&session_id)
.bind(turn_index)
@ -360,6 +663,8 @@ fn save_tool_message_sync(
.bind(&content)
.bind(&tool_call_id)
.bind(token_count)
.bind(&metadata_str)
.bind(&raw_json)
.bind("lead")
.execute(&db_clone)
.await

View File

@ -130,7 +130,8 @@ impl FileStateCache {
}
// 驱逐旧条目直到有足够空间
while self.current_size_bytes + content_len > self.max_size_bytes && !self.cache.is_empty() {
while self.current_size_bytes + content_len > self.max_size_bytes && !self.cache.is_empty()
{
if let Some((_, evicted)) = self.cache.pop_lru() {
self.current_size_bytes = self
.current_size_bytes

View File

@ -13,12 +13,15 @@
pub mod circuit_breaker;
pub mod context;
pub mod denial_tracker;
pub mod error_recovery;
pub mod executor;
pub mod file_cache;
pub mod finalize;
pub mod partitioner;
pub mod permission;
pub mod permission_explainer;
pub mod permission_profile;
pub mod session;
pub mod streaming;
pub mod streaming_executor;
@ -64,12 +67,30 @@ pub struct AgentConfig {
pub token_hard_limit: usize,
/// 最大消息数(超过此阈值触发 snip_compact 层压缩)
pub max_messages: usize,
/// 是否启用 LLM 思考模式(前端可控,默认关闭)
pub enable_thinking: bool,
/// 权限拒绝规则(逗号分隔,格式: ToolName 或 ToolName(content_pattern)
pub permission_deny_rules: Vec<String>,
/// 权限允许规则(逗号分隔)
pub permission_allow_rules: Vec<String>,
/// 权限询问规则(逗号分隔)
pub permission_ask_rules: Vec<String>,
/// 权限模式: "default" | "accept_edits" | "bypass" | "dont_ask"
pub permission_mode: String,
/// 拒绝追踪:连续拒绝上限(默认 3
pub denial_max_consecutive: usize,
/// 拒绝追踪:总拒绝上限(默认 20
pub denial_max_total: usize,
/// 附加允许目录(逗号分隔,扩展文件沙箱范围)
pub additional_allowed_dirs: Vec<String>,
/// 子代理工具白名单(逗号分隔,空=全部工具可用)
pub subagent_allowed_tools: Vec<String>,
}
impl AgentConfig {
/// 从环境变量加载配置,缺失时使用默认值。
pub fn from_env_optional() -> Self {
AgentConfig {
let mut config = AgentConfig {
max_steps: std::env::var("AGENT_MAX_STEPS")
.ok()
.and_then(|v| v.parse().ok())
@ -99,8 +120,63 @@ impl AgentConfig {
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(50),
enable_thinking: false,
permission_deny_rules: parse_comma_list("AGENT_PERMISSIONS_DENY"),
permission_allow_rules: parse_comma_list("AGENT_PERMISSIONS_ALLOW"),
permission_ask_rules: parse_comma_list("AGENT_PERMISSIONS_ASK"),
permission_mode: std::env::var("AGENT_PERMISSION_MODE")
.unwrap_or_else(|_| "default".to_string()),
denial_max_consecutive: std::env::var("AGENT_DENIAL_MAX_CONSECUTIVE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(3),
denial_max_total: std::env::var("AGENT_DENIAL_MAX_TOTAL")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(20),
additional_allowed_dirs: parse_comma_list("AGENT_ADDITIONAL_DIRS"),
subagent_allowed_tools: parse_comma_list("AGENT_SUBAGENT_ALLOWED_TOOLS"),
};
// 加载权限档案AGENT_PERMISSION_PROFILE追加到现有规则
let profile_name = std::env::var("AGENT_PERMISSION_PROFILE").unwrap_or_default();
if !profile_name.is_empty() {
if let Some(profile) = permission_profile::load_profile(&profile_name) {
info!(
"[AgentConfig] 加载权限档案: {} — {}",
profile.name, profile.description
);
permission_profile::apply_profile_to_config(
&profile,
&mut config.permission_deny_rules,
&mut config.permission_allow_rules,
&mut config.permission_ask_rules,
&mut config.permission_mode,
);
} else {
warn!(
"[AgentConfig] 未知的权限档案: {}(可用: {:?}",
profile_name,
permission_profile::list_available_profiles()
);
}
}
config
}
}
/// 解析逗号分隔的环境变量为字符串列表
fn parse_comma_list(env_key: &str) -> Vec<String> {
std::env::var(env_key)
.ok()
.map(|v| {
v.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default()
}
impl Default for AgentConfig {
@ -124,6 +200,8 @@ pub enum AgentStreamEvent {
/// 工具调用开始
#[serde(rename = "tool_call")]
ToolCall {
/// LLM 生成的工具调用 ID用于全链路关联前端/审计/持久化)
id: String,
name: String,
arguments: serde_json::Value,
step: usize,
@ -131,6 +209,8 @@ pub enum AgentStreamEvent {
/// 工具执行结果Observation
#[serde(rename = "tool_result")]
ToolResult {
/// 对应的工具调用 ID前端凭此精确匹配 tool_call 条目
tool_call_id: String,
name: String,
output: String,
is_error: bool,
@ -150,6 +230,19 @@ pub enum AgentStreamEvent {
/// 错误通知
#[serde(rename = "error")]
Error { message: String },
/// 权限请求(需要用户确认工具执行)
#[serde(rename = "permission_request")]
PermissionRequest {
tool_call_id: String,
tool_name: String,
message: String,
arguments: serde_json::Value,
/// 可选的权限风险解释(参考 Claude Code permissionExplainer
explanation: Option<serde_json::Value>,
},
/// 权限响应已处理
#[serde(rename = "permission_response")]
PermissionResponse { tool_call_id: String, allowed: bool },
/// 完成标记
#[serde(rename = "done")]
Done,
@ -206,6 +299,8 @@ pub struct AgentRuntime {
compaction_breaker: Arc<std::sync::Mutex<circuit_breaker::CompactionCircuitBreaker>>,
/// 权限检查器
permission_checker: Arc<permission::PermissionChecker>,
/// 拒绝追踪器(跨 turn 共享)
denial_tracker: Arc<std::sync::Mutex<denial_tracker::DenialTracker>>,
/// 文件状态缓存(跨 turn 共享,用于 Read 去重)
read_file_state: Arc<std::sync::Mutex<file_cache::FileStateCache>>,
}
@ -213,54 +308,32 @@ pub struct AgentRuntime {
impl AgentRuntime {
/// 创建新的运行时实例
pub fn new(app_state: Arc<AppState>) -> Self {
let config = AgentConfig::default();
let queue = Arc::new(BgNotificationQueue::new());
let metrics_data = Arc::new(std::sync::Mutex::new(super::hooks::MetricsData::default()));
let permission_checker = Arc::new(permission::PermissionChecker::new());
let permission_checker = Arc::new(permission::PermissionChecker::from_config(&config));
let denial_tracker = Arc::new(std::sync::Mutex::new(denial_tracker::DenialTracker::new(
config.denial_max_consecutive,
config.denial_max_total,
)));
let skill_registry = app_state.skill_registry.clone();
let mut tool_registry = ToolRegistry::new_with_queue(Some(queue.clone()), skill_registry);
// 注册记忆工具
tool_registry.add_tool(Box::new(crate::agent::tools::memory::SaveMemoryTool::new(
app_state.memory_manager.clone(),
)));
// 替换 DelegateResearchTool 为带有 permission_checker 的版本
// 替换 DelegateResearchTool 为带有 permission_checker 的版本SSE 通道通过 ToolContext 注入)
tool_registry.replace_tool(Box::new(
crate::agent::tools::subagent::DelegateResearchTool::new_with_hooks(
crate::agent::tools::subagent::SubAgentTool::new_with_hooks(
None,
permission_checker.clone(),
None,
),
));
AgentRuntime {
app_state,
config: AgentConfig::default(),
tool_registry,
bg_notification_queue: queue,
metrics_data,
compaction_breaker: Arc::new(std::sync::Mutex::new(
circuit_breaker::CompactionCircuitBreaker::new(),
)),
permission_checker,
read_file_state: Arc::new(std::sync::Mutex::new(file_cache::FileStateCache::new())),
}
// 初始化会话级权限检查器(与 AgentRuntime 使用相同的环境变量规则)
if let Ok(mut session_checker) = app_state.session_permission_checker.write() {
*session_checker = (*permission_checker).clone();
}
/// 创建带自定义配置的运行时实例
pub fn with_config(app_state: Arc<AppState>, config: AgentConfig) -> Self {
let queue = Arc::new(BgNotificationQueue::new());
let metrics_data = Arc::new(std::sync::Mutex::new(super::hooks::MetricsData::default()));
let permission_checker = Arc::new(permission::PermissionChecker::new());
let skill_registry = app_state.skill_registry.clone();
let mut tool_registry = ToolRegistry::new_with_queue(Some(queue.clone()), skill_registry);
tool_registry.add_tool(Box::new(crate::agent::tools::memory::SaveMemoryTool::new(
app_state.memory_manager.clone(),
)));
tool_registry.replace_tool(Box::new(
crate::agent::tools::subagent::DelegateResearchTool::new_with_hooks(
None,
permission_checker.clone(),
None,
),
));
AgentRuntime {
app_state,
config,
@ -271,6 +344,46 @@ impl AgentRuntime {
circuit_breaker::CompactionCircuitBreaker::new(),
)),
permission_checker,
denial_tracker,
read_file_state: Arc::new(std::sync::Mutex::new(file_cache::FileStateCache::new())),
}
}
/// 创建带自定义配置的运行时实例
pub fn with_config(app_state: Arc<AppState>, config: AgentConfig) -> Self {
let queue = Arc::new(BgNotificationQueue::new());
let metrics_data = Arc::new(std::sync::Mutex::new(super::hooks::MetricsData::default()));
let permission_checker = Arc::new(permission::PermissionChecker::from_config(&config));
let denial_tracker = Arc::new(std::sync::Mutex::new(denial_tracker::DenialTracker::new(
config.denial_max_consecutive,
config.denial_max_total,
)));
let skill_registry = app_state.skill_registry.clone();
let mut tool_registry = ToolRegistry::new_with_queue(Some(queue.clone()), skill_registry);
tool_registry.add_tool(Box::new(crate::agent::tools::memory::SaveMemoryTool::new(
app_state.memory_manager.clone(),
)));
tool_registry.replace_tool(Box::new(
crate::agent::tools::subagent::SubAgentTool::new_with_hooks(
None,
permission_checker.clone(),
),
));
// 初始化会话级权限检查器
if let Ok(mut session_checker) = app_state.session_permission_checker.write() {
*session_checker = (*permission_checker).clone();
}
AgentRuntime {
app_state,
config,
tool_registry,
bg_notification_queue: queue,
metrics_data,
compaction_breaker: Arc::new(std::sync::Mutex::new(
circuit_breaker::CompactionCircuitBreaker::new(),
)),
permission_checker,
denial_tracker,
read_file_state: Arc::new(std::sync::Mutex::new(file_cache::FileStateCache::new())),
}
}
@ -284,6 +397,12 @@ impl AgentRuntime {
.unwrap_or_default()
}
/// 设置是否启用 LLM 思考模式
pub fn with_thinking(mut self, enable: bool) -> Self {
self.config.enable_thinking = enable;
self
}
// ── Private Helpers ──
/// 执行文件缓存快照 → 压缩 → 恢复 → 上下文注入 的完整周期。
@ -667,8 +786,10 @@ impl AgentRuntime {
// ── 处理 Thought/Reasoning ──
let mut thought_content = stream_output.reasoning.clone();
if thought_content.is_none() && stream_output.is_tool_call_step
&& !stream_output.content.is_empty() {
if thought_content.is_none()
&& stream_output.is_tool_call_step
&& !stream_output.content.is_empty()
{
thought_content = Some(stream_output.content.clone());
}
@ -682,7 +803,7 @@ impl AgentRuntime {
}
// ── 无工具调用 = 最终回答 ──
let tool_calls = match stream_output.tool_calls {
let mut tool_calls = match stream_output.tool_calls {
Some(ref tc) if !tc.is_empty() => tc.clone(),
_ => {
// 保存最终回答
@ -706,15 +827,14 @@ impl AgentRuntime {
.await?;
messages.push(assistant_msg);
// 发送未发送的 reasoning
// 发送 reasoning当模型思考后直接给出答案、未调用工具时
// thought 尚未在上面的 is_tool_call_step 块中发送)
if let Some(ref thought_text) = stream_output.reasoning {
if thought_content.is_none() {
let _ = tx.send(AgentStreamEvent::Thought {
content: thought_text.clone(),
step,
});
}
}
// Token 使用统计
if let Some(u) = stream_output.usage {
@ -728,6 +848,13 @@ impl AgentRuntime {
}
};
// 修复空 IDLLM 可能不返回 tool_call id
for tc in tool_calls.iter_mut() {
if tc.id.is_empty() {
tc.id = format!("call_{}", &uuid::Uuid::new_v4().to_string()[..8]);
}
}
// ── 工具调用处理 ──
// 检测 todo_write 和 compress_context
let called_todo_write = tool_calls.iter().any(|tc| tc.function.name == "todo_write");
@ -803,6 +930,8 @@ impl AgentRuntime {
self.app_state.clone(),
hook_registry,
Some(&self.permission_checker),
Some(&self.app_state.session_permission_checker),
Some(&self.denial_tracker),
tx,
db,
sid,
@ -812,9 +941,21 @@ impl AgentRuntime {
self.config.tool_timeout_secs,
self.config.max_tool_output_chars,
self.read_file_state.clone(),
self.config.enable_thinking,
self.config.additional_allowed_dirs.clone(),
)
.await;
// 拒绝追踪熔断检查:连续/累计拒绝达到阈值则终止循环
if let Ok(dt) = self.denial_tracker.lock() {
if dt.should_terminate() {
let reason = dt.termination_reason();
warn!("[AgentRuntime] 拒绝熔断触发: {}", reason);
let _ = tx.send(AgentStreamEvent::Error { message: reason });
break;
}
}
// 将工具结果推入消息上下文
for tm in exec_result.tool_messages {
messages.push(tm.chat_message);
@ -897,6 +1038,7 @@ impl AgentRuntime {
step,
session_id,
self.app_state.cancelled_runs.clone(),
self.config.enable_thinking,
)
.await;
@ -991,6 +1133,7 @@ impl AgentRuntime {
step,
session_id,
self.app_state.cancelled_runs.clone(),
self.config.enable_thinking,
)
.await;
@ -1087,6 +1230,7 @@ impl AgentRuntime {
step,
session_id,
self.app_state.cancelled_runs.clone(),
self.config.enable_thinking,
)
.await;
@ -1190,7 +1334,10 @@ impl AgentRuntime {
tx: &mpsc::UnboundedSender<AgentStreamEvent>,
) -> anyhow::Result<()> {
let empty_tools: Vec<crate::clients::llm::ToolDefinition> = Vec::new();
let mut stream_rx = match llm.chat_stream(messages, &empty_tools).await {
let mut stream_rx = match llm
.chat_stream(messages, &empty_tools, self.config.enable_thinking)
.await
{
Ok(rx) => rx,
Err(e) => {
let _ = tx.send(AgentStreamEvent::Error {
@ -1280,9 +1427,20 @@ impl AgentRuntime {
let tool_call_id = msg.tool_call_id.as_deref();
let token_count = content.len() as i32 / 4;
// metadata: 存储结构化的消息元信息thought/tool_calls/tool_call_id 等)
let metadata = serde_json::json!({
"has_thought": thought.is_some(),
"has_tool_calls": tool_calls_json.is_some(),
"step_index": step_index,
});
let metadata_str = serde_json::to_string(&metadata).unwrap_or_default();
// raw_json: 存储完整消息的 JSON 序列化(调试/审计用)
let raw_json = serde_json::to_string(msg).unwrap_or_default();
sqlx::query(
"INSERT INTO agent_messages (session_id, turn_index, step_index, role, content, thought, tool_calls, tool_call_id, token_count, agent_name) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO agent_messages (session_id, turn_index, step_index, role, content, thought, tool_calls, tool_call_id, token_count, metadata, raw_json, agent_name) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(session_id)
.bind(turn_index)
@ -1293,6 +1451,8 @@ impl AgentRuntime {
.bind(&tool_calls_json)
.bind(tool_call_id)
.bind(token_count)
.bind(&metadata_str)
.bind(&raw_json)
.bind(agent_name)
.execute(db)
.await?;

View File

@ -12,7 +12,7 @@
use tracing::info;
use crate::agent::tools::PermissionRule;
use crate::agent::tools::{PermissionRule, PermissionRuleSource};
/// 权限检查结果
#[derive(Debug, Clone, PartialEq)]
@ -35,15 +35,45 @@ impl PermissionResult {
}
}
/// 权限模式 — 控制权限检查的整体行为
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PermissionMode {
/// 默认模式:执行所有权限检查
Default,
/// 接受编辑:自动允许工作目录内的 file_write/file_edit
AcceptEdits,
/// 绕过询问:跳过所有 Ask 检查Deny 规则仍生效)
Bypass,
/// 不询问:将所有 Ask 转为 Deny
DontAsk,
}
impl PermissionMode {
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"accept_edits" | "accept-edits" => PermissionMode::AcceptEdits,
"bypass" => PermissionMode::Bypass,
"dont_ask" | "dontask" | "dont-ask" => PermissionMode::DontAsk,
_ => PermissionMode::Default,
}
}
}
/// 权限检查器 — 维护有序规则列表并逐条匹配
#[derive(Debug, Clone)]
pub struct PermissionChecker {
rules: Vec<PermissionRule>,
mode: PermissionMode,
}
impl PermissionChecker {
/// 创建空的检查器(默认允许所有)
pub fn new() -> Self {
PermissionChecker { rules: Vec::new() }
PermissionChecker {
rules: Vec::new(),
mode: PermissionMode::Default,
}
}
/// 添加规则。先添加的优先级更高。
@ -51,29 +81,76 @@ impl PermissionChecker {
self.rules.push(rule);
}
/// 动态添加规则会话内source = Session
/// 规则插入到列表末尾(优先级低于已有规则)。
pub fn add_rule_dynamic(&mut self, rule: PermissionRule) {
info!("[Permission] 会话内动态添加规则: {:?}", rule);
self.rules.push(rule);
}
/// 动态移除规则。
/// `tool_name` 精确匹配,`kind` 指定移除类型 ("deny" / "allow" / "ask")。
/// 返回移除的规则数量。
pub fn remove_rule_dynamic(&mut self, tool_name: &str, kind: &str) -> usize {
let before = self.rules.len();
self.rules.retain(|rule| {
let (name, is_kind) = match rule {
PermissionRule::Deny { tool_name: n, .. } => (n, kind == "deny"),
PermissionRule::Allow { tool_name: n, .. } => (n, kind == "allow"),
PermissionRule::Ask { tool_name: n, .. } => (n, kind == "ask"),
};
!(name == tool_name && is_kind)
});
let removed = before - self.rules.len();
if removed > 0 {
info!(
"[Permission] 会话内移除 {}/{} 规则 {} 条",
kind, tool_name, removed
);
}
removed
}
/// 动态切换权限模式。
pub fn set_mode(&mut self, mode: PermissionMode) {
info!("[Permission] 会话内切换权限模式: {:?}", mode);
self.mode = mode;
}
/// 检查指定工具是否可以执行。
///
/// 遍历规则列表,返回第一个匹配的决策。
/// 无匹配规则时默认 Allow。
pub fn check(&self, tool_name: &str) -> PermissionResult {
///
/// `tool_args` 用于内容级规则匹配(如 `"run_bash(rm *)"`)。
/// 传入 `None` 时仅进行工具名匹配。
pub fn check(
&self,
tool_name: &str,
tool_args: Option<&serde_json::Value>,
) -> PermissionResult {
for rule in &self.rules {
match rule {
PermissionRule::Deny {
tool_name: name,
reason,
} if Self::matches(name, tool_name) => {
..
} if Self::matches(name, tool_name, tool_args) => {
info!("[Permission] 拒绝工具 {}: {}", tool_name, reason);
return PermissionResult::Denied {
reason: reason.clone(),
};
}
PermissionRule::Allow { tool_name: name } if Self::matches(name, tool_name) => {
PermissionRule::Allow {
tool_name: name, ..
} if Self::matches(name, tool_name, tool_args) => {
return PermissionResult::Allowed;
}
PermissionRule::Ask {
tool_name: name,
message,
} if Self::matches(name, tool_name) => {
..
} if Self::matches(name, tool_name, tool_args) => {
return PermissionResult::AskUser {
message: message.clone(),
};
@ -86,13 +163,129 @@ impl PermissionChecker {
}
/// 检查是否有明确拒绝该工具的规则
pub fn is_denied(&self, tool_name: &str) -> bool {
self.check(tool_name).is_denied()
pub fn is_denied(&self, tool_name: &str, tool_args: Option<&serde_json::Value>) -> bool {
self.check(tool_name, tool_args).is_denied()
}
/// 规则名称匹配:支持精确匹配和通配符 "*"
fn matches(pattern: &str, tool_name: &str) -> bool {
pattern == "*" || pattern == tool_name
/// 根据当前权限模式转换检查结果。
///
/// - `Bypass`: AskUser → Allowed
/// - `DontAsk`: AskUser → Denied
/// - `AcceptEdits`: file_write/file_edit 在工作目录内时 AskUser → Allowed
/// - `Default`: 不变
///
/// Deny 规则在所有模式下都生效(不可覆盖)。
pub fn apply_mode(&self, result: PermissionResult, tool_name: &str) -> PermissionResult {
match self.mode {
PermissionMode::Bypass => match result {
PermissionResult::AskUser { .. } => {
info!(
"[Permission] Bypass 模式:跳过 AskUser 检查对 {}",
tool_name
);
PermissionResult::Allowed
}
other => other,
},
PermissionMode::DontAsk => match result {
PermissionResult::AskUser { .. } => {
info!("[Permission] DontAsk 模式:自动拒绝 {}", tool_name);
PermissionResult::Denied {
reason: "DontAsk 模式下需要确认的操作被自动拒绝".to_string(),
}
}
other => other,
},
PermissionMode::AcceptEdits => {
// AcceptEdits: 工作目录内的文件写入自动允许
// 具体的路径检查在 executor 中完成(需要 cwd 上下文)
// 此处仅做工具名级别判断
match &result {
PermissionResult::AskUser { .. }
if tool_name == "file_write" || tool_name == "file_edit" =>
{
info!("[Permission] AcceptEdits 模式:对 {} 暂保留 AskUserexecutor 中检查路径)", tool_name);
result // 留给 executor 做路径检查
}
_ => result,
}
}
PermissionMode::Default => result,
}
}
/// 规则名称匹配:支持精确匹配、通配符 "*",以及内容级匹配。
///
/// 内容级格式:`"tool_name(content_pattern)"`。
/// 示例:`"run_bash(rm *)"` 匹配 tool_name=run_bash 且 command 参数以 "rm " 开头的调用。
fn matches(pattern: &str, tool_name: &str, tool_args: Option<&serde_json::Value>) -> bool {
// 通配符匹配所有
if pattern == "*" {
return true;
}
// 解析 "tool_name(content_pattern)" 格式
if let Some(paren_pos) = pattern.find('(') {
if pattern.ends_with(')') {
let pattern_tool = &pattern[..paren_pos];
let pattern_content = &pattern[paren_pos + 1..pattern.len() - 1];
// 通配工具名或精确工具名匹配
let tool_matches = pattern_tool == "*" || pattern_tool == tool_name;
if !tool_matches {
return false;
}
// 内容级匹配:从工具参数中提取关键字段
return Self::content_matches(pattern_content, tool_args);
}
}
// 简单精确匹配
pattern == tool_name
}
/// 内容级匹配:从工具参数中提取关键内容字段并与模式比较。
///
/// 支持的模式:
/// - `prefix*` — 前缀通配
/// - `*suffix` — 后缀通配
/// - `exact` — 包含匹配(子串)
fn content_matches(pattern: &str, tool_args: Option<&serde_json::Value>) -> bool {
let args = match tool_args {
Some(a) => a,
None => return false,
};
// 提取工具的主要操作内容字段
let content = if let Some(cmd) = args.get("command").and_then(|v| v.as_str()) {
cmd
} else if let Some(fp) = args.get("file_path").and_then(|v| v.as_str()) {
fp
} else if let Some(p) = args.get("path").and_then(|v| v.as_str()) {
p
} else if let Some(pat) = args.get("pattern").and_then(|v| v.as_str()) {
pat
} else if let Some(url) = args.get("url").and_then(|v| v.as_str()) {
url
} else {
return false;
};
// 前缀通配: "prefix*"
if pattern.ends_with('*') && !pattern.starts_with('*') {
let prefix = &pattern[..pattern.len() - 1];
content.starts_with(prefix)
}
// 后缀通配: "*suffix"
else if pattern.starts_with('*') && !pattern.ends_with('*') {
let suffix = &pattern[1..];
content.ends_with(suffix)
}
// 包含匹配
else {
content.contains(pattern)
}
}
}
@ -102,16 +295,198 @@ impl Default for PermissionChecker {
}
}
impl PermissionChecker {
/// 从 AgentConfig 构建带规则的权限检查器。
///
/// 规则按优先级插入Deny → Ask → Allow先添加的优先级更高
pub fn from_config(config: &crate::agent::runtime::AgentConfig) -> Self {
let mut checker = Self::new();
checker.mode = PermissionMode::from_str(&config.permission_mode);
// Deny 优先
for rule_str in &config.permission_deny_rules {
if let Some(rule) = Self::parse_rule_str(rule_str, "deny") {
checker.add_rule(rule);
}
}
// 然后 Ask
for rule_str in &config.permission_ask_rules {
if let Some(rule) = Self::parse_rule_str(rule_str, "ask") {
checker.add_rule(rule);
}
}
// 最后 Allow
for rule_str in &config.permission_allow_rules {
if let Some(rule) = Self::parse_rule_str(rule_str, "allow") {
checker.add_rule(rule);
}
}
checker
}
/// 解析形如 `"tool_name(content_pattern)"` 或 `"tool_name"` 的规则字符串。
///
/// # Examples
///
/// ```text
/// "run_bash(rm *)" → Deny/Ask/Allow { tool_name: "run_bash(rm *)", ... }
/// "download_paper" → Deny/Ask/Allow { tool_name: "download_paper", ... }
/// "*(sudo)" → 工具通配 + 内容匹配
/// ```
pub fn parse_rule_str(
rule_str: &str,
kind: &str,
) -> Option<crate::agent::tools::PermissionRule> {
let trimmed = rule_str.trim();
if trimmed.is_empty() {
return None;
}
// 保留原始字符串作为 tool_namecontent_pattern 嵌入其中,在 matches() 中解析)
let tool_name = trimmed.to_string();
match kind {
"deny" => Some(PermissionRule::Deny {
tool_name,
reason: format!("环境变量规则禁止: {}", trimmed),
source: PermissionRuleSource::Env,
}),
"allow" => Some(PermissionRule::Allow {
tool_name,
source: PermissionRuleSource::Env,
}),
"ask" => Some(PermissionRule::Ask {
tool_name,
message: format!("是否允许执行: {}?", trimmed),
source: PermissionRuleSource::Env,
}),
_ => None,
}
}
}
// ── Shadowed Rule Detection ──
/// 被遮蔽的规则
#[derive(Debug, Clone)]
pub struct ShadowedRule {
/// 被遮蔽的规则
pub rule: PermissionRule,
/// 遮蔽原因
pub reason: String,
/// 修复建议
pub fix: String,
/// 遮蔽类型: "deny" 或 "ask"
pub shadow_type: String,
}
impl PermissionChecker {
/// 检测被遮蔽的 Allow 规则。
///
/// 检查两种遮蔽:
/// 1. Deny 遮蔽 — 工具级 Deny 规则使具体 Allow 规则永远无法生效
/// 2. Ask 遮蔽 — 工具级 Ask 规则使具体 Allow 规则被绕过(用户仍会被询问)
///
/// 仅检测有具体内容的 Allow 规则(如 `"run_bash(ls *)"`
/// 工具级 Allow 规则不会被遮蔽(本身已覆盖所有)。
pub fn detect_shadowed_rules(&self) -> Vec<ShadowedRule> {
let mut shadowed: Vec<ShadowedRule> = Vec::new();
// 收集工具级 Deny 和 Ask 规则(无 content_pattern 的规则)
let tool_deny_names: std::collections::HashSet<String> = self
.rules
.iter()
.filter_map(|r| match r {
PermissionRule::Deny {
tool_name,
reason: _,
..
} if !tool_name.contains('(') => Some(tool_name.clone()),
_ => None,
})
.collect();
let tool_ask_names: std::collections::HashSet<String> = self
.rules
.iter()
.filter_map(|r| match r {
PermissionRule::Ask {
tool_name,
message: _,
..
} if !tool_name.contains('(') => Some(tool_name.clone()),
_ => None,
})
.collect();
// 检查具体的 Allow 规则是否被遮蔽
for rule in &self.rules {
if let PermissionRule::Allow {
tool_name,
source: _,
} = rule
{
// 只检查有内容的规则(如 "run_bash(ls *)"
if tool_name.contains('(') {
let base_tool = tool_name.split('(').next().unwrap_or(tool_name);
// Deny 遮蔽(更严重)
if tool_deny_names.contains(base_tool) {
shadowed.push(ShadowedRule {
rule: rule.clone(),
reason: format!(
"Allow 规则 {} 被工具级 Deny 规则 {} 完全遮蔽,永远无法生效",
tool_name, base_tool
),
fix: format!(
"移除 Deny 规则 {} 或 Allow 规则 {}",
base_tool, tool_name
),
shadow_type: "deny".to_string(),
});
continue;
}
// Ask 遮蔽(中等)
if tool_ask_names.contains(base_tool) {
shadowed.push(ShadowedRule {
rule: rule.clone(),
reason: format!(
"Allow 规则 {} 被工具级 Ask 规则 {} 遮蔽,用户仍会被询问",
tool_name, base_tool
),
fix: format!("移除 Ask 规则 {} 或 Allow 规则 {}", base_tool, tool_name),
shadow_type: "ask".to_string(),
});
}
}
}
}
shadowed
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::tools::PermissionRule;
use crate::agent::tools::{PermissionRule, PermissionRuleSource};
// 辅助函数:无 args 的快速检查
fn check_no_args(checker: &PermissionChecker, tool_name: &str) -> PermissionResult {
checker.check(tool_name, None)
}
#[test]
fn test_empty_checker_allows_all() {
let checker = PermissionChecker::new();
assert_eq!(checker.check("search_papers"), PermissionResult::Allowed);
assert_eq!(checker.check("download_paper"), PermissionResult::Allowed);
assert_eq!(
check_no_args(&checker, "search_papers"),
PermissionResult::Allowed
);
assert_eq!(
check_no_args(&checker, "download_paper"),
PermissionResult::Allowed
);
}
#[test]
@ -120,12 +495,14 @@ mod tests {
checker.add_rule(PermissionRule::Deny {
tool_name: "download_paper".into(),
reason: "blocked".into(),
source: PermissionRuleSource::Env,
});
checker.add_rule(PermissionRule::Allow {
tool_name: "download_paper".into(),
source: PermissionRuleSource::Env,
});
let result = checker.check("download_paper");
let result = check_no_args(&checker, "download_paper");
assert!(result.is_denied());
}
@ -135,11 +512,12 @@ mod tests {
checker.add_rule(PermissionRule::Deny {
tool_name: "*".into(),
reason: "all blocked".into(),
source: PermissionRuleSource::Env,
});
assert!(checker.check("search_papers").is_denied());
assert!(checker.check("download_paper").is_denied());
assert!(checker.is_denied("rag_search"));
assert!(check_no_args(&checker, "search_papers").is_denied());
assert!(check_no_args(&checker, "download_paper").is_denied());
assert!(checker.is_denied("rag_search", None));
}
#[test]
@ -148,9 +526,10 @@ mod tests {
checker.add_rule(PermissionRule::Ask {
tool_name: "delete_paper".into(),
message: "Are you sure?".into(),
source: PermissionRuleSource::Env,
});
let result = checker.check("delete_paper");
let result = check_no_args(&checker, "delete_paper");
assert_eq!(
result,
PermissionResult::AskUser {
@ -165,9 +544,13 @@ mod tests {
checker.add_rule(PermissionRule::Deny {
tool_name: "download_paper".into(),
reason: "blocked".into(),
source: PermissionRuleSource::Env,
});
assert_eq!(checker.check("search_papers"), PermissionResult::Allowed);
assert_eq!(
check_no_args(&checker, "search_papers"),
PermissionResult::Allowed
);
}
#[test]
@ -176,12 +559,264 @@ mod tests {
// 先添加 Allow后添加 Deny — Allow 先匹配
checker.add_rule(PermissionRule::Allow {
tool_name: "search_papers".into(),
source: PermissionRuleSource::Env,
});
checker.add_rule(PermissionRule::Deny {
tool_name: "search_papers".into(),
reason: "should not match".into(),
source: PermissionRuleSource::Env,
});
assert_eq!(checker.check("search_papers"), PermissionResult::Allowed);
assert_eq!(
check_no_args(&checker, "search_papers"),
PermissionResult::Allowed
);
}
// ── 内容级匹配测试 ──
#[test]
fn test_content_pattern_prefix_wildcard() {
let mut checker = PermissionChecker::new();
checker.add_rule(PermissionRule::Deny {
tool_name: "run_bash(rm *)".into(),
reason: "dangerous rm".into(),
source: PermissionRuleSource::Env,
});
// "rm *" 前缀匹配 "rm -rf /"
let args = serde_json::json!({"command": "rm -rf /"});
let result = checker.check("run_bash", Some(&args));
assert!(result.is_denied());
}
#[test]
fn test_content_pattern_exact_contains() {
let mut checker = PermissionChecker::new();
checker.add_rule(PermissionRule::Deny {
tool_name: "run_bash(sudo)".into(),
reason: "no sudo".into(),
source: PermissionRuleSource::Env,
});
// "sudo" 包含匹配 "sudo systemctl restart"
let args = serde_json::json!({"command": "sudo systemctl restart"});
let result = checker.check("run_bash", Some(&args));
assert!(result.is_denied());
}
#[test]
fn test_content_pattern_no_match_different_command() {
let mut checker = PermissionChecker::new();
checker.add_rule(PermissionRule::Deny {
tool_name: "run_bash(rm *)".into(),
reason: "dangerous rm".into(),
source: PermissionRuleSource::Env,
});
// "ls -la" 不匹配 "rm *"
let args = serde_json::json!({"command": "ls -la"});
let result = checker.check("run_bash", Some(&args));
assert!(result.is_allowed());
}
#[test]
fn test_content_pattern_no_args_defaults_no_match() {
let mut checker = PermissionChecker::new();
checker.add_rule(PermissionRule::Deny {
tool_name: "run_bash(rm *)".into(),
reason: "dangerous rm".into(),
source: PermissionRuleSource::Env,
});
// 无 args → 内容模式不匹配 → 回退到默认允许
let result = checker.check("run_bash", None);
assert!(result.is_allowed());
}
#[test]
fn test_content_pattern_wildcard_tool_name() {
let mut checker = PermissionChecker::new();
checker.add_rule(PermissionRule::Deny {
tool_name: "*(sudo)".into(),
reason: "no sudo on any tool".into(),
source: PermissionRuleSource::Env,
});
// "*" 工具通配 + 内容级匹配
let args = serde_json::json!({"command": "sudo rm -rf /"});
assert!(checker.check("run_bash", Some(&args)).is_denied());
// 不同工具名也匹配
assert!(checker.check("other_tool", Some(&args)).is_denied());
}
#[test]
fn test_content_pattern_file_path() {
let mut checker = PermissionChecker::new();
checker.add_rule(PermissionRule::Ask {
tool_name: "file_write(/etc/*)".into(),
message: "Writing to /etc/?".into(),
source: PermissionRuleSource::Env,
});
// file_path 字段匹配
let args = serde_json::json!({"file_path": "/etc/hosts"});
let result = checker.check("file_write", Some(&args));
assert!(matches!(result, PermissionResult::AskUser { .. }));
}
#[test]
fn test_content_pattern_suffix_wildcard() {
let mut checker = PermissionChecker::new();
checker.add_rule(PermissionRule::Deny {
tool_name: "read_file(*.env)".into(),
reason: "no env files".into(),
source: PermissionRuleSource::Env,
});
// 后缀通配: "*.env" 匹配以 ".env" 结尾的文件名
let args = serde_json::json!({"file_path": ".env"});
assert!(checker.check("read_file", Some(&args)).is_denied());
let args2 = serde_json::json!({"file_path": "prod.env"});
assert!(checker.check("read_file", Some(&args2)).is_denied());
// ".env.production" 不以 ".env" 结尾 → 不匹配
let args3 = serde_json::json!({"file_path": ".env.production"});
assert!(checker.check("read_file", Some(&args3)).is_allowed());
}
#[test]
fn test_content_pattern_path_field() {
let mut checker = PermissionChecker::new();
checker.add_rule(PermissionRule::Allow {
tool_name: "grep_files(src/*)".into(),
source: PermissionRuleSource::Env,
});
// path 字段匹配
let args = serde_json::json!({"path": "src/"});
let result = checker.check("grep_files", Some(&args));
assert!(result.is_allowed());
}
#[test]
fn test_is_denied_with_args() {
let mut checker = PermissionChecker::new();
checker.add_rule(PermissionRule::Deny {
tool_name: "run_bash(rm *)".into(),
reason: "no rm".into(),
source: PermissionRuleSource::Env,
});
let args = serde_json::json!({"command": "rm -rf /"});
assert!(checker.is_denied("run_bash", Some(&args)));
assert!(!checker.is_denied("run_bash", None));
}
// ── 权限模式测试 ──
#[test]
fn test_mode_bypass_converts_ask_to_allowed() {
let mut checker = PermissionChecker::new();
checker.mode = PermissionMode::Bypass;
checker.add_rule(PermissionRule::Ask {
tool_name: "run_bash".into(),
message: "confirm?".into(),
source: PermissionRuleSource::Env,
});
let raw = check_no_args(&checker, "run_bash");
assert!(matches!(raw, PermissionResult::AskUser { .. }));
let result = checker.apply_mode(raw, "run_bash");
// AskUser → Allowed in Bypass mode
assert_eq!(result, PermissionResult::Allowed);
}
#[test]
fn test_mode_bypass_respects_deny() {
let mut checker = PermissionChecker::new();
checker.mode = PermissionMode::Bypass;
checker.add_rule(PermissionRule::Deny {
tool_name: "run_bash".into(),
reason: "blocked".into(),
source: PermissionRuleSource::Env,
});
let raw = check_no_args(&checker, "run_bash");
assert!(raw.is_denied());
let result = checker.apply_mode(raw, "run_bash");
// Deny survives Bypass
assert!(result.is_denied());
}
#[test]
fn test_mode_dontask_converts_ask_to_denied() {
let mut checker = PermissionChecker::new();
checker.mode = PermissionMode::DontAsk;
checker.add_rule(PermissionRule::Ask {
tool_name: "file_write".into(),
message: "confirm?".into(),
source: PermissionRuleSource::Env,
});
let raw = check_no_args(&checker, "file_write");
assert!(matches!(raw, PermissionResult::AskUser { .. }));
let result = checker.apply_mode(raw, "file_write");
// AskUser → Denied in DontAsk mode
assert!(result.is_denied());
}
#[test]
fn test_mode_default_unchanged() {
let mut checker = PermissionChecker::new();
checker.mode = PermissionMode::Default;
checker.add_rule(PermissionRule::Ask {
tool_name: "run_bash".into(),
message: "confirm?".into(),
source: PermissionRuleSource::Env,
});
let raw = check_no_args(&checker, "run_bash");
assert!(matches!(raw, PermissionResult::AskUser { .. }));
let result = checker.apply_mode(raw, "run_bash");
// Default: AskUser unchanged
assert!(matches!(result, PermissionResult::AskUser { .. }));
}
#[test]
fn test_mode_from_str() {
assert_eq!(PermissionMode::from_str("default"), PermissionMode::Default);
assert_eq!(PermissionMode::from_str("bypass"), PermissionMode::Bypass);
assert_eq!(
PermissionMode::from_str("accept_edits"),
PermissionMode::AcceptEdits
);
assert_eq!(
PermissionMode::from_str("accept-edits"),
PermissionMode::AcceptEdits
);
assert_eq!(
PermissionMode::from_str("dont_ask"),
PermissionMode::DontAsk
);
assert_eq!(PermissionMode::from_str("dontask"), PermissionMode::DontAsk);
assert_eq!(PermissionMode::from_str("unknown"), PermissionMode::Default);
}
#[test]
fn test_mode_accept_edits_preserves_ask_for_edit_tools() {
let mut checker = PermissionChecker::new();
checker.mode = PermissionMode::AcceptEdits;
checker.add_rule(PermissionRule::Ask {
tool_name: "file_write".into(),
message: "confirm?".into(),
source: PermissionRuleSource::Env,
});
let raw = check_no_args(&checker, "file_write");
let result = checker.apply_mode(raw, "file_write");
// AcceptEdits 保留 AskUser 让 executor 做路径检查
assert!(matches!(result, PermissionResult::AskUser { .. }));
}
}

View File

@ -0,0 +1,195 @@
// src/agent/runtime/permission_explainer.rs
//
// 权限风险解释器 — 生成人类可读的工具调用风险描述。
// 参考 Claude Code permissionExplainer.ts 设计。
//
// 当前实现:基于工具名和参数的启发式规则(零延迟)。
// 远期增强:调用小型 LLM 生成更详细的风险评估。
//
// 输出格式:{ risk_level: "LOW"|"MEDIUM"|"HIGH", explanation, reasoning, risk }
/// 权限解释结果
#[derive(Debug, Clone, serde::Serialize)]
pub struct PermissionExplanation {
/// 风险等级
pub risk_level: String,
/// 该操作做什么(一句话)
pub explanation: String,
/// 为什么需要执行此操作
pub reasoning: String,
/// 可能出现什么问题
pub risk: String,
}
/// 基于启发式规则生成权限请求的解释说明。
///
/// 根据工具名称和参数内容生成结构化的风险描述,
/// 帮助用户理解 Agent 请求的操作及其潜在风险。
pub fn explain_permission(tool_name: &str, args: &serde_json::Value) -> PermissionExplanation {
match tool_name {
"run_bash" => explain_bash(args),
"file_write" | "file_edit" => explain_file_write(tool_name, args),
"download_paper" => PermissionExplanation {
risk_level: "LOW".into(),
explanation: "下载学术论文 PDF/HTML".into(),
reasoning: "为获取论文全文进行阅读和解析".into(),
risk: "可能下载较大的文件或遇到网络错误".into(),
},
"search_papers" | "get_paper_metadata" | "get_paper_content" | "rag_search"
| "query_target" | "read_file" | "grep_files" | "glob_files" | "load_skill" => {
PermissionExplanation {
risk_level: "LOW".into(),
explanation: format!("执行只读操作: {}", tool_name),
reasoning: "获取信息以完成任务".into(),
risk: "只读操作,无副作用".into(),
}
}
"save_note" | "save_memory" | "todo_write" => PermissionExplanation {
risk_level: "LOW".into(),
explanation: format!("保存数据: {}", tool_name),
reasoning: "持久化重要信息供后续使用".into(),
risk: "写入本地文件系统,但范围受限".into(),
},
"subagent" => PermissionExplanation {
risk_level: "MEDIUM".into(),
explanation: "启动子代理执行子任务".into(),
reasoning: "将复杂任务分解为独立子任务并行处理".into(),
risk: "子代理拥有完整的工具访问权限".into(),
},
_ => PermissionExplanation {
risk_level: "MEDIUM".into(),
explanation: format!("执行工具: {}", tool_name),
reasoning: "Agent 需要此操作来完成任务".into(),
risk: "请确认此操作符合预期".into(),
},
}
}
fn explain_bash(args: &serde_json::Value) -> PermissionExplanation {
let command = args
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("(未知命令)");
// 判断风险等级
let risk_level = if command.contains("rm ")
|| command.contains("sudo")
|| command.contains("chmod")
|| command.contains("chown")
|| command.contains("mkfs")
|| command.contains("dd ")
|| command.contains("> /")
{
"HIGH"
} else if command.contains("pip ")
|| command.contains("npm ")
|| command.contains("cargo ")
|| command.contains("apt ")
|| command.contains("yum ")
|| command.contains("brew ")
|| command.contains("curl ")
|| command.contains("wget ")
|| command.contains("git clone")
{
"MEDIUM"
} else {
"LOW"
};
let explanation = if command.len() > 80 {
format!("执行 Shell 命令: {}...", &command[..80])
} else {
format!("执行 Shell 命令: {}", command)
};
let (reasoning, risk) = match risk_level {
"HIGH" => (
"Agent 需要执行系统级操作".into(),
"该命令可能删除文件、修改系统权限或写入设备".into(),
),
"MEDIUM" => (
"Agent 需要安装依赖或访问网络资源".into(),
"该命令可能下载外部包、修改环境或访问远程服务".into(),
),
_ => (
"Agent 需要运行只读或数据处理命令".into(),
"该命令为安全命令,风险较低".into(),
),
};
PermissionExplanation {
risk_level: risk_level.into(),
explanation,
reasoning,
risk,
}
}
fn explain_file_write(tool_name: &str, args: &serde_json::Value) -> PermissionExplanation {
let file_path = args
.get("file_path")
.and_then(|v| v.as_str())
.unwrap_or("(未知路径)");
let is_system_path = file_path.starts_with("/etc/")
|| file_path.starts_with("/usr/")
|| file_path.starts_with("/boot/")
|| file_path.starts_with("/sys/")
|| file_path == "/etc"
|| file_path.starts_with("/var/");
let risk_level = if is_system_path { "HIGH" } else { "MEDIUM" };
let action = if tool_name == "file_write" {
"写入文件"
} else {
"编辑文件"
};
PermissionExplanation {
risk_level: risk_level.into(),
explanation: format!("{}: {}", action, file_path),
reasoning: "Agent 需要创建或修改文件以完成任务".into(),
risk: if is_system_path {
"修改系统配置文件可能影响系统运行".into()
} else {
"修改工作目录内的文件".into()
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_explain_safe_bash() {
let e = explain_permission("run_bash", &json!({"command": "ls -la"}));
assert_eq!(e.risk_level, "LOW");
}
#[test]
fn test_explain_dangerous_bash() {
let e = explain_permission("run_bash", &json!({"command": "rm -rf /tmp/*"}));
assert_eq!(e.risk_level, "HIGH");
}
#[test]
fn test_explain_file_write_system() {
let e = explain_permission("file_write", &json!({"file_path": "/etc/hosts"}));
assert_eq!(e.risk_level, "HIGH");
}
#[test]
fn test_explain_read_only() {
let e = explain_permission("read_file", &json!({}));
assert_eq!(e.risk_level, "LOW");
assert!(e.risk.contains("只读"));
}
#[test]
fn test_explain_subagent() {
let e = explain_permission("subagent", &json!({}));
assert_eq!(e.risk_level, "MEDIUM");
}
}

View File

@ -0,0 +1,177 @@
// src/agent/runtime/permission_profile.rs
//
// 权限配置档案 — 预定义的权限规则集。
// 通过 AGENT_PERMISSION_PROFILE 环境变量选择:
// "readonly" — 只读访问
// "research" — 科研模式(允许 I/O 但禁止危险 Shell
// (空/未设置) — 使用 AGENT_PERMISSIONS_* 自定义规则
//
// 档案包含 TOML 文件定义profiles/ 目录)和内置常量两种形式。
// 运行时优先从 profiles/ 目录加载同名 .toml 文件,
// 未找到文件时回退到内置常量定义。
/// 序列化为 PermissionChecker 可用的规则格式
#[derive(Debug, Clone)]
pub struct ResolvedProfile {
pub name: String,
pub description: String,
pub deny_rules: Vec<String>,
pub allow_rules: Vec<String>,
pub ask_rules: Vec<String>,
pub mode: String,
}
/// 获取内置只读档案
fn builtin_readonly() -> ResolvedProfile {
ResolvedProfile {
name: "readonly".into(),
description: "只读访问 — 禁止 Shell 执行、文件写入、论文下载/解析".into(),
deny_rules: vec![
"run_bash".into(),
"file_write".into(),
"file_edit".into(),
"download_paper".into(),
"parse_paper".into(),
"subagent".into(),
],
allow_rules: vec![
"read_file".into(),
"grep_files".into(),
"glob_files".into(),
"search_papers".into(),
"get_paper_metadata".into(),
"get_paper_content".into(),
"rag_search".into(),
"query_target".into(),
"load_skill".into(),
],
ask_rules: vec![],
mode: "default".into(),
}
}
/// 获取内置科研档案
fn builtin_research() -> ResolvedProfile {
ResolvedProfile {
name: "research".into(),
description: "科研模式 — 允许文件 I/O、文献下载禁止危险 Shell 命令".into(),
deny_rules: vec![
"run_bash(rm *)".into(),
"run_bash(sudo)".into(),
"run_bash(chmod)".into(),
"run_bash(chown)".into(),
"run_bash(mkfs)".into(),
"run_bash(dd )".into(),
"run_bash(> /)".into(),
],
allow_rules: vec![
"read_file".into(),
"grep_files".into(),
"glob_files".into(),
"search_papers".into(),
"get_paper_metadata".into(),
"get_paper_content".into(),
"download_paper".into(),
"parse_paper".into(),
"rag_search".into(),
"query_target".into(),
"save_note".into(),
"load_skill".into(),
],
ask_rules: vec!["run_bash".into(), "file_write".into(), "file_edit".into()],
mode: "default".into(),
}
}
/// 加载指定名称的权限档案。
///
/// 从内置定义中查找。返回 None 表示档案名称无效。
/// 未来可扩展为从 profiles/*.toml 文件加载(需要添加 toml 依赖)。
pub fn load_profile(name: &str) -> Option<ResolvedProfile> {
match name {
"readonly" => Some(builtin_readonly()),
"research" => Some(builtin_research()),
_ => None,
}
}
/// 列出所有可用档案
pub fn list_available_profiles() -> Vec<String> {
vec!["readonly".to_string(), "research".to_string()]
}
/// 应用档案到 AgentConfig 的权限字段(不可变更新)
pub fn apply_profile_to_config(
profile: &ResolvedProfile,
deny_rules: &mut Vec<String>,
allow_rules: &mut Vec<String>,
ask_rules: &mut Vec<String>,
mode: &mut String,
) {
// 档案规则追加到环境变量规则之后(环境变量规则优先级更高)
for r in &profile.deny_rules {
if !deny_rules.contains(r) {
deny_rules.push(r.clone());
}
}
for r in &profile.allow_rules {
if !allow_rules.contains(r) {
allow_rules.push(r.clone());
}
}
for r in &profile.ask_rules {
if !ask_rules.contains(r) {
ask_rules.push(r.clone());
}
}
if *mode == "default" && profile.mode != "default" {
*mode = profile.mode.clone();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_load_readonly_builtin() {
let profile = load_profile("readonly").expect("readonly profile should exist");
assert_eq!(profile.name, "readonly");
assert!(profile.deny_rules.contains(&"run_bash".to_string()));
assert!(profile.allow_rules.contains(&"read_file".to_string()));
}
#[test]
fn test_load_research_builtin() {
let profile = load_profile("research").expect("research profile should exist");
assert_eq!(profile.name, "research");
assert!(profile.ask_rules.contains(&"run_bash".to_string()));
}
#[test]
fn test_load_invalid_returns_none() {
assert!(load_profile("nonexistent").is_none());
}
#[test]
fn test_list_profiles_includes_builtins() {
let profiles = list_available_profiles();
assert!(profiles.contains(&"readonly".to_string()));
assert!(profiles.contains(&"research".to_string()));
}
#[test]
fn test_apply_profile_to_config() {
let profile = load_profile("readonly").unwrap();
let mut deny = vec!["custom_deny".to_string()];
let mut allow = Vec::new();
let mut ask = Vec::new();
let mut mode = "default".to_string();
apply_profile_to_config(&profile, &mut deny, &mut allow, &mut ask, &mut mode);
assert!(deny.contains(&"custom_deny".to_string())); // 原规则保留
assert!(deny.contains(&"run_bash".to_string())); // 档案规则追加
assert!(allow.contains(&"read_file".to_string()));
}
}

View File

@ -43,9 +43,10 @@ pub async fn process_llm_stream(
step: usize,
session_id: &str,
cancelled_runs: Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
enable_thinking: bool,
) -> StreamOutput {
// 1. 发起 LLM 流式调用
let mut stream_rx = match llm.chat_stream(messages, tool_defs).await {
let mut stream_rx = match llm.chat_stream(messages, tool_defs, enable_thinking).await {
Ok(rx) => rx,
Err(e) => {
error!("[Streaming] LLM stream 调用失败: {}", e);

View File

@ -2,7 +2,7 @@
//
// 子代理运行器 — 上下文隔离子代理(参考 Claude Code s04 Subagents
//
// 父代理通过 delegate_research 工具将子任务委托给子代理执行。
// 父代理通过 subagent 工具将子任务委托给子代理执行。
// 子代理拥有:
// - 全新的 messages 上下文(不包含父代理的中间工具调用)
// - 完整的工具访问权限(与父代理共享 ToolRegistry
@ -17,10 +17,9 @@ use tracing::{info, warn};
use super::compact;
use super::hooks::{
HookRegistry, PostToolUseContext, PreToolUseContext, SubagentStartContext,
SubagentStopContext,
HookRegistry, PostToolUseContext, PreToolUseContext, SubagentStartContext, SubagentStopContext,
};
use super::runtime::permission::PermissionChecker;
use super::runtime::permission::{PermissionChecker, PermissionResult};
use super::runtime::{AgentConfig, AgentStreamEvent};
use super::tools::{ToolContext, ToolOutput, ToolRegistry};
use crate::api::AppState;
@ -37,6 +36,8 @@ pub struct SubAgentRunner {
permission_checker: Arc<PermissionChecker>,
/// 可选的进度发送器(用于向父代理报告中间步骤)
progress_tx: Option<UnboundedSender<AgentStreamEvent>>,
/// 父代理的会话 ID用于子代理消息的数据库持久化
parent_session_id: String,
}
impl SubAgentRunner {
@ -50,6 +51,7 @@ impl SubAgentRunner {
hook_registry: None,
permission_checker: Arc::new(PermissionChecker::new()),
progress_tx: None,
parent_session_id: String::new(),
}
}
@ -68,9 +70,22 @@ impl SubAgentRunner {
hook_registry,
permission_checker,
progress_tx,
parent_session_id: String::new(),
}
}
/// 设置父代理会话 ID调用者应在 run 之前设置)
pub fn with_parent_session(mut self, session_id: String) -> Self {
self.parent_session_id = session_id;
self
}
/// 设置是否启用 LLM 思考模式
pub fn with_thinking(mut self, enable: bool) -> Self {
self.config.enable_thinking = enable;
self
}
/// 使用自定义 ToolRegistry 创建子代理运行器。
/// 用于受限场景(如记忆提取子代理仅需只读 + save_memory
pub fn new_with_registry(app_state: Arc<AppState>, tool_registry: ToolRegistry) -> Self {
@ -81,6 +96,7 @@ impl SubAgentRunner {
hook_registry: None,
permission_checker: Arc::new(PermissionChecker::new()),
progress_tx: None,
parent_session_id: String::new(),
}
}
@ -90,29 +106,51 @@ impl SubAgentRunner {
/// * `system_prompt` - 子代理的系统提示词
/// * `research_prompt` - 要执行的研究任务描述
/// * `max_steps` - 子代理最大推理步数(默认 5
/// * `hook_registry` - 可选的 HookRegistry用于触发子代理生命周期事件
pub async fn run(
&self,
system_prompt: &str,
research_prompt: &str,
max_steps: usize,
) -> ToolOutput {
let subagent_name = "delegate_research";
let subagent_name = format!("sub_{}", &uuid::Uuid::new_v4().to_string()[..8]);
// OnSubagentStart hook
if let Some(ref registry) = self.hook_registry {
registry
.run_on_subagent_start(&SubagentStartContext {
parent_session_id: String::new(),
subagent_name: subagent_name.to_string(),
parent_session_id: self.parent_session_id.clone(),
subagent_name: subagent_name.clone(),
prompt: research_prompt.to_string(),
})
.await;
}
// 保存子代理的 system prompt + user prompt 到数据库
self.save_subagent_message(
&subagent_name,
0,
0,
"system",
system_prompt,
None,
None,
None,
);
self.save_subagent_message(
&subagent_name,
0,
1,
"user",
research_prompt,
None,
None,
None,
);
// 执行实际工作并捕获结果,以便触发 OnSubagentStop hook
let result = self
.run_inner(system_prompt, research_prompt, max_steps)
.run_inner(system_prompt, research_prompt, max_steps, &subagent_name)
.await;
let (is_error, result_summary) = if result.is_error {
(true, result.content.clone())
@ -120,11 +158,23 @@ impl SubAgentRunner {
(false, result.content.chars().take(200).collect())
};
// 保存最终结果
self.save_subagent_message(
&subagent_name,
0,
max_steps as i32 + 1,
"assistant",
&result.content,
None,
None,
None,
);
if let Some(ref registry) = self.hook_registry {
registry
.run_on_subagent_stop(&SubagentStopContext {
parent_session_id: String::new(),
subagent_name: subagent_name.to_string(),
parent_session_id: self.parent_session_id.clone(),
subagent_name: subagent_name.clone(),
result_summary,
steps: max_steps,
is_error,
@ -135,12 +185,62 @@ impl SubAgentRunner {
result
}
/// 保存子代理消息到 agent_messages 表
#[allow(clippy::too_many_arguments)]
fn save_subagent_message(
&self,
agent_name: &str,
turn_index: i32,
step_index: i32,
role: &str,
content: &str,
thought: Option<&str>,
tool_calls_json: Option<&str>,
tool_call_id: Option<&str>,
) {
if self.parent_session_id.is_empty() {
return;
}
let db = self.app_state.db.clone();
let session_id = self.parent_session_id.clone();
let agent = agent_name.to_string();
let role_owned = role.to_string();
let content_owned = content.to_string();
let thought_owned = thought.map(|s| s.to_string());
let tc_json = tool_calls_json.map(|s| s.to_string());
let tc_id = tool_call_id.map(|s| s.to_string());
let metadata = serde_json::json!({ "agent": agent, "is_subagent": true });
let metadata_str = serde_json::to_string(&metadata).unwrap_or_default();
tokio::spawn(async move {
let token_count = content_owned.len() as i32 / 4;
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)
.bind(step_index)
.bind(&role_owned)
.bind(&content_owned)
.bind(&thought_owned)
.bind(&tc_json)
.bind(&tc_id)
.bind(token_count)
.bind(&metadata_str)
.bind(&agent)
.execute(&db)
.await;
});
}
/// 实际执行逻辑(提取为内部方法以便 hook 包装)
async fn run_inner(
&self,
system_prompt: &str,
research_prompt: &str,
max_steps: usize,
subagent_name: &str,
) -> ToolOutput {
let llm = &self.app_state.llm;
let tool_defs = self.tool_registry.definitions();
@ -177,7 +277,10 @@ impl SubAgentRunner {
}
// LLM 流式调用
let mut stream_rx = match llm.chat_stream(&messages, &tool_defs).await {
let mut stream_rx = match llm
.chat_stream(&messages, &tool_defs, self.config.enable_thinking)
.await
{
Ok(rx) => rx,
Err(e) => {
warn!("[SubAgent] LLM stream 失败: {}", e);
@ -186,15 +289,40 @@ impl SubAgentRunner {
};
let mut accumulated_content = String::new();
let mut accumulated_reasoning = String::new();
let mut accumulated_tool_calls: Option<Vec<crate::clients::llm::ToolCall>> = None;
let mut activity_log: Vec<String> = Vec::new();
while let Some(event) = stream_rx.recv().await {
match event {
StreamEvent::ReasoningDelta(delta) => {
accumulated_reasoning.push_str(&delta);
// 转发子代理思考过程到父代理
if let Some(ref tx) = self.progress_tx {
let _ = tx.send(AgentStreamEvent::Thought {
content: format!("[子代理] {}", accumulated_reasoning),
step,
});
}
}
StreamEvent::TextDelta(delta) => {
accumulated_content.push_str(&delta);
}
StreamEvent::ToolCallsComplete(tool_calls) => {
accumulated_tool_calls = Some(tool_calls);
// 确保每个工具调用有唯一 IDLLM 可能不返回 id
let fixed_tool_calls: Vec<crate::clients::llm::ToolCall> = tool_calls
.into_iter()
.map(|tc| {
let id = if tc.id.is_empty() {
format!("call_{}", &uuid::Uuid::new_v4().to_string()[..8])
} else {
tc.id
};
activity_log.push(format!("🔧 调用工具: {}", tc.function.name));
crate::clients::llm::ToolCall { id, ..tc }
})
.collect();
accumulated_tool_calls = Some(fixed_tool_calls);
}
StreamEvent::Done => break,
StreamEvent::Error(e) => {
@ -205,26 +333,38 @@ impl SubAgentRunner {
}
}
// 记录思考过程
if !accumulated_reasoning.is_empty() {
activity_log.push(format!(
"💭 思考: {}",
accumulated_reasoning.chars().take(300).collect::<String>()
));
}
// 无工具调用 = 最终回答
let tool_calls = match accumulated_tool_calls {
Some(ref tc) if !tc.is_empty() => tc.clone(),
_ => {
// 转发最终文本到父代理
// 转发子代理结论到父代理(作为 thought 显示在时间线,不污染 finalAnswer
if let Some(ref tx) = self.progress_tx {
let _ = tx.send(AgentStreamEvent::TextDelta {
content: format!(
"[子代理] {}",
accumulated_content.chars().take(200).collect::<String>()
),
let _ = tx.send(AgentStreamEvent::Thought {
content: accumulated_content.clone(),
step,
});
}
let content_len = accumulated_content.len();
info!("[SubAgent] 子代理完成,返回 {} 字符摘要", content_len);
let summary = format!(
"[子代理活动记录]\n\n{}\n\n[子代理结论]\n\n{}",
activity_log.join("\n"),
accumulated_content
);
return ToolOutput::success(
accumulated_content,
summary,
serde_json::json!({
"steps": step,
"content_length": content_len
"content_length": content_len,
"tool_calls": activity_log.iter().filter(|e| e.starts_with("🔧")).count(),
}),
);
}
@ -240,6 +380,18 @@ impl SubAgentRunner {
None,
Some(tool_calls.clone()),
);
// 持久化 assistant 消息
let tc_json = serde_json::to_string(&tool_calls).unwrap_or_default();
self.save_subagent_message(
subagent_name,
0,
step as i32,
"assistant",
&accumulated_content,
None,
Some(&tc_json),
None,
);
messages.push(assistant_msg);
// 执行工具调用
@ -272,10 +424,8 @@ impl SubAgentRunner {
let args: serde_json::Value = match serde_json::from_str(tool_args_str) {
Ok(v) => v,
Err(e) => {
let error_msg = ChatMessage::tool_result(
&tool_call.id,
format!("参数解析失败: {}", e),
);
let error_msg =
ChatMessage::tool_result(&tool_call.id, format!("参数解析失败: {}", e));
messages.push(error_msg);
continue;
}
@ -284,6 +434,7 @@ impl SubAgentRunner {
// ── 向父代理发送进度事件 ──
if let Some(ref tx) = self.progress_tx {
let _ = tx.send(AgentStreamEvent::ToolCall {
id: tool_call.id.clone(),
name: format!("[sub] {}", tool_name),
arguments: args.clone(),
step,
@ -316,6 +467,16 @@ impl SubAgentRunner {
&tool_call.id,
format!("工具 {} 被阻止: {}", tool_name, reason),
);
self.save_subagent_message(
subagent_name,
0,
step as i32,
"tool",
&format!("工具 {} 被阻止: {}", tool_name, reason),
None,
None,
Some(&tool_call.id),
);
messages.push(tool_msg);
continue;
}
@ -325,16 +486,53 @@ impl SubAgentRunner {
args.clone()
};
// Permission check
if self.permission_checker.is_denied(tool_name) {
warn!("[SubAgent] 权限检查拒绝工具: {}", tool_name);
let tool_msg = ChatMessage::tool_result(
&tool_call.id,
format!("工具 {} 在子代理上下文中不可用(权限不足)", tool_name),
// Permission check — 完整三态检查(包含内容级匹配)
let perm_result = self.permission_checker.check(tool_name, Some(&final_args));
match perm_result {
PermissionResult::Denied { reason } => {
warn!("[SubAgent] 权限检查拒绝工具 {}: {}", tool_name, reason);
let err_msg =
format!("工具 {} 在子代理上下文中不可用: {}", tool_name, reason);
let tool_msg = ChatMessage::tool_result(&tool_call.id, &err_msg);
self.save_subagent_message(
subagent_name,
0,
step as i32,
"tool",
&err_msg,
None,
None,
Some(&tool_call.id),
);
messages.push(tool_msg);
continue;
}
PermissionResult::AskUser { .. } => {
// 子代理上下文中无用户可询问,自动拒绝
warn!(
"[SubAgent] 工具 {} 需要用户确认,子代理上下文中自动拒绝",
tool_name
);
let err_msg =
format!("工具 {} 需要用户确认但在子代理上下文中不可用", tool_name);
let tool_msg = ChatMessage::tool_result(&tool_call.id, &err_msg);
self.save_subagent_message(
subagent_name,
0,
step as i32,
"tool",
&err_msg,
None,
None,
Some(&tool_call.id),
);
messages.push(tool_msg);
continue;
}
PermissionResult::Allowed => {
// 继续执行
}
}
// 执行工具
let output = match self.tool_registry.get(tool_name) {
@ -374,6 +572,7 @@ impl SubAgentRunner {
if let Some(ref tx) = self.progress_tx {
let preview: String = final_output_content.chars().take(200).collect();
let _ = tx.send(AgentStreamEvent::ToolResult {
tool_call_id: tool_call.id.clone(),
name: format!("[sub] {}", tool_name),
output: preview,
is_error: output.is_error,
@ -398,6 +597,16 @@ impl SubAgentRunner {
};
let tool_msg = ChatMessage::tool_result(&tool_call.id, &truncated);
self.save_subagent_message(
subagent_name,
0,
step as i32,
"tool",
&truncated,
None,
None,
Some(&tool_call.id),
);
messages.push(tool_msg);
}
}
@ -415,7 +624,10 @@ impl SubAgentRunner {
));
let empty_tools: Vec<ToolDefinition> = Vec::new();
let mut stream_rx = match llm.chat_stream(&final_messages, &empty_tools).await {
let mut stream_rx = match llm
.chat_stream(&final_messages, &empty_tools, self.config.enable_thinking)
.await
{
Ok(rx) => rx,
Err(e) => {
return ToolOutput::error(format!("子代理最终答案生成失败: {}", e));

View File

@ -35,7 +35,7 @@ pub async fn run_teammate_loop(
cancelled: Arc<AtomicBool>,
) {
let llm = &app_state.llm;
// 队友的工具注册表排除 delegate_research(防止无限委托链)
// 队友的工具注册表排除 subagent(防止无限委托链)
let queue = Arc::new(BgNotificationQueue::new());
let tool_registry =
ToolRegistry::new_with_queue(Some(queue.clone()), app_state.skill_registry.clone());
@ -184,7 +184,10 @@ async fn run_teammate_react_turn(
}
// LLM 流式调用
let mut stream_rx = match llm.chat_stream(messages, tool_defs).await {
let mut stream_rx = match llm
.chat_stream(messages, tool_defs, config.enable_thinking)
.await
{
Ok(rx) => rx,
Err(_) => return None,
};

View File

@ -55,6 +55,27 @@ impl AgentTool for RunBashTool {
InterruptBehavior::Block
}
fn check_permissions(
&self,
args: &serde_json::Value,
) -> Vec<crate::agent::tools::PermissionRule> {
let command = match args.get("command").and_then(|v| v.as_str()) {
Some(c) => c,
None => return Vec::new(),
};
if bash_needs_permission(command) {
vec![crate::agent::tools::PermissionRule::Ask {
tool_name: "run_bash".into(),
message: format!("是否允许执行 Shell 命令: {}", command),
source: crate::agent::tools::PermissionRuleSource::Env,
}]
} else {
// SAFE_COMMANDS 中的命令自动允许
Vec::new()
}
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
let command = match args.get("command").and_then(|v| v.as_str()) {
Some(s) => s,
@ -157,70 +178,432 @@ impl AgentTool for RunBashTool {
}
}
/// 验证 Bash 命令安全性(黑名单 + 启发式检查)。
/// 返回 `Some(reason)` 表示拒绝,`None` 表示允许。
/// 安全命令白名单:这些命令的第一个单词匹配时,自动允许(仍需路径沙箱检查)。
#[allow(dead_code)]
// ── 命令安全验证 ────────────────────────────────────────────────────────────────
/// 安全命令白名单(只读或数据处理类命令)。
/// 这些命令的首个单词匹配时自动允许执行,不再进行黑名单检查。
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",
];
/// 检查命令是否属于安全白名单(第一个单词匹配即可)。
#[allow(dead_code)]
fn is_safe_command(command: &str) -> bool {
let first_word = command.split_whitespace().next().unwrap_or("");
SAFE_COMMANDS.contains(&first_word)
}
fn validate_bash_command(command: &str) -> Option<String> {
let trimmed = command.trim();
// 空命令
if trimmed.is_empty() || trimmed == "bash" || trimmed == "bash -c" {
return Some("不允许执行空命令".to_string());
}
// 禁止交互式/破坏性命令
let interactive_patterns = [
/// 危险命令黑名单(交互式、提权、远程访问、破坏性命令)。
/// 仅对首个命令单词做精确匹配,非子串匹配。
const DANGEROUS_COMMANDS: &[&str] = &[
// 提权/用户切换
"sudo",
"su",
"passwd",
"chsh",
"visudo",
// 远程访问
"ssh",
"telnet",
"login",
"less ",
"more ",
"rlogin",
"rsh",
"ftp",
// 交互式编辑器/分页器
"vim",
"vi",
"nano",
"emacs",
"nvim",
"less",
"more",
"most",
// 交互式监控
"top",
"htop",
"watch ",
"tail -f",
"rm -rf /",
"mkfs.",
"dd if=",
"chmod 777",
"> /dev/",
"btop",
"iotop",
"nmon",
"glances",
// 磁盘/文件系统破坏
"mkfs",
"mke2fs",
"mkfs.ext4",
"mkfs.xfs",
"fdisk",
"parted",
"dd",
// 网络监听/扫描(防信息泄露)
"tcpdump",
"wireshark",
"tshark",
"nmap",
// 登录相关
"login",
"su",
"sulogin",
];
let lower = trimmed.to_lowercase();
for pattern in &interactive_patterns {
if lower.contains(&pattern.to_lowercase()) {
return Some(format!("不允许执行 '{}' 类命令", pattern));
/// 危险参数模式 — 对不在白名单也不在黑名单的命令做二次检查。
const DANGEROUS_ARG_PATTERNS: &[&str] = &[
"rm -rf /", // 递归强制删除根目录
"> /dev/", // 写入裸设备
"mkfs.", // 创建文件系统(任意类型)
"dd if=", // 裸磁盘读写
"chmod 777", // 过度放宽权限
];
/// 从命令字符串中提取首个可执行单词。
///
/// 处理以下情况:
/// - 环境变量赋值: `VAR=val cmd` → `cmd`
/// - 绝对/相对路径: `/usr/bin/python` → `python`
/// - 转义: `\cmd` → `cmd`
/// - 引号: `'cmd'` → `cmd`
fn extract_first_command_word(command: &str) -> String {
for token in command.split_whitespace() {
// 跳过环境变量赋值 (KEY=value 或 KEY=)
if token.contains('=') && !token.starts_with('=') {
if let Some(eq_pos) = token.find('=') {
let before_eq = &token[..eq_pos];
// 确认等号前是合法的变量名
if before_eq.chars().all(|c| c.is_alphanumeric() || c == '_') {
continue;
}
}
}
// 提取路径中的文件名
let word = token.trim_matches(&['\'', '"', '\\'] as &[_]);
if let Some(pos) = word.rfind('/') {
return word[pos + 1..].to_string();
}
return word.to_string();
}
String::new()
}
// 允许通过
/// 网络访问命令 — 当 `AGENT_BLOCK_NETWORK=true`(默认)时被禁止。
const NETWORK_COMMANDS: &[&str] = &[
"curl", "wget", "nc", "netcat", "socat", "ncat", "ftp", "tftp",
];
/// 检查是否启用网络访问限制(默认启用)。
fn is_network_blocked() -> bool {
std::env::var("AGENT_BLOCK_NETWORK")
.map(|v| v != "false" && v != "0")
.unwrap_or(true)
}
/// 检查命令首词是否在安全白名单中。
fn is_safe_command(first_word: &str) -> bool {
SAFE_COMMANDS.contains(&first_word)
}
/// 检查命令首词是否在危险黑名单中。
fn is_dangerous_command(first_word: &str) -> bool {
DANGEROUS_COMMANDS.contains(&first_word)
|| (is_network_blocked() && NETWORK_COMMANDS.contains(&first_word))
}
/// 检测命令是否通过命令替换构造黑名单命令名以绕过检查。
///
/// 例如: `$(echo sud; echo o)` 或 `` `echo passwd` ``
fn has_command_substitution_bypass(command: &str) -> bool {
// 检查命令是否以命令替换开头($() 或 反引号)
let trimmed = command.trim();
// 以 $( 或 ` 开头 → 整个命令名都是动态构造的
if trimmed.starts_with("$(") || trimmed.starts_with('`') {
return true;
}
// 检查首个单词本身是否包含命令替换
if let Some(first_token) = trimmed.split_whitespace().next() {
if first_token.contains("$(") || first_token.contains('`') {
return true;
}
}
false
}
/// 检查命令是否包含危险的参数模式(用于非白名单非黑名单命令的二次检查)。
fn check_dangerous_args(command: &str) -> Option<String> {
let lower = command.to_lowercase();
for pattern in DANGEROUS_ARG_PATTERNS {
if lower.contains(&pattern.to_lowercase()) {
return Some(format!("命令包含危险操作模式: {}", pattern));
}
}
None
}
/// 验证 Bash 命令安全性(白名单优先 → 黑名单 → 危险参数 → 默认允许)。
/// 返回 `Some(reason)` 表示拒绝,`None` 表示允许。
fn validate_bash_command(command: &str) -> Option<String> {
let trimmed = command.trim();
// 空命令 / 裸 bash 调用
if trimmed.is_empty() || trimmed == "bash" || trimmed == "bash -c" {
return Some("不允许执行空命令".to_string());
}
// 检测命令替换绕过
if has_command_substitution_bypass(trimmed) {
return Some(
"命令包含可疑的命令替换模式($(...) 或反引号),请改用直接命令调用".to_string(),
);
}
// 提取首个命令单词做精确匹配
let first_word = extract_first_command_word(trimmed);
if first_word.is_empty() {
return Some("无法解析命令名称".to_string());
}
// 白名单优先:安全命令直接放行
if is_safe_command(&first_word) {
return None;
}
// 黑名单精确匹配
if is_dangerous_command(&first_word) {
return Some(format!(
"不允许执行命令 '{}'(交互式、提权或破坏性命令被禁止)",
first_word
));
}
// 非白非黑:二次检查危险参数模式
if let Some(reason) = check_dangerous_args(trimmed) {
return Some(reason);
}
// 默认允许(路径沙箱和超时控制提供额外安全层)
None
}
/// 检查命令是否需要用户权限确认。
/// 安全白名单中的命令不需确认,其他命令需要。
#[allow(dead_code)]
pub fn bash_needs_permission(command: &str) -> bool {
!is_safe_command(command)
let first_word = extract_first_command_word(command.trim());
!is_safe_command(&first_word)
}
// ── Tests ──
#[cfg(test)]
mod tests {
use super::*;
// ── extract_first_command_word ──
#[test]
fn test_extract_simple_command() {
assert_eq!(extract_first_command_word("ls -la"), "ls");
assert_eq!(extract_first_command_word("grep foo bar"), "grep");
}
#[test]
fn test_extract_with_env_vars() {
assert_eq!(extract_first_command_word("VAR=val cmd arg"), "cmd");
assert_eq!(
extract_first_command_word("RUST_LOG=debug FOO=bar cargo build"),
"cargo"
);
}
#[test]
fn test_extract_path_command() {
assert_eq!(extract_first_command_word("/usr/bin/python3"), "python3");
assert_eq!(extract_first_command_word("./script.sh"), "script.sh");
assert_eq!(
extract_first_command_word("/home/user/bin/my-tool"),
"my-tool"
);
}
#[test]
fn test_extract_quoted_command() {
assert_eq!(extract_first_command_word("'cmd' args"), "cmd");
assert_eq!(extract_first_command_word("\"cmd\""), "cmd");
}
// ── validate_bash_command: 白名单放行 ──
#[test]
fn test_whitelist_allows_safe_commands() {
for cmd in SAFE_COMMANDS {
let full_cmd = format!("{} some arguments --flag", cmd);
assert!(
validate_bash_command(&full_cmd).is_none(),
"安全命令应被允许: {}",
full_cmd
);
}
}
#[test]
fn test_whitelist_allows_safe_commands_with_pipes() {
assert!(validate_bash_command("cat file.txt | grep foo | wc -l").is_none());
assert!(validate_bash_command("find . -name '*.rs' | xargs wc").is_none());
}
// ── validate_bash_command: 黑名单精确拒绝 ──
#[test]
fn test_blacklist_blocks_dangerous_commands() {
for cmd in &["sudo", "ssh", "vim", "nano", "top", "mkfs", "dd", "passwd"] {
let full_cmd = format!("{} some args", cmd);
assert!(
validate_bash_command(&full_cmd).is_some(),
"危险命令应被拒绝: {}",
full_cmd
);
}
}
#[test]
fn test_blacklist_blocks_vi() {
assert!(validate_bash_command("vi /etc/config").is_some());
assert!(validate_bash_command("vi ").is_some());
}
// ── validate_bash_command: 不再误拦(子串匹配修复) ──
#[test]
fn test_no_false_positive_for_ssh_in_string() {
// grep "ssh" 以前会因为包含子串 "ssh " 被误拦,现在应放行
assert!(validate_bash_command("grep ssh_config /etc/ssh/sshd_config").is_none());
assert!(validate_bash_command("grep \"ssh\" *.rs").is_none());
assert!(validate_bash_command("echo 'use sudo carefully'").is_none());
}
#[test]
fn test_no_false_positive_for_vim_in_path() {
// 以前 "vim " 子串会误拦,现在精确匹配首词不误拦
assert!(validate_bash_command("cat /usr/share/vim/vimrc").is_none());
}
#[test]
fn test_no_false_positive_for_top_in_path() {
// 以前 "top" 子串会误拦 stop、desktop 等
assert!(validate_bash_command("cat /tmp/topology.json").is_none());
assert!(validate_bash_command("echo 'htop is useful'").is_none());
}
#[test]
fn test_no_false_positive_for_tail_dash_f() {
// 以前 "tail -f" 子串会误拦 "tail -f" 中合法使用
// 现在 tail 在白名单中,直接放行
assert!(validate_bash_command("tail -n 20 access.log").is_none());
assert!(validate_bash_command("tail -f /var/log/app.log").is_none());
}
// ── validate_bash_command: 命令替换绕过检测 ──
#[test]
fn test_blocks_dollar_parens_bypass() {
assert!(validate_bash_command("$(echo sud; echo o) rm -rf /").is_some());
assert!(validate_bash_command("$(echo vim) file.txt").is_some());
}
#[test]
fn test_blocks_backtick_bypass() {
assert!(validate_bash_command("`echo vim` file.txt").is_some());
}
#[test]
fn test_allows_dollar_parens_in_args() {
// $() 在参数位置(非命令首词)是合法的,如命令替换结果作为参数
assert!(validate_bash_command("echo $(date)").is_none());
assert!(validate_bash_command("grep $(cat pattern.txt) file.txt").is_none());
}
// ── validate_bash_command: 危险参数模式 ──
#[test]
fn test_blocks_rm_rf_root() {
assert!(validate_bash_command("rm -rf /").is_some());
assert!(validate_bash_command("rm -rf / --no-preserve-root").is_some());
}
#[test]
fn test_blocks_write_to_dev() {
assert!(validate_bash_command("dd if=/dev/zero of=/dev/sda").is_some());
}
#[test]
fn test_blocks_chmod_777() {
assert!(validate_bash_command("chmod 777 /tmp/script.sh").is_some());
}
// ── validate_bash_command: 边界情况 ──
#[test]
fn test_rejects_empty_command() {
assert!(validate_bash_command("").is_some());
assert!(validate_bash_command(" ").is_some());
assert!(validate_bash_command("bash").is_some());
assert!(validate_bash_command("bash -c").is_some());
}
#[test]
fn test_allows_common_dev_tools() {
// 开发常用命令不在白名单但也不在黑名单,应默认放行
assert!(validate_bash_command("python script.py").is_none());
assert!(validate_bash_command("git status").is_none());
assert!(validate_bash_command("cargo build").is_none());
assert!(validate_bash_command("npm test").is_none());
}
#[test]
fn test_network_commands_blocking() {
// 此测试统一验证网络命令的阻止/放行逻辑,
// 避免多个测试并行修改 AGENT_BLOCK_NETWORK 导致竞争。
std::env::remove_var("AGENT_BLOCK_NETWORK");
// 默认阻止
assert!(validate_bash_command("curl https://example.com").is_some());
assert!(validate_bash_command("wget https://example.com/file").is_some());
// 显式 true
std::env::set_var("AGENT_BLOCK_NETWORK", "true");
assert!(validate_bash_command("nc -l 1234").is_some());
// 设为 false 放行
std::env::set_var("AGENT_BLOCK_NETWORK", "false");
assert!(validate_bash_command("curl https://example.com").is_none());
assert!(validate_bash_command("wget https://example.com/file").is_none());
std::env::remove_var("AGENT_BLOCK_NETWORK");
}
#[test]
fn test_allows_command_with_env_var_override() {
// PYTHONPATH=/foo python script.py — python 是首词,不在黑白名单
assert!(validate_bash_command("PYTHONPATH=/foo python script.py").is_none());
}
// ── bash_needs_permission ──
#[test]
fn test_bash_needs_permission_for_non_safe_commands() {
assert!(bash_needs_permission("python script.py"));
assert!(bash_needs_permission("git status"));
assert!(bash_needs_permission("curl url"));
}
#[test]
fn test_bash_needs_permission_false_for_safe_commands() {
assert!(!bash_needs_permission("ls -la"));
assert!(!bash_needs_permission("cat file.txt"));
assert!(!bash_needs_permission("grep pattern file"));
}
// ── is_safe_command / is_dangerous_command ──
#[test]
fn test_is_safe_command_matches_whitelist() {
assert!(is_safe_command("ls"));
assert!(is_safe_command("cat"));
assert!(!is_safe_command("python"));
assert!(!is_safe_command("vim"));
}
#[test]
fn test_is_dangerous_command_matches_blacklist() {
assert!(is_dangerous_command("sudo"));
assert!(is_dangerous_command("vim"));
assert!(is_dangerous_command("ssh"));
assert!(!is_dangerous_command("ls"));
assert!(!is_dangerous_command("python"));
}
}

View File

@ -19,16 +19,25 @@ pub fn is_path_allowed(path: &Path, ctx: &ToolContext) -> bool {
None => return false,
},
};
let allowed_roots = [
// 内置允许根目录
let builtin_roots: [Option<std::path::PathBuf>; 3] = [
config.library_dir.canonicalize().ok(),
config.skills_dir.canonicalize().ok(),
std::env::current_dir().ok(),
];
for root in allowed_roots.iter().flatten() {
for root in builtin_roots.iter().flatten() {
if canonical.starts_with(root) {
return true;
}
}
// 附加目录AGENT_ADDITIONAL_DIRS / 会话内动态添加)
for dir_str in &ctx.additional_allowed_dirs {
if let Ok(resolved) = std::path::Path::new(dir_str).canonicalize() {
if canonical.starts_with(&resolved) {
return true;
}
}
}
false
}

View File

@ -44,7 +44,7 @@ pub use filesystem::{
FileEditTool, FileWriteTool, GlobFilesTool, GrepFilesTool, ReadFileTool, RunBashTool,
};
pub use skill::LoadSkillTool;
pub use subagent::DelegateResearchTool;
pub use subagent::SubAgentTool;
pub use team::{CheckTeamInboxTool, SendTeammateMessageTool, SpawnTeammateTool, TeamBroadcastTool};
pub use todo::persist_tasks;
pub use todo::TodoWriteTool;
@ -52,10 +52,18 @@ pub use todo::TodoWriteTool;
/// 工具执行上下文,封装全局共享状态
pub struct ToolContext {
pub app_state: Arc<AppState>,
/// 当前会话 ID用于工具将数据关联到正确的 session
pub session_id: String,
/// 静默模式:子代理运行时为 true跳过用户权限提示
pub silent: bool,
/// 文件状态缓存(跨工具调用共享,用于 Read 去重)
pub read_file_state: Arc<std::sync::Mutex<FileStateCache>>,
/// 当前 SSE 发送通道(工具可通过此通道向用户推送中间事件)
pub sse_tx: Option<tokio::sync::mpsc::UnboundedSender<crate::agent::runtime::AgentStreamEvent>>,
/// 是否启用 LLM 思考模式(继承自父代理配置)
pub enable_thinking: bool,
/// 附加允许目录(扩展文件沙箱范围)
pub additional_allowed_dirs: Vec<String>,
}
impl ToolContext {
@ -63,8 +71,12 @@ impl ToolContext {
pub fn new(app_state: Arc<AppState>) -> Self {
ToolContext {
app_state,
session_id: String::new(),
silent: false,
read_file_state: Arc::new(std::sync::Mutex::new(FileStateCache::new())),
sse_tx: None,
enable_thinking: false,
additional_allowed_dirs: Vec::new(),
}
}
@ -75,17 +87,52 @@ impl ToolContext {
) -> Self {
ToolContext {
app_state,
session_id: String::new(),
silent: false,
read_file_state,
sse_tx: None,
enable_thinking: false,
additional_allowed_dirs: Vec::new(),
}
}
/// 设置 SSE 通道
pub fn with_sse_tx(
mut self,
tx: tokio::sync::mpsc::UnboundedSender<crate::agent::runtime::AgentStreamEvent>,
) -> Self {
self.sse_tx = Some(tx);
self
}
/// 设置会话 ID
pub fn with_session_id(mut self, id: String) -> Self {
self.session_id = id;
self
}
/// 设置思考模式
pub fn with_thinking(mut self, enable: bool) -> Self {
self.enable_thinking = enable;
self
}
/// 设置附加允许目录
pub fn with_additional_dirs(mut self, dirs: Vec<String>) -> Self {
self.additional_allowed_dirs = dirs;
self
}
/// 创建静默上下文(子代理使用)
pub fn silent(app_state: Arc<AppState>) -> Self {
ToolContext {
app_state,
session_id: String::new(),
silent: true,
read_file_state: Arc::new(std::sync::Mutex::new(FileStateCache::new())),
sse_tx: None,
enable_thinking: false,
additional_allowed_dirs: Vec::new(),
}
}
}
@ -130,15 +177,44 @@ pub enum InterruptBehavior {
Block,
}
/// 权限规则来源
#[derive(Debug, Clone, PartialEq)]
pub enum PermissionRuleSource {
/// 环境变量加载AGENT_PERMISSIONS_*
Env,
/// 会话内动态添加API / Always Allow
Session,
}
impl std::fmt::Display for PermissionRuleSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PermissionRuleSource::Env => write!(f, "env"),
PermissionRuleSource::Session => write!(f, "session"),
}
}
}
/// 权限规则 — 工具自定义的权限限制
#[derive(Debug, Clone)]
pub enum PermissionRule {
/// 不可覆盖的拒绝
Deny { tool_name: String, reason: String },
Deny {
tool_name: String,
reason: String,
source: PermissionRuleSource,
},
/// 允许
Allow { tool_name: String },
Allow {
tool_name: String,
source: PermissionRuleSource,
},
/// 需要用户确认
Ask { tool_name: String, message: String },
Ask {
tool_name: String,
message: String,
source: PermissionRuleSource,
},
}
/// 智能体工具 trait
@ -191,9 +267,14 @@ pub trait AgentTool: Send + Sync {
/// 工具注册表,管理所有可用工具。
/// 内部使用 HashMap 实现 O(1) 按名查找,同时保留插入顺序供 definitions() 使用。
///
/// `definition_filter` 用于子代理最小权限:设置后 `definitions()` 仅返回白名单工具,
/// 但 `get()` 仍可查找所有工具(以便对未授权调用返回友好错误消息)。
pub struct ToolRegistry {
tools: std::collections::HashMap<String, Box<dyn AgentTool>>,
ordered_names: Vec<String>,
/// 可选的工具白名单(子代理最小权限)
definition_filter: Option<std::collections::HashSet<String>>,
}
// ── 工具注册辅助函数(消除重复代码) ──
@ -219,7 +300,7 @@ fn add_base_tools(registry: &mut ToolRegistry, skill_registry: Arc<RwLock<SkillR
Box::new(CompressTool),
Box::new(AskUserTool),
Box::new(LoadSkillTool::new(skill_registry)),
Box::new(DelegateResearchTool::new()),
Box::new(SubAgentTool::new()),
];
for tool in tools {
registry.ordered_names.push(tool.name().to_string());
@ -266,6 +347,7 @@ impl ToolRegistry {
ToolRegistry {
tools: std::collections::HashMap::new(),
ordered_names: Vec::new(),
definition_filter: None,
}
}
@ -282,6 +364,7 @@ impl ToolRegistry {
let mut registry = ToolRegistry {
tools: std::collections::HashMap::new(),
ordered_names: Vec::new(),
definition_filter: None,
};
add_base_tools(&mut registry, skill_registry);
if let Some(q) = queue {
@ -326,15 +409,37 @@ impl ToolRegistry {
/// 生成所有工具的 ToolDefinition 列表(用于发送给 LLM
/// 按名称字母序排序以保证跨调用的稳定性,提升 prompt cache 命中率。
/// 若设置了 definition_filter仅返回白名单中的工具。
pub fn definitions(&self) -> Vec<ToolDefinition> {
let mut defs: Vec<_> = self
.tools
.values()
let values: Vec<&Box<dyn AgentTool>> = if let Some(filter) = &self.definition_filter {
self.tools
.iter()
.filter(|(name, _)| filter.contains(*name))
.map(|(_, tool)| tool)
.collect()
} else {
self.tools.values().collect()
};
let mut defs: Vec<_> = values
.iter()
.map(|t| ToolDefinition::new(t.name(), t.description(), t.parameters()))
.collect();
defs.sort_by(|a, b| a.function.name.cmp(&b.function.name));
defs
}
/// 设置工具白名单(子代理最小权限)。
/// 设置后 `definitions()` 仅暴露白名单中的工具给 LLM
/// 但 `get()` 仍可访问所有工具以便对未授权调用返回友好错误消息。
pub fn with_filter(mut self, allowed: &[&str]) -> Self {
self.definition_filter = Some(allowed.iter().map(|s| s.to_string()).collect());
self
}
/// 返回当前所有工具名称列表
pub fn tool_names(&self) -> Vec<String> {
self.ordered_names.clone()
}
}
/// 截断文本到指定最大字符数
@ -403,7 +508,7 @@ mod tests {
assert!(defs.iter().any(|d| d.function.name == "todo_write"));
assert!(defs.iter().any(|d| d.function.name == "compress_context"));
assert!(defs.iter().any(|d| d.function.name == "load_skill"));
assert!(defs.iter().any(|d| d.function.name == "delegate_research"));
assert!(defs.iter().any(|d| d.function.name == "subagent"));
}
#[test]

View File

@ -102,11 +102,11 @@ impl AgentTool for LoadSkillTool {
}
// 变量替换
let session_id = if ctx
.app_state
.config
.database_url
.contains("session") { "current" } else { "" };
let session_id = if ctx.app_state.config.database_url.contains("session") {
"current"
} else {
""
};
let body = substitute_variables(
&skill.body,

View File

@ -1,4 +1,4 @@
// src/agent/tools/subagent.rs — 子代理委托工具 (delegate_research)
// src/agent/tools/subagent.rs — 子代理工具 (subagent)
//
// 参考 Claude Code s04 Subagents 设计。
// LLM 通过此工具将子任务委托给上下文隔离的子代理执行。
@ -6,63 +6,57 @@
use async_trait::async_trait;
use serde_json::json;
use std::sync::Arc;
use tokio::sync::mpsc::UnboundedSender;
use tracing::info;
use super::{AgentTool, InterruptBehavior, ToolContext, ToolOutput};
use crate::agent::hooks::HookRegistry;
use crate::agent::runtime::permission::PermissionChecker;
use crate::agent::runtime::AgentStreamEvent;
use crate::agent::subagent::SubAgentRunner;
/// 子代理委托工具
pub struct DelegateResearchTool {
/// 子代理工具
pub struct SubAgentTool {
hook_registry: Option<Arc<HookRegistry>>,
permission_checker: Arc<PermissionChecker>,
progress_tx: Option<UnboundedSender<AgentStreamEvent>>,
}
impl Default for DelegateResearchTool {
impl Default for SubAgentTool {
fn default() -> Self {
Self::new()
}
}
impl DelegateResearchTool {
impl SubAgentTool {
/// 创建不带 hooks 的工具实例(向后兼容)
pub fn new() -> Self {
DelegateResearchTool {
SubAgentTool {
hook_registry: None,
permission_checker: Arc::new(PermissionChecker::new()),
progress_tx: None,
}
}
/// 创建带完整 hooks/permissions/progress 的工具实例
/// 创建带完整 hooks/permissions 的工具实例SSE 通道通过 ToolContext 注入)
pub fn new_with_hooks(
hook_registry: Option<Arc<HookRegistry>>,
permission_checker: Arc<PermissionChecker>,
progress_tx: Option<UnboundedSender<AgentStreamEvent>>,
) -> Self {
DelegateResearchTool {
SubAgentTool {
hook_registry,
permission_checker,
progress_tx,
}
}
}
#[async_trait]
impl AgentTool for DelegateResearchTool {
impl AgentTool for SubAgentTool {
fn name(&self) -> &str {
"delegate_research"
"subagent"
}
fn description(&self) -> &str {
"将子研究任务委托给独立的子代理执行。子代理拥有完整工具访问权限文献搜索、下载、RAG检索等\
"将子任务委托给独立的子代理执行。子代理拥有完整工具访问权限文献搜索、下载、RAG检索等\
\
\
delegate_research "
"
}
fn parameters(&self) -> serde_json::Value {
@ -101,7 +95,7 @@ impl AgentTool for DelegateResearchTool {
.min(10) as usize;
info!(
"[DelegateResearch] 启动子代理: prompt_len={}, max_steps={}",
"[SubAgent] 启动子代理: prompt_len={}, max_steps={}",
research_prompt.len(),
max_steps
);
@ -111,22 +105,28 @@ impl AgentTool for DelegateResearchTool {
\
";
// 使用 ToolContext 中的 SSE 通道和会话 IDexecutor 在构造 ToolContext 时已注入)
let runner = SubAgentRunner::new_with_hooks(
ctx.app_state.clone(),
self.hook_registry.clone(),
self.permission_checker.clone(),
self.progress_tx.clone(),
);
ctx.sse_tx.clone(),
)
.with_parent_session(ctx.session_id.clone())
.with_thinking(ctx.enable_thinking);
let result = runner.run(system_prompt, &research_prompt, max_steps).await;
if result.is_error {
ToolOutput::error(format!("子代理执行失败: {}", result.content))
} else {
// 包装子代理结果,标注来源
ToolOutput::success(
format!("[子代理研究结果]\n\n{}", result.content),
result.metadata,
)
let final_content = format!("[子代理研究结果]\n\n{}", result.content);
info!(
"[SubAgent] 子代理结果: content_len={}, preview={}...",
final_content.len(),
&final_content.chars().take(150).collect::<String>()
);
ToolOutput::success(final_content, result.metadata)
}
}
}

View File

@ -26,6 +26,9 @@ use crate::agent::runtime::{AgentRuntime, AgentStreamEvent};
pub struct AgentChatRequest {
pub question: String,
pub session_id: Option<String>,
/// 是否启用 LLM 思考模式(默认关闭)
#[serde(default)]
pub thinking: bool,
}
pub async fn chat_agent(
@ -37,7 +40,7 @@ pub async fn chat_agent(
req.question, req.session_id
);
let runtime = AgentRuntime::new(Arc::clone(&state));
let runtime = AgentRuntime::new(Arc::clone(&state)).with_thinking(req.thinking);
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<AgentStreamEvent>();
let question = req.question.clone();
@ -89,6 +92,7 @@ pub struct SessionSummary {
pub title: String,
pub model: String,
pub turn_count: i32,
pub summary: Option<String>,
pub created_at: String,
pub updated_at: String,
}
@ -101,7 +105,7 @@ pub async fn list_sessions(
let offset = params.offset.unwrap_or(0);
let rows = sqlx::query(
"SELECT session_id, title, model, turn_count, created_at, updated_at \
"SELECT session_id, title, model, turn_count, summary, created_at, updated_at \
FROM agent_sessions \
WHERE deleted_at IS NULL \
ORDER BY updated_at DESC \
@ -125,8 +129,9 @@ pub async fn list_sessions(
title: r.get(1),
model: r.get(2),
turn_count: r.get(3),
created_at: r.get(4),
updated_at: r.get(5),
summary: r.get(4),
created_at: r.get(5),
updated_at: r.get(6),
})
.collect();
@ -145,6 +150,7 @@ pub struct SessionDetail {
#[derive(Debug, Serialize)]
pub struct MessageRecord {
pub id: i64,
pub agent_name: String,
pub turn_index: i32,
pub step_index: i32,
pub role: String,
@ -163,7 +169,7 @@ pub async fn get_session(
) -> Result<Json<SessionDetail>, (StatusCode, String)> {
// 查询会话元信息
let session_row = sqlx::query(
"SELECT session_id, title, model, turn_count, created_at, updated_at \
"SELECT session_id, title, model, turn_count, summary, created_at, updated_at \
FROM agent_sessions \
WHERE session_id = ? AND deleted_at IS NULL",
)
@ -183,13 +189,14 @@ pub async fn get_session(
title: session_row.get(1),
model: session_row.get(2),
turn_count: session_row.get(3),
created_at: session_row.get(4),
updated_at: session_row.get(5),
summary: session_row.get(4),
created_at: session_row.get(5),
updated_at: session_row.get(6),
};
// 查询消息列表
// 查询消息列表(包含 lead 和 subagent 消息,前端按 agent_name/metadata 区分渲染)
let msg_rows = sqlx::query(
"SELECT id, turn_index, step_index, role, content, thought, tool_calls, tool_call_id, token_count, metadata, created_at \
"SELECT id, agent_name, turn_index, step_index, role, content, thought, tool_calls, tool_call_id, token_count, metadata, created_at \
FROM agent_messages \
WHERE session_id = ? \
ORDER BY id ASC"
@ -202,21 +209,22 @@ pub async fn get_session(
let messages: Vec<MessageRecord> = msg_rows
.iter()
.map(|r| {
let tool_calls_json: Option<String> = r.get(6);
let metadata_json: Option<String> = r.get(9);
let tool_calls_json: Option<String> = r.get(7);
let metadata_json: Option<String> = r.get(10);
MessageRecord {
id: r.get(0),
turn_index: r.get(1),
step_index: r.get(2),
role: r.get(3),
content: r.get(4),
thought: r.get(5),
agent_name: r.get(1),
turn_index: r.get(2),
step_index: r.get(3),
role: r.get(4),
content: r.get(5),
thought: r.get(6),
tool_calls: tool_calls_json.and_then(|s| serde_json::from_str(&s).ok()),
tool_call_id: r.get(7),
token_count: r.get(8),
tool_call_id: r.get(8),
token_count: r.get(9),
metadata: metadata_json.and_then(|s| serde_json::from_str(&s).ok()),
created_at: r.get(10),
created_at: r.get(11),
}
})
.collect();
@ -473,3 +481,68 @@ pub async fn get_pending_questions(
.collect();
Json(questions)
}
// ── POST /api/chat/sessions/:id/permissions/respond ──
// 用户响应权限请求
pub async fn respond_permission(
State(state): State<Arc<AppState>>,
Path(session_id): Path<String>,
Json(req): Json<super::PermissionResponse>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let mut perms = match state.pending_permissions.lock() {
Ok(p) => p,
Err(_) => return Err((StatusCode::INTERNAL_SERVER_ERROR, "内部状态异常".into())),
};
// 按 tool_call_id 查找匹配的权限请求
let perm_id = perms
.iter()
.find(|(_, p)| p.tool_call_id == req.tool_call_id)
.map(|(id, _)| id.clone());
match perm_id {
Some(id) => {
let perm = perms.remove(&id).unwrap();
match perm.response_tx.send(req) {
Ok(()) => {
info!(
"[API] 用户响应了权限请求: session={} tool_call_id={}",
session_id, perm.tool_call_id
);
Ok(Json(serde_json::json!({"status": "ok"})))
}
Err(_) => Err((StatusCode::GONE, "权限请求已超时或已处理".into())),
}
}
None => Err((
StatusCode::NOT_FOUND,
"未找到该权限请求(可能已超时或已处理)".into(),
)),
}
}
// ── GET /api/chat/sessions/:id/permissions ──
// 获取当前待处理的权限请求(前端轮询)
pub async fn get_pending_permissions(
State(state): State<Arc<AppState>>,
) -> Json<Vec<serde_json::Value>> {
let perms = match state.pending_permissions.lock() {
Ok(p) => p,
Err(_) => return Json(Vec::new()),
};
let result: Vec<serde_json::Value> = perms
.iter()
.map(|(id, p)| {
serde_json::json!({
"permission_id": id,
"tool_call_id": p.tool_call_id,
"tool_name": p.tool_name,
"message": p.message,
"arguments": p.arguments,
})
})
.collect();
Json(result)
}

View File

@ -30,8 +30,7 @@ pub fn convert_ads_doc_to_standard(doc: &AdsPaperDoc) -> StandardPaper {
}
}
}
if arxiv_id.is_empty()
&& doc.bibcode.starts_with("arXiv") {
if arxiv_id.is_empty() && doc.bibcode.starts_with("arXiv") {
arxiv_id = doc.bibcode.replace("arXiv", "").trim().to_string();
}

View File

@ -29,6 +29,25 @@ pub struct PendingQuestion {
pub answer_tx: oneshot::Sender<crate::agent::tools::ask_user::UserAnswer>,
}
/// 用户对权限请求的响应
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionResponse {
pub tool_call_id: String,
pub allowed: bool,
#[serde(default)]
pub allow_always: bool,
}
/// 待处理的权限请求(供权限系统与 API 端点共享)
#[derive(Debug)]
pub struct PendingPermission {
pub tool_call_id: String,
pub tool_name: String,
pub message: String,
pub arguments: serde_json::Value,
pub response_tx: oneshot::Sender<PermissionResponse>,
}
// 全局共享的 Axum 应用上下文状态
pub struct AppState {
pub config: Config,
@ -47,6 +66,11 @@ pub struct AppState {
pub skill_registry: Arc<RwLock<SkillRegistry>>,
/// ask_user 工具 — 待回答的问题
pub pending_questions: Arc<Mutex<HashMap<String, PendingQuestion>>>,
/// 权限检查 — 待处理的权限确认请求
pub pending_permissions: Arc<Mutex<HashMap<String, PendingPermission>>>,
/// 会话级权限检查器(支持 API 动态添加/移除规则,跨 turn 共享)
pub session_permission_checker:
Arc<RwLock<crate::agent::runtime::permission::PermissionChecker>>,
/// SSE 广播通道agent 运行时向所有连接的客户端推送事件)
pub sse_broadcast: Option<broadcast::Sender<AppEvent>>,
/// 项目记忆管理器(跨会话持久化)
@ -82,16 +106,17 @@ pub mod agent;
pub mod helpers;
pub mod notes;
pub mod papers;
pub mod permissions;
pub mod sync;
pub mod targets;
// 提供兼容的 handlers 命名空间,避免修改 main.rs / batch_sync.rs 里的导入
pub mod handlers {
pub use super::agent::{
answer_question, chat_agent, delete_session, get_agent_metrics, get_pending_questions,
get_session, get_session_audit, list_sessions, stop_agent, AgentChatRequest,
AgentMetricsResponse, AuditLogEntry, MessageRecord, SessionDetail, SessionListParams,
SessionSummary,
answer_question, chat_agent, delete_session, get_agent_metrics, get_pending_permissions,
get_pending_questions, get_session, get_session_audit, list_sessions, respond_permission,
stop_agent, AgentChatRequest, AgentMetricsResponse, AuditLogEntry, MessageRecord,
SessionDetail, SessionListParams, SessionSummary,
};
pub use super::helpers::{
check_paper_paths_in_db, convert_ads_doc_to_standard, convert_arxiv_to_standard,
@ -109,6 +134,7 @@ pub mod handlers {
PaperDetailResponse, ParseRequest, ParseResponse, SearchParams, TranslateRequest,
TranslateResponse,
};
pub use super::permissions::{update_permission_mode, update_permission_rules};
pub use super::sync::{
delete_sync_query, get_asset_batch_status, get_meta_sync_count, get_meta_sync_status,
get_sync_queries, run_asset_batch, run_meta_sync, stop_asset_batch, AssetBatchRunRequest,

186
src/api/permissions.rs Normal file
View File

@ -0,0 +1,186 @@
// src/api/permissions.rs
//
// 会话内权限规则动态更新 API。
// 参考 Claude Code PermissionUpdate 设计。
//
// 端点:
// POST /api/chat/sessions/:id/permissions — 添加/移除规则
// PUT /api/chat/sessions/:id/permissions/mode — 切换权限模式
// GET /api/chat/sessions/:id/permissions/rules — 列出当前规则
use axum::extract::{Path, State};
use axum::Json;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tracing::info;
use crate::agent::runtime::permission::{PermissionChecker, PermissionMode};
use crate::agent::tools::{PermissionRule, PermissionRuleSource};
use crate::api::AppState;
// ── Request / Response types ──
#[derive(Debug, Deserialize)]
pub struct PermissionRuleRequest {
/// 规则类型: "deny" | "allow" | "ask"
pub kind: String,
/// 规则字符串,兼容 PermissionChecker 格式(如 "run_bash(rm *)" 或 "*"
pub rule: String,
/// 操作: "add" | "remove"
pub action: String,
}
#[derive(Debug, Deserialize)]
pub struct PermissionModeRequest {
/// 目标模式: "default" | "accept_edits" | "bypass" | "dont_ask"
pub mode: String,
}
#[derive(Debug, Serialize)]
pub struct PermissionRulesResponse {
pub deny_rules: Vec<String>,
pub allow_rules: Vec<String>,
pub ask_rules: Vec<String>,
pub mode: String,
}
#[derive(Debug, Serialize)]
pub struct PermissionActionResponse {
pub success: bool,
pub message: String,
}
// ── Handlers ──
/// 动态添加或移除权限规则。
pub async fn update_permission_rules(
Path(session_id): Path<String>,
State(state): State<Arc<AppState>>,
Json(req): Json<PermissionRuleRequest>,
) -> Json<PermissionActionResponse> {
info!(
"[Permissions API] 会话 {} 请求 {} 规则: kind={}, rule={}",
session_id, req.action, req.kind, req.rule
);
if !["deny", "allow", "ask"].contains(&req.kind.as_str()) {
return Json(PermissionActionResponse {
success: false,
message: format!("无效的规则类型: {},有效值为 deny/allow/ask", req.kind),
});
}
match req.action.as_str() {
"add" => {
// 解析规则字符串并添加到会话级检查器
let rule = match PermissionChecker::parse_rule_str(&req.rule, &req.kind) {
Some(r) => r,
None => {
return Json(PermissionActionResponse {
success: false,
message: format!("无法解析规则: {}", req.rule),
});
}
};
// 强制设为 Session 来源
let session_rule = match rule {
PermissionRule::Deny {
tool_name, reason, ..
} => PermissionRule::Deny {
tool_name,
reason,
source: PermissionRuleSource::Session,
},
PermissionRule::Allow { tool_name, .. } => PermissionRule::Allow {
tool_name,
source: PermissionRuleSource::Session,
},
PermissionRule::Ask {
tool_name, message, ..
} => PermissionRule::Ask {
tool_name,
message,
source: PermissionRuleSource::Session,
},
};
if let Ok(mut checker) = state.session_permission_checker.write() {
checker.add_rule_dynamic(session_rule);
Json(PermissionActionResponse {
success: true,
message: format!("已添加 {}:{} 规则", req.kind, req.rule),
})
} else {
Json(PermissionActionResponse {
success: false,
message: "权限检查器锁定失败".to_string(),
})
}
}
"remove" => {
if let Ok(mut checker) = state.session_permission_checker.write() {
let removed = checker.remove_rule_dynamic(&req.rule, &req.kind);
Json(PermissionActionResponse {
success: removed > 0,
message: if removed > 0 {
format!("已移除 {}{}/{} 规则", removed, req.kind, req.rule)
} else {
format!("未找到匹配的 {}/{} 规则", req.kind, req.rule)
},
})
} else {
Json(PermissionActionResponse {
success: false,
message: "权限检查器锁定失败".to_string(),
})
}
}
other => Json(PermissionActionResponse {
success: false,
message: format!("无效的操作: {},有效值为 add/remove", other),
}),
}
}
/// 切换权限模式。
pub async fn update_permission_mode(
Path(session_id): Path<String>,
State(state): State<Arc<AppState>>,
Json(req): Json<PermissionModeRequest>,
) -> Json<PermissionActionResponse> {
let mode = PermissionMode::from_str(&req.mode);
let mode_str = format!("{:?}", mode);
info!(
"[Permissions API] 会话 {} 切换权限模式: {}",
session_id, mode_str
);
if let Ok(mut checker) = state.session_permission_checker.write() {
checker.set_mode(mode);
Json(PermissionActionResponse {
success: true,
message: format!("权限模式已切换为: {}", mode_str),
})
} else {
Json(PermissionActionResponse {
success: false,
message: "权限检查器锁定失败".to_string(),
})
}
}
/// 列出当前会话的所有权限规则。
pub async fn list_permission_rules(
State(_state): State<Arc<AppState>>,
) -> Json<PermissionRulesResponse> {
// Read-only: return an empty snapshot (the checker doesn't expose its rules list directly).
// For now, return empty — future enhancement can add a snapshot method.
Json(PermissionRulesResponse {
deny_rules: Vec::new(),
allow_rules: Vec::new(),
ask_rules: Vec::new(),
mode: "default".to_string(),
})
}

View File

@ -378,6 +378,7 @@ impl LlmClient {
&self,
messages: &[ChatMessage],
tools: &[ToolDefinition],
enable_thinking: bool,
) -> anyhow::Result<CompletionResult> {
let url = format!("{}/chat/completions", self.api_base);
@ -387,9 +388,10 @@ impl LlmClient {
"temperature": 0.3
});
// 如果是通义千问 (DashScope) 或者是 Qwen 模型,自动开启思考模式
if self.api_base.contains("dashscope.aliyuncs.com")
|| self.model.to_lowercase().contains("qwen")
// 前端可控的思考模式开关(仅对千问/DashScope 启用 enable_thinking 参数)
if enable_thinking
&& (self.api_base.contains("dashscope.aliyuncs.com")
|| self.model.to_lowercase().contains("qwen"))
{
if let Some(obj) = payload.as_object_mut() {
obj.insert("enable_thinking".to_string(), serde_json::json!(true));
@ -456,6 +458,7 @@ impl LlmClient {
&self,
messages: &[ChatMessage],
tools: &[ToolDefinition],
enable_thinking: bool,
) -> anyhow::Result<tokio::sync::mpsc::UnboundedReceiver<StreamEvent>> {
let url = format!("{}/chat/completions", self.api_base);
@ -467,9 +470,10 @@ impl LlmClient {
"stream_options": { "include_usage": true }
});
// 如果是通义千问 (DashScope) 或者是 Qwen 模型,自动开启思考模式
if self.api_base.contains("dashscope.aliyuncs.com")
|| self.model.to_lowercase().contains("qwen")
// 前端可控的思考模式开关(仅对千问/DashScope 启用 enable_thinking 参数)
if enable_thinking
&& (self.api_base.contains("dashscope.aliyuncs.com")
|| self.model.to_lowercase().contains("qwen"))
{
if let Some(obj) = payload.as_object_mut() {
obj.insert("enable_thinking".to_string(), serde_json::json!(true));
@ -551,7 +555,14 @@ impl LlmClient {
let (id, name, args) =
tool_call_accumulators.remove(&idx).unwrap();
ToolCall {
id,
id: if id.is_empty() {
format!(
"call_{}",
&uuid::Uuid::new_v4().to_string()[..8]
)
} else {
id
},
call_type: "function".to_string(),
function: FunctionCall {
name,
@ -690,7 +701,11 @@ impl LlmClient {
.map(|idx| {
let (id, name, args) = tool_call_accumulators.remove(&idx).unwrap();
ToolCall {
id,
id: if id.is_empty() {
format!("call_{}", &uuid::Uuid::new_v4().to_string()[..8])
} else {
id
},
call_type: "function".to_string(),
function: FunctionCall {
name,

View File

@ -182,10 +182,14 @@ async fn main() -> anyhow::Result<()> {
cancelled_runs: Arc::new(Mutex::new(std::collections::HashSet::new())),
skill_registry,
pending_questions: Arc::new(Mutex::new(HashMap::new())),
pending_permissions: Arc::new(Mutex::new(HashMap::new())),
sse_broadcast: None,
memory_manager: Arc::new(tokio::sync::Mutex::new(
astroresearch::agent::memory::MemoryManager::new(config.library_dir.clone()),
)),
session_permission_checker: Arc::new(RwLock::new(
astroresearch::agent::runtime::permission::PermissionChecker::new(),
)),
});
// 7. 设置 Axum 路由、CORS 头以及 React 仪表盘静态资源托管
@ -245,7 +249,23 @@ async fn main() -> anyhow::Result<()> {
.route("/chat/sessions/:id/stop", post(handlers::stop_agent))
.route("/chat/sessions/:id/audit", get(handlers::get_session_audit))
.route("/chat/questions", get(handlers::get_pending_questions))
.route("/chat/answer", post(handlers::answer_question));
.route("/chat/answer", post(handlers::answer_question))
.route(
"/chat/sessions/:id/permissions",
get(handlers::get_pending_permissions),
)
.route(
"/chat/sessions/:id/permissions/respond",
post(handlers::respond_permission),
)
.route(
"/chat/sessions/:id/permissions/rules",
post(handlers::update_permission_rules),
)
.route(
"/chat/sessions/:id/permissions/mode",
axum::routing::put(handlers::update_permission_mode),
);
// 静态文件资源代理托管(当前端打包至 dashboard/dist 后,直接挂载到主域名根路由)
let serve_dir = ServeDir::new("dashboard/dist").fallback(tower_http::services::ServeFile::new(

View File

@ -449,9 +449,7 @@ pub fn convert_html_math_to_latex(
}
fn html_math_to_latex(s: &str) -> String {
let s = s
.replace('', " ")
.replace(['', '\u{2006}'], "\\,");
let s = s.replace('', " ").replace(['', '\u{2006}'], "\\,");
let s = s
.replace('≤', " \\le ")
.replace('≥', " \\ge ")