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;