Skip to main content

netget/server/udp/
actions.rs

1//! UDP protocol actions implementation
2
3use crate::llm::actions::{
4    protocol_trait::{ActionResult, Protocol, Server},
5    ActionDefinition, Parameter,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::net::SocketAddr;
13use std::sync::{Arc, LazyLock};
14use tokio::net::UdpSocket;
15
16/// UDP protocol action handler
17pub struct UdpProtocol {
18    /// The running server's socket, when this instance belongs to one.
19    ///
20    /// `None` for the registry's copy (`UdpProtocol::new()`), which only describes actions and
21    /// events. `send_to_address` needs it and says so when it is missing.
22    socket: Option<Arc<UdpSocket>>,
23}
24
25impl Default for UdpProtocol {
26    fn default() -> Self {
27        Self::new()
28    }
29}
30
31impl UdpProtocol {
32    pub fn new() -> Self {
33        Self { socket: None }
34    }
35
36    pub fn with_socket(socket: Arc<UdpSocket>) -> Self {
37        Self {
38            socket: Some(socket),
39        }
40    }
41}
42
43// Implement Protocol trait (common functionality)
44impl Protocol for UdpProtocol {
45    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
46        vec![send_to_address_action()]
47    }
48    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
49        vec![send_udp_response_action(), ignore_datagram_action()]
50    }
51    fn protocol_name(&self) -> &'static str {
52        "UDP"
53    }
54    fn get_event_types(&self) -> Vec<EventType> {
55        get_udp_event_types()
56    }
57    fn stack_name(&self) -> &'static str {
58        "ETH>IP>UDP"
59    }
60    fn keywords(&self) -> Vec<&'static str> {
61        vec!["udp"]
62    }
63    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
64        use crate::protocol::metadata::{DevelopmentState, PortTransport, ProtocolMetadataV2};
65
66        ProtocolMetadataV2::builder()
67            .port_transport(PortTransport::Udp)
68            .connectionless()
69            // Deliberately silent: raw UDP has no error vocabulary of its own. Whatever the payload
70            // protocol is, NetGet does not know it on this path, so any bytes sent on failure would
71            // be a guess a peer might parse as an answer.
72            .deliberately_silent()
73            .state(DevelopmentState::Beta)
74            .implementation("Manual UDP socket handling with tokio")
75            .llm_control("Full datagram control - all sent/received data")
76            .e2e_testing("std::net::UdpSocket")
77            .notes("Stateless, used by DNS/DHCP/NTP")
78            .build()
79    }
80    fn description(&self) -> &'static str {
81        "UDP datagram server"
82    }
83    fn example_prompt(&self) -> &'static str {
84        "Listen on port 5000 via UDP"
85    }
86    fn group_name(&self) -> &'static str {
87        "Core"
88    }
89
90    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
91        use crate::llm::actions::StartupExamples;
92
93        // Deterministic: reply "PONG" to every datagram, no LLM call.
94        let script = r#"import json, sys
95data = json.load(sys.stdin)
96event = data["event"]
97if data["event_type_id"] == "udp_datagram_received":
98    actions = [{"type": "send_udp_response", "data": "PONG"}]
99else:
100    actions = []
101print(json.dumps({"actions": actions}))"#;
102
103        StartupExamples::new(
104            json!({
105                "type": "open_server",
106                "port": 9000,
107                "base_stack": "udp",
108                "instruction": "UDP echo server that responds to datagrams"
109            }),
110            json!({
111                "type": "open_server",
112                "port": 9000,
113                "base_stack": "udp",
114                "event_handlers": [{
115                    "event_pattern": "udp_datagram_received",
116                    "handler": {
117                        "type": "script",
118                        "language": "python",
119                        "code": script
120                    }
121                }]
122            }),
123            json!({
124                "type": "open_server",
125                "port": 9000,
126                "base_stack": "udp",
127                "event_handlers": [{
128                    "event_pattern": "udp_datagram_received",
129                    "handler": {
130                        "type": "static",
131                        "actions": [{
132                            "type": "send_udp_response",
133                            "data": "PONG"
134                        }]
135                    }
136                }]
137            }),
138        )
139    }
140}
141
142// Implement Server trait (server-specific functionality)
143impl Server for UdpProtocol {
144    fn spawn(
145        &self,
146        ctx: crate::protocol::SpawnContext,
147    ) -> std::pin::Pin<
148        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
149    > {
150        Box::pin(async move {
151            use crate::server::udp::UdpServer;
152            UdpServer::spawn_with_llm_actions(
153                ctx.legacy_listen_addr(),
154                ctx.llm_client,
155                ctx.state,
156                ctx.status_tx,
157                ctx.server_id,
158            )
159            .await
160        })
161    }
162    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
163        let action_type = action
164            .get("type")
165            .and_then(|v| v.as_str())
166            .context("Missing 'type' field in action")?;
167
168        match action_type {
169            "send_to_address" => self.execute_send_to_address(action),
170            "send_udp_response" => self.execute_send_udp_response(action),
171            "ignore_datagram" => Ok(ActionResult::NoAction),
172            _ => Err(anyhow::anyhow!("Unknown UDP action: {}", action_type)),
173        }
174    }
175}
176
177impl UdpProtocol {
178    /// Turn an action's `data` field into the bytes to put on the wire.
179    ///
180    /// `encoding` selects the interpretation:
181    /// - `"text"` (or `"utf8"`) - the string's UTF-8 bytes, verbatim
182    /// - `"hex"`   - hex-decoded, and an error if it is not valid hex
183    /// - absent or `"auto"` - hex if the string happens to parse as hex, otherwise text
184    ///
185    /// `"utf8"` is accepted because the TCP server's equivalent field spells it that way, and a
186    /// model that has just been writing `send_tcp_data` reaches for it here. It used to be
187    /// rejected outright with "Unknown encoding 'utf8'", which lost the whole datagram over a
188    /// spelling.
189    ///
190    /// `auto` is the historical behaviour and stays the default so existing prompts, handlers
191    /// and tests keep working, but it is genuinely ambiguous and worth avoiding: any even-length
192    /// string of hex digits is taken as hex. `{"data": "1234"}` puts two bytes (0x12 0x34) on
193    /// the wire, not the four characters "1234"; so do "abcd", "DEADBEEF" and "0000". Pass
194    /// `"encoding": "text"` whenever the payload is text.
195    ///
196    /// When `auto` actually resolves to hex the guess is logged at WARN. It is the one case
197    /// where a caller can be silently misunderstood, and the whole reason the TCP server was
198    /// given an explicit `encoding` field instead (see the top-level CLAUDE.md); making it
199    /// visible is what separates "the model chose hex" from "we guessed hex at its text".
200    fn decode_payload(action: &serde_json::Value) -> Result<Vec<u8>> {
201        let data = action
202            .get("data")
203            .and_then(|v| v.as_str())
204            .context("Missing 'data' parameter")?;
205
206        match action.get("encoding").and_then(|v| v.as_str()) {
207            Some("text") | Some("utf8") => Ok(data.as_bytes().to_vec()),
208            Some("hex") => hex::decode(data)
209                .context("encoding is 'hex' but 'data' is not valid hex")
210                .map_err(Into::into),
211            Some(other) if other != "auto" => Err(anyhow::anyhow!(
212                "Unknown encoding '{}': expected 'text' (or 'utf8'), 'hex' or 'auto'",
213                other
214            )),
215            _ => match hex::decode(data) {
216                Ok(bytes) => {
217                    tracing::warn!(
218                        "UDP 'data' had no 'encoding' and parses as hex, so {} characters were \
219                         decoded to {} bytes. If it was meant as text, pass \
220                         \"encoding\": \"text\" - this guess is the one way a payload can be \
221                         silently corrupted here.",
222                        data.len(),
223                        bytes.len()
224                    );
225                    Ok(bytes)
226                }
227                Err(_) => Ok(data.as_bytes().to_vec()),
228            },
229        }
230    }
231
232    /// Execute send_to_address async action.
233    ///
234    /// The address used to be parsed for validation and then **discarded**: the result was a
235    /// plain `Output`, and the handler in `mod.rs` writes every `Output` back to the peer that
236    /// sent the current datagram. So an action whose entire purpose is "send somewhere else"
237    /// sent to the same place as `send_udp_response`, and the model was never told.
238    ///
239    /// It now writes the datagram itself, through the server's own socket, and returns
240    /// `NoAction` so `mod.rs` does not additionally echo the payload to the current peer.
241    /// `try_send_to` rather than `send_to` because this executor is synchronous; a UDP send
242    /// does not block in practice, and the one case where it can (a full socket buffer) is
243    /// reported rather than swallowed.
244    fn execute_send_to_address(&self, action: serde_json::Value) -> Result<ActionResult> {
245        let address = action
246            .get("address")
247            .and_then(|v| v.as_str())
248            .context("Missing 'address' parameter")?;
249
250        let addr: SocketAddr = address.parse().context("Invalid socket address format")?;
251        let payload = Self::decode_payload(&action)?;
252
253        let Some(socket) = self.socket.as_ref() else {
254            // Phrased so the whole-tree example audit classifies this as needing runtime
255            // context, which is exactly what it is: the registry's copy of this protocol has no
256            // socket, only the one the running server built does.
257            return Err(anyhow::anyhow!(
258                "send_to_address can only run while answering a udp_datagram_received event on a \
259                 running UDP server: no socket is bound in this context"
260            ));
261        };
262
263        match socket.try_send_to(&payload, addr) {
264            Ok(sent) => {
265                tracing::debug!("UDP send_to_address: {} bytes to {}", sent, addr);
266                Ok(ActionResult::NoAction)
267            }
268            Err(e) => Err(anyhow::anyhow!(
269                "send_to_address could not write {} bytes to {}: {}",
270                payload.len(),
271                addr,
272                e
273            )),
274        }
275    }
276
277    /// Execute send_udp_response sync action
278    fn execute_send_udp_response(&self, action: serde_json::Value) -> Result<ActionResult> {
279        Ok(ActionResult::Output(Self::decode_payload(&action)?))
280    }
281}
282
283/// The `encoding` parameter shared by the two sending actions.
284fn encoding_parameter() -> Parameter {
285    Parameter {
286        name: "encoding".to_string(),
287        type_hint: "string".to_string(),
288        description: "How to read 'data': 'text' (or 'utf8') for literal UTF-8, 'hex' for \
289                      hex-decoded binary, 'auto' (the default) to guess. ALWAYS set it \
290                      explicitly - under 'auto' any even-length run of hex digits is taken as \
291                      hex, so \"1234\" sends the two bytes 0x12 0x34 rather than the four \
292                      characters, and so do \"abcd\", \"DEADBEEF\" and \"0000\". The \
293                      udp_datagram_received event tells you which encoding it used; reply with \
294                      the same one."
295            .to_string(),
296        required: false,
297    }
298    .with_choices(["text", "hex", "auto"])
299}
300
301/// Action definition for send_to_address
302fn send_to_address_action() -> ActionDefinition {
303    ActionDefinition {
304        name: "send_to_address".to_string(),
305        description: "Send a UDP datagram to an address other than the current peer, from this \
306                      server's own socket. Use send_udp_response to answer the peer that sent \
307                      the datagram you are handling."
308            .to_string(),
309        parameters: vec![
310            Parameter {
311                name: "address".to_string(),
312                type_hint: "string".to_string(),
313                description: "Target address in format 'IP:port' (e.g., '127.0.0.1:8080')"
314                    .to_string(),
315                required: true,
316            },
317            Parameter {
318                name: "data".to_string(),
319                type_hint: "string".to_string(),
320                description: "Data to send (see 'encoding')".to_string(),
321                required: true,
322            },
323            encoding_parameter(),
324        ],
325        example: json!({
326            "type": "send_to_address",
327            "address": "127.0.0.1:8080",
328            "data": "Hello from UDP",
329            "encoding": "text"
330        }),
331        log_template: Some(
332            LogTemplate::new()
333                .with_info("-> UDP to {address}")
334                .with_debug("UDP send_to_address: address={address}"),
335        ),
336    }
337}
338
339/// Action definition for send_udp_response
340fn send_udp_response_action() -> ActionDefinition {
341    ActionDefinition {
342        name: "send_udp_response".to_string(),
343        description: "Send UDP response back to the peer that sent the current datagram. To \
344                      send the datagram back unchanged (an echo), put the event's data_preview \
345                      in data and its data_encoding in encoding - that is the datagram itself, \
346                      not a description of it."
347            .to_string(),
348        parameters: vec![
349            Parameter {
350                name: "data".to_string(),
351                type_hint: "string".to_string(),
352                description: "Response payload, sent exactly as written (see 'encoding'). \
353                              The literal bytes to send: for an echo, the event's data_preview."
354                    .to_string(),
355                required: true,
356            },
357            encoding_parameter(),
358        ],
359        example: json!({
360            "type": "send_udp_response",
361            "data": "Response data",
362            "encoding": "text"
363        }),
364        log_template: Some(
365            LogTemplate::new()
366                .with_info("-> UDP response {output_bytes}B")
367                .with_debug("UDP send_udp_response: {output_bytes}B")
368                .with_trace("UDP response: {preview(data,200)}"),
369        ),
370    }
371}
372
373/// Action definition for ignore_datagram
374fn ignore_datagram_action() -> ActionDefinition {
375    ActionDefinition {
376        name: "ignore_datagram".to_string(),
377        description: "Ignore this datagram and don't send a response".to_string(),
378        parameters: vec![],
379        example: json!({
380            "type": "ignore_datagram"
381        }),
382        log_template: Some(LogTemplate::new().with_debug("UDP ignore_datagram")),
383    }
384}
385
386// ============================================================================
387// UDP Event Type Constants
388// ============================================================================
389
390pub static UDP_DATAGRAM_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
391    EventType::new(
392        "udp_datagram_received",
393        "UDP datagram received from a peer. Reply in the same encoding the event reports.",
394        json!({
395            "type": "send_udp_response",
396            "data": "PONG",
397            "encoding": "text"
398        }),
399    )
400    .with_alternative_example(json!({
401        "type": "send_udp_response",
402        "data": "48656c6c6f",
403        "encoding": "hex"
404    }))
405    .with_parameters(vec![
406        Parameter {
407            name: "peer_address".to_string(),
408            type_hint: "string".to_string(),
409            description: "Source address of the datagram (IP:port)".to_string(),
410            required: true,
411        },
412        Parameter {
413            name: "data_length".to_string(),
414            type_hint: "number".to_string(),
415            description: "Length of the received data in bytes".to_string(),
416            required: true,
417        },
418        Parameter {
419            name: "data_encoding".to_string(),
420            type_hint: "string".to_string(),
421            description: "How data_preview is rendered: 'text' if the payload is printable \
422                          ASCII, otherwise 'hex'. Use the same value when replying."
423                .to_string(),
424            required: true,
425        },
426        Parameter {
427            name: "data_preview".to_string(),
428            type_hint: "string".to_string(),
429            description: "The received payload itself, as text or hex per data_encoding. It \
430                          is the whole datagram unless it ends in '...' (payloads over 200 \
431                          bytes are cut there). Echoing means sending this string back."
432                .to_string(),
433            required: false,
434        },
435    ])
436    // `send_to_address` belongs here as well as in the async list. `call_llm` builds a server's
437    // tool list from the *event*, so leaving it out meant the only place the action could
438    // actually work — inside the running server, which is the only context that owns a socket —
439    // was the one place the model was never offered it. Outside that context it now says so
440    // rather than pretending to have sent something.
441    .with_actions(vec![
442        send_udp_response_action(),
443        send_to_address_action(),
444        ignore_datagram_action(),
445    ])
446    .with_log_template(
447        LogTemplate::new()
448            .with_info("UDP {data_length}B from {peer_address}")
449            .with_debug("UDP datagram: {data_length}B from {peer_address}")
450            .with_trace("UDP data: {preview(data_preview,200)}"),
451    )
452});
453
454pub fn get_udp_event_types() -> Vec<EventType> {
455    vec![UDP_DATAGRAM_RECEIVED_EVENT.clone()]
456}