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