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.
- lionherd_core/__init__.py +84 -0
- lionherd_core/base/__init__.py +30 -0
- lionherd_core/base/_utils.py +295 -0
- lionherd_core/base/broadcaster.py +128 -0
- lionherd_core/base/element.py +300 -0
- lionherd_core/base/event.py +322 -0
- lionherd_core/base/eventbus.py +112 -0
- lionherd_core/base/flow.py +236 -0
- lionherd_core/base/graph.py +616 -0
- lionherd_core/base/node.py +212 -0
- lionherd_core/base/pile.py +811 -0
- lionherd_core/base/progression.py +261 -0
- lionherd_core/errors.py +104 -0
- lionherd_core/libs/__init__.py +2 -0
- lionherd_core/libs/concurrency/__init__.py +60 -0
- lionherd_core/libs/concurrency/_cancel.py +85 -0
- lionherd_core/libs/concurrency/_errors.py +80 -0
- lionherd_core/libs/concurrency/_patterns.py +238 -0
- lionherd_core/libs/concurrency/_primitives.py +253 -0
- lionherd_core/libs/concurrency/_priority_queue.py +135 -0
- lionherd_core/libs/concurrency/_resource_tracker.py +66 -0
- lionherd_core/libs/concurrency/_task.py +58 -0
- lionherd_core/libs/concurrency/_utils.py +61 -0
- lionherd_core/libs/schema_handlers/__init__.py +35 -0
- lionherd_core/libs/schema_handlers/_function_call_parser.py +122 -0
- lionherd_core/libs/schema_handlers/_minimal_yaml.py +88 -0
- lionherd_core/libs/schema_handlers/_schema_to_model.py +251 -0
- lionherd_core/libs/schema_handlers/_typescript.py +153 -0
- lionherd_core/libs/string_handlers/__init__.py +15 -0
- lionherd_core/libs/string_handlers/_extract_json.py +65 -0
- lionherd_core/libs/string_handlers/_fuzzy_json.py +103 -0
- lionherd_core/libs/string_handlers/_string_similarity.py +347 -0
- lionherd_core/libs/string_handlers/_to_num.py +63 -0
- lionherd_core/ln/__init__.py +45 -0
- lionherd_core/ln/_async_call.py +314 -0
- lionherd_core/ln/_fuzzy_match.py +166 -0
- lionherd_core/ln/_fuzzy_validate.py +151 -0
- lionherd_core/ln/_hash.py +141 -0
- lionherd_core/ln/_json_dump.py +347 -0
- lionherd_core/ln/_list_call.py +110 -0
- lionherd_core/ln/_to_dict.py +373 -0
- lionherd_core/ln/_to_list.py +190 -0
- lionherd_core/ln/_utils.py +156 -0
- lionherd_core/lndl/__init__.py +62 -0
- lionherd_core/lndl/errors.py +30 -0
- lionherd_core/lndl/fuzzy.py +321 -0
- lionherd_core/lndl/parser.py +427 -0
- lionherd_core/lndl/prompt.py +137 -0
- lionherd_core/lndl/resolver.py +323 -0
- lionherd_core/lndl/types.py +287 -0
- lionherd_core/protocols.py +181 -0
- lionherd_core/py.typed +0 -0
- lionherd_core/types/__init__.py +46 -0
- lionherd_core/types/_sentinel.py +131 -0
- lionherd_core/types/base.py +341 -0
- lionherd_core/types/operable.py +133 -0
- lionherd_core/types/spec.py +313 -0
- lionherd_core/types/spec_adapters/__init__.py +10 -0
- lionherd_core/types/spec_adapters/_protocol.py +125 -0
- lionherd_core/types/spec_adapters/pydantic_field.py +177 -0
- lionherd_core-1.0.0a3.dist-info/METADATA +502 -0
- lionherd_core-1.0.0a3.dist-info/RECORD +64 -0
- lionherd_core-1.0.0a3.dist-info/WHEEL +4 -0
- 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)
|