commandnet 0.1.0__tar.gz
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.
- commandnet-0.1.0/PKG-INFO +174 -0
- commandnet-0.1.0/README.md +150 -0
- commandnet-0.1.0/commandnet/__init__.py +12 -0
- commandnet-0.1.0/commandnet/core/graph.py +69 -0
- commandnet-0.1.0/commandnet/core/models.py +15 -0
- commandnet-0.1.0/commandnet/core/node.py +22 -0
- commandnet-0.1.0/commandnet/engine/runtime.py +87 -0
- commandnet-0.1.0/commandnet/interfaces/event_bus.py +14 -0
- commandnet-0.1.0/commandnet/interfaces/observer.py +6 -0
- commandnet-0.1.0/commandnet/interfaces/persistence.py +14 -0
- commandnet-0.1.0/pyproject.toml +42 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: commandnet
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A lightweight, Pydantic-powered, distributed event-driven state machine and typed node graph runtime.
|
|
5
|
+
Author: Christopher Vaz
|
|
6
|
+
Author-email: christophervaz160@gmail.com
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
14
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pydantic (>=2.0.0)
|
|
17
|
+
Requires-Dist: pytest ; extra == "dev"
|
|
18
|
+
Requires-Dist: pytest-asyncio ; extra == "dev"
|
|
19
|
+
Project-URL: Homepage, https://github.com/NullAxon/commandnet
|
|
20
|
+
Project-URL: Issues, https://github.com/NullAxon/commandnet/issues
|
|
21
|
+
Project-URL: Repository, https://github.com/NullAxon/commandnet
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# CommandNet
|
|
25
|
+
|
|
26
|
+
**CommandNet** is a lightweight, distributed, event-driven state machine and typed node graph runtime for Python 3.11+.
|
|
27
|
+
|
|
28
|
+
It allows you to build durable, asynchronous workflow graphs using strictly typed Python classes and Pydantic models. **CommandNet is not an orchestrator** (no built-in crons, external scheduling, or magic workflow DSLs). Instead, it provides a minimal, dependency-free (except Pydantic) core for executing graph-based logic across distributed workers using any database and message broker you choose.
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- **Strictly Typed Transitions**: Execution graphs are inferred directly from Python type hints (`-> NextNode`). No string-based identifiers.
|
|
33
|
+
- **First-Class Pydantic Support**: Context state is automatically serialized to your database and strictly rehydrated into Pydantic models before node execution.
|
|
34
|
+
- **Distributed-Worker Ready**: Safely runs across multiple horizontally scaled consumers via row-level locking patterns and idempotency checks.
|
|
35
|
+
- **Bring Your Own Infrastructure**: Clean abstract interfaces for `Persistence` (Postgres, SQLite) and `EventBus` (RabbitMQ, NATS, Redis).
|
|
36
|
+
- **Zero Magic**: Deterministic execution, highly observable, and easy to test.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install commandnet
|
|
44
|
+
```
|
|
45
|
+
*Or with Poetry:*
|
|
46
|
+
```bash
|
|
47
|
+
poetry add commandnet
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Quick Start
|
|
53
|
+
|
|
54
|
+
### 1. Define your State (Context)
|
|
55
|
+
Use Pydantic to define the mutable state that will be passed through your graph. CommandNet will automatically validate and rehydrate this data from your database.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from pydantic import BaseModel, Field
|
|
59
|
+
|
|
60
|
+
class AgentContext(BaseModel):
|
|
61
|
+
user_query: str
|
|
62
|
+
is_authenticated: bool = False
|
|
63
|
+
attempts: int = Field(default=0, ge=0)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### 2. Define your Nodes
|
|
67
|
+
Nodes subclass `Node` and must implement an `async def run(self, ctx)`. The **return type hint** dictates the execution graph!
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from typing import Union, Type
|
|
71
|
+
from commandnet import Node
|
|
72
|
+
|
|
73
|
+
class Denied(Node[AgentContext]):
|
|
74
|
+
async def run(self, ctx: AgentContext) -> None: # Returning None means Terminal state
|
|
75
|
+
print(f"[{ctx.user_query}] -> Access Denied.")
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
class Executing(Node[AgentContext]):
|
|
79
|
+
async def run(self, ctx: AgentContext) -> None:
|
|
80
|
+
print(f"[{ctx.user_query}] -> Running task successfully!")
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
class AuthCheck(Node[AgentContext]):
|
|
84
|
+
# The return type explicitly defines the DAG edges:
|
|
85
|
+
async def run(self, ctx: AgentContext) -> Union[Type[Executing], Type[Denied]]:
|
|
86
|
+
print(f"[{ctx.user_query}] -> Checking Auth...")
|
|
87
|
+
ctx.attempts += 1
|
|
88
|
+
|
|
89
|
+
if ctx.user_query == "hack_system":
|
|
90
|
+
return Denied
|
|
91
|
+
|
|
92
|
+
ctx.is_authenticated = True
|
|
93
|
+
return Executing
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### 3. Run the Engine
|
|
97
|
+
Implement the `Persistence` and `EventBus` interfaces for your infrastructure (or use in-memory mocks for testing), and trigger the agent.
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
import asyncio
|
|
101
|
+
from commandnet import Engine, GraphAnalyzer
|
|
102
|
+
|
|
103
|
+
# Note: You must implement Persistence and EventBus interfaces
|
|
104
|
+
# See the `interfaces/` directory for expected methods.
|
|
105
|
+
from my_app.adapters import PostgresPersistence, RabbitMQBus
|
|
106
|
+
|
|
107
|
+
async def main():
|
|
108
|
+
# 1. (Optional) Introspect your graph to visualize or validate it
|
|
109
|
+
dag = GraphAnalyzer.build_graph(AuthCheck)
|
|
110
|
+
print("Graph Structure:", dag)
|
|
111
|
+
# Output: {'AuthCheck': ['Executing', 'Denied'], 'Executing': [], 'Denied': []}
|
|
112
|
+
|
|
113
|
+
# 2. Initialize Engine
|
|
114
|
+
db = PostgresPersistence()
|
|
115
|
+
bus = RabbitMQBus()
|
|
116
|
+
engine = Engine(persistence=db, event_bus=bus)
|
|
117
|
+
|
|
118
|
+
# 3. Start listening to the event queue
|
|
119
|
+
await engine.start_worker()
|
|
120
|
+
|
|
121
|
+
# 4. Trigger an execution
|
|
122
|
+
initial_context = AgentContext(user_query="clean_logs")
|
|
123
|
+
await engine.trigger_agent(
|
|
124
|
+
agent_id="agent-001",
|
|
125
|
+
start_node=AuthCheck,
|
|
126
|
+
initial_context=initial_context
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
asyncio.run(main())
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Pluggable Architecture
|
|
136
|
+
|
|
137
|
+
CommandNet forces you to own your infrastructure. You connect it to your stack by implementing three simple interfaces:
|
|
138
|
+
|
|
139
|
+
### `Persistence`
|
|
140
|
+
Handles locking, saving, and loading the agent's context.
|
|
141
|
+
```python
|
|
142
|
+
class Persistence(ABC):
|
|
143
|
+
async def load_and_lock_agent(self, agent_id: str) -> Tuple[Optional[str], Optional[Dict]]: ...
|
|
144
|
+
async def save_state(self, agent_id: str, node_name: str, context: Dict, event: Event): ...
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### `EventBus`
|
|
148
|
+
Handles emitting transitions and consuming events in your worker loop.
|
|
149
|
+
```python
|
|
150
|
+
class EventBus(ABC):
|
|
151
|
+
async def publish(self, event: Event): ...
|
|
152
|
+
async def subscribe(self, handler: Callable[[Event], Coroutine]): ...
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### `Observer` (Optional)
|
|
156
|
+
Hooks for integrating OpenTelemetry, Prometheus, or custom logging.
|
|
157
|
+
```python
|
|
158
|
+
class Observer(ABC):
|
|
159
|
+
async def on_transition(self, agent_id: str, from_node: str, to_node: str, duration_ms: float): ...
|
|
160
|
+
async def on_error(self, agent_id: str, node: str, error: Exception): ...
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Design Principles
|
|
166
|
+
|
|
167
|
+
1. **Minimalism**: CommandNet aims to be under 1,000 lines of core code. It does one thing perfectly: reliably transitioning state machines via queue events.
|
|
168
|
+
2. **Stateless Nodes**: Node classes are instantiated fresh on every execution. All mutable state lives exclusively in the Pydantic `Context`.
|
|
169
|
+
3. **No String Magic**: You shouldn't need a massive JSON file or string literals to define your graph. Python's `typing` module is powerful enough. If your IDE can autocomple it, CommandNet can route it.
|
|
170
|
+
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
MIT
|
|
174
|
+
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# CommandNet
|
|
2
|
+
|
|
3
|
+
**CommandNet** is a lightweight, distributed, event-driven state machine and typed node graph runtime for Python 3.11+.
|
|
4
|
+
|
|
5
|
+
It allows you to build durable, asynchronous workflow graphs using strictly typed Python classes and Pydantic models. **CommandNet is not an orchestrator** (no built-in crons, external scheduling, or magic workflow DSLs). Instead, it provides a minimal, dependency-free (except Pydantic) core for executing graph-based logic across distributed workers using any database and message broker you choose.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Strictly Typed Transitions**: Execution graphs are inferred directly from Python type hints (`-> NextNode`). No string-based identifiers.
|
|
10
|
+
- **First-Class Pydantic Support**: Context state is automatically serialized to your database and strictly rehydrated into Pydantic models before node execution.
|
|
11
|
+
- **Distributed-Worker Ready**: Safely runs across multiple horizontally scaled consumers via row-level locking patterns and idempotency checks.
|
|
12
|
+
- **Bring Your Own Infrastructure**: Clean abstract interfaces for `Persistence` (Postgres, SQLite) and `EventBus` (RabbitMQ, NATS, Redis).
|
|
13
|
+
- **Zero Magic**: Deterministic execution, highly observable, and easy to test.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install commandnet
|
|
21
|
+
```
|
|
22
|
+
*Or with Poetry:*
|
|
23
|
+
```bash
|
|
24
|
+
poetry add commandnet
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Quick Start
|
|
30
|
+
|
|
31
|
+
### 1. Define your State (Context)
|
|
32
|
+
Use Pydantic to define the mutable state that will be passed through your graph. CommandNet will automatically validate and rehydrate this data from your database.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from pydantic import BaseModel, Field
|
|
36
|
+
|
|
37
|
+
class AgentContext(BaseModel):
|
|
38
|
+
user_query: str
|
|
39
|
+
is_authenticated: bool = False
|
|
40
|
+
attempts: int = Field(default=0, ge=0)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### 2. Define your Nodes
|
|
44
|
+
Nodes subclass `Node` and must implement an `async def run(self, ctx)`. The **return type hint** dictates the execution graph!
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from typing import Union, Type
|
|
48
|
+
from commandnet import Node
|
|
49
|
+
|
|
50
|
+
class Denied(Node[AgentContext]):
|
|
51
|
+
async def run(self, ctx: AgentContext) -> None: # Returning None means Terminal state
|
|
52
|
+
print(f"[{ctx.user_query}] -> Access Denied.")
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
class Executing(Node[AgentContext]):
|
|
56
|
+
async def run(self, ctx: AgentContext) -> None:
|
|
57
|
+
print(f"[{ctx.user_query}] -> Running task successfully!")
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
class AuthCheck(Node[AgentContext]):
|
|
61
|
+
# The return type explicitly defines the DAG edges:
|
|
62
|
+
async def run(self, ctx: AgentContext) -> Union[Type[Executing], Type[Denied]]:
|
|
63
|
+
print(f"[{ctx.user_query}] -> Checking Auth...")
|
|
64
|
+
ctx.attempts += 1
|
|
65
|
+
|
|
66
|
+
if ctx.user_query == "hack_system":
|
|
67
|
+
return Denied
|
|
68
|
+
|
|
69
|
+
ctx.is_authenticated = True
|
|
70
|
+
return Executing
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### 3. Run the Engine
|
|
74
|
+
Implement the `Persistence` and `EventBus` interfaces for your infrastructure (or use in-memory mocks for testing), and trigger the agent.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import asyncio
|
|
78
|
+
from commandnet import Engine, GraphAnalyzer
|
|
79
|
+
|
|
80
|
+
# Note: You must implement Persistence and EventBus interfaces
|
|
81
|
+
# See the `interfaces/` directory for expected methods.
|
|
82
|
+
from my_app.adapters import PostgresPersistence, RabbitMQBus
|
|
83
|
+
|
|
84
|
+
async def main():
|
|
85
|
+
# 1. (Optional) Introspect your graph to visualize or validate it
|
|
86
|
+
dag = GraphAnalyzer.build_graph(AuthCheck)
|
|
87
|
+
print("Graph Structure:", dag)
|
|
88
|
+
# Output: {'AuthCheck': ['Executing', 'Denied'], 'Executing': [], 'Denied': []}
|
|
89
|
+
|
|
90
|
+
# 2. Initialize Engine
|
|
91
|
+
db = PostgresPersistence()
|
|
92
|
+
bus = RabbitMQBus()
|
|
93
|
+
engine = Engine(persistence=db, event_bus=bus)
|
|
94
|
+
|
|
95
|
+
# 3. Start listening to the event queue
|
|
96
|
+
await engine.start_worker()
|
|
97
|
+
|
|
98
|
+
# 4. Trigger an execution
|
|
99
|
+
initial_context = AgentContext(user_query="clean_logs")
|
|
100
|
+
await engine.trigger_agent(
|
|
101
|
+
agent_id="agent-001",
|
|
102
|
+
start_node=AuthCheck,
|
|
103
|
+
initial_context=initial_context
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
asyncio.run(main())
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Pluggable Architecture
|
|
113
|
+
|
|
114
|
+
CommandNet forces you to own your infrastructure. You connect it to your stack by implementing three simple interfaces:
|
|
115
|
+
|
|
116
|
+
### `Persistence`
|
|
117
|
+
Handles locking, saving, and loading the agent's context.
|
|
118
|
+
```python
|
|
119
|
+
class Persistence(ABC):
|
|
120
|
+
async def load_and_lock_agent(self, agent_id: str) -> Tuple[Optional[str], Optional[Dict]]: ...
|
|
121
|
+
async def save_state(self, agent_id: str, node_name: str, context: Dict, event: Event): ...
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### `EventBus`
|
|
125
|
+
Handles emitting transitions and consuming events in your worker loop.
|
|
126
|
+
```python
|
|
127
|
+
class EventBus(ABC):
|
|
128
|
+
async def publish(self, event: Event): ...
|
|
129
|
+
async def subscribe(self, handler: Callable[[Event], Coroutine]): ...
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### `Observer` (Optional)
|
|
133
|
+
Hooks for integrating OpenTelemetry, Prometheus, or custom logging.
|
|
134
|
+
```python
|
|
135
|
+
class Observer(ABC):
|
|
136
|
+
async def on_transition(self, agent_id: str, from_node: str, to_node: str, duration_ms: float): ...
|
|
137
|
+
async def on_error(self, agent_id: str, node: str, error: Exception): ...
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Design Principles
|
|
143
|
+
|
|
144
|
+
1. **Minimalism**: CommandNet aims to be under 1,000 lines of core code. It does one thing perfectly: reliably transitioning state machines via queue events.
|
|
145
|
+
2. **Stateless Nodes**: Node classes are instantiated fresh on every execution. All mutable state lives exclusively in the Pydantic `Context`.
|
|
146
|
+
3. **No String Magic**: You shouldn't need a massive JSON file or string literals to define your graph. Python's `typing` module is powerful enough. If your IDE can autocomple it, CommandNet can route it.
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from .core.models import Event
|
|
2
|
+
from .core.node import Node
|
|
3
|
+
from .core.graph import GraphAnalyzer
|
|
4
|
+
from .interfaces.persistence import Persistence
|
|
5
|
+
from .interfaces.event_bus import EventBus
|
|
6
|
+
from .interfaces.observer import Observer
|
|
7
|
+
from .engine.runtime import Engine
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Event", "Node", "GraphAnalyzer",
|
|
11
|
+
"Persistence", "EventBus", "Observer", "Engine"
|
|
12
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
from typing import Any, Dict, List, Set, Type, Union
|
|
3
|
+
from .node import Node, NODE_REGISTRY
|
|
4
|
+
|
|
5
|
+
class GraphAnalyzer:
|
|
6
|
+
"""Introspects Python annotations to derive transitions using the Node Registry."""
|
|
7
|
+
|
|
8
|
+
@staticmethod
|
|
9
|
+
def get_context_type(node_cls: Type[Node]) -> Type[Any]:
|
|
10
|
+
"""Extracts the expected Context type from the run() method signature."""
|
|
11
|
+
hints = typing.get_type_hints(node_cls.run)
|
|
12
|
+
for param_name, param_type in hints.items():
|
|
13
|
+
if param_name != 'return':
|
|
14
|
+
return param_type
|
|
15
|
+
return dict # Fallback
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def _get_node_names(type_hint: Any) -> List[str]:
|
|
19
|
+
"""Recursively extracts node names from a type hint (handling Union)."""
|
|
20
|
+
# Handle Union types (e.g., Union[A, B] or A | B)
|
|
21
|
+
origin = typing.get_origin(type_hint)
|
|
22
|
+
if origin is Union:
|
|
23
|
+
return [name for arg in typing.get_args(type_hint) for name in GraphAnalyzer._get_node_names(arg)]
|
|
24
|
+
|
|
25
|
+
# Handle ForwardRefs (strings in annotations)
|
|
26
|
+
if isinstance(type_hint, typing.ForwardRef):
|
|
27
|
+
return [type_hint.__forward_arg__]
|
|
28
|
+
|
|
29
|
+
# Handle actual classes
|
|
30
|
+
if isinstance(type_hint, type) and issubclass(type_hint, Node):
|
|
31
|
+
return [type_hint.__name__]
|
|
32
|
+
|
|
33
|
+
return []
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def get_transitions(node_cls: Type[Node]) -> Set[Type[Node]]:
|
|
37
|
+
"""Extracts possible next nodes by looking up names in the NODE_REGISTRY."""
|
|
38
|
+
# Access the raw annotation directly from the function
|
|
39
|
+
ret_annotation = node_cls.run.__annotations__.get("return")
|
|
40
|
+
if not ret_annotation:
|
|
41
|
+
return set()
|
|
42
|
+
|
|
43
|
+
# Get the names of the next nodes
|
|
44
|
+
node_names = GraphAnalyzer._get_node_names(ret_annotation)
|
|
45
|
+
|
|
46
|
+
# Resolve names to actual classes using our Registry
|
|
47
|
+
return {NODE_REGISTRY[name] for name in node_names if name in NODE_REGISTRY}
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def build_graph(start_node: Type[Node]) -> Dict[str, List[str]]:
|
|
51
|
+
"""Recursively builds the execution DAG."""
|
|
52
|
+
graph = {}
|
|
53
|
+
visited = set()
|
|
54
|
+
queue = [start_node]
|
|
55
|
+
|
|
56
|
+
while queue:
|
|
57
|
+
current = queue.pop(0)
|
|
58
|
+
if current in visited:
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
visited.add(current)
|
|
62
|
+
transitions = GraphAnalyzer.get_transitions(current)
|
|
63
|
+
graph[current.__name__] = [t.__name__ for t in transitions]
|
|
64
|
+
|
|
65
|
+
for t in transitions:
|
|
66
|
+
if t not in visited:
|
|
67
|
+
queue.append(t)
|
|
68
|
+
|
|
69
|
+
return graph
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
def utcnow_iso() -> str:
|
|
7
|
+
return datetime.now(timezone.utc).isoformat()
|
|
8
|
+
|
|
9
|
+
class Event(BaseModel):
|
|
10
|
+
"""Represents a state transition event triggering a node execution."""
|
|
11
|
+
event_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
12
|
+
agent_id: str
|
|
13
|
+
node_name: str
|
|
14
|
+
payload: Optional[Dict[str, Any]] = None
|
|
15
|
+
timestamp: str = Field(default_factory=utcnow_iso)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from typing import Dict, Generic, Optional, Type, TypeVar
|
|
4
|
+
|
|
5
|
+
C = TypeVar('C') # Generic Context Type
|
|
6
|
+
NODE_REGISTRY: Dict[str, Type['Node']] = {}
|
|
7
|
+
|
|
8
|
+
class Node(Generic[C], ABC):
|
|
9
|
+
"""
|
|
10
|
+
Stateless unit of execution.
|
|
11
|
+
Subclasses must implement `run` and type-hint the return with next Node classes.
|
|
12
|
+
"""
|
|
13
|
+
def __init_subclass__(cls, **kwargs):
|
|
14
|
+
super().__init_subclass__(**kwargs)
|
|
15
|
+
# Automatically register non-abstract nodes
|
|
16
|
+
if not inspect.isabstract(cls):
|
|
17
|
+
NODE_REGISTRY[cls.__name__] = cls
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
async def run(self, ctx: C) -> Optional[Type['Node']]:
|
|
21
|
+
"""Executes node logic. Returns the next Node class, or None if terminal."""
|
|
22
|
+
pass
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import uuid
|
|
4
|
+
from typing import Any, Optional, Type
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from ..core.models import Event
|
|
8
|
+
from ..core.node import Node, NODE_REGISTRY
|
|
9
|
+
from ..core.graph import GraphAnalyzer
|
|
10
|
+
from ..interfaces.persistence import Persistence
|
|
11
|
+
from ..interfaces.event_bus import EventBus
|
|
12
|
+
from ..interfaces.observer import Observer
|
|
13
|
+
|
|
14
|
+
class Engine:
|
|
15
|
+
"""Manages event-driven transitions, state rehydration, and execution."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
persistence: Persistence,
|
|
20
|
+
event_bus: EventBus,
|
|
21
|
+
observer: Optional[Observer] = None
|
|
22
|
+
):
|
|
23
|
+
self.db = persistence
|
|
24
|
+
self.bus = event_bus
|
|
25
|
+
self.observer = observer or Observer()
|
|
26
|
+
self.logger = logging.getLogger("TypedEngine")
|
|
27
|
+
|
|
28
|
+
async def start_worker(self):
|
|
29
|
+
"""Subscribes the engine to the event bus."""
|
|
30
|
+
await self.bus.subscribe(self.process_event)
|
|
31
|
+
self.logger.info("Worker started, listening for events.")
|
|
32
|
+
|
|
33
|
+
async def trigger_agent(self, agent_id: str, start_node: Type[Node], initial_context: Any):
|
|
34
|
+
"""Initializes a new agent and pushes the first event."""
|
|
35
|
+
ctx_dict = initial_context.model_dump() if isinstance(initial_context, BaseModel) else initial_context
|
|
36
|
+
|
|
37
|
+
start_event = Event(agent_id=agent_id, node_name=start_node.__name__)
|
|
38
|
+
await self.db.save_state(agent_id, start_node.__name__, ctx_dict, start_event)
|
|
39
|
+
await self.bus.publish(start_event)
|
|
40
|
+
|
|
41
|
+
async def process_event(self, event: Event):
|
|
42
|
+
"""Core state machine worker loop."""
|
|
43
|
+
start_time = asyncio.get_event_loop().time()
|
|
44
|
+
|
|
45
|
+
# 1. Concurrency safe load
|
|
46
|
+
current_node_name, ctx_dict = await self.db.load_and_lock_agent(event.agent_id)
|
|
47
|
+
if not current_node_name:
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
# 2. Idempotency Check
|
|
51
|
+
if current_node_name != event.node_name:
|
|
52
|
+
self.logger.info(f"Stale event {event.event_id} for {event.agent_id}. Ignoring.")
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
node_cls = NODE_REGISTRY.get(current_node_name)
|
|
56
|
+
if not node_cls:
|
|
57
|
+
raise RuntimeError(f"Node '{current_node_name}' missing from registry.")
|
|
58
|
+
|
|
59
|
+
# 3. Pydantic Automatic Rehydration
|
|
60
|
+
ctx_type = GraphAnalyzer.get_context_type(node_cls)
|
|
61
|
+
try:
|
|
62
|
+
ctx = ctx_type.model_validate(ctx_dict) if issubclass(ctx_type, BaseModel) else ctx_dict
|
|
63
|
+
except Exception as e:
|
|
64
|
+
self.logger.error(f"Context validation failed for {event.agent_id}: {e}")
|
|
65
|
+
raise
|
|
66
|
+
|
|
67
|
+
# 4. Execute
|
|
68
|
+
node_instance = node_cls()
|
|
69
|
+
try:
|
|
70
|
+
next_node_cls = await node_instance.run(ctx)
|
|
71
|
+
except Exception as e:
|
|
72
|
+
await self.observer.on_error(event.agent_id, current_node_name, e)
|
|
73
|
+
raise # Handled by external broker retry policy
|
|
74
|
+
|
|
75
|
+
# 5. Determine transition & serialize context
|
|
76
|
+
duration = (asyncio.get_event_loop().time() - start_time) * 1000
|
|
77
|
+
new_ctx_dict = ctx.model_dump() if isinstance(ctx, BaseModel) else ctx
|
|
78
|
+
|
|
79
|
+
if next_node_cls:
|
|
80
|
+
next_name = next_node_cls.__name__
|
|
81
|
+
await self.observer.on_transition(event.agent_id, current_node_name, next_name, duration)
|
|
82
|
+
|
|
83
|
+
next_event = Event(agent_id=event.agent_id, node_name=next_name)
|
|
84
|
+
await self.db.save_state(event.agent_id, next_name, new_ctx_dict, next_event)
|
|
85
|
+
await self.bus.publish(next_event)
|
|
86
|
+
else:
|
|
87
|
+
await self.observer.on_transition(event.agent_id, current_node_name, "TERMINAL", duration)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Callable, Coroutine
|
|
3
|
+
from ..core.models import Event
|
|
4
|
+
|
|
5
|
+
class EventBus(ABC):
|
|
6
|
+
@abstractmethod
|
|
7
|
+
async def publish(self, event: Event):
|
|
8
|
+
"""Publishes an event to the queue/broker."""
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
async def subscribe(self, handler: Callable[[Event], Coroutine]):
|
|
13
|
+
"""Registers a worker callback to process incoming events."""
|
|
14
|
+
pass
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
from abc import ABC
|
|
2
|
+
|
|
3
|
+
class Observer(ABC):
|
|
4
|
+
"""Optional hooks for distributed tracing, metrics, and logging."""
|
|
5
|
+
async def on_transition(self, agent_id: str, from_node: str, to_node: str, duration_ms: float): pass
|
|
6
|
+
async def on_error(self, agent_id: str, node: str, error: Exception): pass
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Dict, Optional, Tuple
|
|
3
|
+
from ..core.models import Event
|
|
4
|
+
|
|
5
|
+
class Persistence(ABC):
|
|
6
|
+
@abstractmethod
|
|
7
|
+
async def load_and_lock_agent(self, agent_id: str) -> Tuple[Optional[str], Optional[Dict]]:
|
|
8
|
+
"""Loads state and locks the DB row. Returns (node_name, context_dict)."""
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
async def save_state(self, agent_id: str, node_name: str, context: Dict, event: Event):
|
|
13
|
+
"""Persists the updated context and records the transition event."""
|
|
14
|
+
pass
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "commandnet"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A lightweight, Pydantic-powered, distributed event-driven state machine and typed node graph runtime."
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "Christopher Vaz", email = "christophervaz160@gmail.com" }
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
|
|
11
|
+
dependencies = [
|
|
12
|
+
"pydantic>=2.0.0"
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
22
|
+
"Topic :: System :: Distributed Computing",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
dev = [
|
|
27
|
+
"pytest",
|
|
28
|
+
"pytest-asyncio"
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/NullAxon/commandnet"
|
|
33
|
+
Repository = "https://github.com/NullAxon/commandnet"
|
|
34
|
+
Issues = "https://github.com/NullAxon/commandnet/issues"
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
38
|
+
build-backend = "poetry.core.masonry.api"
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
asyncio_mode = "strict"
|
|
42
|
+
asyncio_default_test_loop_scope = "function"
|