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
@@ -1,99 +1,46 @@
1
1
  """
2
- API endpoint integration for Agentle framework.
3
-
4
- This module provides classes for defining HTTP API endpoints that can be automatically
5
- converted to tools for use by AI agents. This allows users to integrate with REST APIs
6
- without writing HTTP request functions manually.
7
-
8
- Example:
9
- ```python
10
- from agentle.apis.endpoint import Endpoint, API, HTTPMethod, ParameterLocation, EndpointParameter
11
- from agentle.agents.agent import Agent
12
-
13
- # Define individual endpoints
14
- weather_endpoint = Endpoint(
15
- name="get_weather",
16
- description="Get current weather for a location",
17
- call_condition="when user asks about weather, current conditions, or temperature",
18
- url="https://api.weather.com/v1/current",
19
- method=HTTPMethod.GET,
20
- parameters=[
21
- EndpointParameter(
22
- name="location",
23
- description="City name or coordinates",
24
- param_type="string",
25
- location=ParameterLocation.QUERY,
26
- required=True
27
- ),
28
- EndpointParameter(
29
- name="units",
30
- description="Temperature units",
31
- param_type="string",
32
- location=ParameterLocation.QUERY,
33
- default="metric"
34
- )
35
- ]
36
- )
37
-
38
- # Or define an API with multiple endpoints
39
- weather_api = API(
40
- name="WeatherAPI",
41
- base_url="https://api.weather.com/v1",
42
- headers={"Authorization": "Bearer YOUR_TOKEN"},
43
- endpoints=[
44
- Endpoint(
45
- name="get_current_weather",
46
- description="Get current weather conditions",
47
- call_condition="when user asks about current weather",
48
- path="/current",
49
- method=HTTPMethod.GET,
50
- parameters=[
51
- EndpointParameter("location", "Location to get weather for", "string",
52
- ParameterLocation.QUERY, required=True)
53
- ]
54
- ),
55
- Endpoint(
56
- name="get_forecast",
57
- description="Get weather forecast",
58
- call_condition="when user asks about weather forecast or future weather",
59
- path="/forecast",
60
- method=HTTPMethod.GET,
61
- parameters=[
62
- EndpointParameter("location", "Location for forecast", "string",
63
- ParameterLocation.QUERY, required=True),
64
- EndpointParameter("days", "Number of days", "integer",
65
- ParameterLocation.QUERY, default=5)
66
- ]
67
- )
68
- ]
69
- )
70
-
71
- # Use with an agent
72
- agent = Agent(
73
- generation_provider=GoogleGenerationProvider(),
74
- model="gemini-2.5-flash",
75
- instructions="You are a weather assistant.",
76
- tools=[weather_endpoint], # Individual endpoint
77
- apis=[weather_api] # Full API with multiple endpoints
78
- )
79
- ```
2
+ Complete enhanced API endpoint integration for Agentle framework.
3
+
4
+ This module provides comprehensive HTTP API endpoint support with:
5
+ - Multiple authentication methods
6
+ - Advanced retry strategies
7
+ - Circuit breaker pattern
8
+ - Rate limiting
9
+ - Response caching
10
+ - File uploads (multipart/form-data)
11
+ - Streaming responses
12
+ - Request/response hooks
13
+ - SSL/Proxy configuration
14
+ - and more...
80
15
  """
81
16
 
82
17
  from __future__ import annotations
83
18
 
84
19
  import asyncio
85
20
  import logging
86
- from collections.abc import MutableMapping, Sequence
21
+ import random
22
+ from collections.abc import AsyncIterator, MutableMapping, Sequence
87
23
  from typing import Any, Literal
88
24
 
89
25
  import aiohttp
90
26
  from rsb.models.base_model import BaseModel
91
27
  from rsb.models.field import Field
92
28
 
29
+ from agentle.agents.apis.authentication import (
30
+ AuthenticationBase,
31
+ AuthenticationConfig,
32
+ NoAuthentication,
33
+ )
93
34
  from agentle.agents.apis.endpoint_parameter import EndpointParameter
35
+ from agentle.agents.apis.file_upload import FileUpload
94
36
  from agentle.agents.apis.http_method import HTTPMethod
95
37
  from agentle.agents.apis.parameter_location import ParameterLocation
38
+ from agentle.agents.apis.circuit_breaker import CircuitBreaker
39
+ from agentle.agents.apis.circuit_breaker_error import CircuitBreakerError
40
+ from agentle.agents.apis.rate_limiter import RateLimiter
96
41
  from agentle.agents.apis.request_config import RequestConfig
42
+ from agentle.agents.apis.response_cache import ResponseCache
43
+ from agentle.agents.apis.retry_strategy import RetryStrategy
97
44
  from agentle.generations.tools.tool import Tool
98
45
 
99
46
  logger = logging.getLogger(__name__)
@@ -101,11 +48,17 @@ logger = logging.getLogger(__name__)
101
48
 
102
49
  class Endpoint(BaseModel):
103
50
  """
104
- Represents a single HTTP API endpoint that can be called by an agent.
105
-
106
- This class encapsulates all the information needed to make HTTP requests
107
- to a specific API endpoint, including URL, method, parameters, and conditions
108
- for when the agent should use this endpoint.
51
+ Enhanced HTTP API endpoint with comprehensive features.
52
+
53
+ Supports:
54
+ - Multiple authentication methods
55
+ - Advanced retry strategies with circuit breakers
56
+ - Rate limiting and quota management
57
+ - Response caching
58
+ - File uploads
59
+ - Streaming responses
60
+ - Request/response hooks
61
+ - SSL and proxy configuration
109
62
  """
110
63
 
111
64
  name: str = Field(description="Unique name for this endpoint")
@@ -147,23 +100,74 @@ class Endpoint(BaseModel):
147
100
  default_factory=RequestConfig,
148
101
  )
149
102
 
150
- response_format: Literal["json", "text", "bytes"] = Field(
103
+ auth_config: AuthenticationConfig | None = Field(
104
+ description="Authentication configuration",
105
+ default=None,
106
+ )
107
+
108
+ response_format: Literal["json", "text", "bytes", "stream", "xml"] = Field(
151
109
  description="Expected response format", default="json"
152
110
  )
153
111
 
154
- def get_full_url(self, base_url: str | None = None) -> str:
155
- """
156
- Get the complete URL for this endpoint.
112
+ # File upload support
113
+ supports_file_upload: bool = Field(
114
+ description="Whether this endpoint supports file uploads", default=False
115
+ )
157
116
 
158
- Args:
159
- base_url: Base URL to prepend to the path
117
+ # Pagination support
118
+ supports_pagination: bool = Field(
119
+ description="Whether this endpoint supports pagination", default=False
120
+ )
121
+ pagination_param_name: str = Field(
122
+ description="Name of pagination parameter", default="page"
123
+ )
124
+ pagination_style: Literal["page", "offset", "cursor"] = Field(
125
+ description="Style of pagination", default="page"
126
+ )
160
127
 
161
- Returns:
162
- Complete URL for the endpoint
128
+ # Response validation
129
+ validate_response_schema: bool = Field(
130
+ description="Whether to validate response against schema", default=False
131
+ )
132
+ response_schema: dict[str, Any] | None = Field(
133
+ description="JSON schema for response validation", default=None
134
+ )
163
135
 
164
- Raises:
165
- ValueError: If neither url nor (base_url + path) is available
166
- """
136
+ # Advanced features
137
+ enable_hooks: bool = Field(
138
+ description="Enable request/response hooks", default=False
139
+ )
140
+
141
+ # Internal state (not serialized)
142
+ _auth_handler: AuthenticationBase | None = None
143
+ _circuit_breaker: CircuitBreaker | None = None
144
+ _rate_limiter: RateLimiter | None = None
145
+ _response_cache: ResponseCache | None = None
146
+
147
+ def model_post_init(self, __context: Any) -> None:
148
+ """Initialize internal components."""
149
+ super().model_post_init(__context)
150
+
151
+ # Initialize authentication handler
152
+ if self.auth_config:
153
+ self._auth_handler = self.auth_config.create_handler()
154
+ else:
155
+ self._auth_handler = NoAuthentication()
156
+
157
+ # Initialize circuit breaker if enabled
158
+ if self.request_config.enable_circuit_breaker:
159
+ self._circuit_breaker = CircuitBreaker(self.request_config)
160
+
161
+ # Initialize rate limiter if enabled
162
+ if self.request_config.enable_rate_limiting:
163
+ self._rate_limiter = RateLimiter(self.request_config)
164
+
165
+ # Initialize cache if enabled
166
+ if self.request_config.enable_caching:
167
+ self._response_cache = ResponseCache(self.request_config)
168
+
169
+ def get_full_url(self, base_url: str | None = None) -> str:
170
+ """Get the complete URL for this endpoint."""
167
171
  if self.url:
168
172
  return self.url
169
173
 
@@ -175,12 +179,7 @@ class Endpoint(BaseModel):
175
179
  )
176
180
 
177
181
  def get_enhanced_description(self) -> str:
178
- """
179
- Get description enhanced with call condition.
180
-
181
- Returns:
182
- Description with call condition appended if available
183
- """
182
+ """Get description enhanced with call condition."""
184
183
  base_desc = self.description
185
184
 
186
185
  if self.call_condition:
@@ -188,56 +187,124 @@ class Endpoint(BaseModel):
188
187
 
189
188
  return base_desc
190
189
 
191
- def to_tool_parameters(self) -> dict[str, object]:
192
- """
193
- Convert endpoint parameters to tool parameter format.
190
+ def _calculate_retry_delay(self, attempt: int) -> float:
191
+ """Calculate retry delay based on strategy."""
192
+ base_delay = self.request_config.retry_delay
194
193
 
195
- Returns:
196
- Dictionary of parameters in tool format
197
- """
198
- tool_params: dict[str, object] = {}
194
+ if self.request_config.retry_strategy == RetryStrategy.CONSTANT:
195
+ delay = base_delay
199
196
 
200
- for param in self.parameters:
201
- param_info: dict[str, object] = {
202
- "type": param.param_type,
203
- "description": param.description,
204
- "required": param.required,
205
- }
197
+ elif self.request_config.retry_strategy == RetryStrategy.LINEAR:
198
+ delay = base_delay * (attempt + 1)
206
199
 
207
- if param.default is not None:
208
- param_info["default"] = param.default
200
+ elif self.request_config.retry_strategy == RetryStrategy.EXPONENTIAL:
201
+ delay = base_delay * (2**attempt)
209
202
 
210
- if param.enum:
211
- param_info["enum"] = list(param.enum)
203
+ elif self.request_config.retry_strategy == RetryStrategy.FIBONACCI:
204
+ # Calculate Fibonacci number for attempt
205
+ fib = [1, 1]
206
+ for _ in range(attempt):
207
+ fib.append(fib[-1] + fib[-2])
208
+ delay = base_delay * fib[-1]
212
209
 
213
- tool_params[param.name] = param_info
210
+ else:
211
+ delay = base_delay
212
+
213
+ # Add jitter (±20%)
214
+ jitter = delay * 0.2 * (random.random() - 0.5) * 2
215
+ delay = delay + jitter
216
+
217
+ # Cap at 60 seconds
218
+ return min(delay, 60.0)
219
+
220
+ def _should_retry(
221
+ self, response: aiohttp.ClientResponse | None, exception: Exception | None
222
+ ) -> bool:
223
+ """Determine if request should be retried."""
224
+ # Retry on configured status codes
225
+ if response and response.status in self.request_config.retry_on_status_codes:
226
+ return True
227
+
228
+ # Retry on exceptions if configured
229
+ if exception and self.request_config.retry_on_exceptions:
230
+ # Don't retry on certain exceptions
231
+ if isinstance(exception, (asyncio.CancelledError, KeyboardInterrupt)):
232
+ return False
233
+ return True
234
+
235
+ return False
236
+
237
+ async def _parse_response(self, response: aiohttp.ClientResponse) -> Any:
238
+ """Parse response based on format."""
239
+ if self.response_format == "json":
240
+ return await response.json()
241
+ elif self.response_format == "text":
242
+ return await response.text()
243
+ elif self.response_format == "bytes":
244
+ return await response.read()
245
+ elif self.response_format == "xml":
246
+ # Try to parse XML
247
+ try:
248
+ import xml.etree.ElementTree as ET
249
+
250
+ text = await response.text()
251
+ return ET.fromstring(text)
252
+ except Exception:
253
+ return await response.text()
254
+ else:
255
+ return await response.text()
256
+
257
+ async def _handle_streaming_response(
258
+ self, response: aiohttp.ClientResponse
259
+ ) -> AsyncIterator[bytes]:
260
+ """Handle streaming response."""
261
+ async for chunk in response.content.iter_chunked(8192):
262
+ yield chunk
263
+
264
+ async def _validate_response(self, data: Any) -> Any:
265
+ """Validate response against schema if configured."""
266
+ if not self.validate_response_schema or not self.response_schema:
267
+ return data
268
+
269
+ try:
270
+ import jsonschema
214
271
 
215
- return tool_params
272
+ jsonschema.validate(instance=data, schema=self.response_schema)
273
+ return data
274
+ except Exception as e:
275
+ logger.warning(f"Response validation failed: {e}")
276
+ return data
216
277
 
217
- async def _make_request(
278
+ async def make_request(
218
279
  self,
219
280
  base_url: str | None = None,
220
281
  global_headers: MutableMapping[str, str] | None = None,
221
282
  **kwargs: Any,
222
283
  ) -> Any:
223
- """
224
- Internal method to make the HTTP request.
225
-
226
- Args:
227
- base_url: Base URL if using path-based endpoint
228
- global_headers: Global headers to merge with endpoint headers
229
- **kwargs: Parameters for the request
230
-
231
- Returns:
232
- Response data based on response_format
233
- """
284
+ """Internal method to make the HTTP request with all enhancements."""
234
285
  url = self.get_full_url(base_url)
235
286
 
287
+ # Check rate limiter
288
+ if self._rate_limiter:
289
+ await self._rate_limiter.acquire()
290
+
291
+ # Check cache (only for GET requests if configured)
292
+ if (
293
+ self._response_cache
294
+ and self.method == HTTPMethod.GET
295
+ and self.request_config.cache_only_get
296
+ ):
297
+ cached = await self._response_cache.get(url, kwargs)
298
+ if cached is not None:
299
+ logger.debug(f"Cache hit for {url}")
300
+ return cached
301
+
236
302
  # Separate parameters by location
237
303
  query_params: dict[str, Any] = {}
238
304
  body_params: dict[str, Any] = {}
239
305
  header_params: dict[str, str] = {}
240
306
  path_params: dict[str, Any] = {}
307
+ files: dict[str, FileUpload] = {}
241
308
 
242
309
  for param in self.parameters:
243
310
  param_name = param.name
@@ -252,13 +319,27 @@ class Endpoint(BaseModel):
252
319
  else:
253
320
  continue
254
321
 
255
- # Place parameter in appropriate location
322
+ # Handle file uploads
323
+ if isinstance(value, FileUpload):
324
+ files[param_name] = value
325
+ continue
326
+
327
+ # Place parameter in appropriate location with proper type handling
256
328
  if param.location == ParameterLocation.QUERY:
257
- query_params[param_name] = value
329
+ # Handle boolean conversion for query params
330
+ if isinstance(value, bool):
331
+ # Convert Python bool to lowercase string for URL compatibility
332
+ query_params[param_name] = str(value).lower()
333
+ else:
334
+ query_params[param_name] = value
258
335
  elif param.location == ParameterLocation.BODY:
259
336
  body_params[param_name] = value
260
337
  elif param.location == ParameterLocation.HEADER:
261
- header_params[param_name] = str(value)
338
+ # Convert to string for headers
339
+ if isinstance(value, bool):
340
+ header_params[param_name] = str(value).lower()
341
+ else:
342
+ header_params[param_name] = str(value)
262
343
  elif param.location == ParameterLocation.PATH:
263
344
  path_params[param_name] = value
264
345
 
@@ -273,114 +354,189 @@ class Endpoint(BaseModel):
273
354
  headers.update(self.headers)
274
355
  headers.update(header_params)
275
356
 
276
- # Prepare request data
277
- request_kwargs: dict[str, Any] = {
278
- "timeout": aiohttp.ClientTimeout(total=self.request_config.timeout),
279
- "headers": headers,
280
- "allow_redirects": self.request_config.follow_redirects,
281
- }
282
-
283
- if query_params:
284
- request_kwargs["params"] = query_params
357
+ # Apply authentication
358
+ if self._auth_handler:
359
+ await self._auth_handler.refresh_if_needed()
360
+ await self._auth_handler.apply_auth(None, url, headers, query_params) # type: ignore
285
361
 
286
- if body_params and self.method in [
287
- HTTPMethod.POST,
288
- HTTPMethod.PUT,
289
- HTTPMethod.PATCH,
290
- ]:
291
- request_kwargs["json"] = body_params
292
- if "Content-Type" not in headers:
293
- headers["Content-Type"] = "application/json"
362
+ # Prepare connector kwargs (will be used to create fresh connector for each attempt)
363
+ connector_kwargs: dict[str, Any] = {
364
+ "limit": 10,
365
+ "limit_per_host": 5,
366
+ "ttl_dns_cache": 300,
367
+ }
294
368
 
295
- # Use a single session for all retry attempts to avoid cleanup issues
296
- last_exception = None
369
+ if not self.request_config.verify_ssl:
370
+ connector_kwargs["ssl"] = False
297
371
 
298
- # Create connector with proper cleanup settings
299
- connector = aiohttp.TCPConnector(
300
- limit=10, # Limit concurrent connections
301
- limit_per_host=5,
302
- ttl_dns_cache=300,
303
- use_dns_cache=True,
372
+ # Prepare timeout
373
+ timeout = aiohttp.ClientTimeout(
374
+ total=self.request_config.timeout,
375
+ connect=self.request_config.connect_timeout,
376
+ sock_read=self.request_config.read_timeout,
304
377
  )
305
378
 
306
- try:
307
- async with aiohttp.ClientSession(
308
- connector=connector,
309
- timeout=aiohttp.ClientTimeout(
310
- total=self.request_config.timeout * 2
311
- ), # Overall timeout
312
- ) as session:
313
- for attempt in range(self.request_config.max_retries + 1):
314
- try:
315
- logger.debug(
316
- f"Making {self.method} request to {url} (attempt {attempt + 1})"
379
+ # Define the request function for circuit breaker
380
+ async def make_single_request() -> Any:
381
+ """Make a single request attempt."""
382
+ # Create a fresh connector for each request attempt to avoid "Session is closed" errors on retries
383
+ connector = aiohttp.TCPConnector(**connector_kwargs)
384
+ session = None
385
+ try:
386
+ session = aiohttp.ClientSession(connector=connector, timeout=timeout)
387
+ # Prepare request kwargs
388
+ request_kwargs: dict[str, Any] = {
389
+ "headers": headers,
390
+ "allow_redirects": self.request_config.follow_redirects,
391
+ "max_redirects": self.request_config.max_redirects,
392
+ }
393
+
394
+ if query_params:
395
+ request_kwargs["params"] = query_params
396
+
397
+ # Handle different content types
398
+ if files and self.supports_file_upload:
399
+ # Multipart form-data
400
+ form_data = aiohttp.FormData()
401
+ for key, file in files.items():
402
+ form_data.add_field(
403
+ key,
404
+ file.content,
405
+ filename=file.filename,
406
+ content_type=file.mime_type or "application/octet-stream",
407
+ )
408
+ for key, value in body_params.items():
409
+ form_data.add_field(key, str(value))
410
+ request_kwargs["data"] = form_data
411
+
412
+ elif body_params and self.method in [
413
+ HTTPMethod.POST,
414
+ HTTPMethod.PUT,
415
+ HTTPMethod.PATCH,
416
+ ]:
417
+ # JSON body
418
+ request_kwargs["json"] = body_params
419
+ if "Content-Type" not in headers:
420
+ headers["Content-Type"] = "application/json"
421
+
422
+ # Proxy configuration
423
+ if self.request_config.proxy_url:
424
+ request_kwargs["proxy"] = self.request_config.proxy_url
425
+ if self.request_config.proxy_auth:
426
+ request_kwargs["proxy_auth"] = aiohttp.BasicAuth(
427
+ *self.request_config.proxy_auth
317
428
  )
318
429
 
319
- async with session.request(
320
- method=self.method.value, url=url, **request_kwargs
321
- ) as response:
322
- # Check for HTTP errors
323
- if response.status >= 400:
324
- error_text = await response.text()
325
- raise aiohttp.ClientResponseError(
326
- request_info=response.request_info,
327
- history=response.history,
328
- status=response.status,
329
- message=f"HTTP {response.status}: {error_text}",
330
- )
331
-
332
- # Parse response based on format
333
- if self.response_format == "json":
334
- return await response.json()
335
- elif self.response_format == "text":
336
- return await response.text()
337
- elif self.response_format == "bytes":
338
- return await response.read()
339
- else:
340
- return await response.text()
341
-
342
- except asyncio.CancelledError:
343
- # Handle cancellation gracefully
344
- logger.debug(f"Request to {url} was cancelled")
345
- raise
346
- except Exception as e:
347
- last_exception = e
348
- logger.warning(
349
- f"Request attempt {attempt + 1} failed: {str(e)}"
430
+ # Log request if enabled
431
+ if self.request_config.enable_request_logging:
432
+ logger.info(f"Request: {self.method} {url}")
433
+ logger.debug(f"Headers: {headers}")
434
+ logger.debug(f"Params: {query_params}")
435
+
436
+ # Make request
437
+ async with session.request(
438
+ method=self.method.value, url=url, **request_kwargs
439
+ ) as response:
440
+ # Log response if enabled
441
+ if self.request_config.enable_response_logging:
442
+ logger.info(f"Response: {response.status} from {url}")
443
+
444
+ # Handle HTTP errors
445
+ if response.status >= 400:
446
+ error_text = await response.text()
447
+
448
+ # Check for Retry-After header
449
+ if (
450
+ response.status == 429
451
+ and self.request_config.respect_retry_after
452
+ ):
453
+ retry_after = response.headers.get("Retry-After")
454
+ if retry_after:
455
+ try:
456
+ wait_time = int(retry_after)
457
+ logger.warning(
458
+ f"Rate limited. Waiting {wait_time}s as per Retry-After header"
459
+ )
460
+ await asyncio.sleep(wait_time)
461
+ except ValueError:
462
+ pass
463
+
464
+ raise aiohttp.ClientResponseError(
465
+ request_info=response.request_info,
466
+ history=response.history,
467
+ status=response.status,
468
+ message=f"HTTP {response.status}: {error_text}",
350
469
  )
351
470
 
352
- if attempt < self.request_config.max_retries:
353
- # Use exponential backoff with jitter
354
- delay = self.request_config.retry_delay * (2**attempt)
355
- jitter = (
356
- delay
357
- * 0.1
358
- * (0.5 - asyncio.get_event_loop().time() % 1)
359
- )
360
- total_delay = min(delay + jitter, 60.0) # Cap at 60 seconds
361
-
362
- try:
363
- await asyncio.sleep(total_delay)
364
- except asyncio.CancelledError:
365
- logger.debug("Sleep interrupted by cancellation")
366
- raise
367
- else:
368
- break
369
-
370
- except asyncio.CancelledError:
371
- logger.debug(f"HTTP session cancelled during request to {url}")
372
- raise
373
- except Exception as e:
374
- logger.error(f"Error with HTTP session for {url}: {str(e)}")
375
- if last_exception:
376
- raise last_exception
377
- raise
378
- finally:
379
- # Ensure connector is properly closed
380
- if not connector.closed:
381
- await connector.close()
382
-
383
- # If we get here, all attempts failed
471
+ # Handle streaming responses
472
+ if self.response_format == "stream":
473
+ chunks: list[bytes] = []
474
+ async for chunk in self._handle_streaming_response(response):
475
+ chunks.append(chunk)
476
+ return b"".join(chunks)
477
+
478
+ # Parse response
479
+ result = await self._parse_response(response)
480
+
481
+ # Validate response
482
+ result = await self._validate_response(result)
483
+
484
+ # Cache response if configured
485
+ if self._response_cache and self.method == HTTPMethod.GET:
486
+ await self._response_cache.set(url, kwargs, result)
487
+
488
+ return result
489
+ finally:
490
+ # Always close the session to prevent "Session is closed" errors on retries
491
+ if session is not None:
492
+ await session.close()
493
+ # Give the connector time to close properly
494
+ await asyncio.sleep(0.01)
495
+
496
+ # Execute with retries
497
+ last_exception = None
498
+
499
+ for attempt in range(self.request_config.max_retries + 1):
500
+ try:
501
+ # Execute with circuit breaker if enabled
502
+ if self._circuit_breaker:
503
+ result = await self._circuit_breaker.call(make_single_request)
504
+ else:
505
+ result = await make_single_request()
506
+
507
+ return result
508
+
509
+ except asyncio.CancelledError:
510
+ logger.debug(f"Request to {url} was cancelled")
511
+ raise
512
+
513
+ except CircuitBreakerError:
514
+ # Don't retry if circuit is open
515
+ raise
516
+
517
+ except Exception as e:
518
+ last_exception = e
519
+
520
+ # Check if we should retry
521
+ should_retry = self._should_retry(None, e)
522
+
523
+ if not should_retry or attempt >= self.request_config.max_retries:
524
+ break
525
+
526
+ # Calculate delay and wait
527
+ delay = self._calculate_retry_delay(attempt)
528
+ logger.warning(
529
+ f"Request failed (attempt {attempt + 1}/{self.request_config.max_retries + 1}). "
530
+ + f"Retrying in {delay:.2f}s: {str(e)}"
531
+ )
532
+
533
+ try:
534
+ await asyncio.sleep(delay)
535
+ except asyncio.CancelledError:
536
+ logger.debug("Sleep interrupted by cancellation")
537
+ raise
538
+
539
+ # All retries exhausted
384
540
  if last_exception:
385
541
  raise last_exception
386
542
  else:
@@ -391,23 +547,11 @@ class Endpoint(BaseModel):
391
547
  base_url: str | None = None,
392
548
  global_headers: MutableMapping[str, str] | None = None,
393
549
  ) -> Tool[Any]:
394
- """
395
- Convert this endpoint to a Tool instance with proper parameter mapping.
396
-
397
- This fixed version creates tool parameters directly from endpoint parameters
398
- instead of relying on function signature analysis of **kwargs.
399
-
400
- Args:
401
- base_url: Base URL for path-based endpoints
402
- global_headers: Global headers to include in requests
403
-
404
- Returns:
405
- Tool instance that can be used by agents
406
- """
550
+ """Convert this endpoint to a Tool instance."""
407
551
 
408
552
  async def endpoint_callable(**kwargs: Any) -> Any:
409
553
  """Callable function for the tool."""
410
- return await self._make_request(
554
+ return await self.make_request(
411
555
  base_url=base_url, global_headers=global_headers, **kwargs
412
556
  )
413
557
 
@@ -415,11 +559,11 @@ class Endpoint(BaseModel):
415
559
  tool_parameters: dict[str, object] = {}
416
560
 
417
561
  for param in self.parameters:
418
- # Use the parameter's to_tool_parameter_schema method if available
419
562
  if hasattr(param, "to_tool_parameter_schema"):
563
+ # Use the parameter's own schema conversion method
420
564
  tool_parameters[param.name] = param.to_tool_parameter_schema()
421
565
  else:
422
- # Fallback for basic parameters
566
+ # Fallback for parameters without schema method
423
567
  param_info: dict[str, object] = {
424
568
  "type": getattr(param, "param_type", "string") or "string",
425
569
  "description": param.description,
@@ -432,20 +576,35 @@ class Endpoint(BaseModel):
432
576
  if hasattr(param, "enum") and param.enum:
433
577
  param_info["enum"] = list(param.enum)
434
578
 
579
+ # Add constraints for number/primitive types
580
+ if hasattr(param, "parameter_schema") and param.parameter_schema:
581
+ from agentle.agents.apis.primitive_schema import PrimitiveSchema
582
+
583
+ schema = param.parameter_schema
584
+ # Only PrimitiveSchema has minimum, maximum, format
585
+ if isinstance(schema, PrimitiveSchema):
586
+ if schema.minimum is not None:
587
+ param_info["minimum"] = schema.minimum
588
+ if schema.maximum is not None:
589
+ param_info["maximum"] = schema.maximum
590
+ if schema.format:
591
+ param_info["format"] = schema.format
592
+
435
593
  tool_parameters[param.name] = param_info
436
594
 
437
- # Create the tool instance manually instead of using from_callable
595
+ tool_name = "_".join(self.name.lower().split())
596
+
597
+ # Create the tool
438
598
  tool = Tool(
439
- name=self.name,
599
+ name=tool_name,
440
600
  description=self.get_enhanced_description(),
441
601
  parameters=tool_parameters,
442
602
  )
443
603
 
444
- # Set the callable reference manually
445
604
  tool.set_callable_ref(endpoint_callable)
446
605
 
447
606
  logger.debug(
448
- f"Created tool '{self.name}' with {len(tool_parameters)} parameters: {list(tool_parameters.keys())}"
607
+ f"Created tool '{self.name}' with {len(tool_parameters)} parameters"
449
608
  )
450
609
 
451
610
  return tool