//! Fixed, host-controlled tools exposed to the LLM during one invocation.
mod fetch_web_page;
mod html_to_markdown;
mod web_search;
use std::collections::HashMap;
use rand::random;
use serde::Serialize;
use serde_json::{Map, Value};
use url::Url;
use crate::config::{SearchConfig, WebConfig};
use self::{
fetch_web_page::FetchWebPageTool,
html_to_markdown::HtmlToMarkdownTool,
web_search::WebSearchTool,
};
/// Returns bounded host-produced output from a fixed tool call.
#[derive(Debug, Serialize)]
pub struct ToolResult
{
/// Indicates whether the tool completed successfully.
pub ok: bool,
/// Stable non-sensitive result or failure code.
pub code: &'static str,
/// Bounded text provided to the LLM as untrusted data.
pub content: String,
/// Non-sensitive structured metadata for a successful tool result.
pub metadata: Map<String, Value>,
}
impl ToolResult
{
/// Creates a successful bounded tool result.
#[must_use]
pub fn success(code: &'static str, content: String, metadata: Map<String, Value>) -> Self
{
Self { ok: true, code, content, metadata }
}
/// Creates a non-sensitive tool failure visible to the LLM.
#[must_use]
pub fn failure(code: &'static str, content: impl Into<String>) -> Self
{
Self { ok: false, code, content: content.into(), metadata: Map::new() }
}
}
/// Holds downloaded content that is reachable only during one invocation.
pub struct ArtifactStore
{
artifacts: HashMap<String, Artifact>,
total_bytes: usize,
maximum_total_bytes: usize,
}
struct Artifact
{
content_type: String,
bytes: Vec<u8>,
final_url: Url,
}
/// Holds validated research sources in first-use order for one invocation.
#[derive(Default)]
pub struct SourceRegistry
{
sources: Vec<Source>,
}
/// Describes one host-validated public source URL.
#[derive(Clone, Debug)]
pub struct Source
{
/// Normalized absolute HTTP or HTTPS source URL.
pub url: Url,
/// Bounded source title supplied by search or page metadata.
pub title: String,
}
/// Owns all mutable tool state for exactly one LLM invocation.
pub struct ToolScope
{
/// Invocation-local downloaded artifacts.
pub artifacts: ArtifactStore,
/// Invocation-local validated research sources.
pub sources: SourceRegistry,
}
/// Dispatches the three fixed model-visible tools.
#[derive(Clone)]
pub struct ToolRegistry
{
search: WebSearchTool,
fetch: FetchWebPageTool,
convert: HtmlToMarkdownTool,
}
impl ArtifactStore
{
/// Creates an empty invocation-local artifact store.
#[must_use]
pub fn new(maximum_total_bytes: usize) -> Self
{
Self { artifacts: HashMap::new(), total_bytes: 0, maximum_total_bytes }
}
fn insert(
&mut self,
content_type: String,
bytes: Vec<u8>,
final_url: Url,
) -> Result<String, ToolResult>
{
if bytes.len() > self.maximum_total_bytes.saturating_sub(self.total_bytes)
{
return Err(ToolResult::failure(
"artifact_limit",
"The invocation's artifact storage limit was reached.",
));
}
let identifier = format!("art_{:016x}", random::<u64>());
self.total_bytes += bytes.len();
self.artifacts.insert(identifier.clone(), Artifact { content_type, bytes, final_url });
Ok(identifier)
}
fn get(&self, identifier: &str) -> Option<&Artifact>
{
self.artifacts.get(identifier)
}
}
impl SourceRegistry
{
/// Returns whether successful tools registered any research source.
#[must_use]
pub fn is_empty(&self) -> bool
{
self.sources.is_empty()
}
/// Adds one normalized source URL, preserving first-use ordering.
pub fn register(&mut self, url: Url, title: impl Into<String>)
{
if self.sources.iter().any(|source| source.url == url)
{
return;
}
let title: String = title.into().chars().take(200).collect();
self.sources.push(Source { url, title });
}
/// Returns only requested URLs that came from successful host tool results.
#[must_use]
pub fn select(&self, urls: &[Url]) -> Option<Vec<Source>>
{
let mut selected = Vec::new();
for url in urls
{
let source = self.sources.iter().find(|source| source.url == *url)?;
if !selected.iter().any(|existing: &Source| existing.url == source.url)
{
selected.push(source.clone());
}
}
Some(selected)
}
}
impl ToolScope
{
/// Creates clean tool state that cannot be shared with another invocation.
#[must_use]
pub fn new(web: &WebConfig) -> Self
{
Self {
artifacts: ArtifactStore::new(web.max_total_artifact_bytes),
sources: SourceRegistry::default(),
}
}
}
impl ToolRegistry
{
/// Builds isolated HTTP and conversion implementations from validated configuration.
pub fn new(search: SearchConfig, web: WebConfig) -> Result<Self, reqwest::Error>
{
Ok(Self {
search: WebSearchTool::new(search)?,
fetch: FetchWebPageTool::new(web.clone())?,
convert: HtmlToMarkdownTool::new(web),
})
}
/// Executes one exact tool name with host-validated JSON arguments.
pub async fn execute(
&self,
name: &str,
arguments: Value,
scope: &mut ToolScope,
) -> ToolResult
{
match name
{
"web_search" => self.search.execute(arguments, &mut scope.sources).await,
"fetch_web_page" => self.fetch.execute(arguments, scope).await,
"html_to_markdown" => self.convert.execute(arguments, scope).await,
_ => ToolResult::failure("invalid_tool", "This tool name is not available."),
}
}
/// Returns the strict OpenAI function schemas exposed to the LLM.
#[must_use]
pub fn schemas() -> Vec<Value>
{
vec![
serde_json::json!({"type":"function","function":{"name":"web_search","parameters":{"type":"object","additionalProperties":false,"properties":{"query":{"type":"string","minLength":1,"maxLength":500},"result_count":{"type":"integer","minimum":1}},"required":["query"]}}}),
serde_json::json!({"type":"function","function":{"name":"fetch_web_page","parameters":{"type":"object","additionalProperties":false,"properties":{"url":{"type":"string","minLength":1,"maxLength":4096}},"required":["url"]}}}),
serde_json::json!({"type":"function","function":{"name":"html_to_markdown","parameters":{"type":"object","additionalProperties":false,"properties":{"artifact_id":{"type":"string","minLength":1,"maxLength":128}},"required":["artifact_id"]}}}),
]
}
}
#[cfg(test)]
mod tests
{
use url::Url;
use super::SourceRegistry;
#[test]
fn rejects_unregistered_model_sources()
{
let mut registry = SourceRegistry::default();
let source = Url::parse("https://example.test/article").unwrap();
registry.register(source.clone(), "Article");
assert!(registry.select(&[source]).is_some());
assert!(registry.select(&[Url::parse("https://example.test/invented").unwrap()]).is_none());
}
}