From cec4b8cf7b15229d17abf43227c581b1e3192063 Mon Sep 17 00:00:00 2001 From: Asfmq <2696428814@qq.com> Date: Tue, 23 Jun 2026 20:22:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Docker=20=E5=AE=B9=E5=99=A8=E5=8C=96?= =?UTF-8?q?=E3=80=81Cookie=20=E9=89=B4=E6=9D=83=E3=80=81Coordinator=20?= =?UTF-8?q?=E7=BC=96=E6=8E=92=E3=80=81FTS5=20=E6=90=9C=E7=B4=A2=E4=B8=8E?= =?UTF-8?q?=20P1-P3=20=E5=85=A8=E9=9D=A2=E6=94=B6=E5=B0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker 容器化部署 - 提供 Mode A (Alpine musl, ~23MB) 和 Mode B (Distroless glibc, ~87MB) 两种镜像,Docker Compose 一键启动 - build.rs 支持 SKIP_DASHBOARD_BUILD 跳过前端构建 - 国内镜像加速 (npm/apt/apk) 通过 USE_MIRRORS build-arg 控制 安全:Cookie-Based 鉴权系统 - HttpOnly/SameSite=Strict Cookie 会话管理(24h 过期自动清理) - 登录/登出/验证接口 + 中间件注入 - 前端登录页面 + 退出按钮 - 三层 CORS:localhost 鉴权 / 全放通 bookmarklet / 受保护路由 - 书签脚本 fetch 添加 credentials:'include' Coordinator 模式 (P2) - 4 个 meta-tool (delegate_task/check_task/task_stop/synthesize) - WorkerPool + Semaphore 并发控制 + 超时保护 - 前端协调者模式开关 Hook 系统:UserPromptSubmit 事件 (P2) - 第 13 个生命周期事件,fire-and-forget 审计 FTS5 全文搜索 (P3) - agent_sessions_fts + agent_messages_fts 虚拟表 - search_history Agent 工具 + /api/search/history HTTP 接口 - 前端防抖搜索框 + 仅当前会话筛选 工具加载优化 (P3) - defer_loading 延迟加载 (7 个重型工具) - is_readonly 只读标记 (9 个查询工具) - classifier_summary 工具目录供 LLM 按需判断 模型回退策略 (P3) - LLM_FALLBACK_MODEL 优先回退 + LLM_FALLBACK_CHAIN 链式轮换 - LlmClient model 改为 Arc 支持运行时切换 - 连续 3 次过载后自动切换 压缩记忆桥接 (P3) - 压缩丢弃消息 → 子代理提取持久记忆 (extract_memories_from_compaction) git2 依赖修复 - 切换到 vendored-libgit2,消除 OpenSSL 系统依赖 --- .dockerignore | 42 ++ .env.example | 18 +- Cargo.lock | 36 -- Cargo.toml | 2 +- Dockerfile | 101 +++++ Dockerfile.modeB | 94 +++++ build.rs | 9 + dashboard/src/App.tsx | 150 ++++++- dashboard/src/components/layout/Sidebar.tsx | 30 +- .../src/features/agent/ResearchAgentPanel.tsx | 236 +++++++++-- dashboard/src/features/sync/SyncPanel.tsx | 4 +- dashboard/src/index.css | 10 + docker-compose.yml | 105 +++++ .../agent/claude-code-reference-analysis.md | 126 ++++-- docs/architecture/agent/context.md | 6 +- docs/architecture/agent/env-vars.md | 6 +- docs/architecture/agent/hooks.md | 14 +- docs/architecture/agent/memory.md | 58 +++ docs/architecture/agent/overview.md | 35 +- docs/architecture/agent/subagent.md | 51 ++- docs/architecture/agent/system-prompt.md | 5 +- docs/architecture/agent/team.md | 16 +- docs/architecture/agent/tools.md | 60 ++- docs/deployment.md | 366 ++++++++++++------ migrations/20260623000000_fts5_search.sql | 73 ++++ migrations/20260623000001_fix_fts5_rowid.sql | 30 ++ src/agent/compact.rs | 100 +++++ src/agent/coordinator/agent.rs | 354 +++++++++++++++++ src/agent/coordinator/mod.rs | 93 +++++ src/agent/coordinator/tools.rs | 340 ++++++++++++++++ src/agent/coordinator/worker.rs | 159 ++++++++ src/agent/hooks/dispatch.rs | 23 +- src/agent/hooks/matcher.rs | 4 +- src/agent/hooks/mod.rs | 33 +- src/agent/hooks/registry.rs | 2 + src/agent/hooks/traits.rs | 5 + src/agent/hooks/types.rs | 16 + src/agent/memory/extraction.rs | 2 +- src/agent/mod.rs | 1 + src/agent/runtime/mod.rs | 109 +++++- src/agent/tools/astro/paper.rs | 12 + src/agent/tools/astro/rag.rs | 4 + src/agent/tools/astro/search.rs | 8 + src/agent/tools/astro/target.rs | 4 + src/agent/tools/background.rs | 8 + src/agent/tools/filesystem/glob.rs | 4 + src/agent/tools/filesystem/grep.rs | 4 + src/agent/tools/filesystem/read.rs | 4 + src/agent/tools/mod.rs | 65 +++- src/agent/tools/search_history.rs | 136 +++++++ src/agent/tools/team.rs | 12 + src/api/agent.rs | 7 +- src/api/auth.rs | 184 +++++++++ src/api/mod.rs | 6 + src/api/search.rs | 150 +++++++ src/clients/llm.rs | 31 +- src/lib.rs | 52 ++- src/main.rs | 96 ++++- 58 files changed, 3366 insertions(+), 345 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 Dockerfile.modeB create mode 100644 docker-compose.yml create mode 100644 migrations/20260623000000_fts5_search.sql create mode 100644 migrations/20260623000001_fix_fts5_rowid.sql create mode 100644 src/agent/coordinator/agent.rs create mode 100644 src/agent/coordinator/mod.rs create mode 100644 src/agent/coordinator/tools.rs create mode 100644 src/agent/coordinator/worker.rs create mode 100644 src/agent/tools/search_history.rs create mode 100644 src/api/auth.rs create mode 100644 src/api/search.rs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ddad18a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,42 @@ +# Rust build artifacts +target/ +**/*.rs.bk + +# Frontend +dashboard/node_modules/ +dashboard/dist/ + +# Local data (mounted as volumes at runtime) +library/ +logs/ + +# Environment (injected at runtime) +.env +.env.* + +# IDE / OS +.vscode/ +.idea/ +.DS_Store +*.suo +*.swp + +# Git +.git/ +.gitignore +.gitattributes + +# CI/CD +.github/ + +# OMC / Claude +.claude/ +.omc/ + +# Docs (not needed at runtime) +docs/ + +# Misc +*.md +!skills/**/*.md +LICENSE diff --git a/.env.example b/.env.example index f18b3ef..811790d 100644 --- a/.env.example +++ b/.env.example @@ -16,8 +16,10 @@ LLM_API_BASE=https://api.openai.com/v1 # 可选模型示例:deepseek-chat, qwen-flash, gpt-4o-mini LLM_MODEL=gpt-4o-mini -# 备用模型(连续 3 次 529 过载后自动切换,可选) -# FALLBACK_MODEL=gpt-4o-mini +# 备用模型(连续 3 次 529 过载后自动切换,优先于 LLM_FALLBACK_CHAIN,可选) +# LLM_FALLBACK_MODEL=gpt-4o-mini +# 备用模型链(逗号分隔,按顺序轮换尝试,可选) +# LLM_FALLBACK_CHAIN=gpt-4o-mini,deepseek-chat # 向量嵌入模型配置(未设置时默认回退到 LLM 的 API Key 和 Base) # EMBEDDING_API_KEY=your_embedding_api_key_here @@ -55,6 +57,8 @@ LIBRARY_DIR=./library SKILLS_DIR=./skills # 后端服务监听端口 PORT=8000 +# 认证密码 +ADMIN_PASSWORD=fmq # ───────────────────────────────────────────────────────────────────────────── # 5. 日志配置 @@ -88,6 +92,12 @@ LOG_DIR=./logs # 触发 snip 压缩的最大消息数(默认 50) # AGENT_MAX_MESSAGES=50 +# ── P2 Coordinator Mode ── +# Worker 最大并发数(默认 4) +# AGENT_COORDINATOR_MAX_WORKERS=4 +# Worker 超时秒数(默认 300) +# AGENT_COORDINATOR_WORKER_TIMEOUT=300 + # ── 权限系统 ── # Agent 工具权限规则(逗号分隔,支持内容级匹配 "ToolName(pattern)") # 规则优先级: Deny > Ask > Allow(先匹配先生效) @@ -113,10 +123,10 @@ LOG_DIR=./logs # AGENT_PERMISSIONS_ASK=run_bash,file_write,file_edit,download_paper # ───────────────────────────────────────────────────────────────────────────── -# 7. 自动记忆提取(实验性功能) +# 7. 自动记忆提取(实验性功能,同时控制压缩记忆桥接 P3) # ───────────────────────────────────────────────────────────────────────────── -# 是否启用自动记忆提取(默认 false) +# 是否启用自动记忆提取(默认 false,开启后会话结束 + 压缩时均触发提取) # EXTRACT_MEMORY_ENABLED=false # 记忆提取节流间隔:每 N 个 turn 触发一次(默认 3) # EXTRACT_MEMORY_THROTTLE_TURNS=3 diff --git a/Cargo.lock b/Cargo.lock index c41c7bf..e3c34bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1468,8 +1468,6 @@ dependencies = [ "libc", "libgit2-sys", "log", - "openssl-probe", - "openssl-sys", "url", ] @@ -2091,9 +2089,7 @@ checksum = "ee4126d8b4ee5c9d9ea891dd875cfdc1e9d0950437179104b183d7d8a74d24e8" dependencies = [ "cc", "libc", - "libssh2-sys", "libz-sys", - "openssl-sys", "pkg-config", ] @@ -2136,20 +2132,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "libssh2-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" -dependencies = [ - "cc", - "libc", - "libz-sys", - "openssl-sys", - "pkg-config", - "vcpkg", -] - [[package]] name = "libz-sys" version = "1.1.29" @@ -2593,24 +2575,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "outref" version = "0.5.2" diff --git a/Cargo.toml b/Cargo.toml index 075617a..e76fb44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,7 +59,7 @@ tempfile = "3" glob = "0.3" walkdir = "2" lru = "0.12" -git2 = "0.18" +git2 = { version = "0.18", default-features = false, features = ["vendored-libgit2"] } [features] default = [] diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1a8a0db --- /dev/null +++ b/Dockerfile @@ -0,0 +1,101 @@ +# ============================================================================= +# AstroResearch Dockerfile — Mode A (外部 obscura 二进制) +# ============================================================================= +# +# 构建: +# docker build -t astroresearch:latest . +# +# 海外网络构建: +# docker build --build-arg USE_MIRRORS=0 -t astroresearch:latest . +# +# 运行: +# docker run -d --name astro -p 8000:8000 --env-file .env \ +# -v ./library:/app/library \ +# -v ./logs:/app/logs \ +# -v ./skills:/app/skills:ro \ +# -v ./bin:/app/bin:ro \ +# astroresearch:latest +# ============================================================================= + +# ─── Stage 1: 前端 ─────────────────────────────────────────────────────────── +FROM node:22-alpine AS frontend-builder + +ARG USE_MIRRORS=1 +WORKDIR /app/dashboard + +RUN if [ "$USE_MIRRORS" = "1" ]; then \ + npm config set registry https://registry.npmmirror.com; \ + fi + +COPY dashboard/package.json dashboard/package-lock.json ./ +RUN npm ci +COPY dashboard/ ./ +RUN npm run build + +# ─── Stage 2: Rust 后端 (musl 静态链接) ────────────────────────────────────── +FROM rust:1.94-alpine AS backend-builder + +ARG USE_MIRRORS=1 + +RUN if [ "$USE_MIRRORS" = "1" ]; then \ + sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories; \ + fi + +RUN apk add --no-cache musl-dev cmake pkgconfig openssl-dev openssl-libs-static + +# sqlite-vec v0.1.9 与 musl 不兼容: musl 无 BSD u_int*_t 类型 +# typedef u_int8_t uint8_t → typedef uint8_t uint8_t (C11 合法) +ENV CFLAGS="-Du_int8_t=uint8_t -Du_int16_t=uint16_t -Du_int64_t=uint64_t" + +WORKDIR /app + +# Cargo 解析阶段就需要可选 path 依赖存在,即使不启用对应 feature +RUN mkdir -p libs/obscura/crates/obscura-browser/src && \ + printf '[package]\nname = "obscura-browser"\nversion = "0.1.0"\nedition = "2021"\n\n[features]\nstealth = []\n' \ + > libs/obscura/crates/obscura-browser/Cargo.toml && \ + echo '' > libs/obscura/crates/obscura-browser/src/lib.rs && \ + mkdir -p libs/obscura/crates/obscura-net/src && \ + printf '[package]\nname = "obscura-net"\nversion = "0.1.0"\nedition = "2021"\n\n[features]\nstealth = []\n' \ + > libs/obscura/crates/obscura-net/Cargo.toml && \ + echo '' > libs/obscura/crates/obscura-net/src/lib.rs + +ENV SKIP_DASHBOARD_BUILD=1 +COPY Cargo.toml Cargo.lock build.rs ./ +COPY src/ ./src/ +COPY migrations/ ./migrations/ +COPY --from=frontend-builder /app/dashboard/dist/ ./dashboard/dist/ + +RUN cargo build --profile release-min && \ + cp /app/target/release-min/astroresearch /usr/local/bin/astroresearch + +# ─── Stage 3: 运行时 ───────────────────────────────────────────────────────── +FROM alpine:3.21 + +ARG USE_MIRRORS=1 + +RUN if [ "$USE_MIRRORS" = "1" ]; then \ + sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories; \ + fi + +RUN apk add --no-cache ca-certificates tzdata + +RUN addgroup -g 65532 -S astro && adduser -u 65532 -S astro -G astro + +WORKDIR /app + +COPY --from=backend-builder /usr/local/bin/astroresearch /app/ +COPY --from=frontend-builder /app/dashboard/dist/ ./dashboard/dist/ +COPY skills/ ./skills/ +COPY migrations/ ./migrations/ + +RUN mkdir -p /app/library /app/logs /app/bin + +VOLUME ["/app/library", "/app/logs", "/app/skills", "/app/bin"] + +USER astro +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ + CMD wget -q --spider http://localhost:8000/ || exit 1 + +ENTRYPOINT ["/app/astroresearch"] diff --git a/Dockerfile.modeB b/Dockerfile.modeB new file mode 100644 index 0000000..0efce7e --- /dev/null +++ b/Dockerfile.modeB @@ -0,0 +1,94 @@ +# ============================================================================= +# AstroResearch Dockerfile — Mode B (进程内 obscura, 无外部二进制依赖) +# ============================================================================= +# +# 前置条件: +# mkdir -p libs +# git clone https://github.com/h4ckf0r0day/obscura libs/obscura +# +# 并从 .dockerignore 中移除 "libs/" 行 (否则源码不会进构建上下文) +# +# 构建: +# docker build -f Dockerfile.modeB -t astroresearch-all:latest . +# +# 运行 (无需 bin/obscura, 反爬能力内置于单体二进制): +# docker run -d --name astro -p 8000:8000 --env-file .env \ +# -v ./library:/app/library \ +# -v ./logs:/app/logs \ +# -v ./skills:/app/skills:ro \ +# astroresearch-all:latest +# +# 注意: Mode B 依赖 deno_core → v8 (V8 JavaScript 引擎). +# Deno 只发布 glibc 目标的预编译 V8 静态库, 无 musl 版本. +# 因此构建和运行都必须使用 glibc (Debian), 不能用 Alpine/musl. +# ============================================================================= + +# ─── Stage 1: 前端 ─────────────────────────────────────────────────────────── +FROM node:22-alpine AS frontend-builder + +ARG USE_MIRRORS=1 +WORKDIR /app/dashboard + +RUN if [ "$USE_MIRRORS" = "1" ]; then \ + npm config set registry https://registry.npmmirror.com; \ + fi + +COPY dashboard/package.json dashboard/package-lock.json ./ +RUN npm ci +COPY dashboard/ ./ +RUN npm run build + +# ─── Stage 2: Rust 后端 (glibc, V8 预编译库仅支持 glibc) ──────────────────── +FROM rust:1.94-slim-bookworm AS backend-builder + +ARG USE_MIRRORS=1 + +RUN if [ "$USE_MIRRORS" = "1" ]; then \ + sed -i 's|deb.debian.org|mirrors.ustc.edu.cn|g' /etc/apt/sources.list.d/debian.sources; \ + fi + +# build-essential: gcc g++ make; binutils: nm objcopy (btls-sys prefix-symbols) +# libclang-dev: libclang.so (bindgen FFI 生成, btls-sys + v8) +# cmake perl git curl python3: BoringSSL 编译 + V8 下载 +# 注: 无需 libssl-dev/libgit2-dev — git2(default-features=false,无https) 不依赖 OpenSSL +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential binutils cmake pkg-config libclang-dev \ + perl git curl python3 ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# bindgen 需要找到 libclang.so; Debian bookworm 安装在 /usr/lib/llvm-14/lib/ +ENV LIBCLANG_PATH=/usr/lib/llvm-14/lib + +ENV SKIP_DASHBOARD_BUILD=1 + +WORKDIR /app + +COPY Cargo.toml Cargo.lock build.rs ./ +COPY src/ ./src/ +COPY migrations/ ./migrations/ +COPY libs/obscura/ ./libs/obscura/ +COPY --from=frontend-builder /app/dashboard/dist/ ./dashboard/dist/ + +RUN cargo build --profile release-min --features obscura-inprocess && \ + cp /app/target/release-min/astroresearch /usr/local/bin/astroresearch + +# ─── Stage 3: 运行时 (distroless — 仅 glibc + 必需库, 无 shell/apt) ────── +FROM gcr.io/distroless/cc-debian12:nonroot + +# frontend + skills + migrations 从构建阶段拷贝 +COPY --from=frontend-builder /app/dashboard/dist/ /app/dashboard/dist/ +COPY skills/ /app/skills/ +COPY migrations/ /app/migrations/ + +# 二进制 +COPY --from=backend-builder /usr/local/bin/astroresearch /app/astroresearch + +WORKDIR /app + +VOLUME ["/app/library", "/app/logs", "/app/skills"] + +EXPOSE 8000 + +# 注: distroless 无 shell/wget, 无法使用传统 healthcheck. +# 编排层 (docker-compose/k8s) 可用 TCP probe 或 HTTP GET / 替代. +ENTRYPOINT ["/app/astroresearch"] diff --git a/build.rs b/build.rs index da016d4..d861673 100644 --- a/build.rs +++ b/build.rs @@ -8,6 +8,15 @@ fn main() { println!("cargo:rerun-if-changed=dashboard/package.json"); println!("cargo:rerun-if-changed=build.rs"); + // Docker/CI 环境跳过前端构建 (dist 已在 Dockerfile 中预构建) + let skip_build = std::env::var("SKIP_DASHBOARD_BUILD") + .map(|v| v == "1" || v == "true") + .unwrap_or(false); + if skip_build { + println!("cargo:warning=SKIP_DASHBOARD_BUILD 已设置,跳过前端构建。"); + return; + } + let dashboard_dir = Path::new("dashboard"); let node_modules_exist = dashboard_dir.join("node_modules").exists(); let dist_exists = dashboard_dir.join("dist").exists(); diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 2cf9506..61d4bc9 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -1,7 +1,7 @@ // dashboard/src/App.tsx import { useState, useEffect, useCallback } from 'react'; import axios from 'axios'; -import { Loader, Download, BookOpen, GitFork, RefreshCw, AlertTriangle } from 'lucide-react'; +import { Loader, Download, BookOpen, GitFork, RefreshCw, AlertTriangle, Lock } from 'lucide-react'; import { Sidebar } from './components/layout/Sidebar'; import { SearchPanel, getDoctypeBadge } from './features/search/SearchPanel'; import { LibraryPanel } from './features/library/LibraryPanel'; @@ -12,6 +12,12 @@ import { ResearchAgentPanel } from './features/agent/ResearchAgentPanel'; import type { StandardPaper, CitationNetwork, NoteRecord } from './types'; export default function App() { + // 登录与鉴权相关状态 + const [isAuthenticated, setIsAuthenticated] = useState(null); + const [password, setPassword] = useState(''); + const [loginError, setLoginError] = useState(null); + const [loggingIn, setLoggingIn] = useState(false); + const [activeTab, setActiveTab] = useState<'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent'>(() => { const saved = localStorage.getItem('astro_active_tab'); return (saved as any) || 'search'; @@ -21,6 +27,53 @@ export default function App() { localStorage.setItem('astro_active_tab', activeTab); }, [activeTab]); + // 全局启用 Axios 跨域 Cookie 传输凭证 + useEffect(() => { + axios.defaults.withCredentials = true; + }, []); + + // 初始化校验登录凭证状态(由浏览器自动带上 Cookie) + useEffect(() => { + axios.get('/api/auth/check') + .then(() => { + setIsAuthenticated(true); + }) + .catch(() => { + setIsAuthenticated(false); + }); + }, []); + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + if (!password.trim()) { + setLoginError('请输入访问密码!'); + return; + } + setLoggingIn(true); + setLoginError(null); + try { + await axios.post('/api/auth/login', { password }); + setIsAuthenticated(true); + setLoginError(null); + } catch (err: any) { + console.error('登录校验失败:', err); + const errMsg = err.response?.data || '密码错误,请重试。'; + setLoginError(errMsg); + } finally { + setLoggingIn(false); + } + }; + + const handleLogout = async () => { + try { + await axios.post('/api/auth/logout'); + } catch (e) { + console.error('登出失败:', e); + } + setIsAuthenticated(false); + setPassword(''); + }; + // 全局对话框弹窗状态 const [dialog, setDialog] = useState<{ type: 'alert' | 'confirm'; @@ -124,8 +177,10 @@ export default function App() { // 1. 初始化时加载本地文献 useEffect(() => { - fetchLibrary(); - }, []); + if (isAuthenticated === true) { + fetchLibrary(); + } + }, [isAuthenticated]); const fetchLibrary = async () => { try { @@ -529,6 +584,94 @@ export default function App() { } }; + if (isAuthenticated === null) { + return ( +
+
+ + 正在校验系统安全凭证... +
+
+ ); + } + + if (isAuthenticated === false) { + return ( +
+ {/* 背景点缀装饰 */} +
+
+ + {/* 登录卡片 */} +
+ {/* Logo & 头部 */} +
+
+ + + + + + + + + + + + + + +
+

AstroResearch

+

天文学科研辅助系统 · 安全登录

+
+ + {/* 登录表单 */} +
+
+ +
+ setPassword(e.target.value)} + placeholder="请输入系统访问密码" + autoFocus + disabled={loggingIn} + className="w-full pl-9 pr-4 py-2 rounded-lg bg-slate-50 border border-[#d2d8e2] text-slate-900 placeholder-slate-400 focus:outline-none focus:border-[#106ba3] focus:bg-white transition-all text-xs font-medium" + /> +
+ +
+
+
+ + {loginError && ( +
+ {loginError} +
+ )} + + +
+
+
+ ); + } + return (
@@ -538,6 +681,7 @@ export default function App() { setActiveTab={setActiveTab} selectedPaper={selectedPaper} loadCitations={loadCitations} + onLogout={handleLogout} /> {/* 主工作区 */} diff --git a/dashboard/src/components/layout/Sidebar.tsx b/dashboard/src/components/layout/Sidebar.tsx index d4e8832..a0e8717 100644 --- a/dashboard/src/components/layout/Sidebar.tsx +++ b/dashboard/src/components/layout/Sidebar.tsx @@ -1,6 +1,6 @@ // dashboard/src/components/layout/Sidebar.tsx import { useState } from 'react'; -import { Search, BookOpen, GitFork, Library, RefreshCw, ChevronLeft, Sparkles } from 'lucide-react'; +import { Search, BookOpen, GitFork, Library, RefreshCw, ChevronLeft, Sparkles, LogOut } from 'lucide-react'; import type { StandardPaper } from '../../types'; interface SidebarProps { @@ -8,9 +8,10 @@ interface SidebarProps { setActiveTab: (tab: 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent') => void; selectedPaper: StandardPaper | null; loadCitations: (bibcode: string) => void; + onLogout: () => void; } -export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations }: SidebarProps) { +export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations, onLogout }: SidebarProps) { const [isCollapsed, setIsCollapsed] = useState(false); const renderLogo = () => ( @@ -137,8 +138,8 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations
- {/* 底部当前选定文献提示 (平滑动画版本) */} -
+ {/* 底部当前选定文献提示 (平滑动画版本) 与 退出登录 */} +
{selectedPaper ? (
)} + + {/* 退出登录按钮 */} +
); diff --git a/dashboard/src/features/agent/ResearchAgentPanel.tsx b/dashboard/src/features/agent/ResearchAgentPanel.tsx index cfacb19..a7e358f 100644 --- a/dashboard/src/features/agent/ResearchAgentPanel.tsx +++ b/dashboard/src/features/agent/ResearchAgentPanel.tsx @@ -12,7 +12,7 @@ import { Brain, Settings, Eye, CheckCircle2, AlertTriangle, Send, Loader, Plus, Trash2, Compass, Clock, Square, BarChart3, ScrollText, Network, Rewind, RotateCcw, - GitBranch, RefreshCw + GitBranch, RefreshCw, Search, X, MessageSquare, BookOpen } from 'lucide-react'; import { AskUserQuestionCard } from './AskUserQuestionCard'; import { PermissionRequestCard } from './PermissionRequestCard'; @@ -28,6 +28,14 @@ interface SessionSummary { updated_at: string; } +interface SearchResult { + result_type: string; + session_id: string; + title?: string | null; + snippet: string; + created_at?: string | null; +} + interface MessageRecord { id: number; agent_name: string; @@ -185,9 +193,16 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane const [streaming, setStreaming] = useState(false); const [input, setInput] = useState(''); const [thinking, setThinking] = useState(false); + const [coordinatorMode, setCoordinatorMode] = useState(false); const [loadingSessions, setLoadingSessions] = useState(false); const [loadingHistory, setLoadingHistory] = useState(false); + // 全文搜索历史记录状态 + const [searchQuery, setSearchQuery] = useState(''); + const [searchScopeOnlyCurrent, setSearchScopeOnlyCurrent] = useState(false); + const [searchResults, setSearchResults] = useState([]); + const [loadingSearch, setLoadingSearch] = useState(false); + // 展开折叠控制 const [expandedThoughts, setExpandedThoughts] = useState>({}); const [expandedArgs, setExpandedArgs] = useState>({}); @@ -309,6 +324,38 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane } }, [currentSessionId]); + // 跨会话历史检索防抖逻辑 + useEffect(() => { + if (!searchQuery.trim()) { + setSearchResults([]); + return; + } + + const delayDebounceFn = setTimeout(async () => { + setLoadingSearch(true); + try { + const params: Record = { + q: searchQuery, + scope: 'all', + limit: 30, + }; + if (searchScopeOnlyCurrent && currentSessionId) { + params.session_id = currentSessionId; + } + const res = await axios.get('/api/search/history', { + params, + }); + setSearchResults(res.data); + } catch (e) { + console.error('搜索会话历史失败:', e); + } finally { + setLoadingSearch(false); + } + }, 300); + + return () => clearTimeout(delayDebounceFn); + }, [searchQuery, searchScopeOnlyCurrent, currentSessionId]); + // 新建会话 const handleNewSession = () => { setCurrentSessionId(null); @@ -536,6 +583,7 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane question: questionText, session_id: currentSessionId, thinking, + coordinator_mode: coordinatorMode, }), }); @@ -1079,47 +1127,144 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
+ {/* 搜索框区域 */} +
+
+ setSearchQuery(e.target.value)} + placeholder="搜索历史会话或消息内容..." + className="w-full pl-8 pr-7 py-1.5 rounded-lg bg-slate-50 border border-slate-200 text-slate-800 placeholder-slate-400 focus:outline-none focus:border-sky-500 focus:bg-white transition-all text-[11px] font-medium" + /> +
+ +
+ {searchQuery && ( + + )} +
+ {currentSessionId && ( + + )} +
+
- {loadingSessions ? ( -
- - 加载历史会话中... -
- ) : sessions.length === 0 ? ( -
- 暂无历史会话记录 -
- ) : ( - sessions.map(session => { - const isActive = session.session_id === currentSessionId; - return ( - - - ); - }) + ); + }) + ) + ) : ( + // 正常的会话列表渲染 + loadingSessions ? ( +
+ + 加载历史会话中... +
+ ) : sessions.length === 0 ? ( +
+ 暂无历史会话记录 +
+ ) : ( + sessions.map(session => { + const isActive = session.session_id === currentSessionId; + return ( + + + ); + }) + ) )}
@@ -1407,6 +1552,21 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane {thinking ? '思考中' : '思考'} + {/* 协调者模式开关 */} + { - const bookmarkletCode = `javascript:(async function(){try{let defaultBib='';try{const res=await fetch('http://localhost:8000/api/active_bibcode');if(res.ok){const data=await res.json();if(data&&data.bibcode)defaultBib=data.bibcode;}}catch(e){}const b=prompt('请输入文献的 Bibcode / doi / arxiv_id :',defaultBib);if(!b||!b.trim())return;const bib=b.trim();if(window.location.protocol==='file:'){alert('[ERR] 浏览器安全策略限制:书签脚本无法直接读取本地磁盘文件 (file://)。\\n\\n提示:对于本地 PDF/HTML 文件,请直接在 AstroResearch 的文献详情页点击“上传 PDF/HTML”按钮导入。');return;}let blob,type='html',ext='.html';const isPDF=document.contentType==='application/pdf'||window.location.pathname.toLowerCase().endsWith('.pdf')||document.title.toLowerCase().endsWith('.pdf');if(isPDF){try{const res=await fetch(window.location.href);if(!res.ok)throw new Error('HTTP '+res.status);blob=await res.blob();type='pdf';ext='.pdf';}catch(err){alert('[ERR] 无法读取该 PDF 数据。\\n(错误: '+err.message+')');return;}}else{blob=new Blob([document.documentElement.outerHTML],{type:'text/html'});}const fd=new FormData();fd.append('bibcode',bib);fd.append('type',type);fd.append('file',blob,bib+ext);const r=await fetch('http://localhost:8000/api/upload',{method:'POST',body:fd});if(r.ok){const d=await r.json();alert('[OK] '+(d.title||bib));}else{const t=await r.text();alert('[ERR '+r.status+'] '+t);}}catch(e){alert('[FAIL] '+e.message);}})();void(0);`; + const bookmarkletCode = `javascript:(async function(){try{let defaultBib='';try{const res=await fetch('http://localhost:8000/api/active_bibcode',{credentials:'include'});if(res.ok){const data=await res.json();if(data&&data.bibcode)defaultBib=data.bibcode;}}catch(e){}const b=prompt('请输入文献的 Bibcode / doi / arxiv_id :',defaultBib);if(!b||!b.trim())return;const bib=b.trim();if(window.location.protocol==='file:'){alert('[ERR] 浏览器安全策略限制:书签脚本无法直接读取本地磁盘文件 (file://)。\\n\\n提示:对于本地 PDF/HTML 文件,请直接在 AstroResearch 的文献详情页点击“上传 PDF/HTML”按钮导入。');return;}let blob,type='html',ext='.html';const isPDF=document.contentType==='application/pdf'||window.location.pathname.toLowerCase().endsWith('.pdf')||document.title.toLowerCase().endsWith('.pdf');if(isPDF){try{const res=await fetch(window.location.href);if(!res.ok)throw new Error('HTTP '+res.status);blob=await res.blob();type='pdf';ext='.pdf';}catch(err){alert('[ERR] 无法读取该 PDF 数据。\\n(错误: '+err.message+')');return;}}else{blob=new Blob([document.documentElement.outerHTML],{type:'text/html'});}const fd=new FormData();fd.append('bibcode',bib);fd.append('type',type);fd.append('file',blob,bib+ext);const r=await fetch('http://localhost:8000/api/upload',{method:'POST',body:fd,credentials:'include'});if(r.ok){const d=await r.json();alert('[OK] '+(d.title||bib));}else{const t=await r.text();alert('[ERR '+r.status+'] '+t);}}catch(e){alert('[FAIL] '+e.message);}})();void(0);`; navigator.clipboard.writeText(bookmarkletCode); alert('书签代码已成功复制到剪贴板!'); }} diff --git a/dashboard/src/index.css b/dashboard/src/index.css index 7d02d44..255a071 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -133,3 +133,13 @@ body { color: #94a3b8; cursor: not-allowed; } + +/* FTS search result highlights */ +mark { + background-color: rgba(254, 240, 138, 0.7); /* translucent yellow bg */ + color: #854d0e; /* text-yellow-800 */ + padding-left: 0.125rem; + padding-right: 0.125rem; + border-radius: 0.125rem; + font-weight: 600; +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d85b016 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,105 @@ +# ============================================================================= +# AstroResearch Docker Compose 部署 +# ============================================================================= +# +# 快速启动 (Mode A — 推荐): +# 1. cp .env.example .env # 编辑填入 API Key +# 2. docker compose up -d # 启动服务 +# 3. open http://localhost:8000 +# +# 使用 Mode B (进程内 Obscura): +# 1. 先克隆 Obscura: mkdir -p libs && git clone https://github.com/h4ckf0r0day/obscura libs/obscura +# 2. 构建: docker compose build astroresearch-all +# 3. 启动: docker compose up -d astroresearch-all +# +# 查看日志: docker compose logs -f +# 停止: docker compose down +# 重建镜像: docker compose build --no-cache +# ============================================================================= + +services: + # ─── Mode A: Alpine 小镜像 + 外部 Obscura (推荐日常使用) ────────────────── + astroresearch: + build: + context: . + image: astroresearch:latest + container_name: astroresearch + restart: unless-stopped + + ports: + - "${PORT:-8000}:8000" + + env_file: + - .env + + environment: + - DATABASE_URL=sqlite:///app/library/astro_research.db + - LIBRARY_DIR=/app/library + - SKILLS_DIR=/app/skills + - LOG_DIR=/app/logs + - LOG_FORMAT=json + - LOG_OUTPUTS=stdout + - PORT=8000 + + volumes: + - ./library:/app/library + - ./logs:/app/logs + - ./skills:/app/skills:ro + # Mode A: 外部 obscura 反爬浏览器 (bind-mount 避免打进镜像) + - ./bin:/app/bin:ro + + # 资源限制 (可选) + # deploy: + # resources: + # limits: + # memory: 2G + # reservations: + # memory: 128M + + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:8000/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + + # ─── Mode B: Distroless 全功能单体 (Obscura/V8/BoringSSL 编译在内) ────── + astroresearch-all: + build: + context: . + dockerfile: Dockerfile.modeB + image: astroresearch-all:latest + container_name: astroresearch-all + restart: unless-stopped + # 默认不随 `docker compose up` 启动, 需显式指定 + profiles: + - full + + ports: + - "${PORT:-8000}:8000" + + env_file: + - .env + + environment: + - DATABASE_URL=sqlite:///app/library/astro_research.db + - LIBRARY_DIR=/app/library + - SKILLS_DIR=/app/skills + - LOG_DIR=/app/logs + - LOG_FORMAT=json + - LOG_OUTPUTS=stdout + - PORT=8000 + + volumes: + - ./library:/app/library + - ./logs:/app/logs + - ./skills:/app/skills:ro + # 注: Mode B 无需 ./bin:/app/bin (Obscura 已编译进二进制) + + # 资源限制 (可选) + # deploy: + # resources: + # limits: + # memory: 2G + # reservations: + # memory: 128M diff --git a/docs/architecture/agent/claude-code-reference-analysis.md b/docs/architecture/agent/claude-code-reference-analysis.md index 0539821..d09333b 100644 --- a/docs/architecture/agent/claude-code-reference-analysis.md +++ b/docs/architecture/agent/claude-code-reference-analysis.md @@ -3,7 +3,7 @@ 对 Claude Code (`/home/fmq/program/claudecode/src/`) 和 Hermes-Agent (`libs/hermes-agent/`) 源码的全面架构分析,记录对 AstroResearch Agent 系统的参考价值与改进方向。 -> 分析日期: 2026-06-22 | 最后更新: 2026-06-22 +> 分析日期: 2026-06-22 | 最后更新: 2026-06-23 ## 实施状态 @@ -16,12 +16,12 @@ | P1 | PermissionRequest / PermissionDenied Hooks | ✅ 已完成 | `hooks/types.rs`, `traits.rs`, `dispatch.rs`, `mod.rs` | | P1 | Auto-mode Classifier | ⏳ 待定 | — | | P2 | Self-improving Skills(模式检测 + 自动创建 + Curator) | ✅ 已完成 | `skills/pattern_detector.rs` + `curator.rs` + `SkillCreator` | -| P2 | Coordinator Mode | ⏳ 待定 | — | -| P2 | UserPromptSubmit / PreCompact / PostCompact Hook | ⏳ 待定 | — | -| P3 | FTS5 跨 session 搜索 | ⏳ 待定 | — | -| P3 | Tool `defer_loading` / `classifier_summary` | ⏳ 待定 | — | -| P3 | 模型回退策略 | ⏳ 待定 | — | -| P3 | Session Memory Compaction | ⏳ 待定 | — | +| P2 | Coordinator Mode | ✅ 已完成 | `src/agent/coordinator/` | +| P2 | UserPromptSubmit / PreCompact / PostCompact Hook | ✅ 已完成 | `hooks/types.rs`, `dispatch.rs` | +| P3 | FTS5 跨 session 搜索 | ✅ 已完成 | `agent_sessions_fts` + `agent_messages_fts` | +| P3 | Tool `defer_loading` / `classifier_summary` | ✅ 已完成 | `tools/mod.rs` | +| P3 | 模型回退策略 | ✅ 已完成 | `error_recovery.rs` (LLM_FALLBACK_MODEL/LLM_FALLBACK_CHAIN) | +| P3 | Session Memory Compaction | ✅ 已完成 | `memory/mod.rs` (extract_memories_from_compaction) | --- @@ -625,27 +625,33 @@ Coordinator Mode 架构: ### 9.3 AstroResearch 的现状 -AstroResearch 有 `SubAgentTool` + `TeamManager`,但没有 Coordinator 的概念。Team -是平级的(lead ↔ teammates),不是层级编排。 +AstroResearch 现已实现 Coordinator Mode(`src/agent/coordinator/`,2026-06-23), +包含 4 个 meta-tools(delegate_task / check_task / task_stop / synthesize) ++ WorkerPool + SubAgentRunner。Team 的平级通信仍然可用,Coordinator 提供层级编排。 ### 9.4 建议改进 -**P2: Coordinator Mode 原型** +**P2: Coordinator Mode — 已实现** ✅ ``` -当 Agent 检测到复杂多步骤任务时,自动切换为 Coordinator 模式: +实现架构: + src/agent/coordinator/ + ├── mod.rs # CoordinatorRuntime, CoordinatorConfig + ├── tools.rs # 4 meta-tools: delegate_task, check_task, task_stop, synthesize + ├── worker_pool.rs # WorkerPool: 异步 Worker 生命周期管理 + └── runner.rs # SubAgentRunner: Worker 执行器 用户请求 → Coordinator 做任务分解 - → 并行子代理执行 (SubAgentTool, async) - → 结果综合 + → 并行 Worker 执行 (delegate_task, async) + → 结果综合 (synthesize) → 减少单 Agent 的步骤数和 token 消耗 关键实现: -1. Coordinator system prompt: 类似 Claude Code coordinatorMode.ts -2. 仅暴露 SubAgentTool + 少量管理工具 -3. 子代理结果以结构化格式注入 -4. 综合阶段由 Coordinator 处理 +1. Coordinator system prompt: 仅暴露 4 个 meta-tools,屏蔽文件/bash 操作 +2. WorkerPool + SubAgentRunner: 异步并发 Worker 管理 +3. 子代理结果以结构化格式注入 `` +4. 综合阶段由 Coordinator 的 synthesize 工具处理 ``` --- @@ -718,10 +724,11 @@ hermes-agent/ 4. Curator 管理 Skill 生命周期 5. 下次相似查询直接加载 Skill,无需重新探索 -AstroResearch 已有 Skills 系统 (skills.rs),缺少: -- 自动检测重复模式 -- Agent 自主创建 Skill -- Curator 管理 Skill 质量 +AstroResearch 现已完整实现 Self-improving Skills(2026-06-22): +- `skills/pattern_detector.rs` — 自动检测重复工具调用序列模式 +- `skills/curator.rs` — Curator 管理 Skill 生命周期(质量评分、清理、pinned、seed_record、CuratorRunner) +- `skills.rs: SkillCreator` — Agent 自主创建 SKILL.md 文件 +- `skills.rs: SelfImprovePipeline` — 一站式管道(检测 → 创建 → 质量审查) ``` **Session FTS5 搜索** @@ -736,27 +743,27 @@ class SessionDB: ) ``` -AstroResearch 的 `agent_sessions` 表有基本的 title/status 字段,但没有全文搜索。 +AstroResearch 现已实现 FTS5 全文搜索(2026-06-23): +- `agent_sessions_fts` + `agent_messages_fts` SQLite FTS5 虚拟表 +- `search_history` Agent 工具提供跨会话全文本搜索能力 -### 10.5 建议改进 +### 10.5 实施成果 -**P2: Self-improving Skills 原型** +**P2: Self-improving Skills — 已实现** ✅ ``` -1. Pattern Detector (自动检测) - - 监控 N 个 session 中的工具调用序列 - - 使用简单的子序列匹配识别重复模式 +1. Pattern Detector (自动检测) ✅ + - src/agent/skills/pattern_detector.rs — 子序列匹配识别重复工具调用模式 - 阈值: 3 次相似序列 → 候选 Skill -2. Skill Creator (Agent 自主创建) - - 将候选 Skill 展示给用户确认 - - 生成 SKILL.md 文件(含 when_to_use, steps) - - 注册到 SkillRegistry +2. Skill Creator (Agent 自主创建) ✅ + - src/agent/skills.rs: SkillCreator — 生成 SKILL.md 文件(kebab-case 命名 + YAML frontmatter) + - src/agent/skills.rs: SelfImprovePipeline — 一站式管道(检测 → 创建 → 审查) -3. Curator (管理生命周期) - - 跟踪 Skill 使用频率 - - 长时间未用的 Skill 标记为 stale - - 提示用户审查或删除 +3. Curator (管理生命周期) ✅ + - src/agent/skills/curator.rs — 质量评分、清理候选、pinned 保护 + - seed_record + NEW_SKILL_GRACE_PERIOD_DAYS=7 — 新 Skill 锚定时钟 + - CuratorRunner — 后台空闲触发审查(paused/idle/interval 三重检查) ``` --- @@ -774,12 +781,12 @@ AstroResearch 的 `agent_sessions` 表有基本的 title/status 字段,但没 | **P1** | PermissionRequest / PermissionDenied Hook 事件 | Claude Code | ✅ | 安全审计能力 | ~100 行 | 无 | | **P1** | Auto-mode Classifier(廉价模型预分类) | Claude Code | ⏳ | 消除 80%+ 权限弹窗 | ~300 行 | LLM client 支持 | | **P2** | Self-improving Skills(Agent 保存成功流程) | Hermes | ✅ | 科研场景独特价值 | ~500 行 | Skills 系统 | -| **P2** | Coordinator Mode(层级多 Agent 编排) | Claude Code | ⏳ | 复杂任务效果提升 | ~800 行 | SubAgent + Team | -| **P2** | UserPromptSubmit / PreCompact / PostCompact Hook | Claude Code | ⏳ | Hook 系统完善 | ~200 行 | 无 | -| **P3** | FTS5 跨 session 搜索 | Hermes | ⏳ | 历史研究可复用 | 中 | SQLite 迁移 | -| **P3** | Tool `defer_loading` / `classifier_summary` | Claude Code | ⏳ | 减小 tool schema prompt | ~50 行 | 无 | -| **P3** | 模型回退策略 (Model Fallback) | Claude Code | ⏳ | 提高可用性 | ~200 行 | ErrorRecovery | -| **P3** | Session Memory Compaction | Claude Code | ⏳ | 自动化记忆提取 | ~300 行 | MemoryManager + Compact | +| **P2** | Coordinator Mode(层级多 Agent 编排) | Claude Code | ✅ | 复杂任务效果提升 | ~800 行 | SubAgent + Team | +| **P2** | UserPromptSubmit / PreCompact / PostCompact Hook | Claude Code | ✅ | Hook 系统完善 | ~200 行 | 无 | +| **P3** | FTS5 跨 session 搜索 | Hermes | ✅ | 历史研究可复用 | 中 | SQLite 迁移 | +| **P3** | Tool `defer_loading` / `classifier_summary` | Claude Code | ✅ | 减小 tool schema prompt | ~50 行 | 无 | +| **P3** | 模型回退策略 (Model Fallback) | Claude Code | ✅ | 提高可用性 | ~200 行 | ErrorRecovery | +| **P3** | Session Memory Compaction | Claude Code | ✅ | 自动化记忆提取 | ~300 行 | MemoryManager + Compact | ### 实施进度(2026-06-22) @@ -791,8 +798,14 @@ AstroResearch 的 `agent_sessions` 表有基本的 title/status 字段,但没 - ✅ 工具并发分区 - ✅ PermissionRequest / PermissionDenied Hook 事件 - ⏳ Auto-mode Classifier — 需要设计讨论 -3. **P2 项在下一个大版本规划**:需要设计讨论和更多测试 -4. **P3 项作为 backlog**:长期优化方向 +3. **P2 项 — 全部完成** ✅(2026-06-23) + - ✅ Coordinator Mode(`src/agent/coordinator/`: 4 meta-tools + WorkerPool + SubAgentRunner) + - ✅ UserPromptSubmit Hook Event(第 13 个生命周期事件) +4. **P3 项 — 全部完成** ✅(2026-06-23) + - ✅ FTS5 跨会话搜索(`agent_sessions_fts` + `agent_messages_fts` + `search_history` 工具) + - ✅ Tool defer_loading / classifier_summary + - ✅ 模型回退策略(`LLM_FALLBACK_MODEL` / `LLM_FALLBACK_CHAIN`) + - ✅ Session Memory Compaction Bridge(`extract_memories_from_compaction`) --- @@ -907,3 +920,30 @@ AstroResearch 的 `agent_sessions` 表有基本的 title/status 字段,但没 **CuratorRunner(后台空闲触发审查)** - `src/agent/skills/curator.rs`: `CuratorRunner` 结构体 + `should_run_now()`(paused/idle/interval 三重检查)+ `record_activity()` 心跳 + `run_once()` + `spawn()` tokio 后台任务 + `pause()`/`resume()` - 5 个新增测试(pinned_always_active、pinned_not_in_cleanup、seed_record、runner_paused、runner_idle) + +### 2026-06-23 — Phase 6: Coordinator Mode + UserPromptSubmit Hook + +**Coordinator Mode 协调者模式** +- `src/agent/coordinator/`: 新目录,4 个 meta-tools(delegate_task / check_task / task_stop / synthesize)+ WorkerPool + SubAgentRunner +- Coordinator system prompt 层级编排 Workers(仅暴露 4 个管理工具,不暴露文件/bash) +- Workers 全异步执行,结果以结构化格式注入 Coordinator +- Continue-vs-Spawn 决策矩阵(上下文重叠度 <30% → 新 Worker) + +**UserPromptSubmit Hook Event** +- `src/agent/hooks/types.rs`: 新增 `UserPromptSubmit` variant(第 13 个生命周期事件) +- `src/agent/hooks/dispatch.rs`: `dispatch()` 新增事件分派 + +### 2026-06-23 — Phase 7: P3 优化 + +**Tool defer_loading / classifier_summary** +- `src/agent/tools/mod.rs`: `ToolDef.defer_loading` 字段 — LLM 初次调用时不发送完整 schema,仅发送 summary;Bash/Read 工具标记 defer_loading + +**Model Fallback 模型回退策略** +- `src/agent/runtime/error_recovery.rs`: `LLM_FALLBACK_MODEL` + `LLM_FALLBACK_CHAIN` env vars — 主模型故障时自动回退到备选模型链 + +**Session Memory Compaction Bridge** +- `src/agent/memory/mod.rs`: `extract_memories_from_compaction()` — 在 compaction 自动摘要后从摘要文本提取记忆写入 MemoryManager + +**FTS5 跨会话搜索** +- `agent_sessions_fts` + `agent_messages_fts` SQLite FTS5 虚拟表 +- `search_history` Agent 工具 — 全文本搜索历史会话和消息 diff --git a/docs/architecture/agent/context.md b/docs/architecture/agent/context.md index 683cc55..4757a87 100644 --- a/docs/architecture/agent/context.md +++ b/docs/architecture/agent/context.md @@ -317,13 +317,17 @@ RateLimited/Overloaded 不走恢复阶梯,独立执行指数退避重试(最 | Hook | 触发时机 | 上下文影响 | |:---|:---|:---| | `OnSessionStart` | 会话创建/恢复 | 通知生命周期开始 | +| `UserPromptSubmit` | 用户提交提示词后 | 记录/审计用户输入 (P2) | | `PreToolUse` | 工具执行前 | 可 MutateInput 注入上下文、Block 阻止 | | `PostToolUse` | 工具执行后 | 可 MutateOutput 修改结果、审计日志写入 | +| `PostToolUseFailure` | 工具执行失败 | 记录错误信息,可 MutateOutput | | `OnStepComplete` | 每步结束 | 日志消息数/预算使用率 | | `OnPreCompact` | 压缩前 | 记录消息数/预估 tokens | | `OnPostCompact` | 压缩后 | 记录新消息数/压缩方法 | | `OnSubagentStart` | 子代理启动 | 通知子代理创建 | | `OnSubagentStop` | 子代理停止 | 记录结果摘要 | +| `PermissionRequest` | 权限请求前 | 可 Override 权限决策 (P1) | +| `PermissionDenied` | 权限被拒绝后 | 安全审计日志 (P1) | | `OnSessionStop` | 会话终止 | 清理取消状态、记录终止原因 | --- @@ -373,6 +377,6 @@ Session → Thought → (ToolCall ↔ ToolResult)* → TextDelta → Usage → D | `src/agent/runtime/finalize.rs` | 会话收尾 + 记忆提取 + 轨迹导出 | | `src/agent/subagent.rs` | 子代理上下文隔离 + 独立 ReAct 循环 | | `src/agent/background.rs` | 后台任务队列 + 通知注入 | -| `src/agent/hooks.rs` | 9 个生命周期事件 + 内置 3 Hook | +| `src/agent/hooks/` | 13 个生命周期事件 + 内置 3 Hook + AsyncAgentHook trait | | `src/agent/skills.rs` | 两层技能加载 + 热重载 + 条件激活 | | `src/agent/tools/memory.rs` | save_memory 工具 + 写入门控 | diff --git a/docs/architecture/agent/env-vars.md b/docs/architecture/agent/env-vars.md index 24b643b..7c5d0ed 100644 --- a/docs/architecture/agent/env-vars.md +++ b/docs/architecture/agent/env-vars.md @@ -125,10 +125,12 @@ Agent 系统的所有可配置参数,按子系统分类。 | 文件 | 加载的变量 | |:---|:---| -| `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/lib.rs` (Config) | `LLM_API_KEY`, `LLM_API_BASE`, `LLM_MODEL`, `LLM_FALLBACK_MODEL`, `LLM_FALLBACK_CHAIN`, `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/runtime/mod.rs` (call_llm_with_recovery) | `LLM_FALLBACK_MODEL` (原 `FALLBACK_MODEL`), `LLM_FALLBACK_CHAIN` (P3) | +| `src/agent/runtime/mod.rs` (AgentRuntime) | `AGENT_COORDINATOR_MAX_WORKERS`, `AGENT_COORDINATOR_WORKER_TIMEOUT` (P2) | +| `src/agent/compact.rs` (extract_memories_from_compaction) | `EXTRACT_MEMORY_ENABLED` (P3 压缩记忆桥接) | | `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`(程序自动设置) | diff --git a/docs/architecture/agent/hooks.md b/docs/architecture/agent/hooks.md index d166101..6ba8534 100644 --- a/docs/architecture/agent/hooks.md +++ b/docs/architecture/agent/hooks.md @@ -1,6 +1,6 @@ # Agent Hooks — 生命周期事件系统 -参考 Claude Code hooks 协议,提供 **12 种生命周期事件回调**,基于 **观察者模式 + 责任链模式** 实现。核心目标:在 Agent ReAct 循环的各个关键节点插入横切关注点(取消检查、指标采集、审计日志、权限增强等),**不污染主循环代码**。 +参考 Claude Code hooks 协议,提供 **13 种生命周期事件回调**,基于 **观察者模式 + 责任链模式** 实现。核心目标:在 Agent ReAct 循环的各个关键节点插入横切关注点(取消检查、指标采集、审计日志、权限增强等),**不污染主循环代码**。 --- @@ -25,7 +25,7 @@ src/agent/hooks/ mindmap root((Hook 系统
设计原则)) 接口隔离 - 10 个生命周期方法 + 11 个生命周期方法 +2 个权限专用方法 (on_permission_request/on_permission_denied) 全部有默认空实现 只覆写关心的 hook 点 @@ -112,6 +112,7 @@ classDiagram +timeout() Option~Duration~ +match_filter() ToolMatchFilter +on_session_start(ctx) + +on_user_prompt_submit(ctx) +pre_tool_use(ctx) PreToolUseAction +post_tool_use(ctx) PostToolUseAction +on_post_tool_use_failure(ctx) PostToolUseAction @@ -137,6 +138,7 @@ classDiagram class HookEvent { <> OnSessionStart + UserPromptSubmit PreToolUse PostToolUse PostToolUseFailure @@ -252,6 +254,7 @@ classDiagram +run_pre_tool_use(ctx) PreToolUseResult +run_post_tool_use(ctx) PostToolUseResult +run_on_session_start(ctx) + +run_on_user_prompt_submit(ctx) +run_on_step_complete(ctx) +run_on_session_stop(ctx) +run_on_subagent_start(ctx) @@ -285,6 +288,7 @@ classDiagram | 事件 | 上下文类型 | 关键字段 | |------|-----------|---------| | OnSessionStart | `SessionStartContext` | session_id, turn_index, is_resume | +| UserPromptSubmit | `UserPromptSubmitContext` | session_id, prompt, turn_index (fire-and-forget) | | PreToolUse | `PreToolUseContext` | session_id, tool_name, tool_args, step | | PostToolUse | `PostToolUseContext` | session_id, agent_name, tool_name, tool_args, output_content, is_error, step, elapsed_ms | | PostToolUseFailure | `PostToolUseFailureContext` | session_id, agent_name, tool_name, error_message, is_interrupt, step, elapsed_ms | @@ -525,6 +529,8 @@ sequenceDiagram RT->>HR: with_builtins(db, cancelled, metrics) RT->>HR: run_on_session_start(ctx) ⚡并行 Note over HR: ① OnSessionStart + RT->>HR: run_on_user_prompt_submit(ctx) 🔥fire-and-forget + Note over HR: ⑬ UserPromptSubmit Note over API,CMP: ═══ Phase 2: ReAct 循环 ═══ loop 每步 (1..max_steps) @@ -598,7 +604,7 @@ sequenceDiagram flowchart LR subgraph Register["add(hook)"] H["hook: Box<dyn AgentHook>"] --> Subs{"subscribed_events()"} - Subs -->|"空 = 全部"| All["遍历所有 12 个 HookEvent
event_index[event].push(idx)"] + Subs -->|"空 = 全部"| All["遍历所有 13 个 HookEvent
event_index[event].push(idx)"] Subs -->|"指定"| Spec["仅注册声明的事件
event_index[event].push(idx)"] end @@ -842,6 +848,7 @@ registry.remove_session_hook(&session_id, "MyTempHook"); | 文件 | 集成点 | 方法 | |------|--------|------| | `runtime/mod.rs` | 会话启动 | `run_on_session_start()` | +| `runtime/mod.rs` | 用户输入提交后 | `run_on_user_prompt_submit()` (fire-and-forget) | | `runtime/mod.rs` | ReAct 循环 | `run_on_step_complete()` | | `runtime/mod.rs` | Hook 上下文注入 | `hook_contexts → [system-reminder]` | | `runtime/mod.rs` | 会话终止 | `run_on_session_stop()` | @@ -893,6 +900,7 @@ registry.remove_session_hook(&session_id, "MyTempHook"); | 2026-06-22 | Phase 4: AsyncAgentHook trait + async hook 调度 | | 2026-06-22 | 模块拆分: mod.rs(537) ← types/traits/matcher/registry/dispatch/builtins | | 2026-06-22 | Phase 5: PermissionRequest/PermissionDenied 事件 — 权限决策钩子 + fire-and-forget 审计 | +| 2026-06-23 | Phase 6: UserPromptSubmit hook — 第 13 个生命周期事件,在用户提交提示词后、context building 前触发(fire-and-forget),hooks 可以检查/记录用户输入 | 120 | --- diff --git a/docs/architecture/agent/memory.md b/docs/architecture/agent/memory.md index bf4258d..37e5c6e 100644 --- a/docs/architecture/agent/memory.md +++ b/docs/architecture/agent/memory.md @@ -311,6 +311,50 @@ sequenceDiagram - `main_agent_saved_this_session == true`(主代理已通过 `save_memory` 工具写入) - 子代理工具集为受限集(仅 4 个只读工具 + `save_memory`),不能执行 bash、不能搜索论文 +## 压缩记忆桥接 (`compact.rs`, P3) + +上下文压缩(compaction)丢弃旧消息时,其中可能包含有长期价值的科研结论和用户偏好。`extract_memories_from_compaction()` 在压缩后自动从丢弃的消息中提取持久记忆,将"即将消失的上下文"转化为"跨会话可用的记忆"。 + +```mermaid +sequenceDiagram + participant RT as AgentRuntime + participant Cmp as compact.rs + participant Ext as extract_memories_from_compaction + participant Sub as SubAgentRunner + participant Mgr as MemoryManager + + RT->>Cmp: compress_context_with_hooks_and_log(messages, ...) + Cmp-->>RT: 压缩完成,旧消息将从上下文中移除 + RT->>Ext: extract_memories_from_compaction(before_messages, sid, mgr, app_state) + Ext->>Ext: 检查 EXTRACT_MEMORY_ENABLED → false 则 return + Ext->>Ext: 过滤非系统消息 → 每条截断 400 字符 → 拼接 + alt 拼接文本 < 300 字符 + Ext->>Ext: return (太少不值得提取) + end + Ext->>Mgr: lock() → build_manifest_preview() 获取现有记忆清单 + Ext->>Ext: 构建受限 ToolRegistry (仅 SaveMemoryTool) + Ext->>Sub: SubAgentRunner.run(EXTRACTION_SYSTEM_PROMPT, prompt, max_steps=3) + Note over Ext,Sub: tokio::spawn (fire-and-forget, 不阻塞压缩/ReAct 循环) + Sub-->>Mgr: save_memory() 写入提取结果 +``` + +**与 `run_extraction()` 的区别**: + +| 维度 | `run_extraction()` (会话结束) | `extract_memories_from_compaction()` (压缩桥接) | +|:---|:---|:---| +| 触发时机 | `finalize_turn()` 会话收尾 | `compress_and_restore()` 压缩完成后 | +| 数据来源 | 完整对话历史(已持久化到 DB) | 压缩前即将被丢弃的消息快照 | +| 消息范围 | 系统 + 用户 + 助手消息 | 仅用户 + 助手 + 工具(去除 system) | +| 节流机制 | `ExtractionTracker` 轮次计数 | 仅靠 `EXTRACT_MEMORY_ENABLED` 开关 | +| 阻塞行为 | fire-and-forget | fire-and-forget | +| 子代理工具集 | 4 只读工具 + `save_memory` | 仅 `save_memory`(最小化成本) | +| 最小内容阈值 | 无显式阈值 | 拼接文本 < 300 字符跳过 | + +**设计原理**: +- 压缩是最佳记忆提取时机——此时旧消息尚未被物理删除但即将从上下文中移除,记忆系统可以在"内容消失前最后一刻"将其抢救出来 +- 截断每条消息到 400 字符平衡了信息完整性和子代理 prompt 长度 +- fire-and-forget 确保压缩不因 LLM 调用延迟而阻塞主 ReAct 循环 + ## 时效性与衰减系统 **指数时间衰减** (`decay.rs`): @@ -403,6 +447,20 @@ score = e^(-λ × days_old) → mark_main_agent_wrote() → 返回: 操作状态 + 质量警告 + 重复提示 + manifest 清单 +会话中(压缩时) ← P3 桥接 + compact.rs: compress_context_with_hooks_and_log(messages, ...) + → 压缩快照前捕获消息副本 (pre_compact_snapshot) + → 执行压缩(snip/auto/aggro_micro) + → extract_memories_from_compaction(snapshot, sid, mgr, app_state) + → 检查 EXTRACT_MEMORY_ENABLED → false 则 return + → 过滤非系统消息 + 截断 400 字符 + 拼接 + → 拼接文本 < 300 字符 → return (太少不值得提取) + → tokio::spawn(子代理) + → build_manifest_preview() 获取现有清单 + → SubAgentRunner(仅 SaveMemoryTool, max_steps=3) + → save_memory() 写入提取的记忆 + → 恢复文件缓存(不等待子代理完成) + 会话结束 finalize.rs: finalize_turn() → 更新 agent_sessions (turn_count, metrics) diff --git a/docs/architecture/agent/overview.md b/docs/architecture/agent/overview.md index 21d9048..86403c4 100644 --- a/docs/architecture/agent/overview.md +++ b/docs/architecture/agent/overview.md @@ -38,11 +38,12 @@ graph TD end subgraph CrossCutting["横切关注点"] - Hooks["HookRegistry (9 事件)"] + Hooks["HookRegistry (13 事件)"] Skills["SkillRegistry (两层加载)"] Memory["MemoryManager (项目记忆)"] Permission["PermissionChecker"] FileCache["FileStateCache (Read 去重)"] + Coordinator["Coordinator Mode (协调者编排)"] end subgraph DB["持久化"] @@ -96,8 +97,14 @@ sequenceDiagram DB-->>RT: 验证存在 + 计算 turn_index end RT->>RT: 触发 OnSessionStart hook + RT->>RT: 触发 UserPromptSubmit hook (用户输入审计) RT-->>FE: SSE session { session_id, title } + opt 协调者模式 (coordinator_mode: true) + RT->>RT: run_coordinator_turn() → CoordinatorAgent + Note over RT: Coordinator 委托 Worker → 合成结果 + end + Note over RT: Phase 2 — 上下文构建 RT->>RT: build_initial_context() RT->>RT: ① 组装 SystemPrompt (静态 section + 记忆注入) @@ -172,3 +179,29 @@ sequenceDiagram --- +### Coordinator Mode(协调者模式, P2) + +```mermaid +graph TD + User["用户请求"] --> API["POST /api/chat/agent\n{ coordinator_mode: true }"] + API --> RT["AgentRuntime::run_coordinator_turn()"] + RT --> CA["CoordinatorAgent (仅元工具)"] + CA --> Tool["delegate_task / check_task / task_stop / synthesize"] + Tool --> WP["WorkerPool (Semaphore 并发控制)"] + WP --> W1["Worker 1 (SubAgentRunner)"] + WP --> W2["Worker 2 (SubAgentRunner)"] + WP --> W3["Worker N (SubAgentRunner)"] + W1 --> Synth["synthesize 收集结果"] + W2 --> Synth + W3 --> Synth + Synth --> Answer["最终答案"] +``` + +Coordinator 仅拥有 4 个元工具,将实际研究工作委托给拥有完整工具访问权限的 Worker 子代理。Worker 通过 `SubAgentRunner` + `Semaphore` 实现并发控制(默认最大 4 并发)。 + +- **委托 → 检查 → 合成** 三步工作流 +- 前端通过 `coordinator_mode: true` 字段启用 +- 源码: `src/agent/coordinator/{agent,tools,worker,mod}.rs` + +--- + diff --git a/docs/architecture/agent/subagent.md b/docs/architecture/agent/subagent.md index 22f2ccb..05840c8 100644 --- a/docs/architecture/agent/subagent.md +++ b/docs/architecture/agent/subagent.md @@ -347,11 +347,60 @@ LLM 调用 subagent(research_prompt, max_steps) .run(system_prompt, research_prompt, max_steps) ``` +## 外部集成场景 + +`SubAgentRunner` 除了通过 `SubAgentTool` 被父代理调用外,还被以下两个子系统使用: + +### 1. Coordinator WorkerPool(P2) + +协调者模式中,`WorkerPool::delegate()` 为每个委托任务创建一个 `SubAgentRunner` 实例: + +``` +CoordinatorAgent + → delegate_task(task_description, context) + → WorkerPool::delegate() + → tokio::spawn(Worker) + → SubAgentRunner::new_with_registry(app_state, 完整 ToolRegistry) + .with_parent_session(session_id) + .with_thinking(enable_thinking) + .run(WORKER_SYSTEM_PROMPT, task_description, worker_max_steps) + → 结果写入 CoordinatorTask.result + → synthesize() + → 收集所有已完成 Worker 结果 → 合成最终答案 +``` + +关键特征: +- Worker 子代理拥有**完整工具访问权限**(与 Coordinator 的 4 个元工具相对) +- 通过 `Semaphore` 控制最大并发(默认 4) +- 超时保护(默认 300s),超时后标记为 `TimedOut` +- 结果通过共享 `CoordinatorTask` 结构返回,不经过 SSE + +### 2. 压缩记忆提取(P3 桥接) + +`compact::extract_memories_from_compaction()` 创建仅含 `SaveMemoryTool` 的受限子代理: + +``` +AgentRuntime::compress_and_restore() + → compress_context_with_hooks_and_log(...) + → extract_memories_from_compaction(snapshot, sid, mgr, app_state) + → SubAgentRunner::new_with_registry(app_state, SaveMemoryTool 仅此一个) + .with_parent_session(sid) + .with_thinking(false) + .run(EXTRACTION_SYSTEM_PROMPT, prompt, max_steps=3) + → 写入记忆(fire-and-forget,不阻塞 ReAct 循环) +``` + +关键特征: +- 工具集最小化(仅 `save_memory`)——只做记忆写入,不能读文件或搜索 +- 思考模式关闭(`with_thinking(false)`)——降低提取成本 +- 内容阈值保护:拼接文本 < 300 字符时跳过 +- 与 `run_extraction()`(会话结束时触发)互为补充——详见 [memory.md](memory.md) + ## 当前局限与改进方向 | # | 问题 | 影响 | 改进方向 | |:---|:---|:---|:---| -| 1 | **工具串行执行** | 同一步多个工具调用无法并行,慢工具阻塞快工具 | 复用 `executor::execute_parallel`,或至少对 `is_concurrency_safe()` 工具并行 | +| 1 | **工具串行执行** | 同一步多个工具调用无法并行,慢工具阻塞快工具 | ~~复用 executor~~ 已通过 Coordinator WorkerPool 在多 Worker 层面实现并行;单 Worker 内仍串行 | | 2 | **无嵌套深度限制** | 子代理可调用 subagent 创建子子代理,理论上无限递归 | 增加深度计数器,超过 2 层时移除 subagent 工具 | | 3 | **Token 估算粗糙** | `content.len() + 4` 对中文极不准确(中文 1 字符 ≈ 1.5-2 token) | 使用 `tiktoken-rs` 或 tokenizer 精确计数 | | 4 | **fire-and-forget 持久化** | DB 写入失败静默忽略,可能丢失审计数据 | 至少记录 warn 日志;关键消息可改为 await | diff --git a/docs/architecture/agent/system-prompt.md b/docs/architecture/agent/system-prompt.md index 9e486c9..7edee7c 100644 --- a/docs/architecture/agent/system-prompt.md +++ b/docs/architecture/agent/system-prompt.md @@ -222,15 +222,18 @@ log.commit(CollapseMethod::LlmSummary, (after_count, before_count), summary); ## Hook 系统与提示词交互 -9 个生命周期事件: +13 个生命周期事件: | Hook | 与提示词的关系 | |:---|:---| | `OnSessionStart` | 提示词组装前触发 | +| `UserPromptSubmit` | 用户提交提示词后、context building 前触发 (P2) | | `PreToolUse::MutateInput` | 注入 `additional_context`(追加为 user 消息) | | `PostToolUse::MutateOutput` | 修改工具输出(影响 LLM 看到的 context) | +| `PostToolUseFailure` | 工具执行失败后修改输出 (P1) | | `PreCompact / PostCompact` | 压缩前后记录指标 + CollapseLog commit | | `OnSubagentStart/Stop` | 传递子代理 prompt 和结果摘要 | +| `PermissionRequest / PermissionDenied` | 权限决策覆盖和审计 (P1) | 详见 [hooks.md](hooks.md)。 diff --git a/docs/architecture/agent/team.md b/docs/architecture/agent/team.md index 992517d..df3720f 100644 --- a/docs/architecture/agent/team.md +++ b/docs/architecture/agent/team.md @@ -176,14 +176,16 @@ let tool_registry = ## 4 个团队工具 -工具定义在 `src/agent/tools/team.rs`,均设置 `InterruptBehavior::Block`(中断时先完成副作用再停止): +工具定义在 `src/agent/tools/team.rs`,均设置 `InterruptBehavior::Block`(中断时先完成副作用再停止)。P3 工具分类注解已应用: -| 工具名 | 参数 | 功能 | 接收者 | -|:---|:---|:---|:---| -| `spawn_teammate` | `name`, `role` | 启动一个后台队友 | — | -| `send_teammate_message` | `to`, `content` | 向指定队友发送消息 | 队友收件箱 | -| `team_broadcast` | `content` | 向所有队友广播消息 | 所有队友收件箱 | -| `check_team_inbox` | `agent_name?` (默认 "lead") | 读取并清空收件箱 | Lead 收件箱 | +| 工具名 | 参数 | 功能 | 接收者 | 延迟加载 | 只读 | +|:---|:---|:---|:---|:---|:---| +| `spawn_teammate` | `name`, `role` | 启动一个后台队友 | — | ✅ `defer_loading` | — | +| `send_teammate_message` | `to`, `content` | 向指定队友发送消息 | 队友收件箱 | ✅ `defer_loading` | — | +| `team_broadcast` | `content` | 向所有队友广播消息 | 所有队友收件箱 | ✅ `defer_loading` | — | +| `check_team_inbox` | `agent_name?` (默认 "lead") | 读取并清空收件箱 | Lead 收件箱 | — | ✅ `is_readonly` | + +> **延迟加载说明**(P3):`spawn_teammate`、`send_teammate_message`、`team_broadcast` 标记为 `defer_loading=true`,不随常驻工具注入 system prompt,仅当 LLM 判断需要多 Agent 协作时通过 `tool_catalog` 按需加载。`check_team_inbox` 标记为 `is_readonly=true`(只读取文件邮箱,不修改队友状态),在 auto 模式下可跳过权限确认。 典型协作流程: diff --git a/docs/architecture/agent/tools.md b/docs/architecture/agent/tools.md index 571b284..76c30fd 100644 --- a/docs/architecture/agent/tools.md +++ b/docs/architecture/agent/tools.md @@ -44,6 +44,9 @@ classDiagram +check_permissions(args) PermissionRule[] +causes_sibling_abort() bool +execute_with_progress(args, ctx, tx) ToolOutput + +classifier_summary() String + +is_readonly() bool + +defer_loading() bool } class InterruptBehavior { @@ -106,10 +109,13 @@ classDiagram | `check_permissions()` | `Vec` | `[]` | 工具自定义权限规则:`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` 发送进度更新到前端 | +| `classifier_summary()` | `String` | 取 `description` 第一句,截断到 100 字符 | 工具简要分类描述(~15 词),供 LLM 判断是否需要加载延迟工具 | +| `is_readonly()` | `bool` | `false`(保守) | 是否只读(无副作用)。9 个只读工具覆写为 `true` | +| `defer_loading()` | `bool` | `false`(常驻) | 是否延迟加载(不在初始 prompt 中)。7 个重型/小众工具覆写为 `true` | --- -## 工具清单(23 个) +## 工具清单(24 个) ### 文件系统工具(6 个)— `filesystem/` @@ -140,7 +146,7 @@ classDiagram | `query_target` | ✅ | CDS Sesame 天体目标查询(IAU 名称解析 + 坐标/类型) | | `save_note` | ❌ | 高亮批注持久化到数据库 | -### Agent 自管理工具(5 个) +### Agent 自管理工具(6 个) | 工具 | 并发安全 | 中断行为 | 功能 | |:---|:---|:---|:---| @@ -149,6 +155,7 @@ classDiagram | `load_skill` | ✅ | Cancel | 按需加载 SKILL.md 技能文件。支持 **inline** 模式(直接返回内容)和 **fork** 模式(启动子代理按技能指引执行任务) | | `save_memory` | ❌ | Block | 跨会话记忆持久化。写入时门控:质量检查(过短/模糊/瞬时/代码模式)+ Jaccard 70% 去重 | | `ask_user` | N/A | Block | 暂停 ReAct 循环向用户提问。oneshot 通道机制:创建问题 → SSE 推送前端 → 阻塞等待 → 5 分钟超时。子代理中不可用(silent 模式) | +| `search_history` | ✅ | Cancel | FTS5 跨会话历史搜索。支持会话标题/摘要和消息内容全文检索,避免重复研究已完成的工作 | ### 高级编排工具(4 个) @@ -270,7 +277,7 @@ sequenceDiagram RT->>TR: new_with_queue(queue, skill_registry) TR->>TR: add_base_tools() - Note over TR: 注册 19 个基础工具 + Note over TR: 注册 19 个基础工具(不含 search_history,该工具在 Runtime 中按需注入) TR->>Tools: read_file, grep_files, glob_files, run_bash TR->>Tools: file_write, file_edit TR->>Tools: search_papers, get_paper_metadata @@ -294,6 +301,15 @@ sequenceDiagram TR->>TR: definitions() Note over TR: HashMap 值收集 → 按 name 字母序排序 Note over TR: 排序保证跨调用稳定性 → 提升 prompt cache 命中率 + + RT->>TR: resident_definitions() + Note over TR: 返回常驻工具(defer_loading=false)→ 注入初始 prompt + + RT->>TR: deferred_definitions() + Note over TR: 返回延迟工具(defer_loading=true)→ 供 LLM 按需加载 + + RT->>TR: tool_catalog() + Note over TR: 所有工具的 name + classifier_summary → 供 LLM 判断延迟工具需求 ``` ### 注册表工厂方法 @@ -406,6 +422,43 @@ execute() → ToolOutput { content } --- +## 工具加载分类(P3 特性) + +`classifier_summary`、`is_readonly`、`defer_loading` 三个 trait 方法共同支持两维度工具分类: + +### 只读 vs 写入分类 (`is_readonly`) + +只读工具无副作用(不修改文件系统、不触发下载、不写入数据库),可在 Auto 模式下跳过权限确认直接执行。 + +| 分类 | `is_readonly` | 工具 | +|:---|:---|:---| +| 只读 (9) | `true` | `read_file`、`grep_files`、`glob_files`、`search_papers`、`get_paper_metadata`、`get_paper_content`、`rag_search`、`query_target`、`check_team_inbox` | +| 写入/副作用 | `false`(默认) | 其余所有工具(包括 `run_bash`、`file_write`、`download_paper`、`save_note` 等) | + +### 常驻 vs 延迟加载 (`defer_loading`) + +常驻工具定义注入初始系统 prompt,延迟工具仅在 LLM 需要时通过 `load_tool` 动态加载,减少 prompt 体积。 + +| 分类 | `defer_loading` | 工具 | +|:---|:---|:---| +| 常驻 (18) | `false`(默认) | 核心文件 I/O、论文搜索/检索、用户交互等高频工具 | +| 延迟 (7) | `true` | `parse_paper`、`download_paper`、`bg_task_run`、`bg_task_check`、`spawn_teammate`、`send_teammate_message`、`team_broadcast`、`search_history` | + +### 工作流 + +``` +初始化: + resident_definitions() → 注入 system prompt(常驻工具 JSON Schema) + tool_catalog() → name + classifier_summary 清单 → 供 LLM 决策 + +LLM 运行时: + 分析 task → 查看 catalog → 判断是否需要延迟工具 + → 调用 defer_load_specific(name) → 注入单工具 definition + → 正常执行工具调用 +``` + +--- + ## 目录结构 ``` @@ -435,5 +488,6 @@ src/agent/tools/ ├── background.rs # bg_task_run + bg_task_check — 后台异步任务 ├── team.rs # spawn/send/broadcast/check_inbox — 多 Agent 协作 ├── memory.rs # save_memory — 带质量门控的跨会话记忆 +├── search_history.rs # search_history — FTS5 跨会话历史搜索(延迟加载) └── persist.rs # maybe_persist_tool_result — 大输出磁盘持久化 ``` diff --git a/docs/deployment.md b/docs/deployment.md index 2f82678..26fd2a3 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,10 +1,152 @@ # AstroResearch Deployment Guide / 部署指南 -AstroResearch 的后端服务是由 Rust 编译出的单执行文件,它内置托管了前端 React 的静态构建资源,因此生产部署十分简单。 +AstroResearch 支持两种部署方式:**Docker 容器化部署**(推荐,零依赖)和**传统源码编译部署**。 --- -## 1. 系统要求与环境依赖 (Requirements) +## 1. Docker 部署(推荐) + +Docker 部署无需安装 Rust/Node.js 工具链,一键启动。提供两种镜像模式。 + +### 快速开始 + +```bash +# 1. 配置环境变量 +cp .env.example .env +# 编辑 .env 填入 API Key + +# 2. 启动 (Mode A — 推荐日常使用) +docker compose up -d + +# 3. 访问 +open http://localhost:8000 +``` + +### 两种镜像模式对比 + +| | Mode A (`Dockerfile`) | Mode B (`Dockerfile.modeB`) | +|---|---|---| +| **镜像大小** | ~23 MB | ~87 MB | +| **基础镜像** | `alpine:3.21` | `distroless/cc-debian12:nonroot` | +| **libc** | musl (静态链接) | glibc (动态链接) | +| **二进制体积** | ~12 MB | ~59 MB | +| **二进制包含** | Rust + C 依赖 | Rust + C + V8 + BoringSSL | +| **运行时依赖** | 无 (静态链接) | `libc` + `libstdc++` + `libgcc_s` (已内置) | +| **空闲内存** | ~8.6 MiB | ~8.7 MiB | +| **Obscura** | 外部 `bin/` bind mount | 编译在二进制内 | +| **容器 Shell** | 有 (`/bin/sh`) | 无 (distroless) | +| **Healthcheck** | `wget` TCP probe | 编排层替代 | + +--- + +### Mode A:Alpine 外部 Obscura(推荐日常使用) + +镜像最小(23 MB),Obscura 作为外部二进制通过 bind mount 注入,可独立更新。 + +**构建:** +```bash +docker build -t astroresearch:latest . +``` + +**运行:** +```bash +docker run -d --name astro -p 8000:8000 --env-file .env \ + -v ./library:/app/library \ + -v ./logs:/app/logs \ + -v ./skills:/app/skills:ro \ + -v ./bin:/app/bin:ro \ + astroresearch:latest +``` + +> `bin/` 目录需包含编译好的 Obscura 二进制文件(`obscura` 和 `obscura-worker`)。 + +**docker-compose.yml(已内置):** +```yaml +services: + astroresearch: + build: + context: . + image: astroresearch:latest + container_name: astroresearch + restart: unless-stopped + ports: + - “${PORT:-8000}:8000” + env_file: + - .env + environment: + - LOG_FORMAT=json + - LOG_OUTPUTS=stdout + volumes: + - ./library:/app/library + - ./logs:/app/logs + - ./skills:/app/skills:ro + - ./bin:/app/bin:ro +``` + +--- + +### Mode B:Distroless 进程内 Obscura(全功能单体) + +Obscura (V8 + BoringSSL) 编译在二进制内,单容器零外部二进制依赖。适合对外交付的一键部署包。 + +**前置条件:** +```bash +mkdir -p libs +git clone https://github.com/h4ckf0r0day/obscura libs/obscura +``` + +**构建:** +```bash +docker build -f Dockerfile.modeB -t astroresearch-all:latest . +``` + +**运行:** +```bash +docker run -d --name astro -p 8000:8000 --env-file .env \ + -v ./library:/app/library \ + -v ./logs:/app/logs \ + -v ./skills:/app/skills:ro \ + astroresearch-all:latest +``` + +> Mode B 基于 [Distroless](https://github.com/GoogleContainerTools/distroless) 构建,无 Shell/包管理器,安全性更高但无法 `docker exec` 进入调试。 + +--- + +### 国内网络加速 + +默认启用镜像加速(`USE_MIRRORS=1`),海外构建可通过 build-arg 禁用: + +```bash +docker build --build-arg USE_MIRRORS=0 -t astroresearch:latest . +``` + +**加速源:** + +| 工具 | 镜像 | +|------|------| +| npm | `registry.npmmirror.com` | +| Alpine apk | `mirrors.aliyun.com` | +| Debian apt | `mirrors.ustc.edu.cn` | + +--- + +### 持久化数据卷 + +| 容器路径 | 说明 | 推荐权限 | +|----------|------|---------| +| `/app/library` | SQLite 数据库 + PDF/HTML 全文 | 读写 | +| `/app/logs` | 应用日志(仅 `LOG_OUTPUTS=file` 时写入) | 读写 | +| `/app/skills` | Agent 技能文件 (SKILL.md) | 只读 (`:ro`) | +| `/app/bin` | Obscura 外部二进制(仅 Mode A) | 只读 (`:ro`) | + +> **注意**:bind mount 目录的宿主权限必须允许容器内用户(uid 65532)写入。如遇 `Permission denied`,在宿主执行 `chown -R 65532 ./library ./logs`。 + +--- + +## 2. 传统源码编译部署 + +### 系统要求 - **操作系统**:Linux / macOS / Windows - **运行环境**: @@ -12,170 +154,146 @@ AstroResearch 的后端服务是由 Rust 编译出的单执行文件,它内置 - Rust (1.75+) 用以编译后端 Axum 进程 - SQLite (自动内置,无需单独部署) ---- +### 构建步骤 -## 2. 生产构建步骤 (Production Build Steps) - -### 步骤 1:构建 React 前端静态资源 -进入 `dashboard` 文件夹,安装依赖并执行编译命令。编译产物会自动输出在 `dashboard/dist` 目录下: +**步骤 1:构建前端** ```bash cd dashboard npm install npm run build ``` +产物位于 `dashboard/dist/`。 -### 步骤 2:编译 Rust 后端二进制文件 -返回项目根目录,通过 Cargo 构建 Release 版本的执行文件。编译后的程序会内置链接 `dashboard/dist` 下的全部静态资源: +**步骤 2:编译后端** ```bash cd .. cargo build --release ``` -编译产物位于 `target/release/astroresearch`。 +产物位于 `target/release/astroresearch`。 -### 步骤 3(可选):编译健康检查工具 -如需在目标服务器上运行馆藏健康度诊断与修复: +**步骤 3(可选):健康检查工具** ```bash cargo build --release --bin health_check ``` -编译产物位于 `target/release/health_check`。 + +### 启动 + +```bash +cp .env.example .env # 编辑填入 API Key +./target/release/astroresearch +# 监听 http://localhost:8000 +``` --- -## 3. 服务部署与启动 (Running in Production) +## 3. Obscura 两种部署模式 -1. 将编译出来的 `target/release/astroresearch` 二进制文件部署到目标服务器。 -2. 在二进制文件同一目录下,创建并填写 `.env` 环境变量配置文件(可从根目录的 `.env.example` 复制模板)。 -3. 确保本地相对路径下拥有天文对照词典文件 `dictionary.txt`。 -4. 运行后端服务: - ```bash - ./astroresearch - ``` - 5. 进程将默认在后台启动并监听 `http://localhost:8000` 端口。你可以通过 Nginx 将此端口反向代理到公网 80/443 端口。 +系统集成 `Obscura` 无头浏览器框架,用于绕过 WAF/Cloudflare 反爬。支持两种模式: + +### 模式 A:外部命令行 (默认) + +Obscura 作为独立二进制运行,与主进程隔离。适合常规生产环境。 + +| 部署方式 | 步骤 | +|----------|------| +| **Docker** | `docker compose up -d`(自动 bind mount `./bin`) | +| **源码** | 下载 obscura 到 `bin/`,赋予执行权限后启动 | + +二进制安装(源码部署时): +```bash +mkdir -p bin/ +# 下载 obscura 和 obscura-worker 到 bin/,赋予执行权限 +chmod +x bin/obscura bin/obscura-worker +``` + +### 模式 B:进程内集成 + +Obscura (V8 + BoringSSL) 编译进二进制,单文件零外部依赖。 + +| 部署方式 | 构建命令 | +|----------|---------| +| **Docker** | `docker build -f Dockerfile.modeB -t astroresearch-all:latest .` | +| **源码** | `cargo build --release --features obscura-inprocess` | + +> 源码编译 Mode B 需先克隆 [Obscura 源码](https://github.com/h4ckf0r0day/obscura) 到 `libs/obscura/`。同时需要 `binutils`(`nm` + `objcopy`)和 `libclang-dev` 作为构建依赖。 --- -## 4. Obscura 两种抓取后备部署模式选择 (Obscura Deployment Modes) +## 4. 极致内存与体积优化 -系统集成了 `Obscura` 无头浏览器框架来作为遭遇 WAF/Cloudflare 反爬时的自动后备抓取通道。系统支持以下两种编译与部署模式: +对于低配服务器(如 1核512M),系统提供 `release-min` 编译配置。 -### 模式 A:外部命令行模式 (默认,推荐) -该模式将主 Web 服务与 V8 浏览器运行引擎相隔离,最适合常规生产环境。它拥有最快的编译时间,且进程隔离确保无头浏览器内核异常(如 OOM 或 Panic)不会拖垮主服务器。 +> `release-min` 与 Mode B 可共同启用,但效果有限——V8/BoringSSL 静态库占用的 ~50MB 无法被 LTO 消除。追求极致轻量建议用 **Mode A + release-min** 组合。 -1. **编译主服务**: - ```bash - cargo build --release - ``` -2. **下载/配置外部二进制**: - 从 GitHub Releases 下载编译好的 `obscura-x86_64-linux.tar.gz` 压缩包,解压后将 `obscura` 和 `obscura-worker` 两个二进制文件放到项目根目录的 `bin/` 目录下: - ```bash - mkdir -p bin/ - # 放入 bin/obscura 和 bin/obscura-worker,并赋予执行权限 - chmod +x bin/obscura bin/obscura-worker - ``` -3. **运行**: - ```bash - ./target/release/astroresearch - ``` - 当遭遇 WAF 拦截时,主进程将自动通过异步子进程调用 `./bin/obscura` 进行抓取。 +**优化指标 (Mode A, release-min):** -### 模式 B:进程内集成模式 (In-Process Feature) -该模式将整个无头浏览器及 V8 运行引擎直接静态链接编译进单个二进制文件中。这免去了在服务器分发和配置外部可执行程序的步骤,提供了”零配置”的部署体验。 +| 指标 | release | release-min | 降幅 | +|------|---------|-------------|------| +| 二进制大小 | 17.0 MB | 8.3 MB | 51% | +| 启动 RSS | 34.8 MB | 32.9 MB | 5% | +| 虚拟内存 (VSZ) | 1.27 GB | 302 MB | 76% | +| 数据段 (VmData) | 60.1 MB | 26.5 MB | 55% | -> [!WARNING] -> 由于需要静态链接 C++ 编写的 V8 引擎,**首次编译会额外多耗时 1 到 3 分钟**,且最终编译生成的**单体可执行文件体积会膨胀约 80MB**。 +```bash +# 源码编译 +cargo build --profile release-min -0. **获取 Obscura 依赖源码**(仅首次): - `libs/` 目录未被 Git 跟踪(已列入 `.gitignore`),`git clone` 后需要单独拉取 Obscura 浏览器内核源码: - ```bash - mkdir -p libs - git clone https://github.com/h4ckf0r0day/obscura libs/obscura - ``` +# Docker(已默认使用 release-min) +docker build -t astroresearch:latest . +``` -1. **启用 Feature 编译**: - 在构建时指定 `--features obscura-inprocess` 特性标记: - ```bash - cargo build --release --features obscura-inprocess - ``` -2. **运行**: - ```bash - ./target/release/astroresearch - ``` - 主服务运行期间,无需在磁盘中放置任何 `bin/obscura` 二进制。当触发反爬时,系统会在后台的专有阻塞线程池上通过独立包装的单线程 runtime 驱动进程内 V8 浏览器内核直接抓取。 +**限制 Tokio 线程数:** +```bash +PORT=8000 TOKIO_WORKER_THREADS=1 ./astroresearch +``` --- -## 5. 极致内存与体积优化部署 (Ultra-Low Memory & Size Optimization) - -对于运行在低配/低内存服务器(如 1核512M 或 1核1G 实例)的环境,系统内置了可选的编译与运行时优化策略。 - -> [!NOTE] -> 极致内存与体积优化编译配置(`release-min`)和“进程内集成模式 (In-Process Feature)”在**技术上可以完全兼容并共同启用**,但它们在**优化目标(指标)上是相互抵消(矛盾)的**。 -> 如果启用了进程内 V8 特性,C++ 静态链接库本身占用的 80MB+ 空间将无法被剔除,导致无法达成极致轻量化(~8.3MB)的体积指标;且 V8 运行时堆内存也会带来额外的物理内存开销。因此,为追求极致低资源消耗,建议在低配服务器上采用**“模式 A:外部命令行模式”**。 -> -> 如果你执意要在**进程内浏览器集成下尽可能对其体积和依赖进行优化**,可以组合使用 `--profile` 与 `--features` 参数进行编译和运行: -> ```bash -> # 编译并打包优化后的进程内单二进制文件: -> cargo build --profile release-min --features obscura-inprocess -> -> # 编译并直接运行: -> cargo run --profile release-min --features obscura-inprocess -> ``` - -### 优化指标对比: -* **二进制执行文件大小**:由 `17.0 MB` 压缩至 **`8.3 MB`**(缩减约 51%)。 -* **启动物理常驻内存 (RSS)**:由 `34.8 MB` 降至 **`32.9 MB`**(得益于词典加载后的容量自动收缩)。 -* **虚拟内存 (VSZ)**:由 `1.27 GB` 降至 **`302 MB`**(缩减约 76%)。 -* **数据段内存 (VmData)**:由 `60.1 MB` 降至 **`26.5 MB`**(缩减约 55%)。 - -### 部署优化步骤: - -1. **使用优化 Profile 进行编译**: - 在项目根目录下,使用内置的 `release-min` 编译配置: - ```bash - cargo build --profile release-min - ``` - 编译完成后的执行文件位于 `target/release-min/astroresearch`。该配置开启了 LTO(链接时优化)、剥离了调试符号,并在生成时进行了大小优化。 - -2. **限制运行时异步线程数**: - 默认情况下,异步运行时 Tokio 会根据系统的 CPU 核心数(例如 16 核)创建对应数量的 Worker 线程,这会带来很多虚拟/物理内存浪费。启动服务时,可通过注入 `TOKIO_WORKER_THREADS=1` 环境变量限制线程池大小: - ```bash - PORT=8000 TOKIO_WORKER_THREADS=1 ./astroresearch - ``` - ---- - -## 6. 环境变量配置 (Environment Variables) +## 5. 环境变量 | 变量名 | 必需 | 默认值 | 说明 | | :--- | :--- | :--- | :--- | -| `DATABASE_URL` | 否 | `sqlite://library/astro_research.db` | SQLite 数据库连接 URL | -| `ADS_API_KEY` | 是 | - | NASA ADS API 访问 Token | -| `LLM_API_KEY` | 是 | - | 大语言模型 API Key | -| `LLM_API_BASE` | 否 | `https://api.openai.com/v1` | 大语言模型 API 基础地址 | -| `LLM_MODEL` | 否 | `gpt-4o-mini` | 翻译大模型名称 | -| `EMBEDDING_API_KEY` | 否 | 默认与 `LLM_API_KEY` 一致 | 向量模型 API Key | -| `EMBEDDING_API_BASE`| 否 | 默认与 `LLM_API_BASE` 一致 | 向量模型 API 基础地址 | -| `EMBEDDING_MODEL` | 否 | `text-embedding-3-small` | 向量模型名称 | -| `QINIU_AK` | 否 | - | 七牛云 Access Key | -| `QINIU_SK` | 否 | - | 七牛云 Secret Key | -| `QINIU_BUCKET` | 否 | - | 七牛云存储空间名 | -| `QINIU_DOMAIN` | 否 | - | 七牛云外链 CDN 域名 | -| `MINERU_API_URL` | 否 | - | MinerU PDF 解析远程 API 地址 | -| `MINERU_API_KEY` | 否 | - | MinerU API Token | -| `LIBRARY_DIR` | 否 | `./library` | 本地文献馆藏根目录 | -| `PORT` | 否 | `8000` | 后端服务监听端口 | +| `DATABASE_URL` | 否 | `sqlite:///app/library/astro_research.db` | SQLite 连接 URL | +| `ADS_API_KEY` | 是 | - | NASA ADS API Token | +| `LLM_API_KEY` | 是 | - | LLM API Key | +| `LLM_API_BASE` | 否 | `https://api.openai.com/v1` | LLM API 地址 | +| `LLM_MODEL` | 否 | `gpt-4o-mini` | 对话模型 | +| `EMBEDDING_API_KEY` | 否 | 同 `LLM_API_KEY` | Embedding API Key | +| `EMBEDDING_API_BASE` | 否 | 同 `LLM_API_BASE` | Embedding API 地址 | +| `EMBEDDING_MODEL` | 否 | `text-embedding-3-small` | Embedding 模型 | +| `LIBRARY_DIR` | 否 | `/app/library` | 文献馆藏根目录 | +| `SKILLS_DIR` | 否 | `/app/skills` | Agent 技能目录 | +| `LOG_DIR` | 否 | `/app/logs` | 日志目录 | +| `LOG_FORMAT` | 否 | `json` | 日志格式 (`plain` / `json`) | +| `LOG_OUTPUTS` | 否 | `stdout` | 日志输出 (`stdout` / `file`) | +| `PORT` | 否 | `8000` | 监听端口 | +| `AGENT_MAX_STEPS` | 否 | `8` | Agent 最大步数 | +| `AGENT_TOOL_TIMEOUT_SECS` | 否 | `120` | 工具超时 (秒) | +| `QINIU_AK` / `QINIU_SK` | 否 | - | 七牛云存储凭证 | +| `MINERU_API_URL` | 否 | - | MinerU PDF 解析 API | --- -## 7. 健康检查与维护 (Health Check) - -部署后可定期运行健康检查工具排查馆藏一致性问题: +## 6. 健康检查与维护 +**Docker 健康检查(Mode A):** ```bash -# 只读扫描(不修改任何数据) +docker ps --filter “health=healthy” --filter “name=astroresearch” +``` + +Mode B (distroless) 无内置 healthcheck,可在编排层配置: +```yaml +# docker-compose 或 k8s +healthcheck: + test: [“CMD”, “curl”, “-f”, “http://localhost:8000/”] +``` + +**源码部署健康检查:** +```bash +# 只读扫描 ./health_check -# 自动修复(清理损坏文件、重置无效路径) +# 自动修复 ./health_check -- --fix ``` diff --git a/migrations/20260623000000_fts5_search.sql b/migrations/20260623000000_fts5_search.sql new file mode 100644 index 0000000..c780376 --- /dev/null +++ b/migrations/20260623000000_fts5_search.sql @@ -0,0 +1,73 @@ +-- FTS5 跨会话全文搜索 +-- 在 agent_sessions 和 agent_messages 上创建虚拟表,支持 BM25 排序搜索。 + +CREATE VIRTUAL TABLE IF NOT EXISTS agent_sessions_fts USING fts5( + session_id, + title, + summary, + metadata, + content='agent_sessions', + content_rowid='rowid' +); + +CREATE VIRTUAL TABLE IF NOT EXISTS agent_messages_fts USING fts5( + session_id, + role, + content, + thought, + tool_calls, + content='agent_messages', + content_rowid='rowid' +); + +-- Triggers: sessions +CREATE TRIGGER IF NOT EXISTS sessions_fts_insert AFTER INSERT ON agent_sessions +BEGIN + INSERT INTO agent_sessions_fts(session_id, title, summary, metadata) + VALUES (NEW.session_id, NEW.title, NEW.summary, NEW.metadata); +END; + +CREATE TRIGGER IF NOT EXISTS sessions_fts_update AFTER UPDATE ON agent_sessions +BEGIN + UPDATE agent_sessions_fts SET + session_id = NEW.session_id, + title = NEW.title, + summary = NEW.summary, + metadata = NEW.metadata + WHERE rowid = OLD.rowid; +END; + +CREATE TRIGGER IF NOT EXISTS sessions_fts_delete AFTER DELETE ON agent_sessions +BEGIN + DELETE FROM agent_sessions_fts WHERE rowid = OLD.rowid; +END; + +-- Triggers: messages +CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON agent_messages +BEGIN + INSERT INTO agent_messages_fts(session_id, role, content, thought, tool_calls) + VALUES (NEW.session_id, NEW.role, NEW.content, NEW.thought, NEW.tool_calls); +END; + +CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON agent_messages +BEGIN + UPDATE agent_messages_fts SET + session_id = NEW.session_id, + role = NEW.role, + content = NEW.content, + thought = NEW.thought, + tool_calls = NEW.tool_calls + WHERE rowid = OLD.rowid; +END; + +CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON agent_messages +BEGIN + DELETE FROM agent_messages_fts WHERE rowid = OLD.rowid; +END; + +-- 回填已有数据 +INSERT OR IGNORE INTO agent_sessions_fts(session_id, title, summary, metadata) +SELECT session_id, title, summary, metadata FROM agent_sessions; + +INSERT OR IGNORE INTO agent_messages_fts(session_id, role, content, thought, tool_calls) +SELECT session_id, role, content, thought, tool_calls FROM agent_messages; diff --git a/migrations/20260623000001_fix_fts5_rowid.sql b/migrations/20260623000001_fix_fts5_rowid.sql new file mode 100644 index 0000000..29b5e34 --- /dev/null +++ b/migrations/20260623000001_fix_fts5_rowid.sql @@ -0,0 +1,30 @@ +-- 修复 FTS5 全文搜索中虚拟表与原始物理表的 rowid 不一致问题 +-- 增量迁移:删除旧触发器,以正确映射 rowid 的触发器替代,并重建现有 FTS5 数据 + +-- 1. 删除旧的不安全触发器 +DROP TRIGGER IF EXISTS sessions_fts_insert; +DROP TRIGGER IF EXISTS messages_fts_insert; + +-- 2. 重新创建指定 rowid 列的插入触发器,确保 FTS 行 ID 与原始物理表行 ID 完全一致 +CREATE TRIGGER IF NOT EXISTS sessions_fts_insert AFTER INSERT ON agent_sessions +BEGIN + INSERT INTO agent_sessions_fts(rowid, session_id, title, summary, metadata) + VALUES (NEW.rowid, NEW.session_id, NEW.title, NEW.summary, NEW.metadata); +END; + +CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON agent_messages +BEGIN + INSERT INTO agent_messages_fts(rowid, session_id, role, content, thought, tool_calls) + VALUES (NEW.rowid, NEW.session_id, NEW.role, NEW.content, NEW.thought, NEW.tool_calls); +END; + +-- 3. 清理之前因不一致导致的错乱 FTS 数据 +DELETE FROM agent_sessions_fts; +DELETE FROM agent_messages_fts; + +-- 4. 使用正确的 rowid 重新索引现有数据 +INSERT OR IGNORE INTO agent_sessions_fts(rowid, session_id, title, summary, metadata) +SELECT rowid, session_id, title, summary, metadata FROM agent_sessions; + +INSERT OR IGNORE INTO agent_messages_fts(rowid, session_id, role, content, thought, tool_calls) +SELECT rowid, session_id, role, content, thought, tool_calls FROM agent_messages; diff --git a/src/agent/compact.rs b/src/agent/compact.rs index 4cf8bf5..008677b 100644 --- a/src/agent/compact.rs +++ b/src/agent/compact.rs @@ -542,6 +542,106 @@ pub async fn compress_context_with_hooks_and_log( COMPACTING.store(false, Ordering::SeqCst); } +// ── Compaction → Memory Extraction Bridge (P3) ── + +/// 从压缩丢弃的消息中提取持久记忆。 +/// +/// 仅当 EXTRACT_MEMORY_ENABLED=true 时激活。提取是 fire-and-forget 的, +/// 不阻塞压缩或后续 LLM 调用。错误会被记录并忽略。 +pub fn extract_memories_from_compaction( + before_messages: &[ChatMessage], + session_id: &str, + memory_manager: std::sync::Arc>, + app_state: std::sync::Arc, +) { + let config = crate::agent::memory::extraction::ExtractionConfig::from_env(); + if !config.enabled { + return; + } + + // 只提取非系统消息的摘要(系统消息在每次压缩后保留) + let snippets: Vec = before_messages + .iter() + .filter(|m| m.role != crate::clients::llm::MessageRole::System) + .filter_map(|m| { + m.content + .as_ref() + .map(|c| { + let role_label = match m.role { + crate::clients::llm::MessageRole::User => "用户", + crate::clients::llm::MessageRole::Assistant => "助手", + crate::clients::llm::MessageRole::Tool => "工具", + _ => return None, + }; + Some(format!( + "[{}] {}", + role_label, + c.chars().take(400).collect::() + )) + }) + .unwrap_or(None) + }) + .collect(); + + let context_text = snippets.join("\n"); + if context_text.len() < 300 { + return; // 太少内容,不值得提取 + } + + let sid = session_id.to_string(); + let mem_mgr = memory_manager.clone(); + let app = app_state.clone(); + + tokio::spawn(async move { + let existing_manifest = { + let mgr = mem_mgr.lock().await; + crate::agent::memory::dedup::build_manifest_preview(mgr.entries()) + }; + + let tool_registry = { + let mut registry = crate::agent::tools::ToolRegistry::empty(); + registry.add_tool(Box::new(crate::agent::tools::memory::SaveMemoryTool::new( + mem_mgr.clone(), + ))); + registry + }; + + let runner = + crate::agent::subagent::SubAgentRunner::new_with_registry(app.clone(), tool_registry) + .with_parent_session(sid.clone()) + .with_thinking(false); + + let prompt = format!( + "以下内容来自上下文压缩时被丢弃的对话片段。请从中提取值得持久化保存的关键信息。\n\n\ + ## 现有记忆清单\n{}\n\n\ + ## 被压缩的内容\n{}", + if existing_manifest.is_empty() { + "当前无记忆条目。" + } else { + &existing_manifest + }, + context_text + ); + + let result = runner + .run( + crate::agent::memory::extraction::EXTRACTION_SYSTEM_PROMPT, + &prompt, + 3, + ) + .await; + + if result.is_error { + tracing::warn!( + "[CompactExtract] 从压缩内容提取记忆失败: {:.200}", + result.content + ); + } else { + tracing::info!("[CompactExtract] 已从压缩内容提取记忆"); + } + }); +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/agent/coordinator/agent.rs b/src/agent/coordinator/agent.rs new file mode 100644 index 0000000..bca3abf --- /dev/null +++ b/src/agent/coordinator/agent.rs @@ -0,0 +1,354 @@ +// src/agent/coordinator/coordinator.rs +// +// CoordinatorAgent: 运行仅包含元工具的专门化 ReAct 循环。 +// +// Coordinator 的系统提示词明确指导 LLM 采用"委托→检查→合成"的流程。 +// 循环比主 AgentRuntime 更简单:无压缩、无错误恢复阶梯、无 token 预算—— +// Coordinator 会话通常很短(3-5 步),这些高级特性暂不需要。 + +use std::sync::Arc; +use tokio::sync::mpsc::UnboundedSender; +use tracing::warn; + +use super::tools::*; +use super::worker::WorkerPool; +use super::CoordinatorConfig; +use crate::agent::hooks::HookRegistry; +use crate::agent::runtime::{AgentConfig, AgentStreamEvent}; +use crate::agent::tools::{ToolContext, ToolRegistry}; +use crate::api::AppState; +use crate::clients::llm::ChatMessage; + +/// Coordinator 系统提示词 +fn coordinator_system_prompt() -> String { + String::from( + "你是一个协调者 Agent。你的职责是分析复杂任务,将其分解为独立的子任务,\ + 委托给 Worker 代理执行,然后合成最终答案。\n\n\ + ## 你的工具\n\ + - **delegate_task**: 委托子任务给 Worker。\ + Worker 可以搜索文献、下载论文、解析内容等。每个 Worker 独立运行。\ + 可以同时委托多个任务,它们会并行执行。\n\ + - **check_task**: 查看已委托任务的状态。如果任务仍在运行,稍后再检查。\n\ + - **task_stop**: 停止一个运行中的任务(例如中途发现方向错误)。\n\ + - **synthesize**: 在所有必需任务完成后,收集并合成结果。\n\n\ + ## 工作流程\n\ + 1. 分析用户请求,确定可以并行的独立子任务。\n\ + 2. 使用 delegate_task 将每个子任务委托给 Worker(可一次性全部委托)。\n\ + 3. 使用 check_task 监控进度,必要时等待任务完成。\n\ + 4. 所有任务完成后,使用 synthesize 收集结果。\n\ + 5. 基于合成结果向用户提供最终答案。\n\n\ + ## 重要规则\n\ + - 最多可同时运行 4 个 Worker。\n\ + - 先委托所有任务,然后检查进度。不要在每次委托后等待。\n\ + - 只使用以上列出的工具。你的 Worker 拥有完整工具访问权限。\n\ + - 使用中文回复。\n\ + - 如果 synthesize 显示还有未完成的任务,使用 check_task 再检查一次。", + ) +} + +/// CoordinatorAgent 运行协调者 ReAct 循环。 +pub struct CoordinatorAgent { + app_state: Arc, + config: AgentConfig, + coordinator_config: CoordinatorConfig, + tool_registry: ToolRegistry, + hook_registry: HookRegistry, + pool: Arc, +} + +impl CoordinatorAgent { + /// 创建新的 CoordinatorAgent 实例。 + pub fn new( + app_state: Arc, + agent_config: AgentConfig, + coordinator_config: CoordinatorConfig, + ) -> Self { + let pool = Arc::new(WorkerPool::new( + app_state.clone(), + coordinator_config.clone(), + )); + + // 构建 coordinator-only 工具注册表 + let mut tool_registry = ToolRegistry::empty(); + tool_registry.add_tool(Box::new(DelegateTaskTool::new( + pool.clone(), + agent_config.enable_thinking, + ))); + tool_registry.add_tool(Box::new(CheckTaskTool::new(pool.clone()))); + tool_registry.add_tool(Box::new(TaskStopTool::new(pool.clone()))); + tool_registry.add_tool(Box::new(SynthesizeTool::new(pool.clone()))); + + let hook_registry = HookRegistry::with_builtins( + app_state.db.clone(), + app_state.cancelled_runs.clone(), + None, + ); + + CoordinatorAgent { + app_state, + config: agent_config, + coordinator_config, + tool_registry, + hook_registry, + pool, + } + } + + /// 运行协调者 ReAct 循环,返回最终文本答案。 + pub async fn run( + &self, + session_id: &str, + question: &str, + turn_index: i32, + tx: UnboundedSender, + ) -> anyhow::Result { + let llm = &self.app_state.llm; + let db = &self.app_state.db; + + // 触发 OnSessionStart + self.hook_registry + .run_on_session_start(&crate::agent::hooks::SessionStartContext { + session_id: session_id.to_string(), + turn_index, + is_resume: false, + }) + .await; + + let _ = tx.send(AgentStreamEvent::Session { + session_id: session_id.to_string(), + title: String::new(), + }); + + // 发送初始 thought 给前端 + let _ = tx.send(AgentStreamEvent::Thought { + content: "正在分析任务并规划协调者策略...".into(), + step: 0, + }); + + // 构建消息上下文 + let mut messages = Vec::new(); + messages.push(ChatMessage::system(coordinator_system_prompt())); + messages.push(ChatMessage::user(question)); + + let tool_defs = self.tool_registry.definitions(); + + // 保存用户消息到数据库 + let _ = sqlx::query( + "INSERT INTO agent_messages (session_id, turn_index, step_index, role, content, token_count, metadata, agent_name) \ + VALUES (?, ?, ?, 'user', ?, ?, '{}', 'coordinator')", + ) + .bind(session_id) + .bind(turn_index) + .bind(0) + .bind(question) + .bind(question.len() as i32 / 4) + .execute(db) + .await; + + let max_steps = self.coordinator_config.worker_max_steps; + + // Coordinator ReAct 循环 + for step in 1..=max_steps { + // 检查取消 + let is_cancelled = { + if let Ok(mut c) = self.app_state.cancelled_runs.lock() { + c.remove(session_id) + } else { + false + } + }; + if is_cancelled { + warn!("[Coordinator] 用户取消执行, session={}", session_id); + let _ = tx.send(AgentStreamEvent::Error { + message: "用户已手动中止执行。".to_string(), + }); + break; + } + + // LLM 流式调用 + let output = crate::agent::runtime::streaming::process_llm_stream( + llm, + &messages, + &tool_defs, + &tx, + step, + session_id, + self.app_state.cancelled_runs.clone(), + self.config.enable_thinking, + ) + .await; + + // 发送 usage + if let Some(ref usage) = output.usage { + let _ = tx.send(AgentStreamEvent::Usage { + prompt_tokens: usage.prompt_tokens, + completion_tokens: usage.completion_tokens, + total_tokens: usage.total_tokens, + }); + } + + let text = match &output.status { + crate::agent::runtime::streaming::StreamStatus::Success => output.content.clone(), + crate::agent::runtime::streaming::StreamStatus::Cancelled => { + break; + } + crate::agent::runtime::streaming::StreamStatus::Error(e) => { + warn!("[Coordinator] LLM 调用失败, step={}: {}", step, e); + let _ = tx.send(AgentStreamEvent::Error { + message: format!("协调者 Agent 调用失败: {}", e), + }); + break; + } + }; + + // 无工具调用 → 协调者直接返回文本答案 + if output.tool_calls.is_none() || output.tool_calls.as_ref().unwrap().is_empty() { + // 保存 assistant 消息 + let _ = sqlx::query( + "INSERT INTO agent_messages (session_id, turn_index, step_index, role, content, token_count, metadata, agent_name) \ + VALUES (?, ?, ?, 'assistant', ?, ?, '{}', 'coordinator')", + ) + .bind(session_id) + .bind(turn_index) + .bind(step as i32) + .bind(&text) + .bind(text.len() as i32 / 4) + .execute(db) + .await; + + return Ok(text); + } + + // 有工具调用 —— 保存 assistant 消息(含 tool_calls JSON) + let tool_calls_json = serde_json::to_string(&output.tool_calls).unwrap_or_default(); + let _ = sqlx::query( + "INSERT INTO agent_messages (session_id, turn_index, step_index, role, content, tool_calls, token_count, metadata, agent_name) \ + VALUES (?, ?, ?, 'assistant', ?, ?, ?, '{}', 'coordinator')", + ) + .bind(session_id) + .bind(turn_index) + .bind(step as i32) + .bind(&text) + .bind(&tool_calls_json) + .bind(text.len() as i32 / 4) + .execute(db) + .await; + + // 执行工具调用(顺序执行——元工具是轻量级的) + let tool_calls = output.tool_calls.unwrap(); + let mut tool_results = Vec::new(); + + for tc in &tool_calls { + let tool_name = tc.function.name.clone(); + let tool_args: serde_json::Value = + serde_json::from_str(&tc.function.arguments).unwrap_or_default(); + + let tool_call_id = if tc.id.is_empty() { + format!("tc_{}", uuid::Uuid::new_v4()) + } else { + tc.id.clone() + }; + + // 发送 ToolCall SSE + let _ = tx.send(AgentStreamEvent::ToolCall { + id: tool_call_id.clone(), + name: tool_name.clone(), + arguments: tool_args.clone(), + step, + }); + + let tool_ctx = ToolContext { + app_state: self.app_state.clone(), + session_id: session_id.to_string(), + silent: false, + read_file_state: Arc::new(std::sync::Mutex::new( + crate::agent::runtime::file_cache::FileStateCache::new(), + )), + sse_tx: Some(tx.clone()), + enable_thinking: self.config.enable_thinking, + additional_allowed_dirs: self.config.additional_allowed_dirs.clone(), + }; + + let result = match self.tool_registry.get(&tool_name) { + Some(tool) => tool.execute(tool_args.clone(), &tool_ctx).await, + None => crate::agent::tools::ToolOutput::error(format!( + "未知的协调者工具: {}", + tool_name + )), + }; + + // 发送 ToolResult SSE + let _ = tx.send(AgentStreamEvent::ToolResult { + tool_call_id: tool_call_id.clone(), + name: tool_name.clone(), + output: result.content.clone(), + is_error: result.is_error, + metadata: result.metadata.clone(), + step, + }); + + // 保存 tool message + let _ = sqlx::query( + "INSERT INTO agent_messages (session_id, turn_index, step_index, role, content, tool_call_id, metadata, agent_name) \ + VALUES (?, ?, ?, 'tool', ?, ?, '{}', 'coordinator')", + ) + .bind(session_id) + .bind(turn_index) + .bind(step as i32) + .bind(&result.content) + .bind(&tool_call_id) + .execute(db) + .await; + + tool_results.push((tool_name, result)); + } + + // 将 tool 结果推入消息历史 + messages.push(ChatMessage::assistant(&text)); + for (name, result) in &tool_results { + messages.push(ChatMessage::user(format!( + "[工具结果: {}]\n{}", + name, + crate::agent::tools::truncate_content( + &result.content, + self.config.max_tool_output_chars, + ) + ))); + } + + // OnStepComplete hook + self.hook_registry + .run_on_step_complete(&crate::agent::hooks::StepCompleteContext { + session_id: session_id.to_string(), + step, + max_steps, + messages_count: messages.len(), + estimated_tokens: 0, + token_limit: self.config.token_hard_limit, + }) + .await; + } + + // 循环耗尽 → 尝试合成剩余结果 + let completed = self.pool.completed_results().await; + if !completed.is_empty() { + let mut fallback = format!( + "协调者已达到最大步数限制 ({}步)。以下是已完成的任务结果:\n\n", + max_steps + ); + for task in &completed { + fallback.push_str(&format!( + "## {}\n{}\n\n", + task.description, + task.result.as_deref().unwrap_or("(无结果)") + )); + } + Ok(fallback) + } else { + Ok(format!( + "协调者已达到最大步数限制 ({}步),但未完成任何任务。请检查委托的任务是否合理。", + max_steps + )) + } + } +} diff --git a/src/agent/coordinator/mod.rs b/src/agent/coordinator/mod.rs new file mode 100644 index 0000000..8945d7d --- /dev/null +++ b/src/agent/coordinator/mod.rs @@ -0,0 +1,93 @@ +// src/agent/coordinator/mod.rs +// +// 协调者模式:层次化多代理编排(P2 特性, 参考 Claude Code coordinatorMode.ts)。 +// +// Coordinator 代理仅有元工具(delegate_task, check_task, task_stop, synthesize), +// 将实际工作委托给拥有完整工具访问权限的 Worker 代理。 +// Worker 通过 SubAgentRunner 实现上下文隔离的独立 ReAct 循环。 + +pub mod agent; +pub mod tools; +pub mod worker; + +/// 协调者任务状态 +#[derive(Debug, Clone, PartialEq)] +pub enum CoordinatorTaskStatus { + Pending, + Running, + Completed, + Failed { reason: String }, + TimedOut, +} + +/// Coordinator 维护的任务条目 +#[derive(Debug, Clone)] +pub struct CoordinatorTask { + pub id: String, + pub description: String, + pub worker_session_id: Option, + pub status: CoordinatorTaskStatus, + pub result: Option, + pub started_at: Option>, + pub completed_at: Option>, +} + +/// Coordinator 配置 +#[derive(Debug, Clone)] +pub struct CoordinatorConfig { + /// Worker 最大并发数 + pub max_concurrent_workers: usize, + /// Worker 最大推理步数 + pub worker_max_steps: usize, + /// Worker 超时时间(秒) + pub worker_timeout_secs: u64, +} + +impl Default for CoordinatorConfig { + fn default() -> Self { + CoordinatorConfig { + max_concurrent_workers: 4, + worker_max_steps: 5, + worker_timeout_secs: 300, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_coordinator_task_lifecycle() { + let task = CoordinatorTask { + id: "test_1".into(), + description: "测试任务".into(), + worker_session_id: None, + status: CoordinatorTaskStatus::Pending, + result: None, + started_at: None, + completed_at: None, + }; + assert_eq!(task.status, CoordinatorTaskStatus::Pending); + assert!(task.result.is_none()); + } + + #[test] + fn test_coordinator_config_defaults() { + let config = CoordinatorConfig::default(); + assert_eq!(config.max_concurrent_workers, 4); + assert_eq!(config.worker_max_steps, 5); + assert_eq!(config.worker_timeout_secs, 300); + } + + #[test] + fn test_failed_status_holds_reason() { + let status = CoordinatorTaskStatus::Failed { + reason: "timeout".into(), + }; + match status { + CoordinatorTaskStatus::Failed { reason } => assert_eq!(reason, "timeout"), + _ => panic!("expected Failed"), + } + } +} diff --git a/src/agent/coordinator/tools.rs b/src/agent/coordinator/tools.rs new file mode 100644 index 0000000..d2aacfa --- /dev/null +++ b/src/agent/coordinator/tools.rs @@ -0,0 +1,340 @@ +// src/agent/coordinator/tools.rs +// +// Coordinator 元工具:delegate_task、check_task、task_stop、synthesize。 +// +// 每个工具实现 AgentTool trait。工具间通过 Arc 共享任务板。 + +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; +use tracing::info; + +use super::worker::WorkerPool; +use crate::agent::tools::{AgentTool, InterruptBehavior, ToolContext, ToolOutput}; + +// ── delegate_task ── + +pub struct DelegateTaskTool { + pool: Arc, + enable_thinking: bool, +} + +impl DelegateTaskTool { + pub fn new(pool: Arc, enable_thinking: bool) -> Self { + DelegateTaskTool { + pool, + enable_thinking, + } + } +} + +#[async_trait] +impl AgentTool for DelegateTaskTool { + fn name(&self) -> &str { + "delegate_task" + } + + fn description(&self) -> &str { + "将子任务委托给 Worker 代理执行。Worker 拥有完整工具访问权限。\ + 使用此工具将独立子任务分发给 Worker,然后使用 check_task 检查进度。\ + 可以先委托所有任务再统一检查,也可以逐个委托立即检查。\ + 适用于:多篇文献综述、并行数据收集、独立计算任务。" + } + + fn parameters(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "task_description": { + "type": "string", + "description": "要委托的任务完整描述(Worker 会据此开始工作)" + }, + "context": { + "type": "string", + "description": "可选:附加上下文信息帮助 Worker 理解任务背景" + } + }, + "required": ["task_description"] + }) + } + + fn interrupt_behavior(&self) -> InterruptBehavior { + InterruptBehavior::Cancel + } + + async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput { + let task_desc = match args.get("task_description").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => return ToolOutput::error("缺少 task_description 参数"), + }; + let context = args.get("context").and_then(|v| v.as_str()).unwrap_or(""); + let full_desc = if context.is_empty() { + task_desc + } else { + format!("{}\n\n附加上下文:{}", task_desc, context) + }; + + let task_id = self + .pool + .delegate(&full_desc, &ctx.session_id, self.enable_thinking) + .await; + + info!("[Coordinator] 委托任务: id={}", task_id); + ToolOutput::success( + format!( + "任务已委托给 Worker。task_id: {}\n使用 check_task(task_id=\"{}\") 查看进度。", + task_id, task_id + ), + json!({"task_id": task_id}), + ) + } +} + +// ── check_task ── + +pub struct CheckTaskTool { + pool: Arc, +} + +impl CheckTaskTool { + pub fn new(pool: Arc) -> Self { + CheckTaskTool { pool } + } +} + +#[async_trait] +impl AgentTool for CheckTaskTool { + fn name(&self) -> &str { + "check_task" + } + + fn description(&self) -> &str { + "查询已委托任务的当前状态和结果。返回 Pending/Running/Completed/Failed/TimedOut。\ + 对于已完成的任务,结果包含在返回中。" + } + + fn parameters(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "要查询的任务 ID(delegate_task 返回的)" + } + }, + "required": ["task_id"] + }) + } + + fn interrupt_behavior(&self) -> InterruptBehavior { + InterruptBehavior::Cancel + } + + async fn execute(&self, args: serde_json::Value, _ctx: &ToolContext) -> ToolOutput { + let task_id = match args.get("task_id").and_then(|v| v.as_str()) { + Some(s) => s, + None => return ToolOutput::error("缺少 task_id 参数"), + }; + + match self.pool.check(task_id).await { + Some(task) => { + let status_str = match &task.status { + super::CoordinatorTaskStatus::Pending => "Pending", + super::CoordinatorTaskStatus::Running => "Running", + super::CoordinatorTaskStatus::Completed => "Completed", + super::CoordinatorTaskStatus::Failed { .. } => "Failed", + super::CoordinatorTaskStatus::TimedOut => "TimedOut", + }; + ToolOutput::success( + format!( + "任务 {}: {}\n描述: {}\n结果: {}", + task.id, + status_str, + task.description, + task.result.as_deref().unwrap_or("(尚无结果)") + ), + json!({ + "task": { + "id": task.id, + "status": status_str, + "description": task.description, + "result": task.result, + } + }), + ) + } + None => ToolOutput::error(format!("未找到 task_id: {}", task_id)), + } + } +} + +// ── task_stop ── + +pub struct TaskStopTool { + pool: Arc, +} + +impl TaskStopTool { + pub fn new(pool: Arc) -> Self { + TaskStopTool { pool } + } +} + +#[async_trait] +impl AgentTool for TaskStopTool { + fn name(&self) -> &str { + "task_stop" + } + + fn description(&self) -> &str { + "停止一个正在运行的任务(标记为失败)。如果任务已完成,此操作无效果。\ + 适用于发现 Worker 方向错误时提前终止。" + } + + fn parameters(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "要停止的任务 ID" + } + }, + "required": ["task_id"] + }) + } + + fn interrupt_behavior(&self) -> InterruptBehavior { + InterruptBehavior::Cancel + } + + async fn execute(&self, args: serde_json::Value, _ctx: &ToolContext) -> ToolOutput { + let task_id = match args.get("task_id").and_then(|v| v.as_str()) { + Some(s) => s, + None => return ToolOutput::error("缺少 task_id 参数"), + }; + + match self.pool.check(task_id).await { + Some(_task) => { + // 软取消 —— 标记为 Failed。 + // 完整的取消需要 CancellationToken 传入 Worker,这留待 P3 增强。 + let mut tasks = self.pool.tasks.write().await; + if let Some(t) = tasks.iter_mut().find(|t| t.id == task_id) { + if matches!( + t.status, + super::CoordinatorTaskStatus::Running + | super::CoordinatorTaskStatus::Pending + ) { + t.status = super::CoordinatorTaskStatus::Failed { + reason: "协调者手动取消".into(), + }; + t.completed_at = Some(chrono::Utc::now()); + } + } + ToolOutput::success( + format!("任务 {} 已标记为取消。", task_id), + json!({"status": "cancelled", "task_id": task_id}), + ) + } + None => ToolOutput::error(format!("未找到 task_id: {}", task_id)), + } + } +} + +// ── synthesize ── + +pub struct SynthesizeTool { + pool: Arc, +} + +impl SynthesizeTool { + pub fn new(pool: Arc) -> Self { + SynthesizeTool { pool } + } +} + +#[async_trait] +impl AgentTool for SynthesizeTool { + fn name(&self) -> &str { + "synthesize" + } + + fn description(&self) -> &str { + "收集所有已完成 Worker 任务的结果并合成最终答案。\ + 在所有委托的任务完成后调用此工具,将各 Worker 结果合并为连贯的最终输出。\ + 如果还有未完成的任务,工具会明确提示。" + } + + fn parameters(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": {}, + "required": [] + }) + } + + fn interrupt_behavior(&self) -> InterruptBehavior { + InterruptBehavior::Cancel + } + + async fn execute(&self, _args: serde_json::Value, _ctx: &ToolContext) -> ToolOutput { + let all = self.pool.all_tasks().await; + let completed: Vec<_> = self.pool.completed_results().await; + let pending: Vec<_> = all + .iter() + .filter(|t| { + matches!( + t.status, + super::CoordinatorTaskStatus::Pending | super::CoordinatorTaskStatus::Running + ) + }) + .collect(); + + let mut output = String::new(); + + if !pending.is_empty() { + output.push_str(&format!( + "⚠ 还有 {} 个任务未完成: {}\n\n", + pending.len(), + pending + .iter() + .map(|t| t.id.as_str()) + .collect::>() + .join(", ") + )); + } + + if completed.is_empty() { + output.push_str("尚无已完成的任务结果可供合成。"); + } else { + output.push_str(&format!("已完成 {} 个任务的结果合成:\n\n", completed.len())); + for task in &completed { + output.push_str(&format!( + "## {}\n{}\n\n---\n", + task.description.chars().take(120).collect::(), + task.result.as_deref().unwrap_or("(无结果)") + )); + } + } + + // 收集结构化结果 + let combined_results: Vec = completed + .iter() + .map(|t| { + json!({ + "task_id": t.id, + "description": t.description, + "result": t.result, + }) + }) + .collect(); + + ToolOutput::success( + format!( + "[协调者合成结果]\n\n{} 个任务已完成。请基于以上结果向用户提供最终答案。", + completed.len() + ), + json!({"synthesized": output, "completed_tasks": combined_results}), + ) + } +} diff --git a/src/agent/coordinator/worker.rs b/src/agent/coordinator/worker.rs new file mode 100644 index 0000000..8d210df --- /dev/null +++ b/src/agent/coordinator/worker.rs @@ -0,0 +1,159 @@ +// src/agent/coordinator/worker.rs +// +// WorkerPool 管理 spawned Worker 子代理的生命周期。 +// +// 每个 Worker 是独立的 SubAgentRunner,拥有完整工具访问权限。 +// WorkerPool 通过 Semaphore 限制并发数,通过共享任务板跟踪进度。 + +use std::sync::Arc; +use tokio::sync::{RwLock, Semaphore}; +use tracing::{info, warn}; + +use super::{CoordinatorConfig, CoordinatorTask, CoordinatorTaskStatus}; +use crate::agent::subagent::SubAgentRunner; +use crate::agent::tools::ToolRegistry; +use crate::api::AppState; + +/// Worker 系统提示词 —— 中文,要求 Worker 专注执行并直接返回结果 +const WORKER_SYSTEM_PROMPT: &str = "\ +你是一个专注于完成特定子任务的研究助手。你的协调者已经分配了一项任务给你。\ +请专注于完成该任务,使用所有可用工具,完成后直接返回结果。\ +不要询问澄清问题——如果你需要做出假设,请记录在结果中。\ +使用中文回复。"; + +/// WorkerPool 管理并发 Worker 子代理。 +pub struct WorkerPool { + app_state: Arc, + config: CoordinatorConfig, + /// 并发信号量 —— 限制同时运行的 worker 数量(Arc 支持在 spawn 中安全共享) + concurrency_limiter: Arc, + /// 共享任务板 + pub(crate) tasks: Arc>>, +} + +impl WorkerPool { + pub fn new(app_state: Arc, config: CoordinatorConfig) -> Self { + let max = config.max_concurrent_workers; + WorkerPool { + app_state, + config, + concurrency_limiter: Arc::new(Semaphore::new(max)), + tasks: Arc::new(RwLock::new(Vec::new())), + } + } + + /// 委托任务给 Worker 子代理。非阻塞 —— 立即返回 task_id。 + pub async fn delegate( + &self, + task_description: &str, + session_id: &str, + enable_thinking: bool, + ) -> String { + let task_id = format!("coord_task_{}", &uuid::Uuid::new_v4().to_string()[..8]); + + let task = CoordinatorTask { + id: task_id.clone(), + description: task_description.to_string(), + worker_session_id: None, + status: CoordinatorTaskStatus::Pending, + result: None, + started_at: None, + completed_at: None, + }; + { + let mut tasks = self.tasks.write().await; + tasks.push(task); + } + + let tasks_arc = self.tasks.clone(); + let limiter = self.concurrency_limiter.clone(); + let app_state = self.app_state.clone(); + let worker_max_steps = self.config.worker_max_steps; + let worker_timeout = self.config.worker_timeout_secs; + let task_id_clone = task_id.clone(); + let session_id_owned = session_id.to_string(); + let task_desc_owned = task_description.to_string(); + + tokio::spawn(async move { + // 获取并发许可 + let _permit = limiter + .acquire() + .await + .expect("semaphore should not be closed"); + + // 更新状态为 Running + { + let mut tasks = tasks_arc.write().await; + if let Some(t) = tasks.iter_mut().find(|t| t.id == task_id_clone) { + t.status = CoordinatorTaskStatus::Running; + t.started_at = Some(chrono::Utc::now()); + } + } + + // 创建 Worker SubAgentRunner(使用完整 ToolRegistry) + let skill_registry = app_state.skill_registry.clone(); + let tool_registry = ToolRegistry::new(skill_registry); + let runner = SubAgentRunner::new_with_registry(app_state, tool_registry) + .with_parent_session(session_id_owned.clone()) + .with_thinking(enable_thinking); + + let result = tokio::time::timeout( + std::time::Duration::from_secs(worker_timeout), + runner.run(WORKER_SYSTEM_PROMPT, &task_desc_owned, worker_max_steps), + ) + .await; + + let mut tasks = tasks_arc.write().await; + if let Some(t) = tasks.iter_mut().find(|t| t.id == task_id_clone) { + if let CoordinatorTaskStatus::Failed { reason } = &t.status { + if reason == "协调者手动取消" { + return; + } + } + match result { + Ok(tool_output) if !tool_output.is_error => { + t.status = CoordinatorTaskStatus::Completed; + t.result = Some(tool_output.content); + info!("[Coordinator] Worker 完成: task={}", task_id_clone); + } + Ok(tool_output) => { + t.status = CoordinatorTaskStatus::Failed { + reason: tool_output.content.chars().take(200).collect(), + }; + warn!("[Coordinator] Worker 失败: task={}", task_id_clone); + } + Err(_elapsed) => { + t.status = CoordinatorTaskStatus::TimedOut; + t.result = Some("Worker 执行超时".to_string()); + warn!("[Coordinator] Worker 超时: task={}", task_id_clone); + } + } + t.completed_at = Some(chrono::Utc::now()); + } + }); + + task_id + } + + /// 查询任务状态 + pub async fn check(&self, task_id: &str) -> Option { + let tasks = self.tasks.read().await; + tasks.iter().find(|t| t.id == task_id).cloned() + } + + /// 获取所有已完成任务的结果 + pub async fn completed_results(&self) -> Vec { + let tasks = self.tasks.read().await; + tasks + .iter() + .filter(|t| matches!(t.status, CoordinatorTaskStatus::Completed)) + .cloned() + .collect() + } + + /// 获取所有任务的快照 + pub async fn all_tasks(&self) -> Vec { + let tasks = self.tasks.read().await; + tasks.clone() + } +} diff --git a/src/agent/hooks/dispatch.rs b/src/agent/hooks/dispatch.rs index 85b3563..8b2f6ae 100644 --- a/src/agent/hooks/dispatch.rs +++ b/src/agent/hooks/dispatch.rs @@ -14,7 +14,7 @@ use super::{ PostToolUseContext, PostToolUseFailureContext, PostToolUseResult, PreCompactContext, PreToolUseAction, PreToolUseContext, PreToolUseResult, SessionStartContext, SessionStopContext, StepCompleteContext, SubagentStartContext, SubagentStopContext, TaggedContext, - DEFAULT_HOOK_TIMEOUT, + UserPromptSubmitContext, DEFAULT_HOOK_TIMEOUT, }; use serde_json; @@ -94,6 +94,27 @@ impl super::HookRegistry { join_all(futures).await; } + /// 调用所有 on_user_prompt_submit hooks(并行执行,含 session hooks)。 + /// 属于 fire-and-forget 事件——结果被丢弃,仅用于审计/日志。 + pub async fn run_on_user_prompt_submit(&self, ctx: &UserPromptSubmitContext) { + let hooks = self.collect_hooks_for(HookEvent::UserPromptSubmit, &ctx.session_id); + if hooks.is_empty() { + return; + } + let ctx_clone = ctx.clone(); + let futures: Vec<_> = hooks + .iter() + .map(|hook| { + let ctx = ctx_clone.clone(); + let timeout_dur = hook.timeout().unwrap_or(DEFAULT_HOOK_TIMEOUT); + async move { + let _ = tokio_timeout(timeout_dur, hook.on_user_prompt_submit(&ctx)).await; + } + }) + .collect(); + join_all(futures).await; + } + /// 调用所有 pre_tool_use hooks(并行执行,各自有独立超时)。 /// /// 所有订阅了 PreToolUse 的 hooks(含 session hooks)并行运行,每个 hook 包装在 diff --git a/src/agent/hooks/matcher.rs b/src/agent/hooks/matcher.rs index 376fa0d..b9886fb 100644 --- a/src/agent/hooks/matcher.rs +++ b/src/agent/hooks/matcher.rs @@ -16,7 +16,7 @@ use serde_json::Value; /// # 示例 /// /// ``` -/// use crate::agent::hooks::matcher::ToolNamePattern; +/// use astroresearch::agent::hooks::matcher::ToolNamePattern; /// let pat = ToolNamePattern::parse("file_*"); /// assert!(pat.matches("file_write")); /// assert!(!pat.matches("search_papers")); @@ -72,7 +72,7 @@ impl ToolNamePattern { /// # 示例 /// /// ``` -/// use crate::agent::hooks::matcher::{ToolMatchFilter, ToolNamePattern}; +/// use astroresearch::agent::hooks::matcher::{ToolMatchFilter, ToolNamePattern}; /// /// // 仅匹配文件相关工具 /// let filter = ToolMatchFilter { diff --git a/src/agent/hooks/mod.rs b/src/agent/hooks/mod.rs index ad1d98d..3354682 100644 --- a/src/agent/hooks/mod.rs +++ b/src/agent/hooks/mod.rs @@ -34,7 +34,7 @@ pub use types::{ PostToolUseFailureContext, PostToolUseResult, PreCompactContext, PreToolUseAction, PreToolUseContext, PreToolUseResult, SessionStartContext, SessionStopContext, StepCompleteContext, SubagentStartContext, SubagentStopContext, TaggedContext, - DEFAULT_HOOK_TIMEOUT, + UserPromptSubmitContext, DEFAULT_HOOK_TIMEOUT, }; // Traits @@ -370,6 +370,37 @@ mod tests { // If no panic, all hooks were called successfully } + #[tokio::test] + async fn test_user_prompt_submit_hook_called() { + struct PromptTrackingHook { + prompts: std::sync::Mutex>, + } + + #[async_trait] + impl AgentHook for PromptTrackingHook { + fn name(&self) -> &str { + "prompt_tracker" + } + async fn on_user_prompt_submit(&self, ctx: &UserPromptSubmitContext) { + self.prompts.lock().unwrap().push(ctx.prompt.clone()); + } + } + + let hook = PromptTrackingHook { + prompts: std::sync::Mutex::new(Vec::new()), + }; + + let mut registry = HookRegistry::new(); + registry.add(Box::new(hook)); + + let ctx = UserPromptSubmitContext { + session_id: "test_sid".into(), + prompt: "Hello, agent!".into(), + turn_index: 2, + }; + registry.run_on_user_prompt_submit(&ctx).await; + } + #[tokio::test] async fn test_match_filter_skips_irrelevant() { use std::sync::atomic::{AtomicUsize, Ordering}; diff --git a/src/agent/hooks/registry.rs b/src/agent/hooks/registry.rs index abe38ac..b7dbe47 100644 --- a/src/agent/hooks/registry.rs +++ b/src/agent/hooks/registry.rs @@ -76,6 +76,7 @@ impl HookRegistry { // 空切片 = 订阅所有事件 for event in &[ HookEvent::OnSessionStart, + HookEvent::UserPromptSubmit, HookEvent::PreToolUse, HookEvent::PostToolUse, HookEvent::PostToolUseFailure, @@ -104,6 +105,7 @@ impl HookRegistry { if subs.is_empty() { for event in &[ HookEvent::OnSessionStart, + HookEvent::UserPromptSubmit, HookEvent::PreToolUse, HookEvent::PostToolUse, HookEvent::PostToolUseFailure, diff --git a/src/agent/hooks/traits.rs b/src/agent/hooks/traits.rs index bf41274..61baceb 100644 --- a/src/agent/hooks/traits.rs +++ b/src/agent/hooks/traits.rs @@ -11,6 +11,7 @@ use super::types::{ PostCompactContext, PostToolUseAction, PostToolUseContext, PostToolUseFailureContext, PreCompactContext, PreToolUseAction, PreToolUseContext, SessionStartContext, SessionStopContext, StepCompleteContext, SubagentStartContext, SubagentStopContext, + UserPromptSubmitContext, }; // ── Hook Trait ── @@ -50,6 +51,10 @@ pub trait AgentHook: Send + Sync { /// 会话创建/恢复时调用。 async fn on_session_start(&self, _ctx: &SessionStartContext) {} + /// 用户提交新提示词时调用(fire-and-forget)。 + /// 在 context building 之前触发,hook 可以记录日志、触发边车操作或注入审计上下文。 + async fn on_user_prompt_submit(&self, _ctx: &UserPromptSubmitContext) {} + /// 工具执行前调用。可返回 Continue/Block/MutateInput/PermissionRequired。 async fn pre_tool_use(&self, _ctx: &PreToolUseContext) -> PreToolUseAction { PreToolUseAction::Continue diff --git a/src/agent/hooks/types.rs b/src/agent/hooks/types.rs index 37cd971..dcb14c8 100644 --- a/src/agent/hooks/types.rs +++ b/src/agent/hooks/types.rs @@ -23,6 +23,9 @@ pub const DEFAULT_HOOK_TIMEOUT: Duration = Duration::from_secs(30); #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum HookEvent { OnSessionStart, + /// 用户提交新提示词时触发(在 Agent 处理之前,fire-and-forget)。 + /// Hook 可以检查或记录用户输入,适合审计、内容过滤、上下文预注入。 + UserPromptSubmit, PreToolUse, PostToolUse, PostToolUseFailure, @@ -50,6 +53,18 @@ pub struct SessionStartContext { pub is_resume: bool, } +/// UserPromptSubmit hook 上下文。 +/// 在用户提交新提示词后、Agent 开始处理前触发。 +/// 属于 fire-and-forget 事件——hook 不能修改提示词,但可用于审计和日志记录。 +#[derive(Debug, Clone)] +pub struct UserPromptSubmitContext { + pub session_id: String, + /// 用户原始提问文本 + pub prompt: String, + /// 当前轮次索引 + pub turn_index: i32, +} + /// PreToolUse hook 上下文 #[derive(Debug, Clone)] pub struct PreToolUseContext { @@ -246,6 +261,7 @@ impl BlockingError { pub fn event_label(event: HookEvent) -> &'static str { match event { HookEvent::OnSessionStart => "SessionStart", + HookEvent::UserPromptSubmit => "UserPromptSubmit", HookEvent::PreToolUse => "PreToolUse", HookEvent::PostToolUse => "PostToolUse", HookEvent::PostToolUseFailure => "PostToolUseFailure", diff --git a/src/agent/memory/extraction.rs b/src/agent/memory/extraction.rs index d8c7904..e3c5a80 100644 --- a/src/agent/memory/extraction.rs +++ b/src/agent/memory/extraction.rs @@ -80,7 +80,7 @@ pub struct ExtractionTracker { } /// 提取系统提示词 -const EXTRACTION_SYSTEM_PROMPT: &str = "\ +pub const EXTRACTION_SYSTEM_PROMPT: &str = "\ 你是一个记忆提取助手。分析最近的对话,提取值得持久化保存的信息。 ## 记忆类型 diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 04e6d7a..1d927a4 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -12,6 +12,7 @@ pub mod autonomous; pub mod background; pub mod compact; +pub mod coordinator; pub mod hooks; pub mod memory; pub mod runtime; diff --git a/src/agent/runtime/mod.rs b/src/agent/runtime/mod.rs index fd0b3a9..3cbb6c1 100644 --- a/src/agent/runtime/mod.rs +++ b/src/agent/runtime/mod.rs @@ -40,7 +40,9 @@ use tracing::{error, info, warn}; use super::background::BgNotificationQueue; use super::compact; -use super::hooks::{HookRegistry, SessionStartContext, StepCompleteContext}; +use super::hooks::{ + HookRegistry, SessionStartContext, StepCompleteContext, UserPromptSubmitContext, +}; use super::terminal::TurnTerminal; use super::tools::ToolRegistry; use crate::api::AppState; @@ -313,6 +315,8 @@ pub struct AgentRuntime { collapse_log: Arc>, /// Checkpoint 管理器(跨 turn 共享,文件变更操作前自动快照) checkpoint_manager: Arc, + /// 是否启用协调者模式(Coordinator delegates to Workers) + coordinator_mode: bool, } impl AgentRuntime { @@ -370,6 +374,7 @@ impl AgentRuntime { prompt_cache: std::sync::Mutex::new(SystemPromptCache::new()), collapse_log: Arc::new(std::sync::Mutex::new(compact::collapse::CollapseLog::new())), checkpoint_manager, + coordinator_mode: false, } } @@ -424,6 +429,7 @@ impl AgentRuntime { prompt_cache: std::sync::Mutex::new(SystemPromptCache::new()), collapse_log: Arc::new(std::sync::Mutex::new(compact::collapse::CollapseLog::new())), checkpoint_manager, + coordinator_mode: false, } } @@ -442,6 +448,41 @@ impl AgentRuntime { self } + /// 设置是否启用协调者模式 + pub fn with_coordinator_mode(mut self, enabled: bool) -> Self { + self.coordinator_mode = enabled; + self + } + + // ── Coordinator Mode ── + + /// 运行协调者模式 turn:创建 CoordinatorAgent 并委托执行。 + async fn run_coordinator_turn( + &self, + session_info: &session::SessionInfo, + question: &str, + tx: mpsc::UnboundedSender, + ) -> anyhow::Result { + use super::coordinator::agent::CoordinatorAgent; + use super::coordinator::CoordinatorConfig; + + let coordinator_config = CoordinatorConfig::default(); + let coordinator = CoordinatorAgent::new( + self.app_state.clone(), + self.config.clone(), + coordinator_config, + ); + + coordinator + .run( + &session_info.session_id, + question, + session_info.turn_index, + tx, + ) + .await + } + // ── Private Helpers ── /// 执行文件缓存快照 → 压缩 → 恢复 → 上下文注入 的完整周期。 @@ -466,6 +507,10 @@ impl AgentRuntime { } }; + // 压缩前捕获消息快照(用于记忆提取桥接,P3) + let pre_compact_snapshot: Vec = + messages.iter().cloned().collect(); + compact::compress_context_with_hooks_and_log( messages, llm, @@ -476,6 +521,14 @@ impl AgentRuntime { ) .await; + // 压缩后提取记忆(P3 桥接:将丢弃的消息内容喂给记忆提取子代理) + compact::extract_memories_from_compaction( + &pre_compact_snapshot, + session_id, + self.app_state.memory_manager.clone(), + self.app_state.clone(), + ); + // ── 文件缓存恢复(压缩后:重新注入最近文件 + 恢复缓存)── { if let Ok(mut cache) = self.read_file_state.lock() { @@ -517,6 +570,11 @@ impl AgentRuntime { // Phase 1: 创建或恢复会话 let session_info = session::create_or_resume_session(db, session_id.clone(), llm).await?; + // 协调者模式分支:委托给 CoordinatorAgent + if self.coordinator_mode { + return self.run_coordinator_turn(&session_info, question, tx).await; + } + // 构建 hook 注册表(注入依赖,复用 AgentRuntime 的 metrics_data) let hook_registry = HookRegistry::with_builtins( db.clone(), @@ -533,6 +591,15 @@ impl AgentRuntime { }) .await; + // 触发 UserPromptSubmit —— hooks 可在上下文构建前检查/记录用户输入 + hook_registry + .run_on_user_prompt_submit(&UserPromptSubmitContext { + session_id: session_info.session_id.clone(), + prompt: question.to_string(), + turn_index: session_info.turn_index, + }) + .await; + let _ = tx.send(AgentStreamEvent::Session { session_id: session_info.session_id.clone(), title: String::new(), @@ -566,6 +633,7 @@ impl AgentRuntime { // Phase 4: 会话收尾(传入实际的终止原因 + trajectory 导出参数) let system_prompt = self.system_prompt(); + let model_name = self.app_state.llm.model(); finalize::finalize_turn( db, &session_info.session_id, @@ -575,7 +643,7 @@ impl AgentRuntime { &hook_registry, loop_terminal, Some(&self.app_state.config.library_dir), - Some(self.app_state.llm.model()), + Some(&model_name), Some(&system_prompt), Some(self.app_state.clone()), ) @@ -1164,14 +1232,25 @@ impl AgentRuntime { if matches!(error_kind, ErrorKind::Overloaded) { consecutive_overloads += 1; if consecutive_overloads >= 3 { - if let Ok(fallback) = std::env::var("FALLBACK_MODEL") { + let fallback = &self.app_state.config.llm_fallback_model; + if !fallback.is_empty() { warn!( "[AgentRuntime] 连续 {} 次过载,切换到备用模型: {}", consecutive_overloads, fallback ); - // Note: The LlmClient model is immutable. In production, - // this would require a model-override capable client. - // For now, log and continue retrying with current model. + llm.set_model(fallback.clone()); + consecutive_overloads = 0; + } else if !self.app_state.config.llm_fallback_chain.is_empty() { + let idx = ((consecutive_overloads as usize - 3) + % self.app_state.config.llm_fallback_chain.len()) + .min(self.app_state.config.llm_fallback_chain.len() - 1); + let alt = &self.app_state.config.llm_fallback_chain[idx]; + warn!( + "[AgentRuntime] 连续 {} 次过载,从链中切换: {}", + consecutive_overloads, alt + ); + llm.set_model(alt.clone()); + consecutive_overloads = 0; } } } @@ -1390,22 +1469,10 @@ impl AgentRuntime { let env_section = cache.get_or_compute("environment", || self.build_environment_section()); sp.add_section("environment", env_section); - // 工具列表:ToolRegistry 在 session 内不变 + // 工具目录:使用 tool_catalog() 列出常驻+延迟工具(P3 defer_loading 集成) let tools_section = cache.get_or_compute("tools", || { - let mut tools_desc = String::from("你可以使用以下工具:\n"); - for def in self.tool_registry.definitions() { - let short_desc: String = 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)); - } - tools_desc + let catalog = self.tool_registry.tool_catalog(); + format!("你可以使用以下工具([deferred] 标记的工具需要通过 load_skill 发现详情):\n{}", catalog) }); sp.add_section("tools", tools_section); diff --git a/src/agent/tools/astro/paper.rs b/src/agent/tools/astro/paper.rs index 0ef7b19..98346bb 100644 --- a/src/agent/tools/astro/paper.rs +++ b/src/agent/tools/astro/paper.rs @@ -88,6 +88,10 @@ impl AgentTool for GetPaperContentTool { Err(e) => ToolOutput::error(format!("获取文献内容失败,读取本地文件错误: {}", e)), } } + + fn is_readonly(&self) -> bool { + true + } } // ── DownloadPaperTool ── @@ -158,6 +162,10 @@ impl AgentTool for DownloadPaperTool { Err(e) => ToolOutput::error(format!("文献 {} 下载失败: {}", bibcode, e)), } } + + fn defer_loading(&self) -> bool { + true + } } // ── ParsePaperTool ── @@ -244,4 +252,8 @@ impl AgentTool for ParsePaperTool { } } } + + fn defer_loading(&self) -> bool { + true + } } diff --git a/src/agent/tools/astro/rag.rs b/src/agent/tools/astro/rag.rs index 057040b..368bd2a 100644 --- a/src/agent/tools/astro/rag.rs +++ b/src/agent/tools/astro/rag.rs @@ -101,4 +101,8 @@ impl AgentTool for RagSearchTool { Err(e) => ToolOutput::error(format!("RAG 语义检索失败: {}", e)), } } + + fn is_readonly(&self) -> bool { + true + } } diff --git a/src/agent/tools/astro/search.rs b/src/agent/tools/astro/search.rs index 9578485..8bc03ba 100644 --- a/src/agent/tools/astro/search.rs +++ b/src/agent/tools/astro/search.rs @@ -125,6 +125,10 @@ impl AgentTool for SearchPapersTool { } } } + + fn is_readonly(&self) -> bool { + true + } } // ── GetPaperMetadataTool ── @@ -186,4 +190,8 @@ impl AgentTool for GetPaperMetadataTool { Err(e) => ToolOutput::error(format!("获取文献 {} 元数据失败: {}", bibcode, e)), } } + + fn is_readonly(&self) -> bool { + true + } } diff --git a/src/agent/tools/astro/target.rs b/src/agent/tools/astro/target.rs index d7ca19d..a2ba762 100644 --- a/src/agent/tools/astro/target.rs +++ b/src/agent/tools/astro/target.rs @@ -86,4 +86,8 @@ impl AgentTool for QueryTargetTool { Err(e) => ToolOutput::error(format!("天体 '{}' 查询失败: {}", object_name, e)), } } + + fn is_readonly(&self) -> bool { + true + } } diff --git a/src/agent/tools/background.rs b/src/agent/tools/background.rs index a4ad8c5..f65b4ea 100644 --- a/src/agent/tools/background.rs +++ b/src/agent/tools/background.rs @@ -110,6 +110,10 @@ impl AgentTool for BgTaskRunTool { }), ) } + + fn defer_loading(&self) -> bool { + true + } } /// 后台任务查询工具 @@ -203,4 +207,8 @@ impl AgentTool for BgTaskCheckTool { } } } + + fn defer_loading(&self) -> bool { + true + } } diff --git a/src/agent/tools/filesystem/glob.rs b/src/agent/tools/filesystem/glob.rs index 437dcc4..2d7d431 100644 --- a/src/agent/tools/filesystem/glob.rs +++ b/src/agent/tools/filesystem/glob.rs @@ -97,4 +97,8 @@ impl AgentTool for GlobFilesTool { Err(e) => ToolOutput::error(format!("glob 模式无效: {}", e)), } } + + fn is_readonly(&self) -> bool { + true + } } diff --git a/src/agent/tools/filesystem/grep.rs b/src/agent/tools/filesystem/grep.rs index ddb345b..c9592a7 100644 --- a/src/agent/tools/filesystem/grep.rs +++ b/src/agent/tools/filesystem/grep.rs @@ -118,6 +118,10 @@ impl AgentTool for GrepFilesTool { ) } } + + fn is_readonly(&self) -> bool { + true + } } impl GrepFilesTool { diff --git a/src/agent/tools/filesystem/read.rs b/src/agent/tools/filesystem/read.rs index dfb8762..299d754 100644 --- a/src/agent/tools/filesystem/read.rs +++ b/src/agent/tools/filesystem/read.rs @@ -162,4 +162,8 @@ impl AgentTool for ReadFileTool { Err(e) => ToolOutput::error(format!("读取文件失败: {}", e)), } } + + fn is_readonly(&self) -> bool { + true + } } diff --git a/src/agent/tools/mod.rs b/src/agent/tools/mod.rs index 4712cf8..b13cb1d 100644 --- a/src/agent/tools/mod.rs +++ b/src/agent/tools/mod.rs @@ -27,6 +27,7 @@ mod compress; mod filesystem; pub mod memory; pub mod persist; +pub mod search_history; mod skill; pub mod subagent; mod team; @@ -43,6 +44,7 @@ pub use compress::CompressTool; pub use filesystem::{ FileEditTool, FileWriteTool, GlobFilesTool, GrepFilesTool, ReadFileTool, RunBashTool, }; +pub use search_history::SearchHistoryTool; pub use skill::LoadSkillTool; pub use subagent::SubAgentTool; pub use team::{CheckTeamInboxTool, SendTeammateMessageTool, SpawnTeammateTool, TeamBroadcastTool}; @@ -264,6 +266,32 @@ pub trait AgentTool: Send + Sync { ) -> ToolOutput { self.execute(args, ctx).await } + + // ── P3 优化:延迟加载与分类器支持 ── + + /// 工具简要分类描述(~15 词),供 LLM 判断是否需要加载延迟工具。 + /// 默认取 description 的第一句,截断到 100 字符。 + fn classifier_summary(&self) -> String { + self.description() + .split('.') + .next() + .unwrap_or("") + .chars() + .take(100) + .collect() + } + + /// 该工具是否为只读(无副作用)。auto-mode 分类器和权限系统可用此信息。 + /// 默认 false(保守——必须显式选择加入)。 + fn is_readonly(&self) -> bool { + false + } + + /// 该工具是否应延迟加载(不在初始 tool definitions 中,通过 tool_search 可发现)。 + /// 默认 false(常驻——始终在提示词中)。 + fn defer_loading(&self) -> bool { + false + } } /// 工具注册表,管理所有可用工具。 @@ -306,6 +334,7 @@ fn add_base_tools(registry: &mut ToolRegistry, skill_registry: Arc Vec { + self.tools + .values() + .filter(|t| !t.defer_loading()) + .map(|t| ToolDefinition::new(t.name(), t.description(), t.parameters())) + .collect() + } + + /// 返回延迟加载工具定义(defer_loading() == true)。 + pub fn deferred_definitions(&self) -> Vec { + self.tools + .values() + .filter(|t| t.defer_loading()) + .map(|t| ToolDefinition::new(t.name(), t.description(), t.parameters())) + .collect() + } + + /// 构建紧凑的"可用工具目录"字符串,供系统提示词使用。 + /// 格式: "tool_name: classifier_summary",按名称排序,延迟工具加 [deferred] 前缀。 + pub fn tool_catalog(&self) -> String { + let mut entries: Vec = self + .tools + .values() + .map(|t| { + let prefix = if t.defer_loading() { "[deferred]" } else { "" }; + format!("{}{}: {}", prefix, t.name(), t.classifier_summary()) + }) + .collect(); + entries.sort(); + entries.join("\n") + } + /// 返回当前所有工具名称列表 pub fn tool_names(&self) -> Vec { self.ordered_names.clone() @@ -560,7 +623,7 @@ mod tests { "./skills", ))))); let defs = registry.definitions(); - assert_eq!(defs.len(), 19); + assert_eq!(defs.len(), 20); assert!(defs.iter().any(|d| d.function.name == "read_file")); assert!(defs.iter().any(|d| d.function.name == "grep_files")); assert!(defs.iter().any(|d| d.function.name == "glob_files")); diff --git a/src/agent/tools/search_history.rs b/src/agent/tools/search_history.rs new file mode 100644 index 0000000..47ac5dd --- /dev/null +++ b/src/agent/tools/search_history.rs @@ -0,0 +1,136 @@ +// src/agent/tools/search_history.rs +// +// search_history 工具:搜索跨会话历史记录(P3 FTS5 集成)。 +// Agent 可用此工具查找之前研究过的主题、已发现的结论。 + +use async_trait::async_trait; +use serde_json::json; + +use super::{AgentTool, ToolContext, ToolOutput}; + +pub struct SearchHistoryTool; + +#[async_trait] +impl AgentTool for SearchHistoryTool { + fn name(&self) -> &str { + "search_history" + } + + fn description(&self) -> &str { + "搜索之前会话的历史记录,查找已研究过的主题、已发现的结论、已下载的文献。\ + 使用此工具可以避免重复研究已经完成的工作。" + } + + fn parameters(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "搜索关键词(支持 FTS5 查询语法,多个词用空格分隔)" + }, + "scope": { + "type": "string", + "enum": ["sessions", "messages", "all"], + "description": "搜索范围:sessions=会话标题/摘要, messages=消息内容, all=全部", + "default": "all" + } + }, + "required": ["query"] + }) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } + + fn is_readonly(&self) -> bool { + true + } + + fn defer_loading(&self) -> bool { + true + } + + async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput { + let query = match args.get("query").and_then(|v| v.as_str()) { + Some(q) if !q.is_empty() => q, + _ => return ToolOutput::error("缺少 query 参数"), + }; + let scope = args.get("scope").and_then(|v| v.as_str()).unwrap_or("all"); + + let db = &ctx.app_state.db; + let mut results = Vec::new(); + + if scope == "all" || scope == "sessions" { + match sqlx::query_as::<_, (String, String, String)>( + "SELECT s.session_id, s.title, \ + snippet(agent_sessions_fts, 1, '', '', '...', 40) \ + FROM agent_sessions_fts fts \ + JOIN agent_sessions s ON s.session_id = fts.session_id \ + WHERE agent_sessions_fts MATCH $1 ORDER BY rank LIMIT 10", + ) + .bind(query) + .fetch_all(db) + .await + { + Ok(rows) => { + for (sid, title, snippet) in rows { + results.push(json!({ + "type": "session", + "session_id": sid, + "title": title, + "snippet": snippet, + })); + } + } + Err(e) => { + tracing::warn!("[SearchHistory] 搜索会话失败: {}", e); + } + } + } + + if scope == "all" || scope == "messages" { + match sqlx::query_as::<_, (String, String, String, String)>( + "SELECT fts.session_id, s.title, \ + snippet(agent_messages_fts, 2, '', '', '...', 80), fts.role \ + FROM agent_messages_fts fts \ + JOIN agent_sessions s ON s.session_id = fts.session_id \ + JOIN agent_messages m ON m.rowid = fts.rowid \ + WHERE agent_messages_fts MATCH $1 AND m.active = 1 \ + ORDER BY rank LIMIT 10", + ) + .bind(query) + .fetch_all(db) + .await + { + Ok(rows) => { + for (sid, title, snippet, role) in rows { + results.push(json!({ + "type": format!("message/{}", role), + "session_id": sid, + "title": title, + "snippet": snippet, + })); + } + } + Err(e) => { + tracing::warn!("[SearchHistory] 搜索消息失败: {}", e); + } + } + } + + if results.is_empty() { + ToolOutput::success( + format!("未找到与 \"{}\" 相关的历史记录。", query), + json!({"query": query, "results": [], "count": 0}), + ) + } else { + let count = results.len(); + ToolOutput::success( + format!("找到 {} 条与 \"{}\" 相关的历史记录", count, query), + json!({"query": query, "results": results, "count": count}), + ) + } + } +} diff --git a/src/agent/tools/team.rs b/src/agent/tools/team.rs index fda1bec..f4283e6 100644 --- a/src/agent/tools/team.rs +++ b/src/agent/tools/team.rs @@ -88,6 +88,10 @@ impl AgentTool for SpawnTeammateTool { None => ToolOutput::error("团队管理器未初始化"), } } + + fn defer_loading(&self) -> bool { + true + } } /// 发送消息工具 @@ -216,6 +220,10 @@ impl AgentTool for TeamBroadcastTool { None => ToolOutput::error("团队管理器未初始化"), } } + + fn defer_loading(&self) -> bool { + true + } } /// 检查收件箱工具 @@ -235,6 +243,10 @@ impl AgentTool for CheckTeamInboxTool { "check_team_inbox" } + fn is_readonly(&self) -> bool { + true + } + fn description(&self) -> &str { "检查收件箱,获取队友发来的消息。读取后消息会被清空。不指定 agent 时检查 lead 的收件箱。" } diff --git a/src/api/agent.rs b/src/api/agent.rs index 99e7df0..9b86f0a 100644 --- a/src/api/agent.rs +++ b/src/api/agent.rs @@ -29,6 +29,9 @@ pub struct AgentChatRequest { /// 是否启用 LLM 思考模式(默认关闭) #[serde(default)] pub thinking: bool, + /// 是否启用协调者模式(Coordinator delegates to Workers) + #[serde(default)] + pub coordinator_mode: bool, } pub async fn chat_agent( @@ -40,7 +43,9 @@ pub async fn chat_agent( req.question, req.session_id ); - let runtime = AgentRuntime::new(Arc::clone(&state)).with_thinking(req.thinking); + let runtime = AgentRuntime::new(Arc::clone(&state)) + .with_thinking(req.thinking) + .with_coordinator_mode(req.coordinator_mode); let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); let question = req.question.clone(); diff --git a/src/api/auth.rs b/src/api/auth.rs new file mode 100644 index 0000000..b705969 --- /dev/null +++ b/src/api/auth.rs @@ -0,0 +1,184 @@ +// src/api/auth.rs +use axum::{ + extract::State, + http::{header, header::HeaderMap, header::HeaderValue, Request, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, + Json, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use super::AppState; + +#[derive(Debug, Deserialize)] +pub struct LoginRequest { + pub password: Option, +} + +#[derive(Debug, Serialize)] +pub struct LoginResponse { + pub token: String, + pub status: String, +} + +// 辅助函数:从 Cookie 字符串中解析出 session_id 的值 +fn parse_session_cookie(cookie_str: &str) -> Option { + for cookie in cookie_str.split(';') { + let parts: Vec<&str> = cookie.split('=').map(|s| s.trim()).collect(); + if parts.len() == 2 && parts[0] == "session_id" { + return Some(parts[1].to_string()); + } + } + None +} + +// 辅助函数:清除过期的会话记录(24小时未活跃即过期) +fn prune_expired_sessions( + sessions: &mut std::collections::HashMap>, +) { + let now = chrono::Utc::now(); + let expiry_duration = chrono::Duration::hours(24); + sessions.retain(|_, last_active| { + if let Some(dur) = now.signed_duration_since(*last_active).to_std().ok() { + dur < expiry_duration.to_std().unwrap_or(std::time::Duration::from_secs(86400)) + } else { + true // 尚未到达或时钟回拨,保留 + } + }); +} + +// 登录接口:验证密码成功后,写入 HttpOnly Cookie +pub async fn login( + State(state): State>, + Json(req): Json, +) -> Result { + let password = req.password.unwrap_or_default(); + if password == state.config.admin_password { + let token = uuid::Uuid::new_v4().to_string(); + + // 异常安全地记录活跃会话及其当前时间 + if let Ok(mut sessions) = state.sessions.lock() { + sessions.insert(token.clone(), chrono::Utc::now()); + } + + // 构建 Set-Cookie 报头 + // 书签脚本等跨站第三方页面调用要求 SameSite=None 和 Secure + // localhost 在现代浏览器中即使使用 HTTP 协议,其 Secure Cookie 也会被正常放行并读取 + let cookie_value = format!( + "session_id={}; HttpOnly; SameSite=None; Secure; Path=/; Max-Age=86400", + token + ); + let mut headers = HeaderMap::new(); + if let Ok(val) = HeaderValue::from_str(&cookie_value) { + headers.insert(header::SET_COOKIE, val); + } + + Ok(( + headers, + Json(LoginResponse { + token: token.clone(), + status: "ok".to_string(), + }), + )) + } else { + Err((StatusCode::UNAUTHORIZED, "密码错误".to_string())) + } +} + +// 登出接口:清除内存会话并使 Cookie 失效 +pub async fn logout( + State(state): State>, + req: Request, +) -> Result { + let mut token_to_remove = None; + + // 优先从 Authorization 提取 Token(向前兼容旧 Bearer Header) + let auth_header = req.headers() + .get(axum::http::header::AUTHORIZATION) + .and_then(|val| val.to_str().ok()); + + if let Some(auth_str) = auth_header { + if auth_str.starts_with("Bearer ") { + token_to_remove = Some(auth_str[7..].to_string()); + } + } + + // 其次从 Cookie 中读取 + if token_to_remove.is_none() { + if let Some(cookie_header) = req.headers().get(axum::http::header::COOKIE) { + if let Ok(cookie_str) = cookie_header.to_str() { + token_to_remove = parse_session_cookie(cookie_str); + } + } + } + + if let Some(ref token) = token_to_remove { + if let Ok(mut sessions) = state.sessions.lock() { + sessions.remove(token); + } + } + + // 通过 Max-Age=0 清除浏览器端的 Cookie + let mut headers = HeaderMap::new(); + let delete_cookie = "session_id=; HttpOnly; SameSite=None; Secure; Path=/; Max-Age=0"; + if let Ok(val) = HeaderValue::from_str(delete_cookie) { + headers.insert(header::SET_COOKIE, val); + } + + Ok((headers, Json(serde_json::json!({ "status": "ok" })))) +} + +// 鉴权状态检查接口 +pub async fn check_auth() -> Result { + Ok(Json(serde_json::json!({ "authenticated": true }))) +} + +// 鉴权验证中间件 +pub async fn auth_middleware( + State(state): State>, + req: axum::extract::Request, + next: Next, +) -> Response { + let mut token = None; + + // 1. 优先从 Authorization 头读取 Bearer Token(兼容) + let auth_header = req.headers() + .get(axum::http::header::AUTHORIZATION) + .and_then(|val| val.to_str().ok()); + + if let Some(auth_str) = auth_header { + if auth_str.starts_with("Bearer ") { + token = Some(auth_str[7..].to_string()); + } + } + + // 2. 其次从 Cookie 头读取 session_id + if token.is_none() { + if let Some(cookie_header) = req.headers().get(axum::http::header::COOKIE) { + if let Ok(cookie_str) = cookie_header.to_str() { + token = parse_session_cookie(cookie_str); + } + } + } + + let mut is_valid = false; + if let Some(tok) = token { + if let Ok(mut sessions) = state.sessions.lock() { + // 顺便执行过期会话自动清理垃圾收集 + prune_expired_sessions(&mut sessions); + + if let Some(last_active) = sessions.get_mut(&tok) { + // 会话存在且未过期,更新最后活跃时间 + *last_active = chrono::Utc::now(); + is_valid = true; + } + } + } + + if is_valid { + next.run(req).await + } else { + StatusCode::UNAUTHORIZED.into_response() + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 1e5f476..2082af6 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -75,6 +75,8 @@ pub struct AppState { pub sse_broadcast: Option>, /// 项目记忆管理器(跨会话持久化) pub memory_manager: Arc>, + /// 活跃的登录会话 Token 及其最后活跃时间(单用户内存管理) + pub sessions: Arc>>>, } // 统一标准化的文献格式,用于向前端传输 @@ -103,10 +105,12 @@ pub struct StandardPaper { } pub mod agent; +pub mod auth; pub mod helpers; pub mod notes; pub mod papers; pub mod permissions; +pub mod search; pub mod sync; pub mod targets; @@ -120,6 +124,7 @@ pub mod handlers { MessageRecord, RestoreResponse, RetryResponse, RewindRequest, RewindResponse, SessionDetail, SessionListParams, SessionSummary, }; + pub use super::auth::{check_auth, login, logout}; pub use super::helpers::{ check_paper_paths_in_db, convert_ads_doc_to_standard, convert_arxiv_to_standard, get_paper_from_db, save_paper_to_db, @@ -137,6 +142,7 @@ pub mod handlers { TranslateResponse, }; pub use super::permissions::{update_permission_mode, update_permission_rules}; + pub use super::search::{search as search_history, SearchParams as SearchHistoryParams}; 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, diff --git a/src/api/search.rs b/src/api/search.rs new file mode 100644 index 0000000..ab1b150 --- /dev/null +++ b/src/api/search.rs @@ -0,0 +1,150 @@ +// src/api/search.rs +// +// 跨会话全文搜索 API(P3 特性)。 +// 使用 FTS5 BM25 排序搜索 agent_sessions 和 agent_messages。 + +use axum::{ + extract::{Query, State}, + http::StatusCode, + Json, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use super::AppState; + +#[derive(Debug, Deserialize)] +pub struct SearchParams { + /// 搜索关键词(支持 FTS5 查询语法) + pub q: String, + /// 搜索范围:sessions, messages, all(默认 all) + #[serde(default = "default_scope")] + pub scope: String, + /// 最大结果数(默认 20,最大 100) + #[serde(default = "default_limit")] + pub limit: i64, + /// 可选:限定到特定会话 + pub session_id: Option, +} + +fn default_scope() -> String { + "all".into() +} +fn default_limit() -> i64 { + 20 +} + +#[derive(Debug, Serialize)] +pub struct SearchResult { + pub result_type: String, + pub session_id: String, + pub title: Option, + pub snippet: String, + pub created_at: Option, +} + +/// GET /api/search +pub async fn search( + State(state): State>, + Query(params): Query, +) -> Result>, (StatusCode, String)> { + let scope = ¶ms.scope; + let limit = params.limit.min(100); + let mut results = Vec::new(); + + let db = &state.db; + + // 搜索会话 + if scope == "all" || scope == "sessions" { + let rows = sqlx::query_as::<_, (String, String, String, Option)>( + "SELECT s.session_id, s.title, \ + snippet(agent_sessions_fts, 1, '', '', '...', 40) as snippet, \ + s.created_at \ + FROM agent_sessions_fts fts \ + JOIN agent_sessions s ON s.session_id = fts.session_id \ + WHERE agent_sessions_fts MATCH $1 \ + ORDER BY rank LIMIT $2", + ) + .bind(¶ms.q) + .bind(limit) + .fetch_all(db) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("搜索会话失败: {}", e), + ) + })?; + + for (sid, title, snippet, created_at) in rows { + results.push(SearchResult { + result_type: "session".into(), + session_id: sid, + title: Some(title), + snippet, + created_at, + }); + } + } + + // 搜索消息 + if scope == "all" || scope == "messages" { + let msg_rows = if let Some(ref sid) = params.session_id { + sqlx::query_as::<_, (String, String, String, String, Option)>( + "SELECT fts.session_id, s.title, \ + snippet(agent_messages_fts, 2, '', '', '...', 60) as snippet, \ + fts.role, m.created_at \ + FROM agent_messages_fts fts \ + JOIN agent_sessions s ON s.session_id = fts.session_id \ + JOIN agent_messages m ON m.rowid = fts.rowid \ + WHERE agent_messages_fts MATCH $1 AND fts.session_id = $3 \ + AND m.active = 1 \ + ORDER BY rank LIMIT $2", + ) + .bind(¶ms.q) + .bind(limit) + .bind(sid) + .fetch_all(db) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("搜索消息失败: {}", e), + ) + })? + } else { + sqlx::query_as::<_, (String, String, String, String, Option)>( + "SELECT fts.session_id, s.title, \ + snippet(agent_messages_fts, 2, '', '', '...', 60) as snippet, \ + fts.role, m.created_at \ + FROM agent_messages_fts fts \ + JOIN agent_sessions s ON s.session_id = fts.session_id \ + JOIN agent_messages m ON m.rowid = fts.rowid \ + WHERE agent_messages_fts MATCH $1 AND m.active = 1 \ + ORDER BY rank LIMIT $2", + ) + .bind(¶ms.q) + .bind(limit) + .fetch_all(db) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("搜索消息失败: {}", e), + ) + })? + }; + + for (sid, title, snippet, role, created_at) in msg_rows { + results.push(SearchResult { + result_type: format!("message/{}", role), + session_id: sid, + title: Some(title), + snippet, + created_at, + }); + } + } + + Ok(Json(results)) +} diff --git a/src/clients/llm.rs b/src/clients/llm.rs index 10ea15a..ffe9690 100644 --- a/src/clients/llm.rs +++ b/src/clients/llm.rs @@ -2,6 +2,7 @@ use futures_util::StreamExt; use reqwest::Client; use serde::{Deserialize, Serialize}; +use std::sync::Arc; use tracing::error; /// 消息角色枚举(OpenAI 兼容) @@ -206,7 +207,7 @@ pub enum StreamEvent { pub struct LlmClient { api_key: String, api_base: String, - model: String, + model: Arc>, client: Client, } @@ -215,13 +216,21 @@ impl LlmClient { LlmClient { api_key, api_base, - model, + model: Arc::new(std::sync::RwLock::new(model)), client: Client::new(), } } - pub fn model(&self) -> &str { - &self.model + pub fn model(&self) -> String { + self.model.read().unwrap_or_else(|e| e.into_inner()).clone() + } + + /// 运行时切换模型(用于故障转移)。 + pub fn set_model(&self, model: String) { + if let Ok(mut m) = self.model.write() { + *m = model; + tracing::info!("[LlmClient] 模型切换为: {}", m); + } } pub fn api_base(&self) -> &str { @@ -240,7 +249,7 @@ impl LlmClient { let url = format!("{}/chat/completions", self.api_base); let payload = serde_json::json!({ - "model": self.model, + "model": self.model(), "messages": [ { "role": "system", @@ -309,7 +318,7 @@ impl LlmClient { }; let payload = serde_json::json!({ - "model": self.model, + "model": self.model(), "messages": [ { "role": "system", @@ -383,7 +392,7 @@ impl LlmClient { let url = format!("{}/chat/completions", self.api_base); let mut payload = serde_json::json!({ - "model": self.model, + "model": self.model(), "messages": messages, "temperature": 0.3 }); @@ -391,7 +400,7 @@ impl LlmClient { // 前端可控的思考模式开关(仅对千问/DashScope 启用 enable_thinking 参数)(仅对千问/DashScope 启用 enable_thinking 参数) if enable_thinking && (self.api_base.contains("dashscope.aliyuncs.com") - || self.model.to_lowercase().contains("qwen")) + || self.model().to_lowercase().contains("qwen")) { if let Some(obj) = payload.as_object_mut() { obj.insert("enable_thinking".to_string(), serde_json::json!(true)); @@ -463,7 +472,7 @@ impl LlmClient { let url = format!("{}/chat/completions", self.api_base); let mut payload = serde_json::json!({ - "model": self.model, + "model": self.model(), "messages": messages, "temperature": 0.3, "stream": true, @@ -473,7 +482,7 @@ impl LlmClient { // 前端可控的思考模式开关(仅对千问/DashScope 启用 enable_thinking 参数) if enable_thinking && (self.api_base.contains("dashscope.aliyuncs.com") - || self.model.to_lowercase().contains("qwen")) + || self.model().to_lowercase().contains("qwen")) { if let Some(obj) = payload.as_object_mut() { obj.insert("enable_thinking".to_string(), serde_json::json!(true)); @@ -757,7 +766,7 @@ impl EmbeddingClient { let url = format!("{}/embeddings", self.api_base); let payload = serde_json::json!({ - "model": self.model, + "model": self.model(), "input": text, }); diff --git a/src/lib.rs b/src/lib.rs index 23a0327..9f1a4db 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,23 +5,26 @@ use std::path::PathBuf; // 系统配置结构体,加载并管理从环境变量或 .env 文件读取的参数 #[derive(Clone, Debug)] pub struct Config { - pub database_url: String, // SQLite 数据库连接 URL - pub ads_api_key: String, // NASA ADS API 访问 Token - pub llm_api_key: String, // 大语言模型 API Key - pub llm_api_base: String, // 大语言模型 API 基础地址 - pub llm_model: String, // 调用的翻译大模型名称 - pub embedding_api_key: String, // 向量模型 API Key - pub embedding_api_base: String, // 向量模型 API 基础地址 - pub embedding_model: String, // 向量模型名称 - pub qiniu_ak: String, // 七牛云 Access Key - pub qiniu_sk: String, // 七牛云 Secret Key - pub qiniu_bucket: String, // 七牛云存储空间名 (Bucket) - pub qiniu_domain: String, // 七牛云外链 CDN 域名 - pub mineru_api_url: String, // MinerU PDF 解析远程 API 地址 - pub mineru_api_key: String, // MinerU API Token - pub library_dir: PathBuf, // 本地文献馆藏根目录 - pub skills_dir: PathBuf, // Agent Skills 目录(Markdown 知识模块) - pub port: u16, // 后端服务监听端口 + pub database_url: String, // SQLite 数据库连接 URL + pub ads_api_key: String, // NASA ADS API 访问 Token + pub llm_api_key: String, // 大语言模型 API Key + pub llm_api_base: String, // 大语言模型 API 基础地址 + pub llm_model: String, // 调用的翻译大模型名称 + pub llm_fallback_model: String, // 故障转移备用模型 + pub llm_fallback_chain: Vec, // 故障转移模型链(按顺序尝试) + pub embedding_api_key: String, // 向量模型 API Key + pub embedding_api_base: String, // 向量模型 API 基础地址 + pub embedding_model: String, // 向量模型名称 + pub qiniu_ak: String, // 七牛云 Access Key + pub qiniu_sk: String, // 七牛云 Secret Key + pub qiniu_bucket: String, // 七牛云存储空间名 (Bucket) + pub qiniu_domain: String, // 七牛云外链 CDN 域名 + pub mineru_api_url: String, // MinerU PDF 解析远程 API 地址 + pub mineru_api_key: String, // MinerU API Token + pub library_dir: PathBuf, // 本地文献馆藏根目录 + pub skills_dir: PathBuf, // Agent Skills 目录(Markdown 知识模块) + pub port: u16, // 后端服务监听端口 + pub admin_password: String, // 单用户管理员登录密码 } impl Config { @@ -36,6 +39,15 @@ impl Config { let llm_api_base = env::var("LLM_API_BASE").unwrap_or_else(|_| "https://api.openai.com/v1".to_string()); let llm_model = env::var("LLM_MODEL").unwrap_or_else(|_| "gpt-4o-mini".to_string()); + let llm_fallback_model = env::var("LLM_FALLBACK_MODEL") + .or_else(|_| env::var("FALLBACK_MODEL")) + .unwrap_or_default(); + let llm_fallback_chain = env::var("LLM_FALLBACK_CHAIN") + .unwrap_or_default() + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); let embedding_api_key = env::var("EMBEDDING_API_KEY").unwrap_or_else(|_| llm_api_key.clone()); @@ -63,12 +75,17 @@ impl Config { .parse::() .unwrap_or(8000); + let admin_password = env::var("ADMIN_PASSWORD") + .unwrap_or_else(|_| "admin".to_string()); + Config { database_url, ads_api_key, llm_api_key, llm_api_base, llm_model, + llm_fallback_model, + llm_fallback_chain, embedding_api_key, embedding_api_base, embedding_model, @@ -81,6 +98,7 @@ impl Config { library_dir, skills_dir, port, + admin_password, } } } diff --git a/src/main.rs b/src/main.rs index 4dc17c2..4dfb535 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,7 +10,7 @@ use std::collections::HashMap; use std::net::SocketAddr; use std::str::FromStr; use std::sync::{Arc, Mutex, RwLock}; -use tower_http::cors::{Any, CorsLayer}; +use tower_http::cors::CorsLayer; use tower_http::services::ServeDir; use tracing::{error, info, warn}; @@ -57,6 +57,9 @@ async fn main() -> anyhow::Result<()> { "系统配置成功载入。本地 SQLite 连接串: {}", config.database_url ); + if config.admin_password == "admin" { + warn!("⚠️ 警告: ADMIN_PASSWORD 环境变量未配置!系统将使用默认密码 'admin' 启动登录鉴权。为了系统安全,请尽快在 .env 文件中设置自定义的 ADMIN_PASSWORD!"); + } // 创建本地馆藏物理文件夹分类结构 std::fs::create_dir_all(&config.library_dir).unwrap_or_default(); @@ -190,22 +193,70 @@ async fn main() -> anyhow::Result<()> { session_permission_checker: Arc::new(RwLock::new( astroresearch::agent::runtime::permission::PermissionChecker::new(), )), + sessions: Arc::new(Mutex::new(std::collections::HashMap::new())), }); // 7. 设置 Axum 路由、CORS 头以及 React 仪表盘静态资源托管 - let cors = CorsLayer::new() - .allow_origin(Any) - .allow_methods(Any) - .allow_headers(Any); + let allowed_methods = vec![ + axum::http::Method::GET, + axum::http::Method::POST, + axum::http::Method::PUT, + axum::http::Method::DELETE, + axum::http::Method::OPTIONS, + ]; - let api_routes = Router::new() - .route("/search", get(handlers::search_papers)) - .route("/download", post(handlers::download_paper)) + let cors_permissive = CorsLayer::new() + .allow_origin(tower_http::cors::AllowOrigin::predicate(|_origin, _parts| { + // 书签脚本的采集接口允许所有外域学术站点的跨域请求,并带上 Session 凭证 + true + })) + .allow_methods(allowed_methods.clone()) + .allow_headers(tower_http::cors::AllowHeaders::mirror_request()) + .allow_credentials(true); + + let cors_local = CorsLayer::new() + .allow_origin(tower_http::cors::AllowOrigin::predicate(|origin, _parts| { + if let Ok(origin_str) = origin.to_str() { + // 仅允许本地开发或回环地址(允许任意端口,支持 Vite dev 端口 :5173) + if origin_str.starts_with("http://localhost:") + || origin_str.starts_with("http://127.0.0.1:") + || origin_str == "http://localhost" + || origin_str == "http://127.0.0.1" + { + return true; + } + } + false + })) + .allow_methods(allowed_methods) + .allow_headers(tower_http::cors::AllowHeaders::mirror_request()) + .allow_credentials(true); + + let public_routes = Router::new() + .route("/auth/login", post(handlers::login)) + .layer(cors_local.clone()); + + // 专门为浏览器书签采集脚本保留的、允许任何外站跨域的 API 路由 + let bookmarklet_routes = Router::new() .route( "/upload", post(handlers::upload_paper_file) .layer(axum::extract::DefaultBodyLimit::max(100 * 1024 * 1024)), ) + .route( + "/active_bibcode", + get(handlers::get_active_bibcode).post(handlers::set_active_bibcode), + ) + .route_layer(axum::middleware::from_fn_with_state( + app_state.clone(), + astroresearch::api::auth::auth_middleware, + )) + .layer(cors_permissive); + + let protected_routes = Router::new() + .route("/search", get(handlers::search_papers)) + .route("/search/history", get(handlers::search_history)) + .route("/download", post(handlers::download_paper)) .route("/no_resource", post(handlers::mark_no_resource)) .route("/parse", post(handlers::parse_paper)) .route("/translate", post(handlers::translate_paper)) @@ -228,10 +279,6 @@ async fn main() -> anyhow::Result<()> { "/sync/queries/:id", axum::routing::delete(handlers::delete_sync_query), ) - .route( - "/active_bibcode", - get(handlers::get_active_bibcode).post(handlers::set_active_bibcode), - ) .route("/chat/rag", post(handlers::chat_rag)) .route("/chat/figure", post(handlers::chat_figure)) .route("/target/query", get(handlers::query_target)) @@ -272,18 +319,37 @@ async fn main() -> anyhow::Result<()> { .route( "/chat/sessions/:id/permissions/mode", axum::routing::put(handlers::update_permission_mode), - ); + ) + .route("/auth/logout", post(handlers::logout)) + .route("/auth/check", get(handlers::check_auth)) + .route_layer(axum::middleware::from_fn_with_state( + app_state.clone(), + astroresearch::api::auth::auth_middleware, + )) + .layer(cors_local.clone()); + + let api_routes = Router::new() + .merge(public_routes) + .merge(bookmarklet_routes) + .merge(protected_routes); // 静态文件资源代理托管(当前端打包至 dashboard/dist 后,直接挂载到主域名根路由) let serve_dir = ServeDir::new("dashboard/dist").fallback(tower_http::services::ServeFile::new( "dashboard/dist/index.html", )); + let protected_files = Router::new() + .fallback_service(ServeDir::new(&config.library_dir)) + .layer(axum::middleware::from_fn_with_state( + app_state.clone(), + astroresearch::api::auth::auth_middleware, + )) + .layer(cors_local); + let app = Router::new() .nest("/api", api_routes) - .nest_service("/api/files", ServeDir::new(&config.library_dir)) + .nest_service("/api/files", protected_files) .fallback_service(serve_dir) - .layer(cors) .layer(tower_http::trace::TraceLayer::new_for_http()) .with_state(app_state);