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:
parent
49784739fa
commit
f6df9d8136
130
.env.example
130
.env.example
@ -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
|
||||
|
||||
39
CLAUDE.md
39
CLAUDE.md
@ -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.
|
||||
|
||||
@ -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"] }
|
||||
|
||||
95
README.md
95
README.md
@ -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 # 天文学双语名词词典
|
||||
```
|
||||
|
||||
271
dashboard/src/features/agent/AgentMetricsPanel.tsx
Normal file
271
dashboard/src/features/agent/AgentMetricsPanel.tsx
Normal 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;
|
||||
}
|
||||
293
dashboard/src/features/agent/AskUserQuestionCard.tsx
Normal file
293
dashboard/src/features/agent/AskUserQuestionCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
227
dashboard/src/features/agent/AuditLogViewer.tsx
Normal file
227
dashboard/src/features/agent/AuditLogViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
129
dashboard/src/features/agent/PermissionRequestCard.tsx
Normal file
129
dashboard/src/features/agent/PermissionRequestCard.tsx
Normal 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
@ -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;
|
||||
}
|
||||
|
||||
@ -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 的架构范式高度一致。
|
||||
|
||||
以下按**影响优先级**列出可优化领域。
|
||||
|
||||
---
|
||||
|
||||
## 一、CRITICAL:Streaming 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 等有副作用的工具与其他工具竞争导致的不确定性。
|
||||
|
||||
---
|
||||
|
||||
## 三、HIGH:Sibling 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 调用浪费。
|
||||
|
||||
---
|
||||
|
||||
## 四、HIGH:Error Recovery Ladder(错误恢复阶梯)
|
||||
|
||||
### 现状
|
||||
`compact.rs` 只有**主动压缩**(在达到 token 限制前触发)。如果压缩不够激进,413 `prompt_too_long` 错误会直接暴露给用户。
|
||||
|
||||
### Claude Code 做法
|
||||
`query.ts` 实现了多层恢复阶梯:
|
||||
|
||||
```
|
||||
第1层:Context Collapse drain(便宜,commit 已 staged 的 collapse)
|
||||
↓ 失败/不可用
|
||||
第2层:Reactive Compact(fork 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" 错误。
|
||||
|
||||
---
|
||||
|
||||
## 五、MEDIUM:Time-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>,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、MEDIUM:Permission 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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、MEDIUM:Progress 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(搜索进度)。
|
||||
|
||||
---
|
||||
|
||||
## 九、MEDIUM:Token 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-MEDIUM:Interrupt 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 // 默认安全取消
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十一、LOW:Circuit 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十二、LOW:Context Collapse / Projection System(架构级)
|
||||
|
||||
### 现状
|
||||
压缩直接修改消息数组,原始上下文永久丢失。
|
||||
|
||||
### Claude Code 做法
|
||||
`ContextCollapse` 采用 **commit log + projection** 模式:
|
||||
1. 不再直接修改消息
|
||||
2. 将旧的上下文段替换为摘要 + metadata
|
||||
3. 摘要存储在独立的 collapse store 中
|
||||
4. 每次查询循环入口通过 `projectView()` 重放 commit log 重建临时消息视图
|
||||
5. Commit 是 staged(先暂存)再 committed(on 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 生命周期管理
|
||||
102
docs/api.md
102
docs/api.md
@ -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` | 设置当前活跃文献 |
|
||||
|
||||
|
||||
@ -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 (通道 A:arXiv 直连优先)
|
||||
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 (通道 B:ADS 路由回退)
|
||||
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) | 常见问题诊断与修复 |
|
||||
|
||||
378
docs/architecture/agent/context.md
Normal file
378
docs/architecture/agent/context.md
Normal 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/>历史压缩为 <500 字中文"]
|
||||
L3["Layer 3: aggressive_micro<br/>零 API 调用<br/>激进占位符 keep_recent=2"]
|
||||
L4["Layer 4: identity_inject<br/>零 API 调用<br/>注入身份确认块"]
|
||||
L0 --> L1 --> L2 --> L3 --> L4
|
||||
```
|
||||
|
||||
| 层 | 触发条件 | 算法 | API 调用 | 关键参数 |
|
||||
|:---|:---|:---|:---|:---|
|
||||
| **snip** (L0) | 消息数 > `MAX_MESSAGES` (默认 50) | `find_safe_cut_point` 切中间段 → 插入占位消息 | 否 | HEAD_KEEP=3, tail_keep=47 |
|
||||
| **micro** (L1) | L0 后仍超 `context_char_limit × 1.5` | 工具结果 → `[Previous: used {tool_name}]` | 否 | keep_recent=8 |
|
||||
| **auto** (L2) | L1 后仍超限 | LLM 生成中文摘要替换历史 | 是 | 摘要 ≤500 字 |
|
||||
| **aggressive_micro** (L3) | L2 后仍超限 | 同 L1 但仅保留最近 2 条工具结果 | 否 | keep_recent=2 |
|
||||
| **identity** (L4) | 压缩后消息 ≤4 条 | 注入 `[身份确认]` 块 | 否 | 天体物理学研究助手身份 |
|
||||
|
||||
### 3.2 压缩触发机制 (在 ReAct 循环中)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["estimated_tokens > token_budget.soft_limit?"]
|
||||
A --> B["检查熔断器 can_attempt()"]
|
||||
B -->|"Closed / HalfOpen"| C["执行 snapshot_compress_restore()"]
|
||||
B -->|"Open"| D["跳过,记录警告"]
|
||||
C --> E{"messages.len() < before_len ?"}
|
||||
E -->|"是"| F["record_success()"]
|
||||
E -->|"否"| G["record_failure()"]
|
||||
```
|
||||
|
||||
Token 估算策略:
|
||||
- 优先使用 API 返回的精确 `prompt_tokens`
|
||||
- 辅以增量估算:新增消息数 × (content.len() + 4) 字符估算
|
||||
- 首次无 API 数据时回退到 `rough_estimate_tokens`
|
||||
|
||||
### 3.3 手动压缩 (`compress_context` 工具触发)
|
||||
|
||||
- LLM 调用 `compress_context` 工具 → 设置 `pending_manual_compress = true`
|
||||
- 下一轮循环中执行压缩(若刚已自动压缩则跳过)
|
||||
- 手动压缩不受熔断器限制,成功后重置熔断器
|
||||
|
||||
### 3.4 安全切割点 (`find_safe_cut_point`)
|
||||
|
||||
确保不破坏 `assistant(tool_calls) ↔ tool_result` 配对的算法:
|
||||
|
||||
1. 计算候选切割点 `messages.len() - desired_keep`
|
||||
2. 若切割点落在 `tool` 消息上 → 向前追溯到对应 `assistant(tool_calls)` 一并保留
|
||||
3. 向前扫描孤立 `assistant(tool_calls)`(无 tool_result 配对)→ 切点前移
|
||||
|
||||
### 3.5 压缩熔断器 (`runtime/circuit_breaker.rs`)
|
||||
|
||||
防止无限自动压缩的三态熔断器,创建于 `AgentRuntime::new()`,跨 turn 共享:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Closed: 初始状态
|
||||
Closed --> Closed: record_success() → 重置计数
|
||||
Closed --> Open: 连续失败 3 次
|
||||
Open --> HalfOpen: 5 分钟后自动恢复
|
||||
HalfOpen --> Closed: record_success()
|
||||
HalfOpen --> Open: record_failure()
|
||||
Closed --> Closed: reset() (手动压缩成功后)
|
||||
```
|
||||
|
||||
| 参数 | 值 | 说明 |
|
||||
|:---|:---|:---|
|
||||
| `MAX_CONSECUTIVE_FAILURES` | 3 | 连续失败次数阈值 |
|
||||
| `AUTO_RECOVERY_TIMEOUT_SECS` | 300 | 熔断后自动尝试恢复的等待时间 |
|
||||
| 压缩成功判定 | `messages.len() < before_len` | 消息数减少即视为成功 |
|
||||
|
||||
### 3.6 递归守卫
|
||||
|
||||
`COMPACTING` AtomicBool — 压缩内部触发的 LLM 调用可能再次触发压缩,递归守卫通过 `compare_exchange` 防止嵌套压缩死循环。
|
||||
|
||||
---
|
||||
|
||||
## 4. 文件缓存与压缩集成 (`runtime/file_cache.rs`)
|
||||
|
||||
压缩前后的文件状态保护循环 (`snapshot_compress_restore`):
|
||||
|
||||
```
|
||||
压缩前
|
||||
├─ FileStateCache.to_snapshot() → 保存所有已读文件快照(按时间戳降序)
|
||||
└─ FileStateCache.clear() → 清空缓存
|
||||
|
||||
压缩后
|
||||
├─ restore_from_snapshot(max=5) → 恢复最近 5 个文件到 LRU 缓存
|
||||
└─ build_restore_context(max=5) → 生成 "[压缩后恢复: {path}]" 块注入消息
|
||||
```
|
||||
|
||||
| 参数 | 值 | 说明 |
|
||||
|:---|:---|:---|
|
||||
| `MAX_ENTRIES` | 100 | 缓存条目上限 |
|
||||
| `MAX_CACHE_SIZE_BYTES` | 25 MB | 缓存内容总大小上限 |
|
||||
| `POST_COMPACT_MAX_FILES_TO_RESTORE` | 5 | 压缩后恢复的文件数 |
|
||||
| `POST_COMPACT_MAX_CHARS_PER_FILE` | 4,000 | 每文件恢复内容上限 |
|
||||
| `FILE_UNCHANGED_STUB` | 静态字符串 | 文件未变时返回的占位消息 |
|
||||
|
||||
LRU 淘汰策略:容量超限时自动驱逐最久未使用的条目。
|
||||
|
||||
---
|
||||
|
||||
## 5. 错误恢复系统 (`runtime/error_recovery.rs`)
|
||||
|
||||
### 5.1 错误分类 (`classify_error`)
|
||||
|
||||
| ErrorKind | 检测关键词 |
|
||||
|:---|:---|
|
||||
| `RateLimited` | 429, rate limit, rate_limit, too many requests |
|
||||
| `Overloaded` | 529, overloaded, overload, service overloaded |
|
||||
| `PromptTooLong` | prompt_too_long, context length, 413, context_window_exceeded, input length |
|
||||
| `TokenExhausted` | max_tokens, token limit, token_exhausted, maximum context length |
|
||||
| `Timeout` | timeout, timed out, deadline exceeded, 408, 504 |
|
||||
| `ModelError` | 默认归类,携带原始错误字符串 |
|
||||
|
||||
### 5.2 恢复阶梯
|
||||
|
||||
LLM 流式调用失败后的 5 级恢复:
|
||||
|
||||
| 步骤 | RecoveryStep | 操作 | 适用错误 |
|
||||
|:---|:---|:---|:---|
|
||||
| 1 | `AggressiveCompact` | snip + micro(keep_recent=2) | PromptTooLong, TokenExhausted |
|
||||
| 2 | `ReactiveCompact` | LLM 摘要压缩 | 同上 |
|
||||
| 3 | `EscalateTokens` | 提升 hard_limit → 64,000 | 同上 |
|
||||
| 4 | `MultiTurn` | 注入分步恢复消息 | 同上 |
|
||||
| 5 | `Surface` | 放弃恢复,暴露错误 | 不可恢复错误(ModelError 直接到此) |
|
||||
|
||||
每步有 `has_attempted` 守卫,防止无限循环。
|
||||
|
||||
### 5.3 429/529 退避重试(独立快速路径)
|
||||
|
||||
RateLimited/Overloaded 不走恢复阶梯,独立执行指数退避重试(最多 10 次):
|
||||
|
||||
- 延迟公式:`min(500 × 2^attempt, 32,000) + deterministic jitter`
|
||||
- 支持 `retry_after` header 解析
|
||||
- 529 连续 3 次过载 → 尝试切换到 `FALLBACK_MODEL` 环境变量指定的备用模型
|
||||
- 重试期间检查用户取消信号
|
||||
|
||||
---
|
||||
|
||||
## 6. 子代理上下文隔离 (`subagent.rs`)
|
||||
|
||||
父代理通过 `delegate_research` 委托子任务给子代理,子代理拥有独立的上下文环境:
|
||||
|
||||
| 组件 | 隔离方式 |
|
||||
|:---|:---|
|
||||
| 消息上下文 | 全新 `[system_prompt, user_prompt]`,不含父代理中间工具调用 |
|
||||
| 工具访问 | 共享 ToolRegistry(可配置受限 registry,如仅只读工具) |
|
||||
| Hook 管道 | 完整的 PreToolUse/PostToolUse + PermissionChecker |
|
||||
| 进度通知 | 通过 `progress_tx` 向父代理发送 Thought/ToolCall/ToolResult SSE 事件 |
|
||||
| 最终返回 | 仅返回 `[子代理活动记录] + [子代理结论]` 文本摘要 |
|
||||
| 消息持久化 | 以 `agent_name=sub_xxx` 写入父会话的 `agent_messages` 表 |
|
||||
|
||||
子代理也有独立的上下文压缩(同 `compact::compress_context`)、死循环检测(同 `DuplicateDetector` 逻辑)、强制终止(超 max_steps 时调用 `force_final_answer`)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 跨会话持久化
|
||||
|
||||
### 7.1 数据库表
|
||||
|
||||
| 表 | 持久化内容 | 上下文用途 |
|
||||
|:---|:---|:---|
|
||||
| `agent_sessions` | session_id, title, model, turn_count, metadata | 会话生命周期管理 |
|
||||
| `agent_messages` | role, content, thought, tool_calls(JSON), tool_call_id, token_count, metadata, raw_json(完整 ChatMessage), agent_name | 历史恢复 + 审计追溯 |
|
||||
| `agent_tasks` | task_id, content, status, blocked_by, owner | 跨 turn 任务状态恢复 |
|
||||
| `agent_audit_log` | session_id, step, tool_name, status, elapsed_ms, output_preview | 完整审计追踪 |
|
||||
|
||||
### 7.2 文件系统记忆 (`memory/`)
|
||||
|
||||
- Agent 可通过 `save_memory` 工具将重要信息写入 `~/.claude/projects/{project}/memory/`
|
||||
- 四类记忆:`user`, `feedback`, `project`, `reference`
|
||||
- 写入时门控:内容质量检查 + Jaccard 相似度去重(70% 阈值)
|
||||
- 每 turn 结束时 fire-and-forget 自动提取候选记忆
|
||||
- 下次会话的 system prompt 中自动加载最近 5 条记忆
|
||||
|
||||
### 7.3 Trajectory 导出
|
||||
|
||||
每 turn 结束时,`TrajectoryExporter::export()` 将完整会话轨迹导出到文件系统,用于调试和审计。
|
||||
|
||||
---
|
||||
|
||||
## 8. Lifecycle Hooks 与上下文事件
|
||||
|
||||
| Hook | 触发时机 | 上下文影响 |
|
||||
|:---|:---|:---|
|
||||
| `OnSessionStart` | 会话创建/恢复 | 通知生命周期开始 |
|
||||
| `PreToolUse` | 工具执行前 | 可 MutateInput 注入上下文、Block 阻止 |
|
||||
| `PostToolUse` | 工具执行后 | 可 MutateOutput 修改结果、审计日志写入 |
|
||||
| `OnStepComplete` | 每步结束 | 日志消息数/预算使用率 |
|
||||
| `OnPreCompact` | 压缩前 | 记录消息数/预估 tokens |
|
||||
| `OnPostCompact` | 压缩后 | 记录新消息数/压缩方法 |
|
||||
| `OnSubagentStart` | 子代理启动 | 通知子代理创建 |
|
||||
| `OnSubagentStop` | 子代理停止 | 记录结果摘要 |
|
||||
| `OnSessionStop` | 会话终止 | 清理取消状态、记录终止原因 |
|
||||
|
||||
---
|
||||
|
||||
## 9. SSE 流事件 — 前端上下文同步
|
||||
|
||||
Agent 内部上下文通过 SSE 事件实时同步到前端:
|
||||
|
||||
```
|
||||
Session → Thought → (ToolCall ↔ ToolResult)* → TextDelta → Usage → Done
|
||||
```
|
||||
|
||||
`tool_call.id` 贯穿全链路:LLM 生成 ID → 前端精确匹配 tool_call/tool_result 条目 → 审计日志关联。
|
||||
|
||||
---
|
||||
|
||||
## 10. 环境变量配置
|
||||
|
||||
| 变量 | 默认值 | 作用 |
|
||||
|:---|:---|:---|
|
||||
| `AGENT_MAX_STEPS` | 8 | ReAct 最大迭代步数 |
|
||||
| `AGENT_TOOL_TIMEOUT_SECS` | 120 | 工具执行超时(秒) |
|
||||
| `AGENT_MAX_TOOL_OUTPUT_CHARS` | 4000 | 工具输出截断长度(字符) |
|
||||
| `AGENT_CONTEXT_CHAR_LIMIT` | 16000 | 上下文压缩触发点(字符估算) |
|
||||
| `AGENT_TOKEN_SOFT_LIMIT` | 32000 | Token 预算软限制(触发 nudging) |
|
||||
| `AGENT_TOKEN_HARD_LIMIT` | 40000 | Token 预算硬限制(触发强制动作) |
|
||||
| `AGENT_MAX_MESSAGES` | 50 | snip_compact 触发阈值(消息数) |
|
||||
| `FALLBACK_MODEL` | — | 529 连续过载 3 次时的备用模型 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 关键源文件索引
|
||||
|
||||
| 文件 | 职责 |
|
||||
|:---|:---|
|
||||
| `src/agent/compact.rs` | 五层压缩 + 安全切割 + 递归守卫 + 身份注入 |
|
||||
| `src/agent/runtime/mod.rs` | ReAct 循环 + 压缩触发 + 后台通知 + todo nag |
|
||||
| `src/agent/runtime/context.rs` | 初始上下文构建 + 任务状态恢复 |
|
||||
| `src/agent/runtime/session.rs` | 会话 CRUD + 历史消息加载 |
|
||||
| `src/agent/runtime/system_prompt.rs` | 模块化 System Prompt 组装 |
|
||||
| `src/agent/runtime/token_budget.rs` | Token 预算 + 三级 Nudge + Dim Returns |
|
||||
| `src/agent/runtime/circuit_breaker.rs` | 压缩熔断器 |
|
||||
| `src/agent/runtime/error_recovery.rs` | 5 级恢复阶梯 + 错误分类 + 退避重试 |
|
||||
| `src/agent/runtime/file_cache.rs` | 文件状态缓存 + 压缩快照/恢复 |
|
||||
| `src/agent/runtime/streaming.rs` | 流式响应 + 取消竞速 |
|
||||
| `src/agent/runtime/executor.rs` | 工具验证 + 并行执行 + Hook 集成 |
|
||||
| `src/agent/runtime/finalize.rs` | 会话收尾 + 记忆提取 + 轨迹导出 |
|
||||
| `src/agent/subagent.rs` | 子代理上下文隔离 + 独立 ReAct 循环 |
|
||||
| `src/agent/background.rs` | 后台任务队列 + 通知注入 |
|
||||
| `src/agent/hooks.rs` | 9 个生命周期事件 + 内置 3 Hook |
|
||||
| `src/agent/skills.rs` | 两层技能加载 + 热重载 + 条件激活 |
|
||||
| `src/agent/tools/memory.rs` | save_memory 工具 + 写入门控 |
|
||||
134
docs/architecture/agent/env-vars.md
Normal file
134
docs/architecture/agent/env-vars.md
Normal 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 Key(PDF 配图托管) |
|
||||
| `QINIU_SK` | — | 七牛云 Secret Key |
|
||||
| `QINIU_BUCKET` | — | 七牛云存储空间名 |
|
||||
| `QINIU_DOMAIN` | — | 七牛云 CDN 域名 |
|
||||
| `MINERU_API_URL` | — | MinerU PDF 解析远程 API 地址 |
|
||||
| `MINERU_API_KEY` | — | MinerU API Token |
|
||||
|
||||
### 4.4 日志
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|:---|:---|:---|
|
||||
| `LOG_LEVEL` | `info,astroresearch=debug` | 日志级别:trace / debug / info / warn / error,可指定模块级别 |
|
||||
| `LOG_FORMAT` | `pretty` | 日志格式:`pretty`(彩色控制台)或 `json`(结构化) |
|
||||
| `LOG_OUTPUTS` | `stdout,file` | 日志输出目标,逗号分隔:`stdout`(控制台)、`file`(滚动文件) |
|
||||
| `LOG_DIR` | `./logs` | 日志文件目录(仅 `LOG_OUTPUTS` 含 `file` 时生效),按天滚动 |
|
||||
|
||||
### 4.5 其他
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|:---|:---|:---|
|
||||
| `OBSCURA_ROTATE_PROFILE` | `true` | Obscura 浏览器 Profile 轮换,用于绕过 Cloudflare/WAF |
|
||||
| `OBSCURA_ALLOW_PRIVATE_NETWORK` | — | Obscura 内网访问许可(程序自动设置,无需手动配置) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 与模型 Context Window 的配置建议
|
||||
|
||||
推荐的配置比例(为多轮对话预留空间):
|
||||
|
||||
| 模型 Context | 建议 soft_limit | 建议 hard_limit | 说明 |
|
||||
|:---|:---|:---|:---|
|
||||
| 128K (GPT-4o) | 32,000 (25%) | 40,000 (31%) | 默认值适用 |
|
||||
| 128K (Claude) | 32,000 (25%) | 64,000 (50%) | Claude 200K 则向上调整 |
|
||||
| 200K (Claude) | 64,000 (32%) | 100,000 (50%) | 大 context 可更宽松 |
|
||||
| 32K (旧模型) | 8,000 (25%) | 16,000 (50%) | 小 context 需更激进压缩 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 关键源文件索引
|
||||
|
||||
| 文件 | 加载的变量 |
|
||||
|:---|:---|
|
||||
| `src/lib.rs` (Config) | `LLM_API_KEY`, `LLM_API_BASE`, `LLM_MODEL`, `EMBEDDING_*`, `ADS_API_KEY`, `DATABASE_URL`, `LIBRARY_DIR`, `SKILLS_DIR`, `PORT`, `QINIU_*`, `MINERU_*` |
|
||||
| `src/main.rs` | `EMBEDDING_DIM` |
|
||||
| `src/services/logging.rs` | `LOG_LEVEL`, `LOG_FORMAT`, `LOG_OUTPUTS`, `LOG_DIR` |
|
||||
| `src/agent/runtime/mod.rs` (AgentConfig) | `AGENT_MAX_STEPS`, `AGENT_TOOL_TIMEOUT_SECS`, `AGENT_MAX_TOOL_OUTPUT_CHARS`, `AGENT_CONTEXT_CHAR_LIMIT`, `AGENT_TOKEN_SOFT_LIMIT`, `AGENT_TOKEN_HARD_LIMIT`, `AGENT_MAX_MESSAGES` |
|
||||
| `src/agent/runtime/mod.rs` (call_llm_with_recovery) | `FALLBACK_MODEL` |
|
||||
| `src/agent/memory/extraction.rs` (ExtractionConfig) | `EXTRACT_MEMORY_ENABLED`, `EXTRACT_MEMORY_THROTTLE_TURNS`, `EXTRACT_MEMORY_MAX_STEPS` |
|
||||
| `src/services/download.rs` | `OBSCURA_ALLOW_PRIVATE_NETWORK`(程序自动设置) |
|
||||
274
docs/architecture/agent/hooks.md
Normal file
274
docs/architecture/agent/hooks.md
Normal 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<HashSet<String>>"]
|
||||
Registry --> MH["MetricsHook<br/>Arc<Mutex<MetricsData>> (共享)"]
|
||||
Registry --> AH["AuditLogHook<br/>SqlitePool (fire-and-forget 写入)"]
|
||||
```
|
||||
|
||||
## 生命周期事件(9 个)
|
||||
|
||||
| # | 事件 | 触发时机 | 返回值 | 调用位置 |
|
||||
|---|------|---------|--------|---------|
|
||||
| 1 | `OnSessionStart` | 会话创建/恢复 | 无 (fire-and-forget) | `AgentRuntime::run_turn()` |
|
||||
| 2 | `PreToolUse` | 每个工具执行前 | `PreToolUseAction` (可拦截/修改参数) | `executor::execute_parallel()` |
|
||||
| 3 | `PostToolUse` | 每个工具执行后 | `PostToolUseAction` (可修改输出) | `executor::execute_parallel()` |
|
||||
| 4 | `OnStepComplete` | 每步 ReAct 结束 | 无 | `AgentRuntime::run_react_loop()` |
|
||||
| 5 | `OnSessionStop` | 会话终止(任何原因) | 无 | `finalize::finalize_turn()` |
|
||||
| 6 | `OnSubagentStart` | 子代理启动 | 无 | `SubAgentRunner::run()` |
|
||||
| 7 | `OnSubagentStop` | 子代理停止 | 无 | `SubAgentRunner::run()` |
|
||||
| 8 | `OnPreCompact` | 上下文压缩前 | 无 | `compact::compress_context_with_hooks()` |
|
||||
| 9 | `OnPostCompact` | 上下文压缩后 | 无 | `compact::compress_context_with_hooks()` |
|
||||
|
||||
## 核心类型
|
||||
|
||||
### AgentHook trait
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait AgentHook: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
async fn on_session_start(&self, _ctx: &SessionStartContext) {}
|
||||
async fn pre_tool_use(&self, _ctx: &PreToolUseContext) -> PreToolUseAction { ... }
|
||||
async fn post_tool_use(&self, _ctx: &PostToolUseContext) -> PostToolUseAction { ... }
|
||||
async fn on_step_complete(&self, _ctx: &StepCompleteContext) {}
|
||||
async fn on_session_stop(&self, _ctx: &SessionStopContext<'_>) {}
|
||||
async fn on_subagent_start(&self, _ctx: &SubagentStartContext) {}
|
||||
async fn on_subagent_stop(&self, _ctx: &SubagentStopContext) {}
|
||||
async fn on_pre_compact(&self, _ctx: &PreCompactContext) {}
|
||||
async fn on_post_compact(&self, _ctx: &PostCompactContext) {}
|
||||
}
|
||||
```
|
||||
|
||||
所有 9 个方法都有默认空实现——hook 实现者只需覆写关心的 hook 点,遵循**接口隔离原则**。
|
||||
|
||||
### PreToolUseAction(工具执行前返回值)
|
||||
|
||||
```rust
|
||||
pub enum PreToolUseAction {
|
||||
Continue, // 允许执行(默认)
|
||||
Block { reason: String }, // 阻止执行
|
||||
MutateInput { // 修改参数 + 注入上下文
|
||||
updated_args: serde_json::Value,
|
||||
additional_context: Option<String>,
|
||||
},
|
||||
PermissionRequired { permission: String, tool_name: String }, // 需要权限决策 (Phase 2)
|
||||
}
|
||||
```
|
||||
|
||||
向后兼容:`pub type HookAction = PreToolUseAction;`
|
||||
|
||||
### PostToolUseAction(工具执行后返回值)
|
||||
|
||||
```rust
|
||||
pub enum PostToolUseAction {
|
||||
Continue, // 保持输出不变
|
||||
MutateOutput { updated_content: String }, // 修改输出内容
|
||||
}
|
||||
```
|
||||
|
||||
### 聚合结果类型
|
||||
|
||||
```rust
|
||||
pub struct PreToolUseResult {
|
||||
pub action: PreToolUseAction, // 最终动作(第一个 Block 获胜)
|
||||
pub additional_context: Option<String>, // 累积的附加上下文(所有 MutateInput 拼接)
|
||||
pub final_args: serde_json::Value, // 最终参数(最后一个 MutateInput 获胜)
|
||||
}
|
||||
|
||||
pub struct PostToolUseResult {
|
||||
pub final_content: String, // 最终输出(最后一个 MutateOutput 获胜)
|
||||
}
|
||||
```
|
||||
|
||||
## HookRegistry 聚合逻辑
|
||||
|
||||
**PreToolUse 聚合(责任链 + 短路)**:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start["run_pre_tool_use(ctx)"] --> Loop["遍历 hooks,依次调用 pre_tool_use()"]
|
||||
Loop --> Check{"结果类型?"}
|
||||
Check -->|"Block"| Short["立即短路返回<br/>不询问后续 hooks"]
|
||||
Check -->|"MutateInput"| Mut["更新 final_args<br/>累积 additional_context (\\n 拼接)"]
|
||||
Check -->|"PermissionRequired"| Log["记录日志但不阻止执行<br/>(Phase 2 预留)"]
|
||||
Check -->|"Continue"| Next["继续下一个 hook"]
|
||||
Mut --> Next
|
||||
Log --> Next
|
||||
Next --> Loop
|
||||
Short --> Return["返回 PreToolUseResult"]
|
||||
Next -->|"遍历完毕"| Return
|
||||
```
|
||||
|
||||
关键设计:
|
||||
- **第一个 Block 获胜** — 短路保护
|
||||
- **最后一个 MutateInput 获胜** — 后覆盖前
|
||||
- **additional_context 累积** — 多个 hook 的上下文用 `\n` 连接
|
||||
|
||||
**PostToolUse 聚合(全部执行,无短路)**:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start2["run_post_tool_use(ctx)"] --> Loop2["遍历所有 hooks,依次调用 post_tool_use()"]
|
||||
Loop2 --> MutOut{"MutateOutput ?"}
|
||||
MutOut -->|"是"| Update["更新 final_content<br/>(最后的 MutateOutput 获胜)"]
|
||||
MutOut -->|"Continue"| Next2["继续下一个 hook"]
|
||||
Update --> Next2
|
||||
Next2 --> Loop2
|
||||
Next2 -->|"遍历完毕"| Return2["返回 PostToolUseResult { final_content }"]
|
||||
```
|
||||
|
||||
**其余 7 个事件** 均为 fire-and-forget:遍历所有 hooks 调用对应方法,不收集返回值。
|
||||
|
||||
## 内置 Hooks(3 个)
|
||||
|
||||
| Hook | 覆写的事件 | 职责 | 关键依赖 |
|
||||
|------|-----------|------|---------|
|
||||
| `CancellationHook` | `pre_tool_use`, `on_session_stop` | 每次工具执行前检查用户是否中止会话;会话停止时清理取消令牌 | `Arc<Mutex<HashSet<String>>>` (与 AppState 共享) |
|
||||
| `MetricsHook` | `on_session_start`, `post_tool_use`, `on_step_complete`, `on_session_stop` | 采集运行指标:工具调用次数、步数、错误数、token 消耗;每 3 步输出摘要日志 | `Arc<Mutex<MetricsData>>` (**共享引用**,API 通过 `AgentRuntime::get_metrics()` 实时查询) |
|
||||
| `AuditLogHook` | `post_tool_use`, `on_session_stop` | 所有工具调用写入 `agent_audit_log` 表(工具名、状态、耗时、输出预览);会话终止写入 SESSION_STOP 标记 | `SqlitePool` (**fire-and-forget** 写入,不阻塞主循环) |
|
||||
|
||||
> **注意**:代码中**不存在** PermissionHook。权限检查由独立的 `PermissionChecker` (`src/agent/runtime/permission.rs`) 负责,该组件在工具执行前与 hooks 并行调用,不属于 hooks 体系。`PreToolUseAction::PermissionRequired` 变体预留于 Phase 2 完善。
|
||||
|
||||
## 数据流
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant API as API Handler
|
||||
participant RT as AgentRuntime
|
||||
participant HR as HookRegistry
|
||||
participant CH as CancellationHook
|
||||
participant MH as MetricsHook
|
||||
participant AH as AuditLogHook
|
||||
participant EX as Executor
|
||||
|
||||
Note over API,EX: Phase 1 — 会话启动
|
||||
API->>RT: run_turn(question)
|
||||
RT->>HR: HookRegistry::with_builtins(db, cancelled_runs, metrics_data)
|
||||
RT->>HR: run_on_session_start(ctx)
|
||||
HR->>MH: 记录 session_id
|
||||
|
||||
Note over API,EX: Phase 2 — ReAct 循环
|
||||
loop 每步 (最多 max_steps)
|
||||
RT->>RT: LLM 流式调用
|
||||
RT->>EX: execute_parallel(tool_calls, hook_registry)
|
||||
|
||||
par 每个工具调用
|
||||
EX->>HR: run_pre_tool_use(pre_ctx)
|
||||
HR->>CH: 检查取消状态
|
||||
alt 已取消
|
||||
CH-->>HR: Block { reason }
|
||||
HR-->>EX: PreToolUseResult { action: Block }
|
||||
EX-->>EX: 跳过该工具
|
||||
else 未取消
|
||||
CH-->>HR: Continue
|
||||
HR-->>EX: PreToolUseResult { final_args, additional_context }
|
||||
EX->>EX: 执行工具
|
||||
EX->>HR: run_post_tool_use(post_ctx)
|
||||
HR->>MH: 更新工具调用计数/错误数
|
||||
HR->>AH: fire-and-forget INSERT agent_audit_log
|
||||
HR-->>EX: PostToolUseResult { final_content }
|
||||
end
|
||||
end
|
||||
|
||||
EX-->>RT: ToolExecutionResult { tool_messages }
|
||||
RT->>HR: run_on_step_complete(ctx)
|
||||
HR->>MH: 每 3 步输出摘要日志
|
||||
|
||||
opt 上下文超限
|
||||
RT->>RT: snapshot_compress_restore()
|
||||
Note over RT: PreCompact / PostCompact hooks 触发
|
||||
end
|
||||
end
|
||||
|
||||
Note over API,EX: Phase 3 — 会话收尾
|
||||
RT->>RT: finalize_turn()
|
||||
RT->>HR: run_on_session_stop(ctx)
|
||||
HR->>CH: 清理 cancelled_runs
|
||||
HR->>MH: 输出会话结束摘要
|
||||
HR->>AH: fire-and-forget SESSION_STOP 记录
|
||||
RT-->>API: Done (SSE)
|
||||
```
|
||||
|
||||
## HookRegistry 构建
|
||||
|
||||
`AgentRuntime::run_turn()` 在每次 turn 开始时构建 `HookRegistry`:
|
||||
|
||||
```rust
|
||||
let hook_registry = HookRegistry::with_builtins(
|
||||
db.clone(), // → AuditLogHook
|
||||
self.app_state.cancelled_runs.clone(), // → CancellationHook
|
||||
Some(self.metrics_data.clone()), // → MetricsHook (共享引用)
|
||||
);
|
||||
```
|
||||
|
||||
`MetricsHook` 使用 `from_arc()` 复用 `AgentRuntime` 自身的 `metrics_data: Arc<Mutex<MetricsData>>`,确保 hook 内部采集的指标与 `AgentRuntime::get_metrics()` API 查询返回的是同一份数据。
|
||||
|
||||
## 子代理中的 Hooks
|
||||
|
||||
`SubAgentRunner` 拥有独立的 hook 管道,共享同一个 `HookRegistry` 实例:
|
||||
|
||||
- `on_subagent_start` / `on_subagent_stop` 在子代理生命周期的首尾触发
|
||||
- 子代理的工具执行也经过 `run_pre_tool_use` / `run_post_tool_use`(通过 `subagent.rs:447-518`)
|
||||
- **已知不足**:子代理内部的上下文压缩 (`subagent.rs:270`) 直接调用 `compress_context` 而非 `compress_context_with_hooks`,导致 PreCompact/PostCompact 事件**不会**在子代理压缩时触发
|
||||
|
||||
## 扩展方式
|
||||
|
||||
添加自定义 hook 只需两步:
|
||||
|
||||
```rust
|
||||
// 1. 实现 AgentHook trait
|
||||
struct MyCustomHook;
|
||||
|
||||
#[async_trait]
|
||||
impl AgentHook for MyCustomHook {
|
||||
fn name(&self) -> &str { "MyCustomHook" }
|
||||
|
||||
async fn pre_tool_use(&self, ctx: &PreToolUseContext) -> PreToolUseAction {
|
||||
// 自定义逻辑
|
||||
PreToolUseAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 注册到 HookRegistry
|
||||
registry.add(Box::new(MyCustomHook));
|
||||
```
|
||||
|
||||
## 测试覆盖
|
||||
|
||||
`hooks.rs` 包含 8 个单元测试(`#[cfg(test)] mod tests`),覆盖:
|
||||
|
||||
| 测试 | 验证点 |
|
||||
|------|-------|
|
||||
| `test_hook_registry_runs_all_hooks` | 注册表遍历调用所有 hook |
|
||||
| `test_blocking_hook_stops_chain` | Block 短路机制 |
|
||||
| `test_mutate_input_accumulates_context` | 参数修改 + 上下文累积 |
|
||||
| `test_post_tool_use_mutate_output` | 输出修改 |
|
||||
| `test_cancellation_hook_blocks_when_cancelled` | CancellationHook 阻止逻辑 |
|
||||
| `test_cancellation_hook_allows_when_not_cancelled` | CancellationHook 放行逻辑 |
|
||||
| `test_metrics_hook_accumulates_counts` | MetricsHook 累加正确性 |
|
||||
| `test_session_start_hook_called` | OnSessionStart 调用 |
|
||||
| `test_new_lifecycle_events_called` | OnSubagentStart/Stop, PreCompact/PostCompact 调用 |
|
||||
|
||||
## 已知改进项
|
||||
|
||||
| 问题 | 说明 |
|
||||
|------|------|
|
||||
| `PermissionRequired` 未实现 | 代码中存在此变体但被当作 `Continue` 处理,注释标明 "Permission 系统在 Phase 2 中完善" |
|
||||
| 取消检查重复 | `CancellationHook::pre_tool_use` 与 `AgentRuntime::run_react_loop` 中的显式检查存在功能重叠 |
|
||||
| 子代理压缩未走 hooks | `subagent.rs:270` 直接调用 `compress_context` 而非 `compress_context_with_hooks` |
|
||||
| 缺少 `on_error` 事件 | `AgentHook` trait 没有错误生命周期事件,错误场景无法通过 hook 拦截 |
|
||||
|
||||
---
|
||||
432
docs/architecture/agent/memory.md
Normal file
432
docs/architecture/agent/memory.md
Normal 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}.md(frontmatter + content)
|
||||
→ update_index() → 更新 MEMORY.md
|
||||
→ reload()
|
||||
→ mark_main_agent_wrote()
|
||||
→ 返回: 操作状态 + 质量警告 + 重复提示 + manifest 清单
|
||||
|
||||
会话结束
|
||||
finalize.rs: finalize_turn()
|
||||
→ 更新 agent_sessions (turn_count, metrics)
|
||||
→ 运行 OnSessionStop hooks(审计日志、指标)
|
||||
→ 导出轨迹 JSONL
|
||||
→ 如果 EXTRACT_MEMORY_ENABLED:
|
||||
tokio::spawn(run_extraction())
|
||||
→ 检查节流 + 主代理写入标志
|
||||
→ 子代理分析对话 → 提取记忆 → save_memory
|
||||
```
|
||||
|
||||
## 子模块文件索引
|
||||
|
||||
| 文件 | 职责 |
|
||||
|:---|:---|
|
||||
| `src/agent/memory/mod.rs` | `MemoryManager` 核心:构造、`reload`、`save_memory`、`build_system_reminder`、索引维护 |
|
||||
| `src/agent/memory/types.rs` | `MemoryType`(4 变体)、`MemoryStatus`(Active/Historical)、`MemoryEntry`、frontmatter 解析器 |
|
||||
| `src/agent/memory/selection.rs` | LLM 语义记忆选择器 + 指数衰减排序 + recency 回退 |
|
||||
| `src/agent/memory/decay.rs` | 指数时间衰减 (`e^(-λt)`) + Hebbian Hot/Warm/Cool 激活层级 |
|
||||
| `src/agent/memory/age.rs` | 时效标签生成 + `<system-reminder>` freshness 警告 |
|
||||
| `src/agent/memory/guardrails.rs` | `WHAT_NOT_TO_SAVE` 排除规则 + `VERIFY_BEFORE_RECOMMENDING` 验证提醒 |
|
||||
| `src/agent/memory/dedup.rs` | Jaccard 相似度去重 (bigram) + `check_content_quality` 四层门控 + manifest 构建 |
|
||||
| `src/agent/memory/extraction.rs` | 自动提取:`ExtractionConfig`(env) + `ExtractionTracker` + `run_extraction` 子代理 |
|
||||
| `src/agent/tools/memory.rs` | `save_memory` 工具:`AgentTool` trait 实现,完整执行流程 |
|
||||
|
||||
---
|
||||
|
||||
174
docs/architecture/agent/overview.md
Normal file
174
docs/architecture/agent/overview.md
Normal file
@ -0,0 +1,174 @@
|
||||
# Agent 架构概览
|
||||
|
||||
AstroResearch 内置了一个基于 **ReAct** (Thought → Action → Observation) 范式的科研智能体引擎 (`src/agent/`),参考 Claude Code 的分层设计。以下对各子系统的架构、数据流和内部逻辑进行完整说明。
|
||||
|
||||
### 整体架构
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph API["API 层"]
|
||||
SSE["SSE /api/chat/agent"]
|
||||
Sessions["Session CRUD"]
|
||||
Metrics["GET /api/chat/metrics"]
|
||||
Audit["GET /api/chat/sessions/:id/audit"]
|
||||
AskUser["问答 /api/chat/questions + /api/chat/answer"]
|
||||
end
|
||||
|
||||
subgraph Runtime["AgentRuntime — ReAct 引擎"]
|
||||
RunTurn["run_turn() 主入口"]
|
||||
SP["SystemPrompt 组装器"]
|
||||
CtxBuild["Context Builder 上下文构建"]
|
||||
ReAct["ReAct 主循环"]
|
||||
Streaming["streaming.rs 流式处理"]
|
||||
Executor["executor.rs 并行执行"]
|
||||
Finalize["finalize.rs 会话收尾"]
|
||||
TokenBudget["token_budget.rs"]
|
||||
CircuitBreaker["circuit_breaker.rs"]
|
||||
end
|
||||
|
||||
subgraph Tools["工具系统 (tools/)"]
|
||||
AgentTool["AgentTool trait"]
|
||||
Registry["ToolRegistry"]
|
||||
FS["filesystem/ (6 工具)"]
|
||||
Astro["astro/ (7 工具)"]
|
||||
AskUserT["ask_user"]
|
||||
SubAgentT["subagent/delegate_research"]
|
||||
TeamT["team/ (4 工具)"]
|
||||
BG["background (2 工具)"]
|
||||
end
|
||||
|
||||
subgraph CrossCutting["横切关注点"]
|
||||
Hooks["HookRegistry (9 事件)"]
|
||||
Skills["SkillRegistry (两层加载)"]
|
||||
Memory["MemoryManager (项目记忆)"]
|
||||
Permission["PermissionChecker"]
|
||||
FileCache["FileStateCache (Read 去重)"]
|
||||
end
|
||||
|
||||
subgraph DB["持久化"]
|
||||
AgentSessions["agent_sessions"]
|
||||
AgentMessages["agent_messages"]
|
||||
AgentTasks["agent_tasks"]
|
||||
AgentAudit["agent_audit_log"]
|
||||
end
|
||||
|
||||
SSE --> RunTurn
|
||||
RunTurn --> SP
|
||||
RunTurn --> CtxBuild --> DB
|
||||
RunTurn --> ReAct
|
||||
ReAct --> Streaming --> Tools
|
||||
ReAct --> Executor --> Tools
|
||||
ReAct --> TokenBudget
|
||||
ReAct --> CircuitBreaker
|
||||
ReAct --> Finalize
|
||||
|
||||
Hooks -.-> ReAct
|
||||
Hooks -.-> Tools
|
||||
Skills -.-> Tools
|
||||
Memory -.-> SP
|
||||
Permission -.-> Tools
|
||||
FileCache -.-> Tools
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ReAct 运行循环 (`runtime/`)
|
||||
|
||||
主循环由 `AgentRuntime::run_turn()` 驱动,分为 4 个阶段:
|
||||
|
||||
#### 完整生命周期
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant FE as 前端 SSE
|
||||
participant RT as AgentRuntime
|
||||
participant DB as SQLite
|
||||
participant LLM as LLM API
|
||||
participant Tools as ToolRegistry
|
||||
|
||||
FE->>RT: POST /api/chat/agent { question, session_id? }
|
||||
|
||||
Note over RT: Phase 1 — 会话管理
|
||||
RT->>DB: create_or_resume_session()
|
||||
alt 新会话
|
||||
DB-->>RT: session_id = uuid, turn_index = 0
|
||||
else 恢复会话
|
||||
DB-->>RT: 验证存在 + 计算 turn_index
|
||||
end
|
||||
RT->>RT: 触发 OnSessionStart hook
|
||||
RT-->>FE: SSE session { session_id, title }
|
||||
|
||||
Note over RT: Phase 2 — 上下文构建
|
||||
RT->>RT: build_initial_context()
|
||||
RT->>RT: ① 组装 SystemPrompt (静态 section + 记忆注入)
|
||||
RT->>DB: ② 加载历史消息 load_history_for_llm()
|
||||
RT->>DB: ③ 恢复未完成任务 (agent_tasks)
|
||||
RT->>RT: ④ 检查压缩/清理上下文
|
||||
RT->>DB: ⑤ 保存用户消息
|
||||
RT->>RT: ⑥ 运行 PreToolUse hooks 过滤
|
||||
|
||||
Note over RT: Phase 3 — ReAct 循环
|
||||
loop 每步迭代 (step ≤ max_steps)
|
||||
RT->>LLM: chat_stream(messages + tool_defs)
|
||||
LLM-->>RT: ReasoningDelta / TextDelta / ToolCallsComplete
|
||||
RT-->>FE: SSE thought / text_delta / tool_call
|
||||
|
||||
alt 无工具调用 → 最终答案
|
||||
RT->>DB: 保存 assistant 消息
|
||||
RT-->>FE: SSE text_delta → usage → done
|
||||
Note over RT: break 循环
|
||||
else 有工具调用
|
||||
RT->>RT: 检查 token 预算 + 熔断器
|
||||
RT->>RT: validate_and_prepare() — 去重 + 过滤
|
||||
RT->>Tools: execute_parallel() — 并行执行
|
||||
Tools-->>RT: (tool_call_id, name, output)
|
||||
RT-->>FE: SSE tool_result { tool_call_id, name, output }
|
||||
RT->>DB: 保存 tool 消息 + 审计日志
|
||||
RT->>RT: 运行 PostToolUse hooks
|
||||
RT->>RT: 检测压缩需求 (auto_compact)
|
||||
end
|
||||
RT->>RT: 检测循环终止条件
|
||||
end
|
||||
|
||||
Note over RT: Phase 4 — 会话收尾
|
||||
RT->>DB: 更新 turn_count + updated_at
|
||||
RT->>DB: calculate_and_persist_metrics()
|
||||
RT->>RT: 运行 OnSessionStop hook
|
||||
RT-->>FE: SSE done
|
||||
```
|
||||
|
||||
#### 并行工具执行模型
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant ReAct as ReAct 循环
|
||||
participant Val as validate_and_prepare
|
||||
participant Exec as execute_parallel
|
||||
participant T1 as Tool A
|
||||
participant T2 as Tool B
|
||||
participant FE as 前端 SSE
|
||||
|
||||
ReAct->>Val: LLM 返回 [tool_call_a, tool_call_b]
|
||||
Val->>Val: 去重检测 + 权限验证
|
||||
Val-->>ReAct: prepared_calls[] + has_duplicate 标志
|
||||
ReAct->>FE: 发送 tool_call SSE (逐一)
|
||||
ReAct->>Exec: 启动 execute_parallel()
|
||||
par 并行执行
|
||||
Exec->>T1: tool_a.execute(args_a)
|
||||
Exec->>T2: tool_b.execute(args_b)
|
||||
end
|
||||
T1-->>Exec: ToolOutput { content, is_error }
|
||||
Exec-->>FE: SSE tool_result (立即推送)
|
||||
T2-->>Exec: ToolOutput { content, is_error }
|
||||
Exec-->>FE: SSE tool_result (立即推送)
|
||||
Exec-->>ReAct: Vec<(tool_call_id, name, args, output, cancelled)>
|
||||
ReAct->>ReAct: PostToolUse hooks + 审计日志 + 持久化
|
||||
```
|
||||
|
||||
- **并发上限**:由 `max_concurrent_tools` 环境变量控制,默认不限制
|
||||
- **Sibling Abort**:仅 `causes_sibling_abort() = true` 的工具(如 `download_paper`)能在出错时中断兄弟任务
|
||||
- **InterruptBehavior**:`Block` 工具(如 `ask_user`)不可被用户取消;`Cancel` 工具可在取消信号时中断
|
||||
- **超时控制**:每个工具有独立超时,默认 120s(`AGENT_TOOL_TIMEOUT_SECS`)
|
||||
|
||||
---
|
||||
|
||||
941
docs/architecture/agent/permission.md
Normal file
941
docs/architecture/agent/permission.md
Normal 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<PermissionChecker>"]
|
||||
PC2 -->|"check(tool_name, Some(&args))"| SubExec["三态检查<br/>Deny→注入错误 | Ask→自动拒绝 | Allow→执行"]
|
||||
end
|
||||
|
||||
subgraph "execute_parallel() Phase 2.5"
|
||||
PC3["permission_checker: Option<&PermissionChecker>"]
|
||||
PC3 -->|"check() + apply_mode() + 工具级叠加"| DenyCheck["━━ 三态处理 ━━<br/>Deny → 注入错误跳过执行<br/>AskUser → SSE PermissionRequest + oneshot 等待(120s超时)<br/>Allowed → 正常进入执行队列"]
|
||||
end
|
||||
|
||||
PC0 -.->|"构建"| PC1
|
||||
PC1 -.->|"传递给"| PC2
|
||||
PC1 -.->|"传递给"| PC3
|
||||
|
||||
style PC3 fill:#ccffcc,stroke:#00aa00
|
||||
```
|
||||
|
||||
> **✅ 完整已实现**:`executor::execute_parallel()` 在 Phase 2.5(PreToolUse hooks 之后、工具执行之前)执行完整的权限检查管道:
|
||||
> 1. `PermissionChecker::check(tool_name, tool_args)` — 规则链匹配 + 内容级匹配
|
||||
> 2. `PermissionChecker::apply_mode()` — 模式变换(Bypass/DontAsk/AcceptEdits)
|
||||
> 3. Hook `PermissionRequired` 升级 — hook 请求的权限确认为 AskUser
|
||||
> 4. 工具级 `check_permissions()` 叠加 — 工具自定义规则在 Allow 时升级为 Ask
|
||||
> 5. 最终三态分流:Deny → 注入错误 / AskUser → oneshot 交互(120s 超时) / Allowed → 正常执行
|
||||
|
||||
---
|
||||
|
||||
## 第三层:Hook 管道
|
||||
|
||||
Hook 系统提供了可编程的事件拦截点,参考 Claude Code 的 PreToolUse/PostToolUse/Stop hooks 设计。
|
||||
|
||||
定义位置:`src/agent/hooks.rs`
|
||||
|
||||
### PreToolUse 动作类型
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
PreToolUse["PreToolUse hook"]
|
||||
PreToolUse --> Continue["Continue<br/>正常执行"]
|
||||
PreToolUse --> Block["Block { reason }<br/>阻止执行,第一个 Block 短路整个链"]
|
||||
PreToolUse --> Mutate["MutateInput { updated_args, additional_context }<br/>修改参数 + 注入附加上下文"]
|
||||
PreToolUse --> PermReq["PermissionRequired { permission, tool_name }<br/>请求权限决策(Phase 2 待完善)"]
|
||||
```
|
||||
|
||||
### PostToolUse 动作类型
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
PostToolUse["PostToolUse hook"]
|
||||
PostToolUse --> Continue2["Continue<br/>保持输出不变"]
|
||||
PostToolUse --> MutateOut["MutateOutput { updated_content }<br/>修改工具输出(如脱敏)"]
|
||||
```
|
||||
|
||||
### Hook 链执行逻辑 (`run_pre_tool_use`)
|
||||
|
||||
```rust
|
||||
// 遍历所有已注册 hook
|
||||
for hook in &self.hooks {
|
||||
let action = hook.pre_tool_use(ctx).await;
|
||||
match action {
|
||||
Block { reason } => {
|
||||
// 第一个 Block 立即短路返回,不执行后续 hook
|
||||
return PreToolUseResult { action, ... };
|
||||
}
|
||||
MutateInput { updated_args, additional_context } => {
|
||||
// 累积 additional_context(多 hook 拼接)
|
||||
// 更新 final_args(最后一个 MutateInput 的修改生效)
|
||||
}
|
||||
PermissionRequired { .. } => {
|
||||
// 记录日志,但暂不阻塞(Phase 2 完善)
|
||||
}
|
||||
Continue => {} // 继续下一个 hook
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3 个内置 Hook
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class CancellationHook {
|
||||
-cancelled_runs: Arc~Mutex~HashSet~String~~
|
||||
+pre_tool_use() → Block | Continue
|
||||
+on_session_stop() → 清理取消状态
|
||||
}
|
||||
|
||||
class MetricsHook {
|
||||
-data: Arc~Mutex~MetricsData~
|
||||
+on_session_start() → 关联 session_id
|
||||
+post_tool_use() → 累计工具调用/错误计数
|
||||
+on_step_complete() → 每 3 步输出摘要日志
|
||||
+on_session_stop() → 输出终止原因
|
||||
+snapshot() → 返回可查询的指标快照
|
||||
}
|
||||
|
||||
class AuditLogHook {
|
||||
-db: SqlitePool
|
||||
+post_tool_use() → fire-and-forget 写入 agent_audit_log
|
||||
+on_session_stop() → 写入 SESSION_STOP 标记
|
||||
}
|
||||
|
||||
class AgentHook {
|
||||
<<interface>>
|
||||
+name() &str
|
||||
+on_session_start()
|
||||
+pre_tool_use()
|
||||
+post_tool_use()
|
||||
+on_step_complete()
|
||||
+on_session_stop()
|
||||
+on_subagent_start()
|
||||
+on_subagent_stop()
|
||||
+on_pre_compact()
|
||||
+on_post_compact()
|
||||
}
|
||||
|
||||
AgentHook <|-- CancellationHook
|
||||
AgentHook <|-- MetricsHook
|
||||
AgentHook <|-- AuditLogHook
|
||||
```
|
||||
|
||||
### 审计日志 (`agent_audit_log` 表)
|
||||
|
||||
`AuditLogHook` 在每次工具执行后通过 fire-and-forget(`tokio::spawn`)写入审计记录:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `session_id` | 会话 ID |
|
||||
| `step` | ReAct 步数 |
|
||||
| `tool_name` | 工具名称 |
|
||||
| `status` | `"OK"` 或 `"FAIL"` |
|
||||
| `elapsed_ms` | 执行耗时(毫秒) |
|
||||
| `output_preview` | 输出内容前 200 字符 |
|
||||
| `agent_name` | 代理身份(`"lead"` 或子代理名) |
|
||||
|
||||
会话终止时写入一条 `tool_name = 'session'`、`status = 'SESSION_STOP'` 的汇总记录。
|
||||
|
||||
---
|
||||
|
||||
## 第四层:文件系统路径沙箱
|
||||
|
||||
所有文件操作工具(`read_file`、`file_write`、`file_edit`、`glob_files`、`grep_files`、`run_bash` 的 `working_dir` 参数)共享的路径安全检查。
|
||||
|
||||
定义位置:`src/agent/tools/filesystem/security.rs`
|
||||
|
||||
### 允许的根目录
|
||||
|
||||
```rust
|
||||
let allowed_roots = [
|
||||
config.library_dir.canonicalize(), // 论文库目录
|
||||
config.skills_dir.canonicalize(), // Agent Skills 目录
|
||||
std::env::current_dir(), // 项目根目录
|
||||
];
|
||||
```
|
||||
|
||||
### 安全检查函数
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Input["用户提供的路径字符串"] --> PT{"has_path_traversal()<br/>包含 .. 或 ~ ?"}
|
||||
PT -->|是| Reject1["❌ 拒绝"]
|
||||
PT -->|否| Resolve["resolve_path()<br/>绝对路径直接用,相对路径基于 cwd 拼接"]
|
||||
Resolve --> Canon["canonicalize()<br/>消除符号链接"]
|
||||
Canon -->|失败| TryParent["尝试对父目录 canonicalize"]
|
||||
TryParent -->|失败| Reject2["❌ 拒绝:无法解析"]
|
||||
Canon -->|成功| Check{"is_path_allowed()<br/>在 allowed_roots 内?"}
|
||||
TryParent -->|成功| Check
|
||||
Check -->|是| Allow["✅ 允许"]
|
||||
Check -->|否| Reject3["❌ 拒绝:无权访问"]
|
||||
```
|
||||
|
||||
### 防护能力
|
||||
|
||||
| 攻击类型 | 防护方式 |
|
||||
|---|---|
|
||||
| 路径穿越 (`../../../etc/passwd`) | `has_path_traversal()` 拒绝含 `..` 的路径 |
|
||||
| 家目录访问 (`~/`) | `has_path_traversal()` 拒绝含 `~` 的路径 |
|
||||
| 符号链接逃逸 | `canonicalize()` 解析符号链接到真实路径后再检查 |
|
||||
| 绝对路径越界 | `resolve_path()` 解析后再 `is_path_allowed()` |
|
||||
|
||||
### 覆盖的工具
|
||||
|
||||
| 工具 | 受保护的参数 |
|
||||
|---|---|
|
||||
| `read_file` | `file_path` |
|
||||
| `file_write` | `file_path` |
|
||||
| `file_edit` | `file_path` |
|
||||
| `glob_files` | `pattern` (解析后) |
|
||||
| `grep_files` | `path` |
|
||||
| `run_bash` | `working_dir` |
|
||||
|
||||
---
|
||||
|
||||
## 第五层:Bash 命令安全校验
|
||||
|
||||
`run_bash` 工具在路径沙箱之上叠加了命令级安全校验。
|
||||
|
||||
定义位置:`src/agent/tools/filesystem/bash.rs`
|
||||
|
||||
### 校验流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Cmd["command 参数"] --> Trim["trim() 去空格"]
|
||||
Trim --> Empty{"空命令 / bash / bash -c?"}
|
||||
Empty -->|是| Reject1["❌ 拒绝"]
|
||||
Empty -->|否| Lower["to_lowercase()"]
|
||||
Lower --> Blacklist{"黑名单子串匹配<br/>(21 条模式)"}
|
||||
Blacklist -->|命中| Reject2["❌ 拒绝:不允许执行 'X' 类命令"]
|
||||
Blacklist -->|未命中| Execute["✅ 执行"]
|
||||
```
|
||||
|
||||
### 黑名单(精确首词匹配,已修复子串误伤问题)
|
||||
|
||||
校验流程:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Cmd["command 参数"] --> Trim["trim() 去空格"]
|
||||
Trim --> Empty{"空命令 / bash / bash -c?"}
|
||||
Empty -->|是| Reject1["❌ 拒绝"]
|
||||
Empty -->|否| Bypass{"命令替换绕过?<br/>$() 或反引号开头的首词"}
|
||||
Bypass -->|是| Reject2["❌ 拒绝"]
|
||||
Bypass -->|否| Extract["extract_first_command_word()<br/>提取首个命令单词"]
|
||||
Extract --> Whitelist{"SAFE_COMMANDS 白名单?<br/>(35 个安全命令)"}
|
||||
Whitelist -->|是| Allow["✅ 直接允许"]
|
||||
Whitelist -->|否| Blacklist{"DANGEROUS_COMMANDS 黑名单?<br/>(33 个精确匹配)"}
|
||||
Blacklist -->|是| Reject3["❌ 拒绝"]
|
||||
Blacklist -->|否| ArgCheck{"DANGEROUS_ARG_PATTERNS?<br/>(5 个危险参数子串)"}
|
||||
ArgCheck -->|命中| Reject4["❌ 拒绝"]
|
||||
ArgCheck -->|未命中| Allow2["✅ 默认允许<br/>(路径沙箱 + 超时兜底)"]
|
||||
```
|
||||
|
||||
**精确匹配 vs 子串匹配(修复前/后对比)**:
|
||||
|
||||
| 命令 | 修复前(子串) | 修复后(首词精确) |
|
||||
|---|---|---|
|
||||
| `grep "ssh_config" *.rs` | ❌ 误拦(含子串 `ssh `) | ✅ 允许(首词 `grep` 在白名单) |
|
||||
| `echo "use sudo carefully"` | ❌ 误拦(含子串 `sudo `) | ✅ 允许(首词 `echo` 在白名单) |
|
||||
| `cat /usr/share/vim/vimrc` | ❌ 误拦(含子串 `vim `) | ✅ 允许(首词 `cat` 在白名单) |
|
||||
| `python script.py` | ✅ 允许 | ✅ 允许(不在黑名单,默认允许) |
|
||||
| `vim file.txt` | ✅ 拒绝 | ✅ 拒绝(首词 `vim` 在黑名单) |
|
||||
| `$(echo sud; echo o) /etc/passwd` | ✅ 允许(绕过!) | ❌ 拒绝(检测到命令替换绕过) |
|
||||
|
||||
### 安全白名单(已启用)
|
||||
|
||||
```rust
|
||||
const SAFE_COMMANDS: &[&str] = &[
|
||||
"ls", "cat", "head", "tail", "find", "grep", "wc", "echo",
|
||||
"pwd", "sort", "uniq", "cut", "tr", "awk", "sed", "jq",
|
||||
"diff", "file", "stat", "du", "df", "env", "printenv",
|
||||
"which", "basename", "dirname", "realpath", "readlink",
|
||||
"xargs", "tee", "date", "sleep", "true", "false",
|
||||
];
|
||||
```
|
||||
|
||||
白名单内的命令**优先放行**,不经过黑名单检查。非白名单命令经过黑名单精确匹配和危险参数二次检查后默认允许。
|
||||
|
||||
### 其他约束
|
||||
|
||||
| 约束 | 值 | 说明 |
|
||||
|---|---|---|
|
||||
| 超时 | 默认 60s,最大 120s | `AGENT_TOOL_TIMEOUT_SECS` 环境变量 |
|
||||
| 输出截断 | 4000 字符 | `truncate_content()` |
|
||||
| 工作目录 | library_dir / skills_dir / cwd | 受路径沙箱约束 |
|
||||
|
||||
### 已知局限
|
||||
|
||||
1. ~~**黑名单子串匹配**~~ — ✅ 已修复:改用首词精确匹配,`grep "ssh_config"` 不再误拦
|
||||
2. **未限制网络访问** — `curl`、`wget` 不在黑名单中
|
||||
3. **未限制进程数** — fork bomb(如 `:(){ :\|:& };:`)未被检测
|
||||
4. **管道/重定向完整放行** — `<`、`>`、`|` 不做限制
|
||||
5. ~~**`$()` 命令替换**~~ — ✅ 已修复:检测首词位置 `$()` 和反引号绕过
|
||||
6. ~~**白名单未被使用**~~ — ✅ 已修复:`SAFE_COMMANDS` 已集成到 `validate_bash_command()` 中,白名单命令优先放行
|
||||
|
||||
---
|
||||
|
||||
## 第六层:子代理隔离
|
||||
|
||||
子代理通过 `SubAgentRunner` 创建上下文隔离的执行环境,参考 Claude Code Subagents 设计。
|
||||
|
||||
定义位置:`src/agent/subagent.rs`、`src/agent/tools/subagent.rs`
|
||||
|
||||
### 隔离维度对比
|
||||
|
||||
| 维度 | 父代理 | 子代理 |
|
||||
|---|---|---|
|
||||
| 消息上下文 | 完整历史 + 所有中间工具调用 | **全新 messages**:仅 `[system_prompt, user_prompt]` |
|
||||
| 工具注册表 | 完整 `ToolRegistry`(19+ 工具) | 完整 `ToolRegistry`(共享同一个引用) |
|
||||
| 工具上下文 | `ToolContext::new()` | `ToolContext::silent()` — **`silent = true`** |
|
||||
| Hook 管道 | 完整 `HookRegistry` | 继承父代理的 `HookRegistry` |
|
||||
| PermissionChecker | 自己的实例 | 继承父代理的 `PermissionChecker` |
|
||||
| 上下文压缩 | 多层压缩(micro/auto/manual) | 独立的自动压缩 |
|
||||
| 死循环检测 | `DuplicateDetector` per turn | 独立的 `(last_call, consecutive_count)` |
|
||||
|
||||
### Silent 模式
|
||||
|
||||
```rust
|
||||
// src/agent/tools/mod.rs:114
|
||||
pub fn silent(app_state: Arc<AppState>) -> Self {
|
||||
ToolContext {
|
||||
app_state,
|
||||
session_id: String::new(),
|
||||
silent: true, // ← 关键标志
|
||||
read_file_state: ...,
|
||||
sse_tx: None, // ← 无 SSE 通道
|
||||
enable_thinking: false,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Silent 模式效果**:
|
||||
- `ask_user` 工具检测到 `ctx.silent == true` 时**直接返回错误**
|
||||
- 阻止子代理绕过父代理向终端用户提问
|
||||
- 无 SSE 通道 → 子代理的工具调用进度不会单独推送到前端
|
||||
|
||||
### 子代理内部权限流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant SA as SubAgentRunner
|
||||
participant Hook as HookRegistry
|
||||
participant PC as PermissionChecker
|
||||
participant Tool as AgentTool
|
||||
|
||||
SA->>Hook: PreToolUseContext { tool_name, args }
|
||||
Hook-->>SA: PreToolUseResult { action, final_args }
|
||||
|
||||
alt action = Block
|
||||
SA-->>SA: 注入错误 tool_result,跳过执行
|
||||
else action = Continue / MutateInput
|
||||
SA->>PC: is_denied(tool_name)
|
||||
alt 被拒绝
|
||||
SA-->>SA: 注入错误 tool_result,跳过执行
|
||||
else 允许
|
||||
SA->>Tool: execute(final_args, silent_ctx)
|
||||
Tool-->>SA: ToolOutput
|
||||
SA->>Hook: PostToolUse hooks → 可能修改输出
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
> **⚠️ 当前局限**:子代理使用**完整的父代理 ToolRegistry**,不支持按子任务需求裁剪工具列表(如"仅搜索"模式只给只读工具)。未来可引入 `ToolRegistry::restrict()` 方法实现最小权限原则。
|
||||
|
||||
---
|
||||
|
||||
## 第七层:运行时安全约束
|
||||
|
||||
ReAct 循环中的多层终止条件,防止无限循环和资源耗尽。
|
||||
|
||||
定义位置:`src/agent/runtime/mod.rs:442`
|
||||
|
||||
### 终止条件矩阵
|
||||
|
||||
| 条件 | 触发阈值 | 行为 |
|
||||
|---|---|---|
|
||||
| **最大步数** | `step > max_steps` (默认 8) | 强制 LLM 生成最终答案(不带工具调用),注入提醒消息 |
|
||||
| **同质调用** | 连续 3 次相同 `(tool_name, args)` | 注入错误 tool_result,跳过本轮该工具 |
|
||||
| **Token diminishing returns** | 连续多步无新增信息 | 强制结束,注入"请基于已收集信息直接回答" |
|
||||
| **用户取消** | `cancelled_runs` 含当前 `session_id` | 循环开始和工具执行中双重检查,发送 Error SSE 事件 |
|
||||
| **压缩熔断** | `CompactionCircuitBreaker` 连续失败 | 跳过自动压缩,避免无限压缩循环 |
|
||||
| **Token 硬限制** | `token_hard_limit` (默认 40000) | `TokenBudget` 触发强制动作 |
|
||||
|
||||
### 用户取消的双重检查
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 用户
|
||||
participant API as API 层
|
||||
participant State as cancelled_runs
|
||||
participant Loop as ReAct 循环
|
||||
participant Exec as 工具执行
|
||||
|
||||
User->>API: POST /api/chat/cancel
|
||||
API->>State: insert(session_id)
|
||||
|
||||
Note over Loop: 每步迭代开始
|
||||
Loop->>State: contains(session_id)?
|
||||
State-->>Loop: true → break 循环
|
||||
|
||||
Note over Exec: 工具执行中 (每 250ms)
|
||||
loop 取消轮询
|
||||
Exec->>State: contains(session_id)?
|
||||
State-->>Exec: true → interrupt
|
||||
end
|
||||
|
||||
Note over Exec: InterruptBehavior 判断
|
||||
alt interrupt_behavior = Cancel
|
||||
Exec-->>Exec: 立即停止,返回 "执行已被用户取消"
|
||||
else interrupt_behavior = Block
|
||||
Exec-->>Exec: 忽略中断信号,等待完成
|
||||
end
|
||||
```
|
||||
|
||||
- 主循环在**每步开始**检查取消标志
|
||||
- 工具执行器以 **250ms 间隔**轮询取消状态
|
||||
- `Block` 工具(`run_bash`、`save_memory`、`ask_user`、`subagent`)忽略取消信号直到自然完成
|
||||
|
||||
### 取消状态生命周期
|
||||
|
||||
1. 用户通过 API 端点设置 `cancelled_runs.insert(session_id)`
|
||||
2. `CancellationHook::pre_tool_use()` 检测到 → 返回 `Block`
|
||||
3. ReAct 循环入口检测到 → `break` 跳出
|
||||
4. 工具执行检测到 + `InterruptBehavior::Cancel` → 立即返回
|
||||
5. `CancellationHook::on_session_stop()` → 清理 `cancelled_runs.remove(session_id)`
|
||||
|
||||
---
|
||||
|
||||
## 配套安全机制
|
||||
|
||||
### Background Task 安全
|
||||
|
||||
位置:`src/agent/tools/background.rs`、`src/agent/background.rs`
|
||||
|
||||
- `bg_task_run` 用于在后台执行慢速操作(下载、解析)
|
||||
- 后台任务通过 `BgNotificationQueue` 注入结果,不直接访问 Agent 上下文
|
||||
- 结果注入在下一次 LLM 调用前以 user 消息形式推送
|
||||
|
||||
### 工具输出持久化
|
||||
|
||||
位置:`src/agent/tools/persist.rs`
|
||||
|
||||
- 大型工具结果(超过 `max_tool_output_chars`)写入磁盘,消息中只包含 stub
|
||||
- 写入路径:`{library_dir}/tool-results/{tool_call_id}.txt`
|
||||
- 通过调用外部工具读取完整结果,避免上下文污染
|
||||
|
||||
### Token 预算管理
|
||||
|
||||
位置:`src/agent/runtime/token_budget.rs`
|
||||
|
||||
```
|
||||
软限制 (token_soft_limit, 默认 32000)
|
||||
↓ 触发渐进式 nudging 提醒 → 建议 LLM 总结/给出答案
|
||||
↓
|
||||
硬限制 (token_hard_limit, 默认 40000)
|
||||
↓ 触发强制动作 → 上下文压缩或强制结束
|
||||
↓
|
||||
Diminishing Returns 检测
|
||||
↓ 连续无新增信息 → 强制结束 + 直接回答
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 权限检查全链路
|
||||
|
||||
一次完整的工具调用穿越全部 7 层防线:
|
||||
|
||||
```
|
||||
工具调用请求
|
||||
│
|
||||
├─ [L7] 循环入口:步数 / 取消 / diminishing returns 检查
|
||||
│
|
||||
├─ [L7] validate_and_prepare():死循环检测 + 参数解析
|
||||
│
|
||||
├─ [L3] PreToolUse hooks:
|
||||
│ ├── CancellationHook → 检查 cancelled_runs
|
||||
│ ├── 自定义 Hook → Block? MutateInput?
|
||||
│ └── 返回 final_args + additional_context
|
||||
│
|
||||
├─ [L2] PermissionChecker.check() ← ✅ 在 Phase 2.5 调用(PreToolUse hooks 之后、执行之前)
|
||||
│ ├── Deny → 注入错误 result,跳过执行,不进入队列
|
||||
│ ├── AskUser → 暂视为允许(Phase 2 确认交互待完善)
|
||||
│ └── Allowed → 正常进入执行队列
|
||||
│
|
||||
├─ [L1] 分区器 (ToolPartitioner):
|
||||
│ └── is_concurrency_safe() 判断 → 并行 or 串行批次
|
||||
│
|
||||
├─ 工具执行 (每工具独立 Future):
|
||||
│ │
|
||||
│ ├─ [L1] InterruptBehavior 判断 → Cancel 可中断 / Block 不可中断
|
||||
│ │
|
||||
│ ├─ [L4] 路径沙箱 (read_file / file_write / file_edit / glob / grep / bash)
|
||||
│ │
|
||||
│ ├─ [L5] Bash 黑名单 (run_bash):
|
||||
│ │ ├── validate_bash_command() → 空命令 / 黑名单
|
||||
│ │ ├── 工作目录路径沙箱检查
|
||||
│ │ └── 超时控制 (60s default / 120s max)
|
||||
│ │
|
||||
│ ├─ [L6] ask_user Silent 模式检查 → 子代理中直接返回错误
|
||||
│ │
|
||||
│ └─ 超时控制 (AGENT_TOOL_TIMEOUT_SECS, default 120s)
|
||||
│
|
||||
└─ [L3] PostToolUse hooks:
|
||||
├── 输出截断 + 大结果持久化
|
||||
├── MetricsHook → 累计指标
|
||||
├── AuditLogHook → fire-and-forget 审计日志
|
||||
└── MutateOutput → 输出修改
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Claude Code 权限系统对比分析
|
||||
|
||||
> 对比基准:Claude Code (`/home/fmq/program/claudecode/src/utils/permissions/`)
|
||||
> 分析日期:2026-06-17
|
||||
|
||||
### 架构差异总览
|
||||
|
||||
| 维度 | AstroResearch (当前) | Claude Code (参考) | 差距 |
|
||||
|------|---------------------|-------------------|------|
|
||||
| 规则引擎 | ✅ PermissionChecker (完成) | ✅ hasPermissionsToUseTool 多步流水线 | 相当 |
|
||||
| 规则匹配粒度 | ✅ 内容级 `Tool(content*)` 前缀/后缀/包含 | ✅ 前缀/通配/内容级 / 正则 | 小 |
|
||||
| AskUser 交互流 | ✅ SSE → PermissionRequestCard → Allow/Deny/Always Allow | ✅ 完整 SSE → Dialog → 决策 | 相当 |
|
||||
| 权限模式 | ✅ Default/AcceptEdits/Bypass/DontAsk | ✅ 6种模式 (含 plan/auto) | 小 |
|
||||
| 规则持久化 | ✅ 环境变量加载 + `PermissionChecker::from_config()` | ✅ settings.json 多层加载 (8级来源优先级) | 小 |
|
||||
| 规则来源追踪 | ✅ `PermissionRuleSource` 枚举 (Env/Session) | ✅ cliArg > command > session > userSettings > ... | 小 |
|
||||
| Bash 权限分类器 | ✅ SAFE_COMMANDS 白名单 + `check_permissions()` 集成 | ✅ AST解析 + AI分类器 + 异步推测 | 中等 |
|
||||
| 拒绝追踪/熔断 | ✅ `DenialTracker` 连续/累计计数 + ReAct 循环熔断 | ✅ 连续/总计拒绝计数 + 自动终止 | 相当 |
|
||||
| 权限 Hook 集成 | ✅ PreToolUseAction::PermissionRequired 完整流程 | ✅ 完整 PermissionRequest hook + 多路径决议 | 相当 |
|
||||
| 规则遮蔽检测 | ✅ `detect_shadowed_rules()` deny/ask 双重检查 | ✅ `shadowedRuleDetection` deny/ask 遮蔽检测 | 相当 |
|
||||
| Auto Mode (AI 分类) | ❌ 无 | ✅ YOLO classifier + 快速路径 + 安全工具白名单 | **远期** |
|
||||
| 权限解释器 | ✅ 启发式 `explain_permission()` (Bash 风险等级 + 路径检测) | ✅ Haiku 生成风险解释 | 中等 |
|
||||
| 会话内规则更新 | ✅ `POST/PUT /api/chat/sessions/:id/permissions/*` | ✅ `/permissions` 命令 + API | 小 |
|
||||
| 子代理权限继承 | ✅ 完整 `check()` 三态检查 | ✅ 完整继承父级权限上下文 | 相当 |
|
||||
| 附加目录沙箱 | ✅ `AGENT_ADDITIONAL_DIRS` + `is_path_allowed()` 扩展 | ✅ `additionalDirectories` 可配置 | 相当 |
|
||||
|
||||
### Claude Code 权限流水线 (参考架构)
|
||||
|
||||
```
|
||||
hasPermissionsToUseTool(toolName, input, context):
|
||||
Step 1a: 工具级 deny 规则检查 → deny → 返回 deny
|
||||
Step 1b: 工具级 ask 规则检查 → ask → 返回 ask (sandbox 例外)
|
||||
Step 1c: 工具自定义 checkPermissions() → 内容级规则匹配
|
||||
Step 1d: 工具实现返回 deny → deny → 返回 deny
|
||||
Step 1e: requiresUserInteraction? → ask → 强制 ask (bypass 免疫)
|
||||
Step 1f: 内容级 ask 规则 → ask → 强制 ask (bypass 免疫)
|
||||
Step 1g: 安全检查 (敏感路径等) → ask → 强制 ask (bypass 免疫)
|
||||
Step 2a: bypassPermissions 模式? → allow → 返回 allow
|
||||
Step 2b: 工具级 allow 规则 → allow → 返回 allow
|
||||
Step 3: 剩余 passthrough → ask → 返回 ask
|
||||
|
||||
外层模式变换:
|
||||
dontAsk 模式: ask → deny
|
||||
auto 模式: acceptEdits 快速路径 → 安全工具白名单 → AI分类器
|
||||
headless: hooks 先运行 → 无 hook 决定 → auto-deny
|
||||
```
|
||||
|
||||
### 关键设计决策对比
|
||||
|
||||
**1. 规则格式**
|
||||
|
||||
Claude Code 使用 `ToolName(content)` 格式支持内容级规则:
|
||||
```
|
||||
Bash → 匹配所有 bash 命令
|
||||
Bash(npm install) → 匹配精确命令
|
||||
Bash(npm *) → 前缀通配
|
||||
Bash(rm:*) → 旧版前缀(已废弃)
|
||||
Read(.env) → 文件模式
|
||||
mcp__server__tool → MCP 工具级
|
||||
mcp__server → MCP 服务级
|
||||
Agent(Explore) → 代理类型级
|
||||
```
|
||||
|
||||
AstroResearch 已实现相同格式:
|
||||
```
|
||||
"*" → 通配所有工具
|
||||
"tool_name" → 精确工具名匹配
|
||||
"tool_name(content*)" → 前缀通配(如 "run_bash(rm *)" 匹配 "rm -rf /")
|
||||
"tool_name(*suffix)" → 后缀通配(如 "read_file(*.env)" 匹配 ".env")
|
||||
"tool_name(exact)" → 包含匹配(子串命中)
|
||||
"*(content)" → 工具通配 + 内容匹配(如 "*(sudo)" 匹配任意工具的 sudo 命令)
|
||||
```
|
||||
从 args 中自动提取 `command`/`file_path`/`path`/`pattern`/`url` 字段进行内容匹配。
|
||||
|
||||
**2. 权限模式**
|
||||
|
||||
Claude Code 的 6 种模式通过 Shift+Tab 循环切换:
|
||||
- `default` — 标准逐项确认
|
||||
- `acceptEdits` — 工作目录内文件编辑自动通过
|
||||
- `bypassPermissions` — 跳过所有 Ask(deny/ask 规则仍生效;安全检查 bypass 免疫)
|
||||
- `dontAsk` — 所有 Ask 转 Deny
|
||||
- `plan` — 计划模式
|
||||
- `auto` — AI 自动分类(内部使用)
|
||||
|
||||
AstroResearch 已实现 4 种模式(通过 `AGENT_PERMISSION_MODE` 环境变量或 API 切换):
|
||||
- `default` — 标准规则链,Ask 触发用户交互
|
||||
- `acceptEdits` — 工作目录内文件编辑自动通过(路径检查由 executor 完成)
|
||||
- `bypassPermissions` — 跳过所有 Ask(Deny 规则仍生效)
|
||||
- `dontAsk` — 所有 Ask 转为 Deny
|
||||
|
||||
`PermissionChecker::from_config()` 从 `AgentConfig` 加载环境变量规则并构造完整检查器。
|
||||
|
||||
**3. 多路径权限决议**
|
||||
|
||||
Claude Code 的 AskUser 决议支持多个并行路径,任一先返回即生效(`claim()` 模式):
|
||||
- 本地 UI 对话框
|
||||
- Bridge 响应(CCR 远程)
|
||||
- Channel 响应(Telegram 等)
|
||||
- PermissionRequest hooks(后台异步运行)
|
||||
- Bash 分类器(后台推测性异步分类)
|
||||
|
||||
AstroResearch 已实现完整的 AskUser 交互流:
|
||||
- `PermissionChecker::check()` 返回 `AskUser` 时,executor 发送 `AgentStreamEvent::PermissionRequest` SSE 事件
|
||||
- 通过 `oneshot` 通道创建 `PendingPermission`,存入 `AppState::pending_permissions`
|
||||
- 等待用户通过前端 `PermissionRequestCard` 组件响应(Allow / Deny / Always Allow),120s 超时自动拒绝
|
||||
- 单一路径决议(oneshot),不支持多路径 claim 模式
|
||||
|
||||
---
|
||||
|
||||
## 优化路线图
|
||||
|
||||
### ✅ P0 — 已全部完成
|
||||
|
||||
#### P0-1: 规则加载与持久化 ✅
|
||||
|
||||
`AgentConfig::from_env_optional()` 从环境变量加载规则(`AGENT_PERMISSIONS_DENY`/`ALLOW`/`ASK`),`PermissionChecker::from_config()` 按 Deny → Ask → Allow 优先级顺序构造规则链。
|
||||
|
||||
**实现位置**: `src/agent/runtime/mod.rs:113-117`, `src/agent/runtime/permission.rs:268-290`
|
||||
|
||||
#### P0-2: 完成 AskUser 权限交互流 ✅
|
||||
|
||||
executor Phase 2.5 中完整的 AskUser 处理:
|
||||
- `AgentStreamEvent::PermissionRequest` SSE 事件 → 前端 `PermissionRequestCard` 组件
|
||||
- `oneshot` 通道 + `AppState::pending_permissions` 存储
|
||||
- 120s 超时自动拒绝,支持 Allow / Deny / Always Allow 决策
|
||||
|
||||
**实现位置**: `src/agent/runtime/executor.rs:282-422`, `src/api/agent.rs`, `dashboard/src/features/agent/PermissionRequestCard.tsx`
|
||||
|
||||
#### P0-3: 内容级权限匹配 ✅
|
||||
|
||||
`PermissionChecker::matches()` 支持 `"tool_name(content_pattern)"` 格式,前缀通配(`prefix*`)、后缀通配(`*suffix`)、包含匹配,自动从 args 提取 `command`/`file_path`/`path`/`pattern`/`url` 字段。
|
||||
|
||||
**实现位置**: `src/agent/runtime/permission.rs:183-255`
|
||||
|
||||
### ✅ P1 — 已全部完成
|
||||
|
||||
#### P1-1: 权限模式系统 ✅
|
||||
|
||||
`PermissionMode` 枚举实现 4 种模式(Default/AcceptEdits/Bypass/DontAsk),通过 `AGENT_PERMISSION_MODE` 环境变量或 API 切换。`PermissionChecker::apply_mode()` 在 executor Phase 2.5 中对检查结果进行模式变换(Bypass 将 Ask→Allowed,DontAsk 将 Ask→Denied)。
|
||||
|
||||
**实现位置**: `src/agent/runtime/permission.rs:39-60, 139-177`
|
||||
|
||||
#### P1-2: Bash 权限接入 PermissionChecker ✅
|
||||
|
||||
`RunBashTool::check_permissions()` 调用 `bash_needs_permission()` —— 安全白名单中的命令返回空规则(自动允许),非白名单命令返回 `Ask` 规则。executor Phase 2.5 中与 PermissionChecker 结果叠加。
|
||||
|
||||
**实现位置**: `src/agent/tools/filesystem/bash.rs:58-73, 362-365`
|
||||
|
||||
#### P1-3: 权限 Hook 集成 ✅
|
||||
|
||||
`PreToolUseAction::PermissionRequired` 在 executor 中被检测:若 hook 返回 `PermissionRequired` 且 PermissionChecker 返回 `Allowed`,则升级为 `AskUser` 触发用户交互。已修复 Continue 覆盖 meaningful action 的 bug。
|
||||
|
||||
**实现位置**: `src/agent/hooks.rs:378-384`, `src/agent/runtime/executor.rs:221-230`
|
||||
|
||||
#### P1-4: 子代理完整权限继承 ✅
|
||||
|
||||
`SubAgentRunner` 使用 `check(tool_name, Some(&final_args))` 进行三态检查:Deny → 注入错误跳过执行,AskUser → 自动拒绝(子代理不应打断用户),Allowed → 正常执行。
|
||||
|
||||
**实现位置**: `src/agent/subagent.rs`
|
||||
|
||||
### ✅ P2-1、P2-2 — 已实现
|
||||
|
||||
#### P2-1: 拒绝追踪与熔断 ✅
|
||||
|
||||
`DenialTracker` 追踪连续拒绝和总拒绝数,阈值触发 ReAct 循环终止。
|
||||
配置:`AGENT_DENIAL_MAX_CONSECUTIVE` (默认 3) / `AGENT_DENIAL_MAX_TOTAL` (默认 20)。
|
||||
|
||||
**实现位置**: `src/agent/runtime/denial_tracker.rs`, `src/agent/runtime/mod.rs`
|
||||
|
||||
#### P2-2: 会话内规则更新 API ✅
|
||||
|
||||
`POST /api/chat/sessions/:id/permissions/rules` — add/remove 规则
|
||||
`PUT /api/chat/sessions/:id/permissions/mode` — 切换权限模式
|
||||
通过 `AppState::session_permission_checker` (`Arc<RwLock<PermissionChecker>>`) 实现跨 turn 共享。
|
||||
|
||||
**实现位置**: `src/api/permissions.rs`, `src/agent/runtime/executor.rs` Phase 2.5
|
||||
|
||||
### 🟡 P2-3~P2-5 — 远期增强(按需实现)
|
||||
|
||||
#### P2-3: 规则遮蔽检测 ✅
|
||||
|
||||
`PermissionChecker::detect_shadowed_rules()` 检测 Deny/Ask 遮蔽 Allow 的情况,输出 `ShadowedRule` 列表(含 reason + fix 建议),在 AgentRuntime 初始化时通过 `warn!` 日志输出。
|
||||
|
||||
**实现位置**: `src/agent/runtime/permission.rs`
|
||||
|
||||
#### P2-4: 权限解释器 ✅
|
||||
|
||||
启发式 `explain_permission()` 函数,根据工具名和参数生成 `{risk_level, explanation, reasoning, risk}` 结构。Bash 命令通过关键词检测风险等级(HIGH/MEDIUM/LOW),文件操作检测系统路径。结果随 `PermissionRequest` SSE 事件推送到前端。
|
||||
|
||||
**实现位置**: `src/agent/runtime/permission_explainer.rs`, `src/agent/runtime/mod.rs` `AgentStreamEvent::PermissionRequest.explanation`
|
||||
|
||||
#### P2-5: Auto Mode (AI 权限分类器) ❌
|
||||
|
||||
使用 LLM 自动评估工具调用的风险:
|
||||
- 快速路径:`AcceptEdits` 模式自动允许工作目录内的文件编辑
|
||||
- 安全工具白名单:`read_file`、`grep_files`、`search_papers` 等只读操作自动允许
|
||||
- AI 分类:对不确定的操作调用快速模型判断安全性
|
||||
- 失败封闭:分类器不可用时拒绝所有非白名单操作(安全优先)
|
||||
|
||||
**工作量**: 3-5天
|
||||
|
||||
#### P2-4: 权限解释器
|
||||
|
||||
在执行前用 LLM 生成人类可读的风险描述:
|
||||
```
|
||||
"该命令将执行 npm install,可能修改 node_modules/ 目录并下载外部依赖包。"
|
||||
```
|
||||
|
||||
**工作量**: 1天
|
||||
|
||||
#### P2-5: 子代理最小权限 (ToolRegistry::restrict)
|
||||
|
||||
```rust
|
||||
impl ToolRegistry {
|
||||
pub fn restrict(&self, allowed_tools: &[&str]) -> Self {
|
||||
// 创建仅包含指定工具的受限注册表
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**工作量**: 0.5天
|
||||
|
||||
---
|
||||
|
||||
## 实现路线图
|
||||
|
||||
```
|
||||
已完成 (Phase 1): P0-1 规则加载 + P0-2 AskUser 交互流 + P0-3 内容级匹配
|
||||
已完成 (Phase 2): P1-1 权限模式 + P1-2 Bash 集成 + P1-3 Hook 集成 + P1-4 子代理继承
|
||||
已完成 (Phase 3): P2-1 拒绝追踪熔断 + P2-2 会话内规则更新 + P2-3 规则遮蔽检测 + P2-4 权限解释器 + 附加目录沙箱 + 规则来源追踪
|
||||
远期规划 (按需): P2-5 Auto Mode (AI 分类器) + P2-6 权限解释器 LLM 升级 + 子代理最小权限 + 文件写入大小限制
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 待完善项
|
||||
|
||||
| 优先级 | 项目 | 当前状态 | 建议 |
|
||||
|---|---|---|---|
|
||||
| ~~**HIGH**~~ | ~~PermissionChecker 集成到主执行路径~~ | ✅ **已完成** | — |
|
||||
| ~~**HIGH**~~ | ~~Bash 黑名单改为命令解析~~ | ✅ **已完成** | — |
|
||||
| ~~**P0**~~ | ~~规则加载与持久化~~ | ✅ **已完成**:`AgentConfig` 新增 `permission_deny_rules` / `permission_allow_rules` / `permission_ask_rules` / `permission_mode` 字段,通过 `AGENT_PERMISSIONS_*` 环境变量加载 | — |
|
||||
| ~~**P0**~~ | ~~AskUser 权限交互流~~ | ✅ **已完成**:executor Phase 2.5 AskUser 分支重写为完整 oneshot → SSE → 120s 超时流程。前端 `PermissionRequestCard` 组件提供 Allow / Deny / Always Allow | — |
|
||||
| ~~**P0**~~ | ~~内容级权限匹配~~ | ✅ **已完成**:`check(tool_name, tool_args)` 签名,`matches()` 支持 `"tool(content*)"` 格式(前缀/后缀/包含),自动提取 args 字段 | — |
|
||||
| ~~**P1**~~ | ~~权限模式系统~~ | ✅ **已完成**:`PermissionMode` (Default/AcceptEdits/Bypass/DontAsk),`apply_mode()` 方法,`AGENT_PERMISSION_MODE` 配置 | — |
|
||||
| ~~**P1**~~ | ~~Bash 权限集成~~ | ✅ **已完成**:`RunBashTool::check_permissions()` 调用 `bash_needs_permission()`(安全命令自动允许),executor 合并工具级检查 | — |
|
||||
| ~~**P1**~~ | ~~权限 Hook 集成~~ | ✅ **已完成**:`PreToolUseResult::is_permission_required()`,修复 Continue 覆盖 bug,executor 触发 AskUser | — |
|
||||
| ~~**P1**~~ | ~~子代理完整权限继承~~ | ✅ **已完成**:`is_denied()` → `check(tool_name, Some(&final_args))`,子代理中 AskUser 自动拒绝 | — |
|
||||
| ~~**MEDIUM**~~ | ~~拒绝追踪与熔断~~ | ✅ **已完成** | `DenialTracker`:连续/总计拒绝计数,阈值触发 ReAct 循环终止 |
|
||||
| ~~**MEDIUM**~~ | ~~会话内规则更新 API~~ | ✅ **已完成** | `POST/PUT /api/chat/sessions/:id/permissions/*` 动态 add/remove/mode |
|
||||
| ~~**MEDIUM**~~ | ~~权限解释器~~ | ✅ **已完成** | 启发式 `explain_permission()`,Bash 风险等级 + 路径检测,随 SSE PermissionRequest 推送前端 |
|
||||
| **MEDIUM** | Auto Mode (AI 分类器) | 未实现 | LLM 评估风险,快速路径 + 安全工具白名单 |
|
||||
| **LOW** | 子代理最小权限 | 继承全部父工具 | `ToolRegistry::restrict()` |
|
||||
| **LOW** | 文件写入大小限制 | 无上限 | 添加 `max_file_size` 参数 |
|
||||
| **LOW** | 网络访问控制 | `curl`/`wget` 未限制 | Bash 黑名单扩展 |
|
||||
| **LOW** | 用户权限 profiles | 不支持 | YAML/TOML 权限配置 |
|
||||
339
docs/architecture/agent/skills.md
Normal file
339
docs/architecture/agent/skills.md
Normal 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<RwLock<>>)
|
||||
participant SP as SystemPrompt
|
||||
participant LLM as LLM
|
||||
participant Tool as LoadSkillTool
|
||||
participant Sub as SubAgentRunner
|
||||
|
||||
Note over SR: 启动阶段 (main.rs)
|
||||
SR->>FS: discover_skill_dirs() 扫描目录
|
||||
FS-->>SR: parse_skill_file() 解析 YAML + Markdown
|
||||
SR->>SR: refresh() 完整重载,按 usage_score 排序
|
||||
SR->>SR: start_watcher() 启动 notify 文件监听
|
||||
|
||||
Note over SR: Layer 1 — System Reminder (每轮请求)
|
||||
SP->>SR: build_reminder()
|
||||
SR-->>SP: <system-reminder> XML 块
|
||||
SP->>LLM: 注入 system prompt "skills" section
|
||||
|
||||
Note over SR: Layer 2 — 按需加载
|
||||
LLM->>Tool: load_skill(skill_name)
|
||||
Tool->>SR: get_skill(name)
|
||||
SR-->>Tool: Skill { meta + body }
|
||||
alt context = "fork"
|
||||
Tool->>Sub: 启动子代理 (max_steps ≤ 10)
|
||||
Sub-->>Tool: 子代理执行结果
|
||||
else context = "inline" (默认)
|
||||
Tool->>Tool: substitute_variables(body)
|
||||
end
|
||||
Tool-->>LLM: 格式化技能内容
|
||||
Tool->>SR: record_usage(name)
|
||||
```
|
||||
|
||||
## 代码结构
|
||||
|
||||
| 文件 | 行数 | 职责 |
|
||||
|---|---|---|
|
||||
| `src/agent/skills.rs` | 847 | SkillRegistry 缓存、文件解析、热更新、条件激活、系统提示构建 |
|
||||
| `src/agent/tools/skill.rs` | 222 | LoadSkillTool — Layer 2 按需加载的 AgentTool 实现 |
|
||||
| `src/agent/runtime/mod.rs` | ~1182 | 将 `build_reminder()` 注入 SystemPrompt section 3 |
|
||||
| `src/agent/runtime/system_prompt.rs` | ~64 | 静态 system prompt 中引导 LLM 使用 load_skill |
|
||||
| `src/main.rs` | ~151-163 | 启动时初始化 SkillRegistry、refresh、启动文件监听器 |
|
||||
| `skills/{name}/SKILL.md` | — | 实际 skill 定义文件(当前 3 个) |
|
||||
|
||||
## SKILL.md 格式
|
||||
|
||||
每个 skill 为 `skills/{name}/SKILL.md`,包含 YAML frontmatter + Markdown 正文:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: methodology
|
||||
description: 系统性文献综述方法论——如何高效地完成学术文献调研
|
||||
context: inline
|
||||
allowed-tools:
|
||||
- read_file
|
||||
- search_papers
|
||||
model: sonnet
|
||||
argument-hint: "<research question>"
|
||||
when_to_use: 当用户请求文献综述或调研时
|
||||
disable-model-invocation: false
|
||||
user-invocable: true
|
||||
version: "1.0"
|
||||
paths:
|
||||
- "*.rs"
|
||||
- "*.toml"
|
||||
agent: code-reviewer
|
||||
effort: high
|
||||
---
|
||||
|
||||
# Skill 正文 (Markdown)
|
||||
详细指引...
|
||||
```
|
||||
|
||||
### Frontmatter 字段全量说明
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `name` | `string` | 目录名 | Skill 唯一标识 |
|
||||
| `description` | `string` | "(无描述)" | 一句话描述,出现在 Layer 1 列表;**缺少时产生 warning** |
|
||||
| `context` | `"inline"` / `"fork"` | `inline` | 执行模式;**非法值时产生 warning** |
|
||||
| `allowed-tools` | `string[]` | `[]`(无限制) | 工具白名单,fork 模式下建议仅使用这些工具 |
|
||||
| `model` | `"haiku"` / `"sonnet"` / `"opus"` / `"inherit"` | — | 推荐的执行模型 |
|
||||
| `argument-hint` | `string` | — | 参数提示(如 `<research question>`),已定义但 LoadSkillTool 尚未使用 |
|
||||
| `when_to_use` | `string` | — | 触发场景描述,**Layer 1 reminder 中直接拼接到 description 后** |
|
||||
| `disable-model-invocation` | `bool` | `false` | `true` 时 LLM 不能通过 load_skill 工具自动调用;同时控制条件激活的初始状态 |
|
||||
| `user-invocable` | `bool` | `true` | `false` 时不出现在 Layer 1 reminder 中,用户无法手动调用 |
|
||||
| `version` | `string` | — | 版本号 |
|
||||
| `paths` | `string[]` | `[]`(始终激活) | 条件激活的 glob 模式,非空时 skill 仅在匹配文件路径后激活 |
|
||||
| `agent` | `string` | — | fork 模式下游的 agent 类型(如 `code-reviewer`),已定义但 LoadSkillTool 尚未使用 |
|
||||
| `effort` | `string` | — | fork 模式下的 effort 级别,已定义但 LoadSkillTool 尚未使用 |
|
||||
|
||||
### 当前项目 Skill 清单
|
||||
|
||||
| Skill | Context | 状态 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `methodology` | inline | ✅ 完整 | 系统性文献综述 6 步流程:范围界定 → 按引用筛选 → 逐篇深读 → 补充检索 → 交叉验证 → 输出综述 |
|
||||
| `plotting` | fork | 🚧 占位 | 科研绘图规范(matplotlib/seaborn/plotly),白名单 bash+save_note |
|
||||
| `presentation` | fork | 🚧 占位 | 学术 PPT 生成(Python-pptx/Beamer/Marp),白名单 bash+save_note |
|
||||
|
||||
## SkillRegistry 核心实现 (`skills.rs`)
|
||||
|
||||
### 数据结构
|
||||
|
||||
```
|
||||
SkillFrontmatter — serde_yaml 解析的 YAML frontmatter,含 validate() 校验方法
|
||||
│
|
||||
├──▶ SkillMeta — Layer 1 摘要(name, description, context, allowed_tools,
|
||||
│ when_to_use, disable_model_invocation, user_invocable, paths)
|
||||
│
|
||||
└──▶ Skill — Layer 2 完整对象(meta + body + skill_dir)
|
||||
│
|
||||
└──▶ SkillRegistry — 缓存容器 + 生命周期管理
|
||||
├── skills: Vec<Skill>
|
||||
├── last_scan_mtime: Option<SystemTime>
|
||||
└── usage_stats: HashMap<String, SkillUsageStat>
|
||||
```
|
||||
|
||||
### 关键方法
|
||||
|
||||
| 方法 | 返回值 | 说明 |
|
||||
|---|---|---|
|
||||
| `new(skills_dir)` | `Self` | 创建空注册表,调用 `refresh()` 触发初始扫描 |
|
||||
| `refresh()` | `()` | **完整重载**(非增量):扫描目录 → 解析 → 按 `usage_score` 降序排序 |
|
||||
| `needs_refresh()` | `bool` | 检查目录 mtime 是否变化(首次或 mtime > last_scan_mtime)
|
||||
| `build_reminder()` | `Option<String>` | 构建 Layer 1 XML `<system-reminder>` 块,仅包含 `user_invocable=true && disable_model_invocation=false` 的 skill |
|
||||
| `build_tool_description()` | `String` | 动态生成 load_skill 工具的 description(列出所有 `disable_model_invocation=false` 的 skill) |
|
||||
| `get_skill(name)` | `Option<&Skill>` | O(n) 按名称查找完整 Skill |
|
||||
| `list_skills()` | `Vec<SkillMeta>` | 获取所有 skill 的元信息列表 |
|
||||
| `record_usage(name)` | `()` | 记录调用:`invoke_count += 1`,`last_used_at = now` |
|
||||
| `usage_score(name)` | `f64` | **指数衰减评分**:`ln(1 + count) × 0.5^(age_hours / 168)`(7 天半衰期) |
|
||||
| `start_watcher(arc)` | `JoinHandle<()>` | 启动文件监听线程(见 4.6.5) |
|
||||
| `activate_conditional_for_paths(paths)` | `Vec<String>` | 激活匹配指定文件路径的条件 skill(见 4.6.6) |
|
||||
| `matching_skills_for_paths(paths)` | `Vec<SkillMeta>` | 查询匹配指定文件路径的所有 skill(只读,不改变状态) |
|
||||
|
||||
### usage_score 算法
|
||||
|
||||
```
|
||||
usage_score(name) = ln(1 + invoke_count) × 0.5^(age_hours / 168)
|
||||
|
||||
其中:
|
||||
invoke_count — 从 record_usage() 累积
|
||||
age_hours — 自 last_used_at 起的小时数(无记录时为 0.1)
|
||||
168 — 7 天(半衰期),即每过 7 天权重衰减 50%
|
||||
```
|
||||
|
||||
这是一个**指数衰减 + 对数压缩**的评分:频率越高、越近使用,评分越高。`refresh()` 按此评分**降序**排列 skills,使热技能优先出现在 remind 列表中。
|
||||
|
||||
## LoadSkillTool (`tools/skill.rs`)
|
||||
|
||||
实现 `AgentTool` trait,tool name = `"load_skill"`,参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `skill_name` | `string` | ✅ | 要加载的技能名称 |
|
||||
| `max_steps` | `integer` | 否 | fork 模式下子代理最大步数,默认 5,上限 10 |
|
||||
|
||||
### 执行流程
|
||||
|
||||
```
|
||||
execute(args, ctx)
|
||||
│
|
||||
├─ 1. 从 SkillRegistry 缓存读取 Skill(RwLock::read)
|
||||
│ ├─ 命中 → 2
|
||||
│ └─ 未命中 → 返回错误(含可用 skill 列表提示)
|
||||
│
|
||||
├─ 2. record_usage() 更新调用统计(RwLock::write)
|
||||
│
|
||||
├─ 3. substitute_variables(body, skill_dir, session_id)
|
||||
│ 替换 ${SKILL_DIR} → skill 目录绝对路径
|
||||
│ 替换 ${SESSION_ID} → 当前会话 ID(无则清空)
|
||||
│
|
||||
├─ 4. 判断 context 模式
|
||||
│
|
||||
│ ┌─ context = "fork" ──────────────────────────────────────
|
||||
│ │ • 构建子代理 system_prompt(含 skill 正文 + base_dir)
|
||||
│ │ • 调用 SubAgentRunner::run(system_prompt, task, max_steps)
|
||||
│ │ • 返回格式:[子代理执行结果 - 技能: xxx]
|
||||
│ │ • metadata: { context: "fork", execution_mode: "subagent", ... }
|
||||
│ │
|
||||
│ └─ context = "inline" (默认) ─────────────────────────────
|
||||
│ • 格式化输出:# 技能:xxx (描述)\n\nBase directory: ...\n\nbody
|
||||
│ • 附加 allowed_tools 白名单提示
|
||||
│ • metadata: { context: "inline", execution_mode: "inline", ... }
|
||||
│
|
||||
└─ 5. 返回 ToolOutput
|
||||
```
|
||||
|
||||
### 变量替换 (`substitute_variables`)
|
||||
|
||||
| 变量 | 替换目标 | 无值行为 |
|
||||
|---|---|---|
|
||||
| `${SKILL_DIR}` | skill 所在目录的绝对路径(如 `/app/skills/methodology`) | 保持原样(`to_str()` 返回 None 时) |
|
||||
| `${SESSION_ID}` | 当前 Agent 会话 ID | 清空为空字符串 |
|
||||
|
||||
> **已知问题**:当前 session_id 获取逻辑通过检查 `config.database_url.contains("session")` 来决定是否为 "current",这是一个脆弱的 hack,应改为从 `ToolContext` 直接读取 `ctx.session_id`。
|
||||
|
||||
## 热重载 (Hot Reload)
|
||||
|
||||
`SkillRegistry::start_watcher()` 使用 **`notify` crate** 实现事件驱动的文件监听:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ notify 文件监听线程 │
|
||||
│ │
|
||||
│ watcher.watch(skills_dir, RecursiveMode::Recursive) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 仅过滤 SKILL.md 文件变更事件 │
|
||||
│ │ │
|
||||
│ ▼ 发送 () 到 mpsc channel │
|
||||
│ ┌─────────────────┐ │
|
||||
│ │ 300ms debounce │ ← rx.recv_timeout(300ms) │
|
||||
│ │ 合并连续变更 │ 第一个事件后等待 300ms │
|
||||
│ └────────┬────────┘ 期间有新事件则重置计时器 │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ registry.write().refresh() 完整重载 │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 属性 | 说明 |
|
||||
|---|---|
|
||||
| 实现方式 | `notify::recommended_watcher` 事件驱动(非定时轮询) |
|
||||
| 监听范围 | `RecursiveMode::Recursive`(递归监听子目录) |
|
||||
| 过滤条件 | 仅处理文件名 == `SKILL.md` 的事件 |
|
||||
| 防抖窗口 | 300ms — 快速连续的变更合并为一次刷新 |
|
||||
| 刷新策略 | **完整重载**(始终重新扫描整个目录),非增量 |
|
||||
| 测试模式 | `#[cfg(test)]` 下为空实现(不启动线程) |
|
||||
|
||||
## 条件 Skill 激活 (Paths-based Activation)
|
||||
|
||||
部分 skill 通过 `paths` frontmatter 声明 glob 模式,初始状态 `disable_model_invocation = true`,仅在 Agent 访问匹配文件时激活。
|
||||
|
||||
**激活流程(`activate_conditional_for_paths`):**
|
||||
|
||||
```
|
||||
当 Agent 通过 Read/Grep/Glob 访问文件时:
|
||||
for each skill where paths is not empty AND disable_model_invocation == true:
|
||||
if any(file_path matches any(pattern in skill.paths)):
|
||||
skill.disable_model_invocation = false // 激活
|
||||
log: "条件 skill '{name}' 已激活"
|
||||
return newly_activated_skill_names
|
||||
```
|
||||
|
||||
**匹配查询(`matching_skills_for_paths`):** 只读方法,返回匹配的 skill 列表而不改变激活状态,可用于向 LLM 提示当前上下文相关的 skill。
|
||||
|
||||
**Glob 匹配(`glob_match_simple`):**
|
||||
|
||||
| 通配符 | 匹配 | 示例 |
|
||||
|---|---|---|
|
||||
| `*` | 任意非 `/` 字符序列 | `"*.rs"` → `main.rs` |
|
||||
| `**` | 任意字符(含 `/`) | `"**/test/*"` → `src/test/foo` |
|
||||
| `?` | 单个非 `/` 字符 | `"file_?.rs"` → `file_a.rs` |
|
||||
|
||||
使用 `glob::Pattern` crate,降级方案为简单字符串包含匹配。
|
||||
|
||||
## 系统集成
|
||||
|
||||
### 启动初始化 (`main.rs:151-163`)
|
||||
|
||||
```rust
|
||||
// 1. 创建注册表
|
||||
let skill_registry = Arc::new(RwLock::new(SkillRegistry::new(config.skills_dir.clone())));
|
||||
|
||||
// 2. 初始刷新
|
||||
if let Ok(mut reg) = skill_registry.write() {
|
||||
reg.refresh();
|
||||
}
|
||||
|
||||
// 3. 启动文件监听(热更新)
|
||||
let _watcher_handle = SkillRegistry::start_watcher(skill_registry.clone());
|
||||
|
||||
// 4. 注入 AppState
|
||||
let app_state = Arc::new(AppState {
|
||||
skill_registry, // Arc<RwLock<SkillRegistry>>
|
||||
// ... 其他字段
|
||||
});
|
||||
```
|
||||
|
||||
### System Prompt 注入 (`runtime/mod.rs:1178-1187`)
|
||||
|
||||
每轮 LLM 请求构建 system prompt 时,从 SkillRegistry 读取 reminder 并注入为 "skills" section:
|
||||
|
||||
```rust
|
||||
if let Some(skills) = self.app_state.skill_registry
|
||||
.read().ok()
|
||||
.and_then(|r| r.build_reminder())
|
||||
{
|
||||
sp.add_section("skills", skills);
|
||||
}
|
||||
```
|
||||
|
||||
### System Prompt 静态指引 (`system_prompt.rs:64`)
|
||||
|
||||
```
|
||||
7. 对于复杂任务(如文献综述),调用 load_skill 获取方法论指引,再用 todo_write 制定计划。
|
||||
```
|
||||
|
||||
### 工具注册 (`tools/mod.rs:254`)
|
||||
|
||||
LoadSkillTool 在所有工具注册表中作为第 18 个工具注册(紧跟 CompressTool 之后):
|
||||
|
||||
```rust
|
||||
Box::new(LoadSkillTool::new(skill_registry)),
|
||||
```
|
||||
|
||||
### 共享范围
|
||||
|
||||
所有 Agent 组件共享同一个 `Arc<RwLock<SkillRegistry>>` 实例:
|
||||
- 主 Agent(`AgentRuntime`)
|
||||
- 子代理(`SubAgentRunner`)
|
||||
- 团队成员(`teammate.rs`)
|
||||
- 后台任务 Agent(`background.rs`)
|
||||
|
||||
## 与 Claude Code 参考设计的对应关系
|
||||
|
||||
| Claude Code 概念 | AstroResearch 实现 |
|
||||
|---|---|
|
||||
| `src/skills/` 目录 + `SKILL.md` | 完全相同 |
|
||||
| YAML frontmatter(name, description, context, allowed-tools...) | 相同,增加 `version`、`agent`、`effort`、`paths` 字段 |
|
||||
| `<system-reminder>` Layer 1 注入 | `build_reminder()` → 结构化 XML 块 |
|
||||
| `SkillTool` Layer 2 按需加载 | `LoadSkillTool`(AgentTool trait 实现) |
|
||||
| inline 模式(注入指令内容) | ✅ 实现 |
|
||||
| fork 模式(子代理隔离执行) | ✅ 实现(SubAgentRunner) |
|
||||
| 热重载(目录监控) | ✅ `notify` crate + 300ms debounce |
|
||||
| 调用统计 | ✅ 指数衰减评分 |
|
||||
| 条件 skill(paths glob) | ✅ `activate_conditional_for_paths()` |
|
||||
| 变量替换 | ✅ `${SKILL_DIR}`, `${SESSION_ID}` |
|
||||
| `Skill` 工具接口 + `skill` slash command | LoadSkillTool(tool 形式),前端的 `/skill-name` 通过 tool 调用实现 |
|
||||
|
||||
---
|
||||
|
||||
364
docs/architecture/agent/subagent.md
Normal file
364
docs/architecture/agent/subagent.md
Normal 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<AppState><br/>• config: AgentConfig<br/>• tool_registry: ToolRegistry (与父代理共享)<br/>• hook_registry: Option<Arc<HookRegistry>><br/>• permission_checker: Arc<PermissionChecker><br/>• progress_tx: Option<UnboundedSender><br/>• parent_session_id: String"]
|
||||
|
||||
R_ctors["三种构造方式:<br/>① new() — 最简构造<br/>② new_with_hooks() — 完整构造<br/>③ new_with_registry() — 自定义工具集"]
|
||||
|
||||
R_chain["链式配置:<br/>• with_parent_session()<br/>• with_thinking()"]
|
||||
end
|
||||
```
|
||||
|
||||
## 完整执行流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Lead as 父代理 (AgentRuntime)
|
||||
participant Tool as SubAgentTool
|
||||
participant Runner as SubAgentRunner
|
||||
participant LLM as LLM API
|
||||
participant FE as 前端 SSE
|
||||
participant DB as SQLite
|
||||
|
||||
Lead->>Tool: subagent(research_prompt, max_steps?)
|
||||
Tool->>Tool: 参数校验 + 构造 system_prompt
|
||||
Tool->>Runner: new_with_hooks(app_state, hooks, pchecker, sse_tx)
|
||||
Tool->>Runner: .with_parent_session(sid).with_thinking(bool)
|
||||
Tool->>Runner: run(system_prompt, research_prompt, max_steps)
|
||||
|
||||
Note over Runner: Phase 1 — 初始化
|
||||
Runner->>Runner: 生成 subagent_name = "sub_<uuid8>"
|
||||
Runner->>Runner: 触发 OnSubagentStart hook
|
||||
Runner->>DB: 保存 system + user 消息 (fire-and-forget)
|
||||
|
||||
Note over Runner: Phase 2 — run_inner() ReAct 循环
|
||||
Runner->>Runner: 构建全新 messages: [system, user]
|
||||
|
||||
loop 每步迭代 (step ≤ max_steps)
|
||||
Runner->>Runner: 上下文压缩检查 (est_tokens > limit × 1.5)
|
||||
Runner->>LLM: chat_stream(messages, tool_defs, thinking?)
|
||||
LLM-->>Runner: ReasoningDelta / TextDelta / ToolCallsComplete
|
||||
Runner->>FE: Thought "[子代理] ..." (通过 progress_tx)
|
||||
|
||||
alt 无工具调用 → 最终答案
|
||||
Runner->>FE: Thought (子代理结论)
|
||||
Runner-->>Tool: ToolOutput::success([子代理活动记录] + [子代理结论])
|
||||
else 有工具调用
|
||||
Runner->>Runner: 死循环检测 (连续相同调用 ≥ 3 次 → 拦截)
|
||||
loop 每个 tool_call (串行执行)
|
||||
Runner->>FE: ToolCall "[sub] tool_name" (通过 progress_tx)
|
||||
Runner->>Runner: PreToolUse hook (Block → 跳过, MutateInput → 修改参数)
|
||||
Runner->>Runner: 权限检查 (is_denied → 跳过)
|
||||
Runner->>Runner: tool.execute(args, ctx) (timeout 保护)
|
||||
Runner->>Runner: PostToolUse hook (MutateOutput → 修改输出)
|
||||
Runner->>Runner: 输出截断 (max_tool_output_chars)
|
||||
Runner->>FE: ToolResult "[sub] tool_name" (通过 progress_tx)
|
||||
Runner->>DB: 保存 assistant + tool 消息 (fire-and-forget)
|
||||
Runner->>Runner: messages.push(ChatMessage::tool_result)
|
||||
end
|
||||
Runner->>Runner: messages.push(ChatMessage::assistant)
|
||||
end
|
||||
end
|
||||
|
||||
Note over Runner: Phase 3 — 达到最大步数
|
||||
Runner->>LLM: force_final_answer (空工具列表, 无工具调用)
|
||||
LLM-->>Runner: 最终文本答案
|
||||
|
||||
Note over Runner: Phase 4 — 收尾
|
||||
Runner->>DB: 保存最终 assistant 消息
|
||||
Runner->>Runner: 触发 OnSubagentStop hook
|
||||
Runner-->>Tool: ToolOutput { content, metadata }
|
||||
Tool->>Tool: 包装: "[子代理研究结果]\n\n{content}"
|
||||
Tool-->>Lead: ToolOutput
|
||||
```
|
||||
|
||||
## 核心机制详解
|
||||
|
||||
### 1. 上下文隔离
|
||||
|
||||
子代理拥有**全新的 `messages` 向量**,仅包含 system prompt + user prompt,不包含父代理的任何中间工具调用。父代理只会收到最终的文本摘要,中间的工具调用细节(搜索了什么、下载了什么)不会污染父上下文。
|
||||
|
||||
这是子代理系统最核心的价值——与直接在父代理中执行相比,子代理消耗的父上下文 token 是 O(1) 而不是 O(steps)。
|
||||
|
||||
```
|
||||
父代理 context: 子代理 context:
|
||||
┌─────────────────────┐ ┌─────────────────────────┐
|
||||
│ system prompt │ │ system prompt │
|
||||
│ user: "分析星系演化" │ │ user: "搜索星系演化的文献" │
|
||||
│ assistant: tool_calls│ │ assistant: tool_call │
|
||||
│ tool_result: [大文本] │ │ tool_result: [搜索结果] │
|
||||
│ ... (越来越多) │ │ assistant: "找到 5 篇..." │
|
||||
│ │ └─────────────────────────┘
|
||||
│ ← 子代理结果注入这里 │ ↓
|
||||
│ (仅有摘要,无中间步骤)│ 只返回文本摘要给父代理
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
### 2. 工具执行模式 — 串行
|
||||
|
||||
与父代理 `executor.rs` 使用 `FuturesUnordered` 的**并行**执行不同,子代理采用简单的 **for 循环串行**执行:
|
||||
|
||||
```rust
|
||||
// src/agent/subagent.rs:395 — 串行 for 循环
|
||||
for tool_call in &tool_calls {
|
||||
let output = tool.execute(final_args, &tool_ctx).await;
|
||||
messages.push(ChatMessage::tool_result(&tool_call.id, &truncated));
|
||||
}
|
||||
```
|
||||
|
||||
| 特性 | 父代理 (executor.rs) | 子代理 (subagent.rs) |
|
||||
|:---|:---|:---|
|
||||
| 执行方式 | `FuturesUnordered` 并行 | `for` 循环串行 |
|
||||
| 结果推送 | 渐进式(快工具不等待慢工具) | 逐个完成 |
|
||||
| FileStateCache | 支持(避免重复读文件) | 不支持 |
|
||||
| 大结果持久化到磁盘 | `maybe_persist_tool_result` | 无(仅截断) |
|
||||
| Sibling Abort | 支持(出错时中断兄弟任务) | 不支持 |
|
||||
|
||||
**设计考量**:子代理的 `max_steps` 通常为 5,任务规模较小,并行收益有限。串行实现更简单、更可预测,且每次工具调用之前会检查 PreToolUse hook 和权限,串行执行保证了 hook 决策的时序正确性。
|
||||
|
||||
### 3. 死循环检测
|
||||
|
||||
子代理独立维护 `last_call: Option<(String, String)>` 追踪上次的工具调用(名称 + 参数)。当连续 3 次相同调用时触发拦截:
|
||||
|
||||
```rust
|
||||
// src/agent/subagent.rs:416-418
|
||||
let call_key = (tool_name.clone(), tool_args_str.clone());
|
||||
if last_call.as_ref() == Some(&call_key) {
|
||||
consecutive_count += 1;
|
||||
if consecutive_count >= duplicate_threshold { // threshold = 3
|
||||
// 注入错误消息,强制 LLM 停止并给出答案
|
||||
let error_msg = ChatMessage::tool_result(
|
||||
&tool_call.id,
|
||||
format!("工具 {} 被连续重复调用。请停止并给出当前收集到的答案。", tool_name),
|
||||
);
|
||||
messages.push(error_msg);
|
||||
continue; // 跳过本次执行
|
||||
}
|
||||
} else {
|
||||
last_call = Some(call_key);
|
||||
consecutive_count = 1;
|
||||
}
|
||||
```
|
||||
|
||||
这与父代理的 `DuplicateDetector` 功能相同,但实现更简洁——子代理的场景简单,不需要全局去重表。
|
||||
|
||||
### 4. 上下文压缩
|
||||
|
||||
子代理的上下文在每步开始前检查,使用简单的字节长度估算:
|
||||
|
||||
```rust
|
||||
// src/agent/subagent.rs:261-265
|
||||
let est_tokens: usize = messages
|
||||
.iter()
|
||||
.map(|m| m.content.as_ref().map_or(0, |c| c.len()) + 4)
|
||||
.sum();
|
||||
if est_tokens > self.config.context_char_limit * 3 / 2 {
|
||||
compact::compress_context(&mut messages, llm, config.context_char_limit, "subagent").await;
|
||||
}
|
||||
```
|
||||
|
||||
- 触发阈值:`context_char_limit * 1.5`(默认 ~24000 字符)
|
||||
- 压缩方式:调用 `compact::compress_context`(LLM 摘要压缩)
|
||||
- 标识来源:`"subagent"`,区别于 `"lead"` / `"teammate"` / `"background"`
|
||||
|
||||
### 5. force_final_answer — 达到最大步数时的兜底
|
||||
|
||||
```rust
|
||||
// src/agent/subagent.rs:563-600
|
||||
async fn force_final_answer(&self, llm: &LlmClient, messages: &[ChatMessage]) -> ToolOutput {
|
||||
let mut final_messages = messages.to_vec();
|
||||
final_messages.push(ChatMessage::user(
|
||||
"请根据已收集的信息直接给出最终答案,不要再调用工具。",
|
||||
));
|
||||
let empty_tools: Vec<ToolDefinition> = Vec::new(); // 不提供任何工具
|
||||
let mut stream_rx = llm.chat_stream(&final_messages, &empty_tools, ...).await?;
|
||||
// 收集 TextDelta 直到 Done
|
||||
}
|
||||
```
|
||||
|
||||
- 追加一条 user 消息明确要求停止工具调用
|
||||
- 传递**空的工具定义列表**,从 API 层面禁止工具调用
|
||||
- 返回 `metadata: { "forced": true }` 标记这是强制答案
|
||||
|
||||
### 6. 数据库持久化
|
||||
|
||||
所有子代理消息通过 `save_subagent_message` 写入 `agent_messages` 表,使用 fire-and-forget 模式(`tokio::spawn`):
|
||||
|
||||
```rust
|
||||
// src/agent/subagent.rs:190-235
|
||||
fn save_subagent_message(&self, agent_name: &str, turn_index: i32, step_index: i32,
|
||||
role: &str, content: &str, ...) {
|
||||
let metadata = serde_json::json!({ "agent": agent_name, "is_subagent": true });
|
||||
tokio::spawn(async move {
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO agent_messages (session_id, turn_index, step_index, role,
|
||||
content, thought, tool_calls, tool_call_id, token_count, metadata, agent_name)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&session_id)
|
||||
.bind(turn_index) // 始终为 0
|
||||
.bind(step_index) // 当前 step 编号
|
||||
.bind(&role_owned) // system / user / assistant / tool
|
||||
.bind(&content_owned)
|
||||
// ...
|
||||
.bind(&metadata_str) // {"agent":"sub_xxx","is_subagent":true}
|
||||
.bind(&agent) // subagent_name
|
||||
.execute(&db).await;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
持久化的消息类型:
|
||||
- **system** — 子代理系统提示词
|
||||
- **user** — 研究任务描述
|
||||
- **assistant** — 每步 LLM 响应(含 tool_calls JSON)
|
||||
- **tool** — 每个工具的执行结果(截断后)
|
||||
- **assistant** — 最终结果(step_index = max_steps + 1)
|
||||
|
||||
所有消息带 `{"is_subagent": true}` 元数据标记,`agent_name` 字段存放 `sub_<uuid8>` 名称,`turn_index` 始终为 0。
|
||||
|
||||
**设计考量**:fire-and-forget 意味着持久化失败不会阻塞子代理执行。代价是极端情况下(DB 连接断开)可能丢失审计数据。
|
||||
|
||||
## Hook 集成
|
||||
|
||||
子代理在以下时机触发 Hook 事件:
|
||||
|
||||
| 事件 | 触发位置 | 上下文数据 | 用途 |
|
||||
|:---|:---|:---|:---|
|
||||
| `OnSubagentStart` | `run()` Phase 1 | parent_session_id, subagent_name, prompt | 审计、指标初始化 |
|
||||
| `PreToolUse` | 每个工具执行前 | session_id="subagent", tool_name, args | 取消检查、参数修改 |
|
||||
| `PostToolUse` | 每个工具执行后 | session_id="subagent", output_content, is_error | 指标采集、输出修改 |
|
||||
| `OnSubagentStop` | `run()` Phase 4 | result_summary (前200字符), steps, is_error | 审计记录、数据汇总 |
|
||||
|
||||
`PreToolUse` 和 `PostToolUse` 在子代理的工具执行循环中**直接内联实现**(`subagent.rs:444-521`),而非通过 `executor.rs` 的并行管道。这意味着:
|
||||
|
||||
- PreToolUse 的 Block 操作 → 注入错误 tool_result 消息,跳过执行
|
||||
- PreToolUse 的 MutateInput → 替换 final_args
|
||||
- PostToolUse 的 MutateOutput → 替换 final_output_content
|
||||
- Permission check → `is_denied` 直接拒绝
|
||||
|
||||
**与父代理的区别**:父代理通过 `executor::execute_parallel()` 统一处理 hooks + 并发,子代理在自身的 for 循环中手动调用 hook 方法。两者享有相同的 `HookRegistry` 实例(通过 `SubAgentRunner::new_with_hooks` 注入)。
|
||||
|
||||
## SubAgentTool 工具层
|
||||
|
||||
```rust
|
||||
// src/agent/tools/subagent.rs
|
||||
impl AgentTool for SubAgentTool {
|
||||
fn name(&self) -> &str { "subagent" }
|
||||
|
||||
fn parameters(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"research_prompt": {
|
||||
"type": "string",
|
||||
"description": "要委托给子代理执行的完整研究任务描述..."
|
||||
},
|
||||
"max_steps": {
|
||||
"type": "integer",
|
||||
"description": "子代理最大推理步数,默认5,最大10",
|
||||
"default": 5
|
||||
}
|
||||
},
|
||||
"required": ["research_prompt"]
|
||||
})
|
||||
}
|
||||
|
||||
// 子代理可能执行写操作,中断时应阻塞以完成
|
||||
fn interrupt_behavior(&self) -> InterruptBehavior {
|
||||
InterruptBehavior::Block
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`execute` 方法流程:
|
||||
1. 参数校验(`research_prompt` 必填,`max_steps` 取 min(参数, 10))
|
||||
2. 构造子代理 `system_prompt`(天体物理学研究助手,中文回答)
|
||||
3. 创建 `SubAgentRunner::new_with_hooks()` — 注入 hooks、permissions、SSE 通道、session_id
|
||||
4. 调用 `runner.run(system_prompt, &research_prompt, max_steps)`
|
||||
5. 错误 → `ToolOutput::error("子代理执行失败: ...")`
|
||||
6. 成功 → 包装为 `"[子代理研究结果]\n\n{content}"`
|
||||
|
||||
## 与 Team Teammate 的对比
|
||||
|
||||
两者都实现了"将工作委托给独立的 ReAct 循环",但设计上有本质差异:
|
||||
|
||||
| 特性 | SubAgentRunner | Team Teammate |
|
||||
|:---|:---|:---|
|
||||
| 文件位置 | `src/agent/subagent.rs` | `src/agent/team/teammate.rs` |
|
||||
| 触发方式 | LLM 调用 `subagent` 工具 | Lead 通过文件收件箱发送任务 |
|
||||
| 工具集 | 完整 ToolRegistry(含 subagent) | 排除 subagent(防无限委托链) |
|
||||
| Hook 管道 | 完整(PreToolUse/PostToolUse/Start/Stop) | 无 |
|
||||
| SSE 进度 | 支持(通过 progress_tx 透传) | 无 |
|
||||
| DB 持久化 | 完整(agent_messages + agent_audit_log) | 无 |
|
||||
| 运行模式 | 同步:父代理等待子代理完成 | 异步:循环轮询收件箱 |
|
||||
| 生命周期 | 一次性:任务完成即销毁 | 持续:SPAWN → WORKING → IDLE → SHUTDOWN |
|
||||
| 嵌套能力 | 可递归(无深度限制) | 不可(排除 subagent 工具) |
|
||||
| 上下文压缩 | 支持(compact::compress_context) | 支持(同) |
|
||||
| 死循环检测 | 支持(阈值=3) | 无独立检测 |
|
||||
| 最大步数 | 默认 5,最大 10 | `max_steps.min(5)` |
|
||||
| 取消支持 | 无独立取消信号 | `AtomicBool` 取消标志 |
|
||||
|
||||
**使用场景区分**:
|
||||
- **SubAgent** — 同步的、一次性的、需要完整工具能力的子任务("综述近 5 年星系演化的文献")
|
||||
- **Teammate** — 异步的、持续性的、角色分工的并行协作("你负责搜索,他负责解析")
|
||||
|
||||
## 工具注册
|
||||
|
||||
子代理工具 `SubAgentTool` 在 `ToolRegistry::add_base_tools()` 中注册,随后被替换为带 hooks 的实例:
|
||||
|
||||
```rust
|
||||
// src/agent/runtime/mod.rs:264
|
||||
// AgentRuntime 初始化时用带 hooks 的版本替换默认的 SubAgentTool
|
||||
tool_registry.replace_tool(
|
||||
crate::agent::tools::subagent::SubAgentTool::new_with_hooks(
|
||||
hook_registry.clone(),
|
||||
permission_checker.clone(),
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
参数透传链路:
|
||||
```
|
||||
LLM 调用 subagent(research_prompt, max_steps)
|
||||
→ SubAgentTool.execute(args, ToolContext)
|
||||
→ SubAgentRunner::new_with_hooks(app_state, hooks, pchecker, ctx.sse_tx)
|
||||
.with_parent_session(ctx.session_id)
|
||||
.with_thinking(ctx.enable_thinking)
|
||||
.run(system_prompt, research_prompt, max_steps)
|
||||
```
|
||||
|
||||
## 当前局限与改进方向
|
||||
|
||||
| # | 问题 | 影响 | 改进方向 |
|
||||
|:---|:---|:---|:---|
|
||||
| 1 | **工具串行执行** | 同一步多个工具调用无法并行,慢工具阻塞快工具 | 复用 `executor::execute_parallel`,或至少对 `is_concurrency_safe()` 工具并行 |
|
||||
| 2 | **无嵌套深度限制** | 子代理可调用 subagent 创建子子代理,理论上无限递归 | 增加深度计数器,超过 2 层时移除 subagent 工具 |
|
||||
| 3 | **Token 估算粗糙** | `content.len() + 4` 对中文极不准确(中文 1 字符 ≈ 1.5-2 token) | 使用 `tiktoken-rs` 或 tokenizer 精确计数 |
|
||||
| 4 | **fire-and-forget 持久化** | DB 写入失败静默忽略,可能丢失审计数据 | 至少记录 warn 日志;关键消息可改为 await |
|
||||
| 5 | **force_final_answer 不调温度** | 达到 max_steps 时模型可能仍尝试输出工具调用格式 | 降低 temperature 或增加 stop 序列 |
|
||||
| 6 | **subagent_name 不可控** | 自动生成的 UUID 片段不利于日志可读性 | 允许 LLM 传入 `name` 参数作为标识 |
|
||||
| 7 | **无 FileStateCache** | 子代理反复读取同一文件时会重复 I/O | 传入 FileStateCache 或使用全局缓存 |
|
||||
| 8 | **步骤内消息顺序丢失** | 同一步的多个 tool 消息共享相同 step_index | 引入子序号(如 step_index.sub_index) |
|
||||
|
||||
---
|
||||
|
||||
359
docs/architecture/agent/system-prompt.md
Normal file
359
docs/architecture/agent/system-prompt.md
Normal 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 命中率。动态 section(tools、skills、memory)因内容较少,对 cache 影响可控。principles 虽然静态但放在最后,当需要调优时仅破坏最后一个 cache segment。
|
||||
|
||||
---
|
||||
|
||||
## 动态 Section 详解
|
||||
|
||||
### 3.1 工具列表 (tools section)
|
||||
|
||||
```rust
|
||||
let mut tools_desc = String::from("你可以使用以下工具:\n");
|
||||
for def in self.tool_registry.definitions() {
|
||||
let short_desc = def.function.description
|
||||
.split('。').next()
|
||||
.unwrap_or(&def.function.description)
|
||||
.chars().take(80)
|
||||
.collect();
|
||||
tools_desc.push_str(&format!("- {}: {}\n", def.function.name, short_desc));
|
||||
}
|
||||
```
|
||||
|
||||
- 19 个默认工具:`search_papers`, `download_paper`, `parse_paper`, `get_paper_content`, `rag_search`, `query_target`, `save_note`, `read_file`, `grep_files`, `glob_files`, `run_bash`, `file_write`, `file_edit`, `todo_write`, `compress_context`, `load_skill`, `subagent`, `save_memory`, `bg_task_run`
|
||||
- 描述仅取**第一句 + 前 80 字符**作为功能摘要
|
||||
- 完整的参数 JSON Schema 通过 API 的 `tools` 参数单独传递,不在 system prompt 中重复
|
||||
|
||||
### 3.2 技能列表 (skills section) — 两层加载
|
||||
|
||||
参考 Claude Code 的两层技能设计,定义在 `src/agent/skills.rs`:
|
||||
|
||||
**Layer 1 (system-reminder)**:`SkillRegistry.build_reminder()` 在 system prompt 中注入 `<system-reminder>` XML 块。列出所有 `user_invocable=true` 且 `disable_model_invocation=false` 的技能名称 + 描述。每个 skill 约消耗 ~20 tokens。
|
||||
|
||||
```xml
|
||||
<system-reminder>
|
||||
The following skills are available for use with the Skill tool:
|
||||
- methodology: 天体物理研究方法论指南 - When user asks about research methodology
|
||||
- plotting: 数据可视化与科学绘图 - When user wants to create plots
|
||||
- presentation: 学术幻灯片制作 - When user needs to prepare a presentation
|
||||
When a skill matches the user's request, invoke load_skill BEFORE generating any other response about the task.
|
||||
If you see a <command-name> tag in the current conversation turn, the skill has ALREADY been loaded - follow the instructions directly instead of calling load_skill again.
|
||||
</system-reminder>
|
||||
```
|
||||
|
||||
**Layer 2 (load_skill 工具)**:LLM 按需调用 `load_skill(skill_name)` 工具,从 `skills/{name}/SKILL.md` 加载完整内容(YAML frontmatter + Markdown body),注入到消息上下文。完整 skill 约 ~2000 tokens。
|
||||
|
||||
SKILL.md 格式:
|
||||
```yaml
|
||||
---
|
||||
name: methodology
|
||||
description: 天体物理研究方法论指南
|
||||
context: inline # inline | fork
|
||||
when_to_use: When user asks about research methodology
|
||||
allowed-tools:
|
||||
- search_papers
|
||||
- rag_search
|
||||
model: inherit
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# Skill 正文
|
||||
详细内容...
|
||||
```
|
||||
|
||||
**热重载**:SkillRegistry 通过 `notify` crate 监听 skills 目录的文件变更,300ms debounce 后自动刷新。Skill 按使用频率排序(指数衰减评分,7 天半衰期)。
|
||||
|
||||
**条件激活**:Skill 可通过 `paths` frontmatter 声明 glob 模式。Agent 访问匹配文件时自动将 `disable_model_invocation` 设为 false,激活条件 skill。
|
||||
|
||||
### 3.3 项目记忆 (memory section)
|
||||
|
||||
`MemoryManager.build_system_reminder(5)` 从 `{library_dir}/memory/` 目录加载最近 5 条记忆,生成 `<project-memory-context>` XML 块:
|
||||
|
||||
```xml
|
||||
<project-memory-context>
|
||||
[PROJECT MEMORY]
|
||||
[偏好] memory-slug: 一句话描述
|
||||
内容预览前三行
|
||||
[时效提示: 此记忆已超过N天,可能已过时]
|
||||
[反馈] another-memory: 描述 [已更新→new-slug]
|
||||
内容预览...
|
||||
使用 save_memory 工具保存重要信息。记忆内容可能过时,请在使用前验证。
|
||||
</project-memory-context>
|
||||
```
|
||||
|
||||
关键特性:
|
||||
- 按 mtime 排序(最新在前),支持语义选择 + 指数衰减排序
|
||||
- 按类型标注:`[偏好]` / `[反馈]` / `[项目]` / `[参考]`
|
||||
- 过期记忆标记为 `[已更新]` 或 `[已更新→new-slug]`(归档为 `{slug}_v1.md`)
|
||||
- 超过 1 天的记忆注入时效警告
|
||||
- 索引文件 `MEMORY.md` 限制 200 行 / 25KB
|
||||
|
||||
---
|
||||
|
||||
## 上下文初始化与运行时注入 (`src/agent/runtime/context.rs`)
|
||||
|
||||
### 4.1 上下文构建流程
|
||||
|
||||
`build_initial_context()` 在每轮开始时构建完整的消息列表:
|
||||
|
||||
```
|
||||
1. 从数据库加载历史消息(agent_messages 表)
|
||||
2. 如果历史第一条不是 system 角色 → 在位置 0 插入系统提示词
|
||||
3. 追加当前用户问题
|
||||
4. [可选] 追加任务状态恢复提醒(从 agent_tasks 表读取)
|
||||
```
|
||||
|
||||
### 4.2 任务状态恢复
|
||||
|
||||
从 `agent_tasks` 表恢复未完成的任务,格式化注入 user 消息:
|
||||
|
||||
```
|
||||
[当前任务状态]
|
||||
以下是上次会话中持久化的任务计划,请基于最新状态继续工作:
|
||||
|
||||
⏳ [task-1] 搜索相关文献...
|
||||
🔄 [task-2] 分析论文数据... (依赖: task-1)
|
||||
✅ [task-3] 格式化引用... (指派: lead)
|
||||
|
||||
使用 todo_write 工具更新任务进度。
|
||||
```
|
||||
|
||||
### 4.3 运行时 Nudge 注入
|
||||
|
||||
在 ReAct 循环中,system prompt 组装后不再修改。运行时干预通过**注入 user 消息**实现(开闭原则):
|
||||
|
||||
| 触发条件 | Nudge 内容 |
|
||||
|:---|:---|
|
||||
| TodoWrite 连续 3 步未更新 | "提醒:你已经连续多步未更新任务计划。建议调用 todo_write 工具…" |
|
||||
| Token 预算 diminishing returns | "检测到你的后续步骤未产生新信息…请基于已收集的全部信息直接给出最终答案" |
|
||||
| 达到最大步数 (max_steps) | "你已经执行了 N 步(最大 M 步)。请根据已有信息直接给出最终答案" |
|
||||
| 后台任务完成 | "[后台任务完成] ✅ tool_name: bibcode: summary" |
|
||||
|
||||
---
|
||||
|
||||
## 子代理的独立系统提示词 (`src/agent/tools/subagent.rs`)
|
||||
|
||||
子代理拥有独立的消息上下文,通过 `SubAgentRunner::run()` 接收一个**硬编码的简化版系统提示词**:
|
||||
|
||||
```
|
||||
你是一位专业的天体物理学研究助手,在一个独立的子任务上下文中工作。
|
||||
你可以使用文献搜索、下载、RAG检索等工具。
|
||||
请高效完成任务,然后直接给出最终答案。不要进行不必要的重复操作。
|
||||
用中文回答,引用具体文献来源。
|
||||
```
|
||||
|
||||
特点:
|
||||
- 不继承父代理的 tools/skills/memory sections
|
||||
- 共享父代理的 ToolRegistry
|
||||
- 通过 `PermissionChecker` 可在特定场景下限制工具访问
|
||||
- 独立的 ReAct 循环(步数上限通过参数传入,默认 5,最大 10)
|
||||
- 完整的 Hook 管道(PreToolUse/PostToolUse/SubagentStart/SubagentStop)
|
||||
- 包含活跃度日志(activity log),返回给父代理时附带工具调用统计
|
||||
|
||||
### 5.2 团队成员的独立提示词 (`src/agent/team/teammate.rs`)
|
||||
|
||||
队友的 `system_prompt` 和 `task_prompt` 由 `team/manager.rs`(lead 的委托逻辑)在运行时构造并传入 `run_teammate_loop()`:
|
||||
- prompt 内容完全由 lead 的决定
|
||||
- 队友不包含 `subagent` 工具(防止无限委托链)
|
||||
- 更轻量的 ReAct 循环(无 SSE、无 DB 持久化、无 hooks)
|
||||
- 步数上限更严格(min(max_steps, 5))
|
||||
- 通过文件收件箱与 lead 通信(每 5 秒 poll,最长 60 秒)
|
||||
|
||||
---
|
||||
|
||||
## 上下文压缩中的独立提示词 (`src/agent/compact.rs`)
|
||||
|
||||
### 6.1 四层压缩策略
|
||||
|
||||
| 层 | 方法 | API 调用 | 行为 |
|
||||
|:---|:---|:---|:---|
|
||||
| Layer 0 | `snip_compact` | 无 | 消息数超过 50 时截断中间段,保留头 3 + 尾 47 |
|
||||
| Layer 1 | `micro_compact` | 无 | 将较早的工具结果替换为 `[Previous: used {tool_name}]` 占位符 |
|
||||
| Layer 2 | `auto_compact` | 1 次 | LLM 摘要对话历史(见下),注入 `[历史对话摘要]` |
|
||||
| Layer 3 | `aggressive_micro` | 无 | 保留最近 2 条工具结果,其余替换为占位符 |
|
||||
|
||||
### 6.2 LLM 摘要 Prompt
|
||||
|
||||
Layer 2 中调用 LLM 生成摘要时,使用独立的系统提示词:
|
||||
|
||||
```
|
||||
系统: "你是一个对话摘要助手。请提取对话的关键信息和结论。"
|
||||
用户: "请用简洁的中文总结以下对话历史的要点(不超过500字):
|
||||
|
||||
[用户] ...
|
||||
[助手] ...
|
||||
[工具] ..."
|
||||
```
|
||||
|
||||
### 6.3 身份再注入
|
||||
|
||||
如果压缩后消息过少(≤4 条),注入身份确认块防止模型丢失上下文认知:
|
||||
|
||||
```
|
||||
[身份确认] 你是一位专业的天体物理学研究助手。以上是历史对话的压缩摘要。
|
||||
你正在进行的研究任务是回答用户的问题。请基于摘要中的关键信息继续工作,
|
||||
需要更多信息时主动使用工具搜索。
|
||||
```
|
||||
|
||||
### 6.4 安全切割
|
||||
|
||||
`find_safe_cut_point()` 确保压缩时不会破坏 `assistant(tool_calls)` / `tool_result` 配对关系,向前追溯找到完整工具交互的边界。
|
||||
|
||||
### 6.5 熔断器
|
||||
|
||||
`CompactionCircuitBreaker` 防止连续压缩失败时的无限循环。连续 3 次压缩后消息数未减少 → 打开熔断器,后续跳过自动压缩。
|
||||
|
||||
---
|
||||
|
||||
## Hook 系统与提示词的交互 (`src/agent/hooks.rs`)
|
||||
|
||||
Hook 系统定义 9 个生命周期事件,其中与提示词相关的交互:
|
||||
|
||||
| Hook | 与提示词的关系 |
|
||||
|:---|:---|
|
||||
| `OnSessionStart` | 在提示词组装前触发,可影响任务状态恢复逻辑 |
|
||||
| `PreToolUse::MutateInput` | 可向工具执行注入 `additional_context`(作为 user 消息追加) |
|
||||
| `PreToolUse::Block` | 阻止特定工具的执行(如取消检查) |
|
||||
| `PostToolUse::MutateOutput` | 可修改工具输出内容(影响后续 LLM 看到的 context) |
|
||||
| `OnStepComplete` | 每步结束记录 token 估算、消息数等指标 |
|
||||
| `PreCompact` | 压缩前记录消息数和 token 估算 |
|
||||
| `PostCompact` | 压缩后记录最终消息数和压缩方法 |
|
||||
| `OnSubagentStart/Stop` | 子代理启动/停止时传递 prompt 和结果摘要 |
|
||||
| `OnSessionStop` | 会话终止时清理取消状态并记录终止原因 |
|
||||
|
||||
---
|
||||
|
||||
## 完整数据流
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
RT["AgentRuntime 创建<br/>system_prompt() 调用"]
|
||||
|
||||
RT --> S1["Section 1: identity<br/>(静态常量)"]
|
||||
RT --> S2["Section 2: tools<br/>(ToolRegistry definitions)"]
|
||||
RT --> S3["Section 3: skills<br/>(SkillRegistry.build_reminder)"]
|
||||
|
||||
S1 --> S4
|
||||
S2 --> S4
|
||||
S3 --> S4["Section 4: memory (可选)<br/>(MemoryManager.build_reminder, 5 entries)"]
|
||||
|
||||
S4 --> S5["Section 5: principles<br/>(静态常量)"]
|
||||
S5 --> ASM["assemble()<br/>join('\n\n')"]
|
||||
|
||||
ASM --> Main["主 Agent 上下文<br/>build_initial_context()<br/>+ nudge 注入 + 任务恢复 + 后台通知"]
|
||||
ASM --> Sub["子 Agent 上下文<br/>SubAgentRunner.run()<br/>(独立 system_prompt)"]
|
||||
|
||||
Main --> React["ReAct 循环"]
|
||||
Main --> NudgeInj["Nudge 消息注入 (user)"]
|
||||
Main --> Compact["压缩层<br/>generate_summary()<br/>+ identity re-injection"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 相关文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|:---|:---|
|
||||
| `src/agent/runtime/system_prompt.rs` | SystemPrompt 组装器 + 静态常量 |
|
||||
| `src/agent/runtime/mod.rs:1154-1203` | `system_prompt()` 方法 — 5 section 拼装 |
|
||||
| `src/agent/runtime/context.rs` | `build_initial_context()` — 上下文初始化 + 任务恢复 |
|
||||
| `src/agent/skills.rs` | SkillRegistry — 两层技能加载 + 热重载 |
|
||||
| `src/agent/memory/mod.rs` | MemoryManager — 记忆加载 + system reminder 构建 |
|
||||
| `src/agent/compact.rs` | 四层压缩 + LLM 摘要 prompt + 身份再注入 |
|
||||
| `src/agent/tools/subagent.rs` | 子代理系统提示词(硬编码) |
|
||||
| `src/agent/subagent.rs` | SubAgentRunner — 子代理 ReAct 循环 |
|
||||
| `src/agent/team/teammate.rs` | 队友 ReAct 循环(外部传入 system_prompt) |
|
||||
| `src/agent/hooks.rs` | 9 个生命周期 hook + 提示词交互 |
|
||||
|
||||
---
|
||||
|
||||
## 设计要点
|
||||
|
||||
### 优势
|
||||
|
||||
1. **模块化 section 组装**:各 section 独立管理,便于调试和迭代
|
||||
2. **静态 section 前置**:最大化 Anthropic prompt cache 命中率,降低延迟和成本
|
||||
3. **两层 skill 加载**:避免一次性注入所有 skill 的 token 浪费
|
||||
4. **压缩时身份再注入**:防止激进压缩后模型丢失角色认知
|
||||
5. **安全切割点**:`find_safe_cut_point` 确保压缩不破坏 tool_call/tool_result 配对
|
||||
6. **运行时 nudge 而非 system prompt 编辑**:遵循开闭原则,system prompt 保持稳定
|
||||
|
||||
### 潜在改进方向
|
||||
|
||||
1. **子代理系统提示词继承**:当前子代理的 system prompt 是硬编码的,可考虑让子代理也接收 section 组装器,选择性继承 skills/memory
|
||||
2. **压缩 prompt 外部化**:摘要生成和身份确认的 prompt 可配置化,便于独立调优
|
||||
3. **记忆注入锁竞争**:`memory_manager.try_lock()` 在高并发下可能静默失败,考虑使用 `RwLock::read()`
|
||||
4. **工具描述摘要策略**:80 字符截断可能丢失关键语义,可考虑 LLM 预生成工具描述摘要
|
||||
233
docs/architecture/agent/tasks.md
Normal file
233
docs/architecture/agent/tasks.md
Normal file
@ -0,0 +1,233 @@
|
||||
# 任务系统 (Task System)
|
||||
|
||||
任务系统为 Agent 提供 **规划 → 执行跟踪 → 状态持久化 → 跨 turn 恢复** 的完整闭环,参考 Claude Code s12 Task System 和 s17 Autonomous Agents 设计。
|
||||
|
||||
---
|
||||
|
||||
## 概览
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph RT["AgentRuntime (run_react_loop)"]
|
||||
direction TB
|
||||
S1["1. LLM 调用"]
|
||||
S2["2. 检测 todo_write 调用"]
|
||||
S3["3. persist_tasks() 持久化"]
|
||||
S4["4. 每 3 步 nag reminder"]
|
||||
S5["5. context.rs 恢复任务状态"]
|
||||
end
|
||||
|
||||
RT --> TodoWrite["TodoWriteTool<br/>(纯格式化, 并发安全, 无副作用)"]
|
||||
RT --> Context["context.rs<br/>restore_tasks_from_db()<br/>(每 turn 开始时注入)"]
|
||||
RT --> TaskBoard["TaskBoard<br/>(跨 session 共享看板)<br/>can_start() / claim_task() (原子)<br/>list_available_tasks()"]
|
||||
|
||||
TodoWrite --> Persist["persist_tasks<br/>INSERT OR REPLACE INTO<br/>agent_tasks"]
|
||||
|
||||
Persist --> DB["SQLite: agent_tasks<br/>(session_id, task_id UNIQUE)"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据库 Schema
|
||||
|
||||
```sql
|
||||
-- migrations/20260616000000_agent_tasks.sql
|
||||
CREATE TABLE agent_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL, -- LLM 生成的任务唯一标识
|
||||
content TEXT NOT NULL DEFAULT '', -- 任务描述
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK(status IN ('pending', 'in_progress', 'completed')),
|
||||
blocked_by TEXT NOT NULL DEFAULT '[]', -- JSON 数组: ["task_1", "task_2"]
|
||||
owner TEXT NOT NULL DEFAULT '', -- 归属 agent (lead / sub_xxx / teammate)
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (session_id) REFERENCES agent_sessions(session_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE UNIQUE INDEX idx_agent_tasks_session_task ON agent_tasks(session_id, task_id);
|
||||
CREATE INDEX idx_agent_tasks_session ON agent_tasks(session_id);
|
||||
CREATE INDEX idx_agent_tasks_status ON agent_tasks(session_id, status);
|
||||
```
|
||||
|
||||
关键设计点:
|
||||
- **(session_id, task_id) 联合唯一索引**:支持 `INSERT ... ON CONFLICT DO UPDATE` 的 upsert 语义
|
||||
- **`blocked_by` 存 JSON 数组**:如 `["1", "2"]` 表示依赖任务 1 和 2 必须先完成,形成 DAG
|
||||
- **`owner` 字段**(`migrations/20260618000000_agent_identity.sql` 引入):支持多 Agent 场景下的任务归属
|
||||
|
||||
---
|
||||
|
||||
## 任务状态机
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending: 初始状态
|
||||
pending --> pending: LLM 标记 completed<br/>(回退)
|
||||
pending --> in_progress: claim_task()<br/>或 LLM 标记 in_progress
|
||||
in_progress --> completed: LLM 标记 completed
|
||||
completed --> [*]: 终点状态
|
||||
|
||||
note right of in_progress: 同一时刻只能有一个
|
||||
```
|
||||
|
||||
约束规则:
|
||||
1. **最多一个 `in_progress`**:TodoWriteTool 在格式化输出时检测并警告(`tools/todo.rs:115-118`)
|
||||
2. **DAG 依赖**:TaskBoard::can_start() 检查所有 blockedBy 依赖是否为 completed(`task_board.rs:33-63`)
|
||||
3. **原子认领**:claim_task() 使用乐观并发控制,`WHERE owner IS NULL` 保证不被重复认领
|
||||
4. **应用层验证**:persist_tasks 只做自引用检测,不做完整的循环依赖检测(文档明确标注)
|
||||
|
||||
---
|
||||
|
||||
## 核心组件
|
||||
|
||||
### TodoWriteTool (`tools/todo.rs`)
|
||||
|
||||
LLM 通过 function calling 调用的工具,声明参数:
|
||||
|
||||
```json
|
||||
{
|
||||
"todos": [
|
||||
{"id": "1", "content": "搜索相关文献", "status": "completed"},
|
||||
{"id": "2", "content": "分析论文方法", "status": "in_progress", "blockedBy": ["1"]},
|
||||
{"id": "3", "content": "撰写综述", "status": "pending", "blockedBy": ["2"]}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**设计原则 — 工具层与持久化层分离**:
|
||||
- `TodoWriteTool::execute()` 只做格式化和约束校验,不写数据库
|
||||
- `is_concurrency_safe()` 返回 `true`(纯格式化,无副作用)
|
||||
- 实际的 SQLite 持久化由 `AgentRuntime::run_react_loop()` 在检测到 `todo_write` 调用后统一完成(`runtime/mod.rs:845-854`)
|
||||
|
||||
### persist_tasks() (`tools/todo.rs`)
|
||||
|
||||
```rust
|
||||
pub async fn persist_tasks(
|
||||
db: &SqlitePool,
|
||||
session_id: &str,
|
||||
todos: &[serde_json::Value],
|
||||
owner: &str, // "lead" / "sub_xxx" / teammate name
|
||||
) -> anyhow::Result<()>
|
||||
```
|
||||
|
||||
- 使用 `INSERT ... ON CONFLICT(session_id, task_id) DO UPDATE SET ...` 实现 upsert
|
||||
- 自动跳过任务对自身的引用(`blockedBy` 中包含自身 ID 时忽略并记录 warn)
|
||||
- 作为公开 API 导出(`tools/mod.rs:49`),允许 teammate、外部调用者直接操作
|
||||
|
||||
### TaskBoard (`task_board.rs`)
|
||||
|
||||
共享任务看板,提供跨 Agent 的任务可见性:
|
||||
|
||||
| 方法 | 功能 | 并发策略 |
|
||||
|:---|:---|:---|
|
||||
| `can_start(session_id, task_id)` | 遍历 blockedBy,检查所有依赖是否 completed | 只读,天然安全 |
|
||||
| `claim_task(session_id, task_id, claimant)` | 原子认领(status→in_progress, owner→claimant) | `WHERE owner IS NULL OR owner = ''` |
|
||||
| `list_available_tasks(limit)` | 列出所有可认领的 pending 任务,**跨 session** | 只读,附加 can_start 解析 |
|
||||
|
||||
认领 SQL 的原子性保证:
|
||||
```sql
|
||||
UPDATE agent_tasks SET owner = ?, status = 'in_progress'
|
||||
WHERE session_id = ? AND task_id = ?
|
||||
AND (owner IS NULL OR owner = '' OR status = 'pending')
|
||||
```
|
||||
`rows_affected() > 0` 表示认领成功,否则已被他人认领。
|
||||
|
||||
---
|
||||
|
||||
## ReAct 循环中的集成点
|
||||
|
||||
在 `AgentRuntime::run_react_loop()` 中有三个关键集成点:
|
||||
|
||||
### todo_write 检测与持久化 (`mod.rs:752-855`)
|
||||
|
||||
```rust
|
||||
// 检测 todo_write 调用
|
||||
let called_todo_write = tool_calls.iter().any(|tc| tc.function.name == "todo_write");
|
||||
if called_todo_write {
|
||||
steps_since_last_todo = 0; // 重置 nag 计数器
|
||||
}
|
||||
|
||||
// 工具执行完成后持久化
|
||||
if called_todo_write {
|
||||
for prep in &prepared_calls {
|
||||
if prep.tool_name == "todo_write" {
|
||||
if let Some(todos) = prep.args.get("todos").and_then(|t| t.as_array()) {
|
||||
let _ = persist_tasks(db, sid, &todos_vec, "lead").await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 进度催促 Nag Reminder (`mod.rs:589-596`)
|
||||
|
||||
每 3 步未调用 `todo_write`,自动向 LLM 注入提醒:
|
||||
|
||||
```
|
||||
提醒:你已经连续多步未更新任务计划。建议调用 todo_write 工具复盘当前进度并规划后续步骤。
|
||||
```
|
||||
|
||||
`nag_after_steps = 3`,`steps_since_last_todo` 在每次非 todo_write 调用后递增。
|
||||
|
||||
### 跨 Turn 任务恢复 (`context.rs:32-34`)
|
||||
|
||||
每个新 turn 开始时,`restore_tasks_from_db()` 从 agent_tasks 表查询当前 session 的所有任务,格式化后注入 LLM 上下文:
|
||||
|
||||
```
|
||||
[当前任务状态]
|
||||
以下是上次会话中持久化的任务计划,请基于最新状态继续工作:
|
||||
|
||||
✅ [1] 文献检索
|
||||
✅ [2] 文献分析
|
||||
🔄 [3] 撰写综述 (依赖: 1,2)
|
||||
⏳ [4] 最终校对 (依赖: 3)
|
||||
|
||||
使用 todo_write 工具更新任务进度。
|
||||
```
|
||||
|
||||
System prompt 中也包含提示(`system_prompt.rs:66`):
|
||||
> 任务状态会在每轮开始时从数据库恢复,请基于最新状态继续工作。
|
||||
|
||||
---
|
||||
|
||||
## 子代理与任务隔离
|
||||
|
||||
子代理 (`SubAgentRunner`) 与父代理在 Task 层面保持隔离:
|
||||
|
||||
- 子代理拥有独立的 ToolRegistry 和 ReAct 循环(`subagent.rs:248-254`)
|
||||
- 子代理消息标记 `agent_name = "sub_xxx"`,父代理加载历史时只加载 `agent_name = "lead"` 的消息(`session.rs:78`)
|
||||
- 子代理不直接操作父代理的 agent_tasks,只返回文本摘要
|
||||
- `agent_tasks.owner` 字段已为 teammate 场景预留
|
||||
|
||||
---
|
||||
|
||||
## 辅助机制:工具结果持久化 (`tools/persist.rs`)
|
||||
|
||||
与 Task 系统协同工作的工具输出持久化:
|
||||
|
||||
| 维度 | Task 系统 | Persist 系统 |
|
||||
|:---|:---|:---|
|
||||
| 回答的问题 | "要做什么" | "看到了什么" |
|
||||
| 触发条件 | LLM 调用 todo_write | 工具输出超过 max_output_chars |
|
||||
| 存储位置 | SQLite agent_tasks 表 | 磁盘文件 |
|
||||
| 幂等保证 | ON CONFLICT DO UPDATE | 独占创建 (create_new) |
|
||||
|
||||
当工具输出超过 `AGENT_MAX_TOOL_OUTPUT_CHARS`(默认 4000)时,完整内容写入 `{library_dir}/tool-results/{tool_call_id}.txt`,返回 `<persisted-output>` XML 占位符,LLM 后续可通过 `read_file` 读取完整内容。
|
||||
|
||||
---
|
||||
|
||||
## 相关文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|:---|:---|
|
||||
| `src/agent/tools/todo.rs` | TodoWriteTool 定义 + persist_tasks() |
|
||||
| `src/agent/task_board.rs` | TaskBoard — 跨 session 共享看板 |
|
||||
| `src/agent/runtime/mod.rs` | ReAct 循环中的 nag/persist 集成 |
|
||||
| `src/agent/runtime/context.rs` | restore_tasks_from_db — 跨 turn 恢复 |
|
||||
| `src/agent/runtime/system_prompt.rs` | 系统提示词中的 Task 指引 |
|
||||
| `src/agent/tools/persist.rs` | 工具输出磁盘持久化(辅助机制) |
|
||||
| `src/agent/subagent.rs` | 子代理中的 Task 隔离 |
|
||||
| `migrations/20260616000000_agent_tasks.sql` | agent_tasks 表 DDL |
|
||||
| `migrations/20260618000000_agent_identity.sql` | owner 字段引入 |
|
||||
232
docs/architecture/agent/team.md
Normal file
232
docs/architecture/agent/team.md
Normal 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<name, TeamMemberHandle>"]
|
||||
end
|
||||
|
||||
subgraph InboxFS[".team/{session_id}/inbox/"]
|
||||
L["lead.jsonl"]
|
||||
S["searcher.jsonl"]
|
||||
R["reader.jsonl"]
|
||||
end
|
||||
|
||||
subgraph Teammates["Teammates (tokio::spawn)"]
|
||||
T1["searcher<br/>ReAct loop<br/>max 5 steps"]
|
||||
T2["reader<br/>ReAct loop<br/>max 5 steps"]
|
||||
end
|
||||
|
||||
LR -->|"spawn_teammate"| TM
|
||||
LR -->|"send_teammate_message"| S
|
||||
LR -->|"check_team_inbox"| L
|
||||
LR -->|"team_broadcast"| S
|
||||
LR -->|"team_broadcast"| R
|
||||
|
||||
TM -->|"tokio::spawn"| T1
|
||||
TM -->|"tokio::spawn"| T2
|
||||
|
||||
T1 -->|"append result"| L
|
||||
T2 -->|"append result"| L
|
||||
T1 -->|"drain inbox"| S
|
||||
T2 -->|"drain inbox"| R
|
||||
```
|
||||
|
||||
## 通信机制:文件邮箱
|
||||
|
||||
消息传递不通过 channel 或共享内存,而是通过 **append-only JSONL 文件**:
|
||||
|
||||
```
|
||||
.team/{session_id}/inbox/
|
||||
├── lead.jsonl ← 队友将结果写入此处
|
||||
├── searcher.jsonl ← Lead 将任务写入此处
|
||||
└── reader.jsonl ← Lead 将任务写入此处
|
||||
```
|
||||
|
||||
### 消息类型 (`TeamMessage`)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|:---|:---|:---|
|
||||
| `from` | `String` | 发送者名称 |
|
||||
| `to` | `String` | 接收者名称 |
|
||||
| `content` | `String` | 消息内容 |
|
||||
| `msg_type` | `TeamMessageType` | `task` / `result` / `question` / `answer` / `status` |
|
||||
| `timestamp` | `String` | ISO 8601 时间戳 (UTC) |
|
||||
|
||||
### 核心操作
|
||||
|
||||
- **`append_message(team_dir, agent_name, msg)`**:追加一行 JSON 到接收者文件,自动创建目录
|
||||
- **`drain_inbox(team_dir, agent_name)`**:读取所有消息行 → 清空文件 → 返回 Vec(破坏性读取)
|
||||
- **`has_pending(team_dir, agent_name)`**:检查文件是否存在且非空(用于快速轮询)
|
||||
|
||||
> **已知问题**:`drain_inbox()` 在 `read_to_string` 和 `write("")` 之间存在 TOCTOU 竞态条件。若清空前有新消息追入,该消息会丢失。生产环境应使用 advisory file lock 或原子 rename 替代。
|
||||
|
||||
## 队友生命周期
|
||||
|
||||
```
|
||||
┌──────────┐
|
||||
│ SPAWNING │ ← Manager 创建句柄,tokio::spawn 启动循环
|
||||
└────┬─────┘
|
||||
▼
|
||||
┌──────────┐ 有新消息到达 ┌──────────┐
|
||||
│ IDLE │ ─────────────────→ │ WORKING │
|
||||
│ │ ←──────────────── │ │
|
||||
│ 5s×12 │ 任务完成/超时 │ ReAct │
|
||||
│ 轮询 │ │ max 5步 │
|
||||
└────┬─────┘ └──────────┘
|
||||
│ 收到取消信号
|
||||
▼
|
||||
┌──────────┐
|
||||
│ SHUTDOWN │ ← 发送告别 Status 消息给 Lead,退出循环
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
### IDLE 阶段细节 (`teammate.rs:68-92`)
|
||||
|
||||
1. 设置状态为 `Idle`
|
||||
2. `drain_inbox()` 检查是否有待处理消息
|
||||
3. 若为空:以 **5 秒间隔轮询 `has_pending()`**,最长 60 秒(12 次 × 5 秒)
|
||||
4. 在轮询中,若 `cancelled` 标志被设置或 `has_pending()` 返回 true,则提前退出
|
||||
5. 超时无消息 → 回到步骤 2
|
||||
6. 有新消息 → 进入 WORKING
|
||||
|
||||
### WORKING 阶段细节 (`teammate.rs:103-152`)
|
||||
|
||||
1. 设置状态为 `Working`
|
||||
2. 将收件箱消息作为 `user` 角色消息注入到队友的对话历史中
|
||||
3. 调用 `run_teammate_react_turn()`:最多 **5 步**的简化 ReAct 循环
|
||||
4. 若返回结果 → 封装为 `TeamMessageType::Result` 发送到 `lead` 的收件箱
|
||||
5. 若返回 `None`(错误/超时)→ 静默回到 IDLE(**不通知 Lead 出错**)
|
||||
|
||||
## 队友 ReAct 循环 vs 主 Agent 循环
|
||||
|
||||
| 特性 | 主 Agent (Lead) | 队友 (Teammate) |
|
||||
|:---|:---|:---|
|
||||
| 最大步数 | 8(可配置) | 5(硬编码 `min(config.max_steps, 5)`) |
|
||||
| 流式输出 | SSE 实时推送到前端 | 无 SSE,静默消费 |
|
||||
| 工具注册表 | 完整(含 subagent、team 工具) | 排除了 subagent 和 team 工具 |
|
||||
| 上下文压缩 | 四层压缩 + CircuitBreaker | 简单 token 估算 + `compact::compress_context` |
|
||||
| Hooks | PreToolUse / PostToolUse / Stop | 无 |
|
||||
| 权限检查 | PermissionChecker | 无 |
|
||||
| 数据库持久化 | agent_messages 表 | 无 |
|
||||
| 后台通知 | BgNotificationQueue | 有 Queue 但未使用 |
|
||||
| Skills | 两层加载 (list + load_skill) | 无,skill_registry 仅用于工具构造 |
|
||||
|
||||
队友的工具注册表构造 (`teammate.rs:40-41`):
|
||||
```rust
|
||||
let tool_registry =
|
||||
ToolRegistry::new_with_queue(Some(queue.clone()), app_state.skill_registry.clone());
|
||||
```
|
||||
`ToolRegistry::new_with_queue()` 不注册 subagent 和 team 工具,防止无限委托链。
|
||||
|
||||
## TeamManager 设计
|
||||
|
||||
### 锁顺序约定 (CRITICAL)
|
||||
|
||||
存在两个 `tokio::sync::Mutex` 的嵌套获取:
|
||||
1. `TeamManager.handles`(外层)
|
||||
2. `TeamMemberHandle.status`(内层)
|
||||
|
||||
所有代码必须遵守 **handles → status** 的顺序,否则死锁。`list_members()` 作为正确顺序的参考实现。
|
||||
|
||||
### 主要操作
|
||||
|
||||
| 操作 | 说明 | 锁行为 |
|
||||
|:---|:---|:---|
|
||||
| `spawn(name, role)` | tokio::spawn 队友循环,注册句柄 | 获取 handles 锁 |
|
||||
| `stop(name)` | 设置 cancelled 标志 | 获取 handles 锁 |
|
||||
| `stop_all()` | 停止所有队友 | 获取 handles 锁 |
|
||||
| `send_message(from, to, content, type)` | 追加消息到接收者收件箱文件 | 无锁(纯文件 I/O) |
|
||||
| `broadcast(from, content)` | 向 config.members 中所有非发送者成员追加消息 | 无锁(纯文件 I/O) |
|
||||
| `check_inbox(agent_name)` | drain_inbox 并返回消息 | 无锁(纯文件 I/O) |
|
||||
| `list_members()` | 获取所有队友的 (name, role, status) | handles → 各 status(正确顺序) |
|
||||
|
||||
### 队友系统提示词 (`manager.rs:70-93`)
|
||||
|
||||
队友收到的是天体物理学研究助手角色设定 + 工具使用指引 + 通信协议说明。系统提示词始终通过 `build_teammate_system_prompt(role)` 生成,**不使用** `MemberConfig.system_prompt` 字段。
|
||||
|
||||
## 4 个团队工具
|
||||
|
||||
工具定义在 `src/agent/tools/team.rs`,均设置 `InterruptBehavior::Block`(中断时先完成副作用再停止):
|
||||
|
||||
| 工具名 | 参数 | 功能 | 接收者 |
|
||||
|:---|:---|:---|:---|
|
||||
| `spawn_teammate` | `name`, `role` | 启动一个后台队友 | — |
|
||||
| `send_teammate_message` | `to`, `content` | 向指定队友发送消息 | 队友收件箱 |
|
||||
| `team_broadcast` | `content` | 向所有队友广播消息 | 所有队友收件箱 |
|
||||
| `check_team_inbox` | `agent_name?` (默认 "lead") | 读取并清空收件箱 | Lead 收件箱 |
|
||||
|
||||
典型协作流程:
|
||||
|
||||
```
|
||||
1. Lead 调用 spawn_teammate(name="searcher", role="ADS文献搜索专家")
|
||||
2. Lead 调用 send_teammate_message(to="searcher", content="搜索2024年暗物质间接探测综述")
|
||||
3. Lead 继续自己的 ReAct 循环(可以同时发起另一个 spawn_teammate)
|
||||
4. 队友在后台执行 ReAct 循环(最多5步),完成后将结果写入 lead.jsonl
|
||||
5. Lead 调用 check_team_inbox() 读取队友结果
|
||||
6. Lead 综合队友结果,给出最终回答
|
||||
```
|
||||
|
||||
## 与子代理 (`subagent`) 的对比
|
||||
|
||||
代码库中存在**两种委托机制**,适用不同场景:
|
||||
|
||||
| | 子代理 (`subagent`) | 团队 (`team`) |
|
||||
|:---|:---|:---|
|
||||
| 执行模式 | 同步:Lead 等待完成 | 异步:后台运行,Lead 主动轮询 |
|
||||
| 并发度 | 每次调用 1 个 | 可同时运行多个队友 |
|
||||
| 上下文 | 任务完成后丢弃 | 跨任务累积(直到压缩) |
|
||||
| 通信方式 | 工具调用 → 返回摘要 | 文件邮箱:写入/轮询 |
|
||||
| Hooks | PreToolUse / PostToolUse / SubagentStart / SubagentStop | 无 |
|
||||
| 权限检查 | PermissionChecker | 无 |
|
||||
| 进度透传 | SSE 事件到父代理 | 无 |
|
||||
| DB 持久化 | agent_messages 表 | 无 |
|
||||
| 适用场景 | "去完成这个子任务,给我摘要" | "留在后台,持续处理我分配的任务" |
|
||||
|
||||
## 当前状态:未接入
|
||||
|
||||
> **重要**:`TeamManager` 和 4 个团队工具已完整实现,但当前**未接入 AgentRuntime**。
|
||||
>
|
||||
> `AgentRuntime::new()` 调用 `ToolRegistry::new_with_queue()`,而非 `ToolRegistry::new_with_team()`。团队工具通过独立的 `add_team_tools()` 函数接入,但该函数在当前代码路径中从未被调用。
|
||||
>
|
||||
> 实际可用的委托功能由 `subagent` 工具提供(通过 `SubAgentTool` 已接入)。
|
||||
>
|
||||
> 接入方法:在 `AgentRuntime::new()` 和 `with_config()` 中,创建 `TeamManager` 实例并用 `ToolRegistry::new_with_team()` 替代 `new_with_queue()`。
|
||||
|
||||
## 改进建议
|
||||
|
||||
1. **修复 drain_inbox 竞态条件**:使用 advisory file lock 或原子 rename
|
||||
2. **队友错误传播**:`run_teammate_react_turn()` 返回 `None` 时应通知 Lead
|
||||
3. **消息关联 ID**:添加 `correlation_id` 字段,支持请求-响应匹配
|
||||
4. **降低锁持有时间**:将 `send_message` / `broadcast` 的文件 I/O 移出锁临界区(当前已是无锁,但工具层仍持有 `team_manager` 锁)
|
||||
5. **队友名称去重**:spawn 同名队友前检查是否已有活跃队友
|
||||
6. **会话级清理**:Agent 会话结束时调用 `stop_all()` 清理队友和收件箱文件
|
||||
425
docs/architecture/agent/tools.md
Normal file
425
docs/architecture/agent/tools.md
Normal 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 — 大输出磁盘持久化
|
||||
```
|
||||
56
docs/architecture/core-modules.md
Normal file
56
docs/architecture/core-modules.md
Normal 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 更新、容器内日志自动滚动。 |
|
||||
|
||||
---
|
||||
|
||||
183
docs/architecture/core-workflows.md
Normal file
183
docs/architecture/core-workflows.md
Normal 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 (通道 A:arXiv 直连优先)
|
||||
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 (通道 B:ADS 路由回退)
|
||||
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 渲染展示
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
79
docs/architecture/overview.md
Normal file
79
docs/architecture/overview.md
Normal 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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` 团队注册表。 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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
107
docs/development.md
Normal 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
29
profiles/readonly.toml
Normal 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
38
profiles/research.toml
Normal 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"
|
||||
@ -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
|
||||
|
||||
@ -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,7 +384,10 @@ impl HookRegistry {
|
||||
}
|
||||
PreToolUseAction::Continue => {}
|
||||
}
|
||||
final_action = action;
|
||||
// Continue 不应覆盖已设置的 meaningful action(Block/MutateInput/PermissionRequired)
|
||||
if !matches!(action, PreToolUseAction::Continue) {
|
||||
final_action = action;
|
||||
}
|
||||
}
|
||||
|
||||
let ctx_opt = if accumulated_context.is_empty() {
|
||||
|
||||
@ -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 {
|
||||
|
||||
179
src/agent/runtime/denial_tracker.rs
Normal file
179
src/agent/runtime/denial_tracker.rs
Normal 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("累计被拒绝"));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
// 确保每个工具调用有唯一 ID(LLM 可能不返回 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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,10 +120,65 @@ 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 {
|
||||
fn default() -> Self {
|
||||
Self::from_env_optional()
|
||||
@ -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,10 +786,12 @@ 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() {
|
||||
thought_content = Some(stream_output.content.clone());
|
||||
}
|
||||
if thought_content.is_none()
|
||||
&& stream_output.is_tool_call_step
|
||||
&& !stream_output.content.is_empty()
|
||||
{
|
||||
thought_content = Some(stream_output.content.clone());
|
||||
}
|
||||
|
||||
if stream_output.is_tool_call_step {
|
||||
if let Some(ref thought_text) = thought_content {
|
||||
@ -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,14 +827,13 @@ 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,
|
||||
});
|
||||
}
|
||||
let _ = tx.send(AgentStreamEvent::Thought {
|
||||
content: thought_text.clone(),
|
||||
step,
|
||||
});
|
||||
}
|
||||
|
||||
// Token 使用统计
|
||||
@ -728,6 +848,13 @@ impl AgentRuntime {
|
||||
}
|
||||
};
|
||||
|
||||
// 修复空 ID(LLM 可能不返回 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?;
|
||||
|
||||
@ -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 模式:对 {} 暂保留 AskUser(executor 中检查路径)", 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_name(content_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 { .. }));
|
||||
}
|
||||
}
|
||||
|
||||
195
src/agent/runtime/permission_explainer.rs
Normal file
195
src/agent/runtime/permission_explainer.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
177
src/agent/runtime/permission_profile.rs
Normal file
177
src/agent/runtime/permission_profile.rs
Normal 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()));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
// 确保每个工具调用有唯一 ID(LLM 可能不返回 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,15 +486,52 @@ 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),
|
||||
);
|
||||
messages.push(tool_msg);
|
||||
continue;
|
||||
// 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 => {
|
||||
// 继续执行
|
||||
}
|
||||
}
|
||||
|
||||
// 执行工具
|
||||
@ -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));
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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("");
|
||||
/// 危险命令黑名单(交互式、提权、远程访问、破坏性命令)。
|
||||
/// 仅对首个命令单词做精确匹配,非子串匹配。
|
||||
const DANGEROUS_COMMANDS: &[&str] = &[
|
||||
// 提权/用户切换
|
||||
"sudo",
|
||||
"su",
|
||||
"passwd",
|
||||
"chsh",
|
||||
"visudo",
|
||||
// 远程访问
|
||||
"ssh",
|
||||
"telnet",
|
||||
"rlogin",
|
||||
"rsh",
|
||||
"ftp",
|
||||
// 交互式编辑器/分页器
|
||||
"vim",
|
||||
"vi",
|
||||
"nano",
|
||||
"emacs",
|
||||
"nvim",
|
||||
"less",
|
||||
"more",
|
||||
"most",
|
||||
// 交互式监控
|
||||
"top",
|
||||
"htop",
|
||||
"btop",
|
||||
"iotop",
|
||||
"nmon",
|
||||
"glances",
|
||||
// 磁盘/文件系统破坏
|
||||
"mkfs",
|
||||
"mke2fs",
|
||||
"mkfs.ext4",
|
||||
"mkfs.xfs",
|
||||
"fdisk",
|
||||
"parted",
|
||||
"dd",
|
||||
// 网络监听/扫描(防信息泄露)
|
||||
"tcpdump",
|
||||
"wireshark",
|
||||
"tshark",
|
||||
"nmap",
|
||||
// 登录相关
|
||||
"login",
|
||||
"su",
|
||||
"sulogin",
|
||||
];
|
||||
|
||||
/// 危险参数模式 — 对不在白名单也不在黑名单的命令做二次检查。
|
||||
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());
|
||||
}
|
||||
|
||||
// 禁止交互式/破坏性命令
|
||||
let interactive_patterns = [
|
||||
"sudo ",
|
||||
"su ",
|
||||
"passwd",
|
||||
"ssh ",
|
||||
"telnet ",
|
||||
"login",
|
||||
"less ",
|
||||
"more ",
|
||||
"vim ",
|
||||
"vi ",
|
||||
"nano ",
|
||||
"emacs ",
|
||||
"top",
|
||||
"htop",
|
||||
"watch ",
|
||||
"tail -f",
|
||||
"rm -rf /",
|
||||
"mkfs.",
|
||||
"dd if=",
|
||||
"chmod 777",
|
||||
"> /dev/",
|
||||
];
|
||||
|
||||
let lower = trimmed.to_lowercase();
|
||||
for pattern in &interactive_patterns {
|
||||
if lower.contains(&pattern.to_lowercase()) {
|
||||
return Some(format!("不允许执行 '{}' 类命令", pattern));
|
||||
}
|
||||
// 检测命令替换绕过
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 通道和会话 ID(executor 在构造 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
111
src/api/agent.rs
111
src/api/agent.rs
@ -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)
|
||||
}
|
||||
|
||||
@ -30,10 +30,9 @@ pub fn convert_ads_doc_to_standard(doc: &AdsPaperDoc) -> StandardPaper {
|
||||
}
|
||||
}
|
||||
}
|
||||
if arxiv_id.is_empty()
|
||||
&& doc.bibcode.starts_with("arXiv") {
|
||||
arxiv_id = doc.bibcode.replace("arXiv", "").trim().to_string();
|
||||
}
|
||||
if arxiv_id.is_empty() && doc.bibcode.starts_with("arXiv") {
|
||||
arxiv_id = doc.bibcode.replace("arXiv", "").trim().to_string();
|
||||
}
|
||||
|
||||
StandardPaper {
|
||||
bibcode: doc.bibcode.clone(),
|
||||
|
||||
@ -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
186
src/api/permissions.rs
Normal 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(),
|
||||
})
|
||||
}
|
||||
@ -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,
|
||||
|
||||
22
src/main.rs
22
src/main.rs
@ -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(
|
||||
|
||||
@ -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 ")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user