digitalkin 0.3.2.dev7__py3-none-any.whl → 0.3.2.dev8__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 (27) hide show
  1. digitalkin/__version__.py +1 -1
  2. digitalkin/grpc_servers/module_servicer.py +0 -11
  3. digitalkin/grpc_servers/utils/utility_schema_extender.py +2 -1
  4. digitalkin/models/module/module_context.py +136 -23
  5. digitalkin/models/module/setup_types.py +168 -257
  6. digitalkin/models/module/tool_cache.py +27 -187
  7. digitalkin/models/module/tool_reference.py +42 -45
  8. digitalkin/models/services/registry.py +0 -7
  9. digitalkin/modules/_base_module.py +74 -52
  10. digitalkin/services/registry/__init__.py +1 -1
  11. digitalkin/services/registry/default_registry.py +1 -1
  12. digitalkin/services/registry/grpc_registry.py +1 -1
  13. digitalkin/services/registry/registry_models.py +1 -29
  14. digitalkin/services/registry/registry_strategy.py +1 -1
  15. {digitalkin-0.3.2.dev7.dist-info → digitalkin-0.3.2.dev8.dist-info}/METADATA +1 -1
  16. {digitalkin-0.3.2.dev7.dist-info → digitalkin-0.3.2.dev8.dist-info}/RECORD +26 -20
  17. {digitalkin-0.3.2.dev7.dist-info → digitalkin-0.3.2.dev8.dist-info}/top_level.txt +1 -0
  18. modules/archetype_with_tools_module.py +244 -0
  19. monitoring/digitalkin_observability/__init__.py +46 -0
  20. monitoring/digitalkin_observability/http_server.py +150 -0
  21. monitoring/digitalkin_observability/interceptors.py +176 -0
  22. monitoring/digitalkin_observability/metrics.py +201 -0
  23. monitoring/digitalkin_observability/prometheus.py +137 -0
  24. monitoring/tests/test_metrics.py +172 -0
  25. digitalkin/models/module/module_helpers.py +0 -189
  26. {digitalkin-0.3.2.dev7.dist-info → digitalkin-0.3.2.dev8.dist-info}/WHEEL +0 -0
  27. {digitalkin-0.3.2.dev7.dist-info → digitalkin-0.3.2.dev8.dist-info}/licenses/LICENSE +0 -0
@@ -1,17 +1,16 @@
1
- """Setup model types with dynamic schema resolution."""
2
-
3
- from __future__ import annotations
1
+ """Setup model types with dynamic schema resolution and tool reference support."""
4
2
 
5
3
  import copy
6
4
  import types
7
5
  import typing
8
6
  from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar, cast, get_args, get_origin
9
7
 
10
- from pydantic import BaseModel, ConfigDict, PrivateAttr, create_model
8
+ from pydantic import BaseModel, ConfigDict, Field, create_model
11
9
 
12
10
  from digitalkin.logger import logger
13
11
  from digitalkin.models.module.tool_cache import ToolCache
14
12
  from digitalkin.models.module.tool_reference import ToolReference
13
+ from digitalkin.models.services.registry import ModuleInfo
15
14
  from digitalkin.utils.dynamic_schema import (
16
15
  DynamicField,
17
16
  get_fetchers,
@@ -28,25 +27,67 @@ SetupModelT = TypeVar("SetupModelT", bound="SetupModel")
28
27
 
29
28
 
30
29
  class SetupModel(BaseModel, Generic[SetupModelT]):
31
- """Base definition of setup model showing mandatory root fields.
30
+ """Base setup model with dynamic schema and tool cache support."""
31
+
32
+ _clean_model_cache: ClassVar[dict[tuple[type, bool, bool], type]] = {}
32
33
 
33
- Optionally, the setup model can define a config option in json_schema_extra
34
- to be used to initialize the Kin. Supports dynamic schema providers for
35
- runtime value generation.
34
+ def __init_subclass__(cls, **kwargs: Any) -> None: # noqa: ANN401
35
+ """Inject hidden companion fields for ToolReference annotations.
36
36
 
37
- The tool_cache is populated during run_config_setup and contains resolved
38
- ModuleInfo indexed by slug. It is validated during initialize.
37
+ Args:
38
+ **kwargs: Keyword arguments passed to parent.
39
+ """
40
+ super().__init_subclass__(**kwargs)
41
+ cls._inject_tool_cache_fields()
39
42
 
40
- Attributes:
41
- model_fields: Inherited from Pydantic BaseModel, contains field definitions.
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}
42
72
 
43
- See Also:
44
- - Documentation: docs/api/dynamic_schema.md
45
- - Tests: tests/modules/test_setup_model.py
46
- """
73
+ @classmethod
74
+ def _is_tool_reference_annotation(cls, annotation: object) -> bool:
75
+ """Check if annotation is ToolReference or Optional[ToolReference].
47
76
 
48
- _clean_model_cache: ClassVar[dict[tuple[type, bool, bool], type]] = {}
49
- _tool_cache: ToolCache = PrivateAttr(default_factory=ToolCache)
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))
50
91
 
51
92
  @classmethod
52
93
  async def get_clean_model(
@@ -55,30 +96,17 @@ class SetupModel(BaseModel, Generic[SetupModelT]):
55
96
  config_fields: bool,
56
97
  hidden_fields: bool,
57
98
  force: bool = False,
58
- ) -> type[SetupModelT]:
59
- """Dynamically builds and returns a new BaseModel subclass with filtered fields.
60
-
61
- This method filters fields based on their `json_schema_extra` metadata:
62
- - Fields with `{"config": True}` are included only when `config_fields=True`
63
- - Fields with `{"hidden": True}` are included only when `hidden_fields=True`
64
-
65
- When `force=True`, fields with dynamic schema providers will have their
66
- providers called to fetch fresh values for schema metadata like enums.
67
- This includes recursively processing nested BaseModel fields.
99
+ ) -> "type[SetupModelT]":
100
+ """Build filtered model based on json_schema_extra metadata.
68
101
 
69
102
  Args:
70
- config_fields: If True, include fields marked with `{"config": True}`.
71
- These are typically initial configuration fields.
72
- hidden_fields: If True, include fields marked with `{"hidden": True}`.
73
- These are typically runtime-only fields not shown in initial config.
74
- force: If True, refresh dynamic schema fields by calling their providers.
75
- Use this when you need up-to-date values from external sources like
76
- databases or APIs. Default is False for performance.
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.
77
106
 
78
107
  Returns:
79
- A new BaseModel subclass with filtered fields.
108
+ New BaseModel subclass with filtered fields.
80
109
  """
81
- # Check cache for non-forced requests
82
110
  cache_key = (cls, config_fields, hidden_fields)
83
111
  if not force and cache_key in cls._clean_model_cache:
84
112
  return cast("type[SetupModelT]", cls._clean_model_cache[cache_key])
@@ -90,68 +118,53 @@ class SetupModel(BaseModel, Generic[SetupModelT]):
90
118
  is_config = bool(extra.get("config", False)) if isinstance(extra, dict) else False
91
119
  is_hidden = bool(extra.get("hidden", False)) if isinstance(extra, dict) else False
92
120
 
93
- # Skip config unless explicitly included
94
121
  if is_config and not config_fields:
95
- logger.debug("Skipping '%s' (config-only)", name)
96
122
  continue
97
-
98
- # Skip hidden unless explicitly included
99
123
  if is_hidden and not hidden_fields:
100
- logger.debug("Skipping '%s' (hidden-only)", name)
101
124
  continue
102
125
 
103
- # Refresh dynamic schema fields when force=True
104
126
  current_field_info = field_info
105
127
  current_annotation = field_info.annotation
106
128
 
107
129
  if force:
108
- # Check if this field has DynamicField metadata
109
130
  if has_dynamic(field_info):
110
131
  current_field_info = await cls._refresh_field_schema(name, field_info)
111
132
 
112
- # Check if the annotation is a nested BaseModel that might have dynamic fields
113
133
  nested_model = cls._get_base_model_type(current_annotation)
114
134
  if nested_model is not None:
115
135
  refreshed_nested = await cls._refresh_nested_model(nested_model)
116
136
  if refreshed_nested is not nested_model:
117
- # Update annotation to use refreshed nested model
118
137
  current_annotation = refreshed_nested
119
- # Create new field_info with updated annotation (deep copy for safety)
120
138
  current_field_info = copy.deepcopy(current_field_info)
121
139
  current_field_info.annotation = current_annotation
122
140
 
123
141
  clean_fields[name] = (current_annotation, current_field_info)
124
142
 
125
- # Dynamically create a model e.g. "SetupModel"
126
143
  m = create_model(
127
144
  f"{cls.__name__}",
128
- __base__=BaseModel,
145
+ __base__=SetupModel,
129
146
  __config__=ConfigDict(arbitrary_types_allowed=True),
130
147
  **clean_fields,
131
148
  )
132
149
 
133
- # Cache for non-forced requests
134
150
  if not force:
135
151
  cls._clean_model_cache[cache_key] = m
136
152
 
137
153
  return cast("type[SetupModelT]", m)
138
154
 
139
155
  @classmethod
140
- def _get_base_model_type(cls, annotation: type | None) -> type[BaseModel] | None:
141
- """Extract BaseModel type from an annotation.
142
-
143
- Handles direct types, Optional, Union, list, dict, set, tuple, and other generics.
156
+ def _get_base_model_type(cls, annotation: "type | None") -> "type[BaseModel] | None":
157
+ """Extract BaseModel type from annotation.
144
158
 
145
159
  Args:
146
- annotation: The type annotation to inspect.
160
+ annotation: Type annotation to inspect.
147
161
 
148
162
  Returns:
149
- The BaseModel subclass if found, None otherwise.
163
+ BaseModel subclass if found, None otherwise.
150
164
  """
151
165
  if annotation is None:
152
166
  return None
153
167
 
154
- # Direct BaseModel subclass check
155
168
  if isinstance(annotation, type) and issubclass(annotation, BaseModel):
156
169
  return annotation
157
170
 
@@ -166,43 +179,41 @@ class SetupModel(BaseModel, Generic[SetupModelT]):
166
179
  def _extract_base_model_from_args(
167
180
  cls,
168
181
  origin: type,
169
- args: tuple[type, ...],
170
- ) -> type[BaseModel] | None:
182
+ args: "tuple[type, ...]",
183
+ ) -> "type[BaseModel] | None":
171
184
  """Extract BaseModel from generic type arguments.
172
185
 
173
186
  Args:
174
- origin: The generic origin type (list, dict, Union, etc.).
175
- args: The type arguments.
187
+ origin: Generic origin type (list, dict, Union, etc.).
188
+ args: Type arguments.
176
189
 
177
190
  Returns:
178
- The BaseModel subclass if found, None otherwise.
191
+ BaseModel subclass if found, None otherwise.
179
192
  """
180
- # Union/Optional: check each arg (supports both typing.Union and types.UnionType)
181
- # Python 3.10+ uses types.UnionType for X | Y syntax
182
193
  if origin is typing.Union or origin is types.UnionType:
183
194
  return cls._find_base_model_in_args(args)
184
195
 
185
- # list, set, frozenset: check first arg
186
196
  if origin in {list, set, frozenset} and args:
187
197
  return cls._check_base_model(args[0])
188
198
 
189
- # dict: check value type (second arg)
190
199
  dict_value_index = 1
191
200
  if origin is dict and len(args) > dict_value_index:
192
201
  return cls._check_base_model(args[dict_value_index])
193
202
 
194
- # tuple: check first non-ellipsis arg
195
203
  if origin is tuple:
196
204
  return cls._find_base_model_in_args(args, skip_ellipsis=True)
197
205
 
198
206
  return None
199
207
 
200
208
  @classmethod
201
- def _check_base_model(cls, arg: type) -> type[BaseModel] | None:
209
+ def _check_base_model(cls, arg: type) -> "type[BaseModel] | None":
202
210
  """Check if arg is a BaseModel subclass.
203
211
 
212
+ Args:
213
+ arg: Type to check.
214
+
204
215
  Returns:
205
- The BaseModel subclass if arg is one, None otherwise.
216
+ The type if it's a BaseModel subclass, None otherwise.
206
217
  """
207
218
  if isinstance(arg, type) and issubclass(arg, BaseModel):
208
219
  return arg
@@ -211,14 +222,18 @@ class SetupModel(BaseModel, Generic[SetupModelT]):
211
222
  @classmethod
212
223
  def _find_base_model_in_args(
213
224
  cls,
214
- args: tuple[type, ...],
225
+ args: "tuple[type, ...]",
215
226
  *,
216
227
  skip_ellipsis: bool = False,
217
- ) -> type[BaseModel] | None:
218
- """Find first BaseModel in args.
228
+ ) -> "type[BaseModel] | None":
229
+ """Find first BaseModel in type args.
230
+
231
+ Args:
232
+ args: Type arguments to search.
233
+ skip_ellipsis: Skip ellipsis in tuple types.
219
234
 
220
235
  Returns:
221
- The first BaseModel subclass found, None otherwise.
236
+ First BaseModel subclass found, None otherwise.
222
237
  """
223
238
  for arg in args:
224
239
  if arg is type(None):
@@ -231,16 +246,14 @@ class SetupModel(BaseModel, Generic[SetupModelT]):
231
246
  return None
232
247
 
233
248
  @classmethod
234
- async def _refresh_nested_model(cls, model_cls: type[BaseModel]) -> type[BaseModel]:
249
+ async def _refresh_nested_model(cls, model_cls: "type[BaseModel]") -> "type[BaseModel]":
235
250
  """Refresh dynamic fields in a nested BaseModel.
236
251
 
237
- Creates a new model class with all DynamicField metadata resolved.
238
-
239
252
  Args:
240
- model_cls: The nested model class to refresh.
253
+ model_cls: Nested model class to refresh.
241
254
 
242
255
  Returns:
243
- A new model class with refreshed fields, or the original if no changes.
256
+ New model class with refreshed fields, or original if no changes.
244
257
  """
245
258
  has_changes = False
246
259
  clean_fields: dict[str, Any] = {}
@@ -249,12 +262,10 @@ class SetupModel(BaseModel, Generic[SetupModelT]):
249
262
  current_field_info = field_info
250
263
  current_annotation = field_info.annotation
251
264
 
252
- # Check if field has DynamicField metadata
253
265
  if has_dynamic(field_info):
254
266
  current_field_info = await cls._refresh_field_schema(name, field_info)
255
267
  has_changes = True
256
268
 
257
- # Recursively check nested models
258
269
  nested_model = cls._get_base_model_type(current_annotation)
259
270
  if nested_model is not None:
260
271
  refreshed_nested = await cls._refresh_nested_model(nested_model)
@@ -269,8 +280,6 @@ class SetupModel(BaseModel, Generic[SetupModelT]):
269
280
  if not has_changes:
270
281
  return model_cls
271
282
 
272
- # Create new model with refreshed fields
273
- logger.debug("Creating refreshed nested model for '%s'", model_cls.__name__)
274
283
  return create_model(
275
284
  model_cls.__name__,
276
285
  __base__=BaseModel,
@@ -279,132 +288,84 @@ class SetupModel(BaseModel, Generic[SetupModelT]):
279
288
  )
280
289
 
281
290
  @classmethod
282
- async def _refresh_field_schema(cls, field_name: str, field_info: FieldInfo) -> FieldInfo:
283
- """Refresh a field's json_schema_extra with fresh values from dynamic providers.
284
-
285
- This method calls all dynamic providers registered for a field (via Annotated
286
- metadata) and creates a new FieldInfo with the resolved values. The original
287
- field_info is not modified.
288
-
289
- Uses `resolve_safe()` for structured error handling, allowing partial success
290
- when some fetchers fail. Successfully resolved values are still applied.
291
+ async def _refresh_field_schema(cls, field_name: str, field_info: "FieldInfo") -> "FieldInfo":
292
+ """Refresh field's json_schema_extra with values from dynamic providers.
291
293
 
292
294
  Args:
293
- field_name: The name of the field being refreshed (used for logging).
294
- field_info: The original FieldInfo object containing the dynamic providers.
295
+ field_name: Name of field being refreshed.
296
+ field_info: Original FieldInfo with dynamic providers.
295
297
 
296
298
  Returns:
297
- A new FieldInfo object with the same attributes as the original, but with
298
- `json_schema_extra` containing resolved values and Dynamic metadata removed.
299
-
300
- Note:
301
- If all fetchers fail, the original field_info is returned unchanged.
302
- If some fetchers fail, successfully resolved values are still applied.
299
+ New FieldInfo with resolved values, or original if all fetchers fail.
303
300
  """
304
301
  fetchers = get_fetchers(field_info)
305
302
 
306
303
  if not fetchers:
307
304
  return field_info
308
305
 
309
- fetcher_keys = list(fetchers.keys())
310
- logger.debug(
311
- "Refreshing dynamic schema for field '%s' with fetchers: %s",
312
- field_name,
313
- fetcher_keys,
314
- extra={"field_name": field_name, "fetcher_keys": fetcher_keys},
315
- )
316
-
317
- # Resolve all fetchers with structured error handling
318
306
  result = await resolve_safe(fetchers)
319
307
 
320
- # Log any errors that occurred with full details
321
308
  if result.errors:
322
309
  for key, error in result.errors.items():
323
310
  logger.warning(
324
- "Failed to resolve '%s' for field '%s': %s: %s",
311
+ "Failed to resolve '%s' for field '%s': %s",
325
312
  key,
326
313
  field_name,
327
- type(error).__name__,
328
- str(error) or "(no message)",
329
- extra={
330
- "field_name": field_name,
331
- "fetcher_key": key,
332
- "error_type": type(error).__name__,
333
- "error_message": str(error),
334
- "error_repr": repr(error),
335
- },
314
+ error,
336
315
  )
337
316
 
338
- # If no values were resolved, return original field_info
339
317
  if not result.values:
340
- logger.warning(
341
- "All fetchers failed for field '%s', keeping original",
342
- field_name,
343
- )
344
318
  return field_info
345
319
 
346
- # Build new json_schema_extra with resolved values merged
347
320
  extra = field_info.json_schema_extra or {}
348
321
  new_extra = {**extra, **result.values} if isinstance(extra, dict) else result.values
349
322
 
350
- # Create a deep copy of the FieldInfo to avoid shared mutable state
351
323
  new_field_info = copy.deepcopy(field_info)
352
324
  new_field_info.json_schema_extra = new_extra
353
-
354
- # Remove Dynamic from metadata (it's been resolved)
355
- new_metadata = [m for m in new_field_info.metadata if not isinstance(m, DynamicField)]
356
- new_field_info.metadata = new_metadata
357
-
358
- logger.debug(
359
- "Refreshed '%s' with dynamic values: %s",
360
- field_name,
361
- list(result.values.keys()),
362
- )
325
+ new_field_info.metadata = [m for m in new_field_info.metadata if not isinstance(m, DynamicField)]
363
326
 
364
327
  return new_field_info
365
328
 
366
- def resolve_tool_references(self, registry: RegistryStrategy) -> None:
367
- """Resolve all ToolReference fields in this setup instance.
368
-
369
- Recursively walks through all fields, including nested BaseModel instances,
370
- and resolves any ToolReference fields using the provided registry.
329
+ def resolve_tool_references(self, registry: "RegistryStrategy") -> None:
330
+ """Resolve all ToolReference fields recursively.
371
331
 
372
332
  Args:
373
- registry: Registry service to use for resolution.
333
+ registry: Registry service for module discovery.
374
334
  """
335
+ logger.info("Starting resolve_tool_references")
375
336
  self._resolve_tool_references_recursive(self, registry)
337
+ logger.info("Finished resolve_tool_references")
376
338
 
377
339
  @classmethod
378
340
  def _resolve_tool_references_recursive(
379
341
  cls,
380
342
  model_instance: BaseModel,
381
- registry: RegistryStrategy,
343
+ registry: "RegistryStrategy",
382
344
  ) -> None:
383
- """Recursively resolve ToolReference fields in a model instance.
345
+ """Recursively resolve ToolReference fields in a model.
384
346
 
385
347
  Args:
386
- model_instance: The model instance to process.
387
- registry: Registry service to use for resolution.
348
+ model_instance: Model instance to process.
349
+ registry: Registry service for resolution.
388
350
  """
389
351
  for field_name, field_value in model_instance.__dict__.items():
390
352
  if field_value is None:
391
353
  continue
392
-
393
354
  cls._resolve_field_value(field_name, field_value, registry)
394
355
 
395
356
  @classmethod
396
357
  def _resolve_field_value(
397
358
  cls,
398
359
  field_name: str,
399
- field_value: BaseModel | ToolReference | list | dict,
400
- registry: RegistryStrategy,
360
+ field_value: "BaseModel | ToolReference | list | dict",
361
+ registry: "RegistryStrategy",
401
362
  ) -> None:
402
- """Resolve a single field value, handling different types.
363
+ """Resolve a single field value based on its type.
403
364
 
404
365
  Args:
405
- field_name: Name of the field for logging.
406
- field_value: The value to process.
407
- registry: Registry service to use for resolution.
366
+ field_name: Name of the field.
367
+ field_value: Value to process.
368
+ registry: Registry service for resolution.
408
369
  """
409
370
  if isinstance(field_value, ToolReference):
410
371
  cls._resolve_single_tool_reference(field_name, field_value, registry)
@@ -420,40 +381,29 @@ class SetupModel(BaseModel, Generic[SetupModelT]):
420
381
  cls,
421
382
  field_name: str,
422
383
  tool_ref: ToolReference,
423
- registry: RegistryStrategy,
384
+ registry: "RegistryStrategy",
424
385
  ) -> None:
425
- """Resolve a single ToolReference instance.
386
+ """Resolve a single ToolReference.
426
387
 
427
388
  Args:
428
389
  field_name: Name of the field for logging.
429
- tool_ref: The ToolReference instance.
430
- registry: Registry service to use for resolution.
390
+ tool_ref: ToolReference to resolve.
391
+ registry: Registry service for resolution.
431
392
  """
393
+ logger.info("Resolving ToolReference '%s' with module_id='%s'", field_name, tool_ref.config.module_id)
432
394
  try:
433
395
  tool_ref.resolve(registry)
434
- logger.debug(
435
- "Resolved ToolReference field '%s'",
436
- field_name,
437
- extra={"field_name": field_name, "mode": tool_ref.config.mode.value},
438
- )
396
+ logger.info("Resolved ToolReference '%s' -> %s", field_name, tool_ref.module_info)
439
397
  except Exception:
440
- logger.exception(
441
- "Failed to resolve ToolReference field '%s'",
442
- field_name,
443
- extra={"field_name": field_name, "config": tool_ref.config.model_dump()},
444
- )
398
+ logger.exception("Failed to resolve ToolReference '%s'", field_name)
445
399
 
446
400
  @classmethod
447
- def _resolve_list_items(
448
- cls,
449
- items: list,
450
- registry: RegistryStrategy,
451
- ) -> None:
401
+ def _resolve_list_items(cls, items: list, registry: "RegistryStrategy") -> None:
452
402
  """Resolve ToolReference instances in a list.
453
403
 
454
404
  Args:
455
405
  items: List of items to process.
456
- registry: Registry service to use for resolution.
406
+ registry: Registry service for resolution.
457
407
  """
458
408
  for item in items:
459
409
  if isinstance(item, ToolReference):
@@ -462,16 +412,12 @@ class SetupModel(BaseModel, Generic[SetupModelT]):
462
412
  cls._resolve_tool_references_recursive(item, registry)
463
413
 
464
414
  @classmethod
465
- def _resolve_dict_values(
466
- cls,
467
- mapping: dict,
468
- registry: RegistryStrategy,
469
- ) -> None:
470
- """Resolve ToolReference instances in a dict's values.
415
+ def _resolve_dict_values(cls, mapping: dict, registry: "RegistryStrategy") -> None:
416
+ """Resolve ToolReference instances in dict values.
471
417
 
472
418
  Args:
473
419
  mapping: Dict to process.
474
- registry: Registry service to use for resolution.
420
+ registry: Registry service for resolution.
475
421
  """
476
422
  for item in mapping.values():
477
423
  if isinstance(item, ToolReference):
@@ -479,95 +425,60 @@ class SetupModel(BaseModel, Generic[SetupModelT]):
479
425
  elif isinstance(item, BaseModel):
480
426
  cls._resolve_tool_references_recursive(item, registry)
481
427
 
482
- @property
483
- def tool_cache(self) -> ToolCache:
484
- """Get the tool cache for this setup instance.
485
-
486
- Returns:
487
- The ToolCache containing resolved tools.
488
- """
489
- return self._tool_cache
490
-
491
428
  def build_tool_cache(self) -> ToolCache:
492
- """Build the tool cache from resolved ToolReferences.
493
-
494
- This should be called during run_config_setup after resolve_tool_references.
495
- It walks all ToolReference fields and adds resolved ones to the cache.
429
+ """Build tool cache from resolved ToolReferences, populating companion fields.
496
430
 
497
431
  Returns:
498
- The populated ToolCache.
432
+ ToolCache with field names as keys and ModuleInfo as values.
499
433
  """
500
- self._build_tool_cache_recursive(self)
501
- logger.debug(
502
- "Tool cache built",
503
- extra={"slugs": self._tool_cache.list_slugs()},
504
- )
505
- return self._tool_cache
434
+ logger.info("Building tool cache")
435
+ cache = ToolCache()
436
+ self._build_tool_cache_recursive(self, cache)
437
+ logger.info("Tool cache built: %d entries", len(cache.entries))
438
+ return cache
506
439
 
507
- def _build_tool_cache_recursive(self, model_instance: BaseModel) -> None:
508
- """Recursively build tool cache from model fields.
440
+ def _build_tool_cache_recursive(self, model_instance: BaseModel, cache: ToolCache) -> None: # noqa: C901, PLR0912
441
+ """Recursively build tool cache and populate companion fields.
509
442
 
510
443
  Args:
511
- model_instance: The model instance to process.
444
+ model_instance: Model instance to process.
445
+ cache: ToolCache to populate.
512
446
  """
513
447
  for field_name, field_value in model_instance.__dict__.items():
514
448
  if field_value is None:
515
449
  continue
516
450
 
517
- if isinstance(field_value, ToolReference):
518
- self._add_tool_reference_to_cache(field_name, field_value)
451
+ if isinstance(field_value, ToolReference) and field_value.module_info:
452
+ cache_field_name = f"{field_name}_cache"
453
+ if cache_field_name in type(model_instance).model_fields:
454
+ setattr(model_instance, cache_field_name, field_value.module_info)
455
+ cache.add(field_value.module_info.module_id, field_value.module_info)
456
+ logger.debug("Added tool to cache: %s", field_value.module_info.module_id)
519
457
  elif isinstance(field_value, BaseModel):
520
- self._build_tool_cache_recursive(field_value)
458
+ self._build_tool_cache_recursive(field_value, cache)
521
459
  elif isinstance(field_value, list):
522
- self._build_tool_cache_from_list(field_value)
460
+ cache_field_name = f"{field_name}_cache"
461
+ cached_infos = getattr(model_instance, cache_field_name, None) or []
462
+ resolved_infos: list[ModuleInfo] = []
463
+
464
+ for idx, item in enumerate(field_value):
465
+ if isinstance(item, ToolReference):
466
+ # Use resolved info or fallback to cached
467
+ module_info = item.module_info or (cached_infos[idx] if idx < len(cached_infos) else None)
468
+ if module_info:
469
+ resolved_infos.append(module_info)
470
+ cache.add(module_info.module_id, module_info)
471
+ logger.debug("Added tool to cache: %s", module_info.module_id)
472
+ elif isinstance(item, BaseModel):
473
+ self._build_tool_cache_recursive(item, cache)
474
+
475
+ # Update companion field with resolved infos
476
+ if resolved_infos and cache_field_name in type(model_instance).model_fields:
477
+ setattr(model_instance, cache_field_name, resolved_infos)
523
478
  elif isinstance(field_value, dict):
524
- self._build_tool_cache_from_dict(field_value)
525
-
526
- def _add_tool_reference_to_cache(self, field_name: str, tool_ref: ToolReference) -> None:
527
- """Add a resolved ToolReference to the cache.
528
-
529
- Args:
530
- field_name: Name of the field (used as fallback slug).
531
- tool_ref: The ToolReference instance.
532
- """
533
- if tool_ref.module_info:
534
- # Use slug from config, or field name as fallback
535
- slug = tool_ref.slug or field_name
536
- self._tool_cache.add(slug, tool_ref.module_info)
537
-
538
- def _build_tool_cache_from_list(self, items: list) -> None:
539
- """Build tool cache from list items.
540
-
541
- Args:
542
- items: List of items to process.
543
- """
544
- for idx, item in enumerate(items):
545
- if isinstance(item, ToolReference):
546
- self._add_tool_reference_to_cache(f"list_{idx}", item)
547
- elif isinstance(item, BaseModel):
548
- self._build_tool_cache_recursive(item)
549
-
550
- def _build_tool_cache_from_dict(self, mapping: dict) -> None:
551
- """Build tool cache from dict values.
552
-
553
- Args:
554
- mapping: Dict to process.
555
- """
556
- for key, item in mapping.items():
557
- if isinstance(item, ToolReference):
558
- self._add_tool_reference_to_cache(str(key), item)
559
- elif isinstance(item, BaseModel):
560
- self._build_tool_cache_recursive(item)
561
-
562
- def validate_tool_cache(self, registry: RegistryStrategy) -> list[str]:
563
- """Validate all cached tools are still available.
564
-
565
- Should be called during initialize to ensure tools are still valid.
566
-
567
- Args:
568
- registry: Registry to validate against.
569
-
570
- Returns:
571
- List of slugs that are no longer valid.
572
- """
573
- return self._tool_cache.validate(registry)
479
+ for item in field_value.values():
480
+ if isinstance(item, ToolReference) and item.module_info:
481
+ cache.add(item.module_info.module_id, item.module_info)
482
+ logger.debug("Added tool to cache: %s", item.module_info.module_id)
483
+ elif isinstance(item, BaseModel):
484
+ self._build_tool_cache_recursive(item, cache)