Source code for modelly.behaviour.actions.actions

"""modelly.behaviour.actions: Timed, interruptible actions for Modelly agents.

An Action represents something an agent does over time. It integrates with
Modelly's event scheduling system for precise timing and supports interruption
with progress tracking and optional resumption.

Actions are subclassable: override on_start(), on_complete(),
on_interrupt(), and on_fail() to define behavior. The agent-side API
lives on the HasActions mixin; when an action ends, the agent's
on_idle() hook fires, and decisions about what to do next -- chain
another action, resume an interrupted one -- belong there.

Example::

    class Sheep(HasActions, Agent):
        pass

    class Forage(Action):
        def __init__(self, sheep):
            super().__init__(sheep, duration=5.0)

        def on_complete(self):
            self.agent.energy += 30

        def on_interrupt(self, progress):
            self.agent.energy += 30 * progress

    sheep.start_action(Forage(sheep))
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from modelly.log import create_module_logger
from modelly.time import Priority

if TYPE_CHECKING:
    from modelly.behaviour.actions.action import Action
    from modelly.core.model import Model
    from modelly.time import Event

_modelly_logger = create_module_logger()


[docs] class HasActions: """Mixin that lets an agent perform Actions. An agent performs at most one action at a time, held in ``current_action``. Actions are started with start_action(), preempted through interrupt_for() under the should_interrupt() policy, and force-stopped with cancel_action(). Whenever an action ends and nothing replaces it, the on_idle() wake fires. Action instances require their agent to inherit from this mixin. """ if TYPE_CHECKING: # Supplied by the Agent class this mixin is combined with. model: Model unique_id: int def __init__(self, *args, **kwargs) -> None: """Initialize the action slot.""" self.current_action: Action | None = None self._wake_event: Event | None = None self._wake_previous: Action | None = None self._last_wake_time: float = float("-inf") super().__init__(*args, **kwargs)
[docs] def remove(self) -> None: """Remove the agent, abandoning its action and pending wake. If the agent is currently performing an action, the action's scheduled completion event is cancelled silently. The action's on_interrupt() callback is NOT fired, because the agent is being destroyed — not making a behavioral decision. The action moves to no defined end state; it is simply abandoned. A pending on_idle wake is cancelled as well: a removed agent never wakes. If your action holds external resources (e.g., a Resource slot, a reservation, a lock), override remove() and call self.cancel_action() before super().remove() to ensure on_interrupt() fires and cleanup logic runs: def remove(self): self.cancel_action() # Fires on_interrupt for cleanup super().remove() Notes: This is a deliberate design choice. The default silent cleanup is safe and avoids callbacks touching agent state during teardown. Models that need cleanup should opt in explicitly. """ if self.current_action is not None: self.current_action._cancel_event() # Silent cleanup, no callback self.current_action = None if self._wake_event is not None: self._wake_event.cancel() self._wake_event = None self._wake_previous = None super().remove()
[docs] def start_action(self, action: Action) -> Action: """Start performing an action. The action must be in PENDING or INTERRUPTED state and the agent must not be currently performing another action. If one of the action's start requirements does not hold, the action moves to FAILED instead of starting and the agent stays idle. Check action.has_failed rather than assuming the action is running. Args: action: The Action to perform. Must have been created with this agent as its agent. Returns: The started Action. Raises: ValueError: If the agent is already performing an action, or if the action doesn't belong to this agent. """ if self.current_action is not None: raise ValueError( f"Agent {self.unique_id} is already performing an action " f"({self.current_action!r}). Use interrupt_for() or " f"cancel_action() first." ) if action.agent is not self: raise ValueError( f"Action's agent (id={action.agent.unique_id}) does not match " f"this agent (id={self.unique_id})." ) self.current_action = action action.start() # If the action completed instantly (duration=0), start() already # called _do_complete which cleared current_action via the Action. return action
[docs] def should_interrupt(self, current: Action, incoming: Action) -> bool: """Decide whether an incoming action may preempt the current one. Consulted by interrupt_for() whenever the agent is busy, with both priorities already resolved. Override to encode preemption policy, e.g. comparing action names or agent state instead of priorities. Args: current: The action the agent is performing. incoming: The action that wants to replace it. Returns: True to attempt the interruption, False to refuse it. Notes: Returning True cannot override the interruptible flag; use cancel_action() to force. This hook decides policy, the flag stays a hard property of the action. """ return current.interruptible and incoming.priority >= current.priority
[docs] def interrupt_for(self, new_action: Action) -> bool: """Interrupt the current action and start a new one. If there is no current action, simply starts the new one. Otherwise should_interrupt(current, incoming) decides whether to preempt. Args: new_action: The Action to perform instead. Returns: True if the new action was started. False if should_interrupt refused, the current action is non-interruptible, or the new action failed its start requirements. Notes: The False cases differ in what they leave behind. A refusal changes nothing. A failed requirement does not roll the interruption back: the old action is already INTERRUPTED and the agent is left idle, since whether to resume it is the model's decision. """ if self.current_action is not None: new_action._resolve_priority() if not self.should_interrupt(self.current_action, new_action): return False if not self.current_action.interrupt(): return False # interrupt() already cleared current_action self.start_action(new_action) return not new_action.has_failed
[docs] def cancel_action(self) -> bool: """Cancel the current action, ignoring interruptible flag. Calls on_interrupt with partial progress. Returns False only if there is no current action. Returns: True if an action was cancelled, False if idle. """ if self.current_action is None: return False self.current_action.cancel() # cancel() already cleared current_action return True
@property def is_busy(self) -> bool: """Whether the agent is currently performing an action.""" return self.current_action is not None
[docs] def on_idle(self, previous: Action | None) -> None: """Called when the agent's action has ended and nothing replaced it. Fires for every way an action can end -- completion, interruption, cancellation, failure -- as a zero-delay ``Priority.LOW`` event rather than synchronously, so every agent finishing at time t decides after all completions at t have run. Override it to choose what to do next, typically by starting or resuming an action. The default does nothing, and nothing is resumed automatically: an interrupted action stays interrupted unless this hook (or other model code) restarts it. Args: previous: The action that just ended, in its final state (COMPLETED, INTERRUPTED, or FAILED). None is reserved for wakes not caused by an action ending; no built-in trigger sends it today. Notes: At most one wake fires per agent per model time, so a zero-duration action started here cannot re-trigger the hook in the same instant; a wake dropped by this guard is logged at DEBUG level. The wake is skipped when the agent is busy again by the time the event runs -- interrupt_for() refills the slot synchronously, so no idle gap ever existed. If several actions end before the wake runs, they coalesce into one call and ``previous`` is the latest of them. """
def _schedule_wake(self, previous: Action | None) -> None: """Queue the deferred on_idle event, coalescing repeats. Called by Action._release_agent whenever this agent's slot is cleared. If a wake is already pending, or one already fired at the current model time, no second event is queued -- only ``previous`` is brought up to date. A wake dropped by the once-per-time guard is logged at DEBUG level. """ self._wake_previous = previous if self._wake_event is not None: return if self._last_wake_time == self.model.time: _modelly_logger.debug( f"suppressed repeat on_idle wake for agent {self.unique_id} " f"at time {self.model.time} (ending action: {previous!r})" ) return self._wake_event = self.model.schedule_event( self._fire_wake, after=0.0, priority=Priority.LOW ) def _fire_wake(self) -> None: """Run the pending wake: call on_idle if the agent is still free.""" previous = self._wake_previous self._wake_event = None self._wake_previous = None if self.current_action is not None: return # Recorded only when on_idle actually runs, so a busy skip does not # suppress a later release at the same model time. self._last_wake_time = self.model.time self.on_idle(previous)