commandnet 0.2.0__tar.gz → 0.2.2__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.2.2/PKG-INFO +174 -0
- commandnet-0.2.2/README.md +150 -0
- {commandnet-0.2.0 → commandnet-0.2.2}/commandnet/core/graph.py +11 -24
- {commandnet-0.2.0 → commandnet-0.2.2}/commandnet/core/node.py +0 -14
- {commandnet-0.2.0 → commandnet-0.2.2}/commandnet/engine/runtime.py +36 -16
- {commandnet-0.2.0 → commandnet-0.2.2}/pyproject.toml +1 -1
- commandnet-0.2.0/PKG-INFO +0 -174
- commandnet-0.2.0/README.md +0 -150
- {commandnet-0.2.0 → commandnet-0.2.2}/commandnet/__init__.py +0 -0
- {commandnet-0.2.0 → commandnet-0.2.2}/commandnet/core/models.py +0 -0
- {commandnet-0.2.0 → commandnet-0.2.2}/commandnet/interfaces/event_bus.py +0 -0
- {commandnet-0.2.0 → commandnet-0.2.2}/commandnet/interfaces/observer.py +0 -0
- {commandnet-0.2.0 → commandnet-0.2.2}/commandnet/interfaces/persistence.py +0 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: commandnet
|
|
3
|
+
Version: 0.2.2
|
|
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 enables you to build durable, asynchronous workflows using strictly typed Python classes and Pydantic models. Unlike heavy orchestrators, CommandNet provides a minimal core that executes graph-based logic across distributed workers using your choice of database and message broker.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## 🚀 Installation
|
|
33
|
+
|
|
34
|
+
Install CommandNet via pip:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install commandnet
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## ✨ Key Features
|
|
43
|
+
|
|
44
|
+
- **Type-Safe Transitions**: The execution graph is inferred directly from Python type hints (`-> Union[Type[NodeA], Type[NodeB]]`). No external JSON/YAML definitions.
|
|
45
|
+
- **Pydantic State Management**: Context is automatically serialized and rehydrated into Pydantic models with full validation.
|
|
46
|
+
- **Distributed by Design**: Built-in row-level locking and idempotency support for safe execution across horizontally scaled workers.
|
|
47
|
+
- **Fan-out / Fan-in (Parallel)**: Native support for triggering multiple concurrent sub-tasks and merging results back into the parent state.
|
|
48
|
+
- **Native Scheduling**: Schedule nodes to run after a specific delay with built-in idempotency keys to prevent duplicate execution.
|
|
49
|
+
- **Static Validation**: Validate your entire workflow graph (types and connectivity) before a single event is processed.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## 🛠️ Quick Start
|
|
54
|
+
|
|
55
|
+
### 1. Define Your Context
|
|
56
|
+
The "Context" is the persistent state of your agent, defined using Pydantic.
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from pydantic import BaseModel
|
|
60
|
+
|
|
61
|
+
class WorkflowCtx(BaseModel):
|
|
62
|
+
user_id: str
|
|
63
|
+
status: str = "pending"
|
|
64
|
+
attempts: int = 0
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### 2. Define Your Nodes
|
|
68
|
+
Nodes are the building blocks of your graph. The return type hint of the `run` method defines the edges of your DAG.
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from typing import Union, Type, Optional
|
|
72
|
+
from commandnet import Node
|
|
73
|
+
|
|
74
|
+
class ProcessPayment(Node[WorkflowCtx, None]):
|
|
75
|
+
async def run(self, ctx: WorkflowCtx, payload: None) -> None:
|
|
76
|
+
print(f"Processing for {ctx.user_id}...")
|
|
77
|
+
ctx.status = "complete"
|
|
78
|
+
return None # Terminal state
|
|
79
|
+
|
|
80
|
+
class CheckRisk(Node[WorkflowCtx, None]):
|
|
81
|
+
# The return type explicitly defines the possible next nodes
|
|
82
|
+
async def run(self, ctx: WorkflowCtx, payload: None) -> Union[Type[ProcessPayment], None]:
|
|
83
|
+
ctx.attempts += 1
|
|
84
|
+
if ctx.attempts > 3:
|
|
85
|
+
return None # Failure/Stop
|
|
86
|
+
return ProcessPayment
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### 3. Advanced Routing (Parallel & Scheduled)
|
|
90
|
+
CommandNet supports complex workflow patterns beyond simple linear transitions.
|
|
91
|
+
|
|
92
|
+
#### Parallel Fan-out
|
|
93
|
+
```python
|
|
94
|
+
from commandnet import Parallel, ParallelTask
|
|
95
|
+
|
|
96
|
+
class StartAnalysis(Node[WorkflowCtx, None]):
|
|
97
|
+
async def run(self, ctx: WorkflowCtx, payload: None) -> Parallel:
|
|
98
|
+
return Parallel(
|
|
99
|
+
branches=[
|
|
100
|
+
ParallelTask(node_cls=SubTaskNode, sub_context_path="sub_data_1"),
|
|
101
|
+
ParallelTask(node_cls=SubTaskNode, sub_context_path="sub_data_2")
|
|
102
|
+
],
|
|
103
|
+
join_node=FinalMergeNode
|
|
104
|
+
)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
#### Delayed Scheduling
|
|
108
|
+
```python
|
|
109
|
+
from commandnet import Schedule
|
|
110
|
+
|
|
111
|
+
class RetryNode(Node[WorkflowCtx, None]):
|
|
112
|
+
async def run(self, ctx: WorkflowCtx, payload: None) -> Schedule:
|
|
113
|
+
return Schedule(
|
|
114
|
+
node_cls=CheckRisk,
|
|
115
|
+
delay_seconds=300,
|
|
116
|
+
idempotency_key=f"retry-{ctx.attempts}"
|
|
117
|
+
)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## 🏗️ Infrastructure Integration
|
|
123
|
+
|
|
124
|
+
CommandNet is unopinionated about your stack. You simply implement two abstract interfaces:
|
|
125
|
+
|
|
126
|
+
1. **`Persistence`**: Handles locking state in your DB (Postgres, Redis, DynamoDB).
|
|
127
|
+
2. **`EventBus`**: Handles moving events between workers (RabbitMQ, NATS, SQS).
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from commandnet import Engine
|
|
131
|
+
|
|
132
|
+
# Implement these interfaces for your specific stack
|
|
133
|
+
db = MyPostgresAdapter()
|
|
134
|
+
bus = MyRabbitMQAdapter()
|
|
135
|
+
|
|
136
|
+
engine = Engine(persistence=db, event_bus=bus)
|
|
137
|
+
|
|
138
|
+
# Start the worker loop
|
|
139
|
+
await engine.start_worker()
|
|
140
|
+
|
|
141
|
+
# Trigger an execution
|
|
142
|
+
await engine.trigger_agent("agent-123", CheckRisk, WorkflowCtx(user_id="user_abc"))
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## 🔍 Static Analysis & Safety
|
|
148
|
+
|
|
149
|
+
Prevent runtime failures by validating your graph during CI/CD or at startup. The `GraphAnalyzer` checks for disconnected nodes and ensures that if `NodeA` transitions to `NodeB`, they share compatible `Context` types.
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
from commandnet import GraphAnalyzer
|
|
153
|
+
|
|
154
|
+
# This will raise a TypeError if types don't match or a ValueError if edges are broken
|
|
155
|
+
GraphAnalyzer.validate(CheckRisk)
|
|
156
|
+
|
|
157
|
+
# Generate a dictionary representation of your DAG
|
|
158
|
+
dag = GraphAnalyzer.build_graph(CheckRisk)
|
|
159
|
+
print(dag) # {'CheckRisk': ['ProcessPayment'], 'ProcessPayment': []}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## ⚖️ Design Philosophy
|
|
165
|
+
|
|
166
|
+
1. **Code as Truth**: If your IDE can navigate it, CommandNet can run it. No "magic strings."
|
|
167
|
+
2. **Stateless Execution**: Workers don't keep local state. Every node execution starts with a fresh database fetch and lock.
|
|
168
|
+
3. **Zero Magic**: No hidden background threads or global singletons. You control the `Engine` lifecycle.
|
|
169
|
+
4. **Ownership**: CommandNet provides the orchestration logic; you provide the infrastructure.
|
|
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 enables you to build durable, asynchronous workflows using strictly typed Python classes and Pydantic models. Unlike heavy orchestrators, CommandNet provides a minimal core that executes graph-based logic across distributed workers using your choice of database and message broker.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 🚀 Installation
|
|
10
|
+
|
|
11
|
+
Install CommandNet via pip:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install commandnet
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## ✨ Key Features
|
|
20
|
+
|
|
21
|
+
- **Type-Safe Transitions**: The execution graph is inferred directly from Python type hints (`-> Union[Type[NodeA], Type[NodeB]]`). No external JSON/YAML definitions.
|
|
22
|
+
- **Pydantic State Management**: Context is automatically serialized and rehydrated into Pydantic models with full validation.
|
|
23
|
+
- **Distributed by Design**: Built-in row-level locking and idempotency support for safe execution across horizontally scaled workers.
|
|
24
|
+
- **Fan-out / Fan-in (Parallel)**: Native support for triggering multiple concurrent sub-tasks and merging results back into the parent state.
|
|
25
|
+
- **Native Scheduling**: Schedule nodes to run after a specific delay with built-in idempotency keys to prevent duplicate execution.
|
|
26
|
+
- **Static Validation**: Validate your entire workflow graph (types and connectivity) before a single event is processed.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 🛠️ Quick Start
|
|
31
|
+
|
|
32
|
+
### 1. Define Your Context
|
|
33
|
+
The "Context" is the persistent state of your agent, defined using Pydantic.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from pydantic import BaseModel
|
|
37
|
+
|
|
38
|
+
class WorkflowCtx(BaseModel):
|
|
39
|
+
user_id: str
|
|
40
|
+
status: str = "pending"
|
|
41
|
+
attempts: int = 0
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### 2. Define Your Nodes
|
|
45
|
+
Nodes are the building blocks of your graph. The return type hint of the `run` method defines the edges of your DAG.
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from typing import Union, Type, Optional
|
|
49
|
+
from commandnet import Node
|
|
50
|
+
|
|
51
|
+
class ProcessPayment(Node[WorkflowCtx, None]):
|
|
52
|
+
async def run(self, ctx: WorkflowCtx, payload: None) -> None:
|
|
53
|
+
print(f"Processing for {ctx.user_id}...")
|
|
54
|
+
ctx.status = "complete"
|
|
55
|
+
return None # Terminal state
|
|
56
|
+
|
|
57
|
+
class CheckRisk(Node[WorkflowCtx, None]):
|
|
58
|
+
# The return type explicitly defines the possible next nodes
|
|
59
|
+
async def run(self, ctx: WorkflowCtx, payload: None) -> Union[Type[ProcessPayment], None]:
|
|
60
|
+
ctx.attempts += 1
|
|
61
|
+
if ctx.attempts > 3:
|
|
62
|
+
return None # Failure/Stop
|
|
63
|
+
return ProcessPayment
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### 3. Advanced Routing (Parallel & Scheduled)
|
|
67
|
+
CommandNet supports complex workflow patterns beyond simple linear transitions.
|
|
68
|
+
|
|
69
|
+
#### Parallel Fan-out
|
|
70
|
+
```python
|
|
71
|
+
from commandnet import Parallel, ParallelTask
|
|
72
|
+
|
|
73
|
+
class StartAnalysis(Node[WorkflowCtx, None]):
|
|
74
|
+
async def run(self, ctx: WorkflowCtx, payload: None) -> Parallel:
|
|
75
|
+
return Parallel(
|
|
76
|
+
branches=[
|
|
77
|
+
ParallelTask(node_cls=SubTaskNode, sub_context_path="sub_data_1"),
|
|
78
|
+
ParallelTask(node_cls=SubTaskNode, sub_context_path="sub_data_2")
|
|
79
|
+
],
|
|
80
|
+
join_node=FinalMergeNode
|
|
81
|
+
)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
#### Delayed Scheduling
|
|
85
|
+
```python
|
|
86
|
+
from commandnet import Schedule
|
|
87
|
+
|
|
88
|
+
class RetryNode(Node[WorkflowCtx, None]):
|
|
89
|
+
async def run(self, ctx: WorkflowCtx, payload: None) -> Schedule:
|
|
90
|
+
return Schedule(
|
|
91
|
+
node_cls=CheckRisk,
|
|
92
|
+
delay_seconds=300,
|
|
93
|
+
idempotency_key=f"retry-{ctx.attempts}"
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## 🏗️ Infrastructure Integration
|
|
100
|
+
|
|
101
|
+
CommandNet is unopinionated about your stack. You simply implement two abstract interfaces:
|
|
102
|
+
|
|
103
|
+
1. **`Persistence`**: Handles locking state in your DB (Postgres, Redis, DynamoDB).
|
|
104
|
+
2. **`EventBus`**: Handles moving events between workers (RabbitMQ, NATS, SQS).
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from commandnet import Engine
|
|
108
|
+
|
|
109
|
+
# Implement these interfaces for your specific stack
|
|
110
|
+
db = MyPostgresAdapter()
|
|
111
|
+
bus = MyRabbitMQAdapter()
|
|
112
|
+
|
|
113
|
+
engine = Engine(persistence=db, event_bus=bus)
|
|
114
|
+
|
|
115
|
+
# Start the worker loop
|
|
116
|
+
await engine.start_worker()
|
|
117
|
+
|
|
118
|
+
# Trigger an execution
|
|
119
|
+
await engine.trigger_agent("agent-123", CheckRisk, WorkflowCtx(user_id="user_abc"))
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## 🔍 Static Analysis & Safety
|
|
125
|
+
|
|
126
|
+
Prevent runtime failures by validating your graph during CI/CD or at startup. The `GraphAnalyzer` checks for disconnected nodes and ensures that if `NodeA` transitions to `NodeB`, they share compatible `Context` types.
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from commandnet import GraphAnalyzer
|
|
130
|
+
|
|
131
|
+
# This will raise a TypeError if types don't match or a ValueError if edges are broken
|
|
132
|
+
GraphAnalyzer.validate(CheckRisk)
|
|
133
|
+
|
|
134
|
+
# Generate a dictionary representation of your DAG
|
|
135
|
+
dag = GraphAnalyzer.build_graph(CheckRisk)
|
|
136
|
+
print(dag) # {'CheckRisk': ['ProcessPayment'], 'ProcessPayment': []}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## ⚖️ Design Philosophy
|
|
142
|
+
|
|
143
|
+
1. **Code as Truth**: If your IDE can navigate it, CommandNet can run it. No "magic strings."
|
|
144
|
+
2. **Stateless Execution**: Workers don't keep local state. Every node execution starts with a fresh database fetch and lock.
|
|
145
|
+
3. **Zero Magic**: No hidden background threads or global singletons. You control the `Engine` lifecycle.
|
|
146
|
+
4. **Ownership**: CommandNet provides the orchestration logic; you provide the infrastructure.
|
|
147
|
+
|
|
148
|
+
## 📄 License
|
|
149
|
+
|
|
150
|
+
MIT
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import typing
|
|
2
2
|
import inspect
|
|
3
3
|
from typing import Any, Dict, List, Set, Type, Union, get_args, get_origin
|
|
4
|
-
from .node import Node
|
|
4
|
+
from .node import Node
|
|
5
5
|
|
|
6
6
|
class GraphAnalyzer:
|
|
7
7
|
@staticmethod
|
|
@@ -25,16 +25,12 @@ class GraphAnalyzer:
|
|
|
25
25
|
@staticmethod
|
|
26
26
|
def _get_node_names(type_hint: Any) -> List[str]:
|
|
27
27
|
origin = typing.get_origin(type_hint)
|
|
28
|
-
|
|
29
|
-
# 1. Handle Union (and Optional)
|
|
30
28
|
if origin is Union:
|
|
31
29
|
names = []
|
|
32
30
|
for arg in typing.get_args(type_hint):
|
|
33
31
|
if arg is type(None): continue
|
|
34
32
|
names.extend(GraphAnalyzer._get_node_names(arg))
|
|
35
33
|
return names
|
|
36
|
-
|
|
37
|
-
# 2. Handle Type[X] or typing.Type[X]
|
|
38
34
|
if origin in (type, typing.Type):
|
|
39
35
|
arg = typing.get_args(type_hint)[0]
|
|
40
36
|
if inspect.isclass(arg) and issubclass(arg, Node):
|
|
@@ -43,15 +39,12 @@ class GraphAnalyzer:
|
|
|
43
39
|
return [arg.__forward_arg__]
|
|
44
40
|
if isinstance(arg, str):
|
|
45
41
|
return [arg]
|
|
46
|
-
|
|
47
|
-
# 3. Direct class references
|
|
48
42
|
if inspect.isclass(type_hint) and issubclass(type_hint, Node):
|
|
49
43
|
return [type_hint.get_node_name()]
|
|
50
|
-
|
|
51
44
|
return []
|
|
52
45
|
|
|
53
46
|
@staticmethod
|
|
54
|
-
def get_transitions(node_cls: Type[Node]) -> Set[Type[Node]]:
|
|
47
|
+
def get_transitions(node_cls: Type[Node], registry: Dict[str, Type[Node]]) -> Set[Type[Node]]:
|
|
55
48
|
ret_annotation = node_cls.run.__annotations__.get("return")
|
|
56
49
|
if not ret_annotation: return set()
|
|
57
50
|
|
|
@@ -59,47 +52,42 @@ class GraphAnalyzer:
|
|
|
59
52
|
transitions = set()
|
|
60
53
|
|
|
61
54
|
for name in node_names:
|
|
62
|
-
if name not in
|
|
55
|
+
if name not in registry:
|
|
63
56
|
raise RuntimeError(
|
|
64
57
|
f"Graph Error: Node '{node_cls.get_node_name()}' references unknown node '{name}'. "
|
|
65
|
-
"Ensure it is
|
|
58
|
+
"Ensure it is passed to the Engine/Registry."
|
|
66
59
|
)
|
|
67
|
-
transitions.add(
|
|
68
|
-
|
|
60
|
+
transitions.add(registry[name])
|
|
69
61
|
return transitions
|
|
70
62
|
|
|
71
63
|
@staticmethod
|
|
72
|
-
def build_graph(start_node: Type[Node]) -> Dict[str, List[str]]:
|
|
64
|
+
def build_graph(start_node: Type[Node], registry: Dict[str, Type[Node]]) -> Dict[str, List[str]]:
|
|
73
65
|
graph = {}
|
|
74
66
|
visited, queue = set(), [start_node]
|
|
75
67
|
while queue:
|
|
76
68
|
current = queue.pop(0)
|
|
77
69
|
if current in visited: continue
|
|
78
70
|
visited.add(current)
|
|
79
|
-
transitions = GraphAnalyzer.get_transitions(current)
|
|
71
|
+
transitions = GraphAnalyzer.get_transitions(current, registry)
|
|
80
72
|
graph[current.get_node_name()] = [t.get_node_name() for t in transitions]
|
|
81
73
|
for t in transitions:
|
|
82
74
|
if t not in visited: queue.append(t)
|
|
83
75
|
return graph
|
|
84
76
|
|
|
85
77
|
@staticmethod
|
|
86
|
-
def validate(start_node: Type[Node]):
|
|
87
|
-
|
|
88
|
-
graph = GraphAnalyzer.build_graph(start_node)
|
|
89
|
-
|
|
78
|
+
def validate(start_node: Type[Node], registry: Dict[str, Type[Node]]):
|
|
79
|
+
graph = GraphAnalyzer.build_graph(start_node, registry)
|
|
90
80
|
for node_name, edges in graph.items():
|
|
91
|
-
node_cls =
|
|
81
|
+
node_cls = registry.get(node_name)
|
|
92
82
|
if not node_cls:
|
|
93
83
|
raise ValueError(f"Validation Error: Node '{node_name}' missing from registry.")
|
|
94
84
|
|
|
95
85
|
source_ctx = GraphAnalyzer.get_context_type(node_cls)
|
|
96
|
-
|
|
97
86
|
for edge in edges:
|
|
98
|
-
target_cls =
|
|
87
|
+
target_cls = registry.get(edge)
|
|
99
88
|
if not target_cls:
|
|
100
89
|
raise ValueError(f"Validation Error: Edge '{edge}' from '{node_name}' does not exist.")
|
|
101
90
|
|
|
102
|
-
# STRICT CONTEXT MATCHING
|
|
103
91
|
target_ctx = GraphAnalyzer.get_context_type(target_cls)
|
|
104
92
|
if source_ctx is not target_ctx and not issubclass(source_ctx, target_ctx):
|
|
105
93
|
raise TypeError(
|
|
@@ -107,5 +95,4 @@ class GraphAnalyzer:
|
|
|
107
95
|
f"but their Context types do not match! "
|
|
108
96
|
f"({source_ctx.__name__} -> {target_ctx.__name__})"
|
|
109
97
|
)
|
|
110
|
-
|
|
111
98
|
return True
|
|
@@ -5,8 +5,6 @@ from pydantic import BaseModel, ConfigDict
|
|
|
5
5
|
C = TypeVar('C', bound=BaseModel) # Context
|
|
6
6
|
P = TypeVar('P', bound=BaseModel) # Payload (Optional)
|
|
7
7
|
|
|
8
|
-
NODE_REGISTRY: Dict[str, Type['Node']] = {}
|
|
9
|
-
|
|
10
8
|
class ParallelTask(BaseModel):
|
|
11
9
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
12
10
|
node_cls: Type['Node']
|
|
@@ -33,18 +31,6 @@ class Node(Generic[C, P]):
|
|
|
33
31
|
def get_node_name(cls) -> str:
|
|
34
32
|
return cls.__name__
|
|
35
33
|
|
|
36
|
-
def __init_subclass__(cls, **kwargs):
|
|
37
|
-
super().__init_subclass__(**kwargs)
|
|
38
|
-
if not inspect.isabstract(cls):
|
|
39
|
-
name = cls.get_node_name()
|
|
40
|
-
# Prevent registry collisions across modules
|
|
41
|
-
if name in NODE_REGISTRY and NODE_REGISTRY[name] is not cls:
|
|
42
|
-
raise RuntimeError(
|
|
43
|
-
f"NODE_REGISTRY Collision: '{name}' is already registered by {NODE_REGISTRY[name].__module__}. "
|
|
44
|
-
f"Override `get_node_name()` on {cls.__module__}.{name} to ensure uniqueness."
|
|
45
|
-
)
|
|
46
|
-
NODE_REGISTRY[name] = cls
|
|
47
|
-
|
|
48
34
|
async def run(self, ctx: C, payload: Optional[P] = None) -> TransitionResult:
|
|
49
35
|
"""Executes node logic. Returns the Next Node, a Parallel request, a Schedule request, or None."""
|
|
50
36
|
pass
|
|
@@ -1,23 +1,41 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import logging
|
|
3
3
|
from datetime import datetime, timezone, timedelta
|
|
4
|
-
from typing import Optional, Type
|
|
4
|
+
from typing import Optional, Type, Iterable, Dict
|
|
5
5
|
from pydantic import BaseModel
|
|
6
6
|
|
|
7
7
|
from ..core.models import Event
|
|
8
|
-
from ..core.node import Node,
|
|
8
|
+
from ..core.node import Node, Parallel, Schedule
|
|
9
9
|
from ..core.graph import GraphAnalyzer
|
|
10
10
|
from ..interfaces.persistence import Persistence
|
|
11
11
|
from ..interfaces.event_bus import EventBus
|
|
12
12
|
from ..interfaces.observer import Observer
|
|
13
13
|
|
|
14
14
|
class Engine:
|
|
15
|
-
def __init__(
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
persistence: Persistence,
|
|
18
|
+
event_bus: EventBus,
|
|
19
|
+
nodes: Iterable[Type[Node]],
|
|
20
|
+
observer: Optional[Observer] = None
|
|
21
|
+
):
|
|
16
22
|
self.db = persistence
|
|
17
23
|
self.bus = event_bus
|
|
18
24
|
self.observer = observer or Observer()
|
|
19
25
|
self.logger = logging.getLogger("CommandNet")
|
|
20
26
|
self._scheduler_task: Optional[asyncio.Task] = None
|
|
27
|
+
|
|
28
|
+
# Build Engine-scoped registry
|
|
29
|
+
self._registry: Dict[str, Type[Node]] = {}
|
|
30
|
+
for node_cls in nodes:
|
|
31
|
+
name = node_cls.get_node_name()
|
|
32
|
+
if name in self._registry and self._registry[name] is not node_cls:
|
|
33
|
+
raise RuntimeError(f"Node name collision in Engine: '{name}'")
|
|
34
|
+
self._registry[name] = node_cls
|
|
35
|
+
|
|
36
|
+
def validate_graph(self, start_node: Type[Node]):
|
|
37
|
+
"""Helper to validate the graph using this engine's registry."""
|
|
38
|
+
return GraphAnalyzer.validate(start_node, self._registry)
|
|
21
39
|
|
|
22
40
|
async def start_worker(self, poll_interval: float = 1.0):
|
|
23
41
|
await self.bus.subscribe(self.process_event)
|
|
@@ -46,6 +64,9 @@ class Engine:
|
|
|
46
64
|
|
|
47
65
|
async def trigger_agent(self, agent_id: str, start_node: Type[Node], initial_context: BaseModel, payload: Optional[BaseModel] = None):
|
|
48
66
|
node_name = start_node.get_node_name()
|
|
67
|
+
if node_name not in self._registry:
|
|
68
|
+
raise ValueError(f"Node '{node_name}' is not registered with this Engine.")
|
|
69
|
+
|
|
49
70
|
start_event = Event(
|
|
50
71
|
agent_id=agent_id,
|
|
51
72
|
node_name=node_name,
|
|
@@ -64,6 +85,7 @@ class Engine:
|
|
|
64
85
|
locked = True
|
|
65
86
|
try:
|
|
66
87
|
if current_node_name != event.node_name:
|
|
88
|
+
# Same logic as before for sub-state/state
|
|
67
89
|
if "#" in event.agent_id:
|
|
68
90
|
parent_id = event.agent_id.split("#")[0]
|
|
69
91
|
await self.db.save_sub_state(event.agent_id, parent_id, current_node_name, ctx_dict, None)
|
|
@@ -72,9 +94,9 @@ class Engine:
|
|
|
72
94
|
locked = False
|
|
73
95
|
return
|
|
74
96
|
|
|
75
|
-
node_cls =
|
|
97
|
+
node_cls = self._registry.get(current_node_name)
|
|
76
98
|
if not node_cls:
|
|
77
|
-
raise RuntimeError(f"Node '{current_node_name}'
|
|
99
|
+
raise RuntimeError(f"Node '{current_node_name}' not found in this Engine's registry.")
|
|
78
100
|
|
|
79
101
|
ctx_type = GraphAnalyzer.get_context_type(node_cls)
|
|
80
102
|
payload_type = GraphAnalyzer.get_payload_type(node_cls)
|
|
@@ -101,20 +123,22 @@ class Engine:
|
|
|
101
123
|
else:
|
|
102
124
|
await self._handle_terminal(event.agent_id, current_node_name, ctx, duration)
|
|
103
125
|
|
|
104
|
-
locked = False
|
|
126
|
+
locked = False
|
|
105
127
|
|
|
106
128
|
except Exception as e:
|
|
107
129
|
await self.observer.on_error(event.agent_id, current_node_name, e)
|
|
108
130
|
raise
|
|
109
131
|
finally:
|
|
110
132
|
if locked:
|
|
111
|
-
# Deadlock prevention: Exception occurred before state was saved
|
|
112
133
|
await self.db.unlock_agent(event.agent_id)
|
|
113
134
|
|
|
135
|
+
# Remaining _handle_* methods use self._registry and self.validate_graph
|
|
114
136
|
async def _handle_transition(self, agent_id: str, from_node: str, next_node_cls: Type[Node], ctx: BaseModel, duration: float):
|
|
115
137
|
next_name = next_node_cls.get_node_name()
|
|
138
|
+
if next_name not in self._registry:
|
|
139
|
+
raise RuntimeError(f"Transition target '{next_name}' not in registry.")
|
|
140
|
+
|
|
116
141
|
await self.observer.on_transition(agent_id, from_node, next_name, duration)
|
|
117
|
-
|
|
118
142
|
next_event = Event(agent_id=agent_id, node_name=next_name)
|
|
119
143
|
await self.db.save_state(agent_id, next_name, ctx.model_dump(), next_event)
|
|
120
144
|
await self.bus.publish(next_event)
|
|
@@ -122,33 +146,31 @@ class Engine:
|
|
|
122
146
|
async def _handle_parallel_start(self, parent_id: str, parent_ctx: BaseModel, parallel: Parallel, duration: float):
|
|
123
147
|
join_name = parallel.join_node.get_node_name()
|
|
124
148
|
await self.observer.on_transition(parent_id, "ParallelStart", join_name, duration)
|
|
125
|
-
|
|
126
149
|
await self.db.create_task_group(parent_id=parent_id, join_node_name=join_name, task_count=len(parallel.branches))
|
|
127
150
|
|
|
128
151
|
for task in parallel.branches:
|
|
129
152
|
if not hasattr(parent_ctx, task.sub_context_path):
|
|
130
|
-
raise RuntimeError(f"Context missing path: '{task.sub_context_path}'.
|
|
153
|
+
raise RuntimeError(f"Context missing path: '{task.sub_context_path}'.")
|
|
131
154
|
|
|
132
155
|
sub_ctx = getattr(parent_ctx, task.sub_context_path)
|
|
133
156
|
sub_id = f"{parent_id}#{task.sub_context_path}"
|
|
157
|
+
node_name = task.node_cls.get_node_name()
|
|
134
158
|
|
|
135
159
|
evt = Event(
|
|
136
160
|
agent_id=sub_id,
|
|
137
|
-
node_name=
|
|
161
|
+
node_name=node_name,
|
|
138
162
|
payload=task.payload.model_dump() if hasattr(task.payload, "model_dump") else task.payload
|
|
139
163
|
)
|
|
140
|
-
await self.db.save_sub_state(sub_id, parent_id,
|
|
164
|
+
await self.db.save_sub_state(sub_id, parent_id, node_name, sub_ctx.model_dump(), evt)
|
|
141
165
|
await self.bus.publish(evt)
|
|
142
166
|
|
|
143
167
|
await self.db.save_state(parent_id, "WAITING_FOR_JOIN", parent_ctx.model_dump(), None)
|
|
144
168
|
|
|
145
169
|
async def _handle_terminal(self, agent_id: str, from_node: str, ctx: BaseModel, duration: float):
|
|
146
170
|
await self.observer.on_transition(agent_id, from_node, "TERMINAL", duration)
|
|
147
|
-
|
|
148
171
|
if "#" in agent_id:
|
|
149
172
|
parent_id = agent_id.split("#")[0]
|
|
150
173
|
await self.db.save_sub_state(agent_id, parent_id, "TERMINAL", ctx.model_dump(), None)
|
|
151
|
-
|
|
152
174
|
join_node_name = await self.db.register_sub_task_completion(agent_id)
|
|
153
175
|
if join_node_name:
|
|
154
176
|
await self._trigger_recompose(parent_id, join_node_name)
|
|
@@ -158,7 +180,6 @@ class Engine:
|
|
|
158
180
|
async def _handle_schedule(self, agent_id: str, from_node: str, ctx: BaseModel, schedule: Schedule, duration: float):
|
|
159
181
|
target_name = schedule.node_cls.get_node_name()
|
|
160
182
|
await self.observer.on_transition(agent_id, from_node, f"SCHEDULED:{target_name}", duration)
|
|
161
|
-
|
|
162
183
|
run_at_dt = datetime.now(timezone.utc) + timedelta(seconds=schedule.delay_seconds)
|
|
163
184
|
|
|
164
185
|
evt = Event(
|
|
@@ -175,7 +196,6 @@ class Engine:
|
|
|
175
196
|
if "#" in agent_id:
|
|
176
197
|
parent_id = agent_id.split("#")[0]
|
|
177
198
|
await self.db.save_sub_state(agent_id, parent_id, next_node, ctx.model_dump(), None)
|
|
178
|
-
|
|
179
199
|
if not scheduled:
|
|
180
200
|
join_node_name = await self.db.register_sub_task_completion(agent_id)
|
|
181
201
|
if join_node_name:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "commandnet"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.2"
|
|
4
4
|
description = "A lightweight, Pydantic-powered, distributed event-driven state machine and typed node graph runtime."
|
|
5
5
|
authors = [
|
|
6
6
|
{ name = "Christopher Vaz", email = "christophervaz160@gmail.com" }
|
commandnet-0.2.0/PKG-INFO
DELETED
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: commandnet
|
|
3
|
-
Version: 0.2.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
|
-
|
commandnet-0.2.0/README.md
DELETED
|
@@ -1,150 +0,0 @@
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|