Add agent action history

This commit is contained in:
Marek Wolan
2024-03-11 20:10:08 +00:00
parent 66ab5ec980
commit 7599655879
9 changed files with 110 additions and 104 deletions

View File

@@ -14,7 +14,7 @@ class DataManipulationAgent(AbstractScriptedAgent):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.reset_agent_for_episode() self.setup_agent()
def _set_next_execution_timestep(self, timestep: int) -> None: def _set_next_execution_timestep(self, timestep: int) -> None:
"""Set the next execution timestep with a configured random variance. """Set the next execution timestep with a configured random variance.
@@ -43,9 +43,8 @@ class DataManipulationAgent(AbstractScriptedAgent):
return "NODE_APPLICATION_EXECUTE", {"node_id": self.starting_node_idx, "application_id": 0} return "NODE_APPLICATION_EXECUTE", {"node_id": self.starting_node_idx, "application_id": 0}
def reset_agent_for_episode(self) -> None: def setup_agent(self) -> None:
"""Set the next execution timestep when the episode resets.""" """Set the next execution timestep when the episode resets."""
super().reset_agent_for_episode()
self._select_start_node() self._select_start_node()
self._set_next_execution_timestep(self.agent_settings.start_settings.start_step) self._set_next_execution_timestep(self.agent_settings.start_settings.start_step)

View File

@@ -1,6 +1,6 @@
"""Interface for agents.""" """Interface for agents."""
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Dict, List, Optional, Tuple, TYPE_CHECKING from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING
from gymnasium.core import ActType, ObsType from gymnasium.core import ActType, ObsType
from pydantic import BaseModel, model_validator from pydantic import BaseModel, model_validator
@@ -8,11 +8,31 @@ from pydantic import BaseModel, model_validator
from primaite.game.agent.actions import ActionManager from primaite.game.agent.actions import ActionManager
from primaite.game.agent.observations import ObservationManager from primaite.game.agent.observations import ObservationManager
from primaite.game.agent.rewards import RewardFunction from primaite.game.agent.rewards import RewardFunction
from primaite.interface.request import RequestFormat, RequestResponse
if TYPE_CHECKING: if TYPE_CHECKING:
pass pass
class AgentActionHistoryItem(BaseModel):
"""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."""
class AgentStartSettings(BaseModel): class AgentStartSettings(BaseModel):
"""Configuration values for when an agent starts performing actions.""" """Configuration values for when an agent starts performing actions."""
@@ -90,6 +110,7 @@ class AbstractAgent(ABC):
self.observation_manager: Optional[ObservationManager] = observation_space self.observation_manager: Optional[ObservationManager] = observation_space
self.reward_function: Optional[RewardFunction] = reward_function self.reward_function: Optional[RewardFunction] = reward_function
self.agent_settings = agent_settings or AgentSettings() self.agent_settings = agent_settings or AgentSettings()
self.action_history: List[AgentActionHistoryItem] = []
def update_observation(self, state: Dict) -> ObsType: def update_observation(self, state: Dict) -> ObsType:
""" """
@@ -109,7 +130,7 @@ class AbstractAgent(ABC):
:return: Reward from the state. :return: Reward from the state.
:rtype: float :rtype: float
""" """
return self.reward_function.update(state) return self.reward_function.update(state=state, last_action_response=self.action_history[-1])
@abstractmethod @abstractmethod
def get_action(self, obs: ObsType, timestep: int = 0) -> Tuple[str, Dict]: def get_action(self, obs: ObsType, timestep: int = 0) -> Tuple[str, Dict]:
@@ -138,9 +159,15 @@ class AbstractAgent(ABC):
request = self.action_manager.form_request(action_identifier=action, action_options=options) request = self.action_manager.form_request(action_identifier=action, action_options=options)
return request return request
def reset_agent_for_episode(self) -> None: def process_action_response(
"""Agent reset logic should go here.""" self, timestep: int, action: str, parameters: Dict[str, Any], request: RequestFormat, response: RequestResponse
pass ) -> None:
"""Process the response from the most recent action."""
self.action_history.append(
AgentActionHistoryItem(
timestep=timestep, action=action, parameters=parameters, request=request, response=response
)
)
class AbstractScriptedAgent(AbstractAgent): class AbstractScriptedAgent(AbstractAgent):

View File

@@ -26,11 +26,14 @@ the structure:
``` ```
""" """
from abc import abstractmethod from abc import abstractmethod
from typing import Dict, List, Tuple, Type from typing import Dict, List, Tuple, Type, TYPE_CHECKING
from primaite import getLogger from primaite import getLogger
from primaite.game.agent.utils import access_from_nested_dict, NOT_PRESENT_IN_STATE from primaite.game.agent.utils import access_from_nested_dict, NOT_PRESENT_IN_STATE
if TYPE_CHECKING:
from primaite.game.agent.interface import AgentActionHistoryItem
_LOGGER = getLogger(__name__) _LOGGER = getLogger(__name__)
@@ -38,7 +41,9 @@ class AbstractReward:
"""Base class for reward function components.""" """Base class for reward function components."""
@abstractmethod @abstractmethod
def calculate(self, state: Dict) -> float: def calculate(
self, state: Dict, last_action_response: "AgentActionHistoryItem"
) -> float: # todo maybe make last_action_response optional?
"""Calculate the reward for the current state.""" """Calculate the reward for the current state."""
return 0.0 return 0.0
@@ -58,7 +63,9 @@ class AbstractReward:
class DummyReward(AbstractReward): class DummyReward(AbstractReward):
"""Dummy reward function component which always returns 0.""" """Dummy reward function component which always returns 0."""
def calculate(self, state: Dict) -> float: def calculate(
self, state: Dict, last_action_response: "AgentActionHistoryItem"
) -> float: # todo maybe make last_action_response optional?
"""Calculate the reward for the current state.""" """Calculate the reward for the current state."""
return 0.0 return 0.0
@@ -98,7 +105,9 @@ class DatabaseFileIntegrity(AbstractReward):
file_name, file_name,
] ]
def calculate(self, state: Dict) -> float: def calculate(
self, state: Dict, last_action_response: "AgentActionHistoryItem"
) -> float: # todo maybe make last_action_response optional?
"""Calculate the reward for the current state. """Calculate the reward for the current state.
:param state: The current state of the simulation. :param state: The current state of the simulation.
@@ -153,7 +162,9 @@ class WebServer404Penalty(AbstractReward):
""" """
self.location_in_state = ["network", "nodes", node_hostname, "services", service_name] self.location_in_state = ["network", "nodes", node_hostname, "services", service_name]
def calculate(self, state: Dict) -> float: def calculate(
self, state: Dict, last_action_response: "AgentActionHistoryItem"
) -> float: # todo maybe make last_action_response optional?
"""Calculate the reward for the current state. """Calculate the reward for the current state.
:param state: The current state of the simulation. :param state: The current state of the simulation.
@@ -206,7 +217,9 @@ class WebpageUnavailablePenalty(AbstractReward):
self._node = node_hostname self._node = node_hostname
self.location_in_state = ["network", "nodes", node_hostname, "applications", "WebBrowser"] self.location_in_state = ["network", "nodes", node_hostname, "applications", "WebBrowser"]
def calculate(self, state: Dict) -> float: def calculate(
self, state: Dict, last_action_response: "AgentActionHistoryItem"
) -> float: # todo maybe make last_action_response optional?
""" """
Calculate the reward based on current simulation state. Calculate the reward based on current simulation state.
@@ -255,13 +268,17 @@ class GreenAdminDatabaseUnreachablePenalty(AbstractReward):
self._node = node_hostname self._node = node_hostname
self.location_in_state = ["network", "nodes", node_hostname, "applications", "DatabaseClient"] self.location_in_state = ["network", "nodes", node_hostname, "applications", "DatabaseClient"]
def calculate(self, state: Dict) -> float: def calculate(
self, state: Dict, last_action_response: "AgentActionHistoryItem"
) -> float: # todo maybe make last_action_response optional?
""" """
Calculate the reward based on current simulation state. Calculate the reward based on current simulation state.
:param state: The current state of the simulation. :param state: The current state of the simulation.
:type state: Dict :type state: Dict
""" """
if last_action_response.request == ["network", "node", "client_2", "application", "DatabaseClient", "execute"]:
pass # TODO
db_state = access_from_nested_dict(state, self.location_in_state) db_state = access_from_nested_dict(state, self.location_in_state)
if db_state is NOT_PRESENT_IN_STATE or "last_connection_successful" not in db_state: if db_state is NOT_PRESENT_IN_STATE or "last_connection_successful" not in db_state:
_LOGGER.debug(f"Can't calculate reward for {self.__class__.__name__}") _LOGGER.debug(f"Can't calculate reward for {self.__class__.__name__}")
@@ -313,7 +330,9 @@ class RewardFunction:
""" """
self.reward_components.append((component, weight)) self.reward_components.append((component, weight))
def update(self, state: Dict) -> float: def update(
self, state: Dict, last_action_response: "AgentActionHistoryItem"
) -> float: # todo maybe make last_action_response optional?
"""Calculate the overall reward for the current state. """Calculate the overall reward for the current state.
:param state: The current state of the simulation. :param state: The current state of the simulation.
@@ -323,7 +342,7 @@ class RewardFunction:
for comp_and_weight in self.reward_components: for comp_and_weight in self.reward_components:
comp = comp_and_weight[0] comp = comp_and_weight[0]
weight = comp_and_weight[1] weight = comp_and_weight[1]
total += weight * comp.calculate(state=state) total += weight * comp.calculate(state=state, last_action_response=last_action_response)
self.current_reward = total self.current_reward = total
return self.current_reward return self.current_reward

View File

@@ -1,6 +1,6 @@
"""PrimAITE game - Encapsulates the simulation and agents.""" """PrimAITE game - Encapsulates the simulation and agents."""
from ipaddress import IPv4Address from ipaddress import IPv4Address
from typing import Dict, List, Tuple from typing import Dict, List
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@@ -130,49 +130,44 @@ class PrimaiteGame:
""" """
_LOGGER.debug(f"Stepping. Step counter: {self.step_counter}") _LOGGER.debug(f"Stepping. Step counter: {self.step_counter}")
# Get the current state of the simulation
sim_state = self.get_sim_state()
# Update agents' observations and rewards based on the current state
self.update_agents(sim_state)
# Apply all actions to simulation as requests # Apply all actions to simulation as requests
self.apply_agent_actions() action_data = self.apply_agent_actions()
# Advance timestep # Advance timestep
self.advance_timestep() self.advance_timestep()
# Get the current state of the simulation
sim_state = self.get_sim_state()
# Update agents' observations and rewards based on the current state, and the response from the last action
self.update_agents(state=sim_state, action_data=action_data)
def get_sim_state(self) -> Dict: def get_sim_state(self) -> Dict:
"""Get the current state of the simulation.""" """Get the current state of the simulation."""
return self.simulation.describe_state() return self.simulation.describe_state()
def update_agents(self, state: Dict) -> None: def update_agents(self, state: Dict) -> None:
"""Update agents' observations and rewards based on the current state.""" """Update agents' observations and rewards based on the current state."""
for _, agent in self.agents.items(): for agent_name, agent in self.agents.items():
agent.update_observation(state) if self.step_counter > 0: # can't get reward before first action
agent.update_reward(state) agent.update_reward(state=state)
agent.update_observation(state=state)
agent.reward_function.total_reward += agent.reward_function.current_reward agent.reward_function.total_reward += agent.reward_function.current_reward
def apply_agent_actions(self) -> Dict[str, Tuple[str, Dict]]: def apply_agent_actions(self) -> None:
""" """Apply all actions to simulation as requests."""
Apply all actions to simulation as requests.
:return: A recap of each agent's actions, in CAOS format.
:rtype: Dict[str, Tuple[str, Dict]]
"""
agent_actions = {}
for _, agent in self.agents.items(): for _, agent in self.agents.items():
obs = agent.observation_manager.current_observation obs = agent.observation_manager.current_observation
action_choice, options = agent.get_action(obs, timestep=self.step_counter) action_choice, parameters = agent.get_action(obs, timestep=self.step_counter)
request = agent.format_request(action_choice, options) request = agent.format_request(action_choice, parameters)
response = self.simulation.apply_request(request) response = self.simulation.apply_request(request)
agent_actions[agent.agent_name] = { agent.process_action_response(
"action": action_choice, timestep=self.step_counter,
"parameters": options, action=action_choice,
"response": response.model_dump(), parameters=parameters,
} request=request,
return agent_actions response=response,
)
def advance_timestep(self) -> None: def advance_timestep(self) -> None:
"""Advance timestep.""" """Advance timestep."""

View File

@@ -1,7 +1,9 @@
from typing import Dict, ForwardRef, Literal from typing import Dict, ForwardRef, List, Literal, Union
from pydantic import BaseModel, ConfigDict, StrictBool, validate_call from pydantic import BaseModel, ConfigDict, StrictBool, validate_call
RequestFormat = List[Union[str, int, float]]
RequestResponse = ForwardRef("RequestResponse") RequestResponse = ForwardRef("RequestResponse")
"""This makes it possible to type-hint RequestResponse.from_bool return type.""" """This makes it possible to type-hint RequestResponse.from_bool return type."""

View File

@@ -373,7 +373,7 @@
"# Imports\n", "# Imports\n",
"from primaite.config.load import data_manipulation_config_path\n", "from primaite.config.load import data_manipulation_config_path\n",
"from primaite.session.environment import PrimaiteGymEnv\n", "from primaite.session.environment import PrimaiteGymEnv\n",
"from primaite.game.game import PrimaiteGame\n", "from primaite.game.agent.interface import AgentActionHistoryItem\n",
"import yaml\n", "import yaml\n",
"from pprint import pprint\n" "from pprint import pprint\n"
] ]
@@ -425,14 +425,14 @@
"source": [ "source": [
"def friendly_output_red_action(info):\n", "def friendly_output_red_action(info):\n",
" # parse the info dict form step output and write out what the red agent is doing\n", " # parse the info dict form step output and write out what the red agent is doing\n",
" red_info = info['agent_actions']['data_manipulation_attacker']\n", " red_info : AgentActionHistoryItem = info['agent_actions']['data_manipulation_attacker']\n",
" red_action = red_info['action']\n", " red_action = red_info.action\n",
" if red_action == 'DONOTHING':\n", " if red_action == 'DONOTHING':\n",
" red_str = 'DO NOTHING'\n", " red_str = 'DO NOTHING'\n",
" elif red_action == 'NODE_APPLICATION_EXECUTE':\n", " elif red_action == 'NODE_APPLICATION_EXECUTE':\n",
" client = \"client 1\" if red_info['parameters']['node_id'] == 0 else \"client 2\"\n", " client = \"client 1\" if red_info.parameters['node_id'] == 0 else \"client 2\"\n",
" red_str = f\"ATTACK from {client}\"\n", " red_str = f\"ATTACK from {client}\"\n",
" return red_str\n" " return red_str"
] ]
}, },
{ {

View File

@@ -49,23 +49,20 @@ class PrimaiteGymEnv(gymnasium.Env):
# make ProxyAgent store the action chosen my the RL policy # make ProxyAgent store the action chosen my the RL policy
self.agent.store_action(action) self.agent.store_action(action)
# apply_agent_actions accesses the action we just stored # apply_agent_actions accesses the action we just stored
agent_actions = self.game.apply_agent_actions() self.game.apply_agent_actions()
self.game.advance_timestep() self.game.advance_timestep()
state = self.game.get_sim_state() state = self.game.get_sim_state()
self.game.update_agents(state) self.game.update_agents(state)
next_obs = self._get_obs() next_obs = self._get_obs() # this doesn't update observation, just gets the current observation
reward = self.agent.reward_function.current_reward reward = self.agent.reward_function.current_reward
terminated = False terminated = False
truncated = self.game.calculate_truncated() truncated = self.game.calculate_truncated()
info = {"agent_actions": agent_actions} # tell us what all the agents did for convenience. info = {
"agent_actions": {name: agent.action_history[-1] for name, agent in self.game.agents.items()}
} # tell us what all the agents did for convenience.
if self.game.save_step_metadata: if self.game.save_step_metadata:
self._write_step_metadata_json(action, state, reward) self._write_step_metadata_json(action, state, reward)
if self.io.settings.save_agent_actions:
self.io.store_agent_actions(
agent_actions=agent_actions, episode=self.episode_counter, timestep=self.game.step_counter
)
return next_obs, reward, terminated, truncated, info return next_obs, reward, terminated, truncated, info
def _write_step_metadata_json(self, action: int, state: Dict, reward: int): def _write_step_metadata_json(self, action: int, state: Dict, reward: int):
@@ -91,13 +88,13 @@ class PrimaiteGymEnv(gymnasium.Env):
f"avg. reward: {self.agent.reward_function.total_reward}" f"avg. reward: {self.agent.reward_function.total_reward}"
) )
if self.io.settings.save_agent_actions: if self.io.settings.save_agent_actions:
self.io.write_agent_actions(episode=self.episode_counter) all_agent_actions = {name: agent.action_history for name, agent in self.game.agents.items()}
self.io.clear_agent_actions() self.io.write_agent_actions(agent_actions=all_agent_actions, episode=self.episode_counter)
self.game: PrimaiteGame = PrimaiteGame.from_config(cfg=copy.deepcopy(self.game_config)) self.game: PrimaiteGame = PrimaiteGame.from_config(cfg=copy.deepcopy(self.game_config))
self.game.setup_for_episode(episode=self.episode_counter) self.game.setup_for_episode(episode=self.episode_counter)
self.episode_counter += 1 self.episode_counter += 1
state = self.game.get_sim_state() state = self.game.get_sim_state()
self.game.update_agents(state) self.game.update_agents(state=state)
next_obs = self._get_obs() next_obs = self._get_obs()
info = {} info = {}
return next_obs, info return next_obs, info
@@ -217,7 +214,7 @@ class PrimaiteRayMARLEnv(MultiAgentEnv):
# 1. Perform actions # 1. Perform actions
for agent_name, action in actions.items(): for agent_name, action in actions.items():
self.agents[agent_name].store_action(action) self.agents[agent_name].store_action(action)
agent_actions = self.game.apply_agent_actions() self.game.apply_agent_actions()
# 2. Advance timestep # 2. Advance timestep
self.game.advance_timestep() self.game.advance_timestep()
@@ -236,10 +233,6 @@ class PrimaiteRayMARLEnv(MultiAgentEnv):
truncateds["__all__"] = self.game.calculate_truncated() truncateds["__all__"] = self.game.calculate_truncated()
if self.game.save_step_metadata: if self.game.save_step_metadata:
self._write_step_metadata_json(actions, state, rewards) self._write_step_metadata_json(actions, state, rewards)
if self.io.settings.save_agent_actions:
self.io.store_agent_actions(
agent_actions=agent_actions, episode=self.episode_counter, timestep=self.game.step_counter
)
return next_obs, rewards, terminateds, truncateds, infos return next_obs, rewards, terminateds, truncateds, infos
def _write_step_metadata_json(self, actions: Dict, state: Dict, rewards: Dict): def _write_step_metadata_json(self, actions: Dict, state: Dict, rewards: Dict):

View File

@@ -48,8 +48,6 @@ class PrimaiteIO:
SIM_OUTPUT.save_pcap_logs = self.settings.save_pcap_logs SIM_OUTPUT.save_pcap_logs = self.settings.save_pcap_logs
SIM_OUTPUT.save_sys_logs = self.settings.save_sys_logs SIM_OUTPUT.save_sys_logs = self.settings.save_sys_logs
self.agent_action_log: List[Dict] = []
def generate_session_path(self, timestamp: Optional[datetime] = None) -> Path: def generate_session_path(self, timestamp: Optional[datetime] = None) -> Path:
"""Create a folder for the session and return the path to it.""" """Create a folder for the session and return the path to it."""
if timestamp is None: if timestamp is None:
@@ -72,48 +70,23 @@ class PrimaiteIO:
"""Return the path where agent actions will be saved.""" """Return the path where agent actions will be saved."""
return self.session_path / "agent_actions" / f"episode_{episode}.json" return self.session_path / "agent_actions" / f"episode_{episode}.json"
def store_agent_actions(self, agent_actions: Dict, episode: int, timestep: int) -> None: def write_agent_actions(self, agent_actions: Dict[str, List], episode: int) -> None:
"""Cache agent actions for a particular step.
:param agent_actions: Dictionary describing actions for any agents that acted in this timestep. The expected
format contains agent identifiers as keys. The keys should map to a tuple of [CAOS action, parameters]
CAOS action is a string representing one the CAOS actions.
parameters is a dict of parameter names and values for that particular CAOS action.
For example:
{
'green1' : ('NODE_APPLICATION_EXECUTE', {'node_id':1, 'application_id':0}),
'defender': ('DO_NOTHING', {})
}
:type agent_actions: Dict
:param timestep: Simulation timestep when these actions occurred.
:type timestep: int
"""
self.agent_action_log.append(
[
{
"episode": episode,
"timestep": timestep,
"agent_actions": agent_actions,
}
]
)
def write_agent_actions(self, episode: int) -> None:
"""Take the contents of the agent action log and write it to a file. """Take the contents of the agent action log and write it to a file.
:param episode: Episode number :param episode: Episode number
:type episode: int :type episode: int
""" """
data = {}
longest_history = max([len(hist) for hist in agent_actions])
for i in range(longest_history):
data[i] = {"timestep": i, "episode": episode, **{name: acts[i] for name, acts in agent_actions.items()}}
path = self.generate_agent_actions_save_path(episode=episode) path = self.generate_agent_actions_save_path(episode=episode)
path.parent.mkdir(exist_ok=True, parents=True) path.parent.mkdir(exist_ok=True, parents=True)
path.touch() path.touch()
_LOGGER.info(f"Saving agent action log to {path}") _LOGGER.info(f"Saving agent action log to {path}")
with open(path, "w") as file: with open(path, "w") as file:
json.dump(self.agent_action_log, fp=file, indent=1) json.dump(data, fp=file, indent=1)
def clear_agent_actions(self) -> None:
"""Reset the agent action log back to an empty dictionary."""
self.agent_action_log = []
@classmethod @classmethod
def from_config(cls, config: Dict) -> "PrimaiteIO": def from_config(cls, config: Dict) -> "PrimaiteIO":

View File

@@ -7,12 +7,10 @@ from uuid import uuid4
from pydantic import BaseModel, ConfigDict, Field, validate_call from pydantic import BaseModel, ConfigDict, Field, validate_call
from primaite import getLogger from primaite import getLogger
from primaite.interface.request import RequestResponse from primaite.interface.request import RequestFormat, RequestResponse
_LOGGER = getLogger(__name__) _LOGGER = getLogger(__name__)
RequestFormat = List[Union[str, int, float]]
class RequestPermissionValidator(BaseModel): class RequestPermissionValidator(BaseModel):
""" """