flatagents 0.4.1__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.
- flatagents/__init__.py +136 -0
- flatagents/actions.py +239 -0
- flatagents/assets/__init__.py +0 -0
- flatagents/assets/flatagent.d.ts +189 -0
- flatagents/assets/flatagent.schema.json +210 -0
- flatagents/assets/flatagent.slim.d.ts +52 -0
- flatagents/assets/flatmachine.d.ts +363 -0
- flatagents/assets/flatmachine.schema.json +515 -0
- flatagents/assets/flatmachine.slim.d.ts +94 -0
- flatagents/backends.py +222 -0
- flatagents/baseagent.py +814 -0
- flatagents/execution.py +462 -0
- flatagents/expressions/__init__.py +60 -0
- flatagents/expressions/cel.py +101 -0
- flatagents/expressions/simple.py +166 -0
- flatagents/flatagent.py +735 -0
- flatagents/flatmachine.py +1176 -0
- flatagents/gcp/__init__.py +25 -0
- flatagents/gcp/firestore.py +227 -0
- flatagents/hooks.py +380 -0
- flatagents/locking.py +69 -0
- flatagents/monitoring.py +373 -0
- flatagents/persistence.py +200 -0
- flatagents/utils.py +46 -0
- flatagents/validation.py +141 -0
- flatagents-0.4.1.dist-info/METADATA +310 -0
- flatagents-0.4.1.dist-info/RECORD +28 -0
- flatagents-0.4.1.dist-info/WHEEL +4 -0
flatagents/execution.py
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Execution Types for FlatMachine.
|
|
3
|
+
|
|
4
|
+
Provides different execution strategies for agent calls:
|
|
5
|
+
- Default: Single call
|
|
6
|
+
- Parallel: Multiple calls, first success or aggregate
|
|
7
|
+
- Retry: Multiple attempts with backoff
|
|
8
|
+
- MDAP Voting: Multi-sampling with majority vote
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import time
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
from collections import Counter
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
from .monitoring import get_logger
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from .flatagent import FlatAgent
|
|
25
|
+
|
|
26
|
+
logger = get_logger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Registry of execution types
|
|
30
|
+
_EXECUTION_TYPES: Dict[str, type] = {}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def register_execution_type(name: str):
|
|
34
|
+
"""Decorator to register an execution type."""
|
|
35
|
+
def decorator(cls):
|
|
36
|
+
_EXECUTION_TYPES[name] = cls
|
|
37
|
+
return cls
|
|
38
|
+
return decorator
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_execution_type(config: Optional[Dict[str, Any]] = None) -> "ExecutionType":
|
|
42
|
+
"""Get an execution type instance from config."""
|
|
43
|
+
if config is None:
|
|
44
|
+
return DefaultExecution()
|
|
45
|
+
|
|
46
|
+
type_name = config.get("type", "default")
|
|
47
|
+
if type_name not in _EXECUTION_TYPES:
|
|
48
|
+
raise ValueError(f"Unknown execution type: {type_name}")
|
|
49
|
+
|
|
50
|
+
cls = _EXECUTION_TYPES[type_name]
|
|
51
|
+
return cls.from_config(config)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ExecutionType(ABC):
|
|
55
|
+
"""Base class for execution types."""
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def from_config(cls, config: Dict[str, Any]) -> "ExecutionType":
|
|
60
|
+
"""Create instance from YAML config."""
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
async def execute(
|
|
65
|
+
self,
|
|
66
|
+
agent: "FlatAgent",
|
|
67
|
+
input_data: Dict[str, Any]
|
|
68
|
+
) -> Optional[Dict[str, Any]]:
|
|
69
|
+
"""
|
|
70
|
+
Execute the agent with this execution type.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
agent: The FlatAgent to call
|
|
74
|
+
input_data: Input data for the agent
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Agent output dict, or None on failure
|
|
78
|
+
"""
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@register_execution_type("default")
|
|
83
|
+
class DefaultExecution(ExecutionType):
|
|
84
|
+
"""Standard single agent call."""
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def from_config(cls, config: Dict[str, Any]) -> "DefaultExecution":
|
|
88
|
+
return cls()
|
|
89
|
+
|
|
90
|
+
async def execute(
|
|
91
|
+
self,
|
|
92
|
+
agent: "FlatAgent",
|
|
93
|
+
input_data: Dict[str, Any]
|
|
94
|
+
) -> Optional[Dict[str, Any]]:
|
|
95
|
+
"""Single agent call."""
|
|
96
|
+
result = await agent.call(**input_data)
|
|
97
|
+
|
|
98
|
+
if result.output:
|
|
99
|
+
return result.output
|
|
100
|
+
elif result.content:
|
|
101
|
+
return {"content": result.content}
|
|
102
|
+
else:
|
|
103
|
+
return {}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# Parallel Execution Type
|
|
107
|
+
|
|
108
|
+
@register_execution_type("parallel")
|
|
109
|
+
class ParallelExecution(ExecutionType):
|
|
110
|
+
"""
|
|
111
|
+
Run N samples in parallel, return all results.
|
|
112
|
+
|
|
113
|
+
Useful for getting multiple diverse responses to compare or aggregate.
|
|
114
|
+
|
|
115
|
+
Example YAML:
|
|
116
|
+
execution:
|
|
117
|
+
type: parallel
|
|
118
|
+
n_samples: 5
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(self, n_samples: int = 3):
|
|
122
|
+
self.n_samples = n_samples
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
def from_config(cls, config: Dict[str, Any]) -> "ParallelExecution":
|
|
126
|
+
return cls(
|
|
127
|
+
n_samples=config.get("n_samples", 3)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
async def execute(
|
|
131
|
+
self,
|
|
132
|
+
agent: "FlatAgent",
|
|
133
|
+
input_data: Dict[str, Any]
|
|
134
|
+
) -> Optional[Dict[str, Any]]:
|
|
135
|
+
"""Run N agent calls in parallel, return all results."""
|
|
136
|
+
async def single_call():
|
|
137
|
+
result = await agent.call(**input_data)
|
|
138
|
+
if result.output:
|
|
139
|
+
return result.output
|
|
140
|
+
elif result.content:
|
|
141
|
+
return {"content": result.content}
|
|
142
|
+
else:
|
|
143
|
+
return {}
|
|
144
|
+
|
|
145
|
+
# Run all samples in parallel
|
|
146
|
+
tasks = [single_call() for _ in range(self.n_samples)]
|
|
147
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
148
|
+
|
|
149
|
+
# Filter out exceptions
|
|
150
|
+
valid_results = [r for r in results if not isinstance(r, Exception)]
|
|
151
|
+
|
|
152
|
+
if not valid_results:
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
"results": valid_results,
|
|
157
|
+
"count": len(valid_results)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# Retry Execution Type
|
|
162
|
+
|
|
163
|
+
@register_execution_type("retry")
|
|
164
|
+
class RetryExecution(ExecutionType):
|
|
165
|
+
"""
|
|
166
|
+
Retry on failure with configurable backoff delays and jitter.
|
|
167
|
+
|
|
168
|
+
Default backoffs [2, 8, 16, 35] total 61 seconds, intended to wait
|
|
169
|
+
for a fresh RPM (requests per minute) bucket.
|
|
170
|
+
|
|
171
|
+
Example YAML:
|
|
172
|
+
execution:
|
|
173
|
+
type: retry
|
|
174
|
+
backoffs: [2, 8, 16, 35] # Backoff delays in seconds
|
|
175
|
+
jitter: 0.1 # Random jitter factor (0.1 = ±10%)
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
# Default backoffs: 2 + 8 + 16 + 35 = 61 seconds (wait for fresh RPM bucket)
|
|
179
|
+
DEFAULT_BACKOFFS = [2, 8, 16, 35]
|
|
180
|
+
|
|
181
|
+
def __init__(
|
|
182
|
+
self,
|
|
183
|
+
backoffs: Optional[List[float]] = None,
|
|
184
|
+
jitter: float = 0.1
|
|
185
|
+
):
|
|
186
|
+
self.backoffs = backoffs if backoffs is not None else self.DEFAULT_BACKOFFS
|
|
187
|
+
self.jitter = jitter
|
|
188
|
+
|
|
189
|
+
@classmethod
|
|
190
|
+
def from_config(cls, config: Dict[str, Any]) -> "RetryExecution":
|
|
191
|
+
return cls(
|
|
192
|
+
backoffs=config.get("backoffs"),
|
|
193
|
+
jitter=config.get("jitter", 0.1)
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def _apply_jitter(self, delay: float) -> float:
|
|
197
|
+
"""Apply random jitter to a delay."""
|
|
198
|
+
import random
|
|
199
|
+
jitter_range = delay * self.jitter
|
|
200
|
+
return delay + random.uniform(-jitter_range, jitter_range)
|
|
201
|
+
|
|
202
|
+
async def execute(
|
|
203
|
+
self,
|
|
204
|
+
agent: "FlatAgent",
|
|
205
|
+
input_data: Dict[str, Any]
|
|
206
|
+
) -> Optional[Dict[str, Any]]:
|
|
207
|
+
"""Execute with retries on failure."""
|
|
208
|
+
last_error = None
|
|
209
|
+
max_attempts = len(self.backoffs) + 1 # Initial attempt + retries
|
|
210
|
+
|
|
211
|
+
for attempt in range(max_attempts):
|
|
212
|
+
try:
|
|
213
|
+
result = await agent.call(**input_data)
|
|
214
|
+
|
|
215
|
+
if result.output:
|
|
216
|
+
return result.output
|
|
217
|
+
elif result.content:
|
|
218
|
+
return {"content": result.content}
|
|
219
|
+
else:
|
|
220
|
+
return {}
|
|
221
|
+
|
|
222
|
+
except Exception as e:
|
|
223
|
+
last_error = e
|
|
224
|
+
logger.warning(
|
|
225
|
+
f"Attempt {attempt + 1}/{max_attempts} failed: {e}"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# If we have more retries, wait with jitter
|
|
229
|
+
if attempt < len(self.backoffs):
|
|
230
|
+
delay = self._apply_jitter(self.backoffs[attempt])
|
|
231
|
+
logger.info(f"Retrying in {delay:.1f}s...")
|
|
232
|
+
await asyncio.sleep(delay)
|
|
233
|
+
|
|
234
|
+
# All retries exhausted
|
|
235
|
+
logger.error(f"All {max_attempts} attempts failed. Last error: {last_error}")
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# MDAP Voting Execution Type
|
|
240
|
+
|
|
241
|
+
@dataclass
|
|
242
|
+
class MDAPMetrics:
|
|
243
|
+
"""Execution metrics collected during MDAP runs."""
|
|
244
|
+
total_samples: int = 0
|
|
245
|
+
total_red_flags: int = 0
|
|
246
|
+
red_flags_by_reason: Dict[str, int] = field(default_factory=dict)
|
|
247
|
+
samples_per_step: List[int] = field(default_factory=list)
|
|
248
|
+
|
|
249
|
+
def record_red_flag(self, reason: str):
|
|
250
|
+
self.total_red_flags += 1
|
|
251
|
+
self.red_flags_by_reason[reason] = self.red_flags_by_reason.get(reason, 0) + 1
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@register_execution_type("mdap_voting")
|
|
255
|
+
class MDAPVotingExecution(ExecutionType):
|
|
256
|
+
"""
|
|
257
|
+
Multi-sample with first-to-ahead-by-k voting.
|
|
258
|
+
|
|
259
|
+
Implements the voting algorithm from the MAKER paper.
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
def __init__(
|
|
263
|
+
self,
|
|
264
|
+
k_margin: int = 3,
|
|
265
|
+
max_candidates: int = 10,
|
|
266
|
+
max_response_tokens: int = 2048
|
|
267
|
+
):
|
|
268
|
+
self.k_margin = k_margin
|
|
269
|
+
self.max_candidates = max_candidates
|
|
270
|
+
self.max_response_tokens = max_response_tokens
|
|
271
|
+
self.metrics = MDAPMetrics()
|
|
272
|
+
|
|
273
|
+
# Loaded from agent metadata
|
|
274
|
+
self._patterns: Dict[str, Tuple[re.Pattern, str]] = {}
|
|
275
|
+
self._validation_schema: Optional[Dict] = None
|
|
276
|
+
|
|
277
|
+
@classmethod
|
|
278
|
+
def from_config(cls, config: Dict[str, Any]) -> "MDAPVotingExecution":
|
|
279
|
+
return cls(
|
|
280
|
+
k_margin=config.get("k_margin", 3),
|
|
281
|
+
max_candidates=config.get("max_candidates", 10),
|
|
282
|
+
max_response_tokens=config.get("max_response_tokens", 2048)
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def _configure_from_agent(self, agent: "FlatAgent"):
|
|
286
|
+
"""Load parsing and validation config from agent metadata."""
|
|
287
|
+
# Check if agent metadata overrides execution config
|
|
288
|
+
mdap_config = agent.metadata.get('mdap', {})
|
|
289
|
+
if mdap_config.get('k_margin'):
|
|
290
|
+
self.k_margin = mdap_config['k_margin']
|
|
291
|
+
if mdap_config.get('max_candidates'):
|
|
292
|
+
self.max_candidates = mdap_config['max_candidates']
|
|
293
|
+
if mdap_config.get('max_response_tokens'):
|
|
294
|
+
self.max_response_tokens = mdap_config['max_response_tokens']
|
|
295
|
+
|
|
296
|
+
# Load parsing patterns
|
|
297
|
+
parsing_config = agent.metadata.get('parsing', {})
|
|
298
|
+
self._patterns = {}
|
|
299
|
+
for field_name, field_config in parsing_config.items():
|
|
300
|
+
pattern = field_config.get('pattern')
|
|
301
|
+
if pattern:
|
|
302
|
+
self._patterns[field_name] = (
|
|
303
|
+
re.compile(pattern, re.DOTALL),
|
|
304
|
+
field_config.get('type', 'str')
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# Load validation schema
|
|
308
|
+
self._validation_schema = agent.metadata.get('validation', None)
|
|
309
|
+
|
|
310
|
+
def _parse_response(self, content: str) -> Optional[Dict[str, Any]]:
|
|
311
|
+
"""Parse LLM response using regex patterns."""
|
|
312
|
+
if not self._patterns:
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
result = {}
|
|
316
|
+
for field_name, (pattern, field_type) in self._patterns.items():
|
|
317
|
+
match = pattern.search(content)
|
|
318
|
+
if match:
|
|
319
|
+
value = match.group(1)
|
|
320
|
+
if field_type == 'json':
|
|
321
|
+
try:
|
|
322
|
+
result[field_name] = json.loads(value)
|
|
323
|
+
except json.JSONDecodeError:
|
|
324
|
+
return None
|
|
325
|
+
elif field_type == 'int':
|
|
326
|
+
try:
|
|
327
|
+
result[field_name] = int(value)
|
|
328
|
+
except ValueError:
|
|
329
|
+
return None
|
|
330
|
+
else:
|
|
331
|
+
result[field_name] = value
|
|
332
|
+
else:
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
return result
|
|
336
|
+
|
|
337
|
+
def _validate_parsed(self, parsed: Dict[str, Any]) -> bool:
|
|
338
|
+
"""Validate parsed result against JSON Schema."""
|
|
339
|
+
if not self._validation_schema:
|
|
340
|
+
return True
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
import jsonschema
|
|
344
|
+
jsonschema.validate(instance=parsed, schema=self._validation_schema)
|
|
345
|
+
return True
|
|
346
|
+
except Exception:
|
|
347
|
+
return False
|
|
348
|
+
|
|
349
|
+
def _check_red_flags(self, content: str, parsed: Optional[Dict[str, Any]]) -> Optional[str]:
|
|
350
|
+
"""Check response for red flags per MAKER paper."""
|
|
351
|
+
if parsed is None:
|
|
352
|
+
return "format_error"
|
|
353
|
+
|
|
354
|
+
if not self._validate_parsed(parsed):
|
|
355
|
+
return "validation_failed"
|
|
356
|
+
|
|
357
|
+
estimated_tokens = len(content) // 4
|
|
358
|
+
if estimated_tokens > self.max_response_tokens:
|
|
359
|
+
return "length_exceeded"
|
|
360
|
+
|
|
361
|
+
return None
|
|
362
|
+
|
|
363
|
+
async def execute(
|
|
364
|
+
self,
|
|
365
|
+
agent: "FlatAgent",
|
|
366
|
+
input_data: Dict[str, Any]
|
|
367
|
+
) -> Optional[Dict[str, Any]]:
|
|
368
|
+
"""
|
|
369
|
+
Multi-sample with voting - replaces single agent call.
|
|
370
|
+
|
|
371
|
+
Returns the winning parsed response or None.
|
|
372
|
+
"""
|
|
373
|
+
import litellm
|
|
374
|
+
|
|
375
|
+
self._configure_from_agent(agent)
|
|
376
|
+
|
|
377
|
+
votes: Counter = Counter()
|
|
378
|
+
responses: Dict[str, Dict[str, Any]] = {}
|
|
379
|
+
num_samples = 0
|
|
380
|
+
|
|
381
|
+
for _ in range(self.max_candidates):
|
|
382
|
+
try:
|
|
383
|
+
# Render prompts
|
|
384
|
+
system_prompt = agent._render_system_prompt(input_data)
|
|
385
|
+
user_prompt = agent._render_user_prompt(input_data)
|
|
386
|
+
|
|
387
|
+
messages = [
|
|
388
|
+
{"role": "system", "content": system_prompt},
|
|
389
|
+
{"role": "user", "content": user_prompt}
|
|
390
|
+
]
|
|
391
|
+
|
|
392
|
+
response = await litellm.acompletion(
|
|
393
|
+
model=agent.model,
|
|
394
|
+
messages=messages,
|
|
395
|
+
temperature=agent.temperature,
|
|
396
|
+
max_tokens=agent.max_tokens,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
agent.total_api_calls += 1
|
|
400
|
+
num_samples += 1
|
|
401
|
+
self.metrics.total_samples += 1
|
|
402
|
+
|
|
403
|
+
content = response.choices[0].message.content
|
|
404
|
+
if content is None:
|
|
405
|
+
continue
|
|
406
|
+
|
|
407
|
+
# Parse and check
|
|
408
|
+
parsed = self._parse_response(content)
|
|
409
|
+
flag_reason = self._check_red_flags(content, parsed)
|
|
410
|
+
|
|
411
|
+
if flag_reason:
|
|
412
|
+
self.metrics.record_red_flag(flag_reason)
|
|
413
|
+
continue
|
|
414
|
+
|
|
415
|
+
# Vote
|
|
416
|
+
key = json.dumps(parsed, sort_keys=True)
|
|
417
|
+
votes[key] += 1
|
|
418
|
+
responses[key] = parsed
|
|
419
|
+
|
|
420
|
+
# Check for winner
|
|
421
|
+
if votes[key] >= self.k_margin:
|
|
422
|
+
self.metrics.samples_per_step.append(num_samples)
|
|
423
|
+
return parsed
|
|
424
|
+
|
|
425
|
+
if len(votes) >= 2:
|
|
426
|
+
top = votes.most_common(2)
|
|
427
|
+
if top[0][1] - top[1][1] >= self.k_margin:
|
|
428
|
+
self.metrics.samples_per_step.append(num_samples)
|
|
429
|
+
return responses[top[0][0]]
|
|
430
|
+
|
|
431
|
+
except Exception as e:
|
|
432
|
+
logger.warning(f"Sample failed: {e}")
|
|
433
|
+
continue
|
|
434
|
+
|
|
435
|
+
# Majority fallback
|
|
436
|
+
self.metrics.samples_per_step.append(num_samples)
|
|
437
|
+
if votes:
|
|
438
|
+
winner_key = votes.most_common(1)[0][0]
|
|
439
|
+
return responses[winner_key]
|
|
440
|
+
|
|
441
|
+
return None
|
|
442
|
+
|
|
443
|
+
def get_metrics(self) -> Dict[str, Any]:
|
|
444
|
+
"""Get collected metrics."""
|
|
445
|
+
return {
|
|
446
|
+
"total_samples": self.metrics.total_samples,
|
|
447
|
+
"total_red_flags": self.metrics.total_red_flags,
|
|
448
|
+
"red_flags_by_reason": self.metrics.red_flags_by_reason,
|
|
449
|
+
"samples_per_step": self.metrics.samples_per_step,
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
__all__ = [
|
|
454
|
+
"ExecutionType",
|
|
455
|
+
"DefaultExecution",
|
|
456
|
+
"ParallelExecution",
|
|
457
|
+
"RetryExecution",
|
|
458
|
+
"MDAPVotingExecution",
|
|
459
|
+
"get_execution_type",
|
|
460
|
+
"register_execution_type",
|
|
461
|
+
]
|
|
462
|
+
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Expression engines for flatmachines.
|
|
3
|
+
|
|
4
|
+
Provides two modes:
|
|
5
|
+
- simple: Built-in parser for basic comparisons and boolean logic (default)
|
|
6
|
+
- cel: Full CEL support via cel-python (optional extra)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any, Dict, Protocol, runtime_checkable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@runtime_checkable
|
|
13
|
+
class ExpressionEngine(Protocol):
|
|
14
|
+
"""Protocol for expression engines."""
|
|
15
|
+
|
|
16
|
+
def evaluate(self, expression: str, variables: Dict[str, Any]) -> Any:
|
|
17
|
+
"""
|
|
18
|
+
Evaluate an expression with the given variables.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
expression: The expression string to evaluate
|
|
22
|
+
variables: Dictionary of variable names to values
|
|
23
|
+
(e.g., {"context": {...}, "input": {...}, "output": {...}})
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
The result of evaluating the expression
|
|
27
|
+
"""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_expression_engine(mode: str = "simple") -> ExpressionEngine:
|
|
32
|
+
"""
|
|
33
|
+
Get an expression engine by mode.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
mode: "simple" (default) or "cel"
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
ExpressionEngine instance
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
ImportError: If CEL mode requested but cel-python not installed
|
|
43
|
+
ValueError: If unknown mode
|
|
44
|
+
"""
|
|
45
|
+
if mode == "simple":
|
|
46
|
+
from .simple import SimpleExpressionEngine
|
|
47
|
+
return SimpleExpressionEngine()
|
|
48
|
+
elif mode == "cel":
|
|
49
|
+
try:
|
|
50
|
+
from .cel import CELExpressionEngine
|
|
51
|
+
return CELExpressionEngine()
|
|
52
|
+
except ImportError:
|
|
53
|
+
raise ImportError(
|
|
54
|
+
"CEL expression engine requires: pip install flatagents[cel]"
|
|
55
|
+
)
|
|
56
|
+
else:
|
|
57
|
+
raise ValueError(f"Unknown expression engine: {mode}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
__all__ = ["ExpressionEngine", "get_expression_engine"]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CEL expression engine for flatmachines.
|
|
3
|
+
|
|
4
|
+
Wraps cel-python to provide full CEL support including:
|
|
5
|
+
- List macros (all, exists, filter, map)
|
|
6
|
+
- String methods (startsWith, contains, endsWith)
|
|
7
|
+
- Timestamps and durations
|
|
8
|
+
- Type coercion
|
|
9
|
+
|
|
10
|
+
Requires: pip install flatagents[cel]
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from typing import Any, Dict
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import celpy
|
|
17
|
+
from celpy import celtypes
|
|
18
|
+
CEL_AVAILABLE = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
CEL_AVAILABLE = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CELExpressionEngine:
|
|
24
|
+
"""
|
|
25
|
+
CEL expression engine using cel-python.
|
|
26
|
+
|
|
27
|
+
Provides full CEL support for advanced expressions.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self):
|
|
31
|
+
if not CEL_AVAILABLE:
|
|
32
|
+
raise ImportError(
|
|
33
|
+
"CEL expression engine requires cel-python. "
|
|
34
|
+
"Install with: pip install flatagents[cel]"
|
|
35
|
+
)
|
|
36
|
+
self._env = celpy.Environment()
|
|
37
|
+
|
|
38
|
+
def evaluate(self, expression: str, variables: Dict[str, Any]) -> Any:
|
|
39
|
+
"""
|
|
40
|
+
Evaluate a CEL expression with the given variables.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
expression: The CEL expression string to evaluate
|
|
44
|
+
variables: Dictionary of variable names to values
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
The result of evaluating the expression
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
ValueError: If expression syntax is invalid
|
|
51
|
+
"""
|
|
52
|
+
if not expression or not expression.strip():
|
|
53
|
+
return True # Empty expression is always true
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
# Parse the expression
|
|
57
|
+
ast = self._env.compile(expression)
|
|
58
|
+
|
|
59
|
+
# Create the program
|
|
60
|
+
prog = self._env.program(ast)
|
|
61
|
+
|
|
62
|
+
# Convert Python values to CEL types
|
|
63
|
+
cel_vars = self._to_cel_types(variables)
|
|
64
|
+
|
|
65
|
+
# Evaluate
|
|
66
|
+
result = prog.evaluate(cel_vars)
|
|
67
|
+
|
|
68
|
+
# Convert result back to Python
|
|
69
|
+
return self._from_cel_type(result)
|
|
70
|
+
|
|
71
|
+
except Exception as e:
|
|
72
|
+
raise ValueError(f"CEL expression error: {expression} - {e}") from e
|
|
73
|
+
|
|
74
|
+
def _to_cel_types(self, obj: Any) -> Any:
|
|
75
|
+
"""Convert Python types to CEL types."""
|
|
76
|
+
if isinstance(obj, dict):
|
|
77
|
+
return {k: self._to_cel_types(v) for k, v in obj.items()}
|
|
78
|
+
if isinstance(obj, list):
|
|
79
|
+
return [self._to_cel_types(v) for v in obj]
|
|
80
|
+
# Primitives pass through
|
|
81
|
+
return obj
|
|
82
|
+
|
|
83
|
+
def _from_cel_type(self, obj: Any) -> Any:
|
|
84
|
+
"""Convert CEL types back to Python types."""
|
|
85
|
+
if CEL_AVAILABLE:
|
|
86
|
+
if isinstance(obj, celtypes.BoolType):
|
|
87
|
+
return bool(obj)
|
|
88
|
+
if isinstance(obj, celtypes.IntType):
|
|
89
|
+
return int(obj)
|
|
90
|
+
if isinstance(obj, celtypes.DoubleType):
|
|
91
|
+
return float(obj)
|
|
92
|
+
if isinstance(obj, celtypes.StringType):
|
|
93
|
+
return str(obj)
|
|
94
|
+
if isinstance(obj, celtypes.ListType):
|
|
95
|
+
return [self._from_cel_type(v) for v in obj]
|
|
96
|
+
if isinstance(obj, celtypes.MapType):
|
|
97
|
+
return {k: self._from_cel_type(v) for k, v in obj.items()}
|
|
98
|
+
return obj
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
__all__ = ["CELExpressionEngine", "CEL_AVAILABLE"]
|