Skip to main content

netget/client/grpc/
mod.rs

1//! gRPC client implementation
2pub mod actions;
3#[cfg(not(target_arch = "wasm32"))]
4pub(crate) mod http1;
5mod reflection;
6mod streaming;
7
8pub use crate::server::grpc::value_codec::{
9    dynamic_message_to_json, json_to_dynamic_message, proto_value_to_json,
10};
11pub use actions::GrpcClientProtocol;
12
13use anyhow::{Context, Result};
14use bytes::Bytes;
15use http::Request;
16use http_body_util::BodyExt;
17use prost::Message as ProstMessage;
18use prost_reflect::{DescriptorPool, DynamicMessage};
19use std::net::SocketAddr;
20use std::sync::Arc;
21use tokio::sync::{mpsc, Mutex};
22use tonic::transport::{Channel, Endpoint};
23use tower::{Service, ServiceExt};
24use tracing::{debug, error, info};
25
26use crate::client::grpc::actions::{
27    GRPC_CLIENT_CONNECTED_EVENT, GRPC_CLIENT_ERROR_EVENT, GRPC_CLIENT_RESPONSE_RECEIVED_EVENT,
28};
29use crate::client::llm_budget::call_llm_for_client;
30use crate::llm::actions::client_trait::{Client as ClientTrait, ClientActionResult};
31use crate::llm::ollama_client::OllamaClient;
32use crate::llm::ClientLlmResult;
33use crate::protocol::{Event, StartupParams};
34use crate::state::app_state::AppState;
35use crate::state::{ClientId, ClientStatus};
36
37/// gRPC client connection state
38#[derive(Debug, Clone)]
39enum ConnectionState {
40    Idle,
41    Processing,
42}
43
44/// Shared client data
45struct GrpcClientData {
46    channel: Channel,
47    descriptor_pool: Arc<DescriptorPool>,
48    state: ConnectionState,
49    automatic: mpsc::Sender<serde_json::Value>,
50}
51
52/// gRPC client that connects to remote gRPC servers
53pub struct GrpcClient;
54pub const DEFAULT_TLS: bool = false;
55pub const CONNECT_TIMEOUT_SECS: u64 = 10;
56
57struct SocketGuard(std::net::TcpStream);
58impl Drop for SocketGuard {
59    fn drop(&mut self) {
60        let _ = self.0.shutdown(std::net::Shutdown::Both);
61    }
62}
63fn seconds(
64    params: Option<&StartupParams>,
65    key: &str,
66    default: u64,
67    maximum: u64,
68) -> Result<std::time::Duration> {
69    let seconds = params
70        .map(|params| params.get_optional_u64(key))
71        .transpose()?
72        .flatten()
73        .unwrap_or(default);
74    anyhow::ensure!(
75        (1..=maximum).contains(&seconds),
76        "{key} must be 1..{maximum}"
77    );
78    Ok(std::time::Duration::from_secs(seconds))
79}
80
81impl GrpcClient {
82    /// Connect to a gRPC server with integrated LLM actions
83    pub async fn connect_with_llm_actions(
84        remote_addr: String,
85        llm_client: OllamaClient,
86        app_state: Arc<AppState>,
87        status_tx: mpsc::UnboundedSender<String>,
88        client_id: ClientId,
89        startup_params: Option<StartupParams>,
90    ) -> Result<SocketAddr> {
91        let params = startup_params.as_ref();
92        let schema = params
93            .map(|p| p.get_optional_string("proto_schema"))
94            .transpose()?
95            .flatten();
96        let tls = params
97            .map(|p| p.get_optional_bool("use_tls"))
98            .transpose()?
99            .flatten()
100            .unwrap_or(DEFAULT_TLS);
101        let connect = seconds(params, "connect_timeout_secs", CONNECT_TIMEOUT_SECS, 60)?;
102        let stream_timeout = seconds(
103            params,
104            "stream_timeout_secs",
105            streaming::DEFAULT_STREAM_TIMEOUT,
106            3600,
107        )?;
108        let idle = seconds(
109            params,
110            "idle_timeout_secs",
111            streaming::DEFAULT_IDLE_TIMEOUT,
112            3600,
113        )?;
114        let name = params
115            .map(|p| p.get_optional_string("server_name"))
116            .transpose()?
117            .flatten();
118        let ca = params
119            .map(|p| p.get_optional_string("ca_file"))
120            .transpose()?
121            .flatten();
122        anyhow::ensure!(
123            tls || (name.is_none() && ca.is_none()),
124            "server_name/ca_file require use_tls"
125        );
126        let uri: hyper::Uri =
127            format!("{}://{}", if tls { "https" } else { "http" }, remote_addr).parse()?;
128        anyhow::ensure!(
129            uri.path() == "/" && uri.authority().is_some(),
130            "remote address must be host:port"
131        );
132        let host = uri.host().context("endpoint has no hostname")?.to_owned();
133        let port = uri.port_u16().unwrap_or(if tls { 443 } else { 80 });
134        let name = name.unwrap_or_else(|| host.clone());
135        anyhow::ensure!(
136            !name.is_empty() && name.len() <= 253,
137            "server_name too long or empty"
138        );
139        let (channel, descriptor_pool, socket, local, remote) =
140            tokio::time::timeout(connect, async {
141                let pem = if let Some(path) = ca {
142                    Some(
143                        tokio::task::spawn_blocking(move || -> Result<Vec<u8>> {
144                            use std::io::Read;
145                            let metadata = std::fs::metadata(&path)?;
146                            anyhow::ensure!(
147                                metadata.is_file() && metadata.len() <= 1024 * 1024,
148                                "CA must be a regular file at most 1 MiB"
149                            );
150                            let mut options = std::fs::OpenOptions::new();
151                            options.read(true);
152                            #[cfg(unix)]
153                            {
154                                use std::os::unix::fs::OpenOptionsExt;
155                                options.custom_flags(libc::O_NONBLOCK);
156                            }
157                            let file = options.open(path)?;
158                            let metadata = file.metadata()?;
159                            anyhow::ensure!(
160                                metadata.is_file() && metadata.len() <= 1024 * 1024,
161                                "CA must be a regular file at most 1 MiB"
162                            );
163                            let mut bytes = Vec::new();
164                            file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
165                            anyhow::ensure!(bytes.len() <= 1024 * 1024, "CA exceeds 1 MiB");
166                            Ok(bytes)
167                        })
168                        .await??,
169                    )
170                } else {
171                    None
172                };
173                let local_schema = if let Some(schema) = schema {
174                    Some(load_schema(&schema).await?)
175                } else {
176                    None
177                };
178                let stream = tokio::net::TcpStream::connect((host.as_str(), port)).await?;
179                let local = stream.local_addr()?;
180                let remote = stream.peer_addr()?;
181                let raw = stream.into_std()?;
182                let socket = SocketGuard(raw.try_clone()?);
183                let stream = tokio::net::TcpStream::from_std(raw)?;
184                let mut endpoint = Endpoint::from(uri)
185                    .connect_timeout(connect)
186                    .concurrency_limit(16)
187                    .buffer_size(16)
188                    .initial_stream_window_size(65536)
189                    .initial_connection_window_size(1048576)
190                    .http2_max_header_list_size(32768);
191                if tls {
192                    let _ = rustls::crypto::ring::default_provider().install_default();
193                    let mut config = tonic::transport::ClientTlsConfig::new()
194                        .with_webpki_roots()
195                        .domain_name(name);
196                    if let Some(pem) = pem {
197                        config =
198                            config.ca_certificate(tonic::transport::Certificate::from_pem(pem));
199                    }
200                    endpoint = endpoint.tls_config(config)?;
201                }
202                let mut stream = Some(stream);
203                let connector = tower::service_fn(move |_: hyper::Uri| {
204                    let stream = stream.take();
205                    async move {
206                        stream.map(hyper_util::rt::TokioIo::new).ok_or_else(|| {
207                            std::io::Error::new(
208                                std::io::ErrorKind::NotConnected,
209                                "gRPC automatic reconnect is disabled",
210                            )
211                        })
212                    }
213                });
214                let channel = endpoint.connect_with_connector(connector).await?;
215                let pool = match local_schema {
216                    Some(pool) => pool,
217                    None => reflection::discover(channel.clone()).await?,
218                };
219                anyhow::ensure!(
220                    pool.files().len() <= 128 && pool.services().len() <= 128,
221                    "schema exceeds 128 files/services"
222                );
223                anyhow::ensure!(
224                    pool.file_descriptor_protos()
225                        .map(ProstMessage::encoded_len)
226                        .sum::<usize>()
227                        <= 4 * 1024 * 1024,
228                    "schema exceeds 4 MiB"
229                );
230                Ok::<_, anyhow::Error>((channel, pool, socket, local, remote))
231            })
232            .await
233            .context("gRPC connection/schema deadline exceeded")??;
234        let services: Vec<_> = descriptor_pool
235            .services()
236            .map(|service| service.full_name().to_owned())
237            .collect();
238        let (automatic, automatic_rx) = mpsc::channel(16);
239        let data = Arc::new(Mutex::new(GrpcClientData {
240            channel,
241            descriptor_pool: Arc::new(descriptor_pool),
242            state: ConnectionState::Idle,
243            automatic,
244        }));
245        let now = crate::utils::clock::Instant::now();
246        app_state
247            .with_client_mut(client_id, |client| {
248                client.connection = Some(crate::state::ClientConnectionState {
249                    id: client_id,
250                    remote_addr: remote_addr.clone(),
251                    connected_addr: Some(remote),
252                    local_addr: Some(local),
253                    bytes_sent: 0,
254                    bytes_received: 0,
255                    packets_sent: 0,
256                    packets_received: 0,
257                    last_activity: now,
258                    status: ClientStatus::Connected,
259                    status_changed_at: now,
260                    protocol_info: crate::state::server::ProtocolConnectionInfo::new(
261                        serde_json::json!({"tls_verified":tls}),
262                    ),
263                });
264                client.set_protocol_field("grpc_client".into(), serde_json::json!("initialized"));
265                client.set_protocol_field("server_addr".into(), serde_json::json!(remote_addr));
266            })
267            .await;
268        app_state
269            .update_client_status(client_id, ClientStatus::Connected)
270            .await;
271        let commands =
272            crate::client::command_support::register_command_channel(&app_state, client_id).await;
273        let connected = Event::new(
274            &GRPC_CLIENT_CONNECTED_EVENT,
275            serde_json::json!({"server_addr":remote_addr,"services":services}),
276        );
277        let ctx = streaming::ContextData {
278            id: client_id,
279            data,
280            state: app_state.clone(),
281            llm: llm_client,
282            status: status_tx.clone(),
283            protocol: Arc::new(GrpcClientProtocol::new()),
284        };
285        app_state
286            .spawn_client_task(
287                client_id,
288                streaming::run(
289                    ctx,
290                    socket,
291                    commands,
292                    automatic_rx,
293                    connected,
294                    stream_timeout,
295                    idle,
296                ),
297            )
298            .await;
299        let _ = status_tx.send("__UPDATE_UI__".into());
300        Ok(local)
301    }
302}
303
304/// Both peers share bounded schema input and an owned protoc child.
305async fn load_schema(schema_input: &str) -> Result<DescriptorPool> {
306    crate::server::grpc::schema::load(schema_input).await
307}
308
309/// The result of a legacy unary action, consumed by the connection owner.
310enum Applied {
311    /// A gRPC request really went out; `bytes_sent` is the length of the framed
312    /// message (5-byte gRPC header + protobuf payload) that was written to the
313    /// channel and answered.
314    ///
315    /// `pending_notify` is the `grpc_response_received` payload when the caller asked
316    /// for [`Dispatch::Defer`] - it has not been given to the LLM yet, and the caller
317    /// must run [`notify_grpc_response`] with it once it has replied.
318    Sent {
319        bytes_sent: usize,
320        pending_notify: Option<serde_json::Value>,
321    },
322    /// The action ran but put nothing on the wire; `detail` says why.
323    Ran(String),
324    /// The action asked to end the session.
325    Disconnect,
326}
327
328/// How the `grpc_response_received` event that follows a call is delivered.
329#[derive(Clone, Copy)]
330enum Dispatch {
331    /// Raise it - and run whatever the LLM answers - before returning. Used by the
332    /// connected-event handler, which is where that recursion has always lived.
333    Inline,
334    /// Do the call and raise nothing at all -- neither the success event nor the error
335    /// event. Used for a retry the model asks for in answer to `grpc_client_error`: that
336    /// retry must not itself raise another error event, or a persistently failing call
337    /// would drive the model round the same loop forever.
338    Silent,
339    /// Hand the event payload back to the caller instead. The injected-command loop
340    /// uses this so it can reply with the truthful byte count **first** and only then
341    /// raise the event: a client whose events are routed to a manual handler would
342    /// otherwise hold `[ send ]`'s answer hostage for the length of a human's think
343    /// time, and the operator would see a timeout for a call that in fact succeeded.
344    Defer,
345}
346
347/// Execute a gRPC client action
348async fn execute_grpc_action(
349    client_id: ClientId,
350    action: serde_json::Value,
351    grpc_client_data: Arc<Mutex<GrpcClientData>>,
352    app_state: &Arc<AppState>,
353    llm_client: &OllamaClient,
354    status_tx: &mpsc::UnboundedSender<String>,
355    protocol: &Arc<GrpcClientProtocol>,
356    dispatch: Dispatch,
357) -> Result<Applied> {
358    // Parse action using the protocol's execute_action method
359    let action_result = protocol.as_ref().execute_action(action.clone())?;
360    apply_grpc_action(
361        client_id,
362        action_result,
363        grpc_client_data,
364        app_state,
365        llm_client,
366        status_tx,
367        protocol,
368        dispatch,
369    )
370    .await
371}
372
373/// Run one already-executed action. Shared by the connected-event handler and the
374/// injected-command loop so the `grpc_call` decoding exists exactly once.
375#[allow(clippy::too_many_arguments)]
376async fn apply_grpc_action(
377    client_id: ClientId,
378    action_result: ClientActionResult,
379    grpc_client_data: Arc<Mutex<GrpcClientData>>,
380    app_state: &Arc<AppState>,
381    llm_client: &OllamaClient,
382    status_tx: &mpsc::UnboundedSender<String>,
383    protocol: &Arc<GrpcClientProtocol>,
384    dispatch: Dispatch,
385) -> Result<Applied> {
386    match action_result {
387        ClientActionResult::Custom { name, data } if name.starts_with("grpc_stream_") => {
388            grpc_client_data
389                .lock()
390                .await
391                .automatic
392                .try_send(data)
393                .context("stream control queue full or closed")?;
394            Ok(Applied::Ran("stream control queued".into()))
395        }
396        ClientActionResult::Custom { name, data } if name == "grpc_call" => {
397            let service = data["service"]
398                .as_str()
399                .context("Missing service in grpc_call")?;
400            let method = data["method"]
401                .as_str()
402                .context("Missing method in grpc_call")?;
403            let request = &data["request"];
404            let metadata = data.get("metadata").and_then(|v| v.as_object());
405
406            let sent = make_grpc_call(
407                client_id,
408                service,
409                method,
410                request.clone(),
411                metadata.cloned(),
412                grpc_client_data,
413                app_state,
414                llm_client,
415                status_tx,
416                protocol,
417                dispatch,
418            )
419            .await?;
420
421            match sent {
422                Some(report) => Ok(Applied::Sent {
423                    bytes_sent: report.bytes_sent,
424                    pending_notify: report.pending_notify,
425                }),
426                // The per-connection state machine refused the call; nothing was
427                // written, and saying "Sent" here would be a lie.
428                None => Ok(Applied::Ran(format!(
429                    "grpc_call {}/{} skipped: the client is already processing a call",
430                    service, method
431                ))),
432            }
433        }
434        ClientActionResult::Disconnect => {
435            info!("gRPC client {} disconnecting", client_id);
436            app_state
437                .update_client_status(client_id, ClientStatus::Disconnected)
438                .await;
439            let _ = status_tx.send(format!("[CLIENT] gRPC client {} disconnected", client_id));
440            Ok(Applied::Disconnect)
441        }
442        ClientActionResult::WaitForMore => {
443            debug!("gRPC client {} waiting", client_id);
444            Ok(Applied::Ran("wait_for_more".to_string()))
445        }
446        ClientActionResult::NoAction => Ok(Applied::Ran("no_action".to_string())),
447        // Not swallowed: an action this client cannot carry out says so, rather than
448        // looking identical to success.
449        ClientActionResult::Custom { name, .. } => Ok(Applied::Ran(format!(
450            "custom result '{name}' is not handled by the gRPC client"
451        ))),
452        ClientActionResult::SendData(_) => Ok(Applied::Ran(
453            "send_data has no meaning for a gRPC client (tonic owns the HTTP/2 channel)"
454                .to_string(),
455        )),
456        ClientActionResult::Multiple(_) => Ok(Applied::Ran(
457            "multiple results are not produced by the gRPC client".to_string(),
458        )),
459    }
460}
461
462/// Drain injected commands until the channel closes (the client was removed) or an
463/// injected `disconnect` ends the session.
464///
465/// One completed gRPC call.
466struct GrpcCallReport {
467    /// Framed bytes written to the channel: 5-byte gRPC header + protobuf payload.
468    bytes_sent: usize,
469    /// `grpc_response_received` payload not yet given to the LLM (see [`Dispatch`]).
470    pending_notify: Option<serde_json::Value>,
471}
472
473/// Make a gRPC call.
474///
475/// Returns a [`GrpcCallReport`] when the call went out and was answered, or `None` when
476/// the per-connection state machine refused it because another call is in flight. A
477/// caller reporting a [`ClientSendOutcome`] must not turn `None` into `Sent`.
478///
479/// Returns a boxed future rather than being a plain `async fn`.
480///
481/// The error path can retry the call the model asks for, which means this function calls
482/// itself. rustc cannot infer the type of a directly self-referential `async fn` -- it is
483/// an infinitely nested opaque type -- so the body is boxed once here and the recursion
484/// becomes an ordinary dynamic call.
485#[allow(clippy::too_many_arguments)]
486fn make_grpc_call<'a>(
487    client_id: ClientId,
488    service: &'a str,
489    method: &'a str,
490    request_json: serde_json::Value,
491    metadata: Option<serde_json::Map<String, serde_json::Value>>,
492    grpc_client_data: Arc<Mutex<GrpcClientData>>,
493    app_state: &'a Arc<AppState>,
494    llm_client: &'a OllamaClient,
495    status_tx: &'a mpsc::UnboundedSender<String>,
496    protocol: &'a Arc<GrpcClientProtocol>,
497    dispatch: Dispatch,
498) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Option<GrpcCallReport>>> + Send + 'a>>
499{
500    Box::pin(async move {
501        info!("gRPC client {} calling {}/{}", client_id, service, method);
502
503        // Everything that can fail happens **before** the connection is claimed.
504        //
505        // The state used to be set to `Processing` first and reset to `Idle` only after the
506        // network call returned, so any of the four `?`s below — an unknown method, a request
507        // the schema rejects, an unbuildable HTTP request — returned early and left the state
508        // machine `Processing` forever. Every later call then answered "the client is already
509        // processing a call", so one malformed request from the model permanently wedged the
510        // client with nothing in the log to say why.
511        //
512        // Nothing here touches the wire; the descriptor pool is read under the lock and the
513        // guard dropped before the conversion, so a slow encode does not block the command
514        // loop either.
515        let (input_desc, output_desc) = {
516            let data = grpc_client_data.lock().await;
517            let method_desc = data
518                .descriptor_pool
519                .get_service_by_name(service)
520                .and_then(|s| s.methods().find(|m| m.name() == method))
521                .context(format!("Method {}/{} not found in schema", service, method))?;
522            (method_desc.input(), method_desc.output())
523        };
524
525        // Convert JSON request to protobuf
526        let request_msg = json_to_dynamic_message(&request_json, &input_desc)
527            .context("Failed to convert request JSON to protobuf")?;
528
529        // Encode request
530        let request_bytes = request_msg.encode_to_vec();
531
532        info!(
533            "gRPC client {} sending {}-byte request to {}/{}",
534            client_id,
535            request_bytes.len(),
536            service,
537            method
538        );
539
540        // Build gRPC request path
541        let path = format!("/{}/{}", service, method);
542
543        // Create HTTP request with gRPC framing
544        use http::HeaderValue;
545
546        let mut request_builder = Request::builder()
547            .method("POST")
548            .uri(path.clone())
549            .header("content-type", "application/grpc")
550            .header("te", "trailers")
551            .header("grpc-accept-encoding", "identity");
552
553        // Add custom metadata
554        if let Some(meta) = metadata {
555            for (key, value) in meta {
556                if let Some(val_str) = value.as_str() {
557                    if let Ok(header_value) = HeaderValue::from_str(val_str) {
558                        request_builder = request_builder.header(key.as_str(), header_value);
559                    }
560                }
561            }
562        }
563
564        // Encode gRPC message with 5-byte header (compression flag + length)
565        let mut grpc_message = Vec::with_capacity(5 + request_bytes.len());
566        grpc_message.push(0); // No compression
567        grpc_message.extend_from_slice(
568            &u32::try_from(request_bytes.len())
569                .context("gRPC request length exceeds u32")?
570                .to_be_bytes(),
571        );
572        grpc_message.extend_from_slice(&request_bytes);
573
574        // Create body using UnsyncBoxBody which is compatible with tonic
575        use http_body_util::combinators::UnsyncBoxBody;
576        let full_body = http_body_util::Full::new(Bytes::from(grpc_message));
577        let body =
578            UnsyncBoxBody::new(full_body.map_err(|_: std::convert::Infallible| {
579                tonic::Status::internal("infallible error")
580            }));
581        let http_request = request_builder
582            .body(body)
583            .context("Failed to build HTTP request")?;
584
585        // Claim the connection and take the channel under one guard: the old check-then-set
586        // used two separate acquisitions, so two callers could both see `Idle`.
587        let channel = {
588            let mut data = grpc_client_data.lock().await;
589            if matches!(data.state, ConnectionState::Processing) {
590                info!("gRPC client {} is busy, skipping request", client_id);
591                return Ok(None);
592            }
593            data.state = ConnectionState::Processing;
594            data.channel.clone()
595        };
596
597        // Make the call using the channel
598        let result = call_grpc_unary(&channel, http_request).await;
599
600        // Reset to idle. From the claim above to here there is no `?`, so this cannot be
601        // skipped by an early return.
602        {
603            let mut data = grpc_client_data.lock().await;
604            data.state = ConnectionState::Idle;
605        }
606
607        let framed_len = 5 + request_bytes.len();
608
609        match result {
610            Ok(response_bytes) => {
611                // Decode response
612                let response_msg = DynamicMessage::decode(output_desc.clone(), &response_bytes[..])
613                    .context("Failed to decode gRPC response")?;
614
615                // Convert to JSON
616                let response_json = dynamic_message_to_json(&response_msg)?;
617
618                info!(
619                    "gRPC client {} received response for {}/{}",
620                    client_id, service, method
621                );
622
623                let event_data = serde_json::json!({
624                    "service": service,
625                    "method": method,
626                    "response": response_json,
627                });
628
629                let pending_notify = match dispatch {
630                    Dispatch::Inline => {
631                        notify_grpc_response(
632                            client_id,
633                            event_data,
634                            grpc_client_data,
635                            app_state.clone(),
636                            llm_client.clone(),
637                            status_tx.clone(),
638                            protocol.clone(),
639                        )
640                        .await;
641                        None
642                    }
643                    // A retry raises nothing: the caller already reported the original
644                    // failure, and re-raising would restart the error loop this bound exists
645                    // to prevent.
646                    Dispatch::Silent => None,
647                    Dispatch::Defer => Some(event_data),
648                };
649
650                Ok(Some(GrpcCallReport {
651                    bytes_sent: framed_len,
652                    pending_notify,
653                }))
654            }
655            Err(e) => {
656                error!("gRPC client {} call failed: {}", client_id, e);
657
658                // Tell the model, and DO what it answers.
659                //
660                // The answer used to be dropped (`let _ = call_llm_for_client(...)`), so a
661                // model that saw a call fail and wanted to retry it, call a fallback method,
662                // or hang up was ignored -- on the one event whose entire purpose is to let it
663                // react.
664                //
665                // A retry runs with `Dispatch::Silent`, which raises neither the success event
666                // nor another error event. That bound is essential: without it a persistently
667                // failing call would raise an error, be retried, fail, raise another error,
668                // and drive the model round the same loop forever.
669                if matches!(dispatch, Dispatch::Silent) {
670                    // Already a retry. Do not ask again.
671                    return Err(e);
672                }
673                if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
674                    let event = Event::new(
675                        &GRPC_CLIENT_ERROR_EVENT,
676                        serde_json::json!({
677                            "service": service,
678                            "method": method,
679                            "code": "UNKNOWN",
680                            "message": e.to_string(),
681                        }),
682                    );
683
684                    let memory = app_state
685                        .get_memory_for_client(client_id)
686                        .await
687                        .unwrap_or_default();
688
689                    match call_llm_for_client(
690                        llm_client,
691                        app_state,
692                        client_id.to_string(),
693                        &instruction,
694                        &memory,
695                        Some(&event),
696                        protocol.as_ref(),
697                        status_tx,
698                    )
699                    .await
700                    {
701                        Ok(result) => {
702                            if let Some(mem) = result.memory_updates {
703                                app_state.set_memory_for_client(client_id, mem).await;
704                            }
705                            for action in result.actions {
706                                use crate::llm::actions::client_trait::Client;
707                                match protocol.execute_action(action.clone()) {
708                                    Ok(ClientActionResult::Custom { name, data })
709                                        if name == "grpc_call" =>
710                                    {
711                                        let retry_service =
712                                            data["service"].as_str().unwrap_or(service).to_string();
713                                        let retry_method =
714                                            data["method"].as_str().unwrap_or(method).to_string();
715                                        info!(
716                                            "gRPC client {} retrying as {}/{} on the model's \
717                                         request",
718                                            client_id, retry_service, retry_method
719                                        );
720                                        let retry = make_grpc_call(
721                                            client_id,
722                                            &retry_service,
723                                            &retry_method,
724                                            data.get("request").cloned().unwrap_or_default(),
725                                            data.get("metadata")
726                                                .and_then(|m| m.as_object())
727                                                .cloned(),
728                                            grpc_client_data.clone(),
729                                            app_state,
730                                            llm_client,
731                                            status_tx,
732                                            protocol,
733                                            Dispatch::Silent,
734                                        )
735                                        .await;
736                                        if let Err(re) = retry {
737                                            error!(
738                                                "gRPC client {} retry also failed: {}",
739                                                client_id, re
740                                            );
741                                        }
742                                    }
743                                    Ok(ClientActionResult::Disconnect) => {
744                                        info!(
745                                            "gRPC client {} disconnecting after error, as the \
746                                         model asked",
747                                            client_id
748                                        );
749                                        app_state
750                                            .update_client_status(
751                                                client_id,
752                                                crate::state::ClientStatus::Disconnected,
753                                            )
754                                            .await;
755                                        let _ = status_tx.send("__UPDATE_UI__".to_string());
756                                    }
757                                    Ok(_) => {}
758                                    Err(ae) => error!(
759                                        "gRPC client {} rejected its own error-path action: {}",
760                                        client_id, ae
761                                    ),
762                                }
763                            }
764                        }
765                        Err(le) => error!(
766                            "gRPC client {} LLM error on grpc_client_error: {}",
767                            client_id, le
768                        ),
769                    }
770                }
771
772                Err(e)
773            }
774        }
775    })
776}
777
778/// Raise `grpc_response_received` for a completed call and run whatever the LLM answers.
779///
780/// Split out of [`make_grpc_call`] so the injected-command loop can await the network
781/// round-trip - and report a truthful byte count - without also awaiting the LLM.
782#[allow(clippy::too_many_arguments)]
783async fn notify_grpc_response(
784    client_id: ClientId,
785    event_data: serde_json::Value,
786    grpc_client_data: Arc<Mutex<GrpcClientData>>,
787    app_state: Arc<AppState>,
788    llm_client: OllamaClient,
789    status_tx: mpsc::UnboundedSender<String>,
790    protocol: Arc<GrpcClientProtocol>,
791) {
792    let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
793        return;
794    };
795
796    let event = Event::new(&GRPC_CLIENT_RESPONSE_RECEIVED_EVENT, event_data);
797    let memory = app_state
798        .get_memory_for_client(client_id)
799        .await
800        .unwrap_or_default();
801
802    match call_llm_for_client(
803        &llm_client,
804        &app_state,
805        client_id.to_string(),
806        &instruction,
807        &memory,
808        Some(&event),
809        protocol.as_ref(),
810        &status_tx,
811    )
812    .await
813    {
814        Ok(ClientLlmResult {
815            actions,
816            memory_updates,
817        }) => {
818            // Update memory
819            if let Some(mem) = memory_updates {
820                app_state.set_memory_for_client(client_id, mem).await;
821            }
822
823            // Execute actions
824            for action in actions {
825                if let Err(e) = Box::pin(execute_grpc_action(
826                    client_id,
827                    action,
828                    grpc_client_data.clone(),
829                    &app_state,
830                    &llm_client,
831                    &status_tx,
832                    &protocol,
833                    Dispatch::Inline,
834                ))
835                .await
836                {
837                    error!("Failed to execute gRPC action: {}", e);
838                }
839            }
840        }
841        Err(e) => {
842            error!("LLM error for gRPC client {}: {}", client_id, e);
843        }
844    }
845}
846
847/// Make a unary gRPC call using tonic channel
848async fn call_grpc_unary(
849    channel: &Channel,
850    request: Request<http_body_util::combinators::UnsyncBoxBody<Bytes, tonic::Status>>,
851) -> Result<Vec<u8>> {
852    // Clone the channel to get a service we can call
853    let mut client = channel.clone();
854
855    // Call the service
856    let response = client
857        .ready()
858        .await
859        .context("gRPC channel not ready")?
860        .call(request)
861        .await
862        .context("gRPC call failed")?;
863
864    // `grpc-status` is read from the HTTP/2 **trailers** as well as the initial headers.
865    //
866    // Reading only the headers is why this client mishandled every real gRPC server:
867    // grpc-go and tonic send the status in trailers on a normal unary call (headers carry it
868    // only in the "trailers-only" shape), so a genuine `5 NOT_FOUND` arrived here as "absent"
869    // — which this function treats as success — and the caller then got the meaningless
870    // "Response too short" from the empty body that accompanied it. NetGet's own gRPC server
871    // puts the status in the headers, so client-against-our-own-server never showed it.
872    let (parts, body) = response.into_parts();
873    let header_status = grpc_status_of(&parts.headers);
874
875    let collected = body
876        .collect()
877        .await
878        .context("Failed to read response body")?;
879    let trailers = collected.trailers().cloned();
880    let body_bytes = collected.to_bytes();
881
882    let (status_code, status_message) = match header_status {
883        Some(status) => (status, grpc_message_of(&parts.headers)),
884        None => match trailers.as_ref().and_then(grpc_status_of) {
885            Some(status) => (status, trailers.as_ref().and_then(grpc_message_of)),
886            // No status anywhere. Left permissive rather than an error: a body did arrive,
887            // and refusing it would break a peer that only sets the status on failure.
888            None => (0, None),
889        },
890    };
891
892    if status_code != 0 {
893        return Err(anyhow::anyhow!(
894            "gRPC error: status={}, message={}",
895            status_code,
896            status_message.as_deref().unwrap_or("Unknown error")
897        ));
898    }
899
900    // Decode gRPC framing (skip 5-byte header)
901    if body_bytes.len() < 5 {
902        return Err(anyhow::anyhow!("Response too short"));
903    }
904
905    let message_bytes = body_bytes.slice(5..);
906    Ok(message_bytes.to_vec())
907}
908
909/// `grpc-status` from a header or trailer map, if it carries a parseable one.
910fn grpc_status_of(headers: &http::HeaderMap) -> Option<i32> {
911    headers
912        .get("grpc-status")
913        .and_then(|v| v.to_str().ok())
914        .and_then(|s| s.trim().parse::<i32>().ok())
915}
916
917/// `grpc-message` from a header or trailer map.
918fn grpc_message_of(headers: &http::HeaderMap) -> Option<String> {
919    headers
920        .get("grpc-message")
921        .and_then(|v| v.to_str().ok())
922        .map(|s| s.to_string())
923}