swiftpy-framework 0.1.0__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 (211) hide show
  1. swiftpy/.gitignore +0 -0
  2. swiftpy/__init__.py +572 -0
  3. swiftpy/admin/__init__.py +50 -0
  4. swiftpy/admin/auth.py +522 -0
  5. swiftpy/admin/registry.py +567 -0
  6. swiftpy/admin/views.py +1583 -0
  7. swiftpy/ai/__init__.py +199 -0
  8. swiftpy/ai/_deterministic.py +40 -0
  9. swiftpy/ai/_fake.py +604 -0
  10. swiftpy/ai/agents/__init__.py +30 -0
  11. swiftpy/ai/agents/base_agent.py +506 -0
  12. swiftpy/ai/agents/memory.py +864 -0
  13. swiftpy/ai/client.py +1011 -0
  14. swiftpy/ai/conversation.py +171 -0
  15. swiftpy/ai/embeddings.py +321 -0
  16. swiftpy/ai/exceptions.py +75 -0
  17. swiftpy/ai/history_strategies.py +157 -0
  18. swiftpy/ai/middleware.py +918 -0
  19. swiftpy/ai/observability/__init__.py +28 -0
  20. swiftpy/ai/observability/drivers.py +763 -0
  21. swiftpy/ai/observability/observer.py +364 -0
  22. swiftpy/ai/pool.py +102 -0
  23. swiftpy/ai/pricing.py +101 -0
  24. swiftpy/ai/prompts/__init__.py +6 -0
  25. swiftpy/ai/prompts/loader.py +343 -0
  26. swiftpy/ai/providers/__init__.py +22 -0
  27. swiftpy/ai/providers/anthropic_provider.py +353 -0
  28. swiftpy/ai/providers/base.py +273 -0
  29. swiftpy/ai/providers/openai_provider.py +275 -0
  30. swiftpy/ai/rag/__init__.py +53 -0
  31. swiftpy/ai/rag/context_formatter.py +263 -0
  32. swiftpy/ai/rag/pipeline_builder.py +544 -0
  33. swiftpy/ai/rag/reranker.py +484 -0
  34. swiftpy/ai/rag/result.py +78 -0
  35. swiftpy/ai/rag/retriever.py +315 -0
  36. swiftpy/ai/redis_store.py +227 -0
  37. swiftpy/ai/streaming.py +115 -0
  38. swiftpy/ai/tools/__init__.py +22 -0
  39. swiftpy/ai/tools/base_tool.py +489 -0
  40. swiftpy/ai/tools/first_party.py +384 -0
  41. swiftpy/ai/tools/tool_loop.py +417 -0
  42. swiftpy/ai/vectorstore/__init__.py +46 -0
  43. swiftpy/ai/vectorstore/base.py +542 -0
  44. swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
  45. swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
  46. swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
  47. swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
  48. swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
  49. swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
  50. swiftpy/auth/__init__.py +50 -0
  51. swiftpy/auth/api_key_guard.py +544 -0
  52. swiftpy/auth/auth_manager.py +259 -0
  53. swiftpy/auth/exceptions.py +71 -0
  54. swiftpy/auth/jwt_guard.py +230 -0
  55. swiftpy/auth/jwt_issuer.py +658 -0
  56. swiftpy/auth/token_blacklist.py +300 -0
  57. swiftpy/cache/__init__.py +14 -0
  58. swiftpy/cache/cache.py +251 -0
  59. swiftpy/cache/driver.py +216 -0
  60. swiftpy/cache/drivers/memory.py +199 -0
  61. swiftpy/cache/drivers/redis.py +421 -0
  62. swiftpy/console/__init__.py +0 -0
  63. swiftpy/console/cli.py +68 -0
  64. swiftpy/console/commands/__init__.py +0 -0
  65. swiftpy/console/commands/cache.py +164 -0
  66. swiftpy/console/commands/make.py +935 -0
  67. swiftpy/console/commands/migrate.py +280 -0
  68. swiftpy/console/commands/new.py +1806 -0
  69. swiftpy/console/commands/queue.py +306 -0
  70. swiftpy/console/commands/seed.py +252 -0
  71. swiftpy/console/commands/start.py +171 -0
  72. swiftpy/console/commands/storage.py +104 -0
  73. swiftpy/console/loader.py +21 -0
  74. swiftpy/console/stubs/__init__.py +26 -0
  75. swiftpy/console/stubs/controller.stub +14 -0
  76. swiftpy/console/stubs/event.stub +14 -0
  77. swiftpy/console/stubs/job.stub +22 -0
  78. swiftpy/console/stubs/listener.stub +14 -0
  79. swiftpy/console/stubs/mail.stub +20 -0
  80. swiftpy/console/stubs/middleware.stub +16 -0
  81. swiftpy/console/stubs/migration.alter.stub +17 -0
  82. swiftpy/console/stubs/migration.api_keys.stub +22 -0
  83. swiftpy/console/stubs/migration.blank.stub +11 -0
  84. swiftpy/console/stubs/migration.create.stub +15 -0
  85. swiftpy/console/stubs/migration.stub +11 -0
  86. swiftpy/console/stubs/migration.update.stub +17 -0
  87. swiftpy/console/stubs/model.stub +17 -0
  88. swiftpy/console/stubs/provider.stub +18 -0
  89. swiftpy/console/stubs/repository.stub +10 -0
  90. swiftpy/console/stubs/request.stub +16 -0
  91. swiftpy/console/stubs/resource.stub +21 -0
  92. swiftpy/console/stubs/schema.stub +12 -0
  93. swiftpy/console/stubs/seeder.stub +14 -0
  94. swiftpy/console/stubs/service.stub +10 -0
  95. swiftpy/core/__init__.py +0 -0
  96. swiftpy/core/application.py +1306 -0
  97. swiftpy/core/bootstrap.py +30 -0
  98. swiftpy/core/config.py +432 -0
  99. swiftpy/core/container.py +596 -0
  100. swiftpy/core/container_exceptions.py +18 -0
  101. swiftpy/core/context.py +148 -0
  102. swiftpy/core/exceptions.py +352 -0
  103. swiftpy/core/providers/base.py +35 -0
  104. swiftpy/database/__init__.py +41 -0
  105. swiftpy/database/exceptions.py +42 -0
  106. swiftpy/database/identifiers.py +105 -0
  107. swiftpy/database/migrations/__init__.py +36 -0
  108. swiftpy/database/migrations/blueprint.py +431 -0
  109. swiftpy/database/migrations/lock.py +104 -0
  110. swiftpy/database/migrations/migration.py +22 -0
  111. swiftpy/database/migrations/migrator.py +507 -0
  112. swiftpy/database/migrations/schema.py +435 -0
  113. swiftpy/database/orm/__init__.py +33 -0
  114. swiftpy/database/orm/fields.py +92 -0
  115. swiftpy/database/orm/model.py +2034 -0
  116. swiftpy/database/orm/registry.py +33 -0
  117. swiftpy/database/orm/relationships.py +836 -0
  118. swiftpy/database/pagination/__init__.py +16 -0
  119. swiftpy/database/pagination/contracts.py +60 -0
  120. swiftpy/database/pagination/paginator.py +112 -0
  121. swiftpy/database/pagination/simple_paginator.py +97 -0
  122. swiftpy/database/pool.py +218 -0
  123. swiftpy/database/query_builder.py +1803 -0
  124. swiftpy/database/seeder.py +39 -0
  125. swiftpy/database/transaction.py +276 -0
  126. swiftpy/events/__init__.py +10 -0
  127. swiftpy/events/dispatcher.py +308 -0
  128. swiftpy/events/redis_bus.py +552 -0
  129. swiftpy/filesystem/__init__.py +227 -0
  130. swiftpy/filesystem/disk.py +378 -0
  131. swiftpy/filesystem/drivers/__init__.py +11 -0
  132. swiftpy/filesystem/drivers/base.py +156 -0
  133. swiftpy/filesystem/drivers/local.py +559 -0
  134. swiftpy/filesystem/drivers/s3.py +655 -0
  135. swiftpy/filesystem/events.py +37 -0
  136. swiftpy/filesystem/exceptions.py +127 -0
  137. swiftpy/filesystem/manager.py +138 -0
  138. swiftpy/filesystem/metadata.py +22 -0
  139. swiftpy/filesystem/paths.py +99 -0
  140. swiftpy/filesystem/service_provider.py +17 -0
  141. swiftpy/filesystem/uploads/__init__.py +13 -0
  142. swiftpy/filesystem/uploads/handlers.py +362 -0
  143. swiftpy/filesystem/uploads/uploaded_file.py +222 -0
  144. swiftpy/filesystem/uploads/validators.py +186 -0
  145. swiftpy/filesystem/visibility.py +22 -0
  146. swiftpy/helpers/__init__.py +42 -0
  147. swiftpy/helpers/helper.py +180 -0
  148. swiftpy/http/exceptions.py +41 -0
  149. swiftpy/http/middleware.py +307 -0
  150. swiftpy/http/request.py +375 -0
  151. swiftpy/http/response.py +442 -0
  152. swiftpy/http/router.py +1050 -0
  153. swiftpy/mail/__init__.py +58 -0
  154. swiftpy/mail/attachments.py +255 -0
  155. swiftpy/mail/config.py +71 -0
  156. swiftpy/mail/envelope.py +183 -0
  157. swiftpy/mail/exceptions.py +90 -0
  158. swiftpy/mail/facade.py +312 -0
  159. swiftpy/mail/mailable.py +53 -0
  160. swiftpy/mail/manager.py +240 -0
  161. swiftpy/mail/message.py +186 -0
  162. swiftpy/mail/models.py +19 -0
  163. swiftpy/mail/queue/__init__.py +5 -0
  164. swiftpy/mail/queue/job.py +279 -0
  165. swiftpy/mail/service_provider.py +19 -0
  166. swiftpy/mail/templates/__init__.py +5 -0
  167. swiftpy/mail/templates/renderer.py +173 -0
  168. swiftpy/mail/transports/__init__.py +21 -0
  169. swiftpy/mail/transports/api/__init__.py +13 -0
  170. swiftpy/mail/transports/api/base.py +194 -0
  171. swiftpy/mail/transports/api/mailgun.py +158 -0
  172. swiftpy/mail/transports/api/resend.py +156 -0
  173. swiftpy/mail/transports/api/sendgrid.py +172 -0
  174. swiftpy/mail/transports/base.py +24 -0
  175. swiftpy/mail/transports/console.py +91 -0
  176. swiftpy/mail/transports/sendmail.py +128 -0
  177. swiftpy/mail/transports/smtp.py +466 -0
  178. swiftpy/queue/__init__.py +13 -0
  179. swiftpy/queue/dispatcher.py +813 -0
  180. swiftpy/queue/job.py +541 -0
  181. swiftpy/queue/worker.py +556 -0
  182. swiftpy/support/__init__.py +12 -0
  183. swiftpy/support/keys.py +108 -0
  184. swiftpy/support/redis_config.py +223 -0
  185. swiftpy/support/strings.py +114 -0
  186. swiftpy/testing/__init__.py +20 -0
  187. swiftpy/testing/fakes/__init__.py +11 -0
  188. swiftpy/testing/fakes/ai_fake.py +21 -0
  189. swiftpy/testing/fakes/vectorstore_fake.py +194 -0
  190. swiftpy/testing/fixtures.py +124 -0
  191. swiftpy/testing/http_client.py +730 -0
  192. swiftpy/testing/test_case.py +126 -0
  193. swiftpy/validation/__init__.py +97 -0
  194. swiftpy/validation/exceptions.py +26 -0
  195. swiftpy/validation/form_request.py +302 -0
  196. swiftpy/validation/protocols.py +33 -0
  197. swiftpy/validation/rules/__init__.py +89 -0
  198. swiftpy/validation/rules/builtin.py +391 -0
  199. swiftpy/validation/rules/exists.py +86 -0
  200. swiftpy/validation/rules/registry.py +290 -0
  201. swiftpy/validation/rules/required.py +55 -0
  202. swiftpy/validation/rules/unique.py +138 -0
  203. swiftpy/validation/validator.py +612 -0
  204. swiftpy/websockets/__init__.py +19 -0
  205. swiftpy/websockets/connection.py +317 -0
  206. swiftpy/websockets/manager.py +271 -0
  207. swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
  208. swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
  209. swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
  210. swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
  211. swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,417 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import dataclasses
5
+ import datetime
6
+ import enum
7
+ import json
8
+ import math
9
+ from collections.abc import Sequence
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from swiftpy.ai.exceptions import AgentLoopError, AIBudgetExceededError
13
+ from swiftpy.ai.pricing import calculate_token_cost
14
+ from swiftpy.ai.providers.base import (
15
+ LLMResponse,
16
+ Message,
17
+ TokenUsage,
18
+ ToolCall,
19
+ ToolResult,
20
+ redact_sensitive_text,
21
+ )
22
+ from swiftpy.ai.tools.base_tool import BaseTool, validate_tool_arguments
23
+
24
+ if TYPE_CHECKING:
25
+ from swiftpy.ai.client import AIClient
26
+
27
+ _INVALID_ARGS_MARKER = "__invalid_tool_arguments__"
28
+
29
+
30
+ def _to_json_safe(value: Any, _seen: set[int] | None = None) -> Any:
31
+ """Recursively convert Python values to JSON-serializable primitives."""
32
+ if value is None or isinstance(value, (bool, str)):
33
+ return value
34
+ if isinstance(value, enum.Enum):
35
+ return _to_json_safe(value.value, _seen)
36
+ if isinstance(value, int):
37
+ # Guard Python's 4300-digit integer stringification limit
38
+ if value != 0 and value.bit_length() > 14000:
39
+ return str(value)
40
+ return value
41
+ if isinstance(value, float):
42
+ if math.isnan(value):
43
+ return "NaN"
44
+ if math.isinf(value):
45
+ return "Infinity" if value > 0 else "-Infinity"
46
+ return value
47
+ if isinstance(value, complex):
48
+ return str(value)
49
+ if isinstance(value, (datetime.datetime, datetime.date, datetime.time)):
50
+ return value.isoformat()
51
+ if isinstance(value, bytes):
52
+ return value.decode("utf-8", errors="replace")
53
+
54
+ seen = _seen if _seen is not None else set()
55
+ obj_id = id(value)
56
+ if isinstance(value, (dict, list, tuple, set, frozenset)) or dataclasses.is_dataclass(value):
57
+ if obj_id in seen:
58
+ return "<circular>"
59
+ seen.add(obj_id)
60
+ try:
61
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
62
+ return {
63
+ str(k): _to_json_safe(v, seen)
64
+ for k, v in dataclasses.asdict(value).items()
65
+ }
66
+ if isinstance(value, dict):
67
+ return {str(k): _to_json_safe(v, seen) for k, v in value.items()}
68
+ if isinstance(value, (list, tuple, set, frozenset)):
69
+ return [_to_json_safe(v, seen) for v in value]
70
+ finally:
71
+ seen.discard(obj_id)
72
+
73
+ return str(value)
74
+
75
+
76
+ def serialize_tool_result(result: Any) -> str:
77
+ """Serialize any tool return value cleanly into a string payload for LLM/memory persistence."""
78
+ if isinstance(result, str):
79
+ return result
80
+ safe_val = _to_json_safe(result)
81
+ try:
82
+ return json.dumps(safe_val, allow_nan=False)
83
+ except (TypeError, ValueError):
84
+ return str(safe_val)
85
+
86
+
87
+ class ToolLoop:
88
+ """
89
+ Autonomous execution loop coordinating iterative LLM tool calling.
90
+
91
+ Dispatches requests with registered tools, intercepts model-generated tool calls,
92
+ executes matching Python tool handlers concurrently with bounded concurrency,
93
+ appends execution results back into the conversation, and repeats until the
94
+ model emits a final text response (or configured limits are reached).
95
+ """
96
+
97
+ def __init__(
98
+ self,
99
+ client: AIClient,
100
+ tools: Sequence[BaseTool],
101
+ max_iterations: int = 10,
102
+ max_tool_calls: int | None = None,
103
+ allowed_tools: Sequence[str] | None = None,
104
+ tool_timeout: float | None = None,
105
+ max_concurrency: int = 10,
106
+ max_total_tokens: int | None = None,
107
+ max_cost: float | None = None,
108
+ ) -> None:
109
+ if isinstance(max_iterations, bool) or max_iterations <= 0:
110
+ raise ValueError(f"max_iterations must be a positive integer, got {max_iterations}")
111
+ if max_tool_calls is not None and (isinstance(max_tool_calls, bool) or max_tool_calls <= 0):
112
+ raise ValueError(f"max_tool_calls must be a positive integer, got {max_tool_calls}")
113
+ if tool_timeout is not None and (isinstance(tool_timeout, bool) or tool_timeout <= 0):
114
+ raise ValueError(f"tool_timeout must be a positive float, got {tool_timeout}")
115
+ if isinstance(max_concurrency, bool) or not isinstance(max_concurrency, int) or max_concurrency <= 0:
116
+ raise ValueError(f"max_concurrency must be a positive integer, got {max_concurrency}")
117
+ if max_total_tokens is not None and (
118
+ isinstance(max_total_tokens, bool) or not isinstance(max_total_tokens, int) or max_total_tokens <= 0
119
+ ):
120
+ raise ValueError(f"max_total_tokens must be a positive integer, got {max_total_tokens}")
121
+ if max_cost is not None and (
122
+ isinstance(max_cost, bool) or not isinstance(max_cost, (int, float)) or float(max_cost) <= 0
123
+ ):
124
+ raise ValueError(f"max_cost must be a positive float, got {max_cost}")
125
+
126
+ self._client = client
127
+ self._tools = list(tools)
128
+ self._max_iterations = max_iterations
129
+ self._max_tool_calls = max_tool_calls
130
+ self._allowed_tools: set[str] | None = set(allowed_tools) if allowed_tools is not None else None
131
+ self._tool_timeout = tool_timeout
132
+ self._max_concurrency = max_concurrency
133
+ self._max_total_tokens = max_total_tokens
134
+ self._max_cost = float(max_cost) if max_cost is not None else None
135
+
136
+ self._executed_calls: list[ToolCall] = []
137
+ self._executed_results: list[ToolResult] = []
138
+ self._executed_turns: list[tuple[str, list[ToolCall], list[ToolResult]]] = []
139
+ self._iterations: int = 0
140
+ self._total_prompt_tokens: int = 0
141
+ self._total_completion_tokens: int = 0
142
+ self._total_tokens: int = 0
143
+ self._total_cost: float = 0.0
144
+
145
+ @property
146
+ def client(self) -> AIClient:
147
+ return self._client
148
+
149
+ @property
150
+ def tools(self) -> list[BaseTool]:
151
+ return list(self._tools)
152
+
153
+ @property
154
+ def max_iterations(self) -> int:
155
+ return self._max_iterations
156
+
157
+ @property
158
+ def max_tool_calls(self) -> int | None:
159
+ return self._max_tool_calls
160
+
161
+ @property
162
+ def allowed_tools(self) -> set[str] | None:
163
+ return set(self._allowed_tools) if self._allowed_tools is not None else None
164
+
165
+ @property
166
+ def tool_timeout(self) -> float | None:
167
+ return self._tool_timeout
168
+
169
+ @property
170
+ def max_concurrency(self) -> int:
171
+ return self._max_concurrency
172
+
173
+ @property
174
+ def executed_calls(self) -> list[ToolCall]:
175
+ return list(self._executed_calls)
176
+
177
+ @property
178
+ def executed_results(self) -> list[ToolResult]:
179
+ return list(self._executed_results)
180
+
181
+ @property
182
+ def executed_turns(self) -> list[tuple[str, list[ToolCall], list[ToolResult]]]:
183
+ return list(self._executed_turns)
184
+
185
+ @property
186
+ def iterations(self) -> int:
187
+ return self._iterations
188
+
189
+ @property
190
+ def total_usage(self) -> TokenUsage:
191
+ return TokenUsage(
192
+ prompt_tokens=self._total_prompt_tokens,
193
+ completion_tokens=self._total_completion_tokens,
194
+ total_tokens=self._total_tokens,
195
+ )
196
+
197
+ @property
198
+ def total_cost(self) -> float:
199
+ return self._total_cost
200
+
201
+ def _detect_tool_calls(self, response: LLMResponse) -> list[ToolCall]:
202
+ """Detect and extract tool calls from the response object or raw provider payload."""
203
+ if response.tool_calls:
204
+ return response.tool_calls
205
+
206
+ raw = response.raw
207
+ if not isinstance(raw, dict):
208
+ return []
209
+
210
+ # 1. OpenAI-style choices[0].message.tool_calls
211
+ choices = raw.get("choices")
212
+ if isinstance(choices, list) and choices:
213
+ msg = choices[0].get("message", {}) if isinstance(choices[0], dict) else {}
214
+ raw_tc = msg.get("tool_calls") if isinstance(msg, dict) else None
215
+ if isinstance(raw_tc, list):
216
+ extracted: list[ToolCall] = []
217
+ for item in raw_tc:
218
+ if isinstance(item, dict):
219
+ tc_id = str(item.get("id") or "")
220
+ fn = item.get("function") or {}
221
+ fn_name = str(fn.get("name") or "") if isinstance(fn, dict) else ""
222
+ fn_args = fn.get("arguments") if isinstance(fn, dict) else {}
223
+ if isinstance(fn_args, str):
224
+ try:
225
+ parsed_args = json.loads(fn_args)
226
+ if not isinstance(parsed_args, dict):
227
+ parsed_args = {_INVALID_ARGS_MARKER: True, "_raw": fn_args}
228
+ except (json.JSONDecodeError, TypeError, ValueError):
229
+ parsed_args = {_INVALID_ARGS_MARKER: True, "_raw": fn_args}
230
+ elif isinstance(fn_args, dict):
231
+ parsed_args = fn_args
232
+ elif fn_args is None:
233
+ parsed_args = {}
234
+ else:
235
+ parsed_args = {_INVALID_ARGS_MARKER: True, "_raw": fn_args}
236
+ extracted.append(ToolCall(id=tc_id, name=fn_name, arguments=parsed_args))
237
+ return extracted
238
+
239
+ # 2. Anthropic-style content blocks with type='tool_use'
240
+ content_blocks = raw.get("content")
241
+ if isinstance(content_blocks, list):
242
+ extracted = []
243
+ for block in content_blocks:
244
+ if isinstance(block, dict) and block.get("type") == "tool_use":
245
+ tc_id = str(block.get("id") or "")
246
+ name = str(block.get("name") or "")
247
+ input_args = block.get("input")
248
+ if isinstance(input_args, dict):
249
+ coerced_args = dict(input_args)
250
+ elif isinstance(input_args, str):
251
+ try:
252
+ parsed = json.loads(input_args)
253
+ coerced_args = (
254
+ dict(parsed)
255
+ if isinstance(parsed, dict)
256
+ else {_INVALID_ARGS_MARKER: True, "_raw": input_args}
257
+ )
258
+ except (json.JSONDecodeError, ValueError):
259
+ coerced_args = {_INVALID_ARGS_MARKER: True, "_raw": input_args}
260
+ elif input_args is None:
261
+ coerced_args = {}
262
+ else:
263
+ coerced_args = {_INVALID_ARGS_MARKER: True, "_raw": input_args}
264
+ extracted.append(ToolCall(id=tc_id, name=name, arguments=coerced_args))
265
+ if extracted:
266
+ return extracted
267
+
268
+ return []
269
+
270
+ async def _execute_single_tool(
271
+ self,
272
+ tc: ToolCall,
273
+ tool_map: dict[str, BaseTool],
274
+ semaphore: asyncio.Semaphore | None = None,
275
+ ) -> ToolResult:
276
+ """Execute a single tool call with policy, schema validation, redaction, and concurrency bounds."""
277
+ if self._allowed_tools is not None and tc.name not in self._allowed_tools:
278
+ err_msg = f"Error: Tool '{tc.name}' is not permitted by current execution policy."
279
+ return ToolResult(tool_call_id=tc.id, name=tc.name, result={"error": err_msg}, is_error=True)
280
+
281
+ tool = tool_map.get(tc.name)
282
+ if tool is None:
283
+ err_msg = f"Error: Tool '{tc.name}' not found. Available tools: {sorted(tool_map.keys())}"
284
+ return ToolResult(tool_call_id=tc.id, name=tc.name, result={"error": err_msg}, is_error=True)
285
+
286
+ if not isinstance(tc.arguments, dict) or _INVALID_ARGS_MARKER in tc.arguments:
287
+ err_msg = f"Error: Tool '{tc.name}' received invalid non-object arguments."
288
+ return ToolResult(tool_call_id=tc.id, name=tc.name, result={"error": err_msg}, is_error=True)
289
+
290
+ try:
291
+ validate_tool_arguments(tool.parameters, tc.arguments, tool.name)
292
+ except (TypeError, ValueError) as exc:
293
+ err_msg = f"Invalid arguments for tool '{tc.name}': {redact_sensitive_text(str(exc))}"
294
+ return ToolResult(tool_call_id=tc.id, name=tc.name, result={"error": err_msg}, is_error=True)
295
+
296
+ async def _invoke() -> Any:
297
+ coro = tool.handle(**tc.arguments)
298
+ if self._tool_timeout is not None:
299
+ return await asyncio.wait_for(coro, timeout=self._tool_timeout)
300
+ return await coro
301
+
302
+ try:
303
+ if semaphore is not None:
304
+ async with semaphore:
305
+ raw_result = await _invoke()
306
+ else:
307
+ raw_result = await _invoke()
308
+ return ToolResult(tool_call_id=tc.id, name=tc.name, result=raw_result, is_error=False)
309
+ except TimeoutError:
310
+ err_msg = f"Error: Tool '{tc.name}' execution timed out after {self._tool_timeout}s"
311
+ return ToolResult(tool_call_id=tc.id, name=tc.name, result={"error": err_msg}, is_error=True)
312
+ except Exception as exc: # noqa: BLE001
313
+ err_msg = f"Error executing tool '{tc.name}': {redact_sensitive_text(str(exc))}"
314
+ return ToolResult(tool_call_id=tc.id, name=tc.name, result={"error": err_msg}, is_error=True)
315
+
316
+ def _record_and_check_budget(self, response: LLMResponse) -> None:
317
+ usage = response.usage
318
+ turn_total = usage.total_tokens or (usage.prompt_tokens + usage.completion_tokens)
319
+ self._total_prompt_tokens += usage.prompt_tokens
320
+ self._total_completion_tokens += usage.completion_tokens
321
+ self._total_tokens += turn_total
322
+ self._total_cost += calculate_token_cost(
323
+ response.model or "",
324
+ usage.prompt_tokens,
325
+ usage.completion_tokens,
326
+ )
327
+
328
+ if self._max_total_tokens is not None and self._total_tokens > self._max_total_tokens:
329
+ raise AIBudgetExceededError(
330
+ f"Autonomous loop token budget exceeded: {self._total_tokens} > {self._max_total_tokens}"
331
+ )
332
+ if self._max_cost is not None and self._total_cost > self._max_cost:
333
+ raise AIBudgetExceededError(
334
+ f"Autonomous loop cost budget exceeded: ${self._total_cost:.6f} > ${self._max_cost:.6f}"
335
+ )
336
+
337
+ async def run(self, prompt: str | None = None) -> LLMResponse:
338
+ """
339
+ Execute the autonomous tool loop.
340
+
341
+ Returns the final LLMResponse when the model emits a text answer.
342
+ Raises AgentLoopError or AIBudgetExceededError if guards are exceeded.
343
+ """
344
+ if prompt:
345
+ self._client.user(prompt)
346
+
347
+ self._client.tools(self._tools)
348
+ tool_map = {t.name: t for t in self._tools}
349
+ semaphore = asyncio.Semaphore(self._max_concurrency)
350
+
351
+ was_executing = getattr(self._client, "_is_executing_tool_loop", False)
352
+ prev_allowed = getattr(self._client, "_allowed_tools", None)
353
+ prev_suppress = getattr(self._client, "_suppress_tools_in_request", False)
354
+
355
+ self._client._is_executing_tool_loop = True
356
+ if self._allowed_tools is not None:
357
+ self._client._allowed_tools = sorted(self._allowed_tools)
358
+
359
+ try:
360
+ iteration = 0
361
+ while iteration < self._max_iterations:
362
+ self._client._suppress_tools_in_request = (
363
+ self._max_tool_calls is not None
364
+ and len(self._executed_calls) >= self._max_tool_calls
365
+ )
366
+ response = await self._client.send()
367
+ self._record_and_check_budget(response)
368
+
369
+ tool_calls = self._detect_tool_calls(response)
370
+ if not tool_calls:
371
+ self._iterations = iteration + 1
372
+ return response
373
+
374
+ if self._max_tool_calls is not None and (
375
+ len(self._executed_calls) >= self._max_tool_calls
376
+ or len(self._executed_calls) + len(tool_calls) > self._max_tool_calls
377
+ ):
378
+ raise AgentLoopError(
379
+ f"Autonomous agent tool loop exceeded maximum tool calls guard ({self._max_tool_calls}).",
380
+ iterations=iteration,
381
+ )
382
+
383
+ self._client._messages.append(
384
+ Message(
385
+ role="assistant",
386
+ content=response.content,
387
+ tool_calls=tool_calls,
388
+ )
389
+ )
390
+
391
+ tasks = [self._execute_single_tool(tc, tool_map, semaphore) for tc in tool_calls]
392
+ results = list(await asyncio.gather(*tasks))
393
+
394
+ self._executed_turns.append((response.content, list(tool_calls), results))
395
+ for tc, res in zip(tool_calls, results):
396
+ self._executed_calls.append(tc)
397
+ self._executed_results.append(res)
398
+ self._client._messages.append(
399
+ Message(
400
+ role="tool",
401
+ content=serialize_tool_result(res.result),
402
+ name=tc.name,
403
+ tool_call_id=tc.id,
404
+ )
405
+ )
406
+
407
+ iteration += 1
408
+ self._iterations = iteration
409
+
410
+ raise AgentLoopError(
411
+ f"Autonomous agent tool loop exceeded maximum iteration guard ({self._max_iterations}).",
412
+ iterations=iteration,
413
+ )
414
+ finally:
415
+ self._client._is_executing_tool_loop = was_executing
416
+ self._client._allowed_tools = prev_allowed
417
+ self._client._suppress_tools_in_request = prev_suppress
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from swiftpy.ai.vectorstore.base import (
4
+ ALLOWED_METRICS,
5
+ SearchResult,
6
+ VectorRecord,
7
+ VectorStore,
8
+ VectorStoreCapabilities,
9
+ handle_adapter_http_error,
10
+ normalize_record,
11
+ sanitize_error_message,
12
+ validate_vector,
13
+ )
14
+ from swiftpy.ai.vectorstore.chroma_adapter import ChromaAdapter
15
+ from swiftpy.ai.vectorstore.in_memory_adapter import InMemoryVectorStore
16
+ from swiftpy.ai.vectorstore.lancedb_adapter import LanceDbAdapter
17
+ from swiftpy.ai.vectorstore.pgvector_adapter import PgVectorAdapter
18
+ from swiftpy.ai.vectorstore.pinecone_adapter import PineconeAdapter
19
+ from swiftpy.ai.vectorstore.qdrant_adapter import QdrantAdapter
20
+
21
+ # Register all built-in vector store adapters with the VectorStore facade
22
+ VectorStore.register_adapter("in_memory", InMemoryVectorStore)
23
+ VectorStore.register_adapter("pgvector", PgVectorAdapter)
24
+ VectorStore.register_adapter("postgres", PgVectorAdapter)
25
+ VectorStore.register_adapter("qdrant", QdrantAdapter)
26
+ VectorStore.register_adapter("lancedb", LanceDbAdapter)
27
+ VectorStore.register_adapter("chroma", ChromaAdapter)
28
+ VectorStore.register_adapter("pinecone", PineconeAdapter)
29
+
30
+ __all__ = [
31
+ "ALLOWED_METRICS",
32
+ "ChromaAdapter",
33
+ "InMemoryVectorStore",
34
+ "LanceDbAdapter",
35
+ "PgVectorAdapter",
36
+ "PineconeAdapter",
37
+ "QdrantAdapter",
38
+ "SearchResult",
39
+ "VectorRecord",
40
+ "VectorStore",
41
+ "VectorStoreCapabilities",
42
+ "handle_adapter_http_error",
43
+ "normalize_record",
44
+ "sanitize_error_message",
45
+ "validate_vector",
46
+ ]