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.
@@ -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"