Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions python/packages/core/agent_framework/_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,15 @@

logger = logging.getLogger("agent_framework")

# AgentLoopMiddleware stamps this key into the run options while a loop
# iteration is running, so providers scoped to the whole user turn
# (``after_run_once_per_turn``) skip their per-iteration ``after_run`` and only
# fire once at the loop boundary. It rides the run's options rather than a
# context variable: options reach only the runs the loop itself drives, so a
# nested ``agent.run()`` (fresh options, its own session) keeps its own turn,
# and nothing leaks into the caller's context while a stream is paused.
_LOOP_ITERATION_TOKEN_KEY = "_agent_loop_iteration" # nosec B105 - a context-options key, not a credential # ruff: ignore[hardcoded-password-string]

if TYPE_CHECKING:
ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel)
else:
Expand Down Expand Up @@ -545,6 +554,7 @@ async def _run_after_providers(
*,
session: AgentSession | None,
context: SessionContext,
only_per_turn: bool = False,
) -> None:
"""Run after_run on all context providers in reverse order.

Expand All @@ -557,6 +567,10 @@ async def _run_after_providers(
Keyword Args:
session: The conversation session.
context: The invocation context with response populated.
only_per_turn: When True, run only providers that opted into
once-per-turn semantics (``after_run_once_per_turn``); used by
AgentLoopMiddleware when a loop ends. When False, those
providers are skipped while a loop iteration is in progress.
"""
if _defer_run_persistence(partial(self._run_after_providers, session=session, context=context)):
return
Expand All @@ -570,9 +584,17 @@ async def _run_after_providers(
per_service_call_history_required = self.require_per_service_call_history_persistence and any(
isinstance(provider, HistoryProvider) for provider in self.context_providers
)
# The loop stamps the runs it drives via their options; anything else
# (nested run, caller-side run while a stream is paused) is its own turn.
in_loop_iteration = context.options.get(_LOOP_ITERATION_TOKEN_KEY) is not None
for provider in reversed(self.context_providers):
if per_service_call_history_required and isinstance(provider, HistoryProvider):
continue
once_per_turn = getattr(provider, "after_run_once_per_turn", False)
Comment thread
moonbox3 marked this conversation as resolved.
if only_per_turn and not once_per_turn:
continue
if in_loop_iteration and once_per_turn:
continue
Comment thread
moonbox3 marked this conversation as resolved.
if provider_session is None:
raise RuntimeError("Provider session must be available when context providers are configured.")
await provider.after_run(
Expand Down
4 changes: 4 additions & 0 deletions python/packages/core/agent_framework/_compaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -1529,6 +1529,10 @@ class CompactionProvider(ContextProvider):
await agent.run("Hello", session=session)
"""

# Compacting persisted history mid-task rewrites the transcript the loop
# still works from, so defer it to the end of the user turn.
after_run_once_per_turn = True

def __init__(
self,
*,
Expand Down
Loading
Loading