1use 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
13pub 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
42pub 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
82pub 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 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 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 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 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 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}