Files
my_wiki/raw/llm_wiki/src-tauri/src/agent/tools.rs
T

3708 lines
126 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::collections::hash_map::DefaultHasher;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::fs::OpenOptions;
use std::future::Future;
use std::hash::{Hash, Hasher};
use std::io::{Read, Seek, SeekFrom};
use std::path::{Component, Path, PathBuf};
use std::pin::Pin;
use std::process::Stdio;
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tokio::io::AsyncReadExt;
use tokio::process::Command;
use tokio::task::JoinHandle;
use tokio::time::{timeout, Duration};
use walkdir::WalkDir;
use crate::commands::external_search::file_url_for_path;
use crate::commands::search::{self, SearchEmbeddingConfig};
use super::types::{AgentKnowledgeContext, AgentReference, AgentVersionSummary};
use super::workspace::{agent_workspace_path, AGENT_WORKSPACE_DIR};
// Tool I/O limits are backend security boundaries. Do not relax them only in
// the UI: API and MCP callers can invoke the same tools without going through
// React components.
const MAX_READ_PAGE_BYTES: usize = 2 * 1024 * 1024;
const MAX_WRITE_PAGE_BYTES: usize = 2 * 1024 * 1024;
const MAX_WORKSPACE_WRITE_BYTES: usize = 2 * 1024 * 1024;
// Rollback snapshots are sent to the trusted desktop UI only for the current
// process lifetime. Bound them independently from write size so a large Agent
// artifact cannot multiply IPC and in-memory chat costs merely to enable Undo.
const MAX_WORKSPACE_ROLLBACK_BYTES: u64 = 512 * 1024;
const MAX_SOURCE_SEARCH_FILES: usize = 10_000;
const MAX_SOURCE_SNIPPET_CHARS: usize = 500;
const MAX_GRAPH_SEARCH_FILES: usize = 10_000;
const MAX_KNOWLEDGE_CONTEXT_ITEMS: usize = 20;
const WEB_SEARCH_TIMEOUT_SECS: u64 = 30;
const SHELL_EXEC_TIMEOUT_SECS: u64 = 30;
const MAX_SHELL_COMMAND_CHARS: usize = 4_000;
const MAX_SHELL_OUTPUT_CHARS: usize = 20_000;
const MAX_SHELL_GENERATED_FILES: usize = 50;
const SHELL_OUTPUT_DRAIN_TIMEOUT_SECS: u64 = 1;
const DEFAULT_ANYTXT_ENDPOINT: &str = "http://127.0.0.1:9920";
const DEFAULT_ANYTXT_LIMIT: usize = 20;
const ANYTXT_LAST_MODIFY_END: i64 = 2_147_483_647;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ToolEffect {
Read,
Write,
Network,
Process,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ToolSpec {
pub name: String,
pub description: String,
pub effects: Vec<ToolEffect>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parameters: Option<serde_json::Value>,
}
#[allow(dead_code)]
pub trait AgentTool: Send + Sync {
fn spec(&self) -> ToolSpec;
fn execute<'a>(
&'a self,
input: Value,
) -> Pin<Box<dyn Future<Output = Result<Value, String>> + Send + 'a>>;
}
pub trait ToolRegistry {
#[allow(dead_code)]
fn specs(&self) -> Vec<ToolSpec>;
fn execute<'a>(
&'a self,
name: &'a str,
input: Value,
context: ToolContext<'a>,
) -> Pin<Box<dyn Future<Output = Result<Value, String>> + Send + 'a>>;
}
#[derive(Debug, Clone, Default)]
pub struct BuiltinToolRegistry;
#[derive(Clone)]
pub struct ToolContext<'a> {
pub project_path: &'a str,
pub embedding_config: Option<SearchEmbeddingConfig>,
pub web_search_config: Option<WebSearchConfig>,
pub anytxt_config: Option<AnyTxtConfig>,
}
impl ToolRegistry for BuiltinToolRegistry {
fn specs(&self) -> Vec<ToolSpec> {
builtin_tool_specs()
}
fn execute<'a>(
&'a self,
name: &'a str,
input: Value,
context: ToolContext<'a>,
) -> Pin<Box<dyn Future<Output = Result<Value, String>> + Send + 'a>> {
Box::pin(async move {
match name {
"wiki.write_page" => {
let path = input
.get("path")
.and_then(Value::as_str)
.ok_or_else(|| "wiki.write_page requires path".to_string())?;
let content = input
.get("content")
.and_then(Value::as_str)
.ok_or_else(|| "wiki.write_page requires content".to_string())?;
let allow_overwrite = input
.get("allowOverwrite")
.or_else(|| input.get("allow_overwrite"))
.and_then(Value::as_bool)
.unwrap_or(false);
serde_json::to_value(write_wiki_page_with_activity(
context.project_path,
path,
content,
allow_overwrite,
)?)
.map_err(|err| format!("Failed to serialize wiki.write_page result: {err}"))
}
"wiki.search" => {
let query = tool_query(&input, "wiki.search")?;
let top_k = tool_top_k(&input);
let include_content = input
.get("includeContent")
.and_then(Value::as_bool)
.unwrap_or(false);
serde_json::to_value(
run_wiki_search(
context.project_path.to_string(),
query,
top_k,
include_content,
context.embedding_config,
)
.await?,
)
.map_err(|err| format!("Failed to serialize wiki.search result: {err}"))
}
"wiki.read_page" => {
let path = input
.get("path")
.and_then(Value::as_str)
.map(str::trim)
.filter(|path| !path.is_empty())
.ok_or_else(|| "wiki.read_page requires path".to_string())?;
let content = read_wiki_page(context.project_path, path)?;
let normalized_path = normalize_rel_path(path);
let mut knowledge_context = build_knowledge_context_index(context.project_path)
.remove(&normalized_path);
attach_latest_version(
context.project_path,
&normalized_path,
&mut knowledge_context,
);
serde_json::to_value(json!({
"path": path,
"content": content,
"knowledgeContext": knowledge_context,
}))
.map_err(|err| format!("Failed to serialize wiki.read_page result: {err}"))
}
"workspace.write_file" => {
let path = input
.get("path")
.and_then(Value::as_str)
.ok_or_else(|| "workspace.write_file requires path".to_string())?;
let content = input
.get("content")
.and_then(Value::as_str)
.ok_or_else(|| "workspace.write_file requires content".to_string())?;
serde_json::to_value(write_workspace_file(context.project_path, path, content)?)
.map_err(|err| {
format!("Failed to serialize workspace.write_file result: {err}")
})
}
"workspace.append_file" => {
let path = input
.get("path")
.and_then(Value::as_str)
.ok_or_else(|| "workspace.append_file requires path".to_string())?;
let content = input
.get("content")
.and_then(Value::as_str)
.ok_or_else(|| "workspace.append_file requires content".to_string())?;
serde_json::to_value(append_workspace_file(
context.project_path,
path,
content,
)?)
.map_err(|err| {
format!("Failed to serialize workspace.append_file result: {err}")
})
}
"source.search" => {
let query = tool_query(&input, "source.search")?.to_string();
let project_path = context.project_path.to_string();
let top_k = tool_top_k(&input);
// `search_sources` walks the filesystem synchronously.
// Keep it off Tokio worker threads so a large source tree
// cannot stall unrelated Agent/API work.
let references = tokio::task::spawn_blocking(move || {
search_sources(&project_path, &query, top_k)
})
.await
.map_err(|err| format!("source.search worker failed: {err}"))??;
serde_json::to_value(references)
.map_err(|err| format!("Failed to serialize source.search result: {err}"))
}
"graph.search" => {
let query = tool_query(&input, "graph.search")?.to_string();
let project_path = context.project_path.to_string();
let top_k = tool_top_k(&input);
// Graph search also performs synchronous markdown walks.
// Run it in the blocking pool for the same reason as
// `source.search`.
let references = tokio::task::spawn_blocking(move || {
search_graph(&project_path, &query, top_k)
})
.await
.map_err(|err| format!("graph.search worker failed: {err}"))??;
serde_json::to_value(references)
.map_err(|err| format!("Failed to serialize graph.search result: {err}"))
}
"web.search" => {
let query = tool_query(&input, "web.search")?;
serde_json::to_value(
run_web_search(query, context.web_search_config, tool_top_k(&input))
.await?,
)
.map_err(|err| format!("Failed to serialize web.search result: {err}"))
}
"anytxt.search" => {
let query = tool_query(&input, "anytxt.search")?;
serde_json::to_value(
run_anytxt_search(query, context.anytxt_config, tool_top_k(&input)).await?,
)
.map_err(|err| format!("Failed to serialize anytxt.search result: {err}"))
}
"deep_research.run" => {
let query = tool_query(&input, "deep_research.run")?;
serde_json::to_value(json!({
"query": query,
"status": "orchestrated_by_agent_runtime",
}))
.map_err(|err| format!("Failed to serialize deep_research.run result: {err}"))
}
"shell.exec" => {
let command = input
.get("command")
.or_else(|| input.get("query"))
.and_then(Value::as_str)
.ok_or_else(|| "shell.exec requires command".to_string())?;
let timeout_secs = input
.get("timeoutSeconds")
.or_else(|| input.get("timeout_seconds"))
.and_then(Value::as_u64)
.unwrap_or(SHELL_EXEC_TIMEOUT_SECS)
.clamp(1, SHELL_EXEC_TIMEOUT_SECS);
serde_json::to_value(
run_shell_exec(context.project_path, command, timeout_secs).await?,
)
.map_err(|err| format!("Failed to serialize shell.exec result: {err}"))
}
other => Err(format!("Unknown Agent tool: {other}")),
}
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WikiSearchToolOutput {
pub mode: String,
pub token_hits: usize,
pub vector_hits: usize,
pub graph_hits: usize,
pub references: Vec<AgentReference>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ShellExecToolOutput {
pub command: String,
pub exit_code: Option<i32>,
pub stdout: String,
pub stderr: String,
pub timed_out: bool,
#[serde(default)]
pub generated_files: Vec<WorkspaceWriteOutput>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceWriteOutput {
pub path: String,
pub bytes: usize,
#[serde(default)]
pub existed_before: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_content: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WikiWriteOutput {
#[serde(flatten)]
pub reference: AgentReference,
#[serde(default)]
pub existed_before: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_content: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct WebSearchConfig {
pub provider: String,
#[serde(default)]
pub api_key: String,
#[serde(default)]
pub ollama_url: Option<String>,
#[serde(default)]
pub sear_xng_url: Option<String>,
#[serde(default)]
pub sear_xng_categories: Option<Vec<String>>,
#[serde(default)]
pub serp_api_engine: Option<String>,
#[serde(default)]
pub provider_configs: Option<BTreeMap<String, WebSearchProviderOverride>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct WebSearchProviderOverride {
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub ollama_url: Option<String>,
#[serde(default)]
pub sear_xng_url: Option<String>,
#[serde(default)]
pub sear_xng_categories: Option<Vec<String>>,
#[serde(default)]
pub serp_api_engine: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct AnyTxtConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub endpoint: Option<String>,
#[serde(default)]
pub filter_dir: Option<String>,
#[serde(default)]
pub filter_ext: Option<String>,
#[serde(default)]
pub limit: Option<usize>,
}
impl WebSearchConfig {
fn resolved(&self) -> Self {
let provider = self.provider.trim().to_ascii_lowercase();
let Some(override_cfg) = self
.provider_configs
.as_ref()
.and_then(|configs| configs.get(&provider))
else {
return self.clone();
};
Self {
provider: self.provider.clone(),
api_key: override_cfg
.api_key
.clone()
.unwrap_or_else(|| self.api_key.clone()),
ollama_url: override_cfg
.ollama_url
.clone()
.or_else(|| self.ollama_url.clone()),
sear_xng_url: override_cfg
.sear_xng_url
.clone()
.or_else(|| self.sear_xng_url.clone()),
sear_xng_categories: override_cfg
.sear_xng_categories
.clone()
.or_else(|| self.sear_xng_categories.clone()),
serp_api_engine: override_cfg
.serp_api_engine
.clone()
.or_else(|| self.serp_api_engine.clone()),
provider_configs: self.provider_configs.clone(),
}
}
}
// Keep the spec list close to the executor even though the current planner
// still uses fixed tool names. API/MCP tool discovery and future native
// tool-calling should use this list instead of duplicating tool metadata.
#[allow(dead_code)]
pub fn builtin_tool_specs() -> Vec<ToolSpec> {
vec![
ToolSpec {
name: "wiki.search".to_string(),
description: "Search generated LLM Wiki pages using backend keyword/vector retrieval."
.to_string(),
effects: vec![ToolEffect::Read],
parameters: Some(serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string" },
"topK": { "type": "integer", "minimum": 1, "maximum": 10 }
},
"required": ["query"]
})),
},
ToolSpec {
name: "wiki.read_page".to_string(),
description: "Read a project wiki markdown page by project-relative path.".to_string(),
effects: vec![ToolEffect::Read],
parameters: Some(serde_json::json!({
"type": "object",
"properties": { "path": { "type": "string" } },
"required": ["path"]
})),
},
ToolSpec {
name: "source.search".to_string(),
description:
"Search raw source files stored under raw/sources for exact keyword snippets."
.to_string(),
effects: vec![ToolEffect::Read],
parameters: Some(serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string" },
"topK": { "type": "integer", "minimum": 1, "maximum": 10 }
},
"required": ["query"]
})),
},
ToolSpec {
name: "web.search".to_string(),
description: "Search external web sources when the user enables web search."
.to_string(),
effects: vec![ToolEffect::Network],
parameters: Some(serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string" },
"topK": { "type": "integer", "minimum": 1, "maximum": 10 }
},
"required": ["query"]
})),
},
ToolSpec {
name: "graph.search".to_string(),
description: "Retrieve graph relationships, neighbors, backlinks, dependencies, and connections between project entities. Use concise entity or concept names rather than a full question."
.to_string(),
effects: vec![ToolEffect::Read],
parameters: Some(serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string" },
"topK": { "type": "integer", "minimum": 1, "maximum": 10 }
},
"required": ["query"]
})),
},
ToolSpec {
name: "anytxt.search".to_string(),
description: "Search files indexed by an AnyTXT JSON-RPC service.".to_string(),
effects: vec![ToolEffect::Network, ToolEffect::Read],
parameters: Some(serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string" },
"topK": { "type": "integer", "minimum": 1, "maximum": 10 }
},
"required": ["query"]
})),
},
ToolSpec {
name: "deep_research.run".to_string(),
description:
"Collect broader external/local evidence for deep research turns before synthesis."
.to_string(),
effects: vec![ToolEffect::Network, ToolEffect::Read],
parameters: Some(serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string" },
"sources": {
"type": "array",
"items": { "enum": ["web", "anytxt", "wiki", "source"] }
}
},
"required": ["query"]
})),
},
ToolSpec {
name: "wiki.write_page".to_string(),
description:
"Create a Markdown wiki page under wiki/ with project-bound path checks. Existing files require allowOverwrite=true."
.to_string(),
effects: vec![ToolEffect::Write],
parameters: Some(serde_json::json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Project-relative path such as wiki/queries/new-page.md"
},
"content": { "type": "string" },
"allowOverwrite": {
"type": "boolean",
"description": "Defaults to false. Set true only when the user explicitly asks to overwrite an existing wiki page."
}
},
"required": ["path", "content"]
})),
},
ToolSpec {
name: "llm.generate".to_string(),
description: "Generate a final assistant answer from retrieved context.".to_string(),
effects: vec![ToolEffect::Network],
parameters: None,
},
ToolSpec {
name: "skills.load".to_string(),
description: "Load instruction-only project skills from .llm-wiki/skills.".to_string(),
effects: vec![ToolEffect::Read],
parameters: None,
},
ToolSpec {
name: "skill.read_file".to_string(),
description:
"Read a text reference file from an active skill directory by relative path."
.to_string(),
effects: vec![ToolEffect::Read],
parameters: Some(serde_json::json!({
"type": "object",
"properties": {
"skill": {
"type": "string",
"description": "Optional active skill name; required when multiple skills are active."
},
"path": {
"type": "string",
"description": "Relative path inside the active skill directory, such as references/types.md."
}
},
"required": ["path"]
})),
},
ToolSpec {
name: "workspace.write_file".to_string(),
description:
"Write a generated artifact file under the visible agent-workspace directory."
.to_string(),
effects: vec![ToolEffect::Write],
parameters: Some(serde_json::json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Relative path under agent-workspace, such as cover-image/cover.svg."
},
"content": { "type": "string" }
},
"required": ["path", "content"]
})),
},
ToolSpec {
name: "workspace.append_file".to_string(),
description:
"Append generated artifact content under agent-workspace. Use after workspace.write_file for large HTML/PPT files."
.to_string(),
effects: vec![ToolEffect::Write],
parameters: Some(serde_json::json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Relative path under agent-workspace, matching the file being appended."
},
"content": { "type": "string" }
},
"required": ["path", "content"]
})),
},
ToolSpec {
name: "shell.exec".to_string(),
description:
"Run a project-scoped shell command requested by an active skill instruction."
.to_string(),
effects: vec![ToolEffect::Read, ToolEffect::Process],
parameters: Some(serde_json::json!({
"type": "object",
"properties": {
"command": { "type": "string" },
"timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": SHELL_EXEC_TIMEOUT_SECS }
},
"required": ["command"]
})),
},
]
}
fn tool_query<'a>(input: &'a Value, tool: &str) -> Result<&'a str, String> {
input
.get("query")
.and_then(Value::as_str)
.map(str::trim)
.filter(|query| !query.is_empty())
.ok_or_else(|| format!("{tool} requires query"))
}
fn tool_top_k(input: &Value) -> usize {
input
.get("topK")
.or_else(|| input.get("top_k"))
.and_then(Value::as_u64)
.map(|value| value as usize)
.unwrap_or(5)
.clamp(1, 10)
}
#[cfg(test)]
pub fn write_wiki_page_with_options(
project_path: &str,
rel_path: &str,
content: &str,
allow_overwrite: bool,
) -> Result<AgentReference, String> {
Ok(write_wiki_page_with_activity(project_path, rel_path, content, allow_overwrite)?.reference)
}
fn write_wiki_page_with_activity(
project_path: &str,
rel_path: &str,
content: &str,
allow_overwrite: bool,
) -> Result<WikiWriteOutput, String> {
if content.len() > MAX_WRITE_PAGE_BYTES {
return Err("wiki.write_page content is too large".to_string());
}
let rel = normalize_wiki_write_path(rel_path)?;
let path = safe_project_join(project_path, &rel)?;
if let Some(parent) = path.parent() {
// Check the deepest existing ancestor before creating directories. If a
// project already contains a symlink under `wiki/`, this prevents even
// empty intermediate directories from being created outside the project.
ensure_existing_ancestor_bound(project_path, parent)?;
fs::create_dir_all(parent)
.map_err(|err| format!("Failed to create wiki page directory: {err}"))?;
ensure_project_bound_path(project_path, parent)?;
}
// Create-only by default. Prompt injection in retrieved context must not be
// able to silently truncate an existing wiki page.
if path.exists() && !allow_overwrite {
return Err(
"wiki.write_page refuses to overwrite an existing page without allowOverwrite=true"
.to_string(),
);
}
let existed_before = path.is_file();
let previous_content = workspace_rollback_snapshot(&path);
crate::commands::file_history::record_file_version(&path, "baseline", "before.wiki.write_page");
fs::write(&path, content).map_err(|err| format!("Failed to write wiki page: {err}"))?;
crate::commands::file_history::record_file_version(&path, "agent", "wiki.write_page");
Ok(WikiWriteOutput {
reference: AgentReference {
title: extract_markdown_title(content).unwrap_or_else(|| {
Path::new(&rel)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Wiki page")
.replace('-', " ")
}),
path: rel.clone(),
kind: "wiki".to_string(),
snippet: Some(trim_text(&collapse_markdown_preview(content), 500))
.filter(|value| !value.trim().is_empty()),
score: None,
knowledge_context: None,
},
existed_before,
previous_content,
})
}
fn write_workspace_file(
project_path: &str,
rel_path: &str,
content: &str,
) -> Result<WorkspaceWriteOutput, String> {
if content.len() > MAX_WORKSPACE_WRITE_BYTES {
return Err("workspace.write_file content is too large".to_string());
}
let (rel, path) =
resolve_workspace_write_target(project_path, rel_path, "workspace.write_file")?;
if path
.symlink_metadata()
.map(|metadata| metadata.file_type().is_symlink())
.unwrap_or(false)
{
return Err("workspace.write_file refuses to overwrite a symlink".to_string());
}
let existed_before = path.is_file();
let previous_content = workspace_rollback_snapshot(&path);
crate::commands::file_history::record_file_version(
&path,
"baseline",
"before.workspace.write_file",
);
fs::write(&path, content).map_err(|err| format!("workspace.write_file failed: {err}"))?;
crate::commands::file_history::record_file_version(&path, "agent", "workspace.write_file");
Ok(WorkspaceWriteOutput {
path: format!("{AGENT_WORKSPACE_DIR}/{rel}"),
bytes: content.len(),
existed_before,
previous_content,
})
}
fn append_workspace_file(
project_path: &str,
rel_path: &str,
content: &str,
) -> Result<WorkspaceWriteOutput, String> {
if content.len() > MAX_WORKSPACE_WRITE_BYTES {
return Err("workspace.append_file content is too large".to_string());
}
let (rel, path) =
resolve_workspace_write_target(project_path, rel_path, "workspace.append_file")?;
if path
.symlink_metadata()
.map(|metadata| metadata.file_type().is_symlink())
.unwrap_or(false)
{
return Err("workspace.append_file refuses to overwrite a symlink".to_string());
}
let existed_before = path.is_file();
let previous_content = workspace_rollback_snapshot(&path);
crate::commands::file_history::record_file_version(
&path,
"baseline",
"before.workspace.append_file",
);
OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.and_then(|mut file| {
use std::io::Write;
file.write_all(content.as_bytes())
})
.map_err(|err| format!("workspace.append_file failed: {err}"))?;
crate::commands::file_history::record_file_version(&path, "agent", "workspace.append_file");
let bytes = fs::metadata(&path)
.map(|metadata| metadata.len() as usize)
.unwrap_or(content.len());
Ok(WorkspaceWriteOutput {
path: format!("{AGENT_WORKSPACE_DIR}/{rel}"),
bytes,
existed_before,
previous_content,
})
}
fn workspace_rollback_snapshot(path: &Path) -> Option<String> {
let metadata = fs::metadata(path).ok()?;
if !metadata.is_file() || metadata.len() > MAX_WORKSPACE_ROLLBACK_BYTES {
return None;
}
fs::read_to_string(path).ok()
}
fn resolve_workspace_write_target(
project_path: &str,
rel_path: &str,
tool_name: &str,
) -> Result<(String, PathBuf), String> {
let rel = normalize_workspace_write_path(rel_path)
.map_err(|err| err.replace("workspace.write_file", tool_name))?;
let project = Path::new(project_path);
if !project.is_dir() {
return Err(format!("{tool_name} project directory is not available"));
}
let workspace = agent_workspace_path(project);
fs::create_dir_all(&workspace)
.map_err(|err| format!("{tool_name} failed to create workspace: {err}"))?;
ensure_project_bound_path(project_path, &workspace)?;
let path = workspace.join(&rel);
if let Some(parent) = path.parent() {
ensure_existing_ancestor_bound(project_path, parent)?;
fs::create_dir_all(parent)
.map_err(|err| format!("{tool_name} failed to create directory: {err}"))?;
ensure_project_bound_path(project_path, parent)?;
}
Ok((rel, path))
}
pub async fn run_wiki_search(
project_path: String,
query: &str,
top_k: usize,
include_content: bool,
embedding_config: Option<SearchEmbeddingConfig>,
) -> Result<WikiSearchToolOutput, String> {
let query_embedding = search::resolve_query_embedding(query, None, embedding_config).await?;
let search = search::search_project_inner(
project_path.clone(),
query.to_string(),
top_k,
include_content,
query_embedding,
)
.await?;
let project_for_context = project_path.clone();
let context_index =
tokio::task::spawn_blocking(move || build_knowledge_context_index(&project_for_context))
.await
.map_err(|err| format!("wiki.search graph context worker failed: {err}"));
// Retrieval must remain useful if optional graph enrichment fails. The
// search result is the source of truth; context is a bounded enhancement.
let mut context_index = context_index.unwrap_or_default();
let references = search
.results
.iter()
.map(|result| {
let normalized_path = normalize_rel_path(&result.path);
let mut knowledge_context = context_index.remove(&normalized_path);
if let Some(context) = knowledge_context.as_mut() {
context.related_to = result.graph_related_to.clone();
}
attach_latest_version(&project_path, &normalized_path, &mut knowledge_context);
AgentReference {
title: result.title.clone(),
path: result.path.clone(),
kind: if result.graph_related_to.is_empty() {
"wiki".to_string()
} else {
"graph".to_string()
},
snippet: Some(result.snippet.clone()).filter(|s| !s.trim().is_empty()),
score: Some(result.score),
knowledge_context,
}
})
.collect();
Ok(WikiSearchToolOutput {
mode: search.mode,
token_hits: search.token_hits,
vector_hits: search.vector_hits,
graph_hits: search.graph_hits,
references,
})
}
async fn run_shell_exec(
project_path: &str,
command: &str,
timeout_secs: u64,
) -> Result<ShellExecToolOutput, String> {
let command = command.trim();
if command.is_empty() {
return Err("shell.exec command is empty".to_string());
}
if command.chars().count() > MAX_SHELL_COMMAND_CHARS {
return Err("shell.exec command is too long".to_string());
}
let cwd = Path::new(project_path);
if !cwd.is_dir() {
return Err("shell.exec project directory is not available".to_string());
}
let workspace = agent_workspace_path(cwd);
fs::create_dir_all(&workspace)
.map_err(|err| format!("shell.exec failed to create {AGENT_WORKSPACE_DIR}: {err}"))?;
ensure_project_bound_path(project_path, &workspace)?;
let before_files = snapshot_workspace_files(&workspace);
#[cfg(windows)]
let mut child = {
let shell = std::env::var_os("ComSpec").unwrap_or_else(|| "cmd".into());
let mut cmd = Command::new(shell);
cmd.args(["/C", command]);
cmd
};
#[cfg(not(windows))]
let mut child = {
let mut cmd = Command::new("/bin/sh");
cmd.args(["-c", command]);
cmd
};
apply_sanitized_shell_env(&mut child, project_path, &workspace);
child
.current_dir(&workspace)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = child
.spawn()
.map_err(|err| format!("shell.exec failed to start: {err}"))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| "shell.exec failed to capture stdout".to_string())?;
let stderr = child
.stderr
.take()
.ok_or_else(|| "shell.exec failed to capture stderr".to_string())?;
let stdout_task = tokio::spawn(read_limited_output(stdout, MAX_SHELL_OUTPUT_CHARS));
let stderr_task = tokio::spawn(read_limited_output(stderr, MAX_SHELL_OUTPUT_CHARS));
let status = timeout(Duration::from_secs(timeout_secs), child.wait()).await;
let (exit_code, timed_out, timeout_message) = match status {
Ok(Ok(status)) => (status.code(), false, None),
Ok(Err(err)) => return Err(format!("shell.exec failed while waiting: {err}")),
Err(_) => {
let _ = child.start_kill();
let _ = child.wait().await;
(
None,
true,
Some(format!("Command timed out after {timeout_secs}s")),
)
}
};
let stdout = await_shell_output(stdout_task, "stdout").await?;
let mut stderr = await_shell_output(stderr_task, "stderr").await?;
if let Some(message) = timeout_message {
if !stderr.is_empty() {
stderr.push('\n');
}
stderr.push_str(&message);
}
let generated_files = changed_workspace_files(&workspace, before_files);
for output in &generated_files {
if let Some(relative) = output.path.strip_prefix(&format!("{AGENT_WORKSPACE_DIR}/")) {
crate::commands::file_history::record_file_version(
&workspace.join(relative),
"agent",
"shell.exec",
);
}
}
Ok(ShellExecToolOutput {
command: command.to_string(),
exit_code,
stdout,
stderr,
timed_out,
generated_files,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct WorkspaceFileSnapshot {
len: u64,
modified: Option<SystemTime>,
content_hash: Option<u64>,
}
fn snapshot_workspace_files(workspace: &Path) -> BTreeMap<String, WorkspaceFileSnapshot> {
let mut files = BTreeMap::new();
for entry in WalkDir::new(workspace).into_iter().filter_map(Result::ok) {
if !entry.file_type().is_file() {
continue;
}
let Ok(rel) = entry.path().strip_prefix(workspace) else {
continue;
};
let Some(rel) = rel.to_str().map(|value| value.replace('\\', "/")) else {
continue;
};
let Ok(metadata) = entry.metadata() else {
continue;
};
files.insert(
rel,
WorkspaceFileSnapshot {
len: metadata.len(),
modified: metadata.modified().ok(),
content_hash: workspace_file_content_hash(entry.path(), metadata.len()),
},
);
}
files
}
fn workspace_file_content_hash(path: &Path, len: u64) -> Option<u64> {
// Shell-generated artifacts can be rewritten faster than some filesystems
// update mtimes, especially on Windows/external/network volumes. A bounded
// content signature prevents same-size rewrites from disappearing from the
// generated-output list without turning every shell command into an
// unbounded full-workspace read.
const FULL_HASH_LIMIT_BYTES: u64 = 8 * 1024 * 1024;
const EDGE_SAMPLE_BYTES: u64 = 64 * 1024;
let mut file = fs::File::open(path).ok()?;
let mut hasher = DefaultHasher::new();
len.hash(&mut hasher);
if len <= FULL_HASH_LIMIT_BYTES {
let mut bytes = Vec::new();
file.read_to_end(&mut bytes).ok()?;
bytes.hash(&mut hasher);
} else {
let sample = EDGE_SAMPLE_BYTES as usize;
let mut head = vec![0_u8; sample];
file.read_exact(&mut head).ok()?;
head.hash(&mut hasher);
file.seek(SeekFrom::End(-(EDGE_SAMPLE_BYTES as i64))).ok()?;
let mut tail = vec![0_u8; sample];
file.read_exact(&mut tail).ok()?;
tail.hash(&mut hasher);
}
Some(hasher.finish())
}
fn changed_workspace_files(
workspace: &Path,
before: BTreeMap<String, WorkspaceFileSnapshot>,
) -> Vec<WorkspaceWriteOutput> {
let after = snapshot_workspace_files(workspace);
after
.into_iter()
.filter_map(|(rel, snapshot)| {
if before.get(&rel) == Some(&snapshot) {
return None;
}
Some(WorkspaceWriteOutput {
path: format!("{AGENT_WORKSPACE_DIR}/{rel}"),
bytes: snapshot.len as usize,
existed_before: before.contains_key(&rel),
previous_content: None,
})
})
.take(MAX_SHELL_GENERATED_FILES)
.collect()
}
async fn await_shell_output(mut handle: JoinHandle<String>, label: &str) -> Result<String, String> {
match timeout(
Duration::from_secs(SHELL_OUTPUT_DRAIN_TIMEOUT_SECS),
&mut handle,
)
.await
{
Ok(Ok(output)) => Ok(output),
Ok(Err(err)) => Err(format!("shell.exec {label} task failed: {err}")),
Err(_) => {
handle.abort();
Ok(format!(
"[{label} output was still open after command exit and was truncated]"
))
}
}
}
fn apply_sanitized_shell_env(command: &mut Command, project_path: &str, workspace: &Path) {
// This is environment minimization, not an OS sandbox. `shell.exec` is only
// reachable after an exact user approval in the Agent runtime, and approved
// commands can still access the user's normal filesystem through the shell.
// Keep generated artifacts in `LLM_WIKI_AGENT_WORKSPACE`, but do not imply
// stronger process isolation here without adding a real sandbox layer.
command.env_clear();
preserve_shell_env(command, &["PATH", "LANG", "LC_ALL", "LC_CTYPE"]);
#[cfg(not(windows))]
{
preserve_shell_env(
command,
&[
"HOME",
"USER",
"LOGNAME",
"SHELL",
"TMPDIR",
"XDG_CONFIG_HOME",
"XDG_CACHE_HOME",
"XDG_DATA_HOME",
],
);
}
#[cfg(windows)]
{
preserve_shell_env(
command,
&[
"ComSpec",
"SystemRoot",
"WINDIR",
"PATHEXT",
"USERPROFILE",
"USERNAME",
"HOMEDRIVE",
"HOMEPATH",
"TEMP",
"TMP",
"APPDATA",
"LOCALAPPDATA",
"ProgramData",
],
);
}
command.env("LLM_WIKI_PROJECT", project_path);
command.env("LLM_WIKI_PROJECT_PATH", project_path);
command.env("LLM_WIKI_AGENT_WORKSPACE", workspace);
}
fn preserve_shell_env(command: &mut Command, keys: &[&str]) {
for key in keys {
if let Some(value) = std::env::var_os(key) {
command.env(key, value);
}
}
}
async fn read_limited_output<R>(mut reader: R, max_chars: usize) -> String
where
R: tokio::io::AsyncRead + Unpin,
{
let mut kept = Vec::new();
let max_bytes = max_chars.saturating_mul(4);
let mut buffer = [0_u8; 8192];
loop {
let Ok(n) = reader.read(&mut buffer).await else {
break;
};
if n == 0 {
break;
}
if kept.len() < max_bytes {
let remaining = max_bytes - kept.len();
kept.extend_from_slice(&buffer[..n.min(remaining)]);
}
}
let mut text = String::from_utf8_lossy(&kept).to_string();
if text.chars().count() > max_chars {
text = trim_text(&text, max_chars);
}
text
}
pub async fn run_web_search(
query: &str,
config: Option<WebSearchConfig>,
top_k: usize,
) -> Result<Vec<AgentReference>, String> {
let query = query.trim();
if query.is_empty() {
return Ok(Vec::new());
}
let Some(config) = config else {
return Err(
"Web search is enabled for this turn but no search provider is configured.".to_string(),
);
};
let config = config.resolved();
let provider = config.provider.trim().to_ascii_lowercase();
if provider.is_empty() || provider == "none" {
return Err("Web search provider is not configured.".to_string());
}
let max_results = web_search_result_limit(&provider, top_k);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(WEB_SEARCH_TIMEOUT_SECS))
.build()
.map_err(|err| format!("Failed to build web search client: {err}"))?;
let raw = match provider.as_str() {
"firecrawl" => firecrawl_search(&client, query, &config, max_results).await?,
"searxng" => searxng_search(&client, query, &config, max_results).await?,
"tavily" => tavily_search(&client, query, &config, max_results).await?,
"ollama" => ollama_search(&client, query, &config, max_results).await?,
"brave" => brave_search(&client, query, &config, max_results).await?,
"bocha" => bocha_search(&client, query, &config, max_results).await?,
"serpapi" => serpapi_search(&client, query, &config, max_results).await?,
other => {
return Err(format!(
"Web search provider '{other}' is not supported by the Rust Agent yet"
))
}
};
Ok(web_items_to_references(raw, max_results))
}
fn web_search_result_limit(provider: &str, requested: usize) -> usize {
// Bocha documents a 1-50 range. Existing providers retain the historical
// 20-result ceiling so adding Bocha cannot increase their request cost.
let provider_max = if provider == "bocha" { 50 } else { 20 };
requested.clamp(1, provider_max)
}
pub async fn run_anytxt_search(
query: &str,
config: Option<AnyTxtConfig>,
top_k: usize,
) -> Result<Vec<AgentReference>, String> {
let query = query.trim();
if query.is_empty() {
return Ok(Vec::new());
}
let config = config.unwrap_or_default();
if config.enabled == Some(false) {
return Ok(Vec::new());
}
let endpoint = config
.endpoint
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or(DEFAULT_ANYTXT_ENDPOINT)
.trim()
.trim_end_matches('/');
let endpoint = normalize_anytxt_endpoint(endpoint);
let limit = top_k
.clamp(1, 100)
.min(config.limit.unwrap_or(DEFAULT_ANYTXT_LIMIT).clamp(1, 100));
// AnyTXT has its own query syntax. The caller may already have rewritten
// natural language into keyword form, so do not run the source-search
// tokenizer here; pass the pattern through unchanged.
let pattern = query.to_string();
let filter_dir = config.filter_dir.unwrap_or_default();
let filter_ext = config
.filter_ext
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "*".to_string());
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(WEB_SEARCH_TIMEOUT_SECS))
.build()
.map_err(|err| format!("Failed to build AnyTXT client: {err}"))?;
let mut input = json!({
"pattern": pattern,
"filterExt": filter_ext,
"lastModifyBegin": 0,
"lastModifyEnd": ANYTXT_LAST_MODIFY_END,
"limit": limit.to_string(),
"offset": 0,
"order": 0
});
if !filter_dir.trim().is_empty() {
input["filterDir"] = Value::String(filter_dir);
}
let response = client
.post(&endpoint)
.header("Accept", "application/json")
.json(&json!({
"id": 1,
"jsonrpc": "2.0",
"method": "ATRpcServer.Searcher.V1.GetResult",
"params": { "input": input }
}))
.send()
.await
.map_err(|err| {
format!("AnyTXT search failed. Check that ATGUI.exe or the AnyTXT service is running at {endpoint}: {err}")
})?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| format!("Failed to read AnyTXT response: {err}"))?;
if !status.is_success() {
return Err(format!("AnyTXT HTTP {status}: {}", trim_text(&text, 300)));
}
let value: Value = serde_json::from_str(&text)
.map_err(|_| format!("AnyTXT returned invalid JSON: {}", trim_text(&text, 300)))?;
if let Some(error) = value.get("error") {
return Err(format!(
"AnyTXT error: {}",
trim_text(&error.to_string(), 300)
));
}
let mut references = Vec::new();
for item in extract_anytxt_items(&value).into_iter().take(limit) {
let fragment = if !item.fid.trim().is_empty() {
get_anytxt_fragment(&client, &endpoint, &item.fid, &pattern)
.await
.unwrap_or_default()
} else {
String::new()
};
references.push(AgentReference {
title: item.title,
path: file_url_for_path(&item.path),
kind: "anytxt".to_string(),
snippet: Some(trim_text(
if fragment.trim().is_empty() {
&item.snippet
} else {
&fragment
},
1200,
))
.filter(|s| !s.trim().is_empty()),
score: None,
knowledge_context: None,
});
}
Ok(references)
}
#[derive(Debug, Clone)]
struct AnyTxtItem {
fid: String,
title: String,
path: String,
snippet: String,
}
fn extract_anytxt_items(value: &Value) -> Vec<AnyTxtItem> {
let result = value.get("result").unwrap_or(value);
let candidates = first_anytxt_array(
result,
&[
&[][..],
&["items"],
&["files"],
&["results"],
&["list"],
&["value"],
&["data"],
&["output"],
&["output", "items"],
&["output", "files"],
&["output", "results"],
&["output", "list"],
&["output", "value"],
&["output", "data"],
&["data", "items"],
&["data", "files"],
&["data", "results"],
&["data", "list"],
&["data", "value"],
&["data", "output"],
&["data", "output", "items"],
&["data", "output", "files"],
&["data", "output", "results"],
&["data", "output", "list"],
&["data", "output", "value"],
],
)
.unwrap_or_default();
let fields = first_anytxt_fields(
result,
&[
&["field"][..],
&["fields"],
&["output", "field"],
&["output", "fields"],
&["data", "field"],
&["data", "fields"],
&["data", "output", "field"],
&["data", "output", "fields"],
],
)
.unwrap_or_default();
candidates
.into_iter()
.filter_map(|item| {
let record = normalize_anytxt_record(item, &fields);
let fid = string_field(&record, &["fid", "id", "fileId", "file_id"]);
let raw_path = string_field(
&record,
&[
"path",
"file",
"filePath",
"file_path",
"fullPath",
"full_path",
"filename",
"fileName",
"name",
],
);
let path = if raw_path.is_empty() && !fid.is_empty() {
format!("anytxt://{fid}")
} else {
raw_path
};
let title = string_field(&record, &["title", "name", "fileName", "filename"])
.trim()
.to_string();
let title = if title.is_empty() {
Path::new(&path)
.file_name()
.and_then(|s| s.to_str())
.filter(|value| !value.trim().is_empty())
.unwrap_or("AnyTXT result")
.to_string()
} else {
title
};
let snippet = string_field(
&record,
&[
"snippet",
"fragment",
"content",
"contents",
"text",
"summary",
"highlight",
"hitText",
"hit_text",
],
);
if path.is_empty() && snippet.is_empty() {
None
} else {
Some(AnyTxtItem {
fid,
title,
path,
snippet,
})
}
})
.collect()
}
fn first_anytxt_array(value: &Value, paths: &[&[&str]]) -> Option<Vec<Value>> {
for path in paths {
let Some(candidate) = value_at_path(value, path) else {
continue;
};
if let Some(items) = candidate.as_array() {
return Some(items.clone());
}
}
None
}
fn first_anytxt_fields(value: &Value, paths: &[&[&str]]) -> Option<Vec<String>> {
for path in paths {
let Some(candidate) = value_at_path(value, path) else {
continue;
};
let Some(items) = candidate.as_array() else {
continue;
};
let fields = items
.iter()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
if !fields.is_empty() {
return Some(fields);
}
}
None
}
fn value_at_path<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> {
let mut current = value;
for key in path {
current = current.get(*key)?;
}
Some(current)
}
fn normalize_anytxt_record(item: Value, fields: &[String]) -> serde_json::Map<String, Value> {
match item {
Value::Object(object) => object,
Value::Array(row) if !fields.is_empty() => fields
.iter()
.cloned()
.zip(row)
.collect::<serde_json::Map<String, Value>>(),
other => {
let mut object = serde_json::Map::new();
object.insert("text".to_string(), other);
object
}
}
}
fn string_field(record: &serde_json::Map<String, Value>, keys: &[&str]) -> String {
for key in keys {
let Some(value) = record.get(*key) else {
continue;
};
if let Some(text) = value.as_str().filter(|text| !text.trim().is_empty()) {
return text.trim().to_string();
}
if let Some(number) = value.as_i64() {
return number.to_string();
}
if let Some(number) = value.as_u64() {
return number.to_string();
}
}
String::new()
}
async fn get_anytxt_fragment(
client: &reqwest::Client,
endpoint: &str,
fid: &str,
pattern: &str,
) -> Result<String, String> {
let response = client
.post(endpoint)
.header("Accept", "application/json")
.json(&json!({
"id": 2,
"jsonrpc": "2.0",
"method": "ATRpcServer.Searcher.V1.GetFragment",
"params": { "input": { "fid": fid, "pattern": pattern } }
}))
.send()
.await
.map_err(|err| format!("AnyTXT fragment failed: {err}"))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| format!("Failed to read AnyTXT fragment response: {err}"))?;
if !status.is_success() {
return Err(format!(
"AnyTXT fragment HTTP {status}: {}",
trim_text(&text, 300)
));
}
let value: Value = serde_json::from_str(&text).map_err(|_| {
format!(
"AnyTXT fragment returned invalid JSON: {}",
trim_text(&text, 300)
)
})?;
if let Some(error) = value.get("error") {
return Err(format!(
"AnyTXT fragment error: {}",
trim_text(&error.to_string(), 300)
));
}
Ok(extract_anytxt_fragment_text(
value.get("result").unwrap_or(&Value::Null),
))
}
fn extract_anytxt_fragment_text(value: &Value) -> String {
if let Some(text) = value.as_str() {
return text.to_string();
}
if let Some(items) = value.as_array() {
return items
.iter()
.map(extract_anytxt_fragment_text)
.filter(|item| !item.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n");
}
let Some(object) = value.as_object() else {
return String::new();
};
for key in ["text", "fragment", "content", "snippet", "html"] {
if let Some(text) = object.get(key).and_then(Value::as_str) {
return text.to_string();
}
}
for key in ["output", "result", "data", "fragments", "items", "list"] {
if let Some(next) = object.get(key) {
let text = extract_anytxt_fragment_text(next);
if !text.trim().is_empty() {
return text;
}
}
}
String::new()
}
fn normalize_anytxt_endpoint(value: &str) -> String {
if value.starts_with("http://") || value.starts_with("https://") {
value.to_string()
} else {
format!("http://{value}")
}
}
#[derive(Debug, Clone)]
struct WebSearchItem {
title: String,
url: String,
snippet: String,
}
async fn firecrawl_search(
client: &reqwest::Client,
query: &str,
config: &WebSearchConfig,
max_results: usize,
) -> Result<Vec<WebSearchItem>, String> {
let override_cfg = config
.provider_configs
.as_ref()
.and_then(|values| values.get("firecrawl"));
let base = override_cfg
.and_then(|value| value.base_url.as_deref())
.filter(|value| !value.trim().is_empty())
.unwrap_or("https://api.firecrawl.dev")
.trim_end_matches('/');
let mut request = client
.post(format!("{base}/v2/search"))
.header("Accept", "application/json");
if let Some(key) = override_cfg
.and_then(|value| value.api_key.as_deref())
.filter(|value| !value.trim().is_empty())
{
request = request.bearer_auth(key.trim());
}
let response = request
.json(&json!({ "query": query, "limit": max_results }))
.send()
.await
.map_err(|err| format!("Network error reaching Firecrawl Search: {err}"))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| format!("Failed to read Firecrawl response: {err}"))?;
let parsed: Value = serde_json::from_str(&text).map_err(|_| {
format!(
"Firecrawl search returned invalid JSON: {}",
trim_text(&text, 300)
)
})?;
if !status.is_success() || parsed.get("success").and_then(Value::as_bool) == Some(false) {
let msg = parsed
.get("error")
.and_then(Value::as_str)
.map(friendly_firecrawl_error)
.unwrap_or_else(|| format!("Firecrawl search failed ({status})"));
return Err(msg);
}
let items = extract_web_items(&parsed, &["data", "results"]);
Ok(items.into_iter().map(normalize_web_result).collect())
}
async fn searxng_search(
client: &reqwest::Client,
query: &str,
config: &WebSearchConfig,
max_results: usize,
) -> Result<Vec<WebSearchItem>, String> {
let base = config
.sear_xng_url
.as_deref()
.filter(|v| !v.trim().is_empty())
.ok_or_else(|| "SearXNG URL is required for web.search".to_string())?;
let mut url = normalize_searxng_url(base)?;
let categories = config
.sear_xng_categories
.clone()
.unwrap_or_else(|| vec!["general".to_string()]);
url.push_str(&format!(
"?q={}&format=json&categories={}",
url_encode(query),
url_encode(&categories.join(","))
));
let response = client
.get(&url)
.header("Accept", "application/json")
.send()
.await
.map_err(|err| format!("Network error reaching SearXNG: {err}"))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| format!("Failed to read SearXNG response: {err}"))?;
if !status.is_success() {
return Err(format!(
"SearXNG search failed ({status}): {}",
trim_text(&text, 300)
));
}
let parsed: Value = serde_json::from_str(&text)
.map_err(|_| format!("SearXNG returned invalid JSON: {}", trim_text(&text, 300)))?;
let items = parsed
.get("results")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
Ok(items
.into_iter()
.take(max_results)
.map(normalize_web_result)
.collect())
}
async fn tavily_search(
client: &reqwest::Client,
query: &str,
config: &WebSearchConfig,
max_results: usize,
) -> Result<Vec<WebSearchItem>, String> {
let key = required_api_key(config, "Tavily")?;
let response = client
.post("https://api.tavily.com/search")
.json(&json!({
"api_key": key,
"query": query,
"max_results": max_results,
"search_depth": "advanced",
"include_answer": false
}))
.send()
.await
.map_err(|err| format!("Network error reaching Tavily: {err}"))?;
parse_web_json_response(response, "Tavily", |value| {
value
.get("results")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
.into_iter()
.map(normalize_web_result)
.collect()
})
.await
}
async fn ollama_search(
client: &reqwest::Client,
query: &str,
config: &WebSearchConfig,
max_results: usize,
) -> Result<Vec<WebSearchItem>, String> {
let key = required_api_key(config, "Ollama")?;
let base = config
.ollama_url
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or("https://ollama.com")
.trim()
.trim_end_matches('/');
let url = format!("{base}/api/web_search");
let response = client
.post(url)
.header("Accept", "application/json")
.bearer_auth(key)
.json(&json!({
"query": query,
"max_results": max_results
}))
.send()
.await
.map_err(|err| format!("Network error reaching Ollama Web Search: {err}"))?;
parse_web_json_response(response, "Ollama Web Search", |value| {
value
.get("results")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
.into_iter()
.map(normalize_web_result)
.collect()
})
.await
}
async fn brave_search(
client: &reqwest::Client,
query: &str,
config: &WebSearchConfig,
max_results: usize,
) -> Result<Vec<WebSearchItem>, String> {
let key = required_api_key(config, "Brave")?;
let url = format!(
"https://api.search.brave.com/res/v1/web/search?q={}&count={}",
url_encode(query),
max_results.min(20)
);
let response = client
.get(url)
.header("Accept", "application/json")
.header("X-Subscription-Token", key)
.send()
.await
.map_err(|err| format!("Network error reaching Brave Search: {err}"))?;
parse_web_json_response(response, "Brave Search", |value| {
value
.get("web")
.and_then(|web| web.get("results"))
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
.into_iter()
.map(normalize_web_result)
.collect()
})
.await
}
async fn bocha_search(
client: &reqwest::Client,
query: &str,
config: &WebSearchConfig,
max_results: usize,
) -> Result<Vec<WebSearchItem>, String> {
let key = required_api_key(config, "Bocha")?;
let response = client
.post("https://api.bocha.cn/v1/web-search")
.header("Accept", "application/json")
.bearer_auth(key)
.json(&json!({
"query": query,
"freshness": "noLimit",
"summary": true,
"count": max_results.clamp(1, 50)
}))
.send()
.await
.map_err(|err| format!("Network error reaching Bocha Search: {err}"))?;
parse_web_json_response(response, "Bocha Search", parse_bocha_results).await
}
fn parse_bocha_results(value: Value) -> Vec<WebSearchItem> {
value
.get("data")
.and_then(|data| data.get("webPages"))
.and_then(|pages| pages.get("value"))
.and_then(Value::as_array)
.into_iter()
.flatten()
.map(|item| WebSearchItem {
title: item
.get("name")
.and_then(Value::as_str)
.unwrap_or("Untitled")
.to_string(),
url: item
.get("url")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
snippet: item
.get("summary")
.and_then(Value::as_str)
.or_else(|| item.get("snippet").and_then(Value::as_str))
.unwrap_or("")
.to_string(),
})
.collect()
}
async fn serpapi_search(
client: &reqwest::Client,
query: &str,
config: &WebSearchConfig,
max_results: usize,
) -> Result<Vec<WebSearchItem>, String> {
let key = required_api_key(config, "SerpApi")?;
let engine = config.serp_api_engine.as_deref().unwrap_or("google");
let url = format!(
"https://serpapi.com/search?engine={}&q={}&api_key={}&num={}",
url_encode(engine),
url_encode(query),
url_encode(key),
max_results
);
let response = client
.get(url)
.header("Accept", "application/json")
.send()
.await
.map_err(|err| format!("Network error reaching SerpApi: {err}"))?;
parse_web_json_response(response, "SerpApi", |value| {
for key in [
"organic_results",
"news_results",
"images_results",
"video_results",
"videos_results",
"shopping_results",
] {
if let Some(items) = value.get(key).and_then(Value::as_array) {
return items.iter().cloned().map(normalize_web_result).collect();
}
}
Vec::new()
})
.await
}
async fn parse_web_json_response(
response: reqwest::Response,
provider: &str,
parse: impl FnOnce(Value) -> Vec<WebSearchItem>,
) -> Result<Vec<WebSearchItem>, String> {
let status = response.status();
let text = response
.text()
.await
.map_err(|err| format!("Failed to read {provider} response: {err}"))?;
if !status.is_success() {
return Err(format!(
"{provider} search failed ({status}): {}",
trim_text(&text, 300)
));
}
let value: Value = serde_json::from_str(&text).map_err(|_| {
format!(
"{provider} returned invalid JSON: {}",
trim_text(&text, 300)
)
})?;
if let Some(error) = value.get("error").and_then(Value::as_str) {
return Err(format!("{provider} search failed: {error}"));
}
if let Some(message) = provider_payload_error(provider, &value) {
return Err(message);
}
Ok(parse(value))
}
fn provider_payload_error(provider: &str, value: &Value) -> Option<String> {
if provider == "Bocha Search" {
let code = value.get("code").and_then(Value::as_i64);
if code != Some(200) {
let message = value
.get("msg")
.and_then(Value::as_str)
.filter(|message| !message.trim().is_empty())
.unwrap_or("unknown API error");
return Some(format!(
"{provider} failed (code {}): {message}",
code.unwrap_or(0)
));
}
}
if provider == "Brave Search" && value.get("web").is_none() {
let message = value.get("message").and_then(Value::as_str)?;
return Some(format!("{provider} search failed: {message}"));
}
None
}
fn web_items_to_references(raw: Vec<WebSearchItem>, max_results: usize) -> Vec<AgentReference> {
raw.into_iter()
.take(max_results)
.filter(|item| !item.url.trim().is_empty())
.map(|item| AgentReference {
title: item.title,
path: item.url,
kind: "web".to_string(),
snippet: Some(item.snippet).filter(|s| !s.trim().is_empty()),
score: None,
knowledge_context: None,
})
.collect()
}
fn normalize_web_result(value: Value) -> WebSearchItem {
let metadata = value.get("metadata");
let title = value
.get("title")
.or_else(|| metadata.and_then(|m| m.get("title")))
.and_then(Value::as_str)
.unwrap_or("Untitled")
.to_string();
let url = value
.get("url")
.or_else(|| value.get("link"))
.or_else(|| metadata.and_then(|m| m.get("sourceURL")))
.or_else(|| metadata.and_then(|m| m.get("url")))
.or_else(|| value.get("original"))
.or_else(|| value.get("thumbnail"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let snippet = value
.get("snippet")
.or_else(|| value.get("content"))
.or_else(|| value.get("description"))
.or_else(|| metadata.and_then(|m| m.get("description")))
.or_else(|| value.get("summary"))
.or_else(|| value.get("markdown"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
WebSearchItem {
title,
url,
snippet,
}
}
fn extract_web_items(value: &Value, keys: &[&str]) -> Vec<Value> {
for key in keys {
let Some(candidate) = value.get(*key) else {
continue;
};
if let Some(items) = candidate.as_array() {
return items.clone();
}
if let Some(items) = extract_nested_web_items(candidate) {
return items;
}
}
Vec::new()
}
fn extract_nested_web_items(value: &Value) -> Option<Vec<Value>> {
let object = value.as_object()?;
for key in ["web", "results", "items"] {
if let Some(items) = object.get(key).and_then(Value::as_array) {
return Some(items.clone());
}
}
None
}
fn required_api_key<'a>(config: &'a WebSearchConfig, provider: &str) -> Result<&'a str, String> {
let key = config.api_key.trim();
if key.is_empty() {
Err(format!(
"{provider} web.search requires an API key in Settings."
))
} else {
Ok(key)
}
}
fn normalize_searxng_url(value: &str) -> Result<String, String> {
let trimmed = value.trim().trim_end_matches('/');
if trimmed.is_empty() {
return Err("SearXNG URL is required".to_string());
}
let mut url = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
trimmed.to_string()
} else {
format!("https://{trimmed}")
};
if !url.ends_with("/search") {
url.push_str("/search");
}
Ok(url)
}
fn friendly_firecrawl_error(error: &str) -> String {
if error
.to_ascii_lowercase()
.contains("ip address looks suspicious")
{
"Firecrawl Search rejected this IP for key-free access. Add a Firecrawl API key in Settings or choose another Web Search provider.".to_string()
} else {
format!("Firecrawl search failed: {error}")
}
}
#[derive(Debug)]
struct KnowledgePage {
path: String,
title: String,
stem: String,
tags: Vec<String>,
links: Vec<String>,
matches_query: bool,
neighbors: Vec<String>,
}
/// Build one bounded graph/provenance index for a complete retrieval tool
/// invocation. Never call this once per result: large projects would otherwise
/// turn top-k retrieval into top-k full filesystem scans.
fn build_knowledge_context_index(project_path: &str) -> BTreeMap<String, AgentKnowledgeContext> {
build_knowledge_graph_snapshot(project_path, None).1
}
fn build_knowledge_graph_snapshot(
project_path: &str,
query: Option<&str>,
) -> (Vec<KnowledgePage>, BTreeMap<String, AgentKnowledgeContext>) {
let wiki_root = Path::new(project_path).join("wiki");
if !wiki_root.is_dir() {
return (Vec::new(), BTreeMap::new());
}
let mut pages = Vec::new();
for entry in WalkDir::new(&wiki_root).into_iter().filter_map(Result::ok) {
if pages.len() >= MAX_GRAPH_SEARCH_FILES
|| !entry.file_type().is_file()
|| entry.path().extension().and_then(|value| value.to_str()) != Some("md")
{
continue;
}
let Ok(content) = fs::read_to_string(entry.path()) else {
continue;
};
let path = relative_to_project(project_path, entry.path());
if is_hidden_rel(&path) {
continue;
}
let title = search::extract_title(&content, entry.file_name().to_string_lossy().as_ref());
let matches_query = query.is_some_and(|query| {
let haystack = format!("{title} {path} {content}").to_lowercase();
haystack.contains(query)
|| graph_query_terms(query)
.iter()
.any(|term| haystack.contains(term))
});
pages.push(KnowledgePage {
path,
title,
stem: entry
.path()
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_string(),
tags: extract_frontmatter_list(&content, "tags"),
links: extract_wikilinks(&content),
matches_query,
neighbors: Vec::new(),
});
}
let mut aliases: BTreeMap<String, String> = BTreeMap::new();
for page in &pages {
let wiki_relative_path = page.path.strip_prefix("wiki/").unwrap_or(&page.path);
for alias in [&page.stem, &page.title, &page.path, wiki_relative_path] {
aliases.insert(normalize_wiki_link(alias), page.path.clone());
}
}
let mut backlinks: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
let mut neighbors: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for page in &pages {
for link in &page.links {
if let Some(target) = aliases.get(&normalize_wiki_link(link)) {
if target != &page.path {
backlinks
.entry(target.clone())
.or_default()
.insert(page.path.clone());
neighbors
.entry(page.path.clone())
.or_default()
.insert(target.clone());
neighbors
.entry(target.clone())
.or_default()
.insert(page.path.clone());
}
}
}
}
for page in &mut pages {
page.neighbors = neighbors
.remove(&page.path)
.unwrap_or_default()
.into_iter()
.take(MAX_KNOWLEDGE_CONTEXT_ITEMS)
.collect();
}
let contexts = pages
.iter()
.map(|page| {
let mut outgoing_links = page.links.clone();
outgoing_links.sort();
outgoing_links.dedup();
let total_outgoing = outgoing_links.len();
outgoing_links.truncate(MAX_KNOWLEDGE_CONTEXT_ITEMS);
let mut page_backlinks: Vec<String> = backlinks
.remove(&page.path)
.unwrap_or_default()
.into_iter()
.collect();
let total_backlinks = page_backlinks.len();
page_backlinks.truncate(MAX_KNOWLEDGE_CONTEXT_ITEMS);
let link_count = total_outgoing + total_backlinks;
(
page.path.clone(),
AgentKnowledgeContext {
related_to: Vec::new(),
tags: page
.tags
.iter()
.cloned()
.take(MAX_KNOWLEDGE_CONTEXT_ITEMS)
.collect(),
outgoing_links,
backlinks: page_backlinks,
link_count,
latest_version: None,
},
)
})
.collect();
(pages, contexts)
}
fn attach_latest_version(
project_path: &str,
relative_path: &str,
context: &mut Option<AgentKnowledgeContext>,
) {
let Some(context) = context else {
return;
};
context.latest_version = crate::commands::file_history::latest_file_version(
&Path::new(project_path).join(relative_path),
)
.map(|(timestamp, author, tool)| AgentVersionSummary {
timestamp,
author,
tool,
});
}
fn normalize_wiki_link(value: &str) -> String {
value
.split('#')
.next()
.unwrap_or_default()
.trim()
.trim_end_matches(".md")
.replace('\\', "/")
.to_lowercase()
.replace(' ', "-")
}
fn graph_query_terms(query: &str) -> Vec<String> {
query
.split(|character: char| {
character.is_whitespace()
|| matches!(character, ',' | '' | ';' | '' | ':' | '' | '/' | '|')
})
.map(str::trim)
.filter(|term| !term.is_empty())
.map(str::to_string)
.collect()
}
fn extract_wikilinks(content: &str) -> Vec<String> {
let mut links = Vec::new();
let mut rest = content;
while let Some(start) = rest.find("[[") {
rest = &rest[start + 2..];
let Some(end) = rest.find("]]") else {
break;
};
let target = rest[..end].split('|').next().unwrap_or_default().trim();
if !target.is_empty() {
links.push(target.to_string());
}
rest = &rest[end + 2..];
}
links
}
fn extract_frontmatter_list(content: &str, key: &str) -> Vec<String> {
let normalized = content.replace("\r\n", "\n");
let Some(rest) = normalized.strip_prefix("---\n") else {
return Vec::new();
};
let Some(end) = rest.find("\n---") else {
return Vec::new();
};
let prefix = format!("{key}:");
let lines: Vec<&str> = rest[..end].lines().collect();
for (index, line) in lines.iter().enumerate() {
let Some(value) = line.trim().strip_prefix(&prefix) else {
continue;
};
let inline = value.trim();
if inline.starts_with('[') && inline.ends_with(']') {
return inline[1..inline.len() - 1]
.split(',')
.map(|item| item.trim().trim_matches(['\'', '"']).to_string())
.filter(|item| !item.is_empty())
.collect();
}
let mut values = Vec::new();
for next in lines.iter().skip(index + 1) {
let trimmed = next.trim();
let Some(item) = trimmed.strip_prefix('-') else {
break;
};
let item = item.trim().trim_matches(['\'', '"']);
if !item.is_empty() {
values.push(item.to_string());
}
}
return values;
}
Vec::new()
}
pub fn read_wiki_page(project_path: &str, rel_path: &str) -> Result<String, String> {
let rel = normalize_rel_path(rel_path);
if !is_public_read_rel(&rel) || !rel.to_ascii_lowercase().starts_with("wiki/") {
return Err("wiki.read_page path must stay under wiki/".to_string());
}
let path = safe_project_join(project_path, &rel)?;
let meta = fs::metadata(&path).map_err(|err| format!("Failed to read page metadata: {err}"))?;
if !meta.is_file() {
return Err("wiki.read_page path is not a file".to_string());
}
if meta.len() as usize > MAX_READ_PAGE_BYTES {
return Err("wiki.read_page file is too large".to_string());
}
fs::read_to_string(path).map_err(|err| format!("Failed to read wiki page: {err}"))
}
pub fn search_graph(
project_path: &str,
query: &str,
top_k: usize,
) -> Result<Vec<AgentReference>, String> {
let query = query.trim().to_lowercase();
if query.is_empty() {
return Ok(Vec::new());
}
let (pages, mut context_index) = build_knowledge_graph_snapshot(project_path, Some(&query));
let seed_paths: BTreeSet<String> = pages
.iter()
.filter(|page| page.matches_query)
.map(|page| page.path.clone())
.collect();
if seed_paths.is_empty() {
return Ok(Vec::new());
}
let mut refs = Vec::new();
for page in pages {
let Some(page_context) = context_index.get(&page.path) else {
continue;
};
let link_count = page_context.link_count;
let connected_to_seed = page
.neighbors
.iter()
.any(|neighbor| seed_paths.contains(neighbor));
if !page.matches_query && !connected_to_seed {
continue;
}
let rel = page.path;
let relation = if page.matches_query {
"matched entity"
} else {
"direct neighbor"
};
refs.push(AgentReference {
title: page.title,
path: rel.clone(),
kind: "graph".to_string(),
snippet: Some(format!("{relation}; {link_count} related link(s)")),
score: Some(
if page.matches_query {
10_000.0
} else {
5_000.0
} + link_count as f64,
),
knowledge_context: {
let mut context = context_index.remove(&rel);
attach_latest_version(project_path, &rel, &mut context);
context
},
});
}
refs.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.path.cmp(&b.path))
});
refs.truncate(top_k.clamp(1, 10));
Ok(refs)
}
pub fn search_sources(
project_path: &str,
query: &str,
top_k: usize,
) -> Result<Vec<AgentReference>, String> {
let query = query.trim();
if query.is_empty() {
return Err("source.search query is required".to_string());
}
let root = Path::new(project_path).join("raw").join("sources");
if !root.exists() {
return Ok(Vec::new());
}
let lower_query = query.to_lowercase();
let query_terms = source_query_terms(&lower_query);
let mut refs = Vec::new();
let mut seen_files = 0usize;
for entry in WalkDir::new(&root).into_iter().filter_map(Result::ok) {
if !entry.file_type().is_file() {
continue;
}
let rel = relative_to_project(project_path, entry.path());
if is_hidden_rel(&rel) {
continue;
}
seen_files += 1;
if seen_files > MAX_SOURCE_SEARCH_FILES {
eprintln!(
"[Agent] source.search stopped after {MAX_SOURCE_SEARCH_FILES} files in {project_path}"
);
break;
}
let Some(ext) = entry
.path()
.extension()
.and_then(|s| s.to_str())
.map(|s| s.to_ascii_lowercase())
else {
continue;
};
let content = if matches!(
ext.as_str(),
"md" | "markdown"
| "org"
| "txt"
| "json"
| "csv"
| "tsv"
| "yaml"
| "yml"
| "xml"
| "html"
) {
let Ok(content) = fs::read_to_string(entry.path()) else {
continue;
};
content
} else if matches!(
ext.as_str(),
"pdf"
| "doc"
| "docx"
| "pptx"
| "xls"
| "xlsx"
| "odt"
| "ods"
| "odp"
| "epub"
| "mobi"
) {
let Some(content) = crate::commands::fs::read_preprocessed_cache(entry.path()) else {
continue;
};
content
} else {
continue;
};
let lower = content.to_lowercase();
let matched = std::iter::once(lower_query.as_str())
.chain(query_terms.iter().map(String::as_str))
.find_map(|term| lower.find(term).map(|idx| (idx, term.len())));
let Some((byte_idx, _matched_len)) = matched else {
continue;
};
refs.push(AgentReference {
title: entry
.path()
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(&rel)
.to_string(),
path: rel,
kind: "source".to_string(),
snippet: Some(snippet_around_byte(
&content,
byte_idx,
MAX_SOURCE_SNIPPET_CHARS,
)),
score: None,
knowledge_context: None,
});
if refs.len() >= top_k.clamp(1, 10) {
break;
}
}
Ok(refs)
}
fn source_query_terms(query: &str) -> Vec<String> {
query
.split(|c: char| c.is_whitespace() || matches!(c, ',' | '' | ';' | '' | ':' | ''))
.map(str::trim)
.filter(|term| term.chars().count() >= 2)
.filter(|term| {
!matches!(
*term,
"raw"
| "source"
| "sources"
| "file"
| "files"
| "原始资料"
| "原始文件"
| "源文件"
)
})
.map(ToString::to_string)
.collect()
}
fn safe_project_join(project_path: &str, rel: &str) -> Result<PathBuf, String> {
let root = Path::new(project_path);
let rel_path = Path::new(rel);
if rel_path.is_absolute()
|| rel_path
.components()
.any(|component| matches!(component, Component::ParentDir | Component::Prefix(_)))
{
return Err("path must be project-relative".to_string());
}
let joined = root.join(rel_path);
if joined.exists() {
let root_canon = root
.canonicalize()
.map_err(|err| format!("Failed to resolve project path: {err}"))?;
let joined_canon = joined
.canonicalize()
.map_err(|err| format!("Failed to resolve requested path: {err}"))?;
if !joined_canon.starts_with(root_canon) {
return Err("path escapes project directory".to_string());
}
}
Ok(joined)
}
fn ensure_existing_ancestor_bound(project_path: &str, path: &Path) -> Result<(), String> {
let mut cursor = path;
while !cursor.exists() {
cursor = cursor
.parent()
.ok_or_else(|| "path must have an existing project ancestor".to_string())?;
}
ensure_project_bound_path(project_path, cursor)
}
fn ensure_project_bound_path(project_path: &str, path: &Path) -> Result<(), String> {
let root_canon = Path::new(project_path)
.canonicalize()
.map_err(|err| format!("Failed to resolve project path: {err}"))?;
let path_canon = path
.canonicalize()
.map_err(|err| format!("Failed to resolve requested path: {err}"))?;
if !path_canon.starts_with(root_canon) {
return Err("path escapes project directory".to_string());
}
Ok(())
}
fn is_public_read_rel(rel: &str) -> bool {
let lower = rel.to_ascii_lowercase();
if lower.split('/').any(|segment| segment.starts_with('.')) {
return false;
}
lower == "purpose.md"
|| lower == "schema.md"
|| lower.starts_with("wiki/")
|| lower.starts_with("raw/sources/")
}
fn is_hidden_rel(rel: &str) -> bool {
normalize_rel_path(rel)
.split('/')
.any(|segment| segment.starts_with('.'))
}
fn normalize_rel_path(path: &str) -> String {
path.trim()
.replace('\\', "/")
.trim_start_matches('/')
.to_string()
}
fn normalize_wiki_write_path(path: &str) -> Result<String, String> {
let rel = normalize_rel_path(path);
let lower = rel.to_ascii_lowercase();
if !lower.starts_with("wiki/") || !lower.ends_with(".md") {
return Err("wiki.write_page path must be a Markdown file under wiki/".to_string());
}
if lower.split('/').any(|segment| segment.starts_with('.')) {
return Err("wiki.write_page cannot write hidden paths".to_string());
}
let rel_path = Path::new(&rel);
if rel_path.is_absolute()
|| rel_path
.components()
.any(|component| matches!(component, Component::ParentDir | Component::Prefix(_)))
{
return Err("wiki.write_page path must stay inside the project".to_string());
}
for segment in rel.split('/') {
validate_portable_path_segment(segment)?;
}
Ok(rel)
}
fn normalize_workspace_write_path(path: &str) -> Result<String, String> {
let rel = normalize_rel_path(path);
let lower = rel.to_ascii_lowercase();
if rel.is_empty()
|| lower.starts_with("wiki/")
|| lower.starts_with("raw/")
|| lower.split('/').any(|segment| segment.starts_with('.'))
{
return Err(
"workspace.write_file path must be a relative file under agent-workspace".to_string(),
);
}
let rel_path = Path::new(&rel);
if rel_path.is_absolute()
|| rel_path
.components()
.any(|component| matches!(component, Component::ParentDir | Component::Prefix(_)))
{
return Err("workspace.write_file path must stay inside agent-workspace".to_string());
}
for segment in rel.split('/') {
validate_workspace_path_segment(segment)?;
}
Ok(rel)
}
fn validate_workspace_path_segment(segment: &str) -> Result<(), String> {
validate_portable_path_segment(segment)
.map_err(|err| err.replace("wiki.write_page", "workspace.write_file"))
}
fn validate_portable_path_segment(segment: &str) -> Result<(), String> {
if segment.is_empty() {
return Err("wiki.write_page path contains an empty segment".to_string());
}
if segment.ends_with([' ', '.']) {
return Err(
"wiki.write_page path contains a segment ending with a space or dot, which is not portable to Windows"
.to_string(),
);
}
if segment
.chars()
.any(|ch| matches!(ch, '<' | '>' | ':' | '"' | '|' | '?' | '*') || ch <= '\u{1f}')
{
return Err(
"wiki.write_page path contains characters that are invalid on Windows".to_string(),
);
}
let stem = segment
.split('.')
.next()
.unwrap_or(segment)
.trim_end_matches(' ')
.to_ascii_uppercase();
if matches!(
stem.as_str(),
"CON"
| "PRN"
| "AUX"
| "NUL"
| "COM1"
| "COM2"
| "COM3"
| "COM4"
| "COM5"
| "COM6"
| "COM7"
| "COM8"
| "COM9"
| "LPT1"
| "LPT2"
| "LPT3"
| "LPT4"
| "LPT5"
| "LPT6"
| "LPT7"
| "LPT8"
| "LPT9"
) {
return Err("wiki.write_page path uses a Windows reserved device name".to_string());
}
Ok(())
}
fn extract_markdown_title(content: &str) -> Option<String> {
for line in content.lines().take(80) {
let trimmed = line.trim();
if let Some(title) = trimmed.strip_prefix("title:") {
let title = title.trim().trim_matches('"').trim_matches('\'');
if !title.is_empty() {
return Some(title.to_string());
}
}
if let Some(heading) = trimmed.strip_prefix("# ") {
let heading = heading.trim();
if !heading.is_empty() {
return Some(heading.to_string());
}
}
}
None
}
fn collapse_markdown_preview(content: &str) -> String {
content
.lines()
.filter(|line| {
let trimmed = line.trim();
!trimmed.is_empty() && trimmed != "---" && !trimmed.starts_with("title:")
})
.take(12)
.collect::<Vec<_>>()
.join(" ")
}
fn relative_to_project(project_path: &str, path: &Path) -> String {
path.strip_prefix(project_path)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
.trim_start_matches('/')
.to_string()
}
fn snippet_around_byte(content: &str, byte_idx: usize, max_chars: usize) -> String {
let char_idx = content[..byte_idx.min(content.len())].chars().count();
let start = char_idx.saturating_sub(max_chars / 2);
let mut snippet = content
.chars()
.skip(start)
.take(max_chars)
.collect::<String>();
if start > 0 {
snippet.insert_str(0, "...");
}
if content.chars().count() > start + max_chars {
snippet.push_str("...");
}
snippet.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn trim_text(value: &str, max_chars: usize) -> String {
if value.chars().count() <= max_chars {
value.to_string()
} else {
format!("{}...", value.chars().take(max_chars).collect::<String>())
}
}
fn url_encode(value: &str) -> String {
value
.bytes()
.flat_map(|byte| match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
vec![byte as char]
}
b' ' => vec!['+'],
_ => format!("%{byte:02X}").chars().collect(),
})
.collect()
}
#[cfg(test)]
mod tests {
use std::fs;
use uuid::Uuid;
use super::*;
#[test]
fn builtin_tool_specs_include_expected_tools() {
let names = builtin_tool_specs()
.into_iter()
.map(|tool| tool.name)
.collect::<Vec<_>>();
assert!(names.contains(&"wiki.search".to_string()));
assert!(names.contains(&"wiki.read_page".to_string()));
assert!(names.contains(&"source.search".to_string()));
assert!(names.contains(&"graph.search".to_string()));
assert!(names.contains(&"anytxt.search".to_string()));
assert!(names.contains(&"wiki.write_page".to_string()));
assert!(names.contains(&"llm.generate".to_string()));
assert!(names.contains(&"skills.load".to_string()));
assert!(names.contains(&"skill.read_file".to_string()));
assert!(names.contains(&"workspace.write_file".to_string()));
assert!(names.contains(&"workspace.append_file".to_string()));
assert!(names.contains(&"shell.exec".to_string()));
}
#[test]
fn read_wiki_page_rejects_traversal() {
let err = read_wiki_page("/tmp/project", "../secret.md").unwrap_err();
assert!(err.contains("wiki.read_page"));
}
#[test]
fn knowledge_context_indexes_tags_links_backlinks_and_latest_version() {
let root = std::env::temp_dir().join(format!("llm-wiki-context-{}", Uuid::new_v4()));
fs::create_dir_all(root.join(".llm-wiki")).unwrap();
fs::create_dir_all(root.join("wiki/entities")).unwrap();
let alpha = root.join("wiki/entities/alpha.md");
let beta = root.join("wiki/entities/beta.md");
fs::write(
&alpha,
"---\r\ntitle: Alpha\r\ntags: [core, test]\r\n---\r\n# Alpha\r\n\r\n[[Beta]]",
)
.unwrap();
fs::write(
&beta,
"---\ntitle: Beta\ntags:\n - linked\n---\n# Beta\n\n[[entities/alpha]]",
)
.unwrap();
crate::commands::file_history::record_file_version(&alpha, "agent", "test.write");
let mut contexts = build_knowledge_context_index(root.to_str().unwrap());
let mut alpha_context = contexts.remove("wiki/entities/alpha.md");
attach_latest_version(
root.to_str().unwrap(),
"wiki/entities/alpha.md",
&mut alpha_context,
);
let alpha_context = alpha_context.as_ref().unwrap();
let beta_context = contexts.get("wiki/entities/beta.md").unwrap();
assert_eq!(alpha_context.tags, vec!["core", "test"]);
assert_eq!(alpha_context.outgoing_links, vec!["Beta"]);
assert_eq!(alpha_context.backlinks, vec!["wiki/entities/beta.md"]);
assert_eq!(alpha_context.link_count, 2);
assert_eq!(
alpha_context.latest_version.as_ref().unwrap().tool,
"test.write"
);
assert_eq!(beta_context.tags, vec!["linked"]);
assert_eq!(beta_context.backlinks, vec!["wiki/entities/alpha.md"]);
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn registry_executes_declared_read_page_and_deep_tools() {
let root = std::env::temp_dir().join(format!("llm-wiki-tool-registry-{}", Uuid::new_v4()));
fs::create_dir_all(root.join("wiki").join("concepts")).unwrap();
fs::write(root.join("wiki/concepts/a.md"), "# A\n\nBody").unwrap();
let registry = BuiltinToolRegistry;
let context = ToolContext {
project_path: root.to_str().unwrap(),
embedding_config: None,
web_search_config: None,
anytxt_config: None,
};
let read = registry
.execute(
"wiki.read_page",
json!({ "path": "wiki/concepts/a.md" }),
context.clone(),
)
.await
.unwrap();
assert_eq!(read["path"], "wiki/concepts/a.md");
assert!(read["content"].as_str().unwrap().contains("Body"));
let deep = registry
.execute("deep_research.run", json!({ "query": "topic" }), context)
.await
.unwrap();
assert_eq!(deep["status"], "orchestrated_by_agent_runtime");
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn registry_executes_project_scoped_shell_command() {
let root = std::env::temp_dir().join(format!("llm-wiki-shell-tool-{}", Uuid::new_v4()));
fs::create_dir_all(&root).unwrap();
let registry = BuiltinToolRegistry;
let context = ToolContext {
project_path: root.to_str().unwrap(),
embedding_config: None,
web_search_config: None,
anytxt_config: None,
};
let output = registry
.execute(
"shell.exec",
json!({ "command": "echo skill-ok", "timeoutSeconds": 5 }),
context,
)
.await
.unwrap();
assert!(output["stdout"].as_str().unwrap().contains("skill-ok"));
assert_eq!(output["timedOut"], false);
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn shell_exec_timeout_returns_without_unbounded_wait() {
let root = std::env::temp_dir().join(format!("llm-wiki-shell-timeout-{}", Uuid::new_v4()));
fs::create_dir_all(&root).unwrap();
#[cfg(windows)]
let command = "ping -n 6 127.0.0.1 > nul";
#[cfg(not(windows))]
let command = "sleep 5";
let output = run_shell_exec(root.to_str().unwrap(), command, 1)
.await
.unwrap();
assert!(output.timed_out);
assert!(output.stderr.contains("timed out"));
let _ = fs::remove_dir_all(root);
}
#[cfg(not(windows))]
#[tokio::test]
async fn shell_exec_does_not_wait_forever_for_background_pipe_holders() {
let root = std::env::temp_dir().join(format!("llm-wiki-shell-bg-{}", Uuid::new_v4()));
fs::create_dir_all(&root).unwrap();
let output = timeout(
Duration::from_secs(3),
run_shell_exec(root.to_str().unwrap(), "sleep 5 &", 5),
)
.await
.expect("shell.exec should not hang on background grandchildren")
.unwrap();
assert!(output.stdout.contains("output was still open"));
let _ = fs::remove_dir_all(root);
}
#[cfg(not(windows))]
#[tokio::test]
async fn shell_exec_sanitizes_environment() {
let root = std::env::temp_dir().join(format!("llm-wiki-shell-env-{}", Uuid::new_v4()));
fs::create_dir_all(&root).unwrap();
std::env::set_var("LLM_WIKI_SECRET_TEST_SENTINEL", "must-not-leak");
let output = run_shell_exec(root.to_str().unwrap(), "env", 5)
.await
.unwrap();
std::env::remove_var("LLM_WIKI_SECRET_TEST_SENTINEL");
assert!(output
.stdout
.contains(&format!("LLM_WIKI_PROJECT={}", root.to_string_lossy())));
assert!(output.stdout.contains(&format!(
"LLM_WIKI_AGENT_WORKSPACE={}",
root.join(AGENT_WORKSPACE_DIR).to_string_lossy()
)));
assert!(!output.stdout.contains("LLM_WIKI_SECRET_TEST_SENTINEL="));
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn shell_exec_runs_from_visible_agent_workspace() {
let root =
std::env::temp_dir().join(format!("llm-wiki-shell-workspace-{}", Uuid::new_v4()));
fs::create_dir_all(&root).unwrap();
#[cfg(windows)]
let command = "cd && echo hello>generated.txt";
#[cfg(not(windows))]
let command = "pwd && printf hello > generated.txt";
let output = run_shell_exec(root.to_str().unwrap(), command, 5)
.await
.unwrap();
let workspace = root.join(AGENT_WORKSPACE_DIR);
assert!(workspace.is_dir());
assert_eq!(
fs::read_to_string(workspace.join("generated.txt"))
.unwrap()
.trim(),
"hello"
);
assert!(!root.join("generated.txt").exists());
assert!(output
.stdout
.replace('\\', "/")
.contains(&workspace.to_string_lossy().replace('\\', "/")));
assert!(output
.generated_files
.iter()
.any(|file| file.path == "agent-workspace/generated.txt"));
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn shell_exec_reports_changed_workspace_files() {
let root =
std::env::temp_dir().join(format!("llm-wiki-shell-generated-{}", Uuid::new_v4()));
fs::create_dir_all(&root).unwrap();
#[cfg(windows)]
let command = "mkdir images && echo image>images\\cover.png";
#[cfg(not(windows))]
let command = "mkdir -p images && printf image > images/cover.png";
let output = run_shell_exec(root.to_str().unwrap(), command, 5)
.await
.unwrap();
assert!(output
.generated_files
.iter()
.any(|file| { file.path == "agent-workspace/images/cover.png" && file.bytes > 0 }));
let _ = fs::remove_dir_all(root);
}
#[cfg(unix)]
#[tokio::test]
async fn shell_exec_rejects_symlinked_agent_workspace_escape() {
use std::os::unix::fs::symlink;
let root =
std::env::temp_dir().join(format!("llm-wiki-shell-workspace-link-{}", Uuid::new_v4()));
let outside = std::env::temp_dir().join(format!(
"llm-wiki-shell-workspace-outside-{}",
Uuid::new_v4()
));
fs::create_dir_all(&root).unwrap();
fs::create_dir_all(&outside).unwrap();
symlink(&outside, root.join(AGENT_WORKSPACE_DIR)).unwrap();
let err = run_shell_exec(root.to_str().unwrap(), "pwd", 5)
.await
.unwrap_err();
assert!(err.contains("escapes project directory"));
let _ = fs::remove_dir_all(root);
let _ = fs::remove_dir_all(outside);
}
#[test]
fn changed_workspace_files_detects_same_size_rewrites() {
let root =
std::env::temp_dir().join(format!("llm-wiki-shell-same-size-{}", Uuid::new_v4()));
let workspace = root.join(AGENT_WORKSPACE_DIR);
fs::create_dir_all(&workspace).unwrap();
let file = workspace.join("artifact.html");
fs::write(&file, "before").unwrap();
let before = snapshot_workspace_files(&workspace);
fs::write(&file, "after!").unwrap();
let changed = changed_workspace_files(&workspace, before);
assert!(changed
.iter()
.any(|file| file.path == "agent-workspace/artifact.html"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn workspace_write_file_writes_only_visible_agent_workspace_files() {
let root =
std::env::temp_dir().join(format!("llm-wiki-workspace-write-{}", Uuid::new_v4()));
fs::create_dir_all(&root).unwrap();
let written =
write_workspace_file(root.to_str().unwrap(), "cover-image/cover.svg", "<svg/>")
.unwrap();
assert_eq!(written.path, "agent-workspace/cover-image/cover.svg");
assert!(!written.existed_before);
assert_eq!(written.previous_content, None);
assert_eq!(
fs::read_to_string(root.join("agent-workspace/cover-image/cover.svg")).unwrap(),
"<svg/>"
);
assert!(write_workspace_file(root.to_str().unwrap(), "../escape.txt", "x").is_err());
assert!(write_workspace_file(root.to_str().unwrap(), "wiki/page.md", "x").is_err());
assert!(write_workspace_file(root.to_str().unwrap(), ".hidden/file.txt", "x").is_err());
assert!(write_workspace_file(root.to_str().unwrap(), "cover./file.txt", "x").is_err());
assert!(write_workspace_file(root.to_str().unwrap(), "cover /file.txt", "x").is_err());
assert!(write_workspace_file(
root.to_str().unwrap(),
"large.txt",
&"x".repeat(MAX_WORKSPACE_WRITE_BYTES + 1)
)
.unwrap_err()
.contains("too large"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn workspace_append_file_extends_visible_workspace_files() {
let root =
std::env::temp_dir().join(format!("llm-wiki-workspace-append-{}", Uuid::new_v4()));
fs::create_dir_all(&root).unwrap();
write_workspace_file(root.to_str().unwrap(), "ppt/index.html", "<html>").unwrap();
let appended =
append_workspace_file(root.to_str().unwrap(), "ppt/index.html", "</html>").unwrap();
assert_eq!(appended.path, "agent-workspace/ppt/index.html");
assert_eq!(appended.bytes, "<html></html>".len());
assert!(appended.existed_before);
assert_eq!(appended.previous_content.as_deref(), Some("<html>"));
assert_eq!(
fs::read_to_string(root.join("agent-workspace/ppt/index.html")).unwrap(),
"<html></html>"
);
assert!(append_workspace_file(root.to_str().unwrap(), "../escape.txt", "x").is_err());
assert!(append_workspace_file(root.to_str().unwrap(), ".hidden/file.txt", "x").is_err());
let _ = fs::remove_dir_all(root);
}
#[cfg(unix)]
#[test]
fn workspace_write_file_rejects_target_symlink_escape() {
use std::os::unix::fs::symlink;
let root =
std::env::temp_dir().join(format!("llm-wiki-workspace-symlink-{}", Uuid::new_v4()));
let outside =
std::env::temp_dir().join(format!("llm-wiki-workspace-outside-{}", Uuid::new_v4()));
fs::create_dir_all(root.join(AGENT_WORKSPACE_DIR)).unwrap();
fs::write(&outside, "original").unwrap();
symlink(&outside, root.join(AGENT_WORKSPACE_DIR).join("escape.txt")).unwrap();
assert!(
write_workspace_file(root.to_str().unwrap(), "escape.txt", "overwrite")
.unwrap_err()
.contains("symlink")
);
assert_eq!(fs::read_to_string(&outside).unwrap(), "original");
let _ = fs::remove_dir_all(root);
let _ = fs::remove_file(outside);
}
#[cfg(not(windows))]
#[tokio::test]
async fn shell_exec_caps_large_output() {
let root = std::env::temp_dir().join(format!("llm-wiki-shell-cap-{}", Uuid::new_v4()));
fs::create_dir_all(&root).unwrap();
let output = run_shell_exec(root.to_str().unwrap(), "printf '%050000d' 0", 5)
.await
.unwrap();
assert!(output.stdout.chars().count() <= MAX_SHELL_OUTPUT_CHARS + 3);
assert!(output.stdout.ends_with("..."));
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn shell_exec_rejects_invalid_inputs() {
let root = std::env::temp_dir().join(format!("llm-wiki-shell-invalid-{}", Uuid::new_v4()));
fs::create_dir_all(&root).unwrap();
assert!(run_shell_exec(root.to_str().unwrap(), "", 1)
.await
.unwrap_err()
.contains("empty"));
assert!(run_shell_exec(
root.to_str().unwrap(),
&"x".repeat(MAX_SHELL_COMMAND_CHARS + 1),
1
)
.await
.unwrap_err()
.contains("too long"));
let missing = root.join("missing");
assert!(run_shell_exec(missing.to_str().unwrap(), "echo no", 1)
.await
.unwrap_err()
.contains("not available"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn write_wiki_page_rejects_unsafe_paths_and_writes_markdown() {
let root = std::env::temp_dir().join(format!("llm-wiki-agent-write-{}", Uuid::new_v4()));
fs::create_dir_all(root.join("wiki")).unwrap();
assert!(write_wiki_page_with_options(
root.to_str().unwrap(),
"../secret.md",
"# Secret",
false
)
.is_err());
assert!(write_wiki_page_with_options(
root.to_str().unwrap(),
"raw/sources/a.md",
"# A",
false
)
.is_err());
assert!(write_wiki_page_with_options(
root.to_str().unwrap(),
"wiki/.hidden/a.md",
"# A",
false
)
.is_err());
assert!(
write_wiki_page_with_options(root.to_str().unwrap(), "wiki/aux.md", "# A", false)
.is_err()
);
assert!(
write_wiki_page_with_options(root.to_str().unwrap(), "wiki/con.md", "# A", false)
.is_err()
);
assert!(
write_wiki_page_with_options(root.to_str().unwrap(), "wiki/a:b.md", "# A", false)
.is_err()
);
assert!(
write_wiki_page_with_options(root.to_str().unwrap(), "wiki/a?b.md", "# A", false)
.is_err()
);
assert!(write_wiki_page_with_options(
root.to_str().unwrap(),
"wiki/topic./a.md",
"# A",
false
)
.is_err());
assert!(write_wiki_page_with_options(
root.to_str().unwrap(),
"wiki/topic /a.md",
"# A",
false
)
.is_err());
assert!(write_wiki_page_with_options(
root.to_str().unwrap(),
"wiki/queries/huge.md",
&"x".repeat(MAX_WRITE_PAGE_BYTES + 1),
false,
)
.is_err());
let reference = write_wiki_page_with_options(
root.to_str().unwrap(),
"wiki/queries/new-page.md",
"---\ntitle: New Page\n---\n# New Page\n\nBody",
false,
)
.unwrap();
assert_eq!(reference.title, "New Page");
assert_eq!(reference.path, "wiki/queries/new-page.md");
assert!(root.join("wiki/queries/new-page.md").exists());
let overwrite_err = write_wiki_page_with_options(
root.to_str().unwrap(),
"wiki/queries/new-page.md",
"# Replaced",
false,
)
.unwrap_err();
assert!(overwrite_err.contains("refuses to overwrite"));
let overwritten = write_wiki_page_with_options(
root.to_str().unwrap(),
"wiki/queries/new-page.md",
"# Replaced",
true,
)
.unwrap();
assert_eq!(overwritten.title, "Replaced");
let _ = fs::remove_dir_all(root);
}
#[test]
fn wiki_write_activity_captures_create_and_overwrite_state() {
let root =
std::env::temp_dir().join(format!("llm-wiki-agent-write-activity-{}", Uuid::new_v4()));
fs::create_dir_all(root.join("wiki")).unwrap();
let created = write_wiki_page_with_activity(
root.to_str().unwrap(),
"wiki/page.md",
"# Original",
false,
)
.unwrap();
assert!(!created.existed_before);
assert_eq!(created.previous_content, None);
let modified = write_wiki_page_with_activity(
root.to_str().unwrap(),
"wiki/page.md",
"# Updated",
true,
)
.unwrap();
assert!(modified.existed_before);
assert_eq!(modified.previous_content.as_deref(), Some("# Original"));
assert_eq!(modified.reference.path, "wiki/page.md");
let _ = fs::remove_dir_all(root);
}
#[cfg(unix)]
#[test]
fn write_wiki_page_rejects_symlink_parent_escape_for_new_files() {
use std::os::unix::fs::symlink;
let root = std::env::temp_dir().join(format!("llm-wiki-agent-symlink-{}", Uuid::new_v4()));
let outside =
std::env::temp_dir().join(format!("llm-wiki-agent-outside-{}", Uuid::new_v4()));
fs::create_dir_all(root.join("wiki")).unwrap();
fs::create_dir_all(&outside).unwrap();
symlink(&outside, root.join("wiki").join("linked")).unwrap();
let err = write_wiki_page_with_options(
root.to_str().unwrap(),
"wiki/linked/newsub/escape.md",
"# Escape",
false,
)
.unwrap_err();
assert!(err.contains("escapes project directory"));
assert!(!outside.join("newsub").exists());
let _ = fs::remove_dir_all(root);
let _ = fs::remove_dir_all(outside);
}
#[test]
fn search_sources_returns_source_references() {
let root = std::env::temp_dir().join(format!("llm-wiki-source-search-{}", Uuid::new_v4()));
let source_dir = root.join("raw").join("sources");
fs::create_dir_all(&source_dir).unwrap();
fs::write(source_dir.join("paper.txt"), "Coal mine safety case study.").unwrap();
let refs = search_sources(root.to_str().unwrap(), "safety", 5).unwrap();
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].kind, "source");
assert!(refs[0].snippet.as_deref().unwrap().contains("safety"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn search_sources_reads_org_and_fresh_binary_extraction_cache() {
let root = std::env::temp_dir().join(format!("llm-wiki-source-cache-{}", Uuid::new_v4()));
let source_dir = root.join("raw").join("sources");
fs::create_dir_all(source_dir.join(".cache")).unwrap();
fs::write(source_dir.join("notes.org"), "* Policy\nExact org wording").unwrap();
let pdf = source_dir.join("regulation.pdf");
fs::write(&pdf, b"%PDF placeholder").unwrap();
fs::write(
source_dir.join(".cache").join("regulation.pdf.txt"),
"Exact cached regulation wording",
)
.unwrap();
let org_refs = search_sources(root.to_str().unwrap(), "org wording", 5).unwrap();
let pdf_refs = search_sources(root.to_str().unwrap(), "cached regulation", 5).unwrap();
assert!(org_refs
.iter()
.any(|reference| reference.path == "raw/sources/notes.org"));
let pdf_ref = pdf_refs
.iter()
.find(|reference| reference.path == "raw/sources/regulation.pdf")
.unwrap();
assert!(pdf_ref
.snippet
.as_deref()
.unwrap()
.contains("cached regulation"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn source_and_graph_search_skip_hidden_paths() {
let root = std::env::temp_dir().join(format!("llm-wiki-hidden-search-{}", Uuid::new_v4()));
fs::create_dir_all(root.join("raw/sources/.cache")).unwrap();
fs::create_dir_all(root.join("wiki/.hidden")).unwrap();
fs::write(root.join("raw/sources/.cache/secret.txt"), "needle secret").unwrap();
fs::write(
root.join("wiki/.hidden/secret.md"),
"# Secret\n\nneedle [[A]]",
)
.unwrap();
assert!(search_sources(root.to_str().unwrap(), "needle", 5)
.unwrap()
.is_empty());
assert!(search_graph(root.to_str().unwrap(), "needle", 5)
.unwrap()
.is_empty());
let _ = fs::remove_dir_all(root);
}
#[test]
fn search_sources_uses_keyword_terms_from_natural_language() {
let root = std::env::temp_dir().join(format!("llm-wiki-source-search-{}", Uuid::new_v4()));
let source_dir = root.join("raw").join("sources");
fs::create_dir_all(&source_dir).unwrap();
fs::write(source_dir.join("煤矿.txt"), "煤矿安全治理 source details.").unwrap();
let refs = search_sources(root.to_str().unwrap(), "原始资料 煤矿安全", 5).unwrap();
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].kind, "source");
let _ = fs::remove_dir_all(root);
}
#[test]
fn search_graph_returns_relationship_references() {
let root = std::env::temp_dir().join(format!("llm-wiki-graph-search-{}", Uuid::new_v4()));
let wiki_dir = root.join("wiki").join("concepts");
fs::create_dir_all(&wiki_dir).unwrap();
fs::write(
wiki_dir.join("agent.md"),
"---\ntitle: Agent Graph\n---\n# Agent Graph\n\nLinks to [[Tool Registry]] and [[Context Builder]].",
)
.unwrap();
fs::write(
wiki_dir.join("tool-registry.md"),
"---\ntitle: Tool Registry\n---\n# Tool Registry\n\nTool definitions.",
)
.unwrap();
fs::write(
wiki_dir.join("context-builder.md"),
"---\ntitle: Context Builder\n---\n# Context Builder\n\nContext assembly.",
)
.unwrap();
fs::write(
wiki_dir.join("unrelated.md"),
"---\ntitle: Unrelated Hub\n---\n# Unrelated Hub\n\n[[Missing A]] [[Missing B]] [[Missing C]].",
)
.unwrap();
let refs = search_graph(root.to_str().unwrap(), "Agent Graph", 5).unwrap();
assert_eq!(refs.len(), 3);
assert_eq!(refs[0].title, "Agent Graph");
assert!(refs[0]
.snippet
.as_deref()
.unwrap()
.contains("matched entity"));
assert!(refs
.iter()
.skip(1)
.all(|reference| reference.kind == "graph"
&& reference
.snippet
.as_deref()
.unwrap()
.contains("direct neighbor")));
assert!(refs
.iter()
.any(|reference| reference.title == "Tool Registry"));
assert!(refs
.iter()
.any(|reference| reference.title == "Context Builder"));
assert!(!refs
.iter()
.any(|reference| reference.title == "Unrelated Hub"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn searxng_url_normalizes_to_search_endpoint() {
assert_eq!(
normalize_searxng_url("search.example.com").unwrap(),
"https://search.example.com/search"
);
assert_eq!(
normalize_searxng_url("https://search.example.com/search").unwrap(),
"https://search.example.com/search"
);
}
#[test]
fn friendly_firecrawl_error_explains_key_free_ip_rejection() {
let msg = friendly_firecrawl_error("Unfortunately, your IP address looks suspicious");
assert!(msg.contains("rejected this IP"));
}
#[test]
fn run_web_search_drops_empty_url_results_before_mapping_references() {
let refs = web_items_to_references(
vec![
WebSearchItem {
title: "Missing".to_string(),
url: String::new(),
snippet: "no url".to_string(),
},
WebSearchItem {
title: "Valid".to_string(),
url: "https://example.com".to_string(),
snippet: "ok".to_string(),
},
],
10,
);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].title, "Valid");
assert_eq!(refs[0].path, "https://example.com");
}
#[test]
fn web_references_apply_limit_before_empty_url_filter_like_legacy_ui() {
let refs = web_items_to_references(
vec![
WebSearchItem {
title: "Missing".to_string(),
url: String::new(),
snippet: "no url".to_string(),
},
WebSearchItem {
title: "Valid".to_string(),
url: "https://example.com".to_string(),
snippet: "ok".to_string(),
},
],
1,
);
assert!(refs.is_empty());
}
#[test]
fn brave_message_without_web_is_treated_as_error() {
let value = json!({ "message": "invalid subscription token" });
assert_eq!(
provider_payload_error("Brave Search", &value),
Some("Brave Search search failed: invalid subscription token".to_string())
);
}
#[test]
fn bocha_payload_error_requires_success_code() {
let failure = json!({ "code": 401, "msg": "invalid api key" });
assert_eq!(
provider_payload_error("Bocha Search", &failure),
Some("Bocha Search failed (code 401): invalid api key".to_string())
);
assert_eq!(
provider_payload_error("Bocha Search", &json!({ "code": 200, "data": {} })),
None
);
}
#[test]
fn bocha_results_prefer_summary_and_fall_back_to_snippet() {
let items = parse_bocha_results(json!({
"code": 200,
"data": {
"webPages": {
"value": [
{
"name": "Summary result",
"url": "https://example.com/summary",
"snippet": "short",
"summary": "long summary"
},
{
"name": "Snippet result",
"url": "https://example.com/snippet",
"summary": null,
"snippet": "fallback snippet"
}
]
}
}
}));
assert_eq!(items.len(), 2);
assert_eq!(items[0].title, "Summary result");
assert_eq!(items[0].snippet, "long summary");
assert_eq!(items[1].snippet, "fallback snippet");
}
#[test]
fn bocha_accepts_fifty_results_without_changing_other_provider_limits() {
assert_eq!(web_search_result_limit("bocha", 100), 50);
assert_eq!(web_search_result_limit("tavily", 100), 20);
assert_eq!(web_search_result_limit("bocha", 0), 1);
}
#[test]
fn non_brave_message_does_not_mask_valid_provider_payloads() {
let value = json!({ "message": "FYI" });
assert_eq!(provider_payload_error("Tavily", &value), None);
}
#[test]
fn web_result_normalization_accepts_firecrawl_nested_metadata() {
let items = extract_web_items(
&json!({
"data": {
"web": [
{
"metadata": {
"title": "Nested",
"sourceURL": "https://example.com/nested",
"description": "from metadata"
}
}
]
}
}),
&["data", "results"],
);
let item = normalize_web_result(items.into_iter().next().unwrap());
assert_eq!(item.title, "Nested");
assert_eq!(item.url, "https://example.com/nested");
assert_eq!(item.snippet, "from metadata");
}
#[test]
fn url_encode_handles_unicode_terms() {
assert_eq!(url_encode("煤矿 safety"), "%E7%85%A4%E7%9F%BF+safety");
}
#[test]
fn web_search_config_resolves_active_provider_override() {
let mut configs = BTreeMap::new();
configs.insert(
"searxng".to_string(),
WebSearchProviderOverride {
sear_xng_url: Some("https://search.example.com".to_string()),
..Default::default()
},
);
let cfg = WebSearchConfig {
provider: "searxng".to_string(),
provider_configs: Some(configs),
..Default::default()
}
.resolved();
assert_eq!(
cfg.sear_xng_url.as_deref(),
Some("https://search.example.com")
);
}
#[test]
fn extract_anytxt_items_accepts_common_result_shapes() {
let value = json!({
"result": {
"items": [
{ "path": "/docs/a.pdf", "title": "A", "snippet": "coal mine" }
]
}
});
let items = extract_anytxt_items(&value);
assert_eq!(items.len(), 1);
assert_eq!(items[0].title, "A");
assert_eq!(items[0].path, "/docs/a.pdf");
assert_eq!(items[0].snippet, "coal mine");
}
#[test]
fn extract_anytxt_items_accepts_nested_output_and_field_rows() {
let value = json!({
"result": {
"output": {
"field": ["fid", "full_path", "title", "hitText"],
"items": [
["42", "/docs/煤矿.pdf", "煤矿资料", "煤矿安全治理片段"]
]
}
}
});
let items = extract_anytxt_items(&value);
assert_eq!(items.len(), 1);
assert_eq!(items[0].fid, "42");
assert_eq!(items[0].path, "/docs/煤矿.pdf");
assert_eq!(items[0].title, "煤矿资料");
assert_eq!(items[0].snippet, "煤矿安全治理片段");
}
#[test]
fn extract_anytxt_items_keeps_fid_only_results_addressable() {
let value = json!({
"result": {
"data": {
"results": [
{ "fid": 99, "snippet": "fragment only" }
]
}
}
});
let items = extract_anytxt_items(&value);
assert_eq!(items.len(), 1);
assert_eq!(items[0].path, "anytxt://99");
assert_eq!(items[0].snippet, "fragment only");
}
#[test]
fn extract_anytxt_items_accepts_value_shapes() {
let value = json!({
"result": {
"output": {
"value": [
{ "path": "/docs/value.txt", "snippet": "from value" }
]
}
}
});
let items = extract_anytxt_items(&value);
assert_eq!(items.len(), 1);
assert_eq!(items[0].path, "/docs/value.txt");
assert_eq!(items[0].snippet, "from value");
}
}