diff --git a/python/packages/foundry_hosting/README.md b/python/packages/foundry_hosting/README.md index f992d3b091d..3a352eba761 100644 --- a/python/packages/foundry_hosting/README.md +++ b/python/packages/foundry_hosting/README.md @@ -13,9 +13,28 @@ server = ResponsesHostServer(agent=create_agent) ``` Passing an instance reuses that object for the lifetime of the host. Pass a callable when the agent keeps mutable state -outside `AgentSession`. In particular, a `WorkflowAgent` wraps a stateful workflow, so its callable should build a new -workflow, executors, and wrapped agents. Keep the workflow name and executor IDs stable so later requests can find and -restore its checkpoints: +outside `AgentSession`. The Responses host continues regular agents through its existing session store and workflow +agents through its existing checkpoint store. A callable does not make arbitrary instance fields persistent; state +needed by later requests must remain in the supported stores. + +### Hosting a `WorkflowAgent` is deprecated + +Hosting a `WorkflowAgent` (for example `workflow.as_agent()`) through `agent=` is deprecated and should be avoided, and +both `ResponsesHostServer` and `InvocationsHostServer` emit a `DeprecationWarning` for it and log the same message at +`WARNING` level, once per host. A `WorkflowAgent` is stateful: its workflow state stays in memory between runs, so one +instance must never serve requests from different users or conversations. Host the workflow natively with `workflow=` +and a request-aware factory instead; see +[Native Responses workflows](#native-responses-workflows) and +[Native Invocations workflows](#native-invocations-workflows): + +```python +# build_workflow(request) returns a freshly built Workflow for each request. +server = ResponsesHostServer(workflow=build_workflow, parse_response=parse_response) +``` + +Until you migrate `ResponsesHostServer`, pass a callable that builds a new workflow, executors, and wrapped agents for +every request, and never return a shared instance. Keep the workflow name and executor IDs stable so later requests can +find and restore its checkpoints: ```python def create_agent(): @@ -25,9 +44,8 @@ def create_agent(): server = ResponsesHostServer(agent=create_agent) ``` -The Responses host continues regular agents through its existing session store and workflow agents through its -existing checkpoint store. A callable does not make arbitrary instance fields persistent; state needed by later -requests must remain in the supported stores. +`InvocationsHostServer` does not restore workflow checkpoints for agents, so a per-request factory only suits stateless, +single-turn workflows there. Use `workflow=` for any workflow that pauses or spans turns. ## Native Responses workflows @@ -98,7 +116,9 @@ only the current Responses turn to `list[Message]`. It does not load outer history or decode pending replies. Existing `agent=workflow.as_agent()` hosting remains for this beta with a once-per-host deprecation warning because wrapper context providers, history, event projection, and request-info translation are -real semantics and are not silently unwrapped. +real semantics and are not silently unwrapped. See +[Hosting a `WorkflowAgent` is deprecated](#hosting-a-workflowagent-is-deprecated) +for the instance-sharing restriction. With `resilient_background=True`, the application must also enable the AgentServer resilient task subsystem. Host-owned checkpoint/output pairs @@ -428,7 +448,8 @@ storage locally. Stored approvals are scoped under `function_approvals`. ## Native Invocations workflows Host a native workflow with an explicit application parser rather than wrapping it -with `workflow.as_agent()`: +with `workflow.as_agent()`, which is deprecated for hosting (see +[Hosting a `WorkflowAgent` is deprecated](#hosting-a-workflowagent-is-deprecated)): ```python server = InvocationsHostServer( diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py index 0c038cc2f5b..6838e601f5e 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py @@ -29,6 +29,7 @@ SessionStore, SupportsAgentRun, Workflow, + WorkflowAgent, WorkflowCheckpointException, WorkflowEvent, WorkflowExecutor, @@ -347,6 +348,8 @@ def __init__( Args: agent: The agent to handle responses for, or a zero-argument sync or async callable that creates one for each request. Use a callable for agents that keep mutable state outside `AgentSession`. + Hosting a `WorkflowAgent` here is deprecated and should be avoided: it is stateful, so one instance + must never serve requests from different users or conversations. Use `workflow=` instead. workflow: A built native workflow or a sync/async request-aware factory returning a freshly built workflow with stable graph/executor IDs. Hosted workflows require a factory. Supply exactly one of agent or workflow; local workflow instances are single-use and remain application-owned. @@ -402,6 +405,7 @@ def __init__( self._agent = agent self._owns_request_agent = agent is not None and not is_agent(agent) + self._warned_workflow_agent = False self._parse_request = parse_request self._prepare_options = prepare_options self._unsupported_options = validate_unsupported_options(unsupported_options) @@ -424,6 +428,8 @@ def __init__( ) warnings.warn(message, DeprecationWarning, stacklevel=2) logger.warning("DEPRECATION: %s", message) + if isinstance(agent, WorkflowAgent): + self._warn_legacy_workflow() self.invoke_handler(self._handle_invoke) mark_feature_used(FeatureIndex.FOUNDRY_HOSTING) @@ -493,11 +499,30 @@ def _hosted_scope(self, request: Request, context: FoundryAgentRequestContext) - is_hosted=True, ) + def _warn_legacy_workflow(self) -> None: + if not self._warned_workflow_agent: + self._warned_workflow_agent = True + message = ( + "Hosting WorkflowAgent through agent= is deprecated for this beta release and should be avoided. " + "A WorkflowAgent is stateful and keeps workflow state in memory between runs, so one instance must " + "never serve requests from different users or conversations. " + "Use workflow=a_request_aware_factory with an explicit parse_request. " + "A factory that builds a new WorkflowAgent for every request only suits stateless, " + "single-turn workflows, because the Invocations host does not restore workflow checkpoints. " + "Wrapper history, context providers, and event semantics are not automatically unwrapped." + ) + warnings.warn(message, DeprecationWarning, stacklevel=3) + # Request-time calls cannot be attributed to application code, so Python's default filter hides the + # warning there; log it as well. + logger.warning("DEPRECATION: %s", message) + @asynccontextmanager async def _request_agent(self) -> AsyncGenerator[SupportsAgentRun]: if self._agent is None: raise RuntimeError("No agent is configured for Invocations.") agent = await resolve_agent(self._agent) + if isinstance(agent, WorkflowAgent): + self._warn_legacy_workflow() async with AsyncExitStack() as resources: if self._owns_request_agent and isinstance(agent, AbstractAsyncContextManager): await resources.enter_async_context(agent) diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py index 93bc39c7fdd..1d4d684e8ac 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -774,6 +774,8 @@ def __init__( Args: agent: The agent to handle responses for, or a zero-argument sync or async callable that creates one for each request. Use a callable for agents that keep mutable state outside `AgentSession`. + Hosting a `WorkflowAgent` here is deprecated and should be avoided: it is stateful, so one instance + must never serve requests from different users or conversations. Use `workflow=` instead. workflow: A built, unrun native workflow for one-shot execution, or a request-aware sync/async factory returning fresh built graphs, executors, agents, clients, tools, and providers with stable graph/executor IDs. Cannot be combined with ``agent``. @@ -995,13 +997,18 @@ def __init__( def _warn_legacy_workflow(self) -> None: if not self._warned_workflow_agent: self._warned_workflow_agent = True - warnings.warn( - "Hosting WorkflowAgent through agent= is deprecated for this beta release. " + message = ( + "Hosting WorkflowAgent through agent= is deprecated for this beta release and should be avoided. " + "A WorkflowAgent is stateful and keeps workflow state in memory between runs, so one instance must " + "never serve requests from different users or conversations. " "Use workflow=a_request_aware_factory with an explicit parse_response. " - "Wrapper history, context providers, approvals, and event semantics are not automatically unwrapped.", - DeprecationWarning, - stacklevel=3, + "Until you migrate, pass a factory that builds a new WorkflowAgent for every request. " + "Wrapper history, context providers, approvals, and event semantics are not automatically unwrapped." ) + warnings.warn(message, DeprecationWarning, stacklevel=3) + # Request-time calls cannot be attributed to application code, so Python's default filter hides the + # warning there; log it as well. + logger.warning("DEPRECATION: %s", message) async def _ensure_agent_ready(self) -> None: """Lazily enter the agent's async context exactly once. diff --git a/python/packages/foundry_hosting/tests/foundry_hosting/test_native_responses.py b/python/packages/foundry_hosting/tests/foundry_hosting/test_native_responses.py index 322ea5d64ad..e1583ded9a1 100644 --- a/python/packages/foundry_hosting/tests/foundry_hosting/test_native_responses.py +++ b/python/packages/foundry_hosting/tests/foundry_hosting/test_native_responses.py @@ -983,7 +983,9 @@ async def stage(run: HostedWorkflowRun, snapshot: Any, **kwargs: Any) -> None: set_resilient_tasks_enabled(enabled) -async def test_legacy_wrapper_warns_once_per_host_and_keeps_message_semantics() -> None: +async def test_legacy_wrapper_warns_once_per_host_and_keeps_message_semantics( + caplog: pytest.LogCaptureFixture, +) -> None: class _Legacy(Executor): def __init__(self) -> None: super().__init__("legacy") @@ -1004,3 +1006,9 @@ def create() -> Any: assert _texts(first) == ["legacy"] assert _texts(second) == ["again"] assert sum("Hosting WorkflowAgent" in str(item.message) for item in captured) == 1 + message = next(str(item.message) for item in captured if "Hosting WorkflowAgent" in str(item.message)) + assert "should be avoided" in message + assert "stateful" in message + assert "parse_response" in message + logged = [record for record in caplog.records if "DEPRECATION: Hosting WorkflowAgent" in record.getMessage()] + assert [record.levelno for record in logged] == [logging.WARNING] diff --git a/python/packages/foundry_hosting/tests/test_invocations.py b/python/packages/foundry_hosting/tests/test_invocations.py index 8375d41daf6..9b643290de2 100644 --- a/python/packages/foundry_hosting/tests/test_invocations.py +++ b/python/packages/foundry_hosting/tests/test_invocations.py @@ -15,10 +15,12 @@ import json import logging import uuid +import warnings import weakref from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping, Sequence from contextlib import contextmanager from itertools import product +from pathlib import Path from typing import Literal, cast from unittest.mock import AsyncMock, MagicMock, patch @@ -34,12 +36,17 @@ ChatResponse, ChatResponseUpdate, Content, + Executor, FunctionInvocationLayer, InMemoryHistoryProvider, Message, ResponseStream, ServiceSessionId, SessionStore, + WorkflowAgent, + WorkflowBuilder, + WorkflowContext, + handler, tool, ) from azure.ai.agentserver.core import ( @@ -288,6 +295,38 @@ async def _success_text(response: Response) -> str: return json.loads(bytes(response.body))["response"] +class _EchoExecutor(Executor): + def __init__(self) -> None: + super().__init__("echo") + + @handler + async def echo(self, messages: list[Message], ctx: WorkflowContext[str, str]) -> None: + await ctx.yield_output(messages[-1].text) + + +def _make_workflow_agent() -> WorkflowAgent: + return WorkflowBuilder(name="legacy", start_executor=_EchoExecutor()).build().as_agent() + + +def _workflow_agent_warnings(captured: Sequence[warnings.WarningMessage]) -> list[warnings.WarningMessage]: + return [item for item in captured if "Hosting WorkflowAgent" in str(item.message)] + + +def _workflow_agent_logs(caplog: pytest.LogCaptureFixture) -> list[logging.LogRecord]: + return [ + record + for record in caplog.records + if record.levelno == logging.WARNING and "DEPRECATION: Hosting WorkflowAgent" in record.getMessage() + ] + + +async def _invoke_text(server: InvocationsHostServer, message: str) -> str: + request = _make_request({"message": message}) + with _request_context(session_id="session"): + response = await server._handle_invoke(request) # pyright: ignore[reportPrivateUsage] + return await _success_text(response) + + # endregion @@ -648,6 +687,48 @@ def test_rejects_invalid_configuration(self, parameter: str, value: Any, error: InvocationsHostServer(_make_agent(response_text="ok"), **{parameter: value}) +class TestLegacyWorkflowAgentHosting: + async def test_instance_warns_once_per_host(self, caplog: pytest.LogCaptureFixture) -> None: + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always", DeprecationWarning) + server = InvocationsHostServer(_make_workflow_agent()) + texts = [await _invoke_text(server, "one"), await _invoke_text(server, "two")] + + deprecations = _workflow_agent_warnings(captured) + assert len(deprecations) == 1 + assert deprecations[0].category is DeprecationWarning + message = str(deprecations[0].message) + assert "should be avoided" in message + assert "stateful" in message + assert "parse_request" in message + assert "stateless, single-turn" in message + assert Path(deprecations[0].filename).name == Path(__file__).name + assert len(_workflow_agent_logs(caplog)) == 1 + assert texts == ["one", "two"] + + async def test_factory_warns_once_on_first_request(self, caplog: pytest.LogCaptureFixture) -> None: + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always", DeprecationWarning) + server = InvocationsHostServer(_make_workflow_agent) + assert _workflow_agent_warnings(captured) == [] + assert _workflow_agent_logs(caplog) == [] + texts = [await _invoke_text(server, "one"), await _invoke_text(server, "two")] + + assert len(_workflow_agent_warnings(captured)) == 1 + assert len(_workflow_agent_logs(caplog)) == 1 + assert texts == ["one", "two"] + + async def test_regular_agent_does_not_warn(self, caplog: pytest.LogCaptureFixture) -> None: + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always", DeprecationWarning) + server = InvocationsHostServer(_make_agent(response_text="hi")) + text = await _invoke_text(server, "hello") + + assert text == "hi" + assert _workflow_agent_warnings(captured) == [] + assert _workflow_agent_logs(caplog) == [] + + # endregion