Source code for modelly.behaviour.actions.action

"""The Action class: something an agent does over time.

Lifecycle states, hooks and internal transitions live in
``modelly.behaviour.actions.lifecycle``; the agent-side API is the
HasActions mixin in ``modelly.behaviour.actions.actions``.
"""

from __future__ import annotations

from collections.abc import Callable, Iterable
from typing import TYPE_CHECKING

from modelly.behaviour.actions.actions import HasActions
from modelly.behaviour.actions.lifecycle import (
    ActionLifecycle,
    ActionState,
    _as_predicates,
)

if TYPE_CHECKING:
    from modelly.core.agent import Agent
    from modelly.time import Event


def _not_action_agent(agent: Agent) -> TypeError:
    """Build the error raised when an agent lacks the HasActions mixin."""
    return TypeError(
        f"Action requires an agent with action support; make "
        f"{type(agent).__name__} inherit from "
        f"modelly.behaviour.actions.HasActions."
    )


[docs] class Action(ActionLifecycle): """Something an agent does over time. Actions have a duration, can be interrupted, and track their own lifecycle state. They integrate with Modelly's event scheduler for completion timing. Subclass and override on_start/on_complete/on_interrupt for complex behavior. All hooks default to doing nothing (pass). Attributes: agent: The agent performing this action. model: The model (shortcut for agent.model). name: Human-readable identifier. Defaults to the class name. duration: Time to complete. May be a callable(agent) -> float for state-dependent duration, resolved at start time. priority: Importance level. Higher = more important. May be a callable(agent) -> float, resolved once: at start, or earlier if HasActions.interrupt_for weighs it against a running action. interruptible: Whether higher-priority actions can preempt this. start_requirements: Predicates that must hold for the action to start. completion_requirements: Predicates that must hold for the effect to apply. Empty by default. state: Current lifecycle state (PENDING, ACTIVE, COMPLETED, INTERRUPTED, FAILED). progress: Time fraction completed, 0.0 to 1.0. Computed live while the action is active. Notes: Actions hold a reference to their agent, mirroring how agents reference their model. This allows actions to query and modify agent state directly in their hooks. """ def __init__( self, agent: Agent, duration: float | Callable[[Agent], float] = 1.0, *, name: str | None = None, priority: float | Callable[[Agent], float] = 0.0, interruptible: bool = True, start_requirements: Callable[[Agent], bool] | Iterable[Callable[[Agent], bool]] | None = None, completion_requirements: Callable[[Agent], bool] | Iterable[Callable[[Agent], bool]] | None = None, ) -> None: """Initialize an Action. Args: agent: The agent that will perform this action. duration: Time to complete. Either a float or a callable that receives the agent and returns a float. Resolved when start() is called. name: Human-readable name. Defaults to the class name. priority: Importance level for interruption decisions. Either a float or a callable that receives the agent and returns a float. Resolved once, at start() or when interrupt_for first consults the agent's should_interrupt with it. interruptible: If False, interrupt() will fail and return False. start_requirements: A single callable(agent) -> bool, or an iterable of them. All must hold for the action to start. completion_requirements: The same, checked instead when the action completes, gating the effect rather than the attempt. Raises: TypeError: If the agent does not inherit from HasActions. """ if not isinstance(agent, HasActions): raise _not_action_agent(agent) self.agent = agent self.model = agent.model self.interruptible = interruptible self._name: str | None = name self.start_requirements = _as_predicates(start_requirements) self.completion_requirements = _as_predicates(completion_requirements) # Store raw values (may be callables, resolved at start) self._duration_spec = duration self._priority_spec = priority # Resolved values (set in start(); priority possibly earlier, see # _resolve_priority) self.duration: float = 0.0 self.priority: float = 0.0 self._priority_resolved: bool = False # Lifecycle state self.state: ActionState = ActionState.PENDING self._progress: float = 0.0 self._start_time: float = -1.0 self._event: Event | None = None @property def name(self) -> str: """Human-readable name. Returns the class name by default. Can be set via __init__(name=...), per-instance assignment (``action.name = "dig"``), or overridden in subclasses. """ return self._name if self._name is not None else self.__class__.__name__ @name.setter def name(self, value: str) -> None: self._name = value # --- Execution (called by Agent, not typically by users) ---
[docs] def start(self) -> Action: """Start executing this action (or resume from interruption). On first start (PENDING): resolves callable duration/priority, starts from progress=0. On resume (INTERRUPTED): continues from existing progress with remaining duration. If a requirement does not hold the action moves to FAILED instead, firing on_fail() rather than on_start() or on_resume(). Inspect state after starting rather than assuming the action is ACTIVE. Returns: Self, for chaining. Raises: ValueError: If the action is not in PENDING or INTERRUPTED state. A FAILED action cannot be restarted; create a new one. """ resuming = self.state is ActionState.INTERRUPTED self._check_startable() # Gate entry before duration and priority are resolved, so a failing # action neither fires on_start() nor schedules a completion event. if not self._requirements_met(self.start_requirements): self._fail() return self # Resolve callables on first start only if not resuming: self._resolve_specs() self._start_time = self.model.time self.state = ActionState.ACTIVE if resuming: self.on_resume() else: self.on_start() self._schedule_completion() return self
[docs] def interrupt(self) -> bool: """Interrupt this action. Updates progress, fires on_interrupt with the time fraction, and cancels the scheduled completion event. The action moves to INTERRUPTED state and can be resumed later with start(). Returns: True if the action was interrupted, False if it could not be interrupted (non-interruptible or not active). """ if self.state is not ActionState.ACTIVE: return False if not self.interruptible: return False self._freeze_progress() self._cancel_event() self.state = ActionState.INTERRUPTED self._release_agent() self.on_interrupt(self._progress) return True
[docs] def cancel(self) -> bool: """Cancel this action, ignoring the interruptible flag. Like interrupt(), but always succeeds for active actions and moves to INTERRUPTED state. The action can still be resumed if desired. Returns: True if the action was cancelled, False if not active. """ if self.state is not ActionState.ACTIVE: return False self._freeze_progress() self._cancel_event() self.state = ActionState.INTERRUPTED self._release_agent() self.on_interrupt(self._progress) return True
def _check_startable(self) -> None: """Raise ValueError unless the action is PENDING or INTERRUPTED.""" if self.state not in (ActionState.PENDING, ActionState.INTERRUPTED): raise ValueError( f"Cannot start action in {self.state.name} state. " f"Only PENDING or INTERRUPTED actions can be started." ) def _resolve_specs(self) -> None: """Resolve the duration and priority specs on first start.""" self.duration = ( self._duration_spec(self.agent) if callable(self._duration_spec) else self._duration_spec ) self._resolve_priority() if self.duration < 0: raise ValueError(f"Action duration must be >= 0, got {self.duration}") def _schedule_completion(self) -> None: """Complete now if no time remains, else schedule the completion event.""" # Calculate remaining time remaining = self.duration * (1.0 - self._progress) # Instantaneous actions (or fully completed) complete immediately if remaining <= 0: self._do_complete() return # Schedule completion event for remaining duration self._event = self.model.schedule_event(self._do_complete, after=remaining) def __repr__(self) -> str: """Return string representation.""" return ( f"{self.name}(state={self.state.name}, " f"progress={self.progress:.0%}, " f"duration={self.duration})" )