Skip to main content

netget/server/sqs/
actions.rs

1//! AWS SQS protocol actions and event types
2//!
3//! Defines the actions the LLM can take in response to SQS API requests.
4
5use crate::llm::actions::{
6    protocol_trait::{ActionResult, Protocol, Server},
7    ActionDefinition, Parameter, ParameterDefinition,
8};
9use crate::protocol::log_template::LogTemplate;
10use crate::protocol::EventType;
11use crate::state::app_state::AppState;
12use anyhow::Result;
13use serde_json::{json, Value};
14use std::sync::LazyLock;
15
16/// SQS protocol handler
17pub struct SqsProtocol {
18    // Could store connection state here if needed
19}
20
21impl SqsProtocol {
22    pub fn new() -> Self {
23        Self {}
24    }
25}
26
27/// SQS request event - triggered when an SQS API request is received
28pub static SQS_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
29    EventType::new(
30        "sqs_request",
31        "An SQS API request arrived. Answer in the **AWS JSON protocol**: this server replies \
32         with Content-Type application/x-amz-json-1.0, so the body is a JSON object \
33         (`{\"MessageId\": \"...\"}`). SQS also has an older Query API that answers with XML \
34         (<SendMessageResponse xmlns=\"http://queue.amazonaws.com/doc/2012-11-05/\">…) — that \
35         is a different protocol and a client here cannot parse it, however well-formed it is.",
36        json!({
37            "type": "send_sqs_response",
38            "status_code": 200,
39            "body": "{\"MessageId\": \"59c5d728-17e6-4355-a298-1a9364790137\", \"MD5OfMessageBody\": \"5d41402abc4b2a76b9719d911017c592\"}"
40        }),
41    )
42    .with_parameters(vec![
43        Parameter {
44            name: "operation".to_string(),
45            type_hint: "string".to_string(),
46            description: "SQS operation (SendMessage, ReceiveMessage, CreateQueue, etc.)"
47                .to_string(),
48            required: true,
49        },
50        Parameter {
51            name: "queue_url".to_string(),
52            type_hint: "string".to_string(),
53            description: "Target queue URL (if available)".to_string(),
54            required: false,
55        },
56        Parameter {
57            name: "request_body".to_string(),
58            type_hint: "string".to_string(),
59            description: "JSON request body".to_string(),
60            required: true,
61        },
62    ])
63    .with_actions(vec![send_sqs_response_action()])
64    // Only fields `sqs_request` actually carries. `{client_ip}`, `{status}` and
65    // `{duration_ms}` were in this template and in none of the event data, and a missing
66    // field renders as the empty string — so the INFO line read
67    // " SQS SendMessage http://... ->  (ms)" on every request.
68    .with_log_template(
69        LogTemplate::new()
70            .with_info("SQS {operation} {queue_url}")
71            .with_debug("SQS {operation} queue={queue_url}")
72            .with_trace("SQS request: {json_pretty(.)}"),
73    )
74});
75
76fn send_sqs_response_action() -> ActionDefinition {
77    ActionDefinition {
78        name: "send_sqs_response".to_string(),
79        description: "Send an SQS response. The body is sent as \
80                      Content-Type: application/x-amz-json-1.0, so it must be a JSON object — \
81                      never the legacy Query API's XML, which a client on this endpoint cannot \
82                      parse."
83            .to_string(),
84        parameters: vec![
85            Parameter {
86                name: "status_code".to_string(),
87                type_hint: "number".to_string(),
88                description: "HTTP status code (200, 400, 500, etc.)".to_string(),
89                required: true,
90            },
91            Parameter {
92                name: "body".to_string(),
93                type_hint: "string".to_string(),
94                description: "Response body as a JSON object, serialized to a string. This is \
95                              the AWS JSON protocol (application/x-amz-json-1.0): a SendMessage \
96                              reply is {\"MessageId\": \"...\", \"MD5OfMessageBody\": \"...\"}, \
97                              not an <SendMessageResponse> XML document."
98                    .to_string(),
99                required: true,
100            },
101        ],
102        example: serde_json::json!({
103            "type": "send_sqs_response",
104            "status_code": 200,
105            "body": "{\"MessageId\": \"msg-123\", \"MD5OfMessageBody\": \"d41d8cd98f00b204e9800998ecf8427e\"}"
106        }),
107        log_template: Some(
108            LogTemplate::new()
109                .with_info("-> {status_code} ({body_len}B)")
110                .with_debug("SQS response: status={status_code}")
111                .with_trace("SQS response body: {body}"),
112        ),
113    }
114}
115
116/// Read and validate the model-supplied `status_code`.
117///
118/// The old `as u64 as u16` cast wrapped silently, so a status of 65736 became 200 and a
119/// status of 99 or 1000 reached `Response::builder().status()`, which rejects it - and the
120/// `.unwrap()` on the other side of that turned a typo in model output into a panic that
121/// killed the connection task. Reject it here instead, where the message reaches the model.
122pub(crate) fn parse_status_code(action: &Value) -> Result<u16> {
123    let raw = action
124        .get("status_code")
125        .and_then(|v| v.as_u64())
126        .ok_or_else(|| anyhow::anyhow!("Missing or invalid status_code: expected a number"))?;
127
128    if !(100..=599).contains(&raw) {
129        return Err(anyhow::anyhow!(
130            "Invalid status_code {raw}: must be an HTTP status between 100 and 599. \
131             SQS uses 200 for success and 400 with an {{\"__type\": ..., \"message\": ...}} \
132             body for client errors."
133        ));
134    }
135
136    Ok(raw as u16)
137}
138
139pub fn get_sqs_event_types() -> Vec<EventType> {
140    vec![SQS_REQUEST_EVENT.clone()]
141}
142
143// Implement Protocol trait (common functionality)
144impl Protocol for SqsProtocol {
145    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
146        // Deliberately empty. `default_visibility_timeout`, `default_message_retention` and
147        // `max_receive_count` used to be declared here, but the server holds no queue state
148        // to apply them to and `spawn()` never read them - the LLM owns visibility and
149        // retention behaviour entirely, through the instruction. Declaring them promised
150        // enforcement that did not exist.
151        vec![]
152    }
153    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
154        // No async actions for SQS currently (all operations are request-driven)
155        vec![]
156    }
157    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
158        vec![send_sqs_response_action()]
159    }
160    fn protocol_name(&self) -> &'static str {
161        "SQS"
162    }
163    fn get_event_types(&self) -> Vec<EventType> {
164        get_sqs_event_types()
165    }
166    fn stack_name(&self) -> &'static str {
167        "ETH>IP>TCP>HTTP>SQS"
168    }
169    fn keywords(&self) -> Vec<&'static str> {
170        // "queue" alone is intentionally absent: it collided with AMQP. SQS is chosen by
171        // its AWS-specific names and the two-word "message queue" phrase.
172        vec!["sqs", "message queue", "sqs queue", "aws sqs"]
173    }
174    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
175        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
176
177        ProtocolMetadataV2::builder()
178            // Beta: exercised against a real, independent client — aws-sdk-sqs —
179            // covering the AWS SDK issuing real SQS operations. Not Stable: Stable additionally wants spec
180            // compliance and scripting support reviewed, which has not been done here.
181            .state(DevelopmentState::Beta)
182            .implementation("hyper v1.5 HTTP with AWS JSON protocol")
183            .llm_control("All SQS operations (SendMessage, ReceiveMessage, DeleteMessage)")
184            .e2e_testing(
185                "TWO independent clients, neither #[ignore]d and neither able to skip. \
186                 (1) aws-sdk-sqs, the official Rust SDK, in tests/server/sqs/e2e_test.rs. \
187                 (2) the real `aws` CLI (botocore, Python -- a different SDK generation and a \
188                 different serialiser) in tests/server/sqs/real_client_test.rs, completing \
189                 CreateQueue, SendMessage and ReceiveMessage and asserting on what it PRINTED. \
190                 Rendering is what the second client adds: a field name or JSON shape the Rust \
191                 SDK tolerates through its generated deserialiser shows up there as a missing \
192                 column. Verified by renaming MessageId to messageId in the reply, which the \
193                 test then reports. \
194                 THE TEST CANNOT REACH REAL AWS, and that is enforced rather than assumed: \
195                 --endpoint-url is passed on every invocation, the port is asserted non-zero \
196                 before the CLI is spawned, and credentials, region, profile and the EC2 \
197                 metadata service are overridden in the child's environment. The reason is in \
198                 the project CLAUDE.md -- the DynamoDB CLIENT once dropped its target, let the \
199                 SDK resolve the production endpoint, and signed with ambient credentials. \
200                 UNPROVEN, and worth saying plainly: NO SIGNATURE IS VALIDATED. This server \
201                 serves every request unconditionally and does not put Authorization or \
202                 X-Amz-Date into the event, so the model cannot make that decision either. \
203                 Also unproven: long polling, batch operations, dead-letter queues, FIFO \
204                 queues, and the query-protocol wire format older SDKs use.",
205            )
206            .notes("Virtual queues (no persistence); no auth; visibility timeouts are the LLM's job, the server tracks nothing")
207            .max_inbound_bytes(crate::server::sqs::MAX_REQUEST_BYTES)
208            .request_only("SQS is HTTP request/response; a server cannot send a peer anything unprompted")
209            .build()
210    }
211    fn description(&self) -> &'static str {
212        "AWS SQS-compatible message queue server"
213    }
214    fn example_prompt(&self) -> &'static str {
215        "Start an AWS SQS-compatible queue server on port 9324"
216    }
217    fn group_name(&self) -> &'static str {
218        "Database"
219    }
220
221    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
222        use crate::llm::actions::StartupExamples;
223
224        // Deterministic SQS (AWS JSON protocol) replies routed on the operation.
225        // The body is a JSON string in the SQS response shape. No LLM call.
226        let script = r#"import json, sys
227data = json.load(sys.stdin)
228event = data["event"]
229if data["event_type_id"] == "sqs_request":
230    op = event.get("operation", "")
231    if op == "SendMessage":
232        body = json.dumps({"MessageId": "00000000-0000-0000-0000-000000000001",
233                           "MD5OfMessageBody": "d41d8cd98f00b204e9800998ecf8427e"})
234    elif op == "ReceiveMessage":
235        body = json.dumps({"Messages": []})
236    else:
237        body = json.dumps({})
238    actions = [{"type": "send_sqs_response", "status_code": 200, "body": body}]
239else:
240    actions = []
241print(json.dumps({"actions": actions}))"#;
242
243        StartupExamples::new(
244            // LLM mode: keep a real FIFO of messages across requests.
245            json!({
246                "type": "open_server",
247                "port": 9324,
248                "base_stack": "sqs",
249                "instruction": "Act as an SQS queue named 'jobs'. Accept SendMessage requests and remember each message body in order; on ReceiveMessage return the oldest not-yet-received message with a fresh receipt handle, and an empty response once the queue is drained."
250            }),
251            // Script mode: fixed AWS-JSON responses per operation, no LLM call.
252            json!({
253                "type": "open_server",
254                "port": 9324,
255                "base_stack": "sqs",
256                "event_handlers": [{
257                    "event_pattern": "sqs_request",
258                    "handler": {
259                        "type": "script",
260                        "language": "python",
261                        "code": script
262                    }
263                }]
264            }),
265            // Static mode: Fixed responses
266            json!({
267                "type": "open_server",
268                "port": 9324,
269                "base_stack": "sqs",
270                "event_handlers": [{
271                    "event_pattern": "sqs_request",
272                    "handler": {
273                        "type": "static",
274                        "actions": [{
275                            "type": "send_sqs_response",
276                            "status_code": 200,
277                            "body": "{}"
278                        }]
279                    }
280                }]
281            }),
282        )
283    }
284}
285
286// Implement Server trait (server-specific functionality)
287impl Server for SqsProtocol {
288    fn spawn(
289        &self,
290        ctx: crate::protocol::SpawnContext,
291    ) -> std::pin::Pin<
292        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
293    > {
294        Box::pin(async move {
295            use crate::server::sqs::SqsServer;
296            SqsServer::spawn_with_llm_actions(
297                ctx.legacy_listen_addr(),
298                ctx.llm_client,
299                ctx.state,
300                ctx.status_tx,
301                false,
302                ctx.server_id,
303            )
304            .await
305        })
306    }
307    fn execute_action(&self, action: Value) -> Result<ActionResult> {
308        let action_type = action
309            .get("type")
310            .and_then(|v| v.as_str())
311            .ok_or_else(|| anyhow::anyhow!("Missing action type"))?;
312
313        match action_type {
314            "send_sqs_response" => {
315                let status_code = parse_status_code(&action)?;
316
317                let body = action
318                    .get("body")
319                    .and_then(|v| v.as_str())
320                    .ok_or_else(|| anyhow::anyhow!("Missing body"))?
321                    .to_string();
322
323                Ok(ActionResult::Custom {
324                    name: "sqs_response".to_string(),
325                    data: json!({
326                        "status": status_code,
327                        "body": body
328                    }),
329                })
330            }
331            _ => Err(anyhow::anyhow!("Unknown action type: {}", action_type)),
332        }
333    }
334}