// 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 display_name(&self) -> &str {
"检索历史"
}
fn is_internal(&self) -> bool {
true
}
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 raw_query = match args.get("query").and_then(|v| v.as_str()) {
Some(q) if !q.is_empty() => q,
_ => return ToolOutput::error("缺少 query 参数"),
};
let query = crate::services::search::sanitize_fts5_query(raw_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}),
)
}
}
}