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.
@@ -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"]