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.
- swiftpy/.gitignore +0 -0
- swiftpy/__init__.py +572 -0
- swiftpy/admin/__init__.py +50 -0
- swiftpy/admin/auth.py +522 -0
- swiftpy/admin/registry.py +567 -0
- swiftpy/admin/views.py +1583 -0
- swiftpy/ai/__init__.py +199 -0
- swiftpy/ai/_deterministic.py +40 -0
- swiftpy/ai/_fake.py +604 -0
- swiftpy/ai/agents/__init__.py +30 -0
- swiftpy/ai/agents/base_agent.py +506 -0
- swiftpy/ai/agents/memory.py +864 -0
- swiftpy/ai/client.py +1011 -0
- swiftpy/ai/conversation.py +171 -0
- swiftpy/ai/embeddings.py +321 -0
- swiftpy/ai/exceptions.py +75 -0
- swiftpy/ai/history_strategies.py +157 -0
- swiftpy/ai/middleware.py +918 -0
- swiftpy/ai/observability/__init__.py +28 -0
- swiftpy/ai/observability/drivers.py +763 -0
- swiftpy/ai/observability/observer.py +364 -0
- swiftpy/ai/pool.py +102 -0
- swiftpy/ai/pricing.py +101 -0
- swiftpy/ai/prompts/__init__.py +6 -0
- swiftpy/ai/prompts/loader.py +343 -0
- swiftpy/ai/providers/__init__.py +22 -0
- swiftpy/ai/providers/anthropic_provider.py +353 -0
- swiftpy/ai/providers/base.py +273 -0
- swiftpy/ai/providers/openai_provider.py +275 -0
- swiftpy/ai/rag/__init__.py +53 -0
- swiftpy/ai/rag/context_formatter.py +263 -0
- swiftpy/ai/rag/pipeline_builder.py +544 -0
- swiftpy/ai/rag/reranker.py +484 -0
- swiftpy/ai/rag/result.py +78 -0
- swiftpy/ai/rag/retriever.py +315 -0
- swiftpy/ai/redis_store.py +227 -0
- swiftpy/ai/streaming.py +115 -0
- swiftpy/ai/tools/__init__.py +22 -0
- swiftpy/ai/tools/base_tool.py +489 -0
- swiftpy/ai/tools/first_party.py +384 -0
- swiftpy/ai/tools/tool_loop.py +417 -0
- swiftpy/ai/vectorstore/__init__.py +46 -0
- swiftpy/ai/vectorstore/base.py +542 -0
- swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
- swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
- swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
- swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
- swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
- swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
- swiftpy/auth/__init__.py +50 -0
- swiftpy/auth/api_key_guard.py +544 -0
- swiftpy/auth/auth_manager.py +259 -0
- swiftpy/auth/exceptions.py +71 -0
- swiftpy/auth/jwt_guard.py +230 -0
- swiftpy/auth/jwt_issuer.py +658 -0
- swiftpy/auth/token_blacklist.py +300 -0
- swiftpy/cache/__init__.py +14 -0
- swiftpy/cache/cache.py +251 -0
- swiftpy/cache/driver.py +216 -0
- swiftpy/cache/drivers/memory.py +199 -0
- swiftpy/cache/drivers/redis.py +421 -0
- swiftpy/console/__init__.py +0 -0
- swiftpy/console/cli.py +68 -0
- swiftpy/console/commands/__init__.py +0 -0
- swiftpy/console/commands/cache.py +164 -0
- swiftpy/console/commands/make.py +935 -0
- swiftpy/console/commands/migrate.py +280 -0
- swiftpy/console/commands/new.py +1806 -0
- swiftpy/console/commands/queue.py +306 -0
- swiftpy/console/commands/seed.py +252 -0
- swiftpy/console/commands/start.py +171 -0
- swiftpy/console/commands/storage.py +104 -0
- swiftpy/console/loader.py +21 -0
- swiftpy/console/stubs/__init__.py +26 -0
- swiftpy/console/stubs/controller.stub +14 -0
- swiftpy/console/stubs/event.stub +14 -0
- swiftpy/console/stubs/job.stub +22 -0
- swiftpy/console/stubs/listener.stub +14 -0
- swiftpy/console/stubs/mail.stub +20 -0
- swiftpy/console/stubs/middleware.stub +16 -0
- swiftpy/console/stubs/migration.alter.stub +17 -0
- swiftpy/console/stubs/migration.api_keys.stub +22 -0
- swiftpy/console/stubs/migration.blank.stub +11 -0
- swiftpy/console/stubs/migration.create.stub +15 -0
- swiftpy/console/stubs/migration.stub +11 -0
- swiftpy/console/stubs/migration.update.stub +17 -0
- swiftpy/console/stubs/model.stub +17 -0
- swiftpy/console/stubs/provider.stub +18 -0
- swiftpy/console/stubs/repository.stub +10 -0
- swiftpy/console/stubs/request.stub +16 -0
- swiftpy/console/stubs/resource.stub +21 -0
- swiftpy/console/stubs/schema.stub +12 -0
- swiftpy/console/stubs/seeder.stub +14 -0
- swiftpy/console/stubs/service.stub +10 -0
- swiftpy/core/__init__.py +0 -0
- swiftpy/core/application.py +1306 -0
- swiftpy/core/bootstrap.py +30 -0
- swiftpy/core/config.py +432 -0
- swiftpy/core/container.py +596 -0
- swiftpy/core/container_exceptions.py +18 -0
- swiftpy/core/context.py +148 -0
- swiftpy/core/exceptions.py +352 -0
- swiftpy/core/providers/base.py +35 -0
- swiftpy/database/__init__.py +41 -0
- swiftpy/database/exceptions.py +42 -0
- swiftpy/database/identifiers.py +105 -0
- swiftpy/database/migrations/__init__.py +36 -0
- swiftpy/database/migrations/blueprint.py +431 -0
- swiftpy/database/migrations/lock.py +104 -0
- swiftpy/database/migrations/migration.py +22 -0
- swiftpy/database/migrations/migrator.py +507 -0
- swiftpy/database/migrations/schema.py +435 -0
- swiftpy/database/orm/__init__.py +33 -0
- swiftpy/database/orm/fields.py +92 -0
- swiftpy/database/orm/model.py +2034 -0
- swiftpy/database/orm/registry.py +33 -0
- swiftpy/database/orm/relationships.py +836 -0
- swiftpy/database/pagination/__init__.py +16 -0
- swiftpy/database/pagination/contracts.py +60 -0
- swiftpy/database/pagination/paginator.py +112 -0
- swiftpy/database/pagination/simple_paginator.py +97 -0
- swiftpy/database/pool.py +218 -0
- swiftpy/database/query_builder.py +1803 -0
- swiftpy/database/seeder.py +39 -0
- swiftpy/database/transaction.py +276 -0
- swiftpy/events/__init__.py +10 -0
- swiftpy/events/dispatcher.py +308 -0
- swiftpy/events/redis_bus.py +552 -0
- swiftpy/filesystem/__init__.py +227 -0
- swiftpy/filesystem/disk.py +378 -0
- swiftpy/filesystem/drivers/__init__.py +11 -0
- swiftpy/filesystem/drivers/base.py +156 -0
- swiftpy/filesystem/drivers/local.py +559 -0
- swiftpy/filesystem/drivers/s3.py +655 -0
- swiftpy/filesystem/events.py +37 -0
- swiftpy/filesystem/exceptions.py +127 -0
- swiftpy/filesystem/manager.py +138 -0
- swiftpy/filesystem/metadata.py +22 -0
- swiftpy/filesystem/paths.py +99 -0
- swiftpy/filesystem/service_provider.py +17 -0
- swiftpy/filesystem/uploads/__init__.py +13 -0
- swiftpy/filesystem/uploads/handlers.py +362 -0
- swiftpy/filesystem/uploads/uploaded_file.py +222 -0
- swiftpy/filesystem/uploads/validators.py +186 -0
- swiftpy/filesystem/visibility.py +22 -0
- swiftpy/helpers/__init__.py +42 -0
- swiftpy/helpers/helper.py +180 -0
- swiftpy/http/exceptions.py +41 -0
- swiftpy/http/middleware.py +307 -0
- swiftpy/http/request.py +375 -0
- swiftpy/http/response.py +442 -0
- swiftpy/http/router.py +1050 -0
- swiftpy/mail/__init__.py +58 -0
- swiftpy/mail/attachments.py +255 -0
- swiftpy/mail/config.py +71 -0
- swiftpy/mail/envelope.py +183 -0
- swiftpy/mail/exceptions.py +90 -0
- swiftpy/mail/facade.py +312 -0
- swiftpy/mail/mailable.py +53 -0
- swiftpy/mail/manager.py +240 -0
- swiftpy/mail/message.py +186 -0
- swiftpy/mail/models.py +19 -0
- swiftpy/mail/queue/__init__.py +5 -0
- swiftpy/mail/queue/job.py +279 -0
- swiftpy/mail/service_provider.py +19 -0
- swiftpy/mail/templates/__init__.py +5 -0
- swiftpy/mail/templates/renderer.py +173 -0
- swiftpy/mail/transports/__init__.py +21 -0
- swiftpy/mail/transports/api/__init__.py +13 -0
- swiftpy/mail/transports/api/base.py +194 -0
- swiftpy/mail/transports/api/mailgun.py +158 -0
- swiftpy/mail/transports/api/resend.py +156 -0
- swiftpy/mail/transports/api/sendgrid.py +172 -0
- swiftpy/mail/transports/base.py +24 -0
- swiftpy/mail/transports/console.py +91 -0
- swiftpy/mail/transports/sendmail.py +128 -0
- swiftpy/mail/transports/smtp.py +466 -0
- swiftpy/queue/__init__.py +13 -0
- swiftpy/queue/dispatcher.py +813 -0
- swiftpy/queue/job.py +541 -0
- swiftpy/queue/worker.py +556 -0
- swiftpy/support/__init__.py +12 -0
- swiftpy/support/keys.py +108 -0
- swiftpy/support/redis_config.py +223 -0
- swiftpy/support/strings.py +114 -0
- swiftpy/testing/__init__.py +20 -0
- swiftpy/testing/fakes/__init__.py +11 -0
- swiftpy/testing/fakes/ai_fake.py +21 -0
- swiftpy/testing/fakes/vectorstore_fake.py +194 -0
- swiftpy/testing/fixtures.py +124 -0
- swiftpy/testing/http_client.py +730 -0
- swiftpy/testing/test_case.py +126 -0
- swiftpy/validation/__init__.py +97 -0
- swiftpy/validation/exceptions.py +26 -0
- swiftpy/validation/form_request.py +302 -0
- swiftpy/validation/protocols.py +33 -0
- swiftpy/validation/rules/__init__.py +89 -0
- swiftpy/validation/rules/builtin.py +391 -0
- swiftpy/validation/rules/exists.py +86 -0
- swiftpy/validation/rules/registry.py +290 -0
- swiftpy/validation/rules/required.py +55 -0
- swiftpy/validation/rules/unique.py +138 -0
- swiftpy/validation/validator.py +612 -0
- swiftpy/websockets/__init__.py +19 -0
- swiftpy/websockets/connection.py +317 -0
- swiftpy/websockets/manager.py +271 -0
- swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
- swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
- swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
- swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|