1use std::sync::Arc;
11
12use runvil_plugin::{
13 HostServices, InstallError, Pack, PackFuture, PackId, PackInfo, PackRegistry, ServiceKey,
14 ToolContext, ToolError, ToolErrorKind, ToolId, ToolInfo, ToolOutput,
15};
16use serde_json::{Value, json};
17use tokio::runtime::Handle;
18
19use crate::fetch::{FetchClient, WebFetch};
20use crate::provider::SearchProvider;
21use crate::search::WebSearch;
22
23const WEB: PackInfo =
31 PackInfo::new("runvil/web", construct).description("Search the web and fetch pages");
32
33#[expect(
41 clippy::needless_pass_by_value,
42 reason = "the Plugin signature fixes the argument types"
43)]
44pub fn install(packs: &mut PackRegistry, config: Value) -> Result<(), InstallError> {
45 packs.insert(WEB, config["web"].clone())
46}
47
48pub const SEARCH_PROVIDER: ServiceKey<dyn SearchProvider> =
53 ServiceKey::new("runvil/search-provider");
54
55const TOKIO_RUNTIME: ServiceKey<Handle> = ServiceKey::new(runvil_plugin::TOKIO_RUNTIME);
58
59#[derive(Debug)]
61struct Web {
62 tools: Vec<ToolInfo>,
63 fetch: WebFetch,
64 search: WebSearch,
65}
66
67#[expect(
72 clippy::needless_pass_by_value,
73 reason = "the PackInfo construct signature fixes the argument types"
74)]
75fn construct(
76 name: &PackId,
77 config: Value,
78 services: &HostServices,
79) -> Result<Arc<dyn Pack>, ToolError> {
80 if !(config.is_null() || config == json!({})) {
81 return Err(ToolError::message("web takes no configuration"));
82 }
83 let provider = services.get(&SEARCH_PROVIDER).ok_or_else(|| {
84 ToolError::message("web needs runvil/search-provider, and this host provides none")
85 })?;
86 let runtime = services.get(&TOKIO_RUNTIME).ok_or_else(|| {
87 ToolError::message("web needs runvil/tokio-runtime, and this host provides none")
88 })?;
89 let web = Web::new(
90 name,
91 FetchClient::new().tool(Handle::clone(&runtime)),
92 WebSearch::new(provider),
93 )?;
94 Ok(Arc::new(web))
95}
96
97impl Web {
98 fn new(name: &PackId, fetch: WebFetch, search: WebSearch) -> Result<Web, ToolError> {
100 let id = |tool: &str| {
101 ToolId::parse(&format!("{name}/{tool}")).map_err(|e| {
102 ToolError::new(ToolErrorKind::Backend, "web could not name its tools")
103 .with_source(e)
104 })
105 };
106 let tools = vec![
107 ToolInfo::new(id("fetch")?, fetch.description(), fetch.parameters_schema()),
108 ToolInfo::new(
109 id("search")?,
110 search.description(),
111 search.parameters_schema(),
112 ),
113 ];
114 Ok(Web {
115 tools,
116 fetch,
117 search,
118 })
119 }
120
121 #[cfg(test)]
124 fn with_fetch_config(
125 self,
126 name: &PackId,
127 runtime: Handle,
128 config: crate::config::FetchConfig,
129 ) -> Result<Web, crate::config::ConfigError> {
130 let fetch = FetchClient::try_with_config(config)?.tool(runtime);
131 Ok(Web::new(name, fetch, self.search).expect("the tool ids parsed once already"))
132 }
133}
134
135impl Pack for Web {
136 fn tools(&self) -> Vec<ToolInfo> {
137 self.tools.clone()
138 }
139
140 fn call<'a>(
141 &'a self,
142 cx: ToolContext<'a>,
143 args: Value,
144 ) -> PackFuture<'a, Result<ToolOutput, ToolError>> {
145 Box::pin(async move {
146 match cx.tool().name() {
147 "fetch" => self.fetch.call(args).await,
148 "search" => self.search.call(args).await,
149 other => Err(ToolError::message(format!("web has no tool named {other}"))),
150 }
151 })
152 }
153}
154
155#[cfg(test)]
156#[path = "web-tests.rs"]
157mod tests;