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}