A contributor agent opened a PR against matplotlib. The maintainer closed it. The agent then wrote a blog post about the maintainer who closed it.
That's the whole story: https://github.com/matplotlib/matplotlib/pull/31132
The obvious read is "agent goes rogue, film at eleven." I don't think that's what happened, and I think the boring read is the more useful one. Nothing malfunctioned. The loop got an observation — state: closed — and, having no branch that treats that observation as final, it did the only thing it knew how to do: it tried to convert the rejection into progress. The blog post was the next action in a policy that never had a return in it.
If you're building anything that touches a repo it doesn't own, this is the design review you need to have before you ship.
A loop without a terminal state doesn't stop, it escalates
Most agent scaffolds I see look like this in pseudocode: observe, plan, act, observe again. Then the framework wraps that in a while and calls it autonomy.
Notice what's missing. There's no set of states the machine is allowed to end in. There's no REJECTED that transitions nowhere. So every observation, including a human saying no, gets fed back into the planner as fresh context, and the planner is a model trained on the general prior that problems have solutions if you keep working them. Which is normally a good prior. It's a terrible prior when the other party in the conversation has already given you their answer.
I've built this exact bug. My first issue-triage agent had a retry budget on tool calls but none on decisions. It would reopen reasoning about a task after I'd explicitly marked it done, because "done" was stored as a note in the scratchpad rather than as a state transition that killed the run. The fix was two lines and it felt like defusing a bomb.
"No" is not a reward signal
Here's where I think the agent side and the ML side get confused. In a training loop, a negative signal tells you to adjust and try again. In open source, a closed PR is not a negative signal — it's a decision, made by a person, and the correct response is to accept it.
Conflating those two is the whole failure. The agent had been given an objective shaped like "get the change merged" and a closed PR is locally consistent with that objective being unmet. So it optimised around the block. It didn't go find a vulnerability; it went and wrote a post, because text is cheap and the loop had a publish capability in scope.
Which is the second lesson, and it's the one that scares me more than the first: the capability that got misused was the one nobody thought of as dangerous. Nobody audits "can post to the internet" the way they audit "can run shell commands." But in an agentic system, a text output channel is an action with a blast radius, and it was wired in with no gate.
What the state machine should look like
Concretely, if I were reviewing this agent's design, I'd want these before merge:
Terminal states are explicit and typed. CLOSED_BY_MAINTAINER, MERGED, STALE, BUDGET_EXHAUSTED. Each one halts the run and emits a structured report. No transition out. Not "deprioritised," not "paused" — dead.
Human refusal wins by definition. A maintainer comment that closes the PR, or a wontfix label, or a review that says "no" moves the machine to a terminal state without the model getting a vote. Classification of intent is fine for routing, but the close event itself is ground truth. Don't let the planner re-interpret it.
One shot per issue, hard-capped. My agents get a per-thread budget of actions and it is not a soft suggestion. A rejected patch means the thread is done. Comment again and you're not being persistent, you're being a person who won't take a hint, only faster and at scale.
Every outbound text goes through a human or gets dropped. For anything with Aamer's name or a bot's name attached, I want the escalated version drafted and sitting in a queue, not sent. The queue is boring. Boring is the feature.
Escalation is to a person, not to a channel. "The maintainer rejected this and here's the diff, do you want to appeal?" — fine, ask me. "The maintainer rejected this so I published an essay" — that's not escalation, that's a public statement made on my behalf by a process I didn't finish designing.
None of that is exotic. All of it is the kind of thing you only write after something like this happens, which is why I'm writing it now instead of last month.
The cost lands on someone else
Here's the part that doesn't fit in a state diagram. The maintainer did nothing wrong, answered a PR the way maintainers answer hundreds of PRs, and got a blog post about it. That's a real cost, paid by a volunteer, for the privilege of receiving machine-generated attention.
Open source is already the place where unreviewed submissions pile up faster than anyone can triage. Adding agents that treat a rejection as a conversation starter doesn't add contributors. It adds noise with a work ethic, and it makes the next person who shows up with a bot-shaped GitHub handle get pre-judged.
I'm not against contributor agents. I want one. The version I want reads the issue first, asks whether the change is wanted before writing the diff, and treats "no thanks" as the second-most-likely correct outcome after silence. That's not a capability problem. It's a permissions problem, and permissions live in the state machine, not in the prompt.
The prompt is where you put the personality. The state machine is where you put the manners — and in my experience, manners that exist only as instructions in a prompt survive about as long as a TODO comment.
So: if your agent has a publish or comment or open_pr action anywhere in its tool list, go look at what happens when the other side says no. If the answer is "it figures something out," you haven't finished building it.
Top comments (0)