Skip to content

Python: defer turn-scoped after_run providers to the agent loop boundary - #7289

Open
Yufeng He (he-yufeng) wants to merge 5 commits into
microsoft:mainfrom
he-yufeng:fix/loop-turn-scoped-after-run
Open

Python: defer turn-scoped after_run providers to the agent loop boundary#7289
Yufeng He (he-yufeng) wants to merge 5 commits into
microsoft:mainfrom
he-yufeng:fix/loop-turn-scoped-after-run

Conversation

@he-yufeng

Copy link
Copy Markdown
Contributor

Motivation & Context

With create_harness_agent (or any AgentLoopMiddleware usage) every loop iteration is a full agent run, so CompactionProvider.after_run fires per iteration instead of once per user turn. Fired mid-task, compaction rewrites the persisted history the task still works from (#7236).

Description & Review Guide

  • What are the major changes? Providers can declare turn scope with after_run_once_per_turn (default False on ContextProvider). While a loop iteration is active, _run_after_providers skips those providers (tracked through a contextvar the middleware sets around each iteration); when the loop exits, the middleware fires the deferred providers once with the turn-level response via only_per_turn=True. CompactionProvider opts in; HistoryProvider keeps firing per iteration because its persistence is incremental per run.
  • What is the impact of these changes? Compaction now runs once per user turn under an agent loop, on both streaming and non-streaming paths, including the approval-escape and termination exits. Outside loops nothing changes.
  • What do you want reviewers to focus on? The streaming generator in _harness/_loop.py (contextvar reset placement and the finally that fires the deferred providers) and the skip logic in _run_after_providers.

Tests: 4 new tests in test_harness_loop.py (turn-scoped fires once vs run-scoped per iteration, streaming and non-streaming, no-loop baseline, CompactionProvider opt-in). test_harness_loop 92 pass; test_compaction + test_sessions + test_agents 270 pass.

Related Issue

Fixes #7236

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above).
  • This is not a breaking change. If it is a breaking change, add the breaking change label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.

Each AgentLoopMiddleware iteration is a full agent run, so CompactionProvider.after_run fired per iteration and rewrote persisted history mid-task (microsoft#7236). Providers can now opt into turn scope with after_run_once_per_turn; iterations defer them via a contextvar, and the loop fires them once at the boundary. CompactionProvider opts in; HistoryProvider keeps its incremental per-run persistence.
Copilot AI review requested due to automatic review settings July 23, 2026 11:30
@agent-framework-automation agent-framework-automation Bot added the python Usage: [Issues, PRs], Target: Python label Jul 23, 2026

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.

Pull request overview

This PR fixes a harness-loop correctness issue where ContextProvider.after_run was firing once per AgentLoopMiddleware iteration (i.e., mid-turn), by allowing providers to opt into turn-scoped after_run semantics and deferring those providers until the loop boundary. This is intended to prevent providers like CompactionProvider from rewriting persisted history while the loop is still actively using it.

Changes:

  • Adds ContextProvider.after_run_once_per_turn (default False) to mark providers whose after_run should be deferred to the end of an AgentLoopMiddleware loop.
  • Introduces loop-iteration tracking via a contextvar and updates _run_after_providers to skip turn-scoped providers during loop iterations, then fire them once at loop exit.
  • Marks CompactionProvider as turn-scoped and adds tests validating once-per-turn vs once-per-run behavior for both streaming and non-streaming runs.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
python/packages/core/tests/core/test_harness_loop.py Adds tests asserting turn-scoped providers fire once per loop (streaming/non-streaming) and CompactionProvider opts in.
python/packages/core/agent_framework/_sessions.py Extends ContextProvider with the after_run_once_per_turn opt-in flag and documentation.
python/packages/core/agent_framework/_harness/_loop.py Sets a loop-iteration contextvar around each iteration and fires deferred turn-scoped after_run providers once at loop exit.
python/packages/core/agent_framework/_compaction.py Opts CompactionProvider into once-per-turn after_run semantics.
python/packages/core/agent_framework/_agents.py Adds the loop-iteration contextvar and updates _run_after_providers to implement skip/run logic for turn-scoped providers.

Comment on lines +497 to +503
session_context = SessionContext(
session_id=context.session.session_id if context.session else None,
service_session_id=context.session.service_session_id if context.session else None,
input_messages=list(input_messages),
)
session_context._response = response
await run_after(session=context.session, context=session_context, only_per_turn=True)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 213829c. The boundary SessionContext now forwards the original run options, so turn-scoped providers see the same context.options they would on a normal per-run after_run. Covered by a new test that records the options a turn-scoped provider sees at the loop boundary.

Comment on lines +563 to +567
once_per_turn = getattr(provider, "after_run_once_per_turn", False)
if only_per_turn and not once_per_turn:
continue
if in_loop_iteration and once_per_turn:
continue

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 213829c. The contextvar now carries the looping agent instance and _run_after_providers only suppresses when it matches self, so a nested agent.run() inside an iteration keeps firing its own turn-scoped providers. A new test drives an inner agent from a tool inside a loop and asserts its provider still fires once.

…s through

Two review follow-ups: the contextvar now carries the agent instance so a nested agent.run() inside a loop iteration is not suppressed as if it were an iteration, and the boundary SessionContext forwards the original run options to turn-scoped providers.
f"got {type(inner).__name__}."
)

async for update in inner:

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.

Could we avoid retaining _LOOP_ITERATION_ACTIVE across each streamed yield? While the stream is paused, the marker leaks into caller work and suppresses turn-scoped providers on another run of the same agent; pulling the stream from sequential tasks instead raises ValueError during token reset and can invoke the provider twice. This can skip or duplicate CompactionProvider mutations and crash otherwise valid ResponseStream consumption.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Both points addressed in 1ca036d by replacing the contextvar with a stamp on the run options. The loop writes _agent_loop_iteration into the options of the runs it drives; nothing is set or reset around stream yields anymore, so a paused stream leaves the caller's context untouched and there is no token whose reset could land in a different task. A run the caller starts while the stream is paused gets fresh options and keeps its turn. Covered by a test that runs the same agent from the caller mid-stream and asserts its turn-scoped provider still fires.

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)

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.

Could the suppression state distinguish a loop invocation rather than only the agent instance? If an iteration recursively runs the same Agent with a separate session, the nested loop's boundary still sees the outer marker and skips its turn-scoped provider; the outer boundary later flushes only the outer session. That leaves the nested session without compaction or any other opted-in persistence hook.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same fix covers this: the stamp now travels in run options, which are fresh per agent.run(), so a nested loop on the same agent with its own session is governed by its own stamp, not the outer one. Its boundary flush fires normally and only the outer session's flush is driven by the outer stamp. test_nested_same_agent_run_with_separate_session_is_not_suppressed drives exactly that shape and asserts both boundary flushes fire.

@moonbox3

Copy link
Copy Markdown
Contributor

Yufeng He (@he-yufeng) I don't see commit 1ca036d showing up here.

…xtvar

The contextvar marker leaked in two ways. Held across a streamed yield
it bled into the caller's context, suppressing turn-scoped providers on
an unrelated same-agent run while the stream was paused, and a reset
from a different consuming task raised on the token. Keyed to the agent
instance, it also swallowed the boundary flush of a nested loop on the
same agent with its own session.

Stamp the runs the loop drives through their options instead. Run
options reach only the inner runs (they never enter the model request),
a nested or concurrent run starts with fresh options and keeps its own
turn, and there is no token to reset, so stream consumption is safe from
any task.
@he-yufeng

Copy link
Copy Markdown
Contributor Author

Evan Mattson (@moonbox3) my mistake, the commit landed on the wrong local branch and I pushed that one, so the PR head never had it. It's on the PR now as 6dc2e17 (same change, cherry-picked cleanly, the harness-loop suite passes 96/96 locally).

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Python Test Coverage

Python Test Coverage Report •
FileStmtsMissCoverMissing
packages/core/agent_framework
   _agents.py4604490%599, 654, 1192, 1243, 1325–1329, 1428, 1458, 1495, 1587, 1615, 1628, 1680, 1682, 1691–1696, 1701, 1703, 1709–1710, 1717, 1719–1720, 1728–1729, 1732–1734, 1744–1749, 1753, 1758, 1760
   _compaction.py8756792%138–139, 147, 174, 265–266, 284–285, 302, 337, 351, 358, 373–374, 428, 435, 451, 501, 537, 539, 559, 601, 657, 663, 665, 684, 728–733, 745, 829, 831, 846, 891, 953, 1099, 1106, 1112–1116, 1119–1121, 1127, 1146, 1259, 1261, 1263, 1300, 1307, 1312, 1327, 1341–1342, 1345–1346, 1448, 1471, 1483, 1584, 1611, 1695
   _sessions.py9455793%163, 175–176, 213, 224, 238, 262, 307, 312, 314, 324, 356, 368, 378, 487, 556–557, 1277–1281, 1296, 1326, 1363–1364, 1378, 1380, 1399, 1401, 1484, 1524, 1601, 1605, 1615, 1832, 1865–1866, 1871, 1886, 1964–1965, 1967, 2051, 2068, 2147, 2220, 2249, 2268, 2271, 2279–2280, 2292–2293, 2305, 2315, 2345
packages/core/agent_framework/_harness
   _loop.py303797%491, 531, 625, 702, 745, 820, 976
TOTAL45738424690% 

Python Unit Test Overview

Tests Skipped Failures Errors Time
9302 36 💤 0 ❌ 0 🔥 2m 24s ⏱️

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

4 participants