Repository navigation
Description
Observed Behavior
add_agent_framework_fastapi_endpoint(app, workflow, authorization_scope_resolver=...) wraps a bare Workflow as AgentFrameworkWorkflow(workflow=...) at _endpoint.py:207, which is the shared-instance mode. The per-thread isolation inside AgentFrameworkWorkflow is a cache keyed by (snapshot_scope, thread_id) that is only reachable through the workflow_factory= path (_workflow.py:717-727); the shared mode never consults it.
A single AgentExecutor._session object therefore serves every request, including requests carrying different authorization scopes, and one principal's conversation is sent to the model inside another principal's request.
During principal B's request (X-User: bob, threadId: thread-B), _inner_get_response receives:
user: PRIVATE-MESSAGE-FROM-A
assistant: model-reply
user: question from B
id(stub.last_session) == id(EX._session), and that id is identical across both requests, so it is the same object and not a copy. session_id stays 'session-of-A' throughout B's request. Anything held on that session crosses with it: conversation history, ToolApprovalState, standalone FIDES session state, and service_session_id.
Both requests complete with HTTP 200 and the model reply present in the SSE body, so the runs really execute; this is not an error path.
Three statements in the project cannot all be true at the same time:
- The state isolation guidance says "It is not recommended to reuse a single workflow instance for multiple tasks or requests, as this can lead to unintended state sharing."
_endpoint.py:207selects exactly that reuse automatically when a bareWorkflowis handed to the documented entry point, and_validate_authorization_scope_configuration, three lines below, requires an explicit multi-principal authorization mode while never checking this pairing.- Discussion [AG-UI] [.NET] Workflow AsAgent and Unintended state sharing #3755 answers this deployment shape with "This is safe by design" and "Nothing is shared between concurrent requests", reasoning from
run_agent_stream().run_agent_streamis defined in_agent_run.pyand is never called from_workflow.pyor_workflow_run.py, so that reasoning covers the agent path and not the workflow path.
Expected Behavior
Two requests carrying different authorization scopes should not share one AgentSession. Principal B's model call should not contain principal A's messages, and session_id during B's request should not be the one established during A's.
Either the endpoint builds a workflow per (authorization scope, thread id) when it is handed a bare Workflow, the same way the workflow_factory= path already does, or the authorization validation refuses the combination instead of accepting it silently.
Suggested fix, smallest first: extend _validate_authorization_scope_configuration to reject, or at minimum warn, when the wrapped runner is in shared-workflow mode and the endpoint is registered with authorization_scope_resolver or snapshot_scope_resolver. That function already runs three lines below the auto-wrap at _endpoint.py:207 and already performs this class of check.
Larger alternatives: wrap the bare Workflow in a per-thread factory inside _endpoint.py, or correct the guidance so that the state isolation page, the endpoint behaviour, and the answer in Discussion #3755 stop contradicting each other.
Steps to Reproduce
- Build a
Workflowwhose start executor is anAgentExecutor, with an agent that has anInMemoryHistoryProvider. - Register it with
add_agent_framework_fastapi_endpoint(app, workflow, authorization_scope_resolver=...), passing the bareWorkflow, not aworkflow_factory. The helper wraps it asAgentFrameworkWorkflow(workflow=...)at_endpoint.py:207. - Send request A with header
X-User: alice,threadId: thread-A, and a recognisable message such asPRIVATE-MESSAGE-FROM-A. Consume the SSE stream fully. - Send request B with a different principal and thread: header
X-User: bob,threadId: thread-B, a different message id, and any message text. Consume the SSE stream fully. - Inspect the list of messages handed to the chat client during request B, and compare
id()of the executor'sAgentSessionacross both requests.
Observed at step 5: request B's model call contains request A's message, and the AgentSession is the same object in both requests.
The two requests must use distinct message ids. InMemoryHistoryProvider.save_messages calls filter_new_messages, which deduplicates by message id, so reusing one id hides the second turn and makes the history look like it did not accumulate.
Minimal Reproduction
Run from the repository root, against main at commit 6afcecd. It reuses the repository's own test stub (python/packages/ag-ui/tests/ag_ui/conftest.py, StreamingChatClientStub), so no hand-written chat client is involved.
import importlib.util, sys
sys.path.insert(0, "python/packages/ag-ui")
spec = importlib.util.spec_from_file_location(
"cft", "python/packages/ag-ui/tests/ag_ui/conftest.py")
cft = importlib.util.module_from_spec(spec); spec.loader.exec_module(cft)
from fastapi import FastAPI, Header
from fastapi.testclient import TestClient
from agent_framework import ChatResponseUpdate, AgentSession
from agent_framework._sessions import InMemoryHistoryProvider
from agent_framework._workflows._agent_executor import AgentExecutor
from agent_framework._workflows._workflow_builder import WorkflowBuilder
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
CALLS = []
class Spy(cft.StreamingChatClientStub):
def _inner_get_response(self, *, messages, stream=False, options, **kw):
CALLS.append([f"{m.role}: {m.text}" for m in messages])
return super()._inner_get_response(
messages=messages, stream=stream, options=options, **kw)
stub = Spy(cft.stream_from_updates(
[ChatResponseUpdate(role="assistant",
contents=[{"type": "text", "text": "model-reply"}])]))
agent = stub.as_agent(name="A", instructions="x",
context_providers=[InMemoryHistoryProvider(source_id="history")])
EX = AgentExecutor(agent, id="exec")
WF = WorkflowBuilder(start_executor=EX).build()
def scope(x_user: str = Header(default="anon")) -> str:
return f"principal:{x_user}"
app = FastAPI()
# bare Workflow -> _endpoint.py:207 wraps it as AgentFrameworkWorkflow(workflow=...)
add_agent_framework_fastapi_endpoint(app, WF, authorization_scope_resolver=scope)
http = TestClient(app)
def req(user, thread, text):
body = {"threadId": thread, "runId": f"run-{user}",
"messages": [{"id": f"msg-{user}", "role": "user", "content": text}],
"state": {}, "tools": [], "context": [], "forwardedProps": {}}
with http.stream("POST", "/", json=body, headers={"X-User": user}) as r:
return "".join(r.iter_text())
EX._session = AgentSession(session_id="session-of-A")
a = req("alice", "thread-A", "PRIVATE-MESSAGE-FROM-A")
id_a = id(EX._session)
b = req("bob", "thread-B", "question from B")
id_b = id(EX._session)
print("model saw during B :", CALLS[-1])
print("same session object:", id_a == id_b == id(stub.last_session))
print("session_id during B:", EX._session.session_id)
print("A reply reached SSE:", "model-reply" in a)
print("B reply reached SSE:", "model-reply" in b)Output:
model saw during B : ['user: PRIVATE-MESSAGE-FROM-A', 'assistant: model-reply', 'user: question from B']
same session object: True
session_id during B: session-of-A
A reply reached SSE: True
B reply reached SSE: True
The last two lines are the positive control: both runs really reach the model and complete, so the shared session is not an artefact of a failed or short-circuited request.
Error Messages and Stack Traces
None. No exception is raised and no stack trace is produced. Both requests return HTTP 200 and both complete with the model reply present in the SSE body. The defect is silent.
Package Versions
agent-framework-core and agent-framework-ag-ui, from a source checkout of main at commit 6afcecd
Python Version
Python 3.13
Operating System
Linux
Regression
Unknown
Additional Context
Scope of what was actually demonstrated, to keep the report precise:
- The shared
AgentSessionobject and the conversation history crossing between the two requests are demonstrated by the script above, end to end through the endpoint, with a positive control on both runs. ToolApprovalState, standalone FIDES session state andservice_session_idare named because they live on the sameAgentSessionand therefore travel with it, but their organic population by a real approval or policy flow was not separately exercised here. Treat those three as a consequence of the same mechanism rather than as separately demonstrated.
Related: Discussion #3755 ("[AG-UI] [.NET] Workflow AsAgent and Unintended state sharing") asks about this exact deployment shape and is currently marked unanswered, with the reply reasoning from run_agent_stream(). The script above exercises the workflow path, which does not go through that function, so the two may simply be describing different code paths.
This is filed as a correctness and API-consistency bug, not as a security report. The behaviour of a reused workflow instance is documented; what this issue is about is that the documented endpoint helper selects that reuse on the caller's behalf, in the same call where it requires an explicit multi-principal authorization mode.
Acknowledgements
- I searched existing issues and did not find a duplicate.
- I personally verified this behavior and the reproduction details are authentic.
- I will wait for explicit maintainer agreement before starting implementation of a non-trivial change.
Metadata
Metadata
Labels
Type
Projects
- StatusShow more project fieldsNo status
Activity
[-]Python: [Bug]: AG-UI shares one AgentSession across authorization scopes when a bare Workflow is passed to add_agent_framework_fastapi_endpoint[/-][+].NET: Python: [Bug]: AG-UI shares one AgentSession across authorization scopes when a bare Workflow is passed to add_agent_framework_fastapi_endpoint[/+]prassu017 commentedon Oct 28, 2026
I'd like to take this. I'll reproduce it on
mainand open a PR shortly. Could I be assigned?github-actions commentedon Oct 28, 2026
🤖 Automated triage reproduction notes (agent-authored — trust but verify)
Agent analysis
The bug is in
python/packages/ag-ui/agent_framework_ag_ui/_endpoint.py::add_agent_framework_fastapi_endpointaround line 207 andpython/packages/ag-ui/agent_framework_ag_ui/_workflow.py::AgentFrameworkWorkflow._resolve_workflowaround line 714. Passing a bare stateful Workflow withauthorization_scope_resolvercauses distinct principals and thread IDs to reuse its AgentExecutor session. Minimal repro: register a Workflow containing an AgentExecutor with InMemoryHistoryProvider, then send fully consumed SSE requests under two authorization scopes and assert the second model input and session exclude the first request.python/packages/ag-ui/tests/ag_ui/test_issue_981.py1.7.0[-].NET: Python: [Bug]: AG-UI shares one AgentSession across authorization scopes when a bare Workflow is passed to add_agent_framework_fastapi_endpoint[/-][+]Python: [Bug]: AG-UI shares one AgentSession across authorization scopes when a bare Workflow is passed to add_agent_framework_fastapi_endpoint[/+]diegoDrp-Dev commentedon Oct 28, 2026
Thanks for the fast triage. The agent's analysis lines up with what I found: the auto-wrap at
_endpoint.py:207and the_resolve_workflowpath at_workflow.py:714.prassu017 happy for you to take it if that is what the maintainers prefer, I would rather not duplicate work.
If it helps, I already have the diagnosis and a concrete proposal and would be glad to open the PR. I ticked the box about waiting for explicit maintainer agreement before starting a non-trivial change, so I will hold until someone says go either way.
Smallest fix I can see: extend
_validate_authorization_scope_configurationto reject, or at minimum warn, when the wrapped runner is in shared-workflow mode and the endpoint is registered withauthorization_scope_resolverorsnapshot_scope_resolver. It already runs three lines below the auto-wrap and already performs this class of check.Larger alternative: have
_endpoint.pywrap a bareWorkflowin a per-thread factory, so it goes through the(snapshot_scope, thread_id)cache that_workflow.py:717-727already implements. That would make the two registration paths behave the same instead of one of them silently opting out of the isolation.Whichever direction is chosen, there is an existing test pair worth extending, since both currently cover the factory path only:
test_workflow_factory_cache_is_scoped_by_snapshot_scopeandtest_workflow_factory_cache_is_scoped_by_resolver_without_snapshot_store.prassu017 commentedon Oct 29, 2026
Withdrawing my claim so Diego Rodrigues Pereira (@diegoDrp-Dev) and the maintainers can take this forward. Sorry for the noise.
diegoDrp-Dev commentedon Nov 1, 2026
Small housekeeping note: I accidentally closed this issue and reopened it right away. The project board seems to have kept Status as Done from that, so the issue may be out of the working queue even though it is open. Flagging in case someone with board access can move it back.
Still waiting on a maintainer call between the two directions I proposed before I open a PR, per the box I ticked in the template.
eavanvalkenburg commentedon Nov 2, 2026
Thanks for the detailed reproduction and for waiting for a maintainer decision. The team will consider making the Python AG-UI endpoint contract safe and explicit when a shared Workflow is combined with authorization/snapshot scoping. We need to distinguish intentional workflow-instance persistence from the endpoint responsibility to reject incompatible registrations or require scoped construction. This acknowledges core ownership for consideration, not implementation approval or a timing commitment; the compatibility and lifecycle direction needs planning first.
eavanvalkenburg commentedon Nov 2, 2026
/plan Plan the accepted core ownership of the Python AG-UI bare-Workflow/authorization-scoping contract. Current endpoint auto-wrap selects one shared workflow before the scope/thread factory cache; preserve intentional workflow persistence rather than resetting sessions or auto-cloning arbitrary executor graphs to simulate isolation. Compare fail-fast rejection/explicit factory guidance with an explicit scoped-construction API, including existing shared/single-user registrations and authorization_scope_resolver versus snapshot_scope_resolver, with/without snapshot persistence. A warning must not be presented as isolation. Validate via the real endpoint with two principals, distinct message IDs, fully consumed SSE and positive model-call controls, checking history/session identity and existing factory isolation. Separate demonstrated history sharing from unexercised approval/FIDES/service-session consequences; add relevant coverage without claiming those flows were already reproduced. Keep this Python implementation scope distinct from the related .NET discussion. No ready/new assignment before compatibility direction is agreed; retain triage and existing assignee.