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,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
|
+
]
|