use std::collections::BTreeSet;
use std::path::PathBuf;
use gobby_core::ai::generation::{ToolCall, ToolError, ToolExecutor, ToolSchema};
use serde_json::{Value, json};
use crate::ScopeSelection;
use crate::api::ScopeIdentity;
use super::{read, search};
const DEFAULT_SEARCH_LIMIT: usize = 8;
const MAX_SEARCH_LIMIT: usize = 25;
const SEARCH_TOKEN_BUDGET: usize = 1_500;
pub(crate) struct VaultToolExecutor {
selection: ScopeSelection,
vault_root: PathBuf,
scope_identity: ScopeIdentity,
data_source_degraded: BTreeSet<String>,
}
impl VaultToolExecutor {
pub(crate) fn new(
selection: ScopeSelection,
vault_root: PathBuf,
scope_identity: ScopeIdentity,
) -> Self {
Self {
selection,
vault_root,
scope_identity,
data_source_degraded: BTreeSet::new(),
}
}
pub(crate) fn into_data_source_degraded(self) -> Vec<String> {
self.data_source_degraded.into_iter().collect()
}
fn search_vault(&mut self, args: &Value) -> Result<String, ToolError> {
let query = arg_str(args, "query")?;
let limit = arg_usize(args, "limit", DEFAULT_SEARCH_LIMIT, MAX_SEARCH_LIMIT);
let retrieval = search::retrieve(
query.clone(),
self.selection.clone(),
limit,
true,
Some(SEARCH_TOKEN_BUDGET),
)
.map_err(|error| tool_err(format!("vault search failed: {error}")))?;
for degradation in &retrieval.output.degradations {
self.data_source_degraded.insert(degradation.clone());
}
if retrieval.output.results.is_empty() {
return Ok(format!("No vault documents matched `{query}`."));
}
let mut block = format!(
"{} vault document(s) matching `{query}`:\n",
retrieval.output.results.len()
);
for (index, result) in retrieval.output.results.iter().enumerate() {
let title = result
.title
.clone()
.unwrap_or_else(|| result.wiki_page.display().to_string());
block.push_str(&format!(
"\n{}. {title}\n path: {}\n {}\n",
index + 1,
result.wiki_page.display(),
result.snippet.trim()
));
}
Ok(block)
}
fn read_document(&mut self, args: &Value) -> Result<String, ToolError> {
let path = arg_str(args, "path")?;
read::read_document_text(
&self.vault_root,
self.scope_identity.clone(),
PathBuf::from(&path),
)
.map_err(|error| tool_err(format!("read `{path}` failed: {error}")))
}
}
impl ToolExecutor for VaultToolExecutor {
fn schemas(&self) -> Vec<ToolSchema> {
vault_tool_schemas()
}
fn execute(&mut self, call: &ToolCall) -> Result<String, ToolError> {
match call.name.as_str() {
"search_vault" => self.search_vault(&call.arguments),
"read_document" => self.read_document(&call.arguments),
other => Err(tool_err(format!("unknown tool `{other}`"))),
}
}
}
pub(crate) fn vault_tool_schemas() -> Vec<ToolSchema> {
vec![
tool_schema(
"search_vault",
"Hybrid (BM25 + semantic + graph) search over the indexed wiki vault. \
Returns ranked document hits with title, wiki path, and a snippet. Use \
this to discover which vault documents are relevant before reading them.",
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Natural-language or keyword query."
},
"limit": {
"type": "integer",
"description": format!(
"Max results (default {DEFAULT_SEARCH_LIMIT}, max {MAX_SEARCH_LIMIT})."
),
"minimum": 1,
"maximum": MAX_SEARCH_LIMIT
}
},
"required": ["query"]
}),
),
tool_schema(
"read_document",
"Read a single vault document by its wiki path (as returned by \
search_vault). Returns the document title and bounded Markdown content.",
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Vault-relative wiki path, e.g. `topics/example.md`."
}
},
"required": ["path"]
}),
),
]
}
fn tool_schema(name: &str, description: &str, parameters: Value) -> ToolSchema {
ToolSchema {
name: name.to_string(),
description: description.to_string(),
parameters,
}
}
fn tool_err(message: String) -> ToolError {
ToolError::new(message)
}
fn arg_str(args: &Value, key: &str) -> Result<String, ToolError> {
args.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.ok_or_else(|| tool_err(format!("missing required string argument `{key}`")))
}
fn arg_usize(args: &Value, key: &str, default: usize, max: usize) -> usize {
args.get(key)
.and_then(Value::as_u64)
.map(|value| (value as usize).clamp(1, max))
.unwrap_or(default)
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use std::collections::VecDeque;
use std::fs;
use gobby_core::ai::generation::{
ChatCompletion, ChatCompletionRequest, ChatMessage, ChatTransport, ToolCall,
ToolLoopLimits, run_tool_loop,
};
use gobby_core::ai_types::AiError;
use super::*;
fn executor(root: &std::path::Path) -> VaultToolExecutor {
VaultToolExecutor::new(
ScopeSelection::Detect,
root.to_path_buf(),
ScopeIdentity::project("test-project"),
)
}
#[test]
fn schemas_advertise_search_and_read_with_required_args() {
let schemas = vault_tool_schemas();
let names: Vec<&str> = schemas.iter().map(|schema| schema.name.as_str()).collect();
assert_eq!(names, vec!["search_vault", "read_document"]);
for schema in &schemas {
assert_eq!(schema.parameters["type"], "object");
assert!(
schema.parameters["required"].is_array(),
"{} must declare required args",
schema.name
);
}
}
fn write_vault_doc(root: &std::path::Path, body: &str) -> &'static str {
let path = "knowledge/topics/overview.md";
let absolute = root.join(path);
fs::create_dir_all(absolute.parent().expect("parent")).expect("mkdir");
fs::write(absolute, body).expect("write doc");
path
}
#[test]
fn read_document_returns_scoped_vault_content() {
let temp = tempfile::tempdir().expect("tempdir");
let path = write_vault_doc(temp.path(), "# Overview\n\nVault body text.");
let mut executor = executor(temp.path());
let call = ToolCall {
id: "call-1".to_string(),
name: "read_document".to_string(),
arguments: json!({ "path": path }),
};
let result = executor.execute(&call).expect("read succeeds");
assert!(
result.contains("Vault body text."),
"expected document body, got: {result}"
);
}
#[test]
fn execute_rejects_unknown_tool() {
let temp = tempfile::tempdir().expect("tempdir");
let mut executor = executor(temp.path());
let call = ToolCall {
id: "call-x".to_string(),
name: "delete_everything".to_string(),
arguments: Value::Null,
};
let error = executor.execute(&call).expect_err("unknown tool rejected");
assert!(error.message.contains("unknown tool"));
}
struct ScriptedChatTransport {
completions: RefCell<VecDeque<ChatCompletion>>,
}
impl ScriptedChatTransport {
fn new(completions: Vec<ChatCompletion>) -> Self {
Self {
completions: RefCell::new(completions.into()),
}
}
}
impl ChatTransport for ScriptedChatTransport {
fn complete(&self, _request: ChatCompletionRequest<'_>) -> Result<ChatCompletion, AiError> {
Ok(self
.completions
.borrow_mut()
.pop_front()
.expect("scripted completion available"))
}
fn route(&self) -> &'static str {
"stub"
}
}
#[test]
fn lane_b_loop_invokes_vault_tool_then_completes() {
let temp = tempfile::tempdir().expect("tempdir");
let path = write_vault_doc(
temp.path(),
"# Overview\n\nGrounding fact the model retrieved.",
);
let mut executor = executor(temp.path());
let read_call = ToolCall {
id: "call-1".to_string(),
name: "read_document".to_string(),
arguments: json!({ "path": path }),
};
let transport = ScriptedChatTransport::new(vec![
ChatCompletion {
tool_calls: vec![read_call],
finish_reason: Some("tool_calls".to_string()),
..Default::default()
},
ChatCompletion {
content: Some("Final grounded narrative.".to_string()),
finish_reason: Some("stop".to_string()),
..Default::default()
},
]);
let messages = vec![
ChatMessage::system("Investigate the vault, then write."),
ChatMessage::user("Compile a page."),
];
let limits = ToolLoopLimits::default();
let outcome = run_tool_loop(&transport, &mut executor, messages, &limits, None)
.expect("tool loop runs");
assert!(outcome.stop_reason.is_completed());
assert_eq!(
outcome.content.as_deref(),
Some("Final grounded narrative.")
);
assert_eq!(outcome.observability.tool_call_count, 1);
assert!(
outcome
.observability
.tool_names
.contains(&"read_document".to_string())
);
}
}