Repository navigation
Python: [BREAKING] Bind AG-UI workflow agent executors to one authorization scope - #10035
Eduard van Valkenburg (eavanvalkenburg) wants to merge 7 commits into
Conversation
An AgentExecutor owns one AgentSession for its lifetime, so a shared Workflow or WorkflowAgent that serves several authorization scopes shares that conversation and approval state between them. Bind every AgentExecutor of a workflow, including nested workflows, to the first authorization scope that runs it. A run in another scope now ends with a WORKFLOW_SCOPE_CONFLICT RUN_ERROR before it reaches the model. Workflow factories that build new executors for every call, single-user endpoints, and workflows without an AgentExecutor keep working unchanged.
WorkflowBuilder.build() reuses the executors registered on the builder, so a builder target with cache_target=False returns a new Workflow for each request that still shares one AgentExecutor and its AgentSession. Document that in the hosting README and the WorkflowState docstring, create the workflow and its executors inside a per-request factory in the Responses workflow sample, add tests for both target shapes, and note the rule in the Python builder skill references.
Clarify in the AgentExecutor, WorkflowAgent, and ToolApprovalMiddleware docstrings that an instance keeps one session, with its history, pending approvals, and standing approval rules, for its whole lifetime and must not serve callers that need independent state.
There was a problem hiding this comment.
MAF Automated Review — Iteration 1
Result: Findings reported
Scope: full PR (3 commit(s)): 0a4f457c0265, f51585ddf6f5, 2ebcbd2cd015
Model: gpt-5.6-sol
Overview
The PR adds a process-wide, weak-keyed ownership claim for built-in AgentExecutor graphs, validates endpoint scopes, rejects conflicts before workflow execution, and covers direct, nested, factory, race, and approval scenarios extensively. The guard still misses supported A2UI wrappers and shared injected sessions, allows prior unscoped state to become owned by a later authenticated scope, and lets read-only hydration consume the executor's lifetime ownership. Because the conflict behavior intentionally rejects requests previously accepted by a stable package, the migration also requires breaking-change classification.
Reviewed the supplied pull-request change set across correctness, security/reliability, architecture, and failure behavior.
5 verified findings remained after source verification (3 high, 2 medium) across 3 files. Details are attached to the affected lines below.
Affected areas: python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py, python/packages/ag-ui/agent_framework_ag_ui/_session_ownership.py, python/packages/ag-ui/agent_framework_ag_ui/_workflow.py
There was a problem hiding this comment.
🟡 Changes recommended
With snapshot persistence enabled, hydration-only requests incorrectly claim executors in both workflow paths, blocking another scope’s first actual run with WORKFLOW_SCOPE_CONFLICT.
2 open issues
What changed in this PR
This PR addresses cross-scope session sharing in Python AG-UI workflows by binding workflow-owned AgentExecutor instances to one authorization scope. It also updates hosting guidance to create fresh executors for independent requests.
Changes:
- Add ownership checks for shared workflows, nested workflows, and
WorkflowAgentexecution. - Add regression coverage for scope isolation, approval state, and factory-versus-builder behavior.
- Update documentation and the hosting sample to clarify session ownership and per-request construction.
| File | Description |
|---|---|
| skills/agent-framework-builder-python/references/workflow-builder.md | Explain executor reuse across builder calls. |
| skills/agent-framework-builder-python/references/hosting-and-deployment.md | Clarify per-request executor construction. |
| python/samples/04-hosting/af-hosting/local_responses_workflow/README.md | Document factory-based request isolation. |
| python/samples/04-hosting/af-hosting/local_responses_workflow/app.py | Create workflows and executors inside a factory. |
| python/packages/hosting/tests/hosting/test_state.py | Test factory isolation and builder reuse. |
| python/packages/hosting/tests/hosting/_workflow_fixtures.py | Add session-history recording helpers. |
| python/packages/hosting/README.md | Explain independent workflow construction. |
| python/packages/hosting/agent_framework_hosting/_state.py | Clarify target caching and executor ownership in docstrings. |
| python/packages/core/agent_framework/_workflows/_agent.py | Document WorkflowAgent state-sharing constraints. |
| python/packages/core/agent_framework/_workflows/_agent_executor.py | Document executor session lifetime. |
| python/packages/core/agent_framework/_harness/_tool_approval.py | Explain session-owned standing approvals. |
| python/packages/ag-ui/tests/ag_ui/workflow_scope_helpers.py | Add endpoint and approval test helpers. |
| python/packages/ag-ui/tests/ag_ui/test_workflow_scope_ownership.py | Test ownership enforcement and isolation. |
| python/packages/ag-ui/README.md | Document scope conflicts and factory usage. |
| python/packages/ag-ui/AGENTS.md | Describe ownership enforcement boundaries. |
| python/packages/ag-ui/agent_framework_ag_ui/_workflow.py | Check executor ownership before workflow execution. |
| python/packages/ag-ui/agent_framework_ag_ui/_session_ownership.py | Add locked, weak-reference ownership tracking. |
| python/packages/ag-ui/agent_framework_ag_ui/_endpoint.py | Document shared-workflow authorization behavior. |
| python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py | Check ownership for wrapped workflow agents. |
Review effort: Balanced
Look through A2UI runners when claiming the workflow behind a WorkflowAgent. Bind the AgentSession that an AgentExecutor holds as well as the executor, so new executors that inject one shared session are refused for another scope. Treat a run without a scope as an owner of its own, so a workflow that ran without a scope is not handed to an authorization scope later, and the reverse. Claim only after a snapshot replay has returned, so a read-only replay does not take ownership.
There was a problem hiding this comment.
MAF Automated Review — Iteration 2
Result: Findings reported
Scope: 1 net-new commit(s): e253d29142eb
Model: gpt-5.6-sol
Overview
This iteration closes the earlier A2UI-wrapper, shared-session-object, unscoped-run, and hydration-order gaps with recursive traversal, atomic ownership claims, and broad endpoint tests. The remaining registry still treats a session's transient Python object as its identity, so restored copies of one durable session can cross scopes, and its weak-key lookup rejects otherwise valid unhashable session subclasses.
Reviewed the supplied incremental change set across correctness, security/reliability, architecture, and failure behavior.
2 verified findings remained after source verification (1 high, 1 medium) across 1 file. Details are attached to the affected lines below.
Affected areas: python/packages/ag-ui/agent_framework_ag_ui/_session_ownership.py
The ownership registry used a WeakKeyDictionary, which hashes its keys. An AgentSession or AgentExecutor subclass that defines __eq__ is unhashable, so every AG-UI run on it failed, and two distinct objects that compare equal could share one entry. Key the registry by id() with a weak reference, so user-defined hashing and equality never run and an entry disappears when its object is collected. Document what the ownership rule does not cover, such as state that a factory loads from storage into a new object, and say that an executor keeps its session between runs because a checkpoint restore replaces it.
Jose Alvarez (jpalvarezl)
left a comment
There was a problem hiding this comment.
One isolation gap remains in the agent-as-tool path. I left an inline comment because the top-level claim does not cover a shared WorkflowAgent.as_tool(), so separate authorization scopes can still reach the same workflow executors.
AG-UI does not support WorkflowAgent.as_tool(): every caller of an agent that exposes a workflow as a tool reaches the same workflow and its executor sessions, and the tool wrapper hides that workflow from the ownership claim. Record the agent that as_tool() wraps in a private attribute of the tool, keep it by reference when the tool is copied, and follow those links from AG-UI. The endpoint raises ValueError for an agent or workflow that exposes a WorkflowAgent as a tool, and a run ends with a WORKFLOW_AGENT_TOOL_UNSUPPORTED RUN_ERROR when a workflow factory builds one or a tool is added later. The rejection also applies with single_user=True.
Jose Alvarez (jpalvarezl)
left a comment
There was a problem hiding this comment.
The direct, nested, factory, copied-tool, and unhashable-session cases look addressed. I followed up in the existing workflow-agent-tool thread because a supported ContextProvider can still inject that tool after the AG-UI validation has already run, leaving the cross-scope path open.
Say in the AG-UI README and AGENTS.md, the WorkflowAgent and as_tool() docstrings, and the ownership module that exposing a WorkflowAgent as a tool is not supported, including the forms that AG-UI cannot detect: a tool that a context provider adds while a run is prepared, and a tool function that calls a workflow from custom code. AG-UI gives the callers of those no isolation.
Jose Alvarez (jpalvarezl)
left a comment
There was a problem hiding this comment.
The core cross-scope isolation issue and the concrete reachable WorkflowAgent tool paths are addressed. The remaining provider/custom-tool cases are now explicitly outside the supported WorkflowAgent.as_tool() contract, and I did not find another material correctness or security regression in the latest changes.

Motivation & Context
An
AgentExecutorkeeps oneAgentSessionbetween runs and also tracks the approval requests it has raised. Conversation history, pending approvals, and standing "always approve" rules therefore belong to the executor, not to a caller. When oneWorkflow, or oneWorkflowAgent, that holds anAgentExecutorserves several AG-UI authorization scopes, the second scope's model request contains the first scope's history, and the first scope's standing approvals apply to it. An ordinary agent that exposes aWorkflowAgentthroughas_tool()reaches the same shared workflow, and the tool wrapper hides it from the endpoint.add_agent_framework_fastapi_endpointselects that sharing when it receives a bareWorkflow, although theauthorization_scope_resolverdocumentation says it scopes workflow instances. The hosting README and thelocal_responses_workflowsample have the same shape:WorkflowState(builder, cache_target=False)returns a newWorkflowfor each request, butWorkflowBuilder.build()reuses the executors registered on the builder, so a module-levelAgentExecutorcarried its session from one request to the next.Description & Review Guide
_session_ownership.claim_agent_executorsbinds everyAgentExecutorof a workflow, and theAgentSessioneach one holds, to the first authorization scope that runs it. That includes the executors in nestedWorkflowExecutorworkflows and in the workflow behind aWorkflowAgent, also when A2UI runners wrap that agent. A run without a scope owns what it runs in the same way.AgentFrameworkWorkflow.runand theWorkflowAgentbranch of the agent stream call it after a snapshot replay has returned and before any workflow runs, so a read-only replay never takes ownership. A run in another scope ends withRUN_STARTEDandRUN_ERROR(codeWORKFLOW_SCOPE_CONFLICT) and never reaches the model. The server log says to useworkflow_factoryorsingle_user=True. The registry keys objects by identity (id()with a weak reference) and never calls their__hash__or__eq__, so an unhashable session or executor subclass works and two objects that compare equal stay apart.WorkflowAgent.as_tool(), so it rejects it instead of claiming the workflow.add_agent_framework_fastapi_endpointraisesValueErrorfor an agent or workflow that exposes aWorkflowAgentas a tool. A run ends withRUN_STARTEDandRUN_ERROR(codeWORKFLOW_AGENT_TOOL_UNSUPPORTED) before the model when a workflow factory builds one, or when a tool is added after registration. This also applies withsingle_user=True. The newworkflow_agent_toolsfollows the tools thatas_tool()made into sub-agents and into the agents of everyAgentExecutor, with a cycle guard and through A2UI runners. OrdinaryAgent.as_tool()is still served.BaseAgent.as_tool()keeps the agent it wraps in a private_wrapped_agentattribute of the tool, which AG-UI reads. Serialization skips it, and a copy of the tool keeps pointing at the same agent, as the tool function does. Nothing else changes.WorkflowStatedocstring, and thelocal_responses_workflowsample now create the workflow and its executors inside a per-request factory instead of a module-level builder. They also explain why a builder target withcache_target=Falsestill shares its executors.authorization_scope_resolverandsnapshot_scope_resolver, with and without snapshot persistence, and with the same and different thread ids. They cover a shared workflow, a sharedWorkflowAgent(also behind A2UI), nested workflows, factories, a shared injected session, unhashable session and executor subclasses, snapshot replays, runs without a scope,single_user, a constant scope, and a standing approval. Further tests cover the rejection of aWorkflowAgenttool in seven runner forms, at registration and at run time, and show that an ordinary agent tool is still served. A separate module tests the identity registry on its own, and another tests the search for workflow tools (sub-agents, executor agents, nested workflows, cycles, A2UI). Four hosting tests cover factory and builder targets for streaming and non-streaming runs, and two core tests cover theas_tool()link.workflow_factorydocstrings, theAgentExecutor,WorkflowAgent, andToolApprovalMiddlewaredocstrings, and two Python builder skill references. They now say that an executor keeps its session between runs, because a checkpoint restore replaces it.AgentExecutor(a sharedWorkfloworWorkflowAgent, or aworkflow_factorythat returns shared workflows, executors, or an injectedAgentSession) now fails the second scope withWORKFLOW_SCOPE_CONFLICTinstead of sharing history and approvals with it. A workflow that ran without a scope refuses a later scope in the same way, and the reverse. An AG-UI endpoint also no longer serves an agent, or a workflow, that exposes aWorkflowAgentas a tool (WorkflowAgent.as_tool()), also for one scope or withsingle_user=True. Core gains one private attribute on the tools thatas_tool()makes, and its runtime behavior does not change.AgentFrameworkWorkflow(workflow_factory=...)with a factory that builds a new workflow, including newAgentExecutorandAgentSessioninstances, for every call, or register the endpoint withsingle_user=Truewhen every caller is the same principal. Factories that already build new executors and sessions for every call,single_user=True, constant-scope setups, and workflows without anAgentExecutorkeep working. For aWorkflowAgenttool, serve the workflow withAgentFrameworkWorkflow(workflow_factory=...), or build the workflow inside the tool function for every call. Release note: the AG-UI check relies on the new private attribute in core, so theagent-framework-corefloor ofagent-framework-ag-uishould move to the first core release that includes it; with an older core the check finds nothing, as before this change.AgentSession.from_dict()orSessionStore.get()), executors that are notAgentExecutor, or state that sessions share outside the session object. The docs say so. Whether to close the stored-state gap, for example by passing the scope to factories as the scoped-construction question in Python: [Bug]: AG-UI shares one AgentSession across authorization scopes when a bare Workflow is passed to add_agent_framework_fastapi_endpoint #9811 asks, is left open for the maintainers. The threads of one scope still share state in shared mode.WorkflowAgent.as_tool()is rejected instead of claimed, because it is not supported in any form. The rejection cannot see a tool function of your own that calls a workflow, or a workflow tool that a context provider adds while a run is prepared. Those stay unsupported, the docs say so, and AG-UI gives their callers no isolation. A guard at the tool-invocation boundary was left out, because the method is not supported. The change is marked breaking because a second-scope request that used to be accepted now ends with aRUN_ERROR, and because an agent that exposes aWorkflowAgentas a tool is no longer served.Related Issue
Related to #9811 (not a closing reference). The direction for that issue is still under planning, so this PR proposes one for discussion. No other open PR covers this change.
AI Assistance
AI assistance details: GitHub Copilot assisted with the implementation, tests, documentation, and this PR description.
Contribution Checklist
breaking changelabel (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.