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.
- flatmachines/__init__.py +136 -0
- flatmachines/actions.py +408 -0
- flatmachines/adapters/__init__.py +38 -0
- flatmachines/adapters/flatagent.py +86 -0
- flatmachines/adapters/pi_agent_bridge.py +127 -0
- flatmachines/adapters/pi_agent_runner.mjs +99 -0
- flatmachines/adapters/smolagents.py +125 -0
- flatmachines/agents.py +144 -0
- flatmachines/assets/MACHINES.md +141 -0
- flatmachines/assets/README.md +11 -0
- flatmachines/assets/__init__.py +0 -0
- flatmachines/assets/flatagent.d.ts +219 -0
- flatmachines/assets/flatagent.schema.json +271 -0
- flatmachines/assets/flatagent.slim.d.ts +58 -0
- flatmachines/assets/flatagents-runtime.d.ts +523 -0
- flatmachines/assets/flatagents-runtime.schema.json +281 -0
- flatmachines/assets/flatagents-runtime.slim.d.ts +187 -0
- flatmachines/assets/flatmachine.d.ts +403 -0
- flatmachines/assets/flatmachine.schema.json +620 -0
- flatmachines/assets/flatmachine.slim.d.ts +106 -0
- flatmachines/assets/profiles.d.ts +140 -0
- flatmachines/assets/profiles.schema.json +93 -0
- flatmachines/assets/profiles.slim.d.ts +26 -0
- flatmachines/backends.py +222 -0
- flatmachines/distributed.py +835 -0
- flatmachines/distributed_hooks.py +351 -0
- flatmachines/execution.py +638 -0
- flatmachines/expressions/__init__.py +60 -0
- flatmachines/expressions/cel.py +101 -0
- flatmachines/expressions/simple.py +166 -0
- flatmachines/flatmachine.py +1263 -0
- flatmachines/hooks.py +381 -0
- flatmachines/locking.py +69 -0
- flatmachines/monitoring.py +505 -0
- flatmachines/persistence.py +213 -0
- flatmachines/run.py +117 -0
- flatmachines/utils.py +166 -0
- flatmachines/validation.py +79 -0
- flatmachines-1.0.0.dist-info/METADATA +390 -0
- flatmachines-1.0.0.dist-info/RECORD +41 -0
- 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
|
+
]
|
flatmachines/locking.py
ADDED
|
@@ -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
|