Source code for modelly.behaviour.meta_agents.api
"""Membership manager for meta-agents."""
from __future__ import annotations
from collections.abc import Callable, Hashable, Iterable
from typing import Any
from modelly.behaviour.meta_agents.backend import MembershipBackend, RelationKey
from modelly.behaviour.meta_agents.combinations import (
cache_build,
evaluate_combination,
find_combinations,
)
from modelly.behaviour.meta_agents.meta_agent import (
MetaAgent,
_create_meta_agent_instance,
_deduplicate_preserving_order,
)
from modelly.behaviour.meta_agents.queries import MembershipQueries
from modelly.behaviour.meta_agents.views import MembershipEdge, MembershipView
from modelly.core.agent import Agent
from modelly.time.signals import ModelSignals
from modelly.time.signals.util import Message
def _member_relations(
agents: Iterable[Any],
relation: RelationKey,
memberships: Iterable[tuple[Any, RelationKey]] | None,
) -> tuple[list[Any], list[tuple[Any, RelationKey]]]:
"""Return the deduplicated members and their (member, relation) pairs."""
if memberships is not None:
member_relations = list(memberships)
agents = _deduplicate_preserving_order(member for member, _ in member_relations)
return agents, member_relations
agents = _deduplicate_preserving_order(agents)
return agents, [(agent, relation) for agent in agents]
[docs]
class MetaAgents(MembershipQueries):
"""Membership manager for agents composed of other agents.
Tracks who belongs to which group. Change memberships with ``create``,
``add_member``, ``remove_member``, ``deactivate``, and ``dissolve``.
Removing an agent from the model also removes its memberships.
"""
def __init__(self, model: Any, backend: MembershipBackend | None = None) -> None:
"""Create a membership manager bound to one model."""
existing_api = getattr(model, "meta_agents", None)
if existing_api is not None and existing_api is not self:
raise RuntimeError("Model already has a different membership manager")
self.model = model
self.backend = backend or MembershipBackend()
model.meta_agents = self
self.model.observe("agents", ModelSignals.AGENT_REMOVED, self._on_agent_removed)
def _on_agent_removed(self, signal: Message | Any) -> None:
"""Deactivate memberships when a live agent leaves the model."""
if hasattr(signal, "additional_kwargs"):
args = signal.additional_kwargs.get("args")
agent = args[0] if args else signal.additional_kwargs.get("agent")
else:
agent = signal
if agent is not None:
self.deactivate(agent)
def _detach_entity(self, entity: Hashable) -> MembershipView:
"""Remove all incident memberships for one entity."""
snapshot = self.query_memberships(entity)
self.backend.remove_agent(entity)
self.backend.remove_group(entity)
return snapshot
[docs]
def create(
self,
new_agent_class: str,
agents: Iterable[Any],
modelly_agent_type: type[Agent] | None = Agent,
meta_attributes: dict[str, Any] | None = None,
meta_methods: dict[str, Callable] | None = None,
relation: RelationKey = "member",
memberships: Iterable[tuple[Any, RelationKey]] | None = None,
) -> MetaAgent:
"""Create a meta-agent group and record its memberships.
A group is identified by its class name, ``new_agent_class``. When at
least one of the given agents already belongs to an existing group
whose class name matches ``new_agent_class``, that group is reused:
the given agents are added to it and the existing group object is
returned. Otherwise a new group is created and registered as an agent
on the model. Note that calling ``create`` with the same class name
but no overlapping members therefore creates a second, distinct group
with the same name; name-based lookups (e.g. ``members_of("Team")``)
then raise because the name is ambiguous. To force a new group, use a
unique class name (e.g., append a timestamp or UUID). For an example
see the alliance_formation model.
Duplicate members are removed, preserving the order of first
appearance.
Args:
new_agent_class: The group's class name. A group with this name is
reused if any of ``agents`` already belongs to it; otherwise a
new group with this name is created.
agents: Initial members of the group.
modelly_agent_type: Modelly ``Agent`` class used as the base class of the
group agent. Defaults to ``Agent`` when omitted. Ignored when
an existing group is reused. Pass a custom ``Agent`` subclass
to give the group its own behavior.
meta_attributes: Attributes to set on the group. When a group is
reused, these are set on the existing group.
meta_methods: Methods to bind to the group. When a group is
reused, these are bound to the existing group.
relation: Membership relation label for ``agents`` (default
``"member"``). Ignored when ``memberships`` is given.
memberships: Alternative to ``agents``; a list of
``(member, relation)`` tuples so each member can get its own
relation label. When given, ``agents`` and ``relation`` are
ignored.
Returns:
The group agent (newly created or existing).
Examples:
Create a new team with two members (the group agent defaults to a
plain ``Agent`` subclass):
>>> team = model.meta_agents.create("Team", [alice, bob])
Add carol to the same team. Reuse requires at least one given
agent to already be in the group, so include an existing member:
>>> team2 = model.meta_agents.create("Team", [carol, alice])
>>> assert team is team2
This is the same as adding carol using ``add_member`` (more
explicit):
>>> model.meta_agents.add_member("Team", carol)
>>> assert carol in model.meta_agents.members_of("Team")
With the same class name but no overlapping members, a second,
distinct group is created:
>>> other_team = model.meta_agents.create("Team", [dave])
>>> assert other_team is not team
Force new groups by using unique names:
>>> team_a = model.meta_agents.create("Team_2026_A", [...])
>>> team_b = model.meta_agents.create("Team_2026_B", [...])
"""
agents, member_relations = _member_relations(agents, relation, memberships)
meta_agent = _create_meta_agent_instance(
self.model,
new_agent_class,
agents,
modelly_agent_type,
meta_attributes=meta_attributes,
meta_methods=meta_methods,
_membership_api=self,
)
self.backend.bulk_add(
[(member, meta_agent, rel) for member, rel in member_relations]
)
return meta_agent
[docs]
def add_member(
self,
group: Hashable,
member: Hashable,
relation: RelationKey = "member",
) -> MembershipView:
"""Add one member to one group."""
if member not in self.model.agents: # use internal attribute for optimization
raise ValueError(f"member {member!r} is not registered in the model")
group = self._resolve_group(group)
self.backend.add_membership(member, group, relation)
return self.query_memberships(member)
[docs]
def remove_member(
self,
group: Hashable,
member: Hashable,
relation: RelationKey = "member",
) -> MembershipView:
"""Remove one member from one group."""
group = self._resolve_group(group)
self.backend.remove_membership(member, group, relation)
return self.query_memberships(member)
[docs]
def dissolve(self, entity: Hashable) -> MembershipView:
"""Remove an entity's memberships and delete it from the model when possible."""
snapshot = self._detach_entity(entity)
if hasattr(entity, "_remove_from_model"):
entity._remove_from_model()
elif hasattr(entity, "remove"):
entity.remove()
return snapshot
[docs]
def deactivate(self, entity: Hashable) -> MembershipView:
"""Remove an entity from all memberships without deleting the object."""
return self._detach_entity(entity)
cache_build = staticmethod(cache_build)
evaluate_combination = staticmethod(evaluate_combination)
find_combinations = staticmethod(find_combinations)
__all__ = [
"MembershipEdge",
"MembershipView",
"MetaAgents",
]