1use 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
16pub struct UdpProtocol {
18 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
43impl 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()
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 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
142impl 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 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 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 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 fn execute_send_udp_response(&self, action: serde_json::Value) -> Result<ActionResult> {
279 Ok(ActionResult::Output(Self::decode_payload(&action)?))
280 }
281}
282
283fn 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
301fn 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
339fn 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
373fn 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
386pub 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 .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}