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,506 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import uuid
6
+ import weakref
7
+ from collections.abc import Coroutine, Generator, Sequence
8
+ from typing import Any, ClassVar
9
+
10
+ from swiftpy.ai.agents.memory import (
11
+ AgentMemory,
12
+ BaseMemoryBackend,
13
+ InMemoryMemoryBackend,
14
+ )
15
+ from swiftpy.ai.client import AIClient
16
+ from swiftpy.ai.exceptions import AgentLoopError
17
+ from swiftpy.ai.providers.base import (
18
+ LLMResponse,
19
+ Message,
20
+ TokenUsage,
21
+ ToolCall,
22
+ ToolResult,
23
+ )
24
+ from swiftpy.ai.tools.base_tool import BaseTool
25
+ from swiftpy.ai.tools.tool_loop import ToolLoop, serialize_tool_result
26
+ from swiftpy.core.config import Config
27
+
28
+ logger = logging.getLogger("swiftpy.ai.agents.base_agent")
29
+
30
+
31
+ class AgentResponse:
32
+ """
33
+ Structured outcome of an autonomous agent execution turn.
34
+ Exposes final text answer, executed tool calls, tool results, and telemetry.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ content: str,
40
+ role: str = "assistant",
41
+ tool_calls: list[ToolCall] | None = None,
42
+ executed_calls: list[ToolCall] | None = None,
43
+ executed_results: list[ToolResult] | None = None,
44
+ session_id: str = "default",
45
+ iterations: int = 1,
46
+ usage: TokenUsage | None = None,
47
+ model: str = "",
48
+ raw: dict[str, Any] | None = None,
49
+ ) -> None:
50
+ self.content = content
51
+ self.role = role
52
+ self.tool_calls = list(tool_calls or [])
53
+ self.executed_calls = list(executed_calls or [])
54
+ self.executed_results = list(executed_results or [])
55
+ self.session_id = session_id
56
+ self.iterations = iterations
57
+ self.usage = usage or TokenUsage()
58
+ self.model = model
59
+ self.raw = raw or {}
60
+
61
+ def __str__(self) -> str:
62
+ return self.content
63
+
64
+ def __repr__(self) -> str:
65
+ return (
66
+ f"AgentResponse(session_id={self.session_id!r}, "
67
+ f"iterations={self.iterations}, tool_calls={len(self.executed_calls)}, "
68
+ f"content={self.content[:40]!r})"
69
+ )
70
+
71
+
72
+ class AgentTracker:
73
+ """
74
+ Application-level tracker for actively executing background agent tasks.
75
+ Enables concurrency monitoring, task registry, and graceful cancellation on shutdown.
76
+ """
77
+
78
+ _instance: ClassVar[AgentTracker | None] = None
79
+
80
+ def __init__(self) -> None:
81
+ self._tasks: dict[str, AgentTask] = {}
82
+
83
+ @classmethod
84
+ def get_instance(cls) -> AgentTracker:
85
+ if cls._instance is None:
86
+ cls._instance = cls()
87
+ return cls._instance
88
+
89
+ @classmethod
90
+ def reset(cls) -> None:
91
+ """Reset the singleton instance (primarily for tests)."""
92
+ cls._instance = None
93
+
94
+ def register(self, task: AgentTask) -> None:
95
+ self._tasks[task.task_id] = task
96
+
97
+ def unregister(self, task_id: str) -> None:
98
+ self._tasks.pop(task_id, None)
99
+
100
+ @property
101
+ def active_tasks(self) -> list[AgentTask]:
102
+ return [t for t in self._tasks.values() if t.is_running]
103
+
104
+ @property
105
+ def active_count(self) -> int:
106
+ return len(self.active_tasks)
107
+
108
+ async def cancel_all(self, timeout: float = 5.0) -> int:
109
+ """Cancel all registered active agent tasks gracefully."""
110
+ active = self.active_tasks
111
+ if not active:
112
+ return 0
113
+ for t in active:
114
+ t.cancel()
115
+ count = len(active)
116
+ raw_tasks = [t._task for t in active]
117
+ if raw_tasks:
118
+ try:
119
+ await asyncio.wait_for(
120
+ asyncio.gather(*raw_tasks, return_exceptions=True),
121
+ timeout=timeout,
122
+ )
123
+ except TimeoutError:
124
+ pass
125
+ self._tasks.clear()
126
+ return count
127
+
128
+
129
+ class AgentTask:
130
+ """
131
+ Handle for an agent running as a background asyncio task.
132
+ Safely captures exceptions to adhere to SwiftPY Concurrency rules (The Task Rule).
133
+ Provides awaitable execution, status polling, cancellation, and result retrieval.
134
+ """
135
+
136
+ def __init__(
137
+ self,
138
+ coro: Coroutine[Any, Any, AgentResponse],
139
+ session_id: str = "default",
140
+ task_id: str | None = None,
141
+ ) -> None:
142
+ self.task_id = task_id or uuid.uuid4().hex[:12]
143
+ self.session_id = session_id
144
+ self._status = "running"
145
+ self._result: AgentResponse | None = None
146
+ self._exception: BaseException | None = None
147
+
148
+ self._task: asyncio.Task[AgentResponse] = asyncio.create_task(coro)
149
+ self._task.add_done_callback(self._on_done)
150
+ AgentTracker.get_instance().register(self)
151
+
152
+ def _on_done(self, t: asyncio.Task[AgentResponse]) -> None:
153
+ AgentTracker.get_instance().unregister(self.task_id)
154
+ if t.cancelled():
155
+ self._status = "cancelled"
156
+ return
157
+ exc = t.exception()
158
+ if exc is not None:
159
+ self._status = "failed"
160
+ self._exception = exc
161
+ logger.exception(
162
+ "Background agent task '%s' (session '%s') failed: %s",
163
+ self.task_id,
164
+ self.session_id,
165
+ exc,
166
+ exc_info=exc,
167
+ )
168
+ else:
169
+ self._status = "completed"
170
+ self._result = t.result()
171
+
172
+ @property
173
+ def status(self) -> str:
174
+ """Current lifecycle status: 'running', 'completed', 'failed', or 'cancelled'."""
175
+ return self._status
176
+
177
+ @property
178
+ def is_done(self) -> bool:
179
+ """Return True if background execution has concluded (success, failure, or cancelled)."""
180
+ return self._task.done()
181
+
182
+ @property
183
+ def is_running(self) -> bool:
184
+ """Return True if the background task is actively running."""
185
+ return not self._task.done()
186
+
187
+ def result(self) -> AgentResponse:
188
+ """
189
+ Return the AgentResponse if execution succeeded.
190
+ Raises RuntimeError if not done, or re-raises any exception if execution failed.
191
+ """
192
+ if not self._task.done():
193
+ raise RuntimeError(f"Agent task '{self.task_id}' is still running.")
194
+ if self._exception is not None:
195
+ raise self._exception
196
+ if self._result is None:
197
+ raise RuntimeError(f"Agent task '{self.task_id}' produced no result (status: {self._status}).")
198
+ return self._result
199
+
200
+ def exception(self) -> BaseException | None:
201
+ """Return the exception raised by the task, or None if completed successfully or still running."""
202
+ return self._exception
203
+
204
+ def cancel(self) -> bool:
205
+ """Cancel the background agent task if it has not already finished."""
206
+ if self._task.done():
207
+ return False
208
+ cancelled = self._task.cancel()
209
+ if cancelled:
210
+ self._status = "cancelled"
211
+ return cancelled
212
+
213
+ async def wait(self, timeout: float | None = None) -> AgentResponse:
214
+ """Wait for the background task to complete and return the AgentResponse."""
215
+ if timeout is not None:
216
+ return await asyncio.wait_for(self._task, timeout=timeout)
217
+ return await self._task
218
+
219
+ def __await__(self) -> Generator[Any, None, AgentResponse]:
220
+ """Allow direct awaiting: response = await agent.run_background(...)"""
221
+ return self._task.__await__()
222
+
223
+
224
+ class BaseAgent:
225
+ """
226
+ Autonomous agent coordinating system prompt enforcement, memory management,
227
+ iteration limits, and tool-calling loops. Runs directly or as non-blocking
228
+ background asyncio tasks.
229
+ """
230
+
231
+ def __init__(
232
+ self,
233
+ name: str = "Agent",
234
+ *,
235
+ system_prompt: str = "You are a helpful AI assistant.",
236
+ tools: Sequence[BaseTool | type[BaseTool]] | None = None,
237
+ memory: AgentMemory | BaseMemoryBackend | None = None,
238
+ client: AIClient | None = None,
239
+ model: str | None = None,
240
+ provider: str | None = None,
241
+ max_iterations: int = 10,
242
+ max_tool_calls: int | None = None,
243
+ tool_timeout: float | None = None,
244
+ allowed_tools: Sequence[str] | None = None,
245
+ session_id: str | None = None,
246
+ config: Config | None = None,
247
+ max_tool_concurrency: int = 10,
248
+ max_total_tokens: int | None = None,
249
+ max_cost: float | None = None,
250
+ ) -> None:
251
+ if not isinstance(system_prompt, str) or not system_prompt.strip():
252
+ raise ValueError("system_prompt must be a non-empty string.")
253
+ if isinstance(max_iterations, bool) or max_iterations <= 0:
254
+ raise ValueError(f"max_iterations must be a positive integer, got {max_iterations}")
255
+ if max_tool_calls is not None and (isinstance(max_tool_calls, bool) or max_tool_calls <= 0):
256
+ raise ValueError(f"max_tool_calls must be a positive integer, got {max_tool_calls}")
257
+ if tool_timeout is not None and (isinstance(tool_timeout, bool) or tool_timeout <= 0):
258
+ raise ValueError(f"tool_timeout must be a positive float, got {tool_timeout}")
259
+ if (
260
+ isinstance(max_tool_concurrency, bool)
261
+ or not isinstance(max_tool_concurrency, int)
262
+ or max_tool_concurrency <= 0
263
+ ):
264
+ raise ValueError(
265
+ f"max_tool_concurrency must be a positive integer, got {max_tool_concurrency}"
266
+ )
267
+ if max_total_tokens is not None and (
268
+ isinstance(max_total_tokens, bool)
269
+ or not isinstance(max_total_tokens, int)
270
+ or max_total_tokens <= 0
271
+ ):
272
+ raise ValueError(
273
+ f"max_total_tokens must be a positive integer, got {max_total_tokens}"
274
+ )
275
+ if max_cost is not None and (
276
+ isinstance(max_cost, bool)
277
+ or not isinstance(max_cost, (int, float))
278
+ or float(max_cost) <= 0
279
+ ):
280
+ raise ValueError(f"max_cost must be a positive float, got {max_cost}")
281
+
282
+ self.name = name
283
+ self.system_prompt = system_prompt
284
+ self.max_iterations = max_iterations
285
+ self.max_tool_calls = max_tool_calls
286
+ self.tool_timeout = tool_timeout
287
+ self.max_tool_concurrency = max_tool_concurrency
288
+ self.max_total_tokens = max_total_tokens
289
+ self.max_cost = float(max_cost) if max_cost is not None else None
290
+ self.allowed_tools = set(allowed_tools) if allowed_tools is not None else None
291
+ self.default_session_id = session_id or uuid.uuid4().hex[:12]
292
+ self._config = config
293
+ self._model = model
294
+ self._provider = provider
295
+ self._session_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
296
+ weakref.WeakValueDictionary()
297
+ )
298
+
299
+ # Memory configuration
300
+ if isinstance(memory, AgentMemory):
301
+ self._memory = memory
302
+ elif isinstance(memory, BaseMemoryBackend):
303
+ self._memory = AgentMemory(backend=memory, session_id=self.default_session_id)
304
+ else:
305
+ self._memory = AgentMemory(backend=InMemoryMemoryBackend(), session_id=self.default_session_id)
306
+
307
+ # Tools configuration (instantiate classes, isolate copies)
308
+ self._tools: list[BaseTool] = []
309
+ if tools:
310
+ for t in tools:
311
+ if isinstance(t, type) and issubclass(t, BaseTool):
312
+ self._tools.append(t())
313
+ elif isinstance(t, BaseTool):
314
+ self._tools.append(t)
315
+ else:
316
+ raise TypeError(f"Expected BaseTool instance or subclass, got {type(t).__name__}")
317
+
318
+ # Client template or fresh instance
319
+ self._client_template = client
320
+
321
+ def _get_session_lock(self, session_id: str) -> asyncio.Lock:
322
+ """Return an asyncio.Lock scoped to session_id without retaining stale locks forever."""
323
+ lock = self._session_locks.get(session_id)
324
+ if lock is None:
325
+ lock = asyncio.Lock()
326
+ self._session_locks[session_id] = lock
327
+ return lock
328
+
329
+ @property
330
+ def memory(self) -> AgentMemory:
331
+ """Return the default AgentMemory instance."""
332
+ return self._memory
333
+
334
+ @property
335
+ def tools(self) -> list[BaseTool]:
336
+ """Return a defensive copy of registered tools."""
337
+ return list(self._tools)
338
+
339
+ def get_session_memory(self, session_id: str) -> AgentMemory:
340
+ """Retrieve memory bound to a specific session ID."""
341
+ if session_id == self._memory.session_id:
342
+ return self._memory
343
+ return self._memory.fork(session_id)
344
+
345
+ def _create_isolated_client(self) -> AIClient:
346
+ """
347
+ Create a dedicated, isolated AIClient instance for an execution turn.
348
+ Guarantees that concurrent agent executions never share message buffers,
349
+ options, middlewares, or mutate global provider registries.
350
+ """
351
+ if self._client_template is not None:
352
+ return self._client_template.clone(
353
+ include_messages=False,
354
+ include_conversation=False,
355
+ )
356
+
357
+ client = AIClient(config=self._config, default_provider=self._provider)
358
+ if self._model:
359
+ client.model(self._model)
360
+ return client
361
+
362
+ async def run(
363
+ self,
364
+ prompt: str,
365
+ *,
366
+ session_id: str | None = None,
367
+ ) -> AgentResponse:
368
+ """
369
+ Execute the autonomous agent loop synchronously (from the caller's coroutine).
370
+ Serializes same-session turns, enforces system prompt, loads prior messages
371
+ from memory, executes tools, persists conversation turns, and returns AgentResponse.
372
+ """
373
+ if not isinstance(prompt, str) or not prompt.strip():
374
+ raise ValueError("Prompt must be a non-empty string.")
375
+
376
+ sid = session_id or self.default_session_id
377
+ session_mem = self.get_session_memory(sid)
378
+ session_lock = self._get_session_lock(sid)
379
+
380
+ async with session_lock:
381
+ # 1. Load existing dialogue history from memory backend
382
+ history = await session_mem.get_messages()
383
+
384
+ # 2. Build dialogue messages, enforcing a single current system prompt at index 0
385
+ ordered_messages: list[Message] = [
386
+ Message(role="system", content=self.system_prompt)
387
+ ]
388
+ has_system = any(msg.role == "system" for msg in history)
389
+ for msg in history:
390
+ if msg.role != "system":
391
+ ordered_messages.append(msg)
392
+
393
+ # 3. Append the new user prompt
394
+ user_message = Message(role="user", content=prompt)
395
+ ordered_messages.append(user_message)
396
+
397
+ # 4. Create an isolated client and load ordered messages
398
+ client = self._create_isolated_client()
399
+ for msg in ordered_messages:
400
+ client._messages.append(msg)
401
+
402
+ # 5. Initialize isolated ToolLoop with configured tools, budgets, and limits
403
+ loop = ToolLoop(
404
+ client=client,
405
+ tools=self._tools,
406
+ max_iterations=self.max_iterations,
407
+ max_tool_calls=self.max_tool_calls,
408
+ allowed_tools=list(self.allowed_tools) if self.allowed_tools is not None else None,
409
+ tool_timeout=self.tool_timeout,
410
+ max_concurrency=self.max_tool_concurrency,
411
+ max_total_tokens=self.max_total_tokens,
412
+ max_cost=self.max_cost,
413
+ )
414
+
415
+ try:
416
+ llm_response: LLMResponse = await loop.run()
417
+ except AgentLoopError as exc:
418
+ logger.warning("Agent '%s' tool loop reached limit: %s", self.name, exc)
419
+ raise
420
+
421
+ # 6. Capture new dialogue turns generated during this run and persist to memory
422
+ new_messages: list[Message] = []
423
+ if not has_system:
424
+ new_messages.append(Message(role="system", content=self.system_prompt))
425
+ new_messages.append(user_message)
426
+
427
+ # Grouped assistant/tool turns preserve parallel tool calls per turn
428
+ for assistant_content, turn_calls, turn_results in loop.executed_turns:
429
+ new_messages.append(
430
+ Message(
431
+ role="assistant",
432
+ content=assistant_content,
433
+ tool_calls=turn_calls,
434
+ )
435
+ )
436
+ for tc, res in zip(turn_calls, turn_results):
437
+ new_messages.append(
438
+ Message(
439
+ role="tool",
440
+ content=serialize_tool_result(res.result),
441
+ name=tc.name,
442
+ tool_call_id=tc.id,
443
+ )
444
+ )
445
+
446
+ # Final assistant text response
447
+ assistant_message = Message(
448
+ role="assistant",
449
+ content=llm_response.content,
450
+ tool_calls=llm_response.tool_calls if llm_response.tool_calls else None,
451
+ )
452
+ new_messages.append(assistant_message)
453
+
454
+ # Persist messages atomically where supported or sequentially within session lock
455
+ for msg in new_messages:
456
+ await session_mem.add_message(msg)
457
+
458
+ # 7. Construct AgentResponse
459
+ return AgentResponse(
460
+ content=llm_response.content,
461
+ role=llm_response.role,
462
+ tool_calls=llm_response.tool_calls,
463
+ executed_calls=loop.executed_calls,
464
+ executed_results=loop.executed_results,
465
+ session_id=sid,
466
+ iterations=loop.iterations,
467
+ usage=loop.total_usage,
468
+ model=llm_response.model,
469
+ raw=llm_response.raw,
470
+ )
471
+
472
+ def run_background(
473
+ self,
474
+ prompt: str,
475
+ *,
476
+ session_id: str | None = None,
477
+ ) -> AgentTask:
478
+ """
479
+ Run the agent as a non-blocking background asyncio task.
480
+ Adheres to Concurrency rules by attaching an exception logging callback.
481
+ Returns an AgentTask handle.
482
+ """
483
+ coro = self.run(prompt, session_id=session_id)
484
+ sid = session_id or self.default_session_id
485
+ return AgentTask(coro, session_id=sid)
486
+
487
+ def run_in_background(
488
+ self,
489
+ prompt: str,
490
+ *,
491
+ session_id: str | None = None,
492
+ ) -> AgentTask:
493
+ """Alias for run_background()."""
494
+ return self.run_background(prompt, session_id=session_id)
495
+
496
+
497
+ # Class alias
498
+ Agent = BaseAgent
499
+
500
+ __all__ = [
501
+ "Agent",
502
+ "AgentResponse",
503
+ "AgentTask",
504
+ "AgentTracker",
505
+ "BaseAgent",
506
+ ]