Skip to content

Break Autopilot handoff auto-fire cycles#325441

Open
Alexoswin wants to merge 3 commits into
microsoft:mainfrom
Alexoswin:fix/autopilot-handoff-cycle-325440
Open

Break Autopilot handoff auto-fire cycles#325441
Alexoswin wants to merge 3 commits into
microsoft:mainfrom
Alexoswin:fix/autopilot-handoff-cycle-325440

Conversation

@Alexoswin

@Alexoswin Alexoswin commented Jul 11, 2026

Copy link
Copy Markdown

Summary

Fixes #325440.

Autopilot auto-submits a mode's send: true handoff the instant a response completes, with no user turn in between (ChatWidget#renderChatSuggestNextWidget). Nothing currently bounds how many times this can happen in a row: if a handoff chain cycles back on itself — e.g. agent A's auto-send handoff lands on agent B, and B's response offers a handoff back to A — Autopilot resubmits it forever. Each lap is a fully completed, billed turn, and looks like a normal finished response in the UI (retry/thumbs icons and all), so there's no visible signal anything is wrong until a budget cap gets hit.

That matches the reporter's description almost exactly: an unattended Autopilot session "restarted the thread about 40 times" overnight, each restart showing as a normal completed turn with nothing in the transcript explaining why a new one started.

Fix

Autopilot now counts how many send: true handoffs it has auto-fired back to back with no human turn in between, and stops auto-firing once that count reaches AUTOPILOT_MAX_CONSECUTIVE_AUTO_HANDOFFS (5 — in the same neighborhood as the existing in-turn nudge cap, ToolCallingLoop.MAX_AUTOPILOT_ITERATIONS = 3, but a little higher since a cross-turn handoff is a coarser unit of work).

  • shouldAutoFireHandoff(consecutiveAutoFiredCount) in chatModes.ts is the pure decision function.
  • ChatWidget tracks the count in _consecutiveAutopilotAutoHandoffs, incrementing on every auto-fire and resetting to 0 whenever: the user isn't in Autopilot mode, or the widget is reattached to a different session (session picker / history navigation go through the viewModel setter, which is now the single reset point).
  • A legitimate multi-step chain (plan → implement → verify) is unaffected — it stays well under the cap and terminates naturally in a final response with no further handoff, rather than continuing indefinitely.
  • Once suppressed, the handoff still renders as a normal clickable suggestion instead of the widget doing nothing, so the user isn't stuck — they can drive it forward manually, deliberately.

Why a plain count instead of detecting the repeated handoff by id: an earlier version of this PR tried exactly that — track the id of the last auto-fired handoff and refuse to re-fire the same one. It has a real gap: two differently-labeled handoffs pointing at each other (e.g. "Implement" from plan → implement, "Review" from implement → plan) form an infinite loop whose two directions never share an id — which is if anything the more natural way to author a back-and-forth handoff, not a contrived edge case — so an identity-based check would never trip at all. It also had a session-leak bug: the guard field wasn't reset when a widget got reattached to a different session, so a stale value from one session could spuriously suppress a brand new session's legitimate first auto-handoff. A plain consecutive-attempt count has neither problem: it doesn't care whether ids match, and it's explicitly reset per-session.

This intentionally does not try to fix everything in the linked issue (the empty-response "Sorry, no response was returned" thread death and the /compact failure look like separate bugs in the summarization/compaction path around large-context requests) — keeping this PR scoped to the one change that stops money from being spent unattended.

Test plan

  • Unit tests in chatHandoffs.test.ts for shouldAutoFireHandoff: allowed for every count under the cap, blocked at and beyond the cap, and a same-cap comparison showing a same-id cycle and a different-id cycle are governed identically (the gap the earlier id-based approach had).
  • Manual repro of the original overnight-loop scenario wasn't attempted (requires a custom multi-agent handoff cycle + Autopilot + real model spend to trigger) — the unit tests exercise the decision logic directly instead.
  • Could not run the full test suite / typecheck in this environment (dependencies not installed, and a full npm install on this monorepo is a heavyweight operation I didn't want to run unprompted). Reviewed the diff by hand against existing call sites and types, and manually traced the boundary conditions (cap reached exactly, cap exceeded, session-switch reset) against the implementation.

Autopilot auto-submits a mode's send:true handoff the instant a response
completes, with no user turn in between (ChatWidget#renderChatSuggestNextWidget).
If a handoff chain cycles back on itself - e.g. agent A's auto-send handoff
lands on agent B, whose response again offers that same handoff back to A -
nothing stops Autopilot from resubmitting it forever. Each cycle is a fully
completed, billed turn, and looks like a normal finished response in the UI
(retry/thumbs icons and all), so there's no visible signal anything is wrong
until a budget cap is hit.

Track the id of the last handoff Autopilot auto-fired and refuse to fire the
same one again immediately after. Legitimate multi-step chains (plan ->
implement -> verify, a different handoff each time) are unaffected; only an
exact repeat is suppressed, falling back to showing the handoff as a normal
clickable suggestion so the user can still drive it manually.

Fixes microsoft#325440
Copilot AI review requested due to automatic review settings July 11, 2026 17:29

Copilot AI 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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Self-review of the previous commit found two real gaps in the id-based
cycle guard:

1. The guard field lived on ChatWidget but was never reset when the same
   widget instance got reattached to a different session (setModel /
   the viewModel setter). A stale "last fired handoff" from one session
   could spuriously suppress a brand new, unrelated session's first
   legitimate auto-handoff.

2. More fundamentally, comparing handoff *identities* (agent + label)
   across turns has a blind spot: two differently-labeled handoffs
   pointing at each other (e.g. "Implement" from plan -> implement,
   "Review" from implement -> plan) never share an id, so an A <-> B
   loop built that way - which is if anything the more natural way to
   author a back-and-forth handoff - would never be detected at all.

Replaced it with a plain counter: how many send:true handoffs Autopilot
has auto-fired in a row with no human turn in between, capped at
AUTOPILOT_MAX_CONSECUTIVE_AUTO_HANDOFFS. This needs no assumption about
handoff ids lining up across hops, so it bounds any unbounded chain -
repeating or not, same ids or different - not just the specific shape
the earlier check happened to catch. It resets on any non-auto-fired
turn, on leaving Autopilot, and on session switch, and still falls back
to showing the handoff as a manual suggestion once suppressed rather
than doing nothing.

Rewrote the unit tests against the new signature and added coverage for
the session-switch reset and the different-id cycle shape the previous
approach would have missed.
@Alexoswin

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

@roblourens roblourens assigned justschen and unassigned roblourens Jul 13, 2026
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.

Fable 5 dies and loops on Autopilot forever.

4 participants