Skip to main content

netget/client/http2/
mod.rs

1//! HTTP/2 client implementation
2pub mod actions;
3
4pub use actions::Http2ClientProtocol;
5
6use anyhow::{Context, Result};
7use std::net::SocketAddr;
8use std::sync::Arc;
9use std::time::Duration;
10use tokio::sync::mpsc;
11use tracing::{error, info, warn};
12
13use crate::client::http2::actions::HTTP2_CLIENT_RESPONSE_RECEIVED_EVENT;
14use crate::client::http_fetch::FetchClient;
15use crate::client::llm_budget::call_llm_for_client;
16use crate::llm::actions::client_trait::{Client, ClientActionResult};
17use crate::llm::actions::protocol_trait::Protocol;
18use crate::llm::ollama_client::OllamaClient;
19use crate::llm::ClientLlmResult;
20use crate::logging::emit::Log;
21use crate::protocol::Event;
22use crate::state::app_state::AppState;
23use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
24use crate::state::{AccessLogOwner, ClientId, ClientStatus};
25
26/// How often the command loop re-checks that its client still exists. HTTP/2 here is
27/// request/response over reqwest with no read loop, so this is what the old idle task
28/// was for.
29const REMOVAL_CHECK_INTERVAL: Duration = Duration::from_secs(5);
30
31/// One completed HTTP/2 exchange.
32///
33/// Split out of [`Http2Client::make_request`] so the injected-command loop can await
34/// the network round-trip - and report a truthful outcome - without also awaiting the
35/// LLM call the response event triggers.
36pub struct Http2Exchange {
37    pub status_code: u16,
38    pub status_text: String,
39    pub http_version: String,
40    pub headers: serde_json::Map<String, serde_json::Value>,
41    pub body: String,
42}
43
44/// What one executed action did.
45enum Applied {
46    /// The action ran; `detail` says what it did.
47    Executed(String),
48    /// The action asked to end the session.
49    Disconnect,
50}
51
52/// HTTP/2 client that makes requests to remote HTTP/2 servers
53pub struct Http2Client;
54
55impl Http2Client {
56    /// The HTTP/2 client for one host, built once and kept.
57    ///
58    /// This used to be built **per request**, plus one more at connect that was bound to
59    /// `_http_client` and immediately dropped — both on the async runtime. Three costs,
60    /// all of which root `CLAUDE.md` records as measured rather than theoretical:
61    ///
62    /// - `reqwest::Client::builder().build()` sets up the rustls stack and loads the
63    ///   platform root store. On macOS that reads the keychain through Security.framework,
64    ///   synchronously and serialised across processes; called on the async runtime it
65    ///   parks a tokio worker, which is how the `doh` client's whole runtime stalled. It
66    ///   now runs on `spawn_blocking`.
67    /// - A fresh client per request means a fresh connection pool per request, so every
68    ///   request paid for a new TCP + TLS handshake — the opposite of what HTTP/2 is for.
69    /// - **The literal-IP resolver bypass.** `reqwest` hands the URL host to its resolver
70    ///   unconditionally and `hyper-util`'s `GaiResolver` does not special-case a dotted
71    ///   quad, so `http://127.0.0.1:8080` performs a real `getaddrinfo` — measured at 8.25
72    ///   seconds through mDNSResponder under ~100 concurrent processes. `ClientBuilder::
73    ///   resolve` is a per-host override, which is why this cache is keyed by host rather
74    ///   than being one process-wide client.
75    ///
76    /// `http2_prior_knowledge()` is kept: this client speaks cleartext h2c and does not
77    /// negotiate via ALPN, which is what makes it usable against NetGet's own HTTP/2
78    /// server (that server never advertises ALPN).
79    #[cfg(not(target_arch = "wasm32"))]
80    async fn reqwest_client(url: &str) -> Result<reqwest::Client> {
81        use std::collections::HashMap;
82        use std::sync::{Mutex, OnceLock};
83
84        static CLIENTS: OnceLock<Mutex<HashMap<String, reqwest::Client>>> = OnceLock::new();
85        let clients = CLIENTS.get_or_init(|| Mutex::new(HashMap::new()));
86
87        let host = crate::llm::ollama_client::host_of(url).to_string();
88        if let Some(client) = clients.lock().ok().and_then(|map| map.get(&host).cloned()) {
89            return Ok(client);
90        }
91
92        let build_host = host.clone();
93        let built = tokio::task::spawn_blocking(move || {
94            let mut builder = reqwest::Client::builder()
95                .timeout(std::time::Duration::from_secs(30))
96                .redirect(crate::client::http_fetch::same_origin_redirects())
97                .http2_prior_knowledge();
98            // Only when the host *is* an address. A hostname is left alone: resolving it
99            // is the resolver's job, and /etc/hosts may legitimately redirect it.
100            if let Ok(ip) = build_host.parse::<std::net::IpAddr>() {
101                // The port is irrelevant — hyper overwrites it from the URL.
102                builder = builder.resolve(&build_host, std::net::SocketAddr::new(ip, 0));
103            }
104            builder.build().context("Failed to build HTTP/2 client")
105        })
106        .await
107        .context("HTTP/2 client build task panicked")??;
108
109        match clients.lock() {
110            Ok(mut map) => Ok(map.entry(host).or_insert(built).clone()),
111            // Poisoned only if another thread panicked holding the map; the client is
112            // still usable, it just does not get cached.
113            Err(_) => Ok(built),
114        }
115    }
116    /// The HTTP/2 client for `url`'s host: the cached reqwest client natively
117    /// ([`Self::reqwest_client`]); in the browser build the shared hyper transport
118    /// ([`crate::client::http_fetch`]) speaking HTTP/2 with prior knowledge over the page's
119    /// virtual loopback, with the same 30-second bound.
120    async fn http2_client(url: &str) -> Result<FetchClient> {
121        #[cfg(not(target_arch = "wasm32"))]
122        let client = FetchClient::from_reqwest(Self::reqwest_client(url).await?);
123        #[cfg(target_arch = "wasm32")]
124        let client = {
125            let _ = url;
126            FetchClient::transport(Duration::from_secs(30)).http2_prior_knowledge()
127        };
128        Ok(client)
129    }
130
131    /// Connect to an HTTP/2 server with integrated LLM actions
132    pub async fn connect_with_llm_actions(
133        remote_addr: String,
134        llm_client: OllamaClient,
135        app_state: Arc<AppState>,
136        status_tx: mpsc::UnboundedSender<String>,
137        client_id: ClientId,
138        startup_params: Option<crate::protocol::StartupParams>,
139    ) -> Result<SocketAddr> {
140        // For HTTP/2, "connection" is logical, with persistent multiplexed streams
141        // We'll create an HTTP/2 client and store it in protocol_data
142
143        info!(
144            "HTTP/2 client {} initialized for {}",
145            client_id, remote_addr
146        );
147
148        // Warm the client for this host rather than building one and dropping it: this
149        // used to bind to `_http_client`, a full rustls stack constructed at connect time
150        // and discarded, while `perform_request` built another one for every request.
151        Self::http2_client(&remote_addr).await?;
152        // In the browser build the transport has no TLS: an `https://` server is refused here,
153        // with the reason, rather than on the first request. A bare address is spoken to as
154        // cleartext h2c, as `perform_request` does.
155        crate::client::http_fetch::check_url(&if remote_addr.contains("://") {
156            remote_addr.clone()
157        } else {
158            format!("http://{remote_addr}")
159        })?;
160
161        // `default_headers` is declared as "headers included in all requests" and nothing
162        // read it, so setting it changed nothing. Stored here, merged in `perform_request`.
163        let default_headers = match &startup_params {
164            Some(params) => params.get_optional_object("default_headers")?.cloned(),
165            None => None,
166        };
167
168        // Store client in protocol_data
169        app_state
170            .with_client_mut(client_id, |client| {
171                client.set_protocol_field(
172                    "http2_client".to_string(),
173                    serde_json::json!("initialized"),
174                );
175                client.set_protocol_field("base_url".to_string(), serde_json::json!(remote_addr));
176                if let Some(headers) = &default_headers {
177                    client.set_protocol_field(
178                        "default_headers".to_string(),
179                        serde_json::Value::Object(headers.clone()),
180                    );
181                }
182            })
183            .await;
184
185        // Update status
186        app_state
187            .update_client_status(client_id, ClientStatus::Connected)
188            .await;
189        let _ = status_tx.send(format!(
190            "[CLIENT] HTTP/2 client {} ready for {}",
191            client_id, remote_addr
192        ));
193        let _ = status_tx.send("__UPDATE_UI__".to_string());
194
195        // Injected commands (the dashboard's [ send ]). This client raises no
196        // connected event, so there is no LLM call to register ahead of - but the
197        // handle still has to exist before `connect()` returns, or the dashboard
198        // greys out [ send ] on a client that is up.
199        //
200        // This task also replaces the old "poll get_client() every 5s" idle task -
201        // that check is now one arm of the command loop's select!.
202        let command_rx =
203            crate::client::command_support::register_command_channel(&app_state, client_id).await;
204        let cmd_state = app_state.clone();
205        let cmd_llm = llm_client.clone();
206        let cmd_tx = status_tx.clone();
207        let cmd_task = tokio::spawn(async move {
208            Self::command_loop(command_rx, client_id, cmd_state, cmd_llm, cmd_tx).await;
209        });
210        app_state.register_client_task(client_id, cmd_task).await;
211
212        // Raise the connected event.
213        //
214        // It was declared and nothing ever raised it, and nothing else called this
215        // client's request code either -- so a HTTP/2 client initialised itself and
216        // then did nothing for the rest of its life. The command channel was, until now,
217        // the only thing that could reach the wire at all.
218        //
219        // Raised from a registered task rather than inline: a dashboard-created client
220        // defaults to a `*` -> manual rule, and awaiting a parked answer here would block
221        // client creation itself. Requests go through `perform_request`, which raises no
222        // event, so a connect instruction cannot start an unbounded chain.
223        let conn_base_url = remote_addr.clone();
224        let conn_state = app_state.clone();
225        let conn_llm = llm_client.clone();
226        let status_tx_c = status_tx.clone();
227        let conn_task = tokio::spawn(async move {
228            let Some(instruction) = conn_state.get_instruction_for_client(client_id).await else {
229                return;
230            };
231            let protocol = crate::client::http2::actions::Http2ClientProtocol::new();
232            let event = Event::new(
233                &crate::client::http2::actions::HTTP2_CLIENT_CONNECTED_EVENT,
234                serde_json::json!({ "base_url": conn_base_url.clone() }),
235            );
236            match crate::client::llm_budget::call_llm_for_client(
237                &conn_llm,
238                &conn_state,
239                client_id.to_string(),
240                &instruction,
241                "",
242                Some(&event),
243                &protocol,
244                &status_tx_c,
245            )
246            .await
247            {
248                Ok(result) => {
249                    if let Some(mem) = result.memory_updates {
250                        conn_state.set_memory_for_client(client_id, mem).await;
251                    }
252                    use crate::llm::actions::client_trait::{Client, ClientActionResult};
253                    for action in result.actions {
254                        let Ok(ClientActionResult::Custom { name, data }) =
255                            protocol.execute_action(action.clone())
256                        else {
257                            continue;
258                        };
259                        if name != "http2_request" {
260                            continue;
261                        }
262                        match Self::perform_request(
263                            client_id,
264                            data["method"].as_str().unwrap_or("GET").to_string(),
265                            data["path"].as_str().unwrap_or("/").to_string(),
266                            data["headers"].as_object().cloned(),
267                            data["body"].as_str().map(|s| s.to_string()),
268                            &conn_state,
269                            &status_tx_c,
270                        )
271                        .await
272                        {
273                            // The exchange used to be dropped here, so the very first
274                            // request -- the one the connect instruction asks for -- never
275                            // raised `http2_response_received`. The model was told to make
276                            // a request and then never told what came back.
277                            Ok(exchange) => {
278                                Self::notify_response(
279                                    client_id,
280                                    exchange,
281                                    conn_state.clone(),
282                                    conn_llm.clone(),
283                                    status_tx_c.clone(),
284                                    0,
285                                )
286                                .await;
287                            }
288                            Err(e) => error!(
289                                "HTTP/2 client {} connect-time request failed: {}",
290                                client_id, e
291                            ),
292                        }
293                    }
294                }
295                Err(e) => error!(
296                    "HTTP/2 client {} LLM error on connected event: {}",
297                    client_id, e
298                ),
299            }
300        });
301        app_state.register_client_task(client_id, conn_task).await;
302
303        // Return a dummy local address (HTTP/2 is connectionless)
304        Ok("0.0.0.0:0".parse().unwrap())
305    }
306
307    /// Drain injected commands until the channel closes (client removed) or an
308    /// injected `disconnect` ends the session.
309    ///
310    /// `command_support::handle_stream_client_command` cannot serve this client:
311    /// there is no write half, and `send_http2_request` yields
312    /// `ClientActionResult::Custom`. So the action goes through [`Self::apply_action`]
313    /// and the outcome is recorded and replied the way the generic arm does it.
314    async fn command_loop(
315        mut command_rx: mpsc::Receiver<ClientCommand>,
316        client_id: ClientId,
317        app_state: Arc<AppState>,
318        llm_client: OllamaClient,
319        status_tx: mpsc::UnboundedSender<String>,
320    ) {
321        let protocol = Http2ClientProtocol::new();
322        let mut removal_check = tokio::time::interval(REMOVAL_CHECK_INTERVAL);
323        removal_check.tick().await; // the first tick completes immediately
324
325        loop {
326            tokio::select! {
327                received = command_rx.recv() => {
328                    let Some(command) = received else { break };
329                    if Self::handle_command(
330                        &protocol,
331                        command,
332                        client_id,
333                        &app_state,
334                        &llm_client,
335                        &status_tx,
336                    )
337                    .await
338                    {
339                        break;
340                    }
341                }
342                _ = removal_check.tick() => {
343                    if app_state.get_client(client_id).await.is_none() {
344                        info!("HTTP/2 client {} stopped", client_id);
345                        break;
346                    }
347                }
348            }
349        }
350
351        app_state.remove_client_handle(client_id).await;
352        let _ = status_tx.send("__UPDATE_UI__".to_string());
353    }
354
355    /// Execute one injected action, record it, and reply. Returns `true` when the
356    /// command loop should stop.
357    async fn handle_command(
358        protocol: &Http2ClientProtocol,
359        command: ClientCommand,
360        client_id: ClientId,
361        app_state: &Arc<AppState>,
362        llm_client: &OllamaClient,
363        status_tx: &mpsc::UnboundedSender<String>,
364    ) -> bool {
365        let action = command.action.clone();
366        let outcome = match protocol.execute_action(action.clone()) {
367            Err(e) => Ok(ClientSendOutcome::Rejected {
368                error: e.to_string(),
369            }),
370            Ok(action_result) => {
371                match Self::apply_action(action_result, client_id, app_state, llm_client, status_tx)
372                    .await
373                {
374                    // Never `Sent`: reqwest owns the socket and does not report how
375                    // many bytes the request serialised to, so a byte count here would
376                    // be invented. `Executed` carries the response status instead.
377                    Ok(Applied::Executed(detail)) => Ok(ClientSendOutcome::Executed { detail }),
378                    Ok(Applied::Disconnect) => Ok(ClientSendOutcome::Disconnected),
379                    Err(e) => Err(e),
380                }
381            }
382        };
383
384        let outcome_json = match &outcome {
385            Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
386            Err(e) => serde_json::json!({"error": e.to_string()}),
387        };
388        app_state
389            .record_access_log(
390                AccessLogOwner::Client(client_id.as_u32()),
391                protocol.protocol_name(),
392                None,
393                "injected_action",
394                action,
395                vec![outcome_json],
396            )
397            .await;
398
399        let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
400        if let Err(e) = &outcome {
401            error!("HTTP/2 client {} injected action failed: {}", client_id, e);
402            let _ = status_tx.send(format!(
403                "[WARN] Client {} injected action failed: {}",
404                client_id, e
405            ));
406        }
407        let _ = status_tx.send("__UPDATE_UI__".to_string());
408        crate::client::command_support::reply(command, outcome);
409
410        if disconnect {
411            app_state
412                .update_client_status(client_id, ClientStatus::Disconnected)
413                .await;
414        }
415        disconnect
416    }
417
418    /// Turn one executed action into an HTTP/2 request (or a session end).
419    ///
420    /// The exchange is awaited so the caller can report what the server actually
421    /// answered; the response event is delivered from its own registered task, so a
422    /// parked manual handler cannot wedge the command loop for a human's think time.
423    async fn apply_action(
424        action_result: ClientActionResult,
425        client_id: ClientId,
426        app_state: &Arc<AppState>,
427        llm_client: &OllamaClient,
428        status_tx: &mpsc::UnboundedSender<String>,
429    ) -> Result<Applied> {
430        match action_result {
431            ClientActionResult::Custom { name, data } if name == "http2_request" => {
432                let method = data["method"].as_str().unwrap_or("GET").to_string();
433                let path = data["path"].as_str().unwrap_or("/").to_string();
434                let headers = data["headers"].as_object().cloned();
435                let body = data["body"].as_str().map(|s| s.to_string());
436
437                let exchange = Self::perform_request(
438                    client_id,
439                    method.clone(),
440                    path.clone(),
441                    headers,
442                    body,
443                    app_state,
444                    status_tx,
445                )
446                .await?;
447                let detail = format!(
448                    "http2_request {} {} -> {} ({} byte body)",
449                    method,
450                    path,
451                    exchange.status_code,
452                    exchange.body.len()
453                );
454
455                let llm_clone = llm_client.clone();
456                let state_clone = app_state.clone();
457                let status_clone = status_tx.clone();
458                let notify_handle = tokio::spawn(async move {
459                    Http2Client::notify_response(
460                        client_id,
461                        exchange,
462                        state_clone,
463                        llm_clone,
464                        status_clone,
465                        0,
466                    )
467                    .await;
468                });
469                app_state
470                    .register_client_task(client_id, notify_handle)
471                    .await;
472                Ok(Applied::Executed(detail))
473            }
474            ClientActionResult::Disconnect => Ok(Applied::Disconnect),
475            ClientActionResult::WaitForMore => Ok(Applied::Executed("wait_for_more".to_string())),
476            ClientActionResult::NoAction => Ok(Applied::Executed("no_action".to_string())),
477            // Not swallowed: an action this client cannot carry out says so, rather
478            // than looking identical to success.
479            ClientActionResult::Custom { name, .. } => Ok(Applied::Executed(format!(
480                "custom result '{name}' is not handled by the HTTP/2 client"
481            ))),
482            ClientActionResult::SendData(_) => Ok(Applied::Executed(
483                "send_data has no meaning for a request/response HTTP/2 client".to_string(),
484            )),
485            ClientActionResult::Multiple(_) => Ok(Applied::Executed(
486                "multiple results are not produced by the HTTP/2 client".to_string(),
487            )),
488        }
489    }
490
491    /// Make an HTTP/2 request and hand the response to the LLM.
492    #[allow(clippy::too_many_arguments)]
493    pub async fn make_request(
494        client_id: ClientId,
495        method: String,
496        path: String,
497        headers: Option<serde_json::Map<String, serde_json::Value>>,
498        body: Option<String>,
499        app_state: Arc<AppState>,
500        llm_client: OllamaClient,
501        status_tx: mpsc::UnboundedSender<String>,
502    ) -> Result<()> {
503        let exchange = Self::perform_request(
504            client_id, method, path, headers, body, &app_state, &status_tx,
505        )
506        .await?;
507        Self::notify_response(client_id, exchange, app_state, llm_client, status_tx, 0).await;
508        Ok(())
509    }
510
511    /// Perform the HTTP/2 round-trip only. No LLM involvement, so a caller can await
512    /// this and know exactly what the server answered.
513    #[allow(clippy::too_many_arguments)]
514    pub async fn perform_request(
515        client_id: ClientId,
516        method: String,
517        path: String,
518        headers: Option<serde_json::Map<String, serde_json::Value>>,
519        body: Option<String>,
520        app_state: &AppState,
521        status_tx: &mpsc::UnboundedSender<String>,
522    ) -> Result<Http2Exchange> {
523        // Base URL and the startup `default_headers`, read together under one guard.
524        let (base_url, default_headers) = app_state
525            .with_client_mut(client_id, |client| {
526                (
527                    client
528                        .get_protocol_field("base_url")
529                        .and_then(|v| v.as_str())
530                        .map(|s| s.to_string()),
531                    client
532                        .get_protocol_field("default_headers")
533                        .and_then(|v| v.as_object().cloned()),
534                )
535            })
536            .await
537            .unwrap_or((None, None));
538        let base_url = base_url.context("No base URL found")?;
539
540        // Same origin as `remote_addr` or refused: see `resolve_same_origin`. `base_url` is
541        // whatever the client was opened on, often a bare host:port; the helper prefixes
542        // http://, which is right for cleartext h2c. The startup `default_headers` go on
543        // every request, so a request to another host would carry the operator's API key.
544        let url = crate::client::http_fetch::resolve_same_origin(&base_url, &path)?;
545
546        info!(
547            "HTTP/2 client {} making request: {} {}",
548            client_id, method, url
549        );
550
551        let http_client = Self::http2_client(&url).await?;
552
553        let mut request = match method.to_uppercase().as_str() {
554            "GET" => http_client.get(&url),
555            "POST" => http_client.post(&url),
556            "PUT" => http_client.put(&url),
557            "DELETE" => http_client.delete(&url),
558            "HEAD" => http_client.head(&url),
559            "PATCH" => http_client.patch(&url),
560            _ => return Err(anyhow::anyhow!("Unsupported HTTP method: {}", method)),
561        };
562
563        // Startup defaults merged *underneath* the request's own headers, keyed by the
564        // lowercased name. Merged before anything is applied because
565        // `RequestBuilder::header` appends - applying both sets in turn would put two
566        // values of the same header on the wire instead of overriding.
567        let mut merged = serde_json::Map::new();
568        for (key, value) in default_headers.unwrap_or_default() {
569            merged.insert(key.to_ascii_lowercase(), value);
570        }
571        if let Some(hdrs) = headers {
572            for (key, value) in hdrs {
573                merged.insert(key.to_ascii_lowercase(), value);
574            }
575        }
576        for (key, value) in merged {
577            if let Some(val_str) = value.as_str() {
578                request = request.header(&key, val_str);
579            }
580        }
581
582        // Add body
583        if let Some(body_str) = body {
584            request = request.body(body_str);
585        }
586
587        // Make request
588        match request.send().await {
589            Ok(response) => {
590                let status = response.status();
591                let status_code = status.as_u16();
592                let version = response.version();
593
594                // Get headers
595                let mut resp_headers = serde_json::Map::new();
596                for (name, value) in response.headers() {
597                    if let Ok(val_str) = value.to_str() {
598                        resp_headers.insert(name.to_string(), serde_json::json!(val_str));
599                    }
600                }
601
602                // Get body
603                let body_text = response.text().await?;
604
605                info!(
606                    "HTTP/2 client {} received response: {} ({}) version: {:?}",
607                    client_id, status_code, status, version
608                );
609
610                Ok(Http2Exchange {
611                    status_code,
612                    status_text: status.to_string(),
613                    http_version: format!("{:?}", version),
614                    headers: resp_headers,
615                    body: body_text,
616                })
617            }
618            Err(e) => {
619                Log::new(Some(status_tx))
620                    .error(format!("HTTP/2 client {} request failed: {}", client_id, e));
621                Err(e)
622            }
623        }
624    }
625
626    /// How many exchanges deep this client keeps following the model's answers. Each
627    /// response can ask for another request, which produces another response; without a
628    /// bound a model that answers every response with a request never stops.
629    const MAX_FOLLOWUP_DEPTH: u8 = 4;
630
631    /// Hand a completed exchange to the LLM as an `http2_response_received` event.
632    ///
633    /// Boxed with an explicit `+ Send` because the chain is genuinely self-referential
634    /// (report -> request -> report) and the future is awaited inside a `tokio::spawn`.
635    fn notify_response(
636        client_id: ClientId,
637        exchange: Http2Exchange,
638        app_state: Arc<AppState>,
639        llm_client: OllamaClient,
640        status_tx: mpsc::UnboundedSender<String>,
641        depth: u8,
642    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
643        Box::pin(async move {
644            let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
645                return;
646            };
647
648            let protocol = Arc::new(crate::client::http2::actions::Http2ClientProtocol::new());
649            let event = Event::new(
650                &HTTP2_CLIENT_RESPONSE_RECEIVED_EVENT,
651                serde_json::json!({
652                    "status_code": exchange.status_code,
653                    "status_text": exchange.status_text,
654                    "http_version": exchange.http_version,
655                    "headers": exchange.headers,
656                    "body": exchange.body,
657                }),
658            );
659
660            let memory = app_state
661                .get_memory_for_client(client_id)
662                .await
663                .unwrap_or_default();
664
665            match call_llm_for_client(
666                &llm_client,
667                &app_state,
668                client_id.to_string(),
669                &instruction,
670                &memory,
671                Some(&event),
672                protocol.as_ref(),
673                &status_tx,
674            )
675            .await
676            {
677                Ok(ClientLlmResult {
678                    actions,
679                    memory_updates,
680                }) => {
681                    // Update memory
682                    if let Some(mem) = memory_updates {
683                        app_state.set_memory_for_client(client_id, mem).await;
684                    }
685
686                    // Execute what the model asked for, and report each result back to it
687                    // until the depth bound above. These used to be discarded, so a model
688                    // that read a response and wanted to follow it with another request
689                    // was silently ignored — the entire purpose of raising the event.
690                    use crate::llm::actions::client_trait::{Client, ClientActionResult};
691                    for action in actions {
692                        let Ok(ClientActionResult::Custom { name, data }) =
693                            protocol.execute_action(action.clone())
694                        else {
695                            continue;
696                        };
697                        if name != "http2_request" {
698                            continue;
699                        }
700                        match Self::perform_request(
701                            client_id,
702                            data["method"].as_str().unwrap_or("GET").to_string(),
703                            data["path"].as_str().unwrap_or("/").to_string(),
704                            data["headers"].as_object().cloned(),
705                            data["body"].as_str().map(|s| s.to_string()),
706                            &app_state,
707                            &status_tx,
708                        )
709                        .await
710                        {
711                            Ok(exchange) => {
712                                if depth + 1 < Self::MAX_FOLLOWUP_DEPTH {
713                                    Self::notify_response(
714                                        client_id,
715                                        exchange,
716                                        app_state.clone(),
717                                        llm_client.clone(),
718                                        status_tx.clone(),
719                                        depth + 1,
720                                    )
721                                    .await;
722                                } else {
723                                    warn!(
724                                        "HTTP/2 client {} reached the follow-up depth limit \
725                                     ({}); not reporting the response to the model",
726                                        client_id,
727                                        Self::MAX_FOLLOWUP_DEPTH
728                                    );
729                                }
730                            }
731                            Err(e) => error!(
732                                "HTTP/2 client {} follow-up request failed: {}",
733                                client_id, e
734                            ),
735                        }
736                    }
737                }
738                Err(e) => {
739                    error!("LLM error for HTTP/2 client {}: {}", client_id, e);
740                }
741            }
742        })
743    }
744}