Skip to main content

netget/client/jsonrpc/
mod.rs

1//! JSON-RPC 2.0 client implementation
2pub mod actions;
3
4pub use actions::JsonRpcClientProtocol;
5
6use anyhow::{Context, Result};
7use std::net::SocketAddr;
8use std::sync::Arc;
9use tokio::sync::mpsc;
10use tracing::{error, info, warn};
11
12use crate::client::http_fetch::FetchClient;
13use crate::client::jsonrpc::actions::JSONRPC_CLIENT_RESPONSE_RECEIVED_EVENT;
14use crate::client::llm_budget::call_llm_for_client;
15use crate::llm::actions::client_trait::{Client, ClientActionResult};
16use crate::llm::ollama_client::OllamaClient;
17use crate::llm::ClientLlmResult;
18use crate::protocol::Event;
19use crate::state::app_state::AppState;
20use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
21use crate::state::{AccessLogOwner, ClientId, ClientStatus};
22
23/// What [`JsonRpcClient::apply_action`] did with one executed action. JSON-RPC rides on
24/// HTTP request/response, so there is no persistent socket whose byte count could be
25/// reported - every variant carries a description of what actually happened instead.
26enum Applied {
27    /// The action ran; the string describes the effect (request made, nothing to do, ...).
28    Ran(String),
29    /// The action asked to end the session.
30    Disconnect,
31}
32
33/// Whether the response event is raised inline or from its own task.
34#[derive(Clone, Copy)]
35enum Dispatch {
36    /// Raise it here and now. Used by the connected-event LLM path, which already runs in
37    /// its own task and whose ordering the E2E tests rely on.
38    Inline,
39    /// Hand it to a registered task. Used by the injected-command loop so that a manual
40    /// (human-answered) routing rule on `jsonrpc_response_received` cannot hold up the
41    /// command's outcome, or the next injected command.
42    Deferred,
43}
44
45/// One completed exchange waiting to be reported to the LLM.
46enum Notification {
47    Single((u16, Option<serde_json::Value>)),
48    Batch((u16, Option<serde_json::Value>)),
49}
50
51/// How many times a `jsonrpc_response_received` answer may itself provoke another request.
52///
53/// The chain `request -> response event -> the model's next request` is genuinely
54/// self-referential, and an `async fn` that awaits itself has an infinitely-sized future
55/// (E0391). The answer is a depth bound, not silence: the follow-up path used to dispatch
56/// through the `perform_*` cores, which raise no event, so the chain was exactly one step
57/// deep — a `get` issued in reply to a `put` confirmation ran and its result went nowhere.
58/// That is the `elasticsearch`/`http2` defect the root CLAUDE.md names.
59const MAX_FOLLOWUP_DEPTH: usize = 6;
60
61/// JSON-RPC 2.0 client that makes RPC calls to remote servers
62pub struct JsonRpcClient;
63
64impl JsonRpcClient {
65    /// The HTTP client for one host, built once and kept.
66    ///
67    /// This client used to build a fresh `reqwest::Client` **on every single request** — and
68    /// additionally built one at connect and dropped it immediately (`let _http_client = …`),
69    /// which did nothing but pay the cost. Three things that cost:
70    ///
71    /// - `Client::builder().build()` sets up the rustls stack and loads the platform root
72    ///   store; on macOS that reads the system keychain through Security.framework,
73    ///   synchronously and serialised across processes. On the async runtime it parks a tokio
74    ///   worker, so it runs on `spawn_blocking` here.
75    /// - Keeping it means later requests reuse the connection pool instead of paying for a
76    ///   fresh TLS stack and handshake each time. "Each request creates new HTTP connection"
77    ///   was listed as a limitation of this client; it was a consequence of this.
78    /// - **The literal-IP resolver bypass.** `reqwest` hands the URL host to its DNS resolver
79    ///   unconditionally and `hyper-util`'s `GaiResolver` does not special-case a dotted quad,
80    ///   so `http://127.0.0.1:8080` performs a real `getaddrinfo` — on macOS through libinfo
81    ///   to mDNSResponder, one system-wide daemon, measured blocking for 8.25 seconds under
82    ///   ~100 concurrent processes. A NetGet JSON-RPC client is pointed at a literal IP more
83    ///   often than not.
84    ///
85    /// That last point is why the cache is keyed by host rather than being one process-wide
86    /// client: `ClientBuilder::resolve` is a per-host override. Copied from
87    /// `src/client/http/mod.rs`, which carries the full reasoning.
88    #[cfg(not(target_arch = "wasm32"))]
89    async fn reqwest_client(base_url: &str) -> Result<reqwest::Client> {
90        use std::collections::HashMap;
91        use std::sync::{Mutex, OnceLock};
92
93        static CLIENTS: OnceLock<Mutex<HashMap<String, reqwest::Client>>> = OnceLock::new();
94        let clients = CLIENTS.get_or_init(|| Mutex::new(HashMap::new()));
95
96        let host = crate::llm::ollama_client::host_of(base_url).to_string();
97        if let Some(client) = clients.lock().ok().and_then(|map| map.get(&host).cloned()) {
98            return Ok(client);
99        }
100
101        let build_host = host.clone();
102        let built = tokio::task::spawn_blocking(move || {
103            let mut builder = reqwest::Client::builder()
104                .timeout(std::time::Duration::from_secs(30))
105                .use_rustls_tls();
106            // Only when the host *is* an address. A hostname is left alone: resolving it is
107            // the resolver's job, and /etc/hosts or split-horizon DNS may legitimately point
108            // it somewhere unexpected.
109            if let Ok(ip) = build_host.parse::<std::net::IpAddr>() {
110                // The port is irrelevant — hyper overwrites it from the URL — so one override
111                // serves every port this client is later pointed at.
112                builder = builder.resolve(&build_host, std::net::SocketAddr::new(ip, 0));
113            }
114            builder
115                .build()
116                .context("Failed to build HTTP client for JSON-RPC")
117        })
118        .await
119        .context("JSON-RPC HTTP client build task panicked")??;
120
121        // A concurrent builder may have won the race; either client is equally valid, so keep
122        // whichever landed first rather than replacing it and orphaning its pool.
123        match clients.lock() {
124            Ok(mut map) => Ok(map.entry(host).or_insert(built).clone()),
125            Err(_) => Ok(built),
126        }
127    }
128
129    /// The HTTP client for `base_url`: the cached reqwest client natively
130    /// ([`Self::reqwest_client`]), the shared hyper transport over the virtual loopback in the
131    /// browser build ([`crate::client::http_fetch`]), with the same 30-second bound.
132    async fn http_client(base_url: &str) -> Result<FetchClient> {
133        #[cfg(not(target_arch = "wasm32"))]
134        let client = FetchClient::from_reqwest(Self::reqwest_client(base_url).await?);
135        #[cfg(target_arch = "wasm32")]
136        let client = {
137            let _ = base_url;
138            FetchClient::transport(std::time::Duration::from_secs(30))
139        };
140        Ok(client)
141    }
142
143    /// Connect to a JSON-RPC server with integrated LLM actions
144    pub async fn connect_with_llm_actions(
145        remote_addr: String,
146        llm_client: OllamaClient,
147        app_state: Arc<AppState>,
148        status_tx: mpsc::UnboundedSender<String>,
149        client_id: ClientId,
150        startup_params: Option<crate::protocol::StartupParams>,
151    ) -> Result<SocketAddr> {
152        // JSON-RPC is HTTP-based, so "connection" is logical
153        // We'll create an HTTP client and store it in protocol_data
154
155        // Every other client protocol takes a bare `host:port`, and that is what a model
156        // asked to "connect to 127.0.0.1:8080" produces. reqwest requires an absolute URL,
157        // so a bare authority reached the wire as `builder error` (relative URL without a
158        // base) on every single request -- with nothing in the message naming the address.
159        // An explicit scheme is left exactly as given, including https.
160        let remote_addr = if remote_addr.contains("://") {
161            remote_addr
162        } else {
163            format!("http://{remote_addr}")
164        };
165        // In the browser build the transport has no TLS: an `https://` endpoint is refused here,
166        // with the reason, rather than on the first call.
167        crate::client::http_fetch::check_url(&remote_addr)?;
168
169        info!(
170            "JSON-RPC client {} initialized for {}",
171            client_id, remote_addr
172        );
173
174        // The HTTP client is built lazily and cached per host by `Self::http_client`, on the
175        // blocking pool. A client was built here and dropped on the next line
176        // (`let _http_client = …`), paying the whole rustls/keychain cost for nothing, while
177        // every request built another one of its own.
178
179        // `default_headers` is declared as "headers included in all requests" - the one
180        // place this protocol's own docs point at for API keys and bearer tokens - and
181        // nothing read it, so an authenticated endpoint refused every call. Stored here and
182        // applied by `perform_request` / `perform_batch_request`.
183        let default_headers = match &startup_params {
184            Some(params) => params.get_optional_object("default_headers")?.cloned(),
185            None => None,
186        };
187
188        // Store client in protocol_data
189        app_state
190            .with_client_mut(client_id, |client| {
191                client.set_protocol_field(
192                    "jsonrpc_client".to_string(),
193                    serde_json::json!("initialized"),
194                );
195                client.set_protocol_field(
196                    "endpoint".to_string(),
197                    serde_json::json!(remote_addr.clone()),
198                );
199                client.set_protocol_field("next_id".to_string(), serde_json::json!(1));
200                if let Some(headers) = &default_headers {
201                    client.set_protocol_field(
202                        "default_headers".to_string(),
203                        serde_json::Value::Object(headers.clone()),
204                    );
205                }
206            })
207            .await;
208
209        // Update status
210        app_state
211            .update_client_status(client_id, ClientStatus::Connected)
212            .await;
213        let _ = status_tx.send(format!(
214            "[CLIENT] JSON-RPC client {} ready for {}",
215            client_id, remote_addr
216        ));
217        let _ = status_tx.send("__UPDATE_UI__".to_string());
218
219        // Command channel for injected actions (the dashboard's [ send_jsonrpc_request ] /
220        // [ disconnect ]). Registered BEFORE the connected-event LLM call, which a manual
221        // `*` routing rule can park for minutes - the operator must be able to send a
222        // request while it waits.
223        let command_rx =
224            crate::client::command_support::register_command_channel(&app_state, client_id).await;
225        let cmd_task = tokio::spawn(Self::command_loop(
226            command_rx,
227            client_id,
228            app_state.clone(),
229            llm_client.clone(),
230            status_tx.clone(),
231        ));
232        app_state.register_client_task(client_id, cmd_task).await;
233
234        // Call LLM with initial connected event
235        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
236            let protocol = Arc::new(crate::client::jsonrpc::actions::JsonRpcClientProtocol::new());
237            let event = Event::new(
238                &crate::client::jsonrpc::actions::JSONRPC_CLIENT_CONNECTED_EVENT,
239                serde_json::json!({
240                    "endpoint": remote_addr,
241                }),
242            );
243
244            let memory = app_state
245                .get_memory_for_client(client_id)
246                .await
247                .unwrap_or_default();
248
249            // Spawn task to process initial actions
250            let app_state_clone = app_state.clone();
251            let llm_client_clone = llm_client.clone();
252            let status_tx_clone = status_tx.clone();
253            // Registered with AppState so stop_client can abort this task —
254            // dropping a JoinHandle only detaches it in Tokio.
255            let task_registrar = app_state.clone();
256            let task_handle = tokio::spawn(async move {
257                match call_llm_for_client(
258                    &llm_client_clone,
259                    &app_state_clone,
260                    client_id.to_string(),
261                    &instruction,
262                    &memory,
263                    Some(&event),
264                    protocol.as_ref(),
265                    &status_tx_clone,
266                )
267                .await
268                {
269                    Ok(ClientLlmResult {
270                        actions,
271                        memory_updates,
272                    }) => {
273                        // Update memory
274                        if let Some(mem) = memory_updates {
275                            app_state_clone.set_memory_for_client(client_id, mem).await;
276                        }
277
278                        // Execute actions through the same path injected commands use.
279                        for action in actions {
280                            let result = match protocol.execute_action(action) {
281                                Ok(result) => result,
282                                Err(e) => {
283                                    error!("JSON-RPC client {} rejected action: {}", client_id, e);
284                                    continue;
285                                }
286                            };
287                            match Self::apply_action(
288                                result,
289                                Dispatch::Inline,
290                                client_id,
291                                &app_state_clone,
292                                &llm_client_clone,
293                                &status_tx_clone,
294                                0,
295                            )
296                            .await
297                            {
298                                Ok(Applied::Ran(detail)) => {
299                                    info!("JSON-RPC client {}: {}", client_id, detail);
300                                }
301                                Ok(Applied::Disconnect) => break,
302                                Err(e) => {
303                                    error!("JSON-RPC client {} action failed: {}", client_id, e);
304                                }
305                            }
306                        }
307                    }
308                    Err(e) => {
309                        error!("LLM error for JSON-RPC client {}: {}", client_id, e);
310                    }
311                }
312            });
313            task_registrar
314                .register_client_task(client_id, task_handle)
315                .await;
316        }
317
318        // No idle-poll task: the command loop above is this client's only long-lived task
319        // and it ends when the client is removed (`remove_client` drops the command
320        // sender, so `recv()` returns `None`).
321
322        // Return a dummy local address (JSON-RPC is connectionless over HTTP)
323        Ok("0.0.0.0:0".parse().unwrap())
324    }
325
326    /// Run one executed action. Shared by the connected-event LLM path and injected
327    /// commands so the request encoding exists exactly once.
328    ///
329    /// `dispatch` decides only *when the response event is raised*, never what goes on the
330    /// wire: the LLM path keeps raising it inline, while an injected command awaits the
331    /// HTTP round-trip and hands the event to its own task, so a manual routing rule
332    /// parking on `jsonrpc_response_received` cannot wedge the command loop.
333    ///
334    /// `depth` is how many response events already led here; see [`MAX_FOLLOWUP_DEPTH`].
335    ///
336    /// Returns a boxed future rather than being a plain `async fn`. The chain
337    /// `apply_action -> deliver_response -> notify_response -> run_follow_ups ->
338    /// apply_action` is a real cycle, and an `async fn` that awaits itself has an
339    /// infinitely-sized future (E0391); erasing the type at one edge makes it finite, and
340    /// `+ Send` has to be named explicitly because the deferred arm awaits it inside a
341    /// `tokio::spawn`.
342    fn apply_action<'a>(
343        result: ClientActionResult,
344        dispatch: Dispatch,
345        client_id: ClientId,
346        app_state: &'a Arc<AppState>,
347        llm_client: &'a OllamaClient,
348        status_tx: &'a mpsc::UnboundedSender<String>,
349        depth: usize,
350    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Applied>> + Send + 'a>> {
351        Box::pin(async move {
352            match result {
353                ClientActionResult::Custom { name, data } if name == "jsonrpc_request" => {
354                    let method = data
355                        .get("method")
356                        .and_then(|v| v.as_str())
357                        .unwrap_or("")
358                        .to_string();
359                    let params = data.get("params").cloned();
360                    let id = data.get("id").cloned();
361
362                    let exchange =
363                        Self::perform_request(client_id, &method, params, id, app_state).await?;
364                    let detail = format!(
365                        "jsonrpc_request '{}' sent; HTTP {} ({})",
366                        method,
367                        exchange.0,
368                        if exchange.1.is_some() {
369                            "JSON-RPC response received"
370                        } else {
371                            "body was not valid JSON"
372                        }
373                    );
374                    Self::deliver_response(
375                        Notification::Single(exchange),
376                        dispatch,
377                        client_id,
378                        app_state,
379                        llm_client,
380                        status_tx,
381                        depth,
382                    )
383                    .await;
384                    Ok(Applied::Ran(detail))
385                }
386                ClientActionResult::Custom { name, data } if name == "jsonrpc_batch" => {
387                    let requests = data
388                        .get("requests")
389                        .and_then(|v| v.as_array())
390                        .cloned()
391                        .unwrap_or_default();
392                    let count = requests.len();
393
394                    let exchange =
395                        Self::perform_batch_request(client_id, requests, app_state).await?;
396                    let detail = format!(
397                        "jsonrpc_batch of {} request(s) sent; HTTP {} ({})",
398                        count,
399                        exchange.0,
400                        if exchange.1.is_some() {
401                            "batch response received"
402                        } else {
403                            "body was not valid JSON"
404                        }
405                    );
406                    Self::deliver_response(
407                        Notification::Batch(exchange),
408                        dispatch,
409                        client_id,
410                        app_state,
411                        llm_client,
412                        status_tx,
413                        depth,
414                    )
415                    .await;
416                    Ok(Applied::Ran(detail))
417                }
418                ClientActionResult::Disconnect => {
419                    info!("JSON-RPC client {} disconnecting", client_id);
420                    app_state
421                        .update_client_status(client_id, ClientStatus::Disconnected)
422                        .await;
423                    let _ = status_tx.send("__UPDATE_UI__".to_string());
424                    Ok(Applied::Disconnect)
425                }
426                ClientActionResult::Custom { name, .. } => Err(anyhow::anyhow!(
427                    "JSON-RPC client cannot execute custom result '{}'",
428                    name
429                )),
430                // WaitForMore / NoAction / SendData / nested Multiple: nothing to put on the
431                // wire for a request/response protocol.
432                _ => Ok(Applied::Ran(
433                    "no request made (action produced no JSON-RPC call)".to_string(),
434                )),
435            }
436        })
437    }
438
439    /// Raise the response event, inline or from a registered task.
440    async fn deliver_response(
441        notification: Notification,
442        dispatch: Dispatch,
443        client_id: ClientId,
444        app_state: &Arc<AppState>,
445        llm_client: &OllamaClient,
446        status_tx: &mpsc::UnboundedSender<String>,
447        depth: usize,
448    ) {
449        match dispatch {
450            Dispatch::Inline => match notification {
451                Notification::Single(exchange) => {
452                    Self::notify_response(
453                        client_id, exchange, app_state, llm_client, status_tx, depth,
454                    )
455                    .await
456                }
457                Notification::Batch(exchange) => {
458                    Self::notify_batch_response(
459                        client_id, exchange, app_state, llm_client, status_tx, depth,
460                    )
461                    .await
462                }
463            },
464            Dispatch::Deferred => {
465                let state = app_state.clone();
466                let llm = llm_client.clone();
467                let tx = status_tx.clone();
468                let handle = tokio::spawn(async move {
469                    match notification {
470                        Notification::Single(exchange) => {
471                            Self::notify_response(client_id, exchange, &state, &llm, &tx, depth)
472                                .await
473                        }
474                        Notification::Batch(exchange) => {
475                            Self::notify_batch_response(
476                                client_id, exchange, &state, &llm, &tx, depth,
477                            )
478                            .await
479                        }
480                    }
481                });
482                // Registered so stop_client aborts an in-flight LLM call for this event.
483                app_state.register_client_task(client_id, handle).await;
484            }
485        }
486    }
487
488    /// Drain injected commands until the channel closes (the client was removed) or an
489    /// injected `disconnect` ends the session. The request is awaited, so the reported
490    /// [`ClientSendOutcome`] describes a round-trip that really happened.
491    async fn command_loop(
492        mut command_rx: mpsc::Receiver<ClientCommand>,
493        client_id: ClientId,
494        app_state: Arc<AppState>,
495        llm_client: OllamaClient,
496        status_tx: mpsc::UnboundedSender<String>,
497    ) {
498        use crate::llm::actions::protocol_trait::Protocol;
499
500        let protocol = crate::client::jsonrpc::actions::JsonRpcClientProtocol::new();
501
502        while let Some(command) = command_rx.recv().await {
503            let action = command.action.clone();
504            let outcome = match protocol.execute_action(action.clone()) {
505                Err(e) => Ok(ClientSendOutcome::Rejected {
506                    error: e.to_string(),
507                }),
508                Ok(result) => Self::apply_action(
509                    result,
510                    Dispatch::Deferred,
511                    client_id,
512                    &app_state,
513                    &llm_client,
514                    &status_tx,
515                    0,
516                )
517                .await
518                .map(|applied| match applied {
519                    Applied::Disconnect => ClientSendOutcome::Disconnected,
520                    Applied::Ran(detail) => ClientSendOutcome::Executed { detail },
521                }),
522            };
523
524            let outcome_json = match &outcome {
525                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
526                Err(e) => serde_json::json!({"error": e.to_string()}),
527            };
528            app_state
529                .record_access_log(
530                    AccessLogOwner::Client(client_id.as_u32()),
531                    protocol.protocol_name(),
532                    None,
533                    "injected_action",
534                    action,
535                    vec![outcome_json],
536                )
537                .await;
538
539            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
540            if let Err(e) = &outcome {
541                error!(
542                    "JSON-RPC client {} injected action failed: {}",
543                    client_id, e
544                );
545                let _ = status_tx.send(format!(
546                    "[WARN] Client {} injected action failed: {}",
547                    client_id, e
548                ));
549            }
550            let _ = status_tx.send("__UPDATE_UI__".to_string());
551            crate::client::command_support::reply(command, outcome);
552
553            if disconnect {
554                break;
555            }
556        }
557
558        // Nothing can be injected any more: stop the dashboard offering [ send ].
559        app_state.remove_client_handle(client_id).await;
560        let _ = status_tx.send("__UPDATE_UI__".to_string());
561        info!("JSON-RPC client {} command loop ended", client_id);
562    }
563
564    /// Make a JSON-RPC request and hand the response to the LLM.
565    pub async fn make_request(
566        client_id: ClientId,
567        method: String,
568        params: Option<serde_json::Value>,
569        id: Option<serde_json::Value>,
570        app_state: Arc<AppState>,
571        llm_client: OllamaClient,
572        status_tx: mpsc::UnboundedSender<String>,
573    ) -> Result<()> {
574        let exchange = Self::perform_request(client_id, &method, params, id, &app_state).await?;
575        Self::notify_response(client_id, exchange, &app_state, &llm_client, &status_tx, 0).await;
576        Ok(())
577    }
578
579    /// POST one JSON-RPC request and return `(http_status, parsed_body)`. Split out of
580    /// [`Self::make_request`] so the injected-command loop can await the round-trip - and
581    /// report what really happened - without also awaiting the LLM call the response event
582    /// triggers, which a manual routing rule can park for minutes.
583    /// Endpoint plus the request headers to send: the `default_headers` startup parameter
584    /// with JSON-RPC's mandatory `content-type` on top of it, so a default can carry an API
585    /// key or bearer token but cannot make the body unparseable to the server.
586    ///
587    /// Keys are lowercased before merging because HTTP header names are case-insensitive,
588    /// and applied from one map because `RequestBuilder::header` appends rather than
589    /// replaces.
590    async fn endpoint_and_headers(
591        client_id: ClientId,
592        app_state: &Arc<AppState>,
593    ) -> Result<(String, serde_json::Map<String, serde_json::Value>)> {
594        let (endpoint, defaults) = app_state
595            .with_client_mut(client_id, |client| {
596                (
597                    client
598                        .get_protocol_field("endpoint")
599                        .and_then(|v| v.as_str())
600                        .map(|s| s.to_string()),
601                    client
602                        .get_protocol_field("default_headers")
603                        .and_then(|v| v.as_object().cloned()),
604                )
605            })
606            .await
607            .unwrap_or((None, None));
608        let endpoint = endpoint.context("No endpoint found")?;
609
610        let mut headers = serde_json::Map::new();
611        for (key, value) in defaults.unwrap_or_default() {
612            headers.insert(key.to_ascii_lowercase(), value);
613        }
614        headers.insert(
615            "content-type".to_string(),
616            serde_json::json!("application/json"),
617        );
618        Ok((endpoint, headers))
619    }
620
621    async fn perform_request(
622        client_id: ClientId,
623        method: &str,
624        params: Option<serde_json::Value>,
625        id: Option<serde_json::Value>,
626        app_state: &Arc<AppState>,
627    ) -> Result<(u16, Option<serde_json::Value>)> {
628        let (endpoint, headers) = Self::endpoint_and_headers(client_id, app_state).await?;
629
630        info!("JSON-RPC client {} calling method: {}", client_id, method);
631
632        // Build JSON-RPC request
633        let mut request = serde_json::json!({
634            "jsonrpc": "2.0",
635            "method": method,
636        });
637
638        if let Some(p) = params {
639            request["params"] = p;
640        }
641
642        if let Some(request_id) = id {
643            request["id"] = request_id.clone();
644        }
645
646        // Built once per host and kept; see `Self::http_client`.
647        let http_client = Self::http_client(&endpoint).await?;
648
649        let mut http_request = http_client.post(&endpoint);
650        for (key, value) in &headers {
651            if let Some(val_str) = value.as_str() {
652                http_request = http_request.header(key, val_str);
653            }
654        }
655        let response = http_request.json(&request).send().await?;
656
657        let status_code = response.status().as_u16();
658        let body_text = response.text().await?;
659
660        info!(
661            "JSON-RPC client {} received response: {}",
662            client_id, status_code
663        );
664
665        match serde_json::from_str::<serde_json::Value>(&body_text) {
666            Ok(response_json) => Ok((status_code, Some(response_json))),
667            Err(_) => {
668                error!(
669                    "JSON-RPC client {} received invalid JSON response",
670                    client_id
671                );
672                Ok((status_code, None))
673            }
674        }
675    }
676
677    /// Run the actions the model returned for a response event.
678    ///
679    /// They were discarded outright (`actions: _`) at both notify sites, so answering
680    /// `jsonrpc_response_received` did nothing — the whole point of raising it. The first
681    /// repair dispatched through the `perform_*` cores, which raise no event: that bounded
682    /// the loop, but it made the chain exactly **one** step deep, so a request the model
683    /// issued in reply to a response got its own reply thrown away. That is the
684    /// `elasticsearch`/`http2` shape the root CLAUDE.md names, and the prescribed answer is a
685    /// depth bound rather than silence.
686    ///
687    /// So follow-ups now go through the ordinary [`Self::apply_action`] path — they raise
688    /// their own response event and can be chained — capped at [`MAX_FOLLOWUP_DEPTH`]. The
689    /// bound is checked here rather than before raising the event, so the model always learns
690    /// what a request answered; only its next request is refused, and it is refused loudly.
691    async fn run_follow_ups(
692        client_id: ClientId,
693        actions: Vec<serde_json::Value>,
694        app_state: &Arc<AppState>,
695        llm_client: &OllamaClient,
696        status_tx: &mpsc::UnboundedSender<String>,
697        depth: usize,
698    ) {
699        use crate::llm::actions::client_trait::Client;
700
701        if actions.is_empty() {
702            return;
703        }
704        if depth >= MAX_FOLLOWUP_DEPTH {
705            warn!(
706                "JSON-RPC client {} reached the follow-up depth bound ({}); {} action(s) from \
707                 this response were not run",
708                client_id,
709                MAX_FOLLOWUP_DEPTH,
710                actions.len()
711            );
712            let _ = status_tx.send(format!(
713                "[WARN] Client {} stopped chaining JSON-RPC requests at depth {}",
714                client_id, MAX_FOLLOWUP_DEPTH
715            ));
716            return;
717        }
718
719        let protocol = crate::client::jsonrpc::actions::JsonRpcClientProtocol::new();
720        for action in actions {
721            let result = match protocol.execute_action(action) {
722                Ok(result) => result,
723                Err(e) => {
724                    error!(
725                        "JSON-RPC client {} rejected follow-up action: {}",
726                        client_id, e
727                    );
728                    continue;
729                }
730            };
731            match Self::apply_action(
732                result,
733                Dispatch::Inline,
734                client_id,
735                app_state,
736                llm_client,
737                status_tx,
738                depth + 1,
739            )
740            .await
741            {
742                Ok(Applied::Ran(detail)) => info!("JSON-RPC client {}: {}", client_id, detail),
743                Ok(Applied::Disconnect) => break,
744                Err(e) => error!(
745                    "JSON-RPC client {} follow-up action failed: {}",
746                    client_id, e
747                ),
748            }
749        }
750    }
751
752    /// Raise `jsonrpc_response_received` for a completed exchange.
753    async fn notify_response(
754        client_id: ClientId,
755        exchange: (u16, Option<serde_json::Value>),
756        app_state: &Arc<AppState>,
757        llm_client: &OllamaClient,
758        status_tx: &mpsc::UnboundedSender<String>,
759        depth: usize,
760    ) {
761        let Some(response_json) = exchange.1 else {
762            return;
763        };
764        let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
765            return;
766        };
767
768        let protocol = Arc::new(crate::client::jsonrpc::actions::JsonRpcClientProtocol::new());
769        let event = Event::new(&JSONRPC_CLIENT_RESPONSE_RECEIVED_EVENT, response_json);
770
771        let memory = app_state
772            .get_memory_for_client(client_id)
773            .await
774            .unwrap_or_default();
775
776        match call_llm_for_client(
777            llm_client,
778            app_state,
779            client_id.to_string(),
780            &instruction,
781            &memory,
782            Some(&event),
783            protocol.as_ref(),
784            status_tx,
785        )
786        .await
787        {
788            Ok(ClientLlmResult {
789                actions,
790                memory_updates,
791            }) => {
792                // Update memory
793                if let Some(mem) = memory_updates {
794                    app_state.set_memory_for_client(client_id, mem).await;
795                }
796                Self::run_follow_ups(client_id, actions, app_state, llm_client, status_tx, depth)
797                    .await;
798            }
799            Err(e) => {
800                error!("LLM error for JSON-RPC client {}: {}", client_id, e);
801            }
802        }
803    }
804
805    /// Make a batch JSON-RPC request and hand the responses to the LLM.
806    pub async fn make_batch_request(
807        client_id: ClientId,
808        requests: Vec<serde_json::Value>,
809        app_state: Arc<AppState>,
810        llm_client: OllamaClient,
811        status_tx: mpsc::UnboundedSender<String>,
812    ) -> Result<()> {
813        let exchange = Self::perform_batch_request(client_id, requests, &app_state).await?;
814        Self::notify_batch_response(client_id, exchange, &app_state, &llm_client, &status_tx, 0)
815            .await;
816        Ok(())
817    }
818
819    /// POST one JSON-RPC batch and return `(http_status, parsed_body)`. Same split (and
820    /// same reason) as [`Self::perform_request`].
821    async fn perform_batch_request(
822        client_id: ClientId,
823        requests: Vec<serde_json::Value>,
824        app_state: &Arc<AppState>,
825    ) -> Result<(u16, Option<serde_json::Value>)> {
826        let (endpoint, headers) = Self::endpoint_and_headers(client_id, app_state).await?;
827
828        info!(
829            "JSON-RPC client {} sending batch with {} requests",
830            client_id,
831            requests.len()
832        );
833
834        // Build batch request (array of JSON-RPC requests)
835        let mut batch = Vec::new();
836        for req in requests {
837            let mut request = serde_json::json!({
838                "jsonrpc": "2.0",
839            });
840
841            // Merge the request fields
842            if let Some(method) = req.get("method") {
843                request["method"] = method.clone();
844            }
845            if let Some(params) = req.get("params") {
846                request["params"] = params.clone();
847            }
848            if let Some(id) = req.get("id") {
849                request["id"] = id.clone();
850            }
851
852            batch.push(request);
853        }
854
855        // Built once per host and kept; see `Self::http_client`.
856        let http_client = Self::http_client(&endpoint).await?;
857
858        let mut http_request = http_client.post(&endpoint);
859        for (key, value) in &headers {
860            if let Some(val_str) = value.as_str() {
861                http_request = http_request.header(key, val_str);
862            }
863        }
864        let response = http_request.json(&batch).send().await?;
865
866        let status_code = response.status().as_u16();
867        let body_text = response.text().await?;
868
869        info!(
870            "JSON-RPC client {} received batch response: {}",
871            client_id, status_code
872        );
873
874        match serde_json::from_str::<serde_json::Value>(&body_text) {
875            Ok(response_json) => Ok((status_code, Some(response_json))),
876            Err(_) => {
877                error!(
878                    "JSON-RPC client {} received invalid JSON batch response",
879                    client_id
880                );
881                Ok((status_code, None))
882            }
883        }
884    }
885
886    /// Raise `jsonrpc_response_received` for a completed batch exchange.
887    async fn notify_batch_response(
888        client_id: ClientId,
889        exchange: (u16, Option<serde_json::Value>),
890        app_state: &Arc<AppState>,
891        llm_client: &OllamaClient,
892        status_tx: &mpsc::UnboundedSender<String>,
893        depth: usize,
894    ) {
895        let Some(response_json) = exchange.1 else {
896            return;
897        };
898        let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
899            return;
900        };
901
902        let protocol = Arc::new(crate::client::jsonrpc::actions::JsonRpcClientProtocol::new());
903        let event = Event::new(
904            &JSONRPC_CLIENT_RESPONSE_RECEIVED_EVENT,
905            serde_json::json!({
906                "batch": true,
907                "responses": response_json,
908            }),
909        );
910
911        let memory = app_state
912            .get_memory_for_client(client_id)
913            .await
914            .unwrap_or_default();
915
916        match call_llm_for_client(
917            llm_client,
918            app_state,
919            client_id.to_string(),
920            &instruction,
921            &memory,
922            Some(&event),
923            protocol.as_ref(),
924            status_tx,
925        )
926        .await
927        {
928            Ok(ClientLlmResult {
929                actions,
930                memory_updates,
931            }) => {
932                // Update memory
933                if let Some(mem) = memory_updates {
934                    app_state.set_memory_for_client(client_id, mem).await;
935                }
936                Self::run_follow_ups(client_id, actions, app_state, llm_client, status_tx, depth)
937                    .await;
938            }
939            Err(e) => {
940                error!("LLM error for JSON-RPC client {}: {}", client_id, e);
941            }
942        }
943    }
944}