@@ -313,33 +313,6 @@ def __init__(
313313 # Snapshot of progress_counter at the time we last saw _last_coordinator_selection.
314314 self ._last_coordinator_selection_progress : int = 0
315315
316- # Per-participant turn tracking driven by ``WorkflowEvent.executor_completed``.
317- #
318- # In agent-framework 1.3.0 the GroupChat orchestrator agent (the
319- # Coordinator) is invoked directly inside the framework's internal
320- # ``_invoke_agent_helper`` (see
321- # ``agent_framework_orchestrations/_group_chat.py:484``). It is NOT
322- # wrapped in an ``AgentExecutor`` and therefore never surfaces as a
323- # workflow event - which makes the Coordinator-JSON-based loop
324- # detection in ``_complete_agent_response`` permanently dead in 1.3.0.
325- #
326- # The only observable "the conversation is moving" pulse we have is
327- # ``executor_completed`` events for the *participants* (which DO go
328- # through ``AgentExecutor``). We track:
329- # - the most recently completed participant,
330- # - the streak of consecutive completions of that participant,
331- # - the total number of participant turns,
332- # and use these for two safety nets in the streaming loop:
333- # * 3+ consecutive same-participant turns => hard_loop termination
334- # * total turns >= ``max_rounds`` => hard_timeout termination
335- # (independent of ``len(self.agent_responses)`` which only grows on
336- # agent switch and so cannot reach ``max_rounds`` during a same-
337- # participant loop).
338- self ._participant_completions_total : int = 0
339- self ._last_completed_participant : str | None = None
340- self ._participant_completion_streak : int = 0
341- self ._participant_consecutive_loop_threshold : int = 3
342-
343316 def _request_forced_termination (
344317 self , * , reason : str , termination_type : str
345318 ) -> None :
@@ -570,15 +543,6 @@ async def run_stream(
570543 termination_type = "hard_timeout" ,
571544 )
572545
573- # Honor any pending termination request at the *top* of each
574- # iteration so that branches which set the flags (timeout,
575- # participant loop detection, Coordinator finish=true) take
576- # effect immediately on the next event - rather than being
577- # gated on the next ``output`` event arriving (which during a
578- # slow loop can be many seconds away).
579- if self ._forced_termination_requested or self ._termination_requested :
580- break
581-
582546 # In agent-framework 1.3.0, ``workflow.run(stream=True)`` yields
583547 # only ``WorkflowEvent`` instances; ``AgentResponseUpdate`` is
584548 # wrapped inside ``WorkflowEvent.data`` for ``type=="output"``
@@ -588,46 +552,7 @@ async def run_stream(
588552 # ``WorkflowEvent.type`` and inspect ``event.data`` /
589553 # ``event.executor_id`` to route per-participant streaming
590554 # chunks vs the orchestrator's final output.
591- if not isinstance (event , WorkflowEvent ):
592- continue
593-
594- # Participant turn completion. Used for loop / max_rounds
595- # safety nets that work even when the Coordinator is
596- # invisible to the streaming loop (which it is in 1.3.0 -
597- # the Coordinator runs inside the framework's internal
598- # ``_invoke_agent_helper`` and never surfaces as an executor
599- # event). See ``_track_participant_completion`` for details.
600- if event .type == "executor_completed" :
601- src_executor = self ._normalize_executor_id (
602- event .executor_id or ""
603- )
604- if (
605- src_executor in self .agents
606- and src_executor != self .coordinator_name
607- and src_executor != self .get_result_generator_name ()
608- ):
609- # Flush this participant's streaming buffer into a
610- # discrete per-turn ``AgentResponse`` before we track
611- # the completion. Without this, when the framework's
612- # Coordinator picks the same participant back-to-back
613- # (the loop pattern we're trying to detect),
614- # ``_start_agent_if_needed`` sees no agent switch on
615- # the NEXT turn's chunks and the buffer would grow
616- # across turns - producing one merged response rather
617- # than one response per turn.
618- if (
619- self ._last_executor_id == src_executor
620- and self ._current_agent_response
621- ):
622- await self ._complete_agent_response (
623- src_executor , on_agent_response
624- )
625- self ._current_agent_response = []
626- self ._last_executor_id = None
627- self ._track_participant_completion (src_executor )
628- continue
629-
630- if event .type != "output" :
555+ if not isinstance (event , WorkflowEvent ) or event .type != "output" :
631556 continue
632557
633558 data = event .data
@@ -648,12 +573,7 @@ async def run_stream(
648573 callback = on_agent_response ,
649574 )
650575
651- # Secondary max_rounds safety net based on agent switches.
652- # The primary check lives in ``_track_participant_completion``
653- # (driven by ``executor_completed`` events) and works even
654- # when the same agent runs back-to-back. This switch-based
655- # check is kept as defense-in-depth for sessions with
656- # normal alternation.
576+ # Enforce max rounds as a safety guard.
657577 if self .max_rounds and len (self .agent_responses ) >= self .max_rounds :
658578 self ._request_forced_termination (
659579 reason = (
@@ -662,9 +582,13 @@ async def run_stream(
662582 termination_type = "hard_timeout" ,
663583 )
664584
665- # Termination flags are honored at the top of the next
666- # iteration so any branch can request termination
667- # uniformly without duplicating break logic here.
585+ if self ._forced_termination_requested :
586+ break
587+
588+ # If the Coordinator requested finish=true, stop immediately.
589+ if self ._termination_requested :
590+ break
591+
668592 continue
669593
670594 # Final orchestrator output: complete any buffered agent
@@ -853,75 +777,6 @@ def _normalize_executor_id(self, executor_id: str) -> str:
853777 """
854778 return executor_id .split (":" )[- 1 ]
855779
856- def _track_participant_completion (self , src_executor : str ) -> None :
857- """Track a participant turn completion for loop / max_rounds detection.
858-
859- Called from the streaming loop on every ``WorkflowEvent.type ==
860- "executor_completed"`` event whose ``executor_id`` matches one of our
861- registered non-Coordinator, non-ResultGenerator participants.
862-
863- Why this exists (agent-framework 1.3.0 design constraint):
864- The framework's ``GroupChatBuilder.orchestrator_agent`` (our
865- Coordinator) is invoked directly via ``self._agent.run(...)``
866- inside ``agent_framework_orchestrations/_group_chat.py:484``. It
867- is NOT wrapped in an ``AgentExecutor`` and therefore never
868- surfaces as a workflow event. Our existing Coordinator-JSON-based
869- loop detector in ``_complete_agent_response`` (lines ~1118-1181)
870- is consequently permanently dead in 1.3.0. We need an independent
871- loop signal that does NOT rely on Coordinator visibility.
872-
873- Two safety nets enforced here:
874-
875- 1. Same-participant streak (``_participant_consecutive_loop_threshold``,
876- default 3): if the Coordinator keeps selecting the same participant
877- (e.g., the Chief Architect latched on producing an Evidence Pack
878- that never satisfies the next reviewer), 3+ consecutive completions
879- of the same participant force-terminate with ``hard_loop``.
880-
881- 2. Total round budget: each participant turn counts as one round.
882- Once total completions reach ``self.max_rounds`` the workflow
883- force-terminates with ``hard_timeout``. This is independent of
884- ``len(self.agent_responses)`` (which only grows on agent switch
885- via ``_start_agent_if_needed`` and therefore cannot reach
886- ``max_rounds`` during a same-participant loop).
887- """
888- if src_executor == self ._last_completed_participant :
889- self ._participant_completion_streak += 1
890- else :
891- self ._last_completed_participant = src_executor
892- self ._participant_completion_streak = 1
893- self ._participant_completions_total += 1
894-
895- if (
896- self ._participant_completion_streak
897- >= self ._participant_consecutive_loop_threshold
898- ):
899- self ._request_forced_termination (
900- reason = (
901- f"Loop detected: participant '{ src_executor } ' completed "
902- f"{ self ._participant_completion_streak } consecutive turns "
903- "with no other participant in between (Coordinator is "
904- "stuck on the same selection; in agent-framework 1.3.0 "
905- "the Coordinator runs inside the framework and is "
906- "invisible to the streaming loop, so we infer this from "
907- "executor_completed events)"
908- ),
909- termination_type = "hard_loop" ,
910- )
911- return
912-
913- if (
914- self .max_rounds
915- and self ._participant_completions_total >= self .max_rounds
916- ):
917- self ._request_forced_termination (
918- reason = (
919- f"Workflow exceeded max_rounds={ self .max_rounds } "
920- "participant turns; terminating to avoid infinite loop"
921- ),
922- termination_type = "hard_timeout" ,
923- )
924-
925780 async def _start_agent_if_needed (
926781 self ,
927782 agent_name : str ,
0 commit comments