Skip to content

fix: give AgentSelector ownership of its agent order - #1400

Open
teddytennant wants to merge 7 commits into
Farama-Foundation:mainfrom
teddytennant:agent-selector-copy
Open

fix: give AgentSelector ownership of its agent order#1400
teddytennant wants to merge 7 commits into
Farama-Foundation:mainfrom
teddytennant:agent-selector-copy

Conversation

@teddytennant

Copy link
Copy Markdown
Contributor

This is option B from #1332, as requested. It also folds in option A's regression test for the #1229 case.

The problem

AgentSelector.reinit() stored the caller's list by reference:

def reinit(self, agent_order):
    self.agent_order = agent_order   # not a copy

So for every env that passes self.agents — tictactoe, chess, go, connect_four, rps, hanabi, pistonball, pursuit, multiwalker — env._agent_selector.agent_order is env.agents, the same list object. Two pieces of core behavior were quietly riding on that aliasing:

  • AECEnv._was_dead_step() shrinks the cycle via self.agents.remove(agent)
  • the generated_agents example envs grow it via self.agents.append(agent) in add_agent()

Neither ever names the selector, so the coupling is invisible at both ends. It also leaves the selector with agent_order == [] at the end of an episode, where next() raises ZeroDivisionError: integer modulo by zero.

The change

reinit() now copies, and both mutations become explicit:

  • AgentSelector.add_agent() / AgentSelector.remove_agent()
  • AECEnv._was_dead_step() calls remove_agent() — this is where envs previously got removal for free
  • the four generated_agents example envs call add_agent()

remove_agent() is a no-op for an agent that isn't in the cycle, so an env that already dropped the agent itself (KAZ maintains its own _live_agents copy) can call it unconditionally.

It surfaced a real bug in cooperative_pong

cooperative_pong builds its selector once in __init__ and never refreshes it on reset(). Under aliasing that was masked: reset() rebinds self.agents to a fresh list, so the selector's original list was never the one _was_dead_step() drained, and it happened to hold the right agents forever. With the selector owning its order, that stale list does get drained, and the env dies on the second episode. Fixed by re-initing against the fresh agent list on reset, which is what every other env already does.

I'd argue that's the point of the change — the aliasing was hiding it.

Breaking change

A third-party AEC env with a dynamic agent set that adds agents by mutating self.agents and expects the selector to notice will need to call add_agent(). Removal is unaffected, since _was_dead_step() handles it. Envs with a fixed agent set (i.e. nearly all of them) need no changes.

Testing

New test/agent_selector_test.py, 10 tests: that reinit() copies, add_agent/remove_agent, the no-op removal, that _was_dead_step() keeps the cycle in sync, that agents added mid-episode get selected, that the order doesn't leak across resets, and a full dynamic episode staying consistent.

It includes the #1229 regression test: reset while an agent is still missing from .agents restores the whole cycle. (As noted in #1332, that case already worked — this pins it so it doesn't get re-reported.)

Full suite: no newly-failing tests vs main.

Type of change

  • Breaking change (fix or feature that would cause existing functionality to not work as expected)

Checklist:

  • I have run the pre-commit checks with pre-commit run --all-files (see CONTRIBUTING.md instructions to set it up)
  • I have run pytest -v and no errors are present.
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • I solved any possible warnings that pytest -v has generated that are related to my code to the best of my knowledge.
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

AgentSelector.reinit() stored the caller's list by reference, so
env._agent_selector.agent_order was the same object as env.agents for every
env that passed self.agents. AECEnv._was_dead_step() then mutated the
selector's cycle in place via self.agents.remove(agent), and dynamic-agent
envs grew it in place via self.agents.append(agent). The coupling was
invisible and left the selector with an empty agent_order at the end of an
episode, where next() raises ZeroDivisionError.

reinit() now copies, and the two mutations are explicit:

- AECEnv._was_dead_step() calls AgentSelector.remove_agent()
- the generated_agents example envs call AgentSelector.add_agent()

This surfaced a latent bug in cooperative_pong, which built its selector once
in __init__ and never refreshed it on reset(); it worked only because the
selector's stale list happened to hold the same agents. It now reinits against
the fresh agent list.

Refs Farama-Foundation#1332.
Copilot AI review requested due to automatic review settings July 13, 2026 12:09

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@jkterry1

Copy link
Copy Markdown
Member

@teddytennant your PR is failing CI

jkterry1 and others added 2 commits July 14, 2026 13:03
_was_dead_step now removes agents from the selector's cycle, so by the
end of an episode agent_order can be empty. reset() was reiniting from
that drained list, which left envs that only call reset() (notably mpe2,
used by the AgileRL tutorials) dead on the second episode with
ZeroDivisionError.

Keep the order last passed to reinit as a baseline, restore it on
reset(), and treat add_agent/remove_agent as episode-local. generated
agents envs start the cycle with next() after building via add_agent so
reset no longer wipes their setup.
@teddytennant

Copy link
Copy Markdown
Contributor Author

Fixed the AgileRL CI failure.

Root cause: _was_dead_step() now correctly drops agents from the selector's cycle (the whole point of this PR). Envs like mpe2 only call _agent_selector.reset() on env reset and never reinit. reset() was implemented as reinit(self.agent_order), so after a full episode drained the cycle it rewound an empty list and the next next() raised ZeroDivisionError: integer modulo by zero. AgileRL's AsyncPettingZooVecEnv worker hit that on the second episode; the real traceback was then masked by a gymnasium logger format-string bug (TypeError: not enough arguments for format string).

Fix: AgentSelector now remembers the order last passed to reinit() / the constructor and restores it on reset(). add_agent / remove_agent stay episode-local. The generated_agents example envs start the cycle with next() after building via add_agent, so reset() no longer wipes their setup.

New unit tests cover restore-after-removals, drop-episode-local-additions, and reinit-updates-baseline. mpe2 multi-episode (AEC + parallel) and the existing agent_selector suite pass locally.

@dm-ackerman dm-ackerman left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

a few minor-ish things - a couple are just things to think about, but assuming the naming of the selector really ought to be addressed

def reset(self) -> Any:
"""Reset to the original order."""
self.reinit(self.agent_order)
"""Reset to the order last passed to :meth:`reinit`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This should document the return value - it's (maybe) not obvious that reset steps through the agent list

Comment thread pettingzoo/utils/agent_selector.py Outdated
Comment on lines +85 to +87
self.agent_order = list(self._original_agent_order)
self._current_agent = 0
self.selected_agent = 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I believe this can be implemented as self.reinit(self._original_agent_order) for less duplication at a slight performance cost

Comment thread pettingzoo/utils/env.py
Comment on lines +231 to +236
# Envs used to get this for free, because the selector aliased the very
# list we just mutated. It owns a copy now, so drop the agent explicitly.
agent_selector = getattr(self, "_agent_selector", None)
if agent_selector is not None:
agent_selector.remove_agent(agent)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This adds an undocumented expectation that environments have defined their agent selector as _agent_selector. If they haven't, this will potentially yield bugs.
i think this needs to be handled somehow

self._current_agent = 0
self.selected_agent = 0

def add_agent(self, agent: Any) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

weird things can happen if there are duplicate agents added (due to removal and is first/last checks). There was never a check for that before, I don't know if it makes sense to add one now?

Comment thread pettingzoo/utils/agent_selector.py Outdated
Comment on lines +70 to +71
Does nothing if the agent is not in the cycle, so that an env which has
already dropped the agent itself can still call this unconditionally.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm not sure having remove_agent silently ignore the agent not being present is the best option. It's not in line with existing remove() behavior in the python library which raises errors. It's not clear to me that there is a need for supporting duplicate calls to remove_agent(). What is the use case for supporting that?

@dm-ackerman

Copy link
Copy Markdown
Contributor

I made a couple comments on the code - mostly minor things and at least one more than minor thing

However, thinking more on this, I don't think this should be accepted. This is a breaking change that is likely to be overlooked by most users. We're probably not going to tag this version 2.0.0 so we're breaking other peoples code without telling them, which is not lining up with best practices or the project standards (https://farama.org/project_standards)

As for breaking changes, agents is tied to the agent selector (for good or bad) and the code assumes this. See for example AECIterator, agents and agent_selection are implicitly coupled. Breaking that coupling is going to enable subtle bugs that the AI missed, like the iterator breaking. This isn't just an AgentSelector design change, it is a codebase design change and needs more thought put into it to avoid breaking other stuff.

I'm not claiming this is a bad change, just premature. If this is the way to go, it makes more sense in PZ 2.0 than right now.

Is there an actual bug that this is fixing? If not, it seems questionable to knowingly break a bunch of existing environments and add potential bugs for something that is not needed.

Address review feedback: reset() steps through the cycle rather than only
rewinding it, which was not obvious from the docstring, and it can restore
the baseline order through reinit() instead of repeating its body.
@teddytennant

Copy link
Copy Markdown
Contributor Author

Thanks, both good points. I pushed fixes for the three code comments:

  • add_agent now raises if the agent is already in the cycle. You were right that duplicates cause trouble: remove_agent only drops the first copy and is_first/is_last compare by value.
  • remove_agent now raises like list.remove does. The only caller that can't know whether the agent is still there is _was_dead_step, so it checks membership itself now.
  • For the _agent_selector naming: api_test now fails an env whose selector still lists an agent after that agent's final step(None). It looks for selectors by type, not by name, so an env that stores one under a different name gets a clear failure instead of a subtle bug. I also documented the convention on _was_dead_step.

On whether this should land at all, to answer your question directly: yes, there are two real bugs.

next() raises ZeroDivisionError: integer modulo by zero once every agent has been dead stepped out, because the selector's order is the same list _was_dead_step drains. And cooperative_pong builds its selector once in __init__ and never refreshes it on reset, which the aliasing was hiding. It dies on the second episode once the selector owns its order.

Worth noting this is option B from #1332, which was asked for there. If the breakage still feels too early for a 1.x release I'm happy to split it: the ZeroDivisionError fix, the cooperative_pong fix and the regression tests can go in on their own, and the ownership change can wait for 2.0. Just say which you'd prefer.

teddytennant and others added 2 commits August 8, 2026 14:55
Address the remaining review comments:

- add_agent now rejects an agent already in the cycle. Duplicates break
  remove_agent, which drops only the first copy, and is_first/is_last,
  which compare by value.
- remove_agent raises ValueError for an agent that is not in the cycle,
  matching list.remove instead of failing quietly. _was_dead_step, the
  one caller that cannot know whether the agent is still there, checks
  membership itself.
- api_test now asserts that no AgentSelector held by the env still lists
  an agent after that agent's final step(None). It searches by type
  rather than by name, so an env that stores its selector somewhere
  other than _agent_selector fails the test instead of silently keeping
  a stale cycle. The _agent_selector convention is documented on
  _was_dead_step.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants