digitalkin 0.2.25rc0__py3-none-any.whl → 0.3.2.dev14__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.
- base_server/server_async_insecure.py +6 -5
- base_server/server_async_secure.py +6 -5
- base_server/server_sync_insecure.py +5 -4
- base_server/server_sync_secure.py +5 -4
- digitalkin/__version__.py +1 -1
- digitalkin/core/__init__.py +1 -0
- digitalkin/core/common/__init__.py +9 -0
- digitalkin/core/common/factories.py +156 -0
- digitalkin/core/job_manager/__init__.py +1 -0
- digitalkin/{modules → core}/job_manager/base_job_manager.py +138 -32
- digitalkin/core/job_manager/single_job_manager.py +373 -0
- digitalkin/{modules → core}/job_manager/taskiq_broker.py +121 -26
- digitalkin/core/job_manager/taskiq_job_manager.py +541 -0
- digitalkin/core/task_manager/__init__.py +1 -0
- digitalkin/core/task_manager/base_task_manager.py +539 -0
- digitalkin/core/task_manager/local_task_manager.py +108 -0
- digitalkin/core/task_manager/remote_task_manager.py +87 -0
- digitalkin/core/task_manager/surrealdb_repository.py +266 -0
- digitalkin/core/task_manager/task_executor.py +249 -0
- digitalkin/core/task_manager/task_session.py +368 -0
- digitalkin/grpc_servers/__init__.py +1 -19
- digitalkin/grpc_servers/_base_server.py +3 -3
- digitalkin/grpc_servers/module_server.py +120 -195
- digitalkin/grpc_servers/module_servicer.py +81 -44
- digitalkin/grpc_servers/utils/__init__.py +1 -0
- digitalkin/grpc_servers/utils/exceptions.py +0 -8
- digitalkin/grpc_servers/utils/grpc_client_wrapper.py +25 -9
- digitalkin/grpc_servers/utils/grpc_error_handler.py +53 -0
- digitalkin/grpc_servers/utils/utility_schema_extender.py +100 -0
- digitalkin/logger.py +64 -27
- digitalkin/mixins/__init__.py +19 -0
- digitalkin/mixins/base_mixin.py +10 -0
- digitalkin/mixins/callback_mixin.py +24 -0
- digitalkin/mixins/chat_history_mixin.py +110 -0
- digitalkin/mixins/cost_mixin.py +76 -0
- digitalkin/mixins/file_history_mixin.py +93 -0
- digitalkin/mixins/filesystem_mixin.py +46 -0
- digitalkin/mixins/logger_mixin.py +51 -0
- digitalkin/mixins/storage_mixin.py +79 -0
- digitalkin/models/__init__.py +1 -1
- digitalkin/models/core/__init__.py +1 -0
- digitalkin/{modules/job_manager → models/core}/job_manager_models.py +3 -11
- digitalkin/models/core/task_monitor.py +74 -0
- digitalkin/models/grpc_servers/__init__.py +1 -0
- digitalkin/{grpc_servers/utils → models/grpc_servers}/models.py +92 -7
- digitalkin/models/module/__init__.py +18 -11
- digitalkin/models/module/base_types.py +61 -0
- digitalkin/models/module/module.py +9 -1
- digitalkin/models/module/module_context.py +282 -6
- digitalkin/models/module/module_types.py +29 -105
- digitalkin/models/module/setup_types.py +490 -0
- digitalkin/models/module/tool_cache.py +68 -0
- digitalkin/models/module/tool_reference.py +117 -0
- digitalkin/models/module/utility.py +167 -0
- digitalkin/models/services/__init__.py +9 -0
- digitalkin/models/services/cost.py +1 -0
- digitalkin/models/services/registry.py +35 -0
- digitalkin/models/services/storage.py +39 -5
- digitalkin/modules/__init__.py +5 -1
- digitalkin/modules/_base_module.py +265 -167
- digitalkin/modules/archetype_module.py +6 -1
- digitalkin/modules/tool_module.py +16 -3
- digitalkin/modules/trigger_handler.py +7 -6
- digitalkin/modules/triggers/__init__.py +8 -0
- digitalkin/modules/triggers/healthcheck_ping_trigger.py +45 -0
- digitalkin/modules/triggers/healthcheck_services_trigger.py +63 -0
- digitalkin/modules/triggers/healthcheck_status_trigger.py +52 -0
- digitalkin/services/__init__.py +4 -0
- digitalkin/services/communication/__init__.py +7 -0
- digitalkin/services/communication/communication_strategy.py +76 -0
- digitalkin/services/communication/default_communication.py +101 -0
- digitalkin/services/communication/grpc_communication.py +234 -0
- digitalkin/services/cost/__init__.py +9 -2
- digitalkin/services/cost/grpc_cost.py +9 -42
- digitalkin/services/filesystem/default_filesystem.py +0 -2
- digitalkin/services/filesystem/grpc_filesystem.py +10 -39
- digitalkin/services/registry/__init__.py +22 -1
- digitalkin/services/registry/default_registry.py +135 -4
- digitalkin/services/registry/exceptions.py +47 -0
- digitalkin/services/registry/grpc_registry.py +306 -0
- digitalkin/services/registry/registry_models.py +15 -0
- digitalkin/services/registry/registry_strategy.py +88 -4
- digitalkin/services/services_config.py +25 -3
- digitalkin/services/services_models.py +5 -1
- digitalkin/services/setup/default_setup.py +6 -7
- digitalkin/services/setup/grpc_setup.py +52 -15
- digitalkin/services/storage/grpc_storage.py +4 -4
- digitalkin/services/user_profile/__init__.py +12 -0
- digitalkin/services/user_profile/default_user_profile.py +55 -0
- digitalkin/services/user_profile/grpc_user_profile.py +69 -0
- digitalkin/services/user_profile/user_profile_strategy.py +25 -0
- digitalkin/utils/__init__.py +28 -0
- digitalkin/utils/arg_parser.py +1 -1
- digitalkin/utils/development_mode_action.py +2 -2
- digitalkin/utils/dynamic_schema.py +483 -0
- digitalkin/utils/package_discover.py +1 -2
- digitalkin/utils/schema_splitter.py +207 -0
- {digitalkin-0.2.25rc0.dist-info → digitalkin-0.3.2.dev14.dist-info}/METADATA +11 -30
- digitalkin-0.3.2.dev14.dist-info/RECORD +143 -0
- {digitalkin-0.2.25rc0.dist-info → digitalkin-0.3.2.dev14.dist-info}/top_level.txt +1 -0
- modules/archetype_with_tools_module.py +244 -0
- modules/cpu_intensive_module.py +1 -1
- modules/dynamic_setup_module.py +338 -0
- modules/minimal_llm_module.py +1 -1
- modules/text_transform_module.py +1 -1
- monitoring/digitalkin_observability/__init__.py +46 -0
- monitoring/digitalkin_observability/http_server.py +150 -0
- monitoring/digitalkin_observability/interceptors.py +176 -0
- monitoring/digitalkin_observability/metrics.py +201 -0
- monitoring/digitalkin_observability/prometheus.py +137 -0
- monitoring/tests/test_metrics.py +172 -0
- services/filesystem_module.py +7 -5
- services/storage_module.py +4 -2
- digitalkin/grpc_servers/registry_server.py +0 -65
- digitalkin/grpc_servers/registry_servicer.py +0 -456
- digitalkin/grpc_servers/utils/factory.py +0 -180
- digitalkin/modules/job_manager/single_job_manager.py +0 -294
- digitalkin/modules/job_manager/taskiq_job_manager.py +0 -290
- digitalkin-0.2.25rc0.dist-info/RECORD +0 -89
- /digitalkin/{grpc_servers/utils → models/grpc_servers}/types.py +0 -0
- {digitalkin-0.2.25rc0.dist-info → digitalkin-0.3.2.dev14.dist-info}/WHEEL +0 -0
- {digitalkin-0.2.25rc0.dist-info → digitalkin-0.3.2.dev14.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
"""Setup model types with dynamic schema resolution and tool reference support."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import types
|
|
5
|
+
import typing
|
|
6
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar, cast, get_args, get_origin
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, create_model
|
|
9
|
+
|
|
10
|
+
from digitalkin.logger import logger
|
|
11
|
+
from digitalkin.models.module.tool_cache import ToolCache
|
|
12
|
+
from digitalkin.models.module.tool_reference import ToolReference
|
|
13
|
+
from digitalkin.models.services.registry import ModuleInfo
|
|
14
|
+
from digitalkin.utils.dynamic_schema import (
|
|
15
|
+
DynamicField,
|
|
16
|
+
get_fetchers,
|
|
17
|
+
has_dynamic,
|
|
18
|
+
resolve_safe,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from pydantic.fields import FieldInfo
|
|
23
|
+
|
|
24
|
+
from digitalkin.services.registry import RegistryStrategy
|
|
25
|
+
|
|
26
|
+
SetupModelT = TypeVar("SetupModelT", bound="SetupModel")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SetupModel(BaseModel, Generic[SetupModelT]):
|
|
30
|
+
"""Base setup model with dynamic schema and tool cache support."""
|
|
31
|
+
|
|
32
|
+
_clean_model_cache: ClassVar[dict[tuple[type, bool, bool], type]] = {}
|
|
33
|
+
|
|
34
|
+
def __init_subclass__(cls, **kwargs: Any) -> None: # noqa: ANN401
|
|
35
|
+
"""Inject hidden companion fields for ToolReference annotations.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
**kwargs: Keyword arguments passed to parent.
|
|
39
|
+
"""
|
|
40
|
+
super().__init_subclass__(**kwargs)
|
|
41
|
+
cls._inject_tool_cache_fields()
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def _inject_tool_cache_fields(cls) -> None:
|
|
45
|
+
"""Inject hidden companion fields for ToolReference annotations."""
|
|
46
|
+
annotations = getattr(cls, "__annotations__", {})
|
|
47
|
+
new_annotations: dict[str, Any] = {}
|
|
48
|
+
|
|
49
|
+
for field_name, annotation in annotations.items():
|
|
50
|
+
if cls._is_tool_reference_annotation(annotation):
|
|
51
|
+
cache_field_name = f"{field_name}_cache"
|
|
52
|
+
if cache_field_name not in annotations:
|
|
53
|
+
# Check if it's a list type
|
|
54
|
+
origin = get_origin(annotation)
|
|
55
|
+
if origin is list:
|
|
56
|
+
new_annotations[cache_field_name] = list[ModuleInfo]
|
|
57
|
+
setattr(
|
|
58
|
+
cls,
|
|
59
|
+
cache_field_name,
|
|
60
|
+
Field(default_factory=list, json_schema_extra={"hidden": True}),
|
|
61
|
+
)
|
|
62
|
+
else:
|
|
63
|
+
new_annotations[cache_field_name] = ModuleInfo | None
|
|
64
|
+
setattr(
|
|
65
|
+
cls,
|
|
66
|
+
cache_field_name,
|
|
67
|
+
Field(default=None, json_schema_extra={"hidden": True}),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if new_annotations:
|
|
71
|
+
cls.__annotations__ = {**annotations, **new_annotations}
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def _is_tool_reference_annotation(cls, annotation: object) -> bool:
|
|
75
|
+
"""Check if annotation is ToolReference or Optional[ToolReference].
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
annotation: Type annotation to check.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
True if annotation is or contains ToolReference.
|
|
82
|
+
"""
|
|
83
|
+
origin = get_origin(annotation)
|
|
84
|
+
if origin is typing.Union or origin is types.UnionType:
|
|
85
|
+
return any(
|
|
86
|
+
arg is ToolReference or (isinstance(arg, type) and issubclass(arg, ToolReference))
|
|
87
|
+
for arg in get_args(annotation)
|
|
88
|
+
if arg is not type(None)
|
|
89
|
+
)
|
|
90
|
+
return annotation is ToolReference or (isinstance(annotation, type) and issubclass(annotation, ToolReference))
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
async def get_clean_model(
|
|
94
|
+
cls,
|
|
95
|
+
*,
|
|
96
|
+
config_fields: bool,
|
|
97
|
+
hidden_fields: bool,
|
|
98
|
+
force: bool = False,
|
|
99
|
+
) -> "type[SetupModelT]":
|
|
100
|
+
"""Build filtered model based on json_schema_extra metadata.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
config_fields: Include fields with json_schema_extra["config"] = True.
|
|
104
|
+
hidden_fields: Include fields with json_schema_extra["hidden"] = True.
|
|
105
|
+
force: Refresh dynamic schema fields by calling providers.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
New BaseModel subclass with filtered fields.
|
|
109
|
+
"""
|
|
110
|
+
cache_key = (cls, config_fields, hidden_fields)
|
|
111
|
+
if not force and cache_key in cls._clean_model_cache:
|
|
112
|
+
return cast("type[SetupModelT]", cls._clean_model_cache[cache_key])
|
|
113
|
+
|
|
114
|
+
clean_fields: dict[str, Any] = {}
|
|
115
|
+
|
|
116
|
+
for name, field_info in cls.model_fields.items():
|
|
117
|
+
extra = field_info.json_schema_extra or {}
|
|
118
|
+
is_config = bool(extra.get("config", False)) if isinstance(extra, dict) else False
|
|
119
|
+
is_hidden = bool(extra.get("hidden", False)) if isinstance(extra, dict) else False
|
|
120
|
+
|
|
121
|
+
if is_config and not config_fields:
|
|
122
|
+
continue
|
|
123
|
+
if is_hidden and not hidden_fields:
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
current_field_info = field_info
|
|
127
|
+
current_annotation = field_info.annotation
|
|
128
|
+
|
|
129
|
+
if force:
|
|
130
|
+
if has_dynamic(field_info):
|
|
131
|
+
current_field_info = await cls._refresh_field_schema(name, field_info)
|
|
132
|
+
|
|
133
|
+
nested_model = cls._get_base_model_type(current_annotation)
|
|
134
|
+
if nested_model is not None:
|
|
135
|
+
refreshed_nested = await cls._refresh_nested_model(nested_model)
|
|
136
|
+
if refreshed_nested is not nested_model:
|
|
137
|
+
current_annotation = refreshed_nested
|
|
138
|
+
current_field_info = copy.deepcopy(current_field_info)
|
|
139
|
+
current_field_info.annotation = current_annotation
|
|
140
|
+
|
|
141
|
+
clean_fields[name] = (current_annotation, current_field_info)
|
|
142
|
+
|
|
143
|
+
root_extra = cls.model_config.get("json_schema_extra", {})
|
|
144
|
+
|
|
145
|
+
m = create_model(
|
|
146
|
+
f"{cls.__name__}",
|
|
147
|
+
__base__=SetupModel,
|
|
148
|
+
__config__=ConfigDict(
|
|
149
|
+
arbitrary_types_allowed=True,
|
|
150
|
+
json_schema_extra=copy.deepcopy(root_extra) if isinstance(root_extra, dict) else root_extra,
|
|
151
|
+
),
|
|
152
|
+
**clean_fields,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if not force:
|
|
156
|
+
cls._clean_model_cache[cache_key] = m
|
|
157
|
+
|
|
158
|
+
return cast("type[SetupModelT]", m)
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
def _get_base_model_type(cls, annotation: "type | None") -> "type[BaseModel] | None":
|
|
162
|
+
"""Extract BaseModel type from annotation.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
annotation: Type annotation to inspect.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
BaseModel subclass if found, None otherwise.
|
|
169
|
+
"""
|
|
170
|
+
if annotation is None:
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
|
174
|
+
return annotation
|
|
175
|
+
|
|
176
|
+
origin = get_origin(annotation)
|
|
177
|
+
if origin is None:
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
args = get_args(annotation)
|
|
181
|
+
return cls._extract_base_model_from_args(origin, args)
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
def _extract_base_model_from_args(
|
|
185
|
+
cls,
|
|
186
|
+
origin: type,
|
|
187
|
+
args: "tuple[type, ...]",
|
|
188
|
+
) -> "type[BaseModel] | None":
|
|
189
|
+
"""Extract BaseModel from generic type arguments.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
origin: Generic origin type (list, dict, Union, etc.).
|
|
193
|
+
args: Type arguments.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
BaseModel subclass if found, None otherwise.
|
|
197
|
+
"""
|
|
198
|
+
if origin is typing.Union or origin is types.UnionType:
|
|
199
|
+
return cls._find_base_model_in_args(args)
|
|
200
|
+
|
|
201
|
+
if origin in {list, set, frozenset} and args:
|
|
202
|
+
return cls._check_base_model(args[0])
|
|
203
|
+
|
|
204
|
+
dict_value_index = 1
|
|
205
|
+
if origin is dict and len(args) > dict_value_index:
|
|
206
|
+
return cls._check_base_model(args[dict_value_index])
|
|
207
|
+
|
|
208
|
+
if origin is tuple:
|
|
209
|
+
return cls._find_base_model_in_args(args, skip_ellipsis=True)
|
|
210
|
+
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
@classmethod
|
|
214
|
+
def _check_base_model(cls, arg: type) -> "type[BaseModel] | None":
|
|
215
|
+
"""Check if arg is a BaseModel subclass.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
arg: Type to check.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
The type if it's a BaseModel subclass, None otherwise.
|
|
222
|
+
"""
|
|
223
|
+
if isinstance(arg, type) and issubclass(arg, BaseModel):
|
|
224
|
+
return arg
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
@classmethod
|
|
228
|
+
def _find_base_model_in_args(
|
|
229
|
+
cls,
|
|
230
|
+
args: "tuple[type, ...]",
|
|
231
|
+
*,
|
|
232
|
+
skip_ellipsis: bool = False,
|
|
233
|
+
) -> "type[BaseModel] | None":
|
|
234
|
+
"""Find first BaseModel in type args.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
args: Type arguments to search.
|
|
238
|
+
skip_ellipsis: Skip ellipsis in tuple types.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
First BaseModel subclass found, None otherwise.
|
|
242
|
+
"""
|
|
243
|
+
for arg in args:
|
|
244
|
+
if arg is type(None):
|
|
245
|
+
continue
|
|
246
|
+
if skip_ellipsis and arg is ...:
|
|
247
|
+
continue
|
|
248
|
+
result = cls._check_base_model(arg)
|
|
249
|
+
if result is not None:
|
|
250
|
+
return result
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
@classmethod
|
|
254
|
+
async def _refresh_nested_model(cls, model_cls: "type[BaseModel]") -> "type[BaseModel]":
|
|
255
|
+
"""Refresh dynamic fields in a nested BaseModel.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
model_cls: Nested model class to refresh.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
New model class with refreshed fields, or original if no changes.
|
|
262
|
+
"""
|
|
263
|
+
has_changes = False
|
|
264
|
+
clean_fields: dict[str, Any] = {}
|
|
265
|
+
|
|
266
|
+
for name, field_info in model_cls.model_fields.items():
|
|
267
|
+
current_field_info = field_info
|
|
268
|
+
current_annotation = field_info.annotation
|
|
269
|
+
|
|
270
|
+
if has_dynamic(field_info):
|
|
271
|
+
current_field_info = await cls._refresh_field_schema(name, field_info)
|
|
272
|
+
has_changes = True
|
|
273
|
+
|
|
274
|
+
nested_model = cls._get_base_model_type(current_annotation)
|
|
275
|
+
if nested_model is not None:
|
|
276
|
+
refreshed_nested = await cls._refresh_nested_model(nested_model)
|
|
277
|
+
if refreshed_nested is not nested_model:
|
|
278
|
+
current_annotation = refreshed_nested
|
|
279
|
+
current_field_info = copy.deepcopy(current_field_info)
|
|
280
|
+
current_field_info.annotation = current_annotation
|
|
281
|
+
has_changes = True
|
|
282
|
+
|
|
283
|
+
clean_fields[name] = (current_annotation, current_field_info)
|
|
284
|
+
|
|
285
|
+
if not has_changes:
|
|
286
|
+
return model_cls
|
|
287
|
+
|
|
288
|
+
root_extra = cls.model_config.get("json_schema_extra", {})
|
|
289
|
+
|
|
290
|
+
return create_model(
|
|
291
|
+
model_cls.__name__,
|
|
292
|
+
__base__=BaseModel,
|
|
293
|
+
__config__=ConfigDict(
|
|
294
|
+
arbitrary_types_allowed=True,
|
|
295
|
+
json_schema_extra=copy.deepcopy(root_extra) if isinstance(root_extra, dict) else root_extra,
|
|
296
|
+
),
|
|
297
|
+
**clean_fields,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
@classmethod
|
|
301
|
+
async def _refresh_field_schema(cls, field_name: str, field_info: "FieldInfo") -> "FieldInfo":
|
|
302
|
+
"""Refresh field's json_schema_extra with values from dynamic providers.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
field_name: Name of field being refreshed.
|
|
306
|
+
field_info: Original FieldInfo with dynamic providers.
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
New FieldInfo with resolved values, or original if all fetchers fail.
|
|
310
|
+
"""
|
|
311
|
+
fetchers = get_fetchers(field_info)
|
|
312
|
+
|
|
313
|
+
if not fetchers:
|
|
314
|
+
return field_info
|
|
315
|
+
|
|
316
|
+
result = await resolve_safe(fetchers)
|
|
317
|
+
|
|
318
|
+
if result.errors:
|
|
319
|
+
for key, error in result.errors.items():
|
|
320
|
+
logger.warning(
|
|
321
|
+
"Failed to resolve '%s' for field '%s': %s",
|
|
322
|
+
key,
|
|
323
|
+
field_name,
|
|
324
|
+
error,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
if not result.values:
|
|
328
|
+
return field_info
|
|
329
|
+
|
|
330
|
+
extra = field_info.json_schema_extra or {}
|
|
331
|
+
new_extra = {**extra, **result.values} if isinstance(extra, dict) else result.values
|
|
332
|
+
|
|
333
|
+
new_field_info = copy.deepcopy(field_info)
|
|
334
|
+
new_field_info.json_schema_extra = new_extra
|
|
335
|
+
new_field_info.metadata = [m for m in new_field_info.metadata if not isinstance(m, DynamicField)]
|
|
336
|
+
|
|
337
|
+
return new_field_info
|
|
338
|
+
|
|
339
|
+
def resolve_tool_references(self, registry: "RegistryStrategy") -> None:
|
|
340
|
+
"""Resolve all ToolReference fields recursively.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
registry: Registry service for module discovery.
|
|
344
|
+
"""
|
|
345
|
+
logger.info("Starting resolve_tool_references")
|
|
346
|
+
self._resolve_tool_references_recursive(self, registry)
|
|
347
|
+
logger.info("Finished resolve_tool_references")
|
|
348
|
+
|
|
349
|
+
@classmethod
|
|
350
|
+
def _resolve_tool_references_recursive(
|
|
351
|
+
cls,
|
|
352
|
+
model_instance: BaseModel,
|
|
353
|
+
registry: "RegistryStrategy",
|
|
354
|
+
) -> None:
|
|
355
|
+
"""Recursively resolve ToolReference fields in a model.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
model_instance: Model instance to process.
|
|
359
|
+
registry: Registry service for resolution.
|
|
360
|
+
"""
|
|
361
|
+
for field_name, field_value in model_instance.__dict__.items():
|
|
362
|
+
if field_value is None:
|
|
363
|
+
continue
|
|
364
|
+
cls._resolve_field_value(field_name, field_value, registry)
|
|
365
|
+
|
|
366
|
+
@classmethod
|
|
367
|
+
def _resolve_field_value(
|
|
368
|
+
cls,
|
|
369
|
+
field_name: str,
|
|
370
|
+
field_value: "BaseModel | ToolReference | list | dict",
|
|
371
|
+
registry: "RegistryStrategy",
|
|
372
|
+
) -> None:
|
|
373
|
+
"""Resolve a single field value based on its type.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
field_name: Name of the field.
|
|
377
|
+
field_value: Value to process.
|
|
378
|
+
registry: Registry service for resolution.
|
|
379
|
+
"""
|
|
380
|
+
if isinstance(field_value, ToolReference):
|
|
381
|
+
cls._resolve_single_tool_reference(field_name, field_value, registry)
|
|
382
|
+
elif isinstance(field_value, BaseModel):
|
|
383
|
+
cls._resolve_tool_references_recursive(field_value, registry)
|
|
384
|
+
elif isinstance(field_value, list):
|
|
385
|
+
cls._resolve_list_items(field_value, registry)
|
|
386
|
+
elif isinstance(field_value, dict):
|
|
387
|
+
cls._resolve_dict_values(field_value, registry)
|
|
388
|
+
|
|
389
|
+
@classmethod
|
|
390
|
+
def _resolve_single_tool_reference(
|
|
391
|
+
cls,
|
|
392
|
+
field_name: str,
|
|
393
|
+
tool_ref: ToolReference,
|
|
394
|
+
registry: "RegistryStrategy",
|
|
395
|
+
) -> None:
|
|
396
|
+
"""Resolve a single ToolReference.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
field_name: Name of the field for logging.
|
|
400
|
+
tool_ref: ToolReference to resolve.
|
|
401
|
+
registry: Registry service for resolution.
|
|
402
|
+
"""
|
|
403
|
+
logger.info("Resolving ToolReference '%s' with module_id='%s'", field_name, tool_ref.config.module_id)
|
|
404
|
+
try:
|
|
405
|
+
tool_ref.resolve(registry)
|
|
406
|
+
logger.info("Resolved ToolReference '%s' -> %s", field_name, tool_ref.module_info)
|
|
407
|
+
except Exception:
|
|
408
|
+
logger.exception("Failed to resolve ToolReference '%s'", field_name)
|
|
409
|
+
|
|
410
|
+
@classmethod
|
|
411
|
+
def _resolve_list_items(cls, items: list, registry: "RegistryStrategy") -> None:
|
|
412
|
+
"""Resolve ToolReference instances in a list.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
items: List of items to process.
|
|
416
|
+
registry: Registry service for resolution.
|
|
417
|
+
"""
|
|
418
|
+
for item in items:
|
|
419
|
+
if isinstance(item, ToolReference):
|
|
420
|
+
cls._resolve_single_tool_reference("list_item", item, registry)
|
|
421
|
+
elif isinstance(item, BaseModel):
|
|
422
|
+
cls._resolve_tool_references_recursive(item, registry)
|
|
423
|
+
|
|
424
|
+
@classmethod
|
|
425
|
+
def _resolve_dict_values(cls, mapping: dict, registry: "RegistryStrategy") -> None:
|
|
426
|
+
"""Resolve ToolReference instances in dict values.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
mapping: Dict to process.
|
|
430
|
+
registry: Registry service for resolution.
|
|
431
|
+
"""
|
|
432
|
+
for item in mapping.values():
|
|
433
|
+
if isinstance(item, ToolReference):
|
|
434
|
+
cls._resolve_single_tool_reference("dict_value", item, registry)
|
|
435
|
+
elif isinstance(item, BaseModel):
|
|
436
|
+
cls._resolve_tool_references_recursive(item, registry)
|
|
437
|
+
|
|
438
|
+
def build_tool_cache(self) -> ToolCache:
|
|
439
|
+
"""Build tool cache from resolved ToolReferences, populating companion fields.
|
|
440
|
+
|
|
441
|
+
Returns:
|
|
442
|
+
ToolCache with field names as keys and ModuleInfo as values.
|
|
443
|
+
"""
|
|
444
|
+
logger.info("Building tool cache")
|
|
445
|
+
cache = ToolCache()
|
|
446
|
+
self._build_tool_cache_recursive(self, cache)
|
|
447
|
+
logger.info("Tool cache built: %d entries", len(cache.entries))
|
|
448
|
+
return cache
|
|
449
|
+
|
|
450
|
+
def _build_tool_cache_recursive(self, model_instance: BaseModel, cache: ToolCache) -> None: # noqa: C901
|
|
451
|
+
"""Recursively build tool cache and populate companion fields.
|
|
452
|
+
|
|
453
|
+
Args:
|
|
454
|
+
model_instance: Model instance to process.
|
|
455
|
+
cache: ToolCache to populate.
|
|
456
|
+
"""
|
|
457
|
+
for field_name, field_value in model_instance.__dict__.items():
|
|
458
|
+
if field_value is None:
|
|
459
|
+
continue
|
|
460
|
+
if isinstance(field_value, ToolReference):
|
|
461
|
+
cache_field_name = f"{field_name}_cache"
|
|
462
|
+
|
|
463
|
+
cached_info = getattr(model_instance, cache_field_name, None)
|
|
464
|
+
module_info = field_value.module_info or cached_info
|
|
465
|
+
if module_info:
|
|
466
|
+
if not cached_info:
|
|
467
|
+
setattr(model_instance, cache_field_name, module_info)
|
|
468
|
+
cache.add(module_info.module_id, module_info)
|
|
469
|
+
logger.debug("Added tool to cache: %s", module_info.module_id)
|
|
470
|
+
elif isinstance(field_value, BaseModel):
|
|
471
|
+
self._build_tool_cache_recursive(field_value, cache)
|
|
472
|
+
elif isinstance(field_value, list):
|
|
473
|
+
cache_field_name = f"{field_name}_cache"
|
|
474
|
+
cached_infos = getattr(model_instance, cache_field_name, None) or []
|
|
475
|
+
resolved_infos: list[ModuleInfo] = []
|
|
476
|
+
|
|
477
|
+
for idx, item in enumerate(field_value):
|
|
478
|
+
if isinstance(item, ToolReference):
|
|
479
|
+
# Use resolved info or fallback to cached
|
|
480
|
+
module_info = item.module_info or (cached_infos[idx] if idx < len(cached_infos) else None)
|
|
481
|
+
if module_info:
|
|
482
|
+
resolved_infos.append(module_info)
|
|
483
|
+
cache.add(module_info.module_id, module_info)
|
|
484
|
+
logger.debug("Added tool to cache: %s", module_info.module_id)
|
|
485
|
+
elif isinstance(item, BaseModel):
|
|
486
|
+
self._build_tool_cache_recursive(item, cache)
|
|
487
|
+
|
|
488
|
+
# Update companion field with resolved infos
|
|
489
|
+
if resolved_infos:
|
|
490
|
+
setattr(model_instance, cache_field_name, resolved_infos)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Tool cache for resolved tool references."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from digitalkin.logger import logger
|
|
6
|
+
from digitalkin.models.services.registry import ModuleInfo
|
|
7
|
+
from digitalkin.services.registry import RegistryStrategy
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ToolCache(BaseModel):
|
|
11
|
+
"""Registry cache storing resolved tool references by setup field name."""
|
|
12
|
+
|
|
13
|
+
entries: dict[str, ModuleInfo] = Field(default_factory=dict)
|
|
14
|
+
|
|
15
|
+
def add(self, setup_tool_name: str, module_info: ModuleInfo) -> None:
|
|
16
|
+
"""Add a tool to the cache.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
setup_tool_name: Field name from SetupModel used as cache key.
|
|
20
|
+
module_info: Resolved module information.
|
|
21
|
+
"""
|
|
22
|
+
self.entries[setup_tool_name] = module_info
|
|
23
|
+
logger.debug(
|
|
24
|
+
"Tool cached",
|
|
25
|
+
extra={"setup_tool_name": setup_tool_name, "module_id": module_info.module_id},
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def get(
|
|
29
|
+
self,
|
|
30
|
+
setup_tool_name: str,
|
|
31
|
+
*,
|
|
32
|
+
registry: RegistryStrategy | None = None,
|
|
33
|
+
) -> ModuleInfo | None:
|
|
34
|
+
"""Get a tool from cache, optionally querying registry on miss.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
setup_tool_name: Field name to look up.
|
|
38
|
+
registry: Optional registry to query on cache miss.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
ModuleInfo if found, None otherwise.
|
|
42
|
+
"""
|
|
43
|
+
cached = self.entries.get(setup_tool_name)
|
|
44
|
+
if cached:
|
|
45
|
+
return cached
|
|
46
|
+
|
|
47
|
+
if registry:
|
|
48
|
+
try:
|
|
49
|
+
info = registry.discover_by_id(setup_tool_name)
|
|
50
|
+
if info:
|
|
51
|
+
self.add(setup_tool_name, info)
|
|
52
|
+
return info
|
|
53
|
+
except Exception:
|
|
54
|
+
logger.exception("Registry lookup failed", extra={"setup_tool_name": setup_tool_name})
|
|
55
|
+
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
def clear(self) -> None:
|
|
59
|
+
"""Clear all cache entries."""
|
|
60
|
+
self.entries.clear()
|
|
61
|
+
|
|
62
|
+
def list_tools(self) -> list[str]:
|
|
63
|
+
"""List all cached tool names.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
List of setup field names in cache.
|
|
67
|
+
"""
|
|
68
|
+
return list(self.entries.keys())
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Tool reference types for module configuration."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field, PrivateAttr, model_validator
|
|
6
|
+
|
|
7
|
+
from digitalkin.models.services.registry import ModuleInfo
|
|
8
|
+
from digitalkin.services.registry import RegistryStrategy
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ToolSelectionMode(str, Enum):
|
|
12
|
+
"""Tool selection mode."""
|
|
13
|
+
|
|
14
|
+
TAG = "tag"
|
|
15
|
+
FIXED = "fixed"
|
|
16
|
+
DISCOVERABLE = "discoverable"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ToolReferenceConfig(BaseModel):
|
|
20
|
+
"""Tool selection configuration. The module_id serves as both identifier and cache key."""
|
|
21
|
+
|
|
22
|
+
mode: ToolSelectionMode = Field(default=ToolSelectionMode.FIXED)
|
|
23
|
+
module_id: str = Field(default="")
|
|
24
|
+
tag: str = Field(default="")
|
|
25
|
+
organization_id: str = Field(default="")
|
|
26
|
+
|
|
27
|
+
@model_validator(mode="after")
|
|
28
|
+
def validate_config(self) -> "ToolReferenceConfig":
|
|
29
|
+
"""Validate required fields based on mode.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Self if validation passes.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
ValueError: If required field is missing for the mode.
|
|
36
|
+
"""
|
|
37
|
+
if self.mode == ToolSelectionMode.FIXED and not self.module_id:
|
|
38
|
+
msg = "module_id required when mode is FIXED"
|
|
39
|
+
raise ValueError(msg)
|
|
40
|
+
if self.mode == ToolSelectionMode.TAG and not self.tag:
|
|
41
|
+
msg = "tag required when mode is TAG"
|
|
42
|
+
raise ValueError(msg)
|
|
43
|
+
return self
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ToolReference(BaseModel):
|
|
47
|
+
"""Reference to a tool module, resolved via registry during config setup."""
|
|
48
|
+
|
|
49
|
+
config: ToolReferenceConfig
|
|
50
|
+
_cached_info: ModuleInfo | None = PrivateAttr(default=None)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def slug(self) -> str:
|
|
54
|
+
"""Cache key (same as module_id).
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Module ID used as cache key.
|
|
58
|
+
"""
|
|
59
|
+
return self.config.module_id
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def module_id(self) -> str:
|
|
63
|
+
"""Module identifier.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Module ID or empty string if not set.
|
|
67
|
+
"""
|
|
68
|
+
return self.config.module_id
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def module_info(self) -> ModuleInfo | None:
|
|
72
|
+
"""Resolved module information.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
ModuleInfo if resolved, None otherwise.
|
|
76
|
+
"""
|
|
77
|
+
return self._cached_info
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def is_resolved(self) -> bool:
|
|
81
|
+
"""Whether this reference has been resolved.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
True if resolved, False otherwise.
|
|
85
|
+
"""
|
|
86
|
+
return self._cached_info is not None
|
|
87
|
+
|
|
88
|
+
def resolve(self, registry: RegistryStrategy) -> ModuleInfo | None:
|
|
89
|
+
"""Resolve this reference using the registry.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
registry: Registry service for module discovery.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
ModuleInfo if resolved, None for DISCOVERABLE mode or if not found.
|
|
96
|
+
"""
|
|
97
|
+
if self.config.mode == ToolSelectionMode.DISCOVERABLE:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
if self.config.mode == ToolSelectionMode.FIXED and self.config.module_id:
|
|
101
|
+
info = registry.discover_by_id(self.config.module_id)
|
|
102
|
+
if info:
|
|
103
|
+
self._cached_info = info
|
|
104
|
+
return info
|
|
105
|
+
|
|
106
|
+
if self.config.mode == ToolSelectionMode.TAG and self.config.tag:
|
|
107
|
+
results = registry.search(
|
|
108
|
+
name=self.config.tag,
|
|
109
|
+
module_type="tool",
|
|
110
|
+
organization_id=self.config.organization_id,
|
|
111
|
+
)
|
|
112
|
+
if results:
|
|
113
|
+
self._cached_info = results[0]
|
|
114
|
+
self.config.module_id = results[0].module_id
|
|
115
|
+
return results[0]
|
|
116
|
+
|
|
117
|
+
return None
|