Skip to content

Add Actions: event-driven timed agent behavior (v2)#3461

Merged
EwoutH merged 6 commits intomesa:mainfrom
EwoutH:action_v2
Mar 13, 2026
Merged

Add Actions: event-driven timed agent behavior (v2)#3461
EwoutH merged 6 commits intomesa:mainfrom
EwoutH:action_v2

Conversation

@EwoutH
Copy link
Copy Markdown
Member

@EwoutH EwoutH commented Mar 6, 2026

Summary

Adds a minimal, subclassable Action system to mesa.experimental.actions, enabling agents to perform actions that take time, can be interrupted with progress tracking, and can be resumed.

As discussed in #3304, follows-up initial attempt #3308.

Motive

Mesa 3.5 introduced event scheduling (schedule_event, run_for, etc.), but agents still lack a built-in concept of doing something over time. There's no way to know if an agent is busy, interrupt what it's doing, get partial credit for incomplete work, or resume an interrupted task. This has been discussed extensively in #2526, #2529, #2538, #2858, and #3304.

This PR introduces the minimal foundation those proposals build on.

Implementation

Action class (mesa/experimental/actions/actions.py):

  • Subclassable with on_start(), on_resume(), on_complete(), on_interrupt(progress) hooks. All default to pass (or on_start() for on_resume), so users only override what they need
  • name property defaults to the class name (e.g. Forage), can be passed via __init__, set per-instance, or overridden in subclasses
  • Duration and priority accept callables for state-dependent values, resolved at start time
  • Interrupted actions remember progress and schedule only remaining duration on resume
  • ActionState enum: PENDING → ACTIVE → COMPLETED / INTERRUPTED
  • interrupt() respects the interruptible flag and returns bool; cancel() always succeeds
  • Live-computed query properties: progress, remaining_time, elapsed_time, is_resumable

Agent integration (additions to mesa/agent.py):

  • current_action, start_action(), interrupt_for(), cancel_action(), is_busy on base Agent
  • Inert when unused — zero cost for agents that don't use actions
  • Agent.remove() silently cancels any scheduled action event (see Agent removal for rationale)

API

Creating actions

from mesa.experimental.actions import Action, ActionState

# Subclass — override hooks for reusable actions
class Forage(Action):
    def __init__(self, sheep):
        super().__init__(sheep, duration=5.0)

    def on_start(self):
        print(f"Sheep {self.agent.unique_id} starts foraging")

    def on_resume(self):
        print(f"Sheep {self.agent.unique_id} resumes foraging")

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

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

forage = Forage(sheep)

# Minimal subclass — only override what you need
class Rest(Action):
    def on_complete(self):
        self.agent.energy = 100

Name

# Defaults to class name
Forage(sheep).name    # "Forage"
Action(agent).name    # "Action"

# Pass via __init__
Action(agent, duration=3.0, name="dig").name  # "dig"

# Override per-instance
action = Action(agent, duration=3.0)
action.name = "dig"
action.name           # "dig"

# Override in subclass
class Dig(Action):
    @property
    def name(self):
        return f"dig({self.agent.unique_id})"

Duration and priority

# Fixed values
Action(agent, duration=5.0)
Action(agent, duration=5.0, priority=10.0)

# Callable — resolved once when start() is called
Action(agent, duration=lambda a: 10.0 / a.speed)
Action(agent, priority=lambda a: a.threat_level * 2)

# Non-interruptible (cannot be interrupted, only cancelled)
Action(agent, duration=3.0, interruptible=False)

Starting and running actions

# Start an action — schedules a completion event
agent.start_action(action)

# Check if the agent is doing something
agent.is_busy              # True
agent.current_action       # the Action instance
agent.current_action.state # ActionState.ACTIVE

# Let time pass — action completes when duration elapses
model.run_for(5)           # on_complete() fires, agent.is_busy becomes False

Interruption

# Interrupt current action and start a new one
# Returns True if successful, False if current action is non-interruptible
success = agent.interrupt_for(new_action)

# Cancel current action (always succeeds, ignores interruptible flag)
# Useful for forced stops. Returns False only if agent is idle.
agent.cancel_action()

# Both call on_interrupt(progress) on the interrupted action.
# progress is the raw time fraction: 0.0 to 1.0

Querying action state

# All properties are live-computed while active — no stale values
action.state          # ActionState.PENDING / ACTIVE / COMPLETED / INTERRUPTED
action.progress       # 0.0 to 1.0, live time fraction completed
action.remaining_time # duration * (1 - progress), live
action.elapsed_time   # duration * progress, live
action.is_resumable   # True if INTERRUPTED and progress < 1.0
action.name           # class name by default, e.g. "Forage"

Resuming interrupted actions

# An interrupted action remembers its progress
agent.start_action(forage)
model.run_for(3)           # 60% of a 5.0 duration action
agent.cancel_action()      # on_interrupt(0.6) fires

forage.progress            # 0.6
forage.remaining_time      # 2.0
forage.is_resumable        # True

# Resume — schedules completion for remaining duration only
agent.start_action(forage) # on_resume() fires (NOT on_start)
model.run_for(2)           # completes, on_complete() fires

on_start vs on_resume

class BuildWall(Action):
    def on_start(self):
        self.agent.reserve_materials()  # Only on first start
        print("Starting construction")

    def on_resume(self):
        print("Resuming construction")  # Skip material reservation

    def on_complete(self):
        self.agent.wall_built = True

# If on_resume is NOT overridden, it calls on_start by default.
# Override on_resume when first-start and resumption need different behavior.

Agent removal

# remove() silently cancels the scheduled event without firing on_interrupt.
# This is safe: no callbacks touch agent state during teardown.
agent.remove()

# If your action needs cleanup (release resources, dequeue, etc.),
# call cancel_action() before remove():
agent.cancel_action()  # Fires on_interrupt — cleanup runs
agent.remove()

Usage Examples

Sheep: forage, flee, resume

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


class Flee(Action):
    def __init__(self, sheep):
        super().__init__(sheep, duration=2.0, interruptible=False)

    def on_complete(self):
        pass  # Survived

    def on_interrupt(self, progress):
        self.agent.alive = False  # Didn't escape


class Sheep(Agent):
    def __init__(self, model):
        super().__init__(model)
        self.energy = 50.0
        self.alive = True
        self._forage_action = Forage(self)
        self.start_action(self._forage_action)

    def spot_predator(self):
        self.interrupt_for(Flee(self))

    def resume_foraging(self):
        if self._forage_action.is_resumable:
            self.start_action(self._forage_action)
        else:
            self._forage_action = Forage(self)
            self.start_action(self._forage_action)

Call center: worker handles calls, takes breaks

class HandleCall(Action):
    def __init__(self, employee, call):
        duration = employee.model.rng.exponential(8.0)
        super().__init__(employee, duration=duration)
        self.call = call

    def on_complete(self):
        self.agent.calls_handled += 1
        self.call.resolve()

    def on_interrupt(self, progress):
        self.call.requeue()  # Put caller back in line


class TakeBreak(Action):
    def __init__(self, employee):
        super().__init__(employee, duration=15.0, interruptible=False)

    def on_complete(self):
        self.agent.energy = 100

Quick minimal actions

# Minimal subclass for simple effects
class Move(Action):
    def on_complete(self):
        self.agent.move()

agent.start_action(Move(agent, duration=1.0))

# State-dependent duration via callable
class Travel(Action):
    def __init__(self, agent):
        super().__init__(agent, duration=lambda a: a.distance_to_target / a.speed)

    def on_complete(self):
        self.agent.arrive()

Design choices

Subclassable with hooks, no inline callbacks. An Action is a proper class you subclass and override on_start(), on_resume(), on_complete(), and on_interrupt(progress). This follows quaquel's recommendation from the review — it keeps actions clean, self-contained, and avoids having two ways to do the same thing. Even simple actions are just a few lines as a subclass.

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

All hooks default to pass. Users only need to override the specific hooks they care about. A minimal action only needs on_complete. This keeps the boilerplate to a minimum while maintaining the single-approach design.

Separate on_start and on_resume. First start and resumption are semantically different — on_start might reserve resources or initialize state that shouldn't happen twice. on_resume defaults to calling on_start, so users who don't care get the same behavior. Override on_resume when the distinction matters.

Separate on_complete and on_interrupt instead of a single on_effect. The earlier draft (#3308) used one callback with a completion float. That conflates why the action ended with how much was done — you can't reliably tell "completed with weird reward curve" from "interrupted at 70%." Separate hooks mean the user always knows why their code was called.

No reward curves in the base class. Reward curves (mapping time progress to effective completion) encode a specific assumption about how partial work translates to partial value. That's domain logic. The base class gives you raw progress (time fraction, 0–1) and you decide what to do with it. Proportional reward, all-or-nothing, diminishing returns — it's a few lines in your on_interrupt, not a framework concept.

Live-computed progress. progress, remaining_time, and elapsed_time are properties that compute live from the model clock while the action is active. No stale values — you can query progress mid-execution for visualization, conditional logic, or other agents inspecting what this agent is doing.

Resume support through re-startable actions. An interrupted action remembers its progress and can be passed back to start_action() to continue from where it left off — only the remaining duration is scheduled. This covers the common "interrupt for something urgent, then go back to what you were doing" pattern without needing an action queue.

Callable duration and priority. Both accept callable(agent) -> float, resolved once at first start (not re-evaluated on resume). A wolf's chase duration depends on distance; a flee priority depends on threat proximity. This is common enough to support from day one rather than forcing users to pre-compute values.

name passable via __init__. Following quaquel's feedback, the name can be set directly in the constructor (Action(agent, name="dig")), via instance assignment, or by overriding the property in subclasses. Defaults to the class name.

On base Agent, not a subclass. current_action, start_action(), interrupt_for(), cancel_action(), and is_busy live directly on Agent. They're inert when unused (current_action = None). This avoids a migration when the API stabilizes and keeps the cost at one attribute initialized to None.

Silent cleanup on Agent.remove(). When an agent is removed, its action's scheduled event is cancelled but on_interrupt does not fire. The agent is being destroyed, not making a behavioral decision — callbacks touching agent state during teardown can cause errors. Models that need action cleanup (releasing resources, dequeuing) can call cancel_action() before remove() to opt in.

Additional Notes

This is deliberately minimal. Not included (future work):

See #3304 for the full roadmap. This PR provides the foundation those features build on.

@EwoutH EwoutH requested a review from quaquel March 6, 2026 09:59
@EwoutH EwoutH added feature Release notes label experimental Release notes label labels Mar 6, 2026
@quaquel
Copy link
Copy Markdown
Member

quaquel commented Mar 6, 2026

Conceptually, I like the look of this. Will try to dig into the specifics as soon as possible.

@EwoutH
Copy link
Copy Markdown
Member Author

EwoutH commented Mar 6, 2026

Thanks.

One other thing I was considering is giving Action dataclass-like properties (a bit similar we do with Scenario and Schedule).

Then instead of:

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

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

You can just do:

class Forage(Action):
    duration = 5.0

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

@EwoutH EwoutH added trigger-benchmarks Special label that triggers the benchmarking CI and removed trigger-benchmarks Special label that triggers the benchmarking CI labels Mar 6, 2026
@github-actions

This comment was marked as outdated.

@EwoutH
Copy link
Copy Markdown
Member Author

EwoutH commented Mar 8, 2026

@mesa/maintainers I would love some reviews. Especially on conceptual and API level. Are we building the right thing, is it useful for a broad set of users, is it a good building block to build future components on, do we expose the right hooks (both user API and to Mesa itself), etc.


self.model: M = model
self.unique_id = None
self.current_action: Action | None = None
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure I like editing the base class for this. With a lot of the previous experimental features, we just maintained a custom agent that had the extra behavior. I realize that with the various agent subclasses we are getting this becomes more complicated.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see your point. I can provide a Mixin, if that’s preferred.

In this case however, we’re not breaking anything, just adding a single variable and some methods. So personally I think in this case it might be okay.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure. In my view, this reveals a wider issue. Object-oriented stuff is fine, but if you want to compose objects, it starts to break down. Multiple inheritance is hard to reason about, so less than ideal. But that is all we have in Python.

Remember, a similar problem popped up when adding the scheduling stuff to Model. Again, ideally, you want to contain this in a separate object that just contains the parts for scheduling, but not the other parts. But then you need to either use multiple inheritance or use pass-through methods. Neither of which is ideal.

Technically, the problem here cannot be solved with a pure mixin. A mixin only adds methods, not state. But here you need 1 state attribute: current_action as well. Still, I am inclined to favour this approach at least while this is in experimental.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, we need a better integral approach to this.

Let's split that discussion from this PR. For this PR, I think the current approach is fine. We're in 4.0 dev anyways, and had extensive discussions about this feature and API.

@EwoutH
Copy link
Copy Markdown
Member Author

EwoutH commented Mar 10, 2026

@quaquel thanks for the review. Before I dive into the details, there are no overarching, high level, design or conceptual point we need to address at this stage?

@quaquel
Copy link
Copy Markdown
Member

quaquel commented Mar 10, 2026

Not really, other than subclassing version passing functions as per one of my comments on the code.

- Remove inline callback parameters from Action.__init__
- Make name passable via __init__
@EwoutH
Copy link
Copy Markdown
Member Author

EwoutH commented Mar 11, 2026

From my side this PR is ready to merge.

Copy link
Copy Markdown
Member

@quaquel quaquel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am fine with this as is.

I do wonder now, however, about repeated actions....

@EwoutH
Copy link
Copy Markdown
Member Author

EwoutH commented Mar 11, 2026

Maybe one thing: The name Action itself.

Action was taken from the RL world, which is used to talk in terms of Actions, Agents, Environments, Rewards and States. Lot's of our discussions were RL-inspired, so it makes sense some of the terminology comes from there. However, we might want to align it more with ABM or broader simulation.

In simulation literature, I think the closer terms are often activity or process: DES is commonly described in terms of events, activities, and processes, and process-interaction frameworks like SimPy make interruption/preemption first-class.

Since this PR models something durative that can be started, interrupted, resumed, and completed, Activity may be the most precise term. Process also fits, but feels heavier. Action is still defensible, especially if we're considering we're also talking about thinks of ActionSpaces (which is a RL-concept, and makes more sense then ActivitySpace or ProcessSpace (also typing ssS is weird).

CC @mesa/maintainers


One other follow-up is that we might want to split by interruption cause / termination reason. There's a difference between:

  • voluntary cancel (Agent decides to do something else)
  • forced cancel (another Agent or the Environment decides that this action won't/can't be done anymore)

And maybe this is a continuous scale conceptually (what's really "forcing" someone? Aren't we all determinisic?).

Practical example:

  • Sheep is eating grass
  • Second sheep starts eating same grass
  • First sheep has eaten the grass (which is now gone)
  • What happens to the second sheep?

This case points to resource claiming, (forced) resource transfer, research denial, etc. See #3304 (reply in thread).

@EwoutH
Copy link
Copy Markdown
Member Author

EwoutH commented Mar 11, 2026

I think interruption cause can be added later in a backward-compatible way, so it doesn’t need to block this PR. The safest path would be to keep on_interrupt(progress) working as-is and add cause separately, e.g. via an InterruptCause enum stored on the action (last_interrupt_cause) or via an optional second argument with a compatibility shim. The parts that would be harder to change later are semantics like whether cancel() is resumable or whether Agent.remove() should fire interruption hooks.

@quaquel
Copy link
Copy Markdown
Member

quaquel commented Mar 12, 2026

I favour activity. I would not go with process, because we don't actively support process interaction modeling (a devs formalism).

I am not sure we need to add causes. The resource claiming example, I would approach quite differently and potentially even without using Actions/Activities.

@EwoutH
Copy link
Copy Markdown
Member Author

EwoutH commented Mar 13, 2026

Merging as is. It’s experimental. Let’s figure stuff out as we go.

@EwoutH EwoutH merged commit d6145bf into mesa:main Mar 13, 2026
14 checks passed
@EwoutH
Copy link
Copy Markdown
Member Author

EwoutH commented Mar 13, 2026

The development of Actions and the discussions around it exposed many concepts that might (or might not) be useful for core Mesa.

A few future directions to explore, in no particular order:

  1. Interruption causes. The current on_interrupt(progress) doesn't distinguish why the action was interrupted — voluntary switch, forced cancellation, resource disappearing, agent removal by another agent, etc. Adding an InterruptCause enum (or similar) would let hooks respond differently. This can be done backward-compatibly (optional second argument or attribute on the action), but the semantics of cancel() vs interrupt() vs agent removal should be pinned down before people start building on the current API.
  2. Action queues and chaining. Right now agents can only have one action at a time. A natural next step is an action queue so agents can plan sequences: forage → rest → forage. This also enables reschedule_on_interrupt behavior (interrupted actions get re-queued with remaining duration). The queue design needs thought around re-validation — should queued actions re-check preconditions when dequeued?
  3. Resources. As sketched in Actions: Event-driven Agent behavior #3304, a Resource primitive (shared object with limited capacity and a waiting queue) would cover a huge class of models: factory machines, hospital beds, phone lines, charging stations. The natural fit is that Resources hold Actions, not Agents — an Action already carries the agent, duration, and hooks. This is probably the highest-value next addition for DES-style modeling.
  4. Behavioral repertoire and evaluation loop. The bigger vision from Actions: Event-driven Agent behavior #3304 involves agents having a set of available_actions with preconditions and a select_action() method that picks what to do next. This is where different behavioral frameworks (Behavioral Framework #2538) — BDI, needs-based, utility maximization, RL — would plug in as different selection strategies. Given @quaquel's (reasonable) skepticism about whether this can be made generic enough, it might be better to first collect 3-4 concrete use cases and see what patterns emerge before designing the abstraction.
  5. Continuous States (Continuous States #2529). Values that change smoothly over time (energy depleting, hunger increasing) with threshold-triggered re-evaluation are a natural complement to Actions. A ContinuousState crossing a threshold could trigger evaluate(), causing the agent to reconsider its current action. This builds straightforwardly on the event system and observables.
  6. ActionSpace (ActionSpace: Defining the influence Agents can have #2858). A concept like an ActionSpace could define constraints on what's physically or practically possible. It would sit between precondition filtering and action selection, constraining or modifying available actions. Not needed for the core action system but a natural extension once the behavioral loop exists.

Remember Mesa is an ABM library focussing on giving modellers a framework with blocks they can use or not. We're trying to provide building blocks that help lots of modelers in a significant way.

But first, let's build some example models!

@EwoutH EwoutH mentioned this pull request Mar 13, 2026
42 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

experimental Release notes label feature Release notes label

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants