Skip to main content

plugin_web/
web.rs

1//! The crate's Plugin, [`install`], the `web` Pack's `PackInfo`, the
2//! `construct` that builds its one shared object, and the keys of the two
3//! Host-wide services that `construct` reads.
4//!
5//! The fetch client is built once, when the Host builds its Packs, with
6//! the default fetch policy and bound to the Host's runtime handle; the
7//! search tool is built over the Host's search provider. The prompt never
8//! sees either.
9
10use 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
23/// The Pack's `PackInfo`.
24///
25/// Its FQN is `runvil/web`, so its Pack id is `web`. It has no prelude
26/// and needs no per-run service; its `construct` reads
27/// [`SEARCH_PROVIDER`] and the Plugin contract's
28/// [`runvil_plugin::TOKIO_RUNTIME`] service from the Host-wide services
29/// and takes no configuration but `null` or `{}`.
30const WEB: PackInfo =
31    PackInfo::new("runvil/web", construct).description("Search the web and fetch pages");
32
33/// The Plugin: inserts the `web` Pack into `packs` with `config["web"]`, which
34/// is `null` when `config` has no `web` key.
35///
36/// # Errors
37/// Returns the registry's [`InstallError`], such as
38/// [`InstallError::DuplicatePack`] when `packs` holds `runvil/web`
39/// already.
40#[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
48/// The service key for the Host's search provider.
49///
50/// The key's id is `runvil/search-provider`. A Host provides it among
51/// its Host-wide services, and every search runs through it.
52pub const SEARCH_PROVIDER: ServiceKey<dyn SearchProvider> =
53    ServiceKey::new("runvil/search-provider");
54
55/// The key `construct` reads the Host's runtime through. Every fetch is
56/// spawned onto it.
57const TOKIO_RUNTIME: ServiceKey<Handle> = ServiceKey::new(runvil_plugin::TOKIO_RUNTIME);
58
59/// The one object every run shares: its two-tool list and the two tools.
60#[derive(Debug)]
61struct Web {
62    tools: Vec<ToolInfo>,
63    fetch: WebFetch,
64    search: WebSearch,
65}
66
67/// Builds the Pack under the Pack id `name`: refuses any configuration
68/// but `null` or `{}`, reads the two Host-wide services or fails naming
69/// the missing one, and names the tools `<name>/fetch` and
70/// `<name>/search`.
71#[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    /// The Pack over `fetch` and `search`, its tools named under `name`.
99    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    /// Replaces the default fetch policy with a validated custom one,
122    /// rebuilding the fetch tool's info to match.
123    #[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;