Skip to main content

netget/client/sqs/
mod.rs

1//! SQS client implementation
2pub mod actions;
3
4pub use actions::SqsClientProtocol;
5
6use anyhow::{Context, Result};
7use aws_config::BehaviorVersion;
8use aws_sdk_sqs::types::{MessageAttributeValue, QueueAttributeName};
9use std::net::SocketAddr;
10use std::sync::Arc;
11use tokio::sync::mpsc;
12use tracing::{debug, error, info, warn};
13
14use crate::client::llm_budget::call_llm_for_client;
15use crate::client::sqs::actions::{
16    SQS_CLIENT_CONNECTED_EVENT, SQS_MESSAGE_RECEIVED_EVENT, SQS_MESSAGE_SENT_EVENT,
17};
18use crate::llm::actions::client_trait::{Client, ClientActionResult};
19use crate::llm::actions::protocol_trait::Protocol;
20use crate::llm::ollama_client::OllamaClient;
21use crate::llm::ClientLlmResult;
22use crate::protocol::Event;
23use crate::state::app_state::AppState;
24use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
25use crate::state::{AccessLogOwner, ClientId, ClientStatus};
26use crate::utils::truncate::truncate_for_log;
27
28/// Turn the address the operator actually named into an endpoint the AWS SDK will use.
29///
30/// Without this, an `SqsClient` whose `remote_addr` is set and whose `endpoint_url` startup
31/// parameter is not lets `aws_sdk_sqs` resolve its own default — `https://sqs.<region>.amazonaws.com`
32/// — and sign the request with whatever ambient credentials the machine has. A client the
33/// operator pointed at localhost then issued **real queue operations against real AWS**.
34///
35/// That is not hypothetical: it is exactly the DynamoDB defect this repository already fixed,
36/// in the next AWS client along. `tests/vendor_default_fallback_test.rs` is the ratchet that
37/// found it, and exists so there is not a third.
38fn endpoint_url_from_remote_addr(remote_addr: &str) -> Option<String> {
39    let trimmed = remote_addr.trim();
40    if trimmed.is_empty() {
41        return None;
42    }
43    if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
44        Some(trimmed.to_string())
45    } else {
46        Some(format!("http://{trimmed}"))
47    }
48}
49
50/// SQS client that connects to an AWS SQS queue
51pub struct SqsClient;
52
53/// How many times an answer may provoke another answer.
54///
55/// `execute_actions -> apply_action -> send_message/receive_messages ->
56/// spawn_event_notification -> execute_actions` is a genuine cycle: the model is told a
57/// message was sent, and may answer by sending another. Boxing the recursive call made it
58/// compile and nothing bounded it, so a model that answers `sqs_message_sent` with another
59/// `send_message` loops forever, one AWS call and one LLM call per turn. The root
60/// `CLAUDE.md` prescribes the fix: a depth bound, not silence — the chain is allowed to be
61/// useful and then stops.
62const MAX_FOLLOWUP_DEPTH: u8 = 4;
63
64impl SqsClient {
65    /// Connect to an SQS queue with integrated LLM actions
66    pub async fn connect_with_llm_actions(
67        remote_addr: String,
68        llm_client: OllamaClient,
69        app_state: Arc<AppState>,
70        status_tx: mpsc::UnboundedSender<String>,
71        client_id: ClientId,
72        startup_params: Option<crate::protocol::StartupParams>,
73    ) -> Result<SocketAddr> {
74        // Extract startup parameters
75        let queue_url = startup_params
76            .as_ref()
77            .map(|p| p.get_string("queue_url"))
78            .transpose()?
79            .context("Missing required 'queue_url' parameter")?;
80
81        let region = startup_params
82            .as_ref()
83            .map(|p| p.get_optional_string("region"))
84            .transpose()?
85            .flatten();
86        // The SDK builds `https://sqs.<region>.amazonaws.com` from this; see `aws_support`.
87        if let Some(region) = &region {
88            crate::client::aws_support::validate_region("SQS", region)?;
89        }
90
91        let endpoint_url = startup_params
92            .as_ref()
93            .map(|p| p.get_optional_string("endpoint_url"))
94            .transpose()?
95            .flatten();
96
97        // An explicit `endpoint_url` wins; otherwise the address the operator named decides.
98        // The parameter used to be `_remote_addr` and was read nowhere, so neither did.
99        let endpoint_url = endpoint_url.or_else(|| endpoint_url_from_remote_addr(&remote_addr));
100
101        let access_key_id = startup_params
102            .as_ref()
103            .map(|p| p.get_optional_string("access_key_id"))
104            .transpose()?
105            .flatten();
106
107        let secret_access_key = startup_params
108            .as_ref()
109            .map(|p| p.get_optional_string("secret_access_key"))
110            .transpose()?
111            .flatten();
112
113        // A custom endpoint and no credentials would sign with the operator's ambient AWS
114        // identity against a host the model may have chosen; see `aws_support`.
115        crate::client::aws_support::refuse_ambient_credentials(
116            "SQS",
117            endpoint_url.as_deref(),
118            access_key_id.is_some() && secret_access_key.is_some(),
119        )?;
120
121        info!(
122            "SQS client {} connecting to queue: {}",
123            client_id, queue_url
124        );
125
126        // Configure AWS SDK
127        let mut config_loader = aws_config::defaults(BehaviorVersion::latest());
128
129        if let Some(reg) = &region {
130            config_loader = config_loader.region(aws_config::Region::new(reg.clone()));
131        }
132
133        // Explicit credentials when the caller supplied them. Without this the SDK's default
134        // chain runs — environment, `~/.aws/credentials`, then IMDS — so a client the model
135        // created signed with the operator's real AWS identity, with no way to scope it, and
136        // on a machine with no credentials the connect additionally paid for an IMDS probe
137        // that cannot succeed off EC2.
138        let config = config_loader.load().await;
139
140        // Build SQS client
141        let mut sqs_builder = aws_sdk_sqs::config::Builder::from(&config);
142
143        if let Some(endpoint) = &endpoint_url {
144            sqs_builder = sqs_builder.endpoint_url(endpoint);
145        }
146
147        // Explicit credentials when the caller supplied them, overriding whatever the
148        // default chain loaded. Without this a client the model created signed with the
149        // operator's real AWS identity - environment, ~/.aws/credentials or an instance
150        // role - and nothing in the protocol let the caller scope it.
151        if let (Some(key_id), Some(secret)) = (&access_key_id, &secret_access_key) {
152            sqs_builder = sqs_builder.credentials_provider(aws_sdk_sqs::config::Credentials::new(
153                key_id.clone(),
154                secret.clone(),
155                None, // session token
156                None, // expiry
157                "netget_sqs_client",
158            ));
159        }
160
161        let sqs_config = sqs_builder.build();
162        let sqs = aws_sdk_sqs::Client::from_conf(sqs_config);
163
164        // Update client status to connected
165        app_state
166            .update_client_status(client_id, ClientStatus::Connected)
167            .await;
168        let _ = status_tx.send(format!(
169            "[CLIENT] SQS client {} connected to {}",
170            client_id, queue_url
171        ));
172        let _ = status_tx.send("__UPDATE_UI__".to_string());
173
174        info!("SQS client {} connected", client_id);
175
176        let protocol = Arc::new(SqsClientProtocol::new());
177
178        // Command channel for injected actions (the dashboard's [ send ] row).
179        // Registered BEFORE the connected-event LLM call below: a dashboard-created
180        // client defaults to a `*` -> manual routing rule, which parks that call until
181        // a human answers, and [ send ] has to work for the whole park. It is also the
182        // only long-lived task this client has ever had - `connect` used to return with
183        // nothing running, so nothing could act on the queue afterwards.
184        let command_rx =
185            crate::client::command_support::register_command_channel(&app_state, client_id).await;
186        let cmd_task = tokio::spawn(Self::command_loop(
187            command_rx,
188            sqs.clone(),
189            queue_url.clone(),
190            protocol.clone(),
191            client_id,
192            app_state.clone(),
193            llm_client.clone(),
194            status_tx.clone(),
195        ));
196        app_state.register_client_task(client_id, cmd_task).await;
197
198        // Ask the model about the connection from a registered task, not inline.
199        //
200        // A dashboard-created client defaults to a `*` -> manual routing rule, which parks
201        // this call until a human answers or `timeout_secs` (default 300) expires. Awaiting
202        // it here meant `ClientForm::create` blocked for up to five minutes on a client the
203        // operator had just asked for. The command channel is already registered above, so
204        // `[ send ]` works throughout the park — that was anticipated; moving the park off
205        // the creation path was not.
206        let conn_sqs = sqs.clone();
207        let conn_queue = queue_url.clone();
208        let conn_protocol = protocol.clone();
209        let conn_llm = llm_client.clone();
210        let conn_state = app_state.clone();
211        let conn_status = status_tx.clone();
212        let conn_task = tokio::spawn(async move {
213            let event = Event::new(
214                &SQS_CLIENT_CONNECTED_EVENT,
215                serde_json::json!({
216                    "queue_url": conn_queue.clone(),
217                }),
218            );
219
220            let Some(instruction) = conn_state.get_instruction_for_client(client_id).await else {
221                return;
222            };
223            let memory = conn_state
224                .get_memory_for_client(client_id)
225                .await
226                .unwrap_or_default();
227
228            match call_llm_for_client(
229                &conn_llm,
230                &conn_state,
231                client_id.to_string(),
232                &instruction,
233                &memory,
234                Some(&event),
235                conn_protocol.as_ref(),
236                &conn_status,
237            )
238            .await
239            {
240                Ok(ClientLlmResult {
241                    actions,
242                    memory_updates,
243                }) => {
244                    if let Some(mem) = memory_updates {
245                        conn_state.set_memory_for_client(client_id, mem).await;
246                    }
247
248                    if let Err(e) = Self::execute_actions(
249                        actions,
250                        &conn_sqs,
251                        &conn_queue,
252                        conn_protocol.clone(),
253                        &conn_llm,
254                        &conn_state,
255                        &conn_status,
256                        client_id,
257                        0,
258                    )
259                    .await
260                    {
261                        error!(
262                            "SQS client {} connect-time actions failed: {}",
263                            client_id, e
264                        );
265                    }
266                }
267                Err(e) => {
268                    error!("LLM error for SQS client {}: {}", client_id, e);
269                }
270            }
271        });
272        app_state.register_client_task(client_id, conn_task).await;
273
274        // Return a dummy local address (SQS is HTTP-based, no real socket)
275        // Use localhost with client_id as port for uniqueness
276        // A placeholder: SQS is HTTP and this client owns no socket. Wrapped into the
277        // ephemeral range rather than added, because `10000 + client_id` leaves u16 once
278        // the id passes 55535 and the parse then failed *after* the command loop was
279        // already registered — leaving a running task behind a client marked Error.
280        let dummy_port = 10_000u16.wrapping_add((client_id.as_u32() % 50_000) as u16);
281        let dummy_addr: SocketAddr = format!("127.0.0.1:{dummy_port}")
282            .parse()
283            .context("Failed to create dummy socket address")?;
284
285        Ok(dummy_addr)
286    }
287
288    /// Execute SQS actions from LLM
289    #[allow(clippy::too_many_arguments)]
290    fn execute_actions<'a>(
291        actions: Vec<serde_json::Value>,
292        sqs: &'a aws_sdk_sqs::Client,
293        queue_url: &'a str,
294        protocol: Arc<SqsClientProtocol>,
295        llm_client: &'a OllamaClient,
296        app_state: &'a Arc<AppState>,
297        status_tx: &'a mpsc::UnboundedSender<String>,
298        client_id: ClientId,
299        depth: u8,
300    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
301        Box::pin(async move {
302            for action in actions {
303                let result = match protocol.execute_action(action) {
304                    Ok(result) => result,
305                    Err(e) => {
306                        error!("SQS client {} rejected action: {}", client_id, e);
307                        continue;
308                    }
309                };
310                let outcome = Self::apply_action(
311                    result,
312                    &sqs,
313                    queue_url,
314                    protocol.clone(),
315                    llm_client,
316                    app_state,
317                    status_tx,
318                    client_id,
319                    depth,
320                )
321                .await;
322                if matches!(outcome, ClientSendOutcome::Disconnected) {
323                    info!("SQS client {} disconnecting", client_id);
324                    app_state
325                        .update_client_status(client_id, ClientStatus::Disconnected)
326                        .await;
327                    app_state.remove_client_handle(client_id).await;
328                    let _ =
329                        status_tx.send(format!("[CLIENT] SQS client {} disconnected", client_id));
330                    let _ = status_tx.send("__UPDATE_UI__".to_string());
331                    break;
332                }
333                debug!("SQS client {} action outcome: {:?}", client_id, outcome);
334            }
335            Ok(())
336        })
337    }
338
339    /// Apply one executed action against the queue. Shared by the connected-event
340    /// LLM path, by response follow-ups, and by injected commands, so the three
341    /// cannot diverge.
342    ///
343    /// The AWS SDK owns the socket and reports no wire byte count, so a completed
344    /// operation is `Executed { detail }` naming the operation and its result -
345    /// never `Sent`, which would be a fabricated byte count.
346    #[allow(clippy::too_many_arguments)]
347    fn apply_action<'a>(
348        result: ClientActionResult,
349        sqs: &'a aws_sdk_sqs::Client,
350        queue_url: &'a str,
351        protocol: Arc<SqsClientProtocol>,
352        llm_client: &'a OllamaClient,
353        app_state: &'a Arc<AppState>,
354        status_tx: &'a mpsc::UnboundedSender<String>,
355        client_id: ClientId,
356        depth: u8,
357    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ClientSendOutcome> + Send + 'a>> {
358        Box::pin(async move {
359            match result {
360                ClientActionResult::Custom { name, data } => {
361                    let outcome = match name.as_str() {
362                        "send_message" => {
363                            Self::send_message(
364                                sqs,
365                                queue_url,
366                                &data,
367                                protocol.clone(),
368                                llm_client,
369                                app_state,
370                                status_tx,
371                                client_id,
372                                depth,
373                            )
374                            .await
375                        }
376                        "receive_messages" => {
377                            Self::receive_messages(
378                                sqs,
379                                queue_url,
380                                &data,
381                                protocol.clone(),
382                                llm_client,
383                                app_state,
384                                status_tx,
385                                client_id,
386                                depth,
387                            )
388                            .await
389                        }
390                        "delete_message" => {
391                            Self::delete_message(sqs, queue_url, &data, client_id).await
392                        }
393                        "purge_queue" => Self::purge_queue(sqs, queue_url, client_id).await,
394                        "get_queue_attributes" => {
395                            Self::get_queue_attributes(sqs, queue_url, &data, client_id).await
396                        }
397                        other => Err(anyhow::anyhow!("Unknown SQS operation: {}", other)),
398                    };
399                    match outcome {
400                        Ok(value) => ClientSendOutcome::Executed {
401                            detail: format!(
402                                "{} completed: {}",
403                                name,
404                                truncate_for_log(&value.to_string(), 200)
405                            ),
406                        },
407                        Err(e) => {
408                            error!("SQS client {} operation {} failed: {}", client_id, name, e);
409                            let _ = status_tx
410                                .send(format!("[ERROR] SQS operation {} failed: {}", name, e));
411                            // A failed operation is `Rejected`, not `Executed`: both used to
412                            // be `Executed`, so a refusal and a completed send rendered the
413                            // same way in the dashboard.
414                            ClientSendOutcome::Rejected {
415                                error: format!(
416                                    "{} failed: {}",
417                                    name,
418                                    truncate_for_log(&e.to_string(), 200)
419                                ),
420                            }
421                        }
422                    }
423                }
424                ClientActionResult::Disconnect => ClientSendOutcome::Disconnected,
425                ClientActionResult::WaitForMore => ClientSendOutcome::Executed {
426                    detail: "wait_for_more".to_string(),
427                },
428                ClientActionResult::NoAction => ClientSendOutcome::Executed {
429                    detail: "no_action".to_string(),
430                },
431                ClientActionResult::SendData(_) => ClientSendOutcome::Executed {
432                    detail: "send_data has no meaning for the SQS client: it speaks the SQS API \
433                             through the AWS SDK, not a socket this client owns"
434                        .to_string(),
435                },
436                ClientActionResult::Multiple(_) => ClientSendOutcome::Executed {
437                    detail:
438                        "the SQS client's own verbs never produce Multiple; nothing was applied"
439                            .to_string(),
440                },
441            }
442        })
443    }
444
445    /// Drain injected commands until the channel closes (the client was removed) or
446    /// an injected `disconnect` ends the session.
447    ///
448    /// `command_support::handle_stream_client_command` cannot serve this client:
449    /// every SQS verb yields `ClientActionResult::Custom` and there is no write half
450    /// to put bytes on. Actions therefore go through [`Self::apply_action`] - the
451    /// same function the LLM path uses - and the outcome is logged and replied
452    /// exactly the way the generic arm does it.
453    #[allow(clippy::too_many_arguments)]
454    async fn command_loop(
455        mut command_rx: mpsc::Receiver<ClientCommand>,
456        sqs: aws_sdk_sqs::Client,
457        queue_url: String,
458        protocol: Arc<SqsClientProtocol>,
459        client_id: ClientId,
460        app_state: Arc<AppState>,
461        llm_client: OllamaClient,
462        status_tx: mpsc::UnboundedSender<String>,
463    ) {
464        while let Some(command) = command_rx.recv().await {
465            let action = command.action.clone();
466            let outcome = match protocol.execute_action(action.clone()) {
467                Err(e) => ClientSendOutcome::Rejected {
468                    error: e.to_string(),
469                },
470                Ok(result) => {
471                    // Depth 0: an injected action is a fresh chain, whatever provoked
472                    // the operator to send it.
473                    Self::apply_action(
474                        result,
475                        &sqs,
476                        &queue_url,
477                        protocol.clone(),
478                        &llm_client,
479                        &app_state,
480                        &status_tx,
481                        client_id,
482                        0,
483                    )
484                    .await
485                }
486            };
487            let disconnected = matches!(outcome, ClientSendOutcome::Disconnected);
488
489            app_state
490                .record_access_log(
491                    AccessLogOwner::Client(client_id.as_u32()),
492                    protocol.protocol_name(),
493                    None,
494                    "injected_action",
495                    action,
496                    vec![serde_json::to_value(&outcome).unwrap_or(serde_json::Value::Null)],
497                )
498                .await;
499            let _ = status_tx.send("__UPDATE_UI__".to_string());
500            crate::client::command_support::reply(command, Ok(outcome));
501
502            if disconnected {
503                info!("SQS client {} disconnecting on injected action", client_id);
504                app_state
505                    .update_client_status(client_id, ClientStatus::Disconnected)
506                    .await;
507                break;
508            }
509        }
510
511        info!("SQS client {} command loop stopped", client_id);
512        // Never leave the dashboard offering [ send ] into a client that is gone.
513        app_state.remove_client_handle(client_id).await;
514        let _ = status_tx.send("__UPDATE_UI__".to_string());
515    }
516
517    /// Send a message to the SQS queue
518    #[allow(clippy::too_many_arguments)]
519    async fn send_message(
520        sqs: &aws_sdk_sqs::Client,
521        queue_url: &str,
522        data: &serde_json::Value,
523        protocol: Arc<SqsClientProtocol>,
524        llm_client: &OllamaClient,
525        app_state: &Arc<AppState>,
526        status_tx: &mpsc::UnboundedSender<String>,
527        client_id: ClientId,
528        depth: u8,
529    ) -> Result<serde_json::Value> {
530        let message_body = data
531            .get("message_body")
532            .and_then(|v| v.as_str())
533            .context("Missing message_body")?;
534
535        let mut request = sqs
536            .send_message()
537            .queue_url(queue_url)
538            .message_body(message_body);
539
540        // Add delay if specified
541        if let Some(delay) = data.get("delay_seconds").and_then(|v| v.as_i64()) {
542            // Clamped, not cast: `as i32` wraps silently. 0-900 is SQS's documented range.
543            request = request.delay_seconds(delay.clamp(0, 900) as i32);
544        }
545
546        // Add message attributes if specified
547        if let Some(attrs) = data.get("message_attributes").and_then(|v| v.as_object()) {
548            for (key, value) in attrs {
549                if let Some(value_str) = value.as_str() {
550                    let msg_attr = MessageAttributeValue::builder()
551                        .data_type("String")
552                        .string_value(value_str)
553                        .build()?;
554                    request = request.message_attributes(key.clone(), msg_attr);
555                }
556            }
557        }
558
559        let response = request
560            .send()
561            .await
562            .context("Failed to send message to SQS")?;
563
564        let message_id = response.message_id().unwrap_or("unknown").to_string();
565        info!("SQS client {} sent message: {}", client_id, message_id);
566
567        // Raise the sent event (and run whatever the model answers with) from its own
568        // registered task rather than inline. A dashboard-created client defaults to a
569        // `*` -> manual routing rule, so this LLM call can park for minutes waiting for a
570        // human; awaiting it here would wedge the command loop and make an injected action
571        // that in fact succeeded look to the dashboard like a timeout.
572        let event = Event::new(
573            &SQS_MESSAGE_SENT_EVENT,
574            serde_json::json!({
575                "message_id": message_id,
576            }),
577        );
578        Self::spawn_event_notification(
579            event,
580            sqs.clone(),
581            queue_url.to_string(),
582            protocol,
583            llm_client.clone(),
584            app_state.clone(),
585            status_tx.clone(),
586            client_id,
587            depth,
588        )
589        .await;
590
591        Ok(serde_json::json!({ "message_id": message_id }))
592    }
593
594    /// Receive messages from the SQS queue
595    #[allow(clippy::too_many_arguments)]
596    async fn receive_messages(
597        sqs: &aws_sdk_sqs::Client,
598        queue_url: &str,
599        data: &serde_json::Value,
600        protocol: Arc<SqsClientProtocol>,
601        llm_client: &OllamaClient,
602        app_state: &Arc<AppState>,
603        status_tx: &mpsc::UnboundedSender<String>,
604        client_id: ClientId,
605        depth: u8,
606    ) -> Result<serde_json::Value> {
607        let mut request = sqs.receive_message().queue_url(queue_url);
608
609        // Set max messages (default 1, max 10)
610        if let Some(max) = data.get("max_messages").and_then(|v| v.as_i64()) {
611            request = request.max_number_of_messages(max.min(10).max(1) as i32);
612        }
613
614        // Set wait time for long polling (default 0, max 20)
615        if let Some(wait) = data.get("wait_time_seconds").and_then(|v| v.as_i64()) {
616            request = request.wait_time_seconds(wait.min(20).max(0) as i32);
617        }
618
619        // Set visibility timeout
620        if let Some(timeout) = data.get("visibility_timeout").and_then(|v| v.as_i64()) {
621            // Clamped, not cast: `as i32` wraps, so 4294967296 silently became 0 — an
622            // instant-visibility timeout the model never asked for. 0-43200 is SQS's range.
623            request = request.visibility_timeout(timeout.clamp(0, 43_200) as i32);
624        }
625
626        let response = request
627            .send()
628            .await
629            .context("Failed to receive messages from SQS")?;
630
631        let messages = response.messages();
632        let message_count = messages.len();
633        let received_ids: Vec<String> = messages
634            .iter()
635            .map(|m| m.message_id().unwrap_or("").to_string())
636            .collect();
637        info!(
638            "SQS client {} received {} messages",
639            client_id, message_count
640        );
641
642        // The event fires even for an empty poll. It used to be raised only when something
643        // came back, so a "drain the queue" instruction stopped dead at the first empty
644        // response with no way for the model to learn why — short polling returns nothing
645        // most of the time, so that was the normal case, not the edge case.
646        {
647            // Build messages array for LLM
648            let messages_json: Vec<serde_json::Value> = messages
649                .iter()
650                .map(|msg| {
651                    // Convert attributes to JSON-serializable format
652                    let attributes: std::collections::HashMap<String, String> = msg
653                        .attributes()
654                        .map(|attrs| {
655                            attrs
656                                .iter()
657                                .map(|(k, v)| (k.as_str().to_string(), v.clone()))
658                                .collect()
659                        })
660                        .unwrap_or_default();
661
662                    let message_attributes: std::collections::HashMap<String, String> = msg
663                        .message_attributes()
664                        .map(|attrs| {
665                            attrs
666                                .iter()
667                                .map(|(k, v)| {
668                                    (k.clone(), v.string_value().unwrap_or("").to_string())
669                                })
670                                .collect()
671                        })
672                        .unwrap_or_default();
673
674                    serde_json::json!({
675                        "message_id": msg.message_id().unwrap_or(""),
676                        "receipt_handle": msg.receipt_handle().unwrap_or(""),
677                        "body": msg.body().unwrap_or(""),
678                        "attributes": attributes,
679                        "message_attributes": message_attributes,
680                    })
681                })
682                .collect();
683
684            // Call LLM with received messages
685            let event = Event::new(
686                &SQS_MESSAGE_RECEIVED_EVENT,
687                serde_json::json!({
688                    "messages": messages_json,
689                }),
690            );
691
692            // Spawned for the same reason as in `send_message`: a manual routing rule can
693            // park this call, and the command loop must not be held for it.
694            Self::spawn_event_notification(
695                event,
696                sqs.clone(),
697                queue_url.to_string(),
698                protocol,
699                llm_client.clone(),
700                app_state.clone(),
701                status_tx.clone(),
702                client_id,
703                depth,
704            )
705            .await;
706        }
707
708        Ok(serde_json::json!({
709            "count": message_count,
710            "message_ids": received_ids,
711        }))
712    }
713
714    /// Raise one client event off the caller's task, and run whatever the model answers
715    /// with through the normal action path. Never awaited by a caller that holds the
716    /// command loop: an event handler may park this call for a human answer.
717    #[allow(clippy::too_many_arguments)]
718    async fn spawn_event_notification(
719        event: Event,
720        sqs: aws_sdk_sqs::Client,
721        queue_url: String,
722        protocol: Arc<SqsClientProtocol>,
723        llm_client: OllamaClient,
724        app_state: Arc<AppState>,
725        status_tx: mpsc::UnboundedSender<String>,
726        client_id: ClientId,
727        depth: u8,
728    ) {
729        // The cycle stops here rather than in the middle of an operation, so whatever
730        // provoked this event has already completed and only the *next* round-trip is
731        // refused. Nothing bounded it before: a model answering `sqs_message_sent` with
732        // another `send_message` looped forever, one AWS call and one LLM call per turn.
733        if depth >= MAX_FOLLOWUP_DEPTH {
734            warn!(
735                "SQS client {} stopping the follow-up chain at depth {} on {}: the model's \
736                 answers have provoked {} rounds of events",
737                client_id, depth, event.event_type.id, MAX_FOLLOWUP_DEPTH
738            );
739            let _ = status_tx.send(format!(
740                "[CLIENT] SQS client {client_id} follow-up chain capped at \
741                 {MAX_FOLLOWUP_DEPTH} rounds"
742            ));
743            return;
744        }
745
746        let registrar = app_state.clone();
747        let handle = tokio::spawn(async move {
748            // No instruction means the client has no model persona to consult; that is a
749            // configuration, not a failure, so it stays quiet by design.
750            let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
751                debug!(
752                    "SQS client {} has no instruction; not asking the model about {}",
753                    client_id, event.event_type.id
754                );
755                return;
756            };
757            let memory = app_state
758                .get_memory_for_client(client_id)
759                .await
760                .unwrap_or_default();
761
762            // Logged, not swallowed. This was a `let ... else { return }`, so an LLM
763            // outage, a budget refusal or a fail-closed manual timeout on
764            // `sqs_message_received` produced no error!, no status line and nothing in the
765            // log — the model was asked, the call failed, and the client simply went quiet.
766            let (actions, memory_updates) = match call_llm_for_client(
767                &llm_client,
768                &app_state,
769                client_id.to_string(),
770                &instruction,
771                &memory,
772                Some(&event),
773                protocol.as_ref(),
774                &status_tx,
775            )
776            .await
777            {
778                Ok(ClientLlmResult {
779                    actions,
780                    memory_updates,
781                }) => (actions, memory_updates),
782                Err(e) => {
783                    error!(
784                        "SQS client {} LLM error on {}: {}",
785                        client_id, event.event_type.id, e
786                    );
787                    return;
788                }
789            };
790
791            if let Some(mem) = memory_updates {
792                app_state.set_memory_for_client(client_id, mem).await;
793            }
794
795            if let Err(e) = Self::execute_actions(
796                actions,
797                &sqs,
798                &queue_url,
799                protocol,
800                &llm_client,
801                &app_state,
802                &status_tx,
803                client_id,
804                depth + 1,
805            )
806            .await
807            {
808                error!("SQS client {} follow-up actions failed: {}", client_id, e);
809            }
810        });
811        registrar.register_client_task(client_id, handle).await;
812    }
813
814    /// Delete a message from the queue
815    async fn delete_message(
816        sqs: &aws_sdk_sqs::Client,
817        queue_url: &str,
818        data: &serde_json::Value,
819        client_id: ClientId,
820    ) -> Result<serde_json::Value> {
821        let receipt_handle = data
822            .get("receipt_handle")
823            .and_then(|v| v.as_str())
824            .context("Missing receipt_handle")?;
825
826        sqs.delete_message()
827            .queue_url(queue_url)
828            .receipt_handle(receipt_handle)
829            .send()
830            .await
831            .context("Failed to delete message from SQS")?;
832
833        info!("SQS client {} deleted message", client_id);
834        Ok(serde_json::json!({ "deleted": true }))
835    }
836
837    /// Purge all messages from the queue
838    async fn purge_queue(
839        sqs: &aws_sdk_sqs::Client,
840        queue_url: &str,
841        client_id: ClientId,
842    ) -> Result<serde_json::Value> {
843        sqs.purge_queue()
844            .queue_url(queue_url)
845            .send()
846            .await
847            .context("Failed to purge SQS queue")?;
848
849        info!("SQS client {} purged queue", client_id);
850        Ok(serde_json::json!({ "purged": true }))
851    }
852
853    /// Get queue attributes
854    async fn get_queue_attributes(
855        sqs: &aws_sdk_sqs::Client,
856        queue_url: &str,
857        data: &serde_json::Value,
858        client_id: ClientId,
859    ) -> Result<serde_json::Value> {
860        let mut request = sqs.get_queue_attributes().queue_url(queue_url);
861
862        // Add specific attributes if requested
863        if let Some(attr_names) = data.get("attribute_names").and_then(|v| v.as_array()) {
864            for attr in attr_names {
865                if let Some(attr_str) = attr.as_str() {
866                    // Convert string to QueueAttributeName enum
867                    match attr_str {
868                        "ApproximateNumberOfMessages" => {
869                            request = request
870                                .attribute_names(QueueAttributeName::ApproximateNumberOfMessages);
871                        }
872                        "QueueArn" => {
873                            request = request.attribute_names(QueueAttributeName::QueueArn);
874                        }
875                        _ => {
876                            request = request.attribute_names(QueueAttributeName::from(attr_str));
877                        }
878                    }
879                }
880            }
881        } else {
882            // Request all attributes
883            request = request.attribute_names(QueueAttributeName::All);
884        }
885
886        let response = request
887            .send()
888            .await
889            .context("Failed to get queue attributes from SQS")?;
890
891        let attributes: std::collections::HashMap<String, String> = response
892            .attributes()
893            .map(|attrs| {
894                attrs
895                    .iter()
896                    .map(|(k, v)| (k.as_str().to_string(), v.clone()))
897                    .collect()
898            })
899            .unwrap_or_default();
900        info!(
901            "SQS client {} got queue attributes: {:?}",
902            client_id, attributes
903        );
904        Ok(serde_json::json!({ "attributes": attributes }))
905    }
906}