agentle 0.9.4__py3-none-any.whl → 0.9.28__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. agentle/agents/agent.py +175 -10
  2. agentle/agents/agent_run_output.py +8 -1
  3. agentle/agents/apis/__init__.py +79 -6
  4. agentle/agents/apis/api.py +342 -73
  5. agentle/agents/apis/api_key_authentication.py +43 -0
  6. agentle/agents/apis/api_key_location.py +11 -0
  7. agentle/agents/apis/api_metrics.py +16 -0
  8. agentle/agents/apis/auth_type.py +17 -0
  9. agentle/agents/apis/authentication.py +32 -0
  10. agentle/agents/apis/authentication_base.py +42 -0
  11. agentle/agents/apis/authentication_config.py +117 -0
  12. agentle/agents/apis/basic_authentication.py +34 -0
  13. agentle/agents/apis/bearer_authentication.py +52 -0
  14. agentle/agents/apis/cache_strategy.py +12 -0
  15. agentle/agents/apis/circuit_breaker.py +69 -0
  16. agentle/agents/apis/circuit_breaker_error.py +7 -0
  17. agentle/agents/apis/circuit_breaker_state.py +11 -0
  18. agentle/agents/apis/endpoint.py +413 -254
  19. agentle/agents/apis/file_upload.py +23 -0
  20. agentle/agents/apis/hmac_authentication.py +56 -0
  21. agentle/agents/apis/no_authentication.py +27 -0
  22. agentle/agents/apis/oauth2_authentication.py +111 -0
  23. agentle/agents/apis/oauth2_grant_type.py +12 -0
  24. agentle/agents/apis/object_schema.py +86 -1
  25. agentle/agents/apis/params/__init__.py +10 -1
  26. agentle/agents/apis/params/boolean_param.py +44 -0
  27. agentle/agents/apis/params/number_param.py +56 -0
  28. agentle/agents/apis/rate_limit_error.py +7 -0
  29. agentle/agents/apis/rate_limiter.py +57 -0
  30. agentle/agents/apis/request_config.py +126 -4
  31. agentle/agents/apis/request_hook.py +16 -0
  32. agentle/agents/apis/response_cache.py +49 -0
  33. agentle/agents/apis/retry_strategy.py +12 -0
  34. agentle/agents/whatsapp/human_delay_calculator.py +462 -0
  35. agentle/agents/whatsapp/models/audio_message.py +6 -4
  36. agentle/agents/whatsapp/models/key.py +2 -2
  37. agentle/agents/whatsapp/models/whatsapp_bot_config.py +375 -21
  38. agentle/agents/whatsapp/models/whatsapp_response_base.py +31 -0
  39. agentle/agents/whatsapp/models/whatsapp_webhook_payload.py +5 -1
  40. agentle/agents/whatsapp/providers/base/whatsapp_provider.py +51 -0
  41. agentle/agents/whatsapp/providers/evolution/evolution_api_provider.py +237 -10
  42. agentle/agents/whatsapp/providers/meta/meta_whatsapp_provider.py +126 -0
  43. agentle/agents/whatsapp/v2/batch_processor_manager.py +4 -0
  44. agentle/agents/whatsapp/v2/bot_config.py +188 -0
  45. agentle/agents/whatsapp/v2/message_limit.py +9 -0
  46. agentle/agents/whatsapp/v2/payload.py +0 -0
  47. agentle/agents/whatsapp/v2/whatsapp_bot.py +13 -0
  48. agentle/agents/whatsapp/v2/whatsapp_cloud_api_provider.py +0 -0
  49. agentle/agents/whatsapp/v2/whatsapp_provider.py +0 -0
  50. agentle/agents/whatsapp/whatsapp_bot.py +827 -45
  51. agentle/generations/providers/google/adapters/generate_generate_content_response_to_generation_adapter.py +13 -10
  52. agentle/generations/providers/google/google_generation_provider.py +35 -5
  53. agentle/generations/providers/openrouter/_adapters/openrouter_message_to_generated_assistant_message_adapter.py +35 -1
  54. agentle/mcp/servers/stdio_mcp_server.py +23 -4
  55. agentle/parsing/parsers/docx.py +8 -0
  56. agentle/parsing/parsers/file_parser.py +4 -0
  57. agentle/parsing/parsers/pdf.py +7 -1
  58. agentle/storage/__init__.py +11 -0
  59. agentle/storage/file_storage_manager.py +44 -0
  60. agentle/storage/local_file_storage_manager.py +122 -0
  61. agentle/storage/s3_file_storage_manager.py +124 -0
  62. agentle/tts/audio_format.py +6 -0
  63. agentle/tts/elevenlabs_tts_provider.py +108 -0
  64. agentle/tts/output_format_type.py +26 -0
  65. agentle/tts/speech_config.py +14 -0
  66. agentle/tts/speech_result.py +15 -0
  67. agentle/tts/tts_provider.py +16 -0
  68. agentle/tts/voice_settings.py +30 -0
  69. agentle/utils/parse_streaming_json.py +39 -13
  70. agentle/voice_cloning/__init__.py +0 -0
  71. agentle/voice_cloning/voice_cloner.py +0 -0
  72. agentle/web/extractor.py +282 -148
  73. {agentle-0.9.4.dist-info → agentle-0.9.28.dist-info}/METADATA +1 -1
  74. {agentle-0.9.4.dist-info → agentle-0.9.28.dist-info}/RECORD +78 -39
  75. agentle/tts/real_time/definitions/audio_data.py +0 -20
  76. agentle/tts/real_time/definitions/speech_config.py +0 -27
  77. agentle/tts/real_time/definitions/speech_result.py +0 -14
  78. agentle/tts/real_time/definitions/tts_stream_chunk.py +0 -15
  79. agentle/tts/real_time/definitions/voice_gender.py +0 -9
  80. agentle/tts/real_time/definitions/voice_info.py +0 -18
  81. agentle/tts/real_time/real_time_speech_to_text_provider.py +0 -66
  82. /agentle/{tts/real_time → agents/whatsapp/v2}/__init__.py +0 -0
  83. /agentle/{tts/real_time/definitions/__init__.py → agents/whatsapp/v2/in_memory_batch_processor_manager.py} +0 -0
  84. {agentle-0.9.4.dist-info → agentle-0.9.28.dist-info}/WHEEL +0 -0
  85. {agentle-0.9.4.dist-info → agentle-0.9.28.dist-info}/licenses/LICENSE +0 -0
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import datetime
4
4
  import logging
5
5
  import uuid
6
- from collections.abc import AsyncIterator
6
+ from collections.abc import AsyncGenerator, AsyncIterator
7
7
  from logging import Logger
8
8
  from typing import TYPE_CHECKING, Any, Literal, cast, overload
9
9
 
@@ -88,11 +88,11 @@ class GenerateGenerateContentResponseToGenerationAdapter[T](
88
88
  @overload
89
89
  def adapt(
90
90
  self, _f: AsyncIterator["GenerateContentResponse"]
91
- ) -> AsyncIterator[Generation[T]]: ...
91
+ ) -> AsyncGenerator[Generation[T], None]: ...
92
92
 
93
93
  def adapt(
94
94
  self, _f: "GenerateContentResponse | AsyncIterator[GenerateContentResponse]"
95
- ) -> Generation[T] | AsyncIterator[Generation[T]]:
95
+ ) -> Generation[T] | AsyncGenerator[Generation[T], None]:
96
96
  """
97
97
  Convert Google response(s) to Agentle Generation object(s).
98
98
 
@@ -214,7 +214,7 @@ class GenerateGenerateContentResponseToGenerationAdapter[T](
214
214
 
215
215
  async def _adapt_streaming(
216
216
  self, response_stream: AsyncIterator["GenerateContentResponse"]
217
- ) -> AsyncIterator[Generation[T]]:
217
+ ) -> AsyncGenerator[Generation[T], None]:
218
218
  """Adapt a streaming response with proper text accumulation."""
219
219
  generation_id = self.preferred_id or uuid.uuid4()
220
220
  created_time = datetime.datetime.now()
@@ -253,16 +253,19 @@ class GenerateGenerateContentResponseToGenerationAdapter[T](
253
253
  _all_parts.extend(_parts)
254
254
 
255
255
  if _optional_model is not None:
256
- optional_model = parse_streaming_json(
257
- "".join([str(p.text) for p in _all_parts]),
256
+ # Parse streaming JSON and update final_parsed
257
+ accumulated_json_text = "".join([str(p.text) for p in _all_parts])
258
+ parsed_optional_model = parse_streaming_json(
259
+ accumulated_json_text,
258
260
  model=_optional_model,
259
261
  )
260
- chunk.parsed = optional_model
262
+ # Cast the optional model back to T for use in the generation
263
+ final_parsed = cast(T, parsed_optional_model)
261
264
  else:
262
- chunk.parsed = None
265
+ final_parsed = None
263
266
 
264
- # Extract parsed data (usually only available in final chunk)
265
- if hasattr(chunk, "parsed") and chunk.parsed is not None:
267
+ # Also check if chunk has parsed attribute from Google API
268
+ elif hasattr(chunk, "parsed") and chunk.parsed is not None:
266
269
  final_parsed = cast(T | None, chunk.parsed)
267
270
 
268
271
  # Extract usage (usually only in final chunk)
@@ -182,6 +182,37 @@ class GoogleGenerationProvider(GenerationProvider):
182
182
  """
183
183
  return "google"
184
184
 
185
+ @overload
186
+ def stream_async[T](
187
+ self,
188
+ *,
189
+ model: str | ModelKind | None = None,
190
+ messages: Sequence[Message],
191
+ response_schema: type[T],
192
+ generation_config: GenerationConfig | GenerationConfigDict | None = None,
193
+ ) -> AsyncGenerator[Generation[T], None]: ...
194
+
195
+ @overload
196
+ def stream_async(
197
+ self,
198
+ *,
199
+ model: str | ModelKind | None = None,
200
+ messages: Sequence[Message],
201
+ response_schema: None = None,
202
+ generation_config: GenerationConfig | GenerationConfigDict | None = None,
203
+ tools: Sequence[Tool],
204
+ ) -> AsyncGenerator[Generation[WithoutStructuredOutput], None]: ...
205
+
206
+ @overload
207
+ def stream_async(
208
+ self,
209
+ *,
210
+ model: str | ModelKind | None = None,
211
+ messages: Sequence[Message],
212
+ response_schema: None = None,
213
+ generation_config: GenerationConfig | GenerationConfigDict | None = None,
214
+ ) -> AsyncGenerator[Generation[WithoutStructuredOutput], None]: ...
215
+
185
216
  async def stream_async[T = WithoutStructuredOutput](
186
217
  self,
187
218
  *,
@@ -190,7 +221,7 @@ class GoogleGenerationProvider(GenerationProvider):
190
221
  response_schema: type[T] | None = None,
191
222
  generation_config: GenerationConfig | GenerationConfigDict | None = None,
192
223
  tools: Sequence[Tool] | None = None,
193
- ) -> AsyncGenerator[Generation[WithoutStructuredOutput], None]:
224
+ ) -> AsyncGenerator[Generation[T], None]:
194
225
  from google.genai import types
195
226
 
196
227
  if self._normalize_generation_config(generation_config).n > 1:
@@ -260,6 +291,7 @@ class GoogleGenerationProvider(GenerationProvider):
260
291
  tools=_tools,
261
292
  max_output_tokens=_generation_config.max_output_tokens,
262
293
  response_schema=response_schema if bool(response_schema) else None,
294
+ response_mime_type="application/json" if bool(response_schema) else None,
263
295
  automatic_function_calling=types.AutomaticFunctionCallingConfig(
264
296
  disable=disable_function_calling,
265
297
  maximum_remote_calls=maximum_remote_calls,
@@ -295,10 +327,8 @@ class GoogleGenerationProvider(GenerationProvider):
295
327
  raise
296
328
 
297
329
  # Create the response
298
- response = GenerateGenerateContentResponseToGenerationAdapter[
299
- WithoutStructuredOutput
300
- ](
301
- response_schema=None,
330
+ response = GenerateGenerateContentResponseToGenerationAdapter[T](
331
+ response_schema=response_schema,
302
332
  model=used_model,
303
333
  ).adapt(generate_content_response_stream)
304
334
 
@@ -84,11 +84,45 @@ class OpenRouterMessageToGeneratedAssistantMessageAdapter[T](
84
84
  tool_parts: list[ToolExecutionSuggestion] = []
85
85
  for tool_call in tool_calls_data:
86
86
  function_data = tool_call.get("function", {})
87
+
88
+ # Parse arguments with error handling for malformed JSON
89
+ args_str = str(function_data.get("arguments", "{}"))
90
+ args: dict[str, object] = {}
91
+ try:
92
+ args = json.loads(args_str)
93
+ except json.JSONDecodeError as e:
94
+ # Log the error and try to extract the first valid JSON object
95
+ import logging
96
+
97
+ logger = logging.getLogger(__name__)
98
+ logger.warning(
99
+ f"Malformed JSON in tool call arguments: {e}. "
100
+ + "Attempting to parse first valid JSON object. "
101
+ + f"Raw arguments: {args_str[:200]}..."
102
+ )
103
+
104
+ # Try to find the first complete JSON object
105
+ try:
106
+ # Use JSONDecoder to parse incrementally
107
+ decoder = json.JSONDecoder()
108
+ args, idx = decoder.raw_decode(args_str)
109
+ if idx < len(args_str.strip()):
110
+ logger.warning(
111
+ f"Extra data found after position {idx}. "
112
+ + "Using first valid JSON object only."
113
+ )
114
+ except (json.JSONDecodeError, ValueError) as e2:
115
+ logger.error(
116
+ f"Failed to parse tool call arguments even with recovery: {e2}. "
117
+ + "Using empty dict."
118
+ )
119
+ args = {}
120
+
87
121
  tool_parts.append(
88
122
  ToolExecutionSuggestion(
89
123
  id=str(tool_call.get("id", "")),
90
124
  tool_name=str(function_data.get("name", "")),
91
- args=json.loads(str(function_data.get("arguments", "{}"))),
125
+ args=args,
92
126
  )
93
127
  )
94
128
 
@@ -451,12 +451,19 @@ class StdioMCPServer(MCPServerProtocol):
451
451
  self._logger.warning("Server closed stdout")
452
452
  break
453
453
 
454
+ message_str = ""
454
455
  try:
455
456
  message_str = line.decode("utf-8").strip()
456
457
  if not message_str:
457
458
  continue
458
459
 
459
460
  self._logger.debug(f"<- RECV: {message_str}")
461
+
462
+ # Skip lines that don't look like JSON (common with MCP servers that log to stdout)
463
+ if not message_str.startswith("{"):
464
+ self._logger.debug(f"Skipping non-JSON line: {message_str}")
465
+ continue
466
+
460
467
  message = json.loads(message_str)
461
468
 
462
469
  if "id" in message:
@@ -468,8 +475,10 @@ class StdioMCPServer(MCPServerProtocol):
468
475
  else:
469
476
  self._logger.warning(f"Unknown message type: {message}")
470
477
 
471
- except json.JSONDecodeError as e:
472
- self._logger.error(f"Failed to parse JSON: {e}")
478
+ except json.JSONDecodeError:
479
+ self._logger.debug(
480
+ f"Skipping invalid JSON line: {message_str[:100]}"
481
+ )
473
482
  except UnicodeDecodeError as e:
474
483
  self._logger.error(f"Failed to decode UTF-8: {e}")
475
484
 
@@ -762,7 +771,12 @@ class StdioMCPServer(MCPServerProtocol):
762
771
 
763
772
  async def _send_request(self, request: _JsonRpcRequest) -> _JsonRpcResponse:
764
773
  """Send a request to the server and wait for response."""
765
- if not self._is_connected() or not self._stdin:
774
+ # Allow requests during INITIALIZING state (for the initialize request itself)
775
+ if (
776
+ self._connection_state
777
+ not in [ConnectionState.CONNECTED, ConnectionState.INITIALIZING]
778
+ or not self._stdin
779
+ ):
766
780
  raise ConnectionError("Server not connected")
767
781
 
768
782
  request_id = request["id"]
@@ -798,7 +812,12 @@ class StdioMCPServer(MCPServerProtocol):
798
812
 
799
813
  async def _send_notification(self, notification: _JsonRpcNotification) -> None:
800
814
  """Send a notification to the server."""
801
- if not self._is_connected() or not self._stdin:
815
+ # Allow notifications during INITIALIZING state (for the initialized notification)
816
+ if (
817
+ self._connection_state
818
+ not in [ConnectionState.CONNECTED, ConnectionState.INITIALIZING]
819
+ or not self._stdin
820
+ ):
802
821
  raise ConnectionError("Server not connected")
803
822
 
804
823
  try:
@@ -20,6 +20,7 @@ from typing import Literal, Any
20
20
  from rsb.models.field import Field
21
21
 
22
22
 
23
+ from agentle.generations.models.generation.generation_config import GenerationConfig
23
24
  from agentle.generations.models.message_parts.file import FilePart
24
25
  from agentle.generations.models.structured_outputs_store.visual_media_description import (
25
26
  VisualMediaDescription,
@@ -182,6 +183,9 @@ class DocxFileParser(DocumentParser):
182
183
  Note: When this is enabled, most other configuration options are ignored as the AI handles all processing.
183
184
  """
184
185
 
186
+ max_output_tokens: int | None = Field(default=None)
187
+ """Maximum number of tokens to generate in the response."""
188
+
185
189
  async def parse_async(
186
190
  self,
187
191
  document_path: str,
@@ -517,6 +521,9 @@ class DocxFileParser(DocumentParser):
517
521
  "Output clear, concise descriptions suitable for a 'Visual Content' section."
518
522
  ),
519
523
  response_schema=VisualMediaDescription,
524
+ generation_config=GenerationConfig(
525
+ max_output_tokens=self.max_output_tokens
526
+ ),
520
527
  )
521
528
  page_description = agent_response.parsed.md
522
529
  image_cache[page_hash] = (page_description, "")
@@ -663,6 +670,7 @@ class DocxFileParser(DocumentParser):
663
670
  model=self.model,
664
671
  use_native_pdf_processing=True,
665
672
  strategy=self.strategy,
673
+ max_output_tokens=self.max_output_tokens,
666
674
  )
667
675
 
668
676
  logger.debug("Delegating to PDFFileParser with native processing")
@@ -261,6 +261,9 @@ class FileParser(DocumentParser):
261
261
  Note: When this is enabled, most other configuration options are ignored as the AI handles all processing.
262
262
  """
263
263
 
264
+ max_output_tokens: int | None = Field(default=None)
265
+ """Maximum number of tokens to generate in the response."""
266
+
264
267
  async def parse_async(self, document_path: str) -> ParsedFile:
265
268
  """
266
269
  Asynchronously parse a document using the appropriate parser for its file type.
@@ -378,4 +381,5 @@ class FileParser(DocumentParser):
378
381
  use_native_docx_processing=self.use_native_docx_processing,
379
382
  strategy=self.strategy,
380
383
  model=self.model,
384
+ max_output_tokens=self.max_output_tokens,
381
385
  ).parse_async(document_path=str(resolved_path))
@@ -258,6 +258,9 @@ class PDFFileParser(DocumentParser):
258
258
  # Metrics state
259
259
  last_parse_metrics: PDFParseMetrics | None = None
260
260
 
261
+ max_output_tokens: int | None = Field(default=None)
262
+ """Maximum number of tokens to generate in the response."""
263
+
261
264
  async def parse_async(self, document_path: str) -> ParsedFile:
262
265
  """
263
266
  Asynchronously parse a PDF document and convert it to a structured representation.
@@ -739,6 +742,9 @@ class PDFFileParser(DocumentParser):
739
742
  file_part,
740
743
  developer_prompt=developer_prompt,
741
744
  response_schema=VisualMediaDescription,
745
+ generation_config=GenerationConfig(
746
+ max_output_tokens=self.max_output_tokens
747
+ ),
742
748
  ),
743
749
  timeout=self.image_description_timeout,
744
750
  )
@@ -855,7 +861,7 @@ class PDFFileParser(DocumentParser):
855
861
  prompt=[pdf_file_part, prompt],
856
862
  response_schema=PDFPageExtraction,
857
863
  generation_config=GenerationConfig(
858
- timeout_s=300.0,
864
+ timeout_s=300.0, max_output_tokens=self.max_output_tokens
859
865
  ),
860
866
  model=self.model,
861
867
  )
@@ -0,0 +1,11 @@
1
+ """Storage module for file management."""
2
+
3
+ from agentle.storage.file_storage_manager import FileStorageManager
4
+ from agentle.storage.local_file_storage_manager import LocalFileStorageManager
5
+ from agentle.storage.s3_file_storage_manager import S3FileStorageManager
6
+
7
+ __all__ = [
8
+ "FileStorageManager",
9
+ "LocalFileStorageManager",
10
+ "S3FileStorageManager",
11
+ ]
@@ -0,0 +1,44 @@
1
+ """Abstract file storage manager interface."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+
6
+ class FileStorageManager(ABC):
7
+ """Interface for file storage management."""
8
+
9
+ @abstractmethod
10
+ async def upload_file(self, file_data: bytes, filename: str, mime_type: str) -> str:
11
+ """
12
+ Upload file to storage and return public URL.
13
+
14
+ Args:
15
+ file_data: The file content as bytes
16
+ filename: The filename to use for storage
17
+ mime_type: The MIME type of the file
18
+
19
+ Returns:
20
+ Public URL to access the uploaded file
21
+
22
+ Raises:
23
+ FileStorageError: If upload fails
24
+ """
25
+ pass
26
+
27
+ @abstractmethod
28
+ async def delete_file(self, file_url: str) -> bool:
29
+ """
30
+ Delete file from storage by URL.
31
+
32
+ Args:
33
+ file_url: The public URL of the file to delete
34
+
35
+ Returns:
36
+ True if deletion was successful, False otherwise
37
+ """
38
+ pass
39
+
40
+
41
+ class FileStorageError(Exception):
42
+ """Exception raised for file storage operations."""
43
+
44
+ pass
@@ -0,0 +1,122 @@
1
+ """Local file storage manager implementation."""
2
+
3
+ import logging
4
+ import os
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from agentle.storage.file_storage_manager import FileStorageError, FileStorageManager
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class LocalFileStorageManager(FileStorageManager):
15
+ """Local filesystem implementation of file storage manager."""
16
+
17
+ def __init__(
18
+ self,
19
+ storage_dir: str | Path = "./storage",
20
+ base_url: str = "http://localhost:8000",
21
+ create_dirs: bool = True,
22
+ ):
23
+ """
24
+ Initialize local file storage manager.
25
+
26
+ Args:
27
+ storage_dir: Directory to store files
28
+ base_url: Base URL for accessing files (e.g., "http://localhost:8000")
29
+ create_dirs: Whether to create storage directory if it doesn't exist
30
+ """
31
+ self.storage_dir = Path(storage_dir)
32
+ self.base_url = base_url.rstrip("/")
33
+
34
+ if create_dirs:
35
+ self.storage_dir.mkdir(parents=True, exist_ok=True)
36
+ logger.info(f"Created storage directory: {self.storage_dir}")
37
+
38
+ logger.info(f"LocalFileStorageManager initialized: {self.storage_dir}")
39
+
40
+ async def upload_file(self, file_data: bytes, filename: str, mime_type: str) -> str:
41
+ """Upload file to local storage and return public URL."""
42
+ try:
43
+ # Ensure filename is safe
44
+ safe_filename = self._make_filename_safe(filename)
45
+ file_path = self.storage_dir / safe_filename
46
+
47
+ logger.debug(
48
+ f"Uploading file to local storage: {file_path} ({len(file_data)} bytes)"
49
+ )
50
+
51
+ # Write file to disk
52
+ with open(file_path, "wb") as f:
53
+ f.write(file_data)
54
+
55
+ # Generate public URL
56
+ url = f"{self.base_url}/files/{safe_filename}"
57
+
58
+ logger.info(f"File uploaded successfully: {url}")
59
+ return url
60
+
61
+ except OSError as e:
62
+ error_msg = f"Failed to write file to local storage: {e}"
63
+ logger.error(error_msg)
64
+ raise FileStorageError(error_msg) from e
65
+ except Exception as e:
66
+ error_msg = f"Unexpected error uploading to local storage: {e}"
67
+ logger.error(error_msg)
68
+ raise FileStorageError(error_msg) from e
69
+
70
+ async def delete_file(self, file_url: str) -> bool:
71
+ """Delete file from local storage by URL."""
72
+ try:
73
+ # Extract filename from URL
74
+ if "/files/" in file_url:
75
+ filename = file_url.split("/files/")[-1]
76
+ else:
77
+ logger.warning(f"Could not extract filename from URL: {file_url}")
78
+ return False
79
+
80
+ file_path = self.storage_dir / filename
81
+
82
+ if not file_path.exists():
83
+ logger.warning(f"File not found: {file_path}")
84
+ return False
85
+
86
+ logger.debug(f"Deleting file from local storage: {file_path}")
87
+
88
+ file_path.unlink()
89
+
90
+ logger.info(f"File deleted successfully: {filename}")
91
+ return True
92
+
93
+ except OSError as e:
94
+ logger.error(f"Failed to delete file from local storage: {e}")
95
+ return False
96
+ except Exception as e:
97
+ logger.error(f"Unexpected error deleting from local storage: {e}")
98
+ return False
99
+
100
+ def _make_filename_safe(self, filename: str) -> str:
101
+ """Make filename safe for filesystem."""
102
+ # Remove or replace unsafe characters
103
+ safe_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_"
104
+ safe_filename = "".join(c if c in safe_chars else "_" for c in filename)
105
+
106
+ # Ensure it's not empty and has reasonable length
107
+ if not safe_filename or len(safe_filename) > 255:
108
+ timestamp = int(time.time())
109
+ safe_filename = f"file_{timestamp}"
110
+
111
+ return safe_filename
112
+
113
+ def get_storage_info(self) -> dict[str, Any]:
114
+ """Get information about the storage configuration."""
115
+ return {
116
+ "storage_dir": str(self.storage_dir),
117
+ "base_url": self.base_url,
118
+ "exists": self.storage_dir.exists(),
119
+ "is_writable": os.access(self.storage_dir, os.W_OK)
120
+ if self.storage_dir.exists()
121
+ else False,
122
+ }
@@ -0,0 +1,124 @@
1
+ """S3 file storage manager implementation."""
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ try:
7
+ import boto3
8
+ from botocore.exceptions import ClientError
9
+
10
+ has_boto3 = True
11
+ except ImportError:
12
+ boto3 = None # type: ignore
13
+ ClientError = Exception # type: ignore
14
+ has_boto3 = False
15
+
16
+ from agentle.storage.file_storage_manager import FileStorageManager, FileStorageError
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class S3FileStorageManager(FileStorageManager):
22
+ """S3 implementation of file storage manager."""
23
+
24
+ def __init__(
25
+ self,
26
+ bucket_name: str,
27
+ region: str = "us-east-1",
28
+ aws_access_key_id: str | None = None,
29
+ aws_secret_access_key: str | None = None,
30
+ endpoint_url: str | None = None,
31
+ ):
32
+ """
33
+ Initialize S3 file storage manager.
34
+
35
+ Args:
36
+ bucket_name: S3 bucket name
37
+ region: AWS region
38
+ aws_access_key_id: AWS access key (optional, uses default credentials if not provided)
39
+ aws_secret_access_key: AWS secret key (optional, uses default credentials if not provided)
40
+ endpoint_url: Custom S3 endpoint URL (for S3-compatible services like MinIO)
41
+ """
42
+ if not has_boto3 or boto3 is None:
43
+ raise ImportError(
44
+ "boto3 is required for S3FileStorageManager. "
45
+ + "Install it with: pip install boto3"
46
+ )
47
+
48
+ self.bucket_name = bucket_name
49
+ self.region = region
50
+
51
+ # Configure S3 client
52
+ client_kwargs: dict[str, Any] = {"region_name": region}
53
+
54
+ if aws_access_key_id and aws_secret_access_key:
55
+ client_kwargs.update(
56
+ {
57
+ "aws_access_key_id": aws_access_key_id,
58
+ "aws_secret_access_key": aws_secret_access_key,
59
+ }
60
+ )
61
+
62
+ if endpoint_url:
63
+ client_kwargs["endpoint_url"] = endpoint_url
64
+
65
+ self.s3_client = boto3.client("s3", **client_kwargs)
66
+
67
+ logger.info(f"S3FileStorageManager initialized for bucket: {bucket_name}")
68
+
69
+ async def upload_file(self, file_data: bytes, filename: str, mime_type: str) -> str:
70
+ """Upload file to S3 and return public URL."""
71
+ try:
72
+ logger.debug(f"Uploading file to S3: {filename} ({len(file_data)} bytes)")
73
+
74
+ self.s3_client.put_object(
75
+ Bucket=self.bucket_name,
76
+ Key=filename,
77
+ Body=file_data,
78
+ ContentType=mime_type,
79
+ ACL="public-read", # Make file publicly accessible
80
+ )
81
+
82
+ # Generate public URL
83
+ if self.s3_client.meta.region_name == "us-east-1":
84
+ url = f"https://{self.bucket_name}.s3.amazonaws.com/{filename}"
85
+ else:
86
+ url = f"https://{self.bucket_name}.s3.{self.region}.amazonaws.com/{filename}"
87
+
88
+ logger.info(f"File uploaded successfully: {url}")
89
+ return url
90
+
91
+ except ClientError as e:
92
+ error_msg = f"Failed to upload file to S3: {e}"
93
+ logger.error(error_msg)
94
+ raise FileStorageError(error_msg) from e
95
+ except Exception as e:
96
+ error_msg = f"Unexpected error uploading to S3: {e}"
97
+ logger.error(error_msg)
98
+ raise FileStorageError(error_msg) from e
99
+
100
+ async def delete_file(self, file_url: str) -> bool:
101
+ """Delete file from S3 by URL."""
102
+ try:
103
+ # Extract key from URL
104
+ if f"s3.{self.region}.amazonaws.com" in file_url:
105
+ key = file_url.split(f"s3.{self.region}.amazonaws.com/")[-1]
106
+ elif "s3.amazonaws.com" in file_url:
107
+ key = file_url.split("s3.amazonaws.com/")[-1]
108
+ else:
109
+ logger.warning(f"Could not extract S3 key from URL: {file_url}")
110
+ return False
111
+
112
+ logger.debug(f"Deleting file from S3: {key}")
113
+
114
+ self.s3_client.delete_object(Bucket=self.bucket_name, Key=key)
115
+
116
+ logger.info(f"File deleted successfully: {key}")
117
+ return True
118
+
119
+ except ClientError as e:
120
+ logger.error(f"Failed to delete file from S3: {e}")
121
+ return False
122
+ except Exception as e:
123
+ logger.error(f"Unexpected error deleting from S3: {e}")
124
+ return False
@@ -0,0 +1,6 @@
1
+ from typing import Literal
2
+
3
+
4
+ AudioFormat = Literal[
5
+ "audio/mpeg", "audio/wav", "audio/opus", "audio/basic", "application/octet-stream"
6
+ ]