From b22325bcf8dbea53c03bfb01cb42a28853531622 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 18 Jun 2026 07:55:57 +0200 Subject: [PATCH 1/8] Add Python hosting core and Responses channel Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/PACKAGE_STATUS.md | 2 + python/packages/hosting-responses/LICENSE | 21 + python/packages/hosting-responses/README.md | 21 + .../__init__.py | 25 + .../_channel.py | 363 +++++ .../_parsing.py | 169 ++ .../packages/hosting-responses/pyproject.toml | 98 ++ .../hosting-responses/tests/__init__.py | 0 .../hosting-responses/tests/test_channel.py | 272 ++++ .../hosting-responses/tests/test_parsing.py | 144 ++ python/packages/hosting/LICENSE | 21 + python/packages/hosting/README.md | 102 ++ .../agent_framework_hosting/__init__.py | 66 + .../hosting/agent_framework_hosting/_host.py | 1373 +++++++++++++++++ .../agent_framework_hosting/_isolation.py | 76 + .../agent_framework_hosting/_persistence.py | 128 ++ .../agent_framework_hosting/_state_store.py | 146 ++ .../hosting/agent_framework_hosting/_types.py | 212 +++ python/packages/hosting/pyproject.toml | 110 ++ python/packages/hosting/tests/__init__.py | 0 .../hosting/tests/_workflow_fixtures.py | 43 + python/packages/hosting/tests/conftest.py | 25 + python/packages/hosting/tests/test_host.py | 1337 ++++++++++++++++ .../packages/hosting/tests/test_host_disk.py | 227 +++ .../packages/hosting/tests/test_isolation.py | 303 ++++ python/packages/hosting/tests/test_types.py | 50 + python/pyproject.toml | 4 + .../samples/04-hosting/af-hosting/README.md | 42 + .../af-hosting/local_responses/README.md | 47 + .../af-hosting/local_responses/app.py | 113 ++ .../af-hosting/local_responses/call_server.py | 47 + .../af-hosting/local_responses/pyproject.toml | 23 + .../local_responses_workflow/README.md | 82 + .../local_responses_workflow/app.py | 220 +++ .../local_responses_workflow/call_server.py | 54 + .../local_responses_workflow/call_server.rest | 48 + .../local_responses_workflow/pyproject.toml | 23 + .../storage/checkpoints/.gitkeep | 0 python/uv.lock | 62 + 39 files changed, 6099 insertions(+) create mode 100644 python/packages/hosting-responses/LICENSE create mode 100644 python/packages/hosting-responses/README.md create mode 100644 python/packages/hosting-responses/agent_framework_hosting_responses/__init__.py create mode 100644 python/packages/hosting-responses/agent_framework_hosting_responses/_channel.py create mode 100644 python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py create mode 100644 python/packages/hosting-responses/pyproject.toml create mode 100644 python/packages/hosting-responses/tests/__init__.py create mode 100644 python/packages/hosting-responses/tests/test_channel.py create mode 100644 python/packages/hosting-responses/tests/test_parsing.py create mode 100644 python/packages/hosting/LICENSE create mode 100644 python/packages/hosting/README.md create mode 100644 python/packages/hosting/agent_framework_hosting/__init__.py create mode 100644 python/packages/hosting/agent_framework_hosting/_host.py create mode 100644 python/packages/hosting/agent_framework_hosting/_isolation.py create mode 100644 python/packages/hosting/agent_framework_hosting/_persistence.py create mode 100644 python/packages/hosting/agent_framework_hosting/_state_store.py create mode 100644 python/packages/hosting/agent_framework_hosting/_types.py create mode 100644 python/packages/hosting/pyproject.toml create mode 100644 python/packages/hosting/tests/__init__.py create mode 100644 python/packages/hosting/tests/_workflow_fixtures.py create mode 100644 python/packages/hosting/tests/conftest.py create mode 100644 python/packages/hosting/tests/test_host.py create mode 100644 python/packages/hosting/tests/test_host_disk.py create mode 100644 python/packages/hosting/tests/test_isolation.py create mode 100644 python/packages/hosting/tests/test_types.py create mode 100644 python/samples/04-hosting/af-hosting/README.md create mode 100644 python/samples/04-hosting/af-hosting/local_responses/README.md create mode 100644 python/samples/04-hosting/af-hosting/local_responses/app.py create mode 100644 python/samples/04-hosting/af-hosting/local_responses/call_server.py create mode 100644 python/samples/04-hosting/af-hosting/local_responses/pyproject.toml create mode 100644 python/samples/04-hosting/af-hosting/local_responses_workflow/README.md create mode 100644 python/samples/04-hosting/af-hosting/local_responses_workflow/app.py create mode 100644 python/samples/04-hosting/af-hosting/local_responses_workflow/call_server.py create mode 100644 python/samples/04-hosting/af-hosting/local_responses_workflow/call_server.rest create mode 100644 python/samples/04-hosting/af-hosting/local_responses_workflow/pyproject.toml create mode 100644 python/samples/04-hosting/af-hosting/local_responses_workflow/storage/checkpoints/.gitkeep diff --git a/python/PACKAGE_STATUS.md b/python/PACKAGE_STATUS.md index ae8334d2cc1..8bd384a5136 100644 --- a/python/PACKAGE_STATUS.md +++ b/python/PACKAGE_STATUS.md @@ -34,6 +34,8 @@ Status is grouped into these buckets: | `agent-framework-foundry-local` | `python/packages/foundry_local` | `beta` | | `agent-framework-gemini` | `python/packages/gemini` | `alpha` | | `agent-framework-github-copilot` | `python/packages/github_copilot` | `rc` | +| `agent-framework-hosting` | `python/packages/hosting` | `alpha` | +| `agent-framework-hosting-responses` | `python/packages/hosting-responses` | `alpha` | | `agent-framework-hyperlight` | `python/packages/hyperlight` | `beta` | | `agent-framework-lab` | `python/packages/lab` | `beta` | | `agent-framework-mem0` | `python/packages/mem0` | `beta` | diff --git a/python/packages/hosting-responses/LICENSE b/python/packages/hosting-responses/LICENSE new file mode 100644 index 00000000000..9e841e7a26e --- /dev/null +++ b/python/packages/hosting-responses/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/python/packages/hosting-responses/README.md b/python/packages/hosting-responses/README.md new file mode 100644 index 00000000000..ae03d364af3 --- /dev/null +++ b/python/packages/hosting-responses/README.md @@ -0,0 +1,21 @@ +# agent-framework-hosting-responses + +OpenAI Responses-shaped channel for `agent-framework-hosting`. + +Exposes a single `POST /responses` endpoint that accepts the OpenAI +Responses API request body and returns either a Responses-shaped JSON +body or a Server-Sent-Events stream when `stream=True`. + +```python +from agent_framework.openai import OpenAIChatClient +from agent_framework_hosting import AgentFrameworkHost +from agent_framework_hosting_responses import ResponsesChannel + +agent = OpenAIChatClient().as_agent(name="Assistant") + +host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel()]) +host.serve(port=8000) +``` + +The base host plumbing lives in +[`agent-framework-hosting`](https://pypi.org/project/agent-framework-hosting/). diff --git a/python/packages/hosting-responses/agent_framework_hosting_responses/__init__.py b/python/packages/hosting-responses/agent_framework_hosting_responses/__init__.py new file mode 100644 index 00000000000..cd221b56824 --- /dev/null +++ b/python/packages/hosting-responses/agent_framework_hosting_responses/__init__.py @@ -0,0 +1,25 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""OpenAI Responses-shaped channel for ``agent-framework-hosting``.""" + +import importlib.metadata + +from ._channel import ResponsesChannel +from ._parsing import ( + messages_from_responses_input, + parse_responses_identity, + parse_responses_request, +) + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" + +__all__ = [ + "ResponsesChannel", + "__version__", + "messages_from_responses_input", + "parse_responses_identity", + "parse_responses_request", +] diff --git a/python/packages/hosting-responses/agent_framework_hosting_responses/_channel.py b/python/packages/hosting-responses/agent_framework_hosting_responses/_channel.py new file mode 100644 index 00000000000..20eabdfc105 --- /dev/null +++ b/python/packages/hosting-responses/agent_framework_hosting_responses/_channel.py @@ -0,0 +1,363 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""``ResponsesChannel`` — OpenAI Responses-shaped HTTP surface. + +Exposes a single ``POST /responses`` endpoint that accepts +``{"input": "...", "stream": false}`` (and the rest of the Responses API +request body) and returns either a Responses-shaped JSON body +(``stream=False``, default) or a Server-Sent-Events stream +(``stream=True``). + +Payload construction reuses the ``openai.types.responses`` Pydantic +models so the OpenAI Python SDK ``stream=True`` consumer parses every +required field without surprises. +""" + +from __future__ import annotations + +import time +import uuid +from collections.abc import AsyncIterator, Callable, Mapping +from typing import Any + +from agent_framework_hosting import ( + ChannelContext, + ChannelContribution, + ChannelRequest, + ChannelResponseHook, + ChannelRunHook, + ChannelSession, + ChannelStreamUpdateHook, + get_current_isolation_keys, + logger, +) +from openai.types.responses import ( + Response as OpenAIResponse, +) +from openai.types.responses import ( + ResponseCompletedEvent, + ResponseCreatedEvent, + ResponseError, + ResponseFailedEvent, + ResponseOutputMessage, + ResponseOutputText, + ResponseTextDeltaEvent, +) +from starlette.requests import Request +from starlette.responses import JSONResponse, Response, StreamingResponse +from starlette.routing import Route + +from ._parsing import ( + parse_responses_identity, + parse_responses_request, +) + + +class ResponsesChannel: + """Minimal OpenAI-Responses-shaped surface. + + Mounts ``POST /responses`` (default path ``/responses`` so the + full route is ``/responses/responses`` when the channel is prefixed, + or just ``/`` when ``path=""``). + """ + + name = "responses" + + def __init__( + self, + *, + path: str = "/responses", + run_hook: ChannelRunHook | None = None, + response_hook: ChannelResponseHook | None = None, + stream_update_hook: ChannelStreamUpdateHook | None = None, + response_id_factory: Callable[..., str] | None = None, + ) -> None: + """Create a Responses channel. + + Keyword Args: + path: Endpoint path on the host. Default ``"/responses"`` matches + the upstream OpenAI surface; use ``""`` to expose this channel + at the app root. + run_hook: Optional :data:`ChannelRunHook` the host invokes with + the parsed :class:`ChannelRequest` before the agent target + runs. May return a replacement request. + response_hook: Optional :data:`ChannelResponseHook` the host invokes + before the channel serializes an originating + :class:`HostedRunResult` into a Responses envelope. + stream_update_hook: Optional per-update hook + applied while streaming Server-Sent Events. Return a + replacement update, or ``None`` to drop the update. + response_id_factory: Optional callable that mints the + per-request response id. Default produces + ``resp_`` which matches the OpenAI Responses + wire shape. Override when the host backing storage + requires a different id format (e.g. Foundry storage, + whose partition keys are encoded in the id and which + rejects free-form ``resp_*`` ids with a server error). + The same id is used for the channel envelope and for + the host-side anchoring (``ChannelRequest.attributes``) + so storage and replay agree. + + Security note on partition co-location: when a caller + supplies ``previous_response_id`` we forward it to the + factory so id backends that embed partition keys can + co-locate the new record with the chain's existing + partition. The factory passes that hint through to the + storage layer; **partition ownership is enforced at + the storage layer**, not in the channel: the Foundry + storage provider, for example, validates the request + against the bound user/chat isolation keys and rejects + writes whose embedded partition does not match the + authenticated caller's isolation. Channel-level + forwarding is therefore a performance hint, not a + security boundary; the host's isolation middleware + must establish the caller's identity before this + route is entered. + """ + self.path = path + self._hook = run_hook + self.response_hook = response_hook + self._stream_update_hook = stream_update_hook + self._ctx: ChannelContext | None = None + self._response_id_factory: Callable[..., str] = ( + response_id_factory if response_id_factory is not None else (lambda *_a, **_kw: f"resp_{uuid.uuid4().hex}") + ) + + def contribute(self, context: ChannelContext) -> ChannelContribution: + """Capture the host-supplied context and register the endpoint route.""" + self._ctx = context + return ChannelContribution(routes=[Route("/", self._handle, methods=["POST"])]) + + async def _handle(self, request: Request) -> Response: + """Handle a single Responses API call. + + Parses the OpenAI Responses-shaped body into ``Message`` / + ``options`` / ``ChannelSession`` triples via :mod:`._parsing`, + applies the optional ``run_hook``, and either streams an SSE + response stream or returns a one-shot OpenAI ``Response`` envelope. + """ + if self._ctx is None: # pragma: no cover - guarded by Channel lifecycle + return JSONResponse({"error": "channel not initialized"}, status_code=500) + try: + body = await request.json() + except Exception: + return JSONResponse({"error": "invalid json"}, status_code=400) + + try: + messages, options, session = parse_responses_request(body) + except ValueError as exc: + return JSONResponse({"error": str(exc)}, status_code=422) + + # When no ``previous_response_id`` chain anchor is on the body, + # surface the isolation key the **host** lifted off the request + # (via ``_FoundryIsolationASGIMiddleware`` for the default + # Foundry-platform deployment, or whatever middleware the + # operator configured in front of the host) as the channel + # session id, so callers without an explicit anchor still get + # a stable per-conversation session id (used by non-Foundry + # history providers, routing/idempotency, etc.). + # + # Security note: we consume the host-bound contextvar set by the + # ASGI isolation middleware, NOT the raw header off the wire. + # That middleware is the operator's place to enforce auth and + # gate which callers get to set isolation. If you mount the host + # in front of a custom auth boundary, your middleware should + # validate the caller before stamping ``set_current_isolation_keys``; + # never trust raw wire headers to identify a session bucket. + # The chat-iso value is *not* a valid storage anchor: the + # Foundry history provider deliberately ignores it — multi-turn + # storage chaining goes through the ``previous_response_id`` / + # bound ``response_id`` pair on ``ChannelRequest.attributes``. + bound_keys = get_current_isolation_keys() + chat_iso = bound_keys.chat_key if bound_keys is not None else None + if session is None and chat_iso: + session = ChannelSession(isolation_key=chat_iso) + + # Mint the response id once per request so the channel envelope + # (one-shot or streamed) and any host-side anchoring (e.g. the + # Foundry history provider's ``bind_request_context``) agree on + # the same handle. The next turn arrives with this value as + # ``previous_response_id`` and the storage chain walks. We pass + # both anchors via ``ChannelRequest.attributes`` so the host + # can pick them up without a channel-specific contract. + previous_response_id: str | None = None + prev_raw = body.get("previous_response_id") + if isinstance(prev_raw, str) and prev_raw: + previous_response_id = prev_raw + # Pass the previous id (if any) as a hint to the factory so id + # backends that embed partition keys (e.g. Foundry storage) can + # co-locate the new record with the chain's existing partition. + # No-arg factories continue to work via ``Callable[..., str]``. + response_id = self._response_id_factory(previous_response_id) + + attributes: dict[str, Any] = {"response_id": response_id} + if previous_response_id is not None: + attributes["previous_response_id"] = previous_response_id + + # Honor the OpenAI-Responses ``stream`` flag — non-streaming by + # default, SSE when the caller opts in. The channel chooses the + # transport before run hooks execute. + channel_request = ChannelRequest( + channel=self.name, + operation="message.create", + input=messages, + session=session, + options=options or None, + stream=bool(body.get("stream", False)), + identity=parse_responses_identity(body, self.name), + attributes=attributes, + ) + + if channel_request.stream: + return StreamingResponse( + self._stream_events(channel_request, body, response_id=response_id), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + result = await self._ctx.run( + channel_request, + run_hook=self._hook, + protocol_request=body, + response_hook=self.response_hook, + channel_name=self.name, + ) + text = result.result.text + envelope = self._build_response(body, text, status="completed", response_id=response_id) + return JSONResponse(envelope.model_dump(mode="json", exclude_none=True)) + + def _build_response( + self, + body: Mapping[str, Any], + text: str, + *, + status: str, + response_id: str | None = None, + ) -> OpenAIResponse: + """Construct an OpenAI ``Response`` for a finished (non-streaming) run. + + ``status`` mirrors the top-level Response status set values + (``in_progress`` / ``completed`` / ``failed`` / ``incomplete`` / + ``cancelled``). The nested ``ResponseOutputMessage.status`` field + only accepts ``in_progress`` / ``completed`` / ``incomplete``, so + terminal-but-non-success states collapse to ``incomplete`` there + — the failure detail still travels via the top-level ``status`` + and (for streamed errors) the ``error`` field. + + ``response_id``: the per-request id minted in :meth:`_handle`. + Passed in so envelope and storage agree on a single handle per + turn (see :meth:`_handle` notes). Falls back to a fresh uuid + when callers (e.g. :meth:`_stream_events`'s skeleton path + before this argument was introduced) don't supply one. + """ + message_status = status if status in ("in_progress", "completed", "incomplete") else "incomplete" + return OpenAIResponse( + id=response_id or self._response_id_factory(None), + object="response", + created_at=time.time(), + status=status, # type: ignore[arg-type] + model=body.get("model", "agent"), + output=[ + ResponseOutputMessage( + id=f"msg_{uuid.uuid4().hex}", + type="message", + role="assistant", + status=message_status, # type: ignore[arg-type] + content=[ResponseOutputText(type="output_text", text=text, annotations=[])], + ) + ], + parallel_tool_calls=False, + tool_choice="auto", + tools=[], + metadata={}, + ) + + async def _stream_events( + self, + request: ChannelRequest, + body: Mapping[str, Any], + *, + response_id: str, + ) -> AsyncIterator[str]: + """Yield SSE events shaped like the OpenAI Responses streaming protocol. + + Emits ``response.created`` → many ``response.output_text.delta`` + → ``response.completed`` (or ``response.failed`` on error). + """ + if self._ctx is None: # pragma: no cover - guarded by Channel lifecycle + return + + msg_id = f"msg_{uuid.uuid4().hex}" + seq = 0 + + def next_seq() -> int: + nonlocal seq + seq += 1 + return seq + + def sse(event: Any) -> str: + return f"event: {event.type}\ndata: {event.model_dump_json(exclude_none=True)}\n\n" + + skeleton = self._build_response(body, "", status="in_progress", response_id=response_id) + yield sse(ResponseCreatedEvent(type="response.created", response=skeleton, sequence_number=next_seq())) + + accumulated = "" + try: + stream = await self._ctx.run_stream( + request, + run_hook=self._hook, + protocol_request=body, + stream_update_hook=self._stream_update_hook, + response_hook=self.response_hook, + channel_name=self.name, + ) + async for update in stream: + chunk = getattr(update, "text", None) + if chunk: + accumulated += chunk + yield sse( + ResponseTextDeltaEvent( + type="response.output_text.delta", + item_id=msg_id, + output_index=0, + content_index=0, + delta=chunk, + logprobs=[], + sequence_number=next_seq(), + ) + ) + try: + # Finalize so context-provider / history hooks on the agent + # still run even though we are emitting our own SSE. + final_response = await stream.get_final_response() + except Exception: # pragma: no cover - finalize is best-effort + logger.exception("Responses stream finalize failed") + final_response = None + except Exception as exc: + logger.exception("Responses stream consumption failed") + failed = self._build_response(body, accumulated, status="failed", response_id=response_id) + failed.error = ResponseError(code="server_error", message=str(exc)) + yield sse( + ResponseFailedEvent( + type="response.failed", + response=failed, + sequence_number=next_seq(), + ) + ) + return + + completed_text = getattr(final_response, "text", None) or accumulated + completed = self._build_response(body, completed_text, status="completed", response_id=response_id) + # Reuse the same message id we emitted deltas under. + if completed.output and isinstance(completed.output[0], ResponseOutputMessage): + completed.output[0].id = msg_id + yield sse( + ResponseCompletedEvent( + type="response.completed", + response=completed, + sequence_number=next_seq(), + ) + ) + + +__all__ = ["ResponsesChannel"] diff --git a/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py b/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py new file mode 100644 index 00000000000..51d4ea4ecbe --- /dev/null +++ b/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py @@ -0,0 +1,169 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Parsing helpers for the OpenAI Responses-API request body. + +The Responses API accepts ``input`` as either a string or a list of "input +items". An item is either a content part (``input_text`` / ``input_image`` +/ ``input_file``) or a message envelope ``{type: "message", role, +content: [...]}``. We translate that into an Agent Framework ``Message`` +list and split out the ChatOptions-shaped fields the API also carries. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, cast + +from agent_framework import Content, Message +from agent_framework_hosting import ChannelIdentity, ChannelSession + +# OpenAI Responses field name → Agent Framework ChatOptions field name. +_RESPONSES_OPTION_REMAP = { + "max_output_tokens": "max_tokens", + "parallel_tool_calls": "allow_multiple_tool_calls", +} +# Fields we forward to ChatOptions verbatim. ``instructions`` stays here +# because Agent Framework exposes it as a ChatOptions field; it must not be +# lifted into a synthetic system message. +_RESPONSES_OPTION_PASSTHROUGH = { + "instructions", + "temperature", + "top_p", + "metadata", + "user", + "safety_identifier", + "tool_choice", + "tools", + "store", + "response_format", + "stop", + "seed", + "frequency_penalty", + "presence_penalty", + "logit_bias", +} +# Fields the Responses transport owns; they must not be forwarded as options. +_RESPONSES_TRANSPORT_KEYS = {"input", "model", "stream", "previous_response_id"} + + +def parse_responses_identity(body: Mapping[str, Any], channel_name: str) -> ChannelIdentity | None: + """Surface the caller as a :class:`ChannelIdentity` so the host can record it. + + OpenAI Responses replaced ``user`` with ``safety_identifier`` — we use + that as the native id, falling back to the legacy ``user`` field. + """ + native = body.get("safety_identifier") or body.get("user") + if not isinstance(native, str) or not native: + return None + return ChannelIdentity(channel=channel_name, native_id=native) + + +def _content_from_input_item(item: Mapping[str, Any]) -> Content: + """Convert a single OpenAI Responses ``input`` item into a :class:`Content` part. + + Handles the ``input_text``/``output_text``/``text`` text variants, + ``input_image`` URL references, and ``input_file`` references via either + a public URL or a hosted ``file_id``. Raises ``ValueError`` for any + unsupported item type so the surrounding parser can return a 422. + """ + item_type = item.get("type") + if item_type in ("input_text", "output_text", "text"): + return Content.from_text(text=str(item.get("text", ""))) + if item_type == "input_image": + image_url: Any = item.get("image_url") + if isinstance(image_url, Mapping): + image_url = cast("Mapping[str, Any]", image_url).get("url") + if not isinstance(image_url, str): + raise ValueError("input_image requires `image_url`") + return Content.from_uri(uri=image_url, media_type="image/*") + if item_type == "input_file": + if (uri := item.get("file_url")) and isinstance(uri, str): + return Content.from_uri(uri=uri, media_type=item.get("mime_type")) + if file_id := item.get("file_id"): + return Content(type="hosted_file", file_id=str(file_id)) + raise ValueError("input_file requires `file_url` or `file_id`") + raise ValueError(f"Unsupported Responses input content type: {item_type!r}") + + +def messages_from_responses_input(value: Any) -> list[Message]: + """Translate ``input`` (string or list of items) into :class:`Message` objects.""" + if isinstance(value, str): + return [Message("user", [Content.from_text(text=value)])] + if not isinstance(value, list) or not value: + raise ValueError("`input` must be a non-empty string or list") + + messages: list[Message] = [] + pending_user_parts: list[Content] = [] + + def flush() -> None: + """Emit any buffered loose user content as a single user message.""" + if pending_user_parts: + messages.append(Message("user", list(pending_user_parts))) + pending_user_parts.clear() + + for item in cast("list[Any]", value): # type: ignore[redundant-cast] + if not isinstance(item, Mapping): + raise ValueError("each `input` item must be an object") + item_map = cast("Mapping[str, Any]", item) + if item_map.get("type") == "message": + flush() + role = str(item_map.get("role") or "user") + content: Any = item_map.get("content") or [] + parts: list[Content] + if isinstance(content, str): + parts = [Content.from_text(text=content)] + elif isinstance(content, list): + parts = [ + _content_from_input_item(cast("Mapping[str, Any]", c)) + for c in cast("list[Any]", content) # type: ignore[redundant-cast] + if isinstance(c, Mapping) + ] + else: + parts = [] + messages.append(Message(role, parts)) + else: + pending_user_parts.append(_content_from_input_item(item_map)) + + flush() + if not messages: + raise ValueError("`input` produced no messages") + return messages + + +def parse_responses_request( + body: Mapping[str, Any], +) -> tuple[list[Message], dict[str, Any], ChannelSession | None]: + """Translate a Responses-API request body into Agent Framework constructs. + + Returns a triple ``(messages, options, session)`` where: + + - ``messages`` is the parsed conversation. + - ``options`` is a ``ChatOptions``-shaped dict with the model-tunable + fields the channel lifted off the body. + - ``session`` is a :class:`ChannelSession` keyed by + ``previous_response_id`` when one was supplied, else ``None``. + """ + messages = messages_from_responses_input(body.get("input")) + + options: dict[str, Any] = {} + for key, value in body.items(): + if key in _RESPONSES_TRANSPORT_KEYS or value is None: + continue + if (mapped := _RESPONSES_OPTION_REMAP.get(key)) is not None: + options[mapped] = value + elif key in _RESPONSES_OPTION_PASSTHROUGH: + options[key] = value + # silently drop everything else (truncation, reasoning, include, ...) + + session: ChannelSession | None = None + if (prev := body.get("previous_response_id")) and isinstance(prev, str): + session = ChannelSession(isolation_key=prev) + + return messages, options, session + + +__all__ = [ + "messages_from_responses_input", + "parse_responses_identity", + "parse_responses_request", +] diff --git a/python/packages/hosting-responses/pyproject.toml b/python/packages/hosting-responses/pyproject.toml new file mode 100644 index 00000000000..6606c94455a --- /dev/null +++ b/python/packages/hosting-responses/pyproject.toml @@ -0,0 +1,98 @@ +[project] +name = "agent-framework-hosting-responses" +description = "OpenAI Responses-shaped channel for agent-framework-hosting." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] +readme = "README.md" +requires-python = ">=3.10" +version = "1.0.0a260424" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core>=1.2.0,<2", + "agent-framework-hosting==1.0.0a260424", + "openai>=1.99.0,<3", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" + +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [] +timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.coverage.run] +omit = [ + "**/__init__.py" +] + +[tool.pyright] +extends = "../../pyproject.toml" +include = ["agent_framework_hosting_responses"] +exclude = ['tests'] + +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true + +[tool.bandit] +targets = ["agent_framework_hosting_responses"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_hosting_responses" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_responses --cov-report=term-missing:skip-covered tests' + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" diff --git a/python/packages/hosting-responses/tests/__init__.py b/python/packages/hosting-responses/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/hosting-responses/tests/test_channel.py b/python/packages/hosting-responses/tests/test_channel.py new file mode 100644 index 00000000000..6bd224f1393 --- /dev/null +++ b/python/packages/hosting-responses/tests/test_channel.py @@ -0,0 +1,272 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""End-to-end tests for :class:`ResponsesChannel` via Starlette's ``TestClient``.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from dataclasses import dataclass +from typing import Any + +from agent_framework_hosting import ( + AgentFrameworkHost, + HostedRunResult, +) +from starlette.testclient import TestClient + +from agent_framework_hosting_responses import ResponsesChannel + +# --------------------------------------------------------------------------- # +# Fakes # +# --------------------------------------------------------------------------- # + + +@dataclass +class _FakeAgentResponse: + text: str + + +@dataclass +class _FakeUpdate: + text: str + + +class _FakeStream: + """Minimal stand-in for AF's ``ResponseStream`` returned by ``run(stream=True)``.""" + + def __init__(self, chunks: list[str]) -> None: + self._chunks = chunks + self._final = _FakeAgentResponse(text="".join(chunks)) + + def __aiter__(self) -> AsyncIterator[_FakeUpdate]: + async def _gen() -> AsyncIterator[_FakeUpdate]: + for c in self._chunks: + yield _FakeUpdate(c) + + return _gen() + + async def get_final_response(self) -> _FakeAgentResponse: + return self._final + + +class _FakeAgent: + def __init__(self, reply: str = "hello", chunks: list[str] | None = None) -> None: + self._reply = reply + self._chunks = chunks or [reply] + self.calls: list[dict[str, Any]] = [] + + def create_session(self, *, session_id: str | None = None) -> Any: + return {"session_id": session_id} + + def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any: + self.calls.append({"messages": messages, "stream": stream, "kwargs": kwargs}) + if stream: + return _FakeStream(self._chunks) + + async def _coro() -> _FakeAgentResponse: + return _FakeAgentResponse(text=self._reply) + + return _coro() + + +# --------------------------------------------------------------------------- # +# Tests # +# --------------------------------------------------------------------------- # + + +def _make_client( + agent: _FakeAgent | None = None, + *, + path: str = "/responses", +) -> tuple[TestClient, AgentFrameworkHost, _FakeAgent]: + agent = agent or _FakeAgent() + host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel(path=path)]) + return TestClient(host.app), host, agent + + +class TestResponsesChannelNonStreaming: + def test_post_responses_returns_completed_envelope(self) -> None: + client, _host, agent = _make_client(_FakeAgent(reply="hi back")) + with client: + r = client.post("/responses", json={"input": "hi"}) + assert r.status_code == 200 + body = r.json() + assert body["status"] == "completed" + assert body["object"] == "response" + assert body["id"].startswith("resp_") + assert body["output"][0]["content"][0]["text"] == "hi back" + assert len(agent.calls) == 1 + + def test_empty_path_mounts_at_app_root(self) -> None: + client, _host, _agent = _make_client(_FakeAgent(reply="hi back"), path="") + with client: + r = client.post("/", json={"input": "hi"}) + assert r.status_code == 200 + assert r.json()["output"][0]["content"][0]["text"] == "hi back" + + def test_invalid_json_returns_400(self) -> None: + client, *_ = _make_client() + with client: + r = client.post("/responses", content=b"{not json", headers={"content-type": "application/json"}) + assert r.status_code == 400 + + def test_invalid_input_returns_422(self) -> None: + client, *_ = _make_client() + with client: + r = client.post("/responses", json={"input": 42}) + assert r.status_code == 422 + + def test_options_propagate_to_target_run(self) -> None: + client, _host, agent = _make_client() + with client: + r = client.post("/responses", json={"input": "x", "temperature": 0.5, "max_output_tokens": 64}) + assert r.status_code == 200 + opts = agent.calls[0]["kwargs"]["options"] + assert opts == {"temperature": 0.5, "max_tokens": 64} + + def test_previous_response_id_creates_session(self) -> None: + client, _host, agent = _make_client() + with client: + client.post("/responses", json={"input": "x", "previous_response_id": "resp_42"}) + # AgentFrameworkHost converts the channel session into an AgentSession. + sess = agent.calls[0]["kwargs"].get("session") + assert sess is not None + # _FakeAgent.create_session stashes the session_id on the dict it returns. + assert sess["session_id"] == "resp_42" + + def test_chat_isolation_header_ignored_outside_foundry(self) -> None: + client, _host, agent = _make_client() + with client: + client.post( + "/responses", + json={"input": "x"}, + headers={"x-agent-chat-isolation-key": "chat-abc"}, + ) + assert "session" not in agent.calls[0]["kwargs"] + + def test_chat_isolation_header_creates_session_in_foundry(self, monkeypatch: Any) -> None: + """Foundry-style ``x-agent-chat-isolation-key`` falls back to a session anchor. + + First-turn requests have no ``previous_response_id`` (the client + doesn't have one yet), but Foundry Hosted Agents always inject + the isolation headers. The channel must derive a session from the + chat key so the host can build a stable per-conversation session + that history providers persist under. + """ + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1") + client, _host, agent = _make_client() + with client: + client.post( + "/responses", + json={"input": "x"}, + headers={"x-agent-chat-isolation-key": "chat-abc"}, + ) + sess = agent.calls[0]["kwargs"].get("session") + assert sess is not None + assert sess["session_id"] == "chat-abc" + + def test_prev_response_id_wins_over_chat_isolation_header(self, monkeypatch: Any) -> None: + """When both anchors are present, ``previous_response_id`` wins. + + ``previous_response_id`` is the protocol-native chain anchor; the + header fallback is only meant to bootstrap when no protocol + anchor exists. + """ + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1") + client, _host, agent = _make_client() + with client: + client.post( + "/responses", + json={"input": "x", "previous_response_id": "resp_99"}, + headers={"x-agent-chat-isolation-key": "chat-abc"}, + ) + sess = agent.calls[0]["kwargs"].get("session") + assert sess is not None + assert sess["session_id"] == "resp_99" + + def test_response_hook_can_rewrite_originating_reply(self) -> None: + seen_kwargs: list[dict[str, Any]] = [] + + def hook(result: HostedRunResult, **kwargs: Any) -> HostedRunResult: + seen_kwargs.append(dict(kwargs)) + return HostedRunResult(_FakeAgentResponse(text=result.result.text.upper()), session=result.session) + + agent = _FakeAgent(reply="hooked") + host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel(response_hook=hook)]) + + with TestClient(host.app) as client: + r = client.post("/responses", json={"input": "hi"}) + + assert r.status_code == 200 + body = r.json() + assert body["output"][0]["content"][0]["text"] == "HOOKED" + assert seen_kwargs + assert seen_kwargs[0]["channel_name"] == "responses" + + +class TestResponsesChannelStreaming: + def test_sse_emits_created_delta_completed(self) -> None: + agent = _FakeAgent(reply="hello world", chunks=["hello", " ", "world"]) + host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel()]) + with TestClient(host.app) as client: + r = client.post("/responses", json={"input": "hi", "stream": True}) + assert r.status_code == 200 + body = r.text + + # SSE event lines look like "event: \ndata: \n\n". + events = [line[len("event: ") :] for line in body.splitlines() if line.startswith("event: ")] + assert events[0] == "response.created" + assert events[-1] == "response.completed" + assert events.count("response.output_text.delta") == 3 + + def test_sse_transform_hook_can_rewrite_chunks(self) -> None: + agent = _FakeAgent(reply="hello", chunks=["he", "llo"]) + + def transform(update: _FakeUpdate) -> _FakeUpdate: + return _FakeUpdate(text=update.text.upper()) + + host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel(stream_update_hook=transform)]) + with TestClient(host.app) as client: + r = client.post("/responses", json={"input": "hi", "stream": True}) + + assert r.status_code == 200 + assert '"delta":"HE"' in r.text + assert '"delta":"LLO"' in r.text + # Stream update hooks are update-only; they do not rewrite get_final_response(). + assert '"text":"hello"' in r.text + + def test_sse_emits_failed_when_stream_raises(self) -> None: + # Regression: ResponseOutputMessage.status only accepts in_progress/ + # completed/incomplete, so building an OpenAIResponse with status="failed" + # used to crash with a pydantic ValidationError. The channel must map the + # nested message status to "incomplete" while keeping the top-level + # Response.status="failed". + class _BoomStream: + def __aiter__(self) -> AsyncIterator[_FakeUpdate]: + async def _gen() -> AsyncIterator[_FakeUpdate]: + yield _FakeUpdate("partial") + raise RuntimeError("upstream blew up") + + return _gen() + + async def get_final_response(self) -> _FakeAgentResponse: # pragma: no cover + return _FakeAgentResponse(text="") + + class _BoomAgent(_FakeAgent): + def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any: + self.calls.append({"messages": messages, "stream": stream, "kwargs": kwargs}) + if stream: + return _BoomStream() + raise AssertionError("non-streaming path not exercised here") + + host = AgentFrameworkHost(target=_BoomAgent(), channels=[ResponsesChannel()]) + with TestClient(host.app) as client: + r = client.post("/responses", json={"input": "hi", "stream": True}) + assert r.status_code == 200 + body = r.text + + events = [line[len("event: ") :] for line in body.splitlines() if line.startswith("event: ")] + assert events[0] == "response.created" + assert events[-1] == "response.failed" + # The failed envelope must serialize cleanly — i.e. no ValidationError raised. + assert "upstream blew up" in body diff --git a/python/packages/hosting-responses/tests/test_parsing.py b/python/packages/hosting-responses/tests/test_parsing.py new file mode 100644 index 00000000000..e8d47f7f7f1 --- /dev/null +++ b/python/packages/hosting-responses/tests/test_parsing.py @@ -0,0 +1,144 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for the OpenAI Responses request-body parser.""" + +from __future__ import annotations + +import pytest + +from agent_framework_hosting_responses import ( + messages_from_responses_input, + parse_responses_identity, + parse_responses_request, +) + + +class TestMessagesFromResponsesInput: + def test_string_input_becomes_single_user_message(self) -> None: + msgs = messages_from_responses_input("hello") + assert len(msgs) == 1 + assert msgs[0].role == "user" + assert msgs[0].text == "hello" + + def test_input_text_items_collapse_into_one_user_message(self) -> None: + msgs = messages_from_responses_input([{"type": "input_text", "text": "a"}, {"type": "input_text", "text": "b"}]) + assert len(msgs) == 1 + assert msgs[0].role == "user" + assert msgs[0].text == "a b" + + def test_message_envelope_with_string_content(self) -> None: + msgs = messages_from_responses_input([ + {"type": "message", "role": "system", "content": "be brief"}, + {"type": "message", "role": "user", "content": "hi"}, + ]) + assert [m.role for m in msgs] == ["system", "user"] + assert msgs[0].text == "be brief" + + def test_message_envelope_with_content_parts(self) -> None: + msgs = messages_from_responses_input([ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "describe this"}], + } + ]) + assert msgs[0].text == "describe this" + + def test_pending_text_flushes_before_message_envelope(self) -> None: + msgs = messages_from_responses_input([ + {"type": "input_text", "text": "first"}, + {"type": "message", "role": "user", "content": "second"}, + ]) + assert len(msgs) == 2 + assert msgs[0].text == "first" + assert msgs[1].text == "second" + + def test_image_url_via_string(self) -> None: + msgs = messages_from_responses_input([{"type": "input_image", "image_url": "https://example.com/cat.png"}]) + assert len(msgs) == 1 + # Image content present. + assert any(getattr(c, "uri", None) == "https://example.com/cat.png" for c in msgs[0].contents) + + def test_image_url_via_object(self) -> None: + msgs = messages_from_responses_input([ + {"type": "input_image", "image_url": {"url": "https://example.com/cat.png"}} + ]) + assert any(getattr(c, "uri", None) == "https://example.com/cat.png" for c in msgs[0].contents) + + def test_unknown_input_type_raises(self) -> None: + with pytest.raises(ValueError, match="Unsupported"): + messages_from_responses_input([{"type": "weird"}]) + + def test_empty_list_raises(self) -> None: + with pytest.raises(ValueError, match="non-empty"): + messages_from_responses_input([]) + + def test_non_string_non_list_raises(self) -> None: + with pytest.raises(ValueError): + messages_from_responses_input(42) # type: ignore[arg-type] + + def test_image_url_missing_raises(self) -> None: + with pytest.raises(ValueError, match="image_url"): + messages_from_responses_input([{"type": "input_image"}]) + + +class TestParseResponsesRequest: + def test_instructions_are_forwarded_as_chat_options(self) -> None: + msgs, opts, sess = parse_responses_request({"input": "hi", "instructions": "be brief"}) + assert len(msgs) == 1 + assert msgs[0].role == "user" + assert msgs[0].text == "hi" + assert opts["instructions"] == "be brief" + assert sess is None + + def test_options_passthrough(self) -> None: + _, opts, _ = parse_responses_request({"input": "x", "temperature": 0.4, "top_p": 0.9, "tool_choice": "auto"}) + assert opts["temperature"] == 0.4 + assert opts["top_p"] == 0.9 + assert opts["tool_choice"] == "auto" + + def test_options_remap(self) -> None: + _, opts, _ = parse_responses_request({"input": "x", "max_output_tokens": 256, "parallel_tool_calls": False}) + assert opts == {"max_tokens": 256, "allow_multiple_tool_calls": False} + + def test_transport_keys_not_forwarded(self) -> None: + _, opts, _ = parse_responses_request({ + "input": "x", + "model": "gpt-x", + "stream": True, + "previous_response_id": "r", + }) + for key in ("input", "model", "stream", "previous_response_id"): + assert key not in opts + + def test_unknown_keys_silently_dropped(self) -> None: + _, opts, _ = parse_responses_request({"input": "x", "truncation": "auto", "reasoning": {"effort": "low"}}) + assert opts == {} + + def test_none_values_dropped(self) -> None: + _, opts, _ = parse_responses_request({"input": "x", "temperature": None}) + assert "temperature" not in opts + + def test_previous_response_id_becomes_session(self) -> None: + _, _, sess = parse_responses_request({"input": "x", "previous_response_id": "resp_42"}) + assert sess is not None + assert sess.isolation_key == "resp_42" + + +class TestParseResponsesIdentity: + def test_safety_identifier_preferred(self) -> None: + ident = parse_responses_identity({"safety_identifier": "abc", "user": "legacy"}, "responses") + assert ident is not None + assert ident.native_id == "abc" + assert ident.channel == "responses" + + def test_fallback_to_user(self) -> None: + ident = parse_responses_identity({"user": "legacy"}, "responses") + assert ident is not None + assert ident.native_id == "legacy" + + def test_returns_none_when_absent(self) -> None: + assert parse_responses_identity({}, "responses") is None + + def test_returns_none_for_non_string(self) -> None: + assert parse_responses_identity({"safety_identifier": 42}, "responses") is None diff --git a/python/packages/hosting/LICENSE b/python/packages/hosting/LICENSE new file mode 100644 index 00000000000..9e841e7a26e --- /dev/null +++ b/python/packages/hosting/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/python/packages/hosting/README.md b/python/packages/hosting/README.md new file mode 100644 index 00000000000..c43d857975d --- /dev/null +++ b/python/packages/hosting/README.md @@ -0,0 +1,102 @@ +# agent-framework-hosting + +Multi-channel hosting for Microsoft Agent Framework agents. + +`agent-framework-hosting` lets you serve a single agent or workflow target +through one or more **channels**. The host owns one Starlette ASGI app, +route/lifecycle composition, and per-`isolation_key` session resolution. +Each channel owns its protocol parsing and response rendering. + +The base package contains only channel-neutral plumbing: + +- `AgentFrameworkHost` — the Starlette host. +- `Channel` — the channel protocol. +- `ChannelRequest` / `ChannelSession` / `ChannelIdentity` — the request + envelope and optional channel metadata. +- `ChannelContext` / `ChannelContribution` / `ChannelCommand` — channel-side + hooks for invoking the target and contributing routes, commands, and + lifecycle callbacks. +- `ChannelRunHook` / `ChannelResponseHook` / `ChannelStreamUpdateHook` — + host-invoked customization seams. + +`ChannelStreamUpdateHook` applies to streamed updates only. It is not a +substitute for final-response redaction. + +Concrete channels live in their own packages so you only install what you use: + +| Package | Transport | +|---|---| +| `agent-framework-hosting-responses` | OpenAI Responses API | + +Additional channel packages can build on the same host contract without adding +their protocol dependencies to the base package. + +## Install + +```bash +pip install agent-framework-hosting agent-framework-hosting-responses +# or with Hypercorn pre-installed for the demo `host.serve(...)` helper +pip install "agent-framework-hosting[serve]" agent-framework-hosting-responses +# add the [disk] extra to persist reset-session aliases +pip install "agent-framework-hosting[disk]" +``` + +## Quickstart + +```python +from agent_framework.openai import OpenAIChatClient +from agent_framework_hosting import AgentFrameworkHost, Channel + +agent = OpenAIChatClient().as_agent(name="Assistant") + +# Add channels from sibling packages, e.g. `agent-framework-hosting-responses` +# exposes a `ResponsesChannel` that serves the OpenAI Responses API. +channels: list[Channel] = [] + +host = AgentFrameworkHost(target=agent, channels=channels) +host.serve(port=8000) +``` + +## Session state and workflow checkpoints + +By default the host keeps live `AgentSession` objects and reset-session aliases +in memory. Channels opt into continuity by setting +`ChannelRequest.session = ChannelSession(isolation_key=...)`; requests with the +same isolation key reuse the same host-created session. + +For long-running deployments that need `reset_session(...)` aliases to survive +restart, pass `state_dir`: + +```python +host = AgentFrameworkHost( + target=agent, + channels=channels, + state_dir="./.host-state", +) +``` + +This creates `./.host-state/sessions/` and stores only lightweight alias +bookkeeping. Live `AgentSession` objects are still rehydrated lazily by the +configured history provider on the next turn. + +For workflow targets, `checkpoint_location=...` is the clearest way to enable +checkpoint persistence. As a convenience, `state_dir="./.host-state"` also +derives `./.host-state/checkpoints/` for workflow targets. Use the mapping form +when you want only one component: + +```python +from agent_framework_hosting import HostStatePaths + +host = AgentFrameworkHost( + target=workflow, + channels=channels, + state_dir=HostStatePaths( + sessions="/var/lib/myapp/sessions", + checkpoints="/var/lib/myapp/checkpoints", + ), +) +``` + +Cross-channel identity linking, multicast delivery, background runs, +continuation tokens, and durable delivery runners are follow-up enhancements, +not part of this v1 host contract. diff --git a/python/packages/hosting/agent_framework_hosting/__init__.py b/python/packages/hosting/agent_framework_hosting/__init__.py new file mode 100644 index 00000000000..ab78ccd4b9e --- /dev/null +++ b/python/packages/hosting/agent_framework_hosting/__init__.py @@ -0,0 +1,66 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Multi-channel hosting for Microsoft Agent Framework agents. + +Serve a single agent target through one or more **channels** — pluggable +adapters that expose the target over different transports. The base +package contains only the channel-neutral plumbing; concrete channels +ship in their own packages, such as ``agent-framework-hosting-responses``, +so users install only what they need. +""" + +import importlib.metadata + +from ._host import AgentFrameworkHost, ChannelContext, logger +from ._isolation import ( + ISOLATION_HEADER_CHAT, + ISOLATION_HEADER_USER, + IsolationKeys, + get_current_isolation_keys, + reset_current_isolation_keys, + set_current_isolation_keys, +) +from ._types import ( + Channel, + ChannelCommand, + ChannelCommandContext, + ChannelContribution, + ChannelIdentity, + ChannelRequest, + ChannelResponseHook, + ChannelRunHook, + ChannelSession, + ChannelStreamUpdateHook, + HostedRunResult, + HostStatePaths, +) + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" + +__all__ = [ + "ISOLATION_HEADER_CHAT", + "ISOLATION_HEADER_USER", + "AgentFrameworkHost", + "Channel", + "ChannelCommand", + "ChannelCommandContext", + "ChannelContext", + "ChannelContribution", + "ChannelIdentity", + "ChannelRequest", + "ChannelResponseHook", + "ChannelRunHook", + "ChannelSession", + "ChannelStreamUpdateHook", + "HostStatePaths", + "HostedRunResult", + "IsolationKeys", + "__version__", + "get_current_isolation_keys", + "logger", + "reset_current_isolation_keys", + "set_current_isolation_keys", +] diff --git a/python/packages/hosting/agent_framework_hosting/_host.py b/python/packages/hosting/agent_framework_hosting/_host.py new file mode 100644 index 00000000000..3d5287f38e0 --- /dev/null +++ b/python/packages/hosting/agent_framework_hosting/_host.py @@ -0,0 +1,1373 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""The :class:`AgentFrameworkHost` and its :class:`ChannelContext` bridge. + +The host is a small Starlette wrapper: + +- ``__init__`` accepts a hostable target (``SupportsAgentRun`` agent or + ``Workflow``) and a sequence of channels. +- :meth:`AgentFrameworkHost.app` lazily builds a Starlette app by calling + every channel's ``contribute`` and mounting the returned routes under + the channel's ``path`` (empty path → mount at the app root). +- :class:`ChannelContext` exposes ``run`` / ``run_stream`` for channels to + invoke; the host handles hook invocation and per-``isolation_key`` session + caching. + +Per SPEC-002 (and ADR-0026), the host is intentionally thin so the bulk +of channel-specific behaviour stays in the channel package. Identity +linking, multicast delivery, background runs, and durable delivery are +follow-up enhancements layered outside this v1 host contract. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import uuid +from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence +from contextlib import AbstractContextManager, ExitStack, asynccontextmanager +from pathlib import Path +from typing import TYPE_CHECKING, Any, cast + +from agent_framework import ( + AgentResponse, + AgentResponseUpdate, + CheckpointStorage, + Content, + FileCheckpointStorage, + Message, + ResponseStream, + SupportsAgentRun, + Workflow, + WorkflowEvent, +) +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.requests import Request +from starlette.responses import PlainTextResponse +from starlette.routing import BaseRoute, Mount, Route, WebSocketRoute +from starlette.types import ASGIApp, Receive, Scope, Send + +from ._isolation import ( + ISOLATION_HEADER_CHAT, + ISOLATION_HEADER_USER, + IsolationKeys, + reset_current_isolation_keys, + set_current_isolation_keys, +) +from ._persistence import normalize_state_dir +from ._state_store import SessionsStateStore, build_session_aliases +from ._types import ( + Channel, + ChannelRequest, + ChannelResponseHook, + ChannelRunHook, + ChannelStreamUpdateHook, + HostedRunResult, + HostStatePaths, +) + +if TYPE_CHECKING: + from agent_framework._workflows._workflow import WorkflowRunResult + +logger = logging.getLogger("agent_framework.hosting") + + +def _exact_path_route(path: str, route: BaseRoute) -> BaseRoute | None: + """Clone a root route so ``Mount('/x', Route('/'))`` also handles ``/x`` without a redirect.""" + if isinstance(route, Route) and route.path == "/": + return Route( + path, + route.endpoint, + methods=route.methods, + name=route.name, + include_in_schema=route.include_in_schema, + ) + if isinstance(route, WebSocketRoute) and route.path == "/": + return WebSocketRoute(path, route.endpoint, name=route.name) + return None + + +def _checkpoint_path_for_isolation_key(root: Path, isolation_key: str) -> Path: + r"""Return ``root / isolation_key`` after rejecting path-traversal patterns. + + Isolation keys are intentionally caller-controlled: they originate from + inbound HTTP headers (``x-agent-{user,chat}-isolation-key`` injected by + the Foundry runtime), from channel-supplied derivations such as + ``telegram:`` / ``entra:``, or from a channel ``run_hook`` + that may read body fields. Joining such a value into a filesystem path + without validation is CWE-22: a value such as ``../../../etc/foo`` or + ``\\foo`` (Windows UNC) would let the resulting checkpoint directory + escape the configured root. + + The check intentionally uses a denylist so legitimate namespaced keys + (``telegram:42``, ``entra:abc-def``) are preserved as-is. Rejected: + + * any key containing ``/``, ``\\``, or NUL; + * keys that reduce to empty after stripping dots (``.``, ``..``, ``...``, + ...); + * absolute paths (``os.path.isabs``); + * keys carrying a drive letter prefix (``os.path.splitdrive`` — catches + Windows ``C:/...`` and single-letter ``X:foo`` constructs that + ``Path("/root") / "X:foo"`` would otherwise interpret as drive-rooted). + + After joining, both ``root`` and the resolved target are normalised and + the target is verified to stay under the resolved root as defence in + depth — if the denylist ever misses a pattern, this final check still + refuses the join. + + Raises: + ValueError: If ``isolation_key`` is not a non-empty string or fails + any of the validation steps above. + """ + if not isinstance(isolation_key, str) or not isolation_key: + raise ValueError("isolation_key must be a non-empty string") + if ( + "/" in isolation_key + or "\\" in isolation_key + or "\x00" in isolation_key + or isolation_key.strip(".") == "" + or os.path.isabs(isolation_key) + or os.path.splitdrive(isolation_key)[0] + # ``splitdrive`` only recognises drive letters on Windows; reject + # the ``X:rest`` pattern explicitly so a payload crafted on a + # POSIX host still fails closed if the resulting directory ever + # round-trips to Windows storage. + or (len(isolation_key) >= 2 and isolation_key[0].isalpha() and isolation_key[1] == ":") + ): + raise ValueError(f"Invalid isolation_key for checkpoint path: {isolation_key!r}") + + root_resolved = root.resolve() + target = (root_resolved / isolation_key).resolve() + if not target.is_relative_to(root_resolved): + raise ValueError(f"Invalid isolation_key for checkpoint path: {isolation_key!r}") + return target + + +def _workflow_output_to_text(value: Any) -> str: + """Render a single workflow ``output`` payload as plain text. + + Used by the streaming path (``_workflow_event_to_update``) when an + executor emits an arbitrary Python object that the host then has to + serialise into an :class:`AgentResponseUpdate` content for the SSE + stream. ``AgentResponse`` and ``AgentResponseUpdate`` carry text + natively; everything else is best-effort ``str()``. + """ + text = getattr(value, "text", None) + if isinstance(text, str): + return text + return str(value) + + +async def _apply_run_hook( + hook: ChannelRunHook, + request: ChannelRequest, + *, + target: SupportsAgentRun | Workflow, + protocol_request: Any | None, +) -> ChannelRequest: + """Invoke a run hook with the host-owned calling convention.""" + result = hook(request, target=target, protocol_request=protocol_request) + if isinstance(result, Awaitable): + return await result + return result + + +async def _apply_response_hook( + hook: ChannelResponseHook, + result: HostedRunResult[Any], + *, + request: ChannelRequest, + channel_name: str | None, +) -> HostedRunResult[Any]: + """Invoke a response hook with the host-owned calling convention.""" + out = hook(result, request=request, channel_name=channel_name or request.channel) + if isinstance(out, Awaitable): + return await out + return out + + +def _workflow_event_to_update(event: WorkflowEvent[Any]) -> AgentResponseUpdate | None: + """Map a :class:`WorkflowEvent` to a channel-friendly :class:`AgentResponseUpdate`. + + Returns ``None`` for events the host should drop (anything that is not + user-visible output). The original event is preserved on the update's + ``raw_representation`` so consumers can recover full workflow context. + """ + if event.type != "output": + return None + payload: Any = event.data + if isinstance(payload, AgentResponseUpdate): + # Already a streaming update — pass through but tag the source so + # downstream hooks can tell it came from a workflow executor. + if payload.raw_representation is None: + payload.raw_representation = event + return payload + if isinstance(payload, Content): + # Preserve the original content (image, function call, audio, …) + # rather than stringifying — the host stays modality-agnostic + # and lets each destination channel decide what it can render. + return AgentResponseUpdate( + contents=[payload], + role="assistant", + author_name=event.executor_id, + raw_representation=event, + ) + text = _workflow_output_to_text(payload) + return AgentResponseUpdate( + contents=[Content.from_text(text=text)], + role="assistant", + author_name=event.executor_id, + raw_representation=event, + ) + + +@asynccontextmanager +async def _suppress_already_consumed() -> AsyncIterator[None]: + """Yield, swallowing finalizer failures so consumer cleanup never crashes the host. + + The bridge stream calls ``get_final_response()`` after iterating the + workflow stream so the workflow's cleanup hooks run; on some paths the + stream considers itself already finalized (or its inner stream was + closed by ``__anext__`` auto-finalization) and the finalizer raises. + We are inside an async-generator ``finally`` block during teardown, + so we MUST NOT propagate — that would mask the iteration's real + result and cascade into the channel's own cleanup. We always log + with ``exc_info=True`` so the swallowed failure is observable in + operator logs (a regression in the workflow's own cleanup hooks + would otherwise vanish into a clean run). + """ + try: + yield + except RuntimeError as exc: + # Narrow match: only the two documented benign messages produced + # by ``ResponseStream`` / async-iteration teardown should be + # swallowed. Anything else (executor-side ``RuntimeError`` from a + # ``raise RuntimeError(...)`` in user code, runner-context state + # error, checkpoint-store ``RuntimeError`` during the post-run + # flush, …) is a real bug and is escalated to the unexpected-error + # branch so it's logged with a full stack trace at ERROR. We + # still don't propagate (we're in an async-generator ``finally`` + # during teardown) — see the docstring. + message = str(exc) + if "Inner stream not available" in message or "Event loop is closed" in message: + logger.warning("workflow stream finalize raised RuntimeError; cleanup skipped", exc_info=True) + else: + logger.exception("workflow stream finalize raised an unexpected RuntimeError; cleanup skipped") + except Exception: + # Anything else (checkpoint write failure, context-provider + # error in a cleanup hook, executor-side bug, …) is a real + # problem. ``logger.exception`` includes the traceback and + # routes at ERROR so it's grep-able in production. We still + # don't propagate — see the docstring. + logger.exception("workflow stream finalize raised an unexpected error; cleanup skipped") + + +class _BoundResponseStream: + """Adapter that keeps an :class:`ExitStack` open across stream iteration. + + Streaming runs return a :class:`ResponseStream` synchronously, but + consumption happens later (the channel iterates). For host-bound + request context (e.g. Foundry response-id binding) to survive that + gap, we hold the stack open until the underlying stream is exhausted + or :meth:`aclose` is called. We forward awaitable + async-iterator + + ``get_final_response`` semantics so the channel sees a normal + ``ResponseStream``-shaped object. + + Lifecycle: + + * Async iteration (``async for u in stream``) — the stack is closed + in the iterator's ``finally`` after the inner stream is drained. + * ``await stream`` — convenience for ``await get_final_response()``; + the stack is closed when ``get_final_response`` runs because that + path also routes through :meth:`_close`. + * ``await stream.get_final_response()`` — closes the stack in + ``finally``. + * Manual cleanup — call :meth:`aclose` (idempotent). Safe to call + from a ``finally`` even after iteration / ``get_final_response`` + already closed the stack. + """ + + def __init__(self, inner: Any, stack: ExitStack) -> None: + self._inner = inner + self._stack = stack + self._closed = False + + def _close(self) -> None: + if self._closed: + return + self._closed = True + self._stack.close() + + async def aclose(self) -> None: + """Idempotently release the bound request context. + + Channels that abandon the stream without iterating it (e.g. + early-return on a validation failure) MUST call this in a + ``finally`` so the host-bound contextvars don't leak for the + lifetime of the host. Calling after the stack already closed + (via iteration / ``get_final_response``) is a no-op. + """ + self._close() + + def __await__(self) -> Any: + # Convenience: ``await stream`` ≡ ``await stream.get_final_response()``. + # We route through ``get_final_response`` so the stack closes in + # its ``finally`` block, instead of leaking the binding for the + # host's lifetime as the previous direct-await delegation did. + return self.get_final_response().__await__() + + def __aiter__(self) -> AsyncIterator[Any]: + return self._wrap() + + async def _wrap(self) -> AsyncIterator[Any]: + try: + async for item in self._inner: + yield item + finally: + self._close() + + async def get_final_response(self) -> Any: + try: + return await self._inner.get_final_response() + finally: + self._close() + + def __getattr__(self, name: str) -> Any: + return getattr(self._inner, name) + + +class _HostResponseStream: + """Adapter that applies host-owned stream and final-response hooks.""" + + def __init__( + self, + inner: Any, + *, + request: ChannelRequest, + stream_update_hook: ChannelStreamUpdateHook | None = None, + response_hook: ChannelResponseHook | None = None, + channel_name: str | None = None, + ) -> None: + self._inner = inner + self._request = request + self._stream_update_hook = stream_update_hook + self._response_hook = response_hook + self._channel_name = channel_name + + def __await__(self) -> Any: + return self.get_final_response().__await__() + + def __aiter__(self) -> AsyncIterator[Any]: + return self._wrap() + + async def _wrap(self) -> AsyncIterator[Any]: + async for update in self._inner: + if self._stream_update_hook is None: + yield update + continue + transformed = self._stream_update_hook(update) + if isinstance(transformed, Awaitable): + transformed = await transformed + if transformed is None: + continue + yield transformed + + async def get_final_response(self) -> Any: + result = await self._inner.get_final_response() + if self._response_hook is None: + return result + shaped = await _apply_response_hook( + self._response_hook, + HostedRunResult(result), + request=self._request, + channel_name=self._channel_name, + ) + return shaped.result + + async def aclose(self) -> None: + close = getattr(self._inner, "aclose", None) + if close is not None: + await close() + + def __getattr__(self, name: str) -> Any: + return getattr(self._inner, name) + + +class ChannelContext: + """Host-owned bridge that channels call to invoke the target.""" + + def __init__(self, host: AgentFrameworkHost) -> None: + """Bind the context to its owning :class:`AgentFrameworkHost`. + + The host instance is the source of truth for the target, registered + channels, sessions, and lifecycle state. Channels only ever receive a + context; they never see the host directly. + """ + self._host = host + + @property + def target(self) -> SupportsAgentRun | Workflow: + """The hostable target the channel should invoke.""" + return self._host.target + + async def run( + self, + request: ChannelRequest, + *, + run_hook: ChannelRunHook | None = None, + protocol_request: Any | None = None, + response_hook: ChannelResponseHook | None = None, + channel_name: str | None = None, + ) -> HostedRunResult[Any]: + """Invoke the target for ``request`` and return a channel-neutral result. + + For agent targets the return type narrows to + ``HostedRunResult[AgentResponse]``; for workflow targets to + ``HostedRunResult[WorkflowRunResult]``. The static return is left + as ``HostedRunResult[Any]`` because :class:`ChannelContext` is + agnostic to which target shape the host was constructed with; + channels narrow at the call site if they need it. + + Args: + request: The channel-built request envelope. + + Keyword Args: + run_hook: Optional channel-supplied hook the host applies before + invoking the target. + protocol_request: Raw channel-native payload passed to + ``run_hook``. + response_hook: Optional channel-supplied hook the host applies to + the completed result before returning it. + channel_name: Channel name passed to ``response_hook``. Defaults + to ``request.channel``. + """ + prepared = await self._host._apply_run_hook( # pyright: ignore[reportPrivateUsage] + request, + hook=run_hook, + protocol_request=protocol_request, + ) + result = await self._host._invoke(prepared) # pyright: ignore[reportPrivateUsage] + return await self._host._apply_response_hook( # pyright: ignore[reportPrivateUsage] + result, + request=prepared, + hook=response_hook, + channel_name=channel_name, + ) + + async def run_stream( + self, + request: ChannelRequest, + *, + run_hook: ChannelRunHook | None = None, + protocol_request: Any | None = None, + stream_update_hook: ChannelStreamUpdateHook | None = None, + response_hook: ChannelResponseHook | None = None, + channel_name: str | None = None, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: + """Apply host-owned hooks and invoke the target with ``stream=True``. + + Channels iterate the stream directly (it acts like an AsyncGenerator) + and are responsible for delivering updates to their wire protocol. + When ``stream_update_hook`` is supplied, the host applies it during + iteration to rewrite or drop individual updates before they hit the wire. + + Args: + request: The channel-built request envelope. + + Keyword Args: + run_hook: Optional channel-supplied hook the host applies before + opening the target stream. + protocol_request: Raw channel-native payload passed to + ``run_hook``. + stream_update_hook: Optional host-applied update transform. + response_hook: Optional host-applied final-response transform. + channel_name: Channel name passed to ``response_hook``. Defaults + to ``request.channel``. + """ + prepared = await self._host._apply_run_hook( # pyright: ignore[reportPrivateUsage] + request, + hook=run_hook, + protocol_request=protocol_request, + ) + stream = self._host._invoke_stream(prepared) # pyright: ignore[reportPrivateUsage] + if stream_update_hook is None and response_hook is None: + return stream + return _HostResponseStream( + stream, + request=prepared, + stream_update_hook=stream_update_hook, + response_hook=response_hook, + channel_name=channel_name, + ) # type: ignore[return-value] + + +class _FoundryIsolationASGIMiddleware: + """Lift the two well-known Foundry isolation headers into a contextvar. + + The Foundry Hosted Agents runtime injects + ``x-agent-{user,chat}-isolation-key`` on every inbound HTTP request. + Storage providers that need partition-aware writes (notably + :class:`FoundryHostedAgentHistoryProvider`) read those keys via + :func:`get_current_isolation_keys` to avoid every channel having to + parse Foundry-specific headers itself. We intentionally inspect + only HTTP scopes; lifespan/websocket scopes are forwarded + untouched. When neither header is present the contextvar stays at + its default ``None``, so local-dev requests behave as before. + """ + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + user_key: str | None = None + chat_key: str | None = None + for raw_name, raw_value in scope.get("headers") or (): + name = raw_name.decode("latin-1").lower() + if name == ISOLATION_HEADER_USER: + user_key = raw_value.decode("latin-1") or None + elif name == ISOLATION_HEADER_CHAT: + chat_key = raw_value.decode("latin-1") or None + if user_key is None and chat_key is None: + await self.app(scope, receive, send) + return + token = set_current_isolation_keys(IsolationKeys(user_key=user_key, chat_key=chat_key)) + try: + await self.app(scope, receive, send) + finally: + reset_current_isolation_keys(token) + + +class AgentFrameworkHost: + """Owns one Starlette app, one hostable target, and a sequence of channels.""" + + def __init__( + self, + target: SupportsAgentRun | Workflow, + *, + channels: Sequence[Channel], + debug: bool = False, + checkpoint_location: str | os.PathLike[str] | CheckpointStorage | None = None, + state_dir: str | os.PathLike[str] | HostStatePaths | Mapping[str, str | os.PathLike[str]] | None = None, + ) -> None: + """Create a host for ``target`` and its channels. + + Args: + target: The hostable target to invoke from channels — either a + ``SupportsAgentRun``-compatible agent or a ``Workflow``. The + host detects the kind and dispatches to the appropriate + execution seam (``agent.run(...)`` vs ``workflow.run(message=...)``). + For workflow targets, channels (or their ``run_hook``) are + responsible for shaping ``ChannelRequest.input`` into the + workflow start executor's typed input. + + Keyword Args: + channels: The channels to expose. Each channel contributes routes + and commands that are mounted under ``channel.path`` (defaulting + to the channel name). + debug: Whether to enable Starlette's debug mode (stack traces in + responses, etc.) and per-channel debug logging. + checkpoint_location: When ``target`` is a :class:`Workflow`, the + location used to persist workflow checkpoints across requests. + Either a filesystem path (``str`` / ``PathLike``) — the host + creates a per-conversation + :class:`~agent_framework.FileCheckpointStorage` rooted at + ``checkpoint_location / `` — or a + :class:`~agent_framework.CheckpointStorage` instance the host + uses as-is (caller owns scoping). Per-request behaviour: + requests without ``ChannelRequest.session.isolation_key`` + are run without checkpointing. When set on a workflow that + already has its own checkpoint storage configured + (``WorkflowBuilder(checkpoint_storage=...)``), the host + refuses to start so ownership of checkpointing is + unambiguous. Ignored for ``SupportsAgentRun`` targets (a + warning is emitted). Takes precedence over + ``state_dir['checkpoints']`` (or the auto-derived + ``state_dir/checkpoints/`` subfolder); a warning surfaces + the double-configuration. + state_dir: Opt-in disk persistence for host-managed state. + When set, the host writes session aliases created by + :meth:`reset_session` to a :mod:`diskcache`-backed store + under ``state_dir``. When the target is a + :class:`Workflow`, the auto-derived + ``state_dir/checkpoints/`` subfolder (or the + ``checkpoints`` key of the mapping form) is also used + as the workflow checkpoint location (equivalent to + passing ``checkpoint_location`` directly). Accepts: + + * ``None`` (default) — everything stays in memory; the + process owns its state and loses it on exit. Matches + today's behaviour exactly. + * ``str`` / :class:`os.PathLike` — the host derives + default subpaths ``state_dir/sessions/`` and + (for workflow targets) ``state_dir/checkpoints/``. + Recommended for most + long-running-host deployments — one path, no extra + config, all components persist together. Note: when + the target is a Workflow this enables workflow + checkpoint persistence; use the mapping form below + and omit ``checkpoints`` to opt out. + * :class:`HostStatePaths` typed dict / plain + ``Mapping`` — per-component overrides for callers that + want each component on a different volume (fast local + SSD for checkpoints, network-attached volume for + sessions, …). Components missing from the mapping fall + back to in-memory (or, for ``checkpoints``, to no + checkpoint persistence). Unknown keys raise + ``ValueError`` to surface typos early. + + The ``sessions`` component requires the + optional ``diskcache`` dependency (install with + ``pip install 'agent-framework-hosting[disk]'``); + ``checkpoints`` uses the core + :class:`~agent_framework.FileCheckpointStorage` and has + no extra dependency. The disk-cache-backed sessions + component acquires an OS-level advisory lock on its + directory; a second host pointed at the same path raises + :class:`RuntimeError` at construction so two processes + do not race session-alias writes. When + ``checkpoint_location`` is supplied explicitly, the + ``checkpoints`` sub-path is ignored. + """ + self.target: SupportsAgentRun | Workflow = target + self._is_workflow = isinstance(target, Workflow) + self.channels = list(channels) + self._debug = debug + self._app: Starlette | None = None + self._state_paths: dict[str, Path | None] = normalize_state_dir(state_dir) + checkpoints_explicit_in_mapping = isinstance(state_dir, Mapping) and "checkpoints" in state_dir + derived_checkpoint_path = self._state_paths.get("checkpoints") + self._checkpoint_location: Path | CheckpointStorage | None = None + effective_checkpoint_source: str | os.PathLike[str] | CheckpointStorage | None = checkpoint_location + if checkpoint_location is None and derived_checkpoint_path is not None: + # Only consume the derived path when the target is a + # Workflow; non-workflow targets get a warning (explicit + # mapping case) or a silent ignore (single-path case). + if self._is_workflow: + effective_checkpoint_source = derived_checkpoint_path + elif checkpoints_explicit_in_mapping: + logger.warning("state_dir['checkpoints'] is set but target is not a Workflow; ignoring.") + elif checkpoint_location is not None and derived_checkpoint_path is not None: + # Both the legacy parameter and the new state_dir component + # configure the same thing. Keep the explicit one and + # surface the double-config so the user notices the no-op. + logger.warning( + "Both checkpoint_location and state_dir['checkpoints'] are set " + "(state_dir['checkpoints']=%s); the explicit checkpoint_location " + "takes precedence and the state_dir sub-path is ignored. " + "Use the HostStatePaths mapping form and omit 'checkpoints' to " + "configure session-alias persistence without also enabling " + "host-managed workflow checkpointing.", + derived_checkpoint_path, + ) + if effective_checkpoint_source is not None: + if not self._is_workflow: + # Only the legacy parameter path can reach here for a + # non-workflow target (the derived path was already + # short-circuited above). Preserve the historical + # warning text so existing users see the same message. + logger.warning("checkpoint_location is set but target is not a Workflow; ignoring.") + else: + workflow: Workflow = target # type: ignore[assignment] + if workflow._runner_context.has_checkpointing(): # type: ignore[reportPrivateUsage] + raise RuntimeError( + "Workflow already has checkpoint storage configured " + "(WorkflowBuilder(checkpoint_storage=...)). The host " + "manages checkpoints when checkpoint_location (or " + "state_dir['checkpoints']) is set; remove one of the " + "two configurations." + ) + if isinstance(effective_checkpoint_source, (str, os.PathLike)): + self._checkpoint_location = Path(os.fspath(effective_checkpoint_source)) + else: + # Anything else is treated as a CheckpointStorage instance. + # ``CheckpointStorage`` is a non-runtime-checkable Protocol, + # so we cannot ``isinstance``-check it directly. + self._checkpoint_location = effective_checkpoint_source + self._sessions: dict[str, Any] = {} + sessions_path = self._state_paths.get("sessions") + self._sessions_store: SessionsStateStore | None + if sessions_path is not None: + self._sessions_store = SessionsStateStore(sessions_path) + self._session_aliases: dict[str, str] = build_session_aliases(self._sessions_store) + else: + self._sessions_store = None + self._session_aliases = {} + # Set by ``serve()`` so the lifespan startup handler doesn't + # double-log the banner; remains ``False`` when callers mount + # ``host.app`` under their own ASGI server. + self._startup_logged: bool = False + + @property + def app(self) -> Starlette: + """Lazily build (and cache) the Starlette application.""" + if self._app is None: + self._app = self._build_app() + return self._app + + def serve( + self, + *, + host: str = "127.0.0.1", + port: int = 8000, + workers: int = 1, + **config_kwargs: Any, + ) -> None: + """Start the host on ``host:port`` using Hypercorn. + + Hypercorn is the same ASGI server the Foundry Hosted Agents + runtime uses for production deployments, so running locally with + the same server keeps dev/prod parity (Trio fallbacks, lifespan + semantics, HTTP/2 support, …). Install with the ``serve`` extra + (``pip install agent-framework-hosting[serve]``). + + Args: + host: Interface to bind. Defaults to ``127.0.0.1``. + port: TCP port to bind. Defaults to ``8000``. + workers: Number of worker processes. Defaults to ``1``; + Hypercorn's process model only kicks in for ``>1``. + **config_kwargs: Forwarded to :class:`hypercorn.config.Config` + via attribute assignment, so any documented Hypercorn + config field (e.g. ``keep_alive_timeout=...``, + ``access_log_format=...``) can be set directly. + """ + try: + from hypercorn.asyncio import ( # pyright: ignore[reportMissingImports] + serve as _hypercorn_serve, # pyright: ignore[reportUnknownVariableType] + ) + from hypercorn.config import Config # pyright: ignore[reportMissingImports, reportUnknownVariableType] + except ImportError as exc: # pragma: no cover - exercised at runtime + raise RuntimeError( + "AgentFrameworkHost.serve() requires hypercorn. " + "Install with `pip install agent-framework-hosting[serve]` or `pip install hypercorn`." + ) from exc + + config = Config() # pyright: ignore[reportUnknownVariableType] + config.bind = [f"{host}:{port}"] # pyright: ignore[reportUnknownMemberType] + config.workers = workers # pyright: ignore[reportUnknownMemberType] + for key, value in config_kwargs.items(): + setattr(config, key, value) # pyright: ignore[reportUnknownArgumentType] + + # Touch ``self.app`` so the lifespan startup log fires once before + # we hand off to hypercorn — gives a single, readable banner of + # what the host is exposing without requiring channels to log + # individually. + app = self.app + self._log_startup(host=host, port=port, workers=workers) + # Mark as already logged so the lifespan startup handler does not + # double-log the same banner. + self._startup_logged = True + + # ``hypercorn.asyncio.serve`` has a complex partially-typed signature + # (multiple ASGI/WSGI app overloads) and its ``Scope`` definition + # diverges from Starlette's; cast both sides to ``Any`` to keep the + # call site readable without sprinkling per-error suppressions. + serve_callable = cast(Any, _hypercorn_serve) + asyncio.run(serve_callable(app, config)) + + def reset_session(self, isolation_key: str) -> None: + """Rotate ``isolation_key`` to a fresh session id without deleting history. + + Old turns are preserved on disk under their original session id and + remain accessible by passing that id explicitly (e.g. as + ``previous_response_id``). Future requests using ``isolation_key`` + get a new, empty ``AgentSession``. + """ + new_id = f"{isolation_key}#{uuid.uuid4().hex[:8]}" + self._session_aliases[isolation_key] = new_id + self._sessions.pop(isolation_key, None) + + # -- internals --------------------------------------------------------- # + + def _log_startup( + self, + *, + host: str | None = None, + port: int | None = None, + workers: int | None = None, + ) -> None: + """Emit a single human-friendly startup banner. + + Mirrors the ``AgentServerHost`` convention from + ``azure.ai.agentserver.core``: one INFO line that captures the + target type, every channel + its endpoint path, the bind address + (when known), whether we're running inside a Foundry Hosted + Agents container, and the worker count. Keeps log noise low + while still giving an operator a single grep-able anchor when + triaging. + + Called from both :meth:`serve` (which knows the bind triple) + and the ASGI lifespan ``startup`` phase (which does not — the + host may be embedded under any caller-managed ASGI server). + Bind fields are omitted from the log line when unknown. + """ + target_kind = "Workflow" if isinstance(self.target, Workflow) else type(self.target).__name__ + target_name = getattr(self.target, "name", None) or target_kind + channels_repr = ", ".join( + f"{ch.name}@{ch.path or '/'}" # blank path means "mounted at root" + for ch in self.channels + ) + is_hosted = bool(os.environ.get("FOUNDRY_HOSTING_ENVIRONMENT")) + bind = f"{host}:{port}" if host is not None and port is not None else "" + logger.info( + "AgentFrameworkHost starting: target=%s (%s) bind=%s workers=%s hosted=%s channels=[%s]", + target_name, + target_kind, + bind, + workers if workers is not None else "", + is_hosted, + channels_repr or "", + ) + + def _build_app(self) -> Starlette: + context = ChannelContext(self) + routes: list[BaseRoute] = [] + on_startup: list[Callable[[], Awaitable[None]]] = [] + on_shutdown: list[Callable[[], Awaitable[None]]] = [] + + # ``/readiness`` is the standard probe path the Foundry Hosted Agents + # runtime hits to gate traffic. We expose it unconditionally — once the + # ASGI app is up the host considers itself ready (channels register + # their own startup hooks and may run before the first request, but + # readiness is intentionally cheap so the platform's probe never times + # out on transient channel work). Mounted first so a channel cannot + # accidentally shadow it. + async def _readiness(_request: Request) -> PlainTextResponse: # noqa: RUF029 + """Liveness/readiness probe handler used by Foundry Hosted Agents.""" + return PlainTextResponse("ok") + + routes.append(Route("/readiness", _readiness, methods=["GET"])) + + for channel in self.channels: + contribution = channel.contribute(context) + # Channels publish routes relative to their root; mount under channel.path. + # An empty path means "mount at the app root" — useful when an external + # platform requires the channel endpoint at "/" or at a route contributed + # by the channel. + if contribution.routes: + if channel.path: + channel_routes = list(contribution.routes) + exact_routes = [ + exact_route + for route in channel_routes + if (exact_route := _exact_path_route(channel.path, route)) is not None + ] + routes.extend(exact_routes) + routes.append(Mount(channel.path, routes=channel_routes)) + else: + routes.extend(contribution.routes) + on_startup.extend(contribution.on_startup) + on_shutdown.extend(contribution.on_shutdown) + + @asynccontextmanager + async def lifespan(_app: Starlette) -> AsyncIterator[None]: + # Emit the startup banner once. ``serve()`` may have already + # logged it (it logs eagerly so the banner appears before + # control passes to hypercorn); the lifespan still logs it + # for callers that mount ``host.app`` directly under their + # own ASGI server. + if not self._startup_logged: + self._log_startup() + self._startup_logged = True + # Run every startup callback; collect (don't propagate) so + # one bad channel doesn't leave its peers half-initialised + # AND deny us a chance to pair-up shutdown calls. After all + # callbacks have been attempted, raise the FIRST error so + # Starlette / the ASGI server still aborts boot — and log + # every other failure so operators can see them all in one + # log scrape rather than discovering them turn-by-turn. + startup_errors: list[tuple[str, BaseException]] = [] + for cb in on_startup: + try: + await cb() + except Exception as exc: + name = getattr(cb, "__qualname__", repr(cb)) + logger.exception("lifespan startup: callback %s failed", name) + startup_errors.append((name, exc)) + if startup_errors: + _, first_exc = startup_errors[0] + if len(startup_errors) > 1: + logger.error( + "lifespan startup: %d callback(s) failed; first error re-raised, " + "remaining failures already logged above (%s)", + len(startup_errors), + ", ".join(n for n, _ in startup_errors[1:]), + ) + raise first_exc + try: + yield + finally: + # Same shape on the shutdown side: walk every callback + # so a bad one can't leave its peers leaking + # tasks/sockets/sessions, then raise the first if any + # failed so the server's exit code reflects the failure. + shutdown_errors: list[tuple[str, BaseException]] = [] + for cb in on_shutdown: + try: + await cb() + except Exception as exc: + name = getattr(cb, "__qualname__", repr(cb)) + logger.exception("lifespan shutdown: callback %s failed", name) + shutdown_errors.append((name, exc)) + if self._sessions_store is not None: + try: + self._sessions_store.close() + except Exception as exc: # pragma: no cover - defensive + logger.exception("lifespan shutdown: sessions store close failed") + shutdown_errors.append(("SessionsStateStore.close", exc)) + if shutdown_errors: + _, first_exc = shutdown_errors[0] + if len(shutdown_errors) > 1: + logger.error( + "lifespan shutdown: %d callback(s) failed; first error re-raised, " + "remaining failures already logged above (%s)", + len(shutdown_errors), + ", ".join(n for n, _ in shutdown_errors[1:]), + ) + raise first_exc + + middleware = ( + [Middleware(_FoundryIsolationASGIMiddleware)] if os.environ.get("FOUNDRY_HOSTING_ENVIRONMENT") else [] + ) + return Starlette( + debug=self._debug, + routes=routes, + lifespan=lifespan, + middleware=middleware, + ) + + def _build_run_kwargs(self, request: ChannelRequest) -> dict[str, Any]: + # The host keys a per-isolation_key AgentSession off the channel's + # session hint so context providers (FileHistoryProvider, …) on the + # target see one session per end user. + session = None + if request.session_mode != "disabled" and request.session is not None: + isolation_key = request.session.isolation_key + if isolation_key is not None and hasattr(self.target, "create_session"): + session_id = self._session_aliases.get(isolation_key, isolation_key) + session = self._sessions.get(isolation_key) + if session is None: + # Concurrency note: ``create_session`` is sync today, + # so the get/set window has no await point and CPython + # serialises us against other tasks. ``setdefault`` is + # the atomic primitive that keeps us safe even if a + # future ``create_session`` ever yields — both racers + # would see ``session is None``, both construct a new + # session, but only the first ``setdefault`` wins; the + # loser's just-built session is discarded (one + # transient orphan max per race window) instead of + # silently overwriting a peer-bound session that + # other in-flight requests are already using. + # ``create_session`` lives on agent-typed targets but not on + # ``Workflow``; the ``hasattr`` above guards the call site. + new_session = self.target.create_session( # pyright: ignore[reportAttributeAccessIssue, reportUnknownVariableType, reportUnknownMemberType] + session_id=session_id + ) + session = self._sessions.setdefault(isolation_key, new_session) # pyright: ignore[reportUnknownArgumentType] + + run_kwargs: dict[str, Any] = {} + if session is not None: + run_kwargs["session"] = session + if request.options: + run_kwargs["options"] = request.options + return run_kwargs + + async def _apply_run_hook( + self, + request: ChannelRequest, + *, + hook: ChannelRunHook | None, + protocol_request: Any | None, + ) -> ChannelRequest: + """Apply a channel-supplied run hook under host ownership.""" + if hook is None: + return request + return await _apply_run_hook( + hook, + request, + target=self.target, + protocol_request=protocol_request, + ) + + async def _apply_response_hook( + self, + result: HostedRunResult[Any], + *, + request: ChannelRequest, + hook: ChannelResponseHook | None, + channel_name: str | None, + ) -> HostedRunResult[Any]: + """Apply a channel-supplied response hook under host ownership.""" + if hook is None: + return result + return await _apply_response_hook(hook, result, request=request, channel_name=channel_name) + + def _log_incoming(self, request: ChannelRequest, *, stream: bool) -> None: + """Emit a structured INFO summary for every incoming target invocation. + + When ``debug=True`` is set on the host, also dump the channel-native + settings the channel attached to the ``ChannelRequest`` — ``options`` + (the ChatOptions-shaped fields the channel parsed from its protocol + payload, e.g. temperature/tools/tool_choice for Responses), plus + ``attributes`` / ``metadata`` (the channel's protocol-specific bag, + e.g. ``chat_id`` / ``callback_query_id`` for Telegram). + + Uses ``extra={...}`` so structured-logging consumers (the + Foundry hosted-agent log shipper, OpenTelemetry handlers, …) + can index per-field rather than re-parsing a template string. + """ + isolation_key = request.session.isolation_key if request.session is not None else None + logger.info( + "channel request", + extra={ + "channel": request.channel, + "operation": request.operation, + "stream": stream, + "session": isolation_key, + "session_mode": request.session_mode, + }, + ) + logger.debug( + "channel request details", + extra={ + "channel": request.channel, + "options": dict(request.options) if request.options else {}, + "attributes": dict(request.attributes) if request.attributes else {}, + "metadata": dict(request.metadata) if request.metadata else {}, + }, + ) + + def _bind_request_context(self, request: ChannelRequest) -> ExitStack: + """Bind any per-request anchors a target's context-providers expose. + + Channels announce per-request anchors (currently ``response_id`` + and ``previous_response_id``) via ``ChannelRequest.attributes``. + Some history providers — notably the Foundry hosted-agent history + provider — need to write storage under the same ``response_id`` + the channel surfaces on its envelope so the next turn's + ``previous_response_id`` walks the chain. Rather than the host + knowing about specific provider classes, we duck-type: any + context provider on the target that exposes a + ``bind_request_context(response_id=..., previous_response_id=..., + **_)`` context-manager gets it called with the request's + attribute values. Per-request platform isolation keys are handled + separately by :class:`_FoundryIsolationASGIMiddleware` (lifted + off the inbound headers into a contextvar) so providers don't + depend on channels to forward them. Bindings are scoped to the + returned :class:`ExitStack` which the caller must enter before + invoking the target and leave after the run completes. + """ + stack = ExitStack() + attrs = request.attributes or {} + response_id = attrs.get("response_id") + if not isinstance(response_id, str) or not response_id: + return stack + previous_response_id = attrs.get("previous_response_id") + if previous_response_id is not None and not isinstance(previous_response_id, str): + previous_response_id = None + + providers: Sequence[Any] = getattr(self.target, "context_providers", None) or () + + for provider in providers: + bind = getattr(provider, "bind_request_context", None) + if not callable(bind): + continue + stack.enter_context( + cast( + "AbstractContextManager[Any]", + bind( + response_id=response_id, + previous_response_id=previous_response_id, + ), + ) + ) + return stack + + async def _invoke(self, request: ChannelRequest) -> HostedRunResult[AgentResponse]: + self._log_incoming(request, stream=False) + if self._is_workflow: + # Workflow targets follow a separate path; the dedicated dispatch + # is parameterised on ``WorkflowRunResult`` so the static return + # type of ``_invoke`` itself stays the agent-shaped envelope. + return await self._invoke_workflow(request) # type: ignore[return-value] + run_kwargs = self._build_run_kwargs(request) + with self._bind_request_context(request): + # ``_is_workflow`` is False here so ``self.target`` is an + # ``Agent``-shaped target whose ``.run`` returns + # :class:`AgentResponse`. Narrow back to keep ``result.messages`` + # well-typed without conditional imports of ``Agent``. + agent_target = cast("SupportsAgentRun", self.target) + result = await agent_target.run(self._wrap_input(request), **run_kwargs) + # Carry the full :class:`AgentResponse` as the typed envelope + # ``result`` so channels (and developer-supplied response hooks) + # can read ``messages``, ``value``, ``usage_details``, + # ``response_id`` … directly off the target output without the + # host pre-shaping any of it. The bound session (if any) is + # surfaced so channels that want to render session metadata + # don't have to re-resolve it. + return HostedRunResult(result, session=run_kwargs.get("session")) + + def _invoke_stream(self, request: ChannelRequest) -> ResponseStream[AgentResponseUpdate, AgentResponse]: + self._log_incoming(request, stream=True) + if self._is_workflow: + return self._invoke_workflow_stream(request) + run_kwargs = self._build_run_kwargs(request) + # ``run(stream=True)`` returns a ResponseStream synchronously (it is + # itself awaitable / async-iterable). We hand it back to the channel + # so the channel can drive iteration and apply its transform hook. + # Streaming flows iterate after this method returns, which is + # *outside* a sync ``with`` block — so we wrap the underlying + # stream in an adapter that holds the binding open across the + # iteration lifecycle. + binder = self._bind_request_context(request) + return _BoundResponseStream( # type: ignore[return-value] + self.target.run(self._wrap_input(request), stream=True, **run_kwargs), + binder, + ) + + def _resolve_checkpoint_storage(self, request: ChannelRequest) -> CheckpointStorage | None: + """Build (or return) the per-request checkpoint storage, or ``None``. + + Returns ``None`` when no ``checkpoint_location`` is configured or + when the request lacks a stable session key — without a key we + cannot scope checkpoints per conversation, and we'd rather skip + checkpointing than pollute a single shared store. + + When ``checkpoint_location`` is a path, the per-conversation + directory is built via :func:`_checkpoint_path_for_isolation_key` + which rejects path-traversal patterns in ``isolation_key`` and + verifies the resolved directory stays under the configured root + (CWE-22 defence). Invalid keys cause the request to skip + checkpointing with a WARNING rather than escape the root or + crash the request. + """ + if self._checkpoint_location is None: + return None + if request.session is None or not request.session.isolation_key: + return None + if isinstance(self._checkpoint_location, Path): + try: + target = _checkpoint_path_for_isolation_key(self._checkpoint_location, request.session.isolation_key) + except ValueError as exc: + logger.warning( + "Skipping checkpoint storage for request: %s", + exc, + ) + return None + return FileCheckpointStorage(str(target)) + # Caller-supplied storage — used as-is; caller owns scoping. + return self._checkpoint_location + + async def _invoke_workflow(self, request: ChannelRequest) -> HostedRunResult[WorkflowRunResult]: + """Dispatch to ``Workflow.run`` and wrap the result in a typed envelope. + + The channel's ``run_hook`` is the canonical adapter for shaping + ``request.input`` into the workflow start executor's typed input + (free-form text from a Telegram message, structured ``Responses`` + ``input`` items, …). When no hook is wired, ``request.input`` is + forwarded verbatim — appropriate for workflows whose start executor + accepts the channel's native input type (commonly ``str``). + + When ``checkpoint_location`` is configured on the host, a + per-conversation checkpoint storage is resolved, the workflow is + restored from its latest checkpoint (if any) and then re-run with + the new input — mirroring the resume semantics of the Foundry + Responses host. + + The full :class:`~agent_framework._workflows._workflow.WorkflowRunResult` + is carried unchanged on :attr:`HostedRunResult.result` so + destination channels can iterate :meth:`WorkflowRunResult.get_outputs`, + inspect :meth:`WorkflowRunResult.get_final_state`, or pull other + per-executor events themselves. The host intentionally does not + map outputs onto messages — channels (and developer-supplied + response hooks) own that projection because what counts as a + "renderable output" is wire-format-specific. + + Workflows do not own session state in the agent sense, so + ``HostedRunResult.session`` is ``None`` for workflow targets. + """ + # Workflows do not own session state in the agent sense and do not + # accept ``session=`` / ``options=`` kwargs. The channel's run_hook is + # the seam for any per-run customization; nothing flows through here. + workflow: Workflow = self.target # type: ignore[assignment] + storage = self._resolve_checkpoint_storage(request) + await self._restore_workflow_checkpoint(workflow, storage) + result = ( + await workflow.run(request.input, checkpoint_storage=storage) + if storage is not None + else await workflow.run(request.input) + ) + return HostedRunResult(result) + + @staticmethod + async def _restore_workflow_checkpoint( + workflow: Workflow, + storage: CheckpointStorage | None, + ) -> None: + """Rehydrate ``workflow`` from its latest checkpoint, if any. + + Shared between the blocking and streaming workflow paths so the + restore step stays in lockstep across both — both must observe + the same in-memory state when they apply the new input. + + If ``storage.get_latest`` returns ``None`` (no prior checkpoint + recorded) the call is a benign no-op. A non-``None`` checkpoint + whose stored events are empty (stale or partially-written + ``checkpoint_id``) is logged at WARNING so operators can detect + the silent-state-loss case without sifting through INFO logs. + """ + if storage is None: + return + latest = await storage.get_latest(workflow_name=workflow.name) + if latest is None: + return + # The blocking restore call is a no-op invocation that just + # rehydrates state; the streaming path drains the same + # restoration stream below to achieve the same effect. + result = await workflow.run(checkpoint_id=latest.checkpoint_id, checkpoint_storage=storage) + events = getattr(result, "events", None) + if events is not None and not events: + logger.warning( + "workflow checkpoint restore produced zero events " + "(workflow=%s checkpoint_id=%s) — state may not be rehydrated", + workflow.name, + latest.checkpoint_id, + ) + + def _invoke_workflow_stream(self, request: ChannelRequest) -> ResponseStream[AgentResponseUpdate, AgentResponse]: + """Bridge ``Workflow.run(stream=True)`` to a channel-facing ``ResponseStream``. + + Wraps the workflow's ``ResponseStream[WorkflowEvent, WorkflowRunResult]`` + in a new ``ResponseStream[AgentResponseUpdate, AgentResponse]`` so + channels can iterate it identically to an agent stream and apply + their ``stream_update_hook`` callables. + + Mapping rules: + + - ``output`` events whose ``data`` is already an + :class:`AgentResponseUpdate` (the common case for workflows + containing :class:`AgentExecutor`) pass through unchanged. + - ``output`` events with any other ``data`` are wrapped into a + single-text-content :class:`AgentResponseUpdate`. + - All other event types (``status``, ``executor_invoked``, + ``superstep_*``, lifecycle, …) are filtered out — channels only + care about user-visible text. Hooks can opt back in by inspecting + ``raw_representation`` on the produced updates. + + The original :class:`WorkflowEvent` is stashed on + ``AgentResponseUpdate.raw_representation`` so advanced consumers + (telemetry, debug UIs) can recover the full workflow timeline. + + Checkpoint restoration (when ``checkpoint_location`` is set) runs + before the input stream is opened so the new turn observes the + restored state. + """ + workflow: Workflow = self.target # type: ignore[assignment] + storage = self._resolve_checkpoint_storage(request) + + async def _bridge() -> AsyncIterator[AgentResponseUpdate]: + # Same restore step the blocking path runs (see + # ``_restore_workflow_checkpoint``) — kept inside the bridge + # so the in-memory state is rehydrated lazily on first + # iteration rather than at stream-construction time. + await self._restore_workflow_checkpoint_streaming(workflow, storage) + workflow_stream = workflow.run(request.input, stream=True, checkpoint_storage=storage) + try: + async for event in workflow_stream: + update = _workflow_event_to_update(event) + if update is not None: + yield update + finally: + async with _suppress_already_consumed(): + await workflow_stream.get_final_response() + + async def _finalize(updates: Sequence[AgentResponseUpdate]) -> AgentResponse: # noqa: RUF029 + return AgentResponse.from_updates(updates) + + return ResponseStream[AgentResponseUpdate, AgentResponse](_bridge(), finalizer=_finalize) + + @staticmethod + async def _restore_workflow_checkpoint_streaming( + workflow: Workflow, + storage: CheckpointStorage | None, + ) -> None: + """Streaming-path counterpart to :meth:`_restore_workflow_checkpoint`. + + ``Workflow.run(stream=True, checkpoint_id=...)`` returns a stream + whose updates we don't care about — we just need the side-effect + of rehydration. Drained inline so the new-input run that follows + observes the restored state. + + A latest checkpoint that drains to zero events (stale or + partially-written ``checkpoint_id``) is logged at WARNING so + operators can detect the silent-state-loss case, mirroring the + blocking helper. + """ + if storage is None: + return + latest = await storage.get_latest(workflow_name=workflow.name) + if latest is None: + return + drained = 0 + async for _ in workflow.run( + stream=True, + checkpoint_id=latest.checkpoint_id, + checkpoint_storage=storage, + ): + drained += 1 + if drained == 0: + logger.warning( + "workflow checkpoint restore stream produced zero events " + "(workflow=%s checkpoint_id=%s) — state may not be rehydrated", + workflow.name, + latest.checkpoint_id, + ) + + def _wrap_input(self, request: ChannelRequest) -> Message | list[Message]: + """Promote ``request.input`` to ``Message``(s) carrying channel metadata. + + Channels deliver inputs as plain text, a single ``Message``, or a list + of ``Message`` (e.g. a Responses-API request that includes a ``system`` + instruction plus the user turn). To preserve channel provenance and + optional identity metadata on the persisted history record (and make it + visible to context providers, evals, audits), we attach a ``hosting`` + block under ``additional_properties``. AF's + ``Message.to_dict`` round-trips ``additional_properties`` through any + ``HistoryProvider`` that serializes via ``to_dict`` (e.g. + ``FileHistoryProvider``) and the framework explicitly does *not* + forward these fields to model providers, so they are safe to attach. + + For a list of messages we attach the metadata to the LAST message that + will be persisted (typically the user turn) — this keeps a single, + searchable record of where the inbound message came from. + """ + hosting_meta: dict[str, Any] = {"channel": request.channel} + if request.identity is not None: + hosting_meta["identity"] = { + "channel": request.identity.channel, + "native_id": request.identity.native_id, + "attributes": dict(request.identity.attributes) if request.identity.attributes else {}, + } + raw = request.input + if isinstance(raw, Message): + raw.additional_properties = {**(raw.additional_properties or {}), "hosting": hosting_meta} + return raw + if isinstance(raw, list) and raw and all(isinstance(m, Message) for m in raw): + messages: list[Message] = [m for m in raw if isinstance(m, Message)] + last = messages[-1] + last.additional_properties = {**(last.additional_properties or {}), "hosting": hosting_meta} + return messages + # ``raw`` is typed as ``AgentRunInputs`` (str | Content | Message | Sequence[…]). + # The remaining cases are str / Content / Mapping — wrap as a single user message. + return Message( + role="user", + contents=[raw], # type: ignore[list-item] + additional_properties={"hosting": hosting_meta}, + ) + + +__all__ = ["AgentFrameworkHost", "ChannelContext", "logger"] diff --git a/python/packages/hosting/agent_framework_hosting/_isolation.py b/python/packages/hosting/agent_framework_hosting/_isolation.py new file mode 100644 index 00000000000..53fb2f1e548 --- /dev/null +++ b/python/packages/hosting/agent_framework_hosting/_isolation.py @@ -0,0 +1,76 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Per-request isolation keys read from inbound HTTP headers. + +The Foundry Hosted Agents runtime injects two well-known headers on every +request it forwards to the user's container: + +* ``x-agent-user-isolation-key`` — opaque per-user partition key +* ``x-agent-chat-isolation-key`` — opaque per-conversation partition key + +When the headers are present we are running inside (or being driven by) the +Foundry runtime; when they are absent we are running in plain local dev. The +host installs an ASGI middleware in :meth:`AgentFrameworkHost._build_app` +that reads both headers off every inbound HTTP request and pushes them into +the :data:`current_isolation_keys` contextvar for the duration of the +request, then resets it. Providers that need partition-aware storage (most +notably ``FoundryHostedAgentHistoryProvider``) read the contextvar via +:func:`get_current_isolation_keys` and apply the keys to their backend +calls — so app authors don't have to wire any middleware themselves and +channels stay free of Foundry-specific header knowledge. + +The contextvar holds a plain :class:`IsolationKeys` mapping; conversion to +provider-specific types (e.g. Foundry's ``IsolationContext``) happens at +the consuming provider so this module has no provider dependencies. +""" + +from __future__ import annotations + +from contextvars import ContextVar, Token + +__all__ = [ + "ISOLATION_HEADER_CHAT", + "ISOLATION_HEADER_USER", + "IsolationKeys", + "current_isolation_keys", + "get_current_isolation_keys", + "reset_current_isolation_keys", + "set_current_isolation_keys", +] + + +ISOLATION_HEADER_USER = "x-agent-user-isolation-key" +ISOLATION_HEADER_CHAT = "x-agent-chat-isolation-key" + + +class IsolationKeys: + """Per-request Foundry isolation keys lifted off the inbound headers.""" + + def __init__(self, user_key: str | None = None, chat_key: str | None = None) -> None: + self.user_key = user_key + self.chat_key = chat_key + + @property + def is_empty(self) -> bool: + return self.user_key is None and self.chat_key is None + + +current_isolation_keys: ContextVar[IsolationKeys | None] = ContextVar( + "agent_framework_hosting_isolation_keys", + default=None, +) + + +def get_current_isolation_keys() -> IsolationKeys | None: + """Return the isolation keys bound to the current request, if any.""" + return current_isolation_keys.get() + + +def set_current_isolation_keys(keys: IsolationKeys | None) -> Token[IsolationKeys | None]: + """Bind ``keys`` to the current async context and return a reset token.""" + return current_isolation_keys.set(keys) + + +def reset_current_isolation_keys(token: Token[IsolationKeys | None]) -> None: + """Restore the isolation contextvar to its prior value.""" + current_isolation_keys.reset(token) diff --git a/python/packages/hosting/agent_framework_hosting/_persistence.py b/python/packages/hosting/agent_framework_hosting/_persistence.py new file mode 100644 index 00000000000..9ecc4dd23e0 --- /dev/null +++ b/python/packages/hosting/agent_framework_hosting/_persistence.py @@ -0,0 +1,128 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Shared persistence primitives for the hosting package. + +The simplified hosting core keeps disk persistence only for session aliases +created by :meth:`AgentFrameworkHost.reset_session` and for workflow +checkpoint path derivation. The on-disk session-alias store uses the optional +``diskcache`` package installed via the ``[disk]`` extra. +""" + +from __future__ import annotations + +import contextlib +import os +import sys +from collections.abc import Mapping +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ._types import HostStatePaths + +_KNOWN_COMPONENTS: tuple[str, ...] = ("sessions", "checkpoints") + + +def load_diskcache() -> Any: + """Lazy-import :mod:`diskcache` with a helpful error when missing.""" + try: + import diskcache # type: ignore[import-untyped] + except ImportError as exc: # pragma: no cover - exercised via tests by monkeypatching + raise ImportError( + "agent-framework-hosting was asked to persist session aliases to disk " + "(state_dir['sessions'] is set) but the optional `diskcache` dependency " + "is not installed. Install the disk extra: " + "`pip install 'agent-framework-hosting[disk]`." + ) from exc + return diskcache + + +def acquire_state_dir_lock(component_dir: Path) -> Any: + """Acquire an exclusive single-owner lock on a component's state dir. + + Raises: + RuntimeError: If another process already holds the lock. + """ + component_dir.mkdir(parents=True, exist_ok=True) + lock_path = component_dir / ".lock" + fh = open(lock_path, "a+", encoding="utf-8") # noqa: SIM115 - kept open for lifetime + try: + if sys.platform == "win32": + import msvcrt + + try: + msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1) + except OSError as exc: + fh.close() + raise RuntimeError( + f"Another process already holds the hosting state lock at {lock_path}. " + "Point each host at its own state_dir." + ) from exc + else: + import fcntl + + try: + fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as exc: + fh.close() + raise RuntimeError( + f"Another process already holds the hosting state lock at {lock_path}. " + "Point each host at its own state_dir." + ) from exc + except RuntimeError: + raise + except Exception: + fh.close() + raise + return fh + + +def release_state_dir_lock(handle: Any) -> None: + """Release a lock previously acquired by :func:`acquire_state_dir_lock`.""" + if handle is None: + return + with contextlib.suppress(Exception): + handle.close() + + +def normalize_state_dir( + state_dir: str | os.PathLike[str] | HostStatePaths | Mapping[str, str | os.PathLike[str]] | None, +) -> dict[str, Path | None]: + """Resolve the host-level ``state_dir`` parameter into a per-component map. + + Accepts ``None``, a single root path, or a mapping with ``sessions`` and + ``checkpoints`` keys. Unknown keys raise ``ValueError`` so obsolete + ``runner`` / ``links`` configuration is rejected instead of silently + doing nothing. + """ + result: dict[str, Path | None] = {name: None for name in _KNOWN_COMPONENTS} + if state_dir is None: + return result + + if isinstance(state_dir, (str, os.PathLike)): + root = Path(os.fspath(state_dir)) + for name in _KNOWN_COMPONENTS: + result[name] = root / name + return result + + if isinstance(state_dir, Mapping): + unknown = [k for k in state_dir if k not in _KNOWN_COMPONENTS] + if unknown: + raise ValueError( + f"state_dir mapping contains unknown component key(s): {unknown!r}. " + f"Known components are: {list(_KNOWN_COMPONENTS)!r}." + ) + for name in _KNOWN_COMPONENTS: + raw_value: Any = state_dir.get(name) + if raw_value is None: + result[name] = None + continue + if isinstance(raw_value, (str, os.PathLike)): + result[name] = Path(os.fspath(raw_value)) + else: + raise TypeError(f"state_dir[{name!r}] must be a str or PathLike — got {type(raw_value).__name__}") + return result + + raise TypeError( + f"state_dir must be a str, PathLike, HostStatePaths mapping, or None — got {type(state_dir).__name__}" + ) diff --git a/python/packages/hosting/agent_framework_hosting/_state_store.py b/python/packages/hosting/agent_framework_hosting/_state_store.py new file mode 100644 index 00000000000..817f9bd52e1 --- /dev/null +++ b/python/packages/hosting/agent_framework_hosting/_state_store.py @@ -0,0 +1,146 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Disk-backed wrapper for the host's session-alias map. + +``AgentFrameworkHost.reset_session(isolation_key)`` rotates future requests for +that isolation key onto a new session id. Persisting the alias map lets that +rotation survive a host restart without introducing cross-channel identity or +delivery state into the core host. +""" + +from __future__ import annotations + +import logging +import os +from collections.abc import Mapping +from pathlib import Path +from typing import Any, TypeVar + +from ._persistence import ( + acquire_state_dir_lock, + load_diskcache, + release_state_dir_lock, +) + +logger = logging.getLogger(__name__) + +_V = TypeVar("_V") +_ALIASES_PREFIX = "aliases:" + + +class SessionsStateStore: + """One disk cache + lock for host-side session aliases.""" + + def __init__(self, sessions_dir: str | os.PathLike[str]) -> None: + self._sessions_dir: Path = Path(os.fspath(sessions_dir)) + diskcache = load_diskcache() + self._lock_handle: Any = acquire_state_dir_lock(self._sessions_dir) + try: + self._cache: Any = diskcache.Cache(str(self._sessions_dir)) + except Exception: + release_state_dir_lock(self._lock_handle) + self._lock_handle = None + raise + + @property + def cache(self) -> Any: + """Return the underlying :mod:`diskcache` Cache.""" + return self._cache + + def close(self) -> None: + """Close the cache and release the directory lock.""" + if self._cache is not None: + try: + self._cache.close() + except Exception: # pragma: no cover - close errors aren't actionable + logger.exception("SessionsStateStore: failed to close cache cleanly") + self._cache = None + if self._lock_handle is not None: + release_state_dir_lock(self._lock_handle) + self._lock_handle = None + + +class _PersistedDict(dict[str, _V]): + """Drop-in :class:`dict` whose mutations mirror to a diskcache prefix.""" + + def __init__( + self, + store: SessionsStateStore, + key_prefix: str, + initial: Mapping[str, _V] | None = None, + ) -> None: + super().__init__() + self._store = store + self._prefix = key_prefix + cache: Any = store.cache + for raw_key in cache.iterkeys(): + if not isinstance(raw_key, str) or not raw_key.startswith(key_prefix): + continue + try: + value: Any = cache.get(raw_key) + except Exception: + logger.exception("SessionsStateStore: failed to rehydrate %s; skipping", raw_key) + continue + logical_key = raw_key[len(key_prefix) :] + super().__setitem__(logical_key, value) + if initial: + for key, value in initial.items(): + self[key] = value + + def __setitem__(self, key: str, value: _V) -> None: + super().__setitem__(key, value) + try: + self._store.cache.set(self._prefix + key, value) + except Exception: # pragma: no cover - cache write failures aren't actionable + logger.exception("SessionsStateStore: failed to persist %s%s", self._prefix, key) + + def __delitem__(self, key: str) -> None: + super().__delitem__(key) + try: + del self._store.cache[self._prefix + key] + except KeyError: + pass + except Exception: # pragma: no cover - cache write failures aren't actionable + logger.exception("SessionsStateStore: failed to evict %s%s", self._prefix, key) + + def pop(self, key: str, *args: Any) -> _V: + """Mirror ``dict.pop`` to disk.""" + value: _V = super().pop(key, *args) + try: + del self._store.cache[self._prefix + key] + except KeyError: + pass + except Exception: # pragma: no cover + logger.exception("SessionsStateStore: failed to evict %s%s", self._prefix, key) + return value + + def clear(self) -> None: + """Mirror ``dict.clear`` to disk.""" + keys = list(self.keys()) + super().clear() + cache = self._store.cache + for key in keys: + try: + del cache[self._prefix + key] + except KeyError: + pass + except Exception: # pragma: no cover + logger.exception("SessionsStateStore: failed to evict %s%s during clear", self._prefix, key) + + def update( # type: ignore[override] + self, + other: Mapping[str, _V] | None = None, + /, + **kwargs: _V, + ) -> None: + """Mirror ``dict.update`` to disk one item at a time.""" + if other is not None: + for key in other: + self[key] = other[key] + for key, value in kwargs.items(): + self[key] = value + + +def build_session_aliases(store: SessionsStateStore) -> dict[str, str]: + """Return the disk-backed session-alias map for ``store``.""" + return _PersistedDict[str](store, _ALIASES_PREFIX) diff --git a/python/packages/hosting/agent_framework_hosting/_types.py b/python/packages/hosting/agent_framework_hosting/_types.py new file mode 100644 index 00000000000..23c172d9a70 --- /dev/null +++ b/python/packages/hosting/agent_framework_hosting/_types.py @@ -0,0 +1,212 @@ +# Copyright (c) Microsoft. All rights reserved. + +# ``ChannelRequest`` is the only intentional dataclass here (callers use +# ``dataclasses.replace`` on it in run hooks). The other types are plain +# Python classes by preference, so the "could be a dataclass" lint is muted +# at the file level. +# ruff: noqa: B903 + +"""Channel-neutral request envelope and channel protocol types. + +These types form the boundary between the host and individual channels. +A channel parses its native payload, builds a :class:`ChannelRequest`, and +hands it to :class:`ChannelContext.run` (or ``run_stream``) on the host. +The channel owns rendering the result back onto its originating protocol. +""" + +from __future__ import annotations + +import os +from collections.abc import Awaitable, Callable, Mapping, Sequence +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Generic, Protocol, TypedDict, TypeVar, runtime_checkable + +from agent_framework import ( + AgentResponseUpdate, + AgentRunInputs, +) +from starlette.routing import BaseRoute + +if TYPE_CHECKING: + from ._host import ChannelContext + + +class ChannelSession: + """Channel-supplied session hint. + + The host turns this into an ``AgentSession`` keyed by ``isolation_key`` so + every distinct end user gets their own context-provider state (e.g. one + ``FileHistoryProvider`` JSONL file per user). + """ + + def __init__(self, isolation_key: str | None = None) -> None: + self.isolation_key = isolation_key + + +class ChannelIdentity: + """Channel-native identity metadata observed on a request. + + The simplified hosting core records this only on the persisted input + message's ``additional_properties["hosting"]`` block and forwards it + through run/response hooks. Cross-channel linking and recipient lookup are + follow-up concerns, not part of the v1 host contract. + """ + + def __init__( + self, + channel: str, + native_id: str, + attributes: Mapping[str, Any] | None = None, + ) -> None: + self.channel = channel + self.native_id = native_id + self.attributes: Mapping[str, Any] = attributes if attributes is not None else dict() + + +@dataclass +class ChannelRequest: + """Uniform invocation envelope every channel produces from its native payload. + + Kept as a dataclass so app authors can use ``dataclasses.replace(...)`` in + run hooks to produce a modified envelope without re-listing every field. + """ + + channel: str + operation: str + input: AgentRunInputs + session: ChannelSession | None = None + options: Mapping[str, Any] | None = None + session_mode: str = "auto" + metadata: Mapping[str, Any] = field(default_factory=lambda: {}) + attributes: Mapping[str, Any] = field(default_factory=lambda: {}) + stream: bool = False + identity: ChannelIdentity | None = None + + +class ChannelCommand: + """A discoverable command a channel exposes to its users (e.g. ``/reset``).""" + + def __init__( + self, + name: str, + description: str, + handle: Callable[[ChannelCommandContext], Awaitable[None]], + ) -> None: + self.name = name + self.description = description + self.handle = handle + + +class ChannelCommandContext: + """Context passed to a :class:`ChannelCommand` handler.""" + + def __init__( + self, + request: ChannelRequest, + reply: Callable[[str], Awaitable[None]], + ) -> None: + self.request = request + self.reply = reply + + +_EMPTY_ROUTES: tuple[BaseRoute, ...] = () +_EMPTY_COMMANDS: tuple[ChannelCommand, ...] = () +_EMPTY_LIFECYCLE: tuple[Callable[[], Awaitable[None]], ...] = () + + +class ChannelContribution: + """Routes, commands, and lifecycle hooks a channel contributes to the host.""" + + def __init__( + self, + routes: Sequence[BaseRoute] = _EMPTY_ROUTES, + commands: Sequence[ChannelCommand] = _EMPTY_COMMANDS, + on_startup: Sequence[Callable[[], Awaitable[None]]] = _EMPTY_LIFECYCLE, + on_shutdown: Sequence[Callable[[], Awaitable[None]]] = _EMPTY_LIFECYCLE, + ) -> None: + self.routes = routes + self.commands = commands + self.on_startup = on_startup + self.on_shutdown = on_shutdown + + +class _Unset: + """Sentinel for ``HostedRunResult.replace`` overrides. + + Distinguishes "caller did not pass this kwarg" from "caller passed + ``None`` explicitly" — needed because ``session`` is ``None`` in + many envelopes and we want the no-arg call to preserve it. + """ + + +_UNSET = _Unset() + + +TResult = TypeVar("TResult") + + +class HostedRunResult(Generic[TResult]): + """Channel-neutral envelope around the target's full-fidelity result. + + The host does not flatten or pre-shape the target output. Channels and + response hooks read the underlying result type directly and serialize the + subset their wire format can carry. + """ + + def __init__( + self, + result: TResult, + *, + session: Any | None = None, + ) -> None: + self.result = result + self.session = session + + def replace( + self, + *, + result: TResult | _Unset = _UNSET, + session: Any | _Unset | None = _UNSET, + ) -> HostedRunResult[TResult]: + """Return a shallow copy with the supplied fields overridden.""" + new: HostedRunResult[TResult] = HostedRunResult.__new__(HostedRunResult) # pyright: ignore[reportUnknownVariableType] + new.result = self.result if isinstance(result, _Unset) else result + new.session = self.session if isinstance(session, _Unset) else session + return new + + +class HostStatePaths(TypedDict, total=False): + """Per-component disk paths for host-managed state. + + Only session aliases and workflow checkpoints remain in the simplified + host. Linking stores, active-channel maps, identity registries, and runner + queues are follow-up concerns. + """ + + sessions: str | os.PathLike[str] + """Where the host persists session aliases created by ``reset_session``.""" + + checkpoints: str | os.PathLike[str] + """Where the host persists workflow checkpoints for ``Workflow`` targets.""" + + +ChannelStreamUpdateHook = Callable[ + [AgentResponseUpdate], + "AgentResponseUpdate | Awaitable[AgentResponseUpdate | None] | None", +] + + +ChannelRunHook = Callable[..., "Awaitable[ChannelRequest] | ChannelRequest"] + + +ChannelResponseHook = Callable[..., "Awaitable[HostedRunResult[Any]] | HostedRunResult[Any]"] + + +@runtime_checkable +class Channel(Protocol): + """A pluggable adapter that exposes one transport on the host.""" + + name: str + path: str + + def contribute(self, context: ChannelContext) -> ChannelContribution: ... diff --git a/python/packages/hosting/pyproject.toml b/python/packages/hosting/pyproject.toml new file mode 100644 index 00000000000..f412c84c293 --- /dev/null +++ b/python/packages/hosting/pyproject.toml @@ -0,0 +1,110 @@ +[project] +name = "agent-framework-hosting" +description = "Multi-channel hosting for Microsoft Agent Framework agents." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] +readme = "README.md" +requires-python = ">=3.10" +version = "1.0.0a260424" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core>=1.2.0,<2", + "starlette>=0.37", +] + +[project.optional-dependencies] +serve = [ + "hypercorn>=0.17", +] +disk = [ + "diskcache>=5.6", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" + +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [] +timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.coverage.run] +omit = [ + "**/__init__.py" +] + +[tool.pyright] +extends = "../../pyproject.toml" +include = ["agent_framework_hosting"] +exclude = ['tests'] + +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true + +[tool.bandit] +targets = ["agent_framework_hosting"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_hosting" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_hosting --cov-report=term-missing:skip-covered tests' + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" + +[dependency-groups] +dev = [ + "httpx>=0.28.1", +] diff --git a/python/packages/hosting/tests/__init__.py b/python/packages/hosting/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/hosting/tests/_workflow_fixtures.py b/python/packages/hosting/tests/_workflow_fixtures.py new file mode 100644 index 00000000000..f59bb8cab8e --- /dev/null +++ b/python/packages/hosting/tests/_workflow_fixtures.py @@ -0,0 +1,43 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Workflow fixtures for hosting tests. + +Defined in a module that does not use ``from __future__ import annotations`` +because the workflow handler validation reflects on real annotation objects +rather than stringified forms. +""" + +from agent_framework import Executor, Workflow, WorkflowBuilder, WorkflowContext, handler + + +class _UpperExecutor(Executor): + @handler + async def handle(self, text: str, ctx: WorkflowContext[str]) -> None: + await ctx.yield_output(text.upper()) + + +class _EchoExecutor(Executor): + @handler + async def handle(self, text: str, ctx: WorkflowContext[str]) -> None: + await ctx.yield_output(text) + + +def build_upper_workflow() -> Workflow: + return WorkflowBuilder(start_executor=_UpperExecutor(id="upper")).build() + + +def build_echo_workflow() -> Workflow: + return WorkflowBuilder(start_executor=_EchoExecutor(id="echo")).build() + + +class _MultiChunkExecutor(Executor): + """Yields three separate ``output`` events so streaming has something to chew on.""" + + @handler + async def handle(self, text: str, ctx: WorkflowContext[str]) -> None: + for chunk in (f"{text}-1", f"{text}-2", f"{text}-3"): + await ctx.yield_output(chunk) + + +def build_multi_chunk_workflow() -> Workflow: + return WorkflowBuilder(start_executor=_MultiChunkExecutor(id="multi")).build() diff --git a/python/packages/hosting/tests/conftest.py b/python/packages/hosting/tests/conftest.py new file mode 100644 index 00000000000..aa677567126 --- /dev/null +++ b/python/packages/hosting/tests/conftest.py @@ -0,0 +1,25 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Pytest configuration for hosting tests.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + + +def pytest_configure() -> None: + """Make local workflow fixtures importable in package and aggregate test modes.""" + module_name = "tests._workflow_fixtures" + if module_name in sys.modules: + return + + fixture_path = Path(__file__).with_name("_workflow_fixtures.py") + spec = importlib.util.spec_from_file_location(module_name, fixture_path) + if spec is None or spec.loader is None: + raise ImportError(f"Unable to load workflow fixtures from {fixture_path}") + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) diff --git a/python/packages/hosting/tests/test_host.py b/python/packages/hosting/tests/test_host.py new file mode 100644 index 00000000000..1bbf53a7cde --- /dev/null +++ b/python/packages/hosting/tests/test_host.py @@ -0,0 +1,1337 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for :class:`AgentFrameworkHost` invocation, session, and delivery routing.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Sequence +from dataclasses import dataclass, field +from typing import Any + +import pytest +from agent_framework import AgentResponse, AgentResponseUpdate, Content, Message, ResponseStream +from agent_framework._workflows._events import WorkflowEvent +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import BaseRoute, Route +from starlette.testclient import TestClient + +from agent_framework_hosting import ( + AgentFrameworkHost, + Channel, + ChannelContext, + ChannelContribution, + ChannelIdentity, + ChannelRequest, + ChannelSession, + HostedRunResult, +) +from agent_framework_hosting._host import _workflow_event_to_update + + +async def _ping(_request: Request) -> JSONResponse: + return JSONResponse({"ok": True}) + + +# --------------------------------------------------------------------------- # +# Fakes # +# --------------------------------------------------------------------------- # + + +@dataclass +class _FakeAgentSession: + session_id: str | None = None + service_session_id: str | None = None + + +@dataclass +class _FakeAgentResponse: + text: str + + @property + def messages(self) -> list[Message]: + # Real ``AgentResponse`` carries a list of messages; the host's + # ``_invoke`` forwards them on the ``HostedRunResult``. Synthesise + # a single assistant text message so tests that assert on + # ``payload.text`` keep working unchanged. + return [Message(role="assistant", contents=[Content.from_text(text=self.text)])] + + +class _FakeAgent: + """Minimal :class:`SupportsAgentRun` implementation that records invocations.""" + + def __init__(self, reply: str = "ok") -> None: + self._reply = reply + self.calls: list[dict[str, Any]] = [] + self.created_sessions: list[_FakeAgentSession] = [] + + def create_session(self, *, session_id: str | None = None) -> _FakeAgentSession: + s = _FakeAgentSession(session_id=session_id) + self.created_sessions.append(s) + return s + + def run(self, messages: Any = None, *, stream: bool = False, session: Any = None, **kwargs: Any) -> Any: + self.calls.append({"messages": messages, "stream": stream, "session": session, "kwargs": kwargs}) + if stream: + updates = [AgentResponseUpdate(contents=[Content.from_text(text=self._reply)], role="assistant")] + + async def _gen() -> AsyncIterator[AgentResponseUpdate]: + for update in updates: + yield update + + async def _finalize(items: Sequence[AgentResponseUpdate]) -> AgentResponse: # noqa: RUF029 + return AgentResponse.from_updates(items) + + return ResponseStream[AgentResponseUpdate, AgentResponse](_gen(), finalizer=_finalize) + + async def _coro() -> _FakeAgentResponse: + return _FakeAgentResponse(text=self._reply) + + return _coro() + + +class _RecordingChannel: + """Minimal :class:`Channel` for host tests.""" + + def __init__(self, name: str = "fake", path: str = "/fake") -> None: + self.name = name + self.path = path + self.context: ChannelContext | None = None + # Provide a single trivial route so contribute() exercises the endpoint path. + self._routes: Sequence[BaseRoute] = (Route("/ping", _ping),) + + def contribute(self, context: ChannelContext) -> ChannelContribution: + self.context = context + return ChannelContribution(routes=self._routes) + + +def _assistant_response(text: str) -> AgentResponse: + """Build a one-message ``AgentResponse`` to use as a ``HostedRunResult.result``.""" + return AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text(text=text)])]) + + +def _make_reply(text: str = "reply") -> HostedRunResult[AgentResponse]: + """Build a ``HostedRunResult[AgentResponse]`` carrying a single assistant text message. + + Test ergonomic mirroring what the host's ``_invoke`` produces for an + agent target — channels (and our delivery tests) receive a typed + envelope whose ``result`` is a real :class:`AgentResponse`. + """ + return HostedRunResult(_assistant_response(text)) + + +@dataclass +class _LifecycleChannel: + name: str = "lifecycle" + path: str = "" + started: list[str] = field(default_factory=list) + stopped: list[str] = field(default_factory=list) + + def contribute(self, context: ChannelContext) -> ChannelContribution: + async def on_start() -> None: + self.started.append("up") + + async def on_stop() -> None: + self.stopped.append("down") + + return ChannelContribution(on_startup=[on_start], on_shutdown=[on_stop]) + + +# --------------------------------------------------------------------------- # +# Host wiring # +# --------------------------------------------------------------------------- # + + +class TestHostWiring: + def test_channel_is_recognized(self) -> None: + ch = _RecordingChannel() + assert isinstance(ch, Channel) + + def test_app_mounts_channel_routes_under_path(self) -> None: + agent = _FakeAgent() + ch = _RecordingChannel(path="/fake") + host = AgentFrameworkHost(target=agent, channels=[ch]) + + with TestClient(host.app) as client: + r = client.get("/fake/ping") + assert r.status_code == 200 + assert r.json() == {"ok": True} + + def test_app_mounts_root_route_at_exact_channel_path(self) -> None: + agent = _FakeAgent() + ch = _RecordingChannel(path="/fake") + ch._routes = (Route("/", _ping),) + host = AgentFrameworkHost(target=agent, channels=[ch]) + + with TestClient(host.app, follow_redirects=False) as client: + r = client.get("/fake") + assert r.status_code == 200 + assert r.json() == {"ok": True} + assert client.get("/fake/").status_code == 200 + + def test_app_mounts_at_root_when_path_is_empty(self) -> None: + agent = _FakeAgent() + ch = _RecordingChannel(path="") + host = AgentFrameworkHost(target=agent, channels=[ch]) + + with TestClient(host.app) as client: + r = client.get("/ping") + assert r.status_code == 200 + + def test_app_is_cached(self) -> None: + host = AgentFrameworkHost(target=_FakeAgent(), channels=[_RecordingChannel()]) + assert host.app is host.app + + def test_lifespan_invokes_startup_and_shutdown(self) -> None: + agent = _FakeAgent() + ch = _LifecycleChannel() + host = AgentFrameworkHost(target=agent, channels=[ch]) + with TestClient(host.app): + assert ch.started == ["up"] + assert ch.stopped == ["down"] + + def test_app_exposes_readiness_probe(self) -> None: + host = AgentFrameworkHost(target=_FakeAgent(), channels=[_RecordingChannel()]) + with TestClient(host.app) as client: + r = client.get("/readiness") + assert r.status_code == 200 + assert r.text == "ok" + + +# --------------------------------------------------------------------------- # +# Invoke + sessions # +# --------------------------------------------------------------------------- # + + +class TestHostInvoke: + async def test_invoke_wraps_input_with_hosting_metadata(self) -> None: + agent = _FakeAgent(reply="hello") + ch = _RecordingChannel(name="responses") + host = AgentFrameworkHost(target=agent, channels=[ch]) + # Force ``app`` build to trigger ``contribute``. + _ = host.app + assert ch.context is not None + + req = ChannelRequest( + channel="responses", + operation="message.create", + input="hi", + session=ChannelSession(isolation_key="user:1"), + identity=ChannelIdentity(channel="responses", native_id="user:1"), + ) + result = await ch.context.run(req) + + assert result.result.text == "hello" + assert len(agent.calls) == 1 + msg = agent.calls[0]["messages"] + assert msg.role == "user" + assert msg.additional_properties["hosting"]["channel"] == "responses" + assert msg.additional_properties["hosting"]["identity"] == { + "channel": "responses", + "native_id": "user:1", + "attributes": {}, + } + + async def test_invoke_caches_session_per_isolation_key(self) -> None: + agent = _FakeAgent() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + req_a = ChannelRequest( + channel=ch.name, operation="op", input="1", session=ChannelSession(isolation_key="alice") + ) + req_b = ChannelRequest( + channel=ch.name, operation="op", input="2", session=ChannelSession(isolation_key="alice") + ) + req_c = ChannelRequest(channel=ch.name, operation="op", input="3", session=ChannelSession(isolation_key="bob")) + + await ch.context.run(req_a) + await ch.context.run(req_b) + await ch.context.run(req_c) + + # Two distinct sessions created (alice, bob) — never re-created. + assert len(agent.created_sessions) == 2 + assert agent.calls[0]["session"] is agent.calls[1]["session"] + assert agent.calls[0]["session"] is not agent.calls[2]["session"] + + async def test_session_disabled_does_not_create_session(self) -> None: + agent = _FakeAgent() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest( + channel=ch.name, + operation="op", + input="x", + session=ChannelSession(isolation_key="alice"), + session_mode="disabled", + ) + await ch.context.run(req) + assert agent.created_sessions == [] + assert agent.calls[0]["session"] is None + + async def test_reset_session_rotates_id_and_drops_cache(self) -> None: + agent = _FakeAgent() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest(channel=ch.name, operation="op", input="x", session=ChannelSession(isolation_key="alice")) + await ch.context.run(req) + first_session = agent.calls[-1]["session"] + assert first_session.session_id == "alice" + + host.reset_session("alice") + await ch.context.run(req) + second_session = agent.calls[-1]["session"] + # New session, new id (alias rotation), distinct object. + assert second_session is not first_session + assert second_session.session_id != "alice" + assert second_session.session_id.startswith("alice#") + + async def test_options_propagates_to_target_run(self) -> None: + agent = _FakeAgent() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest( + channel=ch.name, + operation="op", + input="x", + session=ChannelSession(isolation_key="alice"), + options={"temperature": 0.4}, + ) + await ch.context.run(req) + assert agent.calls[0]["kwargs"]["options"] == {"temperature": 0.4} + + +class TestHostOwnedHooks: + async def test_context_run_applies_run_hook_before_invocation(self) -> None: + agent = _FakeAgent() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + captured: dict[str, Any] = {} + + async def hook(request: ChannelRequest, **kwargs: Any) -> ChannelRequest: + captured["target"] = kwargs["target"] + captured["protocol_request"] = kwargs["protocol_request"] + return ChannelRequest( + channel=request.channel, + operation=request.operation, + input="rewritten", + session=request.session, + ) + + req = ChannelRequest(channel=ch.name, operation="op", input="original", session=ChannelSession("alice")) + await ch.context.run(req, run_hook=hook, protocol_request={"raw": True}) + + assert captured["target"] is agent + assert captured["protocol_request"] == {"raw": True} + assert agent.calls[0]["messages"].text == "rewritten" + + async def test_context_run_stream_applies_run_hook_before_opening_stream(self) -> None: + agent = _FakeAgent() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + def hook(request: ChannelRequest, **_: Any) -> ChannelRequest: + return ChannelRequest(channel=request.channel, operation=request.operation, input="streamed") + + stream = await ch.context.run_stream( + ChannelRequest(channel=ch.name, operation="op", input="original"), + run_hook=hook, + stream_update_hook=lambda update: AgentResponseUpdate( + contents=[Content.from_text(text=update.text.upper())], + role="assistant", + ), + ) + + chunks = [update.text async for update in stream] + assert chunks == ["OK"] + assert agent.calls[0]["messages"].text == "streamed" + + +# --------------------------------------------------------------------------- # +# Workflow target # +# --------------------------------------------------------------------------- # + + +class TestHostWorkflowTarget: + """The host accepts a ``Workflow`` and dispatches to ``workflow.run(...)``.""" + + async def test_invoke_workflow_collapses_outputs_to_hosted_run_result(self) -> None: + from ._workflow_fixtures import build_upper_workflow + + workflow = build_upper_workflow() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=workflow, channels=[ch]) + _ = host.app + assert ch.context is not None + + # The channel's run_hook is the canonical adapter from a free-form input + # to a workflow's typed input; here the start executor accepts ``str`` + # already so the channel forwards ``input`` verbatim. + req = ChannelRequest(channel="fake", operation="message.create", input="hello") + result = await ch.context.run(req) + + assert list(result.result.get_outputs()) == ["HELLO"] + # No session caching for workflow targets — Workflow has no + # ``create_session`` and the host must not invent one. + assert host._sessions == {} + + async def test_stream_workflow_yields_updates_and_finalizes(self) -> None: + from ._workflow_fixtures import build_echo_workflow + + workflow = build_echo_workflow() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=workflow, channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest(channel="fake", operation="message.create", input="hi") + stream = await ch.context.run_stream(req) + + updates: list[AgentResponseUpdate] = [] + async for update in stream: + updates.append(update) + + # The echo workflow yields a single ``output`` event whose payload is + # the original string; the host wraps non-update payloads into a + # one-shot ``AgentResponseUpdate`` carrying the text. + assert [u.text for u in updates] == ["hi"] + # ``raw_representation`` preserves the source ``WorkflowEvent`` so + # advanced consumers (telemetry, debug UIs) can recover the full + # workflow timeline. + assert all(u.raw_representation is not None for u in updates) + + final = await stream.get_final_response() + assert final.text == "hi" + + async def test_stream_workflow_yields_one_update_per_output_event(self) -> None: + from ._workflow_fixtures import build_multi_chunk_workflow + + workflow = build_multi_chunk_workflow() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=workflow, channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest(channel="fake", operation="message.create", input="x") + stream = await ch.context.run_stream(req) + + chunks: list[str] = [] + async for update in stream: + chunks.append(update.text) + # The originating ``executor_id`` is propagated via author_name so + # multi-agent workflows can route per-author rendering downstream. + assert update.author_name == "multi" + + assert chunks == ["x-1", "x-2", "x-3"] + final = await stream.get_final_response() + assert final.text == "x-1x-2x-3" + + def test_workflow_event_to_update_drops_non_output_events(self) -> None: + event = WorkflowEvent("intermediate", executor_id="worker", data="hidden") + + assert _workflow_event_to_update(event) is None + + def test_workflow_event_to_update_preserves_agent_response_update_payload(self) -> None: + event = WorkflowEvent( + "output", + executor_id="worker", + data=AgentResponseUpdate(contents=[Content.from_text("chunk")], role="assistant"), + ) + + update = _workflow_event_to_update(event) + + assert update is event.data + assert update.raw_representation is event + + def test_workflow_event_to_update_preserves_content_payload(self) -> None: + content = Content.from_data(data=b"\x89PNG", media_type="image/png", raw_representation={"source": "test"}) + event = WorkflowEvent("output", executor_id="worker", data=content) + + update = _workflow_event_to_update(event) + + assert update is not None + assert update.contents == [content] + assert update.contents[0].raw_representation == {"source": "test"} + assert update.author_name == "worker" + assert update.raw_representation is event + + +class TestHostWorkflowCheckpointing: + """The host scopes per-conversation checkpoints when ``checkpoint_location`` is set.""" + + def test_rejects_workflow_with_existing_checkpoint_storage(self, tmp_path: Any) -> None: + from agent_framework import InMemoryCheckpointStorage, WorkflowBuilder + + from ._workflow_fixtures import _UpperExecutor + + workflow = WorkflowBuilder( + start_executor=_UpperExecutor(id="upper"), + checkpoint_storage=InMemoryCheckpointStorage(), + ).build() + with pytest.raises(RuntimeError, match="already has checkpoint storage"): + AgentFrameworkHost( + target=workflow, + channels=[_RecordingChannel()], + checkpoint_location=tmp_path, + ) + + def test_warns_when_target_is_agent(self, tmp_path: Any, caplog: Any) -> None: + import logging as _logging + + agent = _FakeAgent() + with caplog.at_level(_logging.WARNING, logger="agent_framework.hosting"): + host = AgentFrameworkHost(target=agent, channels=[_RecordingChannel()], checkpoint_location=tmp_path) + assert host._checkpoint_location is None + assert any("checkpoint_location" in rec.message for rec in caplog.records) + + async def test_invoke_skips_checkpointing_when_no_isolation_key(self, tmp_path: Any) -> None: + from ._workflow_fixtures import build_upper_workflow + + workflow = build_upper_workflow() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=workflow, channels=[ch], checkpoint_location=tmp_path) + _ = host.app + assert ch.context is not None + + # No session -> no scoping key -> no checkpoint storage written. + req = ChannelRequest(channel="fake", operation="message.create", input="hi") + result = await ch.context.run(req) + + assert list(result.result.get_outputs()) == ["HI"] + assert list(tmp_path.iterdir()) == [] + + async def test_invoke_writes_checkpoint_under_isolation_key(self, tmp_path: Any) -> None: + from ._workflow_fixtures import build_upper_workflow + + workflow = build_upper_workflow() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=workflow, channels=[ch], checkpoint_location=tmp_path) + _ = host.app + assert ch.context is not None + + req = ChannelRequest( + channel="fake", + operation="message.create", + input="hi", + session=ChannelSession(isolation_key="alice"), + ) + result = await ch.context.run(req) + assert list(result.result.get_outputs()) == ["HI"] + + # FileCheckpointStorage rooted at / should + # have produced at least one checkpoint file scoped to that user. + scoped = tmp_path / "alice" + assert scoped.exists() + assert any(scoped.iterdir()), "expected at least one checkpoint to be written under the per-user dir" + + async def test_stream_writes_checkpoint_under_isolation_key(self, tmp_path: Any) -> None: + from ._workflow_fixtures import build_echo_workflow + + workflow = build_echo_workflow() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=workflow, channels=[ch], checkpoint_location=tmp_path) + _ = host.app + assert ch.context is not None + + req = ChannelRequest( + channel="fake", + operation="message.create", + input="hi", + session=ChannelSession(isolation_key="bob"), + ) + stream = await ch.context.run_stream(req) + async for _ in stream: + pass + await stream.get_final_response() + + scoped = tmp_path / "bob" + assert scoped.exists() + assert any(scoped.iterdir()) + + async def test_caller_supplied_checkpoint_storage_used_as_is(self, tmp_path: Any) -> None: + from agent_framework import InMemoryCheckpointStorage + + from ._workflow_fixtures import build_upper_workflow + + storage = InMemoryCheckpointStorage() + workflow = build_upper_workflow() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=workflow, channels=[ch], checkpoint_location=storage) + _ = host.app + assert ch.context is not None + assert host._checkpoint_location is storage + + req = ChannelRequest( + channel="fake", + operation="message.create", + input="hi", + session=ChannelSession(isolation_key="carol"), + ) + await ch.context.run(req) + + # The caller-owned storage is used directly (no per-user scoping + # applied by the host); a checkpoint should appear in it. + checkpoints = await storage.list_checkpoints(workflow_name=workflow.name) + assert checkpoints, "expected the caller-supplied storage to receive a checkpoint" + # And nothing should have been written into the tmp_path tree. + assert list(tmp_path.iterdir()) == [] + + +class TestCheckpointPathForIsolationKey: + """Path-traversal hardening for isolation keys joined into checkpoint paths.""" + + @pytest.mark.parametrize( + "isolation_key", + [ + "alice", + "telegram:42", + "entra:abc-def_0123", + "responses:user.name", + "x" * 200, + ], + ) + def test_accepts_legitimate_keys(self, tmp_path: Any, isolation_key: str) -> None: + from agent_framework_hosting._host import _checkpoint_path_for_isolation_key + + target = _checkpoint_path_for_isolation_key(tmp_path, isolation_key) + assert target == (tmp_path / isolation_key).resolve() + assert target.is_relative_to(tmp_path.resolve()) + + @pytest.mark.parametrize( + "isolation_key", + [ + "", + ".", + "..", + "...", + "../etc", + "../../etc/passwd", + "a/b", + "a\\b", + "with\x00nul", + "/abs/path", + "C:/foo", + "C:foo", + ], + ) + def test_rejects_traversal_patterns(self, tmp_path: Any, isolation_key: str) -> None: + from agent_framework_hosting._host import _checkpoint_path_for_isolation_key + + with pytest.raises(ValueError, match="isolation_key"): + _checkpoint_path_for_isolation_key(tmp_path, isolation_key) + + def test_rejects_non_string(self, tmp_path: Any) -> None: + from agent_framework_hosting._host import _checkpoint_path_for_isolation_key + + with pytest.raises(ValueError, match="non-empty string"): + _checkpoint_path_for_isolation_key(tmp_path, None) # type: ignore[arg-type] + + +class TestHostWorkflowCheckpointingPathTraversal: + """End-to-end: malicious isolation keys must not escape ``checkpoint_location``.""" + + async def test_traversal_key_skips_checkpointing_with_warning(self, tmp_path: Any, caplog: Any) -> None: + import logging as _logging + + from ._workflow_fixtures import build_upper_workflow + + workflow = build_upper_workflow() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=workflow, channels=[ch], checkpoint_location=tmp_path) + _ = host.app + assert ch.context is not None + + req = ChannelRequest( + channel="fake", + operation="message.create", + input="hi", + session=ChannelSession(isolation_key="../escape"), + ) + with caplog.at_level(_logging.WARNING, logger="agent_framework.hosting"): + result = await ch.context.run(req) + + assert list(result.result.get_outputs()) == ["HI"] + # Nothing should have been written under tmp_path. + assert list(tmp_path.iterdir()) == [] + assert any( + "Skipping checkpoint storage" in rec.message and "isolation_key" in rec.message for rec in caplog.records + ) + + async def test_separator_in_key_skips_checkpointing(self, tmp_path: Any) -> None: + from ._workflow_fixtures import build_upper_workflow + + workflow = build_upper_workflow() + ch = _RecordingChannel() + host = AgentFrameworkHost(target=workflow, channels=[ch], checkpoint_location=tmp_path) + _ = host.app + assert ch.context is not None + + # A literal separator in the key is a configuration smell at best + # and an attack at worst; either way it must not create a sub-path. + req = ChannelRequest( + channel="fake", + operation="message.create", + input="hi", + session=ChannelSession(isolation_key="evil/sub"), + ) + result = await ch.context.run(req) + + assert list(result.result.get_outputs()) == ["HI"] + assert list(tmp_path.iterdir()) == [] + + +# --------------------------------------------------------------------------- # +# HostedRunResult — generic typed envelope # +# --------------------------------------------------------------------------- # + + +class TestHostedRunResult: + """The envelope is a thin generic wrapper around the target's + full-fidelity ``result`` plus an optional session reference. The + host does NOT pre-shape or flatten ``result.messages`` / + ``result.get_outputs()`` — channels read the canonical accessor on + the underlying result type themselves.""" + + def test_result_field_carries_full_fidelity_payload(self) -> None: + resp = AgentResponse( + messages=[Message(role="assistant", contents=[Content.from_text("hello")])], + response_id="r-1", + ) + env: HostedRunResult[AgentResponse] = HostedRunResult(resp) + # ``result`` is the canonical accessor; metadata like + # ``response_id`` round-trips through unchanged because the host + # never re-shapes the payload. + assert env.result is resp + assert env.result.text == "hello" + assert env.result.response_id == "r-1" + assert env.session is None + + def test_session_field_attached_and_optional(self) -> None: + resp = _assistant_response("ok") + session = _FakeAgentSession(session_id="sess-1") + env = HostedRunResult(resp, session=session) + assert env.session is session + + def test_replace_clones_envelope_without_touching_result_by_default(self) -> None: + resp = _assistant_response("orig") + original = HostedRunResult(resp, session=_FakeAgentSession(session_id="s")) + clone = original.replace() + # Clone is a distinct envelope but the inner ``result`` is the + # same object — channels that need a deep copy of ``result`` + # itself do the copy themselves. + assert clone is not original + assert clone.result is original.result + assert clone.session is original.session + + def test_replace_rebinds_result_without_perturbing_original(self) -> None: + original = HostedRunResult(_assistant_response("orig")) + clone = original.replace(result=_assistant_response("shaped")) + assert original.result.text == "orig" + assert clone.result.text == "shaped" + + def test_replace_supports_explicit_none_session(self) -> None: + original = HostedRunResult(_assistant_response("x"), session=_FakeAgentSession(session_id="s")) + clone = original.replace(session=None) + assert clone.session is None + # Source envelope untouched. + assert original.session is not None + + async def test_invoke_preserves_full_agent_response_on_result(self) -> None: + """The host's ``_invoke`` carries the agent's ``AgentResponse`` + through unchanged on ``result``. Channels see image / tool / + structured content alongside text — and metadata like + ``response_id`` — without the host pre-shaping anything.""" + + class _MultiModalResponse: + def __init__(self) -> None: + self.text = "summary" + self.response_id = "resp-xyz" + self.messages = [ + Message( + role="assistant", + contents=[ + Content.from_text("summary"), + # Non-text content the host must NOT drop. + Content.from_data(data=b"\x89PNG", media_type="image/png"), + ], + ), + ] + + class _MultiModalAgent: + def create_session(self, *, session_id: str | None = None) -> _FakeAgentSession: + return _FakeAgentSession(session_id=session_id) + + async def run(self, *_args: Any, **_kwargs: Any) -> Any: + return _MultiModalResponse() + + ch = _RecordingChannel(name="responses") + host = AgentFrameworkHost(target=_MultiModalAgent(), channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest(channel="responses", operation="op", input="hi") + env = await ch.context.run(req) + # Full agent response carried through verbatim — no flattening. + assert env.result.text == "summary" + assert env.result.response_id == "resp-xyz" + assert len(env.result.messages) == 1 + types = [c.type for c in env.result.messages[0].contents] + assert "text" in types and "data" in types + + +# --------------------------------------------------------------------------- # +# Bind request context — duck-typed hook on context providers # +# --------------------------------------------------------------------------- # + + +from contextlib import contextmanager # noqa: E402 + + +class _RecordingContextProvider: + """Stand-in for a ``HistoryProvider`` that exposes the duck-typed + ``bind_request_context(response_id=..., previous_response_id=..., **_)`` + seam the host calls. Records (event, payload) pairs so tests can + assert call ordering relative to the agent run + stream lifecycle. + """ + + def __init__(self, *, name: str = "rec") -> None: + self.name = name + # (event, payload) tuples — events: "enter", "exit", "agent_start", + # "agent_end", "stream_yield", "stream_done". + self.events: list[tuple[str, Any]] = [] + + @contextmanager + def bind_request_context(self, **kwargs: Any) -> Any: + # Snapshot the call kwargs on enter (so tests can assert + # response_id / previous_response_id forwarding) and the same + # snapshot on exit so we can verify the SAME payload bracketed + # the agent run. + snapshot = dict(kwargs) + self.events.append(("enter", snapshot)) + try: + yield + finally: + self.events.append(("exit", snapshot)) + + +class _ProvidersAgent: + """Agent stand-in that exposes ``context_providers`` so the host's + ``_flat_context_providers`` finds the recording provider. + + Mirrors the real :class:`agent_framework.Agent.run` shape: a sync + ``def`` that returns either an ``Awaitable[AgentResponse]`` (for + ``stream=False``) or a :class:`ResponseStream` synchronously (for + ``stream=True``). The host's ``_invoke_stream`` relies on the sync + return so it can wrap the stream in ``_BoundResponseStream`` and + hand it to channels for later iteration. + """ + + def __init__(self, providers: Sequence[Any], *, reply: str = "ok") -> None: + self.context_providers = list(providers) + self._reply = reply + self.calls: list[dict[str, Any]] = [] + + def create_session(self, *, session_id: str | None = None) -> _FakeAgentSession: + return _FakeAgentSession(session_id=session_id) + + def run( + self, + messages: Any = None, + *, + stream: bool = False, + session: Any = None, + **kwargs: Any, + ) -> Any: + self.calls.append({"messages": messages, "stream": stream, "session": session, "kwargs": kwargs}) + + if stream: + providers = self.context_providers + updates = [ + AgentResponseUpdate(contents=[Content.from_text("chunk-1")], role="assistant"), + AgentResponseUpdate(contents=[Content.from_text("chunk-2")], role="assistant"), + ] + + async def _gen() -> AsyncIterator[AgentResponseUpdate]: + # ``agent_start`` is only recorded once iteration begins; + # if the channel abandons the stream without iterating + # we expect to see neither ``agent_start`` nor any + # ``stream_yield`` events. + for prov in providers: + if isinstance(prov, _RecordingContextProvider): + prov.events.append(("agent_start", None)) + for u in updates: + for prov in providers: + if isinstance(prov, _RecordingContextProvider): + prov.events.append(("stream_yield", u.text)) + yield u + + async def _finalize(items: Sequence[AgentResponseUpdate]) -> AgentResponse: # noqa: RUF029 + for prov in providers: + if isinstance(prov, _RecordingContextProvider): + prov.events.append(("stream_done", len(items))) + return AgentResponse.from_updates(items) + + return ResponseStream[AgentResponseUpdate, AgentResponse](_gen(), finalizer=_finalize) + + async def _coro() -> _FakeAgentResponse: + for prov in self.context_providers: + if isinstance(prov, _RecordingContextProvider): + prov.events.append(("agent_start", None)) + prov.events.append(("agent_end", None)) + return _FakeAgentResponse(text=self._reply) + + return _coro() + + +class _ProviderWrapper: + """Wrap children in a ``providers`` attribute (mirrors the + ``ContextProviderBase`` aggregation shape).""" + + def __init__(self, providers: Sequence[Any]) -> None: + self.providers = list(providers) + + +class TestBindRequestContext: + """The host walks ``target.context_providers``, descends one level + when a provider exposes a ``providers`` attribute, and calls + ``bind_request_context(response_id=..., previous_response_id=...)`` + on every provider that supports it. Foundry response-id chaining + plugs into this exact seam — a regression that mistypes the kwarg + name, drops the descent, or fails to keep the binding open across + the agent run silently breaks chained writes.""" + + async def test_bind_called_with_request_attributes(self) -> None: + prov = _RecordingContextProvider() + agent = _ProvidersAgent([prov]) + ch = _RecordingChannel(name="responses") + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest( + channel="responses", + operation="op", + input="hi", + session=ChannelSession(isolation_key="alice"), + attributes={"response_id": "resp_abc", "previous_response_id": "resp_prev"}, + ) + result = await ch.context.run(req) + assert result.result.text == "ok" + + # Bind ↔ unbind brackets the agent run. + events = [name for name, _ in prov.events] + assert events == ["enter", "agent_start", "agent_end", "exit"] + + # Both response_id and previous_response_id forwarded by name. + _, enter_payload = prov.events[0] + assert enter_payload["response_id"] == "resp_abc" + assert enter_payload["previous_response_id"] == "resp_prev" + + async def test_bind_skipped_when_no_response_id_attribute(self) -> None: + """Without a ``response_id`` attribute on the request, the host + skips the binding entirely — the contract requires one to anchor + the chain.""" + prov = _RecordingContextProvider() + agent = _ProvidersAgent([prov]) + ch = _RecordingChannel(name="responses") + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest(channel="responses", operation="op", input="hi") + await ch.context.run(req) + assert prov.events == [("agent_start", None), ("agent_end", None)] + + async def test_bind_does_not_descend_into_providers_attribute(self) -> None: + """The host does not introspect ``ContextProviderBase`` aggregator + wrappers. Aggregator providers are responsible for forwarding the + bind to their children themselves (``AggregateContextProvider`` + already does this). The host treats whatever ``agent.context_providers`` + exposes as the final, flat list.""" + prov = _RecordingContextProvider(name="inner") + wrapper = _ProviderWrapper([prov]) + agent = _ProvidersAgent([wrapper]) + ch = _RecordingChannel(name="responses") + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest( + channel="responses", + operation="op", + input="hi", + attributes={"response_id": "resp_xyz"}, + ) + await ch.context.run(req) + # The wrapper does not implement ``response_context``, so the + # inner provider must NOT have been entered by the host. + assert ("enter", {"response_id": "resp_xyz", "previous_response_id": None}) not in prov.events + + async def test_bind_held_open_until_stream_exhaustion(self) -> None: + """Streaming runs return a ``ResponseStream`` synchronously but + consumption happens later. The binding must survive that gap and + only release after the iterator drains so the provider sees + every yielded chunk under the bound context.""" + prov = _RecordingContextProvider() + agent = _ProvidersAgent([prov]) + ch = _RecordingChannel(name="responses") + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest( + channel="responses", + operation="op", + input="hi", + stream=True, + attributes={"response_id": "resp_stream"}, + ) + stream = await ch.context.run_stream(req) + + # As soon as run_stream returns, the binding must already be open + # so any provider work that happens during iteration sees it. + names_after_create = [name for name, _ in prov.events] + assert names_after_create.count("enter") == 1 + assert "exit" not in names_after_create + + chunks: list[str] = [] + async for u in stream: + chunks.append(u.text) + assert chunks == ["chunk-1", "chunk-2"] + + # After exhaustion the binding must be released — exactly once. + names_after_drain = [name for name, _ in prov.events] + assert names_after_drain.count("enter") == 1 + assert names_after_drain.count("exit") == 1 + # Brackets surround every stream_yield. + enter_idx = names_after_drain.index("enter") + exit_idx = names_after_drain.index("exit") + yield_idxs = [i for i, name in enumerate(names_after_drain) if name == "stream_yield"] + assert all(enter_idx < i < exit_idx for i in yield_idxs) + + +# --------------------------------------------------------------------------- # +# Agent-target streaming — `_BoundResponseStream` adapter behaviour # +# --------------------------------------------------------------------------- # + + +class TestBoundResponseStream: + """The ``_BoundResponseStream`` adapter holds the bind-context + ``ExitStack`` open across iteration. Cover the iterator-finally + close, ``get_final_response`` close, double-close idempotence, + ``aclose()``, ``__getattr__`` forwarding, and the awaitable path + (which now routes through ``get_final_response`` so it doesn't + leak the binding).""" + + async def test_get_final_response_closes_binding(self) -> None: + prov = _RecordingContextProvider() + agent = _ProvidersAgent([prov]) + ch = _RecordingChannel(name="responses") + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest( + channel="responses", + operation="op", + input="hi", + stream=True, + attributes={"response_id": "resp_get_final"}, + ) + stream = await ch.context.run_stream(req) + # Skip iteration and go straight to ``get_final_response``; + # the adapter must drain the inner stream itself and close + # the binding in ``finally``. + final = await stream.get_final_response() + assert final.text == "chunk-1chunk-2" + names = [n for n, _ in prov.events] + assert names.count("enter") == 1 + assert names.count("exit") == 1 + + async def test_double_close_is_idempotent(self) -> None: + prov = _RecordingContextProvider() + agent = _ProvidersAgent([prov]) + ch = _RecordingChannel(name="responses") + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest( + channel="responses", + operation="op", + input="hi", + stream=True, + attributes={"response_id": "resp_idem"}, + ) + stream = await ch.context.run_stream(req) + async for _u in stream: + pass + # Iteration's finally already closed; an explicit ``aclose`` + # afterwards must be a no-op (no second exit event). + await stream.aclose() # type: ignore[attr-defined] + await stream.aclose() # type: ignore[attr-defined] + names = [n for n, _ in prov.events] + assert names.count("exit") == 1 + + async def test_aclose_releases_binding_when_stream_abandoned(self) -> None: + """A channel that abandons the stream without iterating must + be able to call ``aclose()`` so the host-bound contextvars + don't leak for the host's lifetime.""" + prov = _RecordingContextProvider() + agent = _ProvidersAgent([prov]) + ch = _RecordingChannel(name="responses") + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest( + channel="responses", + operation="op", + input="hi", + stream=True, + attributes={"response_id": "resp_abandon"}, + ) + stream = await ch.context.run_stream(req) + await stream.aclose() # type: ignore[attr-defined] + + # Binding released without iterating. + names = [n for n, _ in prov.events] + assert names.count("enter") == 1 + assert names.count("exit") == 1 + # Agent never ran — we abandoned before iteration. + assert "agent_start" not in names + + async def test_getattr_forwards_to_inner_stream(self) -> None: + """``_BoundResponseStream.__getattr__`` forwards unknown + attributes to the inner ``ResponseStream``; channels that + check, e.g., ``stream.add_result_hook(...)`` must keep working.""" + prov = _RecordingContextProvider() + agent = _ProvidersAgent([prov]) + ch = _RecordingChannel(name="responses") + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest( + channel="responses", + operation="op", + input="hi", + stream=True, + attributes={"response_id": "resp_getattr"}, + ) + stream = await ch.context.run_stream(req) + # ``with_result_hook`` is a real method on ``ResponseStream``; + # if forwarding broke this would AttributeError. + try: + assert callable(stream.with_result_hook) # type: ignore[attr-defined] + finally: + await stream.aclose() # type: ignore[attr-defined] + + async def test_await_path_routes_through_get_final_response(self) -> None: + """``await stream`` is a convenience for ``await + get_final_response()``. The previous direct delegation leaked + the binding for the host's lifetime; the new routing closes the + stack in the same ``finally`` as ``get_final_response``.""" + prov = _RecordingContextProvider() + agent = _ProvidersAgent([prov]) + ch = _RecordingChannel(name="responses") + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + req = ChannelRequest( + channel="responses", + operation="op", + input="hi", + stream=True, + attributes={"response_id": "resp_await"}, + ) + stream = await ch.context.run_stream(req) + final = await stream # exercises __await__ + assert final.text == "chunk-1chunk-2" + names = [n for n, _ in prov.events] + assert names.count("enter") == 1 + assert names.count("exit") == 1 + + +# --------------------------------------------------------------------------- # +# `_wrap_input` — list[Message] LAST-message metadata stamping # +# --------------------------------------------------------------------------- # + + +class TestWrapInputListMessages: + """The ``hosting`` block lands on the LAST message of a list — the + contract is load-bearing: the user turn (typically last) must + carry the channel provenance + identity for history correlation; + a regression stamping ``messages[0]`` instead silently breaks + every multi-message payload.""" + + async def test_metadata_lands_on_last_message_only(self) -> None: + agent = _FakeAgent() + ch = _RecordingChannel(name="responses") + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + # Responses-API style: a system instruction followed by a user + # turn. Only the user turn (LAST) gets stamped. + system = Message(role="system", contents=[Content.from_text("be concise")]) + user = Message(role="user", contents=[Content.from_text("hi")]) + req = ChannelRequest( + channel="responses", + operation="op", + input=[system, user], + identity=ChannelIdentity(channel="responses", native_id="user:1"), + ) + await ch.context.run(req) + + forwarded = agent.calls[0]["messages"] + assert isinstance(forwarded, list) + assert len(forwarded) == 2 + # System stays clean. + assert (system.additional_properties or {}).get("hosting") is None + # User turn carries the metadata. + hosting = forwarded[-1].additional_properties["hosting"] + assert hosting["channel"] == "responses" + assert hosting["identity"]["native_id"] == "user:1" + + async def test_single_message_payload_still_works(self) -> None: + """Regression guard: the single-``Message`` branch must be + unchanged by the LAST-of-list logic above.""" + agent = _FakeAgent() + ch = _RecordingChannel(name="responses") + host = AgentFrameworkHost(target=agent, channels=[ch]) + _ = host.app + assert ch.context is not None + + only = Message(role="user", contents=[Content.from_text("hi")]) + req = ChannelRequest(channel="responses", operation="op", input=only) + await ch.context.run(req) + forwarded = agent.calls[0]["messages"] + assert isinstance(forwarded, Message) + assert forwarded.additional_properties["hosting"]["channel"] == "responses" + + +# --------------------------------------------------------------------------- # +# Lifespan callback aggregation # +# --------------------------------------------------------------------------- # + + +class _RaisingLifecycleChannel: + """Channel whose startup OR shutdown callback raises a controlled error.""" + + def __init__(self, name: str, *, fail_on: str) -> None: + self.name = name + self.path = "" + self._fail_on = fail_on # "startup" | "shutdown" + self.start_calls: list[str] = [] + self.stop_calls: list[str] = [] + + def contribute(self, _context: ChannelContext) -> ChannelContribution: + async def _start() -> None: + self.start_calls.append("up") + if self._fail_on == "startup": + raise RuntimeError(f"startup-boom-{self.name}") + + async def _stop() -> None: + self.stop_calls.append("down") + if self._fail_on == "shutdown": + raise RuntimeError(f"shutdown-boom-{self.name}") + + return ChannelContribution(on_startup=[_start], on_shutdown=[_stop]) + + +class _OkLifecycleChannel: + def __init__(self, name: str) -> None: + self.name = name + self.path = "" + self.start_calls: list[str] = [] + self.stop_calls: list[str] = [] + + def contribute(self, _context: ChannelContext) -> ChannelContribution: + async def _start() -> None: + self.start_calls.append("up") + + async def _stop() -> None: + self.stop_calls.append("down") + + return ChannelContribution(on_startup=[_start], on_shutdown=[_stop]) + + +class TestLifespanAggregation: + """One bad startup / shutdown callback must NOT abort the rest — + every channel gets a chance to wire / unwire so half-initialised + state doesn't leak. The first error is still raised so the + process exits with a failure; remaining errors are logged so + operators see them all in one log scrape.""" + + def test_shutdown_failure_does_not_skip_peer_shutdowns(self, caplog: Any) -> None: + import logging as _logging + + agent = _FakeAgent() + bad = _RaisingLifecycleChannel("bad", fail_on="shutdown") + ok1 = _OkLifecycleChannel("ok1") + ok2 = _OkLifecycleChannel("ok2") + # Order: bad first so that without aggregation, ok1+ok2 would + # never get to run their shutdown callbacks. + host = AgentFrameworkHost(target=agent, channels=[bad, ok1, ok2]) + + with caplog.at_level(_logging.ERROR, logger="agent_framework.hosting"): # noqa: SIM117 + with pytest.raises(RuntimeError, match="shutdown-boom-bad"), TestClient(host.app): + pass + + # Every channel had its shutdown attempted, even though `bad` raised. + assert bad.stop_calls == ["down"] + assert ok1.stop_calls == ["down"] + assert ok2.stop_calls == ["down"] + + def test_startup_failure_aggregates_logs_and_raises_first(self, caplog: Any) -> None: + import logging as _logging + + agent = _FakeAgent() + ok1 = _OkLifecycleChannel("ok1") + bad = _RaisingLifecycleChannel("bad", fail_on="startup") + ok2 = _OkLifecycleChannel("ok2") + another_bad = _RaisingLifecycleChannel("bad2", fail_on="startup") + host = AgentFrameworkHost( + target=agent, + channels=[ok1, bad, ok2, another_bad], + ) + + with caplog.at_level(_logging.ERROR, logger="agent_framework.hosting"): # noqa: SIM117 + # The first failing callback's error is the one that + # propagates; remaining failures are logged. + with pytest.raises(RuntimeError, match="startup-boom-bad"), TestClient(host.app): + pass + + # Every startup callback ran (even ok2 / another_bad after the + # first failure) so we get a complete picture in the logs. + assert ok1.start_calls == ["up"] + assert bad.start_calls == ["up"] + assert ok2.start_calls == ["up"] + assert another_bad.start_calls == ["up"] + + # Both failures show up in operator logs. ``logger.exception`` puts + # the exception payload in ``record.exc_text``; the formatted summary + # of the second failure goes into ``record.message`` via the + # aggregate "N callback(s) failed" line. + log_messages = [rec.getMessage() for rec in caplog.records] + log_exc_texts = [rec.exc_text or "" for rec in caplog.records] + log_text = "\n".join(log_messages + log_exc_texts) + assert "startup-boom-bad" in log_text + assert "startup-boom-bad2" in log_text or "callback(s) failed" in log_text diff --git a/python/packages/hosting/tests/test_host_disk.py b/python/packages/hosting/tests/test_host_disk.py new file mode 100644 index 00000000000..47c78d2edc2 --- /dev/null +++ b/python/packages/hosting/tests/test_host_disk.py @@ -0,0 +1,227 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for narrowed ``state_dir`` support in :class:`AgentFrameworkHost`.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from agent_framework_hosting import AgentFrameworkHost, ChannelContext, ChannelContribution + +pytest.importorskip("diskcache") + + +class _AgentStub: + """Bare-minimum SupportsAgentRun stub for host construction.""" + + async def run(self, *_args: Any, **_kwargs: Any) -> None: # pragma: no cover - unused + return None + + +class _ChannelStub: + name = "stub" + path = "/stub" + + def contribute(self, _context: ChannelContext) -> ChannelContribution: + return ChannelContribution() + + +def _close_host_disk(host: AgentFrameworkHost) -> None: + """Release any session-alias store held by ``host``.""" + if host._sessions_store is not None: + host._sessions_store.close() + + +def test_state_dir_none_keeps_plain_alias_dict(tmp_path: Path) -> None: + """No store, no alias persistence, no files written.""" + host = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()]) + assert host._sessions_store is None + assert isinstance(host._session_aliases, dict) + assert list(tmp_path.iterdir()) == [] + + +def test_string_state_dir_creates_sessions_subfolder_only(tmp_path: Path) -> None: + """Passing a single path expands to ``sessions/`` plus lazy checkpoint path.""" + host = AgentFrameworkHost( + target=_AgentStub(), + channels=[_ChannelStub()], + state_dir=tmp_path, + ) + try: + assert host._sessions_store is not None + assert (tmp_path / "sessions").is_dir() + assert not (tmp_path / "runner").exists() + assert not (tmp_path / "links").exists() + # Checkpoint path is derived but not created for agent targets. + assert not (tmp_path / "checkpoints").exists() + finally: + _close_host_disk(host) + + +def test_per_component_session_path(tmp_path: Path) -> None: + """Dict form lets callers route session aliases to a specific root.""" + sessions_dir = tmp_path / "state" + host = AgentFrameworkHost( + target=_AgentStub(), + channels=[_ChannelStub()], + state_dir={"sessions": sessions_dir}, + ) + try: + assert sessions_dir.is_dir() + assert host._sessions_store is not None + assert host._checkpoint_location is None + finally: + _close_host_disk(host) + + +@pytest.mark.parametrize("key", ["runner", "links", "active", "identities"]) +def test_removed_state_dir_component_keys_raise(tmp_path: Path, key: str) -> None: + """Obsolete follow-up components should fail loudly instead of becoming no-ops.""" + with pytest.raises(ValueError, match="unknown"): + AgentFrameworkHost( + target=_AgentStub(), + channels=[_ChannelStub()], + state_dir={key: tmp_path / key}, # type: ignore[dict-item] + ) + + +def test_session_aliases_survive_restart(tmp_path: Path) -> None: + """Aliases written on host #1 must be visible to host #2.""" + state_dir = tmp_path / "state" + + host1 = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()], state_dir=state_dir) + host1._session_aliases["user-1"] = "sess-abc" + host1._session_aliases["user-2"] = "sess-def" + _close_host_disk(host1) + + host2 = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()], state_dir=state_dir) + try: + assert host2._session_aliases["user-1"] == "sess-abc" + assert host2._session_aliases["user-2"] == "sess-def" + finally: + _close_host_disk(host2) + + +def _build_simple_workflow() -> Any: + """Build a no-op workflow for checkpoint-wiring tests.""" + from ._workflow_fixtures import build_upper_workflow + + return build_upper_workflow() + + +def test_single_path_state_dir_wires_workflow_checkpoints(tmp_path: Path) -> None: + """``state_dir="/foo"`` + workflow target → ``/foo/checkpoints/`` is used.""" + workflow = _build_simple_workflow() + host = AgentFrameworkHost( + target=workflow, + channels=[_ChannelStub()], + state_dir=tmp_path, + ) + try: + assert host._checkpoint_location == tmp_path / "checkpoints" + finally: + _close_host_disk(host) + + +def test_mapping_state_dir_checkpoints_key_wires_workflow_checkpoints(tmp_path: Path) -> None: + """``state_dir={"checkpoints": ...}`` + workflow target → that path is used.""" + workflow = _build_simple_workflow() + ckpt_dir = tmp_path / "ck" + host = AgentFrameworkHost( + target=workflow, + channels=[_ChannelStub()], + state_dir={"checkpoints": ckpt_dir}, + ) + try: + assert host._checkpoint_location == ckpt_dir + assert host._sessions_store is None + finally: + _close_host_disk(host) + + +def test_mapping_state_dir_omits_checkpoints_for_workflow(tmp_path: Path) -> None: + """Mapping form lets workflow callers opt out of checkpoint persistence.""" + workflow = _build_simple_workflow() + host = AgentFrameworkHost( + target=workflow, + channels=[_ChannelStub()], + state_dir={"sessions": tmp_path / "s"}, + ) + try: + assert host._checkpoint_location is None + finally: + _close_host_disk(host) + + +def test_explicit_checkpoint_location_wins_over_state_dir(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: + """``checkpoint_location`` + ``state_dir`` → explicit param wins + warn.""" + workflow = _build_simple_workflow() + explicit = tmp_path / "explicit-ck" + with caplog.at_level("WARNING", logger="agent_framework.hosting"): + host = AgentFrameworkHost( + target=workflow, + channels=[_ChannelStub()], + checkpoint_location=explicit, + state_dir=tmp_path, + ) + try: + assert host._checkpoint_location == explicit + assert any( + "state_dir['checkpoints']" in rec.message and "checkpoint_location" in rec.message for rec in caplog.records + ) + finally: + _close_host_disk(host) + + +def test_state_dir_checkpoints_for_agent_target_silent_for_single_path(tmp_path: Path) -> None: + """Single-path state_dir + agent target → no checkpoint, no warning.""" + host = AgentFrameworkHost( + target=_AgentStub(), + channels=[_ChannelStub()], + state_dir=tmp_path, + ) + try: + assert host._checkpoint_location is None + assert not (tmp_path / "checkpoints").exists() + finally: + _close_host_disk(host) + + +def test_state_dir_checkpoints_for_agent_target_warns_when_explicit( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Mapping form with ``checkpoints`` + agent target → warn.""" + with caplog.at_level("WARNING", logger="agent_framework.hosting"): + host = AgentFrameworkHost( + target=_AgentStub(), + channels=[_ChannelStub()], + state_dir={"checkpoints": tmp_path / "ck"}, + ) + try: + assert host._checkpoint_location is None + assert any( + "state_dir['checkpoints']" in rec.message and "not a Workflow" in rec.message for rec in caplog.records + ) + finally: + _close_host_disk(host) + + +def test_state_dir_checkpoints_conflicts_with_workflow_own_storage(tmp_path: Path) -> None: + """Derived checkpoint path triggers the same conflict guard as explicit.""" + from agent_framework import InMemoryCheckpointStorage, WorkflowBuilder + + from ._workflow_fixtures import _UpperExecutor + + workflow = WorkflowBuilder( + start_executor=_UpperExecutor(id="upper"), + checkpoint_storage=InMemoryCheckpointStorage(), + ).build() + with pytest.raises(RuntimeError, match="already has checkpoint storage"): + AgentFrameworkHost( + target=workflow, + channels=[_ChannelStub()], + state_dir=tmp_path, + ) diff --git a/python/packages/hosting/tests/test_isolation.py b/python/packages/hosting/tests/test_isolation.py new file mode 100644 index 00000000000..84fcd35e299 --- /dev/null +++ b/python/packages/hosting/tests/test_isolation.py @@ -0,0 +1,303 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for the per-request isolation contextvar surface in +:mod:`agent_framework_hosting._isolation`. + +The isolation keys are the ONLY seam Foundry-aware providers use to +find partition keys, and the host's ASGI middleware lifts them off the +two well-known headers on every inbound HTTP request. A regression +that drops the lookup, mistypes a header name, or fails to reset the +contextvar would silently misroute writes / leak per-request state +across requests, with zero unit-test signal — so cover the surface +fully here. +""" + +from __future__ import annotations + +import asyncio + +import pytest +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import BaseRoute, Route +from starlette.testclient import TestClient + +from agent_framework_hosting import ( + Channel, + ChannelContext, + ChannelContribution, + IsolationKeys, + get_current_isolation_keys, + reset_current_isolation_keys, + set_current_isolation_keys, +) +from agent_framework_hosting._isolation import ( # pyright: ignore[reportPrivateUsage] + ISOLATION_HEADER_CHAT, + ISOLATION_HEADER_USER, + current_isolation_keys, +) + + +class TestIsolationKeys: + def test_defaults_to_none_pair(self) -> None: + keys = IsolationKeys() + assert keys.user_key is None + assert keys.chat_key is None + assert keys.is_empty is True + + def test_partial_with_only_user_is_not_empty(self) -> None: + keys = IsolationKeys(user_key="alice") + assert keys.user_key == "alice" + assert keys.chat_key is None + assert keys.is_empty is False + + def test_partial_with_only_chat_is_not_empty(self) -> None: + keys = IsolationKeys(chat_key="general") + assert keys.is_empty is False + + def test_full_pair_is_not_empty(self) -> None: + keys = IsolationKeys(user_key="alice", chat_key="general") + assert keys.is_empty is False + + +class TestContextVarHelpers: + def test_default_is_none(self) -> None: + # Each test gets a fresh contextvar value because pytest runs + # tests in fresh contexts. ``get`` returns the default. + assert get_current_isolation_keys() is None + + def test_set_and_get_round_trip(self) -> None: + token = set_current_isolation_keys(IsolationKeys(user_key="alice", chat_key="general")) + try: + current = get_current_isolation_keys() + assert current is not None + assert current.user_key == "alice" + assert current.chat_key == "general" + finally: + reset_current_isolation_keys(token) + # Reset restores prior value (None in the default context). + assert get_current_isolation_keys() is None + + def test_set_with_none_clears(self) -> None: + outer = set_current_isolation_keys(IsolationKeys(user_key="alice")) + try: + inner = set_current_isolation_keys(None) + try: + assert get_current_isolation_keys() is None + finally: + reset_current_isolation_keys(inner) + # Reset surfaces the outer value again. + current = get_current_isolation_keys() + assert current is not None + assert current.user_key == "alice" + finally: + reset_current_isolation_keys(outer) + + def test_module_level_contextvar_is_the_same_instance(self) -> None: + """Direct contextvar access (used by the ASGI middleware) and the + public `get_current_isolation_keys()` helper read from the SAME + underlying contextvar. A regression that introduced a second + contextvar would silently break the middleware → provider hop.""" + token = current_isolation_keys.set(IsolationKeys(user_key="bob")) + try: + via_helper = get_current_isolation_keys() + assert via_helper is not None + assert via_helper.user_key == "bob" + finally: + current_isolation_keys.reset(token) + + +class TestHeaderConstants: + """The two header names are part of the public contract — they + match the ones the Foundry Hosted Agents runtime stamps on every + inbound request. A typo here would silently misroute partition + writes.""" + + def test_user_header_value(self) -> None: + assert ISOLATION_HEADER_USER == "x-agent-user-isolation-key" + + def test_chat_header_value(self) -> None: + assert ISOLATION_HEADER_CHAT == "x-agent-chat-isolation-key" + + +# --------------------------------------------------------------------------- # +# End-to-end: ASGI middleware lifts the headers into the contextvar. +# --------------------------------------------------------------------------- # + + +class _IsolationProbeChannel: + """A minimal Channel that exposes a single GET route which captures + the contextvar value INSIDE the request and returns it as JSON. + + Tests use this to exercise the full middleware → contextvar → + handler hop end-to-end. + """ + + name = "probe" + path = "" + + def __init__(self) -> None: + self.captured: list[IsolationKeys | None] = [] + + async def _handler(_request: Request) -> JSONResponse: + keys = get_current_isolation_keys() + self.captured.append(keys) + payload = ( + {"user": keys.user_key, "chat": keys.chat_key} + if keys is not None + else {"user": None, "chat": None, "_present": False} + ) + return JSONResponse(payload) + + self._routes: list[BaseRoute] = [Route("/probe", _handler)] + + def contribute(self, _context: ChannelContext) -> ChannelContribution: + return ChannelContribution(routes=self._routes) + + +def _make_host_with_probe() -> tuple[object, _IsolationProbeChannel]: + from agent_framework_hosting import AgentFrameworkHost + + class _NoopAgent: + async def run(self, *_args: object, **_kwargs: object) -> object: # pragma: no cover - never called + raise RuntimeError("not invoked") + + probe = _IsolationProbeChannel() + assert isinstance(probe, Channel) + host = AgentFrameworkHost(target=_NoopAgent(), channels=[probe]) # type: ignore[arg-type] + return host, probe + + +class TestIsolationMiddlewareEndToEnd: + def test_headers_ignored_outside_foundry_environment(self) -> None: + host, probe = _make_host_with_probe() + with TestClient(host.app) as client: # type: ignore[attr-defined] + r = client.get( + "/probe", + headers={ + ISOLATION_HEADER_USER: "alice-uid", + ISOLATION_HEADER_CHAT: "general-cid", + }, + ) + assert r.status_code == 200 + assert r.json() == {"user": None, "chat": None, "_present": False} + assert probe.captured == [None] + + def test_both_headers_lifted_into_contextvar(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1") + host, probe = _make_host_with_probe() + with TestClient(host.app) as client: # type: ignore[attr-defined] + r = client.get( + "/probe", + headers={ + ISOLATION_HEADER_USER: "alice-uid", + ISOLATION_HEADER_CHAT: "general-cid", + }, + ) + assert r.status_code == 200 + assert r.json() == {"user": "alice-uid", "chat": "general-cid"} + assert len(probe.captured) == 1 + captured = probe.captured[0] + assert captured is not None + assert captured.user_key == "alice-uid" + assert captured.chat_key == "general-cid" + + def test_only_user_header_lifted(self, monkeypatch: pytest.MonkeyPatch) -> None: + """One-header-only branch: the middleware still binds (chat=None).""" + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1") + host, probe = _make_host_with_probe() + with TestClient(host.app) as client: # type: ignore[attr-defined] + r = client.get("/probe", headers={ISOLATION_HEADER_USER: "alice-uid"}) + assert r.status_code == 200 + assert r.json() == {"user": "alice-uid", "chat": None} + + def test_only_chat_header_lifted(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1") + host, probe = _make_host_with_probe() + with TestClient(host.app) as client: # type: ignore[attr-defined] + r = client.get("/probe", headers={ISOLATION_HEADER_CHAT: "general-cid"}) + assert r.status_code == 200 + assert r.json() == {"user": None, "chat": "general-cid"} + + def test_no_headers_keeps_contextvar_none(self) -> None: + """Local-dev path: with neither header present the middleware is + a no-op and the contextvar stays at its default ``None`` — + providers see "no isolation" and route to the in-memory + fallback rather than picking up stale per-request state.""" + host, probe = _make_host_with_probe() + with TestClient(host.app) as client: # type: ignore[attr-defined] + r = client.get("/probe") + assert r.status_code == 200 + assert r.json() == {"user": None, "chat": None, "_present": False} + assert probe.captured == [None] + + def test_empty_header_value_treated_as_absent(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A header that's present but empty must not bind an empty key — + ``IsolationContext`` rejects empty strings on the read side.""" + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1") + host, probe = _make_host_with_probe() + with TestClient(host.app) as client: # type: ignore[attr-defined] + r = client.get( + "/probe", + headers={ + ISOLATION_HEADER_USER: "", + ISOLATION_HEADER_CHAT: "general-cid", + }, + ) + assert r.status_code == 200 + # Empty user header decodes to None; chat key stays bound. + assert r.json() == {"user": None, "chat": "general-cid"} + + def test_contextvar_resets_after_request(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The middleware must call ``reset_current_isolation_keys`` in + a ``finally`` so per-request state never leaks across requests + or back into the calling thread's context.""" + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1") + host, probe = _make_host_with_probe() + with TestClient(host.app) as client: # type: ignore[attr-defined] + r1 = client.get("/probe", headers={ISOLATION_HEADER_USER: "alice-uid"}) + assert r1.status_code == 200 + # Reading the contextvar OUTSIDE the request scope must see + # the default — not the value the prior request bound. + assert get_current_isolation_keys() is None + # And a follow-up request without headers gets a clean + # ``None`` rather than inheriting alice-uid. + r2 = client.get("/probe") + assert r2.json() == {"user": None, "chat": None, "_present": False} + + def test_concurrent_requests_get_isolated_contextvars(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Different requests run in different async contexts; binding + from request A must NOT leak into a concurrent request B.""" + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1") + host, probe = _make_host_with_probe() + + async def _drive() -> None: + # Run two requests in parallel asyncio tasks against the + # same TestClient and assert their captures don't bleed + # into each other. + async def _hit(user_key: str) -> dict[str, str | None]: + with TestClient(host.app) as client: # type: ignore[attr-defined] + r = client.get("/probe", headers={ISOLATION_HEADER_USER: user_key}) + return r.json() # type: ignore[no-any-return] + + r_alice, r_bob = await asyncio.gather(_hit("alice-uid"), _hit("bob-uid")) + assert r_alice == {"user": "alice-uid", "chat": None} + assert r_bob == {"user": "bob-uid", "chat": None} + + asyncio.run(_drive()) + + +class TestNonHttpScopesPassThrough: + """The middleware intentionally only inspects ``http`` scopes; + lifespan / websocket scopes are forwarded untouched. A regression + that touched lifespan scopes here would crash boot.""" + + async def test_lifespan_scope_does_not_consult_headers(self) -> None: + # The TestClient context manager exercises the lifespan scope + # implicitly; if the middleware tried to decode headers on a + # non-http scope this would raise. Exercise it without binding + # any contextvar work. + host, _probe = _make_host_with_probe() + with TestClient(host.app): # type: ignore[attr-defined] + # Just enter / exit; no requests. + pass diff --git a/python/packages/hosting/tests/test_types.py b/python/packages/hosting/tests/test_types.py new file mode 100644 index 00000000000..2c77cbee6b5 --- /dev/null +++ b/python/packages/hosting/tests/test_types.py @@ -0,0 +1,50 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for the channel-neutral envelope types in :mod:`agent_framework_hosting._types`.""" + +from __future__ import annotations + +from agent_framework_hosting import ( + ChannelIdentity, + ChannelRequest, + ChannelSession, +) + + +class TestChannelRequest: + def test_required_fields_only(self) -> None: + req = ChannelRequest(channel="responses", operation="message.create", input="hi") + assert req.channel == "responses" + assert req.operation == "message.create" + assert req.input == "hi" + assert req.session is None + assert req.options is None + assert req.session_mode == "auto" + assert req.metadata == {} + assert req.attributes == {} + assert req.stream is False + assert req.identity is None + + def test_with_session_and_identity(self) -> None: + req = ChannelRequest( + channel="telegram", + operation="message.create", + input="hi", + session=ChannelSession(isolation_key="user:42"), + identity=ChannelIdentity(channel="telegram", native_id="42"), + ) + assert req.session is not None + assert req.session.isolation_key == "user:42" + assert req.identity is not None + assert req.identity.channel == "telegram" + assert req.identity.native_id == "42" + + +class TestChannelIdentity: + def test_attributes_default_empty_mapping(self) -> None: + ident = ChannelIdentity(channel="teams", native_id="abc") + assert dict(ident.attributes) == {} + + def test_attributes_passthrough(self) -> None: + ident = ChannelIdentity(channel="teams", native_id="abc", attributes={"role": "user"}) + assert dict(ident.attributes) == {"role": "user"} diff --git a/python/pyproject.toml b/python/pyproject.toml index 0a4e6f34a93..6f5d91291f0 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -87,6 +87,8 @@ agent-framework-foundry-hosting = { workspace = true } agent-framework-foundry-local = { workspace = true } agent-framework-gemini = { workspace = true } agent-framework-github-copilot = { workspace = true } +agent-framework-hosting = { workspace = true } +agent-framework-hosting-responses = { workspace = true } agent-framework-hyperlight = { workspace = true } agent-framework-lab = { workspace = true } agent-framework-mem0 = { workspace = true } @@ -210,6 +212,8 @@ executionEnvironments = [ { root = "packages/foundry/tests", reportPrivateUsage = "none" }, { root = "packages/foundry_local/tests", reportPrivateUsage = "none" }, { root = "packages/github_copilot/tests", reportPrivateUsage = "none" }, + { root = "packages/hosting/tests", reportPrivateUsage = "none" }, + { root = "packages/hosting-responses/tests", reportPrivateUsage = "none" }, { root = "packages/lab/gaia/tests", reportPrivateUsage = "none" }, { root = "packages/lab/lightning/tests", reportPrivateUsage = "none" }, { root = "packages/lab/tau2/tests", reportPrivateUsage = "none" }, diff --git a/python/samples/04-hosting/af-hosting/README.md b/python/samples/04-hosting/af-hosting/README.md new file mode 100644 index 00000000000..cc5f179e11f --- /dev/null +++ b/python/samples/04-hosting/af-hosting/README.md @@ -0,0 +1,42 @@ +# Multi-channel hosting samples + +End-to-end samples for serving an `agent-framework` agent (or workflow) +through one or more **channels** with `agent-framework-hosting`. + +The general hosting plumbing lives in +[`agent-framework-hosting`](../../../packages/hosting); each channel is +its own package. This first sample set includes +`agent-framework-hosting-responses`. + +| Sample | What it shows | Packaging | +|---|---|---| +| [`local_responses/`](./local_responses) | The minimal shape: one agent + one `@tool` + `ResponsesChannel` + a single `run_hook` that strips caller-supplied options and forces a `reasoning` preset. | **Local only.** Start here to learn the run-hook seam. | +| [`local_responses_workflow/`](./local_responses_workflow) | A 4-step `Workflow` (typed `SloganBrief` intake → writer → legal → formatter) hosted behind the Responses channel via a `run_hook` that parses inbound text/JSON into the workflow's typed input. The host writes per-conversation checkpoints via `checkpoint_location=…`. Demonstrates workflow targets + structured input adaptation + resume-across-turns. Includes a `call_server.rest` file with REST examples. | **Local only.** | + +Each sample is fully self-contained — its own `pyproject.toml`, `uv.lock`, +server `app.py`, calling script(s), and `storage/` directory. Every +sample uses `[tool.uv.sources]` to wire its `agent-framework-hosting*` +dependencies to the +[`main`](https://github.com/microsoft/agent-framework/tree/main) +branch of the upstream repo via git refs, so they install cleanly outside +the monorepo while the hosting packages are still pre-PyPI. Once those +packages publish, drop the `[tool.uv.sources]` block and let the +declared deps resolve from PyPI. + +## Relationship to `../foundry-hosted-agents/` + +The sibling [`../foundry-hosted-agents/`](../foundry-hosted-agents) directory +contains samples for the **`agent-framework-hosted`** stack — agents +that run **inside** the Foundry Hosted Agents platform using its +built-in protocol surface (Responses, Invocations, conversation store, +isolation, identity), with **no `agent-framework-hosting` package +involved**. + +| Aspect | `af-hosting/` (this directory) | `foundry-hosted-agents/` | +|---|---|---| +| Server stack | `agent-framework-hosting` + `agent-framework-hosting-responses` | `agent-framework-hosted` only — the Foundry Hosted Agents runtime owns the HTTP surface | +| Channels | Responses only in this initial sample set | The platform exposes Responses + Invocations | +| Run target | Local Hypercorn (`local_responses/`, `local_responses_workflow/`) | Hosted Agents *or* local container; targets the Hosted Agents platform contract | +| When to pick this | You want to learn the host/channel seams locally or need custom hosting middleware | You want zero hosting boilerplate, leveraging the Foundry-managed surface | + +The table above summarizes the cross-sample story. diff --git a/python/samples/04-hosting/af-hosting/local_responses/README.md b/python/samples/04-hosting/af-hosting/local_responses/README.md new file mode 100644 index 00000000000..e6250b65e12 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses/README.md @@ -0,0 +1,47 @@ +# local_responses — Responses-only with a settings-altering hook + +The smallest end-to-end `agent-framework-hosting` shape: one Foundry +agent with a `@tool`, one `ResponsesChannel`, one `run_hook`. Useful as +the entry-point sample for understanding the **channel run-hook** seam +without any multi-channel or identity-link concerns. + +What the run hook demonstrates: + +- **Strips** caller-supplied `temperature` / `store` so the host owns + those settings. +- **Forces** a `reasoning` preset (`effort=medium`, `summary=auto`) on + every turn — caller-side overrides are ignored. + +`app:app` is a module-level Starlette ASGI app; recommended local launch +is Hypercorn. + +## Run + +```bash +export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com +export FOUNDRY_MODEL=gpt-5.4-nano +az login + +uv sync +uv run hypercorn app:app --bind 0.0.0.0:8000 +``` + +Single-process for quick iteration: + +```bash +uv run python app.py +``` + +## Call locally + +```bash +uv sync --group dev + +# Plain call: +uv run python call_server.py "What is the weather in Tokyo?" + +# Continue an existing conversation by its `response.id`: +uv run python call_server.py --previous-response-id "And in Seattle?" +``` + +> This sample is **local-only** — no Dockerfile, no Foundry packaging. diff --git a/python/samples/04-hosting/af-hosting/local_responses/app.py b/python/samples/04-hosting/af-hosting/local_responses/app.py new file mode 100644 index 00000000000..17312786116 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses/app.py @@ -0,0 +1,113 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Minimal Responses-only hosting sample. + +Single agent with one ``@tool`` (``lookup_weather``), single channel +(``ResponsesChannel``), one ``run_hook`` that demonstrates the +settings-mutation seam over caller-supplied options. + +What the hook does +------------------ +On every Responses request the hook receives the ``ChannelRequest`` that +the channel built from the inbound HTTP body. It: + +- strips ``store`` (this agent owns persistence) and ``temperature`` + (the configured model may not honor it), +- forces a ``reasoning`` effort + summary preset so the deployed surface + is consistent regardless of what the caller sent. + +The hook is the documented escape hatch over the uniform +``ChannelRequest`` envelope. + +Run +--- +``app`` is a module-level Starlette ASGI app. Recommended local launch:: + + uv sync + az login + export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com + export FOUNDRY_MODEL=gpt-5.4-nano + uv run hypercorn app:app --bind 0.0.0.0:8000 + +Or use the ``__main__`` block (single-process Hypercorn) for quick +iteration:: + + uv run python app.py + +Then call it:: + + uv run python call_server.py "What is the weather in Tokyo?" +""" + +from __future__ import annotations + +import os +from dataclasses import replace +from pathlib import Path +from random import randint +from typing import Annotated + +from agent_framework import Agent, FileHistoryProvider, tool +from agent_framework_foundry import FoundryChatClient +from agent_framework_hosting import AgentFrameworkHost, ChannelRequest +from agent_framework_hosting_responses import ResponsesChannel +from azure.identity.aio import DefaultAzureCredential + +SESSIONS_DIR = Path(__file__).resolve().parent / "storage" / "sessions" +SESSIONS_DIR.mkdir(parents=True, exist_ok=True) + + +@tool(approval_mode="never_require") +def lookup_weather( + location: Annotated[str, "The city to look up weather for."], +) -> str: + """Return a deterministic weather report for a city.""" + high_temp = randint(5, 25) + reports = { + "Seattle": f"Seattle is rainy with a high of {high_temp}°C.", + "Amsterdam": f"Amsterdam is cloudy with a high of {high_temp}°C.", + "Tokyo": f"Tokyo is clear with a high of {high_temp}°C.", + } + return reports.get(location, f"{location} is sunny with a high of {high_temp}°C.") + + +def responses_hook(request: ChannelRequest, **_: object) -> ChannelRequest: + """Strip caller-supplied options the host should own and force a + reasoning preset.""" + options = dict(request.options or {}) + + # The agent's default_options own ``store``; the model may not honor + # ``temperature``. Strip both so the caller can't override. + options.pop("temperature", None) + options.pop("store", None) + + # Force a consistent reasoning preset on every turn. + options["reasoning"] = {"effort": "medium", "summary": "auto"} + + return replace(request, options=options or None) + + +def build_host() -> AgentFrameworkHost: + agent = Agent( + client=FoundryChatClient(credential=DefaultAzureCredential()), + name="WeatherAgent", + instructions=( + "You are a friendly weather assistant. Use the lookup_weather tool " + "for any weather question and answer in one short sentence." + ), + tools=[lookup_weather], + context_providers=[FileHistoryProvider(SESSIONS_DIR)], + default_options={"store": False}, + ) + return AgentFrameworkHost( + target=agent, + channels=[ResponsesChannel(run_hook=responses_hook)], + debug=True, + ) + + +app = build_host().app + + +if __name__ == "__main__": + build_host().serve(host="0.0.0.0", port=int(os.environ.get("PORT", "8000"))) diff --git a/python/samples/04-hosting/af-hosting/local_responses/call_server.py b/python/samples/04-hosting/af-hosting/local_responses/call_server.py new file mode 100644 index 00000000000..aeeaa39479b --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses/call_server.py @@ -0,0 +1,47 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Local client for the local_responses sample. + +Posts to ``/responses`` using the standard ``openai`` SDK. + +Pass ``--previous-response-id `` to continue a conversation by its +``response.id`` (returned in the prior response). + +Start the server first (in another shell):: + + uv run python app.py + +Then:: + + uv run python call_server.py "What is the weather in Tokyo?" +""" + +from __future__ import annotations + +import sys + +from openai import OpenAI + +BASE_URL = "http://127.0.0.1:8000" + + +def main() -> None: + args = sys.argv[1:] + previous_response_id: str | None = None + if len(args) >= 2 and args[0] == "--previous-response-id": + previous_response_id = args[1] + args = args[2:] + print(f"Resuming response: {previous_response_id}") + prompt = " ".join(args) or "What is the weather in Tokyo?" + client = OpenAI(base_url=BASE_URL, api_key="not-needed") + response = client.responses.create( + model="agent", + input=prompt, + previous_response_id=previous_response_id, + ) + print(f"User: {prompt}") + print(f"Agent: {response.output_text}") + + +if __name__ == "__main__": + main() diff --git a/python/samples/04-hosting/af-hosting/local_responses/pyproject.toml b/python/samples/04-hosting/af-hosting/local_responses/pyproject.toml new file mode 100644 index 00000000000..701af50be9d --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "agent-framework-hosting-sample-local-responses" +version = "0.0.1" +description = "Minimal Responses-only local hosting sample with a settings-altering run hook." +requires-python = ">=3.10" +dependencies = [ + "agent-framework-foundry", + "agent-framework-hosting", + "agent-framework-hosting-responses", + "azure-identity", + "aiohttp>=3.13.5", + "hypercorn>=0.17", +] + +[dependency-groups] +dev = ["openai>=1.99"] + +[tool.uv] +package = false + +[tool.uv.sources] +agent-framework-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "main", subdirectory = "python/packages/hosting" } +agent-framework-hosting-responses = { git = "https://github.com/microsoft/agent-framework.git", branch = "main", subdirectory = "python/packages/hosting-responses" } diff --git a/python/samples/04-hosting/af-hosting/local_responses_workflow/README.md b/python/samples/04-hosting/af-hosting/local_responses_workflow/README.md new file mode 100644 index 00000000000..3c3fed324f1 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses_workflow/README.md @@ -0,0 +1,82 @@ +# local_responses_workflow — workflow target with structured intake + checkpoints + +A `Workflow` (intake → writer → legal reviewer → formatter) hosted +behind the **Responses API**, with the host configured to +**persist per-conversation checkpoints**. Mirrors +[`../../foundry-hosted-agents/responses/05_workflows/`](../../foundry-hosted-agents/responses/05_workflows/) +but uses the `agent-framework-hosting` stack instead of the +Foundry-Hosted-Agents runtime, and adds a structured intake step +(`SloganBrief` with `topic` / `style` / `audience` fields) at the front +of the workflow. + +## What's interesting + +- `AgentFrameworkHost(target=workflow, …)` — the host detects a + `Workflow` target and dispatches to `workflow.run(...)` (no + `Agent.create_session(...)`). +- `ResponsesChannel` is mounted at `/responses` with a `brief_hook` + that **adapts the channel-native input into the workflow start executor's + typed input**. Responses delivers a `list[Message]`; the hook normalises it + to text and produces a `SloganBrief`. +- The hook parses the inbound text as JSON + (`{"topic": ..., "style": ..., "audience": ...}`); if parsing fails + it uses the whole text as `topic` with defaults. +- The workflow's first executor (`BriefIntakeExecutor`) accepts + `SloganBrief` directly — that's what gets sent into `workflow.run(...)` + by the host. +- `checkpoint_location=storage/checkpoints/` — the host scopes a + `FileCheckpointStorage` per conversation (Responses keys it on + `previous_response_id` / `conversation_id`) and **restores from the + latest checkpoint at the start of every turn** before applying the new + input. Without an isolation key the host skips checkpointing for that request. +- No `HistoryProvider` — the workflow owns its own state via the + checkpoint store. + +## Run + +```bash +export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com +export FOUNDRY_MODEL=gpt-5.4-nano +az login + +uv sync +uv run hypercorn app:app --bind 0.0.0.0:8000 +``` + +Single-process for quick iteration: + +```bash +uv run python app.py +``` + +## Call locally + +Two clients are provided next to `app.py`: + +- **`call_server.py`** — Python client using the OpenAI SDK (Responses + API only). +- **`call_server.rest`** — raw REST examples for the Responses endpoint + (open in VS Code with the REST Client extension or any compatible HTTP-file + runner). + +```bash +uv sync --group dev + +# Structured brief via the OpenAI SDK (Responses API): +uv run python call_server.py \ + '{"topic": "electric SUV", "style": "playful", "audience": "young families"}' + +# Plain topic (style/audience default to "modern" / "general"): +uv run python call_server.py "electric SUV" + +# Continue an existing conversation by its `response.id`: +uv run python call_server.py --previous-response-id \ + '{"topic": "electric SUV", "style": "retro", "audience": "boomers"}' +``` + +After a few turns, inspect `storage/checkpoints//` — +each conversation has its own subdirectory of checkpoint files written +by the host. + +> This sample is **local-only** — no Dockerfile, no Foundry packaging. +> A Foundry-Hosted-Agents-compatible packaging sample will be added separately. diff --git a/python/samples/04-hosting/af-hosting/local_responses_workflow/app.py b/python/samples/04-hosting/af-hosting/local_responses_workflow/app.py new file mode 100644 index 00000000000..040e9a1e223 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses_workflow/app.py @@ -0,0 +1,220 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Hosted workflow sample with a structured intake step + checkpoint location. + +Same three-agent slogan workflow as +``../../foundry-hosted-agents/responses/05_workflows/main.py`` (writer → +legal reviewer → formatter), but with an extra **structured intake** +step at the front and driven through the ``agent-framework-hosting`` +stack instead of the Foundry-Hosted-Agents runtime. + +Workflow shape +-------------- +``BriefIntakeExecutor`` (typed :class:`SloganBrief` input) → ``writer`` +→ ``legal_reviewer`` → ``formatter``. The intake step formats the +structured brief into a prompt the writer agent understands. + +What this sample shows +---------------------- +- A :class:`~agent_framework.Workflow` is a valid hosting target — the + host detects it and dispatches to ``workflow.run(...)`` instead of + ``agent.run(...)``. +- ``ResponsesChannel(run_hook=...)`` is the seam for **adapting the + channel-native input into the workflow start executor's typed input**. + The hook here parses the inbound text as JSON + (``{"topic": ..., "style": ..., "audience": ...}``) — if parsing + fails it falls back to using the whole text as ``topic`` with + defaults — and replaces ``ChannelRequest.input`` with a + :class:`SloganBrief`. +- ``AgentFrameworkHost(checkpoint_location=...)`` enables + per-conversation workflow checkpointing. The host scopes the + checkpoint storage by ``ChannelRequest.session.isolation_key`` + (Responses uses ``previous_response_id`` / ``conversation_id`` as the + isolation key), and restores from the latest checkpoint before each + new turn — so a multi-turn workflow can resume across requests. +- No ``HistoryProvider`` is configured: the workflow owns its own state + via the checkpoint store; the agent-history seam is for plain + ``SupportsAgentRun`` agents. + +Run +--- +``app`` is a module-level Starlette ASGI app:: + + uv sync + az login + export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com + export FOUNDRY_MODEL=gpt-5.4-nano + uv run hypercorn app:app --bind 0.0.0.0:8000 + +Or for quick iteration:: + + uv run python app.py + +Then call it with a structured brief:: + + uv run python call_server.py \\ + '{"topic": "electric SUV", "style": "playful", "audience": "young families"}' + +Or with just a topic — the hook fills in defaults:: + + uv run python call_server.py "Create a slogan for an electric SUV." +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, replace +from pathlib import Path + +from agent_framework import ( + Agent, + AgentExecutor, + Executor, + Message, + WorkflowBuilder, + WorkflowContext, + handler, +) +from agent_framework_foundry import FoundryChatClient +from agent_framework_hosting import AgentFrameworkHost, ChannelRequest +from agent_framework_hosting_responses import ResponsesChannel +from azure.identity.aio import DefaultAzureCredential + +CHECKPOINTS_DIR = Path(__file__).resolve().parent / "storage" / "checkpoints" +CHECKPOINTS_DIR.mkdir(parents=True, exist_ok=True) + + +@dataclass +class SloganBrief: + """Typed input for the workflow's first executor.""" + + topic: str + style: str = "modern" + audience: str = "general" + + +class BriefIntakeExecutor(Executor): + """Format a :class:`SloganBrief` into a prompt for the writer agent.""" + + @handler + async def handle(self, brief: SloganBrief, ctx: WorkflowContext[str]) -> None: + prompt = ( + f"Topic: {brief.topic}\n" + f"Style: {brief.style}\n" + f"Audience: {brief.audience}\n\n" + "Write a single short slogan that fits the topic, style, and audience." + ) + await ctx.send_message(prompt) + + +def _extract_text(value: object) -> str: + """Pull plain text out of whatever the Responses channel produced. + + The channel hands the host either a ``str`` (rare on the Responses + surface) or a list of :class:`Message`. The hook collapses both to + a single concatenated string before attempting to parse a brief. + """ + if isinstance(value, str): + return value + if isinstance(value, Message): + return value.text + if isinstance(value, list): + return "\n".join(_extract_text(item) for item in value) + return "" + + +def _parse_brief(text: str) -> SloganBrief: + """Parse user text into a :class:`SloganBrief`. + + Accepts a JSON object with ``topic`` / ``style`` / ``audience`` + keys; falls back to using the whole text as ``topic`` with the + other fields defaulted. + """ + text = text.strip() + if text.startswith("{"): + try: + data = json.loads(text) + except json.JSONDecodeError: + data = None + if isinstance(data, dict) and "topic" in data: + return SloganBrief( + topic=str(data["topic"]), + style=str(data.get("style", "modern")), + audience=str(data.get("audience", "general")), + ) + return SloganBrief(topic=text or "a generic product") + + +def brief_hook(request: ChannelRequest, **_: object) -> ChannelRequest: + """Adapt the channel's free-form text into the workflow's typed input. + + This is the canonical seam for shaping ``ChannelRequest.input`` into + the workflow start executor's input type — here :class:`SloganBrief` + instead of the list of :class:`Message` produced by the Responses channel. + """ + brief = _parse_brief(_extract_text(request.input)) + return replace(request, input=brief) + + +def build_host() -> AgentFrameworkHost: + client = FoundryChatClient(credential=DefaultAzureCredential()) + + writer = Agent( + client=client, + name="writer", + instructions=("You are an excellent slogan writer. You create new slogans based on the given topic."), + ) + legal = Agent( + client=client, + name="legal_reviewer", + instructions=( + "You are an excellent legal reviewer. " + "Make necessary corrections to the slogan so that it is legally compliant." + ), + ) + formatter = Agent( + client=client, + name="formatter", + instructions=( + "You are an excellent content formatter. " + "You take the slogan and format it in a cool retro style when printing to a terminal." + ), + ) + + intake_ex = BriefIntakeExecutor(id="intake") + # ``context_mode="last_agent"`` ensures each agent only sees the + # previous executor's output — matching the Foundry sample. + writer_ex = AgentExecutor(writer, context_mode="last_agent") + legal_ex = AgentExecutor(legal, context_mode="last_agent") + format_ex = AgentExecutor(formatter, context_mode="last_agent") + + workflow = ( + WorkflowBuilder( + start_executor=intake_ex, + output_executors=[format_ex], + ) + .add_edge(intake_ex, writer_ex) + .add_edge(writer_ex, legal_ex) + .add_edge(legal_ex, format_ex) + .build() + ) + + return AgentFrameworkHost( + target=workflow, + channels=[ + ResponsesChannel(run_hook=brief_hook), + ], + # The host writes a per-conversation FileCheckpointStorage rooted + # at ``CHECKPOINTS_DIR / `` and restores from the + # latest checkpoint at the start of every turn. + checkpoint_location=CHECKPOINTS_DIR, + debug=True, + ) + + +app = build_host().app + + +if __name__ == "__main__": + build_host().serve(host="0.0.0.0", port=int(os.environ.get("PORT", "8000"))) diff --git a/python/samples/04-hosting/af-hosting/local_responses_workflow/call_server.py b/python/samples/04-hosting/af-hosting/local_responses_workflow/call_server.py new file mode 100644 index 00000000000..238de564ed5 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses_workflow/call_server.py @@ -0,0 +1,54 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Local client for the local_responses_workflow sample. + +The server expects a structured slogan brief. You can either pass a +JSON object or a plain topic string (the server's run hook fills the +other fields with defaults). + +Pass ``--previous-response-id `` to continue a conversation by its +``response.id`` — the host uses that as the workflow checkpoint scope +key, so the workflow resumes from where it left off. + +Start the server first (in another shell):: + + uv run python app.py + +Then:: + + uv run python call_server.py \\ + '{"topic": "electric SUV", "style": "playful", "audience": "young families"}' + + uv run python call_server.py "electric SUV" # uses default style/audience +""" + +from __future__ import annotations + +import sys + +from openai import OpenAI + +BASE_URL = "http://127.0.0.1:8000" + + +def main() -> None: + args = sys.argv[1:] + previous_response_id: str | None = None + if len(args) >= 2 and args[0] == "--previous-response-id": + previous_response_id = args[1] + args = args[2:] + print(f"Resuming response: {previous_response_id}") + prompt = " ".join(args) or '{"topic": "electric SUV", "style": "playful", "audience": "young families"}' + client = OpenAI(base_url=BASE_URL, api_key="not-needed") + response = client.responses.create( + model="agent", + input=prompt, + previous_response_id=previous_response_id, + ) + print(f"User: {prompt}") + print(f"Agent: {response.output_text}") + print(f"response.id: {response.id}") + + +if __name__ == "__main__": + main() diff --git a/python/samples/04-hosting/af-hosting/local_responses_workflow/call_server.rest b/python/samples/04-hosting/af-hosting/local_responses_workflow/call_server.rest new file mode 100644 index 00000000000..ef30538047c --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses_workflow/call_server.rest @@ -0,0 +1,48 @@ +# local_responses_workflow — REST examples +# +# Use with the VS Code "REST Client" extension (humao.rest-client) or +# JetBrains HTTP Client. Each `###` block is one request. +# +# Start the server in another shell first: +# uv run python app.py + +@host = http://127.0.0.1:8000 + +### +# 1. Responses API — structured brief +POST {{host}}/responses +Content-Type: application/json + +{ + "model": "agent", + "input": "{\"topic\": \"electric SUV\", \"style\": \"playful\", \"audience\": \"young families\"}" +} + +### +# 2. Responses API — plain topic, defaults applied by the run hook +POST {{host}}/responses +Content-Type: application/json + +{ + "model": "agent", + "input": "vintage espresso machine" +} + +### +# 3. Responses API — continue the conversation by previous_response_id +# Replace with `id` from one of the responses above — +# the host uses it as the workflow checkpoint scope key, so the +# workflow resumes from its latest checkpoint before applying the +# new input. +POST {{host}}/responses +Content-Type: application/json + +{ + "model": "agent", + "previous_response_id": "", + "input": "{\"topic\": \"electric SUV\", \"style\": \"retro\", \"audience\": \"boomers\"}" +} + +### +# 4. Readiness probe +GET {{host}}/readiness diff --git a/python/samples/04-hosting/af-hosting/local_responses_workflow/pyproject.toml b/python/samples/04-hosting/af-hosting/local_responses_workflow/pyproject.toml new file mode 100644 index 00000000000..915bdb1298f --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses_workflow/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "agent-framework-hosting-sample-local-responses-workflow" +version = "0.0.1" +description = "Local hosting sample exposing a 3-agent workflow over the Responses API with per-conversation checkpoint storage." +requires-python = ">=3.10" +dependencies = [ + "agent-framework-foundry", + "agent-framework-hosting", + "agent-framework-hosting-responses", + "azure-identity", + "aiohttp>=3.13.5", + "hypercorn>=0.17", +] + +[dependency-groups] +dev = ["openai>=1.99"] + +[tool.uv] +package = false + +[tool.uv.sources] +agent-framework-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "main", subdirectory = "python/packages/hosting" } +agent-framework-hosting-responses = { git = "https://github.com/microsoft/agent-framework.git", branch = "main", subdirectory = "python/packages/hosting-responses" } diff --git a/python/samples/04-hosting/af-hosting/local_responses_workflow/storage/checkpoints/.gitkeep b/python/samples/04-hosting/af-hosting/local_responses_workflow/storage/checkpoints/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/uv.lock b/python/uv.lock index a7fd5e7fc15..8b66f8ef33b 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -50,6 +50,8 @@ members = [ "agent-framework-foundry-local", "agent-framework-gemini", "agent-framework-github-copilot", + "agent-framework-hosting", + "agent-framework-hosting-responses", "agent-framework-hyperlight", "agent-framework-lab", "agent-framework-mem0", @@ -614,6 +616,57 @@ requires-dist = [ { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=1.0.0,<2" }, ] +[[package]] +name = "agent-framework-hosting" +version = "1.0.0a260424" +source = { editable = "packages/hosting" } +dependencies = [ + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.optional-dependencies] +disk = [ + { name = "diskcache", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +serve = [ + { name = "hypercorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-framework-core", editable = "packages/core" }, + { name = "diskcache", marker = "extra == 'disk'", specifier = ">=5.6" }, + { name = "hypercorn", marker = "extra == 'serve'", specifier = ">=0.17" }, + { name = "starlette", specifier = ">=0.37" }, +] +provides-extras = ["serve", "disk"] + +[package.metadata.requires-dev] +dev = [{ name = "httpx", specifier = ">=0.28.1" }] + +[[package]] +name = "agent-framework-hosting-responses" +version = "1.0.0a260424" +source = { editable = "packages/hosting-responses" } +dependencies = [ + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-hosting", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-framework-core", editable = "packages/core" }, + { name = "agent-framework-hosting", editable = "packages/hosting" }, + { name = "openai", specifier = ">=1.99.0,<3" }, +] + [[package]] name = "agent-framework-hyperlight" version = "1.0.0b260521" @@ -2122,6 +2175,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/c4/da7089cd7aa4ab554f56e18a7fb08dcfed8fd2ae91fa528f5b1be207a148/deepdiff-9.0.0-py3-none-any.whl", hash = "sha256:b1ae0dd86290d86a03de5fbee728fde43095c1472ae4974bdab23ab4656305bd", size = 170540, upload-time = "2026-03-30T05:52:22.008Z" }, ] +[[package]] +name = "diskcache" +version = "5.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" }, +] + [[package]] name = "distro" version = "1.9.0" From af0c2f7b5bd4802f9035a784d6bc250bb69352ef Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 18 Jun 2026 08:58:09 +0200 Subject: [PATCH 2/8] Address hosting core review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../_channel.py | 59 +++++++++++++++---- .../_parsing.py | 10 ++-- .../hosting-responses/tests/test_channel.py | 41 ++++++++++++- .../hosting-responses/tests/test_parsing.py | 18 ++++++ .../hosting/agent_framework_hosting/_host.py | 48 ++++++++------- 5 files changed, 137 insertions(+), 39 deletions(-) diff --git a/python/packages/hosting-responses/agent_framework_hosting_responses/_channel.py b/python/packages/hosting-responses/agent_framework_hosting_responses/_channel.py index 20eabdfc105..a126ebd5ce5 100644 --- a/python/packages/hosting-responses/agent_framework_hosting_responses/_channel.py +++ b/python/packages/hosting-responses/agent_framework_hosting_responses/_channel.py @@ -15,10 +15,11 @@ from __future__ import annotations +import json import time import uuid -from collections.abc import AsyncIterator, Callable, Mapping -from typing import Any +from collections.abc import AsyncIterator, Callable, Mapping, Sequence +from typing import Any, cast from agent_framework_hosting import ( ChannelContext, @@ -56,9 +57,8 @@ class ResponsesChannel: """Minimal OpenAI-Responses-shaped surface. - Mounts ``POST /responses`` (default path ``/responses`` so the - full route is ``/responses/responses`` when the channel is prefixed, - or just ``/`` when ``path=""``). + Mounts one ``POST`` route at ``path``. The default path is ``/responses``; + use ``path=""`` to expose the route at the app root. """ name = "responses" @@ -189,6 +189,8 @@ async def _handle(self, request: Request) -> Response: # co-locate the new record with the chain's existing partition. # No-arg factories continue to work via ``Callable[..., str]``. response_id = self._response_id_factory(previous_response_id) + if session is None: + session = ChannelSession(isolation_key=response_id) attributes: dict[str, Any] = {"response_id": response_id} if previous_response_id is not None: @@ -222,9 +224,9 @@ async def _handle(self, request: Request) -> Response: response_hook=self.response_hook, channel_name=self.name, ) - text = result.result.text + text = _result_to_text(result.result) envelope = self._build_response(body, text, status="completed", response_id=response_id) - return JSONResponse(envelope.model_dump(mode="json", exclude_none=True)) + return JSONResponse(_response_payload(envelope)) def _build_response( self, @@ -251,12 +253,13 @@ def _build_response( before this argument was introduced) don't supply one. """ message_status = status if status in ("in_progress", "completed", "incomplete") else "incomplete" + model = body.get("model") return OpenAIResponse( id=response_id or self._response_id_factory(None), object="response", - created_at=time.time(), + created_at=int(time.time()), status=status, # type: ignore[arg-type] - model=body.get("model", "agent"), + model=model if isinstance(model, str) and model else "agent", output=[ ResponseOutputMessage( id=f"msg_{uuid.uuid4().hex}", @@ -296,7 +299,7 @@ def next_seq() -> int: return seq def sse(event: Any) -> str: - return f"event: {event.type}\ndata: {event.model_dump_json(exclude_none=True)}\n\n" + return f"event: {event.type}\ndata: {_event_json(event)}\n\n" skeleton = self._build_response(body, "", status="in_progress", response_id=response_id) yield sse(ResponseCreatedEvent(type="response.created", response=skeleton, sequence_number=next_seq())) @@ -346,7 +349,7 @@ def sse(event: Any) -> str: ) return - completed_text = getattr(final_response, "text", None) or accumulated + completed_text = _result_to_text(final_response) if final_response is not None else accumulated completed = self._build_response(body, completed_text, status="completed", response_id=response_id) # Reuse the same message id we emitted deltas under. if completed.output and isinstance(completed.output[0], ResponseOutputMessage): @@ -360,4 +363,38 @@ def sse(event: Any) -> str: ) +def _result_to_text(result: Any) -> str: + """Render an agent or workflow result to plain text for Responses JSON.""" + text = getattr(result, "text", None) + if isinstance(text, str): + return text + get_outputs = getattr(result, "get_outputs", None) + if callable(get_outputs): + return "".join(_output_to_text(output) for output in cast("Sequence[Any]", get_outputs())) + return str(result) + + +def _output_to_text(output: Any) -> str: + text = getattr(output, "text", None) + if isinstance(text, str): + return text + return str(output) + + +def _response_payload(response: OpenAIResponse) -> dict[str, Any]: + payload = response.model_dump(mode="json", exclude_none=True) + created_at = payload.get("created_at") + if isinstance(created_at, float): + payload["created_at"] = int(created_at) + return payload + + +def _event_json(event: Any) -> str: + payload = cast("dict[str, Any]", event.model_dump(mode="json", exclude_none=True)) + response = cast("dict[str, Any] | None", payload.get("response")) + if isinstance(response, dict) and isinstance(response.get("created_at"), float): + response["created_at"] = int(response["created_at"]) + return json.dumps(payload, separators=(",", ":")) + + __all__ = ["ResponsesChannel"] diff --git a/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py b/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py index 51d4ea4ecbe..eb1e81e99eb 100644 --- a/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py +++ b/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py @@ -113,11 +113,11 @@ def flush() -> None: if isinstance(content, str): parts = [Content.from_text(text=content)] elif isinstance(content, list): - parts = [ - _content_from_input_item(cast("Mapping[str, Any]", c)) - for c in cast("list[Any]", content) # type: ignore[redundant-cast] - if isinstance(c, Mapping) - ] + parts = [] + for content_item in cast("list[Any]", content): # type: ignore[redundant-cast] + if not isinstance(content_item, Mapping): + raise ValueError("each message `content` item must be an object") + parts.append(_content_from_input_item(cast("Mapping[str, Any]", content_item))) else: parts = [] messages.append(Message(role, parts)) diff --git a/python/packages/hosting-responses/tests/test_channel.py b/python/packages/hosting-responses/tests/test_channel.py index 6bd224f1393..cc2fef639e5 100644 --- a/python/packages/hosting-responses/tests/test_channel.py +++ b/python/packages/hosting-responses/tests/test_channel.py @@ -15,6 +15,7 @@ from starlette.testclient import TestClient from agent_framework_hosting_responses import ResponsesChannel +from agent_framework_hosting_responses._channel import _result_to_text # pyright: ignore[reportPrivateUsage] # --------------------------------------------------------------------------- # # Fakes # @@ -78,9 +79,13 @@ def _make_client( agent: _FakeAgent | None = None, *, path: str = "/responses", + response_id_factory: Any | None = None, ) -> tuple[TestClient, AgentFrameworkHost, _FakeAgent]: agent = agent or _FakeAgent() - host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel(path=path)]) + host = AgentFrameworkHost( + target=agent, + channels=[ResponsesChannel(path=path, response_id_factory=response_id_factory)], + ) return TestClient(host.app), host, agent @@ -94,9 +99,17 @@ def test_post_responses_returns_completed_envelope(self) -> None: assert body["status"] == "completed" assert body["object"] == "response" assert body["id"].startswith("resp_") + assert isinstance(body["created_at"], int) assert body["output"][0]["content"][0]["text"] == "hi back" assert len(agent.calls) == 1 + def test_non_string_model_falls_back_to_agent(self) -> None: + client, _host, _agent = _make_client(_FakeAgent(reply="hi")) + with client: + r = client.post("/responses", json={"input": "hi", "model": None}) + assert r.status_code == 200 + assert r.json()["model"] == "agent" + def test_empty_path_mounts_at_app_root(self) -> None: client, _host, _agent = _make_client(_FakeAgent(reply="hi back"), path="") with client: @@ -134,15 +147,25 @@ def test_previous_response_id_creates_session(self) -> None: # _FakeAgent.create_session stashes the session_id on the dict it returns. assert sess["session_id"] == "resp_42" + def test_first_turn_response_id_creates_session(self) -> None: + client, _host, agent = _make_client(response_id_factory=lambda *_: "resp_first") + with client: + client.post("/responses", json={"input": "x"}) + sess = agent.calls[0]["kwargs"].get("session") + assert sess is not None + assert sess["session_id"] == "resp_first" + def test_chat_isolation_header_ignored_outside_foundry(self) -> None: - client, _host, agent = _make_client() + client, _host, agent = _make_client(response_id_factory=lambda *_: "resp_local") with client: client.post( "/responses", json={"input": "x"}, headers={"x-agent-chat-isolation-key": "chat-abc"}, ) - assert "session" not in agent.calls[0]["kwargs"] + sess = agent.calls[0]["kwargs"].get("session") + assert sess is not None + assert sess["session_id"] == "resp_local" def test_chat_isolation_header_creates_session_in_foundry(self, monkeypatch: Any) -> None: """Foundry-style ``x-agent-chat-isolation-key`` falls back to a session anchor. @@ -204,6 +227,18 @@ def hook(result: HostedRunResult, **kwargs: Any) -> HostedRunResult: assert seen_kwargs[0]["channel_name"] == "responses" +class TestResultTextRendering: + def test_result_text_prefers_text_property(self) -> None: + assert _result_to_text(_FakeAgentResponse(text="plain")) == "plain" + + def test_result_text_projects_workflow_outputs(self) -> None: + class _WorkflowResult: + def get_outputs(self) -> list[Any]: + return [_FakeAgentResponse(text="one"), " two"] + + assert _result_to_text(_WorkflowResult()) == "one two" + + class TestResponsesChannelStreaming: def test_sse_emits_created_delta_completed(self) -> None: agent = _FakeAgent(reply="hello world", chunks=["hello", " ", "world"]) diff --git a/python/packages/hosting-responses/tests/test_parsing.py b/python/packages/hosting-responses/tests/test_parsing.py index e8d47f7f7f1..5c19449d79d 100644 --- a/python/packages/hosting-responses/tests/test_parsing.py +++ b/python/packages/hosting-responses/tests/test_parsing.py @@ -44,6 +44,24 @@ def test_message_envelope_with_content_parts(self) -> None: ]) assert msgs[0].text == "describe this" + def test_message_envelope_rejects_non_object_content_item(self) -> None: + with pytest.raises(ValueError, match="content.*object"): + messages_from_responses_input([{"type": "message", "role": "user", "content": ["bad"]}]) + + def test_input_file_via_url(self) -> None: + msgs = messages_from_responses_input([ + {"type": "input_file", "file_url": "https://example.com/report.pdf", "mime_type": "application/pdf"} + ]) + assert msgs[0].contents[0].uri == "https://example.com/report.pdf" + + def test_input_file_via_file_id(self) -> None: + msgs = messages_from_responses_input([{"type": "input_file", "file_id": "file_123"}]) + assert msgs[0].contents[0].file_id == "file_123" + + def test_input_file_missing_anchor_raises(self) -> None: + with pytest.raises(ValueError, match="input_file"): + messages_from_responses_input([{"type": "input_file"}]) + def test_pending_text_flushes_before_message_envelope(self) -> None: msgs = messages_from_responses_input([ {"type": "input_text", "text": "first"}, diff --git a/python/packages/hosting/agent_framework_hosting/_host.py b/python/packages/hosting/agent_framework_hosting/_host.py index 3d5287f38e0..2e5969b4828 100644 --- a/python/packages/hosting/agent_framework_hosting/_host.py +++ b/python/packages/hosting/agent_framework_hosting/_host.py @@ -638,6 +638,7 @@ def __init__( self.channels = list(channels) self._debug = debug self._app: Starlette | None = None + self._workflow_lock = asyncio.Lock() self._state_paths: dict[str, Path | None] = normalize_state_dir(state_dir) checkpoints_explicit_in_mapping = isinstance(state_dir, Mapping) and "checkpoints" in state_dir derived_checkpoint_path = self._state_paths.get("checkpoints") @@ -1031,15 +1032,16 @@ def _log_incoming(self, request: ChannelRequest, *, stream: bool) -> None: "session_mode": request.session_mode, }, ) - logger.debug( - "channel request details", - extra={ - "channel": request.channel, - "options": dict(request.options) if request.options else {}, - "attributes": dict(request.attributes) if request.attributes else {}, - "metadata": dict(request.metadata) if request.metadata else {}, - }, - ) + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "channel request details", + extra={ + "channel": request.channel, + "options": dict(request.options) if request.options else {}, + "attributes": dict(request.attributes) if request.attributes else {}, + "metadata": dict(request.metadata) if request.metadata else {}, + }, + ) def _bind_request_context(self, request: ChannelRequest) -> ExitStack: """Bind any per-request anchors a target's context-providers expose. @@ -1093,7 +1095,12 @@ async def _invoke(self, request: ChannelRequest) -> HostedRunResult[AgentRespons # Workflow targets follow a separate path; the dedicated dispatch # is parameterised on ``WorkflowRunResult`` so the static return # type of ``_invoke`` itself stays the agent-shaped envelope. - return await self._invoke_workflow(request) # type: ignore[return-value] + # Workflow instances own mutable runner context and do not support + # concurrent ``run`` calls. Keep the normal Workflow programming + # model intact by serializing requests to the shared workflow + # instance supplied to this host. + async with self._workflow_lock: + return await self._invoke_workflow(request) # type: ignore[return-value] run_kwargs = self._build_run_kwargs(request) with self._bind_request_context(request): # ``_is_workflow`` is False here so ``self.target`` is an @@ -1274,16 +1281,17 @@ async def _bridge() -> AsyncIterator[AgentResponseUpdate]: # ``_restore_workflow_checkpoint``) — kept inside the bridge # so the in-memory state is rehydrated lazily on first # iteration rather than at stream-construction time. - await self._restore_workflow_checkpoint_streaming(workflow, storage) - workflow_stream = workflow.run(request.input, stream=True, checkpoint_storage=storage) - try: - async for event in workflow_stream: - update = _workflow_event_to_update(event) - if update is not None: - yield update - finally: - async with _suppress_already_consumed(): - await workflow_stream.get_final_response() + async with self._workflow_lock: + await self._restore_workflow_checkpoint_streaming(workflow, storage) + workflow_stream = workflow.run(request.input, stream=True, checkpoint_storage=storage) + try: + async for event in workflow_stream: + update = _workflow_event_to_update(event) + if update is not None: + yield update + finally: + async with _suppress_already_consumed(): + await workflow_stream.get_final_response() async def _finalize(updates: Sequence[AgentResponseUpdate]) -> AgentResponse: # noqa: RUF029 return AgentResponse.from_updates(updates) From 186221956bbfca535eff59ea9e129b7422b78f34 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 18 Jun 2026 09:11:02 +0200 Subject: [PATCH 3/8] Adopt source pyright typing setup for hosting packages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../packages/hosting-responses/pyproject.toml | 18 ------------------ python/packages/hosting/pyproject.toml | 18 ------------------ 2 files changed, 36 deletions(-) diff --git a/python/packages/hosting-responses/pyproject.toml b/python/packages/hosting-responses/pyproject.toml index 6606c94455a..701d3111352 100644 --- a/python/packages/hosting-responses/pyproject.toml +++ b/python/packages/hosting-responses/pyproject.toml @@ -63,20 +63,6 @@ extends = "../../pyproject.toml" include = ["agent_framework_hosting_responses"] exclude = ['tests'] -[tool.mypy] -plugins = ['pydantic.mypy'] -strict = true -python_version = "3.10" -ignore_missing_imports = true -disallow_untyped_defs = true -no_implicit_optional = true -check_untyped_defs = true -warn_return_any = true -show_error_codes = true -warn_unused_ignores = false -disallow_incomplete_defs = true -disallow_untyped_decorators = true - [tool.bandit] targets = ["agent_framework_hosting_responses"] exclude_dirs = ["tests"] @@ -85,10 +71,6 @@ exclude_dirs = ["tests"] executor.type = "uv" include = "../../shared_tasks.toml" -[tool.poe.tasks.mypy] -help = "Run MyPy for this package." -cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_hosting_responses" - [tool.poe.tasks.test] help = "Run the default unit test suite for this package." cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_responses --cov-report=term-missing:skip-covered tests' diff --git a/python/packages/hosting/pyproject.toml b/python/packages/hosting/pyproject.toml index f412c84c293..208e0e920b8 100644 --- a/python/packages/hosting/pyproject.toml +++ b/python/packages/hosting/pyproject.toml @@ -70,20 +70,6 @@ extends = "../../pyproject.toml" include = ["agent_framework_hosting"] exclude = ['tests'] -[tool.mypy] -plugins = ['pydantic.mypy'] -strict = true -python_version = "3.10" -ignore_missing_imports = true -disallow_untyped_defs = true -no_implicit_optional = true -check_untyped_defs = true -warn_return_any = true -show_error_codes = true -warn_unused_ignores = false -disallow_incomplete_defs = true -disallow_untyped_decorators = true - [tool.bandit] targets = ["agent_framework_hosting"] exclude_dirs = ["tests"] @@ -92,10 +78,6 @@ exclude_dirs = ["tests"] executor.type = "uv" include = "../../shared_tasks.toml" -[tool.poe.tasks.mypy] -help = "Run MyPy for this package." -cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_hosting" - [tool.poe.tasks.test] help = "Run the default unit test suite for this package." cmd = 'pytest -m "not integration" --cov=agent_framework_hosting --cov-report=term-missing:skip-covered tests' From c6abecc0688853eb9a9d23a29b48827e9c57f0d2 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 18 Jun 2026 09:20:32 +0200 Subject: [PATCH 4/8] Cover ResponsesChannel custom path routing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/hosting-responses/tests/test_channel.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/python/packages/hosting-responses/tests/test_channel.py b/python/packages/hosting-responses/tests/test_channel.py index cc2fef639e5..c170d1b7d2a 100644 --- a/python/packages/hosting-responses/tests/test_channel.py +++ b/python/packages/hosting-responses/tests/test_channel.py @@ -117,6 +117,15 @@ def test_empty_path_mounts_at_app_root(self) -> None: assert r.status_code == 200 assert r.json()["output"][0]["content"][0]["text"] == "hi back" + def test_custom_path_mounts_route_under_host_path(self) -> None: + client, _host, _agent = _make_client(_FakeAgent(reply="custom"), path="/api/responses") + with client: + r = client.post("/api/responses", json={"input": "hi"}) + missing = client.post("/api/responses/responses", json={"input": "hi"}) + assert r.status_code == 200 + assert r.json()["output"][0]["content"][0]["text"] == "custom" + assert missing.status_code == 404 + def test_invalid_json_returns_400(self) -> None: client, *_ = _make_client() with client: From c5718e0cff2317ee9e750774db6da69d4d772575 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 18 Jun 2026 09:31:43 +0200 Subject: [PATCH 5/8] Align hosting tests with package layout Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../hosting-responses/tests/__init__.py | 0 .../{ => hosting_responses}/test_channel.py | 0 .../{ => hosting_responses}/test_parsing.py | 0 python/packages/hosting/tests/__init__.py | 0 python/packages/hosting/tests/conftest.py | 25 ------------------- .../tests/{ => hosting}/_workflow_fixtures.py | 0 .../hosting/tests/{ => hosting}/test_host.py | 22 ++++++++-------- .../tests/{ => hosting}/test_host_disk.py | 5 ++-- .../tests/{ => hosting}/test_isolation.py | 0 .../hosting/tests/{ => hosting}/test_types.py | 0 10 files changed, 12 insertions(+), 40 deletions(-) delete mode 100644 python/packages/hosting-responses/tests/__init__.py rename python/packages/hosting-responses/tests/{ => hosting_responses}/test_channel.py (100%) rename python/packages/hosting-responses/tests/{ => hosting_responses}/test_parsing.py (100%) delete mode 100644 python/packages/hosting/tests/__init__.py delete mode 100644 python/packages/hosting/tests/conftest.py rename python/packages/hosting/tests/{ => hosting}/_workflow_fixtures.py (100%) rename python/packages/hosting/tests/{ => hosting}/test_host.py (98%) rename python/packages/hosting/tests/{ => hosting}/test_host_disk.py (98%) rename python/packages/hosting/tests/{ => hosting}/test_isolation.py (100%) rename python/packages/hosting/tests/{ => hosting}/test_types.py (100%) diff --git a/python/packages/hosting-responses/tests/__init__.py b/python/packages/hosting-responses/tests/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/python/packages/hosting-responses/tests/test_channel.py b/python/packages/hosting-responses/tests/hosting_responses/test_channel.py similarity index 100% rename from python/packages/hosting-responses/tests/test_channel.py rename to python/packages/hosting-responses/tests/hosting_responses/test_channel.py diff --git a/python/packages/hosting-responses/tests/test_parsing.py b/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py similarity index 100% rename from python/packages/hosting-responses/tests/test_parsing.py rename to python/packages/hosting-responses/tests/hosting_responses/test_parsing.py diff --git a/python/packages/hosting/tests/__init__.py b/python/packages/hosting/tests/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/python/packages/hosting/tests/conftest.py b/python/packages/hosting/tests/conftest.py deleted file mode 100644 index aa677567126..00000000000 --- a/python/packages/hosting/tests/conftest.py +++ /dev/null @@ -1,25 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Pytest configuration for hosting tests.""" - -from __future__ import annotations - -import importlib.util -import sys -from pathlib import Path - - -def pytest_configure() -> None: - """Make local workflow fixtures importable in package and aggregate test modes.""" - module_name = "tests._workflow_fixtures" - if module_name in sys.modules: - return - - fixture_path = Path(__file__).with_name("_workflow_fixtures.py") - spec = importlib.util.spec_from_file_location(module_name, fixture_path) - if spec is None or spec.loader is None: - raise ImportError(f"Unable to load workflow fixtures from {fixture_path}") - - module = importlib.util.module_from_spec(spec) - sys.modules[module_name] = module - spec.loader.exec_module(module) diff --git a/python/packages/hosting/tests/_workflow_fixtures.py b/python/packages/hosting/tests/hosting/_workflow_fixtures.py similarity index 100% rename from python/packages/hosting/tests/_workflow_fixtures.py rename to python/packages/hosting/tests/hosting/_workflow_fixtures.py diff --git a/python/packages/hosting/tests/test_host.py b/python/packages/hosting/tests/hosting/test_host.py similarity index 98% rename from python/packages/hosting/tests/test_host.py rename to python/packages/hosting/tests/hosting/test_host.py index 1bbf53a7cde..46e130fa318 100644 --- a/python/packages/hosting/tests/test_host.py +++ b/python/packages/hosting/tests/hosting/test_host.py @@ -371,7 +371,7 @@ class TestHostWorkflowTarget: """The host accepts a ``Workflow`` and dispatches to ``workflow.run(...)``.""" async def test_invoke_workflow_collapses_outputs_to_hosted_run_result(self) -> None: - from ._workflow_fixtures import build_upper_workflow + from _workflow_fixtures import build_upper_workflow workflow = build_upper_workflow() ch = _RecordingChannel() @@ -391,7 +391,7 @@ async def test_invoke_workflow_collapses_outputs_to_hosted_run_result(self) -> N assert host._sessions == {} async def test_stream_workflow_yields_updates_and_finalizes(self) -> None: - from ._workflow_fixtures import build_echo_workflow + from _workflow_fixtures import build_echo_workflow workflow = build_echo_workflow() ch = _RecordingChannel() @@ -419,7 +419,7 @@ async def test_stream_workflow_yields_updates_and_finalizes(self) -> None: assert final.text == "hi" async def test_stream_workflow_yields_one_update_per_output_event(self) -> None: - from ._workflow_fixtures import build_multi_chunk_workflow + from _workflow_fixtures import build_multi_chunk_workflow workflow = build_multi_chunk_workflow() ch = _RecordingChannel() @@ -475,10 +475,9 @@ class TestHostWorkflowCheckpointing: """The host scopes per-conversation checkpoints when ``checkpoint_location`` is set.""" def test_rejects_workflow_with_existing_checkpoint_storage(self, tmp_path: Any) -> None: + from _workflow_fixtures import _UpperExecutor from agent_framework import InMemoryCheckpointStorage, WorkflowBuilder - from ._workflow_fixtures import _UpperExecutor - workflow = WorkflowBuilder( start_executor=_UpperExecutor(id="upper"), checkpoint_storage=InMemoryCheckpointStorage(), @@ -500,7 +499,7 @@ def test_warns_when_target_is_agent(self, tmp_path: Any, caplog: Any) -> None: assert any("checkpoint_location" in rec.message for rec in caplog.records) async def test_invoke_skips_checkpointing_when_no_isolation_key(self, tmp_path: Any) -> None: - from ._workflow_fixtures import build_upper_workflow + from _workflow_fixtures import build_upper_workflow workflow = build_upper_workflow() ch = _RecordingChannel() @@ -516,7 +515,7 @@ async def test_invoke_skips_checkpointing_when_no_isolation_key(self, tmp_path: assert list(tmp_path.iterdir()) == [] async def test_invoke_writes_checkpoint_under_isolation_key(self, tmp_path: Any) -> None: - from ._workflow_fixtures import build_upper_workflow + from _workflow_fixtures import build_upper_workflow workflow = build_upper_workflow() ch = _RecordingChannel() @@ -540,7 +539,7 @@ async def test_invoke_writes_checkpoint_under_isolation_key(self, tmp_path: Any) assert any(scoped.iterdir()), "expected at least one checkpoint to be written under the per-user dir" async def test_stream_writes_checkpoint_under_isolation_key(self, tmp_path: Any) -> None: - from ._workflow_fixtures import build_echo_workflow + from _workflow_fixtures import build_echo_workflow workflow = build_echo_workflow() ch = _RecordingChannel() @@ -564,10 +563,9 @@ async def test_stream_writes_checkpoint_under_isolation_key(self, tmp_path: Any) assert any(scoped.iterdir()) async def test_caller_supplied_checkpoint_storage_used_as_is(self, tmp_path: Any) -> None: + from _workflow_fixtures import build_upper_workflow from agent_framework import InMemoryCheckpointStorage - from ._workflow_fixtures import build_upper_workflow - storage = InMemoryCheckpointStorage() workflow = build_upper_workflow() ch = _RecordingChannel() @@ -648,7 +646,7 @@ class TestHostWorkflowCheckpointingPathTraversal: async def test_traversal_key_skips_checkpointing_with_warning(self, tmp_path: Any, caplog: Any) -> None: import logging as _logging - from ._workflow_fixtures import build_upper_workflow + from _workflow_fixtures import build_upper_workflow workflow = build_upper_workflow() ch = _RecordingChannel() @@ -673,7 +671,7 @@ async def test_traversal_key_skips_checkpointing_with_warning(self, tmp_path: An ) async def test_separator_in_key_skips_checkpointing(self, tmp_path: Any) -> None: - from ._workflow_fixtures import build_upper_workflow + from _workflow_fixtures import build_upper_workflow workflow = build_upper_workflow() ch = _RecordingChannel() diff --git a/python/packages/hosting/tests/test_host_disk.py b/python/packages/hosting/tests/hosting/test_host_disk.py similarity index 98% rename from python/packages/hosting/tests/test_host_disk.py rename to python/packages/hosting/tests/hosting/test_host_disk.py index 47c78d2edc2..05f1bbbf218 100644 --- a/python/packages/hosting/tests/test_host_disk.py +++ b/python/packages/hosting/tests/hosting/test_host_disk.py @@ -107,7 +107,7 @@ def test_session_aliases_survive_restart(tmp_path: Path) -> None: def _build_simple_workflow() -> Any: """Build a no-op workflow for checkpoint-wiring tests.""" - from ._workflow_fixtures import build_upper_workflow + from _workflow_fixtures import build_upper_workflow return build_upper_workflow() @@ -211,10 +211,9 @@ def test_state_dir_checkpoints_for_agent_target_warns_when_explicit( def test_state_dir_checkpoints_conflicts_with_workflow_own_storage(tmp_path: Path) -> None: """Derived checkpoint path triggers the same conflict guard as explicit.""" + from _workflow_fixtures import _UpperExecutor from agent_framework import InMemoryCheckpointStorage, WorkflowBuilder - from ._workflow_fixtures import _UpperExecutor - workflow = WorkflowBuilder( start_executor=_UpperExecutor(id="upper"), checkpoint_storage=InMemoryCheckpointStorage(), diff --git a/python/packages/hosting/tests/test_isolation.py b/python/packages/hosting/tests/hosting/test_isolation.py similarity index 100% rename from python/packages/hosting/tests/test_isolation.py rename to python/packages/hosting/tests/hosting/test_isolation.py diff --git a/python/packages/hosting/tests/test_types.py b/python/packages/hosting/tests/hosting/test_types.py similarity index 100% rename from python/packages/hosting/tests/test_types.py rename to python/packages/hosting/tests/hosting/test_types.py From 1219527ab4c32c92a572c925c9f33015201476ca Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 18 Jun 2026 09:40:58 +0200 Subject: [PATCH 6/8] Fix hosting workflow fixture imports in aggregate tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../hosting/tests/hosting/conftest.py | 25 +++++++++++++++++++ .../hosting/tests/hosting/test_host.py | 20 +++++++-------- .../hosting/tests/hosting/test_host_disk.py | 4 +-- 3 files changed, 37 insertions(+), 12 deletions(-) create mode 100644 python/packages/hosting/tests/hosting/conftest.py diff --git a/python/packages/hosting/tests/hosting/conftest.py b/python/packages/hosting/tests/hosting/conftest.py new file mode 100644 index 00000000000..b0ca8e6b911 --- /dev/null +++ b/python/packages/hosting/tests/hosting/conftest.py @@ -0,0 +1,25 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Pytest configuration for hosting tests.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + + +def pytest_configure() -> None: + """Make workflow fixtures importable in package-local and aggregate test modes.""" + module_name = "hosting_workflow_fixtures" + if module_name in sys.modules: + return + + fixture_path = Path(__file__).with_name("_workflow_fixtures.py") + spec = importlib.util.spec_from_file_location(module_name, fixture_path) + if spec is None or spec.loader is None: + raise ImportError(f"Unable to load workflow fixtures from {fixture_path}") + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) diff --git a/python/packages/hosting/tests/hosting/test_host.py b/python/packages/hosting/tests/hosting/test_host.py index 46e130fa318..d1777df3496 100644 --- a/python/packages/hosting/tests/hosting/test_host.py +++ b/python/packages/hosting/tests/hosting/test_host.py @@ -371,7 +371,7 @@ class TestHostWorkflowTarget: """The host accepts a ``Workflow`` and dispatches to ``workflow.run(...)``.""" async def test_invoke_workflow_collapses_outputs_to_hosted_run_result(self) -> None: - from _workflow_fixtures import build_upper_workflow + from hosting_workflow_fixtures import build_upper_workflow workflow = build_upper_workflow() ch = _RecordingChannel() @@ -391,7 +391,7 @@ async def test_invoke_workflow_collapses_outputs_to_hosted_run_result(self) -> N assert host._sessions == {} async def test_stream_workflow_yields_updates_and_finalizes(self) -> None: - from _workflow_fixtures import build_echo_workflow + from hosting_workflow_fixtures import build_echo_workflow workflow = build_echo_workflow() ch = _RecordingChannel() @@ -419,7 +419,7 @@ async def test_stream_workflow_yields_updates_and_finalizes(self) -> None: assert final.text == "hi" async def test_stream_workflow_yields_one_update_per_output_event(self) -> None: - from _workflow_fixtures import build_multi_chunk_workflow + from hosting_workflow_fixtures import build_multi_chunk_workflow workflow = build_multi_chunk_workflow() ch = _RecordingChannel() @@ -475,8 +475,8 @@ class TestHostWorkflowCheckpointing: """The host scopes per-conversation checkpoints when ``checkpoint_location`` is set.""" def test_rejects_workflow_with_existing_checkpoint_storage(self, tmp_path: Any) -> None: - from _workflow_fixtures import _UpperExecutor from agent_framework import InMemoryCheckpointStorage, WorkflowBuilder + from hosting_workflow_fixtures import _UpperExecutor workflow = WorkflowBuilder( start_executor=_UpperExecutor(id="upper"), @@ -499,7 +499,7 @@ def test_warns_when_target_is_agent(self, tmp_path: Any, caplog: Any) -> None: assert any("checkpoint_location" in rec.message for rec in caplog.records) async def test_invoke_skips_checkpointing_when_no_isolation_key(self, tmp_path: Any) -> None: - from _workflow_fixtures import build_upper_workflow + from hosting_workflow_fixtures import build_upper_workflow workflow = build_upper_workflow() ch = _RecordingChannel() @@ -515,7 +515,7 @@ async def test_invoke_skips_checkpointing_when_no_isolation_key(self, tmp_path: assert list(tmp_path.iterdir()) == [] async def test_invoke_writes_checkpoint_under_isolation_key(self, tmp_path: Any) -> None: - from _workflow_fixtures import build_upper_workflow + from hosting_workflow_fixtures import build_upper_workflow workflow = build_upper_workflow() ch = _RecordingChannel() @@ -539,7 +539,7 @@ async def test_invoke_writes_checkpoint_under_isolation_key(self, tmp_path: Any) assert any(scoped.iterdir()), "expected at least one checkpoint to be written under the per-user dir" async def test_stream_writes_checkpoint_under_isolation_key(self, tmp_path: Any) -> None: - from _workflow_fixtures import build_echo_workflow + from hosting_workflow_fixtures import build_echo_workflow workflow = build_echo_workflow() ch = _RecordingChannel() @@ -563,8 +563,8 @@ async def test_stream_writes_checkpoint_under_isolation_key(self, tmp_path: Any) assert any(scoped.iterdir()) async def test_caller_supplied_checkpoint_storage_used_as_is(self, tmp_path: Any) -> None: - from _workflow_fixtures import build_upper_workflow from agent_framework import InMemoryCheckpointStorage + from hosting_workflow_fixtures import build_upper_workflow storage = InMemoryCheckpointStorage() workflow = build_upper_workflow() @@ -646,7 +646,7 @@ class TestHostWorkflowCheckpointingPathTraversal: async def test_traversal_key_skips_checkpointing_with_warning(self, tmp_path: Any, caplog: Any) -> None: import logging as _logging - from _workflow_fixtures import build_upper_workflow + from hosting_workflow_fixtures import build_upper_workflow workflow = build_upper_workflow() ch = _RecordingChannel() @@ -671,7 +671,7 @@ async def test_traversal_key_skips_checkpointing_with_warning(self, tmp_path: An ) async def test_separator_in_key_skips_checkpointing(self, tmp_path: Any) -> None: - from _workflow_fixtures import build_upper_workflow + from hosting_workflow_fixtures import build_upper_workflow workflow = build_upper_workflow() ch = _RecordingChannel() diff --git a/python/packages/hosting/tests/hosting/test_host_disk.py b/python/packages/hosting/tests/hosting/test_host_disk.py index 05f1bbbf218..77d3fd9b278 100644 --- a/python/packages/hosting/tests/hosting/test_host_disk.py +++ b/python/packages/hosting/tests/hosting/test_host_disk.py @@ -107,7 +107,7 @@ def test_session_aliases_survive_restart(tmp_path: Path) -> None: def _build_simple_workflow() -> Any: """Build a no-op workflow for checkpoint-wiring tests.""" - from _workflow_fixtures import build_upper_workflow + from hosting_workflow_fixtures import build_upper_workflow return build_upper_workflow() @@ -211,8 +211,8 @@ def test_state_dir_checkpoints_for_agent_target_warns_when_explicit( def test_state_dir_checkpoints_conflicts_with_workflow_own_storage(tmp_path: Path) -> None: """Derived checkpoint path triggers the same conflict guard as explicit.""" - from _workflow_fixtures import _UpperExecutor from agent_framework import InMemoryCheckpointStorage, WorkflowBuilder + from hosting_workflow_fixtures import _UpperExecutor workflow = WorkflowBuilder( start_executor=_UpperExecutor(id="upper"), From d0dc90bf4e0be6b3883265e7f487c97574ed926c Mon Sep 17 00:00:00 2001 From: Ashutosh0x Date: Sat, 20 Jun 2026 14:40:53 +0530 Subject: [PATCH 7/8] Python: Fix ResponsesChannel session continuity and harden _result_to_text for workflows Fixes three issues flagged by automated review on PR #6580: 1. Session isolation key continuity (Bugs 2 & 3): When no external session is provided, the fallback isolation key now prefers `previous_response_id` over the current `response_id`. This ensures checkpoint storage, FileHistoryProvider, and other session-scoped stores find data written during the preceding turn instead of silently starting fresh. 2. Workflow result rendering (Bug 1): `_result_to_text` now handles WorkflowRunResult objects whose `get_outputs()` returns an empty list by falling back to `get_final_state()` before the generic `str()` cast. 3. Added tests for empty-output workflow results, final-state fallback, and plain-string rendering. --- .../_channel.py | 39 +++++++++++++++++-- .../tests/hosting_responses/test_channel.py | 27 +++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/python/packages/hosting-responses/agent_framework_hosting_responses/_channel.py b/python/packages/hosting-responses/agent_framework_hosting_responses/_channel.py index a126ebd5ce5..0418a8aa8ba 100644 --- a/python/packages/hosting-responses/agent_framework_hosting_responses/_channel.py +++ b/python/packages/hosting-responses/agent_framework_hosting_responses/_channel.py @@ -190,7 +190,17 @@ async def _handle(self, request: Request) -> Response: # No-arg factories continue to work via ``Callable[..., str]``. response_id = self._response_id_factory(previous_response_id) if session is None: - session = ChannelSession(isolation_key=response_id) + # When continuing a multi-turn chain, anchor the isolation + # key to the *previous* response id so that checkpoint + # storage, FileHistoryProvider, and other session-scoped + # stores locate data written during the preceding turn. + # Without this, each turn mints a fresh isolation key (its + # own response_id) and the host's + # ``_resolve_checkpoint_storage`` / history provider sees + # an empty store — silently dropping conversation state. + session = ChannelSession( + isolation_key=previous_response_id or response_id, + ) attributes: dict[str, Any] = {"response_id": response_id} if previous_response_id is not None: @@ -364,13 +374,36 @@ def sse(event: Any) -> str: def _result_to_text(result: Any) -> str: - """Render an agent or workflow result to plain text for Responses JSON.""" + """Render an agent or workflow result to plain text for Responses JSON. + + Handles three shapes: + + 1. **Agent results** — carry a ``.text`` attribute with the final + assistant message. + 2. **Workflow results** — expose ``.get_outputs()`` yielding + per-executor output objects (which may themselves have ``.text``). + When ``get_outputs()`` is empty or returns non-text objects the + fallback to ``str()`` keeps the envelope valid. + 3. **Unknown / primitive** — ``str()`` cast. + """ + # Fast path for agent results. text = getattr(result, "text", None) if isinstance(text, str): return text + # Workflow path: iterate per-executor outputs. get_outputs = getattr(result, "get_outputs", None) if callable(get_outputs): - return "".join(_output_to_text(output) for output in cast("Sequence[Any]", get_outputs())) + outputs = list(cast("Sequence[Any]", get_outputs())) + if outputs: + return "".join(_output_to_text(output) for output in outputs) + # Workflow completed but no output executors emitted content. + # Fall through to the ``get_final_state`` / ``str`` path so + # the caller still gets something renderable. + get_final_state = getattr(result, "get_final_state", None) + if callable(get_final_state): + state = get_final_state() + if state is not None: + return str(state) return str(result) diff --git a/python/packages/hosting-responses/tests/hosting_responses/test_channel.py b/python/packages/hosting-responses/tests/hosting_responses/test_channel.py index c170d1b7d2a..0f2c6feaaae 100644 --- a/python/packages/hosting-responses/tests/hosting_responses/test_channel.py +++ b/python/packages/hosting-responses/tests/hosting_responses/test_channel.py @@ -247,6 +247,33 @@ def get_outputs(self) -> list[Any]: assert _result_to_text(_WorkflowResult()) == "one two" + def test_result_text_empty_outputs_falls_back_to_final_state(self) -> None: + """When get_outputs() returns an empty list, fall back to get_final_state().""" + + class _EmptyOutputsResult: + def get_outputs(self) -> list[Any]: + return [] + + def get_final_state(self) -> str: + return "final state content" + + assert _result_to_text(_EmptyOutputsResult()) == "final state content" + + def test_result_text_empty_outputs_no_final_state_falls_back_to_str(self) -> None: + """When get_outputs() is empty and no get_final_state(), use str().""" + + class _EmptyResult: + def get_outputs(self) -> list[Any]: + return [] + + def __str__(self) -> str: + return "stringified result" + + assert _result_to_text(_EmptyResult()) == "stringified result" + + def test_result_text_plain_string_uses_str(self) -> None: + assert _result_to_text("just a string") == "just a string" + class TestResponsesChannelStreaming: def test_sse_emits_created_delta_completed(self) -> None: From ffa63ffb8b693e30398defd3deaca7e7e56c2845 Mon Sep 17 00:00:00 2001 From: Ashutosh0x Date: Sat, 20 Jun 2026 14:56:44 +0530 Subject: [PATCH 8/8] Address Copilot review comments: body validation, content parsing, deterministic sample, response.id print - Add body type validation in _channel.py (reject non-object JSON with 422) - Reject invalid message content types in _parsing.py instead of silent empty - Make lookup_weather deterministic via location hash instead of randint - Print response.id in call_server.py for multi-turn chain usability --- .../agent_framework_hosting_responses/_channel.py | 2 ++ .../agent_framework_hosting_responses/_parsing.py | 2 +- python/samples/04-hosting/af-hosting/local_responses/app.py | 3 +-- .../04-hosting/af-hosting/local_responses/call_server.py | 1 + 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/python/packages/hosting-responses/agent_framework_hosting_responses/_channel.py b/python/packages/hosting-responses/agent_framework_hosting_responses/_channel.py index 0418a8aa8ba..136f8c82533 100644 --- a/python/packages/hosting-responses/agent_framework_hosting_responses/_channel.py +++ b/python/packages/hosting-responses/agent_framework_hosting_responses/_channel.py @@ -142,6 +142,8 @@ async def _handle(self, request: Request) -> Response: body = await request.json() except Exception: return JSONResponse({"error": "invalid json"}, status_code=400) + if not isinstance(body, Mapping): + return JSONResponse({"error": "request body must be a JSON object"}, status_code=422) try: messages, options, session = parse_responses_request(body) diff --git a/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py b/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py index eb1e81e99eb..b45fd431594 100644 --- a/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py +++ b/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py @@ -119,7 +119,7 @@ def flush() -> None: raise ValueError("each message `content` item must be an object") parts.append(_content_from_input_item(cast("Mapping[str, Any]", content_item))) else: - parts = [] + raise ValueError("message `content` must be a string or list") messages.append(Message(role, parts)) else: pending_user_parts.append(_content_from_input_item(item_map)) diff --git a/python/samples/04-hosting/af-hosting/local_responses/app.py b/python/samples/04-hosting/af-hosting/local_responses/app.py index 17312786116..90985227b09 100644 --- a/python/samples/04-hosting/af-hosting/local_responses/app.py +++ b/python/samples/04-hosting/af-hosting/local_responses/app.py @@ -44,7 +44,6 @@ import os from dataclasses import replace from pathlib import Path -from random import randint from typing import Annotated from agent_framework import Agent, FileHistoryProvider, tool @@ -62,7 +61,7 @@ def lookup_weather( location: Annotated[str, "The city to look up weather for."], ) -> str: """Return a deterministic weather report for a city.""" - high_temp = randint(5, 25) + high_temp = 5 + (sum(location.encode("utf-8")) % 21) reports = { "Seattle": f"Seattle is rainy with a high of {high_temp}°C.", "Amsterdam": f"Amsterdam is cloudy with a high of {high_temp}°C.", diff --git a/python/samples/04-hosting/af-hosting/local_responses/call_server.py b/python/samples/04-hosting/af-hosting/local_responses/call_server.py index aeeaa39479b..142cbd9d55e 100644 --- a/python/samples/04-hosting/af-hosting/local_responses/call_server.py +++ b/python/samples/04-hosting/af-hosting/local_responses/call_server.py @@ -41,6 +41,7 @@ def main() -> None: ) print(f"User: {prompt}") print(f"Agent: {response.output_text}") + print(f"response.id: {response.id}") if __name__ == "__main__":