dt-forge 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dt_forge/__init__.py +16 -0
- dt_forge/autonomous/__init__.py +19 -0
- dt_forge/autonomous/base.py +29 -0
- dt_forge/autonomous/deployer.py +74 -0
- dt_forge/autonomous/gym_env.py +109 -0
- dt_forge/autonomous/ooda.py +176 -0
- dt_forge/autonomous/overseer.py +255 -0
- dt_forge/autonomous/planner.py +73 -0
- dt_forge/autonomous/trainer.py +67 -0
- dt_forge/cli/__init__.py +1 -0
- dt_forge/cli/main.py +268 -0
- dt_forge/collection/__init__.py +15 -0
- dt_forge/collection/aggregate.py +132 -0
- dt_forge/collection/base.py +77 -0
- dt_forge/collection/collection_twin.py +107 -0
- dt_forge/collection/composite.py +144 -0
- dt_forge/collection/network_twin.py +189 -0
- dt_forge/collection/orchestrator.py +39 -0
- dt_forge/connector/__init__.py +12 -0
- dt_forge/connector/api_connector.py +62 -0
- dt_forge/connector/base.py +71 -0
- dt_forge/connector/ditto_connector.py +77 -0
- dt_forge/connector/mqtt_connector.py +83 -0
- dt_forge/core/__init__.py +18 -0
- dt_forge/core/base.py +95 -0
- dt_forge/core/config.py +153 -0
- dt_forge/core/events.py +74 -0
- dt_forge/core/lifecycle.py +65 -0
- dt_forge/core/registry.py +77 -0
- dt_forge/core/types.py +57 -0
- dt_forge/data/__init__.py +13 -0
- dt_forge/data/management/__init__.py +4 -0
- dt_forge/data/management/health.py +51 -0
- dt_forge/data/management/pipeline.py +96 -0
- dt_forge/data/storage/__init__.py +16 -0
- dt_forge/data/storage/base.py +84 -0
- dt_forge/data/storage/influx.py +136 -0
- dt_forge/data/storage/minio_store.py +84 -0
- dt_forge/data/storage/mongo.py +94 -0
- dt_forge/data/storage/postgres.py +47 -0
- dt_forge/data/storage/provenance.py +58 -0
- dt_forge/data/storage/redis_store.py +65 -0
- dt_forge/data/text_ingestor.py +140 -0
- dt_forge/data/writer.py +103 -0
- dt_forge/infra/__init__.py +4 -0
- dt_forge/infra/docker.py +350 -0
- dt_forge/infra/health_check.py +95 -0
- dt_forge/intelligent/__init__.py +15 -0
- dt_forge/intelligent/agent.py +150 -0
- dt_forge/intelligent/base.py +42 -0
- dt_forge/intelligent/knowledge_graph.py +184 -0
- dt_forge/intelligent/mas.py +225 -0
- dt_forge/intelligent/tools.py +100 -0
- dt_forge/ml/__init__.py +3 -0
- dt_forge/ml/corpus.py +85 -0
- dt_forge/network/__init__.py +4 -0
- dt_forge/network/ingestor.py +96 -0
- dt_forge/network/transport.py +98 -0
- dt_forge/notifications/__init__.py +3 -0
- dt_forge/notifications/notifier.py +103 -0
- dt_forge/physical/__init__.py +4 -0
- dt_forge/physical/adapters/__init__.py +1 -0
- dt_forge/physical/base.py +34 -0
- dt_forge/physical/simulator.py +107 -0
- dt_forge/reactive/__init__.py +16 -0
- dt_forge/reactive/actions.py +50 -0
- dt_forge/reactive/base.py +31 -0
- dt_forge/reactive/fsm_engine.py +204 -0
- dt_forge/reactive/pid.py +94 -0
- dt_forge/reactive/rule_engine.py +135 -0
- dt_forge/reactive/rule_repository.py +113 -0
- dt_forge/services/__init__.py +4 -0
- dt_forge/services/api/__init__.py +3 -0
- dt_forge/services/api/app.py +51 -0
- dt_forge/services/api/routes.py +63 -0
- dt_forge/services/api/streaming.py +65 -0
- dt_forge/services/base.py +57 -0
- dt_forge/services/composite.py +35 -0
- dt_forge/services/ditto/__init__.py +4 -0
- dt_forge/services/ditto/client.py +168 -0
- dt_forge/services/ditto/sync.py +69 -0
- dt_forge/session/__init__.py +3 -0
- dt_forge/session/context.py +153 -0
- dt_forge/simulation/__init__.py +16 -0
- dt_forge/simulation/base.py +23 -0
- dt_forge/simulation/discrete_event.py +59 -0
- dt_forge/simulation/forecaster.py +55 -0
- dt_forge/simulation/ode_model.py +78 -0
- dt_forge/simulation/runner.py +105 -0
- dt_forge/simulation/surrogate.py +79 -0
- dt_forge-0.3.0.dist-info/METADATA +44 -0
- dt_forge-0.3.0.dist-info/RECORD +95 -0
- dt_forge-0.3.0.dist-info/WHEEL +5 -0
- dt_forge-0.3.0.dist-info/entry_points.txt +2 -0
- dt_forge-0.3.0.dist-info/top_level.txt +1 -0
dt_forge/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""DT-Forge: Domain-agnostic Python framework for digital twin architectures."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from dt_forge.core.base import AbstractDigitalTwin, LayerBase
|
|
6
|
+
from dt_forge.core.config import TwinConfig, SensorFieldSpec
|
|
7
|
+
from dt_forge.core.events import EventBus, DomainEvent
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"AbstractDigitalTwin",
|
|
11
|
+
"LayerBase",
|
|
12
|
+
"TwinConfig",
|
|
13
|
+
"SensorFieldSpec",
|
|
14
|
+
"EventBus",
|
|
15
|
+
"DomainEvent",
|
|
16
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from dt_forge.autonomous.base import AbstractAutonomousController
|
|
2
|
+
from dt_forge.autonomous.ooda import OODALoop
|
|
3
|
+
from dt_forge.autonomous.overseer import AutonomousOverseer, OverseerDecision
|
|
4
|
+
from dt_forge.autonomous.planner import GoalPlanner, Goal
|
|
5
|
+
from dt_forge.autonomous.gym_env import GenericTwinEnv
|
|
6
|
+
from dt_forge.autonomous.trainer import PolicyTrainer
|
|
7
|
+
from dt_forge.autonomous.deployer import PolicyDeployer
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"AbstractAutonomousController",
|
|
11
|
+
"OODALoop",
|
|
12
|
+
"AutonomousOverseer",
|
|
13
|
+
"OverseerDecision",
|
|
14
|
+
"GoalPlanner",
|
|
15
|
+
"Goal",
|
|
16
|
+
"GenericTwinEnv",
|
|
17
|
+
"PolicyTrainer",
|
|
18
|
+
"PolicyDeployer",
|
|
19
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Abstract base for autonomous controllers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AbstractAutonomousController(ABC):
|
|
9
|
+
"""Base class for any autonomous control strategy."""
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
async def observe(self) -> dict:
|
|
13
|
+
"""Collect a comprehensive situation snapshot."""
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
async def orient(self, observation: dict) -> dict:
|
|
18
|
+
"""Contextualise the situation against goals."""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
async def decide(self, observation: dict, assessment: dict) -> dict:
|
|
23
|
+
"""Select an action plan."""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
async def act(self, plan: dict) -> None:
|
|
28
|
+
"""Execute the decided plan."""
|
|
29
|
+
...
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""PolicyDeployer: live RL inference using a trained SB3 policy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from dt_forge.autonomous.gym_env import GenericTwinEnv
|
|
12
|
+
from dt_forge.data.storage.base import TimeSeriesStore
|
|
13
|
+
from dt_forge.network.transport import MQTTTransport
|
|
14
|
+
from dt_forge.core.config import TwinConfig
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PolicyDeployer:
|
|
20
|
+
"""
|
|
21
|
+
Loads a trained SB3 policy and applies it in a live twin loop.
|
|
22
|
+
|
|
23
|
+
On each step_once() call:
|
|
24
|
+
1. Reads current observations from InfluxDB
|
|
25
|
+
2. Runs the policy to get an action
|
|
26
|
+
3. Publishes the control command via MQTT
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
policy_path: str,
|
|
32
|
+
config: "TwinConfig",
|
|
33
|
+
ts_store: "TimeSeriesStore",
|
|
34
|
+
mqtt_transport: "MQTTTransport",
|
|
35
|
+
obs_fields: list[str],
|
|
36
|
+
control_field: str,
|
|
37
|
+
ctrl_min: float,
|
|
38
|
+
ctrl_max: float,
|
|
39
|
+
algorithm: str = "SAC",
|
|
40
|
+
):
|
|
41
|
+
from stable_baselines3 import SAC, TD3, PPO, A2C
|
|
42
|
+
|
|
43
|
+
self._cfg = config
|
|
44
|
+
self._ts = ts_store
|
|
45
|
+
self._mqtt = mqtt_transport
|
|
46
|
+
self._obs_fields = obs_fields
|
|
47
|
+
self._control_field = control_field
|
|
48
|
+
self._ctrl_min = ctrl_min
|
|
49
|
+
self._ctrl_max = ctrl_max
|
|
50
|
+
|
|
51
|
+
_algo_map = {"SAC": SAC, "TD3": TD3, "PPO": PPO, "A2C": A2C}
|
|
52
|
+
AlgoClass = _algo_map.get(algorithm.upper(), SAC)
|
|
53
|
+
self._policy = AlgoClass.load(policy_path)
|
|
54
|
+
log.info("PolicyDeployer loaded '%s' (%s)", policy_path, algorithm)
|
|
55
|
+
|
|
56
|
+
def _get_observation(self) -> np.ndarray:
|
|
57
|
+
return np.array(
|
|
58
|
+
[self._ts.get_latest(f) or 0.0 for f in self._obs_fields],
|
|
59
|
+
dtype=np.float32,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def _scale_action(self, action: np.ndarray) -> float:
|
|
63
|
+
a = float(np.clip(action[0], -1.0, 1.0))
|
|
64
|
+
return self._ctrl_min + (a + 1.0) / 2.0 * (self._ctrl_max - self._ctrl_min)
|
|
65
|
+
|
|
66
|
+
async def step_once(self) -> float:
|
|
67
|
+
obs = self._get_observation()
|
|
68
|
+
action, _ = self._policy.predict(obs, deterministic=True)
|
|
69
|
+
ctrl = self._scale_action(action)
|
|
70
|
+
self._mqtt.publish(
|
|
71
|
+
self._cfg.topic_control,
|
|
72
|
+
{self._control_field: round(ctrl, 4)},
|
|
73
|
+
)
|
|
74
|
+
return ctrl
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""GenericTwinEnv: Gymnasium environment wrapping any TwinModel."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, Callable, TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from dt_forge.simulation.base import TwinModel
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GenericTwinEnv:
|
|
17
|
+
"""
|
|
18
|
+
Gymnasium environment that wraps any TwinModel.
|
|
19
|
+
|
|
20
|
+
Configuration:
|
|
21
|
+
model — TwinModel instance to use as the environment
|
|
22
|
+
control_field — name of the variable the agent adjusts
|
|
23
|
+
process_variable— name of the variable being regulated
|
|
24
|
+
target — desired value for process_variable
|
|
25
|
+
ctrl_min/max — valid range for control output
|
|
26
|
+
obs_fields — field names forming the observation
|
|
27
|
+
obs_low/high — bounds for observation space
|
|
28
|
+
reward_fn — optional custom reward function(obs, target) → float
|
|
29
|
+
max_steps — episode length
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
metadata = {"render_modes": []}
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
model: "TwinModel",
|
|
38
|
+
control_field: str,
|
|
39
|
+
process_variable: str,
|
|
40
|
+
target: float,
|
|
41
|
+
ctrl_min: float,
|
|
42
|
+
ctrl_max: float,
|
|
43
|
+
obs_fields: list[str],
|
|
44
|
+
obs_low: np.ndarray,
|
|
45
|
+
obs_high: np.ndarray,
|
|
46
|
+
reward_fn: Callable | None = None,
|
|
47
|
+
max_steps: int = 500,
|
|
48
|
+
):
|
|
49
|
+
import gymnasium as gym
|
|
50
|
+
from gymnasium import spaces
|
|
51
|
+
|
|
52
|
+
self.model = model
|
|
53
|
+
self.control_field = control_field
|
|
54
|
+
self.process_variable = process_variable
|
|
55
|
+
self.target = target
|
|
56
|
+
self.ctrl_min = ctrl_min
|
|
57
|
+
self.ctrl_max = ctrl_max
|
|
58
|
+
self.obs_fields = obs_fields
|
|
59
|
+
self.reward_fn = reward_fn or self._default_reward
|
|
60
|
+
self.max_steps = max_steps
|
|
61
|
+
self._step_count = 0
|
|
62
|
+
self._last_obs: dict[str, float] = {}
|
|
63
|
+
|
|
64
|
+
self.observation_space = spaces.Box(
|
|
65
|
+
obs_low.astype(np.float32),
|
|
66
|
+
obs_high.astype(np.float32),
|
|
67
|
+
dtype=np.float32,
|
|
68
|
+
)
|
|
69
|
+
self.action_space = spaces.Box(
|
|
70
|
+
np.array([-1.0], dtype=np.float32),
|
|
71
|
+
np.array([1.0], dtype=np.float32),
|
|
72
|
+
shape=(1,),
|
|
73
|
+
dtype=np.float32,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def _default_reward(self, obs: dict, target: float) -> float:
|
|
77
|
+
pv = obs.get(f"sim_{self.process_variable}") or obs.get(self.process_variable) or 0.0
|
|
78
|
+
return -abs(pv - target)
|
|
79
|
+
|
|
80
|
+
def _scale_action(self, action: np.ndarray) -> float:
|
|
81
|
+
"""Map [-1, 1] → [ctrl_min, ctrl_max]."""
|
|
82
|
+
a = float(np.clip(action[0], -1.0, 1.0))
|
|
83
|
+
return self.ctrl_min + (a + 1.0) / 2.0 * (self.ctrl_max - self.ctrl_min)
|
|
84
|
+
|
|
85
|
+
def _get_obs(self) -> np.ndarray:
|
|
86
|
+
return np.array(
|
|
87
|
+
[self._last_obs.get(f, 0.0) for f in self.obs_fields],
|
|
88
|
+
dtype=np.float32,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def reset(self, *, seed=None, options=None):
|
|
92
|
+
self.model.reset()
|
|
93
|
+
self._step_count = 0
|
|
94
|
+
self._last_obs = {}
|
|
95
|
+
obs = self._get_obs()
|
|
96
|
+
return obs, {}
|
|
97
|
+
|
|
98
|
+
def step(self, action: np.ndarray):
|
|
99
|
+
ctrl = self._scale_action(action)
|
|
100
|
+
outputs = self.model.step(dt=1.0, inputs={self.control_field: ctrl})
|
|
101
|
+
self._last_obs = outputs
|
|
102
|
+
obs = self._get_obs()
|
|
103
|
+
reward = self.reward_fn(outputs, self.target)
|
|
104
|
+
self._step_count += 1
|
|
105
|
+
terminated = self._step_count >= self.max_steps
|
|
106
|
+
return obs, reward, terminated, False, {}
|
|
107
|
+
|
|
108
|
+
def render(self): ...
|
|
109
|
+
def close(self): ...
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""OODALoop: Observe-Orient-Decide-Act autonomous control layer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from dt_forge.autonomous.base import AbstractAutonomousController
|
|
11
|
+
from dt_forge.core.base import LayerBase
|
|
12
|
+
from dt_forge.core.events import DomainEvent
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from dt_forge.autonomous.deployer import PolicyDeployer
|
|
16
|
+
from dt_forge.autonomous.planner import GoalPlanner
|
|
17
|
+
from dt_forge.core.config import TwinConfig
|
|
18
|
+
from dt_forge.core.events import EventBus
|
|
19
|
+
from dt_forge.data.storage.base import CacheStore, DocumentStore, TimeSeriesStore
|
|
20
|
+
from dt_forge.intelligent.mas import MultiAgentSystem
|
|
21
|
+
from dt_forge.notifications.notifier import HumanNotifier
|
|
22
|
+
from dt_forge.reactive.rule_engine import ThresholdRuleEngine
|
|
23
|
+
from dt_forge.services.ditto.client import DittoClient
|
|
24
|
+
from dt_forge.simulation.base import TwinModel
|
|
25
|
+
|
|
26
|
+
log = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OODALoop(LayerBase, AbstractAutonomousController):
|
|
30
|
+
"""
|
|
31
|
+
The OODA loop provides full situational awareness and autonomous control.
|
|
32
|
+
|
|
33
|
+
Observe — gather state from all lower layers
|
|
34
|
+
Orient — contextualise using models, history, goals
|
|
35
|
+
Decide — select action plan (rule-based, RL policy, goal planner)
|
|
36
|
+
Act — execute actions on lower layers or via connectors
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
layer_name = "autonomous"
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
config: "TwinConfig",
|
|
44
|
+
event_bus: "EventBus",
|
|
45
|
+
*,
|
|
46
|
+
ts_store: "TimeSeriesStore",
|
|
47
|
+
cache: "CacheStore",
|
|
48
|
+
doc_store: "DocumentStore",
|
|
49
|
+
ditto_client: "DittoClient",
|
|
50
|
+
models: dict[str, "TwinModel"],
|
|
51
|
+
reactive: "ThresholdRuleEngine",
|
|
52
|
+
mas: "MultiAgentSystem",
|
|
53
|
+
connectors: list,
|
|
54
|
+
planner: "GoalPlanner",
|
|
55
|
+
policy: "PolicyDeployer | None" = None,
|
|
56
|
+
loop_interval: int = 5,
|
|
57
|
+
notifier: "HumanNotifier | None" = None,
|
|
58
|
+
):
|
|
59
|
+
super().__init__(config, event_bus)
|
|
60
|
+
self.ts = ts_store
|
|
61
|
+
self.cache = cache
|
|
62
|
+
self.doc = doc_store
|
|
63
|
+
self.ditto = ditto_client
|
|
64
|
+
self.models = models
|
|
65
|
+
self.reactive = reactive
|
|
66
|
+
self.mas = mas
|
|
67
|
+
self.connectors = connectors
|
|
68
|
+
self.planner = planner
|
|
69
|
+
self.policy = policy
|
|
70
|
+
self.loop_interval = loop_interval
|
|
71
|
+
self.notifier = notifier
|
|
72
|
+
|
|
73
|
+
async def observe(self) -> dict:
|
|
74
|
+
base = {
|
|
75
|
+
"state": self.cache.get_state(),
|
|
76
|
+
"health": self.cache.get_latest_cached("health_score"),
|
|
77
|
+
"telemetry": {
|
|
78
|
+
f: self.ts.get_latest(f) for f in self.config.field_names
|
|
79
|
+
},
|
|
80
|
+
"recent_events": self.doc.get_recent_events(5),
|
|
81
|
+
}
|
|
82
|
+
if self.mas:
|
|
83
|
+
base["mas_findings"] = {
|
|
84
|
+
a.agent_name: self.mas.get_agent_detail(a.agent_name)
|
|
85
|
+
for a in self.mas.agents
|
|
86
|
+
}
|
|
87
|
+
return base
|
|
88
|
+
|
|
89
|
+
async def direct_agent(self, agent_name: str, question: str) -> str:
|
|
90
|
+
"""Send a targeted query to a named MAS agent from the autonomous layer."""
|
|
91
|
+
if self.mas is None:
|
|
92
|
+
return "MAS layer not available."
|
|
93
|
+
return await self.mas.ask_agent(agent_name, question)
|
|
94
|
+
|
|
95
|
+
async def orient(self, observation: dict) -> dict:
|
|
96
|
+
return await self.planner.assess(observation)
|
|
97
|
+
|
|
98
|
+
async def decide(self, observation: dict, assessment: dict) -> dict:
|
|
99
|
+
if assessment.get("requires_human_intervention"):
|
|
100
|
+
return {
|
|
101
|
+
"action": "request_human",
|
|
102
|
+
"reason": assessment.get("reason", "intervention required"),
|
|
103
|
+
}
|
|
104
|
+
if assessment.get("requires_shutdown"):
|
|
105
|
+
return {
|
|
106
|
+
"action": "shutdown",
|
|
107
|
+
"reason": assessment.get("reason", "autonomous shutdown"),
|
|
108
|
+
}
|
|
109
|
+
if assessment.get("needs_external_data"):
|
|
110
|
+
return {
|
|
111
|
+
"action": "query_peer",
|
|
112
|
+
"target_twin": assessment.get("target_twin", ""),
|
|
113
|
+
"query": assessment.get("query", {}),
|
|
114
|
+
}
|
|
115
|
+
if self.policy and assessment.get("risk_level") == "low":
|
|
116
|
+
return {"action": "rl_control"}
|
|
117
|
+
return {"action": "maintain_current"}
|
|
118
|
+
|
|
119
|
+
async def act(self, plan: dict) -> None:
|
|
120
|
+
action = plan["action"]
|
|
121
|
+
|
|
122
|
+
if action == "request_human":
|
|
123
|
+
self.doc.log_event(
|
|
124
|
+
"human_intervention_requested", plan, severity="critical"
|
|
125
|
+
)
|
|
126
|
+
await self.bus.publish(
|
|
127
|
+
DomainEvent(
|
|
128
|
+
event_type="autonomous.human_requested",
|
|
129
|
+
source_layer="autonomous",
|
|
130
|
+
source_asset=self.config.asset_id,
|
|
131
|
+
payload=plan,
|
|
132
|
+
severity="critical",
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
self.log.warning("Human intervention requested: %s", plan.get("reason"))
|
|
136
|
+
if self.notifier:
|
|
137
|
+
await self.notifier.send(plan.get("reason", "intervention required"), plan)
|
|
138
|
+
|
|
139
|
+
elif action == "shutdown":
|
|
140
|
+
self.reactive.shutdown_asset() # type: ignore[attr-defined]
|
|
141
|
+
self.doc.log_event("autonomous_shutdown", plan, severity="critical")
|
|
142
|
+
|
|
143
|
+
elif action == "query_peer":
|
|
144
|
+
for conn in self.connectors:
|
|
145
|
+
if conn.can_reach(plan.get("target_twin", "")):
|
|
146
|
+
result = await conn.query(plan["target_twin"], plan.get("query", {}))
|
|
147
|
+
self.doc.log_event("peer_query", {"result": result}, severity="info")
|
|
148
|
+
break
|
|
149
|
+
|
|
150
|
+
elif action == "rl_control":
|
|
151
|
+
if self.policy:
|
|
152
|
+
await self.policy.step_once()
|
|
153
|
+
|
|
154
|
+
async def start(self) -> None:
|
|
155
|
+
self._running = True
|
|
156
|
+
self.log.info("OODA autonomous loop started (interval=%ds)", self.loop_interval)
|
|
157
|
+
while self._running:
|
|
158
|
+
try:
|
|
159
|
+
obs = await self.observe()
|
|
160
|
+
assessment = await self.orient(obs)
|
|
161
|
+
plan = await self.decide(obs, assessment)
|
|
162
|
+
await self.act(plan)
|
|
163
|
+
try:
|
|
164
|
+
# Publish a compact summary for the dashboard and monitoring tools.
|
|
165
|
+
# Wrapped in try/except so a Redis outage cannot kill the control loop.
|
|
166
|
+
self.cache.set_latest("ooda_last_cycle", {
|
|
167
|
+
"action": plan.get("action", "unknown"),
|
|
168
|
+
"reason": plan.get("reason", ""),
|
|
169
|
+
"risk_level": assessment.get("risk_level", ""),
|
|
170
|
+
"ts_s": int(time.time()),
|
|
171
|
+
})
|
|
172
|
+
except Exception:
|
|
173
|
+
pass
|
|
174
|
+
except Exception as e:
|
|
175
|
+
self.log.error("OODA loop error: %s", e)
|
|
176
|
+
await asyncio.sleep(self.loop_interval)
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""AutonomousOverseer: LLM-driven Layer 6 decision agent for the OODA loop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import TYPE_CHECKING, Callable, Awaitable
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from dt_forge.core.config import TwinConfig
|
|
11
|
+
from dt_forge.intelligent.mas import MultiAgentSystem
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class OverseerDecision:
|
|
18
|
+
"""Structured output from one overseer reasoning cycle."""
|
|
19
|
+
action: str
|
|
20
|
+
reasoning: str
|
|
21
|
+
risk_level: str
|
|
22
|
+
goals_addressed: list[str] = field(default_factory=list)
|
|
23
|
+
agent_queries: list[dict] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_SAFE_DEFAULT = OverseerDecision(
|
|
27
|
+
action="no_action",
|
|
28
|
+
reasoning="Overseer did not produce a structured decision — defaulting to no_action.",
|
|
29
|
+
risk_level="low",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AutonomousOverseer:
|
|
34
|
+
"""
|
|
35
|
+
LangChain tool-calling agent that drives the OODA orient and decide phases.
|
|
36
|
+
|
|
37
|
+
On each call to run(), the overseer:
|
|
38
|
+
1. Receives a formatted observation including all MAS agent findings
|
|
39
|
+
2. Optionally calls query_mas_agent to interrogate specific agents
|
|
40
|
+
3. Calls submit_decision to produce a structured OverseerDecision
|
|
41
|
+
|
|
42
|
+
The decision is logged verbatim for human audit. The OODA loop's decide()
|
|
43
|
+
method then applies hard safety constraints on top — the overseer handles
|
|
44
|
+
strategy, the constraints handle safety boundaries.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
config : TwinConfig
|
|
49
|
+
llm : LangChain chat model (ChatOllama, ChatOpenAI, etc.)
|
|
50
|
+
mas : The OODA loop's own MultiAgentSystem
|
|
51
|
+
goals : Goal names in priority order (used in system prompt)
|
|
52
|
+
available_actions : Action strings the overseer may choose
|
|
53
|
+
extra_query_fn : Optional async (agent_name, question) -> str for cross-MAS queries
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
config: "TwinConfig",
|
|
59
|
+
llm,
|
|
60
|
+
mas: "MultiAgentSystem",
|
|
61
|
+
goals: list[str],
|
|
62
|
+
available_actions: list[str],
|
|
63
|
+
extra_query_fn: Callable[[str, str], Awaitable[str]] | None = None,
|
|
64
|
+
):
|
|
65
|
+
from langchain_classic.agents import AgentExecutor, create_tool_calling_agent
|
|
66
|
+
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
|
67
|
+
from langchain_core.tools import StructuredTool
|
|
68
|
+
|
|
69
|
+
self._mas = mas
|
|
70
|
+
self._available_actions = available_actions
|
|
71
|
+
self._extra_query_fn = extra_query_fn
|
|
72
|
+
self._captured: OverseerDecision | None = None
|
|
73
|
+
self._queries: list[dict] = []
|
|
74
|
+
|
|
75
|
+
async def _query_agent(agent_name: str, question: str) -> str:
|
|
76
|
+
"""Query a named MAS agent for deeper analysis on a specific topic.
|
|
77
|
+
|
|
78
|
+
agent_name: exact agent name (e.g. 'irrigation_decision', 'soil_moisture_monitor')
|
|
79
|
+
question: specific analytical question to put to the agent
|
|
80
|
+
"""
|
|
81
|
+
try:
|
|
82
|
+
if self._extra_query_fn:
|
|
83
|
+
result = await self._extra_query_fn(agent_name, question)
|
|
84
|
+
else:
|
|
85
|
+
result = await self._mas.ask_agent(agent_name, question)
|
|
86
|
+
self._queries.append({
|
|
87
|
+
"agent": agent_name,
|
|
88
|
+
"question": question,
|
|
89
|
+
"answer": result[:500],
|
|
90
|
+
})
|
|
91
|
+
return result
|
|
92
|
+
except Exception as e:
|
|
93
|
+
return f"Error querying '{agent_name}': {e}"
|
|
94
|
+
|
|
95
|
+
def _submit_decision(
|
|
96
|
+
action: str,
|
|
97
|
+
reasoning: str,
|
|
98
|
+
risk_level: str,
|
|
99
|
+
goals_addressed: list[str] | None = None,
|
|
100
|
+
) -> str:
|
|
101
|
+
"""Submit your final autonomous decision. You MUST call this tool.
|
|
102
|
+
|
|
103
|
+
action : one of the available actions listed in your instructions
|
|
104
|
+
reasoning : full explanation — cite specific agent findings and sensor values
|
|
105
|
+
risk_level : 'low', 'medium', 'high', or 'critical'
|
|
106
|
+
goals_addressed : list of goal names this decision serves
|
|
107
|
+
"""
|
|
108
|
+
self._captured = OverseerDecision(
|
|
109
|
+
action=action,
|
|
110
|
+
reasoning=reasoning,
|
|
111
|
+
risk_level=risk_level,
|
|
112
|
+
goals_addressed=goals_addressed or [],
|
|
113
|
+
agent_queries=list(self._queries),
|
|
114
|
+
)
|
|
115
|
+
return "Decision recorded."
|
|
116
|
+
|
|
117
|
+
tools = [
|
|
118
|
+
StructuredTool.from_function(
|
|
119
|
+
coroutine=_query_agent,
|
|
120
|
+
name="query_mas_agent",
|
|
121
|
+
description=(
|
|
122
|
+
"Query a specific MAS agent by name for deeper analysis. "
|
|
123
|
+
"Use when findings are ambiguous or you need more detail before deciding."
|
|
124
|
+
),
|
|
125
|
+
),
|
|
126
|
+
StructuredTool.from_function(
|
|
127
|
+
func=_submit_decision,
|
|
128
|
+
name="submit_decision",
|
|
129
|
+
description=(
|
|
130
|
+
"Submit your final autonomous decision. "
|
|
131
|
+
"MUST be called before finishing your analysis."
|
|
132
|
+
),
|
|
133
|
+
),
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
prompt = ChatPromptTemplate.from_messages([
|
|
137
|
+
("system", self._system_prompt(config, goals, available_actions)),
|
|
138
|
+
("human", "{input}"),
|
|
139
|
+
MessagesPlaceholder("agent_scratchpad"),
|
|
140
|
+
])
|
|
141
|
+
|
|
142
|
+
agent = create_tool_calling_agent(llm, tools, prompt)
|
|
143
|
+
self.executor = AgentExecutor(
|
|
144
|
+
agent=agent, tools=tools,
|
|
145
|
+
verbose=False,
|
|
146
|
+
max_iterations=8, # cap tool-call rounds to prevent runaway LLM loops
|
|
147
|
+
return_intermediate_steps=False,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _system_prompt(config, goals: list[str], available_actions: list[str]) -> str:
|
|
152
|
+
goals_block = "\n".join(f" {i+1}. {g}" for i, g in enumerate(goals))
|
|
153
|
+
actions_block = ", ".join(f"'{a}'" for a in available_actions)
|
|
154
|
+
return (
|
|
155
|
+
f"You are the Autonomous Overseer of the '{config.asset_name}' digital twin "
|
|
156
|
+
f"(ID: {config.asset_id}). You are Layer 6 — the highest-level autonomous "
|
|
157
|
+
f"decision maker in a layered control architecture.\n\n"
|
|
158
|
+
"LAYER RESPONSIBILITIES:\n"
|
|
159
|
+
" Layer 4 – Reactive: Threshold rules, FSM transitions, PID control. "
|
|
160
|
+
"Handles routine, deterministic situations automatically. You do NOT duplicate these.\n"
|
|
161
|
+
" Layer 5 – Intelligent: MAS agents that diagnose, analyse, and reason. "
|
|
162
|
+
"They observe and produce findings but CANNOT act on their own.\n"
|
|
163
|
+
" Layer 6 – You: Strategic, goal-driven decisions. You read MAS findings, "
|
|
164
|
+
"query agents for deeper analysis when needed, and decide what the system should do "
|
|
165
|
+
"at a level no lower layer can handle alone.\n\n"
|
|
166
|
+
f"YOUR DECLARED GOALS (priority order):\n{goals_block}\n\n"
|
|
167
|
+
f"AVAILABLE ACTIONS: {actions_block}\n\n"
|
|
168
|
+
"YOUR PROCESS EACH CYCLE:\n"
|
|
169
|
+
"1. Read the MAS agent findings. Agents flagging anomalies deserve attention.\n"
|
|
170
|
+
"2. If any finding is ambiguous or you need deeper analysis, call query_mas_agent.\n"
|
|
171
|
+
"3. Assess whether the situation requires strategic intervention or is already "
|
|
172
|
+
"handled by lower layers.\n"
|
|
173
|
+
"4. Reason explicitly about your declared goals and which are at risk.\n"
|
|
174
|
+
"5. Call submit_decision with: action, your full reasoning (cite findings and "
|
|
175
|
+
"sensor values), risk level, and which goals your decision addresses.\n\n"
|
|
176
|
+
"CONSTRAINTS:\n"
|
|
177
|
+
"- Choose 'no_action' when the reactive layer already covers the situation.\n"
|
|
178
|
+
"- Only escalate to actuating actions (e.g. 'irrigate') when agent findings or "
|
|
179
|
+
"telemetry justify it beyond what rule-based layers already handle.\n"
|
|
180
|
+
"- Your reasoning is logged verbatim for human audit — be specific and traceable.\n"
|
|
181
|
+
"- You MUST call submit_decision before finishing."
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def _format_observation(self, observation: dict) -> str:
|
|
185
|
+
lines = ["=== CURRENT SYSTEM STATE ==="]
|
|
186
|
+
|
|
187
|
+
for key in ("sdt_state", "pdt_state", "bpdt_state", "fsm_state"):
|
|
188
|
+
if key in observation:
|
|
189
|
+
lines.append(f" {key}: {observation[key]}")
|
|
190
|
+
|
|
191
|
+
for key in ("vwc_10cm", "cwsi", "psi_mpa", "yield_penalty",
|
|
192
|
+
"depl_rate", "et_rate", "time_since_irr_hr",
|
|
193
|
+
"vwc_30cm", "time_to_pwp_hr", "time_to_wilting_hr"):
|
|
194
|
+
if key in observation:
|
|
195
|
+
v = observation[key]
|
|
196
|
+
lines.append(f" {key}: {v:.4f}" if isinstance(v, float) else f" {key}: {v}")
|
|
197
|
+
|
|
198
|
+
# MAS findings — consolidated from all twins
|
|
199
|
+
all_findings: dict = observation.get("all_mas_findings") or {}
|
|
200
|
+
if not all_findings:
|
|
201
|
+
own = observation.get("mas_findings")
|
|
202
|
+
if own:
|
|
203
|
+
all_findings = {"this_twin": own}
|
|
204
|
+
|
|
205
|
+
if all_findings:
|
|
206
|
+
lines.append("\n=== MAS AGENT FINDINGS ===")
|
|
207
|
+
for twin_label, findings in all_findings.items():
|
|
208
|
+
if not findings:
|
|
209
|
+
continue
|
|
210
|
+
lines.append(f"\n[{twin_label.upper()} agents]")
|
|
211
|
+
for agent_name, detail in findings.items():
|
|
212
|
+
if not detail:
|
|
213
|
+
lines.append(f" {agent_name}: (no data yet)")
|
|
214
|
+
continue
|
|
215
|
+
err = detail.get("error")
|
|
216
|
+
if err:
|
|
217
|
+
lines.append(f" {agent_name}: ERROR — {err}")
|
|
218
|
+
continue
|
|
219
|
+
anomaly = detail.get("anomaly", False)
|
|
220
|
+
findings_inner = detail.get("findings") or {}
|
|
221
|
+
action = findings_inner.get("action", "monitoring")
|
|
222
|
+
summary = findings_inner.get("summary", "")
|
|
223
|
+
flag = "⚠ ANOMALY" if anomaly else "OK"
|
|
224
|
+
lines.append(f" {agent_name} [{flag}] action={action}")
|
|
225
|
+
if summary:
|
|
226
|
+
lines.append(f" {summary[:350]}")
|
|
227
|
+
|
|
228
|
+
recent = observation.get("recent_events", [])
|
|
229
|
+
if recent:
|
|
230
|
+
lines.append("\n=== RECENT EVENTS ===")
|
|
231
|
+
for ev in recent[:5]:
|
|
232
|
+
sev = ev.get("severity", "info").upper()
|
|
233
|
+
etype = ev.get("event_type", "unknown")
|
|
234
|
+
lines.append(f" [{sev}] {etype}")
|
|
235
|
+
|
|
236
|
+
if self._mas:
|
|
237
|
+
names = [a.agent_name for a in self._mas.agents]
|
|
238
|
+
lines.append(f"\nAgents you can query: {', '.join(names)}")
|
|
239
|
+
|
|
240
|
+
return "\n".join(lines)
|
|
241
|
+
|
|
242
|
+
async def run(self, observation: dict) -> OverseerDecision:
|
|
243
|
+
"""Run one overseer cycle. Returns a structured OverseerDecision."""
|
|
244
|
+
# Reset per-cycle state so findings from the previous run don't bleed through.
|
|
245
|
+
# _captured and _queries are written by tool closures (_submit_decision, _query_agent).
|
|
246
|
+
self._captured = None
|
|
247
|
+
self._queries = []
|
|
248
|
+
try:
|
|
249
|
+
await self.executor.ainvoke({"input": self._format_observation(observation)})
|
|
250
|
+
except Exception as e:
|
|
251
|
+
log.error("AutonomousOverseer cycle error: %s", e)
|
|
252
|
+
if self._captured is None:
|
|
253
|
+
log.warning("Overseer did not call submit_decision — applying safe default")
|
|
254
|
+
return _SAFE_DEFAULT
|
|
255
|
+
return self._captured
|