flatagents 0.1.0__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 ADDED
@@ -0,0 +1,30 @@
1
+ __version__ = "0.1.0"
2
+
3
+ from .baseagent import (
4
+ FlatAgent,
5
+ LLMBackend,
6
+ LiteLLMBackend,
7
+ AISuiteBackend,
8
+ Extractor,
9
+ FreeExtractor,
10
+ FreeThinkingExtractor,
11
+ StructuredExtractor,
12
+ ToolsExtractor,
13
+ RegexExtractor,
14
+ )
15
+ from .declarativeagent import DeclarativeAgent
16
+
17
+ __all__ = [
18
+ "__version__",
19
+ "FlatAgent",
20
+ "LLMBackend",
21
+ "LiteLLMBackend",
22
+ "AISuiteBackend",
23
+ "Extractor",
24
+ "FreeExtractor",
25
+ "FreeThinkingExtractor",
26
+ "StructuredExtractor",
27
+ "ToolsExtractor",
28
+ "RegexExtractor",
29
+ "DeclarativeAgent",
30
+ ]
@@ -0,0 +1,699 @@
1
+ """
2
+ Self-contained FlatAgent base class with pluggable LLM backends.
3
+
4
+ Unifies the agent interface, configuration, and execution loop into a single class.
5
+ LLM interaction is delegated to an LLMBackend, allowing different providers.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ import os
11
+ import random
12
+ from abc import ABC, abstractmethod
13
+ from typing import Any, Tuple, Callable, List, Dict, Optional, Protocol, runtime_checkable
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ try:
18
+ import litellm
19
+ except ImportError:
20
+ litellm = None
21
+
22
+ try:
23
+ import aisuite
24
+ except ImportError:
25
+ aisuite = None
26
+
27
+ try:
28
+ import yaml
29
+ except ImportError:
30
+ yaml = None
31
+
32
+ import json
33
+
34
+
35
+ # ─────────────────────────────────────────────────────────────────────────────
36
+ # LLM Backend Protocol and Implementations
37
+ # ─────────────────────────────────────────────────────────────────────────────
38
+
39
+ @runtime_checkable
40
+ class LLMBackend(Protocol):
41
+ """Protocol for LLM backends. Implement this to support different providers."""
42
+
43
+ total_cost: float
44
+ total_api_calls: int
45
+
46
+ async def call(
47
+ self,
48
+ messages: List[Dict[str, str]],
49
+ **kwargs
50
+ ) -> str:
51
+ """
52
+ Call the LLM with the given messages.
53
+
54
+ Args:
55
+ messages: List of message dicts with 'role' and 'content' keys
56
+ **kwargs: Additional parameters (temperature, max_tokens, etc.)
57
+
58
+ Returns:
59
+ The LLM response content as a string
60
+ """
61
+ ...
62
+
63
+ async def call_raw(
64
+ self,
65
+ messages: List[Dict[str, str]],
66
+ **kwargs
67
+ ) -> Any:
68
+ """
69
+ Call the LLM and return the raw response object.
70
+
71
+ Args:
72
+ messages: List of message dicts with 'role' and 'content' keys
73
+ **kwargs: Additional parameters (temperature, max_tokens, etc.)
74
+
75
+ Returns:
76
+ The raw LiteLLM/provider response object
77
+ """
78
+ ...
79
+
80
+
81
+ class LiteLLMBackend:
82
+ """LLM backend using the litellm library."""
83
+
84
+ def __init__(
85
+ self,
86
+ model: str,
87
+ temperature: float = 0.7,
88
+ max_tokens: int = 2048,
89
+ top_p: float = 1.0,
90
+ frequency_penalty: float = 0.0,
91
+ presence_penalty: float = 0.0,
92
+ retry_delays: Optional[List[float]] = None,
93
+ ):
94
+ if litellm is None:
95
+ raise ImportError("litellm is required. Install with: pip install litellm")
96
+
97
+ self.model = model
98
+ self.llm_kwargs = {
99
+ "temperature": temperature,
100
+ "max_tokens": max_tokens,
101
+ "top_p": top_p,
102
+ "frequency_penalty": frequency_penalty,
103
+ "presence_penalty": presence_penalty,
104
+ }
105
+ self.retry_delays = retry_delays or [1, 2, 4, 8]
106
+ self.total_cost = 0.0
107
+ self.total_api_calls = 0
108
+
109
+ logger.info(f"Initialized LiteLLMBackend with model: {model}")
110
+
111
+ async def call_raw(
112
+ self,
113
+ messages: List[Dict[str, str]],
114
+ **kwargs
115
+ ) -> Any:
116
+ """Call the LLM and return the raw response object with retry logic."""
117
+ call_kwargs = {**self.llm_kwargs, **kwargs}
118
+
119
+ last_exception = None
120
+ for attempt, delay in enumerate(self.retry_delays):
121
+ try:
122
+ self.total_api_calls += 1
123
+ logger.info(f"Calling LLM (Attempt {attempt + 1}/{len(self.retry_delays)})...")
124
+
125
+ response = await litellm.acompletion(
126
+ model=self.model,
127
+ messages=messages,
128
+ **call_kwargs
129
+ )
130
+
131
+ if response is None or response.choices is None or len(response.choices) == 0:
132
+ raise ValueError("Received an empty or invalid response from the LLM.")
133
+
134
+ # Track cost if available
135
+ if hasattr(response, '_hidden_params') and 'response_cost' in response._hidden_params:
136
+ self.total_cost += response._hidden_params['response_cost']
137
+
138
+ return response
139
+
140
+ except Exception as e:
141
+ last_exception = e
142
+ logger.warning(f"LLM call failed on attempt {attempt + 1}: {e}")
143
+ if attempt < len(self.retry_delays) - 1:
144
+ jittered_delay = delay + random.random()
145
+ logger.info(f"Retrying in {jittered_delay:.2f} seconds...")
146
+ await asyncio.sleep(jittered_delay)
147
+
148
+ logger.error("All retry attempts failed.")
149
+ raise last_exception or RuntimeError("LLM call failed after all retries")
150
+
151
+ async def call(
152
+ self,
153
+ messages: List[Dict[str, str]],
154
+ **kwargs
155
+ ) -> str:
156
+ """Call the LLM and return the content string."""
157
+ response = await self.call_raw(messages, **kwargs)
158
+ content = response.choices[0].message.content
159
+ if content is None:
160
+ raise ValueError("The LLM response content was empty.")
161
+ logger.info(f"LLM response received: '{content[:100]}...'")
162
+ return content
163
+
164
+
165
+ class AISuiteBackend:
166
+ """
167
+ LLM backend using the aisuite library (by Andrew Ng).
168
+
169
+ Provides a unified interface to multiple providers:
170
+ OpenAI, Anthropic, Google, AWS, Cohere, Mistral, Ollama, HuggingFace.
171
+
172
+ Model format: "provider:model" (e.g., "openai:gpt-4o", "anthropic:claude-3-5-sonnet")
173
+ """
174
+
175
+ def __init__(
176
+ self,
177
+ model: str,
178
+ temperature: float = 0.7,
179
+ max_tokens: int = 2048,
180
+ top_p: float = 1.0,
181
+ retry_delays: Optional[List[float]] = None,
182
+ ):
183
+ if aisuite is None:
184
+ raise ImportError("aisuite is required. Install with: pip install aisuite")
185
+
186
+ # Normalize model format: accept both "provider/model" and "provider:model"
187
+ self.model = model.replace("/", ":", 1) if "/" in model else model
188
+ self.llm_kwargs = {
189
+ "temperature": temperature,
190
+ "max_tokens": max_tokens,
191
+ "top_p": top_p,
192
+ }
193
+ self.retry_delays = retry_delays or [1, 2, 4, 8]
194
+ self.total_cost = 0.0
195
+ self.total_api_calls = 0
196
+ self.client = aisuite.Client()
197
+
198
+ logger.info(f"Initialized AISuiteBackend with model: {self.model}")
199
+
200
+ async def call_raw(
201
+ self,
202
+ messages: List[Dict[str, str]],
203
+ **kwargs
204
+ ) -> Any:
205
+ """Call the LLM and return the raw response object with retry logic."""
206
+ call_kwargs = {**self.llm_kwargs, **kwargs}
207
+
208
+ last_exception = None
209
+ for attempt, delay in enumerate(self.retry_delays):
210
+ try:
211
+ self.total_api_calls += 1
212
+ logger.info(f"Calling LLM via AISuite (Attempt {attempt + 1}/{len(self.retry_delays)})...")
213
+
214
+ # aisuite is sync-only, wrap in thread for async compatibility
215
+ response = await asyncio.to_thread(
216
+ self.client.chat.completions.create,
217
+ model=self.model,
218
+ messages=messages,
219
+ **call_kwargs
220
+ )
221
+
222
+ if response is None or response.choices is None or len(response.choices) == 0:
223
+ raise ValueError("Received an empty or invalid response from the LLM.")
224
+
225
+ # Track cost from usage if available
226
+ if hasattr(response, 'usage') and response.usage:
227
+ # Estimate cost based on token counts (rough estimate)
228
+ # This is approximate; providers have different pricing
229
+ usage = response.usage
230
+ prompt_tokens = getattr(usage, 'prompt_tokens', 0) or 0
231
+ completion_tokens = getattr(usage, 'completion_tokens', 0) or 0
232
+ # Very rough estimate: $0.01 per 1K tokens average
233
+ estimated_cost = (prompt_tokens + completion_tokens) * 0.00001
234
+ self.total_cost += estimated_cost
235
+
236
+ return response
237
+
238
+ except Exception as e:
239
+ last_exception = e
240
+ logger.warning(f"AISuite call failed on attempt {attempt + 1}: {e}")
241
+ if attempt < len(self.retry_delays) - 1:
242
+ jittered_delay = delay + random.random()
243
+ logger.info(f"Retrying in {jittered_delay:.2f} seconds...")
244
+ await asyncio.sleep(jittered_delay)
245
+
246
+ logger.error("All retry attempts failed.")
247
+ raise last_exception or RuntimeError("AISuite call failed after all retries")
248
+
249
+ async def call(
250
+ self,
251
+ messages: List[Dict[str, str]],
252
+ **kwargs
253
+ ) -> str:
254
+ """Call the LLM and return the content string."""
255
+ response = await self.call_raw(messages, **kwargs)
256
+ content = response.choices[0].message.content
257
+ if content is None:
258
+ raise ValueError("The LLM response content was empty.")
259
+ logger.info(f"LLM response received: '{content[:100]}...'")
260
+ return content
261
+
262
+
263
+ # ─────────────────────────────────────────────────────────────────────────────
264
+ # Extractors (process LiteLLM responses into structured output)
265
+ # ─────────────────────────────────────────────────────────────────────────────
266
+
267
+ @runtime_checkable
268
+ class Extractor(Protocol):
269
+ """Protocol for response extractors. Process raw LLM responses into structured output."""
270
+
271
+ def extract(self, response: Any) -> Any:
272
+ """
273
+ Extract structured data from a raw LLM response.
274
+
275
+ Args:
276
+ response: Raw response object from LLMBackend.call_raw()
277
+
278
+ Returns:
279
+ Extracted/structured data
280
+ """
281
+ ...
282
+
283
+
284
+ class FreeExtractor:
285
+ """Returns the raw response content as-is. No parsing."""
286
+
287
+ def extract(self, response: Any) -> str:
288
+ """Extract raw content string."""
289
+ content = response.choices[0].message.content
290
+ return content if content is not None else ""
291
+
292
+
293
+ class FreeThinkingExtractor:
294
+ """
295
+ Preserves reasoning/thinking from the response.
296
+ Returns: { "thinking": str, "response": str }
297
+
298
+ Works with models that return thinking in:
299
+ - A separate 'thinking' field
300
+ - Content blocks with type='thinking'
301
+ - <thinking> tags in content
302
+ """
303
+
304
+ def extract(self, response: Any) -> Dict[str, str]:
305
+ """Extract thinking and response separately."""
306
+ import re
307
+ message = response.choices[0].message
308
+ content = message.content or ""
309
+ thinking = ""
310
+
311
+ # Check for thinking in message attributes (provider-specific)
312
+ if hasattr(message, 'thinking') and message.thinking:
313
+ thinking = message.thinking
314
+ # Check for thinking in content blocks (Anthropic style)
315
+ elif hasattr(message, 'content_blocks'):
316
+ for block in message.content_blocks or []:
317
+ if getattr(block, 'type', None) == 'thinking':
318
+ thinking = getattr(block, 'text', '')
319
+ elif getattr(block, 'type', None) == 'text':
320
+ content = getattr(block, 'text', content)
321
+ # Check for <thinking> tags in content
322
+ elif '<thinking>' in content and '</thinking>' in content:
323
+ match = re.search(r'<thinking>(.*?)</thinking>', content, re.DOTALL)
324
+ if match:
325
+ thinking = match.group(1).strip()
326
+ content = re.sub(r'<thinking>.*?</thinking>', '', content, flags=re.DOTALL).strip()
327
+
328
+ return {"thinking": thinking, "response": content}
329
+
330
+
331
+ class StructuredExtractor:
332
+ """
333
+ Extracts structured JSON output using response_format.
334
+ Requires the LLM call to include response_format parameter.
335
+ """
336
+
337
+ def __init__(self, schema: Optional[Dict] = None):
338
+ """
339
+ Args:
340
+ schema: Optional JSON schema for validation
341
+ """
342
+ self.schema = schema
343
+
344
+ def extract(self, response: Any) -> Dict[str, Any]:
345
+ """Extract and parse JSON from response."""
346
+ content = response.choices[0].message.content
347
+ if content is None:
348
+ return {}
349
+
350
+ try:
351
+ parsed = json.loads(content)
352
+ return parsed
353
+ except json.JSONDecodeError as e:
354
+ logger.warning(f"Failed to parse JSON response: {e}")
355
+ return {"_raw": content, "_error": str(e)}
356
+
357
+
358
+ class ToolsExtractor:
359
+ """
360
+ Extracts tool calls from the response.
361
+ Returns: { "tool_calls": [...], "content": str }
362
+ """
363
+
364
+ def extract(self, response: Any) -> Dict[str, Any]:
365
+ """Extract tool calls and content."""
366
+ message = response.choices[0].message
367
+ content = message.content or ""
368
+ tool_calls = []
369
+
370
+ if hasattr(message, 'tool_calls') and message.tool_calls:
371
+ for tc in message.tool_calls:
372
+ tool_call = {
373
+ "id": getattr(tc, 'id', None),
374
+ "type": getattr(tc, 'type', 'function'),
375
+ "function": {
376
+ "name": tc.function.name if hasattr(tc, 'function') else None,
377
+ "arguments": tc.function.arguments if hasattr(tc, 'function') else None,
378
+ }
379
+ }
380
+ # Parse arguments JSON if present
381
+ if tool_call["function"]["arguments"]:
382
+ try:
383
+ tool_call["function"]["arguments"] = json.loads(
384
+ tool_call["function"]["arguments"]
385
+ )
386
+ except json.JSONDecodeError:
387
+ pass # Keep as string if not valid JSON
388
+ tool_calls.append(tool_call)
389
+
390
+ return {"tool_calls": tool_calls, "content": content}
391
+
392
+
393
+ class RegexExtractor:
394
+ """
395
+ Extracts fields from response using regex patterns.
396
+ Patterns are provided at runtime, not in the spec.
397
+
398
+ Can extract from:
399
+ - Raw LLM response object (response.choices[0].message.content)
400
+ - Plain string
401
+ """
402
+
403
+ def __init__(self, patterns: Dict[str, str], types: Optional[Dict[str, str]] = None):
404
+ """
405
+ Args:
406
+ patterns: Map of field names to regex patterns (must have capture group)
407
+ types: Optional map of field names to type names ('str', 'int', 'float', 'bool', 'json')
408
+ """
409
+ import re
410
+ self.patterns = {name: re.compile(pattern) for name, pattern in patterns.items()}
411
+ self.types = types or {}
412
+
413
+ def extract(self, response: Any) -> Optional[Dict[str, Any]]:
414
+ """Extract fields using regex patterns."""
415
+ # Handle both response object and plain string
416
+ if isinstance(response, str):
417
+ content = response
418
+ else:
419
+ content = response.choices[0].message.content
420
+
421
+ if content is None:
422
+ return None
423
+
424
+ result = {}
425
+ for field_name, pattern in self.patterns.items():
426
+ match = pattern.search(content)
427
+ if not match:
428
+ logger.debug(f"Field '{field_name}' pattern did not match")
429
+ return None
430
+
431
+ value = match.group(1)
432
+ field_type = self.types.get(field_name, 'str')
433
+
434
+ try:
435
+ if field_type == 'json':
436
+ result[field_name] = json.loads(value)
437
+ elif field_type == 'int':
438
+ result[field_name] = int(value)
439
+ elif field_type == 'float':
440
+ result[field_name] = float(value)
441
+ elif field_type == 'bool':
442
+ result[field_name] = value.lower() in ('true', '1', 'yes')
443
+ else:
444
+ result[field_name] = value
445
+ except (json.JSONDecodeError, ValueError) as e:
446
+ logger.debug(f"Failed to parse field '{field_name}': {e}")
447
+ return None
448
+
449
+ return result
450
+
451
+
452
+ # ─────────────────────────────────────────────────────────────────────────────
453
+ # FlatAgent Base Class
454
+ # ─────────────────────────────────────────────────────────────────────────────
455
+
456
+ class FlatAgent(ABC):
457
+ """
458
+ Abstract base class for self-contained flat agents.
459
+
460
+ Combines the agent interface, configuration, and execution loop.
461
+ LLM interaction is delegated to a pluggable LLMBackend.
462
+
463
+ Configuration can be provided via:
464
+ - config_file: Path to a YAML configuration file
465
+ - config_dict: A dictionary with configuration
466
+ - backend: Custom LLMBackend instance (overrides config-based backend)
467
+ - **kwargs: Override individual parameters
468
+
469
+ Example usage:
470
+ class MyAgent(FlatAgent):
471
+ def create_initial_state(self): return {}
472
+ def generate_step_prompt(self, state): return "..."
473
+ def update_state(self, state, result): return {**state, 'result': result}
474
+ def is_solved(self, state): return state.get('done', False)
475
+
476
+ # Using config file (creates LiteLLMBackend automatically)
477
+ agent = MyAgent(config_file="config.yaml")
478
+
479
+ # Using custom backend
480
+ backend = LiteLLMBackend(model="openai/gpt-4", temperature=0.5)
481
+ agent = MyAgent(backend=backend)
482
+
483
+ trace = await agent.execute()
484
+ """
485
+
486
+ DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant."
487
+
488
+ def __init__(
489
+ self,
490
+ config_file: Optional[str] = None,
491
+ config_dict: Optional[Dict] = None,
492
+ backend: Optional[LLMBackend] = None,
493
+ **kwargs
494
+ ):
495
+ """
496
+ Initialize the agent with configuration and optional backend.
497
+
498
+ Args:
499
+ config_file: Path to YAML config file
500
+ config_dict: Configuration dictionary
501
+ backend: Custom LLMBackend (if not provided, creates LiteLLMBackend from config)
502
+ **kwargs: Override specific config values
503
+ """
504
+ self._load_config(config_file, config_dict, **kwargs)
505
+
506
+ if backend is not None:
507
+ self.backend = backend
508
+ else:
509
+ self.backend = self._create_default_backend()
510
+
511
+ logger.info(f"Initialized {self.__class__.__name__} with backend: {self.backend.__class__.__name__}")
512
+
513
+ def _load_config(
514
+ self,
515
+ config_file: Optional[str],
516
+ config_dict: Optional[Dict],
517
+ **kwargs
518
+ ):
519
+ """Load and process configuration from file (YAML or JSON), dict, or kwargs."""
520
+ config = {}
521
+
522
+ if config_file is not None:
523
+ if not os.path.exists(config_file):
524
+ raise FileNotFoundError(f"Configuration file not found: {config_file}")
525
+
526
+ with open(config_file, 'r') as f:
527
+ if config_file.endswith('.json'):
528
+ config = json.load(f) or {}
529
+ else:
530
+ if yaml is None:
531
+ raise ImportError("pyyaml is required for YAML config files. Install with: pip install pyyaml")
532
+ config = yaml.safe_load(f) or {}
533
+ elif config_dict is not None:
534
+ config = config_dict
535
+
536
+ model_config = config.get('model', {})
537
+ defaults = config.get('litellm_defaults', {})
538
+
539
+ # Build model name from provider/name if needed
540
+ provider = model_config.get('provider')
541
+ model_name = model_config.get('name')
542
+ if provider and model_name and '/' not in model_name:
543
+ full_model_name = f"{provider}/{model_name}"
544
+ else:
545
+ full_model_name = model_name
546
+
547
+ def get_value(key: str, fallback: Any) -> Any:
548
+ return kwargs.get(key, model_config.get(key, defaults.get(key, fallback)))
549
+
550
+ # Store config values for backend creation
551
+ self.model = kwargs.get('model', full_model_name)
552
+ self.temperature = get_value('temperature', 0.7)
553
+ self.max_tokens = get_value('max_tokens', 2048)
554
+ self.top_p = get_value('top_p', 1.0)
555
+ self.frequency_penalty = get_value('frequency_penalty', 0.0)
556
+ self.presence_penalty = get_value('presence_penalty', 0.0)
557
+ self.retry_delays = model_config.get('retry_delays', [1, 2, 4, 8])
558
+
559
+ # Store raw config for subclass access
560
+ self.config = config
561
+
562
+ def _create_default_backend(self) -> LLMBackend:
563
+ """Create the default LiteLLMBackend from loaded config."""
564
+ if self.model is None:
565
+ raise ValueError("Model name is required. Provide via config file, config_dict, or 'model' kwarg.")
566
+
567
+ return LiteLLMBackend(
568
+ model=self.model,
569
+ temperature=self.temperature,
570
+ max_tokens=self.max_tokens,
571
+ top_p=self.top_p,
572
+ frequency_penalty=self.frequency_penalty,
573
+ presence_penalty=self.presence_penalty,
574
+ retry_delays=self.retry_delays,
575
+ )
576
+
577
+ # ─────────────────────────────────────────────────────────────────────────
578
+ # Convenience Properties (delegate to backend)
579
+ # ─────────────────────────────────────────────────────────────────────────
580
+
581
+ @property
582
+ def total_cost(self) -> float:
583
+ """Total cost accumulated by the backend."""
584
+ return self.backend.total_cost
585
+
586
+ @property
587
+ def total_api_calls(self) -> int:
588
+ """Total API calls made by the backend."""
589
+ return self.backend.total_api_calls
590
+
591
+ # ─────────────────────────────────────────────────────────────────────────
592
+ # Abstract Methods (subclasses must implement)
593
+ # ─────────────────────────────────────────────────────────────────────────
594
+
595
+ @abstractmethod
596
+ def create_initial_state(self, *args, **kwargs) -> Any:
597
+ """Create the initial state for the problem."""
598
+ pass
599
+
600
+ @abstractmethod
601
+ def generate_step_prompt(self, state: Any) -> str:
602
+ """Generate the user prompt for the next step based on current state."""
603
+ pass
604
+
605
+ @abstractmethod
606
+ def update_state(self, current_state: Any, step_result: Any) -> Any:
607
+ """Update the state based on the step result."""
608
+ pass
609
+
610
+ @abstractmethod
611
+ def is_solved(self, state: Any) -> bool:
612
+ """Check if the problem is solved."""
613
+ pass
614
+
615
+ # ─────────────────────────────────────────────────────────────────────────
616
+ # Overridable Hooks
617
+ # ─────────────────────────────────────────────────────────────────────────
618
+
619
+ def get_system_prompt(self) -> str:
620
+ """
621
+ Get the system prompt for LLM calls.
622
+ Override to customize the system prompt for your agent.
623
+ """
624
+ return self.DEFAULT_SYSTEM_PROMPT
625
+
626
+ def get_response_parser(self) -> Callable[[str], Any]:
627
+ """
628
+ Get the response parser for this agent.
629
+ Override to provide domain-specific parsing of LLM responses.
630
+ """
631
+ return lambda x: x
632
+
633
+ def validate_step_result(self, step_result: Any) -> bool:
634
+ """
635
+ Validate that a step result is acceptable before updating state.
636
+ Override for domain-specific validation.
637
+ """
638
+ return step_result is not None
639
+
640
+ def step_generator(self, state: Any) -> Tuple[Tuple[str, str], Callable[[str], Any]]:
641
+ """
642
+ Generate the prompt tuple and parser for the current state.
643
+
644
+ Returns:
645
+ Tuple of ((system_prompt, user_prompt), response_parser)
646
+
647
+ Override for full control over prompt generation.
648
+ """
649
+ system_prompt = self.get_system_prompt()
650
+ user_prompt = self.generate_step_prompt(state)
651
+ parser = self.get_response_parser()
652
+ return (system_prompt, user_prompt), parser
653
+
654
+ # ─────────────────────────────────────────────────────────────────────────
655
+ # Execution
656
+ # ─────────────────────────────────────────────────────────────────────────
657
+
658
+ async def execute(self, *args, **kwargs) -> List[Any]:
659
+ """
660
+ Execute the agent to solve the problem.
661
+
662
+ Args:
663
+ *args, **kwargs: Passed to create_initial_state()
664
+
665
+ Returns:
666
+ List of states representing the execution trace
667
+ """
668
+ logger.info(f"Starting execution with args={args}, kwargs={kwargs}")
669
+
670
+ state = self.create_initial_state(*args, **kwargs)
671
+ trace = [state]
672
+
673
+ while not self.is_solved(state):
674
+ prompt_tuple, parser = self.step_generator(state)
675
+ raw_result = await self._call_llm(prompt_tuple)
676
+ parsed_result = parser(raw_result)
677
+
678
+ if not self.validate_step_result(parsed_result):
679
+ logger.warning(f"Step result validation failed: {parsed_result}")
680
+
681
+ state = self.update_state(state, parsed_result)
682
+ trace.append(state)
683
+ logger.info("State updated.")
684
+
685
+ logger.info(f"Execution completed. Trace length: {len(trace)} states")
686
+ return trace
687
+
688
+ async def _call_llm(self, prompt_tuple: Tuple[str, str]) -> str:
689
+ """
690
+ Call the LLM backend with the given prompt.
691
+
692
+ Override this for custom pre/post processing around LLM calls.
693
+ """
694
+ system_prompt, user_prompt = prompt_tuple
695
+ messages = [
696
+ {"role": "system", "content": system_prompt},
697
+ {"role": "user", "content": user_prompt},
698
+ ]
699
+ return await self.backend.call(messages)
@@ -0,0 +1,255 @@
1
+ """
2
+ DeclarativeAgent - A single LLM call configured entirely via YAML or JSON.
3
+
4
+ See declarative-agent.d.ts for the TypeScript type definition.
5
+
6
+ An agent is a single LLM call: model + prompts + output schema.
7
+ Workflows handle composition, branching, and loops.
8
+ """
9
+
10
+ import json
11
+ import logging
12
+ from typing import Any, Dict, Optional
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ try:
17
+ import jinja2
18
+ except ImportError:
19
+ jinja2 = None
20
+
21
+ try:
22
+ import litellm
23
+ except ImportError:
24
+ litellm = None
25
+
26
+
27
+ class DeclarativeAgent:
28
+ """
29
+ A single LLM call configured entirely via YAML. No code required.
30
+
31
+ v0.4.0 Container format:
32
+
33
+ spec: declarative_agent
34
+ spec_version: "0.4.0"
35
+
36
+ data:
37
+ name: greeter
38
+
39
+ model:
40
+ provider: cerebras
41
+ name: zai-glm-4.6
42
+ temperature: 0.7
43
+
44
+ system: "You are a friendly greeter."
45
+
46
+ user: |
47
+ Greet the user named {{ input.name }}.
48
+
49
+ output:
50
+ greeting:
51
+ type: str
52
+ description: "A friendly greeting message"
53
+
54
+ metadata:
55
+ author: "your-name"
56
+
57
+ Example usage:
58
+ agent = DeclarativeAgent(config_file="agent.yaml")
59
+ result = await agent.call(name="Alice")
60
+ print(result) # {"greeting": "Hello, Alice!"}
61
+ """
62
+
63
+ SPEC_VERSION = "0.4.0"
64
+ DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant."
65
+
66
+ def __init__(
67
+ self,
68
+ config_file: Optional[str] = None,
69
+ config_dict: Optional[Dict] = None,
70
+ **kwargs
71
+ ):
72
+ if jinja2 is None:
73
+ raise ImportError("jinja2 is required for DeclarativeAgent. Install with: pip install jinja2")
74
+ if litellm is None:
75
+ raise ImportError("litellm is required for DeclarativeAgent. Install with: pip install litellm")
76
+
77
+ self._load_config(config_file, config_dict, **kwargs)
78
+ self._validate_spec()
79
+ self._parse_agent_config()
80
+
81
+ # Tracking
82
+ self.total_cost = 0.0
83
+ self.total_api_calls = 0
84
+
85
+ logger.info(f"Initialized DeclarativeAgent: {self.agent_name}")
86
+
87
+ def _load_config(
88
+ self,
89
+ config_file: Optional[str],
90
+ config_dict: Optional[Dict],
91
+ **kwargs
92
+ ):
93
+ """Load v0.4.0 container config."""
94
+ import os
95
+ try:
96
+ import yaml
97
+ except ImportError:
98
+ yaml = None
99
+
100
+ config = {}
101
+ if config_file is not None:
102
+ if not os.path.exists(config_file):
103
+ raise FileNotFoundError(f"Configuration file not found: {config_file}")
104
+ with open(config_file, 'r') as f:
105
+ if config_file.endswith('.json'):
106
+ config = json.load(f) or {}
107
+ else:
108
+ if yaml is None:
109
+ raise ImportError("pyyaml is required for YAML config files.")
110
+ config = yaml.safe_load(f) or {}
111
+ elif config_dict is not None:
112
+ config = config_dict
113
+
114
+ self.config = config
115
+
116
+ # Extract model config from data section
117
+ data = config.get('data', {})
118
+ model_config = data.get('model', {})
119
+
120
+ # Build model name from provider/name
121
+ provider = model_config.get('provider')
122
+ model_name = model_config.get('name')
123
+ if provider and model_name and '/' not in model_name:
124
+ full_model_name = f"{provider}/{model_name}"
125
+ else:
126
+ full_model_name = model_name
127
+
128
+ # Set model attributes (with kwargs override)
129
+ self.model = kwargs.get('model', full_model_name)
130
+ self.temperature = kwargs.get('temperature', model_config.get('temperature', 0.7))
131
+ self.max_tokens = kwargs.get('max_tokens', model_config.get('max_tokens', 2048))
132
+
133
+ def _validate_spec(self):
134
+ """Validate the spec envelope."""
135
+ config = self.config
136
+
137
+ if config.get('spec') != 'declarative_agent':
138
+ raise ValueError(
139
+ f"Invalid spec: expected 'declarative_agent', got '{config.get('spec')}'. "
140
+ "Config must have: spec: declarative_agent"
141
+ )
142
+
143
+ if 'data' not in config:
144
+ raise ValueError("Config missing 'data' section")
145
+
146
+ spec_version = config.get('spec_version', '')
147
+ if not spec_version.startswith('0.4'):
148
+ logger.warning(f"spec_version '{spec_version}' may not be fully compatible with {self.SPEC_VERSION}")
149
+
150
+ def _parse_agent_config(self):
151
+ """Parse the v0.4.0 declarative configuration."""
152
+ data = self.config['data']
153
+ self.metadata = self.config.get('metadata', {})
154
+
155
+ # Agent name
156
+ self.agent_name = data.get('name') or self.metadata.get('name', 'unnamed-agent')
157
+
158
+ # Prompts
159
+ self._system_prompt = data.get('system', self.DEFAULT_SYSTEM_PROMPT)
160
+ self._user_prompt_template = data.get('user', '')
161
+ self._instruction_suffix = data.get('instruction_suffix', '')
162
+
163
+ # Compile Jinja2 template
164
+ self._jinja_env = jinja2.Environment(undefined=jinja2.StrictUndefined)
165
+ self._compiled_user = self._jinja_env.from_string(self._user_prompt_template)
166
+
167
+ # Output schema (stored for reference, extraction uses json_object mode)
168
+ self.output_schema = data.get('output', {})
169
+
170
+ def _render_user_prompt(self, input_data: Dict[str, Any]) -> str:
171
+ """Render user prompt with input data."""
172
+ prompt = self._compiled_user.render(input=input_data)
173
+ if self._instruction_suffix:
174
+ prompt = f"{prompt}\n\n{self._instruction_suffix}"
175
+ return prompt
176
+
177
+ def _build_output_instruction(self) -> str:
178
+ """Build instruction for JSON output based on schema."""
179
+ if not self.output_schema:
180
+ return ""
181
+
182
+ fields = []
183
+ for name, field_def in self.output_schema.items():
184
+ desc = field_def.get('description', '')
185
+ field_type = field_def.get('type', 'str')
186
+ enum_vals = field_def.get('enum')
187
+
188
+ parts = [f'"{name}"']
189
+ if desc:
190
+ parts.append(f"({desc})")
191
+ if enum_vals:
192
+ parts.append(f"- one of: {enum_vals}")
193
+
194
+ fields.append(" ".join(parts))
195
+
196
+ return "Respond with JSON containing: " + ", ".join(fields)
197
+
198
+ async def call(self, **input_data) -> Dict[str, Any]:
199
+ """
200
+ Execute a single LLM call with the given input.
201
+
202
+ Args:
203
+ **input_data: Input values available as {{ input.* }} in templates
204
+
205
+ Returns:
206
+ Dict with output fields as defined in the output schema
207
+ """
208
+ user_prompt = self._render_user_prompt(input_data)
209
+
210
+ # Add output instruction if we have a schema
211
+ output_instruction = self._build_output_instruction()
212
+ if output_instruction:
213
+ user_prompt = f"{user_prompt}\n\n{output_instruction}"
214
+
215
+ messages = [
216
+ {"role": "system", "content": self._system_prompt},
217
+ {"role": "user", "content": user_prompt}
218
+ ]
219
+
220
+ params = {
221
+ "model": self.model,
222
+ "messages": messages,
223
+ "temperature": self.temperature,
224
+ "max_tokens": self.max_tokens,
225
+ }
226
+
227
+ # Use JSON mode if we have an output schema
228
+ if self.output_schema:
229
+ params["response_format"] = {"type": "json_object"}
230
+
231
+ response = await litellm.acompletion(**params)
232
+
233
+ # Track usage
234
+ self.total_api_calls += 1
235
+ if hasattr(response, 'usage') and response.usage:
236
+ input_tokens = getattr(response.usage, 'prompt_tokens', 0)
237
+ output_tokens = getattr(response.usage, 'completion_tokens', 0)
238
+ self.total_cost += (input_tokens * 0.001 + output_tokens * 0.002) / 1000
239
+
240
+ content = response.choices[0].message.content
241
+
242
+ # Parse JSON response if we have an output schema
243
+ if self.output_schema and content:
244
+ try:
245
+ return json.loads(content)
246
+ except json.JSONDecodeError:
247
+ logger.warning(f"Failed to parse JSON response: {content}")
248
+ return {"_raw": content}
249
+
250
+ return {"_raw": content}
251
+
252
+ def call_sync(self, **input_data) -> Dict[str, Any]:
253
+ """Synchronous wrapper for call()."""
254
+ import asyncio
255
+ return asyncio.run(self.call(**input_data))
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: flatagents
3
+ Version: 0.1.0
4
+ Summary: A lightweight framework for building LLM-powered agents with pluggable backends.
5
+ Project-URL: Homepage, https://github.com/memgrafter/flatagents
6
+ Project-URL: Repository, https://github.com/memgrafter/flatagents
7
+ Project-URL: Issues, https://github.com/memgrafter/flatagents/issues
8
+ Author-email: memgrafter@gmail.com
9
+ License-Expression: MIT
10
+ Keywords: agents,ai,aisuite,framework,litellm,llm
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: jinja2
22
+ Requires-Dist: pyyaml
23
+ Provides-Extra: aisuite
24
+ Requires-Dist: aisuite; extra == 'aisuite'
25
+ Provides-Extra: all
26
+ Requires-Dist: aisuite; extra == 'all'
27
+ Requires-Dist: litellm; extra == 'all'
28
+ Provides-Extra: litellm
29
+ Requires-Dist: litellm; extra == 'litellm'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # FlatAgents Python SDK
33
+
34
+ Reference implementation of the [FlatAgents spec](../../README.md).
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ pip install flatagents[litellm] # LiteLLM backend
40
+ pip install flatagents[aisuite] # AISuite backend
41
+ pip install flatagents[all] # Both backends
42
+ ```
43
+
44
+ ## Usage
45
+
46
+ ### From YAML/JSON Config
47
+
48
+ ```python
49
+ from flatagents import DeclarativeAgent
50
+
51
+ agent = DeclarativeAgent(config_file="agent.yaml")
52
+ result = await agent.execute(input={"question": "What is 2+2?"})
53
+ ```
54
+
55
+ ### From Dictionary
56
+
57
+ ```python
58
+ from flatagents import DeclarativeAgent
59
+
60
+ config = {
61
+ "spec": "declarative_agent",
62
+ "spec_version": "0.4.0",
63
+ "data": {
64
+ "name": "calculator",
65
+ "model": {"provider": "openai", "name": "gpt-4"},
66
+ "system": "You are a calculator.",
67
+ "user": "Calculate: {{ input.expression }}",
68
+ "output": {
69
+ "result": {"type": "float", "description": "The calculated result"}
70
+ }
71
+ }
72
+ }
73
+
74
+ agent = DeclarativeAgent(config_dict=config)
75
+ result = await agent.execute(input={"expression": "2 + 2"})
76
+ ```
77
+
78
+ ### Custom Agent (Subclass FlatAgent)
79
+
80
+ ```python
81
+ from flatagents import FlatAgent
82
+
83
+ class MyAgent(FlatAgent):
84
+ def create_initial_state(self):
85
+ return {"count": 0}
86
+
87
+ def generate_step_prompt(self, state):
88
+ return f"Count is {state['count']}. What's next?"
89
+
90
+ def update_state(self, state, result):
91
+ return {**state, "count": int(result)}
92
+
93
+ def is_solved(self, state):
94
+ return state["count"] >= 10
95
+
96
+ agent = MyAgent(config_file="config.yaml")
97
+ trace = await agent.execute()
98
+ ```
99
+
100
+ ## LLM Backends
101
+
102
+ Two backends available:
103
+
104
+ ```python
105
+ from flatagents import LiteLLMBackend, AISuiteBackend
106
+
107
+ # LiteLLM - model format: provider/model
108
+ backend = LiteLLMBackend(model="openai/gpt-4o", temperature=0.7)
109
+
110
+ # AISuite - model format: provider:model
111
+ backend = AISuiteBackend(model="openai:gpt-4o", temperature=0.7)
112
+ ```
113
+
114
+ ### Custom Backend
115
+
116
+ Implement the `LLMBackend` protocol:
117
+
118
+ ```python
119
+ class MyBackend:
120
+ total_cost: float = 0.0
121
+ total_api_calls: int = 0
122
+
123
+ async def call(self, messages: list, **kwargs) -> str:
124
+ self.total_api_calls += 1
125
+ return "response"
126
+
127
+ agent = MyAgent(backend=MyBackend())
128
+ ```
129
+
130
+ ## License
131
+
132
+ MIT License - see [LICENSE](../../LICENSE) for details.
@@ -0,0 +1,6 @@
1
+ flatagents/__init__.py,sha256=iWJO0hDqTV7Mc5oGWbj_TzuEqkk5h289LFyEMkABdqk,569
2
+ flatagents/baseagent.py,sha256=DAMMLeJ5eBVIuxUVUaHqgCmm0ro7j5r2NzQDFZ_iH6Q,26973
3
+ flatagents/declarativeagent.py,sha256=QB-VfY1q7uLPddAsykGaRYWJF2Z7vxpHDpxvB4KjO9E,8352
4
+ flatagents-0.1.0.dist-info/METADATA,sha256=1h_vmezUZ5TAHHlGtKWf1bLPrReWfuiy-gU0KQLiL18,3522
5
+ flatagents-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
6
+ flatagents-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any