diff --git a/docs/specs/004-python-function-calling-loop.md b/docs/specs/004-python-function-calling-loop.md index 8462dc76ac..0b4251b9b4 100644 --- a/docs/specs/004-python-function-calling-loop.md +++ b/docs/specs/004-python-function-calling-loop.md @@ -120,6 +120,10 @@ Code-reading landmarks: - `_process_model_function_calls(...)` handles only calls from a completed model response. - `_try_execute_function_calls(...)` decides approval/declaration/execution behavior for a batch. - `_replace_approval_contents_with_results(...)` is the occurrence-aware approval transcript normalizer. +- `FunctionInvocationLayer._update_function_invocation_continuation_state(...)` updates continuation state after + every service response. Provider layers may override it to carry provider-specific continuation metadata into + the next service call, but must delegate to the base implementation so generic conversation continuation remains + synchronized with the active `AgentSession`. ### Approval pause and resume diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index 05606d0a5f..d22f6e14e1 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -1160,6 +1160,38 @@ def _call_chat_client( client_kwargs=context["client_kwargs"], ) + def _update_session_from_chat_response( + self, + session: AgentSession | None, + response: ChatResponse[Any], + ) -> None: + """Update session continuation state from a chat response.""" + if ( + session + and response.conversation_id + and not is_local_history_conversation_id(response.conversation_id) + and session.service_session_id != response.conversation_id + ): + session.service_session_id = response.conversation_id + + def _update_session_from_chat_response_update( + self, + session: AgentSession | None, + update: AgentResponseUpdate, + ) -> None: + """Update session continuation state from a streaming agent update.""" + if session is None: + return + raw = update.raw_representation + conversation_id = getattr(raw, "conversation_id", None) if raw else None + if ( + isinstance(conversation_id, str) + and conversation_id + and not is_local_history_conversation_id(conversation_id) + and session.service_session_id != conversation_id + ): + session.service_session_id = conversation_id + async def _parse_non_streaming_response( self, context: _RunContext, @@ -1174,13 +1206,7 @@ async def _parse_non_streaming_response( message.author_name = context["agent_name"] session = context["session"] - if ( - session - and response.conversation_id - and not is_local_history_conversation_id(response.conversation_id) - and session.service_session_id != response.conversation_id - ): - session.service_session_id = response.conversation_id + self._update_session_from_chat_response(session, response) agent_response = _build_agent_response_from_chat_response( response, @@ -1232,18 +1258,7 @@ async def _post_hook(response: AgentResponse) -> None: def _propagate_conversation_id(update: AgentResponseUpdate) -> AgentResponseUpdate: """Eagerly propagate conversation_id to session as updates arrive.""" - session = context["session"] - if session is None: - return update - raw = update.raw_representation - conversation_id = getattr(raw, "conversation_id", None) if raw else None - if ( - isinstance(conversation_id, str) - and conversation_id - and not is_local_history_conversation_id(conversation_id) - and session.service_session_id != conversation_id - ): - session.service_session_id = conversation_id + self._update_session_from_chat_response_update(context["session"], update) return update def _suppress_response_id(update: AgentResponseUpdate) -> AgentResponseUpdate: diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index e7760c9cbd..2d8f1ced47 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1923,27 +1923,6 @@ def _update_conversation_id( options["conversation_id"] = conversation_id -def _update_continuation_state( - kwargs: dict[str, Any], - response: ChatResponse[Any], - *, - session: AgentSession | None, - options: dict[str, Any] | None = None, -) -> None: - """Update in-flight and persisted continuation state from a response.""" - conversation_id = response.conversation_id - if conversation_id is None: - return - - _update_conversation_id(kwargs, conversation_id, options) - if ( - session is not None - and not response.has_internal_conversation_id() - and session.service_session_id != conversation_id - ): - session.service_session_id = conversation_id - - def _clear_internal_conversation_id(response: ChatResponse[Any]) -> ChatResponse[Any]: if response.has_internal_conversation_id(): response.conversation_id = None @@ -2867,6 +2846,27 @@ def __init__( kwargs["middleware"] = chat_middleware super().__init__(**kwargs) + def _update_function_invocation_continuation_state( + self, + kwargs: dict[str, Any], + response: ChatResponse[Any], + *, + session: AgentSession | None, + options: dict[str, Any] | None = None, + ) -> None: + """Update continuation state after a function-loop service call.""" + conversation_id = response.conversation_id + if conversation_id is None: + return + + _update_conversation_id(kwargs, conversation_id, options) + if ( + session is not None + and not response.has_internal_conversation_id() + and session.service_session_id != conversation_id + ): + session.service_session_id = conversation_id + def _get_function_middleware_pipeline( self, runtime_middleware: Sequence[FunctionMiddlewareTypes], @@ -2953,7 +2953,7 @@ async def _get_response_with_function_invocation( ): _ensure_function_invocation_limit_fallback_response(response) aggregated_usage = add_usage_details(aggregated_usage, response.usage_details) - _update_continuation_state( + self._update_function_invocation_continuation_state( request_kwargs, response, session=invocation_session, @@ -3004,7 +3004,7 @@ async def _get_response_with_function_invocation( ) _ensure_function_invocation_limit_fallback_response(response) aggregated_usage = add_usage_details(aggregated_usage, response.usage_details) - _update_continuation_state( + self._update_function_invocation_continuation_state( request_kwargs, response, session=invocation_session, @@ -3094,7 +3094,7 @@ async def _stream_response_with_function_invocation( fallback_added = False if function_call_limit_reached: fallback_added = _ensure_function_invocation_limit_fallback_response(response) - _update_continuation_state( + self._update_function_invocation_continuation_state( request_kwargs, response, session=invocation_session, @@ -3161,7 +3161,7 @@ async def _stream_response_with_function_invocation( yield update final_response = await final_inner_stream.get_final_response() fallback_added = _ensure_function_invocation_limit_fallback_response(final_response) - _update_continuation_state( + self._update_function_invocation_continuation_state( request_kwargs, final_response, session=invocation_session, diff --git a/python/packages/core/agent_framework/foundry/__init__.py b/python/packages/core/agent_framework/foundry/__init__.py index 325b368d10..7c250f0ce2 100644 --- a/python/packages/core/agent_framework/foundry/__init__.py +++ b/python/packages/core/agent_framework/foundry/__init__.py @@ -23,6 +23,7 @@ "DocumentStatus": ("agent_framework_azure_contentunderstanding", "agent-framework-azure-contentunderstanding"), "FileSearchBackend": ("agent_framework_azure_contentunderstanding", "agent-framework-azure-contentunderstanding"), "FileSearchConfig": ("agent_framework_azure_contentunderstanding", "agent-framework-azure-contentunderstanding"), + "FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY": ("agent_framework_foundry", "agent-framework-foundry"), "FoundryAgent": ("agent_framework_foundry", "agent-framework-foundry"), "FoundryAgentOptions": ("agent_framework_foundry", "agent-framework-foundry"), "FoundryChatClient": ("agent_framework_foundry", "agent-framework-foundry"), diff --git a/python/packages/core/agent_framework/foundry/__init__.pyi b/python/packages/core/agent_framework/foundry/__init__.pyi index d3ed7b15b4..45f1ea0f56 100644 --- a/python/packages/core/agent_framework/foundry/__init__.pyi +++ b/python/packages/core/agent_framework/foundry/__init__.pyi @@ -12,6 +12,7 @@ from agent_framework_azure_contentunderstanding import ( FileSearchConfig, ) from agent_framework_foundry import ( + FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY, FoundryAgent, FoundryChatClient, FoundryChatOptions, @@ -50,6 +51,7 @@ from agent_framework_foundry_local import ( ) __all__ = [ + "FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY", "AgentSessionStoreProvider", "AnalysisSection", "AnthropicFoundryClient", diff --git a/python/packages/core/tests/core/test_foundry_namespace.py b/python/packages/core/tests/core/test_foundry_namespace.py index a0696ab156..3797a35809 100644 --- a/python/packages/core/tests/core/test_foundry_namespace.py +++ b/python/packages/core/tests/core/test_foundry_namespace.py @@ -11,18 +11,21 @@ FoundryChatClient = _foundry.FoundryChatClient FoundryMemoryProvider = _foundry.FoundryMemoryProvider +FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY = _foundry.FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY FoundryAgentSessionStore = _foundry_hosting.FoundryAgentSessionStore ResponsesHostServer = _foundry_hosting.ResponsesHostServer FoundryLocalClient = _foundry_local.FoundryLocalClient def test_foundry_namespace_exposes_cloud_and_local_symbols() -> None: + assert foundry.FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY is FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY assert foundry.FoundryChatClient is FoundryChatClient assert foundry.FoundryMemoryProvider is FoundryMemoryProvider assert foundry.FoundryAgentSessionStore is FoundryAgentSessionStore assert foundry.ResponsesHostServer is ResponsesHostServer assert foundry.FoundryLocalClient is FoundryLocalClient assert "FoundryChatClient" in dir(foundry) + assert "FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY" in dir(foundry) assert "FoundryLocalClient" in dir(foundry) assert "FoundryAgentSessionStore" in dir(foundry) assert "ResponsesHostServer" in dir(foundry) diff --git a/python/packages/foundry/README.md b/python/packages/foundry/README.md index bcaa83f616..ef2c0bba46 100644 --- a/python/packages/foundry/README.md +++ b/python/packages/foundry/README.md @@ -114,7 +114,7 @@ project conversation and returns an `AgentSession` that can be passed to `agent.run(...)` without reaching into the raw OpenAI client. ```python -from agent_framework.foundry import FoundryAgent +from agent_framework.foundry import FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY, FoundryAgent agent = FoundryAgent( project_endpoint=project_endpoint, @@ -126,9 +126,11 @@ session = await agent.create_conversation() response = await agent.run("Help me plan a trip to Seattle.", session=session) ``` -This is separate from hosted-agent `isolation_key` sessions: the created -conversation ID is stored on `AgentSession.service_session_id`, while the local -`session_id` remains available for application/session storage. +For HostedAgents, start with a normal `AgentSession`. When no hosted-agent +session ID is supplied, the service creates one and the agent stores it in +`session.state[FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY]`. The response conversation +ID or response ID remains separate in `session.service_session_id` and is used +as the next request's continuation handle. ## Publishing an agent as a Foundry prompt agent diff --git a/python/packages/foundry/agent_framework_foundry/__init__.py b/python/packages/foundry/agent_framework_foundry/__init__.py index 1ee0fc56dd..57fcf804b6 100644 --- a/python/packages/foundry/agent_framework_foundry/__init__.py +++ b/python/packages/foundry/agent_framework_foundry/__init__.py @@ -2,7 +2,13 @@ import importlib.metadata -from ._agent import FoundryAgent, FoundryAgentOptions, RawFoundryAgent, RawFoundryAgentChatClient +from ._agent import ( + FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY, + FoundryAgent, + FoundryAgentOptions, + RawFoundryAgent, + RawFoundryAgentChatClient, +) from ._chat_client import FoundryChatClient, FoundryChatOptions, RawFoundryChatClient from ._embedding_client import ( FoundryEmbeddingClient, @@ -25,6 +31,7 @@ __version__ = "0.0.0" __all__ = [ + "FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY", "FoundryAgent", "FoundryAgentOptions", "FoundryChatClient", diff --git a/python/packages/foundry/agent_framework_foundry/_agent.py b/python/packages/foundry/agent_framework_foundry/_agent.py index 28a840c9e3..862c54a2a9 100644 --- a/python/packages/foundry/agent_framework_foundry/_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -11,14 +11,17 @@ import logging import sys +import warnings from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence from typing import TYPE_CHECKING, Any, ClassVar, Generic, cast from agent_framework import ( AgentMiddlewareLayer, + AgentResponseUpdate, AgentSession, ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, + ChatResponse, ChatResponseUpdate, ContextProvider, FunctionInvocationConfiguration, @@ -69,6 +72,7 @@ logger: logging.Logger = logging.getLogger("agent_framework.foundry") + AzureTokenProvider = Callable[[], str | Awaitable[str]] AzureCredentialTypes = TokenCredential | AsyncTokenCredential @@ -90,6 +94,9 @@ class FoundryAgentSettings(TypedDict, total=False): agent_version: str | None +FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY = "foundry_hosted_agent_session_id" + + class FoundryAgentOptions(OpenAIChatOptions, total=False): """Microsoft Foundry agent-specific chat options. @@ -98,8 +105,7 @@ class FoundryAgentOptions(OpenAIChatOptions, total=False): Keyword Args: extra_body: Additional request body values sent to the Responses API. - isolation_key: Isolation key used when lazily creating a hosted-agent - session through the project's agent operations. + isolation_key: Deprecated. This option no longer has any effect. """ extra_body: dict[str, Any] @@ -128,14 +134,10 @@ def _merge_extra_body(extra_body: Any | None, *, additions: Mapping[str, Any] | return merged -def _uses_foundry_agent_session(conversation_id: Any) -> bool: - """Return whether a conversation_id should be treated as a Foundry agent session id.""" - return ( - isinstance(conversation_id, str) - and bool(conversation_id) - and not conversation_id.startswith("resp_") - and not conversation_id.startswith("conv_") - ) +def _extract_foundry_hosted_agent_session_id(response: Any) -> str | None: + """Extract a Foundry hosted-agent session ID from a service response.""" + agent_session_id = getattr(response, "agent_session_id", None) + return agent_session_id if isinstance(agent_session_id, str) and agent_session_id else None def _build_agent_reference(agent_name: str, agent_version: str | None) -> dict[str, str]: @@ -342,6 +344,13 @@ async def _prepare_options( ) -> dict[str, Any]: """Prepare options for the Responses API and validate client-side tools.""" caller_requested_encrypted_reasoning = "reasoning.encrypted_content" in (options.get("include") or []) + prepared_options = dict(options) + if prepared_options.pop("isolation_key", None) is not None: + warnings.warn( + "The 'isolation_key' option is deprecated and no longer has any effect.", + DeprecationWarning, + stacklevel=2, + ) # Validate tools — only FunctionTool allowed tools = options.get("tools", []) @@ -358,7 +367,7 @@ async def _prepare_options( prepared_messages, _instructions = self._prepare_messages_for_azure_ai(messages) # Call parent prepare_options (OpenAI Responses API format) - run_options = await super()._prepare_options(prepared_messages, options, **kwargs) + run_options = await super()._prepare_options(prepared_messages, prepared_options, **kwargs) # Foundry Agent deployments can reject the OpenAI client's automatic encrypted-reasoning # opt-in even when the configured model otherwise supports reasoning. Preserve an explicit @@ -375,29 +384,22 @@ async def _prepare_options( run_options["input"] = self._transform_input_for_azure_ai(cast(list[dict[str, Any]], run_options["input"])) # Merge caller-supplied extra_body with any agent-specific request payload. - conversation_id = options.get("conversation_id") extra_body = _merge_extra_body(run_options.pop("extra_body", None)) - if _uses_foundry_agent_session(conversation_id): - run_options.pop("previous_response_id", None) - run_options.pop("conversation", None) - run_options.pop("model", None) - extra_body["agent_session_id"] = conversation_id + agent_session_id = extra_body.get("agent_session_id") # Non-preview Prompt/Hosted Agent calls need agent_reference in the request body to # tell the Responses API which Foundry agent (and version) is in use, since ``model`` # is stripped below. The preview path injects the reference via the OpenAI client kwarg # ``agent_name`` instead, so skip there. See issue #5582. if not self.allow_preview: extra_body.setdefault("agent_reference", _build_agent_reference(self.agent_name, self.agent_version)) - should_strip_model = _uses_foundry_agent_session(conversation_id) or ( - conversation_id is None and not options.get("model") + should_strip_model = agent_session_id is not None or ( + options.get("conversation_id") is None and not options.get("model") ) if should_strip_model: run_options.pop("model", None) if extra_body: run_options["extra_body"] = extra_body - run_options.pop("isolation_key", None) - # Strip tool fields from the request body. This client always targets a pre-provisioned # Foundry agent (agent_name is required), and the service rejects requests that carry both # an agent reference and tool declarations with HTTP 400 "Not allowed when agent is @@ -426,8 +428,8 @@ def _parse_response_from_openai( options: dict[str, Any], ) -> Any: parsed_response = super()._parse_response_from_openai(response, options) - if _uses_foundry_agent_session(options.get("conversation_id")): - parsed_response.conversation_id = None + if agent_session_id := _extract_foundry_hosted_agent_session_id(response): + parsed_response.additional_properties["agent_session_id"] = agent_session_id return parsed_response @override @@ -447,8 +449,10 @@ def _parse_chunk_from_openai( function_call_ids, seen_reasoning_delta_item_ids, ) - if _uses_foundry_agent_session(options.get("conversation_id")): - update.conversation_id = None + if agent_session_id := _extract_foundry_hosted_agent_session_id(getattr(event, "response", None)): + if update.additional_properties is None: + update.additional_properties = {} + update.additional_properties["agent_session_id"] = agent_session_id return update @override @@ -509,24 +513,6 @@ def _transform_input_for_azure_ai(self, input_items: list[dict[str, Any]]) -> li return transformed - async def get_agent_version(self) -> str | None: - """Return the agent version if available, else None.""" - if self.agent_version is not None: - return self.agent_version - if not self.allow_preview: - return None - agent_details = await cast(Any, self.project_client.beta.agents).get(agent_name=self.agent_name) - versions_object = getattr(agent_details, "versions", None) - if not isinstance(versions_object, Mapping): - raise TypeError("Foundry agent details did not include a versions mapping.") - versions = cast(Mapping[str, Any], versions_object) - latest_version = versions.get("latest") - agent_version = getattr(cast(Any, latest_version), "version", None) - if not isinstance(agent_version, str): - raise TypeError("Foundry agent details did not include a latest version string.") - self.agent_version = agent_version - return agent_version - async def close(self) -> None: """Close the project client if we created it.""" if self._should_close_client: @@ -622,6 +608,33 @@ def __init__( timeout=timeout, ) + @override + def _update_function_invocation_continuation_state( + self, + kwargs: dict[str, Any], + response: ChatResponse[Any], + *, + session: AgentSession | None, + options: dict[str, Any] | None = None, + ) -> None: + super()._update_function_invocation_continuation_state( + kwargs, + response, + session=session, + options=options, + ) + agent_session_id = response.additional_properties.get("agent_session_id") + if not isinstance(agent_session_id, str) or not agent_session_id: + return + # Persist the ID for later runs and inject it into the current options because function + # invocation can make another service call before the agent prepares a new run context. + if session is not None: + session.state[FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY] = agent_session_id + if options is not None: + extra_body = _merge_extra_body(options.get("extra_body")) + extra_body["agent_session_id"] = agent_session_id + options["extra_body"] = extra_body + class RawFoundryAgent( RawAgent[FoundryAgentOptionsT], @@ -752,48 +765,6 @@ def __init__( additional_properties=dict(additional_properties) if additional_properties is not None else None, ) - def _resolve_service_session_isolation_key(self, isolation_key: str | None = None) -> str: - """Resolve the isolation key from an explicit value or default_options.""" - resolved_isolation_key = ( - isolation_key if isolation_key is not None else self.default_options.get("isolation_key") - ) - if resolved_isolation_key is None: - raise ValueError("isolation_key is required. Pass it explicitly or set default_options['isolation_key'].") - return resolved_isolation_key - - async def _create_service_session_id( - self, - *, - isolation_key: str | None = None, - ) -> str: - """Create a hosted Foundry service session and return the service session ID.""" - if not isinstance(self.client, RawFoundryAgentChatClient): - raise TypeError("_create_service_session_id requires a RawFoundryAgentChatClient-based client.") - if not self.client.allow_preview: - raise RuntimeError("Hosted Foundry service sessions require allow_preview=True.") - - create_session_kwargs: dict[str, Any] = { - "agent_name": self.client.agent_name, - "isolation_key": self._resolve_service_session_isolation_key(isolation_key), - } - if version := await self.client.get_agent_version(): - from azure.ai.projects.models import VersionRefIndicator - - create_session_kwargs["version_indicator"] = VersionRefIndicator(agent_version=version) - - session_agents = cast(Any, self.client.project_client.agents) - create_session = getattr(session_agents, "create_session", None) - if create_session is None: - session_agents = cast(Any, self.client.project_client.beta.agents) - create_session = session_agents.create_session - - service_session = await create_session(**create_session_kwargs) - agent_session_id = getattr(service_session, "agent_session_id", None) - if not isinstance(agent_session_id, str) or not agent_session_id: - raise ValueError("Hosted Foundry session creation did not return a non-empty agent_session_id.") - - return agent_session_id - async def create_conversation(self, *, session_id: str | None = None) -> AgentSession: """Create a project-level Foundry conversation session. @@ -826,19 +797,12 @@ async def _prepare_run_context( client_kwargs: Mapping[str, Any] | None, ) -> _RunContext: runtime_options = dict(options) if options else {} - effective_options = { - **{key: value for key, value in self.default_options.items() if value is not None}, - **{key: value for key, value in runtime_options.items() if value is not None}, - } - - if ( - session is not None - and session.service_session_id is None - and effective_options.get("isolation_key") is not None - ): - session.service_session_id = await self._create_service_session_id( - isolation_key=cast(str | None, effective_options.get("isolation_key")), - ) + agent_session_id = session.state.get(FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY) if session is not None else None + if isinstance(agent_session_id, str) and agent_session_id: + extra_body = _merge_extra_body(self.default_options.get("extra_body")) + extra_body.update(_merge_extra_body(runtime_options.get("extra_body"))) + extra_body.setdefault("agent_session_id", agent_session_id) + runtime_options["extra_body"] = extra_body return await super()._prepare_run_context( messages=messages, @@ -851,6 +815,30 @@ async def _prepare_run_context( client_kwargs=client_kwargs, ) + @override + def _update_session_from_chat_response( + self, + session: AgentSession | None, + response: ChatResponse[Any], + ) -> None: + super()._update_session_from_chat_response(session, response) + agent_session_id = response.additional_properties.get("agent_session_id") + if session is not None and isinstance(agent_session_id, str) and agent_session_id: + session.state[FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY] = agent_session_id + + @override + def _update_session_from_chat_response_update( + self, + session: AgentSession | None, + update: AgentResponseUpdate, + ) -> None: + super()._update_session_from_chat_response_update(session, update) + agent_session_id = ( + update.additional_properties.get("agent_session_id") if update.additional_properties is not None else None + ) + if session is not None and isinstance(agent_session_id, str) and agent_session_id: + session.state[FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY] = agent_session_id + async def configure_azure_monitor( self, enable_sensitive_data: bool = False, @@ -999,10 +987,11 @@ def __init__( already be configured for preview APIs before being passed to ``FoundryAgent``. - To lazily create HostedAgent service sessions inside the agent, pass an - ``isolation_key`` through ``default_options`` (or per-run options). The - agent stores the resulting HostedAgent session ID in - ``AgentSession.service_session_id`` and reuses it on subsequent runs. + For HostedAgents, the service creates an agent session when the first + request does not specify one. The agent stores that infrastructure ID in + ``AgentSession.state["foundry_hosted_agent_session_id"]`` and sends it through + ``extra_body`` on later requests. ``AgentSession.service_session_id`` + remains the conversation continuation handle. Keyword Args: project_endpoint: The Foundry project endpoint URL. @@ -1011,9 +1000,7 @@ def __init__( credential: Azure credential for authentication. project_client: An existing AIProjectClient to use. allow_preview: Enables preview opt-in on internally-created AIProjectClient. - Set this to ``True`` for HostedAgents that need preview-only - session APIs, including lazy service session creation from - ``isolation_key``. + Set this to ``True`` for HostedAgents that need preview APIs. default_headers: Additional HTTP headers for requests made through the OpenAI client. tools: Function tools to provide to the agent. Only ``FunctionTool`` objects are accepted. context_providers: Optional context providers. @@ -1026,8 +1013,8 @@ def __init__( description: Optional local description for the local agent wrapper. instructions: Optional instructions for the local agent wrapper. default_options: Default chat options for the local agent wrapper. - ``FoundryAgentOptions`` can include ``isolation_key`` and - ``extra_body`` when working with HostedAgents. + ``FoundryAgentOptions`` can include ``extra_body`` when working + with HostedAgents. require_per_service_call_history_persistence: Whether to require per-service-call chat history persistence when using local history providers. function_invocation_configuration: Optional function invocation configuration override. diff --git a/python/packages/foundry/tests/foundry/test_foundry_agent.py b/python/packages/foundry/tests/foundry/test_foundry_agent.py index e6fc1cde13..9ae2938ed6 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_agent.py +++ b/python/packages/foundry/tests/foundry/test_foundry_agent.py @@ -18,6 +18,7 @@ Agent, AgentExecutor, AgentResponse, + AgentResponseUpdate, AgentSession, ChatContext, ChatMiddleware, @@ -30,6 +31,7 @@ WorkflowBuilder, tool, ) +from agent_framework.foundry import FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY from agent_framework_openai._chat_client import RawOpenAIChatClient from agent_framework_openai._feature_usage import FeatureIndex as OpenAIFeatureIndex from azure.ai.projects import models as projects_models @@ -537,6 +539,33 @@ async def test_raw_foundry_agent_chat_client_prepare_options_no_tool_warning_whe assert not any("cannot be sent when an agent is specified" in record.message for record in caplog.records) +async def test_raw_foundry_agent_chat_client_prepare_options_warns_for_deprecated_isolation_key() -> None: + """Test that isolation_key remains accepted as a deprecated no-op.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + with ( + patch( + "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", + new_callable=AsyncMock, + return_value={"model": "gpt-4.1"}, + ) as mock_prepare_options, + pytest.warns(DeprecationWarning, match="isolation_key.*no longer has any effect"), + ): + await client._prepare_options( + messages=[Message(role="user", contents="hi")], + options={"isolation_key": "tenant-123"}, + ) + + assert mock_prepare_options.await_args + assert "isolation_key" not in mock_prepare_options.await_args.args[1] + + async def test_raw_foundry_agent_chat_client_prepare_options_strips_model_for_hosted_session() -> None: """Test that model is stripped when using a hosted agent session (not a PromptAgent).""" @@ -555,15 +584,19 @@ async def test_raw_foundry_agent_chat_client_prepare_options_strips_model_for_ho return_value={ "model": "gpt-4.1", "previous_response_id": "resp_abc", + "extra_body": {"agent_session_id": "agent-session-123"}, }, ): result = await client._prepare_options( messages=[Message(role="user", contents="hi")], - options={"conversation_id": "agent-session-123"}, + options={ + "conversation_id": "caresp_123", + "extra_body": {"agent_session_id": "agent-session-123"}, + }, ) assert "model" not in result - assert "previous_response_id" not in result + assert result["previous_response_id"] == "resp_abc" assert result["extra_body"]["agent_session_id"] == "agent-session-123" assert result["extra_body"]["agent_reference"] == {"name": "test-agent", "type": "agent_reference"} @@ -745,8 +778,8 @@ async def test_raw_foundry_agent_chat_client_prepare_options_preserves_model_for assert "extra_body" not in result or "agent_session_id" not in result.get("extra_body", {}) -async def test_raw_foundry_agent_chat_client_prepare_options_maps_agent_session_id_to_extra_body() -> None: - """Test that service_session_id is forwarded as agent_session_id for hosted sessions.""" +async def test_raw_foundry_agent_chat_client_prepare_options_preserves_both_session_ids() -> None: + """Test that response continuation and hosted-agent session IDs are both forwarded.""" mock_project = MagicMock() mock_openai = MagicMock() @@ -761,13 +794,16 @@ async def test_raw_foundry_agent_chat_client_prepare_options_maps_agent_session_ "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", new_callable=AsyncMock, return_value={ - "extra_body": {"custom": "value"}, - "previous_response_id": "should-be-removed", + "extra_body": {"custom": "value", "agent_session_id": "agent-session-123"}, + "previous_response_id": "caresp_123", }, ): result = await client._prepare_options( messages=[Message(role="user", contents="hi")], - options={"conversation_id": "agent-session-123", "isolation_key": "iso-key"}, + options={ + "conversation_id": "caresp_123", + "extra_body": {"agent_session_id": "agent-session-123"}, + }, ) assert result["extra_body"] == { @@ -775,13 +811,11 @@ async def test_raw_foundry_agent_chat_client_prepare_options_maps_agent_session_ "agent_session_id": "agent-session-123", "agent_reference": {"name": "test-agent", "type": "agent_reference"}, } - assert "previous_response_id" not in result - assert "conversation" not in result - assert "isolation_key" not in result + assert result["previous_response_id"] == "caresp_123" -def test_raw_foundry_agent_chat_client_parse_response_suppresses_conversation_id_for_agent_sessions() -> None: - """Test that agent-session continuations do not overwrite session.service_session_id.""" +def test_raw_foundry_agent_chat_client_parse_response_preserves_conversation_and_agent_session_ids() -> None: + """Test that response continuation and hosted-agent session IDs remain separate.""" mock_project = MagicMock() mock_project.get_openai_client.return_value = MagicMock() @@ -791,21 +825,23 @@ def test_raw_foundry_agent_chat_client_parse_response_suppresses_conversation_id agent_name="test-agent", ) - parsed = ChatResponse(conversation_id="resp_123") + parsed = ChatResponse(conversation_id="caresp_123") + response = SimpleNamespace(agent_session_id="agent-session-123") with patch( "agent_framework_openai._chat_client.RawOpenAIChatClient._parse_response_from_openai", return_value=parsed, ): result = client._parse_response_from_openai( - response=MagicMock(), - options={"conversation_id": "agent-session-123"}, + response=response, + options={}, ) - assert result.conversation_id is None + assert result.conversation_id == "caresp_123" + assert result.additional_properties["agent_session_id"] == "agent-session-123" -def test_raw_foundry_agent_chat_client_parse_chunk_suppresses_conversation_id_for_agent_sessions() -> None: - """Test that agent-session stream updates do not overwrite session.service_session_id.""" +def test_raw_foundry_agent_chat_client_parse_chunk_preserves_conversation_and_agent_session_ids() -> None: + """Test that streaming continuation and hosted-agent session IDs remain separate.""" mock_project = MagicMock() mock_project.get_openai_client.return_value = MagicMock() @@ -815,18 +851,23 @@ def test_raw_foundry_agent_chat_client_parse_chunk_suppresses_conversation_id_fo agent_name="test-agent", ) - parsed = ChatResponseUpdate(conversation_id="resp_123") + parsed = ChatResponseUpdate(conversation_id="caresp_123") with patch( "agent_framework_openai._chat_client.RawOpenAIChatClient._parse_chunk_from_openai", return_value=parsed, ): result = client._parse_chunk_from_openai( - event=MagicMock(type="response.output_text.delta"), - options={"conversation_id": "agent-session-123"}, + event=SimpleNamespace( + type="response.created", + response=SimpleNamespace(agent_session_id="agent-session-123"), + ), + options={}, function_call_ids={}, ) - assert result.conversation_id is None + assert result.conversation_id == "caresp_123" + assert result.additional_properties + assert result.additional_properties["agent_session_id"] == "agent-session-123" def test_raw_foundry_agent_chat_client_check_model_presence_is_noop() -> None: @@ -1051,25 +1092,18 @@ def my_func() -> str: assert agent.default_options.get("tools") is not None -async def test_raw_foundry_agent_prepare_run_context_creates_service_session_from_isolation_key() -> None: - """Test that RawFoundryAgent lazily creates a hosted session and stores it on service_session_id.""" +async def test_raw_foundry_agent_prepare_run_context_injects_agent_session_id_from_state() -> None: + """Test that hosted-agent session state is sent separately from response continuation.""" mock_project = MagicMock() mock_project.get_openai_client.return_value = MagicMock() - mock_project.agents = SimpleNamespace() - mock_project.beta = SimpleNamespace( - agents=SimpleNamespace( - create_session=AsyncMock(return_value=SimpleNamespace(agent_session_id="agent-session-123")) - ) - ) - agent = RawFoundryAgent( project_client=mock_project, agent_name="test-agent", - agent_version="1.0", - allow_preview=True, + default_options={"extra_body": {"default": "value"}}, ) - session = AgentSession() + session = AgentSession(service_session_id="caresp_123") + session.state[FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY] = "agent-session-123" with patch( "agent_framework._agents.RawAgent._prepare_run_context", @@ -1079,7 +1113,7 @@ async def test_raw_foundry_agent_prepare_run_context_creates_service_session_fro messages="hi", session=session, tools=None, - options={"isolation_key": "iso-key"}, + options={"extra_body": {"runtime": "value"}}, compaction_strategy=None, tokenizer=None, function_invocation_kwargs=None, @@ -1087,63 +1121,76 @@ async def test_raw_foundry_agent_prepare_run_context_creates_service_session_fro ) assert result == {"ok": True} - assert session.service_session_id == "agent-session-123" - mock_project.beta.agents.create_session.assert_awaited_once() - create_session_kwargs = mock_project.beta.agents.create_session.await_args.kwargs - assert create_session_kwargs["agent_name"] == "test-agent" - assert create_session_kwargs["isolation_key"] == "iso-key" - assert "version_indicator" in create_session_kwargs - mock_prepare_run_context.assert_awaited_once() + assert session.service_session_id == "caresp_123" + assert mock_prepare_run_context.await_args + assert mock_prepare_run_context.await_args.kwargs["options"]["extra_body"] == { + "default": "value", + "runtime": "value", + "agent_session_id": "agent-session-123", + } -async def test_raw_foundry_agent_create_service_session_uses_stable_agents_operations() -> None: - """Test that hosted sessions use the stable agents operations when available.""" +def test_foundry_agent_updates_session_from_response_ids() -> None: + """Test that response and hosted-agent session IDs persist independently.""" - create_session = AsyncMock(return_value=SimpleNamespace(agent_session_id="agent-session-123")) mock_project = MagicMock() mock_project.get_openai_client.return_value = MagicMock() - mock_project.agents = SimpleNamespace(create_session=create_session) + agent = RawFoundryAgent( + project_client=mock_project, + agent_name="test-agent", + ) + session = AgentSession() + response = ChatResponse( + conversation_id="caresp_123", + additional_properties={"agent_session_id": "agent-session-123"}, + ) + agent._update_session_from_chat_response(session, response) + + assert session.service_session_id == "caresp_123" + assert session.state[FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY] == "agent-session-123" + + +def test_foundry_agent_updates_session_from_streaming_agent_session_id() -> None: + """Test that streaming chat metadata is translated to hosted-agent session state.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() agent = RawFoundryAgent( project_client=mock_project, agent_name="test-agent", - agent_version="1.0", - allow_preview=True, ) + session = AgentSession() + update = AgentResponseUpdate(additional_properties={"agent_session_id": "agent-session-123"}) - result = await agent._create_service_session_id(isolation_key="iso-key") + agent._update_session_from_chat_response_update(session, update) - assert result == "agent-session-123" - create_session.assert_awaited_once() - assert create_session.await_args is not None - create_session_kwargs = create_session.await_args.kwargs - assert create_session_kwargs["agent_name"] == "test-agent" - assert create_session_kwargs["isolation_key"] == "iso-key" - assert "version_indicator" in create_session_kwargs + assert session.state[FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY] == "agent-session-123" -async def test_raw_foundry_agent_prepare_run_context_requires_preview_for_hosted_sessions() -> None: - """Test that hosted-agent sessions require allow_preview=True.""" +def test_foundry_chat_client_updates_function_loop_continuation_state() -> None: + """Test that a service-created agent session is reused within a function loop.""" mock_project = MagicMock() mock_project.get_openai_client.return_value = MagicMock() - agent = RawFoundryAgent( + client = _FoundryAgentChatClient( project_client=mock_project, agent_name="test-agent", ) + session = AgentSession() + response = ChatResponse( + conversation_id="caresp_123", + additional_properties={"agent_session_id": "agent-session-123"}, + ) + options: dict[str, Any] = {} - with pytest.raises(RuntimeError, match="allow_preview=True"): - await agent._prepare_run_context( - messages="hi", - session=AgentSession(), - tools=None, - options={"isolation_key": "iso-key"}, - compaction_strategy=None, - tokenizer=None, - function_invocation_kwargs=None, - client_kwargs=None, - ) + client._update_function_invocation_continuation_state({}, response, session=session, options=options) + + assert session.service_session_id == "caresp_123" + assert session.state[FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY] == "agent-session-123" + assert options["conversation_id"] == "caresp_123" + assert options["extra_body"]["agent_session_id"] == "agent-session-123" async def test_foundry_agent_create_conversation_returns_agent_session() -> None: diff --git a/python/samples/04-hosting/foundry-hosted-agents/README.md b/python/samples/04-hosting/foundry-hosted-agents/README.md index e766966bce..4c7f1f34d6 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/README.md @@ -22,7 +22,33 @@ This directory contains samples that demonstrate how to use hosted [Agent Framew | 9 | [Foundry Memory](responses/foundry_memory/) | An agent with persistent semantic memory backed by a Microsoft Foundry Memory Store, using `FoundryMemoryProvider` to remember user facts across sessions. | | 10 | [Monty CodeAct](responses/monty_codeact/) | An agent with a Monty-backed CodeAct context provider, exposing a single `execute_code` tool that runs Python in a [pydantic-monty](https://github.com/pydantic/monty) interpreter and invokes typed host tools (`compute`, `fetch_data`) from inside the sandbox. Uses the beta `agent-framework-monty` package. | | 11 | [Foundry Toolbox MCP Skills](responses/foundry_toolbox_mcp_skills/) | An agent that discovers MCP-based skills attached to a Foundry Toolbox and serves them via `SkillsProvider(MCPSkillsSource(...))`, fetching `SKILL.md` bodies and supplementary resources on demand. | -| 12 | [Using deployed agent](responses/using_deployed_agent.py) | A sample demonstrating how to invoke an agent that has already been deployed to Foundry, showing how to interact with a hosted agent in code. | +| 12 | [Using deployed agent](responses/using_deployed_agent.py) | Invoke an agent already deployed to Foundry using either a service-created or user-created hosted session, then delete the session after use. | + +## Session Identifiers + +Foundry hosted agents use multiple session-related values for different purposes. They are stored together on an +Agent Framework `AgentSession`, but they are not interchangeable. + +| Value | Owner | Purpose | Lifecycle | +|-------|-------|---------|-----------| +| `AgentSession` | Agent Framework | A lightweight application-side container that keeps identifiers and mutable state together across agent runs. | Create one per logical application conversation and pass it to each `agent.run(...)` call. It can be serialized if the application needs to persist it. | +| `AgentSession.session_id` | Agent Framework/application | Identifies the local `AgentSession`, including lookup in an Agent Framework session store. It does not identify a Foundry resource. | Generated locally by default, or supplied by the application. Deleting a Foundry session does not delete this local identifier. | +| `AgentSession.service_session_id` | Responses API | Continues the model-side response or conversation chain. For Foundry agents, this may be a response ID sent as `previous_response_id` or a conversation ID sent as `conversation`. | Agent Framework updates and reuses it automatically. It is not the Foundry hosted-agent session ID and is not passed to `project_client.agents.delete_session(...)`. | +| Foundry `agent_session_id` | Foundry Agent Service | Identifies the hosted-agent [runtime session](https://learn.microsoft.com/en-us/azure/foundry/agents/concepts/hosted-agents#isolation-model) used by the deployed agent. Foundry can create it on the first request, or the application can create it explicitly with `project_client.agents.create_session(...)`. | Agent Framework stores it in `AgentSession.state[FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY]` and sends it as `extra_body["agent_session_id"]` on later requests. Delete it with `project_client.agents.delete_session(agent_name, agent_session_id)` when finished. | + +During a hosted-agent conversation, one `AgentSession` can therefore contain both remote values: + +```python +session.service_session_id +# Response or conversation continuation handle + +session.state[FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY] +# Foundry hosted-agent session ID +``` + +Keep the same `AgentSession` across turns so Agent Framework can forward both values correctly. When cleaning up, +read the Foundry `agent_session_id` from `session.state` and pass that value to the Foundry session deletion API. +See [Using deployed agent](responses/using_deployed_agent.py) for service-created and user-created lifecycle examples. ### Invocations API diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/using_deployed_agent.py b/python/samples/04-hosting/foundry-hosted-agents/responses/using_deployed_agent.py index 6f574b4125..796eb016bd 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/using_deployed_agent.py +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/using_deployed_agent.py @@ -4,10 +4,9 @@ import asyncio import os -from typing import cast from agent_framework import AgentSession -from agent_framework.foundry import FoundryAgent +from agent_framework.foundry import FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY, FoundryAgent from azure.ai.projects.aio import AIProjectClient from azure.ai.projects.models import VersionRefIndicator from azure.identity.aio import AzureCliCredential @@ -17,7 +16,7 @@ """ This sample demonstrates how to connect to the deployed basic Foundry agent with -`FoundryAgent`. +`FoundryAgent`. It shows both service-managed and user-managed hosted-agent sessions. The sample uses environment variables for configuration, which can be set in a .env file or in the environment directly: Environment variables: @@ -34,37 +33,69 @@ """ -async def create_hosted_agent_session( +async def run_conversation(agent: FoundryAgent, session: AgentSession) -> None: + """Run a multi-turn conversation using the supplied session.""" + queries = [ + "Hi!", + "Your name is Javis. What can you do?", + "What is your name?", + ] + for query in queries: + print(f"\nUser: {query}") + print("Agent: ", end="", flush=True) + async for chunk in agent.run(query, session=session, stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) + print() + + +async def run_service_managed_session( + *, + agent: FoundryAgent, + project_client: AIProjectClient, + agent_name: str, +) -> None: + """Let Foundry create the hosted-agent session, then delete it when finished.""" + session = AgentSession() + print("\nService-managed hosted-agent session") + print(f"Before first request: {session.state.get(FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY)}") + try: + await run_conversation(agent, session) + print(f"After conversation: {session.state.get(FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY)}") + finally: + hosted_session_id = session.state.get(FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY) + if isinstance(hosted_session_id, str) and hosted_session_id: + await project_client.agents.delete_session(agent_name, hosted_session_id) + print(f"Deleted session: {hosted_session_id}") + + +async def run_user_managed_session( *, agent: FoundryAgent, project_client: AIProjectClient, agent_name: str, agent_version: str | None, -) -> AgentSession: - """Create a hosted-agent service session and wrap it in an AgentSession.""" +) -> None: + """Create, attach, and delete a hosted-agent session explicitly.""" resolved_agent_version = agent_version if resolved_agent_version is None: agent_details = await project_client.agents.get(agent_name) resolved_agent_version = agent_details.versions.latest.version - service_session = await project_client.agents.create_session( + hosted_session = await project_client.agents.create_session( agent_name, version_indicator=VersionRefIndicator(agent_version=resolved_agent_version), ) - return agent.get_session(service_session.agent_session_id) - + session = AgentSession() + session.state[FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY] = hosted_session.agent_session_id -async def delete_hosted_agent_session( - *, - project_client: AIProjectClient, - agent_name: str, - session: AgentSession, -) -> None: - """Delete a hosted-agent service session.""" - await project_client.agents.delete_session( - agent_name, - cast(str, session.service_session_id), - ) + print("\nUser-managed hosted-agent session") + print(f"Created session: {hosted_session.agent_session_id}") + try: + await run_conversation(agent, session) + finally: + await project_client.agents.delete_session(agent_name, hosted_session.agent_session_id) + print(f"Deleted session: {hosted_session.agent_session_id}") async def main() -> None: @@ -87,43 +118,22 @@ async def main() -> None: allow_preview=True, ) as agent, ): - session = await create_hosted_agent_session( + # Path 1: Let the service create the hosted-agent session on the first request, + # then delete it when the conversation ends. + await run_service_managed_session( agent=agent, project_client=project_client, agent_name=agent_name, - agent_version=agent_version, ) - try: - # 1. Send the first turn. - query = "Hi!" - print(f"User: {query}") - print("Agent: ", end="", flush=True) - async for chunk in agent.run(query, session=session, stream=True): - if chunk.text: - print(chunk.text, end="", flush=True) - - # 2. Continue the conversation with the same deployed agent session. - query = "Your name is Javis. What can you do?" - print(f"\nUser: {query}") - print("Agent: ", end="", flush=True) - async for chunk in agent.run(query, session=session, stream=True): - if chunk.text: - print(chunk.text, end="", flush=True) - - # 3. Ask a follow-up question in the same session. - query = "What is your name?" - print(f"\nUser: {query}") - print("Agent: ", end="", flush=True) - async for chunk in agent.run(query, session=session, stream=True): - if chunk.text: - print(chunk.text, end="", flush=True) - finally: - await delete_hosted_agent_session( - project_client=project_client, - agent_name=agent_name, - session=session, - ) + # Path 2: Create the hosted-agent session explicitly, attach its ID to AgentSession + # state, and delete the hosted-agent session when the conversation ends. + await run_user_managed_session( + agent=agent, + project_client=project_client, + agent_name=agent_name, + agent_version=agent_version, + ) if __name__ == "__main__": @@ -131,10 +141,24 @@ async def main() -> None: """ Sample output: +Service-managed hosted-agent session +Before first request: None +User: Hi! +Agent: Hello! How can I help you today? +User: Your name is Javis. What can you do? +Agent: I can answer questions and help with tasks using the instructions configured on the deployed agent. +User: What is your name? +Agent: My name is Javis. +After conversation: +Deleted session: + +User-managed hosted-agent session +Created session: User: Hi! Agent: Hello! How can I help you today? User: Your name is Javis. What can you do? Agent: I can answer questions and help with tasks using the instructions configured on the deployed agent. User: What is your name? Agent: My name is Javis. +Deleted session: """