flatmachines 1.0.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.
Files changed (41) hide show
  1. flatmachines/__init__.py +136 -0
  2. flatmachines/actions.py +408 -0
  3. flatmachines/adapters/__init__.py +38 -0
  4. flatmachines/adapters/flatagent.py +86 -0
  5. flatmachines/adapters/pi_agent_bridge.py +127 -0
  6. flatmachines/adapters/pi_agent_runner.mjs +99 -0
  7. flatmachines/adapters/smolagents.py +125 -0
  8. flatmachines/agents.py +144 -0
  9. flatmachines/assets/MACHINES.md +141 -0
  10. flatmachines/assets/README.md +11 -0
  11. flatmachines/assets/__init__.py +0 -0
  12. flatmachines/assets/flatagent.d.ts +219 -0
  13. flatmachines/assets/flatagent.schema.json +271 -0
  14. flatmachines/assets/flatagent.slim.d.ts +58 -0
  15. flatmachines/assets/flatagents-runtime.d.ts +523 -0
  16. flatmachines/assets/flatagents-runtime.schema.json +281 -0
  17. flatmachines/assets/flatagents-runtime.slim.d.ts +187 -0
  18. flatmachines/assets/flatmachine.d.ts +403 -0
  19. flatmachines/assets/flatmachine.schema.json +620 -0
  20. flatmachines/assets/flatmachine.slim.d.ts +106 -0
  21. flatmachines/assets/profiles.d.ts +140 -0
  22. flatmachines/assets/profiles.schema.json +93 -0
  23. flatmachines/assets/profiles.slim.d.ts +26 -0
  24. flatmachines/backends.py +222 -0
  25. flatmachines/distributed.py +835 -0
  26. flatmachines/distributed_hooks.py +351 -0
  27. flatmachines/execution.py +638 -0
  28. flatmachines/expressions/__init__.py +60 -0
  29. flatmachines/expressions/cel.py +101 -0
  30. flatmachines/expressions/simple.py +166 -0
  31. flatmachines/flatmachine.py +1263 -0
  32. flatmachines/hooks.py +381 -0
  33. flatmachines/locking.py +69 -0
  34. flatmachines/monitoring.py +505 -0
  35. flatmachines/persistence.py +213 -0
  36. flatmachines/run.py +117 -0
  37. flatmachines/utils.py +166 -0
  38. flatmachines/validation.py +79 -0
  39. flatmachines-1.0.0.dist-info/METADATA +390 -0
  40. flatmachines-1.0.0.dist-info/RECORD +41 -0
  41. flatmachines-1.0.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,505 @@
1
+ """
2
+ Monitoring and observability utilities for FlatAgents.
3
+
4
+ Provides standardized logging configuration and OpenTelemetry-based metrics.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ import os
10
+ import sys
11
+ import time
12
+ from contextlib import contextmanager
13
+ from dataclasses import is_dataclass, fields
14
+ from enum import Enum
15
+ from typing import Any, Dict, Optional
16
+
17
+ # ─────────────────────────────────────────────────────────────────────────────
18
+ # Logging Configuration
19
+ # ─────────────────────────────────────────────────────────────────────────────
20
+
21
+ # Global logger registry
22
+ _loggers: Dict[str, logging.Logger] = {}
23
+ _logging_configured = False
24
+
25
+
26
+ class JSONFormatter(logging.Formatter):
27
+ """
28
+ Production-ready JSON log formatter.
29
+
30
+ Properly escapes message content and includes exception info.
31
+ """
32
+
33
+ def format(self, record: logging.LogRecord) -> str:
34
+ log_entry = {
35
+ "time": self.formatTime(record, '%Y-%m-%dT%H:%M:%S'),
36
+ "name": record.name,
37
+ "level": record.levelname,
38
+ "message": record.getMessage(),
39
+ }
40
+
41
+ # Add exception info if present
42
+ if record.exc_info:
43
+ log_entry["exception"] = self.formatException(record.exc_info)
44
+
45
+ # Add extra fields if any
46
+ if hasattr(record, 'extra') and record.extra:
47
+ log_entry["extra"] = record.extra
48
+
49
+ return json.dumps(log_entry, ensure_ascii=False)
50
+
51
+
52
+ def setup_logging(
53
+ level: Optional[str] = None,
54
+ format: Optional[str] = None,
55
+ force: bool = False
56
+ ) -> None:
57
+ """
58
+ Configure SDK-wide logging with sensible defaults.
59
+
60
+ Args:
61
+ level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
62
+ Defaults to FLATAGENTS_LOG_LEVEL env var or INFO.
63
+ format: Log format style. Options:
64
+ - 'standard': Human-readable with timestamps
65
+ - 'json': Structured JSON logging
66
+ - 'simple': Just level and message
67
+ - Custom format string
68
+ Defaults to FLATAGENTS_LOG_FORMAT env var or 'standard'.
69
+ force: If True, reconfigure even if already configured.
70
+
71
+ Environment Variables:
72
+ FLATAGENTS_LOG_DIR: If set, logs will be written to files in this directory.
73
+ Each process gets a unique log file with PID and timestamp.
74
+ FLATAGENTS_LOG_LEVEL: Default log level (DEBUG, INFO, etc.)
75
+ FLATAGENTS_LOG_FORMAT: Log format style
76
+
77
+ Example:
78
+ >>> from flatagents import setup_logging
79
+ >>> setup_logging(level='DEBUG')
80
+ >>> # Or via environment:
81
+ >>> # export FLATAGENTS_LOG_LEVEL=DEBUG
82
+ >>> # export FLATAGENTS_LOG_FORMAT=json
83
+ >>> # export FLATAGENTS_LOG_DIR=/path/to/logs
84
+ """
85
+ global _logging_configured
86
+
87
+ if _logging_configured and not force:
88
+ return
89
+
90
+ # Determine log level
91
+ if level is None:
92
+ level = os.getenv('FLATAGENTS_LOG_LEVEL', 'INFO').upper()
93
+
94
+ log_level = getattr(logging, level.upper(), logging.INFO)
95
+
96
+ # Determine format
97
+ if format is None:
98
+ format = os.getenv('FLATAGENTS_LOG_FORMAT', 'standard')
99
+
100
+ # Create appropriate formatter
101
+ if format == 'json':
102
+ formatter = JSONFormatter()
103
+ elif format == 'simple':
104
+ log_format = '%(levelname)s - %(message)s'
105
+ formatter = logging.Formatter(log_format, datefmt='%Y-%m-%d %H:%M:%S')
106
+ elif format == 'standard':
107
+ log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
108
+ formatter = logging.Formatter(log_format, datefmt='%Y-%m-%d %H:%M:%S')
109
+ else:
110
+ # Custom format string
111
+ formatter = logging.Formatter(format, datefmt='%Y-%m-%d %H:%M:%S')
112
+
113
+ # Get root logger for flatagents
114
+ root_logger = logging.getLogger()
115
+ root_logger.setLevel(log_level)
116
+
117
+ # Clear existing handlers if force
118
+ if force:
119
+ root_logger.handlers.clear()
120
+
121
+ # Always add stdout handler
122
+ stdout_handler = logging.StreamHandler(sys.stdout)
123
+ stdout_handler.setFormatter(formatter)
124
+ root_logger.addHandler(stdout_handler)
125
+
126
+ # Add file handler if FLATAGENTS_LOG_DIR is set
127
+ log_dir = os.getenv('FLATAGENTS_LOG_DIR')
128
+ if log_dir:
129
+ from datetime import datetime
130
+
131
+ # Create log directory if it doesn't exist
132
+ os.makedirs(log_dir, exist_ok=True)
133
+
134
+ # Create unique log file per process (PID + timestamp)
135
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
136
+ pid = os.getpid()
137
+ log_file = os.path.join(log_dir, f"flatagents_{pid}_{timestamp}.log")
138
+
139
+ file_handler = logging.FileHandler(log_file, mode='a', encoding='utf-8')
140
+ file_handler.setFormatter(formatter)
141
+ root_logger.addHandler(file_handler)
142
+
143
+ # Log where we're writing to
144
+ root_logger.info(f"Logging to file: {log_file}")
145
+
146
+ _logging_configured = True
147
+
148
+
149
+ def get_logger(name: str) -> logging.Logger:
150
+ """
151
+ Get a properly configured logger for a module.
152
+
153
+ Args:
154
+ name: Logger name (typically __name__ from the calling module)
155
+
156
+ Returns:
157
+ Configured logger instance
158
+
159
+ Example:
160
+ >>> from flatagents import get_logger
161
+ >>> logger = get_logger(__name__)
162
+ >>> logger.info("Agent started")
163
+ """
164
+ if name not in _loggers:
165
+ # Ensure logging is configured
166
+ if not _logging_configured:
167
+ setup_logging()
168
+
169
+ logger = logging.getLogger(name)
170
+ _loggers[name] = logger
171
+
172
+ return _loggers[name]
173
+
174
+
175
+ # ─────────────────────────────────────────────────────────────────────────────
176
+ # Metrics with OpenTelemetry
177
+ # ─────────────────────────────────────────────────────────────────────────────
178
+
179
+ # Lazy imports for OpenTelemetry (optional dependency)
180
+ _otel_available = False
181
+ _meter = None
182
+ _metrics_enabled = False
183
+ _metrics_init_attempted = False # Prevent repeated init attempts
184
+ _cached_histograms: Dict[str, Any] = {} # Cache for histogram instruments
185
+
186
+ try:
187
+ from opentelemetry import metrics
188
+ from opentelemetry.sdk.metrics import MeterProvider
189
+ from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
190
+ from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
191
+ from opentelemetry.sdk.resources import Resource, SERVICE_NAME
192
+ _otel_available = True
193
+ except ImportError:
194
+ _otel_available = False
195
+
196
+
197
+ def _init_metrics() -> None:
198
+ """Initialize OpenTelemetry metrics if enabled and available."""
199
+ global _meter, _metrics_enabled, _metrics_init_attempted
200
+
201
+ _metrics_init_attempted = True
202
+
203
+ # Check if metrics should be enabled (default: true)
204
+ enabled = os.getenv('FLATAGENTS_METRICS_ENABLED', 'true').lower() not in ('false', '0', 'no')
205
+
206
+ if not enabled:
207
+ _metrics_enabled = False
208
+ return
209
+
210
+ if not _otel_available:
211
+ logger = get_logger(__name__)
212
+ logger.warning(
213
+ "Metrics enabled but OpenTelemetry not available. "
214
+ "Install with: pip install opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp"
215
+ )
216
+ _metrics_enabled = False
217
+ return
218
+
219
+ try:
220
+ # Get service name from environment or use default
221
+ service_name = os.getenv('OTEL_SERVICE_NAME', 'flatagents')
222
+
223
+ # Create resource with service name
224
+ resource = Resource(attributes={
225
+ SERVICE_NAME: service_name
226
+ })
227
+
228
+ # Check which exporter to use (default to console so metrics are visible)
229
+ exporter_type = os.getenv('OTEL_METRICS_EXPORTER', 'console').lower()
230
+
231
+ if exporter_type == 'console':
232
+ # Use console exporter for testing/debugging
233
+ try:
234
+ from opentelemetry.sdk.metrics.export import (
235
+ ConsoleMetricExporter,
236
+ MetricExporter,
237
+ MetricExportResult,
238
+ )
239
+
240
+ def _to_jsonable(value):
241
+ if value is None or isinstance(value, (str, int, float, bool)):
242
+ return value
243
+ if isinstance(value, Enum):
244
+ return value.value
245
+ if is_dataclass(value):
246
+ return {f.name: _to_jsonable(getattr(value, f.name)) for f in fields(value)}
247
+ if isinstance(value, dict):
248
+ return {str(k): _to_jsonable(v) for k, v in value.items()}
249
+ if isinstance(value, (list, tuple, set)):
250
+ return [_to_jsonable(v) for v in value]
251
+ if hasattr(value, "__dict__"):
252
+ return {str(k): _to_jsonable(v) for k, v in vars(value).items()}
253
+ return str(value)
254
+
255
+ def _compact_metrics_formatter(metrics_data) -> str:
256
+ try:
257
+ from opentelemetry.sdk.metrics.export import MetricsJSONEncoder
258
+ line = json.dumps(metrics_data, cls=MetricsJSONEncoder, separators=(",", ":"))
259
+ except Exception:
260
+ try:
261
+ from opentelemetry.sdk.metrics.export import metrics_to_json
262
+ compact = metrics_to_json(metrics_data)
263
+ if isinstance(compact, str):
264
+ line = compact.replace("\n", "")
265
+ else:
266
+ line = json.dumps(compact, separators=(",", ":"))
267
+ except Exception:
268
+ line = json.dumps(_to_jsonable(metrics_data), separators=(",", ":"))
269
+ if not line.endswith("\n"):
270
+ line = f"{line}\n"
271
+ return line
272
+
273
+ class _CompactConsoleMetricExporter(MetricExporter):
274
+ def __init__(self, preferred_temporality=None, preferred_aggregation=None):
275
+ super().__init__(
276
+ preferred_temporality=preferred_temporality,
277
+ preferred_aggregation=preferred_aggregation,
278
+ )
279
+
280
+ def export(self, metrics_data):
281
+ try:
282
+ line = _compact_metrics_formatter(metrics_data)
283
+ sys.stdout.write(line)
284
+ sys.stdout.flush()
285
+ return MetricExportResult.SUCCESS
286
+ except Exception:
287
+ return MetricExportResult.FAILURE
288
+
289
+ def shutdown(self, timeout_millis: int = 30000):
290
+ return None
291
+
292
+ def force_flush(self, timeout_millis: int = 30000):
293
+ return True
294
+
295
+ try:
296
+ import inspect
297
+ if "formatter" in inspect.signature(ConsoleMetricExporter).parameters:
298
+ exporter = ConsoleMetricExporter(formatter=_compact_metrics_formatter)
299
+ else:
300
+ exporter = _CompactConsoleMetricExporter()
301
+ except Exception:
302
+ exporter = _CompactConsoleMetricExporter()
303
+ except ImportError:
304
+ logger = get_logger(__name__)
305
+ logger.warning("Console exporter not available, falling back to OTLP")
306
+ exporter = OTLPMetricExporter(
307
+ endpoint=os.getenv('OTEL_EXPORTER_OTLP_ENDPOINT'),
308
+ )
309
+ else:
310
+ # Configure OTLP exporter (supports Datadog, Honeycomb, etc.)
311
+ exporter = OTLPMetricExporter(
312
+ endpoint=os.getenv('OTEL_EXPORTER_OTLP_ENDPOINT'),
313
+ # Headers can be set via OTEL_EXPORTER_OTLP_HEADERS env var
314
+ )
315
+
316
+ # Create meter provider with periodic export
317
+ reader = PeriodicExportingMetricReader(
318
+ exporter=exporter,
319
+ export_interval_millis=int(os.getenv('OTEL_METRIC_EXPORT_INTERVAL', '5000' if exporter_type == 'console' else '60000'))
320
+ )
321
+
322
+ provider = MeterProvider(
323
+ resource=resource,
324
+ metric_readers=[reader]
325
+ )
326
+
327
+ metrics.set_meter_provider(provider)
328
+ _meter = metrics.get_meter(__name__)
329
+ _metrics_enabled = True
330
+
331
+ logger = get_logger(__name__)
332
+ logger.info(f"OpenTelemetry metrics enabled for service: {service_name}")
333
+
334
+ except Exception as e:
335
+ logger = get_logger(__name__)
336
+ logger.warning(f"Failed to initialize OpenTelemetry metrics: {e}")
337
+ _metrics_enabled = False
338
+
339
+
340
+ def get_meter():
341
+ """
342
+ Get the OpenTelemetry meter for creating custom metrics.
343
+
344
+ Returns:
345
+ OpenTelemetry Meter instance or None if metrics disabled
346
+
347
+ Example:
348
+ >>> from flatagents import get_meter
349
+ >>> meter = get_meter()
350
+ >>> if meter:
351
+ ... counter = meter.create_counter("my_custom_metric")
352
+ ... counter.add(1, {"attribute": "value"})
353
+ """
354
+ global _meter
355
+
356
+ if not _metrics_init_attempted:
357
+ _init_metrics()
358
+
359
+ return _meter
360
+
361
+
362
+ class AgentMonitor:
363
+ """
364
+ Context manager for tracking agent execution metrics.
365
+
366
+ Automatically tracks:
367
+ - Execution duration
368
+ - Success/failure status
369
+ - Custom metrics via the metrics dict
370
+
371
+ Example:
372
+ >>> from flatagents import AgentMonitor
373
+ >>> with AgentMonitor("my-agent") as monitor:
374
+ ... # Do agent work
375
+ ... monitor.metrics["tokens"] = 1500
376
+ ... monitor.metrics["cost"] = 0.03
377
+ >>> # Metrics automatically emitted on exit
378
+ """
379
+
380
+ def __init__(self, agent_id: str, extra_attributes: Optional[Dict[str, Any]] = None):
381
+ """
382
+ Initialize the monitor.
383
+
384
+ Args:
385
+ agent_id: Identifier for this agent/operation
386
+ extra_attributes: Additional attributes to attach to all metrics
387
+ """
388
+ self.agent_id = agent_id
389
+ self.start_time = None
390
+ self.metrics: Dict[str, Any] = {}
391
+ self.extra_attributes = extra_attributes or {}
392
+ self.logger = get_logger(f"flatagents.monitor.{agent_id}")
393
+
394
+ # Get or create metric instruments
395
+ self._meter = get_meter()
396
+ if self._meter:
397
+ self._duration_histogram = self._meter.create_histogram(
398
+ "flatagents.agent.duration",
399
+ unit="ms",
400
+ description="Agent execution duration"
401
+ )
402
+ self._token_counter = self._meter.create_counter(
403
+ "flatagents.agent.tokens",
404
+ description="Tokens used by agent"
405
+ )
406
+ self._cost_counter = self._meter.create_counter(
407
+ "flatagents.agent.cost",
408
+ description="Estimated cost of agent execution"
409
+ )
410
+ self._status_counter = self._meter.create_counter(
411
+ "flatagents.agent.executions",
412
+ description="Agent execution count by status"
413
+ )
414
+
415
+ def __enter__(self):
416
+ """Start monitoring."""
417
+ self.start_time = time.time()
418
+ self.logger.debug(f"Agent {self.agent_id} started")
419
+ return self
420
+
421
+ def __exit__(self, exc_type, exc_val, exc_tb):
422
+ """Stop monitoring and emit metrics."""
423
+ duration_ms = (time.time() - self.start_time) * 1000
424
+ status = "success" if exc_type is None else "error"
425
+
426
+ # Build attributes
427
+ attributes = {
428
+ "agent_id": self.agent_id,
429
+ "status": status,
430
+ **self.extra_attributes
431
+ }
432
+
433
+ if exc_type is not None:
434
+ attributes["error_type"] = exc_type.__name__
435
+
436
+ # Log completion
437
+ self.logger.info(
438
+ f"Agent {self.agent_id} completed in {duration_ms:.2f}ms - {status}"
439
+ )
440
+
441
+ # Emit metrics if enabled
442
+ if self._meter:
443
+ self._duration_histogram.record(duration_ms, attributes)
444
+ self._status_counter.add(1, attributes)
445
+
446
+ if "tokens" in self.metrics:
447
+ self._token_counter.add(self.metrics["tokens"], attributes)
448
+
449
+ if "cost" in self.metrics:
450
+ self._cost_counter.add(self.metrics["cost"], attributes)
451
+
452
+ # Don't suppress exceptions
453
+ return False
454
+
455
+
456
+ # ─────────────────────────────────────────────────────────────────────────────
457
+ # Convenience context manager for temporary metrics
458
+ # ─────────────────────────────────────────────────────────────────────────────
459
+
460
+ @contextmanager
461
+ def track_operation(operation_name: str, **attributes):
462
+ """
463
+ Track duration of an operation.
464
+
465
+ Args:
466
+ operation_name: Name of the operation
467
+ **attributes: Additional attributes to attach
468
+
469
+ Example:
470
+ >>> from flatagents.monitoring import track_operation
471
+ >>> with track_operation("llm_call", model="gpt-4"):
472
+ ... response = await llm.call(messages)
473
+ """
474
+ meter = get_meter()
475
+ start_time = time.time()
476
+
477
+ try:
478
+ yield
479
+ status = "success"
480
+ except Exception as e:
481
+ status = "error"
482
+ attributes["error_type"] = type(e).__name__
483
+ raise
484
+ finally:
485
+ duration_ms = (time.time() - start_time) * 1000
486
+
487
+ if meter:
488
+ # Cache histogram to avoid recreating on each call
489
+ cache_key = f"flatagents.{operation_name}.duration"
490
+ if cache_key not in _cached_histograms:
491
+ _cached_histograms[cache_key] = meter.create_histogram(
492
+ cache_key,
493
+ unit="ms",
494
+ description=f"Duration of {operation_name}"
495
+ )
496
+ _cached_histograms[cache_key].record(duration_ms, {**attributes, "status": status})
497
+
498
+
499
+ __all__ = [
500
+ "setup_logging",
501
+ "get_logger",
502
+ "get_meter",
503
+ "AgentMonitor",
504
+ "track_operation",
505
+ ]