1use std::sync::Arc;
10
11use runvil_plugin::{
12 HostServices, Package, Plugin, PluginFuture, PluginId, ServiceKey, ToolContext, ToolDescriptor,
13 ToolError, ToolErrorKind, ToolId, ToolOutput,
14};
15use serde_json::{Value, json};
16use tokio::runtime::Handle;
17
18use crate::fetch::{FetchClient, WebFetch};
19use crate::provider::SearchProvider;
20use crate::search::WebSearch;
21
22pub const PACKAGE: Package =
30 Package::new("runvil/web", construct).description("Search the web and fetch pages");
31
32pub const SEARCH_PROVIDER: ServiceKey<dyn SearchProvider> =
37 ServiceKey::new("runvil/search-provider");
38
39const TOKIO_RUNTIME: ServiceKey<Handle> = ServiceKey::new(runvil_plugin::TOKIO_RUNTIME);
42
43#[derive(Debug)]
45struct Web {
46 tools: Vec<ToolDescriptor>,
47 fetch: WebFetch,
48 search: WebSearch,
49}
50
51#[expect(
55 clippy::needless_pass_by_value,
56 reason = "the Package construct signature fixes the argument types"
57)]
58fn construct(
59 name: &PluginId,
60 config: Value,
61 services: &HostServices,
62) -> Result<Arc<dyn Plugin>, ToolError> {
63 if !(config.is_null() || config == json!({})) {
64 return Err(ToolError::message("web takes no configuration"));
65 }
66 let provider = services.get(&SEARCH_PROVIDER).ok_or_else(|| {
67 ToolError::message("web needs runvil/search-provider, and this host provides none")
68 })?;
69 let runtime = services.get(&TOKIO_RUNTIME).ok_or_else(|| {
70 ToolError::message("web needs runvil/tokio-runtime, and this host provides none")
71 })?;
72 let web = Web::new(
73 name,
74 FetchClient::new().tool(Handle::clone(&runtime)),
75 WebSearch::new(provider),
76 )?;
77 Ok(Arc::new(web))
78}
79
80impl Web {
81 fn new(name: &PluginId, fetch: WebFetch, search: WebSearch) -> Result<Web, ToolError> {
83 let id = |tool: &str| {
84 ToolId::parse(&format!("{name}/{tool}")).map_err(|e| {
85 ToolError::new(ToolErrorKind::Backend, "web could not name its tools")
86 .with_source(e)
87 })
88 };
89 let tools = vec![
90 ToolDescriptor::new(id("fetch")?, fetch.description(), fetch.parameters_schema()),
91 ToolDescriptor::new(
92 id("search")?,
93 search.description(),
94 search.parameters_schema(),
95 ),
96 ];
97 Ok(Web {
98 tools,
99 fetch,
100 search,
101 })
102 }
103
104 #[cfg(test)]
107 fn with_fetch_config(
108 self,
109 name: &PluginId,
110 runtime: Handle,
111 config: crate::config::FetchConfig,
112 ) -> Result<Web, crate::config::ConfigError> {
113 let fetch = FetchClient::try_with_config(config)?.tool(runtime);
114 Ok(Web::new(name, fetch, self.search).expect("the tool ids parsed once already"))
115 }
116}
117
118impl Plugin for Web {
119 fn tools(&self) -> Vec<ToolDescriptor> {
120 self.tools.clone()
121 }
122
123 fn call<'a>(
124 &'a self,
125 cx: ToolContext<'a>,
126 args: Value,
127 ) -> PluginFuture<'a, Result<ToolOutput, ToolError>> {
128 Box::pin(async move {
129 match cx.tool().name() {
130 "fetch" => self.fetch.call(args).await,
131 "search" => self.search.call(args).await,
132 other => Err(ToolError::message(format!("web has no tool named {other}"))),
133 }
134 })
135 }
136}
137
138#[cfg(test)]
139#[path = "web-tests.rs"]
140mod tests;