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.
Files changed (122) hide show
  1. base_server/server_async_insecure.py +6 -5
  2. base_server/server_async_secure.py +6 -5
  3. base_server/server_sync_insecure.py +5 -4
  4. base_server/server_sync_secure.py +5 -4
  5. digitalkin/__version__.py +1 -1
  6. digitalkin/core/__init__.py +1 -0
  7. digitalkin/core/common/__init__.py +9 -0
  8. digitalkin/core/common/factories.py +156 -0
  9. digitalkin/core/job_manager/__init__.py +1 -0
  10. digitalkin/{modules → core}/job_manager/base_job_manager.py +138 -32
  11. digitalkin/core/job_manager/single_job_manager.py +373 -0
  12. digitalkin/{modules → core}/job_manager/taskiq_broker.py +121 -26
  13. digitalkin/core/job_manager/taskiq_job_manager.py +541 -0
  14. digitalkin/core/task_manager/__init__.py +1 -0
  15. digitalkin/core/task_manager/base_task_manager.py +539 -0
  16. digitalkin/core/task_manager/local_task_manager.py +108 -0
  17. digitalkin/core/task_manager/remote_task_manager.py +87 -0
  18. digitalkin/core/task_manager/surrealdb_repository.py +266 -0
  19. digitalkin/core/task_manager/task_executor.py +249 -0
  20. digitalkin/core/task_manager/task_session.py +368 -0
  21. digitalkin/grpc_servers/__init__.py +1 -19
  22. digitalkin/grpc_servers/_base_server.py +3 -3
  23. digitalkin/grpc_servers/module_server.py +120 -195
  24. digitalkin/grpc_servers/module_servicer.py +81 -44
  25. digitalkin/grpc_servers/utils/__init__.py +1 -0
  26. digitalkin/grpc_servers/utils/exceptions.py +0 -8
  27. digitalkin/grpc_servers/utils/grpc_client_wrapper.py +25 -9
  28. digitalkin/grpc_servers/utils/grpc_error_handler.py +53 -0
  29. digitalkin/grpc_servers/utils/utility_schema_extender.py +100 -0
  30. digitalkin/logger.py +64 -27
  31. digitalkin/mixins/__init__.py +19 -0
  32. digitalkin/mixins/base_mixin.py +10 -0
  33. digitalkin/mixins/callback_mixin.py +24 -0
  34. digitalkin/mixins/chat_history_mixin.py +110 -0
  35. digitalkin/mixins/cost_mixin.py +76 -0
  36. digitalkin/mixins/file_history_mixin.py +93 -0
  37. digitalkin/mixins/filesystem_mixin.py +46 -0
  38. digitalkin/mixins/logger_mixin.py +51 -0
  39. digitalkin/mixins/storage_mixin.py +79 -0
  40. digitalkin/models/__init__.py +1 -1
  41. digitalkin/models/core/__init__.py +1 -0
  42. digitalkin/{modules/job_manager → models/core}/job_manager_models.py +3 -11
  43. digitalkin/models/core/task_monitor.py +74 -0
  44. digitalkin/models/grpc_servers/__init__.py +1 -0
  45. digitalkin/{grpc_servers/utils → models/grpc_servers}/models.py +92 -7
  46. digitalkin/models/module/__init__.py +18 -11
  47. digitalkin/models/module/base_types.py +61 -0
  48. digitalkin/models/module/module.py +9 -1
  49. digitalkin/models/module/module_context.py +282 -6
  50. digitalkin/models/module/module_types.py +29 -105
  51. digitalkin/models/module/setup_types.py +490 -0
  52. digitalkin/models/module/tool_cache.py +68 -0
  53. digitalkin/models/module/tool_reference.py +117 -0
  54. digitalkin/models/module/utility.py +167 -0
  55. digitalkin/models/services/__init__.py +9 -0
  56. digitalkin/models/services/cost.py +1 -0
  57. digitalkin/models/services/registry.py +35 -0
  58. digitalkin/models/services/storage.py +39 -5
  59. digitalkin/modules/__init__.py +5 -1
  60. digitalkin/modules/_base_module.py +265 -167
  61. digitalkin/modules/archetype_module.py +6 -1
  62. digitalkin/modules/tool_module.py +16 -3
  63. digitalkin/modules/trigger_handler.py +7 -6
  64. digitalkin/modules/triggers/__init__.py +8 -0
  65. digitalkin/modules/triggers/healthcheck_ping_trigger.py +45 -0
  66. digitalkin/modules/triggers/healthcheck_services_trigger.py +63 -0
  67. digitalkin/modules/triggers/healthcheck_status_trigger.py +52 -0
  68. digitalkin/services/__init__.py +4 -0
  69. digitalkin/services/communication/__init__.py +7 -0
  70. digitalkin/services/communication/communication_strategy.py +76 -0
  71. digitalkin/services/communication/default_communication.py +101 -0
  72. digitalkin/services/communication/grpc_communication.py +234 -0
  73. digitalkin/services/cost/__init__.py +9 -2
  74. digitalkin/services/cost/grpc_cost.py +9 -42
  75. digitalkin/services/filesystem/default_filesystem.py +0 -2
  76. digitalkin/services/filesystem/grpc_filesystem.py +10 -39
  77. digitalkin/services/registry/__init__.py +22 -1
  78. digitalkin/services/registry/default_registry.py +135 -4
  79. digitalkin/services/registry/exceptions.py +47 -0
  80. digitalkin/services/registry/grpc_registry.py +306 -0
  81. digitalkin/services/registry/registry_models.py +15 -0
  82. digitalkin/services/registry/registry_strategy.py +88 -4
  83. digitalkin/services/services_config.py +25 -3
  84. digitalkin/services/services_models.py +5 -1
  85. digitalkin/services/setup/default_setup.py +6 -7
  86. digitalkin/services/setup/grpc_setup.py +52 -15
  87. digitalkin/services/storage/grpc_storage.py +4 -4
  88. digitalkin/services/user_profile/__init__.py +12 -0
  89. digitalkin/services/user_profile/default_user_profile.py +55 -0
  90. digitalkin/services/user_profile/grpc_user_profile.py +69 -0
  91. digitalkin/services/user_profile/user_profile_strategy.py +25 -0
  92. digitalkin/utils/__init__.py +28 -0
  93. digitalkin/utils/arg_parser.py +1 -1
  94. digitalkin/utils/development_mode_action.py +2 -2
  95. digitalkin/utils/dynamic_schema.py +483 -0
  96. digitalkin/utils/package_discover.py +1 -2
  97. digitalkin/utils/schema_splitter.py +207 -0
  98. {digitalkin-0.2.25rc0.dist-info → digitalkin-0.3.2.dev14.dist-info}/METADATA +11 -30
  99. digitalkin-0.3.2.dev14.dist-info/RECORD +143 -0
  100. {digitalkin-0.2.25rc0.dist-info → digitalkin-0.3.2.dev14.dist-info}/top_level.txt +1 -0
  101. modules/archetype_with_tools_module.py +244 -0
  102. modules/cpu_intensive_module.py +1 -1
  103. modules/dynamic_setup_module.py +338 -0
  104. modules/minimal_llm_module.py +1 -1
  105. modules/text_transform_module.py +1 -1
  106. monitoring/digitalkin_observability/__init__.py +46 -0
  107. monitoring/digitalkin_observability/http_server.py +150 -0
  108. monitoring/digitalkin_observability/interceptors.py +176 -0
  109. monitoring/digitalkin_observability/metrics.py +201 -0
  110. monitoring/digitalkin_observability/prometheus.py +137 -0
  111. monitoring/tests/test_metrics.py +172 -0
  112. services/filesystem_module.py +7 -5
  113. services/storage_module.py +4 -2
  114. digitalkin/grpc_servers/registry_server.py +0 -65
  115. digitalkin/grpc_servers/registry_servicer.py +0 -456
  116. digitalkin/grpc_servers/utils/factory.py +0 -180
  117. digitalkin/modules/job_manager/single_job_manager.py +0 -294
  118. digitalkin/modules/job_manager/taskiq_job_manager.py +0 -290
  119. digitalkin-0.2.25rc0.dist-info/RECORD +0 -89
  120. /digitalkin/{grpc_servers/utils → models/grpc_servers}/types.py +0 -0
  121. {digitalkin-0.2.25rc0.dist-info → digitalkin-0.3.2.dev14.dist-info}/WHEEL +0 -0
  122. {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