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,489 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import enum
|
|
5
|
+
import functools
|
|
6
|
+
import inspect
|
|
7
|
+
import math
|
|
8
|
+
import re
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
11
|
+
from types import UnionType
|
|
12
|
+
from typing import Any, Literal, Union, get_args, get_origin, get_type_hints, overload
|
|
13
|
+
|
|
14
|
+
_TOOL_NAME_PATTERN = re.compile(r"\A[a-zA-Z0-9_-]+\Z")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BaseTool(ABC):
|
|
18
|
+
"""
|
|
19
|
+
Abstract contract for executable tools in SwiftPY AI agents and tool loops.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def name(self) -> str:
|
|
25
|
+
"""The unique name of the tool, matching ^[a-zA-Z0-9_-]+$."""
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def description(self) -> str:
|
|
30
|
+
"""A detailed description of what the tool does and when the LLM should invoke it."""
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def parameters(self) -> dict[str, Any]:
|
|
35
|
+
"""JSON Schema dictionary describing the parameters required by this tool."""
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
async def handle(self, *args: Any, **kwargs: Any) -> Any:
|
|
39
|
+
"""
|
|
40
|
+
Execute the tool asynchronously with arguments provided by the model.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> dict[str, Any]:
|
|
44
|
+
"""
|
|
45
|
+
Format the tool schema as an OpenAI-compatible function definition.
|
|
46
|
+
"""
|
|
47
|
+
return {
|
|
48
|
+
"type": "function",
|
|
49
|
+
"function": {
|
|
50
|
+
"name": self.name,
|
|
51
|
+
"description": self.description,
|
|
52
|
+
"parameters": self.parameters,
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
def to_anthropic_dict(self) -> dict[str, Any]:
|
|
57
|
+
"""
|
|
58
|
+
Format the tool schema as an Anthropic-compatible tool definition.
|
|
59
|
+
"""
|
|
60
|
+
return {
|
|
61
|
+
"name": self.name,
|
|
62
|
+
"description": self.description,
|
|
63
|
+
"input_schema": self.parameters,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _infer_param_schema(ann: Any) -> dict[str, Any]:
|
|
68
|
+
"""Map a Python type annotation to a detailed JSON Schema dictionary."""
|
|
69
|
+
if ann is inspect.Parameter.empty or ann is Any:
|
|
70
|
+
return {"type": "string"}
|
|
71
|
+
|
|
72
|
+
origin = get_origin(ann)
|
|
73
|
+
args = get_args(ann)
|
|
74
|
+
|
|
75
|
+
# Literal[...]
|
|
76
|
+
if origin is Literal:
|
|
77
|
+
values = list(args)
|
|
78
|
+
val_type = "string"
|
|
79
|
+
if values and all(isinstance(v, bool) for v in values):
|
|
80
|
+
val_type = "boolean"
|
|
81
|
+
elif values and all(isinstance(v, int) and not isinstance(v, bool) for v in values):
|
|
82
|
+
val_type = "integer"
|
|
83
|
+
elif values and all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in values):
|
|
84
|
+
val_type = "number"
|
|
85
|
+
return {"type": val_type, "enum": values}
|
|
86
|
+
|
|
87
|
+
# Enum subclasses
|
|
88
|
+
if isinstance(ann, type) and issubclass(ann, enum.Enum):
|
|
89
|
+
enum_values = [e.value for e in ann]
|
|
90
|
+
if issubclass(ann, enum.IntEnum) or (
|
|
91
|
+
enum_values and all(isinstance(v, int) and not isinstance(v, bool) for v in enum_values)
|
|
92
|
+
):
|
|
93
|
+
val_type = "integer"
|
|
94
|
+
elif enum_values and all(isinstance(v, bool) for v in enum_values):
|
|
95
|
+
val_type = "boolean"
|
|
96
|
+
elif enum_values and all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in enum_values):
|
|
97
|
+
val_type = "number"
|
|
98
|
+
else:
|
|
99
|
+
val_type = "string"
|
|
100
|
+
return {"type": val_type, "enum": enum_values}
|
|
101
|
+
|
|
102
|
+
# Union & Optional (e.g. str | None, Optional[int], Union[int, str])
|
|
103
|
+
if origin in (Union, UnionType):
|
|
104
|
+
non_none_args = [a for a in args if a is not type(None)]
|
|
105
|
+
is_nullable = len(non_none_args) < len(args)
|
|
106
|
+
if len(non_none_args) == 1:
|
|
107
|
+
schema = _infer_param_schema(non_none_args[0])
|
|
108
|
+
if is_nullable:
|
|
109
|
+
schema["nullable"] = True
|
|
110
|
+
return schema
|
|
111
|
+
schemas = [_infer_param_schema(a) for a in non_none_args]
|
|
112
|
+
return {"anyOf": schemas, "nullable": is_nullable} if is_nullable else {"anyOf": schemas}
|
|
113
|
+
|
|
114
|
+
# Arrays: list, tuple, set, Sequence
|
|
115
|
+
if ann is list or origin in (list, set, tuple, Sequence):
|
|
116
|
+
item_schema = _infer_param_schema(args[0]) if args else {"type": "string"}
|
|
117
|
+
return {"type": "array", "items": item_schema}
|
|
118
|
+
|
|
119
|
+
# Objects: dict, Mapping
|
|
120
|
+
if ann is dict or origin in (dict, Mapping):
|
|
121
|
+
return {"type": "object"}
|
|
122
|
+
|
|
123
|
+
# Primitives: Note that bool is a subclass of int in Python, so bool must be checked first!
|
|
124
|
+
if ann is bool:
|
|
125
|
+
return {"type": "boolean"}
|
|
126
|
+
if ann is int:
|
|
127
|
+
return {"type": "integer"}
|
|
128
|
+
if ann is float:
|
|
129
|
+
return {"type": "number"}
|
|
130
|
+
if ann is str:
|
|
131
|
+
return {"type": "string"}
|
|
132
|
+
|
|
133
|
+
return {"type": "string"}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _extract_enum_type(ann: Any) -> type[enum.Enum] | None:
|
|
137
|
+
"""Extract Enum subclass from direct annotations or Optional[Enum] unions."""
|
|
138
|
+
if isinstance(ann, type) and issubclass(ann, enum.Enum):
|
|
139
|
+
return ann
|
|
140
|
+
origin = get_origin(ann)
|
|
141
|
+
if origin in (Union, UnionType):
|
|
142
|
+
non_none = [a for a in get_args(ann) if a is not type(None)]
|
|
143
|
+
if len(non_none) == 1 and isinstance(non_none[0], type) and issubclass(non_none[0], enum.Enum):
|
|
144
|
+
return non_none[0]
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _unwrap_partial_and_callable(fn: Callable[..., Any]) -> tuple[Callable[..., Any], Callable[..., Any]]:
|
|
149
|
+
"""Return (underlying_target, hint_and_doc_target) handling partials and callable instances."""
|
|
150
|
+
base: Any = fn
|
|
151
|
+
while isinstance(base, functools.partial):
|
|
152
|
+
base = base.func
|
|
153
|
+
hint_target = base
|
|
154
|
+
if (
|
|
155
|
+
not inspect.isfunction(base)
|
|
156
|
+
and not inspect.ismethod(base)
|
|
157
|
+
and not inspect.isbuiltin(base)
|
|
158
|
+
and not isinstance(base, type)
|
|
159
|
+
and callable(base)
|
|
160
|
+
):
|
|
161
|
+
hint_target = base.__call__
|
|
162
|
+
return base, hint_target
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def is_async_callable(fn: Callable[..., Any]) -> bool:
|
|
166
|
+
"""Return True if a function, partial, or callable object defines an async entrypoint."""
|
|
167
|
+
current: Any = fn
|
|
168
|
+
while isinstance(current, functools.partial):
|
|
169
|
+
current = current.func
|
|
170
|
+
if inspect.iscoroutinefunction(current):
|
|
171
|
+
return True
|
|
172
|
+
if callable(current) and not inspect.isfunction(current) and not inspect.ismethod(current):
|
|
173
|
+
return inspect.iscoroutinefunction(type(current).__call__)
|
|
174
|
+
return False
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _validate_arg_type(
|
|
178
|
+
param_name: str,
|
|
179
|
+
value: Any,
|
|
180
|
+
ann: Any,
|
|
181
|
+
schema: Mapping[str, Any],
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Validate a runtime parameter value recursively against declared type annotation and JSON schema."""
|
|
184
|
+
origin = get_origin(ann)
|
|
185
|
+
args = get_args(ann)
|
|
186
|
+
schema_type = schema.get("type")
|
|
187
|
+
is_nullable = (
|
|
188
|
+
schema.get("nullable", False)
|
|
189
|
+
or schema_type == "null"
|
|
190
|
+
or (isinstance(schema_type, (list, tuple)) and "null" in schema_type)
|
|
191
|
+
or (origin in (Union, UnionType) and type(None) in args)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if value is None:
|
|
195
|
+
if is_nullable:
|
|
196
|
+
return
|
|
197
|
+
raise TypeError(f"Parameter '{param_name}' cannot be None.")
|
|
198
|
+
|
|
199
|
+
# Check enum values (guarding bool == int equivalence in Python)
|
|
200
|
+
if "enum" in schema:
|
|
201
|
+
enum_vals = schema["enum"]
|
|
202
|
+
raw_val = value.value if isinstance(value, enum.Enum) else value
|
|
203
|
+
if not any(v == raw_val and isinstance(raw_val, bool) is isinstance(v, bool) for v in enum_vals):
|
|
204
|
+
raise ValueError(
|
|
205
|
+
f"Parameter '{param_name}' must be one of {enum_vals}, got {value!r}"
|
|
206
|
+
)
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
# Check union types
|
|
210
|
+
union_schemas = schema.get("anyOf") or schema.get("oneOf")
|
|
211
|
+
if isinstance(union_schemas, Sequence):
|
|
212
|
+
matched = False
|
|
213
|
+
for s in union_schemas:
|
|
214
|
+
if isinstance(s, Mapping):
|
|
215
|
+
try:
|
|
216
|
+
_validate_arg_type(param_name, value, Any, s)
|
|
217
|
+
matched = True
|
|
218
|
+
break
|
|
219
|
+
except (TypeError, ValueError):
|
|
220
|
+
continue
|
|
221
|
+
if not matched:
|
|
222
|
+
raise TypeError(f"Parameter '{param_name}' does not match any allowed Union type.")
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
expected_type = schema.get("type")
|
|
226
|
+
if expected_type == "boolean":
|
|
227
|
+
if not isinstance(value, bool):
|
|
228
|
+
raise TypeError(f"Parameter '{param_name}' must be a boolean, got {type(value).__name__}")
|
|
229
|
+
elif expected_type == "integer":
|
|
230
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
231
|
+
raise TypeError(f"Parameter '{param_name}' must be an integer, got {type(value).__name__}")
|
|
232
|
+
elif expected_type == "number":
|
|
233
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
234
|
+
raise TypeError(f"Parameter '{param_name}' must be a number, got {type(value).__name__}")
|
|
235
|
+
if isinstance(value, float) and (math.isnan(value) or math.isinf(value)):
|
|
236
|
+
raise ValueError(f"Parameter '{param_name}' must be a finite number, got {value!r}")
|
|
237
|
+
elif expected_type == "string":
|
|
238
|
+
if not isinstance(value, str):
|
|
239
|
+
raise TypeError(f"Parameter '{param_name}' must be a string, got {type(value).__name__}")
|
|
240
|
+
elif expected_type == "array":
|
|
241
|
+
if not isinstance(value, (list, tuple, set)):
|
|
242
|
+
raise TypeError(f"Parameter '{param_name}' must be an array, got {type(value).__name__}")
|
|
243
|
+
item_schema = schema.get("items")
|
|
244
|
+
if isinstance(item_schema, Mapping):
|
|
245
|
+
for idx, elem in enumerate(value):
|
|
246
|
+
_validate_arg_type(f"{param_name}[{idx}]", elem, Any, item_schema)
|
|
247
|
+
elif expected_type == "object":
|
|
248
|
+
if not isinstance(value, Mapping):
|
|
249
|
+
raise TypeError(f"Parameter '{param_name}' must be an object/dict, got {type(value).__name__}")
|
|
250
|
+
validate_tool_arguments(schema, value, param_name)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def validate_tool_arguments(
|
|
254
|
+
schema: Mapping[str, Any],
|
|
255
|
+
arguments: Mapping[str, Any],
|
|
256
|
+
tool_name: str = "",
|
|
257
|
+
) -> None:
|
|
258
|
+
"""Validate a tool argument mapping against a JSON Schema definition."""
|
|
259
|
+
if not isinstance(arguments, Mapping):
|
|
260
|
+
label = f"tool '{tool_name}'" if tool_name else "tool"
|
|
261
|
+
raise TypeError(f"Expected object/dict arguments for {label}, got {type(arguments).__name__}")
|
|
262
|
+
|
|
263
|
+
properties = schema.get("properties")
|
|
264
|
+
prop_map: Mapping[str, Any] = properties if isinstance(properties, Mapping) else {}
|
|
265
|
+
required = schema.get("required")
|
|
266
|
+
if isinstance(required, Sequence) and not isinstance(required, (str, bytes)):
|
|
267
|
+
for req in required:
|
|
268
|
+
if isinstance(req, str) and req not in arguments:
|
|
269
|
+
label = f" for '{tool_name}'" if tool_name else ""
|
|
270
|
+
raise TypeError(f"Missing required parameter '{req}'{label}.")
|
|
271
|
+
|
|
272
|
+
if schema.get("additionalProperties") is False:
|
|
273
|
+
allowed_keys = set(prop_map.keys())
|
|
274
|
+
for key in arguments:
|
|
275
|
+
if key not in allowed_keys:
|
|
276
|
+
raise TypeError(
|
|
277
|
+
f"Unexpected parameter '{key}'. Allowed parameters: {sorted(allowed_keys)}"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
for key, val in arguments.items():
|
|
281
|
+
if key in prop_map and isinstance(prop_map[key], Mapping):
|
|
282
|
+
_validate_arg_type(str(key), val, Any, prop_map[key])
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class FunctionTool(BaseTool):
|
|
286
|
+
"""
|
|
287
|
+
Adapter converting a standard Python callable into a BaseTool instance
|
|
288
|
+
with parameter schema inference, input validation, and asynchronous execution.
|
|
289
|
+
"""
|
|
290
|
+
|
|
291
|
+
def __init__(
|
|
292
|
+
self,
|
|
293
|
+
fn: Callable[..., Any] | None = None,
|
|
294
|
+
name: str | None = None,
|
|
295
|
+
description: str | None = None,
|
|
296
|
+
parameters: dict[str, Any] | None = None,
|
|
297
|
+
*,
|
|
298
|
+
func: Callable[..., Any] | None = None,
|
|
299
|
+
) -> None:
|
|
300
|
+
target_fn = fn or func
|
|
301
|
+
if target_fn is None:
|
|
302
|
+
raise ValueError("FunctionTool requires a callable function.")
|
|
303
|
+
self._fn = target_fn
|
|
304
|
+
underlying, hint_target = _unwrap_partial_and_callable(target_fn)
|
|
305
|
+
|
|
306
|
+
raw_name = str(
|
|
307
|
+
name
|
|
308
|
+
or getattr(target_fn, "__name__", None)
|
|
309
|
+
or getattr(underlying, "__name__", None)
|
|
310
|
+
or type(underlying).__name__
|
|
311
|
+
or "tool"
|
|
312
|
+
)
|
|
313
|
+
if not _TOOL_NAME_PATTERN.fullmatch(raw_name):
|
|
314
|
+
raise ValueError(f"Invalid tool name '{raw_name}'. Must match ^[a-zA-Z0-9_-]+$")
|
|
315
|
+
|
|
316
|
+
self._name: str = raw_name
|
|
317
|
+
doc = inspect.getdoc(target_fn) or inspect.getdoc(underlying) or f"Execute {self._name}"
|
|
318
|
+
self._description = description or doc.strip()
|
|
319
|
+
self._sig = inspect.signature(target_fn)
|
|
320
|
+
self._hints = (
|
|
321
|
+
get_type_hints(hint_target)
|
|
322
|
+
if hasattr(hint_target, "__annotations__")
|
|
323
|
+
else {}
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
if parameters is not None:
|
|
327
|
+
self._parameters = parameters
|
|
328
|
+
else:
|
|
329
|
+
self._parameters = self._infer_parameters(target_fn, self._sig, self._hints)
|
|
330
|
+
|
|
331
|
+
@property
|
|
332
|
+
def name(self) -> str:
|
|
333
|
+
return self._name
|
|
334
|
+
|
|
335
|
+
@property
|
|
336
|
+
def description(self) -> str:
|
|
337
|
+
return self._description
|
|
338
|
+
|
|
339
|
+
@property
|
|
340
|
+
def parameters(self) -> dict[str, Any]:
|
|
341
|
+
return self._parameters
|
|
342
|
+
|
|
343
|
+
async def handle(self, *args: Any, **kwargs: Any) -> Any:
|
|
344
|
+
bound_kwargs = kwargs.copy()
|
|
345
|
+
params_list = list(self._sig.parameters.values())
|
|
346
|
+
positional_params = [
|
|
347
|
+
p
|
|
348
|
+
for p in params_list
|
|
349
|
+
if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
|
350
|
+
]
|
|
351
|
+
|
|
352
|
+
# 1. Check for missing required parameters (accounting for supplied positional args)
|
|
353
|
+
supplied_pos_names = {p.name for p in positional_params[: len(args)]}
|
|
354
|
+
defaulted_names = {
|
|
355
|
+
p.name for p in params_list if p.default is not inspect.Parameter.empty
|
|
356
|
+
}
|
|
357
|
+
for req in self._parameters.get("required", []):
|
|
358
|
+
if (
|
|
359
|
+
req not in bound_kwargs
|
|
360
|
+
and req not in supplied_pos_names
|
|
361
|
+
and req not in defaulted_names
|
|
362
|
+
):
|
|
363
|
+
raise TypeError(f"Missing required parameter '{req}' for tool '{self.name}'.")
|
|
364
|
+
|
|
365
|
+
# 2. Check for unexpected parameters if callable does not accept **kwargs
|
|
366
|
+
has_var_kw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params_list)
|
|
367
|
+
if not has_var_kw:
|
|
368
|
+
allowed = set(self._sig.parameters.keys())
|
|
369
|
+
for k in bound_kwargs:
|
|
370
|
+
if k not in allowed:
|
|
371
|
+
raise TypeError(
|
|
372
|
+
f"Unexpected parameter '{k}' for tool '{self.name}'. Allowed parameters: {sorted(allowed)}"
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
# 3. Validate parameter types & coerce Enum arguments
|
|
376
|
+
for k, v in list(bound_kwargs.items()):
|
|
377
|
+
param = self._sig.parameters.get(k)
|
|
378
|
+
if param is not None:
|
|
379
|
+
ann = self._hints.get(k, param.annotation)
|
|
380
|
+
schema = self._parameters.get("properties", {}).get(k, {})
|
|
381
|
+
_validate_arg_type(k, v, ann, schema)
|
|
382
|
+
enum_cls = _extract_enum_type(ann)
|
|
383
|
+
if enum_cls is not None and v is not None and not isinstance(v, enum_cls):
|
|
384
|
+
matched = [
|
|
385
|
+
member
|
|
386
|
+
for member in enum_cls
|
|
387
|
+
if member.value == v and isinstance(member.value, bool) is isinstance(v, bool)
|
|
388
|
+
]
|
|
389
|
+
if matched:
|
|
390
|
+
bound_kwargs[k] = matched[0]
|
|
391
|
+
|
|
392
|
+
# 4. Safely bind arguments, mapping POSITIONAL_ONLY parameters passed via kwargs
|
|
393
|
+
pos_only_params = [
|
|
394
|
+
p for p in params_list if p.kind == inspect.Parameter.POSITIONAL_ONLY
|
|
395
|
+
]
|
|
396
|
+
bind_pos_args = list(args)
|
|
397
|
+
bind_kwargs = bound_kwargs.copy()
|
|
398
|
+
if len(bind_pos_args) < len(pos_only_params):
|
|
399
|
+
for p in pos_only_params[len(bind_pos_args) :]:
|
|
400
|
+
if p.name in bind_kwargs:
|
|
401
|
+
bind_pos_args.append(bind_kwargs.pop(p.name))
|
|
402
|
+
elif p.default is not inspect.Parameter.empty:
|
|
403
|
+
bind_pos_args.append(p.default)
|
|
404
|
+
else:
|
|
405
|
+
break
|
|
406
|
+
|
|
407
|
+
try:
|
|
408
|
+
bound = self._sig.bind(*bind_pos_args, **bind_kwargs)
|
|
409
|
+
bound.apply_defaults()
|
|
410
|
+
except TypeError as exc:
|
|
411
|
+
raise TypeError(f"Argument binding error for tool '{self.name}': {exc}") from exc
|
|
412
|
+
|
|
413
|
+
# 5. Execute: await async callables directly, offload sync callables to worker thread
|
|
414
|
+
if is_async_callable(self._fn):
|
|
415
|
+
return await self._fn(*bound.args, **bound.kwargs)
|
|
416
|
+
return await asyncio.to_thread(self._fn, *bound.args, **bound.kwargs)
|
|
417
|
+
|
|
418
|
+
@staticmethod
|
|
419
|
+
def _infer_parameters(
|
|
420
|
+
fn: Callable[..., Any],
|
|
421
|
+
sig: inspect.Signature,
|
|
422
|
+
hints: dict[str, Any],
|
|
423
|
+
) -> dict[str, Any]:
|
|
424
|
+
"""Infer JSON schema parameters from signature and type hints."""
|
|
425
|
+
properties: dict[str, Any] = {}
|
|
426
|
+
required: list[str] = []
|
|
427
|
+
|
|
428
|
+
for param_name, param in sig.parameters.items():
|
|
429
|
+
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
430
|
+
continue
|
|
431
|
+
|
|
432
|
+
ann = hints.get(param_name, param.annotation)
|
|
433
|
+
schema = _infer_param_schema(ann)
|
|
434
|
+
schema["description"] = f"Parameter '{param_name}'"
|
|
435
|
+
properties[param_name] = schema
|
|
436
|
+
|
|
437
|
+
# A parameter is required if it has no default and is not explicitly optional/nullable
|
|
438
|
+
if param.default is inspect.Parameter.empty and not schema.get("nullable", False):
|
|
439
|
+
required.append(param_name)
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
"type": "object",
|
|
443
|
+
"properties": properties,
|
|
444
|
+
"required": required,
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
@overload
|
|
449
|
+
def tool(
|
|
450
|
+
fn: Callable[..., Any],
|
|
451
|
+
*,
|
|
452
|
+
name: str | None = None,
|
|
453
|
+
description: str | None = None,
|
|
454
|
+
parameters: dict[str, Any] | None = None,
|
|
455
|
+
) -> FunctionTool: ...
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
@overload
|
|
459
|
+
def tool(
|
|
460
|
+
fn: None = None,
|
|
461
|
+
*,
|
|
462
|
+
name: str | None = None,
|
|
463
|
+
description: str | None = None,
|
|
464
|
+
parameters: dict[str, Any] | None = None,
|
|
465
|
+
) -> Callable[[Callable[..., Any]], FunctionTool]: ...
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def tool(
|
|
469
|
+
fn: Callable[..., Any] | None = None,
|
|
470
|
+
*,
|
|
471
|
+
name: str | None = None,
|
|
472
|
+
description: str | None = None,
|
|
473
|
+
parameters: dict[str, Any] | None = None,
|
|
474
|
+
) -> FunctionTool | Callable[[Callable[..., Any]], FunctionTool]:
|
|
475
|
+
"""
|
|
476
|
+
Decorator converting a Python function into a BaseTool instance.
|
|
477
|
+
"""
|
|
478
|
+
def decorator(target: Callable[..., Any]) -> FunctionTool:
|
|
479
|
+
return FunctionTool(
|
|
480
|
+
target,
|
|
481
|
+
name=name,
|
|
482
|
+
description=description,
|
|
483
|
+
parameters=parameters,
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
if fn is not None:
|
|
487
|
+
return decorator(fn)
|
|
488
|
+
return decorator
|
|
489
|
+
return decorator
|