eneros-agent
eneros-agent provides the Agent runtime and built-in domain Agents, treating Agents as first-class grid nodes within the operating system’s scheduling. Agents directly access topology, Load Flow, time series, and devices through kernel APIs without redundant modeling. EnerOS includes four categories of built-in domain Agents (DispatchAgent / SelfHealingAgent / TradingAgent / PlanningAgent), and provides the Agent trait for user customization.
Responsibilities
eneros-agent handles the following core responsibilities:
- Agent Lifecycle: Register → Start → Pause → Resume → Stop, state machine management
- Domain Agent Implementation: Four built-in Agent categories: dispatch, self-healing, trading, planning
- Multi-Agent Collaboration: Communication through the event bus (
eneros-eventbus), with Orchestrator orchestrating task decomposition and aggregation
- Tool Invocation: Agents execute kernel APIs (Load Flow, constraints, control) through the tool engine provided by
eneros-tool
- Memory and Reasoning: Integrates
eneros-memory short-term/long-term memory and eneros-reasoning reasoning engine (LLM)
- Grid Location Awareness: Each Agent has a clear electrical location and jurisdiction within the topology
- Permission Boundaries: Role and jurisdiction-based access control; unauthorized operations are rejected by the gateway
Key Types and Interfaces
Agent trait
use async_trait::async_trait;
use eneros_core::{AgentId, Result};
use eneros_topology::NetworkGraph;
use eneros_timeseries::TimeSeriesEngine;
use eneros_eventbus::EventBus;
use eneros_tool::ToolRegistry;
#[async_trait]
pub trait Agent: Send + Sync {
fn id(&self) -> &AgentId;
fn agent_type(&self) -> AgentType;
fn jurisdiction(&self) -> &Jurisdiction;
async fn run(&mut self, ctx: &mut AgentContext<'_>) -> AgentResult<()>;
async fn on_event(&mut self, event: &Event) -> AgentResult<()>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentType {
Dispatch,
SelfHealing,
Trading,
Planning,
Custom,
}
#[derive(Debug, Clone)]
pub struct Jurisdiction {
pub buses: Vec<eneros_core::BusId>,
pub branches: Vec<eneros_core::BranchId>,
pub region: Option<String>,
pub role: AgentRole,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentRole {
Viewer,
Operator,
Planner,
Administrator,
}
#[derive(Debug, Clone)]
pub enum AgentError {
ToolFailed(String),
ConstraintRejected(String),
Unauthorized,
Internal(String),
}
pub type AgentResult<T> = std::result::Result<T, AgentError>;
AgentContext
pub struct AgentContext<'a> {
network: &'a NetworkGraph,
timeseries: &'a TimeSeriesEngine,
eventbus: &'a EventBus,
tools: &'a ToolRegistry,
memory: &'a mut eneros_memory::MemoryStore,
}
impl<'a> AgentContext<'a> {
pub async fn get_network(&self) -> &NetworkGraph {
self.network
}
pub async fn call_tool(&self, name: &str, args: &serde_json::Value) -> AgentResult<serde_json::Value> {
self.tools.call(name, args).await.map_err(AgentError::ToolFailed)
}
pub async fn publish(&self, event: impl Into<Event>) {
self.eventbus.publish(event.into()).await;
}
pub async fn remember(&mut self, key: &str, value: &serde_json::Value) -> AgentResult<()> {
self.memory.store(key, value).await.map_err(|e| AgentError::Internal(e.to_string()))
}
}
Orchestrator
use std::collections::HashMap;
pub struct Orchestrator {
agents: HashMap<AgentId, Box<dyn Agent>>,
schedule: Vec<AgentId>,
tick_interval: std::time::Duration,
}
impl Default for Orchestrator {
fn default() -> Self {
Self {
agents: HashMap::new(),
schedule: Vec::new(),
tick_interval: std::time::Duration::from_millis(100),
}
}
}
impl Orchestrator {
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, agent: Box<dyn Agent>) {
let id = agent.id().clone();
self.schedule.push(id.clone());
self.agents.insert(id, agent);
}
pub fn unregister(&mut self, id: &AgentId) -> Option<Box<dyn Agent>> {
self.schedule.retain(|x| x != id);
self.agents.remove(id)
}
pub async fn step(&mut self, ctx: &mut AgentContext<'_>) -> AgentResult<()> {
for id in self.schedule.clone() {
if let Some(agent) = self.agents.get_mut(&id) {
agent.run(ctx).await?;
}
}
Ok(())
}
pub async fn run_forever(&mut self, mut ctx: AgentContext<'_>) -> AgentResult<()> {
loop {
self.step(&mut ctx).await?;
tokio::time::sleep(self.tick_interval).await;
}
}
pub fn agent_count(&self) -> usize {
self.agents.len()
}
}
Built-in Domain Agents
pub struct DispatchAgent {
id: AgentId,
jurisdiction: Jurisdiction,
state: DispatchState,
}
#[derive(Debug, Clone)]
pub struct DispatchState {
pub last_dispatch_mw: f64,
pub last_tick: Option<chrono::DateTime<chrono::Utc>>,
}
impl DispatchAgent {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: AgentId::new(id),
jurisdiction: Jurisdiction {
buses: Vec::new(),
branches: Vec::new(),
region: None,
role: AgentRole::Operator,
},
state: DispatchState {
last_dispatch_mw: 0.0,
last_tick: None,
},
}
}
pub fn with_jurisdiction(mut self, j: Jurisdiction) -> Self {
self.jurisdiction = j;
self
}
}
#[async_trait]
impl Agent for DispatchAgent {
fn id(&self) -> &AgentId {
&self.id
}
fn agent_type(&self) -> AgentType {
AgentType::Dispatch
}
fn jurisdiction(&self) -> &Jurisdiction {
&self.jurisdiction
}
async fn run(&mut self, ctx: &mut AgentContext<'_>) -> AgentResult<()> {
let net = ctx.get_network().await;
let pf_result = ctx.call_tool("powerflow", &serde_json::json!({"network_id": "default"})).await?;
let total_mw: f64 = pf_result["total_generation_mw"].as_f64().unwrap_or(0.0);
self.state.last_dispatch_mw = total_mw;
self.state.last_tick = Some(chrono::Utc::now());
Ok(())
}
async fn on_event(&mut self, _event: &Event) -> AgentResult<()> {
Ok(())
}
}
pub struct SelfHealingAgent {
id: AgentId,
jurisdiction: Jurisdiction,
isolation_strategy: IsolationStrategy,
}
#[derive(Debug, Clone, Copy)]
pub enum IsolationStrategy {
FastIsolation,
ConservativeIsolation,
}
impl SelfHealingAgent {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: AgentId::new(id),
jurisdiction: Jurisdiction {
buses: Vec::new(),
branches: Vec::new(),
region: None,
role: AgentRole::Operator,
},
isolation_strategy: IsolationStrategy::FastIsolation,
}
}
}
Core API
| Method | Signature | Description |
|---|
Orchestrator::new | fn new() -> Self | Construct orchestrator |
Orchestrator::register | fn register(&mut self, agent: Box<dyn Agent>) | Register Agent |
Orchestrator::unregister | fn unregister(&mut self, id: &AgentId) -> Option<Box<dyn Agent>> | Unregister Agent |
Orchestrator::step | async fn step(&mut self, ctx: &mut AgentContext) -> AgentResult<()> | Single-step execution of all Agents |
Orchestrator::run_forever | async fn run_forever(&mut self, ctx: AgentContext) -> AgentResult<()> | Run main loop forever |
Agent::run | async fn run(&mut self, ctx: &mut AgentContext) -> AgentResult<()> | Single execution (trait method) |
Agent::on_event | async fn on_event(&mut self, event: &Event) -> AgentResult<()> | Event callback |
AgentContext::call_tool | async fn call_tool(&self, name, args) -> AgentResult<Value> | Invoke tool |
AgentContext::publish | async fn publish(&self, event: impl Into<Event>) | Publish event |
AgentContext::remember | async fn remember(&mut self, key, value) -> AgentResult<()> | Write to memory |
Usage Examples
Register and Run Multiple Agents
use eneros_agent::{Orchestrator, DispatchAgent, SelfHealingAgent, AgentContext};
use std::time::Duration;
let mut orch = Orchestrator::new();
orch.tick_interval = Duration::from_millis(100);
orch.register(Box::new(DispatchAgent::new("dispatch_01")));
orch.register(Box::new(SelfHealingAgent::new("selfheal_01")));
println!("Registered Agent count: {}", orch.agent_count());
let ctx = AgentContext::new(&network, ×eries, &eventbus, &tools, &mut memory);
orch.run_forever(ctx).await?;
Single-Step Execution
let mut ctx = AgentContext::new(&network, ×eries, &eventbus, &tools, &mut memory);
for step in 0..10 {
println!("=== Step {} ===", step);
orch.step(&mut ctx).await?;
}
Custom Agent
use async_trait::async_trait;
use eneros_agent::{Agent, AgentType, AgentResult, AgentContext, Jurisdiction, AgentRole, AgentError};
use eneros_core::AgentId;
struct VoltageMonitorAgent {
id: AgentId,
jurisdiction: Jurisdiction,
}
impl VoltageMonitorAgent {
fn new(id: impl Into<String>) -> Self {
Self {
id: AgentId::new(id),
jurisdiction: Jurisdiction {
buses: Vec::new(),
branches: Vec::new(),
region: None,
role: AgentRole::Viewer,
},
}
}
}
#[async_trait]
impl Agent for VoltageMonitorAgent {
fn id(&self) -> &AgentId {
&self.id
}
fn agent_type(&self) -> AgentType {
AgentType::Custom
}
fn jurisdiction(&self) -> &Jurisdiction {
&self.jurisdiction
}
async fn run(&mut self, ctx: &mut AgentContext<'_>) -> AgentResult<()> {
let net = ctx.get_network().await;
let violations = ctx.call_tool("check_constraints", &serde_json::json!({})).await?;
if violations["count"].as_u64().unwrap_or(0) > 0 {
ctx.publish(AlarmEvent::critical(violations.clone())).await;
ctx.remember("last_violation", &violations).await?;
}
Ok(())
}
async fn on_event(&mut self, event: &Event) -> AgentResult<()> {
match event {
Event::TopologyChanged(_) => {
tracing::info!("Topology changed, re-validating");
}
_ => {}
}
Ok(())
}
}
let mut orch = Orchestrator::new();
orch.register(Box::new(VoltageMonitorAgent::new("vm_01")));
Agent with Jurisdiction and Permissions
use eneros_agent::{DispatchAgent, Jurisdiction, AgentRole};
use eneros_core::ElementId;
let jurisdiction = Jurisdiction {
buses: vec![ElementId::new("bus_1"), ElementId::new("bus_2")],
branches: vec![ElementId::new("branch_1_2")],
region: Some("east".into()),
role: AgentRole::Operator,
};
let agent = DispatchAgent::new("dispatch_east")
.with_jurisdiction(jurisdiction);
orch.register(Box::new(agent));
Test environment: 4 cores / 8GB / Ubuntu 22.04 / Rust 1.78 release build.
| Operation | Latency | Description |
|---|
| Agent registration | < 5μs | HashMap insertion |
| Single Agent tick (no tool call) | < 50μs | State update only |
| Single Agent tick (with Load Flow tool) | < 15ms | Mainly Load Flow solving time |
| 1000 Agents single-step scheduling | < 5ms | Excluding tool calls |
| Event broadcast (1000 subscribers) | < 1ms | Event bus broadcast |
Configuration Parameter Table
[agent] Configuration Section
| Field | Type | Default | Description |
|---|
max_concurrent | usize | 64 | Maximum concurrent Agents |
default_tick_interval_ms | u64 | 100 | Default tick interval (milliseconds) |
memory_retention_hours | u32 | 24 | Short-term memory retention hours |
enable_reasoning | bool | true | Whether to enable LLM reasoning |
AgentRole Permission Matrix
| Role | View | Operate | Plan | Manage |
|---|
Viewer | ✓ | ✗ | ✗ | ✗ |
Operator | ✓ | ✓ | ✗ | ✗ |
Planner | ✓ | ✓ | ✓ | ✗ |
Administrator | ✓ | ✓ | ✓ | ✓ |
Dependencies
Own Dependencies
[dependencies]
eneros-core = { path = "../core", version = "0.47.0" }
eneros-topology = { path = "../topology", version = "0.47.0" }
eneros-powerflow = { path = "../powerflow", version = "0.47.0" }
eneros-constraint = { path = "../constraint", version = "0.47.0" }
eneros-memory = { path = "../memory", version = "0.47.0" }
eneros-tool = { path = "../tool", version = "0.47.0" }
eneros-reasoning = { path = "../reasoning", version = "0.47.0" }
eneros-eventbus = { path = "../eventbus", version = "0.47.0" }
async-trait = "0.1"
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tracing = "0.1"
chrono = { version = "0.4", features = ["serde"] }
Depended On By
eneros-gateway — Gateway proxies Agent command dispatch
eneros-dashboard — Dashboard displays Agent status
eneros-edge — Edge nodes run lightweight Agents
eneros-aiops — AIOps integrates Agent decision-making
Version and Compatibility
| eneros-agent Version | EnerOS Version | Key Changes |
|---|
| 0.47.x | 0.47.x | Added Jurisdiction and AgentRole permission model |
| 0.46.x | 0.46.x | Integrated eneros-reasoning LLM reasoning |
| 0.45.x (LTS) | 0.45.x | LTS version, security updates until 2028 |