Add Actions: event-driven timed agent behavior (v2)#3461
Conversation
Introduce a minimal Action system that lets agents perform actions over time, with interruption and progress tracking support.
|
Conceptually, I like the look of this. Will try to dig into the specifics as soon as possible. |
|
Thanks. One other thing I was considering is giving Then instead of: class Forage(Action):
def __init__(self, sheep):
super().__init__(sheep, duration=5.0)
def on_complete(self):
self.agent.energy += 30You can just do: class Forage(Action):
duration = 5.0
def on_complete(self):
self.agent.energy += 30 |
This comment was marked as outdated.
This comment was marked as outdated.
|
@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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
@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? |
|
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__
|
From my side this PR is ready to merge. |
quaquel
left a comment
There was a problem hiding this comment.
I am fine with this as is.
I do wonder now, however, about repeated actions....
|
Maybe one thing: The name 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, CC @mesa/maintainers One other follow-up is that we might want to split by interruption cause / termination reason. There's a difference between:
And maybe this is a continuous scale conceptually (what's really "forcing" someone? Aren't we all determinisic?). Practical example:
This case points to resource claiming, (forced) resource transfer, research denial, etc. See #3304 (reply in thread). |
|
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 |
|
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. |
|
Merging as is. It’s experimental. Let’s figure stuff out as we go. |
|
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:
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! |
Summary
Adds a minimal, subclassable
Actionsystem tomesa.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
Actionclass (mesa/experimental/actions/actions.py):on_start(),on_resume(),on_complete(),on_interrupt(progress)hooks. All default topass(oron_start()foron_resume), so users only override what they neednameproperty defaults to the class name (e.g.Forage), can be passed via__init__, set per-instance, or overridden in subclassesActionStateenum: PENDING → ACTIVE → COMPLETED / INTERRUPTEDinterrupt()respects theinterruptibleflag and returns bool;cancel()always succeedsprogress,remaining_time,elapsed_time,is_resumableAgent integration (additions to
mesa/agent.py):current_action,start_action(),interrupt_for(),cancel_action(),is_busyon base AgentAgent.remove()silently cancels any scheduled action event (see Agent removal for rationale)API
Creating actions
Name
Duration and priority
Starting and running actions
Interruption
Querying action state
Resuming interrupted actions
on_start vs on_resume
Agent removal
Usage Examples
Sheep: forage, flee, resume
Call center: worker handles calls, takes breaks
Quick minimal actions
Design choices
Subclassable with hooks, no inline callbacks. An Action is a proper class you subclass and override
on_start(),on_resume(),on_complete(), andon_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.All hooks default to
pass. Users only need to override the specific hooks they care about. A minimal action only needson_complete. This keeps the boilerplate to a minimum while maintaining the single-approach design.Separate
on_startandon_resume. First start and resumption are semantically different —on_startmight reserve resources or initialize state that shouldn't happen twice.on_resumedefaults to callingon_start, so users who don't care get the same behavior. Overrideon_resumewhen the distinction matters.Separate
on_completeandon_interruptinstead of a singleon_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 youron_interrupt, not a framework concept.Live-computed progress.
progress,remaining_time, andelapsed_timeare 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.namepassable 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(), andis_busylive 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_interruptdoes 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 callcancel_action()beforeremove()to opt in.Additional Notes
This is deliberately minimal. Not included (future work):
action.then(next_action))evaluate()/select_action()behavioral loopSee #3304 for the full roadmap. This PR provides the foundation those features build on.