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.
Files changed (95) hide show
  1. dt_forge/__init__.py +16 -0
  2. dt_forge/autonomous/__init__.py +19 -0
  3. dt_forge/autonomous/base.py +29 -0
  4. dt_forge/autonomous/deployer.py +74 -0
  5. dt_forge/autonomous/gym_env.py +109 -0
  6. dt_forge/autonomous/ooda.py +176 -0
  7. dt_forge/autonomous/overseer.py +255 -0
  8. dt_forge/autonomous/planner.py +73 -0
  9. dt_forge/autonomous/trainer.py +67 -0
  10. dt_forge/cli/__init__.py +1 -0
  11. dt_forge/cli/main.py +268 -0
  12. dt_forge/collection/__init__.py +15 -0
  13. dt_forge/collection/aggregate.py +132 -0
  14. dt_forge/collection/base.py +77 -0
  15. dt_forge/collection/collection_twin.py +107 -0
  16. dt_forge/collection/composite.py +144 -0
  17. dt_forge/collection/network_twin.py +189 -0
  18. dt_forge/collection/orchestrator.py +39 -0
  19. dt_forge/connector/__init__.py +12 -0
  20. dt_forge/connector/api_connector.py +62 -0
  21. dt_forge/connector/base.py +71 -0
  22. dt_forge/connector/ditto_connector.py +77 -0
  23. dt_forge/connector/mqtt_connector.py +83 -0
  24. dt_forge/core/__init__.py +18 -0
  25. dt_forge/core/base.py +95 -0
  26. dt_forge/core/config.py +153 -0
  27. dt_forge/core/events.py +74 -0
  28. dt_forge/core/lifecycle.py +65 -0
  29. dt_forge/core/registry.py +77 -0
  30. dt_forge/core/types.py +57 -0
  31. dt_forge/data/__init__.py +13 -0
  32. dt_forge/data/management/__init__.py +4 -0
  33. dt_forge/data/management/health.py +51 -0
  34. dt_forge/data/management/pipeline.py +96 -0
  35. dt_forge/data/storage/__init__.py +16 -0
  36. dt_forge/data/storage/base.py +84 -0
  37. dt_forge/data/storage/influx.py +136 -0
  38. dt_forge/data/storage/minio_store.py +84 -0
  39. dt_forge/data/storage/mongo.py +94 -0
  40. dt_forge/data/storage/postgres.py +47 -0
  41. dt_forge/data/storage/provenance.py +58 -0
  42. dt_forge/data/storage/redis_store.py +65 -0
  43. dt_forge/data/text_ingestor.py +140 -0
  44. dt_forge/data/writer.py +103 -0
  45. dt_forge/infra/__init__.py +4 -0
  46. dt_forge/infra/docker.py +350 -0
  47. dt_forge/infra/health_check.py +95 -0
  48. dt_forge/intelligent/__init__.py +15 -0
  49. dt_forge/intelligent/agent.py +150 -0
  50. dt_forge/intelligent/base.py +42 -0
  51. dt_forge/intelligent/knowledge_graph.py +184 -0
  52. dt_forge/intelligent/mas.py +225 -0
  53. dt_forge/intelligent/tools.py +100 -0
  54. dt_forge/ml/__init__.py +3 -0
  55. dt_forge/ml/corpus.py +85 -0
  56. dt_forge/network/__init__.py +4 -0
  57. dt_forge/network/ingestor.py +96 -0
  58. dt_forge/network/transport.py +98 -0
  59. dt_forge/notifications/__init__.py +3 -0
  60. dt_forge/notifications/notifier.py +103 -0
  61. dt_forge/physical/__init__.py +4 -0
  62. dt_forge/physical/adapters/__init__.py +1 -0
  63. dt_forge/physical/base.py +34 -0
  64. dt_forge/physical/simulator.py +107 -0
  65. dt_forge/reactive/__init__.py +16 -0
  66. dt_forge/reactive/actions.py +50 -0
  67. dt_forge/reactive/base.py +31 -0
  68. dt_forge/reactive/fsm_engine.py +204 -0
  69. dt_forge/reactive/pid.py +94 -0
  70. dt_forge/reactive/rule_engine.py +135 -0
  71. dt_forge/reactive/rule_repository.py +113 -0
  72. dt_forge/services/__init__.py +4 -0
  73. dt_forge/services/api/__init__.py +3 -0
  74. dt_forge/services/api/app.py +51 -0
  75. dt_forge/services/api/routes.py +63 -0
  76. dt_forge/services/api/streaming.py +65 -0
  77. dt_forge/services/base.py +57 -0
  78. dt_forge/services/composite.py +35 -0
  79. dt_forge/services/ditto/__init__.py +4 -0
  80. dt_forge/services/ditto/client.py +168 -0
  81. dt_forge/services/ditto/sync.py +69 -0
  82. dt_forge/session/__init__.py +3 -0
  83. dt_forge/session/context.py +153 -0
  84. dt_forge/simulation/__init__.py +16 -0
  85. dt_forge/simulation/base.py +23 -0
  86. dt_forge/simulation/discrete_event.py +59 -0
  87. dt_forge/simulation/forecaster.py +55 -0
  88. dt_forge/simulation/ode_model.py +78 -0
  89. dt_forge/simulation/runner.py +105 -0
  90. dt_forge/simulation/surrogate.py +79 -0
  91. dt_forge-0.3.0.dist-info/METADATA +44 -0
  92. dt_forge-0.3.0.dist-info/RECORD +95 -0
  93. dt_forge-0.3.0.dist-info/WHEEL +5 -0
  94. dt_forge-0.3.0.dist-info/entry_points.txt +2 -0
  95. 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