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
flatmachines/hooks.py ADDED
@@ -0,0 +1,381 @@
1
+ """
2
+ MachineHooks - Extensibility points for FlatMachine.
3
+
4
+ Hooks allow custom logic at key points in machine execution:
5
+ - Before/after state entry/exit
6
+ - Before/after agent calls
7
+ - On transitions
8
+ - On errors
9
+
10
+ Includes built-in LoggingHooks and MetricsHooks implementations.
11
+ """
12
+
13
+ import logging
14
+ import time
15
+ from abc import ABC
16
+ from typing import Any, Dict, Optional
17
+
18
+ from . import __version__
19
+ from .monitoring import get_logger
20
+
21
+ logger = get_logger(__name__)
22
+
23
+ try:
24
+ import httpx
25
+ except ImportError:
26
+ httpx = None
27
+
28
+
29
+ class MachineHooks(ABC):
30
+ """
31
+ Base class for machine hooks.
32
+
33
+ Override methods to customize machine behavior.
34
+ All methods have default implementations that pass through unchanged.
35
+
36
+ Example:
37
+ from flatmachines import get_logger
38
+ logger = get_logger(__name__)
39
+
40
+ class MyHooks(MachineHooks):
41
+ def on_state_enter(self, state_name, context):
42
+ logger.info(f"Entering state: {state_name}")
43
+ return context
44
+
45
+ machine = FlatMachine(config_file="...", hooks=MyHooks())
46
+ """
47
+
48
+ def on_machine_start(self, context: Dict[str, Any]) -> Dict[str, Any]:
49
+ """
50
+ Called when machine execution starts.
51
+
52
+ Args:
53
+ context: Initial context
54
+
55
+ Returns:
56
+ Modified context
57
+ """
58
+ return context
59
+
60
+ def on_machine_end(self, context: Dict[str, Any], final_output: Dict[str, Any]) -> Dict[str, Any]:
61
+ """
62
+ Called when machine execution ends.
63
+
64
+ Args:
65
+ context: Final context
66
+ final_output: Output from final state
67
+
68
+ Returns:
69
+ Modified final output
70
+ """
71
+ return final_output
72
+
73
+ def on_state_enter(self, state_name: str, context: Dict[str, Any]) -> Dict[str, Any]:
74
+ """
75
+ Called before executing a state.
76
+
77
+ Args:
78
+ state_name: Name of the state being entered
79
+ context: Current context
80
+
81
+ Returns:
82
+ Modified context
83
+ """
84
+ return context
85
+
86
+ def on_state_exit(
87
+ self,
88
+ state_name: str,
89
+ context: Dict[str, Any],
90
+ output: Optional[Dict[str, Any]]
91
+ ) -> Optional[Dict[str, Any]]:
92
+ """
93
+ Called after executing a state.
94
+
95
+ Args:
96
+ state_name: Name of the state that was executed
97
+ context: Current context
98
+ output: Output from the state (agent output or None)
99
+
100
+ Returns:
101
+ Modified output
102
+ """
103
+ return output
104
+
105
+ def on_transition(
106
+ self,
107
+ from_state: str,
108
+ to_state: str,
109
+ context: Dict[str, Any]
110
+ ) -> str:
111
+ """
112
+ Called when transitioning between states.
113
+
114
+ Can override the target state.
115
+
116
+ Args:
117
+ from_state: Source state name
118
+ to_state: Target state name (from transition evaluation)
119
+ context: Current context
120
+
121
+ Returns:
122
+ Actual target state name (can override)
123
+ """
124
+ return to_state
125
+
126
+ def on_error(
127
+ self,
128
+ state_name: str,
129
+ error: Exception,
130
+ context: Dict[str, Any]
131
+ ) -> Optional[str]:
132
+ """
133
+ Called when an error occurs during state execution.
134
+
135
+ Args:
136
+ state_name: Name of the state where error occurred
137
+ error: The exception that was raised
138
+ context: Current context
139
+
140
+ Returns:
141
+ State to transition to, or None to re-raise the error
142
+ """
143
+ return None # Re-raise by default
144
+
145
+ def on_action(
146
+ self,
147
+ action_name: str,
148
+ context: Dict[str, Any]
149
+ ) -> Dict[str, Any]:
150
+ """
151
+ Called for custom hook actions defined in states.
152
+
153
+ Args:
154
+ action_name: Name of the action to execute
155
+ context: Current context
156
+
157
+ Returns:
158
+ Modified context
159
+ """
160
+ logger.warning(f"Unhandled action: {action_name}")
161
+ return context
162
+
163
+
164
+ class LoggingHooks(MachineHooks):
165
+ """Hooks that log all state transitions."""
166
+
167
+ def __init__(self, log_level: int = logging.INFO):
168
+ self.log_level = log_level
169
+
170
+ def on_machine_start(self, context: Dict[str, Any]) -> Dict[str, Any]:
171
+ logger.log(self.log_level, "Machine execution started")
172
+ return context
173
+
174
+ def on_machine_end(self, context: Dict[str, Any], final_output: Dict[str, Any]) -> Dict[str, Any]:
175
+ logger.log(self.log_level, f"Machine execution ended with output: {final_output}")
176
+ return final_output
177
+
178
+ def on_state_enter(self, state_name: str, context: Dict[str, Any]) -> Dict[str, Any]:
179
+ logger.log(self.log_level, f"Entering state: {state_name}")
180
+ return context
181
+
182
+ def on_state_exit(
183
+ self,
184
+ state_name: str,
185
+ context: Dict[str, Any],
186
+ output: Optional[Dict[str, Any]]
187
+ ) -> Optional[Dict[str, Any]]:
188
+ logger.log(self.log_level, f"Exiting state: {state_name}")
189
+ return output
190
+
191
+ def on_transition(self, from_state: str, to_state: str, context: Dict[str, Any]) -> str:
192
+ logger.log(self.log_level, f"Transition: {from_state} -> {to_state}")
193
+ return to_state
194
+
195
+
196
+ class MetricsHooks(MachineHooks):
197
+ """Hooks that track execution metrics."""
198
+
199
+ def __init__(self):
200
+ self.state_counts: Dict[str, int] = {}
201
+ self.transition_counts: Dict[str, int] = {}
202
+ self.total_states_executed = 0
203
+ self.error_count = 0
204
+
205
+ def on_state_enter(self, state_name: str, context: Dict[str, Any]) -> Dict[str, Any]:
206
+ self.state_counts[state_name] = self.state_counts.get(state_name, 0) + 1
207
+ self.total_states_executed += 1
208
+ return context
209
+
210
+ def on_transition(self, from_state: str, to_state: str, context: Dict[str, Any]) -> str:
211
+ key = f"{from_state}->{to_state}"
212
+ self.transition_counts[key] = self.transition_counts.get(key, 0) + 1
213
+ return to_state
214
+
215
+ def on_error(self, state_name: str, error: Exception, context: Dict[str, Any]) -> Optional[str]:
216
+ self.error_count += 1
217
+ return None
218
+
219
+ def get_metrics(self) -> Dict[str, Any]:
220
+ """Get collected metrics."""
221
+ return {
222
+ "state_counts": self.state_counts,
223
+ "transition_counts": self.transition_counts,
224
+ "total_states_executed": self.total_states_executed,
225
+ "error_count": self.error_count,
226
+ }
227
+
228
+
229
+ class CompositeHooks(MachineHooks):
230
+ """Compose multiple hooks together."""
231
+
232
+ def __init__(self, *hooks: MachineHooks):
233
+ self.hooks = list(hooks)
234
+
235
+ def on_machine_start(self, context: Dict[str, Any]) -> Dict[str, Any]:
236
+ for hook in self.hooks:
237
+ context = hook.on_machine_start(context)
238
+ return context
239
+
240
+ def on_machine_end(self, context: Dict[str, Any], final_output: Dict[str, Any]) -> Dict[str, Any]:
241
+ for hook in self.hooks:
242
+ final_output = hook.on_machine_end(context, final_output)
243
+ return final_output
244
+
245
+ def on_state_enter(self, state_name: str, context: Dict[str, Any]) -> Dict[str, Any]:
246
+ for hook in self.hooks:
247
+ context = hook.on_state_enter(state_name, context)
248
+ return context
249
+
250
+ def on_state_exit(
251
+ self,
252
+ state_name: str,
253
+ context: Dict[str, Any],
254
+ output: Optional[Dict[str, Any]]
255
+ ) -> Optional[Dict[str, Any]]:
256
+ for hook in self.hooks:
257
+ output = hook.on_state_exit(state_name, context, output)
258
+ return output
259
+
260
+ def on_transition(self, from_state: str, to_state: str, context: Dict[str, Any]) -> str:
261
+ for hook in self.hooks:
262
+ to_state = hook.on_transition(from_state, to_state, context)
263
+ return to_state
264
+
265
+ def on_error(self, state_name: str, error: Exception, context: Dict[str, Any]) -> Optional[str]:
266
+ for hook in self.hooks:
267
+ result = hook.on_error(state_name, error, context)
268
+ if result is not None:
269
+ return result
270
+ return None
271
+
272
+ def on_action(self, action_name: str, context: Dict[str, Any]) -> Dict[str, Any]:
273
+ for hook in self.hooks:
274
+ context = hook.on_action(action_name, context)
275
+ return context
276
+
277
+
278
+ class WebhookHooks(MachineHooks):
279
+ """
280
+ Hooks that dispatch events to an HTTP endpoint.
281
+
282
+ Requires 'httpx' installed.
283
+ """
284
+
285
+ def __init__(
286
+ self,
287
+ endpoint: str,
288
+ timeout: float = 5.0,
289
+ api_key: Optional[str] = None
290
+ ):
291
+ if httpx is None:
292
+ raise ImportError("httpx is required for WebhookHooks")
293
+
294
+ self.endpoint = endpoint
295
+ self.timeout = timeout
296
+ self.headers = {
297
+ "Content-Type": "application/json",
298
+ "User-Agent": f"FlatAgents/{__version__}"
299
+ }
300
+ if api_key:
301
+ self.headers["Authorization"] = f"Bearer {api_key}"
302
+
303
+ async def _send(self, event: str, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
304
+ """Send event to webhook."""
305
+ data = {"event": event, **payload}
306
+ try:
307
+ async with httpx.AsyncClient() as client:
308
+ response = await client.post(
309
+ self.endpoint,
310
+ json=data,
311
+ headers=self.headers,
312
+ timeout=self.timeout
313
+ )
314
+ response.raise_for_status()
315
+ if response.status_code == 204:
316
+ return None
317
+ return response.json()
318
+ except Exception as e:
319
+ logger.error(f"Webhook error ({event}): {e}")
320
+ return None
321
+
322
+ async def on_machine_start(self, context: Dict[str, Any]) -> Dict[str, Any]:
323
+ resp = await self._send("machine_start", {"context": context})
324
+ if resp and "context" in resp:
325
+ return resp["context"]
326
+ return context
327
+
328
+ async def on_machine_end(self, context: Dict[str, Any], final_output: Dict[str, Any]) -> Dict[str, Any]:
329
+ resp = await self._send("machine_end", {"context": context, "output": final_output})
330
+ if resp and "output" in resp:
331
+ return resp["output"]
332
+ return final_output
333
+
334
+ async def on_state_enter(self, state_name: str, context: Dict[str, Any]) -> Dict[str, Any]:
335
+ resp = await self._send("state_enter", {"state": state_name, "context": context})
336
+ if resp and "context" in resp:
337
+ return resp["context"]
338
+ return context
339
+
340
+ async def on_state_exit(
341
+ self,
342
+ state_name: str,
343
+ context: Dict[str, Any],
344
+ output: Optional[Dict[str, Any]]
345
+ ) -> Optional[Dict[str, Any]]:
346
+ resp = await self._send("state_exit", {"state": state_name, "context": context, "output": output})
347
+ if resp and "output" in resp:
348
+ return resp["output"]
349
+ return output
350
+
351
+ async def on_transition(self, from_state: str, to_state: str, context: Dict[str, Any]) -> str:
352
+ resp = await self._send("transition", {"from": from_state, "to": to_state, "context": context})
353
+ if resp and "to_state" in resp:
354
+ return resp["to_state"]
355
+ return to_state
356
+
357
+ async def on_error(self, state_name: str, error: Exception, context: Dict[str, Any]) -> Optional[str]:
358
+ resp = await self._send("error", {
359
+ "state": state_name,
360
+ "error": str(error),
361
+ "error_type": type(error).__name__,
362
+ "context": context
363
+ })
364
+ if resp and "recovery_state" in resp:
365
+ return resp["recovery_state"]
366
+ return None # Re-raise
367
+
368
+ async def on_action(self, action_name: str, context: Dict[str, Any]) -> Dict[str, Any]:
369
+ resp = await self._send("action", {"action": action_name, "context": context})
370
+ if resp and "context" in resp:
371
+ return resp["context"]
372
+ return context
373
+
374
+
375
+ __all__ = [
376
+ "MachineHooks",
377
+ "LoggingHooks",
378
+ "MetricsHooks",
379
+ "CompositeHooks",
380
+ "WebhookHooks",
381
+ ]
@@ -0,0 +1,69 @@
1
+ import fcntl
2
+ import asyncio
3
+ import os
4
+ from abc import ABC, abstractmethod
5
+ from typing import Optional
6
+ from pathlib import Path
7
+ import contextlib
8
+
9
+ class ExecutionLock(ABC):
10
+ """Abstract interface for concurrency control."""
11
+
12
+ @abstractmethod
13
+ async def acquire(self, key: str) -> bool:
14
+ """Acquire lock for key. Returns True if successful."""
15
+ pass
16
+
17
+ @abstractmethod
18
+ async def release(self, key: str) -> None:
19
+ """Release lock for key."""
20
+ pass
21
+
22
+ class LocalFileLock(ExecutionLock):
23
+ """
24
+ File-based lock using fcntl.flock.
25
+ Works on local filesystems and NFS (mostly).
26
+ NOT suited for distributed cloud storage (S3/GCS).
27
+ """
28
+
29
+ def __init__(self, lock_dir: str = ".locks"):
30
+ self.lock_dir = Path(lock_dir)
31
+ self.lock_dir.mkdir(parents=True, exist_ok=True)
32
+ self._files = {}
33
+
34
+ async def acquire(self, key: str) -> bool:
35
+ """Attempts to acquire a non-blocking exclusive lock."""
36
+ path = self.lock_dir / f"{key}.lock"
37
+
38
+ try:
39
+ # Keep file handle open while locked
40
+ f = open(path, 'a+')
41
+ try:
42
+ # LOCK_EX | LOCK_NB = Exclusive, Non-Blocking
43
+ fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
44
+ self._files[key] = f
45
+ return True
46
+ except (IOError, OSError):
47
+ f.close()
48
+ return False
49
+ except Exception:
50
+ return False
51
+
52
+ async def release(self, key: str) -> None:
53
+ if key in self._files:
54
+ f = self._files.pop(key)
55
+ try:
56
+ fcntl.flock(f.fileno(), fcntl.LOCK_UN)
57
+ finally:
58
+ f.close()
59
+ # Optional: unlink file? Usually simpler to leave it empty
60
+ # Path(f.name).unlink(missing_ok=True)
61
+
62
+ class NoOpLock(ExecutionLock):
63
+ """Used when concurrency control is disabled or managed externally."""
64
+
65
+ async def acquire(self, key: str) -> bool:
66
+ return True
67
+
68
+ async def release(self, key: str) -> None:
69
+ pass