Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
bb9e92b
fix: isolate MCP sessions by workflow session
baywet Sep 22, 2026
5232c71
.NET/Python: Address MCP session review feedback
baywet Sep 22, 2026
777056f
Address MCP review feedback
baywet Sep 23, 2026
2182915
chore: linting
baywet Sep 23, 2026
c5a98d7
Merge origin/main into fix/mcp-workflow-session-isolation
baywet Sep 23, 2026
f1dde52
Address MCP review lifecycle comments
Copilot Sep 23, 2026
3293b65
Address MCP session review feedback
Copilot Sep 23, 2026
2ce112b
Fix MCP creation cancellation and run isolation
Copilot Sep 23, 2026
4024d20
Merge branch 'main' into fix/mcp-workflow-session-isolation
baywet Sep 28, 2026
bda47d1
fix: retry MCP waiter after creator cancellation
baywet Sep 28, 2026
f5c133f
chore: Use generated session ID for workflow session context
baywet Sep 28, 2026
5f21a68
chore: Use generated workflow session ID for context
baywet Sep 28, 2026
3c97cc2
Merge origin/main into fix/mcp-workflow-session-isolation
baywet Sep 28, 2026
97c636d
fix: make MCP shutdown cancellation-safe
baywet Sep 28, 2026
bfcd02e
chore: Potential fix for pull request finding 'Statement has no effect'
baywet Sep 29, 2026
d8d8c61
fix: support MCP cancellation on Python 3.10
baywet Sep 29, 2026
46abaee
test: avoid assigning None from MCP shutdown
baywet Sep 29, 2026
b470205
fix: observe failed MCP client creation tasks
baywet Sep 29, 2026
f47001b
chore: Potential fix for pull request finding 'Statement has no effect'
baywet Sep 29, 2026
80988a0
test: await MCP shutdown through gather
baywet Sep 29, 2026
9548e5b
Fix MCP shutdown cancellation draining
baywet Sep 29, 2026
af94e4f
Fix net472 MCP lifetime test build
baywet Sep 29, 2026
dd617dc
Wait for MCP client creators during disposal
baywet Sep 29, 2026
f33b4c6
chore: Potential fix for pull request finding 'Generic catch clause'
baywet Sep 29, 2026
18720f0
Harden nested MCP session lifetimes
baywet Sep 29, 2026
bdee27b
Clarify cancelled MCP task coordination
baywet Sep 29, 2026
1283c99
Harden MCP restore and cancellation handling
baywet Sep 29, 2026
447f8b6
Fix async workflow test naming
baywet Sep 29, 2026
a5adae5
Surface MCP cleanup failures during shutdown
baywet Oct 5, 2026
2ac83cf
Fix cancellation assertions in MCP shutdown test
baywet Oct 5, 2026
d1eab3f
Make MCP approval checkpoint test deterministic
baywet Oct 5, 2026
abcd9c7
Exclude expected MCP disposal rejection
baywet Oct 5, 2026
e4058ba
Fix declarative MCP session continuity
baywet Oct 5, 2026
c5ccc2d
Fix MCP session concurrency review findings
baywet Oct 6, 2026
c82c4ac
Merge origin/main into fix/mcp-workflow-session-isolation
baywet Oct 6, 2026
9acfbe4
Stabilize MCP cancellation assertions
baywet Oct 6, 2026
29d114d
Fix cross-framework cancellation assertion
baywet Oct 6, 2026
e227494
Fix workflow agent session identity on rejected concurrent runs
baywet Oct 7, 2026
0b293d3
Fix fresh workflow session isolation and MCP cleanup cancellation
baywet Oct 7, 2026
c8ef63f
Preserve originating MCP scope through approval continuations
baywet Oct 7, 2026
b10b5c4
Distinguish agent turns from direct workflow MCP sessions
baywet Oct 7, 2026
2cfd09d
Reject mixed MCP approval batches and preserve cleanup outcomes
baywet Oct 7, 2026
8204ea0
Annotate MCP workflow regression inputs for Zuban
baywet Oct 7, 2026
c3cb5b8
Isolate sessionless workflow agent MCP scopes
baywet Oct 7, 2026
ab67a01
chore: Update _close_claimed_entry to manage cleanup outcome
baywet Oct 8, 2026
b6ea27c
Merge branch 'main' into fix/mcp-workflow-session-isolation
baywet Oct 8, 2026
c8df515
Fix background-agent stale-waiter test thread-pool starvation
baywet Oct 8, 2026
49865cf
Merge main into fix/mcp-workflow-session-isolation
baywet Oct 15, 2026
839e4f0
fix: finish MCP cache cleanup and preserve entry checkpoint identity
baywet Oct 15, 2026
b4d4d1a
fix: bound MCP client lifetimes and align workflow dependencies
baywet Oct 15, 2026
31acf5b
fix: select built workspace wheels in meta install checks
baywet Oct 15, 2026
ec94868
fix: preserve cancellation during MCP lifecycle cleanup
baywet Oct 15, 2026
d6c9526
fix: retain workflow identity on response continuations
baywet Oct 15, 2026
0e2f255
Merge origin/main into fix/mcp-workflow-session-isolation
baywet Oct 15, 2026
30ff41a
fix: retain originating workflow scope for external input
baywet Oct 15, 2026
3b2f7b3
fix(python): preserve pending request scopes and recover legacy conti…
baywet Oct 15, 2026
97ed49b
fix(python): use generator unpacking in continuation regression
baywet Oct 15, 2026
16dc9b2
fix(workflows): validate queued response origins before replay
baywet Oct 15, 2026
39f9174
fix(workflows): preflight nested checkpoints before restoring state
baywet Oct 15, 2026
87c2e35
Merge branch 'main' into fix/mcp-workflow-session-isolation
baywet Oct 16, 2026
27ec8f9
fix(workflows): validate delegated responses before consuming state
baywet Oct 16, 2026
3e29746
fix(workflows): preflight delegated response migrations together
baywet Oct 16, 2026
f35ed14
fix(tests): initialize nested workflow wrapper before iteration
baywet Oct 16, 2026
9e4d86f
fix(workflows): preflight queued wrapped child replies
baywet Oct 16, 2026
fde8d77
chore: remove packaging changes from workflow fix
baywet Oct 16, 2026
e5886f4
Merge branch 'main' into fix/mcp-workflow-session-isolation
baywet Oct 19, 2026
2c52eed
chore: resets dotnet changes
baywet Oct 19, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
---
status: proposed
contact: baywet
date: 2026-10-15
deciders: eavanvalkenburg, westey-m
---

# Preserve declarative request origins with opt-in legacy recovery

## Context and Problem Statement

A declarative workflow can retain requests from several fresh runs. Resuming a
question, tool approval, or agent external-input request must restore its
originating workflow scope rather than inherit the most recent run's MCP session.
Older checkpoints do not retain that origin, so the framework cannot infer which
session should be resumed.

## Decision Drivers

- All declarative pause types need the same origin and response-batch rules.
- Existing checkpoints need an explicit recovery path without guessing an origin.
- Repeated restoration must select the same migrated scope without sharing a newer run's session.

## Considered Options

- Use the current workflow scope for requests without an origin. This needs no
host changes and retains apparent continuity, but can attach an older request
to an unrelated fresh run.
- Reject all unknown origins and require restarting the workflow. This avoids
guessing, but prevents hosts from recovering otherwise usable pending requests.
- Automatically assign isolated scopes. This permits recovery without host
changes, but silently loses the previous MCP session's server-side state.
- Require explicit host opt-in to assign stable, isolated scopes. This makes the
continuity trade-off visible and permits recovery, but requires host changes
and separate resumes for legacy requests.

## Decision Outcome

Choose explicit host opt-in through
`WorkflowFactory(migrate_legacy_request_scopes=True)`, defaulting to `False`:

- Questions/external input, function approvals, MCP approvals, and agent external
input retain their originating scope in the request. Response handlers restore
it before continuing. Agent requests also retain their own loop configuration.
- Factory-built workflows validate all submitted request origins before consuming
responses. Mixed-scope batches are rejected without changing request metadata.
- Checkpoint restores validate queued response origins before restoring shared or
executor state, including embedded child checkpoints. Submitted and already
queued responses share the same batch validation before pending requests are consumed.
- Nested restoration preflights a detached copy of the complete embedded tree
with each child's own policy before applying live state or queues. Migration
metadata is confined to that candidate; a rejected child leaves earlier
siblings, parent executors, and the supplied checkpoint unchanged.
- Without opt-in, a missing origin produces an actionable recovery error.
- With opt-in, a missing origin becomes
`legacy-request:<framework request ID>`. The distinct namespace never selects
the active run's scope; preserving the request ID makes migration stable across
workers and repeated restoration. Legacy requests must be resumed separately.
- Existing valid origins are unchanged. Invalid stored origins are not migrated.
Migration does not change header verification or authorize an unverifiable
MCP header context.
- Newly emitted requests and subsequent checkpoints retain the migrated scope.
The old MCP session and its server-side history cannot be reconstructed.
Hosts requiring that continuity must restart rather than opt in.
- Legacy agent loop configuration remains available from its old checkpoint
slot; new requests own their configuration instead of replacing shared state.

Checkpoint contents and framework request IDs remain trusted host-owned data.
Migration does not reconstruct state already overwritten in a legacy checkpoint.
This is a checkpoint-compatibility change with an explicit recovery path, not
transparent restoration of an old session.

The proposed deciders are Python code owners; maintainer and
engineering-management approval is still required.
3 changes: 3 additions & 0 deletions python/packages/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,9 @@ The vector store API is experimental under the shared `VECTOR_STORES` feature ID
by that continuation, can atomically restore a supplied checkpoint before cancellation, and returns the resulting
`WorkflowRunResult`. Cancelling a computer request also cancels the other pending requests in that agent's batch,
retains already resolved sibling evidence in terminal output, and resets its session before new input.
- **Nested checkpoint restoration** - Stored and embedded restores preflight a detached checkpoint tree before
replacing live state or queues. `WorkflowExecutor` delegates its private validation hook to its child's runner,
so each child uses its own response-origin policy and migration metadata stays in the candidate snapshot.
- **`WorkflowBuilder`** - Fluent API for building workflows, including explicit
`output_from` / `intermediate_output_from` selection for caller-facing emissions. `output_from`
is an allow-list for **Workflow Output**; unselected executor payloads are hidden unless
Expand Down
36 changes: 25 additions & 11 deletions python/packages/core/agent_framework/_workflows/_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ async def _run_impl(
output_events: list[WorkflowEvent[Any]] = []
async for event in self._run_core(
session_messages,
session.session_id if session is not None else None,
checkpoint_id,
checkpoint_storage,
streaming=False,
Expand Down Expand Up @@ -418,6 +419,7 @@ async def _run_stream_impl(
all_updates: list[AgentResponseUpdate] = []
async for event in self._run_core(
session_messages,
session.session_id if session is not None else None,
checkpoint_id,
checkpoint_storage,
streaming=True,
Expand All @@ -439,6 +441,7 @@ async def _run_stream_impl(
async def _run_core(
self,
input_messages: Sequence[Message],
agent_session_id: str | None,
checkpoint_id: str | None,
checkpoint_storage: CheckpointStorage | None,
streaming: bool,
Expand All @@ -453,6 +456,7 @@ async def _run_core(

Args:
input_messages: Normalized input messages to process.
agent_session_id: Framework-local identity of the supplied agent session.
checkpoint_id: ID of checkpoint to restore from.
checkpoint_storage: Runtime checkpoint storage.
streaming: Whether to use streaming workflow methods.
Expand All @@ -470,19 +474,23 @@ async def _run_core(
logger.debug(f"Restoring workflow from checkpoint {checkpoint_id}")
# Restore the workflow from checkpoint
if streaming:
async for _ in self.workflow.run(
restore_stream = self.workflow.run(
stream=True,
checkpoint_id=checkpoint_id,
checkpoint_storage=checkpoint_storage,
tools=tools,
):
)
self.workflow._set_agent_run_context(agent_session_id) # pyright: ignore[reportPrivateUsage]
async for _ in restore_stream:
pass
else:
_ = await self.workflow.run(
restore_result = self.workflow.run(
checkpoint_id=checkpoint_id,
checkpoint_storage=checkpoint_storage,
tools=tools,
)
self.workflow._set_agent_run_context(agent_session_id) # pyright: ignore[reportPrivateUsage]
_ = await restore_result
if not input_messages:
logger.info("No input messages provided; the workflow has been restored to the checkpoint state.")
return
Expand All @@ -500,43 +508,49 @@ async def _run_core(
pending_requests = await self.workflow._runner_context.get_pending_request_info_events() # pyright: ignore[reportPrivateUsage]
function_responses = self._extract_function_responses(input_messages, pending_requests)
if streaming:
async for event in self.workflow.run(
response_stream = self.workflow.run(
responses=function_responses,
stream=True,
checkpoint_storage=checkpoint_storage,
tools=tools,
function_invocation_kwargs=function_invocation_kwargs,
client_kwargs=client_kwargs,
):
)
async for event in response_stream:
yield event
else:
for event in await self.workflow.run(
response_result = self.workflow.run(
responses=function_responses,
checkpoint_storage=checkpoint_storage,
tools=tools,
function_invocation_kwargs=function_invocation_kwargs,
client_kwargs=client_kwargs,
):
)
for event in await response_result:
yield event
elif final_state == WorkflowRunState.IDLE:
if streaming:
async for event in self.workflow.run(
response_stream = self.workflow.run(
message=input_messages,
stream=True,
checkpoint_storage=checkpoint_storage,
tools=tools,
function_invocation_kwargs=function_invocation_kwargs,
client_kwargs=client_kwargs,
):
)
self.workflow._set_agent_run_context(agent_session_id) # pyright: ignore[reportPrivateUsage]
async for event in response_stream:
yield event
else:
for event in await self.workflow.run(
response_result = self.workflow.run(
message=input_messages,
checkpoint_storage=checkpoint_storage,
tools=tools,
function_invocation_kwargs=function_invocation_kwargs,
client_kwargs=client_kwargs,
):
)
self.workflow._set_agent_run_context(agent_session_id) # pyright: ignore[reportPrivateUsage]
for event in await response_result:
yield event
else:
raise AgentException(f"The underlying workflow is in an invalid state to restart: {final_state}.")
Expand Down
6 changes: 6 additions & 0 deletions python/packages/core/agent_framework/_workflows/_const.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@
# to pass kwargs from workflow.run() through to agent.run() and @tool functions.
WORKFLOW_RUN_KWARGS_KEY = "_workflow_run_kwargs"

# State key for the AgentSession identity supplied through WorkflowAgent.
WORKFLOW_AGENT_SESSION_ID_KEY = "_workflow_agent_session_id"

# State key identifying WorkflowAgent turns, including turns without an AgentSession.
WORKFLOW_AGENT_RUN_KEY = "_workflow_agent_run"

# Key used to store collision-free, executor-aware workflow invocation kwargs.
# WORKFLOW_RUN_KWARGS_KEY remains in the legacy dict format for compatibility with
# older first-party packages and checkpoints.
Expand Down
15 changes: 14 additions & 1 deletion python/packages/core/agent_framework/_workflows/_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import inspect
import logging
import types
from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Sequence
from typing import Any, TypeVar, overload

from ..observability import create_processing_span
Expand All @@ -31,6 +31,8 @@

logger = logging.getLogger(__name__)

_ResponseBatchValidation = tuple[Callable[[Sequence[WorkflowEvent]], None], Sequence[WorkflowEvent]]


# region Executor
class Executor(RequestInfoMixin, DictConvertible):
Expand Down Expand Up @@ -533,6 +535,17 @@ async def _cancel_pending_request(self, request_id: str, ctx: WorkflowContext[An
"""Release executor-owned state for a cancelled workflow request."""
del request_id, ctx

async def _get_response_validation_batches(
self, requests: Sequence[WorkflowEvent]
) -> list[_ResponseBatchValidation]:
"""Collect delegated response policies without changing pending requests."""
del requests
return []

def _validate_checkpoint_restore(self, state: dict[str, Any], requests: Sequence[WorkflowEvent] = ()) -> None:
"""Validate a checkpoint snapshot without changing live executor state."""
del state, requests

async def on_checkpoint_save(self) -> dict[str, Any]:
"""Hook called when the workflow is being saved to a checkpoint.

Expand Down
Loading
Loading