From a6ea50e3ab2e41ed0ebc256104a897d3f8d11e64 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 1 Sep 2026 17:58:41 +0200 Subject: [PATCH 1/5] Python: select Foundry hosting history source Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a4c73599-9f66-4c22-bd66-96fe676e0ce6 --- ...9-python-foundry-hosting-history-source.md | 119 +++++++++++ python/packages/foundry_hosting/README.md | 37 ++++ .../_responses.py | 85 ++++++-- .../foundry_hosting/tests/test_responses.py | 186 +++++++++++++++++- 4 files changed, 407 insertions(+), 20 deletions(-) create mode 100644 docs/decisions/0039-python-foundry-hosting-history-source.md diff --git a/docs/decisions/0039-python-foundry-hosting-history-source.md b/docs/decisions/0039-python-foundry-hosting-history-source.md new file mode 100644 index 0000000000..3e76bd775f --- /dev/null +++ b/docs/decisions/0039-python-foundry-hosting-history-source.md @@ -0,0 +1,119 @@ +--- +status: proposed +contact: eavanvalkenburg +date: 2026-09-01 +deciders: eavanvalkenburg, moonbox3 +consulted: sachinkahawala +--- + +# Select the conversation history source for Python Foundry hosting + +## Context and Problem Statement + +`ResponsesHostServer` currently replays the AgentServer response transcript into every agent run. An `AgentSession` +can also restore a downstream `service_session_id`, causing the model service to combine its stored conversation with +the replayed AgentServer transcript. This duplicates prior turns and compounds on each request. + +Conversation data can exist in four places: + +1. the AgentServer `ResponseProviderProtocol`; +2. an Agent Framework `HistoryProvider`; +3. `AgentSession.state`, persisted by a `SessionStore` and used by `InMemoryHistoryProvider`; and +4. the downstream model service when `store=True`. + +The host must prevent duplicate model history without removing the regular agent storage choices. + +## Decision Drivers + +- Feed one canonical conversation transcript into each model call. +- Keep AgentServer response persistence independent from the model's history source. +- Preserve the normal agent choice between `HistoryProvider` and downstream service storage. +- Retain AgentServer response history as the default hosting behavior. +- Avoid forcing the measured Foundry `store=False` streaming latency on users who select regular agent history. +- Make existing sessions containing a downstream service ID safe after upgrade. + +## Considered Options + +### Always use AgentServer response history + +- Good: one simple default and parity with current .NET Foundry hosting. +- Good: the selected response provider controls the transcript used by the model. +- Bad: users cannot use normal agent history providers or service-side continuation. +- Bad: raw benchmarking measured a 5.43-second median streaming penalty for `store=False` on a Foundry project + endpoint using `gpt-5.4-nano`; the OpenAI public endpoint did not show this penalty. + +### Clear the service ID but leave downstream storage enabled + +- Good: avoids duplicated input and preserves Foundry streaming latency. +- Bad: creates an untracked stored response or conversation on every model call. +- Bad: service-side storage incurs retention and cost but is never used for continuation. + +### Add separate AgentServer, history-provider, service, and automatic modes + +- Good: makes each possible authority explicit at the hosting layer. +- Bad: duplicates history-selection behavior already implemented by `Agent`. +- Bad: an automatic mode changes authority based on provider output, making retention and recovery unpredictable. + +### Select AgentServer history or regular agent history + +- Good: the host makes only the decision it owns: whether AgentServer history supersedes normal agent behavior. +- Good: regular agent mode preserves service storage, in-session history, and external history providers. +- Good: `ResponseProviderProtocol`, `HistoryProvider`, and `SessionStore` remain independent extension points. +- Neutral: AgentServer still manages protocol-level Responses persistence in regular agent mode, according to the outer + request, but does not replay that transcript into the model. + +## Decision Outcome + +Add `history_source: Literal["agent_server", "agent"] = "agent_server"` to `ResponsesHostServer`. + +With `history_source="agent_server"`: + +- load-enabled `HistoryProvider` instances are rejected; +- an agent-level default `conversation_id` is rejected; +- the configured response provider transcript and current input are passed to the agent; +- downstream `store=False` overrides agent defaults on every run; +- a restored `service_session_id` is cleared before the run; +- a client that still returns a service ID fails the response and the contaminated session is not saved; and +- a transient `InMemoryHistoryProvider` supports intra-run function calls but is removed before session persistence. + +With `history_source="agent"`: + +- only current request input is passed by hosting; +- load-enabled history providers are allowed; +- downstream storage options are not changed; and +- normal `Agent` behavior selects service storage, an explicit history provider, or automatic in-session history. + +The AgentServer response provider continues to control Responses API persistence and retrieval in both modes, according +to the outer request. The session-store provider also remains independent. Consequently, regular agent mode can combine +`InMemoryHistoryProvider` with the default `FoundryAgentSessionStore` to persist model history in Foundry without using +the AgentServer response transcript as model input. + +## Developer Experience + +```python +# Default: AgentServer response history is model history. +ResponsesHostServer(agent) + +# Regular Agent history and downstream storage behavior. +ResponsesHostServer(agent, history_source="agent") +``` + +Passing `store=None` or omitting `store` continues to select the environment's default AgentServer response provider. +It does not disable response persistence. + +## Consequences + +- Good: existing applications keep AgentServer history as their default. +- Good: applications can retain service-side storage and its current Foundry latency characteristics. +- Good: the API does not introduce a second history-selection state machine. +- Bad: default mode mutates the supplied `RawAgent` by installing a transient history provider. +- Bad: regular agent history and AgentServer response history may differ, which response-oriented evaluations must + document. +- Neutral: switching an existing conversation between modes may require resetting its persisted session/history. + +## More Information + +- [Issue #7955](https://github.com/microsoft/agent-framework/issues/7955) +- [Closed Python PR #7957](https://github.com/microsoft/agent-framework/pull/7957) +- [Merged .NET PR #7525](https://github.com/microsoft/agent-framework/pull/7525) +- [Merged .NET follow-up PR #7572](https://github.com/microsoft/agent-framework/pull/7572) diff --git a/python/packages/foundry_hosting/README.md b/python/packages/foundry_hosting/README.md index d73bc714c8..c5c43f8dc6 100644 --- a/python/packages/foundry_hosting/README.md +++ b/python/packages/foundry_hosting/README.md @@ -2,6 +2,43 @@ This package provides the integration of Agent Framework agents and workflows with the Foundry Agent Server, which can be hosted on Foundry infrastructure. +## Conversation history + +`ResponsesHostServer` uses AgentServer response history as the model's conversation history by default: + +```python +server = ResponsesHostServer(agent) +``` + +In this mode, the configured AgentServer response provider supplies the prior transcript. Hosting rejects +`HistoryProvider` instances with `load_messages=True` and agents configured with a default `conversation_id`, adds a +transient in-memory provider for function-call loops, forces downstream `store=False`, and clears restored downstream +service IDs. These safeguards ensure the model receives the transcript once. + +To preserve the agent's regular history and service-storage behavior, select the agent as the history source: + +```python +server = ResponsesHostServer(agent, history_source="agent") +``` + +Hosting then passes only current request input, allows load-enabled history providers, and does not override the +agent's downstream `store` option. For example, `InMemoryHistoryProvider` stores messages in `AgentSession.state`, which +the default `FoundryAgentSessionStore` persists in Foundry: + +```python +agent = Agent( + client=client, + context_providers=[InMemoryHistoryProvider()], + default_options={"store": False}, +) +server = ResponsesHostServer(agent, history_source="agent") +``` + +The `store` argument remains independent: it selects the AgentServer response provider used for Responses API +persistence and retrieval. Omitting it or passing `None` selects the environment default. With +`history_source="agent_server"`, that response provider also supplies model history; with `history_source="agent"`, it +does not. + ## State store ### Local persistence 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 d75628cbab..85c875fee3 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -336,6 +336,7 @@ def __init__( agent_session_store_provider: StoreProvider[SessionStore] | None = None, checkpoint_store_provider: ContextScopedStoreProvider[CheckpointStorage] | None = None, function_approval_store_provider: StoreProvider[FunctionApprovalStore] | None = None, + history_source: Literal["agent_server", "agent"] = "agent_server", **kwargs: Any, ) -> None: """Initialize a ResponsesHostServer. @@ -351,14 +352,21 @@ def __init__( If not provided, a default `CheckpointStoreProvider` will be used. function_approval_store_provider: Optional provider for function approval storage. If not provided, a default `FunctionApprovalStoreProvider` will be used. + history_source: Source of conversation history supplied to the model for regular agents. + `"agent_server"` (default) uses the transcript from the configured response store, + rejects load-enabled agent history providers, and disables downstream service storage. + `"agent"` passes only the current request input and preserves the agent's normal + history-provider or service-storage behavior. AgentServer still manages Responses + API persistence through `store` in both modes. **kwargs: Additional keyword arguments. Note: - 1. The agent must not have a history provider with `load_messages=True`, - because history is managed by the hosting infrastructure. - 2. The agent must not have any context providers that maintain context - in memory, because the hosting environment may get deactivated between - requests, and any in-memory context would be lost. + 1. When `history_source="agent_server"`, the agent must not have a history provider + with `load_messages=True`, because history is managed by the hosting infrastructure. + 2. Context providers must not keep required state only on their Python instances, + because the hosting environment may get deactivated between requests. Provider + state carried by `AgentSession`, including `InMemoryHistoryProvider` messages in + `history_source="agent"` mode, is persisted by the configured session store. 3. Resiliency (resilient_background=True) is ONLY supported for workflows; constructing this server with a non-workflow agent and `resilient_background=True` raises `RuntimeError`. When resiliency is enabled, and the server crashes mid-response: @@ -374,18 +382,35 @@ def __init__( collected, and that isn't guaranteed to have happened in time. Raises: + ValueError: If `history_source` is not supported. RuntimeError: If `resilient_background=True` is requested for a non-workflow agent, or if `steerable_conversations=True` is requested for a workflow agent. """ + if history_source not in ("agent_server", "agent"): + raise ValueError("history_source must be either 'agent_server' or 'agent'.") + super().__init__(prefix=prefix, options=options, store=store, **kwargs) - for provider in getattr(agent, "context_providers", []): - if isinstance(provider, HistoryProvider) and provider.load_messages: - if _is_hosted_responses_history_sentinel(provider): - continue + self._uses_agent_server_history = history_source == "agent_server" + if self._uses_agent_server_history: + for provider in getattr(agent, "context_providers", []): + if isinstance(provider, HistoryProvider) and provider.load_messages: + if _is_hosted_responses_history_sentinel(provider): + continue + raise RuntimeError( + "AgentServer response history is enabled, but the agent has a HistoryProvider " + "with load_messages=True. Remove that provider or construct ResponsesHostServer " + "with history_source='agent' to use the agent's regular history setup." + ) + default_options = getattr(agent, "default_options", None) + typed_default_options: Mapping[str, Any] = ( + cast(Mapping[str, Any], default_options) if isinstance(default_options, Mapping) else {} + ) + if typed_default_options.get("conversation_id") is not None: raise RuntimeError( - "There shouldn't be a history provider with `load_messages=True` already present. " - "History is managed by the hosting infrastructure." + "AgentServer response history is enabled, but the agent has a default conversation_id. " + "Remove that option or construct ResponsesHostServer with history_source='agent' to resume " + "the downstream service conversation." ) self._is_workflow_agent = False @@ -398,7 +423,7 @@ def __init__( self._is_workflow_agent = True self._uses_hosted_responses_history = False - if not self._is_workflow_agent and isinstance(agent, RawAgent): + if self._uses_agent_server_history and not self._is_workflow_agent and isinstance(agent, RawAgent): self._uses_hosted_responses_history = True if not any( _is_hosted_responses_history_sentinel(provider) @@ -615,21 +640,29 @@ async def _handle_inner_agent( request_interrupted = False try: - if self._uses_hosted_responses_history: + if self._uses_agent_server_history: session.state.pop(_HOSTED_RESPONSES_HISTORY_SOURCE_ID, None) + # A restored service ID belongs to the downstream model service. Replaying the + # AgentServer transcript while resuming that service history would duplicate every + # prior turn, so AgentServer-history mode always starts the model call statelessly. + session.service_session_id = None input_items = await context.get_input_items() input_messages = await _items_to_messages(input_items, approval_storage=approval_storage) - history = await context.get_history() + history_messages: list[Message] = [] + if self._uses_agent_server_history: + history = await context.get_history() + history_messages = await _output_items_to_messages(history, approval_storage=approval_storage) run_kwargs: dict[str, Any] = { - "messages": [ - *(await _output_items_to_messages(history, approval_storage=approval_storage)), - *input_messages, - ], + "messages": [*history_messages, *input_messages], "session": session, } chat_options, are_options_set = _to_chat_options(request) + if self._uses_agent_server_history: + # The response provider already owns the transcript used for this run. Keep the + # downstream service stateless so it cannot become a second history source. + chat_options["store"] = False if are_options_set and not isinstance(self._agent, RawAgent): logger.warning("Agent doesn't support runtime options. They will be ignored.") @@ -662,8 +695,22 @@ async def _handle_inner_agent( finally: if self._uses_hosted_responses_history: session.state.pop(_HOSTED_RESPONSES_HISTORY_SOURCE_ID, None) + + # A service ID here means the client stored the turn despite the forced `store=False`. + # Do not persist a session that could resume that unreconciled history on a later turn. + stored_output_violation = self._uses_agent_server_history and session.service_session_id is not None + if stored_output_violation: + misconfigured = RuntimeError( + "The agent's chat client stored this turn server-side while AgentServer response history " + "is supplying the conversation. Configure the client to honor store=False, or construct " + "ResponsesHostServer with history_source='agent' to use the agent's regular history setup." + ) + logger.error("%s", misconfigured) + if request_failure is None and not request_interrupted: + request_failure = misconfigured try: - await session_storage.set(session_save_id, session) + if not stored_output_violation: + await session_storage.set(session_save_id, session) except Exception as save_error: save_failure = save_error if request_interrupted: diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index 50f0c0ff50..2e92bb87ed 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -12,6 +12,7 @@ import asyncio import json +import logging import uuid from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass @@ -193,6 +194,44 @@ async def stream_response() -> AsyncIterator[ChatResponseUpdate]: return ResponseStream(stream_response(), finalizer=ChatResponse.from_updates) +class _ServiceStorageRecordingClient(BaseChatClient): + """Record service-storage options and mimic a client that returns a conversation ID.""" + + STORES_BY_DEFAULT = True + + def __init__(self, *, honors_store: bool = True) -> None: + super().__init__() + self._honors_store = honors_store + self.calls: list[list[Message]] = [] + self.store_options: list[Any] = [] + self.conversation_ids: list[Any] = [] + + def _inner_get_response( + self, + *, + messages: Sequence[Message], + stream: bool, + options: Mapping[str, Any], + **kwargs: Any, + ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: + del kwargs + assert stream is True, "The inner agent only runs in stream mode in Foundry Hosted Agents." + self.calls.append(list(messages)) + self.store_options.append(options.get("store")) + self.conversation_ids.append(options.get("conversation_id")) + stores_response = options.get("store") is not False if self._honors_store else True + conversation_id = "service-thread-1" if stores_response else None + + async def stream_response() -> AsyncIterator[ChatResponseUpdate]: + yield ChatResponseUpdate( + contents=[Content.from_text("recorded")], + role="assistant", + conversation_id=conversation_id, + ) + + return ResponseStream(stream_response(), finalizer=ChatResponse.from_updates) + + class _PerServiceCallHistoryProvider(HistoryProvider): def __init__(self) -> None: super().__init__("per_service_call_history", load_messages=False) @@ -725,9 +764,45 @@ async def save_messages( response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])]) ) agent.context_providers = [hp] - with pytest.raises(RuntimeError, match="history provider"): + with pytest.raises(RuntimeError, match="HistoryProvider"): ResponsesHostServer(agent) + def test_init_allows_history_provider_with_load_messages_for_agent_history(self) -> None: + hp = InMemoryHistoryProvider() + agent = _make_agent( + response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])]) + ) + agent.context_providers = [hp] + + ResponsesHostServer(agent, history_source="agent") + + assert agent.context_providers == [hp] + + def test_init_rejects_invalid_history_source(self) -> None: + agent = _make_agent( + response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])]) + ) + + with pytest.raises(ValueError, match="history_source"): + ResponsesHostServer(agent, history_source="invalid") # type: ignore[arg-type] + + def test_init_rejects_default_conversation_id_for_agent_server_history(self) -> None: + agent = Agent( + client=_ServiceStorageRecordingClient(), + default_options={"conversation_id": "service-thread"}, + ) + + with pytest.raises(RuntimeError, match="default conversation_id"): + ResponsesHostServer(agent) + + def test_init_allows_default_conversation_id_for_agent_history(self) -> None: + agent = Agent( + client=_ServiceStorageRecordingClient(), + default_options={"conversation_id": "service-thread"}, + ) + + ResponsesHostServer(agent, history_source="agent") + def test_init_rejects_resilient_background_for_non_workflow_agent(self, tmp_path: Path) -> None: agent = _make_agent( response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])]) @@ -845,6 +920,115 @@ async def test_responses_history_is_not_duplicated_by_default_local_history(self assert InMemoryHistoryProvider.DEFAULT_SOURCE_ID not in stored.state assert "_foundry_responses_history" not in stored.state + async def test_agent_server_history_disables_service_storage(self) -> None: + client = _ServiceStorageRecordingClient() + agent = Agent( + client=client, + name="Service Storage Agent", + default_options={"store": True}, + ) + store = SessionStore() + server = _make_server(agent, session_store=store) + + first = await _post(server, input_text="first") + second = await _post(server, input_text="second", previous_response_id=first.json()["id"]) + + assert second.json()["status"] == "completed" + assert [[message.text for message in call] for call in client.calls] == [ + ["first"], + ["first", "recorded", "second"], + ] + assert client.store_options == [False, False] + assert client.conversation_ids == [None, None] + + stored = await store.get(second.json()["id"]) + assert stored is not None + assert stored.service_session_id is None + + async def test_agent_server_history_clears_restored_service_session_id(self) -> None: + client = _ServiceStorageRecordingClient() + agent = Agent(client=client, name="Migrated Agent") + store = SessionStore() + server = _make_server(agent, session_store=store) + first = await _post(server, input_text="first") + first_id = first.json()["id"] + stale_session = await store.get(first_id) + assert stale_session is not None + stale_session.service_session_id = "contaminated-service-thread" + await store.set(first_id, stale_session) + client.store_options.clear() + client.conversation_ids.clear() + + response = await _post(server, input_text="next", previous_response_id=first_id) + + assert response.json()["status"] == "completed" + assert client.store_options == [False] + assert client.conversation_ids == [None] + stored = await store.get(response.json()["id"]) + assert stored is not None + assert stored.service_session_id is None + + async def test_agent_history_preserves_service_storage(self) -> None: + client = _ServiceStorageRecordingClient() + agent = Agent( + client=client, + name="Agent Managed Service Storage", + default_options={"store": True}, + ) + store = SessionStore() + server = _make_server(agent, session_store=store, history_source="agent") + + first = await _post(server, input_text="first") + second = await _post(server, input_text="second", previous_response_id=first.json()["id"]) + + assert second.json()["status"] == "completed" + assert [[message.text for message in call] for call in client.calls] == [["first"], ["second"]] + assert client.store_options == [True, True] + assert client.conversation_ids == [None, "service-thread-1"] + stored = await store.get(second.json()["id"]) + assert stored is not None + assert stored.service_session_id == "service-thread-1" + + async def test_agent_history_uses_in_memory_history_from_session_store(self) -> None: + client = _RecordingHistoryClient() + history = InMemoryHistoryProvider() + agent = Agent( + client=client, + name="Agent Managed In-Memory History", + context_providers=[history], + default_options={"store": False}, + ) + store = SessionStore() + server = _make_server(agent, session_store=store, history_source="agent") + + first = await _post(server, input_text="first") + second = await _post(server, input_text="second", previous_response_id=first.json()["id"]) + + assert second.json()["status"] == "completed" + assert [[message.text for message in call] for call in client.calls] == [ + ["first"], + ["first", "recorded", "second"], + ] + stored = await store.get(second.json()["id"]) + assert stored is not None + assert history.source_id in stored.state + + async def test_client_that_ignores_disabled_storage_fails_without_saving_session( + self, + caplog: pytest.LogCaptureFixture, + ) -> None: + client = _ServiceStorageRecordingClient(honors_store=False) + agent = Agent(client=client, name="Ignores Store Agent") + store = SessionStore() + server = _make_server(agent, session_store=store) + + with caplog.at_level(logging.ERROR): + response = await _post(server, input_text="first") + + assert response.json()["status"] == "failed" + assert "stored this turn server-side" in caplog.text + assert await store.get(response.json()["id"]) is None + async def test_per_service_call_persistence_preserves_function_loop_history(self) -> None: provider = _PerServiceCallHistoryProvider() client = _FunctionLoopRecordingClient(provider) From 98c216ce5d8b5b54d2366fb26c864656ede315c7 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 1 Sep 2026 18:03:40 +0200 Subject: [PATCH 2/5] Docs: correct history source ADR metadata Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a4c73599-9f66-4c22-bd66-96fe676e0ce6 --- docs/decisions/0039-python-foundry-hosting-history-source.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/decisions/0039-python-foundry-hosting-history-source.md b/docs/decisions/0039-python-foundry-hosting-history-source.md index 3e76bd775f..c18a81c3cd 100644 --- a/docs/decisions/0039-python-foundry-hosting-history-source.md +++ b/docs/decisions/0039-python-foundry-hosting-history-source.md @@ -3,7 +3,6 @@ status: proposed contact: eavanvalkenburg date: 2026-09-01 deciders: eavanvalkenburg, moonbox3 -consulted: sachinkahawala --- # Select the conversation history source for Python Foundry hosting From a77eeb50b4a88ed6be7c8d0ef1cd24d2703e5ece Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 1 Sep 2026 18:18:22 +0200 Subject: [PATCH 3/5] Python: harden hosted history selection Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a4c73599-9f66-4c22-bd66-96fe676e0ce6 --- ...9-python-foundry-hosting-history-source.md | 16 +-- python/packages/foundry_hosting/README.md | 6 +- .../_responses.py | 27 ++++- .../foundry_hosting/tests/test_responses.py | 50 ++++++++- .../tests/test_responses_int.py | 100 ++++++++++++++---- 5 files changed, 166 insertions(+), 33 deletions(-) diff --git a/docs/decisions/0039-python-foundry-hosting-history-source.md b/docs/decisions/0039-python-foundry-hosting-history-source.md index c18a81c3cd..b3f9b1ceb5 100644 --- a/docs/decisions/0039-python-foundry-hosting-history-source.md +++ b/docs/decisions/0039-python-foundry-hosting-history-source.md @@ -27,8 +27,9 @@ The host must prevent duplicate model history without removing the regular agent - Feed one canonical conversation transcript into each model call. - Keep AgentServer response persistence independent from the model's history source. - Preserve the normal agent choice between `HistoryProvider` and downstream service storage. +- Let applications choose storage that satisfies their compliance, residency, retention, deletion, encryption, and + audit requirements. - Retain AgentServer response history as the default hosting behavior. -- Avoid forcing the measured Foundry `store=False` streaming latency on users who select regular agent history. - Make existing sessions containing a downstream service ID safe after upgrade. ## Considered Options @@ -38,12 +39,12 @@ The host must prevent duplicate model history without removing the regular agent - Good: one simple default and parity with current .NET Foundry hosting. - Good: the selected response provider controls the transcript used by the model. - Bad: users cannot use normal agent history providers or service-side continuation. -- Bad: raw benchmarking measured a 5.43-second median streaming penalty for `store=False` on a Foundry project - endpoint using `gpt-5.4-nano`; the OpenAI public endpoint did not show this penalty. +- Bad: applications cannot choose a history backend that meets their data-governance requirements independently of + AgentServer protocol storage. ### Clear the service ID but leave downstream storage enabled -- Good: avoids duplicated input and preserves Foundry streaming latency. +- Good: avoids duplicated input. - Bad: creates an untracked stored response or conversation on every model call. - Bad: service-side storage incurs retention and cost but is never used for continuation. @@ -58,6 +59,7 @@ The host must prevent duplicate model history without removing the regular agent - Good: the host makes only the decision it owns: whether AgentServer history supersedes normal agent behavior. - Good: regular agent mode preserves service storage, in-session history, and external history providers. - Good: `ResponseProviderProtocol`, `HistoryProvider`, and `SessionStore` remain independent extension points. +- Good: applications can select the storage boundary and lifecycle required by their compliance policies. - Neutral: AgentServer still manages protocol-level Responses persistence in regular agent mode, according to the outer request, but does not replay that transcript into the model. @@ -70,7 +72,8 @@ With `history_source="agent_server"`: - load-enabled `HistoryProvider` instances are rejected; - an agent-level default `conversation_id` is rejected; - the configured response provider transcript and current input are passed to the agent; -- downstream `store=False` overrides agent defaults on every run; +- clients advertising `STORES_BY_DEFAULT=True` receive a downstream `store=False` override; +- for other clients, an explicit agent-level `store` option is removed and no storage option is forwarded; - a restored `service_session_id` is cleared before the run; - a client that still returns a service ID fails the response and the contaminated session is not saved; and - a transient `InMemoryHistoryProvider` supports intra-run function calls but is removed before session persistence. @@ -103,7 +106,8 @@ It does not disable response persistence. ## Consequences - Good: existing applications keep AgentServer history as their default. -- Good: applications can retain service-side storage and its current Foundry latency characteristics. +- Good: applications can choose service-side, session-backed, or external history storage to meet data-governance + requirements. - Good: the API does not introduce a second history-selection state machine. - Bad: default mode mutates the supplied `RawAgent` by installing a transient history provider. - Bad: regular agent history and AgentServer response history may differ, which response-oriented evaluations must diff --git a/python/packages/foundry_hosting/README.md b/python/packages/foundry_hosting/README.md index c5c43f8dc6..bdd6036801 100644 --- a/python/packages/foundry_hosting/README.md +++ b/python/packages/foundry_hosting/README.md @@ -12,8 +12,10 @@ server = ResponsesHostServer(agent) In this mode, the configured AgentServer response provider supplies the prior transcript. Hosting rejects `HistoryProvider` instances with `load_messages=True` and agents configured with a default `conversation_id`, adds a -transient in-memory provider for function-call loops, forces downstream `store=False`, and clears restored downstream -service IDs. These safeguards ensure the model receives the transcript once. +transient in-memory provider for function-call loops, and clears restored downstream service IDs. For clients that +advertise `STORES_BY_DEFAULT=True`, hosting forces downstream `store=False`; for other clients it removes an explicit +agent-level `store` option and does not forward one. These safeguards ensure the model receives the transcript once +without sending unsupported storage options. To preserve the agent's regular history and service-storage behavior, select the agent as the history source: 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 85c875fee3..7a922b86b0 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -392,6 +392,7 @@ def __init__( super().__init__(prefix=prefix, options=options, store=store, **kwargs) self._uses_agent_server_history = history_source == "agent_server" + self._client_stores_by_default = False if self._uses_agent_server_history: for provider in getattr(agent, "context_providers", []): if isinstance(provider, HistoryProvider) and provider.load_messages: @@ -412,6 +413,17 @@ def __init__( "Remove that option or construct ResponsesHostServer with history_source='agent' to resume " "the downstream service conversation." ) + agent_client = getattr(agent, "client", None) + storage_capability_owner = agent_client if agent_client is not None else agent + self._client_stores_by_default = getattr(storage_capability_owner, "STORES_BY_DEFAULT", False) is True + if not self._client_stores_by_default and isinstance(default_options, dict): + cast(dict[str, Any], default_options).pop("store", None) + elif isinstance(agent, RawAgent): + # A caller may reuse an agent that was previously attached to an AgentServer-history + # host. Restore regular agent behavior by removing only the host-owned sentinel. + agent.context_providers[:] = [ + provider for provider in agent.context_providers if not _is_hosted_responses_history_sentinel(provider) + ] self._is_workflow_agent = False if isinstance(agent, WorkflowAgent): @@ -660,12 +672,21 @@ async def _handle_inner_agent( } chat_options, are_options_set = _to_chat_options(request) if self._uses_agent_server_history: - # The response provider already owns the transcript used for this run. Keep the - # downstream service stateless so it cannot become a second history source. - chat_options["store"] = False + if self._client_stores_by_default: + # The response provider already owns the transcript used for this run. Keep a + # storing downstream service stateless so it cannot become a second history source. + chat_options["store"] = False + else: + # Do not pass a storage option to clients that do not advertise support for it. + chat_options.pop("store", None) if are_options_set and not isinstance(self._agent, RawAgent): logger.warning("Agent doesn't support runtime options. They will be ignored.") + if self._uses_agent_server_history and self._client_stores_by_default: + # Request generation options are unsupported for custom agents, but the + # host-owned storage directive must still reach an agent that advertises + # service-side storage. + run_kwargs["options"] = {"store": False} else: run_kwargs["options"] = chat_options diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index 2e92bb87ed..f219c33feb 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -175,6 +175,7 @@ class _RecordingHistoryClient(BaseChatClient): def __init__(self) -> None: super().__init__() self.calls: list[list[Message]] = [] + self.options: list[dict[str, Any]] = [] def _inner_get_response( self, @@ -184,9 +185,10 @@ def _inner_get_response( options: Mapping[str, Any], **kwargs: Any, ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: - del options, kwargs + del kwargs assert stream is True, "The inner agent only runs in stream mode in Foundry Hosted Agents." self.calls.append(list(messages)) + self.options.append(dict(options)) async def stream_response() -> AsyncIterator[ChatResponseUpdate]: yield ChatResponseUpdate(contents=[Content.from_text("recorded")], role="assistant") @@ -803,6 +805,23 @@ def test_init_allows_default_conversation_id_for_agent_history(self) -> None: ResponsesHostServer(agent, history_source="agent") + def test_init_agent_history_removes_hosted_history_sentinel_from_reused_agent(self) -> None: + agent = Agent(client=_ServiceStorageRecordingClient()) + ResponsesHostServer(agent) + assert any( + provider.source_id == "_foundry_responses_history" + for provider in agent.context_providers + if isinstance(provider, HistoryProvider) + ) + + ResponsesHostServer(agent, history_source="agent") + + assert not any( + provider.source_id == "_foundry_responses_history" + for provider in agent.context_providers + if isinstance(provider, HistoryProvider) + ) + def test_init_rejects_resilient_background_for_non_workflow_agent(self, tmp_path: Path) -> None: agent = _make_agent( response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])]) @@ -945,6 +964,35 @@ async def test_agent_server_history_disables_service_storage(self) -> None: assert stored is not None assert stored.service_session_id is None + async def test_agent_server_history_removes_store_for_non_storing_client(self) -> None: + client = _RecordingHistoryClient() + agent = Agent( + client=client, + name="Non-Storing Agent", + default_options={"store": True}, + ) + server = _make_server(agent, session_store=SessionStore()) + + response = await _post(server, input_text="first") + + assert response.json()["status"] == "completed" + assert "store" not in agent.default_options + assert "store" not in client.options[0] + + async def test_agent_server_history_preserves_storage_directive_for_custom_agent_options(self) -> None: + agent = _make_agent( + response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("ok")])]), + raw_agent=False, + ) + agent.client = MagicMock() + agent.client.STORES_BY_DEFAULT = True + server = _make_server(agent, session_store=SessionStore()) + + response = await _post(server, input_text="first", temperature=0.5) + + assert response.json()["status"] == "completed" + assert agent.run.call_args.kwargs["options"] == {"store": False} + async def test_agent_server_history_clears_restored_service_session_id(self) -> None: client = _ServiceStorageRecordingClient() agent = Agent(client=client, name="Migrated Agent") diff --git a/python/packages/foundry_hosting/tests/test_responses_int.py b/python/packages/foundry_hosting/tests/test_responses_int.py index b157ef978b..0079037c4b 100644 --- a/python/packages/foundry_hosting/tests/test_responses_int.py +++ b/python/packages/foundry_hosting/tests/test_responses_int.py @@ -34,6 +34,7 @@ Agent, Content, Executor, + InMemoryHistoryProvider, Message, SlidingWindowStrategy, WorkflowBuilder, @@ -73,7 +74,6 @@ def server() -> ResponsesHostServer: agent = Agent( client=client, # ty: ignore[invalid-argument-type] instructions="You are a concise assistant. Keep answers very short (one or two sentences).", - default_options={"store": False}, # pyrefly: ignore[bad-argument-type] ) return ResponsesHostServer(agent, store=InMemoryResponseProvider()) @@ -85,19 +85,37 @@ async def get_weather(location: Annotated[str, "The city name"]) -> str: return f"The weather in {location} is 72°F and sunny." -@pytest.fixture -def server_with_tools() -> ResponsesHostServer: - """Create a ResponsesHostServer whose agent has a tool.""" +@pytest.fixture(params=["agent_server", "agent"], ids=["agent-server-history", "agent-history"]) +def history_server(request: pytest.FixtureRequest) -> ResponsesHostServer: + """Create a real Foundry server for each model-history source.""" client = FoundryChatClient(credential=AzureCliCredential()) # pyrefly: ignore[bad-argument-type] + agent = Agent( + client=client, # ty: ignore[invalid-argument-type] + instructions="You are a concise assistant. Keep answers very short (one or two sentences).", + default_options={"store": True}, # pyrefly: ignore[bad-argument-type] + ) + return ResponsesHostServer( + agent, + store=InMemoryResponseProvider(), + history_source=request.param, + ) + +@pytest.fixture(params=["agent_server", "agent"], ids=["agent-server-history", "agent-history"]) +def history_server_with_tools(request: pytest.FixtureRequest) -> ResponsesHostServer: + """Create a real Foundry tool-calling server for each model-history source.""" + client = FoundryChatClient(credential=AzureCliCredential()) # pyrefly: ignore[bad-argument-type] agent = Agent( client=client, # ty: ignore[invalid-argument-type] instructions="You are a concise assistant. Use the provided tools when appropriate. Keep answers very short.", tools=[get_weather], - default_options={"store": False}, # pyrefly: ignore[bad-argument-type] + default_options={"store": True}, # pyrefly: ignore[bad-argument-type] + ) + return ResponsesHostServer( + agent, + store=InMemoryResponseProvider(), + history_source=request.param, ) - - return ResponsesHostServer(agent, store=InMemoryResponseProvider()) # --------------------------------------------------------------------------- @@ -445,11 +463,11 @@ async def test_explicit_user_assistant_user_conversation(self, server: Responses @pytest.mark.flaky @pytest.mark.integration @skip_if_foundry_hosting_integration_tests_disabled - async def test_two_turn_conversation(self, server: ResponsesHostServer) -> None: + async def test_two_turn_conversation(self, history_server: ResponsesHostServer) -> None: """Turn 1: introduce context. Turn 2: ask about it using previous_response_id.""" # Turn 1 resp1 = await _post_json( - server, + history_server, { "input": "My favorite color is blue. Remember that.", "stream": False, @@ -463,7 +481,7 @@ async def test_two_turn_conversation(self, server: ResponsesHostServer) -> None: # Turn 2 — references turn 1 resp2 = await _post_json( - server, + history_server, { "input": "What is my favorite color?", "stream": False, @@ -482,11 +500,11 @@ async def test_two_turn_conversation(self, server: ResponsesHostServer) -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_foundry_hosting_integration_tests_disabled - async def test_three_turn_conversation(self, server: ResponsesHostServer) -> None: + async def test_three_turn_conversation(self, history_server: ResponsesHostServer) -> None: """Three sequential turns to verify history accumulates correctly.""" # Turn 1 resp1 = await _post_json( - server, + history_server, { "input": "I have a pet dog named Max.", "stream": False, @@ -497,7 +515,7 @@ async def test_three_turn_conversation(self, server: ResponsesHostServer) -> Non # Turn 2 resp2 = await _post_json( - server, + history_server, { "input": "I also have a cat named Luna.", "stream": False, @@ -509,7 +527,7 @@ async def test_three_turn_conversation(self, server: ResponsesHostServer) -> Non # Turn 3 — should remember both pets resp3 = await _post_json( - server, + history_server, { "input": "What are my pets' names?", "stream": False, @@ -527,11 +545,11 @@ async def test_three_turn_conversation(self, server: ResponsesHostServer) -> Non @pytest.mark.flaky @pytest.mark.integration @skip_if_foundry_hosting_integration_tests_disabled - async def test_multi_turn_streaming(self, server: ResponsesHostServer) -> None: + async def test_multi_turn_streaming(self, history_server: ResponsesHostServer) -> None: """Multi-turn conversation with streaming on the second turn.""" # Turn 1 — non-streaming resp1 = await _post_json( - server, + history_server, { "input": "My favorite number is 42.", "stream": False, @@ -542,7 +560,7 @@ async def test_multi_turn_streaming(self, server: ResponsesHostServer) -> None: # Turn 2 — streaming resp2 = await _post_json( - server, + history_server, { "input": "What is my favorite number?", "stream": True, @@ -562,6 +580,46 @@ async def test_multi_turn_streaming(self, server: ResponsesHostServer) -> None: done_events = [e for e in events if e["event"] == "response.output_text.done"] assert "42" in done_events[0]["data"]["text"] + @pytest.mark.flaky + @pytest.mark.integration + @skip_if_foundry_hosting_integration_tests_disabled + async def test_agent_history_with_in_memory_provider(self) -> None: + """Regular agent mode can persist in-session history while the model service stays stateless.""" + agent = Agent( + client=FoundryChatClient(credential=AzureCliCredential()), # ty: ignore[invalid-argument-type] + instructions="Answer questions using the supplied conversation history. Keep answers very short.", + context_providers=[InMemoryHistoryProvider()], + default_options={"store": False}, # pyrefly: ignore[bad-argument-type] + ) + server = ResponsesHostServer( + agent, + store=InMemoryResponseProvider(), + history_source="agent", + ) + + first = await _post_json( + server, + { + "input": "My favorite city is Lisbon. Remember that.", + "stream": False, + }, + ) + assert first.status_code == 200 + + second = await _post_json( + server, + { + "input": "What is my favorite city?", + "stream": False, + "previous_response_id": first.json()["id"], + }, + ) + + assert second.status_code == 200 + output_messages = [item for item in second.json()["output"] if item["type"] == "message"] + assert len(output_messages) == 1 + assert "lisbon" in output_messages[0]["content"][0]["text"].lower() + class TestReasoningHostedMcpReplay: """Regression coverage for stateless reasoning + hosted MCP replay.""" @@ -761,10 +819,10 @@ class TestToolCalling: @pytest.mark.flaky @pytest.mark.integration @skip_if_foundry_hosting_integration_tests_disabled - async def test_tool_call_non_streaming(self, server_with_tools: ResponsesHostServer) -> None: + async def test_tool_call_non_streaming(self, history_server_with_tools: ResponsesHostServer) -> None: """Agent invokes a tool and returns a final answer (non-streaming).""" resp = await _post_json( - server_with_tools, + history_server_with_tools, { "input": "What is the weather in Seattle?", "stream": False, @@ -784,10 +842,10 @@ async def test_tool_call_non_streaming(self, server_with_tools: ResponsesHostSer @pytest.mark.flaky @pytest.mark.integration @skip_if_foundry_hosting_integration_tests_disabled - async def test_tool_call_streaming(self, server_with_tools: ResponsesHostServer) -> None: + async def test_tool_call_streaming(self, history_server_with_tools: ResponsesHostServer) -> None: """Agent invokes a tool and returns a final answer (streaming).""" resp = await _post_json( - server_with_tools, + history_server_with_tools, { "input": "What is the weather in Seattle?", "stream": True, From 5713d33ca739a32646220766ec1c8741635d57b6 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 1 Sep 2026 21:02:31 +0200 Subject: [PATCH 4/5] Python: document hosted agent ownership Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a4c73599-9f66-4c22-bd66-96fe676e0ce6 --- ...9-python-foundry-hosting-history-source.md | 2 ++ python/packages/foundry_hosting/README.md | 3 ++ .../_responses.py | 12 +++---- .../foundry_hosting/tests/test_responses.py | 31 +++++-------------- 4 files changed, 16 insertions(+), 32 deletions(-) diff --git a/docs/decisions/0039-python-foundry-hosting-history-source.md b/docs/decisions/0039-python-foundry-hosting-history-source.md index b3f9b1ceb5..d17fa3b92a 100644 --- a/docs/decisions/0039-python-foundry-hosting-history-source.md +++ b/docs/decisions/0039-python-foundry-hosting-history-source.md @@ -110,6 +110,8 @@ It does not disable response persistence. requirements. - Good: the API does not introduce a second history-selection state machine. - Bad: default mode mutates the supplied `RawAgent` by installing a transient history provider. +- Neutral: a `ResponsesHostServer` owns its supplied agent instance; reusing that agent with another host or invoking it + directly after server construction is unsupported. - Bad: regular agent history and AgentServer response history may differ, which response-oriented evaluations must document. - Neutral: switching an existing conversation between modes may require resetting its persisted session/history. diff --git a/python/packages/foundry_hosting/README.md b/python/packages/foundry_hosting/README.md index bdd6036801..710d2d9fb0 100644 --- a/python/packages/foundry_hosting/README.md +++ b/python/packages/foundry_hosting/README.md @@ -17,6 +17,9 @@ advertise `STORES_BY_DEFAULT=True`, hosting forces downstream `store=False`; for agent-level `store` option and does not forward one. These safeguards ensure the model receives the transcript once without sending unsupported storage options. +`ResponsesHostServer` owns the supplied agent instance and may add hosting-specific context providers. Do not reuse that +agent with another host or invoke it directly after constructing the server. + To preserve the agent's regular history and service-storage behavior, select the agent as the history source: ```python 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 7a922b86b0..43713c8a13 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -367,13 +367,15 @@ def __init__( because the hosting environment may get deactivated between requests. Provider state carried by `AgentSession`, including `InMemoryHistoryProvider` messages in `history_source="agent"` mode, is persisted by the configured session store. - 3. Resiliency (resilient_background=True) is ONLY supported for workflows; constructing this + 3. The server owns the supplied agent instance and may add hosting-specific providers. + Do not reuse the same agent with another host or invoke it directly after construction. + 4. Resiliency (resilient_background=True) is ONLY supported for workflows; constructing this server with a non-workflow agent and `resilient_background=True` raises `RuntimeError`. When resiliency is enabled, and the server crashes mid-response: - Background responses are automatically re-invoked on server restart (client won't see the crash). - Stream events are preserved for client reconnection. - State is maintained across crashes. - 4. Steering (steerable_conversations=True) is ONLY supported for non-workflow agents; constructing + 5. Steering (steerable_conversations=True) is ONLY supported for non-workflow agents; constructing this server with a workflow agent and `steerable_conversations=True` raises `RuntimeError`. Steering a workflow is conceptually undefined -- a workflow's graph may have loops or parallel branches with no single well-defined "current point" to cancel and resume from, unlike an @@ -418,12 +420,6 @@ def __init__( self._client_stores_by_default = getattr(storage_capability_owner, "STORES_BY_DEFAULT", False) is True if not self._client_stores_by_default and isinstance(default_options, dict): cast(dict[str, Any], default_options).pop("store", None) - elif isinstance(agent, RawAgent): - # A caller may reuse an agent that was previously attached to an AgentServer-history - # host. Restore regular agent behavior by removing only the host-owned sentinel. - agent.context_providers[:] = [ - provider for provider in agent.context_providers if not _is_hosted_responses_history_sentinel(provider) - ] self._is_workflow_agent = False if isinstance(agent, WorkflowAgent): diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index f219c33feb..d6b51d7374 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -786,12 +786,12 @@ def test_init_rejects_invalid_history_source(self) -> None: ) with pytest.raises(ValueError, match="history_source"): - ResponsesHostServer(agent, history_source="invalid") # type: ignore[arg-type] + ResponsesHostServer(agent, history_source=cast(Any, "invalid")) def test_init_rejects_default_conversation_id_for_agent_server_history(self) -> None: agent = Agent( client=_ServiceStorageRecordingClient(), - default_options={"conversation_id": "service-thread"}, + default_options={"conversation_id": "service-thread"}, # pyrefly: ignore[bad-argument-type] ) with pytest.raises(RuntimeError, match="default conversation_id"): @@ -800,28 +800,11 @@ def test_init_rejects_default_conversation_id_for_agent_server_history(self) -> def test_init_allows_default_conversation_id_for_agent_history(self) -> None: agent = Agent( client=_ServiceStorageRecordingClient(), - default_options={"conversation_id": "service-thread"}, + default_options={"conversation_id": "service-thread"}, # pyrefly: ignore[bad-argument-type] ) ResponsesHostServer(agent, history_source="agent") - def test_init_agent_history_removes_hosted_history_sentinel_from_reused_agent(self) -> None: - agent = Agent(client=_ServiceStorageRecordingClient()) - ResponsesHostServer(agent) - assert any( - provider.source_id == "_foundry_responses_history" - for provider in agent.context_providers - if isinstance(provider, HistoryProvider) - ) - - ResponsesHostServer(agent, history_source="agent") - - assert not any( - provider.source_id == "_foundry_responses_history" - for provider in agent.context_providers - if isinstance(provider, HistoryProvider) - ) - def test_init_rejects_resilient_background_for_non_workflow_agent(self, tmp_path: Path) -> None: agent = _make_agent( response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])]) @@ -944,7 +927,7 @@ async def test_agent_server_history_disables_service_storage(self) -> None: agent = Agent( client=client, name="Service Storage Agent", - default_options={"store": True}, + default_options={"store": True}, # pyrefly: ignore[bad-argument-type] ) store = SessionStore() server = _make_server(agent, session_store=store) @@ -969,7 +952,7 @@ async def test_agent_server_history_removes_store_for_non_storing_client(self) - agent = Agent( client=client, name="Non-Storing Agent", - default_options={"store": True}, + default_options={"store": True}, # pyrefly: ignore[bad-argument-type] ) server = _make_server(agent, session_store=SessionStore()) @@ -1021,7 +1004,7 @@ async def test_agent_history_preserves_service_storage(self) -> None: agent = Agent( client=client, name="Agent Managed Service Storage", - default_options={"store": True}, + default_options={"store": True}, # pyrefly: ignore[bad-argument-type] ) store = SessionStore() server = _make_server(agent, session_store=store, history_source="agent") @@ -1044,7 +1027,7 @@ async def test_agent_history_uses_in_memory_history_from_session_store(self) -> client=client, name="Agent Managed In-Memory History", context_providers=[history], - default_options={"store": False}, + default_options={"store": False}, # pyrefly: ignore[bad-argument-type] ) store = SessionStore() server = _make_server(agent, session_store=store, history_source="agent") From f2e61be114854cffc8cfd4fd59f8766593d9a38e Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 3 Sep 2026 09:51:50 +0200 Subject: [PATCH 5/5] Python: enforce hosted history capabilities Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a4c73599-9f66-4c22-bd66-96fe676e0ce6 --- ...9-python-foundry-hosting-history-source.md | 7 +- python/packages/foundry_hosting/README.md | 14 ++- .../_responses.py | 109 ++++++++++-------- .../foundry_hosting/tests/test_responses.py | 91 +++++++++++++-- 4 files changed, 152 insertions(+), 69 deletions(-) diff --git a/docs/decisions/0039-python-foundry-hosting-history-source.md b/docs/decisions/0039-python-foundry-hosting-history-source.md index d17fa3b92a..a8845b3814 100644 --- a/docs/decisions/0039-python-foundry-hosting-history-source.md +++ b/docs/decisions/0039-python-foundry-hosting-history-source.md @@ -70,7 +70,9 @@ Add `history_source: Literal["agent_server", "agent"] = "agent_server"` to `Resp With `history_source="agent_server"`: - load-enabled `HistoryProvider` instances are rejected; -- an agent-level default `conversation_id` is rejected; +- regular agents must implement `RawAgent` and their clients must declare `STORES_BY_DEFAULT` so hosting can enforce + runtime storage options; +- agent-level `conversation_id`, `previous_response_id`, and `conversation` defaults are rejected; - the configured response provider transcript and current input are passed to the agent; - clients advertising `STORES_BY_DEFAULT=True` receive a downstream `store=False` override; - for other clients, an explicit agent-level `store` option is removed and no storage option is forwarded; @@ -83,7 +85,8 @@ With `history_source="agent"`: - only current request input is passed by hosting; - load-enabled history providers are allowed; - downstream storage options are not changed; and -- normal `Agent` behavior selects service storage, an explicit history provider, or automatic in-session history. +- normal `Agent` behavior selects service storage, an explicit history provider, or automatic in-session history; +- custom `SupportsAgentRun` implementations remain supported without receiving unsupported runtime chat options. The AgentServer response provider continues to control Responses API persistence and retrieval in both modes, according to the outer request. The session-store provider also remains independent. Consequently, regular agent mode can combine diff --git a/python/packages/foundry_hosting/README.md b/python/packages/foundry_hosting/README.md index 710d2d9fb0..b457e14829 100644 --- a/python/packages/foundry_hosting/README.md +++ b/python/packages/foundry_hosting/README.md @@ -11,11 +11,15 @@ server = ResponsesHostServer(agent) ``` In this mode, the configured AgentServer response provider supplies the prior transcript. Hosting rejects -`HistoryProvider` instances with `load_messages=True` and agents configured with a default `conversation_id`, adds a -transient in-memory provider for function-call loops, and clears restored downstream service IDs. For clients that -advertise `STORES_BY_DEFAULT=True`, hosting forces downstream `store=False`; for other clients it removes an explicit -agent-level `store` option and does not forward one. These safeguards ensure the model receives the transcript once -without sending unsupported storage options. +`HistoryProvider` instances with `load_messages=True` and agents configured with a default `conversation_id`, +`previous_response_id`, or `conversation`, adds a transient in-memory provider for function-call loops, and clears +restored downstream service IDs. For clients that advertise `STORES_BY_DEFAULT=True`, hosting forces downstream +`store=False`; for other clients it removes an explicit agent-level `store` option and does not forward one. These +safeguards ensure the model receives the transcript once without sending unsupported storage options. + +AgentServer history requires a framework `RawAgent` whose client declares the boolean `STORES_BY_DEFAULT` capability; +the agent's runtime options then let hosting enforce downstream storage behavior. Custom `SupportsAgentRun` +implementations must use `history_source="agent"` because that protocol does not accept runtime chat options. `ResponsesHostServer` owns the supplied agent instance and may add hosting-specific context providers. Do not reuse that agent with another host or invoke it directly after constructing the server. 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 43713c8a13..eb06ede136 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -354,7 +354,8 @@ def __init__( If not provided, a default `FunctionApprovalStoreProvider` will be used. history_source: Source of conversation history supplied to the model for regular agents. `"agent_server"` (default) uses the transcript from the configured response store, - rejects load-enabled agent history providers, and disables downstream service storage. + requires a `RawAgent` whose client declares `STORES_BY_DEFAULT`, rejects load-enabled + agent history providers, and disables downstream service storage. `"agent"` passes only the current request input and preserves the agent's normal history-provider or service-storage behavior. AgentServer still manages Responses API persistence through `store` in both modes. @@ -385,18 +386,42 @@ def __init__( Raises: ValueError: If `history_source` is not supported. - RuntimeError: If `resilient_background=True` is requested for a non-workflow agent, or if + RuntimeError: If the agent configuration conflicts with the selected history source, + `resilient_background=True` is requested for a non-workflow agent, or `steerable_conversations=True` is requested for a workflow agent. """ if history_source not in ("agent_server", "agent"): raise ValueError("history_source must be either 'agent_server' or 'agent'.") - super().__init__(prefix=prefix, options=options, store=store, **kwargs) + is_workflow_agent = isinstance(agent, WorkflowAgent) + if is_workflow_agent and agent.workflow._runner_context.has_checkpointing(): # pyright: ignore[reportPrivateUsage] + raise RuntimeError( + "There should not be a checkpoint storage already present in the workflow agent. " + "The hosting infrastructure will manage checkpoints instead." + ) + + resilient_background = bool(options and options.resilient_background) + if resilient_background and not is_workflow_agent: + raise RuntimeError( + "resilient_background=True is only supported for workflow agents. " + "Crash recovery cannot be provided for non-workflow agents." + ) + if options and options.steerable_conversations and is_workflow_agent: + raise RuntimeError( + "steerable_conversations=True is only supported for non-workflow agents. " + "Steering cannot be provided reliably for workflow agents." + ) - self._uses_agent_server_history = history_source == "agent_server" - self._client_stores_by_default = False - if self._uses_agent_server_history: - for provider in getattr(agent, "context_providers", []): + uses_agent_server_history = history_source == "agent_server" + client_stores_by_default = False + if uses_agent_server_history and not is_workflow_agent: + if not isinstance(agent, RawAgent): + raise RuntimeError( + "history_source='agent_server' requires a RawAgent so hosting can enforce downstream " + "storage options. Construct ResponsesHostServer with history_source='agent' for a custom " + "SupportsAgentRun implementation." + ) + for provider in agent.context_providers: if isinstance(provider, HistoryProvider) and provider.load_messages: if _is_hosted_responses_history_sentinel(provider): continue @@ -405,34 +430,38 @@ def __init__( "with load_messages=True. Remove that provider or construct ResponsesHostServer " "with history_source='agent' to use the agent's regular history setup." ) - default_options = getattr(agent, "default_options", None) - typed_default_options: Mapping[str, Any] = ( - cast(Mapping[str, Any], default_options) if isinstance(default_options, Mapping) else {} - ) - if typed_default_options.get("conversation_id") is not None: + service_continuation_options = [ + name + for name in ("conversation_id", "previous_response_id", "conversation") + if agent.default_options.get(name) is not None + ] + if service_continuation_options: raise RuntimeError( - "AgentServer response history is enabled, but the agent has a default conversation_id. " - "Remove that option or construct ResponsesHostServer with history_source='agent' to resume " - "the downstream service conversation." + "AgentServer response history is enabled, but the agent has downstream service continuation " + f"option(s): {', '.join(service_continuation_options)}. Remove them or construct " + "ResponsesHostServer with history_source='agent' to resume the downstream service conversation." ) - agent_client = getattr(agent, "client", None) - storage_capability_owner = agent_client if agent_client is not None else agent - self._client_stores_by_default = getattr(storage_capability_owner, "STORES_BY_DEFAULT", False) is True - if not self._client_stores_by_default and isinstance(default_options, dict): - cast(dict[str, Any], default_options).pop("store", None) - - self._is_workflow_agent = False - if isinstance(agent, WorkflowAgent): - if agent.workflow._runner_context.has_checkpointing(): # pyright: ignore[reportPrivateUsage] + stores_by_default = getattr(cast(Any, agent).client, "STORES_BY_DEFAULT", None) + if not isinstance(stores_by_default, bool): raise RuntimeError( - "There should not be a checkpoint storage already present in the workflow agent. " - "The hosting infrastructure will manage checkpoints instead." + "history_source='agent_server' requires the agent's chat client to declare " + "STORES_BY_DEFAULT so hosting can enforce downstream storage behavior." ) - self._is_workflow_agent = True + client_stores_by_default = stores_by_default + + # No caller-owned agent state is mutated until all validation and base-host construction succeed. + super().__init__(prefix=prefix, options=options, store=store, **kwargs) + + self._uses_agent_server_history = uses_agent_server_history + self._client_stores_by_default = client_stores_by_default + self._is_workflow_agent = is_workflow_agent + self._resilient_background = resilient_background self._uses_hosted_responses_history = False if self._uses_agent_server_history and not self._is_workflow_agent and isinstance(agent, RawAgent): self._uses_hosted_responses_history = True + if not self._client_stores_by_default: + agent.default_options.pop("store", None) if not any( _is_hosted_responses_history_sentinel(provider) for provider in cast(Sequence[ContextProvider], agent.context_providers) @@ -463,21 +492,6 @@ def __init__( else function_approval_store_provider ) - # Resiliency check: fail loud rather than silently downgrading to a non-recoverable row. - self._resilient_background = bool(options and options.resilient_background) - if self._resilient_background and not self._is_workflow_agent: - raise RuntimeError( - "resilient_background=True is only supported for workflow agents. " - "Crash recovery cannot be provided for non-workflow agents." - ) - - # Steering check: steering a workflow is conceptually undefined and also impractical today. - if options and options.steerable_conversations and self._is_workflow_agent: - raise RuntimeError( - "steerable_conversations=True is only supported for non-workflow agents. " - "Steering cannot be provided reliably for workflow agents." - ) - # Lazy agent lifecycle: the agent (and any MCP tools it owns) is entered on # the first request rather than at server startup, so that authentication # failures during MCP connect can be surfaced to the client as an @@ -676,15 +690,10 @@ async def _handle_inner_agent( # Do not pass a storage option to clients that do not advertise support for it. chat_options.pop("store", None) - if are_options_set and not isinstance(self._agent, RawAgent): - logger.warning("Agent doesn't support runtime options. They will be ignored.") - if self._uses_agent_server_history and self._client_stores_by_default: - # Request generation options are unsupported for custom agents, but the - # host-owned storage directive must still reach an agent that advertises - # service-side storage. - run_kwargs["options"] = {"store": False} - else: + if isinstance(self._agent, RawAgent): run_kwargs["options"] = chat_options + elif are_options_set: + logger.warning("Agent doesn't support runtime options. They will be ignored.") # Non-workflow agents can't be resilient, so there is no exit_for_recovery path here: # both shutdown and steering/cancel just wind the turn down once observed. diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index d6b51d7374..73b4c51171 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -137,6 +137,9 @@ def _make_agent( agent.name = "Test Agent" agent.description = "A mock agent for testing" agent.context_providers = [] + agent.default_options = {} + agent.client = MagicMock() + agent.client.STORES_BY_DEFAULT = False def create_session(*, session_id: str | None = None) -> AgentSession: return AgentSession(session_id=session_id) @@ -171,6 +174,39 @@ def run_streaming(*args: Any, **kwargs: Any) -> ResponseStream[AgentResponseUpda return agent +class _StrictCustomAgent: + """Custom SupportsAgentRun implementation without a runtime options keyword.""" + + id = "strict-custom-agent" + name = "Strict Custom Agent" + description = "Exercises the exact SupportsAgentRun keyword contract." + + def __init__(self) -> None: + self.context_providers: list[Any] = [] + self.calls: list[Any] = [] + + def create_session(self, *, session_id: str | None = None) -> AgentSession: + return AgentSession(session_id=session_id) + + def run( + self, + messages: Any = None, + *, + stream: bool = False, + session: AgentSession | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: + del session, function_invocation_kwargs, client_kwargs + assert stream is True + self.calls.append(messages) + + async def updates() -> AsyncIterator[AgentResponseUpdate]: + yield AgentResponseUpdate(contents=[Content.from_text("ok")], role="assistant") + + return ResponseStream(updates(), finalizer=AgentResponse.from_updates) + + class _RecordingHistoryClient(BaseChatClient): def __init__(self) -> None: super().__init__() @@ -788,13 +824,14 @@ def test_init_rejects_invalid_history_source(self) -> None: with pytest.raises(ValueError, match="history_source"): ResponsesHostServer(agent, history_source=cast(Any, "invalid")) - def test_init_rejects_default_conversation_id_for_agent_server_history(self) -> None: + @pytest.mark.parametrize("option_name", ["conversation_id", "previous_response_id", "conversation"]) + def test_init_rejects_default_service_continuation_for_agent_server_history(self, option_name: str) -> None: agent = Agent( client=_ServiceStorageRecordingClient(), - default_options={"conversation_id": "service-thread"}, # pyrefly: ignore[bad-argument-type] + default_options=cast(Any, {option_name: "service-thread"}), ) - with pytest.raises(RuntimeError, match="default conversation_id"): + with pytest.raises(RuntimeError, match=option_name): ResponsesHostServer(agent) def test_init_allows_default_conversation_id_for_agent_history(self) -> None: @@ -805,6 +842,32 @@ def test_init_allows_default_conversation_id_for_agent_history(self) -> None: ResponsesHostServer(agent, history_source="agent") + def test_init_rejects_custom_agent_for_agent_server_history(self) -> None: + with pytest.raises(RuntimeError, match="history_source='agent'"): + ResponsesHostServer(cast(Any, _StrictCustomAgent())) + + def test_init_requires_storage_capability_for_agent_server_history(self) -> None: + agent = _make_agent() + agent.client = object() + + with pytest.raises(RuntimeError, match="STORES_BY_DEFAULT"): + ResponsesHostServer(agent) + + def test_failed_init_does_not_mutate_agent(self) -> None: + agent = Agent( + client=_RecordingHistoryClient(), + default_options={"store": True}, # pyrefly: ignore[bad-argument-type] + ) + + with pytest.raises(RuntimeError, match="resilient_background"): + ResponsesHostServer( + agent, + options=ResponsesServerOptions(resilient_background=True), + ) + + assert agent.default_options["store"] is True + assert agent.context_providers == [] + def test_init_rejects_resilient_background_for_non_workflow_agent(self, tmp_path: Path) -> None: agent = _make_agent( response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])]) @@ -962,19 +1025,14 @@ async def test_agent_server_history_removes_store_for_non_storing_client(self) - assert "store" not in agent.default_options assert "store" not in client.options[0] - async def test_agent_server_history_preserves_storage_directive_for_custom_agent_options(self) -> None: - agent = _make_agent( - response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("ok")])]), - raw_agent=False, - ) - agent.client = MagicMock() - agent.client.STORES_BY_DEFAULT = True - server = _make_server(agent, session_store=SessionStore()) + async def test_agent_history_does_not_forward_runtime_options_to_custom_agent(self) -> None: + agent = _StrictCustomAgent() + server = _make_server(agent, session_store=SessionStore(), history_source="agent") response = await _post(server, input_text="first", temperature=0.5) assert response.json()["status"] == "completed" - assert agent.run.call_args.kwargs["options"] == {"store": False} + assert len(agent.calls) == 1 async def test_agent_server_history_clears_restored_service_session_id(self) -> None: client = _ServiceStorageRecordingClient() @@ -3303,6 +3361,9 @@ def _make_multi_response_agent( agent.name = "Test Agent" agent.description = "A mock agent for testing" agent.context_providers = [] + agent.default_options = {} + agent.client = MagicMock() + agent.client.STORES_BY_DEFAULT = False def create_session(*, session_id: str | None = None) -> AgentSession: return AgentSession(session_id=session_id) @@ -4819,6 +4880,9 @@ async def _raise_stream() -> AsyncIterator[AgentResponseUpdate]: agent.name = "Test Agent" agent.description = "A mock agent for testing" agent.context_providers = [] + agent.default_options = {} + agent.client = MagicMock() + agent.client.STORES_BY_DEFAULT = False def create_session(*, session_id: str | None = None) -> AgentSession: return AgentSession(session_id=session_id) @@ -4867,6 +4931,9 @@ async def _raise_stream() -> AsyncIterator[AgentResponseUpdate]: agent.name = "Test Agent" agent.description = "A mock agent for testing" agent.context_providers = [] + agent.default_options = {} + agent.client = MagicMock() + agent.client.STORES_BY_DEFAULT = False def create_session(*, session_id: str | None = None) -> AgentSession: return AgentSession(session_id=session_id)