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,213 @@
1
+ import json
2
+ import fcntl
3
+ import asyncio
4
+ import logging
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any, Dict, Optional, List
7
+ from dataclasses import dataclass, asdict, field
8
+ from pathlib import Path
9
+ from datetime import datetime, timezone
10
+ import aiofiles
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ @dataclass
15
+ class MachineSnapshot:
16
+ """Wire format for machine checkpoints."""
17
+ execution_id: str
18
+ machine_name: str
19
+ spec_version: str
20
+ current_state: str
21
+ context: Dict[str, Any]
22
+ step: int
23
+ created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
24
+ event: Optional[str] = None # The event that triggered this checkpoint (machine_start, etc)
25
+ output: Optional[Dict[str, Any]] = None # Output if captured at state_exit/machine_end
26
+ total_api_calls: Optional[int] = None # Cumulative API calls
27
+ total_cost: Optional[float] = None # Cumulative cost
28
+ # Lineage (v0.4.0)
29
+ parent_execution_id: Optional[str] = None # ID of launcher machine if this was launched
30
+ # Outbox pattern (v0.4.0)
31
+ pending_launches: Optional[List[Dict[str, Any]]] = None # LaunchIntent dicts awaiting completion
32
+
33
+ class PersistenceBackend(ABC):
34
+ """Abstract storage backend for checkpoints."""
35
+
36
+ @abstractmethod
37
+ async def save(self, key: str, value: bytes) -> None:
38
+ pass
39
+
40
+ @abstractmethod
41
+ async def load(self, key: str) -> Optional[bytes]:
42
+ pass
43
+
44
+ @abstractmethod
45
+ async def delete(self, key: str) -> None:
46
+ pass
47
+
48
+ class LocalFileBackend(PersistenceBackend):
49
+ """File-based persistence backend."""
50
+
51
+ def __init__(self, base_dir: str = ".checkpoints"):
52
+ self.base_dir = Path(base_dir)
53
+ self.base_dir.mkdir(parents=True, exist_ok=True)
54
+
55
+ def _validate_key(self, key: str) -> None:
56
+ """Validate key to prevent path traversal attacks."""
57
+ if '..' in key or key.startswith('/'):
58
+ raise ValueError(f"Invalid checkpoint key: {key}")
59
+
60
+ async def save(self, key: str, value: bytes) -> None:
61
+ self._validate_key(key)
62
+ path = self.base_dir / key
63
+ path.parent.mkdir(parents=True, exist_ok=True)
64
+
65
+ # Write to temp file first for atomicity
66
+ temp_path = path.parent / f".{path.name}.tmp"
67
+ async with aiofiles.open(temp_path, 'wb') as f:
68
+ await f.write(value)
69
+
70
+ # Atomic rename (safe on POSIX and Windows)
71
+ temp_path.replace(path)
72
+
73
+ async def load(self, key: str) -> Optional[bytes]:
74
+ self._validate_key(key)
75
+ path = self.base_dir / key
76
+ if not path.exists():
77
+ return None
78
+ async with aiofiles.open(path, 'rb') as f:
79
+ return await f.read()
80
+
81
+ async def delete(self, key: str) -> None:
82
+ self._validate_key(key)
83
+ path = self.base_dir / key
84
+ path.unlink(missing_ok=True)
85
+
86
+ class MemoryBackend(PersistenceBackend):
87
+ """In-memory backend for ephemeral executions."""
88
+
89
+ def __init__(self):
90
+ self._store: Dict[str, bytes] = {}
91
+
92
+ async def save(self, key: str, value: bytes) -> None:
93
+ self._store[key] = value
94
+
95
+ async def load(self, key: str) -> Optional[bytes]:
96
+ return self._store.get(key)
97
+
98
+ async def delete(self, key: str) -> None:
99
+ self._store.pop(key, None)
100
+
101
+ class CheckpointManager:
102
+ """Manages saving and loading machine snapshots."""
103
+
104
+ def __init__(self, backend: PersistenceBackend, execution_id: str):
105
+ self.backend = backend
106
+ self.execution_id = execution_id
107
+
108
+ def _snapshot_key(self, event: str, step: int) -> str:
109
+ """Generate key for specific snapshot."""
110
+ return f"{self.execution_id}/step_{step:06d}_{event}.json"
111
+
112
+ def _latest_pointer_key(self) -> str:
113
+ """Key that points to the latest snapshot."""
114
+ return f"{self.execution_id}/latest"
115
+
116
+ def _safe_serialize_value(self, value: Any, path: str, non_serializable: List[str]) -> Any:
117
+ """Recursively serialize a value, converting non-JSON types to strings."""
118
+ if isinstance(value, dict):
119
+ result = {}
120
+ for k, v in value.items():
121
+ try:
122
+ json.dumps({k: v})
123
+ result[k] = v
124
+ except (TypeError, OverflowError):
125
+ result[k] = self._safe_serialize_value(v, f"{path}.{k}", non_serializable)
126
+ return result
127
+ elif isinstance(value, list):
128
+ result = []
129
+ for i, item in enumerate(value):
130
+ try:
131
+ json.dumps(item)
132
+ result.append(item)
133
+ except (TypeError, OverflowError):
134
+ result.append(self._safe_serialize_value(item, f"{path}[{i}]", non_serializable))
135
+ return result
136
+ else:
137
+ try:
138
+ json.dumps(value)
139
+ return value
140
+ except (TypeError, OverflowError):
141
+ original_type = type(value).__name__
142
+ non_serializable.append(f"{path} ({original_type})")
143
+ return str(value)
144
+
145
+ def _safe_serialize(self, data: Dict[str, Any]) -> str:
146
+ """Safely serialize data to JSON, handling non-serializable objects."""
147
+ try:
148
+ return json.dumps(data)
149
+ except (TypeError, OverflowError):
150
+ # Identify and warn about specific non-serializable fields
151
+ safe_data = {}
152
+ non_serializable_fields: List[str] = []
153
+
154
+ for k, v in data.items():
155
+ if isinstance(v, dict):
156
+ # Recursively check nested dicts
157
+ try:
158
+ json.dumps(v)
159
+ safe_data[k] = v
160
+ except (TypeError, OverflowError):
161
+ safe_data[k] = self._safe_serialize_value(v, k, non_serializable_fields)
162
+ elif isinstance(v, list):
163
+ # Recursively check lists
164
+ try:
165
+ json.dumps(v)
166
+ safe_data[k] = v
167
+ except (TypeError, OverflowError):
168
+ safe_data[k] = self._safe_serialize_value(v, k, non_serializable_fields)
169
+ else:
170
+ try:
171
+ json.dumps({k: v})
172
+ safe_data[k] = v
173
+ except (TypeError, OverflowError):
174
+ original_type = type(v).__name__
175
+ safe_data[k] = str(v)
176
+ non_serializable_fields.append(f"{k} ({original_type})")
177
+
178
+ if non_serializable_fields:
179
+ logger.warning(
180
+ f"Context fields not JSON serializable, converted to strings: "
181
+ f"{', '.join(non_serializable_fields)}. "
182
+ f"These values will lose type information on restore."
183
+ )
184
+
185
+ return json.dumps(safe_data)
186
+
187
+ async def save_checkpoint(self, snapshot: MachineSnapshot) -> None:
188
+ """Save a snapshot and update latest pointer."""
189
+ data = asdict(snapshot)
190
+ json_bytes = self._safe_serialize(data).encode('utf-8')
191
+
192
+ # Save the immutable snapshot
193
+ key = self._snapshot_key(snapshot.event or "unknown", snapshot.step)
194
+ await self.backend.save(key, json_bytes)
195
+
196
+ # Update pointer to this key
197
+ await self.backend.save(self._latest_pointer_key(), key.encode('utf-8'))
198
+
199
+ async def load_latest(self) -> Optional[MachineSnapshot]:
200
+ """Load the latest snapshot."""
201
+ # Get pointer
202
+ ptr_bytes = await self.backend.load(self._latest_pointer_key())
203
+ if not ptr_bytes:
204
+ return None
205
+
206
+ # Get snapshot
207
+ key = ptr_bytes.decode('utf-8')
208
+ data_bytes = await self.backend.load(key)
209
+ if not data_bytes:
210
+ return None
211
+
212
+ data = json.loads(data_bytes.decode('utf-8'))
213
+ return MachineSnapshot(**data)
flatmachines/run.py ADDED
@@ -0,0 +1,117 @@
1
+ """
2
+ FlatMachines CLI Runner.
3
+
4
+ Entry point for running machines via subprocess:
5
+ python -m flatmachines.run --config machine.yml --input '{"key": "value"}'
6
+
7
+ Used by SubprocessInvoker for fire-and-forget machine execution.
8
+ """
9
+
10
+ import argparse
11
+ import asyncio
12
+ import json
13
+ import logging
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ logging.basicConfig(
18
+ level=logging.INFO,
19
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
20
+ )
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def main():
26
+ parser = argparse.ArgumentParser(
27
+ description="Run a FlatMachine from the command line",
28
+ prog="python -m flatmachines.run"
29
+ )
30
+ parser.add_argument(
31
+ "--config", "-c",
32
+ required=True,
33
+ help="Path to machine config file (YAML or JSON)"
34
+ )
35
+ parser.add_argument(
36
+ "--input", "-i",
37
+ default="{}",
38
+ help="JSON string of input data"
39
+ )
40
+ parser.add_argument(
41
+ "--execution-id", "-e",
42
+ help="Predetermined execution ID"
43
+ )
44
+ parser.add_argument(
45
+ "--parent-id", "-p",
46
+ help="Parent execution ID for lineage tracking"
47
+ )
48
+ parser.add_argument(
49
+ "--max-steps",
50
+ type=int,
51
+ default=1000,
52
+ help="Maximum execution steps (default: 1000)"
53
+ )
54
+ parser.add_argument(
55
+ "--verbose", "-v",
56
+ action="store_true",
57
+ help="Enable verbose logging"
58
+ )
59
+
60
+ args = parser.parse_args()
61
+
62
+ if args.verbose:
63
+ logging.getLogger().setLevel(logging.DEBUG)
64
+
65
+ # Parse input
66
+ try:
67
+ input_data = json.loads(args.input)
68
+ except json.JSONDecodeError as e:
69
+ logger.error(f"Invalid JSON input: {e}")
70
+ sys.exit(1)
71
+
72
+ # Validate config path
73
+ config_path = Path(args.config)
74
+ if not config_path.exists():
75
+ logger.error(f"Config file not found: {config_path}")
76
+ sys.exit(1)
77
+
78
+ # Import here to avoid circular imports
79
+ from .flatmachine import FlatMachine
80
+
81
+ # Build machine with optional execution IDs
82
+ machine_kwargs = {
83
+ "config_file": str(config_path),
84
+ }
85
+
86
+ if args.execution_id:
87
+ machine_kwargs["_execution_id"] = args.execution_id
88
+ if args.parent_id:
89
+ machine_kwargs["_parent_execution_id"] = args.parent_id
90
+
91
+ try:
92
+ machine = FlatMachine(**machine_kwargs)
93
+
94
+ # Run the machine
95
+ result = asyncio.run(
96
+ machine.execute(
97
+ input=input_data,
98
+ max_steps=args.max_steps
99
+ )
100
+ )
101
+
102
+ # Output result as JSON
103
+ print(json.dumps(result, indent=2, default=str))
104
+
105
+ # Log stats
106
+ logger.info(
107
+ f"Execution complete: {machine.total_api_calls} API calls, "
108
+ f"${machine.total_cost:.4f} cost"
109
+ )
110
+
111
+ except Exception as e:
112
+ logger.exception(f"Execution failed: {e}")
113
+ sys.exit(1)
114
+
115
+
116
+ if __name__ == "__main__":
117
+ main()
flatmachines/utils.py ADDED
@@ -0,0 +1,166 @@
1
+ """Utility functions for flatmachines."""
2
+
3
+ import re
4
+ from types import SimpleNamespace
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from .monitoring import get_logger
8
+
9
+ logger = get_logger(__name__)
10
+
11
+
12
+ def check_spec_version(config_version: Optional[str], sdk_version: str) -> str:
13
+ """
14
+ Check spec version compatibility and warn if mismatched.
15
+
16
+ Args:
17
+ config_version: Version from config file (may be None)
18
+ sdk_version: Current SDK version (__version__)
19
+
20
+ Returns:
21
+ The effective spec version (config_version or sdk_version as default)
22
+ """
23
+ effective_version = config_version or sdk_version
24
+ sdk_major_minor = '.'.join(sdk_version.split('.')[:2])
25
+ config_major_minor = '.'.join(effective_version.split('.')[:2])
26
+
27
+ if config_major_minor != sdk_major_minor:
28
+ logger.warning(
29
+ f"Config version {effective_version} may not be fully supported. "
30
+ f"Current SDK version is {sdk_version}."
31
+ )
32
+
33
+ return effective_version
34
+
35
+
36
+ def strip_markdown_json(content: str) -> str:
37
+ """
38
+ Extract JSON from potentially wrapped response content.
39
+
40
+ LLMs sometimes wrap JSON responses in markdown code blocks like:
41
+ ```json
42
+ {"key": "value"}
43
+ ```
44
+
45
+ Or include explanatory text before/after the JSON:
46
+ "Here is the result:
47
+ ```json
48
+ {"key": "value"}
49
+ ```"
50
+
51
+ This function extracts the JSON so json.loads() can parse it.
52
+
53
+ Args:
54
+ content: Raw string that may contain markdown-wrapped JSON
55
+
56
+ Returns:
57
+ Extracted JSON string
58
+ """
59
+ if not content:
60
+ return content
61
+
62
+ text = content.strip()
63
+
64
+ # First, try to find JSON in a markdown code fence (anywhere in content)
65
+ fence_pattern = r'```(?:json|JSON)?\s*\n?([\s\S]*?)\n?```'
66
+ match = re.search(fence_pattern, text)
67
+ if match:
68
+ return match.group(1).strip()
69
+
70
+ # If no fence, try to find a raw JSON object or array
71
+ json_pattern = r'(\{[\s\S]*\}|\[[\s\S]*\])'
72
+ match = re.search(json_pattern, text)
73
+ if match:
74
+ return match.group(1)
75
+
76
+ return text
77
+
78
+
79
+ def _get_attr(obj: Any, key: str, default: Any = None) -> Any:
80
+ if obj is None:
81
+ return default
82
+ if hasattr(obj, key):
83
+ return getattr(obj, key)
84
+ if isinstance(obj, dict):
85
+ return obj.get(key, default)
86
+ return default
87
+
88
+
89
+ def _coerce_usage(usage: Any) -> Any:
90
+ if usage is None:
91
+ return None
92
+ if isinstance(usage, dict):
93
+ return SimpleNamespace(**usage)
94
+ return usage
95
+
96
+
97
+ async def consume_litellm_stream(stream: Any) -> Any:
98
+ content_parts: List[str] = []
99
+ tool_calls: Dict[int, Dict[str, Any]] = {}
100
+ usage_data: Any = None
101
+ finish_reason: Optional[str] = None
102
+
103
+ async for chunk in stream:
104
+ if chunk is None:
105
+ continue
106
+ usage = _get_attr(chunk, "usage")
107
+ if usage:
108
+ usage_data = usage
109
+
110
+ choices = _get_attr(chunk, "choices")
111
+ if not choices:
112
+ continue
113
+ choice0 = choices[0]
114
+ finish = _get_attr(choice0, "finish_reason")
115
+ if finish:
116
+ finish_reason = finish
117
+
118
+ delta = _get_attr(choice0, "delta")
119
+ if not delta:
120
+ continue
121
+
122
+ content_piece = _get_attr(delta, "content")
123
+ if content_piece:
124
+ content_parts.append(content_piece)
125
+
126
+ delta_tool_calls = _get_attr(delta, "tool_calls")
127
+ if delta_tool_calls:
128
+ for tc in delta_tool_calls:
129
+ index = _get_attr(tc, "index", 0)
130
+ entry = tool_calls.setdefault(index, {"id": None, "name": None, "arguments": []})
131
+ tc_id = _get_attr(tc, "id")
132
+ if tc_id:
133
+ entry["id"] = tc_id
134
+ function = _get_attr(tc, "function")
135
+ if function:
136
+ name = _get_attr(function, "name")
137
+ if name:
138
+ entry["name"] = name
139
+ arguments = _get_attr(function, "arguments")
140
+ if arguments:
141
+ entry["arguments"].append(arguments)
142
+
143
+ content = "".join(content_parts)
144
+ message_fields: Dict[str, Any] = {"content": content}
145
+ if tool_calls:
146
+ tool_call_objs = []
147
+ for index in sorted(tool_calls):
148
+ entry = tool_calls[index]
149
+ tool_call_objs.append(SimpleNamespace(
150
+ id=entry["id"],
151
+ function=SimpleNamespace(
152
+ name=entry["name"],
153
+ arguments="".join(entry["arguments"]) if entry["arguments"] else ""
154
+ )
155
+ ))
156
+ message_fields["tool_calls"] = tool_call_objs
157
+
158
+ message = SimpleNamespace(**message_fields)
159
+ choice = SimpleNamespace(message=message)
160
+ if finish_reason is not None:
161
+ choice.finish_reason = finish_reason
162
+
163
+ return SimpleNamespace(
164
+ choices=[choice],
165
+ usage=_coerce_usage(usage_data)
166
+ )
@@ -0,0 +1,79 @@
1
+ """
2
+ Schema validation for flatmachine configurations.
3
+
4
+ Uses JSON Schema validation against the bundled schema.
5
+ Validation errors are warnings by default to avoid breaking user configs.
6
+ """
7
+
8
+ import json
9
+ import warnings
10
+ from importlib.resources import files
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ _ASSETS = files("flatmachines.assets")
14
+
15
+
16
+ class ValidationWarning(UserWarning):
17
+ """Warning for schema validation issues."""
18
+
19
+
20
+
21
+ def _load_schema(filename: str) -> Optional[Dict[str, Any]]:
22
+ try:
23
+ content = (_ASSETS / filename).read_text()
24
+ return json.loads(content)
25
+ except FileNotFoundError:
26
+ return None
27
+
28
+
29
+ def _validate_with_jsonschema(config: Dict[str, Any], schema: Dict[str, Any]) -> List[str]:
30
+ try:
31
+ import jsonschema
32
+ except ImportError:
33
+ return []
34
+
35
+ errors: List[str] = []
36
+ validator = jsonschema.Draft7Validator(schema)
37
+ for error in validator.iter_errors(config):
38
+ path = ".".join(str(p) for p in error.absolute_path) or "(root)"
39
+ errors.append(f"{path}: {error.message}")
40
+ return errors
41
+
42
+
43
+ def validate_flatmachine_config(
44
+ config: Dict[str, Any],
45
+ warn: bool = True,
46
+ strict: bool = False,
47
+ ) -> List[str]:
48
+ """Validate a flatmachine configuration against the schema."""
49
+ schema = _load_schema("flatmachine.schema.json")
50
+ if schema is None:
51
+ return []
52
+
53
+ errors = _validate_with_jsonschema(config, schema)
54
+
55
+ if errors:
56
+ if strict:
57
+ raise ValueError(
58
+ "Flatmachine config validation failed:\n"
59
+ + "\n".join(f" - {e}" for e in errors)
60
+ )
61
+ if warn:
62
+ warnings.warn(
63
+ "Flatmachine config has validation issues:\n"
64
+ + "\n".join(f" - {e}" for e in errors),
65
+ ValidationWarning,
66
+ stacklevel=3,
67
+ )
68
+
69
+ return errors
70
+
71
+
72
+ def get_flatmachine_schema() -> Optional[Dict[str, Any]]:
73
+ """Get the bundled flatmachine JSON schema."""
74
+ return _load_schema("flatmachine.schema.json")
75
+
76
+
77
+ def get_asset(filename: str) -> str:
78
+ """Get the contents of a bundled asset file."""
79
+ return (_ASSETS / filename).read_text()