lionherd-core 1.0.0a3__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 (64) hide show
  1. lionherd_core/__init__.py +84 -0
  2. lionherd_core/base/__init__.py +30 -0
  3. lionherd_core/base/_utils.py +295 -0
  4. lionherd_core/base/broadcaster.py +128 -0
  5. lionherd_core/base/element.py +300 -0
  6. lionherd_core/base/event.py +322 -0
  7. lionherd_core/base/eventbus.py +112 -0
  8. lionherd_core/base/flow.py +236 -0
  9. lionherd_core/base/graph.py +616 -0
  10. lionherd_core/base/node.py +212 -0
  11. lionherd_core/base/pile.py +811 -0
  12. lionherd_core/base/progression.py +261 -0
  13. lionherd_core/errors.py +104 -0
  14. lionherd_core/libs/__init__.py +2 -0
  15. lionherd_core/libs/concurrency/__init__.py +60 -0
  16. lionherd_core/libs/concurrency/_cancel.py +85 -0
  17. lionherd_core/libs/concurrency/_errors.py +80 -0
  18. lionherd_core/libs/concurrency/_patterns.py +238 -0
  19. lionherd_core/libs/concurrency/_primitives.py +253 -0
  20. lionherd_core/libs/concurrency/_priority_queue.py +135 -0
  21. lionherd_core/libs/concurrency/_resource_tracker.py +66 -0
  22. lionherd_core/libs/concurrency/_task.py +58 -0
  23. lionherd_core/libs/concurrency/_utils.py +61 -0
  24. lionherd_core/libs/schema_handlers/__init__.py +35 -0
  25. lionherd_core/libs/schema_handlers/_function_call_parser.py +122 -0
  26. lionherd_core/libs/schema_handlers/_minimal_yaml.py +88 -0
  27. lionherd_core/libs/schema_handlers/_schema_to_model.py +251 -0
  28. lionherd_core/libs/schema_handlers/_typescript.py +153 -0
  29. lionherd_core/libs/string_handlers/__init__.py +15 -0
  30. lionherd_core/libs/string_handlers/_extract_json.py +65 -0
  31. lionherd_core/libs/string_handlers/_fuzzy_json.py +103 -0
  32. lionherd_core/libs/string_handlers/_string_similarity.py +347 -0
  33. lionherd_core/libs/string_handlers/_to_num.py +63 -0
  34. lionherd_core/ln/__init__.py +45 -0
  35. lionherd_core/ln/_async_call.py +314 -0
  36. lionherd_core/ln/_fuzzy_match.py +166 -0
  37. lionherd_core/ln/_fuzzy_validate.py +151 -0
  38. lionherd_core/ln/_hash.py +141 -0
  39. lionherd_core/ln/_json_dump.py +347 -0
  40. lionherd_core/ln/_list_call.py +110 -0
  41. lionherd_core/ln/_to_dict.py +373 -0
  42. lionherd_core/ln/_to_list.py +190 -0
  43. lionherd_core/ln/_utils.py +156 -0
  44. lionherd_core/lndl/__init__.py +62 -0
  45. lionherd_core/lndl/errors.py +30 -0
  46. lionherd_core/lndl/fuzzy.py +321 -0
  47. lionherd_core/lndl/parser.py +427 -0
  48. lionherd_core/lndl/prompt.py +137 -0
  49. lionherd_core/lndl/resolver.py +323 -0
  50. lionherd_core/lndl/types.py +287 -0
  51. lionherd_core/protocols.py +181 -0
  52. lionherd_core/py.typed +0 -0
  53. lionherd_core/types/__init__.py +46 -0
  54. lionherd_core/types/_sentinel.py +131 -0
  55. lionherd_core/types/base.py +341 -0
  56. lionherd_core/types/operable.py +133 -0
  57. lionherd_core/types/spec.py +313 -0
  58. lionherd_core/types/spec_adapters/__init__.py +10 -0
  59. lionherd_core/types/spec_adapters/_protocol.py +125 -0
  60. lionherd_core/types/spec_adapters/pydantic_field.py +177 -0
  61. lionherd_core-1.0.0a3.dist-info/METADATA +502 -0
  62. lionherd_core-1.0.0a3.dist-info/RECORD +64 -0
  63. lionherd_core-1.0.0a3.dist-info/WHEEL +4 -0
  64. lionherd_core-1.0.0a3.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,323 @@
1
+ # Copyright (c) 2025, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ from pydantic import (
5
+ BaseModel,
6
+ ValidationError as PydanticValidationError,
7
+ )
8
+
9
+ from lionherd_core.libs.schema_handlers._function_call_parser import parse_function_call
10
+ from lionherd_core.types import Operable
11
+
12
+ from .errors import MissingFieldError, TypeMismatchError
13
+ from .parser import parse_value
14
+ from .types import ActionCall, LactMetadata, LNDLOutput, LvarMetadata
15
+
16
+
17
+ def resolve_references_prefixed(
18
+ out_fields: dict[str, list[str] | str],
19
+ lvars: dict[str, LvarMetadata],
20
+ lacts: dict[str, LactMetadata],
21
+ operable: Operable,
22
+ ) -> LNDLOutput:
23
+ """Resolve namespace-prefixed OUT{} fields and validate against operable specs.
24
+
25
+ Args:
26
+ out_fields: Parsed OUT{} block (field -> list of var names OR literal value)
27
+ lvars: Extracted namespace-prefixed lvar declarations
28
+ lacts: Extracted action declarations (name -> LactMetadata)
29
+ operable: Operable containing allowed specs
30
+
31
+ Returns:
32
+ LNDLOutput with validated Pydantic model instances or scalar values
33
+
34
+ Raises:
35
+ MissingFieldError: Required spec field not in OUT{}
36
+ TypeMismatchError: Variable model doesn't match spec type
37
+ ValueError: Variable not found, field mismatch, or name collision
38
+ """
39
+ # Check for name collisions between lvars and lacts
40
+ lvar_names = set(lvars.keys())
41
+ lact_names = set(lacts.keys())
42
+ collisions = lvar_names & lact_names
43
+
44
+ if collisions:
45
+ raise ValueError(
46
+ f"Name collision detected: {collisions} used in both <lvar> and <lact> declarations"
47
+ )
48
+ # Check all fields in OUT{} are allowed by operable
49
+ operable.check_allowed(*out_fields.keys())
50
+
51
+ # Check all required specs present
52
+ for spec in operable.get_specs():
53
+ is_required = spec.get("required", True)
54
+ if is_required and spec.name not in out_fields:
55
+ raise MissingFieldError(f"Required field '{spec.name}' missing from OUT{{}}")
56
+
57
+ # Resolve and validate each field (collect all errors)
58
+ validated_fields = {}
59
+ parsed_actions: dict[str, ActionCall] = {} # Actions referenced in OUT{}
60
+ errors: list[Exception] = []
61
+
62
+ for field_name, value in out_fields.items():
63
+ try:
64
+ # Get spec for this field
65
+ spec = operable.get(field_name)
66
+ if spec is None:
67
+ raise ValueError(
68
+ f"OUT{{}} field '{field_name}' has no corresponding Spec in Operable"
69
+ )
70
+
71
+ # Get type from spec
72
+ target_type = spec.base_type
73
+
74
+ # Check if this is a scalar type (float, str, int, bool)
75
+ is_scalar = target_type in (float, str, int, bool)
76
+
77
+ if is_scalar:
78
+ # Handle scalar assignment
79
+ if isinstance(value, list):
80
+ # Array syntax for scalar - should be single variable or action
81
+ if len(value) != 1:
82
+ raise ValueError(
83
+ f"Scalar field '{field_name}' cannot use multiple variables, got {value}"
84
+ )
85
+ var_name = value[0]
86
+
87
+ # Check if this is an action reference
88
+ if var_name in lacts:
89
+ # Get action metadata
90
+ lact_meta = lacts[var_name]
91
+
92
+ # Parse function call with context
93
+ try:
94
+ parsed_call = parse_function_call(lact_meta.call)
95
+ except ValueError as e:
96
+ raise ValueError(
97
+ f"Invalid function call syntax in action '{var_name}' for scalar field '{field_name}':\n"
98
+ f" Action call: {lact_meta.call}\n"
99
+ f" Parse error: {e}"
100
+ ) from e
101
+
102
+ # Create ActionCall instance
103
+ action_call = ActionCall(
104
+ name=var_name,
105
+ function=parsed_call["tool"],
106
+ arguments=parsed_call["arguments"],
107
+ raw_call=lact_meta.call,
108
+ )
109
+ parsed_actions[var_name] = action_call
110
+
111
+ # For scalar actions, we mark this field for later execution
112
+ # The actual execution happens externally, we just store the action
113
+ # For now, we can't validate the result type, so we skip type conversion
114
+ # The field will be populated with the action result during execution
115
+ validated_fields[field_name] = action_call
116
+ continue
117
+
118
+ # Look up variable in lvars
119
+ if var_name not in lvars:
120
+ raise ValueError(
121
+ f"Variable or action '{var_name}' referenced in OUT{{}} but not declared"
122
+ )
123
+
124
+ lvar_meta = lvars[var_name]
125
+ parsed_value = parse_value(lvar_meta.value)
126
+ else:
127
+ # Literal value (string)
128
+ parsed_value = parse_value(value)
129
+
130
+ # Type conversion and validation
131
+ try:
132
+ validated_value = target_type(parsed_value)
133
+ except (ValueError, TypeError) as e:
134
+ raise ValueError(
135
+ f"Failed to convert value for field '{field_name}' to {target_type.__name__}: {e}"
136
+ ) from e
137
+
138
+ validated_fields[field_name] = validated_value
139
+
140
+ else:
141
+ # Handle Pydantic BaseModel construction
142
+ if not isinstance(value, list):
143
+ raise ValueError(
144
+ f"BaseModel field '{field_name}' requires array syntax, got literal: {value}"
145
+ )
146
+
147
+ var_list = value
148
+
149
+ # Validate it's a BaseModel
150
+ if not isinstance(target_type, type) or not issubclass(target_type, BaseModel):
151
+ raise TypeError(
152
+ f"Spec base_type for '{field_name}' must be a Pydantic BaseModel or scalar type, "
153
+ f"got {target_type}"
154
+ )
155
+
156
+ # Special case: single action reference that returns entire model
157
+ if len(var_list) == 1 and var_list[0] in lacts:
158
+ action_name = var_list[0]
159
+ lact_meta = lacts[action_name]
160
+
161
+ # Direct actions (no namespace) can return entire model
162
+ if lact_meta.model is None:
163
+ # Parse function call with context
164
+ try:
165
+ parsed_call = parse_function_call(lact_meta.call)
166
+ except ValueError as e:
167
+ raise ValueError(
168
+ f"Invalid function call syntax in direct action '{action_name}' for BaseModel field '{field_name}':\n"
169
+ f" Action call: {lact_meta.call}\n"
170
+ f" Parse error: {e}"
171
+ ) from e
172
+
173
+ # Create ActionCall instance
174
+ action_call = ActionCall(
175
+ name=action_name,
176
+ function=parsed_call["tool"],
177
+ arguments=parsed_call["arguments"],
178
+ raw_call=lact_meta.call,
179
+ )
180
+ parsed_actions[action_name] = action_call
181
+
182
+ # Store action as field value (will be executed to get model instance)
183
+ validated_fields[field_name] = action_call
184
+ continue
185
+ # If namespaced, fall through to mixing logic below
186
+
187
+ # Build kwargs from variable list - supports mixing lvars and namespaced actions
188
+ kwargs = {}
189
+ for var_name in var_list:
190
+ # Check if this is an action reference
191
+ if var_name in lacts:
192
+ lact_meta = lacts[var_name]
193
+
194
+ # Namespaced actions must specify which field they populate
195
+ if lact_meta.model is None or lact_meta.field is None:
196
+ raise ValueError(
197
+ f"Direct action '{var_name}' cannot be mixed with lvars in BaseModel field '{field_name}'. "
198
+ f"Use namespaced syntax: <lact {target_type.__name__}.fieldname {var_name}>...</lact>"
199
+ )
200
+
201
+ # Validate: model name matches
202
+ if lact_meta.model != target_type.__name__:
203
+ raise TypeMismatchError(
204
+ f"Action '{var_name}' is for model '{lact_meta.model}', "
205
+ f"but field '{field_name}' expects '{target_type.__name__}'"
206
+ )
207
+
208
+ # Parse action function call with context
209
+ try:
210
+ parsed_call = parse_function_call(lact_meta.call)
211
+ except ValueError as e:
212
+ raise ValueError(
213
+ f"Invalid function call syntax in namespaced action '{var_name}' for field '{lact_meta.model}.{lact_meta.field}':\n"
214
+ f" Action call: {lact_meta.call}\n"
215
+ f" Parse error: {e}"
216
+ ) from e
217
+
218
+ # Create ActionCall instance
219
+ action_call = ActionCall(
220
+ name=var_name,
221
+ function=parsed_call["tool"],
222
+ arguments=parsed_call["arguments"],
223
+ raw_call=lact_meta.call,
224
+ )
225
+ parsed_actions[var_name] = action_call
226
+
227
+ # Use the namespaced field to map action result
228
+ kwargs[lact_meta.field] = action_call
229
+ continue
230
+
231
+ # Look up variable in lvars
232
+ if var_name not in lvars:
233
+ raise ValueError(
234
+ f"Variable or action '{var_name}' referenced in OUT{{}} but not declared"
235
+ )
236
+
237
+ lvar_meta = lvars[var_name]
238
+
239
+ # Validate: model name matches
240
+ if lvar_meta.model != target_type.__name__:
241
+ raise TypeMismatchError(
242
+ f"Variable '{var_name}' is for model '{lvar_meta.model}', "
243
+ f"but field '{field_name}' expects '{target_type.__name__}'"
244
+ )
245
+
246
+ # Map field name to kwargs
247
+ kwargs[lvar_meta.field] = parse_value(lvar_meta.value)
248
+
249
+ # Construct Pydantic model instance
250
+ # WARNING: model_construct() bypasses Pydantic validation when ActionCall objects present.
251
+ # Caller MUST re-validate after executing actions using revalidate_with_action_results().
252
+ # See LNDLOutput docstring for complete action execution lifecycle.
253
+ has_actions = any(isinstance(v, ActionCall) for v in kwargs.values())
254
+ try:
255
+ if has_actions:
256
+ # PARTIAL VALIDATION: Field constraints, validators, and type checking bypassed
257
+ instance = target_type.model_construct(**kwargs)
258
+ else:
259
+ # FULL VALIDATION: Normal Pydantic validation for models without actions
260
+ instance = target_type(**kwargs)
261
+ except PydanticValidationError as e:
262
+ raise ValueError(
263
+ f"Failed to construct {target_type.__name__} for field '{field_name}': {e}"
264
+ ) from e
265
+
266
+ # Apply validators/rules if specified in spec metadata
267
+ validators = spec.get("validator")
268
+ if validators:
269
+ validators = validators if isinstance(validators, list) else [validators]
270
+ for validator in validators:
271
+ if hasattr(validator, "invoke"):
272
+ instance = validator.invoke(field_name, instance, target_type)
273
+ else:
274
+ instance = validator(instance)
275
+
276
+ validated_fields[field_name] = instance
277
+
278
+ except Exception as e:
279
+ # Collect errors for aggregation
280
+ errors.append(e)
281
+
282
+ # Raise all collected errors as ExceptionGroup
283
+ if errors:
284
+ raise ExceptionGroup("LNDL validation failed", errors)
285
+
286
+ return LNDLOutput(
287
+ fields=validated_fields,
288
+ lvars=lvars,
289
+ lacts=lacts,
290
+ actions=parsed_actions,
291
+ raw_out_block=str(out_fields),
292
+ )
293
+
294
+
295
+ def parse_lndl(response: str, operable: Operable) -> LNDLOutput:
296
+ """Parse LNDL response and validate against operable specs.
297
+
298
+ Args:
299
+ response: Full LLM response containing lvars, lacts, and OUT{}
300
+ operable: Operable containing allowed specs
301
+
302
+ Returns:
303
+ LNDLOutput with validated fields and parsed actions
304
+ """
305
+ from .parser import (
306
+ extract_lacts_prefixed,
307
+ extract_lvars_prefixed,
308
+ extract_out_block,
309
+ parse_out_block_array,
310
+ )
311
+
312
+ # 1. Extract namespace-prefixed lvars
313
+ lvars_prefixed = extract_lvars_prefixed(response)
314
+
315
+ # 2. Extract action declarations (with namespace support)
316
+ lacts_prefixed = extract_lacts_prefixed(response)
317
+
318
+ # 3. Extract and parse OUT{} block with array syntax
319
+ out_content = extract_out_block(response)
320
+ out_fields = parse_out_block_array(out_content)
321
+
322
+ # 4. Resolve references and validate
323
+ return resolve_references_prefixed(out_fields, lvars_prefixed, lacts_prefixed, operable)
@@ -0,0 +1,287 @@
1
+ # Copyright (c) 2025, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Core types for LNDL (Lion Directive Language)."""
5
+
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel
10
+
11
+
12
+ @dataclass(slots=True, frozen=True)
13
+ class LvarMetadata:
14
+ """Metadata for namespace-prefixed lvar.
15
+
16
+ Example: <lvar Report.title title>Good Title</lvar>
17
+ → LvarMetadata(model="Report", field="title", local_name="title", value="Good Title")
18
+ """
19
+
20
+ model: str # Model name (e.g., "Report")
21
+ field: str # Field name (e.g., "title")
22
+ local_name: str # Local variable name (e.g., "title")
23
+ value: str # Raw string value
24
+
25
+
26
+ @dataclass(slots=True, frozen=True)
27
+ class LactMetadata:
28
+ """Metadata for action declaration (namespaced or direct).
29
+
30
+ Examples:
31
+ Namespaced: <lact Report.summary s>generate_summary(...)</lact>
32
+ → LactMetadata(model="Report", field="summary", local_name="s", call="generate_summary(...)")
33
+
34
+ Direct: <lact search>search(...)</lact>
35
+ → LactMetadata(model=None, field=None, local_name="search", call="search(...)")
36
+ """
37
+
38
+ model: str | None # Model name (e.g., "Report") or None for direct actions
39
+ field: str | None # Field name (e.g., "summary") or None for direct actions
40
+ local_name: str # Local reference name (e.g., "s", "search")
41
+ call: str # Raw function call string
42
+
43
+
44
+ @dataclass(slots=True, frozen=True)
45
+ class ParsedConstructor:
46
+ """Parsed type constructor from OUT{} block."""
47
+
48
+ class_name: str
49
+ kwargs: dict[str, Any]
50
+ raw: str
51
+
52
+ @property
53
+ def has_dict_unpack(self) -> bool:
54
+ """Check if constructor uses **dict unpacking."""
55
+ return any(k.startswith("**") for k in self.kwargs)
56
+
57
+
58
+ @dataclass(slots=True, frozen=True)
59
+ class ActionCall:
60
+ """Parsed action call from <lact> tag.
61
+
62
+ Represents a tool/function invocation declared in LNDL response.
63
+ Actions are only executed if referenced in OUT{} block.
64
+
65
+ Attributes:
66
+ name: Local reference name (e.g., "search", "validate")
67
+ function: Function/tool name to invoke
68
+ arguments: Parsed arguments dict
69
+ raw_call: Original Python function call string
70
+ """
71
+
72
+ name: str
73
+ function: str
74
+ arguments: dict[str, Any]
75
+ raw_call: str
76
+
77
+
78
+ @dataclass(slots=True, frozen=True)
79
+ class LNDLOutput:
80
+ """Validated LNDL output with action execution lifecycle.
81
+
82
+ Action Execution Lifecycle:
83
+ ---------------------------
84
+ 1. **Parse**: LNDL response parsed, ActionCall objects created for referenced actions
85
+ 2. **Partial Validation**: BaseModels with ActionCall fields use model_construct() to bypass validation
86
+ 3. **Execute**: Caller executes actions using .actions dict, collects results
87
+ 4. **Re-validate**: Caller replaces ActionCall objects with results and re-validates models
88
+
89
+ Fields containing ActionCall objects have **partial validation** only:
90
+ - Field constraints (validators, bounds, regex) are NOT enforced
91
+ - Type checking is bypassed
92
+ - Re-validation MUST occur after action execution
93
+
94
+ Example:
95
+ >>> output = parse_lndl(response, operable)
96
+ >>> # Execute actions
97
+ >>> action_results = {}
98
+ >>> for name, action in output.actions.items():
99
+ >>> result = execute_tool(action.function, action.arguments)
100
+ >>> action_results[name] = result
101
+ >>>
102
+ >>> # Re-validate models with action results
103
+ >>> for field_name, value in output.fields.items():
104
+ >>> if isinstance(value, BaseModel) and has_action_calls(value):
105
+ >>> value = revalidate_with_action_results(value, action_results)
106
+ >>> output.fields[field_name] = value
107
+ """
108
+
109
+ fields: dict[str, BaseModel | ActionCall] # BaseModel instances or ActionCall (pre-execution)
110
+ lvars: dict[str, str] | dict[str, LvarMetadata] # Preserved for debugging
111
+ lacts: dict[str, LactMetadata] # All declared actions (for debugging/reference)
112
+ actions: dict[str, ActionCall] # Actions referenced in OUT{} (pending execution)
113
+ raw_out_block: str # Preserved for debugging
114
+
115
+ def __getitem__(self, key: str) -> BaseModel | ActionCall:
116
+ return self.fields[key]
117
+
118
+ def __getattr__(self, key: str) -> BaseModel | ActionCall:
119
+ if key in ("fields", "lvars", "lacts", "actions", "raw_out_block"):
120
+ return object.__getattribute__(self, key)
121
+ return self.fields[key]
122
+
123
+
124
+ def has_action_calls(model: BaseModel) -> bool:
125
+ """Check if a BaseModel instance contains any ActionCall objects in its fields.
126
+
127
+ Recursively checks nested BaseModel fields and collection fields (list, dict, tuple, set)
128
+ to detect ActionCall objects at any depth.
129
+
130
+ Args:
131
+ model: Pydantic BaseModel instance to check
132
+
133
+ Returns:
134
+ True if any field value is an ActionCall (at any nesting level), False otherwise
135
+
136
+ Example:
137
+ >>> report = Report.model_construct(title="Report", summary=ActionCall(...))
138
+ >>> has_action_calls(report)
139
+ True
140
+ >>> # Also detects in nested models
141
+ >>> nested = NestedReport(main=report)
142
+ >>> has_action_calls(nested)
143
+ True
144
+ """
145
+
146
+ def _check_value(value: Any) -> bool:
147
+ """Recursively check a value for ActionCall objects."""
148
+ # Direct ActionCall
149
+ if isinstance(value, ActionCall):
150
+ return True
151
+
152
+ # Nested BaseModel - recurse
153
+ if isinstance(value, BaseModel):
154
+ return has_action_calls(value)
155
+
156
+ # Collections - check items
157
+ if isinstance(value, (list, tuple, set)):
158
+ return any(_check_value(item) for item in value)
159
+
160
+ if isinstance(value, dict):
161
+ return any(_check_value(v) for v in value.values())
162
+
163
+ return False
164
+
165
+ return any(_check_value(value) for value in model.__dict__.values())
166
+
167
+
168
+ def ensure_no_action_calls(model: BaseModel) -> BaseModel:
169
+ """Validate that model contains no unexecuted ActionCall objects.
170
+
171
+ Use this guard before persisting models to prevent database corruption or logic errors.
172
+ Models with ActionCall placeholders must be re-validated with action results first.
173
+
174
+ Recursively checks nested models and collections for ActionCall objects.
175
+
176
+ Args:
177
+ model: BaseModel instance to validate
178
+
179
+ Returns:
180
+ The same model instance if validation passes
181
+
182
+ Raises:
183
+ ValueError: If model contains any ActionCall objects, with field path details
184
+
185
+ Example:
186
+ >>> # CRITICAL: Always guard before persistence
187
+ >>> output = parse_lndl_fuzzy(llm_response, operable)
188
+ >>> report = output.report
189
+ >>>
190
+ >>> # Execute actions first
191
+ >>> action_results = execute_actions(output.actions)
192
+ >>> validated_report = revalidate_with_action_results(report, action_results)
193
+ >>>
194
+ >>> # Safe to persist - guard will pass
195
+ >>> db.save(ensure_no_action_calls(validated_report))
196
+ >>>
197
+ >>> # BAD: Forgot revalidation - guard prevents corruption
198
+ >>> db.save(ensure_no_action_calls(report)) # Raises ValueError!
199
+ """
200
+
201
+ def _find_action_call_fields(obj: Any, path: str = "") -> list[str]:
202
+ """Find all field paths containing ActionCall objects."""
203
+ paths = []
204
+
205
+ if isinstance(obj, ActionCall):
206
+ return [path] if path else ["<root>"]
207
+
208
+ if isinstance(obj, BaseModel):
209
+ for field_name, value in obj.__dict__.items():
210
+ field_path = f"{path}.{field_name}" if path else field_name
211
+ paths.extend(_find_action_call_fields(value, field_path))
212
+
213
+ elif isinstance(obj, (list, tuple, set)):
214
+ for idx, item in enumerate(obj):
215
+ item_path = f"{path}[{idx}]"
216
+ paths.extend(_find_action_call_fields(item, item_path))
217
+
218
+ elif isinstance(obj, dict):
219
+ for key, value in obj.items():
220
+ dict_path = f"{path}[{key!r}]"
221
+ paths.extend(_find_action_call_fields(value, dict_path))
222
+
223
+ return paths
224
+
225
+ if has_action_calls(model):
226
+ model_name = type(model).__name__
227
+ action_call_fields = _find_action_call_fields(model)
228
+ fields_str = ", ".join(action_call_fields[:3]) # Show first 3 fields
229
+ if len(action_call_fields) > 3:
230
+ fields_str += f" (and {len(action_call_fields) - 3} more)"
231
+
232
+ raise ValueError(
233
+ f"{model_name} contains unexecuted actions in fields: {fields_str}. "
234
+ f"Models with ActionCall placeholders must be re-validated after action execution. "
235
+ f"Call revalidate_with_action_results() before using this model."
236
+ )
237
+ return model
238
+
239
+
240
+ def revalidate_with_action_results(
241
+ model: BaseModel,
242
+ action_results: dict[str, Any],
243
+ ) -> BaseModel:
244
+ """Replace ActionCall fields with execution results and re-validate the model.
245
+
246
+ This function must be called after executing actions to restore full Pydantic validation.
247
+ Models constructed with model_construct() have bypassed validation and may contain
248
+ ActionCall objects where actual values are expected.
249
+
250
+ Args:
251
+ model: BaseModel instance with ActionCall placeholders
252
+ action_results: Dict mapping action names to their execution results
253
+
254
+ Returns:
255
+ Fully validated BaseModel instance with action results substituted
256
+
257
+ Raises:
258
+ ValidationError: If action results don't satisfy field constraints
259
+
260
+ Example:
261
+ >>> # Model has ActionCall in summary field
262
+ >>> report = Report.model_construct(title="Report", summary=action_call)
263
+ >>>
264
+ >>> # Execute action and get result
265
+ >>> action_results = {"summarize": "Generated summary text"}
266
+ >>>
267
+ >>> # Re-validate with results
268
+ >>> validated_report = revalidate_with_action_results(report, action_results)
269
+ >>> isinstance(validated_report.summary, str) # True, no longer ActionCall
270
+ True
271
+ """
272
+ # Get current field values
273
+ kwargs = model.model_dump()
274
+
275
+ # Replace ActionCall objects with their execution results
276
+ for field_name, value in model.__dict__.items():
277
+ if isinstance(value, ActionCall):
278
+ # Find result by action name
279
+ if value.name not in action_results:
280
+ raise ValueError(
281
+ f"Action '{value.name}' in field '{field_name}' has no execution result. "
282
+ f"Available results: {list(action_results.keys())}"
283
+ )
284
+ kwargs[field_name] = action_results[value.name]
285
+
286
+ # Re-construct with full validation
287
+ return type(model)(**kwargs)