Skip to main content

plugin_web/
web.rs

1//! The `web` Plugin's label, [`PACKAGE`], the `construct` that builds its
2//! one shared object, and the keys of the two Host-wide services that
3//! `construct` reads.
4//!
5//! The fetch client is built once, at install, with the default fetch
6//! policy and bound to the Host's runtime handle; the search tool is
7//! built over the Host's search provider. The prompt never sees either.
8
9use 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
22/// The Plugin's label, which a Host adds to its package registry.
23///
24/// Its name is `runvil/web`, so a Host that picks no name installs it
25/// as `web`. It has no prelude and needs no per-run service; its
26/// `construct` reads [`SEARCH_PROVIDER`] and the Plugin contract's
27/// [`runvil_plugin::TOKIO_RUNTIME`] service from the Host-wide
28/// services and takes no configuration but `null` or `{}`.
29pub const PACKAGE: Package =
30    Package::new("runvil/web", construct).description("Search the web and fetch pages");
31
32/// The service key for the Host's search provider.
33///
34/// The key's id is `runvil/search-provider`. A Host provides it among
35/// its Host-wide services, and every search runs through it.
36pub const SEARCH_PROVIDER: ServiceKey<dyn SearchProvider> =
37    ServiceKey::new("runvil/search-provider");
38
39/// The key `construct` reads the Host's runtime through. Every fetch is
40/// spawned onto it.
41const TOKIO_RUNTIME: ServiceKey<Handle> = ServiceKey::new(runvil_plugin::TOKIO_RUNTIME);
42
43/// The one object every run shares: its two-tool list and the two tools.
44#[derive(Debug)]
45struct Web {
46    tools: Vec<ToolDescriptor>,
47    fetch: WebFetch,
48    search: WebSearch,
49}
50
51/// Builds the Plugin under `name`: refuses any configuration but `null` or
52/// `{}`, reads the two Host-wide services or fails naming the missing one,
53/// and names the tools `<name>/fetch` and `<name>/search`.
54#[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    /// The Plugin over `fetch` and `search`, its tools named under `name`.
82    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    /// Replaces the default fetch policy with a validated custom one,
105    /// rebuilding the fetch tool's descriptor to match.
106    #[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;