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/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index f75a3f2dd3..a776b2df82 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -142,7 +142,7 @@
-
+
diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock.csproj b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock.csproj
index 274ac15c97..d16c3b03b3 100644
--- a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock.csproj
+++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock.csproj
@@ -9,7 +9,7 @@
-
+
diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/README.md b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/README.md
index 06d4012bd9..7d49ca1380 100644
--- a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/README.md
+++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/README.md
@@ -1,11 +1,11 @@
# Agent with Memory Using Valkey + Amazon Bedrock
-This sample demonstrates using Valkey for persistent chat history with the Agent Framework, powered by Amazon Bedrock via the `AWSSDK.Extensions.Bedrock.MEAI` adapter.
+This sample demonstrates using Valkey for persistent chat history with the Agent Framework, powered by Amazon Bedrock via the `AWS.Bedrock.MEAI` adapter.
## Components
- **ValkeyChatHistoryProvider** — Persists conversation history across sessions using Valkey lists. Works with any Valkey or Redis OSS server (no search module required).
-- **Amazon Bedrock** — Provides the LLM via `AWSSDK.Extensions.Bedrock.MEAI`, which implements `IChatClient` from `Microsoft.Extensions.AI`.
+- **Amazon Bedrock** — Provides the LLM via `AWS.Bedrock.MEAI`, which implements `IChatClient` from `Microsoft.Extensions.AI`.
## Prerequisites
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,