Skip to main content

netget/client/sqs/
actions.rs

1//! SQS client protocol actions implementation
2
3use crate::llm::actions::{
4    client_trait::{Client, ClientActionResult},
5    protocol_trait::Protocol,
6    ActionDefinition, Parameter, ParameterDefinition,
7};
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14/// SQS client connected event
15pub static SQS_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
16    EventType::new(
17        "sqs_connected",
18        "SQS client initialized and ready to interact with queue",
19        json!({
20            "type": "send_message",
21            "message_body": "Processed successfully"
22        }),
23    )
24    .with_parameters(vec![Parameter {
25        name: "queue_url".to_string(),
26        type_hint: "string".to_string(),
27        description: "SQS queue URL".to_string(),
28        required: true,
29    }])
30});
31
32/// SQS message received event
33pub static SQS_MESSAGE_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
34    EventType::new(
35        "sqs_message_received",
36        "Messages received from SQS queue. An empty `messages` array means the poll returned \
37         nothing, which is the normal case for short polling — answer with `receive_messages` \
38         to keep waiting, or stop.",
39        // A real action, not `{"type": "placeholder"}`: this is the response example the
40        // model is prompted with, so a placeholder here taught it a verb that does not exist.
41        json!({"type": "delete_message", "receipt_handle": "AQEBw...=="}),
42    )
43    .with_parameters(vec![Parameter {
44        name: "messages".to_string(),
45        type_hint: "array".to_string(),
46        description: "Array of received messages".to_string(),
47        required: true,
48    }])
49});
50
51/// SQS message sent event
52pub static SQS_MESSAGE_SENT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
53    EventType::new(
54        "sqs_message_sent",
55        "Message successfully sent to SQS queue",
56        json!({"type": "receive_messages", "max_messages": 5}),
57    )
58    .with_parameters(vec![Parameter {
59        name: "message_id".to_string(),
60        type_hint: "string".to_string(),
61        description: "ID of the sent message".to_string(),
62        required: true,
63    }])
64});
65
66/// SQS client protocol action handler
67pub struct SqsClientProtocol;
68
69impl SqsClientProtocol {
70    pub fn new() -> Self {
71        Self
72    }
73}
74
75// Implement Protocol trait (common functionality)
76impl Protocol for SqsClientProtocol {
77    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
78        vec![
79            ParameterDefinition {
80                name: "queue_url".to_string(),
81                description: "SQS queue URL to interact with".to_string(),
82                type_hint: "string".to_string(),
83                required: true,
84                example: json!("https://sqs.us-east-1.amazonaws.com/123456789012/MyQueue"),
85                default: None,
86            },
87            ParameterDefinition {
88                name: "region".to_string(),
89                description: "AWS region (e.g., us-east-1, eu-west-1)".to_string(),
90                type_hint: "string".to_string(),
91                required: false,
92                example: json!("us-east-1"),
93                default: None,
94            },
95            ParameterDefinition {
96                name: "endpoint_url".to_string(),
97                description: "Custom endpoint URL for local testing (e.g., http://localhost:9324)"
98                    .to_string(),
99                type_hint: "string".to_string(),
100                required: false,
101                example: json!("http://localhost:9324"),
102                default: None,
103            },
104            // Without these the SDK falls back to its default chain — environment,
105            // ~/.aws/credentials, then IMDS — so a client the model created would sign with
106            // the operator's real AWS identity and there was no way to scope it. Supplying
107            // them installs a static provider instead.
108            ParameterDefinition {
109                name: "access_key_id".to_string(),
110                description: "AWS access key ID. When this and secret_access_key are given, \
111                              they are used instead of the ambient AWS credential chain \
112                              (environment, ~/.aws/credentials, instance role)."
113                    .to_string(),
114                type_hint: "string".to_string(),
115                required: false,
116                example: json!("AKIAIOSFODNN7EXAMPLE"),
117                default: None,
118            },
119            ParameterDefinition {
120                name: "secret_access_key".to_string(),
121                description: "AWS secret access key, used with access_key_id.".to_string(),
122                type_hint: "string".to_string(),
123                required: false,
124                example: json!("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
125                default: None,
126            },
127        ]
128    }
129    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
130        vec![
131            ActionDefinition {
132                name: "send_message".to_string(),
133                description: "Send a message to the SQS queue".to_string(),
134                parameters: vec![
135                    Parameter {
136                        name: "message_body".to_string(),
137                        type_hint: "string".to_string(),
138                        description: "The message content to send".to_string(),
139                        required: true,
140                    },
141                    Parameter {
142                        name: "message_attributes".to_string(),
143                        type_hint: "object".to_string(),
144                        description: "Optional message attributes (key-value pairs)".to_string(),
145                        required: false,
146                    },
147                    Parameter {
148                        name: "delay_seconds".to_string(),
149                        type_hint: "number".to_string(),
150                        description: "Delay in seconds before message becomes available"
151                            .to_string(),
152                        required: false,
153                    },
154                ],
155                example: json!({
156                    "type": "send_message",
157                    "message_body": "Hello from NetGet",
158                    "message_attributes": {
159                        "priority": "high",
160                        "source": "netget"
161                    }
162                }),
163                log_template: None,
164            },
165            ActionDefinition {
166                name: "receive_messages".to_string(),
167                description: "Receive messages from the SQS queue".to_string(),
168                parameters: vec![
169                    Parameter {
170                        name: "max_messages".to_string(),
171                        type_hint: "number".to_string(),
172                        description: "Maximum number of messages to receive (1-10)".to_string(),
173                        required: false,
174                    },
175                    Parameter {
176                        name: "wait_time_seconds".to_string(),
177                        type_hint: "number".to_string(),
178                        description: "Long polling wait time in seconds (0-20)".to_string(),
179                        required: false,
180                    },
181                    Parameter {
182                        name: "visibility_timeout".to_string(),
183                        type_hint: "number".to_string(),
184                        description: "Visibility timeout in seconds for received messages"
185                            .to_string(),
186                        required: false,
187                    },
188                ],
189                example: json!({
190                    "type": "receive_messages",
191                    "max_messages": 5,
192                    "wait_time_seconds": 10
193                }),
194                log_template: None,
195            },
196            ActionDefinition {
197                name: "delete_message".to_string(),
198                description: "Delete a message from the queue using its receipt handle".to_string(),
199                parameters: vec![Parameter {
200                    name: "receipt_handle".to_string(),
201                    type_hint: "string".to_string(),
202                    description: "Receipt handle of the message to delete".to_string(),
203                    required: true,
204                }],
205                example: json!({
206                    "type": "delete_message",
207                    "receipt_handle": "AQEBwJnKyrHigUMZj6rY..."
208                }),
209                log_template: None,
210            },
211            ActionDefinition {
212                name: "purge_queue".to_string(),
213                description: "Delete all messages in the queue".to_string(),
214                parameters: vec![],
215                example: json!({
216                    "type": "purge_queue"
217                }),
218                log_template: None,
219            },
220            ActionDefinition {
221                name: "get_queue_attributes".to_string(),
222                description: "Get attributes of the queue (message count, etc.)".to_string(),
223                parameters: vec![Parameter {
224                    name: "attribute_names".to_string(),
225                    type_hint: "array".to_string(),
226                    description: "List of attribute names to retrieve".to_string(),
227                    required: false,
228                }],
229                example: json!({
230                    "type": "get_queue_attributes",
231                    "attribute_names": ["ApproximateNumberOfMessages", "QueueArn"]
232                }),
233                log_template: None,
234            },
235            ActionDefinition {
236                name: "disconnect".to_string(),
237                description: "Disconnect from the SQS queue".to_string(),
238                parameters: vec![],
239                example: json!({
240                    "type": "disconnect"
241                }),
242                log_template: None,
243            },
244        ]
245    }
246    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
247        vec![
248            ActionDefinition {
249                name: "send_message".to_string(),
250                description: "Send a message to the queue in response to received messages"
251                    .to_string(),
252                parameters: vec![Parameter {
253                    name: "message_body".to_string(),
254                    type_hint: "string".to_string(),
255                    description: "The message content to send".to_string(),
256                    required: true,
257                }],
258                example: json!({
259                    "type": "send_message",
260                    "message_body": "Processed successfully"
261                }),
262                log_template: None,
263            },
264            ActionDefinition {
265                name: "delete_message".to_string(),
266                description: "Delete a received message".to_string(),
267                parameters: vec![Parameter {
268                    name: "receipt_handle".to_string(),
269                    type_hint: "string".to_string(),
270                    description: "Receipt handle from received message".to_string(),
271                    required: true,
272                }],
273                example: json!({
274                    "type": "delete_message",
275                    "receipt_handle": "AQEBwJnKyrHigUMZj6rY..."
276                }),
277                log_template: None,
278            },
279        ]
280    }
281    fn protocol_name(&self) -> &'static str {
282        "SQS"
283    }
284    /// The three statics themselves, not rebuilt copies.
285    ///
286    /// This used to construct three fresh `EventType`s with different descriptions, no
287    /// parameters and `{"type": "placeholder"}` response examples — so the documentation and
288    /// registry surfaces described one thing while the events actually raised at runtime
289    /// (the `LazyLock` statics above) described another.
290    fn get_event_types(&self) -> Vec<EventType> {
291        vec![
292            SQS_CLIENT_CONNECTED_EVENT.clone(),
293            SQS_MESSAGE_RECEIVED_EVENT.clone(),
294            SQS_MESSAGE_SENT_EVENT.clone(),
295        ]
296    }
297    fn stack_name(&self) -> &'static str {
298        "ETH>IP>TCP>HTTP>SQS"
299    }
300    fn keywords(&self) -> Vec<&'static str> {
301        vec!["sqs", "sqs client", "connect to sqs", "aws sqs", "queue"]
302    }
303    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
304        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
305
306        ProtocolMetadataV2::builder()
307            .state(DevelopmentState::Experimental)
308            .implementation("AWS SDK for SQS with HTTP/JSON protocol")
309            .llm_control("Full control over queue operations")
310            .e2e_testing("LocalStack SQS container")
311            .build()
312    }
313    fn description(&self) -> &'static str {
314        "AWS SQS client for queue-based messaging"
315    }
316    fn example_prompt(&self) -> &'static str {
317        "Connect to SQS queue MyQueue and receive messages"
318    }
319    fn group_name(&self) -> &'static str {
320        "Cloud"
321    }
322    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
323        use crate::llm::actions::StartupExamples;
324        use serde_json::json;
325
326        // A custom endpoint is refused without explicit credentials (`client::aws_support`),
327        // so every example names a dummy pair, which is what a local emulator accepts.
328        StartupExamples::new(
329            // LLM mode: LLM controls SQS operations
330            json!({
331                "type": "open_client",
332                "remote_addr": "localhost:9324",
333                "base_stack": "sqs",
334                "startup_params": {
335                    "queue_url": "http://localhost:9324/000000000000/MyQueue",
336                    "endpoint_url": "http://localhost:9324",
337                    "access_key_id": "local",
338                    "secret_access_key": "local"
339                },
340                "instruction": "Send a test message and receive messages from the queue"
341            }),
342            // Script mode: Code-based message processing
343            json!({
344                "type": "open_client",
345                "remote_addr": "localhost:9324",
346                "base_stack": "sqs",
347                "startup_params": {
348                    "queue_url": "http://localhost:9324/000000000000/MyQueue",
349                    "endpoint_url": "http://localhost:9324",
350                    "access_key_id": "local",
351                    "secret_access_key": "local"
352                },
353                "event_handlers": [{
354                    "event_pattern": "sqs_message_received",
355                    "handler": {
356                        "type": "script",
357                        "language": "python",
358                        "code": "<sqs_client_handler>"
359                    }
360                }]
361            }),
362            // Static mode: Fixed message operations
363            json!({
364                "type": "open_client",
365                "remote_addr": "localhost:9324",
366                "base_stack": "sqs",
367                "startup_params": {
368                    "queue_url": "http://localhost:9324/000000000000/MyQueue",
369                    "endpoint_url": "http://localhost:9324",
370                    "access_key_id": "local",
371                    "secret_access_key": "local"
372                },
373                "event_handlers": [
374                    {
375                        "event_pattern": "sqs_connected",
376                        "handler": {
377                            "type": "static",
378                            "actions": [{
379                                "type": "send_message",
380                                "message_body": "Test message from NetGet"
381                            }]
382                        }
383                    },
384                    {
385                        "event_pattern": "sqs_message_sent",
386                        "handler": {
387                            "type": "static",
388                            "actions": [{
389                                "type": "disconnect"
390                            }]
391                        }
392                    }
393                ]
394            }),
395        )
396    }
397}
398
399// Implement Client trait (client-specific functionality)
400impl Client for SqsClientProtocol {
401    fn connect(
402        &self,
403        ctx: crate::protocol::ConnectContext,
404    ) -> std::pin::Pin<
405        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
406    > {
407        Box::pin(async move {
408            use crate::client::sqs::SqsClient;
409            SqsClient::connect_with_llm_actions(
410                ctx.remote_addr,
411                ctx.llm_client,
412                ctx.state,
413                ctx.status_tx,
414                ctx.client_id,
415                ctx.startup_params,
416            )
417            .await
418        })
419    }
420    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
421        let action_type = action
422            .get("type")
423            .and_then(|v| v.as_str())
424            .context("Missing 'type' field in action")?;
425
426        match action_type {
427            "send_message" => {
428                let message_body = action
429                    .get("message_body")
430                    .and_then(|v| v.as_str())
431                    .context("Missing 'message_body' field")?
432                    .to_string();
433
434                let message_attributes = action.get("message_attributes").cloned();
435
436                let delay_seconds = action.get("delay_seconds").and_then(|v| v.as_i64());
437
438                Ok(ClientActionResult::Custom {
439                    name: "send_message".to_string(),
440                    data: json!({
441                        "message_body": message_body,
442                        "message_attributes": message_attributes,
443                        "delay_seconds": delay_seconds,
444                    }),
445                })
446            }
447            "receive_messages" => {
448                let max_messages = action.get("max_messages").and_then(|v| v.as_i64());
449
450                let wait_time_seconds = action.get("wait_time_seconds").and_then(|v| v.as_i64());
451
452                let visibility_timeout = action.get("visibility_timeout").and_then(|v| v.as_i64());
453
454                Ok(ClientActionResult::Custom {
455                    name: "receive_messages".to_string(),
456                    data: json!({
457                        "max_messages": max_messages,
458                        "wait_time_seconds": wait_time_seconds,
459                        "visibility_timeout": visibility_timeout,
460                    }),
461                })
462            }
463            "delete_message" => {
464                let receipt_handle = action
465                    .get("receipt_handle")
466                    .and_then(|v| v.as_str())
467                    .context("Missing 'receipt_handle' field")?
468                    .to_string();
469
470                Ok(ClientActionResult::Custom {
471                    name: "delete_message".to_string(),
472                    data: json!({
473                        "receipt_handle": receipt_handle,
474                    }),
475                })
476            }
477            "purge_queue" => Ok(ClientActionResult::Custom {
478                name: "purge_queue".to_string(),
479                data: json!({}),
480            }),
481            "get_queue_attributes" => {
482                let attribute_names = action
483                    .get("attribute_names")
484                    .and_then(|v| v.as_array())
485                    .map(|arr| {
486                        arr.iter()
487                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
488                            .collect::<Vec<_>>()
489                    });
490
491                Ok(ClientActionResult::Custom {
492                    name: "get_queue_attributes".to_string(),
493                    data: json!({
494                        "attribute_names": attribute_names,
495                    }),
496                })
497            }
498            "disconnect" => Ok(ClientActionResult::Disconnect),
499            _ => Err(anyhow::anyhow!(
500                "Unknown SQS client action: {}",
501                action_type
502            )),
503        }
504    }
505}