Skip to main content

harness_web/
web.rs

1//! [`Web`], the `promptforge/web` Plugin, and the keys of the two
2//! services it reads.
3//!
4//! The fetch client is built once, at construction, with the fetch policy;
5//! activation binds it to the run's runtime handle and builds the search
6//! tool over the run's search provider. The prompt never sees either.
7
8use std::sync::Arc;
9
10use harness::plugin::{
11    Contribution, Plugin, PluginError, PluginErrorKind, PluginId, RunServices, ServiceId,
12    ServiceKey,
13};
14use tokio::runtime::Handle;
15
16use crate::config::{ConfigError, FetchConfig};
17use crate::fetch::FetchClient;
18use crate::provider::SearchProvider;
19use crate::search::WebSearch;
20
21/// The service key for the Host's search provider.
22///
23/// The key's id is `promptforge/search-provider`. Every
24/// `promptforge/web/search` call runs through the provider registered
25/// under this key.
26pub const SEARCH_PROVIDER: ServiceKey<dyn SearchProvider> =
27    ServiceKey::new("promptforge/search-provider");
28
29/// The service key for the Host's tokio runtime handle.
30///
31/// The key's id is `promptforge/tokio-runtime`. The fetch tool spawns
32/// every `promptforge/web/fetch` call onto the runtime registered under
33/// this key.
34pub const TOKIO_RUNTIME: ServiceKey<Handle> = ServiceKey::new("promptforge/tokio-runtime");
35
36/// A Plugin that gives a run web access: one tool that fetches a page
37/// and one that searches the web.
38///
39/// Its id is `promptforge/web`. `promptforge/web/fetch` fetches a URL
40/// through a hardened HTTP client and returns its content as text, with
41/// an HTML page rendered as markdown. `promptforge/web/search` runs a
42/// search through the Host's [`SearchProvider`].
43///
44/// It needs two services: the search provider registered under the key
45/// [`SEARCH_PROVIDER`] and the tokio runtime handle registered under the
46/// key [`TOKIO_RUNTIME`]. A run that requires the Plugin is refused
47/// when either service is missing. A run that declares it optional gets
48/// the web tools only when both services are present.
49#[derive(Debug, Clone)]
50pub struct Web {
51    /// The stable identity, `promptforge/web`.
52    id: PluginId,
53    /// The fetch client, built over its validated policy.
54    fetch: FetchClient,
55}
56
57impl Web {
58    /// Builds the Plugin with the default fetch policy.
59    ///
60    /// The HTTP client is built here, once, and every run's fetch tool
61    /// shares it.
62    ///
63    /// # Panics
64    /// Panics only if the built-in Plugin id `promptforge/web` fails to
65    /// parse, or if the HTTP client fails to build for the default policy
66    /// because the TLS backend failed to initialize. Either would be a
67    /// defect outside the caller's control.
68    #[must_use]
69    pub fn new() -> Web {
70        #[expect(
71            clippy::expect_used,
72            reason = "the id is a literal of the Plugin id grammar; a parse failure is a defect in this file, not a caller-actionable condition"
73        )]
74        let id = PluginId::parse("promptforge/web").expect("the literal web Plugin id parses");
75        Web {
76            id,
77            fetch: FetchClient::new(),
78        }
79    }
80
81    /// Replaces the default fetch policy with a validated custom one.
82    ///
83    /// # Errors
84    /// Returns [`ConfigError`] if the HTTP client fails to build for
85    /// `config` (for example a TLS backend that fails to initialize).
86    pub fn with_fetch_config(mut self, config: FetchConfig) -> Result<Web, ConfigError> {
87        self.fetch = FetchClient::try_with_config(config)?;
88        Ok(self)
89    }
90}
91
92impl Default for Web {
93    fn default() -> Web {
94        Web::new()
95    }
96}
97
98impl Plugin for Web {
99    fn id(&self) -> &PluginId {
100        &self.id
101    }
102
103    #[expect(
104        clippy::unnecessary_literal_bound,
105        reason = "the Plugin trait fixes this return type to &str, so the &'static str suggestion cannot be applied"
106    )]
107    fn description(&self) -> &str {
108        "Fetch a web page as markdown and search the web through the Host's search provider."
109    }
110
111    fn needs(&self) -> &[ServiceId] {
112        const NEEDS: &[ServiceId] = &[SEARCH_PROVIDER.id(), TOKIO_RUNTIME.id()];
113        NEEDS
114    }
115
116    fn create(&self, services: &RunServices) -> Result<Contribution, PluginError> {
117        if services.cancel.is_cancelled() {
118            return Err(
119                PluginError::message("promptforge/web: the run was cancelled")
120                    .with_kind(PluginErrorKind::Cancelled),
121            );
122        }
123        let (Some(provider), Some(runtime)) =
124            (services.get(&SEARCH_PROVIDER), services.get(&TOKIO_RUNTIME))
125        else {
126            return Ok(Contribution::default());
127        };
128        Ok(Contribution {
129            tools: vec![
130                Arc::new(self.fetch.tool(Handle::clone(&runtime))),
131                Arc::new(WebSearch::new(provider)),
132            ],
133            prelude: None,
134        })
135    }
136}
137
138#[cfg(test)]
139#[path = "web-tests.rs"]
140mod tests;