From ed3b2cb9f046ab5116690e8258a95635f3e8b0cf Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Thu, 16 Jul 2026 10:39:24 -0700 Subject: [PATCH 1/2] Ensure session isolation for FHA invocation impl --- .../_invocations.py | 55 +++- .../foundry_hosting/tests/test_invocations.py | 276 ++++++++++++++++++ .../tests/test_invocations_int.py | 242 +++++++++++++++ .../invocations/01_basic/README.md | 6 + 4 files changed, 572 insertions(+), 7 deletions(-) create mode 100644 python/packages/foundry_hosting/tests/test_invocations.py create mode 100644 python/packages/foundry_hosting/tests/test_invocations_int.py diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py index df3c09c6234..4deda52b24a 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py @@ -1,9 +1,10 @@ # Copyright (c) Microsoft. All rights reserved. from agent_framework import AgentSession, BaseAgent, SupportsAgentRun +from azure.ai.agentserver.core import get_request_context from azure.ai.agentserver.invocations import InvocationAgentServerHost from starlette.requests import Request -from starlette.responses import JSONResponse, Response, StreamingResponse +from starlette.responses import Response, StreamingResponse from typing_extensions import Any, AsyncGenerator @@ -37,10 +38,53 @@ def __init__( self._sessions: dict[str, AgentSession] = {} self.invoke_handler(self._handle_invoke) + def _partition_key(self) -> str: + """Get the partition key for the current request. + + A partition key is made up of the session ID and user ID. If the request is not + from a hosted environment, the partition key will be just the session ID. In the + Foundry hosted environment, the partition key is used to maintain isolation between + different sessions and users, such that one user cannot access another user's sessions. + + Returns: + The partition key for the current request. + + Exceptions: + RuntimeError: If the context doesn't contain the expected IDs. + """ + context = get_request_context() + + # Fail fast if the service is on protocol v1.0.0 + if self.config.is_hosted and context.call_id is None: + raise RuntimeError( + "The hosted environment is running on protocol 1.0.0, but the agent requires protocol 2.0.0. " + "Please upgrade your agent protocol to 2.0.0 in `agent.manifest.yaml` or `agent.yaml`, or " + "downgrade the `agent-framework-foundry-hosting` package to `1.0.0a260625` or before to use 1.0.0." + ) + + if self.config.is_hosted: + if not context.session_id or not context.user_id: + raise RuntimeError( + "The hosted environment is missing session_id or user_id in the request context. " + "Please ensure that the request is coming from a valid Foundry platform service." + ) + return f"{context.session_id}:{context.user_id}" + + if not context.session_id: + raise RuntimeError( + "The request context is missing session_id. Please ensure that the request is a valid request." + ) + + return context.session_id + async def _handle_invoke(self, request: Request) -> Response: """Invoke the agent with the given request.""" + try: + session_id = self._partition_key() + except Exception as e: + return Response(content=str(e), status_code=500) + data = await request.json() - session_id: str = request.state.session_id stream = data.get("stream", False) user_message = data.get("message", None) @@ -65,8 +109,5 @@ async def stream_response() -> AsyncGenerator[str]: headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, ) - response = await self._agent.run([user_message], session=session, stream=stream) - return JSONResponse({ - "response": response.text, - "session_id": session_id, - }) + response = await self._agent.run([user_message], session=session) + return Response(content=response.text) diff --git a/python/packages/foundry_hosting/tests/test_invocations.py b/python/packages/foundry_hosting/tests/test_invocations.py new file mode 100644 index 00000000000..bbd9c62f09a --- /dev/null +++ b/python/packages/foundry_hosting/tests/test_invocations.py @@ -0,0 +1,276 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Unit tests for InvocationsHostServer. + +These tests exercise ``InvocationsHostServer`` directly by constructing the +host, driving ``_partition_key`` and ``_handle_invoke`` with a fake agent and +mock requests. The Foundry request context is injected via the public +``set_request_context`` / ``reset_request_context`` helpers rather than by +patching, matching the style used in ``test_toolbox.py``. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Iterator +from contextlib import contextmanager +from unittest.mock import AsyncMock, MagicMock + +import pytest +from agent_framework import ( + AgentResponse, + AgentResponseUpdate, + AgentSession, + Content, + Message, + ServiceSessionId, +) +from azure.ai.agentserver.core import ( + FoundryAgentRequestContext, + reset_request_context, + set_request_context, +) +from starlette.requests import Request +from starlette.responses import Response, StreamingResponse +from typing_extensions import Any + +from agent_framework_foundry_hosting import InvocationsHostServer + +# region Helpers + + +class _FakeAgent: + """Minimal agent implementing the ``SupportsAgentRun`` protocol. + + ``run`` returns an awaitable when ``stream`` is ``False`` and an async + iterator when ``stream`` is ``True``. Call arguments are recorded on + ``calls`` for assertions. + """ + + def __init__( + self, + *, + response: AgentResponse | None = None, + stream_updates: list[AgentResponseUpdate] | None = None, + ) -> None: + self.id = "fake-agent" + self.name = "Fake Agent" + self.description = "A fake agent for testing" + self._response = response + self._stream_updates = stream_updates or [] + self.calls: list[dict[str, Any]] = [] + + def run( + self, + messages: Any = None, + *, + stream: bool = False, + session: AgentSession | None = None, + **kwargs: Any, + ) -> Any: + self.calls.append({"messages": messages, "stream": stream, "session": session}) + if stream: + + async def _gen() -> AsyncIterator[AgentResponseUpdate]: + for update in self._stream_updates: + yield update + + return _gen() + + async def _run() -> AgentResponse: + assert self._response is not None + return self._response + + return _run() + + def create_session(self, *, session_id: str | None = None) -> AgentSession: + return AgentSession(session_id=session_id) + + def get_session( + self, + service_session_id: str | ServiceSessionId, + *, + session_id: str | None = None, + ) -> AgentSession: + return AgentSession(service_session_id=service_session_id, session_id=session_id) + + +def _make_agent( + *, + response_text: str | None = None, + stream_texts: list[str] | None = None, +) -> _FakeAgent: + """Build a ``_FakeAgent`` from plain text for non-streaming/streaming runs.""" + response = None + if response_text is not None: + response = AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text(response_text)])]) + stream_updates = None + if stream_texts is not None: + stream_updates = [AgentResponseUpdate(contents=[Content.from_text(t)]) for t in stream_texts] + return _FakeAgent(response=response, stream_updates=stream_updates) + + +def _make_request(payload: dict[str, Any]) -> Request: + """Build a mock Starlette request whose ``json()`` returns ``payload``.""" + request = MagicMock(spec=Request) + request.json = AsyncMock(return_value=payload) + return request + + +@contextmanager +def _request_context( + *, + call_id: str | None = None, + user_id: str | None = None, + session_id: str | None = None, +) -> Iterator[None]: + """Install a Foundry request context for the duration of the block.""" + token = set_request_context(FoundryAgentRequestContext(call_id=call_id, user_id=user_id, session_id=session_id)) + try: + yield + finally: + reset_request_context(token) + + +async def _collect_stream(response: StreamingResponse) -> str: + """Concatenate the string chunks produced by a StreamingResponse.""" + chunks: list[str] = [] + async for chunk in response.body_iterator: + chunks.append(chunk if isinstance(chunk, str) else chunk.decode()) + return "".join(chunks) + + +# endregion + + +# region Initialization + + +class TestInit: + def test_accepts_supports_agent_run(self) -> None: + server = InvocationsHostServer(_make_agent(response_text="hi")) + assert server._agent is not None # pyright: ignore[reportPrivateUsage] + assert server._sessions == {} # pyright: ignore[reportPrivateUsage] + + def test_rejects_non_supports_agent_run(self) -> None: + with pytest.raises(TypeError, match="SupportsAgentRun"): + InvocationsHostServer(object()) # type: ignore[arg-type] + + +# endregion + + +# region Partition key + + +class TestPartitionKey: + def test_local_returns_session_id(self) -> None: + server = InvocationsHostServer(_make_agent(response_text="hi")) + with _request_context(session_id="sess-1"): + assert server._partition_key() == "sess-1" # pyright: ignore[reportPrivateUsage] + + def test_local_missing_session_id_raises(self) -> None: + server = InvocationsHostServer(_make_agent(response_text="hi")) + with _request_context(), pytest.raises(RuntimeError, match="missing session_id"): + server._partition_key() # pyright: ignore[reportPrivateUsage] + + def test_hosted_without_call_id_raises_protocol_error(self) -> None: + server = InvocationsHostServer(_make_agent(response_text="hi")) + server.config.is_hosted = True + with ( + _request_context(session_id="sess-1", user_id="user-1"), + pytest.raises(RuntimeError, match="protocol 2.0.0"), + ): + server._partition_key() # pyright: ignore[reportPrivateUsage] + + def test_hosted_missing_user_id_raises(self) -> None: + server = InvocationsHostServer(_make_agent(response_text="hi")) + server.config.is_hosted = True + with ( + _request_context(call_id="call-1", session_id="sess-1"), + pytest.raises(RuntimeError, match="missing session_id or user_id"), + ): + server._partition_key() # pyright: ignore[reportPrivateUsage] + + def test_hosted_returns_composite_key(self) -> None: + server = InvocationsHostServer(_make_agent(response_text="hi")) + server.config.is_hosted = True + with _request_context(call_id="call-1", session_id="sess-1", user_id="user-1"): + assert server._partition_key() == "sess-1:user-1" # pyright: ignore[reportPrivateUsage] + + +# endregion + + +# region Handle invoke + + +class TestHandleInvoke: + async def test_missing_message_returns_400(self) -> None: + server = InvocationsHostServer(_make_agent(response_text="hi")) + request = _make_request({"stream": False}) + with _request_context(session_id="sess-1"): + response = await server._handle_invoke(request) # pyright: ignore[reportPrivateUsage] + assert isinstance(response, Response) + assert response.status_code == 400 + + async def test_missing_message_streaming_returns_400(self) -> None: + server = InvocationsHostServer(_make_agent(stream_texts=["a"])) + request = _make_request({"stream": True}) + with _request_context(session_id="sess-1"): + response = await server._handle_invoke(request) # pyright: ignore[reportPrivateUsage] + assert isinstance(response, StreamingResponse) + assert response.status_code == 400 + + async def test_partition_key_failure_returns_500(self) -> None: + server = InvocationsHostServer(_make_agent(response_text="hi")) + request = _make_request({"message": "Hi"}) + # No session_id in the (local) context -> _partition_key raises -> 500. + with _request_context(): + response = await server._handle_invoke(request) # pyright: ignore[reportPrivateUsage] + assert isinstance(response, Response) + assert response.status_code == 500 + + async def test_non_streaming_returns_agent_text(self) -> None: + agent = _make_agent(response_text="Hello!") + server = InvocationsHostServer(agent) + request = _make_request({"message": "Hi", "stream": False}) + with _request_context(session_id="sess-1"): + response = await server._handle_invoke(request) # pyright: ignore[reportPrivateUsage] + + assert isinstance(response, Response) + assert response.status_code == 200 + assert response.body.decode() == "Hello!" + # Agent is called with the message wrapped in a list and the cached session. + assert agent.calls[0]["messages"] == ["Hi"] + assert agent.calls[0]["stream"] is False + assert agent.calls[0]["session"] is server._sessions["sess-1"] # pyright: ignore[reportPrivateUsage] + + async def test_streaming_yields_update_text(self) -> None: + agent = _make_agent(stream_texts=["Hel", "lo", "!"]) + server = InvocationsHostServer(agent) + request = _make_request({"message": "Hi", "stream": True}) + with _request_context(session_id="sess-1"): + response = await server._handle_invoke(request) # pyright: ignore[reportPrivateUsage] + + assert isinstance(response, StreamingResponse) + assert response.media_type == "text/event-stream" + assert await _collect_stream(response) == "Hello!" + assert agent.calls[0]["messages"] == "Hi" + assert agent.calls[0]["stream"] is True + + async def test_session_is_reused_across_requests(self) -> None: + agent = _make_agent(response_text="ok") + server = InvocationsHostServer(agent) + + with _request_context(session_id="sess-1"): + await server._handle_invoke(_make_request({"message": "one"})) # pyright: ignore[reportPrivateUsage] + first_session = server._sessions["sess-1"] # pyright: ignore[reportPrivateUsage] + await server._handle_invoke(_make_request({"message": "two"})) # pyright: ignore[reportPrivateUsage] + second_session = server._sessions["sess-1"] # pyright: ignore[reportPrivateUsage] + + assert first_session is second_session + assert list(server._sessions) == ["sess-1"] # pyright: ignore[reportPrivateUsage] + assert agent.calls[0]["session"] is agent.calls[1]["session"] + + +# endregion diff --git a/python/packages/foundry_hosting/tests/test_invocations_int.py b/python/packages/foundry_hosting/tests/test_invocations_int.py new file mode 100644 index 00000000000..136760da74f --- /dev/null +++ b/python/packages/foundry_hosting/tests/test_invocations_int.py @@ -0,0 +1,242 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Integration tests for InvocationsHostServer with a real Foundry endpoint. + +These tests exercise the full HTTP pipeline using httpx.AsyncClient with +ASGITransport — no real server process is started. The agent talks to a real +Foundry project endpoint so every test requires valid credentials. + +The invocations protocol is intentionally simple: a request is a JSON body with +a ``message`` field (and an optional ``stream`` flag). Non-streaming responses +return the agent's answer as plain text; streaming responses return the answer +as a ``text/event-stream`` of text chunks. Session continuity is keyed off the +``agent_session_id`` query parameter. + +Required environment variables: + FOUNDRY_PROJECT_ENDPOINT - The Azure AI Foundry project endpoint URL. + FOUNDRY_MODEL - The model deployment name (e.g. gpt-4o). +""" + +from __future__ import annotations + +import os +from typing import Annotated, Any + +import httpx +import pytest +from agent_framework import Agent, tool +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential + +from agent_framework_foundry_hosting import InvocationsHostServer + +# --------------------------------------------------------------------------- +# Skip / marker helpers +# --------------------------------------------------------------------------- + +skip_if_foundry_hosting_integration_tests_disabled = pytest.mark.skipif( + os.getenv("FOUNDRY_PROJECT_ENDPOINT", "") in ("", "https://test-project.services.ai.azure.com/") + or os.getenv("FOUNDRY_MODEL", "") == "", + reason="No real FOUNDRY_PROJECT_ENDPOINT or FOUNDRY_MODEL provided; skipping integration tests.", +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def server() -> InvocationsHostServer: + """Create an InvocationsHostServer backed by a real Foundry agent.""" + 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": False}, # pyrefly: ignore[bad-argument-type] + ) + + return InvocationsHostServer(agent) + + +@tool +async def get_weather(location: Annotated[str, "The city name"]) -> str: + """Get the current weather in a given location.""" + return f"The weather in {location} is 72°F and sunny." + + +@pytest.fixture +def server_with_tools() -> InvocationsHostServer: + """Create an InvocationsHostServer whose agent has a tool.""" + 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] + ) + + return InvocationsHostServer(agent) + + +# --------------------------------------------------------------------------- +# HTTP helpers +# --------------------------------------------------------------------------- + + +async def _post_invocation( + server: InvocationsHostServer, + *, + message: str, + stream: bool = False, + session_id: str | None = None, +) -> httpx.Response: + """Send a POST /invocations request with the given message. + + When ``session_id`` is provided it is forwarded as the ``agent_session_id`` + query parameter so the server reuses the same conversation session. + """ + payload: dict[str, Any] = {"message": message, "stream": stream} + params = {"agent_session_id": session_id} if session_id is not None else None + transport = httpx.ASGITransport(app=server) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + return await client.post("/invocations", json=payload, params=params, timeout=120) + + +# --------------------------------------------------------------------------- +# Tests — basic text input +# --------------------------------------------------------------------------- + + +class TestBasicText: + """Simple text-in / text-out round trips.""" + + @pytest.mark.flaky + @pytest.mark.integration + @skip_if_foundry_hosting_integration_tests_disabled + async def test_simple_text_non_streaming(self, server: InvocationsHostServer) -> None: + """Non-streaming: send a message and get the agent's text answer.""" + resp = await _post_invocation(server, message="Say hello in exactly three words.", stream=False) + + assert resp.status_code == 200 + assert len(resp.text) > 0 + + @pytest.mark.flaky + @pytest.mark.integration + @skip_if_foundry_hosting_integration_tests_disabled + async def test_simple_text_streaming(self, server: InvocationsHostServer) -> None: + """Streaming: send a message and receive text chunks as an event stream.""" + resp = await _post_invocation(server, message="Say hello in exactly three words.", stream=True) + + assert resp.status_code == 200 + assert "text/event-stream" in resp.headers["content-type"] + assert len(resp.text) > 0 + + @pytest.mark.flaky + @pytest.mark.integration + @skip_if_foundry_hosting_integration_tests_disabled + async def test_missing_message_returns_400(self, server: InvocationsHostServer) -> None: + """A request without a ``message`` field is rejected with a 400.""" + transport = httpx.ASGITransport(app=server) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.post("/invocations", json={"stream": False}, timeout=120) + + assert resp.status_code == 400 + + +# --------------------------------------------------------------------------- +# Tests — multi-turn conversations +# --------------------------------------------------------------------------- + + +class TestMultiTurn: + """Multi-round conversations using a shared agent_session_id.""" + + @pytest.mark.flaky + @pytest.mark.integration + @skip_if_foundry_hosting_integration_tests_disabled + async def test_two_turn_conversation(self, server: InvocationsHostServer) -> None: + """Turn 1 establishes context; turn 2 recalls it via the same session.""" + session_id = "int-test-session-two-turn" + + resp1 = await _post_invocation( + server, + message="My favorite color is blue. Remember that.", + stream=False, + session_id=session_id, + ) + assert resp1.status_code == 200 + + resp2 = await _post_invocation( + server, + message="What is my favorite color? Answer with a single word.", + stream=False, + session_id=session_id, + ) + assert resp2.status_code == 200 + assert "blue" in resp2.text.lower() + + @pytest.mark.flaky + @pytest.mark.integration + @skip_if_foundry_hosting_integration_tests_disabled + async def test_multi_turn_streaming(self, server: InvocationsHostServer) -> None: + """Multi-turn conversation with streaming on the second turn.""" + session_id = "int-test-session-stream" + + resp1 = await _post_invocation( + server, + message="My favorite number is 42.", + stream=False, + session_id=session_id, + ) + assert resp1.status_code == 200 + + resp2 = await _post_invocation( + server, + message="What is my favorite number?", + stream=True, + session_id=session_id, + ) + assert resp2.status_code == 200 + assert "text/event-stream" in resp2.headers["content-type"] + assert "42" in resp2.text + + +# --------------------------------------------------------------------------- +# Tests — tool calling +# --------------------------------------------------------------------------- + + +class TestToolCalling: + """Tests that verify function-tool round trips through the hosting layer.""" + + @pytest.mark.flaky + @pytest.mark.integration + @skip_if_foundry_hosting_integration_tests_disabled + async def test_tool_call_non_streaming(self, server_with_tools: InvocationsHostServer) -> None: + """Agent invokes a tool and returns a final answer (non-streaming).""" + resp = await _post_invocation( + server_with_tools, + message="What is the weather in Seattle?", + stream=False, + ) + + assert resp.status_code == 200 + assert "72" in resp.text + + @pytest.mark.flaky + @pytest.mark.integration + @skip_if_foundry_hosting_integration_tests_disabled + async def test_tool_call_streaming(self, server_with_tools: InvocationsHostServer) -> None: + """Agent invokes a tool and streams a final answer.""" + resp = await _post_invocation( + server_with_tools, + message="What is the weather in Seattle?", + stream=True, + ) + + assert resp.status_code == 200 + assert "text/event-stream" in resp.headers["content-type"] + assert "72" in resp.text diff --git a/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/README.md b/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/README.md index 307a10cfddc..85a0a2ecd50 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/README.md @@ -28,6 +28,12 @@ Send a POST request to the server with a JSON body containing a "message" field curl -X POST http://localhost:8088/invocations -i -H "Content-Type: application/json" -d '{"message": "Hi"}' ``` +Or with streaming: + +```bash +curl -X POST http://localhost:8088/invocations -i -H "Content-Type: application/json" -d '{"message": "Hi", "stream": true}' +``` + The server will respond with a JSON object containing the response text. The `-i` flag in the `curl` command includes the HTTP response headers in the output, which includes the session ID that can be used for multi-turn conversations. Here is an example of the response: ``` From 37615995067fd8b91b84955919380d1856f2a011 Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Thu, 16 Jul 2026 11:15:21 -0700 Subject: [PATCH 2/2] Fix type check errors --- .../agent_framework_foundry_hosting/_invocations.py | 7 ++----- .../foundry_hosting/tests/test_invocations.py | 12 ++++-------- .../04-hosting/foundry-hosted-agents/README.md | 2 +- .../invocations/01_basic/README.md | 2 +- .../invocations/02_break_glass/README.md | 2 +- .../invocations/02_break_glass/main.py | 4 ++-- 6 files changed, 11 insertions(+), 18 deletions(-) diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py index 4deda52b24a..f01c7a94684 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py @@ -1,6 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. -from agent_framework import AgentSession, BaseAgent, SupportsAgentRun +from agent_framework import AgentSession, SupportsAgentRun from azure.ai.agentserver.core import get_request_context from azure.ai.agentserver.invocations import InvocationAgentServerHost from starlette.requests import Request @@ -13,7 +13,7 @@ class InvocationsHostServer(InvocationAgentServerHost): def __init__( self, - agent: BaseAgent, + agent: SupportsAgentRun, *, openapi_spec: dict[str, Any] | None = None, **kwargs: Any, @@ -31,9 +31,6 @@ def __init__( """ super().__init__(openapi_spec=openapi_spec, **kwargs) - if not isinstance(agent, SupportsAgentRun): - raise TypeError("Agent must support the SupportsAgentRun interface") - self._agent = agent self._sessions: dict[str, AgentSession] = {} self.invoke_handler(self._handle_invoke) diff --git a/python/packages/foundry_hosting/tests/test_invocations.py b/python/packages/foundry_hosting/tests/test_invocations.py index bbd9c62f09a..05bcdc1d5ba 100644 --- a/python/packages/foundry_hosting/tests/test_invocations.py +++ b/python/packages/foundry_hosting/tests/test_invocations.py @@ -53,8 +53,8 @@ def __init__( stream_updates: list[AgentResponseUpdate] | None = None, ) -> None: self.id = "fake-agent" - self.name = "Fake Agent" - self.description = "A fake agent for testing" + self.name: str | None = "Fake Agent" + self.description: str | None = "A fake agent for testing" self._response = response self._stream_updates = stream_updates or [] self.calls: list[dict[str, Any]] = [] @@ -135,7 +135,7 @@ async def _collect_stream(response: StreamingResponse) -> str: """Concatenate the string chunks produced by a StreamingResponse.""" chunks: list[str] = [] async for chunk in response.body_iterator: - chunks.append(chunk if isinstance(chunk, str) else chunk.decode()) + chunks.append(chunk if isinstance(chunk, str) else bytes(chunk).decode()) return "".join(chunks) @@ -151,10 +151,6 @@ def test_accepts_supports_agent_run(self) -> None: assert server._agent is not None # pyright: ignore[reportPrivateUsage] assert server._sessions == {} # pyright: ignore[reportPrivateUsage] - def test_rejects_non_supports_agent_run(self) -> None: - with pytest.raises(TypeError, match="SupportsAgentRun"): - InvocationsHostServer(object()) # type: ignore[arg-type] - # endregion @@ -239,7 +235,7 @@ async def test_non_streaming_returns_agent_text(self) -> None: assert isinstance(response, Response) assert response.status_code == 200 - assert response.body.decode() == "Hello!" + assert bytes(response.body).decode() == "Hello!" # Agent is called with the message wrapped in a list and the cached session. assert agent.calls[0]["messages"] == ["Hi"] assert agent.calls[0]["stream"] is False diff --git a/python/samples/04-hosting/foundry-hosted-agents/README.md b/python/samples/04-hosting/foundry-hosted-agents/README.md index d85bf7feb2a..6f2b223d2d0 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/README.md @@ -29,7 +29,7 @@ This directory contains samples that demonstrate how to use hosted [Agent Framew | # | Sample | Description | |---|--------|-------------| -| 1 | [Basic](invocations/01_basic/) | A minimal agent demonstrating session state management via `agent_session_id` in URL params/response headers. | +| 1 | [Basic](invocations/01_basic/) | A minimal agent demonstrating basic request/response using the invocations protocol. | | 2 | [Break Glass](invocations/02_break_glass/) | An agent demonstrating a "break glass" scenario where customizations of the API behaviors are needed, allowing for more direct control over how requests and responses are handled by the hosting layer. | ## Running the Agent Host Locally diff --git a/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/README.md b/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/README.md index 85a0a2ecd50..395d6cefb44 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/README.md @@ -46,7 +46,7 @@ x-platform-server: azure-ai-agentserver-core/2.0.0a20260410006 (python/3.12) date: Fri, 17 Apr 2026 23:46:44 GMT server: hypercorn-h11 -{"response":"Hi! How can I help?"} +Hi! How can I help? ``` ### Multi-turn conversation diff --git a/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/README.md b/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/README.md index ef5257ef1fa..9e088bd6f74 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/README.md @@ -40,7 +40,7 @@ x-platform-server: azure-ai-agentserver-core/2.0.0a20260410006 (python/3.12) date: Fri, 17 Apr 2026 23:46:44 GMT server: hypercorn-h11 -{"response":"Hi! How can I help?"} +Hi! How can I help? ``` ### Multi-turn conversation diff --git a/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/main.py b/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/main.py index f143b13840f..45b884ec779 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/main.py +++ b/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/main.py @@ -9,7 +9,7 @@ from azure.identity import DefaultAzureCredential from dotenv import load_dotenv from starlette.requests import Request -from starlette.responses import JSONResponse, Response, StreamingResponse +from starlette.responses import Response, StreamingResponse # Load environment variables from .env file load_dotenv() @@ -67,7 +67,7 @@ async def stream_response() -> AsyncGenerator[str]: ) response = await agent.run([user_message], session=session, stream=stream) - return JSONResponse({"response": response.text}) + return Response(content=response.text) if __name__ == "__main__":