Skip to main content

netget/server/usb/serial/
actions.rs

1//! USB CDC ACM Serial protocol actions
2
3#[cfg(all(unix, feature = "libusb"))]
4use crate::llm::actions::{
5    protocol_trait::{ActionResult, Protocol, Server},
6    ActionDefinition, Parameter,
7};
8#[cfg(all(unix, feature = "libusb"))]
9use crate::protocol::log_template::LogTemplate;
10#[cfg(all(unix, feature = "libusb"))]
11use crate::{protocol::EventType, server::connection::ConnectionId, state::app_state::AppState};
12#[cfg(all(unix, feature = "libusb"))]
13use anyhow::{Context, Result};
14#[cfg(all(unix, feature = "libusb"))]
15use serde_json::json;
16#[cfg(all(unix, feature = "libusb"))]
17use std::{
18    collections::HashMap,
19    sync::{Arc, LazyLock},
20};
21
22// Action constructors. Free functions rather than inline definitions so each event type can
23// declare the actions that answer it - an event listing none leaves the model with only the
24// common actions and every USB-Serial action it returns is rejected as unknown.
25/// Optional connection selector shared by both wire actions.
26///
27/// It is optional on purpose. A virtual serial port normally has exactly one host attached, and
28/// asking a model to copy an id back is a reliable source of wrong answers. When exactly one
29/// port is attached the action needs no id; when several are, an omitted id is an error naming
30/// the candidates rather than a guess.
31#[cfg(all(unix, feature = "libusb"))]
32fn connection_id_parameter() -> Parameter {
33    Parameter {
34        name: "connection_id".to_string(),
35        // "string", matching what the events emit (`"conn-7"`). Declaring "integer" while the
36        // event parameters declare "string" told the model to transform the one value it can
37        // copy, and the executor then rejected the transformed form.
38        type_hint: "string".to_string(),
39        description: "Which attached port to act on, as the event reports it (e.g. \"conn-7\"). \
40            Omit it when only one host is attached; required (copy it from the event) when \
41            there are several."
42            .to_string(),
43        required: false,
44    }
45}
46
47#[cfg(all(unix, feature = "libusb"))]
48fn send_data_action() -> ActionDefinition {
49    ActionDefinition {
50        name: "send_data".to_string(),
51        description: "Send data to the host over the virtual serial port, as if a device had \
52            written it to the wire."
53            .to_string(),
54        parameters: vec![
55            Parameter {
56                name: "data".to_string(),
57                type_hint: "string".to_string(),
58                description: "Text to send. Include an explicit \"\\n\" or \"\\r\\n\" if the host \
59                    expects line-terminated output"
60                    .to_string(),
61                required: true,
62            },
63            connection_id_parameter(),
64        ],
65        example: json!({"type": "send_data", "data": "Hello\n"}),
66        log_template: Some(
67            LogTemplate::new()
68                .with_info("-> USB serial send {data_len}B")
69                .with_debug("USB-Serial send_data: data='{data}'"),
70        ),
71    }
72}
73
74#[cfg(all(unix, feature = "libusb"))]
75fn set_line_coding_action() -> ActionDefinition {
76    ActionDefinition {
77        name: "set_line_coding".to_string(),
78        description: "Set the baud rate and line parameters this virtual port reports. Defaults \
79            to 115200 8N1."
80            .to_string(),
81        parameters: vec![
82            Parameter {
83                name: "baud_rate".to_string(),
84                type_hint: "number".to_string(),
85                description: "Bits per second (e.g., 115200)".to_string(),
86                required: true,
87            },
88            Parameter {
89                name: "data_bits".to_string(),
90                type_hint: "number".to_string(),
91                description: "5, 6, 7, 8, or 16".to_string(),
92                required: false,
93            },
94            Parameter {
95                name: "parity".to_string(),
96                type_hint: "string".to_string(),
97                description: "'none', 'odd', 'even', 'mark', 'space'".to_string(),
98                required: false,
99            },
100            Parameter {
101                name: "stop_bits".to_string(),
102                type_hint: "number".to_string(),
103                description: "1, 1.5, or 2".to_string(),
104                required: false,
105            },
106            connection_id_parameter(),
107        ],
108        example: json!({"type": "set_line_coding", "baud_rate": 9600}),
109        log_template: Some(
110            LogTemplate::new()
111                .with_info("-> USB serial line coding {baud_rate} baud")
112                .with_debug("USB-Serial set_line_coding: baud={baud_rate} data_bits={data_bits} parity={parity} stop_bits={stop_bits}"),
113        ),
114    }
115}
116
117#[cfg(all(unix, feature = "libusb"))]
118fn wait_for_more_action() -> ActionDefinition {
119    ActionDefinition {
120        name: "wait_for_more".to_string(),
121        description: "Send nothing and wait for the host to write more. Correct whenever the \
122            input so far is incomplete - a partial line, for instance."
123            .to_string(),
124        parameters: vec![],
125        example: json!({"type": "wait_for_more"}),
126        log_template: Some(
127            LogTemplate::new()
128                .with_info("-> USB serial wait for more")
129                .with_debug("USB-Serial wait_for_more"),
130        ),
131    }
132}
133
134#[cfg(all(unix, feature = "libusb"))]
135pub static USB_SERIAL_ATTACHED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
136    EventType::new(
137        "usb_serial_attached",
138        "A host opened this virtual serial port (it appears as /dev/ttyACM0 on Linux). Send a \
139         banner or prompt if the device should greet the host, adjust the line coding, or \
140         wait_for_more to stay silent until the host writes.",
141        json!({"type": "send_data", "data": "READY\r\n"}),
142    )
143    .with_parameters(vec![Parameter {
144        name: "connection_id".to_string(),
145        type_hint: "string".to_string(),
146        description: "Connection ID".to_string(),
147        required: true,
148    }])
149    .with_actions(vec![
150        send_data_action(),
151        set_line_coding_action(),
152        wait_for_more_action(),
153    ])
154    .with_alternative_example(json!({"type": "wait_for_more"}))
155});
156
157#[cfg(all(unix, feature = "libusb"))]
158pub static USB_SERIAL_DETACHED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
159    EventType::new(
160        "usb_serial_detached",
161        "The host closed this virtual serial port. Purely informational - the port is gone, so \
162         written data has nowhere to go.",
163        json!({"type": "show_message", "message": "USB serial host detached"}),
164    )
165    .with_parameters(vec![Parameter {
166        name: "connection_id".to_string(),
167        type_hint: "string".to_string(),
168        description: "Connection ID".to_string(),
169        required: true,
170    }])
171    .with_no_actions()
172});
173
174#[cfg(all(unix, feature = "libusb"))]
175pub static USB_SERIAL_DATA_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
176    EventType::new(
177        "usb_serial_data_received",
178        "The host wrote data to the virtual serial port. Reply with send_data, or wait_for_more \
179         if what arrived is only part of a command.",
180        json!({"type": "send_data", "data": "OK\r\n"}),
181    )
182    .with_parameters(vec![
183        Parameter {
184            name: "connection_id".to_string(),
185            type_hint: "string".to_string(),
186            description: "Connection ID".to_string(),
187            required: true,
188        },
189        Parameter {
190            name: "data".to_string(),
191            type_hint: "string".to_string(),
192            description: "Received data as string".to_string(),
193            required: true,
194        },
195    ])
196    .with_actions(vec![
197        send_data_action(),
198        set_line_coding_action(),
199        wait_for_more_action(),
200    ])
201    .with_alternative_example(json!({"type": "wait_for_more"}))
202});
203
204/// The per-connection CDC ACM handlers, keyed by connection.
205///
206/// A `std::sync::Mutex` rather than a tokio one because `usbip` requires
207/// `Arc<Mutex<Box<dyn UsbInterfaceHandler + Send>>>` from `std`, and because `execute_action`
208/// is synchronous. The guard is never held across an `.await`.
209#[cfg(all(unix, feature = "libusb"))]
210type SharedHandler = Arc<std::sync::Mutex<Box<dyn usbip::UsbInterfaceHandler + Send>>>;
211
212#[cfg(all(unix, feature = "libusb"))]
213pub struct UsbSerialProtocol {
214    handlers: Arc<std::sync::Mutex<HashMap<ConnectionId, SharedHandler>>>,
215}
216
217#[cfg(all(unix, feature = "libusb"))]
218impl UsbSerialProtocol {
219    pub fn new() -> Self {
220        Self {
221            handlers: Arc::new(std::sync::Mutex::new(HashMap::new())),
222        }
223    }
224
225    /// Register the handler that drives one attached port.
226    pub fn set_handler(&self, connection_id: ConnectionId, handler: SharedHandler) {
227        if let Ok(mut handlers) = self.handlers.lock() {
228            handlers.insert(connection_id, handler);
229        }
230    }
231
232    /// Drop the handler for a port whose host has detached, so a later action cannot reach a
233    /// device that is gone.
234    pub fn remove_handler(&self, connection_id: ConnectionId) {
235        if let Ok(mut handlers) = self.handlers.lock() {
236            handlers.remove(&connection_id);
237        }
238    }
239
240    /// Resolve which port an action refers to.
241    ///
242    /// An explicit `connection_id` wins. Otherwise the single attached port is used — and if
243    /// there is not exactly one, this is an error rather than a guess: writing to the wrong
244    /// serial port is indistinguishable from writing to the right one from the model's side.
245    fn resolve_handler(&self, action: &serde_json::Value) -> Result<SharedHandler> {
246        let handlers = self
247            .handlers
248            .lock()
249            .map_err(|_| anyhow::anyhow!("USB serial handler registry poisoned"))?;
250
251        // Accept both spellings. Events report `connection_id` as the *string* `"conn-7"`
252        // (`mod.rs` formats it with `to_string()`), so an `as_u64()`-only read rejected the
253        // one value the model has any reason to send - it can only echo back what the event
254        // gave it. With one port attached that silently fell through to the inference path
255        // and looked like it worked; with two or more, every action failed with "the action
256        // must name one with 'connection_id'" and no value the model could send would work.
257        // Same repair as usb-keyboard, usb-mouse and usb-msc.
258        // `id as u32` silently aliased: `connection_id: 4294967298` resolved to connection 2.
259        let requested = action["connection_id"]
260            .as_u64()
261            .and_then(|id| u32::try_from(id).ok())
262            .map(ConnectionId::new)
263            .or_else(|| {
264                action["connection_id"]
265                    .as_str()
266                    .and_then(ConnectionId::from_string)
267            });
268
269        if let Some(connection_id) = requested {
270            return handlers.get(&connection_id).cloned().ok_or_else(|| {
271                anyhow::anyhow!(
272                    "No USB serial port attached on connection {}",
273                    connection_id
274                )
275            });
276        }
277
278        match handlers.len() {
279            0 => Err(anyhow::anyhow!(
280                "No USB serial port is attached, so there is nothing to write to"
281            )),
282            1 => Ok(handlers.values().next().cloned().expect("len checked")),
283            _ => {
284                let mut ids: Vec<u32> = handlers.keys().map(|c| c.as_u32()).collect();
285                ids.sort_unstable();
286                Err(anyhow::anyhow!(
287                    "{} USB serial ports are attached ({:?}); the action must name one with \
288                     'connection_id'",
289                    ids.len(),
290                    ids
291                ))
292            }
293        }
294    }
295
296    /// Tell the host its bytes were dropped, because the model could not be reached.
297    ///
298    /// Queues a CDC `SERIAL_STATE` notification with `bOverRun` set on the port's interrupt IN
299    /// endpoint. Returns whether a handler was found; the session may already have ended.
300    pub fn signal_overrun(&self, connection_id: ConnectionId) -> bool {
301        let handler = match self.handlers.lock() {
302            Ok(handlers) => handlers.get(&connection_id).cloned(),
303            Err(_) => None,
304        };
305        let Some(handler) = handler else {
306            return false;
307        };
308        let Ok(mut guard) = handler.lock() else {
309            return false;
310        };
311        match guard
312            .as_any()
313            .downcast_mut::<crate::server::usb::serial::handler::UsbCdcAcmSerialHandler>()
314        {
315            Some(serial) => {
316                serial.queue_serial_state_overrun();
317                true
318            }
319            None => false,
320        }
321    }
322
323    /// Run `f` against the CDC ACM handler an action refers to.
324    fn with_serial_handler<T>(
325        &self,
326        action: &serde_json::Value,
327        f: impl FnOnce(&mut crate::server::usb::serial::handler::UsbCdcAcmSerialHandler) -> T,
328    ) -> Result<T> {
329        let handler = self.resolve_handler(action)?;
330        let mut guard = handler
331            .lock()
332            .map_err(|_| anyhow::anyhow!("USB serial handler mutex poisoned"))?;
333        let serial = guard
334            .as_any()
335            .downcast_mut::<crate::server::usb::serial::handler::UsbCdcAcmSerialHandler>()
336            .context("Handler is not a USB CDC ACM serial handler")?;
337        Ok(f(serial))
338    }
339}
340
341#[cfg(all(unix, feature = "libusb"))]
342impl Protocol for UsbSerialProtocol {
343    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
344        vec![
345            crate::llm::actions::ParameterDefinition {
346                name: "first_byte_timeout_secs".to_string(),
347                type_hint: "number".to_string(),
348                description: "Seconds a peer that has only connected may send no USB/IP message \
349                              at all before the server closes it. Default 30. USB/IP is \
350                              client-speaks-first and nothing here answers a peer that has not \
351                              asked - `usbip attach` and `usbip list -r` both write their \
352                              request as soon as the socket is up - so a short bound is the \
353                              honest one. Raise it only if your host is reached over a link \
354                              where a handshake can take that long."
355                    .to_string(),
356                required: false,
357                example: serde_json::json!(30),
358                default: Some(serde_json::json!(
359                    crate::server::usb::guard::DEFAULT_FIRST_MESSAGE_TIMEOUT.as_secs()
360                )),
361            },
362            crate::llm::actions::ParameterDefinition {
363                name: "idle_timeout_secs".to_string(),
364                type_hint: "number".to_string(),
365                description: "Seconds an attached host may go without sending a further USB/IP \
366                              message before the server closes the session. Default 1800. \
367                              USB/IP has no keepalive and nothing obliges an attached host to \
368                              speak - a serial port a host has imported and is asking nothing of \
369                              is idle by design - so this is deliberately long and exists to \
370                              reap a peer that is gone rather than to police one that is here. \
371                              Lower it if you are exporting to strangers."
372                    .to_string(),
373                required: false,
374                example: serde_json::json!(1800),
375                default: Some(serde_json::json!(
376                    crate::server::usb::guard::DEFAULT_IDLE_TIMEOUT.as_secs()
377                )),
378            },
379        ]
380    }
381    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
382        vec![]
383    }
384    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
385        vec![
386            send_data_action(),
387            set_line_coding_action(),
388            wait_for_more_action(),
389        ]
390    }
391    fn protocol_name(&self) -> &'static str {
392        "USB-Serial"
393    }
394    fn get_event_types(&self) -> Vec<EventType> {
395        vec![
396            USB_SERIAL_ATTACHED_EVENT.clone(),
397            USB_SERIAL_DETACHED_EVENT.clone(),
398            USB_SERIAL_DATA_RECEIVED_EVENT.clone(),
399        ]
400    }
401    fn stack_name(&self) -> &'static str {
402        "USB>CDC>ACM"
403    }
404    fn keywords(&self) -> Vec<&'static str> {
405        vec!["usb", "serial", "cdc", "acm", "uart", "tty"]
406    }
407    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
408        crate::protocol::metadata::ProtocolMetadataV2::builder()
409            .state(crate::protocol::metadata::DevelopmentState::Experimental)
410            .implementation(
411                "Virtual USB CDC ACM serial port over USB/IP. usbip::handler runs on the \
412                 accepted socket, so the listen port is whatever the caller asks for. The CDC \
413                 interface handler is hand-written (src/server/usb/serial/handler.rs) because \
414                 usbip's own UsbCdcAcmHandler discards host writes and handles no class \
415                 requests; the CDC functional descriptors and endpoint layout still come from \
416                 the crate.",
417            )
418            .llm_control(
419                "send_data queues bytes for the host's next bulk IN transfer; set_line_coding \
420                 changes what GET_LINE_CODING reports. All three events are emitted: \
421                 usb_serial_attached on connect, usb_serial_data_received on every host write, \
422                 usb_serial_detached when the USB/IP session ends.",
423            )
424            .e2e_testing(
425                "E2E drives a real USB/IP client over TCP: OP_REQ_IMPORT, then bulk OUT and \
426                 bulk IN URBs, asserting the bytes the host would read. No usbip kernel module \
427                 or root is needed.",
428            )
429            .privilege_requirement(crate::protocol::metadata::PrivilegeRequirement::None)
430            .notes(
431                "Attaching from a real Linux host still needs the vhci-hcd module and root on \
432                 the client side; that path is untested. SET_LINE_CODING from the host is \
433                 recorded but raises no event, so a handler cannot react to the host changing \
434                 the baud rate. There is no flow control and no serial-state notification on \
435                 the interrupt endpoint (break, DCD, framing errors are never reported).",
436            )
437            // The USB/IP screen in `src/server/usb/guard.rs` refuses a `USBIP_CMD_SUBMIT`
438            // declaring more than this, before the crate allocates from the number.
439            .max_inbound_bytes(crate::server::usb::guard::MAX_TRANSFER_BUFFER_BYTES)
440            .build()
441    }
442    fn description(&self) -> &'static str {
443        "Virtual USB serial port (CDC ACM)"
444    }
445    fn example_prompt(&self) -> &'static str {
446        "Create a USB serial port and echo back any data received"
447    }
448    fn group_name(&self) -> &'static str {
449        "USB Devices"
450    }
451
452    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
453        use crate::llm::actions::StartupExamples;
454        use serde_json::json;
455
456        // Deterministic: echo received serial data back to the host, no LLM
457        // call.
458        let script = r#"import json, sys
459data = json.load(sys.stdin)
460event = data["event"]
461if data["event_type_id"] == "usb_serial_data_received":
462    actions = [{"type": "send_data", "data": str(event.get("data", ""))}]
463else:
464    actions = []
465print(json.dumps({"actions": actions}))"#;
466
467        StartupExamples::new(
468            // LLM mode: LLM handles USB serial device
469            json!({
470                "type": "open_server",
471                "port": 3240,
472                "base_stack": "usb-serial",
473                "instruction": "Create a USB serial port and echo back any data received"
474            }),
475            // Script mode: Code-based serial handling
476            json!({
477                "type": "open_server",
478                "port": 3240,
479                "base_stack": "usb-serial",
480                "event_handlers": [{
481                    "event_pattern": "usb_serial_data_received",
482                    "handler": {
483                        "type": "script",
484                        "language": "python",
485                        "code": script
486                    }
487                }]
488            }),
489            // Static mode: Fixed serial response
490            json!({
491                "type": "open_server",
492                "port": 3240,
493                "base_stack": "usb-serial",
494                "event_handlers": [{
495                    "event_pattern": "usb_serial_attached",
496                    "handler": {
497                        "type": "static",
498                        "actions": [{
499                            "type": "send_data",
500                            "data": "Welcome to NetGet USB Serial!\r\n"
501                        }]
502                    }
503                }]
504            }),
505        )
506    }
507}
508
509#[cfg(all(unix, feature = "libusb"))]
510impl Server for UsbSerialProtocol {
511    fn spawn(
512        &self,
513        ctx: crate::protocol::SpawnContext,
514    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
515    {
516        Box::pin(async move {
517            // Read here rather than through a shared helper so the parameter names appear
518            // in this protocol's own source: `tests/startup_param_drift_test.rs` proves a
519            // declared knob is wired by finding its name outside the declaration, and a read
520            // one directory up is invisible to it — and to the next person reading this file.
521            // The numbers and the argument for them are in `src/server/usb/guard.rs`.
522            let secs = |name: &str| -> anyhow::Result<Option<u64>> {
523                Ok(ctx
524                    .startup_params
525                    .as_ref()
526                    .map(|p| p.get_optional_u64(name))
527                    .transpose()?
528                    .flatten())
529            };
530            let first_byte_timeout_secs = secs("first_byte_timeout_secs")?;
531            let idle_timeout_secs = secs("idle_timeout_secs")?;
532            crate::server::usb::serial::UsbSerialServer::spawn_with_llm_actions(
533                ctx.legacy_listen_addr(),
534                ctx.llm_client,
535                ctx.state,
536                ctx.status_tx,
537                ctx.server_id,
538                first_byte_timeout_secs,
539                idle_timeout_secs,
540            )
541            .await
542        })
543    }
544
545    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
546        let action_type = action["type"]
547            .as_str()
548            .context("Action must have 'type' field")?;
549        match action_type {
550            "send_data" => {
551                let data = action["data"]
552                    .as_str()
553                    .context("send_data requires 'data' field")?;
554                if data.is_empty() {
555                    return Err(anyhow::anyhow!("send_data requires non-empty 'data'"));
556                }
557                let bytes = data.as_bytes().to_vec();
558                let dropped =
559                    self.with_serial_handler(&action, |serial| serial.queue_tx(&bytes))?;
560                if dropped > 0 {
561                    // The transmit buffer is bounded, so a host that is not draining it makes
562                    // this a real refusal. Saying so is the point: the alternative is the model
563                    // believing it sent bytes that were thrown away.
564                    return Err(anyhow::anyhow!(
565                        "send_data queued {} of {} byte(s); the transmit buffer is full because \
566                         the host is not reading. Wait for it to drain before sending more.",
567                        bytes.len() - dropped,
568                        bytes.len()
569                    ));
570                }
571                Ok(ActionResult::NoAction)
572            }
573            "set_line_coding" => {
574                // Range-checked before the cast, not after. `as u32` and `as u8` wrap
575                // silently, so `baud_rate: 5000000000` became 705032704 and `data_bits: 264`
576                // became 8 -- the port then reported a line configuration to the host that
577                // nobody had asked for, and GET_LINE_CODING handed the wrong one back.
578                let baud_raw = action["baud_rate"]
579                    .as_u64()
580                    .context("set_line_coding requires 'baud_rate'")?;
581                let baud_rate = u32::try_from(baud_raw).ok().filter(|b| *b > 0).context(
582                    "set_line_coding 'baud_rate' must be between 1 and 4294967295 (CDC PSTN 1.2 \
583                     encodes dwDTERate as 32 bits)",
584                )?;
585
586                let data_bits_raw = action["data_bits"].as_u64().unwrap_or(8);
587                if !matches!(data_bits_raw, 5 | 6 | 7 | 8 | 16) {
588                    return Err(anyhow::anyhow!(
589                        "set_line_coding 'data_bits' is {}; CDC PSTN 1.2 table 17 allows only \
590                         5, 6, 7, 8 or 16",
591                        data_bits_raw
592                    ));
593                }
594                let data_bits = data_bits_raw as u8;
595
596                // The wire encodes parity and stop bits as small integers (CDC PSTN 1.2,
597                // table 17); the model speaks names.
598                let parity = match action["parity"].as_str().unwrap_or("none") {
599                    "none" => 0,
600                    "odd" => 1,
601                    "even" => 2,
602                    "mark" => 3,
603                    "space" => 4,
604                    other => {
605                        return Err(anyhow::anyhow!(
606                            "set_line_coding: unknown parity '{}' (expected none, odd, even, \
607                             mark or space)",
608                            other
609                        ))
610                    }
611                };
612                let stop_bits = match action["stop_bits"].as_f64().unwrap_or(1.0) {
613                    v if (v - 1.0).abs() < f64::EPSILON => 0,
614                    v if (v - 1.5).abs() < f64::EPSILON => 1,
615                    v if (v - 2.0).abs() < f64::EPSILON => 2,
616                    other => {
617                        return Err(anyhow::anyhow!(
618                            "set_line_coding: unknown stop_bits {} (expected 1, 1.5 or 2)",
619                            other
620                        ))
621                    }
622                };
623
624                let line_coding = crate::server::usb::descriptors::LineCoding {
625                    baud_rate,
626                    stop_bits,
627                    parity,
628                    data_bits,
629                };
630                self.with_serial_handler(&action, |serial| serial.set_line_coding(line_coding))?;
631                Ok(ActionResult::NoAction)
632            }
633            "wait_for_more" => Ok(ActionResult::WaitForMore),
634            _ => Err(anyhow::anyhow!("Unknown action type: {}", action_type)),
635        }
636    }
637}