Skip to main content

netget/client/http/
mod.rs

1//! HTTP client implementation.
2//!
3//! Natively every request goes through reqwest (TLS, HTTP/2, a pooled client per host). In the
4//! browser build it goes through [`transport`], hyper 1's HTTP/1.1 client over the page's
5//! virtual loopback — reqwest's wasm backend is the browser's `fetch`, which is not `Send` and
6//! cannot reach NetGet's in-page servers. Everything above the round trip — the command
7//! channel, the model's answers, the follow-up chain — is the same code on both.
8pub mod actions;
9/// The browser transport, shared with the other HTTP-family clients through
10/// [`crate::client::http_fetch`].
11pub use crate::client::http_fetch::transport;
12pub use crate::client::http_fetch::transport::HttpExchange;
13
14pub use actions::HttpClientProtocol;
15
16use anyhow::{Context, Result};
17use std::net::SocketAddr;
18use std::sync::Arc;
19use std::time::Duration;
20use tokio::sync::mpsc;
21use tracing::{debug, error, info, warn};
22
23use crate::client::http::actions::{
24    HTTP_CLIENT_CONNECTED_EVENT, HTTP_CLIENT_RESPONSE_RECEIVED_EVENT,
25};
26use crate::client::llm_budget::call_llm_for_client;
27use crate::llm::actions::client_trait::{Client, ClientActionResult};
28use crate::llm::actions::protocol_trait::Protocol;
29use crate::llm::ollama_client::OllamaClient;
30use crate::llm::ClientLlmResult;
31use crate::logging::emit::Log;
32use crate::protocol::Event;
33use crate::state::app_state::AppState;
34use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
35use crate::state::{AccessLogOwner, ClientId, ClientStatus};
36
37/// How often the command loop re-checks that its client still exists. HTTP has no
38/// socket to notice a close on, so this is what the old idle task was for.
39const REMOVAL_CHECK_INTERVAL: Duration = Duration::from_secs(5);
40
41/// What one executed action did. Shared vocabulary between the connected-event
42/// handler and the injected-command loop.
43enum Applied {
44    /// The action ran; `detail` says what it did.
45    Executed(String),
46    /// The action asked to end the session.
47    Disconnect,
48}
49
50/// How an `http_request` is issued.
51#[derive(Clone, Copy)]
52enum Dispatch {
53    /// Spawn the request and return immediately. Used by the connected-event
54    /// handler, which runs inline in `connect()` and must not block client creation
55    /// on a request that can take the full 30s timeout.
56    Spawn,
57    /// Await the HTTP exchange so the caller can report what actually happened. Used
58    /// by the injected-command loop. The response event is still delivered to the
59    /// LLM, from its own registered task, so a parked manual handler cannot wedge the
60    /// command loop for the length of a human's think time.
61    Await,
62}
63
64/// HTTP client that makes requests to remote HTTP servers
65pub struct HttpClient;
66
67impl HttpClient {
68    /// The HTTP client for one host, built once and kept.
69    ///
70    /// Three things this avoids, all of which cost real time on every request before:
71    ///
72    /// - `reqwest::Client::builder().build()` sets up the rustls stack and loads the
73    ///   platform root store — on macOS that reads the system keychain through
74    ///   Security.framework, which is synchronous and serialises across processes — so it
75    ///   runs on `spawn_blocking` rather than parking a tokio worker.
76    /// - It is kept, so requests after the first reuse the connection pool instead of
77    ///   paying for a fresh TLS stack and handshake each time.
78    /// - **The literal-IP resolver bypass.** `reqwest` hands the URL host to its DNS
79    ///   resolver unconditionally and `hyper-util`'s `GaiResolver` does not special-case a
80    ///   dotted quad, so `http://127.0.0.1:8080` performs a real `getaddrinfo`. On macOS
81    ///   that goes through libinfo to mDNSResponder — one system-wide daemon, measured
82    ///   blocking for **8.25 seconds** under ~100 concurrent processes. A NetGet HTTP
83    ///   client is pointed at a literal IP more often than not, so this is the common case,
84    ///   not the exotic one.
85    ///
86    /// That last point is why the cache is **keyed by host** rather than being a single
87    /// process-wide client: `ClientBuilder::resolve` is a per-host override, so a single
88    /// shared client cannot carry one for a host it has not been told about. The map is
89    /// only ever as large as the set of hosts this process has been pointed at.
90    ///
91    /// The override logic mirrors `ollama_client::without_dns_for_literal_ip`, which is
92    /// private and builds a client with reqwest's default TLS backend; this one keeps
93    /// `use_rustls_tls()` deliberately, since native-tls on macOS is the keychain path the
94    /// first bullet is about. `host_of` — the part that is easy to get wrong, and did get
95    /// wrong once by leaving the port on — is shared.
96    ///
97    /// Protocol versions are negotiated via ALPN during the handshake, so one client
98    /// serves both HTTP/1.1 and HTTP/2.
99    #[cfg(not(target_arch = "wasm32"))]
100    async fn http_client(base_url: &str) -> Result<reqwest::Client> {
101        use std::collections::HashMap;
102        use std::sync::{Mutex, OnceLock};
103
104        static CLIENTS: OnceLock<Mutex<HashMap<String, reqwest::Client>>> = OnceLock::new();
105        let clients = CLIENTS.get_or_init(|| Mutex::new(HashMap::new()));
106
107        let host = crate::llm::ollama_client::host_of(base_url).to_string();
108        if let Some(client) = clients.lock().ok().and_then(|map| map.get(&host).cloned()) {
109            return Ok(client);
110        }
111
112        let build_host = host.clone();
113        let built = tokio::task::spawn_blocking(move || {
114            let mut builder = reqwest::Client::builder()
115                .timeout(std::time::Duration::from_secs(30))
116                .redirect(crate::client::http_fetch::same_origin_redirects())
117                .use_rustls_tls();
118            // Only when the host *is* an address. A hostname is left alone: resolving
119            // `localhost` or a real name is the resolver's job, and /etc/hosts or
120            // split-horizon DNS may legitimately point it somewhere unexpected.
121            if let Ok(ip) = build_host.parse::<std::net::IpAddr>() {
122                // The port is irrelevant — hyper overwrites it from the URL — so one
123                // override serves every port this client is later pointed at.
124                builder = builder.resolve(&build_host, std::net::SocketAddr::new(ip, 0));
125            }
126            builder.build().context("Failed to build HTTP client")
127        })
128        .await
129        .context("HTTP client build task panicked")??;
130
131        // A concurrent builder may have won the race; either client is equally valid, so
132        // keep whichever landed first rather than replacing it and orphaning its pool.
133        match clients.lock() {
134            Ok(mut map) => Ok(map.entry(host).or_insert(built).clone()),
135            // Poisoned only if another thread panicked while holding the map. The freshly
136            // built client is still usable; it just does not get cached.
137            Err(_) => Ok(built),
138        }
139    }
140
141    /// Connect to an HTTP server with integrated LLM actions
142    pub async fn connect_with_llm_actions(
143        remote_addr: String,
144        llm_client: OllamaClient,
145        app_state: Arc<AppState>,
146        status_tx: mpsc::UnboundedSender<String>,
147        client_id: ClientId,
148        startup_params: Option<crate::protocol::StartupParams>,
149    ) -> Result<SocketAddr> {
150        // For HTTP, "connection" is logical, not a persistent TCP connection
151        // We'll create an HTTP client and store it in protocol_data
152
153        info!("HTTP client {} initialized for {}", client_id, remote_addr);
154
155        // Store client in protocol_data
156        // Ensure base_url has http:// scheme
157        let base_url = if remote_addr.starts_with("http://") || remote_addr.starts_with("https://")
158        {
159            remote_addr.clone()
160        } else {
161            format!("http://{}", remote_addr)
162        };
163
164        // Warm the client for this host here rather than building one and dropping it.
165        // This used to bind to `_http_client`: a full rustls stack constructed at connect
166        // time and immediately discarded, while every request built another one. It is
167        // warmed *after* `base_url` exists, because the cache is keyed by host — warming
168        // an unrelated entry would leave the first real request paying the build cost.
169        #[cfg(not(target_arch = "wasm32"))]
170        Self::http_client(&base_url).await?;
171        // The browser transport has no TLS; say so at connect time rather than on the first
172        // request, so the dashboard's form shows why instead of a client that never works.
173        #[cfg(target_arch = "wasm32")]
174        transport::parse_http_url(&base_url)?;
175
176        // `default_headers` is declared in `get_startup_parameters()` as "headers included
177        // in all requests". It was never read, so setting it changed nothing. It is stored
178        // here and merged in `perform_request`, where a header the model names on the
179        // request itself wins over the default of the same name.
180        let default_headers = match &startup_params {
181            Some(params) => params.get_optional_object("default_headers")?.cloned(),
182            None => None,
183        };
184
185        app_state
186            .with_client_mut(client_id, |client| {
187                client.set_protocol_field(
188                    "http_client".to_string(),
189                    serde_json::json!("initialized"),
190                );
191                client.set_protocol_field("base_url".to_string(), serde_json::json!(base_url));
192                if let Some(headers) = &default_headers {
193                    client.set_protocol_field(
194                        "default_headers".to_string(),
195                        serde_json::Value::Object(headers.clone()),
196                    );
197                }
198            })
199            .await;
200
201        // Update status
202        app_state
203            .update_client_status(client_id, ClientStatus::Connected)
204            .await;
205        let _ = status_tx.send(format!(
206            "[CLIENT] HTTP client {} ready for {}",
207            client_id, remote_addr
208        ));
209        let _ = status_tx.send("__UPDATE_UI__".to_string());
210
211        // Injected commands (the dashboard's [ send ]). Registered BEFORE the
212        // connected-event LLM call below: a dashboard-created client defaults to a
213        // `*` manual rule, so that call can park for minutes waiting for a human and
214        // [ send ] has to work for the whole park.
215        //
216        // This task also replaces the old "poll get_client() every 5s" idle task -
217        // that check is now one arm of the command loop's select!.
218        let command_rx =
219            crate::client::command_support::register_command_channel(&app_state, client_id).await;
220        let cmd_state = app_state.clone();
221        let cmd_llm = llm_client.clone();
222        let cmd_tx = status_tx.clone();
223        let cmd_task = tokio::spawn(async move {
224            Self::command_loop(command_rx, client_id, cmd_state, cmd_llm, cmd_tx).await;
225        });
226        app_state.register_client_task(client_id, cmd_task).await;
227
228        // Call LLM with http_connected event
229        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
230            let event = Event::new(
231                &HTTP_CLIENT_CONNECTED_EVENT,
232                serde_json::json!({
233                    "base_url": base_url.clone(),
234                }),
235            );
236
237            match call_llm_for_client(
238                &llm_client,
239                &app_state,
240                client_id.to_string(),
241                &instruction,
242                "", // No memory yet for initial connection
243                Some(&event),
244                &crate::client::http::actions::HttpClientProtocol,
245                &status_tx,
246            )
247            .await
248            {
249                Ok(result) => {
250                    // Execute actions from LLM response
251                    let protocol = crate::client::http::actions::HttpClientProtocol;
252
253                    for action in result.actions {
254                        match protocol.execute_action(action.clone()) {
255                            Ok(action_result) => {
256                                match Self::apply_action(
257                                    action_result,
258                                    Dispatch::Spawn,
259                                    client_id,
260                                    &app_state,
261                                    &llm_client,
262                                    &status_tx,
263                                )
264                                .await
265                                {
266                                    Ok(Applied::Disconnect) => {
267                                        info!("LLM requested disconnect after connect");
268                                        // Drop the command handle so the dashboard stops
269                                        // offering [ send ]; that also ends the command loop.
270                                        app_state.remove_client_handle(client_id).await;
271                                        app_state
272                                            .update_client_status(
273                                                client_id,
274                                                ClientStatus::Disconnected,
275                                            )
276                                            .await;
277                                        let _ = status_tx.send("__UPDATE_UI__".to_string());
278                                        return Ok("0.0.0.0:0".parse().unwrap());
279                                    }
280                                    Ok(Applied::Executed(detail)) => {
281                                        debug!(
282                                            "HTTP client {} after connect: {}",
283                                            client_id, detail
284                                        );
285                                    }
286                                    Err(e) => {
287                                        error!(
288                                            "HTTP client {} request after connect failed: {}",
289                                            client_id, e
290                                        );
291                                    }
292                                }
293                            }
294                            Err(e) => {
295                                error!("Action execution error: {}", e);
296                            }
297                        }
298                    }
299                }
300                Err(e) => {
301                    error!("LLM error on http_connected event: {}", e);
302                }
303            }
304        }
305
306        // Return a dummy local address (HTTP is connectionless)
307        Ok("0.0.0.0:0".parse().unwrap())
308    }
309
310    /// Drain injected commands until the channel closes (client removed) or an
311    /// injected `disconnect` ends the session.
312    ///
313    /// `command_support::handle_stream_client_command` cannot serve this client:
314    /// there is no write half, and `send_http_request` yields
315    /// `ClientActionResult::Custom`. So the action goes through [`Self::apply_action`]
316    /// - the same function the connected-event path uses - and the outcome is
317    /// recorded and replied exactly the way the generic arm does it.
318    async fn command_loop(
319        mut command_rx: mpsc::Receiver<ClientCommand>,
320        client_id: ClientId,
321        app_state: Arc<AppState>,
322        llm_client: OllamaClient,
323        status_tx: mpsc::UnboundedSender<String>,
324    ) {
325        let protocol = HttpClientProtocol;
326        let mut removal_check = tokio::time::interval(REMOVAL_CHECK_INTERVAL);
327        removal_check.tick().await; // the first tick completes immediately
328
329        loop {
330            tokio::select! {
331                received = command_rx.recv() => {
332                    let Some(command) = received else { break };
333                    if Self::handle_command(
334                        &protocol,
335                        command,
336                        client_id,
337                        &app_state,
338                        &llm_client,
339                        &status_tx,
340                    )
341                    .await
342                    {
343                        break;
344                    }
345                }
346                _ = removal_check.tick() => {
347                    if app_state.get_client(client_id).await.is_none() {
348                        info!("HTTP client {} stopped", client_id);
349                        break;
350                    }
351                }
352            }
353        }
354
355        app_state.remove_client_handle(client_id).await;
356        let _ = status_tx.send("__UPDATE_UI__".to_string());
357    }
358
359    /// Execute one injected action, record it, and reply. Returns `true` when the
360    /// command loop should stop.
361    async fn handle_command(
362        protocol: &HttpClientProtocol,
363        command: ClientCommand,
364        client_id: ClientId,
365        app_state: &Arc<AppState>,
366        llm_client: &OllamaClient,
367        status_tx: &mpsc::UnboundedSender<String>,
368    ) -> bool {
369        let action = command.action.clone();
370        let outcome = match protocol.execute_action(action.clone()) {
371            Err(e) => Ok(ClientSendOutcome::Rejected {
372                error: e.to_string(),
373            }),
374            Ok(action_result) => match Self::apply_action(
375                action_result,
376                Dispatch::Await,
377                client_id,
378                app_state,
379                llm_client,
380                status_tx,
381            )
382            .await
383            {
384                // Never `Sent`: reqwest owns the socket and does not report how many
385                // bytes the request serialised to, so a byte count here would be
386                // invented. `Executed` carries the response status instead, which is
387                // both true and more useful.
388                Ok(Applied::Executed(detail)) => Ok(ClientSendOutcome::Executed { detail }),
389                Ok(Applied::Disconnect) => Ok(ClientSendOutcome::Disconnected),
390                Err(e) => Err(e),
391            },
392        };
393
394        let outcome_json = match &outcome {
395            Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
396            Err(e) => serde_json::json!({"error": e.to_string()}),
397        };
398        app_state
399            .record_access_log(
400                AccessLogOwner::Client(client_id.as_u32()),
401                protocol.protocol_name(),
402                None,
403                "injected_action",
404                action,
405                vec![outcome_json],
406            )
407            .await;
408
409        let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
410        if let Err(e) = &outcome {
411            error!("HTTP client {} injected action failed: {}", client_id, e);
412            let _ = status_tx.send(format!(
413                "[WARN] Client {} injected action failed: {}",
414                client_id, e
415            ));
416        }
417        let _ = status_tx.send("__UPDATE_UI__".to_string());
418        crate::client::command_support::reply(command, outcome);
419
420        if disconnect {
421            app_state
422                .update_client_status(client_id, ClientStatus::Disconnected)
423                .await;
424        }
425        disconnect
426    }
427
428    /// Turn one executed action into an HTTP request (or a session end).
429    ///
430    /// Shared by the connected-event handler and the injected-command loop so the
431    /// `http_request` decoding exists exactly once.
432    async fn apply_action(
433        action_result: ClientActionResult,
434        dispatch: Dispatch,
435        client_id: ClientId,
436        app_state: &Arc<AppState>,
437        llm_client: &OllamaClient,
438        status_tx: &mpsc::UnboundedSender<String>,
439    ) -> Result<Applied> {
440        match action_result {
441            ClientActionResult::Custom { name, data } if name == "http_request" => {
442                let method = data["method"].as_str().unwrap_or("GET").to_string();
443                let path = data["path"].as_str().unwrap_or("/").to_string();
444                let headers = data["headers"].as_object().cloned();
445                let body = data["body"].as_str().map(|s| s.to_string());
446
447                match dispatch {
448                    Dispatch::Spawn => {
449                        let llm_clone = llm_client.clone();
450                        let state_clone = app_state.clone();
451                        let status_clone = status_tx.clone();
452                        let (log_method, log_path) = (method.clone(), path.clone());
453
454                        let request_handle = tokio::spawn(async move {
455                            if let Err(e) = HttpClient::make_request(
456                                client_id,
457                                method,
458                                path,
459                                headers,
460                                body,
461                                state_clone,
462                                llm_clone,
463                                status_clone,
464                            )
465                            .await
466                            {
467                                error!("HTTP request failed: {}", e);
468                            }
469                        });
470                        // Registered so an in-flight request (and the LLM call it makes
471                        // on completion) is aborted when the client is stopped.
472                        app_state
473                            .register_client_task(client_id, request_handle)
474                            .await;
475                        Ok(Applied::Executed(format!(
476                            "http_request {} {} dispatched",
477                            log_method, log_path
478                        )))
479                    }
480                    Dispatch::Await => {
481                        let exchange = Self::perform_request(
482                            client_id,
483                            method.clone(),
484                            path.clone(),
485                            headers,
486                            body,
487                            app_state,
488                            status_tx,
489                        )
490                        .await?;
491                        let detail = format!(
492                            "http_request {} {} -> {} ({} byte body)",
493                            method,
494                            path,
495                            exchange.status_code,
496                            exchange.body.len()
497                        );
498
499                        let llm_clone = llm_client.clone();
500                        let state_clone = app_state.clone();
501                        let status_clone = status_tx.clone();
502                        let notify_handle = tokio::spawn(async move {
503                            HttpClient::notify_response(
504                                client_id,
505                                exchange,
506                                state_clone,
507                                llm_clone,
508                                status_clone,
509                                0,
510                            )
511                            .await;
512                        });
513                        app_state
514                            .register_client_task(client_id, notify_handle)
515                            .await;
516                        Ok(Applied::Executed(detail))
517                    }
518                }
519            }
520            ClientActionResult::Disconnect => Ok(Applied::Disconnect),
521            ClientActionResult::WaitForMore => Ok(Applied::Executed("wait_for_more".to_string())),
522            ClientActionResult::NoAction => Ok(Applied::Executed("no_action".to_string())),
523            // Not swallowed: an action this client cannot carry out says so, rather
524            // than looking identical to success.
525            ClientActionResult::Custom { name, .. } => Ok(Applied::Executed(format!(
526                "custom result '{name}' is not handled by the HTTP client"
527            ))),
528            ClientActionResult::SendData(_) => Ok(Applied::Executed(
529                "send_data has no meaning for a request/response HTTP client".to_string(),
530            )),
531            ClientActionResult::Multiple(_) => Ok(Applied::Executed(
532                "multiple results are not produced by the HTTP client".to_string(),
533            )),
534        }
535    }
536
537    /// Make an HTTP request and hand the response to the LLM.
538    #[allow(clippy::too_many_arguments)]
539    pub async fn make_request(
540        client_id: ClientId,
541        method: String,
542        path: String,
543        headers: Option<serde_json::Map<String, serde_json::Value>>,
544        body: Option<String>,
545        app_state: Arc<AppState>,
546        llm_client: OllamaClient,
547        status_tx: mpsc::UnboundedSender<String>,
548    ) -> Result<()> {
549        let exchange = Self::perform_request(
550            client_id, method, path, headers, body, &app_state, &status_tx,
551        )
552        .await?;
553        Self::notify_response(client_id, exchange, app_state, llm_client, status_tx, 0).await;
554        Ok(())
555    }
556
557    /// Perform the HTTP round-trip only. No LLM involvement, so a caller can await
558    /// this and know exactly what the server answered.
559    #[allow(clippy::too_many_arguments)]
560    pub async fn perform_request(
561        client_id: ClientId,
562        method: String,
563        path: String,
564        headers: Option<serde_json::Map<String, serde_json::Value>>,
565        body: Option<String>,
566        app_state: &AppState,
567        status_tx: &mpsc::UnboundedSender<String>,
568    ) -> Result<HttpExchange> {
569        // Base URL and the startup `default_headers`, read together under one guard.
570        let (base_url, default_headers) = app_state
571            .with_client_mut(client_id, |client| {
572                (
573                    client
574                        .get_protocol_field("base_url")
575                        .and_then(|v| v.as_str())
576                        .map(|s| s.to_string()),
577                    client
578                        .get_protocol_field("default_headers")
579                        .and_then(|v| v.as_object().cloned()),
580                )
581            })
582            .await
583            .unwrap_or((None, None));
584        let base_url = base_url.context("No base URL found")?;
585
586        // Same origin as `remote_addr` or refused: see `resolve_same_origin`. The startup
587        // `default_headers` go on every request, so a request to another host would carry
588        // the operator's API key there.
589        let url = crate::client::http_fetch::resolve_same_origin(&base_url, &path)?;
590
591        info!(
592            "HTTP client {} making request: {} {}",
593            client_id, method, url
594        );
595
596        // Startup defaults merged *underneath* the request's own headers. Merged into one
597        // map before anything is applied, because `RequestBuilder::header` appends: setting
598        // the same name twice would put both values on the wire instead of overriding.
599        // Keyed by lowercased name, because HTTP header names are case-insensitive and
600        // `Accept` from the request must replace `accept` from the defaults.
601        let mut merged = serde_json::Map::new();
602        for (key, value) in default_headers.unwrap_or_default() {
603            merged.insert(key.to_ascii_lowercase(), value);
604        }
605        if let Some(hdrs) = headers {
606            for (key, value) in hdrs {
607                merged.insert(key.to_ascii_lowercase(), value);
608            }
609        }
610        let merged: Vec<(String, String)> = merged
611            .into_iter()
612            .filter_map(|(key, value)| value.as_str().map(|v| (key, v.to_string())))
613            .collect();
614
615        let result = Self::round_trip(&method, &url, merged, body).await;
616        match result {
617            Ok(exchange) => {
618                info!(
619                    "HTTP client {} received response: {}",
620                    client_id, exchange.status_text
621                );
622                Ok(exchange)
623            }
624            Err(e) => {
625                Log::new(Some(status_tx))
626                    .error(format!("HTTP client {} request failed: {}", client_id, e));
627                Err(e)
628            }
629        }
630    }
631
632    /// The network round trip through reqwest: the native transport.
633    #[cfg(not(target_arch = "wasm32"))]
634    async fn round_trip(
635        method: &str,
636        url: &str,
637        headers: Vec<(String, String)>,
638        body: Option<String>,
639    ) -> Result<HttpExchange> {
640        // Keyed on the *request* URL, not the base: `path` may be an absolute URL
641        // pointing at a different host, and that host needs its own resolver override.
642        let http_client = Self::http_client(url).await?;
643
644        let mut request = match method.to_uppercase().as_str() {
645            "GET" => http_client.get(url),
646            "POST" => http_client.post(url),
647            "PUT" => http_client.put(url),
648            "DELETE" => http_client.delete(url),
649            "HEAD" => http_client.head(url),
650            "PATCH" => http_client.patch(url),
651            _ => return Err(anyhow::anyhow!("Unsupported HTTP method: {}", method)),
652        };
653        for (key, value) in headers {
654            request = request.header(&key, value);
655        }
656        if let Some(body_str) = body {
657            request = request.body(body_str);
658        }
659
660        let mut response = request.send().await?;
661        let status = response.status();
662        let mut resp_headers = serde_json::Map::new();
663        for (name, value) in response.headers() {
664            if let Ok(val_str) = value.to_str() {
665                resp_headers.insert(name.to_string(), serde_json::json!(val_str));
666            }
667        }
668        // Read chunk by chunk against the same bound the browser transport enforces, so a
669        // server that streams without end is refused rather than buffered until the process
670        // dies.
671        let mut body = Vec::new();
672        while let Some(chunk) = response.chunk().await? {
673            if body.len() + chunk.len() > transport::MAX_RESPONSE_BODY_BYTES {
674                anyhow::bail!(
675                    "response body exceeds the {}-byte limit; refused",
676                    transport::MAX_RESPONSE_BODY_BYTES
677                );
678            }
679            body.extend_from_slice(&chunk);
680        }
681
682        Ok(HttpExchange {
683            status_code: status.as_u16(),
684            status_text: status.to_string(),
685            headers: resp_headers,
686            body: String::from_utf8_lossy(&body).into_owned(),
687        })
688    }
689
690    /// The network round trip in the browser: hyper's HTTP/1.1 client over the page's
691    /// virtual loopback, see [`transport`].
692    #[cfg(target_arch = "wasm32")]
693    async fn round_trip(
694        method: &str,
695        url: &str,
696        headers: Vec<(String, String)>,
697        body: Option<String>,
698    ) -> Result<HttpExchange> {
699        transport::fetch(
700            method,
701            url,
702            &headers,
703            body,
704            transport::REQUEST_TIMEOUT,
705            transport::MAX_RESPONSE_BODY_BYTES,
706        )
707        .await
708    }
709
710    /// How many exchanges deep this client keeps following the model's answers. Each
711    /// response can ask for another request, which produces another response; without a
712    /// bound a model that answers every response with a request never stops.
713    const MAX_FOLLOWUP_DEPTH: u8 = 4;
714
715    /// Hand a completed exchange to the LLM as an `http_response_received` event.
716    ///
717    /// Boxed with an explicit `+ Send` because the chain is genuinely self-referential
718    /// (report → request → report) and the future is awaited inside a `tokio::spawn`,
719    /// which inference will not give `Send` on its own. This is the shape root
720    /// `CLAUDE.md` prescribes for the "client asks the model and throws the answer away"
721    /// family of defects: **a depth bound, not silence.**
722    fn notify_response(
723        client_id: ClientId,
724        exchange: HttpExchange,
725        app_state: Arc<AppState>,
726        llm_client: OllamaClient,
727        status_tx: mpsc::UnboundedSender<String>,
728        depth: u8,
729    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
730        Box::pin(async move {
731            let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
732                return;
733            };
734
735            let protocol = Arc::new(crate::client::http::actions::HttpClientProtocol::new());
736            let event = Event::new(
737                &HTTP_CLIENT_RESPONSE_RECEIVED_EVENT,
738                serde_json::json!({
739                    "status_code": exchange.status_code,
740                    "status_text": exchange.status_text,
741                    "headers": exchange.headers,
742                    "body": exchange.body,
743                }),
744            );
745
746            let memory = app_state
747                .get_memory_for_client(client_id)
748                .await
749                .unwrap_or_default();
750
751            match call_llm_for_client(
752                &llm_client,
753                &app_state,
754                client_id.to_string(),
755                &instruction,
756                &memory,
757                Some(&event),
758                protocol.as_ref(),
759                &status_tx,
760            )
761            .await
762            {
763                Ok(ClientLlmResult {
764                    actions,
765                    memory_updates,
766                }) => {
767                    // Update memory
768                    if let Some(mem) = memory_updates {
769                        app_state.set_memory_for_client(client_id, mem).await;
770                    }
771
772                    // Execute what the model asked for, and report each result back to it
773                    // until the depth bound. Reporting used to stop after one hop: the
774                    // follow-up ran but its response raised no event, so a model that read
775                    // a 302 and asked for the redirect target never learned what it got.
776                    use crate::llm::actions::client_trait::{Client, ClientActionResult};
777                    for action in actions {
778                        let Ok(ClientActionResult::Custom { name, data }) =
779                            protocol.execute_action(action.clone())
780                        else {
781                            continue;
782                        };
783                        if name != "http_request" {
784                            continue;
785                        }
786                        match Self::perform_request(
787                            client_id,
788                            data["method"].as_str().unwrap_or("GET").to_string(),
789                            data["path"].as_str().unwrap_or("/").to_string(),
790                            data["headers"].as_object().cloned(),
791                            data["body"].as_str().map(|s| s.to_string()),
792                            &app_state,
793                            &status_tx,
794                        )
795                        .await
796                        {
797                            Ok(exchange) => {
798                                if depth + 1 < Self::MAX_FOLLOWUP_DEPTH {
799                                    Self::notify_response(
800                                        client_id,
801                                        exchange,
802                                        app_state.clone(),
803                                        llm_client.clone(),
804                                        status_tx.clone(),
805                                        depth + 1,
806                                    )
807                                    .await;
808                                } else {
809                                    warn!(
810                                        "HTTP client {} reached the follow-up depth limit \
811                                         ({}); not reporting the response to the model",
812                                        client_id,
813                                        Self::MAX_FOLLOWUP_DEPTH
814                                    );
815                                }
816                            }
817                            Err(e) => {
818                                error!("HTTP client {} follow-up request failed: {}", client_id, e)
819                            }
820                        }
821                    }
822                }
823                Err(e) => {
824                    error!("LLM error for HTTP client {}: {}", client_id, e);
825                }
826            }
827        })
828    }
829}