Skip to main content

netget/client/s3/
actions.rs

1//! S3 client protocol actions implementation
2
3use crate::llm::actions::{
4    client_trait::{Client, ClientActionResult},
5    ActionDefinition, Parameter, ParameterDefinition,
6};
7use crate::protocol::EventType;
8use crate::state::app_state::AppState;
9use anyhow::{Context, Result};
10use serde_json::json;
11use std::sync::LazyLock;
12
13/// S3 client connected event
14pub static S3_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
15    EventType::new(
16        "s3_connected",
17        "S3 client initialized and ready to access buckets",
18        json!({
19            "type": "put_object",
20            "bucket": "my-bucket",
21            "key": "data/file.txt",
22            "body": "Hello, S3!",
23            "content_type": "text/plain"
24        }),
25    )
26    .with_parameters(vec![
27        Parameter {
28            name: "endpoint".to_string(),
29            type_hint: "string".to_string(),
30            description: "S3 endpoint URL".to_string(),
31            required: true,
32        },
33        Parameter {
34            name: "region".to_string(),
35            type_hint: "string".to_string(),
36            description: "AWS region".to_string(),
37            required: true,
38        },
39    ])
40});
41
42/// S3 client response received event
43pub static S3_CLIENT_RESPONSE_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
44    EventType::new(
45        "s3_response_received",
46        "S3 operation completed with response",
47        json!({
48            "type": "put_object",
49            "bucket": "my-bucket",
50            "key": "result.json",
51            "body": "{\"status\": \"processed\"}"
52        }),
53    )
54    .with_parameters(vec![
55        Parameter {
56            name: "operation".to_string(),
57            type_hint: "string".to_string(),
58            description: "S3 operation performed (put_object, get_object, etc.)".to_string(),
59            required: true,
60        },
61        Parameter {
62            name: "success".to_string(),
63            type_hint: "boolean".to_string(),
64            description: "Whether the operation succeeded".to_string(),
65            required: true,
66        },
67        Parameter {
68            name: "result".to_string(),
69            type_hint: "object".to_string(),
70            description: "Operation result data".to_string(),
71            required: false,
72        },
73        Parameter {
74            name: "error".to_string(),
75            type_hint: "string".to_string(),
76            description: "Error message if operation failed".to_string(),
77            required: false,
78        },
79    ])
80});
81
82/// S3 client protocol action handler
83pub struct S3ClientProtocol;
84
85impl S3ClientProtocol {
86    pub fn new() -> Self {
87        Self
88    }
89}
90
91impl crate::llm::actions::protocol_trait::Protocol for S3ClientProtocol {
92    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
93        vec![
94            ParameterDefinition {
95                name: "access_key_id".to_string(),
96                description: "AWS access key ID for authentication".to_string(),
97                type_hint: "string".to_string(),
98                required: true,
99                example: json!("AKIAIOSFODNN7EXAMPLE"),
100                default: None,
101            },
102            ParameterDefinition {
103                name: "secret_access_key".to_string(),
104                description: "AWS secret access key for authentication".to_string(),
105                type_hint: "string".to_string(),
106                required: true,
107                example: json!("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
108                default: None,
109            },
110            ParameterDefinition {
111                name: "region".to_string(),
112                description: "AWS region (e.g., us-east-1, eu-west-1)".to_string(),
113                type_hint: "string".to_string(),
114                required: false,
115                example: json!("us-east-1"),
116                default: None,
117            },
118            ParameterDefinition {
119                name: "endpoint_url".to_string(),
120                description: "Custom S3 endpoint URL (for S3-compatible services like MinIO)"
121                    .to_string(),
122                type_hint: "string".to_string(),
123                required: false,
124                example: json!("http://localhost:9000"),
125                default: None,
126            },
127        ]
128    }
129
130    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
131        vec![
132            ActionDefinition {
133                name: "put_object".to_string(),
134                description: "Upload an object to an S3 bucket".to_string(),
135                parameters: vec![
136                    Parameter {
137                        name: "bucket".to_string(),
138                        type_hint: "string".to_string(),
139                        description: "Bucket name".to_string(),
140                        required: true,
141                    },
142                    Parameter {
143                        name: "key".to_string(),
144                        type_hint: "string".to_string(),
145                        description: "Object key (path/filename)".to_string(),
146                        required: true,
147                    },
148                    Parameter {
149                        name: "body".to_string(),
150                        type_hint: "string".to_string(),
151                        description: "Object content. Plain text by default; set \
152                            encoding=\"base64\" to upload binary and put the base64 here."
153                            .to_string(),
154                        required: true,
155                    },
156                    Parameter {
157                        name: "encoding".to_string(),
158                        type_hint: "string".to_string(),
159                        description: "How to read `body`: \"utf8\" (default) stores it as \
160                            written, \"base64\" decodes it to bytes first. Required for binary \
161                            - without it the base64 text itself is stored."
162                            .to_string(),
163                        required: false,
164                    },
165                    Parameter {
166                        name: "content_type".to_string(),
167                        type_hint: "string".to_string(),
168                        description: "Content type (e.g., text/plain, application/json)"
169                            .to_string(),
170                        required: false,
171                    },
172                ],
173                example: json!({
174                    "type": "put_object",
175                    "bucket": "my-bucket",
176                    "key": "data/file.txt",
177                    "body": "Hello, S3!",
178                    "encoding": "utf8",
179                    "content_type": "text/plain"
180                }),
181                log_template: None,
182            },
183            ActionDefinition {
184                name: "get_object".to_string(),
185                description: "Download an object from an S3 bucket".to_string(),
186                parameters: vec![
187                    Parameter {
188                        name: "bucket".to_string(),
189                        type_hint: "string".to_string(),
190                        description: "Bucket name".to_string(),
191                        required: true,
192                    },
193                    Parameter {
194                        name: "key".to_string(),
195                        type_hint: "string".to_string(),
196                        description: "Object key (path/filename)".to_string(),
197                        required: true,
198                    },
199                ],
200                example: json!({
201                    "type": "get_object",
202                    "bucket": "my-bucket",
203                    "key": "data/file.txt"
204                }),
205                log_template: None,
206            },
207            ActionDefinition {
208                name: "list_buckets".to_string(),
209                description: "List all S3 buckets".to_string(),
210                parameters: vec![],
211                example: json!({
212                    "type": "list_buckets"
213                }),
214                log_template: None,
215            },
216            ActionDefinition {
217                name: "list_objects".to_string(),
218                description: "List objects in an S3 bucket".to_string(),
219                parameters: vec![
220                    Parameter {
221                        name: "bucket".to_string(),
222                        type_hint: "string".to_string(),
223                        description: "Bucket name".to_string(),
224                        required: true,
225                    },
226                    Parameter {
227                        name: "prefix".to_string(),
228                        type_hint: "string".to_string(),
229                        description: "Filter objects by prefix (folder path)".to_string(),
230                        required: false,
231                    },
232                    Parameter {
233                        name: "max_keys".to_string(),
234                        type_hint: "number".to_string(),
235                        description: "Maximum number of keys to return".to_string(),
236                        required: false,
237                    },
238                ],
239                example: json!({
240                    "type": "list_objects",
241                    "bucket": "my-bucket",
242                    "prefix": "data/",
243                    "max_keys": 100
244                }),
245                log_template: None,
246            },
247            ActionDefinition {
248                name: "delete_object".to_string(),
249                description: "Delete an object from an S3 bucket".to_string(),
250                parameters: vec![
251                    Parameter {
252                        name: "bucket".to_string(),
253                        type_hint: "string".to_string(),
254                        description: "Bucket name".to_string(),
255                        required: true,
256                    },
257                    Parameter {
258                        name: "key".to_string(),
259                        type_hint: "string".to_string(),
260                        description: "Object key (path/filename)".to_string(),
261                        required: true,
262                    },
263                ],
264                example: json!({
265                    "type": "delete_object",
266                    "bucket": "my-bucket",
267                    "key": "data/file.txt"
268                }),
269                log_template: None,
270            },
271            ActionDefinition {
272                name: "head_object".to_string(),
273                description: "Get metadata for an object without downloading it".to_string(),
274                parameters: vec![
275                    Parameter {
276                        name: "bucket".to_string(),
277                        type_hint: "string".to_string(),
278                        description: "Bucket name".to_string(),
279                        required: true,
280                    },
281                    Parameter {
282                        name: "key".to_string(),
283                        type_hint: "string".to_string(),
284                        description: "Object key (path/filename)".to_string(),
285                        required: true,
286                    },
287                ],
288                example: json!({
289                    "type": "head_object",
290                    "bucket": "my-bucket",
291                    "key": "data/file.txt"
292                }),
293                log_template: None,
294            },
295            ActionDefinition {
296                name: "create_bucket".to_string(),
297                description: "Create a new S3 bucket".to_string(),
298                parameters: vec![Parameter {
299                    name: "bucket".to_string(),
300                    type_hint: "string".to_string(),
301                    description: "Bucket name".to_string(),
302                    required: true,
303                }],
304                example: json!({
305                    "type": "create_bucket",
306                    "bucket": "my-new-bucket"
307                }),
308                log_template: None,
309            },
310            ActionDefinition {
311                name: "delete_bucket".to_string(),
312                description: "Delete an S3 bucket (must be empty)".to_string(),
313                parameters: vec![Parameter {
314                    name: "bucket".to_string(),
315                    type_hint: "string".to_string(),
316                    description: "Bucket name".to_string(),
317                    required: true,
318                }],
319                example: json!({
320                    "type": "delete_bucket",
321                    "bucket": "my-old-bucket"
322                }),
323                log_template: None,
324            },
325            ActionDefinition {
326                name: "disconnect".to_string(),
327                description: "Disconnect from the S3 service".to_string(),
328                parameters: vec![],
329                example: json!({
330                    "type": "disconnect"
331                }),
332                log_template: None,
333            },
334        ]
335    }
336
337    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
338        // Same actions available in sync context (after receiving responses)
339        vec![
340            ActionDefinition {
341                name: "put_object".to_string(),
342                description: "Upload another object in response to previous operation".to_string(),
343                parameters: vec![
344                    Parameter {
345                        name: "bucket".to_string(),
346                        type_hint: "string".to_string(),
347                        description: "Bucket name".to_string(),
348                        required: true,
349                    },
350                    Parameter {
351                        name: "key".to_string(),
352                        type_hint: "string".to_string(),
353                        description: "Object key".to_string(),
354                        required: true,
355                    },
356                    Parameter {
357                        name: "body".to_string(),
358                        type_hint: "string".to_string(),
359                        description: "Object content".to_string(),
360                        required: true,
361                    },
362                ],
363                example: json!({
364                    "type": "put_object",
365                    "bucket": "my-bucket",
366                    "key": "result.json",
367                    "body": "{\"status\": \"processed\"}"
368                }),
369                log_template: None,
370            },
371            ActionDefinition {
372                name: "get_object".to_string(),
373                description: "Download another object in response to previous operation"
374                    .to_string(),
375                parameters: vec![
376                    Parameter {
377                        name: "bucket".to_string(),
378                        type_hint: "string".to_string(),
379                        description: "Bucket name".to_string(),
380                        required: true,
381                    },
382                    Parameter {
383                        name: "key".to_string(),
384                        type_hint: "string".to_string(),
385                        description: "Object key".to_string(),
386                        required: true,
387                    },
388                ],
389                example: json!({
390                    "type": "get_object",
391                    "bucket": "my-bucket",
392                    "key": "next-file.txt"
393                }),
394                log_template: None,
395            },
396        ]
397    }
398
399    fn protocol_name(&self) -> &'static str {
400        "S3"
401    }
402
403    fn get_event_types(&self) -> Vec<EventType> {
404        vec![
405            EventType::new(
406                "s3_connected",
407                "Triggered when S3 client is initialized",
408                json!({"type": "placeholder", "event_id": "s3_connected"}),
409            ),
410            EventType::new(
411                "s3_response_received",
412                "Triggered when S3 client receives a response",
413                json!({"type": "placeholder", "event_id": "s3_response_received"}),
414            ),
415        ]
416    }
417
418    fn stack_name(&self) -> &'static str {
419        "ETH>IP>TCP>HTTP>S3"
420    }
421
422    fn keywords(&self) -> Vec<&'static str> {
423        vec!["s3", "s3 client", "aws s3", "object storage", "minio"]
424    }
425
426    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
427        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
428
429        ProtocolMetadataV2::builder()
430            .state(DevelopmentState::Experimental)
431            .implementation("AWS SDK for Rust (aws-sdk-s3)")
432            .llm_control("Full control over S3 operations (put, get, list, delete)")
433            .e2e_testing("LocalStack, MinIO, or real AWS S3")
434            .build()
435    }
436
437    fn description(&self) -> &'static str {
438        "S3 client for object storage operations (AWS S3 and S3-compatible services)"
439    }
440
441    fn example_prompt(&self) -> &'static str {
442        "Connect to S3 at s3.amazonaws.com and list all buckets"
443    }
444
445    fn group_name(&self) -> &'static str {
446        "Cloud"
447    }
448    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
449        use crate::llm::actions::StartupExamples;
450        use serde_json::json;
451
452        StartupExamples::new(
453            // LLM mode: LLM controls S3 client interactions
454            json!({
455                "type": "open_client",
456                "remote_addr": "s3.amazonaws.com:443",
457                "base_stack": "s3",
458                "instruction": "List all S3 buckets and report their contents"
459            }),
460            // Script mode: Code-based deterministic responses
461            json!({
462                "type": "open_client",
463                "remote_addr": "s3.amazonaws.com:443",
464                "base_stack": "s3",
465                "event_handlers": [{
466                    "event_pattern": "s3_response_received",
467                    "handler": {
468                        "type": "script",
469                        "language": "python",
470                        "code": "<s3_client_handler>"
471                    }
472                }]
473            }),
474            // Static mode: Fixed responses
475            json!({
476                "type": "open_client",
477                "remote_addr": "s3.amazonaws.com:443",
478                "base_stack": "s3",
479                "event_handlers": [
480                    {
481                        "event_pattern": "s3_connected",
482                        "handler": {
483                            "type": "static",
484                            "actions": [{
485                                "type": "list_buckets"
486                            }]
487                        }
488                    },
489                    {
490                        "event_pattern": "s3_response_received",
491                        "handler": {
492                            "type": "static",
493                            "actions": [{
494                                "type": "disconnect"
495                            }]
496                        }
497                    }
498                ]
499            }),
500        )
501    }
502}
503
504impl Client for S3ClientProtocol {
505    fn connect(
506        &self,
507        ctx: crate::protocol::ConnectContext,
508    ) -> std::pin::Pin<
509        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
510    > {
511        Box::pin(async move {
512            use crate::client::s3::S3Client;
513            S3Client::connect_with_llm_actions(
514                ctx.remote_addr,
515                ctx.llm_client,
516                ctx.state,
517                ctx.status_tx,
518                ctx.client_id,
519                ctx.startup_params,
520            )
521            .await
522        })
523    }
524
525    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
526        let action_type = action
527            .get("type")
528            .and_then(|v| v.as_str())
529            .context("Missing 'type' field in action")?;
530
531        match action_type {
532            "put_object" => {
533                let bucket = action
534                    .get("bucket")
535                    .and_then(|v| v.as_str())
536                    .context("Missing 'bucket' field")?
537                    .to_string();
538
539                let key = action
540                    .get("key")
541                    .and_then(|v| v.as_str())
542                    .context("Missing 'key' field")?
543                    .to_string();
544
545                let body = action
546                    .get("body")
547                    .and_then(|v| v.as_str())
548                    .context("Missing 'body' field")?
549                    .to_string();
550
551                let content_type = action
552                    .get("content_type")
553                    .and_then(|v| v.as_str())
554                    .map(|s| s.to_string());
555
556                // `encoding` has to travel with the body. `put_object` reads it and decodes
557                // properly, but this arm never copied it across, so `data["encoding"]` was
558                // always absent, the "utf8" default always won, and a model following the
559                // documented `encoding: "base64"` stored the literal base64 ASCII in the
560                // object — the exact `send_tcp_data` defect, recreated one layer up, in the
561                // executor rather than the operation. Validated here so an unusable value is
562                // an action failure the model is told about rather than a surprise later.
563                let encoding = action
564                    .get("encoding")
565                    .and_then(|v| v.as_str())
566                    .unwrap_or("utf8");
567                if !matches!(encoding, "utf8" | "base64") {
568                    anyhow::bail!(
569                        "Unknown encoding {encoding:?} for put_object; use \"utf8\" (the \
570                         default, storing the characters of `body` as written) or \"base64\" \
571                         (decoding `body` into the object's bytes)."
572                    );
573                }
574
575                Ok(ClientActionResult::Custom {
576                    name: "s3_put_object".to_string(),
577                    data: json!({
578                        "bucket": bucket,
579                        "key": key,
580                        "body": body,
581                        "encoding": encoding,
582                        "content_type": content_type,
583                    }),
584                })
585            }
586            "get_object" => {
587                let bucket = action
588                    .get("bucket")
589                    .and_then(|v| v.as_str())
590                    .context("Missing 'bucket' field")?
591                    .to_string();
592
593                let key = action
594                    .get("key")
595                    .and_then(|v| v.as_str())
596                    .context("Missing 'key' field")?
597                    .to_string();
598
599                Ok(ClientActionResult::Custom {
600                    name: "s3_get_object".to_string(),
601                    data: json!({
602                        "bucket": bucket,
603                        "key": key,
604                    }),
605                })
606            }
607            "list_buckets" => Ok(ClientActionResult::Custom {
608                name: "s3_list_buckets".to_string(),
609                data: json!({}),
610            }),
611            "list_objects" => {
612                let bucket = action
613                    .get("bucket")
614                    .and_then(|v| v.as_str())
615                    .context("Missing 'bucket' field")?
616                    .to_string();
617
618                let prefix = action
619                    .get("prefix")
620                    .and_then(|v| v.as_str())
621                    .map(|s| s.to_string());
622
623                let max_keys = action
624                    .get("max_keys")
625                    .and_then(|v| v.as_i64())
626                    .map(|n| n as i32);
627
628                Ok(ClientActionResult::Custom {
629                    name: "s3_list_objects".to_string(),
630                    data: json!({
631                        "bucket": bucket,
632                        "prefix": prefix,
633                        "max_keys": max_keys,
634                    }),
635                })
636            }
637            "delete_object" => {
638                let bucket = action
639                    .get("bucket")
640                    .and_then(|v| v.as_str())
641                    .context("Missing 'bucket' field")?
642                    .to_string();
643
644                let key = action
645                    .get("key")
646                    .and_then(|v| v.as_str())
647                    .context("Missing 'key' field")?
648                    .to_string();
649
650                Ok(ClientActionResult::Custom {
651                    name: "s3_delete_object".to_string(),
652                    data: json!({
653                        "bucket": bucket,
654                        "key": key,
655                    }),
656                })
657            }
658            "head_object" => {
659                let bucket = action
660                    .get("bucket")
661                    .and_then(|v| v.as_str())
662                    .context("Missing 'bucket' field")?
663                    .to_string();
664
665                let key = action
666                    .get("key")
667                    .and_then(|v| v.as_str())
668                    .context("Missing 'key' field")?
669                    .to_string();
670
671                Ok(ClientActionResult::Custom {
672                    name: "s3_head_object".to_string(),
673                    data: json!({
674                        "bucket": bucket,
675                        "key": key,
676                    }),
677                })
678            }
679            "create_bucket" => {
680                let bucket = action
681                    .get("bucket")
682                    .and_then(|v| v.as_str())
683                    .context("Missing 'bucket' field")?
684                    .to_string();
685
686                Ok(ClientActionResult::Custom {
687                    name: "s3_create_bucket".to_string(),
688                    data: json!({
689                        "bucket": bucket,
690                    }),
691                })
692            }
693            "delete_bucket" => {
694                let bucket = action
695                    .get("bucket")
696                    .and_then(|v| v.as_str())
697                    .context("Missing 'bucket' field")?
698                    .to_string();
699
700                Ok(ClientActionResult::Custom {
701                    name: "s3_delete_bucket".to_string(),
702                    data: json!({
703                        "bucket": bucket,
704                    }),
705                })
706            }
707            "disconnect" => Ok(ClientActionResult::Disconnect),
708            _ => Err(anyhow::anyhow!("Unknown S3 client action: {}", action_type)),
709        }
710    }
711}