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..d17fa3b92a --- /dev/null +++ b/docs/decisions/0039-python-foundry-hosting-history-source.md @@ -0,0 +1,124 @@ +--- +status: proposed +contact: eavanvalkenburg +date: 2026-09-01 +deciders: eavanvalkenburg, moonbox3 +--- + +# 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. +- Let applications choose storage that satisfies their compliance, residency, retention, deletion, encryption, and + audit requirements. +- Retain AgentServer response history as the default hosting behavior. +- 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: 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. +- 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. +- 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. + +## 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; +- 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. + +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 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. +- 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. + +## 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..710d2d9fb0 100644 --- a/python/packages/foundry_hosting/README.md +++ b/python/packages/foundry_hosting/README.md @@ -2,6 +2,48 @@ 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, 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. + +`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 +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..43713c8a13 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,21 +352,30 @@ 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. - 3. Resiliency (resilient_background=True) is ONLY supported for workflows; constructing this + 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. 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 @@ -374,19 +384,42 @@ 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" + 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: + 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." ) + 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): @@ -398,7 +431,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,24 +648,41 @@ 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: + 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 @@ -662,8 +712,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..d6b51d7374 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 @@ -174,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, @@ -183,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") @@ -193,6 +196,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 +766,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=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"}, # pyrefly: ignore[bad-argument-type] + ) + + 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"}, # pyrefly: ignore[bad-argument-type] + ) + + 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 +922,144 @@ 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}, # pyrefly: ignore[bad-argument-type] + ) + 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_removes_store_for_non_storing_client(self) -> None: + client = _RecordingHistoryClient() + agent = Agent( + client=client, + name="Non-Storing Agent", + default_options={"store": True}, # pyrefly: ignore[bad-argument-type] + ) + 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") + 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}, # pyrefly: ignore[bad-argument-type] + ) + 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}, # pyrefly: ignore[bad-argument-type] + ) + 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) 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,