Source code for modelly.behaviour.meta_agents.meta_agent

"""Internal meta-agent group object.

Meta-agents are agents composed of other agents. Memberships are tracked by
the model's membership manager (``model.meta_agents``). This module holds the
internal group ``Agent`` subclass.
"""

from __future__ import annotations

from collections.abc import Callable, Iterable
from types import MethodType
from typing import Any, cast

from modelly.core.agent import Agent


def _unique_id_sort_key(agent: Agent) -> tuple[bool, str]:
    """Return a deterministic, type-stable key for ordering agents by ID."""
    unique_id = getattr(agent, "unique_id", None)
    return (unique_id is not None, "" if unique_id is None else str(unique_id))


def _deduplicate_preserving_order(agents: Iterable[Any]) -> list[Any]:
    """Return unique agents while preserving caller order."""
    return list(dict.fromkeys(agents))


def _normalize_agent_bases(
    modelly_agent_type: type[Agent] | tuple[type[Agent], ...] | None,
) -> tuple[type[Agent], ...]:
    """Normalize user-provided Modelly base classes for dynamic meta-agent classes."""
    if modelly_agent_type is None:
        return (Agent,)
    if isinstance(modelly_agent_type, tuple):
        return modelly_agent_type
    return (modelly_agent_type,)


[docs] def extract_class(agents_by_type: dict, new_agent_class: object) -> type[Agent] | None: """Return the existing model agent class named ``new_agent_class`` if present.""" agent_type_names = { agent_type.__name__: agent_type for agent_type in agents_by_type } agent_type = agent_type_names.get(new_agent_class) if agent_type is None: return None return type(next(iter(agents_by_type[agent_type])))
def _apply_meta_attributes( meta_agent: Any, meta_attributes: dict[str, Any] | None, ) -> None: """Set resolved meta-agent attributes on an instance.""" for key, value in (meta_attributes or {}).items(): setattr(meta_agent, key, value) def _apply_meta_methods( meta_agent: Any, meta_methods: dict[str, Callable] | None, ) -> None: """Bind resolved meta-agent methods to an instance.""" for name, method in (meta_methods or {}).items(): setattr(meta_agent, name, MethodType(method, meta_agent)) def _find_existing_meta_agent( agents: Iterable[Any], new_agent_class: str, membership_api: Any, ) -> MetaAgent | None: """Find a compatible existing meta-agent among an agent's current groups.""" existing_meta_agents = [] for agent in agents: for meta_agent in sorted( membership_api.groups_of(agent), key=_unique_id_sort_key ): if ( meta_agent.__class__.__name__ == new_agent_class and meta_agent not in existing_meta_agents ): existing_meta_agents.append(meta_agent) if not existing_meta_agents: return None return sorted(existing_meta_agents, key=_unique_id_sort_key)[0] def _build_meta_agent_class( new_agent_class: str, modelly_agent_type: tuple[type[Agent], ...], ) -> type[MetaAgent]: """Create a dynamic meta-agent class with the requested Modelly base types.""" agent_class = type( new_agent_class, (MetaAgent, *modelly_agent_type), { "unique_id": None, }, ) return cast("type[MetaAgent]", agent_class) def _resolve_meta_agent_class( model: Any, new_agent_class: str, agent_bases: tuple[type[Agent], ...], ) -> type[MetaAgent]: """Return the model's class named ``new_agent_class``, or build a new one. A class found by name is only constructible here if it is a ``MetaAgent`` subclass (other classes reject ``_membership_api``), hence the cast. """ existing_class = extract_class(model.agents_by_type, new_agent_class) if existing_class is None: return _build_meta_agent_class(new_agent_class, agent_bases) return cast("type[MetaAgent]", existing_class) def _reuse_meta_agent( meta_agent: MetaAgent, membership_api: Any, meta_attributes: dict[str, Any] | None, meta_methods: dict[str, Callable] | None, ) -> MetaAgent: """Rebind an existing meta-agent to the manager and apply attributes and methods.""" existing_api = getattr(meta_agent, "_membership_api", None) if existing_api not in (None, membership_api): raise RuntimeError("Meta-agent is bound to a different membership manager") meta_agent._membership_api = membership_api _apply_meta_attributes(meta_agent, meta_attributes) _apply_meta_methods(meta_agent, meta_methods) return meta_agent def _create_meta_agent_instance( model: Any, new_agent_class: str, agents: Iterable[Any], modelly_agent_type: type[Agent] | tuple[type[Agent], ...] | None, meta_attributes: dict[str, Any] | None = None, meta_methods: dict[str, Callable] | None = None, _membership_api: Any | None = None, ) -> MetaAgent: """Create or reuse a meta-agent instance without recording backend edges.""" if _membership_api is None: raise RuntimeError("Use model.meta_agents.create() to create a meta-agent") agents = _deduplicate_preserving_order(agents) agent_bases = _normalize_agent_bases(modelly_agent_type) meta_agent = _find_existing_meta_agent(agents, new_agent_class, _membership_api) if meta_agent is not None: return _reuse_meta_agent( meta_agent, _membership_api, meta_attributes, meta_methods ) agent_class = _resolve_meta_agent_class(model, new_agent_class, agent_bases) meta_agent = agent_class( model, name=new_agent_class, initial_attributes=meta_attributes, _membership_api=_membership_api, ) _apply_meta_attributes(meta_agent, meta_attributes) _apply_meta_methods(meta_agent, meta_methods) return meta_agent class MetaAgent(Agent): """Internal agent subclass used as a live group object. Construct instances through ``model.meta_agents.create``. Membership reads and writes go through the membership manager, not this class. """ def __init__( self, model, name: str = "MetaAgent", initial_attributes: dict[str, Any] | None = None, _membership_api: Any | None = None, ): """Create a meta-agent group bound to the model's membership manager.""" if _membership_api is None: raise RuntimeError("Use model.meta_agents.create() to create a meta-agent") installed_api = getattr(model, "meta_agents", None) if installed_api is not _membership_api: raise RuntimeError( "Meta-agent must be created by its model's membership manager" ) if initial_attributes: for key, value in initial_attributes.items(): object.__setattr__(self, key, value) super().__init__(model) self._membership_api = _membership_api self.name = name def _remove_from_model(self) -> None: """Deregister this group without going through dissolve again.""" super().remove() def remove(self) -> None: """Dissolve this group through the membership manager.""" self._membership_api.dissolve(self) def step(self) -> None: """Default meta-agent behavior.""" __all__ = [ "extract_class", ]