Skip to main content

netget/client/xmlrpc/
mod.rs

1//! XML-RPC client implementation
2pub mod actions;
3pub mod response_guard;
4
5pub use actions::XmlRpcClientProtocol;
6
7use anyhow::{Context, Result};
8use std::collections::BTreeMap;
9use std::net::SocketAddr;
10use std::sync::Arc;
11use tokio::sync::mpsc;
12use tracing::{error, info};
13
14use crate::client::llm_budget::call_llm_for_client;
15use crate::client::xmlrpc::actions::{
16    XMLRPC_CLIENT_CONNECTED_EVENT, XMLRPC_CLIENT_RESPONSE_RECEIVED_EVENT,
17};
18use crate::llm::actions::client_trait::{Client, ClientActionResult};
19use crate::llm::ollama_client::OllamaClient;
20use crate::llm::ClientLlmResult;
21use crate::logging::emit::Log;
22use crate::protocol::Event;
23use crate::state::app_state::AppState;
24use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
25use crate::state::{AccessLogOwner, ClientId, ClientStatus};
26
27/// What [`XmlRpcClient::apply_action`] did with one executed action. XML-RPC is
28/// request/response over HTTP with no socket of our own, so no byte count can honestly be
29/// reported - each variant carries a description of the effect instead.
30enum Applied {
31    /// The action ran; the string describes what happened.
32    Ran(String),
33    /// The action asked to end the session.
34    Disconnect,
35}
36
37/// What a completed XML-RPC call answered.
38enum CallOutcome {
39    /// The server returned a value (converted to JSON).
40    Result(serde_json::Value),
41    /// The server returned an XML-RPC `<fault>`; the call reached it and was refused.
42    Fault(String),
43}
44
45/// Whether the response event is raised inline or from its own task.
46#[derive(Clone, Copy)]
47enum Dispatch {
48    /// Raise it here and now. Used by the connected-event LLM path (which already runs in
49    /// its own spawned task) and by the model's own follow-up calls.
50    Inline,
51    /// Hand it to a registered task. Used by the injected-command loop so a manual
52    /// (human-answered) routing rule on `xmlrpc_response_received` cannot hold up the
53    /// command's outcome, or the next injected command.
54    Deferred,
55}
56
57/// Request timeout when the `timeout_secs` startup parameter is not set. Matches the value
58/// that parameter's own description names as the default.
59const DEFAULT_TIMEOUT_SECS: u64 = 30;
60
61/// Deepest `<array>`/`<struct>` nesting NetGet will walk in either direction.
62///
63/// Both conversions below are recursive, and one of them walks a value that came off the
64/// network. A recursive walk with no counter is a stack overflow, which in Rust is a `SIGSEGV`
65/// against the guard page rather than a panic — `tokio::spawn` cannot contain it and the whole
66/// NetGet process dies. 64 is far past anything an XML-RPC API expresses; real structs are
67/// depth 2 or 3.
68///
69/// **This bound does not make the client safe against a hostile server, and cannot.** See
70/// [`XmlRpcClient::xmlrpc_value_to_json`].
71const MAX_VALUE_DEPTH: usize = 64;
72
73/// XML-RPC client that calls methods on remote servers
74pub struct XmlRpcClient;
75
76impl XmlRpcClient {
77    /// Connect to an XML-RPC server with integrated LLM actions
78    pub async fn connect_with_llm_actions(
79        remote_addr: String,
80        _llm_client: OllamaClient,
81        app_state: Arc<AppState>,
82        status_tx: mpsc::UnboundedSender<String>,
83        client_id: ClientId,
84        startup_params: Option<crate::protocol::StartupParams>,
85    ) -> Result<SocketAddr> {
86        // For XML-RPC, "connection" is logical, not a persistent connection
87        // The server URL is stored and used for each method call
88
89        // `timeout_secs` was declared as "request timeout in seconds (default: 30)" and
90        // read by nothing, so a call to an unresponsive server never returned. See
91        // `perform_call` for what it can and cannot bound.
92        let timeout_secs = match &startup_params {
93            Some(params) => params.get_optional_u64("timeout_secs")?,
94            None => None,
95        }
96        .unwrap_or(DEFAULT_TIMEOUT_SECS);
97
98        info!(
99            "XML-RPC client {} initialized for {}",
100            client_id, remote_addr
101        );
102
103        // Ensure URL is properly formatted
104        let server_url =
105            if remote_addr.starts_with("http://") || remote_addr.starts_with("https://") {
106                remote_addr.clone()
107            } else {
108                format!("http://{}", remote_addr)
109            };
110
111        // Store server URL in protocol_data
112        app_state
113            .with_client_mut(client_id, |client| {
114                client.set_protocol_field("server_url".to_string(), serde_json::json!(server_url));
115                client.set_protocol_field(
116                    "timeout_secs".to_string(),
117                    serde_json::json!(timeout_secs),
118                );
119            })
120            .await;
121
122        // Update status
123        app_state
124            .update_client_status(client_id, ClientStatus::Connected)
125            .await;
126        Log::new(Some(&status_tx)).info(format!(
127            "XML-RPC client {} ready for {}",
128            client_id, server_url
129        ));
130        let _ = status_tx.send("__UPDATE_UI__".to_string());
131
132        // Command channel for injected actions (the dashboard's [ call_xmlrpc_method ] /
133        // [ disconnect ]). Registered BEFORE the connected-event LLM call below, which is
134        // awaited inline and which a manual `*` routing rule can park for minutes - the
135        // operator must be able to call a method while it waits.
136        let command_rx =
137            crate::client::command_support::register_command_channel(&app_state, client_id).await;
138        let cmd_task = tokio::spawn(Self::command_loop(
139            command_rx,
140            client_id,
141            app_state.clone(),
142            _llm_client.clone(),
143            status_tx.clone(),
144        ));
145        app_state.register_client_task(client_id, cmd_task).await;
146
147        // Call LLM with initial connected event to trigger first action
148        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
149            let protocol = Arc::new(crate::client::xmlrpc::actions::XmlRpcClientProtocol::new());
150            let event = Event::new(
151                &XMLRPC_CLIENT_CONNECTED_EVENT,
152                serde_json::json!({
153                    "server_url": server_url,
154                }),
155            );
156
157            let memory = app_state
158                .get_memory_for_client(client_id)
159                .await
160                .unwrap_or_default();
161
162            if let Ok(ClientLlmResult {
163                actions,
164                memory_updates,
165            }) = call_llm_for_client(
166                &_llm_client,
167                &app_state,
168                client_id.to_string(),
169                &instruction,
170                &memory,
171                Some(&event),
172                protocol.as_ref(),
173                &status_tx,
174            )
175            .await
176            {
177                // Update memory
178                if let Some(mem) = memory_updates {
179                    app_state.set_memory_for_client(client_id, mem).await;
180                }
181
182                // Execute initial actions through the same path injected commands use.
183                for action in actions {
184                    let result = match protocol.execute_action(action) {
185                        Ok(result) => result,
186                        Err(e) => {
187                            error!("XML-RPC client {} rejected action: {}", client_id, e);
188                            continue;
189                        }
190                    };
191
192                    if matches!(result, ClientActionResult::Disconnect) {
193                        info!(
194                            "XML-RPC client {} disconnecting on initial action",
195                            client_id
196                        );
197                        let _ = Self::apply_action(
198                            result,
199                            Dispatch::Inline,
200                            client_id,
201                            &app_state,
202                            &_llm_client,
203                            &status_tx,
204                        )
205                        .await;
206                        // Nothing can be injected into a session that never started.
207                        app_state.remove_client_handle(client_id).await;
208                        let _ = status_tx.send("__UPDATE_UI__".to_string());
209                        return Ok("0.0.0.0:0".parse().unwrap());
210                    }
211
212                    // The method call itself is spawned so `connect` still returns
213                    // promptly; injected commands await it instead (see `command_loop`).
214                    let app_state_clone = app_state.clone();
215                    let llm_client_clone = _llm_client.clone();
216                    let status_tx_clone = status_tx.clone();
217                    // Registered with AppState so stop_client can abort this task —
218                    // dropping a JoinHandle only detaches it in Tokio.
219                    let task_registrar = app_state.clone();
220                    let task_handle = tokio::spawn(async move {
221                        if let Err(e) = Self::apply_action(
222                            result,
223                            Dispatch::Inline,
224                            client_id,
225                            &app_state_clone,
226                            &llm_client_clone,
227                            &status_tx_clone,
228                        )
229                        .await
230                        {
231                            error!("XML-RPC client {} action failed: {}", client_id, e);
232                        }
233                    });
234                    task_registrar
235                        .register_client_task(client_id, task_handle)
236                        .await;
237                }
238            }
239        }
240
241        // No idle-poll task: the command loop is this client's long-lived task and it ends
242        // when the client is removed (`remove_client` drops the command sender).
243
244        // Return a dummy local address (XML-RPC is connectionless)
245        Ok("0.0.0.0:0".parse().unwrap())
246    }
247
248    /// Run one executed action. Shared by the connected-event LLM path and injected
249    /// commands so the `xmlrpc_call` decoding exists exactly once.
250    async fn apply_action(
251        result: ClientActionResult,
252        dispatch: Dispatch,
253        client_id: ClientId,
254        app_state: &Arc<AppState>,
255        llm_client: &OllamaClient,
256        status_tx: &mpsc::UnboundedSender<String>,
257    ) -> Result<Applied> {
258        match result {
259            ClientActionResult::Custom { name, data } if name == "xmlrpc_call" => {
260                let method = data
261                    .get("method_name")
262                    .and_then(|v| v.as_str())
263                    .context("Missing 'method_name' in xmlrpc_call")?
264                    .to_string();
265                let params = data
266                    .get("params")
267                    .and_then(|v| v.as_array())
268                    .cloned()
269                    .unwrap_or_default();
270                let param_count = params.len();
271
272                let outcome =
273                    Self::perform_call(client_id, &method, params, app_state, status_tx).await?;
274                let detail = match &outcome {
275                    CallOutcome::Result(_) => format!(
276                        "xmlrpc_call '{}' with {} param(s): server returned a result",
277                        method, param_count
278                    ),
279                    CallOutcome::Fault(fault) => format!(
280                        "xmlrpc_call '{}' with {} param(s): server returned fault: {}",
281                        method, param_count, fault
282                    ),
283                };
284
285                match dispatch {
286                    Dispatch::Inline => {
287                        Self::notify_call_result(
288                            client_id,
289                            method,
290                            outcome,
291                            app_state.clone(),
292                            llm_client.clone(),
293                            status_tx.clone(),
294                        )
295                        .await;
296                    }
297                    Dispatch::Deferred => {
298                        let handle = tokio::spawn(Self::notify_call_result(
299                            client_id,
300                            method,
301                            outcome,
302                            app_state.clone(),
303                            llm_client.clone(),
304                            status_tx.clone(),
305                        ));
306                        // Registered so stop_client aborts an in-flight LLM call.
307                        app_state.register_client_task(client_id, handle).await;
308                    }
309                }
310                Ok(Applied::Ran(detail))
311            }
312            ClientActionResult::Disconnect => {
313                info!("XML-RPC client {} disconnecting", client_id);
314                app_state
315                    .update_client_status(client_id, ClientStatus::Disconnected)
316                    .await;
317                let _ = status_tx.send("__UPDATE_UI__".to_string());
318                Ok(Applied::Disconnect)
319            }
320            ClientActionResult::Custom { name, .. } => Err(anyhow::anyhow!(
321                "XML-RPC client cannot execute custom result '{}'",
322                name
323            )),
324            // WaitForMore / NoAction / SendData / nested Multiple: no method call to make.
325            _ => Ok(Applied::Ran(
326                "no method called (action produced no XML-RPC request)".to_string(),
327            )),
328        }
329    }
330
331    /// Drain injected commands until the channel closes (the client was removed) or an
332    /// injected `disconnect` ends the session. The call is awaited, so the reported
333    /// [`ClientSendOutcome`] describes a round-trip that really happened - including its
334    /// failure, which surfaces as an error rather than a fake success.
335    async fn command_loop(
336        mut command_rx: mpsc::Receiver<ClientCommand>,
337        client_id: ClientId,
338        app_state: Arc<AppState>,
339        llm_client: OllamaClient,
340        status_tx: mpsc::UnboundedSender<String>,
341    ) {
342        use crate::llm::actions::protocol_trait::Protocol;
343
344        let protocol = crate::client::xmlrpc::actions::XmlRpcClientProtocol::new();
345
346        while let Some(command) = command_rx.recv().await {
347            let action = command.action.clone();
348            let outcome = match protocol.execute_action(action.clone()) {
349                Err(e) => Ok(ClientSendOutcome::Rejected {
350                    error: e.to_string(),
351                }),
352                Ok(result) => Self::apply_action(
353                    result,
354                    Dispatch::Deferred,
355                    client_id,
356                    &app_state,
357                    &llm_client,
358                    &status_tx,
359                )
360                .await
361                .map(|applied| match applied {
362                    Applied::Disconnect => ClientSendOutcome::Disconnected,
363                    Applied::Ran(detail) => ClientSendOutcome::Executed { detail },
364                }),
365            };
366
367            let outcome_json = match &outcome {
368                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
369                Err(e) => serde_json::json!({"error": e.to_string()}),
370            };
371            app_state
372                .record_access_log(
373                    AccessLogOwner::Client(client_id.as_u32()),
374                    protocol.protocol_name(),
375                    None,
376                    "injected_action",
377                    action,
378                    vec![outcome_json],
379                )
380                .await;
381
382            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
383            if let Err(e) = &outcome {
384                error!("XML-RPC client {} injected action failed: {}", client_id, e);
385                let _ = status_tx.send(format!(
386                    "[WARN] Client {} injected action failed: {}",
387                    client_id, e
388                ));
389            }
390            let _ = status_tx.send("__UPDATE_UI__".to_string());
391            crate::client::command_support::reply(command, outcome);
392
393            if disconnect {
394                break;
395            }
396        }
397
398        app_state.remove_client_handle(client_id).await;
399        let _ = status_tx.send("__UPDATE_UI__".to_string());
400        info!("XML-RPC client {} command loop ended", client_id);
401    }
402
403    /// Call an XML-RPC method
404    pub fn call_method(
405        client_id: ClientId,
406        method_name: String,
407        params: Vec<serde_json::Value>,
408        app_state: Arc<AppState>,
409        llm_client: OllamaClient,
410        status_tx: mpsc::UnboundedSender<String>,
411    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send>> {
412        Box::pin(async move {
413            Self::call_method_impl(
414                client_id,
415                method_name,
416                params,
417                app_state,
418                llm_client,
419                status_tx,
420            )
421            .await
422        })
423    }
424
425    /// Implementation of call_method: perform the call, then report it.
426    async fn call_method_impl(
427        client_id: ClientId,
428        method_name: String,
429        params: Vec<serde_json::Value>,
430        app_state: Arc<AppState>,
431        llm_client: OllamaClient,
432        status_tx: mpsc::UnboundedSender<String>,
433    ) -> Result<()> {
434        let outcome =
435            Self::perform_call(client_id, &method_name, params, &app_state, &status_tx).await?;
436        let failed = match &outcome {
437            CallOutcome::Fault(fault) => Some(fault.clone()),
438            CallOutcome::Result(_) => None,
439        };
440        Self::notify_call_result(
441            client_id,
442            method_name,
443            outcome,
444            app_state,
445            llm_client,
446            status_tx,
447        )
448        .await;
449        match failed {
450            Some(fault) => Err(anyhow::anyhow!("XML-RPC error: {}", fault)),
451            None => Ok(()),
452        }
453    }
454
455    /// Make one XML-RPC call and return what the server answered.
456    ///
457    /// Split out of [`Self::call_method_impl`] so the injected-command loop can await the
458    /// round-trip - and report what really happened - without also awaiting the LLM call
459    /// the response event triggers, which a manual routing rule can park for minutes.
460    /// A transport failure is an `Err`; an XML-RPC `<fault>` is a `CallOutcome::Fault`,
461    /// because the server did answer.
462    async fn perform_call(
463        client_id: ClientId,
464        method_name: &str,
465        params: Vec<serde_json::Value>,
466        app_state: &Arc<AppState>,
467        status_tx: &mpsc::UnboundedSender<String>,
468    ) -> Result<CallOutcome> {
469        // Server URL and the `timeout_secs` startup parameter, read together under one guard.
470        let (server_url, timeout_secs) = app_state
471            .with_client_mut(client_id, |client| {
472                (
473                    client
474                        .get_protocol_field("server_url")
475                        .and_then(|v| v.as_str())
476                        .map(|s| s.to_string()),
477                    client
478                        .get_protocol_field("timeout_secs")
479                        .and_then(|v| v.as_u64()),
480                )
481            })
482            .await
483            .unwrap_or((None, None));
484        let server_url = server_url.context("No server URL found")?;
485        let timeout = std::time::Duration::from_secs(timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS));
486
487        info!(
488            "XML-RPC client {} calling method: {} with {} params",
489            client_id,
490            method_name,
491            params.len()
492        );
493
494        // Convert JSON values to xmlrpc::Value
495        let mut xmlrpc_params = Vec::new();
496        for param in params {
497            let xmlrpc_value = Self::json_to_xmlrpc_value(param)?;
498            xmlrpc_params.push(xmlrpc_value);
499        }
500
501        let method_owned = method_name.to_string();
502        let log_name = method_name.to_string();
503
504        // NetGet does the HTTP itself rather than calling `Request::call_url`, and the reason
505        // is safety, not convenience. `call_url` owns the fetch *and* the parse, and the
506        // crate's parser recurses once per `<value>` with no depth counter and no cap on the
507        // body - a hostile server's first reply can stack-overflow the whole process. What
508        // gives us a seam is that `Request::call` accepts any `xmlrpc::Transport`, a public
509        // trait with an associated `Stream: Read`; the reqwest 0.11 `RequestBuilder` is one
510        // provided implementation of it, not the signature. So: serialise here, fetch here
511        // under a real per-request timeout, screen the bytes in `response_guard`, then let the
512        // crate parse what it is handed. See `response_guard`'s module docs.
513        let request_xml = {
514            let mut request = xmlrpc::Request::new(&method_owned);
515            for param in &xmlrpc_params {
516                request = request.arg(param.clone());
517            }
518            let mut buf = Vec::new();
519            request
520                .write_as_xml(&mut buf)
521                .context("Failed to serialise the XML-RPC request")?;
522            buf
523        };
524
525        let http = response_guard::http_client_for(&server_url, timeout).await;
526        let fetch = async {
527            let response = http
528                .post(&server_url)
529                .header(reqwest::header::USER_AGENT, "Rust xmlrpc")
530                .header(reqwest::header::CONTENT_TYPE, "text/xml; charset=utf-8")
531                .body(request_xml)
532                .send()
533                .await?;
534            response_guard::check_response(&response)?;
535            response_guard::read_body_capped(response).await
536        };
537
538        // `timeout_secs` bounds the whole exchange. Unlike the old arrangement it now also
539        // *cancels* it: the fetch is an ordinary future rather than a blocking-pool thread
540        // holding a socket until its own TCP timeout, and the parse that follows is bounded
541        // by `MAX_RESPONSE_BYTES` rather than by whatever the server chose to send.
542        let body = match tokio::time::timeout(timeout, fetch).await {
543            Ok(Ok(body)) => body,
544            Ok(Err(e)) => {
545                // A refusal is a decision, not a transport hiccup, and is logged as one.
546                if let Some(refusal) = e.downcast_ref::<response_guard::ResponseRefusal>() {
547                    Log::new(Some(status_tx)).error(format!(
548                        "XML-RPC client {} refused the reply to '{}' decision={} ({})",
549                        client_id,
550                        log_name,
551                        refusal.decision_tag(),
552                        refusal
553                    ));
554                } else {
555                    Log::new(Some(status_tx)).error(format!(
556                        "XML-RPC client {} call to {} failed: {}",
557                        client_id, log_name, e
558                    ));
559                }
560                return Err(e);
561            }
562            Err(_) => {
563                Log::new(Some(status_tx)).error(format!(
564                    "XML-RPC client {} call to {} timed out after {}s",
565                    client_id,
566                    log_name,
567                    timeout.as_secs()
568                ));
569                return Err(anyhow::anyhow!(
570                    "XML-RPC call '{}' timed out after {}s (timeout_secs)",
571                    log_name,
572                    timeout.as_secs()
573                ));
574            }
575        };
576
577        // The scan and the crate's parser are both CPU-bound over at most
578        // `MAX_RESPONSE_BYTES`, so they run on the blocking pool together.
579        let scan_and_parse = tokio::task::spawn_blocking(move || {
580            response_guard::scan_depth(&body)?;
581            let mut request = xmlrpc::Request::new(&method_owned);
582            for param in xmlrpc_params {
583                request = request.arg(param);
584            }
585            Ok::<_, anyhow::Error>(request.call(response_guard::PrefetchedTransport::new(body)))
586        })
587        .await;
588
589        match scan_and_parse {
590            Ok(Ok(Ok(response))) => {
591                info!(
592                    "XML-RPC client {} received response for {}",
593                    client_id, log_name
594                );
595                Ok(CallOutcome::Result(Self::xmlrpc_value_to_json(&response)))
596            }
597            // The server answered with a `<fault>`: a real answer, reported as one.
598            Ok(Ok(Err(err))) if err.fault().is_some() => {
599                let fault_msg = err.to_string();
600                error!(
601                    "XML-RPC client {} fault for {}: {}",
602                    client_id, log_name, fault_msg
603                );
604                Ok(CallOutcome::Fault(fault_msg))
605            }
606            // A parse failure is not a fault - the server said something unusable.
607            Ok(Ok(Err(err))) => {
608                Log::new(Some(status_tx)).error(format!(
609                    "XML-RPC client {} could not parse the reply to {}: {}",
610                    client_id, log_name, err
611                ));
612                Err(anyhow::anyhow!(
613                    "XML-RPC reply to '{}' could not be parsed: {}",
614                    log_name,
615                    err
616                ))
617            }
618            Ok(Err(refusal)) => {
619                if let Some(r) = refusal.downcast_ref::<response_guard::ResponseRefusal>() {
620                    Log::new(Some(status_tx)).error(format!(
621                        "XML-RPC client {} refused the reply to '{}' decision={} ({})",
622                        client_id,
623                        log_name,
624                        r.decision_tag(),
625                        r
626                    ));
627                }
628                Err(refusal)
629            }
630            Err(e) => {
631                Log::new(Some(status_tx))
632                    .error(format!("XML-RPC client {} call failed: {}", client_id, e));
633                Err(e.into())
634            }
635        }
636    }
637
638    /// Raise `xmlrpc_response_received` for a completed call and run whatever the model
639    /// answers with (which may be a follow-up call, hence the boxed recursion).
640    fn notify_call_result(
641        client_id: ClientId,
642        method_name: String,
643        outcome: CallOutcome,
644        app_state: Arc<AppState>,
645        llm_client: OllamaClient,
646        status_tx: mpsc::UnboundedSender<String>,
647    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
648        Box::pin(async move {
649            let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
650                return;
651            };
652
653            let protocol = Arc::new(crate::client::xmlrpc::actions::XmlRpcClientProtocol::new());
654            let data = match &outcome {
655                CallOutcome::Result(result) => serde_json::json!({
656                    "method_name": method_name,
657                    "result": result,
658                }),
659                CallOutcome::Fault(fault) => serde_json::json!({
660                    "method_name": method_name,
661                    "fault": { "error": fault },
662                }),
663            };
664            let event = Event::new(&XMLRPC_CLIENT_RESPONSE_RECEIVED_EVENT, data);
665
666            let memory = app_state
667                .get_memory_for_client(client_id)
668                .await
669                .unwrap_or_default();
670
671            match call_llm_for_client(
672                &llm_client,
673                &app_state,
674                client_id.to_string(),
675                &instruction,
676                &memory,
677                Some(&event),
678                protocol.as_ref(),
679                &status_tx,
680            )
681            .await
682            {
683                Ok(ClientLlmResult {
684                    actions,
685                    memory_updates,
686                }) => {
687                    if let Some(mem) = memory_updates {
688                        app_state.set_memory_for_client(client_id, mem).await;
689                    }
690
691                    // Execute the follow-up actions through the shared path.
692                    for action in actions {
693                        let result = match protocol.execute_action(action) {
694                            Ok(result) => result,
695                            Err(e) => {
696                                error!("XML-RPC client {} rejected action: {}", client_id, e);
697                                continue;
698                            }
699                        };
700                        match Self::apply_action(
701                            result,
702                            Dispatch::Inline,
703                            client_id,
704                            &app_state,
705                            &llm_client,
706                            &status_tx,
707                        )
708                        .await
709                        {
710                            Ok(Applied::Ran(detail)) => {
711                                info!("XML-RPC client {}: {}", client_id, detail)
712                            }
713                            Ok(Applied::Disconnect) => break,
714                            Err(e) => {
715                                error!("XML-RPC client {} action failed: {}", client_id, e)
716                            }
717                        }
718                    }
719                }
720                Err(e) => {
721                    error!("LLM error for XML-RPC client {}: {}", client_id, e);
722                }
723            }
724        })
725    }
726
727    /// Convert JSON value to xmlrpc::Value, bounded at [`MAX_VALUE_DEPTH`].
728    ///
729    /// The input is model output, which `serde_json` already caps at its own 128-level
730    /// recursion limit, so this bound is belt-and-braces on that side. It is the *other*
731    /// direction that matters — see [`Self::xmlrpc_value_to_json`].
732    fn json_to_xmlrpc_value(json: serde_json::Value) -> Result<xmlrpc::Value> {
733        Self::json_to_xmlrpc_value_at(json, 0)
734    }
735
736    fn json_to_xmlrpc_value_at(json: serde_json::Value, depth: usize) -> Result<xmlrpc::Value> {
737        if depth >= MAX_VALUE_DEPTH {
738            anyhow::bail!("parameter nesting deeper than {} levels", MAX_VALUE_DEPTH);
739        }
740        match json {
741            serde_json::Value::Null => Ok(xmlrpc::Value::String("".to_string())),
742            serde_json::Value::Bool(b) => Ok(xmlrpc::Value::Bool(b)),
743            serde_json::Value::Number(n) => {
744                if let Some(i) = n.as_i64() {
745                    if i >= i32::MIN as i64 && i <= i32::MAX as i64 {
746                        Ok(xmlrpc::Value::Int(i as i32))
747                    } else {
748                        Ok(xmlrpc::Value::Int64(i))
749                    }
750                } else if let Some(f) = n.as_f64() {
751                    Ok(xmlrpc::Value::Double(f))
752                } else {
753                    Err(anyhow::anyhow!("Invalid number"))
754                }
755            }
756            serde_json::Value::String(s) => Ok(xmlrpc::Value::String(s)),
757            serde_json::Value::Array(arr) => {
758                let mut xmlrpc_arr = Vec::new();
759                for item in arr {
760                    xmlrpc_arr.push(Self::json_to_xmlrpc_value_at(item, depth + 1)?);
761                }
762                Ok(xmlrpc::Value::Array(xmlrpc_arr))
763            }
764            serde_json::Value::Object(obj) => {
765                let mut xmlrpc_struct = BTreeMap::new();
766                for (key, value) in obj {
767                    xmlrpc_struct.insert(key, Self::json_to_xmlrpc_value_at(value, depth + 1)?);
768                }
769                Ok(xmlrpc::Value::Struct(xmlrpc_struct))
770            }
771        }
772    }
773
774    /// Convert an `xmlrpc::Value` from a server's reply into JSON for the model, bounded at
775    /// [`MAX_VALUE_DEPTH`]. Anything deeper is replaced by a marker string rather than walked.
776    ///
777    /// The walk is recursive over a value that came off the network, and a recursive walk with
778    /// no counter is a stack overflow — `SIGSEGV` against the guard page, not a panic, so
779    /// `tokio::spawn` cannot contain it and the whole NetGet process dies.
780    ///
781    /// **This bound is NetGet's half of the problem, and it is not the whole of it.** `xmlrpc`
782    /// 0.15's own `Parser::parse_value` → `parse_value_inner` → `parse_value` recurses with no
783    /// depth counter, so a deep enough reply overflows the stack *before* a single value
784    /// reaches this function. The crate's half is bounded in
785    /// [`response_guard`](super::response_guard), which screens the response bytes for size
786    /// and nesting depth before the parser is allowed to touch them. Both bounds are needed:
787    /// this one walks the values the parser produced, that one decides whether the parser gets
788    /// to run at all.
789    fn xmlrpc_value_to_json(value: &xmlrpc::Value) -> serde_json::Value {
790        Self::xmlrpc_value_to_json_at(value, 0)
791    }
792
793    fn xmlrpc_value_to_json_at(value: &xmlrpc::Value, depth: usize) -> serde_json::Value {
794        if depth >= MAX_VALUE_DEPTH {
795            return serde_json::json!(format!(
796                "<nesting deeper than {} levels was not decoded>",
797                MAX_VALUE_DEPTH
798            ));
799        }
800        match value {
801            xmlrpc::Value::Int(i) => serde_json::json!(i),
802            xmlrpc::Value::Int64(i) => serde_json::json!(i),
803            xmlrpc::Value::Bool(b) => serde_json::json!(b),
804            xmlrpc::Value::String(s) => serde_json::json!(s),
805            xmlrpc::Value::Double(f) => serde_json::json!(f),
806            xmlrpc::Value::DateTime(dt) => serde_json::json!(dt.to_string()),
807            xmlrpc::Value::Base64(b) => {
808                // Convert to base64 string for JSON
809                serde_json::json!(base64::Engine::encode(
810                    &base64::engine::general_purpose::STANDARD,
811                    b
812                ))
813            }
814            xmlrpc::Value::Array(arr) => {
815                let json_arr: Vec<_> = arr
816                    .iter()
817                    .map(|v| Self::xmlrpc_value_to_json_at(v, depth + 1))
818                    .collect();
819                serde_json::json!(json_arr)
820            }
821            xmlrpc::Value::Struct(s) => {
822                let mut obj = serde_json::Map::new();
823                for (key, value) in s {
824                    obj.insert(key.clone(), Self::xmlrpc_value_to_json_at(value, depth + 1));
825                }
826                serde_json::json!(obj)
827            }
828            xmlrpc::Value::Nil => serde_json::Value::Null,
829        }
830    }
831}