Files
PrimAITE/src/primaite/game/agent/interface.py

209 lines
8.2 KiB
Python
Raw Normal View History

2025-01-02 15:05:06 +00:00
# © Crown-owned copyright 2025, Defence Science and Technology Laboratory UK
2023-10-19 01:56:40 +01:00
"""Interface for agents."""
from __future__ import annotations
from abc import abstractmethod
from typing import Any, ClassVar, Dict, List, Optional, Tuple, Type, TYPE_CHECKING
2023-10-06 10:36:29 +01:00
from gymnasium.core import ActType, ObsType
from pydantic import BaseModel, ConfigDict, Field
2023-09-26 12:54:56 +01:00
2023-10-02 17:21:43 +01:00
from primaite.game.agent.actions import ActionManager
from primaite.game.agent.agent_log import AgentLog
from primaite.game.agent.observations.observation_manager import ObservationManager
2023-09-26 12:54:56 +01:00
from primaite.game.agent.rewards import RewardFunction
2024-03-11 20:10:08 +00:00
from primaite.interface.request import RequestFormat, RequestResponse
if TYPE_CHECKING:
pass
2023-09-26 12:54:56 +01:00
__all__ = ("AgentHistoryItem", "AbstractAgent", "AbstractScriptedAgent", "ProxyAgent")
2024-12-17 12:50:14 +00:00
2023-09-26 12:54:56 +01:00
2024-05-31 15:00:18 +01:00
class AgentHistoryItem(BaseModel):
2024-03-11 20:10:08 +00:00
"""One entry of an agent's action log - what the agent did and how the simulator responded in 1 step."""
timestep: int
"""Timestep of this action."""
action: str
"""CAOS Action name."""
parameters: Dict[str, Any]
"""CAOS parameters for the given action."""
request: RequestFormat
"""The request that was sent to the simulation based on the CAOS action chosen."""
response: RequestResponse
"""The response sent back by the simulator for this action."""
2024-05-31 15:00:18 +01:00
reward: Optional[float] = None
reward_info: Dict[str, Any] = {}
2024-03-11 20:10:08 +00:00
class AbstractAgent(BaseModel):
2023-09-26 12:54:56 +01:00
"""Base class for scripted and RL agents."""
_registry: ClassVar[Dict[str, Type[AbstractAgent]]] = {}
logger: AgentLog = AgentLog(agent_name="Abstract_Agent")
history: List[AgentHistoryItem] = []
config: "AbstractAgent.ConfigSchema" = Field(default_factory=lambda: AbstractAgent.ConfigSchema())
action_manager: "ActionManager"
observation_manager: "ObservationManager"
reward_function: "RewardFunction"
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
class ConfigSchema(BaseModel):
"""
Configuration Schema for AbstractAgents.
:param type: Type of agent being generated.
:type type: str
:param observation_space: Observation space for the agent.
:type observation_space: Optional[ObservationSpace]
:param reward_function: Reward function for the agent.
:type reward_function: Optional[RewardFunction]
:param agent_settings: Configurable Options for Abstracted Agents.
:type agent_settings: Optional[AgentSettings]
"""
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
type: str = "AbstractAgent"
def __init_subclass__(cls, identifier: str, **kwargs: Any) -> None:
if identifier in cls._registry:
raise ValueError(f"Cannot create a new agent under reserved name {identifier}")
cls._registry[identifier] = cls
super().__init_subclass__(**kwargs)
@property
def flatten_obs(self) -> bool:
2024-12-17 12:50:14 +00:00
"""Return agent flatten_obs param."""
return self.config.flatten_obs
@classmethod
def from_config(cls, config: Dict) -> "AbstractAgent":
"""Creates an agent component from a configuration dictionary."""
2025-01-13 10:26:48 +00:00
if config["type"] not in cls._registry:
return ValueError(f"Invalid Agent Type: {config['type']}")
obj = cls(
config=cls.ConfigSchema(**config["agent_settings"]),
action_manager=ActionManager.from_config(config["game"], config["action_manager"]),
observation_manager=ObservationManager.from_config(config["observation_manager"]),
reward_function=RewardFunction.from_config(config["reward_function"]),
)
return obj
def update_observation(self, state: Dict) -> ObsType:
2023-10-02 17:21:43 +01:00
"""
Convert a state from the simulator into an observation for the agent using the observation space.
2023-10-02 17:21:43 +01:00
state : dict state directly from simulation.describe_state
output : dict state according to CAOS.
"""
return self.observation_manager.update(state)
2023-10-02 17:21:43 +01:00
def update_reward(self, state: Dict) -> float:
"""
Use the reward function to calculate a reward from the state.
:param state: State of the environment.
:type state: Dict
:return: Reward from the state.
:rtype: float
"""
return self.reward_function.update(state=state, last_action_response=self.history[-1])
2023-10-02 17:21:43 +01:00
@abstractmethod
2024-03-04 10:43:38 +00:00
def get_action(self, obs: ObsType, timestep: int = 0) -> Tuple[str, Dict]:
"""
Return an action to be taken in the environment.
Subclasses should implement agent logic here. It should use the observation as input to decide best next action.
:param obs: Observation of the environment.
:type obs: ObsType
2024-02-27 13:30:16 +00:00
:param timestep: The current timestep in the simulation, used for non-RL agents. Optional
:type timestep: int
:return: Action to be taken in the environment.
:rtype: Tuple[str, Dict]
"""
2023-11-13 16:04:25 +00:00
# in RL agent, this method will send CAOS observation to RL agent, then receive a int 0-39,
2023-10-02 17:21:43 +01:00
# then use a bespoke conversion to take 1-40 int back into CAOS action
return ("do_nothing", {})
2023-10-02 17:21:43 +01:00
2023-10-09 18:35:30 +01:00
def format_request(self, action: Tuple[str, Dict], options: Dict[str, int]) -> List[str]:
2023-10-02 17:21:43 +01:00
# this will take something like APPLICATION.EXECUTE and add things like target_ip_address in simulator.
# therefore the execution definition needs to be a mapping from CAOS into SIMULATOR
"""Format action into format expected by the simulator, and apply execution definition if applicable."""
request = self.action_manager.form_request(action_identifier=action, action_options=options)
2023-10-06 20:32:52 +01:00
return request
2023-10-02 17:21:43 +01:00
2024-03-11 20:10:08 +00:00
def process_action_response(
self, timestep: int, action: str, parameters: Dict[str, Any], request: RequestFormat, response: RequestResponse
) -> None:
"""Process the response from the most recent action."""
self.history.append(
2024-05-31 15:00:18 +01:00
AgentHistoryItem(
2024-03-11 20:10:08 +00:00
timestep=timestep, action=action, parameters=parameters, request=request, response=response
)
)
2024-01-09 14:53:15 +00:00
2024-05-31 15:00:18 +01:00
def save_reward_to_history(self) -> None:
"""Update the most recent history item with the reward value."""
self.history[-1].reward = self.reward_function.current_reward
2024-05-31 15:00:18 +01:00
2023-09-26 12:54:56 +01:00
class AbstractScriptedAgent(AbstractAgent, identifier="AbstractScriptedAgent"):
2023-09-26 12:54:56 +01:00
"""Base class for actors which generate their own behaviour."""
config: "AbstractScriptedAgent.ConfigSchema" = Field(default_factory=lambda: AbstractScriptedAgent.ConfigSchema())
class ConfigSchema(AbstractAgent.ConfigSchema):
"""Configuration Schema for AbstractScriptedAgents."""
type: str = "AbstractScriptedAgent"
@abstractmethod
2024-03-04 10:43:38 +00:00
def get_action(self, obs: ObsType, timestep: int = 0) -> Tuple[str, Dict]:
"""Return an action to be taken in the environment."""
return super().get_action(obs=obs, timestep=timestep)
class ProxyAgent(AbstractAgent, identifier="ProxyAgent"):
"""Agent that sends observations to an RL model and receives actions from that model."""
config: "ProxyAgent.ConfigSchema" = Field(default_factory=lambda: ProxyAgent.ConfigSchema())
most_recent_action: ActType = None
2024-11-27 15:29:51 +00:00
class ConfigSchema(AbstractAgent.ConfigSchema):
"""Configuration Schema for Proxy Agent."""
type: str = "Proxy_Agent"
flatten_obs: bool = False
action_masking: bool = False
2024-03-04 10:43:38 +00:00
def get_action(self, obs: ObsType, timestep: int = 0) -> Tuple[str, Dict]:
"""
Return the agent's most recent action, formatted in CAOS format.
:param obs: Observation for the agent. Not used by ProxyAgents, but required by the interface.
:type obs: ObsType
2024-03-04 10:43:38 +00:00
:param timestep: Current simulation timestep. Not used by ProxyAgents, bur required for the interface.
:type timestep: int
:return: Action to be taken in CAOS format.
:rtype: Tuple[str, Dict]
"""
return self.action_manager.get_action(self.most_recent_action)
def store_action(self, action: ActType):
"""
Store the most recent action taken by the agent.
The environment is responsible for calling this method when it receives an action from the agent policy.
"""
2024-11-27 15:29:51 +00:00
self.most_recent_action = action