From 45637f6d43005e87982c3332cc8380997c702f4d Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 2 Jul 2026 16:45:08 +0200 Subject: [PATCH 01/15] Add Python hosting protocol helper surface Introduce AgentFrameworkState and SessionStore for app-owned hosting routes, add Responses run conversion/rendering helpers, and update the local Responses sample to use native FastAPI routing with streaming support. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/hosting-responses/README.md | 50 +- .../__init__.py | 10 + .../_parsing.py | 785 +++++++++++++++++- .../tests/hosting_responses/test_parsing.py | 129 +++ python/packages/hosting/README.md | 120 +-- .../agent_framework_hosting/__init__.py | 5 + .../hosting/agent_framework_hosting/_state.py | 222 +++++ .../hosting/tests/hosting/test_state.py | 138 +++ .../samples/04-hosting/af-hosting/README.md | 2 +- .../af-hosting/local_responses/README.md | 44 +- .../af-hosting/local_responses/app.py | 153 ++-- .../local_responses/call_server_af.py | 14 +- .../af-hosting/local_responses/pyproject.toml | 3 +- 13 files changed, 1471 insertions(+), 204 deletions(-) create mode 100644 python/packages/hosting/agent_framework_hosting/_state.py create mode 100644 python/packages/hosting/tests/hosting/test_state.py diff --git a/python/packages/hosting-responses/README.md b/python/packages/hosting-responses/README.md index ae03d364af3..956d59f516f 100644 --- a/python/packages/hosting-responses/README.md +++ b/python/packages/hosting-responses/README.md @@ -1,21 +1,49 @@ # agent-framework-hosting-responses -OpenAI Responses-shaped channel for `agent-framework-hosting`. +OpenAI Responses-shaped helpers for app-owned 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`. +This package provides the Responses-specific conversion layer: + +- `responses_to_run(...)` — convert a Responses request body into Agent + Framework run values. +- `responses_session_id(...)` — extract a prior `resp_*` response id or + `conv_*` conversation id from the request body when present. +- `create_response_id(...)` — mint a Responses-shaped response id. +- `responses_from_run(...)` — convert an `AgentResponse` into a + Responses-compatible JSON payload. +- `responses_stream_events_from_run(...)` — convert an Agent Framework + `ResponseStream` into Responses-compatible SSE events. + +FastAPI/Starlette/Django/Azure Functions code owns route registration, +authentication, status codes, response construction, and background work. ```python -from agent_framework.openai import OpenAIChatClient -from agent_framework_hosting import AgentFrameworkHost -from agent_framework_hosting_responses import ResponsesChannel +from agent_framework_hosting import AgentFrameworkState, SessionStore +from agent_framework_hosting_responses import ( + create_response_id, + responses_from_run, + responses_session_id, + responses_to_run, +) +from fastapi import Body, FastAPI +from fastapi.responses import JSONResponse + +app = FastAPI() +state = AgentFrameworkState(agent, session_store=SessionStore) -agent = OpenAIChatClient().as_agent(name="Assistant") -host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel()]) -host.serve(port=8000) +@app.post("/responses") +async def responses(body: dict = Body(...)) -> JSONResponse: + run = responses_to_run(body) + session_id = responses_session_id(body) + response_id = create_response_id() + result = await (await state.get_target()).run( + run["messages"], + session=await state.get_session(session_id or response_id), + options=run["options"], + ) + return JSONResponse(responses_from_run(result, response_id=response_id, session_id=session_id)) ``` -The base host plumbing lives in +The base execution-state helpers live 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 index cd221b56824..eea77e876a8 100644 --- a/python/packages/hosting-responses/agent_framework_hosting_responses/__init__.py +++ b/python/packages/hosting-responses/agent_framework_hosting_responses/__init__.py @@ -6,9 +6,14 @@ from ._channel import ResponsesChannel from ._parsing import ( + create_response_id, messages_from_responses_input, parse_responses_identity, parse_responses_request, + responses_from_run, + responses_session_id, + responses_stream_events_from_run, + responses_to_run, ) try: @@ -19,7 +24,12 @@ __all__ = [ "ResponsesChannel", "__version__", + "create_response_id", "messages_from_responses_input", "parse_responses_identity", "parse_responses_request", + "responses_from_run", + "responses_session_id", + "responses_stream_events_from_run", + "responses_to_run", ] 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 fa742c55563..72c8d632118 100644 --- a/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py +++ b/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py @@ -15,11 +15,30 @@ from __future__ import annotations -from collections.abc import Mapping +import json +import time +import uuid +from collections.abc import AsyncIterator, Mapping, Sequence from typing import Any, cast -from agent_framework import Content, Message -from agent_framework_hosting import ChannelIdentity, ChannelSession +from agent_framework import AgentResponse, AgentResponseUpdate, ChatOptions, Content, Message, ResponseStream +from agent_framework_hosting import AgentRunArgs, ChannelIdentity, ChannelSession +from openai.types.responses import ( + Response as OpenAIResponse, +) +from openai.types.responses import ( + ResponseFunctionToolCall, + ResponseFunctionToolCallOutputItem, + ResponseInputFile, + ResponseInputImage, + ResponseInputText, + ResponseOutputItem, + ResponseOutputMessage, + ResponseOutputText, +) +from pydantic import TypeAdapter, ValidationError + +_RESPONSE_OUTPUT_ITEM_ADAPTER: TypeAdapter[Any] = TypeAdapter(ResponseOutputItem) # OpenAI Responses field name → Agent Framework ChatOptions field name. _RESPONSES_OPTION_REMAP = { @@ -29,6 +48,7 @@ # Fields the Responses transport owns; they are consumed separately and must # not also appear in options. _RESPONSES_TRANSPORT_KEYS = frozenset({"input", "stream", "previous_response_id"}) +_RESPONSES_RUN_TRANSPORT_KEYS = frozenset({"input", "stream", "previous_response_id", "conversation_id"}) def parse_responses_identity(body: Mapping[str, Any], channel_name: str) -> ChannelIdentity | None: @@ -149,8 +169,767 @@ def parse_responses_request( return messages, options, session +def create_response_id() -> str: + """Create a Responses-shaped response id.""" + return f"resp_{uuid.uuid4().hex}" + + +def responses_session_id(body: Mapping[str, Any]) -> str | None: + """Return the Responses session id from request body, if present. + + The returned value can be a ``resp_*`` previous response id or a ``conv_*`` + conversation id. Callers choose whether this request-derived value is + trusted for their route and deployment. + + Args: + body: OpenAI Responses-shaped request body. + + Returns: + Previous response id, conversation id, or ``None``. + """ + previous_response_id = body.get("previous_response_id") + if isinstance(previous_response_id, str) and previous_response_id: + return previous_response_id + conversation_id = body.get("conversation_id") + if isinstance(conversation_id, str) and conversation_id: + return conversation_id + return None + + +def responses_to_run(body: Mapping[str, Any]) -> AgentRunArgs: + """Convert a Responses request body into Agent Framework run values. + + Args: + body: OpenAI Responses-shaped request body. + + Returns: + Arguments corresponding to ``Agent.run``. + + Raises: + ValueError: If the request body has invalid ``input``. + """ + messages = messages_from_responses_input(body.get("input")) + options: dict[str, Any] = {} + for key, value in body.items(): + if key in _RESPONSES_RUN_TRANSPORT_KEYS or value is None: + continue + options[_RESPONSES_OPTION_REMAP.get(key, key)] = value + return AgentRunArgs( + messages=messages, + options=cast("ChatOptions[Any]", options), + stream=bool(body.get("stream", False)), + ) + + +def responses_from_run( + result: AgentResponse[Any], + *, + response_id: str, + session_id: str | None = None, +) -> dict[str, Any]: + """Convert an Agent Framework response into a Responses payload. + + Args: + result: Agent response returned by a run. + + Keyword Args: + response_id: Id for the response being created. + session_id: Optional prior ``resp_*`` or ``conv_*`` session id. When it + is a conversation id, the helper renders it in the Responses + conversation field. + + Returns: + Responses-compatible JSON payload. + """ + output_items = _result_to_output_items(result, status="completed") + response_kwargs: dict[str, Any] = { + "id": response_id, + "object": "response", + "created_at": int(time.time()), + "status": "completed", + "model": _model_from_result(result), + "output": output_items, + "parallel_tool_calls": False, + "tool_choice": "auto", + "tools": [], + "metadata": {}, + } + if session_id is not None and session_id.startswith("conv_"): + response_kwargs["conversation"] = {"id": session_id} + return _response_payload(OpenAIResponse(**response_kwargs)) + + +def _model_from_result(result: Any) -> str: + model = getattr(result, "model", None) + if isinstance(model, str) and model: + return model + raw = getattr(result, "raw_representation", None) + raw_model = getattr(raw, "model", None) + if isinstance(raw_model, str) and raw_model: + return raw_model + additional_properties = getattr(result, "additional_properties", None) + if isinstance(additional_properties, Mapping): + additional_model = cast(Mapping[str, Any], additional_properties).get("model") + if isinstance(additional_model, str) and additional_model: + return additional_model + return "agent" + + +def _result_to_output_items(result: Any, *, status: str) -> list[ResponseOutputItem]: + """Render an agent or workflow result as Responses output items.""" + messages = getattr(result, "messages", None) + if isinstance(messages, Sequence) and not isinstance(messages, (str, bytes, bytearray)): + return _messages_to_output_items(cast("Sequence[Any]", messages), status=status) + + if isinstance(result, Message): + return _messages_to_output_items([result], status=status) + if isinstance(result, Content): + return _contents_to_output_items([result], status=status) + + get_outputs = getattr(result, "get_outputs", None) + if callable(get_outputs): + output_items: list[ResponseOutputItem] = [] + for output in cast("Sequence[Any]", get_outputs()): + output_items.extend(_output_to_output_items(output, status=status)) + return output_items + + text = getattr(result, "text", None) + if isinstance(text, str): + return _text_output_items(text, status=status) + return _text_output_items(_result_to_text(result), status=status) + + +def _output_to_output_items(output: Any, *, status: str) -> list[ResponseOutputItem]: + if isinstance(output, Message): + return _messages_to_output_items([output], status=status) + if isinstance(output, Content): + return _contents_to_output_items([output], status=status) + messages = getattr(output, "messages", None) + if isinstance(messages, Sequence) and not isinstance(messages, (str, bytes, bytearray)): + return _messages_to_output_items(cast("Sequence[Any]", messages), status=status) + text = getattr(output, "text", None) + if isinstance(text, str): + return _text_output_items(text, status=status) + return _text_output_items(str(output), status=status) + + +def _messages_to_output_items(messages: Sequence[Any], *, status: str) -> list[ResponseOutputItem]: + output_items: list[ResponseOutputItem] = [] + message_contents: list[Content] = [] + + for message in messages: + if not isinstance(message, Message): + if message_contents: + output_items.extend(_contents_to_output_items(message_contents, status=status)) + message_contents.clear() + output_items.extend(_output_to_output_items(message, status=status)) + continue + message_contents.extend(message.contents) + + if message_contents: + output_items.extend(_contents_to_output_items(message_contents, status=status)) + + return output_items + + +def _contents_to_output_items( + contents: Sequence[Content], + *, + status: str, + seen_raw_items: dict[tuple[str, str], int] | None = None, +) -> list[ResponseOutputItem]: + output_items: list[ResponseOutputItem] = [] + message_content: list[Any] = [] + seen: dict[tuple[str, str], int] = seen_raw_items if seen_raw_items is not None else {} + + def flush_message() -> None: + if not message_content: + return + output_items.append(_message_output_item(message_content, status=status)) + message_content.clear() + + content_list = list(contents) + index = 0 + while index < len(content_list): + content = content_list[index] + raw_item = _raw_response_output_item(content.raw_representation) + if raw_item is not None: + raw_key = _response_output_item_key(raw_item) + if raw_key in seen: + output_items[seen[raw_key]] = raw_item + else: + flush_message() + seen[raw_key] = len(output_items) + output_items.append(raw_item) + index += 1 + continue + + next_content = content_list[index + 1] if index + 1 < len(content_list) else None + if _is_matching_code_interpreter_result(content, next_content): + flush_message() + output_items.append(_code_interpreter_output_item(content, status=status, result_content=next_content)) + index += 2 + continue + if _is_matching_image_generation_result(content, next_content): + flush_message() + output_items.append(_image_generation_output_item(content, status=status, result_content=next_content)) + index += 2 + continue + if _is_matching_mcp_result(content, next_content): + flush_message() + output_items.append(_mcp_call_output_item(content, status=status, result_content=next_content)) + index += 2 + continue + + match content.type: + case "text": + message_content.append(_message_text_content(content)) + case "text_reasoning": + flush_message() + output_items.append(_reasoning_output_item(content, status=status)) + case "function_call": + flush_message() + output_items.append(_function_call_output_item(content, status=status)) + case "function_result": + flush_message() + output_items.append(_function_result_output_item(content, status=status)) + case "code_interpreter_tool_call" | "code_interpreter_tool_result": + flush_message() + output_items.append(_code_interpreter_output_item(content, status=status)) + case "image_generation_tool_call" | "image_generation_tool_result": + flush_message() + output_items.append(_image_generation_output_item(content, status=status)) + case "mcp_server_tool_call": + flush_message() + output_items.append(_mcp_call_output_item(content, status=status)) + case "mcp_server_tool_result": + flush_message() + output_items.append(_mcp_result_output_item(content, status=status)) + case "shell_tool_call": + flush_message() + output_items.append(_shell_call_output_item(content, status=status)) + case "shell_tool_result": + flush_message() + output_items.append(_shell_result_output_item(content, status=status)) + case "function_approval_request": + flush_message() + output_items.append(_function_approval_request_output_item(content)) + case "function_approval_response": + flush_message() + output_items.append(_function_approval_response_output_item(content)) + case "data" | "uri" | "hosted_file": + flush_message() + output_items.append(_media_content_output_item(content, status=status)) + case "error": + message_content.append(ResponseOutputText(type="output_text", text=str(content), annotations=[])) + case _: + flush_message() + output_items.extend(_text_output_items(json.dumps(content.to_dict(), default=str), status=status)) + index += 1 + + flush_message() + return output_items + + +def _is_matching_code_interpreter_result(content: Content, next_content: Content | None) -> bool: + return ( + content.type == "code_interpreter_tool_call" + and next_content is not None + and next_content.type == "code_interpreter_tool_result" + and content.call_id == next_content.call_id + ) + + +def _is_matching_image_generation_result(content: Content, next_content: Content | None) -> bool: + return ( + content.type == "image_generation_tool_call" + and next_content is not None + and next_content.type == "image_generation_tool_result" + and content.image_id == next_content.image_id + ) + + +def _is_matching_mcp_result(content: Content, next_content: Content | None) -> bool: + return ( + content.type == "mcp_server_tool_call" + and next_content is not None + and next_content.type == "mcp_server_tool_result" + and content.call_id == next_content.call_id + ) + + +def _message_status(status: str) -> str: + return status if status in ("in_progress", "completed", "incomplete") else "incomplete" + + +def _text_output_items(text: str, *, status: str, message_id: str | None = None) -> list[ResponseOutputItem]: + return [ + _message_output_item( + [ResponseOutputText(type="output_text", text=text, annotations=[])], + status=status, + message_id=message_id, + ) + ] + + +def _message_output_item(content: Sequence[Any], *, status: str, message_id: str | None = None) -> ResponseOutputItem: + return cast( + ResponseOutputItem, + ResponseOutputMessage( + id=message_id or f"msg_{uuid.uuid4().hex}", + type="message", + role="assistant", + status=_message_status(status), # type: ignore[arg-type] + content=list(content), + ), + ) + + +def _message_text_content(content: Content) -> Any: + raw_type = _raw_type(content.raw_representation) + if raw_type in ("output_text", "refusal"): + return content.raw_representation + return ResponseOutputText(type="output_text", text=content.text or "", annotations=[]) + + +def _reasoning_output_item(content: Content, *, status: str) -> ResponseOutputItem: + item_data: dict[str, Any] = { + "id": content.id or f"rs_{uuid.uuid4().hex}", + "type": "reasoning", + "summary": [], + "status": _message_status(status), + } + if content.text: + item_data["content"] = [{"type": "reasoning_text", "text": content.text}] + if content.protected_data: + item_data["encrypted_content"] = content.protected_data + return _response_output_item(item_data) + + +def _function_call_output_item(content: Content, *, status: str) -> ResponseOutputItem: + return cast( + ResponseOutputItem, + ResponseFunctionToolCall( + id=content.additional_properties.get("fc_id") if content.additional_properties else None, + type="function_call", + call_id=content.call_id or f"call_{uuid.uuid4().hex}", + name=content.name or "tool", + arguments=_arguments_to_str(content.arguments), + status=_message_status(status), # type: ignore[arg-type] + ), + ) + + +def _function_result_output_item(content: Content, *, status: str) -> ResponseOutputItem: + if content.exception: + output: str | list[Any] = content.exception + elif output_parts := _content_parts_to_input_items(content.items): + output = output_parts + elif isinstance(content.result, str): + output = content.result + elif content.result is None: + output = "" + else: + output = json.dumps(content.result, default=str) + return cast( + ResponseOutputItem, + ResponseFunctionToolCallOutputItem( + id=f"fcout_{uuid.uuid4().hex}", + type="function_call_output", + call_id=content.call_id or f"call_{uuid.uuid4().hex}", + output=output, + status=_message_status(status), # type: ignore[arg-type] + ), + ) + + +def _code_interpreter_output_item( + content: Content, + *, + status: str, + result_content: Content | None = None, +) -> ResponseOutputItem: + output_parts: list[dict[str, Any]] = [] + outputs_value: Any = result_content.outputs if result_content is not None else content.outputs + if isinstance(outputs_value, Sequence) and not isinstance(outputs_value, (str, bytes, bytearray)): + for item in cast(Sequence[Any], outputs_value): + if isinstance(item, Content) and item.type == "text": + output_parts.append({"type": "logs", "logs": item.text or ""}) + elif isinstance(item, Content) and item.type in ("data", "uri") and item.uri: + output_parts.append({"type": "image", "url": item.uri}) + + return _response_output_item({ + "id": _content_item_id(content, result_content) or f"ci_{uuid.uuid4().hex}", + "type": "code_interpreter_call", + "code": _content_sequence_text(content.inputs), + "container_id": str(_content_property(content, result_content, "container_id") or "agent_framework"), + "outputs": output_parts or None, + "status": _code_interpreter_status(status), + }) + + +def _image_generation_output_item( + content: Content, + *, + status: str, + result_content: Content | None = None, +) -> ResponseOutputItem: + result_source = result_content.outputs if result_content is not None else content.outputs + image_id = content.image_id or (result_content.image_id if result_content is not None else None) + return _response_output_item({ + "id": image_id or f"ig_{uuid.uuid4().hex}", + "type": "image_generation_call", + "result": _image_generation_result(result_source), + "status": _image_generation_status(status), + }) + + +def _mcp_call_output_item( + content: Content, + *, + status: str, + result_content: Content | None = None, +) -> ResponseOutputItem: + return _response_output_item({ + "id": content.call_id or f"mcp_{uuid.uuid4().hex}", + "type": "mcp_call", + "server_label": content.server_name or "default", + "name": content.tool_name or "tool", + "arguments": _arguments_to_str(content.arguments), + "output": _stringify_output(result_content.output) if result_content is not None else None, + "status": _mcp_status(status), + }) + + +def _mcp_result_output_item(content: Content, *, status: str) -> ResponseOutputItem: + return _response_output_item({ + "id": content.call_id or f"mcp_{uuid.uuid4().hex}", + "type": "mcp_call", + "server_label": content.server_name or "default", + "name": content.tool_name or "tool", + "arguments": "", + "output": _stringify_output(content.output), + "status": _mcp_status(status), + }) + + +def _shell_call_output_item(content: Content, *, status: str) -> ResponseOutputItem: + return _response_output_item({ + "id": content.additional_properties.get("item_id") or f"shell_{uuid.uuid4().hex}", + "type": "shell_call", + "call_id": content.call_id or f"call_{uuid.uuid4().hex}", + "action": { + "commands": content.commands or [], + "timeout_ms": content.timeout_ms, + "max_output_length": content.max_output_length, + }, + "environment": {"type": "local"}, + "status": _message_status(status), + }) + + +def _shell_result_output_item(content: Content, *, status: str) -> ResponseOutputItem: + outputs: list[dict[str, Any]] = [] + outputs_value: Any = content.outputs + if isinstance(outputs_value, Sequence) and not isinstance(outputs_value, (str, bytes, bytearray)): + for item in cast(Sequence[Any], outputs_value): + if not isinstance(item, Content): + continue + outcome = {"type": "timeout"} if item.timed_out else {"type": "exit", "exit_code": item.exit_code or 0} + outputs.append({"stdout": item.stdout or "", "stderr": item.stderr or "", "outcome": outcome}) + + return _response_output_item({ + "id": content.additional_properties.get("item_id") or f"shellout_{uuid.uuid4().hex}", + "type": "shell_call_output", + "call_id": content.call_id or f"call_{uuid.uuid4().hex}", + "output": outputs, + "max_output_length": content.max_output_length, + "status": _message_status(status), + }) + + +def _function_approval_request_output_item(content: Content) -> ResponseOutputItem: + function_call = content.function_call + return _response_output_item({ + "id": content.id or f"approval_{uuid.uuid4().hex}", + "type": "mcp_approval_request", + "server_label": ( + function_call.additional_properties.get("server_label", "agent_framework") + if function_call is not None + else "agent_framework" + ), + "name": function_call.name if function_call is not None and function_call.name else "tool", + "arguments": _arguments_to_str(function_call.arguments if function_call is not None else None), + }) + + +def _function_approval_response_output_item(content: Content) -> ResponseOutputItem: + return _response_output_item({ + "id": content.id or f"approval_{uuid.uuid4().hex}", + "type": "mcp_approval_response", + "approval_request_id": content.id or "", + "approve": bool(content.approved), + }) + + +def _media_content_output_item(content: Content, *, status: str) -> ResponseOutputItem: + parts = _content_parts_to_input_items([content]) + if parts: + return cast( + ResponseOutputItem, + ResponseFunctionToolCallOutputItem( + id=f"content_{uuid.uuid4().hex}", + type="function_call_output", + call_id=f"content_{uuid.uuid4().hex}", + output=parts, + status=_message_status(status), # type: ignore[arg-type] + ), + ) + return _text_output_items(json.dumps(content.to_dict(), default=str), status=status)[0] + + +def _content_parts_to_input_items(contents: Sequence[Content] | None) -> list[Any]: + if not contents: + return [] + + parts: list[Any] = [] + for content in contents: + match content.type: + case "text": + parts.append(ResponseInputText(type="input_text", text=content.text or "")) + case "data" | "uri": + if not content.uri: + continue + if _is_image_content(content): + parts.append(ResponseInputImage(type="input_image", image_url=content.uri, detail="auto")) + else: + parts.append(ResponseInputFile(type="input_file", file_url=content.uri)) + case "hosted_file": + if content.file_id: + parts.append(ResponseInputFile(type="input_file", file_id=content.file_id)) + case _: + parts.append(ResponseInputText(type="input_text", text=json.dumps(content.to_dict(), default=str))) + return parts + + +def _content_sequence_text(contents: Sequence[Content] | None) -> str | None: + if not contents: + return None + text = "".join(content.text or "" for content in contents if content.type == "text") + return text or None + + +def _is_image_content(content: Content) -> bool: + media_type = content.media_type or "" + if media_type.startswith("image/"): + return True + return (content.uri or "").startswith("data:image/") + + +def _image_generation_result(outputs: Any) -> str | None: + if isinstance(outputs, Content): + return _image_generation_content_result(outputs) + if isinstance(outputs, Sequence) and not isinstance(outputs, (str, bytes, bytearray)): + for output in cast(Sequence[Any], outputs): + if isinstance(output, Content) and (result := _image_generation_content_result(output)): + return result + if isinstance(outputs, str): + return outputs + return None + + +def _image_generation_content_result(content: Content) -> str | None: + uri = content.uri + if not uri: + return None + if ";base64," in uri: + return uri.split(";base64,", 1)[1] + return uri + + +def _content_item_id(content: Content, result_content: Content | None = None) -> str | None: + item_id = content.additional_properties.get("item_id") + if isinstance(item_id, str) and item_id: + return item_id + if result_content is not None: + result_item_id = result_content.additional_properties.get("item_id") + if isinstance(result_item_id, str) and result_item_id: + return result_item_id + return content.call_id or (result_content.call_id if result_content is not None else None) + + +def _content_property(content: Content, result_content: Content | None, key: str) -> Any: + if key in content.additional_properties: + return content.additional_properties[key] + if result_content is not None and key in result_content.additional_properties: + return result_content.additional_properties[key] + return None + + +def _code_interpreter_status(status: str) -> str: + if status in ("in_progress", "completed", "incomplete", "failed"): + return status + return "incomplete" + + +def _image_generation_status(status: str) -> str: + if status in ("in_progress", "completed", "failed"): + return status + return "failed" + + +def _mcp_status(status: str) -> str: + if status in ("in_progress", "completed", "incomplete", "failed"): + return status + return "incomplete" + + +def _arguments_to_str(arguments: Any | None) -> str: + if arguments is None: + return "" + if isinstance(arguments, str): + return arguments + return json.dumps(arguments, default=str) + + +def _stringify_output(output: Any) -> str: + if output is None: + return "" + if isinstance(output, str): + return output + if isinstance(output, Sequence) and not isinstance(output, (str, bytes, bytearray)): + return "".join(_stringify_output(item) for item in cast(Sequence[Any], output)) + return json.dumps(output, default=str) + + +def _raw_response_output_item(raw: Any) -> ResponseOutputItem | None: + if _raw_type(raw) is None: + return None + try: + return cast(ResponseOutputItem, _RESPONSE_OUTPUT_ITEM_ADAPTER.validate_python(raw)) + except ValidationError: + return None + + +def _response_output_item(value: Mapping[str, Any]) -> ResponseOutputItem: + return cast(ResponseOutputItem, _RESPONSE_OUTPUT_ITEM_ADAPTER.validate_python(value)) + + +def _response_output_item_key(item: ResponseOutputItem) -> tuple[str, str]: + item_type = _raw_type(item) or "unknown" + item_id = getattr(item, "id", None) or getattr(item, "call_id", None) + if isinstance(item_id, str) and item_id: + return item_type, item_id + return item_type, str(id(item)) + + +def _raw_type(raw: Any) -> str | None: + raw_type = getattr(raw, "type", None) + if isinstance(raw_type, str): + return raw_type + if isinstance(raw, Mapping): + mapping_type = cast(Mapping[str, Any], raw).get("type") + if isinstance(mapping_type, str): + return mapping_type + return None + + +def _result_to_text(result: Any) -> str: + 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 _sse_event(event_type: str, payload: Mapping[str, Any]) -> str: + """Format one Server-Sent Event.""" + return f"event: {event_type}\ndata: {_json_dumps(payload)}\n\n" + + +def _json_dumps(payload: Mapping[str, Any]) -> str: + """Serialize a Responses SSE payload.""" + return json.dumps(payload, separators=(",", ":")) + + +async def responses_stream_events_from_run( + stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]], + *, + response_id: str, + session_id: str | None = None, +) -> AsyncIterator[str]: + """Convert an Agent Framework response stream into Responses SSE events. + + Args: + stream: Agent Framework response stream returned by ``agent.run(..., + stream=True)``. + + Keyword Args: + response_id: Id for the response being created. + session_id: Optional prior ``resp_*`` or ``conv_*`` session id. + + Yields: + Server-Sent Event strings. + """ + yield _sse_event( + "response.created", + { + "type": "response.created", + "response": { + "id": response_id, + "object": "response", + "created_at": int(time.time()), + "status": "in_progress", + "model": "agent", + "output": [], + }, + }, + ) + + async for update in stream: + if update.text: + yield _sse_event( + "response.output_text.delta", + { + "type": "response.output_text.delta", + "delta": update.text, + }, + ) + + final = await stream.get_final_response() + yield _sse_event( + "response.completed", + { + "type": "response.completed", + "response": responses_from_run(final, response_id=response_id, session_id=session_id), + }, + ) + + __all__ = [ + "create_response_id", "messages_from_responses_input", "parse_responses_identity", "parse_responses_request", + "responses_from_run", + "responses_session_id", + "responses_stream_events_from_run", + "responses_to_run", ] diff --git a/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py b/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py index bdc65058066..6fd749d898e 100644 --- a/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py +++ b/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py @@ -4,12 +4,20 @@ from __future__ import annotations +from collections.abc import AsyncIterator, Sequence + import pytest +from agent_framework import AgentResponse, AgentResponseUpdate, Content, Message, ResponseStream from agent_framework_hosting_responses import ( + create_response_id, messages_from_responses_input, parse_responses_identity, parse_responses_request, + responses_from_run, + responses_session_id, + responses_stream_events_from_run, + responses_to_run, ) @@ -167,3 +175,124 @@ def test_returns_none_when_absent(self) -> None: def test_returns_none_for_non_string(self) -> None: assert parse_responses_identity({"safety_identifier": 42}, "responses") is None + + +class TestResponsesRunHelpers: + def test_create_response_id_shape(self) -> None: + response_id = create_response_id() + + assert response_id.startswith("resp_") + + def test_responses_session_id_prefers_previous_response(self) -> None: + assert responses_session_id({"previous_response_id": "resp_1", "conversation_id": "conv_1"}) == "resp_1" + + def test_responses_session_id_uses_conversation_id(self) -> None: + assert responses_session_id({"conversation_id": "conv_1"}) == "conv_1" + + def test_responses_session_id_returns_none_when_absent(self) -> None: + assert responses_session_id({"input": "hi"}) is None + + def test_responses_to_run_returns_messages_options_and_stream(self) -> None: + run = responses_to_run({ + "input": "hi", + "stream": True, + "previous_response_id": "resp_1", + "conversation_id": "conv_1", + "max_output_tokens": 32, + "model": "gpt-x", + }) + + assert run["messages"][0].text == "hi" + assert run["stream"] is True + assert run["options"] == {"max_tokens": 32, "model": "gpt-x"} + + def test_responses_from_run_returns_response_payload(self) -> None: + result = AgentResponse( + messages=Message(role="assistant", contents=[Content.from_text("hello")]), + additional_properties={"model": "test-model"}, + ) + + payload = responses_from_run(result, response_id="resp_new") + + assert payload["id"] == "resp_new" + assert payload["model"] == "test-model" + assert payload["output"][0]["content"][0]["text"] == "hello" + + def test_responses_from_run_preserves_multimodal_output_items(self) -> None: + result = AgentResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_text_reasoning(id="rs_1", text="checking"), + Content.from_function_call("call_1", "collect_media", arguments={"city": "Seattle"}), + Content.from_function_result( + "call_1", + result=[ + Content.from_text("caption"), + Content.from_uri("https://example.com/cat.png", media_type="image/png"), + Content.from_hosted_file("file_pdf", media_type="application/pdf"), + ], + ), + Content.from_text("done"), + ], + ) + ) + + payload = responses_from_run(result, response_id="resp_new") + + output = payload["output"] + assert [item["type"] for item in output] == [ + "reasoning", + "function_call", + "function_call_output", + "message", + ] + assert output[0]["content"][0]["text"] == "checking" + assert output[1]["name"] == "collect_media" + assert output[1]["arguments"] == '{"city": "Seattle"}' + assert output[2]["output"] == [ + {"text": "caption", "type": "input_text"}, + {"detail": "auto", "type": "input_image", "image_url": "https://example.com/cat.png"}, + {"type": "input_file", "file_id": "file_pdf"}, + ] + assert output[3]["content"][0]["text"] == "done" + + def test_responses_from_run_maps_conversation_session(self) -> None: + result = AgentResponse(messages=Message(role="assistant", contents=[Content.from_text("hello")])) + + payload = responses_from_run(result, response_id="resp_new", session_id="conv_1") + + assert payload["conversation"] == {"id": "conv_1"} + + def test_responses_from_run_omits_previous_response_session(self) -> None: + result = AgentResponse(messages=Message(role="assistant", contents=[Content.from_text("hello")])) + + payload = responses_from_run(result, response_id="resp_new", session_id="resp_1") + + assert "conversation" not in payload + + async def test_responses_stream_events_from_run(self) -> None: + async def updates() -> AsyncIterator[AgentResponseUpdate]: + yield AgentResponseUpdate(contents=[Content.from_text("hel")], role="assistant") + yield AgentResponseUpdate(contents=[Content.from_text("lo")], role="assistant") + + def finalizer(items: Sequence[AgentResponseUpdate]) -> AgentResponse: + return AgentResponse.from_updates(items) + + stream = ResponseStream(updates(), finalizer=finalizer) + + events = [ + event + async for event in responses_stream_events_from_run( + stream, + response_id="resp_new", + session_id="conv_1", + ) + ] + + assert events[0].startswith("event: response.created") + assert "response.output_text.delta" in events[1] + assert "hel" in events[1] + assert "lo" in events[2] + assert events[-1].startswith("event: response.completed") + assert '"conversation":{"id":"conv_1"}' in events[-1] diff --git a/python/packages/hosting/README.md b/python/packages/hosting/README.md index d08be242d1f..944325b95c1 100644 --- a/python/packages/hosting/README.md +++ b/python/packages/hosting/README.md @@ -1,122 +1,40 @@ # agent-framework-hosting -Multi-channel hosting for Microsoft Agent Framework agents. +Shared execution-state helpers for app-owned Agent Framework hosting. -`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. +This package keeps Agent Framework state separate from web-framework concerns: -The base package contains only channel-neutral plumbing: +- `AgentFrameworkState` — stores an agent/workflow target and optional session + state for routes that the app owns. +- `SessionStore` — maps an app-selected session id to an `AgentSession` for + non-persisted servers. +- Existing experimental channel-hosting types remain available while the package + is unreleased, but the v1 direction is protocol helpers plus app-owned routes. -- `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]" -``` +Use FastAPI, Starlette, Azure Functions, Django, or another framework for route +registration, auth, middleware, response construction, and background work. ## Quickstart ```python from agent_framework.openai import OpenAIChatClient -from agent_framework_hosting import AgentFrameworkHost, Channel +from agent_framework_hosting import AgentFrameworkState, SessionStore agent = OpenAIChatClient().as_agent(name="Assistant") +state = AgentFrameworkState(agent, session_store=SessionStore) -# 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. - -The host treats `isolation_key` as an opaque partition key. Each channel or -hosting environment decides where that key comes from: - -- protocol headers supplied by a trusted platform, -- request body fields such as a previous response or conversation ID, -- route/path parameters, -- channel-native metadata such as chat/user IDs, or -- environment-provided context in an ephemeral host. - -The host should be able to carry any of those sources as long as the channel or -platform has already authenticated and authorized the caller before passing the -key to `ChannelSession`. - -The built-in request-context helper recognizes the `x-agent-user-isolation-key` -and `x-agent-chat-isolation-key` header names because some hosting -environments, including Foundry Hosted Agents, already use them. Reusing those -header names does **not** mean `agent-framework-hosting` is the supported way to -run on Foundry Hosted Agents; use `agent-framework-foundry-hosting` for that -hosting surface. - -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", -) +session = await state.get_session("conversation-1") +result = await (await state.get_target()).run("Hello", session=session) ``` -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: +Targets can be direct instances, synchronous factories, asynchronous factories, +or awaitables: ```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", - ), -) +state = AgentFrameworkState(create_agent) # cached by default +state = AgentFrameworkState(create_agent, cache_target=False) ``` 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. +not part of this v1 state surface. diff --git a/python/packages/hosting/agent_framework_hosting/__init__.py b/python/packages/hosting/agent_framework_hosting/__init__.py index ab78ccd4b9e..cb5ed5567fd 100644 --- a/python/packages/hosting/agent_framework_hosting/__init__.py +++ b/python/packages/hosting/agent_framework_hosting/__init__.py @@ -20,6 +20,7 @@ reset_current_isolation_keys, set_current_isolation_keys, ) +from ._state import AgentFrameworkState, AgentRunArgs, SessionStore, WorkflowRunArgs from ._types import ( Channel, ChannelCommand, @@ -44,6 +45,8 @@ "ISOLATION_HEADER_CHAT", "ISOLATION_HEADER_USER", "AgentFrameworkHost", + "AgentFrameworkState", + "AgentRunArgs", "Channel", "ChannelCommand", "ChannelCommandContext", @@ -58,6 +61,8 @@ "HostStatePaths", "HostedRunResult", "IsolationKeys", + "SessionStore", + "WorkflowRunArgs", "__version__", "get_current_isolation_keys", "logger", diff --git a/python/packages/hosting/agent_framework_hosting/_state.py b/python/packages/hosting/agent_framework_hosting/_state.py new file mode 100644 index 00000000000..86b4ae49125 --- /dev/null +++ b/python/packages/hosting/agent_framework_hosting/_state.py @@ -0,0 +1,222 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import inspect +from collections.abc import Awaitable, Callable, Mapping +from typing import Any, Generic, TypedDict, TypeVar + +from agent_framework import AgentRunInputs, AgentSession, ChatOptions, SupportsAgentRun, Workflow + + +class SessionStore: + """In-memory session lookup for non-persisted servers. + + The store maps application-selected session ids to ``AgentSession`` + instances. The id is an opaque partition key; callers are responsible for + deciding whether it came from a trusted request field, platform context, or + other route-local state. + """ + + def __init__(self, agent: SupportsAgentRun) -> None: + """Create a session store for ``agent``. + + Args: + agent: The agent that creates sessions when a session id is first + observed. + """ + self.agent = agent + self._sessions: dict[str, AgentSession] = {} + + async def get(self, session_id: str) -> AgentSession: + """Return the session for ``session_id``, creating it when needed. + + Args: + session_id: Opaque app-selected session id. + + Returns: + The cached or newly created ``AgentSession``. + + Raises: + ValueError: If ``session_id`` is empty. + """ + if not session_id: + raise ValueError("session_id must be a non-empty string") + if session_id not in self._sessions: + self._sessions[session_id] = self.agent.create_session(session_id=session_id) + return self._sessions[session_id] + + async def reset(self, session_id: str) -> None: + """Forget the current session for ``session_id``. + + Args: + session_id: Opaque app-selected session id. + + Raises: + ValueError: If ``session_id`` is empty. + """ + if not session_id: + raise ValueError("session_id must be a non-empty string") + self._sessions.pop(session_id, None) + + +TargetT = TypeVar("TargetT", SupportsAgentRun, Workflow) +SessionStoreFactory = Callable[[SupportsAgentRun], SessionStore] + + +class AgentRunArgs(TypedDict): + """Arguments prepared for ``Agent.run``.""" + + messages: AgentRunInputs + options: ChatOptions[Any] + stream: bool + + +class WorkflowRunArgs(TypedDict): + """Arguments prepared for ``Workflow.run``.""" + + message: Any | None + responses: Mapping[str, Any] | None + stream: bool + + +class AgentFrameworkState(Generic[TargetT]): + """Shared execution state for app-owned hosting routes. + + ``AgentFrameworkState`` intentionally does not own routes, middleware, + protocol dispatch, or native SDK calls. Web frameworks keep those concerns; + this object holds the Agent Framework target and optional session store that + route code may share. + """ + + def __init__( + self, + target: TargetT | Awaitable[TargetT] | Callable[[], TargetT | Awaitable[TargetT]], + *, + session_store: SessionStore | type[SessionStore] | SessionStoreFactory | None = None, + cache_target: bool = True, + ) -> None: + """Create shared state for ``target``. + + Args: + target: Agent or workflow target used by route code. May be a + target instance, a synchronous factory, an asynchronous factory, + or an awaitable target. + + Keyword Args: + session_store: Existing store, store class, or factory. When omitted + and ``target`` is an agent, an in-memory ``SessionStore`` is + created. Workflow targets do not get a default session store. + cache_target: Whether to cache a resolved callable/awaitable target. + Defaults to ``True`` so expensive target setup happens once. + + Raises: + ValueError: If ``cache_target=False`` is used with a one-shot + awaitable target. + TypeError: If a session store class/factory is supplied for a + workflow target. + """ + if not cache_target and inspect.isawaitable(target): + raise ValueError("cache_target=False requires a target instance or callable target factory") + self._target_source = target + self._cache_target = cache_target + self._cached_target: TargetT | None = None + self._session_store_source = session_store + self._cached_session_store = session_store if isinstance(session_store, SessionStore) else None + if not callable(target) and not inspect.isawaitable(target): + self._cached_target = target + if self._cached_session_store is None and isinstance(target, SupportsAgentRun): + self._cached_session_store = self._init_session_store(target, session_store) + elif session_store is not None and not isinstance(target, SupportsAgentRun): + raise TypeError("session_store requires an agent target that supports create_session") + + async def get_target(self) -> TargetT: + """Return the resolved target. + + Returns: + The target instance. Callable and awaitable targets are resolved + first and cached by default. + """ + if self._cache_target and self._cached_target is not None: + return self._cached_target + + target = self._target_source() if callable(self._target_source) else self._target_source + if inspect.isawaitable(target): + target = await target + if self._cache_target: + self._cached_target = target + return target + + async def get_session_store(self) -> SessionStore: + """Return the session store for the current target. + + Returns: + The configured or lazily created ``SessionStore``. + + Raises: + TypeError: If the resolved target is not an agent target. + """ + if self._cached_session_store is not None: + return self._cached_session_store + + target = await self.get_target() + store = self._init_session_store(target, self._session_store_source) + if self._cache_target: + self._cached_session_store = store + return store + + async def get_session(self, session_id: str) -> AgentSession: + """Return the session for ``session_id`` from the current store. + + Args: + session_id: Opaque app-selected session id. + + Returns: + The cached or newly created ``AgentSession``. + """ + store = await self.get_session_store() + return await store.get(session_id) + + async def reset_session(self, session_id: str) -> None: + """Forget the current session for ``session_id``. + + Args: + session_id: Opaque app-selected session id. + """ + store = await self.get_session_store() + await store.reset(session_id) + + @property + def target(self) -> TargetT: + """Return a synchronously available target. + + Raises: + RuntimeError: If the target is a callable or awaitable that has not + been resolved with :meth:`get_target`. + """ + if self._cached_target is not None: + return self._cached_target + if not callable(self._target_source) and not inspect.isawaitable(self._target_source): + return self._target_source + raise RuntimeError("target is resolved asynchronously; use `await state.get_target()`") + + @property + def session_store(self) -> SessionStore | None: + """Return a synchronously available session store, if one is cached.""" + return self._cached_session_store + + def _init_session_store( + self, + target: TargetT, + session_store: SessionStore | type[SessionStore] | SessionStoreFactory | None, + ) -> SessionStore: + if isinstance(session_store, SessionStore): + return session_store + + if not isinstance(target, SupportsAgentRun): + raise TypeError("session_store requires an agent target that supports create_session") + + if session_store is None: + return SessionStore(target) + + return session_store(target) diff --git a/python/packages/hosting/tests/hosting/test_state.py b/python/packages/hosting/tests/hosting/test_state.py new file mode 100644 index 00000000000..8626c6c2e44 --- /dev/null +++ b/python/packages/hosting/tests/hosting/test_state.py @@ -0,0 +1,138 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +from typing import Any + +import pytest +from agent_framework import AgentResponse, AgentSession, Message + +from agent_framework_hosting import AgentFrameworkState, SessionStore + + +class _FakeAgent: + """Minimal agent target for state tests.""" + + id = "fake-agent" + name = "Fake Agent" + description = "Fake agent for tests" + + def __init__(self) -> None: + self.created_sessions: list[AgentSession] = [] + + def create_session(self, *, session_id: str | None = None) -> AgentSession: + session = AgentSession(session_id=session_id) + self.created_sessions.append(session) + return session + + def get_session(self, service_session_id: str, *, session_id: str | None = None) -> AgentSession: + return AgentSession(session_id=session_id, service_session_id=service_session_id) + + async def run(self, messages: Any = None, **_: Any) -> AgentResponse: + return AgentResponse(messages=Message(role="assistant", contents=["ok"])) + + +class TestSessionStore: + async def test_get_reuses_session_for_same_id(self) -> None: + agent = _FakeAgent() + store = SessionStore(agent) + + first = await store.get("session-1") + second = await store.get("session-1") + + assert first is second + assert first.session_id == "session-1" + assert len(agent.created_sessions) == 1 + + async def test_reset_forgets_session(self) -> None: + agent = _FakeAgent() + store = SessionStore(agent) + + first = await store.get("session-1") + await store.reset("session-1") + second = await store.get("session-1") + + assert first is not second + assert len(agent.created_sessions) == 2 + + async def test_empty_session_id_raises(self) -> None: + store = SessionStore(_FakeAgent()) + + with pytest.raises(ValueError, match="session_id"): + await store.get("") + with pytest.raises(ValueError, match="session_id"): + await store.reset("") + + +class TestAgentFrameworkState: + def test_default_session_store_for_agent(self) -> None: + agent = _FakeAgent() + state = AgentFrameworkState(agent) + + assert state.target is agent + assert isinstance(state.session_store, SessionStore) + + def test_accepts_session_store_instance(self) -> None: + agent = _FakeAgent() + store = SessionStore(agent) + state = AgentFrameworkState(agent, session_store=store) + + assert state.target is agent + assert state.session_store is store + + def test_accepts_session_store_factory(self) -> None: + agent = _FakeAgent() + + def factory(target: Any) -> SessionStore: + return SessionStore(target) + + state = AgentFrameworkState(agent, session_store=factory) + + assert isinstance(state.session_store, SessionStore) + + async def test_callable_target_cached_by_default(self) -> None: + calls = 0 + + def create_agent() -> _FakeAgent: + nonlocal calls + calls += 1 + return _FakeAgent() + + state = AgentFrameworkState(create_agent) + + first = await state.get_target() + second = await state.get_target() + + assert first is second + assert calls == 1 + + async def test_callable_target_cache_can_be_disabled(self) -> None: + calls = 0 + + def create_agent() -> _FakeAgent: + nonlocal calls + calls += 1 + return _FakeAgent() + + state = AgentFrameworkState(create_agent, cache_target=False) + + first = await state.get_target() + second = await state.get_target() + + assert first is not second + assert calls == 2 + + async def test_async_callable_target(self) -> None: + async def create_agent() -> _FakeAgent: + return _FakeAgent() + + state = AgentFrameworkState(create_agent) + + assert isinstance(await state.get_target(), _FakeAgent) + + async def test_get_session_resolves_target_and_store(self) -> None: + state = AgentFrameworkState(lambda: _FakeAgent()) + + session = await state.get_session("session-1") + + assert session.session_id == "session-1" diff --git a/python/samples/04-hosting/af-hosting/README.md b/python/samples/04-hosting/af-hosting/README.md index c21ebbecd5e..367dfcabc71 100644 --- a/python/samples/04-hosting/af-hosting/README.md +++ b/python/samples/04-hosting/af-hosting/README.md @@ -10,7 +10,7 @@ its own package. This first sample set includes | 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/`](./local_responses) | The minimal shape: one agent + one `@tool` + native FastAPI route + Responses helper functions + `SessionStore`. | **Local only.** Start here to learn the helper 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.** | | [`local_telegram/`](./local_telegram) | Telegram bot with `@tool`, `FileHistoryProvider`, `run_hook`, and slash commands (`/new`, `/whoami`, `/weather`). Pure Telegram — no HTTP endpoint. | **Local only.** Start here to learn the Telegram channel. | | [`local_multi_channel/`](./local_multi_channel) | Same agent behind two channels at once: `ResponsesChannel` + `TelegramChannel`. Shared `FileHistoryProvider` enables cross-channel session resumption (resume a Telegram chat from the Responses endpoint by passing the Telegram isolation key as `previous_response_id`). | **Local only.** | diff --git a/python/samples/04-hosting/af-hosting/local_responses/README.md b/python/samples/04-hosting/af-hosting/local_responses/README.md index e4b08f408c9..2ffcf5642da 100644 --- a/python/samples/04-hosting/af-hosting/local_responses/README.md +++ b/python/samples/04-hosting/af-hosting/local_responses/README.md @@ -1,19 +1,29 @@ -# local_responses — Responses-only with a settings-altering hook +# local_responses — Responses helpers with native FastAPI routes -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. +The smallest end-to-end Responses hosting shape: one Foundry agent with a +`@tool`, one native FastAPI route, a small `SessionStore`, and the Responses +helper functions: -What the run hook demonstrates: +- `responses_to_run(...)` +- `responses_session_id(...)` +- `create_response_id(...)` +- `responses_from_run(...)` -- **Strips** caller-supplied `model` / `temperature` / `store` so the - host owns the backing deployment and persistence settings. -- **Forces** a `reasoning` preset (`effort=medium`, `summary=auto`) on - every turn — caller-side overrides are ignored. +The sample demonstrates the lighter hosting direction. Agent Framework provides +the run conversion and session-state pieces; FastAPI owns route registration, +request bodies, response objects, and server startup. -`app:app` is a module-level Starlette ASGI app; recommended local launch -is Hypercorn. +What the route demonstrates: + +- **Strips** caller-supplied `model` / `temperature` / `store` so the app owns + deployment and persistence settings. +- **Forces** a `reasoning` preset (`effort=medium`, `summary=auto`) on every + turn. +- Produces the AF messages, options, and session id that the route passes to + `agent.run(...)`. + +`app:app` is a module-level FastAPI ASGI app; recommended local launch is +Hypercorn. ## Run @@ -40,14 +50,14 @@ uv sync --group dev # Plain OpenAI SDK call: uv run python call_server.py -# The client intentionally omits `model`; the host chooses the backing -# deployment from FOUNDRY_MODEL. +# The client intentionally omits `model`; the app chooses the backing deployment +# from FOUNDRY_MODEL. -# The script then sends a second turn, "And what about Amsterdam?", -# using the first `response.id` as `previous_response_id`. +# The script then sends a second turn, "And what about Amsterdam?", using the +# first `response.id` as `previous_response_id`. # Same two-turn interaction through an Agent Framework Agent backed by -# OpenAIChatClient, with streaming enabled: +# OpenAIChatClient: uv run python call_server_af.py ``` 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 73313614748..e00caa5ccc9 100644 --- a/python/samples/04-hosting/af-hosting/local_responses/app.py +++ b/python/samples/04-hosting/af-hosting/local_responses/app.py @@ -1,28 +1,19 @@ # Copyright (c) Microsoft. All rights reserved. -"""Minimal Responses-only hosting sample. +"""Minimal Responses-only hosting sample with native FastAPI routes. -Single agent with one ``@tool`` (``lookup_weather``), single channel -(``ResponsesChannel``), one ``run_hook`` that demonstrates the -settings-mutation seam over caller-supplied options. +This sample demonstrates the helper-first hosting shape: -What the hook does ------------------- -On every Responses request the hook receives the ``ChannelRequest`` that -the channel built from the inbound HTTP body. It: - -- strips ``model`` (the host owns the backing deployment), ``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. +1. ``agent-framework-hosting-responses`` converts Responses request/response + payloads to and from Agent Framework run values. +2. ``agent-framework-hosting`` owns shared execution state via + ``AgentFrameworkState`` and ``SessionStore``. +3. FastAPI owns the route, request parsing, policy decisions, and response + object. Run --- -``app`` is a module-level Starlette ASGI app. Recommended local launch:: +``app`` is a module-level FastAPI ASGI app. Recommended local launch:: uv sync az login @@ -42,16 +33,26 @@ from __future__ import annotations +import asyncio import os -from dataclasses import replace from pathlib import Path -from typing import Annotated +from typing import Annotated, Any, cast -from agent_framework import Agent, FileHistoryProvider, tool +from agent_framework import Agent, FileHistoryProvider, ResponseStream, tool from agent_framework_foundry import FoundryChatClient -from agent_framework_hosting import AgentFrameworkHost, ChannelRequest -from agent_framework_hosting_responses import ResponsesChannel +from agent_framework_hosting import AgentFrameworkState, SessionStore +from agent_framework_hosting_responses import ( + create_response_id, + responses_from_run, + responses_session_id, + responses_stream_events_from_run, + responses_to_run, +) from azure.identity.aio import DefaultAzureCredential +from fastapi import Body, FastAPI, HTTPException +from fastapi.responses import JSONResponse, StreamingResponse +from hypercorn.asyncio import serve +from hypercorn.config import Config SESSIONS_DIR = Path(__file__).resolve().parent / "storage" / "sessions" SESSIONS_DIR.mkdir(parents=True, exist_ok=True) @@ -71,37 +72,9 @@ def lookup_weather( return reports.get(location, f"{location} is sunny with a high of {high_temp}°C.") -# the run hook defines what you want to allow the user to passthrough when they call your host -# since the responses clients can call with all of the responses options, -# you can decide with this run_hook which of those: are rejected -# which are passed through, which are altered, which are added. -# In this sample below, we are removing, model, temperature and store if set -# and we add reasoning, but note that this could also be set on the Agent itself -# the difference is that this option is specific to the Responses channel -# so if you want to differentiate between options over channels -# you would set the option in the run_hook, if it needs to be the same (like store) -# you would set it in the agent. -def run_hook(request: ChannelRequest, **_: object) -> ChannelRequest: - """Strip caller-supplied options the host should own and force a - reasoning preset.""" - options = dict(request.options or {}) - - # The host owns the backing deployment; the agent's default_options - # own ``store``; the model may not honor ``temperature``. Strip them - # so the caller can't override. - options.pop("model", None) - 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: - # Here we define how our agent should run, with tools, options, etc: - agent = Agent( +def create_agent() -> Agent: + """Create the sample weather agent.""" + return Agent( client=FoundryChatClient(credential=DefaultAzureCredential()), name="WeatherAgent", instructions=( @@ -112,15 +85,75 @@ def build_host() -> AgentFrameworkHost: context_providers=[FileHistoryProvider(SESSIONS_DIR)], default_options={"store": False}, ) - return AgentFrameworkHost( - target=agent, - channels=[ResponsesChannel(run_hook=run_hook)], - debug=True, + + +app = FastAPI() +state = AgentFrameworkState(create_agent, session_store=SessionStore) + + +@app.post("/responses") +async def responses(body: dict[str, Any] = Body(...)) -> JSONResponse | StreamingResponse: # noqa: B008 + """Handle one OpenAI Responses-shaped request.""" + run = responses_to_run(body) + session_id = responses_session_id(body) + response_id = create_response_id() + + options = dict(run["options"]) + # App-specific policy: caller cannot pick deployment/persistence settings, + # and this sample forces a consistent reasoning preset. + options.pop("model", None) + options.pop("temperature", None) + options.pop("store", None) + options["reasoning"] = {"effort": "medium", "summary": "auto"} + options_for_run = cast(Any, options) + + target = cast(Agent[Any], await state.get_target()) + session = await state.get_session(session_id or response_id) + if run["stream"]: + stream = target.run( + run["messages"], + stream=True, + session=session, + options=options_for_run, + ) + if not isinstance(stream, ResponseStream): + raise HTTPException(status_code=500, detail="agent did not return a response stream") + return StreamingResponse( + responses_stream_events_from_run( + stream, + response_id=response_id, + session_id=session_id, + ), + media_type="text/event-stream", + ) + + result = await target.run( + run["messages"], + session=session, + options=options_for_run, + ) + return JSONResponse( + responses_from_run( + result, + response_id=response_id, + session_id=session_id, + ) ) -app = build_host().app +async def main() -> None: + """Run the sample with Hypercorn for local development.""" + config = Config() + config.bind = [f"0.0.0.0:{int(os.environ.get('PORT', '8000'))}"] + await serve(cast(Any, app), config) if __name__ == "__main__": - build_host().serve(host="0.0.0.0", port=int(os.environ.get("PORT", "8000"))) + asyncio.run(main()) + +""" +Sample output: +User: What is the weather in Tokyo? +Agent: Tokyo is clear with a high of 18°C. +Response ID: resp_... +""" diff --git a/python/samples/04-hosting/af-hosting/local_responses/call_server_af.py b/python/samples/04-hosting/af-hosting/local_responses/call_server_af.py index 91e8d3aa6fd..3c93a88c748 100644 --- a/python/samples/04-hosting/af-hosting/local_responses/call_server_af.py +++ b/python/samples/04-hosting/af-hosting/local_responses/call_server_af.py @@ -3,8 +3,8 @@ """Agent Framework agent client for the local_responses sample. Creates a local :class:`agent_framework.Agent` backed by -:class:`agent_framework.openai.OpenAIChatClient`, points that client at the -hosted ``/responses`` endpoint, and streams both turns: +:class:`agent_framework.openai.OpenAIChatClient` and points that client at the +hosted ``/responses`` endpoint for both turns: 1. ``What is the weather in Tokyo?`` 2. ``And what about Amsterdam?`` @@ -45,14 +45,8 @@ async def main() -> None: for prompt in PROMPTS: print(f"User: {prompt}") - stream = agent.run(prompt, stream=True, session=session) - print("Agent: ", end="", flush=True) - async for update in stream: - if update.text: - print(update.text, end="", flush=True) - - response = await stream.get_final_response() - print("\n") + response = await agent.run(prompt, session=session) + print(f"Agent: {response.text}\n") print(f"Response ID: {response.response_id}\n") diff --git a/python/samples/04-hosting/af-hosting/local_responses/pyproject.toml b/python/samples/04-hosting/af-hosting/local_responses/pyproject.toml index f301dd6bfc4..adb6550ac2a 100644 --- a/python/samples/04-hosting/af-hosting/local_responses/pyproject.toml +++ b/python/samples/04-hosting/af-hosting/local_responses/pyproject.toml @@ -1,7 +1,7 @@ [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." +description = "Minimal Responses-only local hosting sample with native FastAPI routes." requires-python = ">=3.10" dependencies = [ "agent-framework-foundry", @@ -9,6 +9,7 @@ dependencies = [ "agent-framework-hosting-responses", "azure-identity", "aiohttp>=3.13.5", + "fastapi>=0.115.0,<0.138.1", "hypercorn>=0.17", ] From e862e104e7934a1bda3b24f7759da4cddefdcba7 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 2 Jul 2026 18:22:00 +0200 Subject: [PATCH 02/15] Fix CI failures, session continuity, and streaming model reporting - Fix constrained TargetT TypeVar in AgentFrameworkState: split __init__ into per-shape overloads (instance/sync factory/async factory/awaitable) since a bound TypeVar combined with one big Callable/Awaitable union parameter was unsolvable across pyright/pyrefly/ty/zuban. - Fix _FakeAgent test fixtures to structurally satisfy SupportsAgentRun (matching attribute types and overloaded run()), which the above surfaced. - Add SessionStore.put() to alias an additional session id to an already-resolved session, and use it in the local_responses sample to fix a real session-continuity bug: previous_response_id rotates every turn, so without aliasing the newly minted response id, turn 3+ of a conversation silently lost all prior history. Verified against a live Foundry model across a 3-turn conversation. - Fix responses_stream_events_from_run to report the real model instead of the "agent" fallback: AgentResponse.from_updates never carries a raw representation forward, so capture model from the individual streamed updates' raw representations instead. Verified live. - Add response_model=None to the sample's FastAPI route (it could not boot at all: FastAPI tried to build a Pydantic response model from the JSONResponse | StreamingResponse return annotation). - Map responses_to_run's ValueError to HTTP 400 instead of a 500. - Add HTTP round-trip integration tests (packages/hosting-responses) that exercise the same FastAPI + AgentFrameworkState + Responses helper wiring as the sample via httpx.ASGITransport, including a regression test for the session-continuity fix. - Add Workflow-target test coverage, SessionStore.put/reset_session tests, and TypeError-path coverage to packages/hosting/tests/hosting/test_state.py. - Extend call_server.py / call_server_af.py to a third conversation turn so they actually exercise the continuity chain (previous scripts stopped at turn 2, which would never have revealed the bug above). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../_parsing.py | 26 +- .../packages/hosting-responses/pyproject.toml | 6 + .../hosting_responses/test_http_round_trip.py | 265 ++++++++++++++++++ .../tests/hosting_responses/test_parsing.py | 6 +- python/packages/hosting/README.md | 6 +- .../hosting/agent_framework_hosting/_state.py | 67 ++++- .../hosting/tests/hosting/test_state.py | 135 ++++++++- .../af-hosting/local_responses/README.md | 12 +- .../af-hosting/local_responses/app.py | 28 +- .../af-hosting/local_responses/call_server.py | 16 +- .../local_responses/call_server_af.py | 13 +- python/uv.lock | 12 + 12 files changed, 561 insertions(+), 31 deletions(-) create mode 100644 python/packages/hosting-responses/tests/hosting_responses/test_http_round_trip.py 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 72c8d632118..c391aeb979a 100644 --- a/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py +++ b/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py @@ -259,6 +259,21 @@ def responses_from_run( return _response_payload(OpenAIResponse(**response_kwargs)) +def _model_from_update(update: AgentResponseUpdate) -> str | None: + """Best-effort model id from one streamed update's raw representation. + + ``AgentResponse.from_updates`` does not carry a chunk's raw representation + forward onto the finalized response (see ``_finalize_response`` in core), + so ``_model_from_result`` can never find a model for a streamed result. + Each ``AgentResponseUpdate`` still has its own raw chat chunk, which + usually reports the model, so the streaming SSE helper captures it here + instead. + """ + raw = update.raw_representation + model = getattr(raw, "model", None) + return model if isinstance(model, str) and model else None + + def _model_from_result(result: Any) -> str: model = getattr(result, "model", None) if isinstance(model, str) and model: @@ -903,7 +918,10 @@ async def responses_stream_events_from_run( }, ) + model: str | None = None async for update in stream: + if model is None: + model = _model_from_update(update) if update.text: yield _sse_event( "response.output_text.delta", @@ -914,11 +932,17 @@ async def responses_stream_events_from_run( ) final = await stream.get_final_response() + payload = responses_from_run(final, response_id=response_id, session_id=session_id) + if model is not None: + # The finalized `AgentResponse` never carries a raw representation + # (see `_model_from_update`), so prefer the model observed on the + # stream's own chunks over `responses_from_run`'s "agent" fallback. + payload["model"] = model yield _sse_event( "response.completed", { "type": "response.completed", - "response": responses_from_run(final, response_id=response_id, session_id=session_id), + "response": payload, }, ) diff --git a/python/packages/hosting-responses/pyproject.toml b/python/packages/hosting-responses/pyproject.toml index a8b96654d21..82806d9ebaa 100644 --- a/python/packages/hosting-responses/pyproject.toml +++ b/python/packages/hosting-responses/pyproject.toml @@ -28,6 +28,12 @@ dependencies = [ "openai>=1.99.0,<3", ] +[dependency-groups] +dev = [ + "fastapi>=0.115.0,<0.138.1", + "httpx>=0.28.1", +] + [tool.uv] prerelease = "if-necessary-or-explicit" environments = [ diff --git a/python/packages/hosting-responses/tests/hosting_responses/test_http_round_trip.py b/python/packages/hosting-responses/tests/hosting_responses/test_http_round_trip.py new file mode 100644 index 00000000000..4955f3040d2 --- /dev/null +++ b/python/packages/hosting-responses/tests/hosting_responses/test_http_round_trip.py @@ -0,0 +1,265 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""HTTP round-trip tests: POST -> FastAPI route -> JSON/SSE response. + +These exercise the same wiring as the `local_responses` sample: helpers from +`agent_framework_hosting_responses` convert between the Responses protocol and +Agent Framework run values, `agent_framework_hosting`'s `AgentFrameworkState` / +`SessionStore` hold shared execution state, and a small FastAPI route owns +everything else (parsing, policy, response construction). Requests go through +`httpx.AsyncClient` with `ASGITransport` -- no real server process or live +model is involved. +""" + +from __future__ import annotations + +import json +from collections.abc import AsyncIterator, Awaitable, Mapping +from typing import Any, Literal, overload + +import httpx +from agent_framework import ( + AgentResponse, + AgentResponseUpdate, + AgentRunInputs, + AgentSession, + Content, + Message, + ResponseStream, +) +from agent_framework_hosting import AgentFrameworkState, SessionStore +from fastapi import Body, FastAPI, HTTPException +from fastapi.responses import JSONResponse, StreamingResponse + +from agent_framework_hosting_responses import ( + create_response_id, + responses_from_run, + responses_session_id, + responses_stream_events_from_run, + responses_to_run, +) + + +class _StubAgent: + """Deterministic ``SupportsAgentRun`` stub that tracks session continuity. + + Each call records the ``session_id`` of the ``AgentSession`` it was + invoked with and a per-session turn counter, so tests can assert that a + chain of requests reused one session instead of silently starting fresh + ones. + """ + + id = "stub-agent" + name: str | None = "stub-agent" + description: str | None = "stub agent for HTTP round-trip tests" + + def __init__(self) -> None: + self.session_ids_seen: list[str | None] = [] + self.turn_counts: dict[str | None, int] = {} + + def create_session(self, *, session_id: str | None = None) -> AgentSession: + return AgentSession(session_id=session_id) + + def get_session(self, service_session_id: str, *, session_id: str | None = None) -> AgentSession: + return AgentSession(session_id=session_id, service_session_id=service_session_id) + + @overload + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: Literal[False] = ..., + session: AgentSession | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + ) -> Awaitable[AgentResponse[Any]]: ... + + @overload + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: Literal[True], + session: AgentSession | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... + + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: bool = False, + session: AgentSession | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: + session_id = session.session_id if session is not None else None + self.session_ids_seen.append(session_id) + self.turn_counts[session_id] = self.turn_counts.get(session_id, 0) + 1 + text = f"turn {self.turn_counts[session_id]} for session {session_id}" + + if stream: + + async def _stream() -> AsyncIterator[AgentResponseUpdate]: + yield AgentResponseUpdate(contents=[Content.from_text(text=text)], role="assistant") + + return ResponseStream(_stream(), finalizer=lambda updates: AgentResponse.from_updates(updates)) + + async def _get_response() -> AgentResponse[Any]: + return AgentResponse(messages=Message(role="assistant", contents=[Content.from_text(text=text)])) + + return _get_response() + + +def _build_app(agent: _StubAgent) -> FastAPI: + """Build a minimal FastAPI app mirroring the `local_responses` sample's route.""" + app = FastAPI() + state = AgentFrameworkState(agent, session_store=SessionStore) + + @app.post("/responses", response_model=None) + async def responses(body: dict[str, Any] = Body(...)) -> JSONResponse | StreamingResponse: # noqa: B008 + try: + run = responses_to_run(body) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + session_id = responses_session_id(body) + response_id = create_response_id() + + target = await state.get_target() + store = await state.get_session_store() + lookup_id = session_id or response_id + session = await store.get(lookup_id) + if response_id != lookup_id: + # Alias the newly minted response id to this turn's session, same + # as the sample, so the next `previous_response_id` still resolves. + await store.put(response_id, session) + + if run["stream"]: + stream = target.run(run["messages"], stream=True, session=session) + if not isinstance(stream, ResponseStream): + raise HTTPException(status_code=500, detail="agent did not return a response stream") + return StreamingResponse( + responses_stream_events_from_run(stream, response_id=response_id, session_id=session_id), + media_type="text/event-stream", + ) + + result = await target.run(run["messages"], session=session) + return JSONResponse(responses_from_run(result, response_id=response_id, session_id=session_id)) + + return app + + +async def _post(app: FastAPI, payload: dict[str, Any]) -> httpx.Response: + """Send a POST /responses request through the ASGI app, no real socket involved.""" + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + return await client.post("/responses", json=payload, timeout=30) + + +def _parse_sse_events(body: str) -> list[dict[str, Any]]: + """Parse SSE text into a list of `{"event": ..., "data": ...}` dicts.""" + events: list[dict[str, Any]] = [] + for block in body.split("\n\n"): + if not block.strip(): + continue + event_type: str | None = None + data: str | None = None + for line in block.split("\n"): + if line.startswith("event: "): + event_type = line[len("event: ") :] + elif line.startswith("data: "): + data = line[len("data: ") :] + if event_type is not None and data is not None: + events.append({"event": event_type, "data": json.loads(data)}) + return events + + +class TestNonStreamingRoundTrip: + async def test_returns_responses_shaped_payload(self) -> None: + app = _build_app(_StubAgent()) + response = await _post(app, {"input": "hello"}) + + assert response.status_code == 200 + payload = response.json() + assert payload["object"] == "response" + assert payload["status"] == "completed" + assert payload["id"].startswith("resp_") + assert any(item["type"] == "message" for item in payload["output"]) + + async def test_invalid_input_returns_400_not_500(self) -> None: + app = _build_app(_StubAgent()) + response = await _post(app, {}) + + assert response.status_code == 400 + assert "input" in response.json()["detail"] + + +class TestStreamingRoundTrip: + async def test_stream_emits_created_delta_and_completed_events(self) -> None: + app = _build_app(_StubAgent()) + response = await _post(app, {"input": "hello", "stream": True}) + + assert response.status_code == 200 + assert "text/event-stream" in response.headers["content-type"] + + events = _parse_sse_events(response.text) + event_types = [e["event"] for e in events] + assert event_types[0] == "response.created" + assert event_types[-1] == "response.completed" + assert "response.output_text.delta" in event_types + + completed = events[-1]["data"]["response"] + assert completed["status"] == "completed" + assert completed["id"].startswith("resp_") + + +class TestSessionContinuity: + """Regression coverage for the `previous_response_id` aliasing fix. + + `previous_response_id` rotates every turn. Without aliasing the newly + minted response id to the same session, turn 3 would silently resolve to + a brand-new, empty session instead of the one from turns 1-2. + """ + + async def test_previous_response_id_chain_preserves_session_across_three_turns(self) -> None: + agent = _StubAgent() + app = _build_app(agent) + + turn1 = await _post(app, {"input": "hi"}) + assert turn1.status_code == 200 + turn2 = await _post(app, {"input": "still there?", "previous_response_id": turn1.json()["id"]}) + assert turn2.status_code == 200 + turn3 = await _post(app, {"input": "still there?", "previous_response_id": turn2.json()["id"]}) + assert turn3.status_code == 200 + + assert len(agent.session_ids_seen) == 3 + # All three turns must have run against the same underlying session, + # not three independent ones. + first_session_id = agent.session_ids_seen[0] + assert first_session_id is not None + assert agent.session_ids_seen == [first_session_id] * 3 + assert agent.turn_counts[first_session_id] == 3 + + async def test_conversation_id_preserves_session_across_turns(self) -> None: + agent = _StubAgent() + app = _build_app(agent) + + turn1 = await _post(app, {"input": "hi", "conversation_id": "conv_stable"}) + assert turn1.status_code == 200 + turn2 = await _post(app, {"input": "still there?", "conversation_id": "conv_stable"}) + assert turn2.status_code == 200 + + assert agent.session_ids_seen == ["conv_stable", "conv_stable"] + assert agent.turn_counts["conv_stable"] == 2 + + async def test_unrelated_requests_get_independent_sessions(self) -> None: + agent = _StubAgent() + app = _build_app(agent) + + first = await _post(app, {"input": "hi"}) + second = await _post(app, {"input": "unrelated"}) + + assert first.status_code == 200 + assert second.status_code == 200 + assert agent.session_ids_seen[0] != agent.session_ids_seen[1] diff --git a/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py b/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py index 6fd749d898e..5aaebf4b5fe 100644 --- a/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py +++ b/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py @@ -5,6 +5,7 @@ from __future__ import annotations from collections.abc import AsyncIterator, Sequence +from typing import cast import pytest from agent_framework import AgentResponse, AgentResponseUpdate, Content, Message, ResponseStream @@ -202,7 +203,10 @@ def test_responses_to_run_returns_messages_options_and_stream(self) -> None: "model": "gpt-x", }) - assert run["messages"][0].text == "hi" + # `responses_to_run` always produces a `list[Message]`; the TypedDict + # field is typed as the wider `Agent.run` input shape, so narrow here. + messages = cast("list[Message]", run["messages"]) + assert messages[0].text == "hi" assert run["stream"] is True assert run["options"] == {"max_tokens": 32, "model": "gpt-x"} diff --git a/python/packages/hosting/README.md b/python/packages/hosting/README.md index 944325b95c1..032fddd3908 100644 --- a/python/packages/hosting/README.md +++ b/python/packages/hosting/README.md @@ -7,7 +7,11 @@ This package keeps Agent Framework state separate from web-framework concerns: - `AgentFrameworkState` — stores an agent/workflow target and optional session state for routes that the app owns. - `SessionStore` — maps an app-selected session id to an `AgentSession` for - non-persisted servers. + non-persisted servers. `get`/`reset` manage a session by its own id; `put` + aliases an *additional* id to an already-resolved session — useful when a + protocol's continuation id rotates every turn (for example, OpenAI + Responses' `previous_response_id`) and a later request needs to resolve the + new id back to the same conversation. - Existing experimental channel-hosting types remain available while the package is unreleased, but the v1 direction is protocol helpers plus app-owned routes. diff --git a/python/packages/hosting/agent_framework_hosting/_state.py b/python/packages/hosting/agent_framework_hosting/_state.py index 86b4ae49125..cb6aa6218b7 100644 --- a/python/packages/hosting/agent_framework_hosting/_state.py +++ b/python/packages/hosting/agent_framework_hosting/_state.py @@ -4,7 +4,7 @@ import inspect from collections.abc import Awaitable, Callable, Mapping -from typing import Any, Generic, TypedDict, TypeVar +from typing import Any, Generic, TypedDict, TypeVar, overload from agent_framework import AgentRunInputs, AgentSession, ChatOptions, SupportsAgentRun, Workflow @@ -59,8 +59,29 @@ async def reset(self, session_id: str) -> None: raise ValueError("session_id must be a non-empty string") self._sessions.pop(session_id, None) + async def put(self, session_id: str, session: AgentSession) -> None: + """Associate an existing ``session`` with an additional ``session_id``. -TargetT = TypeVar("TargetT", SupportsAgentRun, Workflow) + Use this to alias a rotating protocol id (for example, a freshly + minted response id) to a session that was already looked up under a + different, prior id. Protocols whose continuation id changes every + turn (such as OpenAI Responses' ``previous_response_id`` chaining) + need this so a later request referencing the new id still resolves + to the same conversation instead of starting a fresh session. + + Args: + session_id: Opaque app-selected session id to associate. + session: The session to associate with ``session_id``. + + Raises: + ValueError: If ``session_id`` is empty. + """ + if not session_id: + raise ValueError("session_id must be a non-empty string") + self._sessions[session_id] = session + + +TargetT = TypeVar("TargetT", bound="SupportsAgentRun | Workflow") SessionStoreFactory = Callable[[SupportsAgentRun], SessionStore] @@ -89,6 +110,48 @@ class AgentFrameworkState(Generic[TargetT]): route code may share. """ + # `target` accepts an instance, a sync/async factory, or a bare awaitable. + # Each shape is declared as its own overload rather than one big union + # because type checkers struggle to bind `TargetT` when it appears both + # bare and inside `Callable`/`Awaitable` alternatives in a single union + # parameter (observed as inference failures across pyright, pyrefly, ty, + # and zuban). + @overload + def __init__( + self, + target: TargetT, + *, + session_store: SessionStore | type[SessionStore] | SessionStoreFactory | None = None, + cache_target: bool = True, + ) -> None: ... + + @overload + def __init__( + self, + target: Callable[[], TargetT], + *, + session_store: SessionStore | type[SessionStore] | SessionStoreFactory | None = None, + cache_target: bool = True, + ) -> None: ... + + @overload + def __init__( + self, + target: Callable[[], Awaitable[TargetT]], + *, + session_store: SessionStore | type[SessionStore] | SessionStoreFactory | None = None, + cache_target: bool = True, + ) -> None: ... + + @overload + def __init__( + self, + target: Awaitable[TargetT], + *, + session_store: SessionStore | type[SessionStore] | SessionStoreFactory | None = None, + cache_target: bool = True, + ) -> None: ... + def __init__( self, target: TargetT | Awaitable[TargetT] | Callable[[], TargetT | Awaitable[TargetT]], diff --git a/python/packages/hosting/tests/hosting/test_state.py b/python/packages/hosting/tests/hosting/test_state.py index 8626c6c2e44..f09c9b9f758 100644 --- a/python/packages/hosting/tests/hosting/test_state.py +++ b/python/packages/hosting/tests/hosting/test_state.py @@ -2,20 +2,47 @@ from __future__ import annotations -from typing import Any +import importlib +from collections.abc import AsyncIterator, Awaitable, Mapping +from typing import Any, Literal, overload import pytest -from agent_framework import AgentResponse, AgentSession, Message +from agent_framework import ( + AgentResponse, + AgentResponseUpdate, + AgentRunInputs, + AgentSession, + Content, + Message, + ResponseStream, + Workflow, +) from agent_framework_hosting import AgentFrameworkState, SessionStore +def _workflow_fixture(name: str) -> Any: + """Load a fixture from ``_workflow_fixtures.py`` via the ``conftest``-registered alias. + + Mirrors ``test_host.py``'s helper: the local ``conftest.py`` registers + ``_workflow_fixtures.py`` under the collision-proof name + ``hosting_workflow_fixtures`` so it stays importable in both + package-local and aggregate pytest runs. + """ + return getattr(importlib.import_module("hosting_workflow_fixtures"), name) + + class _FakeAgent: - """Minimal agent target for state tests.""" + """Minimal agent target for state tests. + + Declares ``run`` with the same two overloads as ``SupportsAgentRun`` (one + per ``stream`` value) so it satisfies the protocol under static type + checking, not just at runtime. + """ - id = "fake-agent" - name = "Fake Agent" - description = "Fake agent for tests" + id: str = "fake-agent" + name: str | None = "Fake Agent" + description: str | None = "Fake agent for tests" def __init__(self) -> None: self.created_sessions: list[AgentSession] = [] @@ -28,8 +55,48 @@ def create_session(self, *, session_id: str | None = None) -> AgentSession: def get_session(self, service_session_id: str, *, session_id: str | None = None) -> AgentSession: return AgentSession(session_id=session_id, service_session_id=service_session_id) - async def run(self, messages: Any = None, **_: Any) -> AgentResponse: - return AgentResponse(messages=Message(role="assistant", contents=["ok"])) + @overload + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: Literal[False] = ..., + session: AgentSession | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + ) -> Awaitable[AgentResponse[Any]]: ... + + @overload + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: Literal[True], + session: AgentSession | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... + + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: bool = False, + session: AgentSession | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: + if stream: + + async def _stream() -> AsyncIterator[AgentResponseUpdate]: + yield AgentResponseUpdate(contents=[Content.from_text(text="ok")], role="assistant") + + return ResponseStream(_stream(), finalizer=lambda updates: AgentResponse.from_updates(updates)) + + async def _get_response() -> AgentResponse[Any]: + return AgentResponse(messages=Message(role="assistant", contents=[Content.from_text(text="ok")])) + + return _get_response() class TestSessionStore: @@ -63,6 +130,24 @@ async def test_empty_session_id_raises(self) -> None: with pytest.raises(ValueError, match="session_id"): await store.reset("") + async def test_put_aliases_new_id_to_existing_session(self) -> None: + agent = _FakeAgent() + store = SessionStore(agent) + + session = await store.get("resp_1") + await store.put("resp_2", session) + + assert await store.get("resp_2") is session + # Aliasing did not create a second session via the agent. + assert len(agent.created_sessions) == 1 + + async def test_put_empty_session_id_raises(self) -> None: + store = SessionStore(_FakeAgent()) + session = await store.get("resp_1") + + with pytest.raises(ValueError, match="session_id"): + await store.put("", session) + class TestAgentFrameworkState: def test_default_session_store_for_agent(self) -> None: @@ -136,3 +221,37 @@ async def test_get_session_resolves_target_and_store(self) -> None: session = await state.get_session("session-1") assert session.session_id == "session-1" + + async def test_reset_session_forgets_session(self) -> None: + agent = _FakeAgent() + state = AgentFrameworkState(agent) + + first = await state.get_session("session-1") + await state.reset_session("session-1") + second = await state.get_session("session-1") + + assert first is not second + assert len(agent.created_sessions) == 2 + + def test_session_store_for_non_agent_target_raises_type_error(self) -> None: + workflow = _workflow_fixture("build_echo_workflow")() + + with pytest.raises(TypeError, match="session_store requires an agent target"): + AgentFrameworkState(workflow, session_store=SessionStore) + + async def test_workflow_target_has_no_default_session_store(self) -> None: + workflow: Workflow = _workflow_fixture("build_echo_workflow")() + state = AgentFrameworkState(workflow) + + assert await state.get_target() is workflow + assert state.session_store is None + with pytest.raises(TypeError, match="session_store requires an agent target"): + await state.get_session_store() + + async def test_workflow_target_resolved_from_factory(self) -> None: + build_echo_workflow = _workflow_fixture("build_echo_workflow") + + state = AgentFrameworkState(build_echo_workflow) + + target = await state.get_target() + assert isinstance(target, Workflow) diff --git a/python/samples/04-hosting/af-hosting/local_responses/README.md b/python/samples/04-hosting/af-hosting/local_responses/README.md index 2ffcf5642da..31e4a2d5412 100644 --- a/python/samples/04-hosting/af-hosting/local_responses/README.md +++ b/python/samples/04-hosting/af-hosting/local_responses/README.md @@ -21,6 +21,10 @@ What the route demonstrates: turn. - Produces the AF messages, options, and session id that the route passes to `agent.run(...)`. +- **Aliases** each newly minted response id to the session it was just + resolved from. OpenAI's `previous_response_id` rotates every turn, so + without this alias step turn 3+ of a conversation would silently resolve to + a brand-new, empty session instead of the one from earlier turns. `app:app` is a module-level FastAPI ASGI app; recommended local launch is Hypercorn. @@ -53,10 +57,12 @@ uv run python call_server.py # The client intentionally omits `model`; the app chooses the backing deployment # from FOUNDRY_MODEL. -# The script then sends a second turn, "And what about Amsterdam?", using the -# first `response.id` as `previous_response_id`. +# The script then sends two more turns, each continuing from the previous +# turn's `response.id` as `previous_response_id`. The third turn asks about +# the first turn's city, so it only succeeds if the server still remembers +# that far back in the chain. -# Same two-turn interaction through an Agent Framework Agent backed by +# Same three-turn interaction through an Agent Framework Agent backed by # OpenAIChatClient: uv run python call_server_af.py ``` 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 e00caa5ccc9..bc399539612 100644 --- a/python/samples/04-hosting/af-hosting/local_responses/app.py +++ b/python/samples/04-hosting/af-hosting/local_responses/app.py @@ -91,10 +91,13 @@ def create_agent() -> Agent: state = AgentFrameworkState(create_agent, session_store=SessionStore) -@app.post("/responses") +@app.post("/responses", response_model=None) async def responses(body: dict[str, Any] = Body(...)) -> JSONResponse | StreamingResponse: # noqa: B008 """Handle one OpenAI Responses-shaped request.""" - run = responses_to_run(body) + try: + run = responses_to_run(body) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc session_id = responses_session_id(body) response_id = create_response_id() @@ -108,7 +111,16 @@ async def responses(body: dict[str, Any] = Body(...)) -> JSONResponse | Streamin options_for_run = cast(Any, options) target = cast(Agent[Any], await state.get_target()) - session = await state.get_session(session_id or response_id) + store = await state.get_session_store() + lookup_id = session_id or response_id + session = await store.get(lookup_id) + if response_id != lookup_id: + # `previous_response_id` chaining rotates its id every turn. Alias the + # newly minted response id to this turn's session so the next + # request (which will send this response's id back as its + # `previous_response_id`) still resolves to the same conversation + # instead of silently starting a new one. + await store.put(response_id, session) if run["stream"]: stream = target.run( run["messages"], @@ -151,9 +163,7 @@ async def main() -> None: if __name__ == "__main__": asyncio.run(main()) -""" -Sample output: -User: What is the weather in Tokyo? -Agent: Tokyo is clear with a high of 18°C. -Response ID: resp_... -""" +# Sample output: +# User: What is the weather in Tokyo? +# Agent: Tokyo is clear with a high of 18°C. +# Response ID: resp_... 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 7066e9e7ac5..648e7fc9868 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 @@ -15,8 +15,10 @@ uv run python call_server.py -The script sends a follow-up turn ("And what about Amsterdam?") using the -first response's ``response.id`` as ``previous_response_id``. +The script sends two follow-up turns, each continuing from the previous +turn's ``response.id`` as ``previous_response_id``. The third turn asks about +information from the *first* turn only, so it also exercises session +continuity across a rotating response id chain, not just a single hop. """ from __future__ import annotations @@ -26,6 +28,7 @@ BASE_URL = "http://127.0.0.1:8000" PROMPT = "What is the weather in Tokyo?" FOLLOW_UP_PROMPT = "And what about Amsterdam?" +THIRD_PROMPT = "Which of the two cities we just discussed is warmer?" def main() -> None: @@ -46,6 +49,15 @@ def main() -> None: print(f"Agent: {follow_up.output_text}") print(f"Response ID: {follow_up.id}") + third = client.responses.create( + input=THIRD_PROMPT, + previous_response_id=follow_up.id, + ) + print() + print(f"User: {THIRD_PROMPT}") + print(f"Agent: {third.output_text}") + print(f"Response ID: {third.id}") + if __name__ == "__main__": main() diff --git a/python/samples/04-hosting/af-hosting/local_responses/call_server_af.py b/python/samples/04-hosting/af-hosting/local_responses/call_server_af.py index 3c93a88c748..078087c0912 100644 --- a/python/samples/04-hosting/af-hosting/local_responses/call_server_af.py +++ b/python/samples/04-hosting/af-hosting/local_responses/call_server_af.py @@ -4,14 +4,18 @@ Creates a local :class:`agent_framework.Agent` backed by :class:`agent_framework.openai.OpenAIChatClient` and points that client at the -hosted ``/responses`` endpoint for both turns: +hosted ``/responses`` endpoint for all turns: 1. ``What is the weather in Tokyo?`` 2. ``And what about Amsterdam?`` +3. ``Which of the two cities we just discussed is warmer?`` -Both turns use the same :class:`agent_framework.AgentSession`; the first -turn binds the hosted response id to the session, and the second turn -continues through that session. +All turns use the same :class:`agent_framework.AgentSession`; the first turn +binds the hosted response id to the session, and later turns continue through +that session via a chain of rotating ``previous_response_id`` values. The +third turn only makes sense if the server still remembers the first turn, so +it also exercises session continuity across that whole chain, not just a +single hop. Start the server first (in another shell):: @@ -33,6 +37,7 @@ PROMPTS = [ "What is the weather in Tokyo?", "And what about Amsterdam?", + "Which of the two cities we just discussed is warmer?", ] diff --git a/python/uv.lock b/python/uv.lock index 52f43e37a62..b4eb603f0d0 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -677,6 +677,12 @@ dependencies = [ { name = "openai" }, ] +[package.dev-dependencies] +dev = [ + { name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { 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" }, @@ -684,6 +690,12 @@ requires-dist = [ { name = "openai", specifier = ">=1.99.0,<3" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "fastapi", specifier = ">=0.115.0,<0.138.1" }, + { name = "httpx", specifier = ">=0.28.1" }, +] + [[package]] name = "agent-framework-hosting-telegram" version = "1.0.0a260625" From 944a13f9e58070c90a8dc1351e07f881ad90b849 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Fri, 3 Jul 2026 10:03:57 +0200 Subject: [PATCH 03/15] Simplify session-continuity aliasing: fold put() into get() Per feedback: the growth of SessionStore was not the problem -- it's intentional, since OpenAI's previous_response_id is designed to let a caller continue (fork) from any earlier response, not just the latest one, so every response id has to stay independently resolvable. That part stays as-is. What was too complex was the call site: routes had to manually fetch a session and then conditionally alias it with a separate put() call. Folded that into a single get(session_id, alias=...) call instead: - SessionStore.get() gains an optional `alias` keyword that registers an additional id for the same session in the same call (no-op if alias is None or equal to session_id). Removed the separate put() method. - AgentFrameworkState.get_session() passes `alias` through. - local_responses sample and the HTTP round-trip integration tests now do `await state.get_session(lookup_id, alias=response_id)` instead of pulling the store out and orchestrating get()/put() by hand. - Documented that this in-memory SessionStore intentionally never evicts (by design, to support forking), and that a storage-backed replacement (Redis, a database, ...) is responsible for its own TTL/eviction policy. Verified against a live Foundry model across a 3-turn previous_response_id chain after the simplification. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../hosting_responses/test_http_round_trip.py | 9 +-- python/packages/hosting/README.md | 19 +++++-- .../hosting/agent_framework_hosting/_state.py | 55 ++++++++++--------- .../hosting/tests/hosting/test_state.py | 15 ++++- .../af-hosting/local_responses/README.md | 8 ++- .../af-hosting/local_responses/app.py | 14 ++--- 6 files changed, 69 insertions(+), 51 deletions(-) diff --git a/python/packages/hosting-responses/tests/hosting_responses/test_http_round_trip.py b/python/packages/hosting-responses/tests/hosting_responses/test_http_round_trip.py index 4955f3040d2..c7c5ee4159d 100644 --- a/python/packages/hosting-responses/tests/hosting_responses/test_http_round_trip.py +++ b/python/packages/hosting-responses/tests/hosting_responses/test_http_round_trip.py @@ -127,13 +127,10 @@ async def responses(body: dict[str, Any] = Body(...)) -> JSONResponse | Streamin response_id = create_response_id() target = await state.get_target() - store = await state.get_session_store() lookup_id = session_id or response_id - session = await store.get(lookup_id) - if response_id != lookup_id: - # Alias the newly minted response id to this turn's session, same - # as the sample, so the next `previous_response_id` still resolves. - await store.put(response_id, session) + # Alias the newly minted response id to this turn's session, same as + # the sample, so a later `previous_response_id` still resolves. + session = await state.get_session(lookup_id, alias=response_id) if run["stream"]: stream = target.run(run["messages"], stream=True, session=session) diff --git a/python/packages/hosting/README.md b/python/packages/hosting/README.md index 032fddd3908..06e960edb73 100644 --- a/python/packages/hosting/README.md +++ b/python/packages/hosting/README.md @@ -7,17 +7,26 @@ This package keeps Agent Framework state separate from web-framework concerns: - `AgentFrameworkState` — stores an agent/workflow target and optional session state for routes that the app owns. - `SessionStore` — maps an app-selected session id to an `AgentSession` for - non-persisted servers. `get`/`reset` manage a session by its own id; `put` - aliases an *additional* id to an already-resolved session — useful when a - protocol's continuation id rotates every turn (for example, OpenAI - Responses' `previous_response_id`) and a later request needs to resolve the - new id back to the same conversation. + non-persisted servers. `get(session_id, alias=...)` resolves (creating if + needed) and, in the same call, can register an *additional* id for the same + session — useful when a protocol's continuation id rotates every turn (for + example, OpenAI Responses' `previous_response_id`, which by design lets a + caller continue from any earlier point, not just the latest turn) and a + later request needs to resolve the new id back to the same conversation. + `reset` forgets a session by its own id. - Existing experimental channel-hosting types remain available while the package is unreleased, but the v1 direction is protocol helpers plus app-owned routes. Use FastAPI, Starlette, Azure Functions, Django, or another framework for route registration, auth, middleware, response construction, and background work. +> The built-in `SessionStore` is an in-memory `dict` with no eviction — every +> id it has ever seen (including aliases) stays resolvable for the life of the +> process, which is intentional for the reasons above. If you back a +> `SessionStore`-shaped store with real storage (Redis, a database, ...), you +> are responsible for that store's own TTL/eviction policy; this reference +> implementation does not model that concern. + ## Quickstart ```python diff --git a/python/packages/hosting/agent_framework_hosting/_state.py b/python/packages/hosting/agent_framework_hosting/_state.py index cb6aa6218b7..007e55397d8 100644 --- a/python/packages/hosting/agent_framework_hosting/_state.py +++ b/python/packages/hosting/agent_framework_hosting/_state.py @@ -16,6 +16,16 @@ class SessionStore: instances. The id is an opaque partition key; callers are responsible for deciding whether it came from a trusted request field, platform context, or other route-local state. + + This reference implementation is a plain ``dict`` with no eviction: every + id ever seen stays resolvable for the life of the process. That is + intentional here (protocols such as OpenAI Responses' + ``previous_response_id`` let a caller continue from *any* earlier point in + a conversation, not just the latest turn, so every id needs to stay + independently addressable -- see :meth:`get`'s ``alias`` argument). + A storage-backed replacement (Redis, a database, ...) should apply its own + TTL/eviction policy for these ids; this in-memory store does not model + that concern. """ def __init__(self, agent: SupportsAgentRun) -> None: @@ -28,12 +38,21 @@ def __init__(self, agent: SupportsAgentRun) -> None: self.agent = agent self._sessions: dict[str, AgentSession] = {} - async def get(self, session_id: str) -> AgentSession: + async def get(self, session_id: str, *, alias: str | None = None) -> AgentSession: """Return the session for ``session_id``, creating it when needed. Args: session_id: Opaque app-selected session id. + Keyword Args: + alias: Optional additional id to register for the same session in + one call, for example a freshly minted id that the next + request will present instead of ``session_id``. A no-op when + ``alias`` is ``None`` or equal to ``session_id``. Callers that + want a single stable key for a whole conversation instead of + per-turn aliases should use a stable id (such as a + ``conversation_id``) as ``session_id`` and skip ``alias``. + Returns: The cached or newly created ``AgentSession``. @@ -44,7 +63,10 @@ async def get(self, session_id: str) -> AgentSession: raise ValueError("session_id must be a non-empty string") if session_id not in self._sessions: self._sessions[session_id] = self.agent.create_session(session_id=session_id) - return self._sessions[session_id] + session = self._sessions[session_id] + if alias and alias != session_id: + self._sessions[alias] = session + return session async def reset(self, session_id: str) -> None: """Forget the current session for ``session_id``. @@ -59,27 +81,6 @@ async def reset(self, session_id: str) -> None: raise ValueError("session_id must be a non-empty string") self._sessions.pop(session_id, None) - async def put(self, session_id: str, session: AgentSession) -> None: - """Associate an existing ``session`` with an additional ``session_id``. - - Use this to alias a rotating protocol id (for example, a freshly - minted response id) to a session that was already looked up under a - different, prior id. Protocols whose continuation id changes every - turn (such as OpenAI Responses' ``previous_response_id`` chaining) - need this so a later request referencing the new id still resolves - to the same conversation instead of starting a fresh session. - - Args: - session_id: Opaque app-selected session id to associate. - session: The session to associate with ``session_id``. - - Raises: - ValueError: If ``session_id`` is empty. - """ - if not session_id: - raise ValueError("session_id must be a non-empty string") - self._sessions[session_id] = session - TargetT = TypeVar("TargetT", bound="SupportsAgentRun | Workflow") SessionStoreFactory = Callable[[SupportsAgentRun], SessionStore] @@ -228,17 +229,21 @@ async def get_session_store(self) -> SessionStore: self._cached_session_store = store return store - async def get_session(self, session_id: str) -> AgentSession: + async def get_session(self, session_id: str, *, alias: str | None = None) -> AgentSession: """Return the session for ``session_id`` from the current store. Args: session_id: Opaque app-selected session id. + Keyword Args: + alias: Optional additional id to register for the same session in + one call. See :meth:`SessionStore.get`. + Returns: The cached or newly created ``AgentSession``. """ store = await self.get_session_store() - return await store.get(session_id) + return await store.get(session_id, alias=alias) async def reset_session(self, session_id: str) -> None: """Forget the current session for ``session_id``. diff --git a/python/packages/hosting/tests/hosting/test_state.py b/python/packages/hosting/tests/hosting/test_state.py index f09c9b9f758..f920803bd2c 100644 --- a/python/packages/hosting/tests/hosting/test_state.py +++ b/python/packages/hosting/tests/hosting/test_state.py @@ -135,18 +135,27 @@ async def test_put_aliases_new_id_to_existing_session(self) -> None: store = SessionStore(agent) session = await store.get("resp_1") - await store.put("resp_2", session) + aliased = await store.get("resp_1", alias="resp_2") + assert aliased is session assert await store.get("resp_2") is session # Aliasing did not create a second session via the agent. assert len(agent.created_sessions) == 1 + async def test_alias_equal_to_session_id_is_a_no_op(self) -> None: + agent = _FakeAgent() + store = SessionStore(agent) + + session = await store.get("resp_1", alias="resp_1") + + assert session.session_id == "resp_1" + assert len(agent.created_sessions) == 1 + async def test_put_empty_session_id_raises(self) -> None: store = SessionStore(_FakeAgent()) - session = await store.get("resp_1") with pytest.raises(ValueError, match="session_id"): - await store.put("", session) + await store.get("", alias="resp_2") class TestAgentFrameworkState: diff --git a/python/samples/04-hosting/af-hosting/local_responses/README.md b/python/samples/04-hosting/af-hosting/local_responses/README.md index 31e4a2d5412..943c040d5db 100644 --- a/python/samples/04-hosting/af-hosting/local_responses/README.md +++ b/python/samples/04-hosting/af-hosting/local_responses/README.md @@ -22,9 +22,11 @@ What the route demonstrates: - Produces the AF messages, options, and session id that the route passes to `agent.run(...)`. - **Aliases** each newly minted response id to the session it was just - resolved from. OpenAI's `previous_response_id` rotates every turn, so - without this alias step turn 3+ of a conversation would silently resolve to - a brand-new, empty session instead of the one from earlier turns. + resolved from, via `state.get_session(lookup_id, alias=response_id)`. + OpenAI's `previous_response_id` rotates every turn *by design* — it lets a + caller continue from any earlier response, not just the latest one — so + every response id needs to stay independently resolvable, not just the + most recent. `app:app` is a module-level FastAPI ASGI app; recommended local launch is Hypercorn. 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 bc399539612..3b692ec98d9 100644 --- a/python/samples/04-hosting/af-hosting/local_responses/app.py +++ b/python/samples/04-hosting/af-hosting/local_responses/app.py @@ -111,16 +111,12 @@ async def responses(body: dict[str, Any] = Body(...)) -> JSONResponse | Streamin options_for_run = cast(Any, options) target = cast(Agent[Any], await state.get_target()) - store = await state.get_session_store() lookup_id = session_id or response_id - session = await store.get(lookup_id) - if response_id != lookup_id: - # `previous_response_id` chaining rotates its id every turn. Alias the - # newly minted response id to this turn's session so the next - # request (which will send this response's id back as its - # `previous_response_id`) still resolves to the same conversation - # instead of silently starting a new one. - await store.put(response_id, session) + # `previous_response_id` chaining rotates its id every turn, and OpenAI's + # Responses API deliberately lets a caller continue from *any* earlier + # response, not just the latest one -- so the newly minted response id + # also needs to resolve back to this session on a later request. + session = await state.get_session(lookup_id, alias=response_id) if run["stream"]: stream = target.run( run["messages"], From 3bb63205b71ce4e4c057d67de6317fdbfe2d50c9 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Fri, 3 Jul 2026 11:22:12 +0200 Subject: [PATCH 04/15] Refine hosting state helpers Split the shared state surface into AgentState and WorkflowState, keep SessionStore and CheckpointStore as plain storage, and make state helpers responsible for get-or-create behavior. Update the Responses sample and HTTP round-trip tests to store the post-run session explicitly under the minted response id, and support WorkflowBuilder/orchestration-style builders via structural build() support. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/hosting-responses/README.md | 8 +- .../hosting_responses/test_http_round_trip.py | 23 +- python/packages/hosting/README.md | 81 +++- .../agent_framework_hosting/__init__.py | 15 +- .../hosting/agent_framework_hosting/_state.py | 434 +++++++++++------- .../tests/hosting/_workflow_fixtures.py | 5 + .../hosting/tests/hosting/test_state.py | 251 ++++++---- .../af-hosting/local_responses/README.md | 5 +- .../af-hosting/local_responses/app.py | 35 +- 9 files changed, 571 insertions(+), 286 deletions(-) diff --git a/python/packages/hosting-responses/README.md b/python/packages/hosting-responses/README.md index 956d59f516f..dd6a121a30e 100644 --- a/python/packages/hosting-responses/README.md +++ b/python/packages/hosting-responses/README.md @@ -18,7 +18,7 @@ FastAPI/Starlette/Django/Azure Functions code owns route registration, authentication, status codes, response construction, and background work. ```python -from agent_framework_hosting import AgentFrameworkState, SessionStore +from agent_framework_hosting import AgentState from agent_framework_hosting_responses import ( create_response_id, responses_from_run, @@ -29,7 +29,7 @@ from fastapi import Body, FastAPI from fastapi.responses import JSONResponse app = FastAPI() -state = AgentFrameworkState(agent, session_store=SessionStore) +state = AgentState(agent) @app.post("/responses") @@ -37,11 +37,13 @@ async def responses(body: dict = Body(...)) -> JSONResponse: run = responses_to_run(body) session_id = responses_session_id(body) response_id = create_response_id() + session = await state.get_or_create_session(session_id or response_id) result = await (await state.get_target()).run( run["messages"], - session=await state.get_session(session_id or response_id), + session=session, options=run["options"], ) + await state.session_store.set(response_id, session) return JSONResponse(responses_from_run(result, response_id=response_id, session_id=session_id)) ``` diff --git a/python/packages/hosting-responses/tests/hosting_responses/test_http_round_trip.py b/python/packages/hosting-responses/tests/hosting_responses/test_http_round_trip.py index c7c5ee4159d..f1efb1616bc 100644 --- a/python/packages/hosting-responses/tests/hosting_responses/test_http_round_trip.py +++ b/python/packages/hosting-responses/tests/hosting_responses/test_http_round_trip.py @@ -4,7 +4,7 @@ These exercise the same wiring as the `local_responses` sample: helpers from `agent_framework_hosting_responses` convert between the Responses protocol and -Agent Framework run values, `agent_framework_hosting`'s `AgentFrameworkState` / +Agent Framework run values, `agent_framework_hosting`'s `AgentState` / `SessionStore` hold shared execution state, and a small FastAPI route owns everything else (parsing, policy, response construction). Requests go through `httpx.AsyncClient` with `ASGITransport` -- no real server process or live @@ -27,7 +27,7 @@ Message, ResponseStream, ) -from agent_framework_hosting import AgentFrameworkState, SessionStore +from agent_framework_hosting import AgentState from fastapi import Body, FastAPI, HTTPException from fastapi.responses import JSONResponse, StreamingResponse @@ -115,7 +115,7 @@ async def _get_response() -> AgentResponse[Any]: def _build_app(agent: _StubAgent) -> FastAPI: """Build a minimal FastAPI app mirroring the `local_responses` sample's route.""" app = FastAPI() - state = AgentFrameworkState(agent, session_store=SessionStore) + state = AgentState(agent) @app.post("/responses", response_model=None) async def responses(body: dict[str, Any] = Body(...)) -> JSONResponse | StreamingResponse: # noqa: B008 @@ -128,20 +128,29 @@ async def responses(body: dict[str, Any] = Body(...)) -> JSONResponse | Streamin target = await state.get_target() lookup_id = session_id or response_id - # Alias the newly minted response id to this turn's session, same as - # the sample, so a later `previous_response_id` still resolves. - session = await state.get_session(lookup_id, alias=response_id) + session = await state.get_or_create_session(lookup_id) if run["stream"]: stream = target.run(run["messages"], stream=True, session=session) if not isinstance(stream, ResponseStream): raise HTTPException(status_code=500, detail="agent did not return a response stream") + + async def stream_events() -> AsyncIterator[str]: + async for event in responses_stream_events_from_run( + stream, + response_id=response_id, + session_id=session_id, + ): + yield event + await state.session_store.set(response_id, session) + return StreamingResponse( - responses_stream_events_from_run(stream, response_id=response_id, session_id=session_id), + stream_events(), media_type="text/event-stream", ) result = await target.run(run["messages"], session=session) + await state.session_store.set(response_id, session) return JSONResponse(responses_from_run(result, response_id=response_id, session_id=session_id)) return app diff --git a/python/packages/hosting/README.md b/python/packages/hosting/README.md index 06e960edb73..5217ac39e94 100644 --- a/python/packages/hosting/README.md +++ b/python/packages/hosting/README.md @@ -4,50 +4,89 @@ Shared execution-state helpers for app-owned Agent Framework hosting. This package keeps Agent Framework state separate from web-framework concerns: -- `AgentFrameworkState` — stores an agent/workflow target and optional session - state for routes that the app owns. -- `SessionStore` — maps an app-selected session id to an `AgentSession` for - non-persisted servers. `get(session_id, alias=...)` resolves (creating if - needed) and, in the same call, can register an *additional* id for the same - session — useful when a protocol's continuation id rotates every turn (for - example, OpenAI Responses' `previous_response_id`, which by design lets a - caller continue from any earlier point, not just the latest turn) and a - later request needs to resolve the new id back to the same conversation. - `reset` forgets a session by its own id. +- `AgentState` — pairs an agent target with a `SessionStore` + (`session_id -> AgentSession`). +- `WorkflowState` — pairs a workflow target with a `CheckpointStore` + (`session_id -> CheckpointStorage`). + +Both stores are plain storage: `get`/`set`/`delete` by an app-selected id, +nothing more. Neither one knows how to create a new value for an id it +hasn't seen before — use `AgentState.get_or_create_session(...)` / +`WorkflowState.get_or_create_checkpoint_storage(...)` for that, since only +the state object has both the store and the resolved target. + - Existing experimental channel-hosting types remain available while the package is unreleased, but the v1 direction is protocol helpers plus app-owned routes. Use FastAPI, Starlette, Azure Functions, Django, or another framework for route registration, auth, middleware, response construction, and background work. -> The built-in `SessionStore` is an in-memory `dict` with no eviction — every -> id it has ever seen (including aliases) stays resolvable for the life of the -> process, which is intentional for the reasons above. If you back a -> `SessionStore`-shaped store with real storage (Redis, a database, ...), you -> are responsible for that store's own TTL/eviction policy; this reference -> implementation does not model that concern. +> The built-in `SessionStore` / `CheckpointStore` are in-memory `dict`s with +> no eviction — every id ever stored stays resolvable for the life of the +> process. That is intentional: protocols such as OpenAI Responses' +> `previous_response_id` are designed to let a caller continue from *any* +> earlier point in a conversation, not just the latest turn, so every id +> handed out needs to stay independently resolvable. If you back either +> store with real storage (Redis, a database, ...), you are responsible for +> that store's own TTL/eviction policy; these in-memory reference +> implementations do not model that concern. ## Quickstart ```python from agent_framework.openai import OpenAIChatClient -from agent_framework_hosting import AgentFrameworkState, SessionStore +from agent_framework_hosting import AgentState agent = OpenAIChatClient().as_agent(name="Assistant") -state = AgentFrameworkState(agent, session_store=SessionStore) +state = AgentState(agent) -session = await state.get_session("conversation-1") +session = await state.get_or_create_session("conversation-1") result = await (await state.get_target()).run("Hello", session=session) ``` +If a protocol mints a new continuation id on every response, store the session +explicitly after `run(...)` returns. `run(...)` may update the session, so store +the post-run object: + +```python +session = await state.get_or_create_session(previous_response_id) +result = await (await state.get_target()).run("Hello", session=session) +await state.session_store.set(response_id, session) +``` + Targets can be direct instances, synchronous factories, asynchronous factories, or awaitables: ```python -state = AgentFrameworkState(create_agent) # cached by default -state = AgentFrameworkState(create_agent, cache_target=False) +state = AgentState(create_agent) # cached by default +state = AgentState(create_agent, cache_target=False) +``` + +`WorkflowState` mirrors this shape for workflow targets: + +```python +from agent_framework_hosting import WorkflowState + +state = WorkflowState(create_workflow) +storage = await state.get_or_create_checkpoint_storage("conversation-1") +result = await (await state.get_target()).run("Hello", checkpoint_storage=storage) ``` +`WorkflowState` also accepts an unbuilt workflow builder directly: + +```python +from agent_framework import WorkflowBuilder +from agent_framework_hosting import WorkflowState + +builder = WorkflowBuilder(start_executor=executor) +state = WorkflowState(builder) # calls builder.build() when the target is resolved +``` + +This is structural: orchestration builders from `agent_framework_orchestrations` +(`SequentialBuilder`, `ConcurrentBuilder`, `HandoffBuilder`, `GroupChatBuilder`, +and `MagenticBuilder`) also work because they expose the same zero-argument +`build() -> Workflow` method. + Cross-channel identity linking, multicast delivery, background runs, continuation tokens, and durable delivery runners are follow-up enhancements, not part of this v1 state surface. diff --git a/python/packages/hosting/agent_framework_hosting/__init__.py b/python/packages/hosting/agent_framework_hosting/__init__.py index cb5ed5567fd..99d98367573 100644 --- a/python/packages/hosting/agent_framework_hosting/__init__.py +++ b/python/packages/hosting/agent_framework_hosting/__init__.py @@ -20,7 +20,15 @@ reset_current_isolation_keys, set_current_isolation_keys, ) -from ._state import AgentFrameworkState, AgentRunArgs, SessionStore, WorkflowRunArgs +from ._state import ( + AgentRunArgs, + AgentState, + CheckpointStore, + SessionStore, + SupportsBuild, + WorkflowRunArgs, + WorkflowState, +) from ._types import ( Channel, ChannelCommand, @@ -45,8 +53,8 @@ "ISOLATION_HEADER_CHAT", "ISOLATION_HEADER_USER", "AgentFrameworkHost", - "AgentFrameworkState", "AgentRunArgs", + "AgentState", "Channel", "ChannelCommand", "ChannelCommandContext", @@ -58,11 +66,14 @@ "ChannelRunHook", "ChannelSession", "ChannelStreamUpdateHook", + "CheckpointStore", "HostStatePaths", "HostedRunResult", "IsolationKeys", "SessionStore", + "SupportsBuild", "WorkflowRunArgs", + "WorkflowState", "__version__", "get_current_isolation_keys", "logger", diff --git a/python/packages/hosting/agent_framework_hosting/_state.py b/python/packages/hosting/agent_framework_hosting/_state.py index 007e55397d8..a16a30c4706 100644 --- a/python/packages/hosting/agent_framework_hosting/_state.py +++ b/python/packages/hosting/agent_framework_hosting/_state.py @@ -1,75 +1,126 @@ # Copyright (c) Microsoft. All rights reserved. +"""Shared execution state for app-owned hosting routes. + +Two independent state holders, one per target kind, since agents and +workflows keep different continuation state: + +- ``AgentState`` pairs an agent target with a ``SessionStore`` + (``session_id -> AgentSession``). +- ``WorkflowState`` pairs a workflow target with a ``CheckpointStore`` + (``session_id -> CheckpointStorage``). + +Both stores are plain storage: they only get/set/delete what they are given. +Neither one knows how to create a new value for a ``session_id`` it hasn't +seen before -- that is the corresponding state object's job (see +``AgentState.get_or_create_session`` / ``WorkflowState.get_or_create_checkpoint_storage``), +since only the state object has both the store and the resolved target. +""" + from __future__ import annotations import inspect from collections.abc import Awaitable, Callable, Mapping -from typing import Any, Generic, TypedDict, TypeVar, overload +from typing import Any, Generic, Protocol, TypedDict, TypeVar, cast, runtime_checkable -from agent_framework import AgentRunInputs, AgentSession, ChatOptions, SupportsAgentRun, Workflow +from agent_framework import ( + AgentRunInputs, + AgentSession, + ChatOptions, + CheckpointStorage, + InMemoryCheckpointStorage, + SupportsAgentRun, + Workflow, +) class SessionStore: - """In-memory session lookup for non-persisted servers. - - The store maps application-selected session ids to ``AgentSession`` - instances. The id is an opaque partition key; callers are responsible for - deciding whether it came from a trusted request field, platform context, or - other route-local state. - - This reference implementation is a plain ``dict`` with no eviction: every - id ever seen stays resolvable for the life of the process. That is - intentional here (protocols such as OpenAI Responses' - ``previous_response_id`` let a caller continue from *any* earlier point in - a conversation, not just the latest turn, so every id needs to stay - independently addressable -- see :meth:`get`'s ``alias`` argument). - A storage-backed replacement (Redis, a database, ...) should apply its own - TTL/eviction policy for these ids; this in-memory store does not model - that concern. + """Plain in-memory ``session_id -> AgentSession`` lookup. + + This store only stores and retrieves; it does not create sessions. Use + :meth:`AgentState.get_or_create_session` for that -- it resolves the + agent target and calls ``target.create_session(...)`` the first time a + given ``session_id`` is seen, then stores the result here. + + No eviction: every id ever stored stays resolvable for the life of the + process. That is intentional -- protocols such as OpenAI Responses' + ``previous_response_id`` are designed to let a caller continue from *any* + earlier point in a conversation, not just the latest turn, so every id + that has been handed out needs to stay independently resolvable. If you + back a ``SessionStore``-shaped store with real storage (Redis, a + database, ...), you are responsible for that store's own TTL/eviction + policy; this in-memory reference implementation does not model that + concern. """ - def __init__(self, agent: SupportsAgentRun) -> None: - """Create a session store for ``agent``. + def __init__(self) -> None: + """Create an empty session store.""" + self._sessions: dict[str, AgentSession] = {} + + async def get(self, session_id: str) -> AgentSession | None: + """Return the stored session for ``session_id``, or ``None`` if absent. Args: - agent: The agent that creates sessions when a session id is first - observed. + session_id: Opaque app-selected session id. + + Raises: + ValueError: If ``session_id`` is empty. """ - self.agent = agent - self._sessions: dict[str, AgentSession] = {} + if not session_id: + raise ValueError("session_id must be a non-empty string") + return self._sessions.get(session_id) - async def get(self, session_id: str, *, alias: str | None = None) -> AgentSession: - """Return the session for ``session_id``, creating it when needed. + async def set(self, session_id: str, session: AgentSession) -> None: + """Store ``session`` under ``session_id``, replacing any existing entry. Args: session_id: Opaque app-selected session id. + session: The session to store. - Keyword Args: - alias: Optional additional id to register for the same session in - one call, for example a freshly minted id that the next - request will present instead of ``session_id``. A no-op when - ``alias`` is ``None`` or equal to ``session_id``. Callers that - want a single stable key for a whole conversation instead of - per-turn aliases should use a stable id (such as a - ``conversation_id``) as ``session_id`` and skip ``alias``. + Raises: + ValueError: If ``session_id`` is empty. + """ + if not session_id: + raise ValueError("session_id must be a non-empty string") + self._sessions[session_id] = session - Returns: - The cached or newly created ``AgentSession``. + async def delete(self, session_id: str) -> None: + """Forget the stored session for ``session_id``, if any. + + Args: + session_id: Opaque app-selected session id. Raises: ValueError: If ``session_id`` is empty. """ if not session_id: raise ValueError("session_id must be a non-empty string") - if session_id not in self._sessions: - self._sessions[session_id] = self.agent.create_session(session_id=session_id) - session = self._sessions[session_id] - if alias and alias != session_id: - self._sessions[alias] = session - return session + self._sessions.pop(session_id, None) + + +class CheckpointStore: + """Plain in-memory ``session_id -> CheckpointStorage`` lookup. - async def reset(self, session_id: str) -> None: - """Forget the current session for ``session_id``. + Maps an app-selected session id to a :class:`CheckpointStorage` scoped to + that conversation. This store only stores and retrieves; it does not + decide which checkpoint within that storage to resume from. Use + :meth:`WorkflowState.get_or_create_checkpoint_storage` to create a fresh + ``CheckpointStorage`` the first time a given ``session_id`` is seen. + + Resuming a prior run is a separate, run-time decision the route makes: + call ``storage.get_latest(workflow_name=...)`` yourself and pass its + ``checkpoint_id`` into ``workflow.run(checkpoint_id=..., checkpoint_storage=storage)``. + + No eviction, for the same reason as :class:`SessionStore` -- see that + class's docstring. + """ + + def __init__(self) -> None: + """Create an empty checkpoint store.""" + self._storages: dict[str, CheckpointStorage] = {} + + async def get(self, session_id: str) -> CheckpointStorage | None: + """Return the stored checkpoint storage for ``session_id``, or ``None`` if absent. Args: session_id: Opaque app-selected session id. @@ -79,11 +130,52 @@ async def reset(self, session_id: str) -> None: """ if not session_id: raise ValueError("session_id must be a non-empty string") - self._sessions.pop(session_id, None) + return self._storages.get(session_id) + async def set(self, session_id: str, storage: CheckpointStorage) -> None: + """Store ``storage`` under ``session_id``, replacing any existing entry. -TargetT = TypeVar("TargetT", bound="SupportsAgentRun | Workflow") -SessionStoreFactory = Callable[[SupportsAgentRun], SessionStore] + Args: + session_id: Opaque app-selected session id. + storage: The checkpoint storage to store. + + Raises: + ValueError: If ``session_id`` is empty. + """ + if not session_id: + raise ValueError("session_id must be a non-empty string") + self._storages[session_id] = storage + + async def delete(self, session_id: str) -> None: + """Forget the stored checkpoint storage for ``session_id``, if any. + + Args: + session_id: Opaque app-selected session id. + + Raises: + ValueError: If ``session_id`` is empty. + """ + if not session_id: + raise ValueError("session_id must be a non-empty string") + self._storages.pop(session_id, None) + + +AgentT = TypeVar("AgentT", bound=SupportsAgentRun) +WorkflowT = TypeVar("WorkflowT", bound=Workflow) + + +@runtime_checkable +class SupportsBuild(Protocol): + """A builder that produces a ``Workflow`` via a zero-argument ``build()``. + + Matches ``agent_framework.WorkflowBuilder`` and the orchestration + builders in ``agent_framework_orchestrations`` (``ConcurrentBuilder``, + ``GroupChatBuilder``, ``HandoffBuilder``, ``MagenticBuilder``, + ``SequentialBuilder``) structurally, without ``agent-framework-hosting`` + depending on either package. + """ + + def build(self) -> Workflow: ... class AgentRunArgs(TypedDict): @@ -102,99 +194,49 @@ class WorkflowRunArgs(TypedDict): stream: bool -class AgentFrameworkState(Generic[TargetT]): - """Shared execution state for app-owned hosting routes. +class AgentState(Generic[AgentT]): + """Shared execution state for app-owned agent hosting routes. - ``AgentFrameworkState`` intentionally does not own routes, middleware, - protocol dispatch, or native SDK calls. Web frameworks keep those concerns; - this object holds the Agent Framework target and optional session store that - route code may share. + Holds the Agent Framework agent target and a :class:`SessionStore` that + route code may share. Does not own routes, middleware, protocol + dispatch, or native SDK calls -- web frameworks keep those concerns. """ - # `target` accepts an instance, a sync/async factory, or a bare awaitable. - # Each shape is declared as its own overload rather than one big union - # because type checkers struggle to bind `TargetT` when it appears both - # bare and inside `Callable`/`Awaitable` alternatives in a single union - # parameter (observed as inference failures across pyright, pyrefly, ty, - # and zuban). - @overload - def __init__( - self, - target: TargetT, - *, - session_store: SessionStore | type[SessionStore] | SessionStoreFactory | None = None, - cache_target: bool = True, - ) -> None: ... - - @overload def __init__( self, - target: Callable[[], TargetT], + target: AgentT | Awaitable[AgentT] | Callable[[], AgentT | Awaitable[AgentT]], *, - session_store: SessionStore | type[SessionStore] | SessionStoreFactory | None = None, - cache_target: bool = True, - ) -> None: ... - - @overload - def __init__( - self, - target: Callable[[], Awaitable[TargetT]], - *, - session_store: SessionStore | type[SessionStore] | SessionStoreFactory | None = None, - cache_target: bool = True, - ) -> None: ... - - @overload - def __init__( - self, - target: Awaitable[TargetT], - *, - session_store: SessionStore | type[SessionStore] | SessionStoreFactory | None = None, - cache_target: bool = True, - ) -> None: ... - - def __init__( - self, - target: TargetT | Awaitable[TargetT] | Callable[[], TargetT | Awaitable[TargetT]], - *, - session_store: SessionStore | type[SessionStore] | SessionStoreFactory | None = None, + session_store: SessionStore | None = None, cache_target: bool = True, ) -> None: """Create shared state for ``target``. Args: - target: Agent or workflow target used by route code. May be a - target instance, a synchronous factory, an asynchronous factory, - or an awaitable target. + target: Agent target used by route code. May be a target + instance, a synchronous factory, an asynchronous factory, or + an awaitable target. Keyword Args: - session_store: Existing store, store class, or factory. When omitted - and ``target`` is an agent, an in-memory ``SessionStore`` is - created. Workflow targets do not get a default session store. - cache_target: Whether to cache a resolved callable/awaitable target. - Defaults to ``True`` so expensive target setup happens once. + session_store: Existing store to use. Defaults to a fresh + in-memory :class:`SessionStore`. + cache_target: Whether to cache a resolved callable/awaitable + target. Defaults to ``True`` so expensive target setup + happens once. Raises: ValueError: If ``cache_target=False`` is used with a one-shot awaitable target. - TypeError: If a session store class/factory is supplied for a - workflow target. """ if not cache_target and inspect.isawaitable(target): raise ValueError("cache_target=False requires a target instance or callable target factory") self._target_source = target self._cache_target = cache_target - self._cached_target: TargetT | None = None - self._session_store_source = session_store - self._cached_session_store = session_store if isinstance(session_store, SessionStore) else None + self._cached_target: AgentT | None = None if not callable(target) and not inspect.isawaitable(target): self._cached_target = target - if self._cached_session_store is None and isinstance(target, SupportsAgentRun): - self._cached_session_store = self._init_session_store(target, session_store) - elif session_store is not None and not isinstance(target, SupportsAgentRun): - raise TypeError("session_store requires an agent target that supports create_session") + self._session_store: SessionStore = session_store if session_store is not None else SessionStore() - async def get_target(self) -> TargetT: + async def get_target(self) -> AgentT: """Return the resolved target. Returns: @@ -211,51 +253,115 @@ async def get_target(self) -> TargetT: self._cached_target = target return target - async def get_session_store(self) -> SessionStore: - """Return the session store for the current target. - - Returns: - The configured or lazily created ``SessionStore``. + @property + def target(self) -> AgentT: + """Return a synchronously available target. Raises: - TypeError: If the resolved target is not an agent target. + RuntimeError: If the target is a callable or awaitable that has not + been resolved with :meth:`get_target`. """ - if self._cached_session_store is not None: - return self._cached_session_store + if self._cached_target is not None: + return self._cached_target + if not callable(self._target_source) and not inspect.isawaitable(self._target_source): + return self._target_source + raise RuntimeError("target is resolved asynchronously; use `await state.get_target()`") - target = await self.get_target() - store = self._init_session_store(target, self._session_store_source) - if self._cache_target: - self._cached_session_store = store - return store + @property + def session_store(self) -> SessionStore: + """Return the session store for this state.""" + return self._session_store - async def get_session(self, session_id: str, *, alias: str | None = None) -> AgentSession: - """Return the session for ``session_id`` from the current store. + async def get_or_create_session(self, session_id: str) -> AgentSession: + """Return the session for ``session_id``, creating and storing one if missing. Args: session_id: Opaque app-selected session id. - Keyword Args: - alias: Optional additional id to register for the same session in - one call. See :meth:`SessionStore.get`. - Returns: - The cached or newly created ``AgentSession``. + The stored or newly created ``AgentSession``. """ - store = await self.get_session_store() - return await store.get(session_id, alias=alias) + session = await self._session_store.get(session_id) + if session is None: + target = await self.get_target() + session = target.create_session(session_id=session_id) + await self._session_store.set(session_id, session) + return session - async def reset_session(self, session_id: str) -> None: - """Forget the current session for ``session_id``. + +class WorkflowState(Generic[WorkflowT]): + """Shared execution state for app-owned workflow hosting routes. + + Holds the Agent Framework workflow target and a :class:`CheckpointStore` + that route code may share. Does not own routes, middleware, protocol + dispatch, or native SDK calls -- web frameworks keep those concerns. + """ + + def __init__( + self, + target: WorkflowT | SupportsBuild | Awaitable[WorkflowT] | Callable[[], WorkflowT | Awaitable[WorkflowT]], + *, + checkpoint_store: CheckpointStore | None = None, + cache_target: bool = True, + ) -> None: + """Create shared state for ``target``. Args: - session_id: Opaque app-selected session id. + target: Workflow target used by route code. May be a target + instance, a ``WorkflowBuilder``-shaped builder (see + :class:`SupportsBuild`; the state calls ``build()`` for you), + a synchronous factory, an asynchronous factory, or an + awaitable target. + + Keyword Args: + checkpoint_store: Existing store to use. Defaults to a fresh + in-memory :class:`CheckpointStore`. + cache_target: Whether to cache a resolved callable/awaitable/built + target. Defaults to ``True`` so expensive target setup + happens once. + + Raises: + ValueError: If ``cache_target=False`` is used with a one-shot + awaitable target. """ - store = await self.get_session_store() - await store.reset(session_id) + if isinstance(target, SupportsBuild): + # WorkflowBuilder (and the orchestration builders) are not + # themselves callable or awaitable, so normalize to the bound + # `build` method -- the resolution logic below already knows how + # to treat a zero-arg factory. `build()` is typed to return the + # `Workflow` base class rather than this instance's narrower + # `WorkflowT`, but it is the same object the caller asked for. + target = cast("Callable[[], WorkflowT]", target.build) + if not cache_target and inspect.isawaitable(target): + raise ValueError("cache_target=False requires a target instance or callable target factory") + self._target_source = target + self._cache_target = cache_target + self._cached_target: WorkflowT | None = None + if not callable(target) and not inspect.isawaitable(target): + self._cached_target = target + self._checkpoint_store: CheckpointStore = ( + checkpoint_store if checkpoint_store is not None else CheckpointStore() + ) + + async def get_target(self) -> WorkflowT: + """Return the resolved target. + + Returns: + The target instance. Callable and awaitable targets are resolved + first and cached by default. + """ + if self._cache_target and self._cached_target is not None: + return self._cached_target + + target = self._target_source() if callable(self._target_source) else self._target_source + if inspect.isawaitable(target): + target = await target + if self._cache_target: + self._cached_target = target + return target @property - def target(self) -> TargetT: + def target(self) -> WorkflowT: """Return a synchronously available target. Raises: @@ -269,22 +375,30 @@ def target(self) -> TargetT: raise RuntimeError("target is resolved asynchronously; use `await state.get_target()`") @property - def session_store(self) -> SessionStore | None: - """Return a synchronously available session store, if one is cached.""" - return self._cached_session_store - - def _init_session_store( - self, - target: TargetT, - session_store: SessionStore | type[SessionStore] | SessionStoreFactory | None, - ) -> SessionStore: - if isinstance(session_store, SessionStore): - return session_store - - if not isinstance(target, SupportsAgentRun): - raise TypeError("session_store requires an agent target that supports create_session") + def checkpoint_store(self) -> CheckpointStore: + """Return the checkpoint store for this state.""" + return self._checkpoint_store + + async def get_or_create_checkpoint_storage(self, session_id: str) -> CheckpointStorage: + """Return the checkpoint storage for ``session_id``, creating and storing one if missing. + + Unlike an agent, a ``Workflow`` has no ``create_session``-style + factory method, so "creating" one for a new ``session_id`` means + allocating a fresh, empty :class:`InMemoryCheckpointStorage` -- there + is nothing to restore yet. Pass the returned storage into + ``workflow.run(checkpoint_storage=...)``. To resume a prior run for + this ``session_id`` instead of starting fresh, call + ``storage.get_latest(workflow_name=...)`` yourself first and pass its + ``checkpoint_id`` into ``workflow.run(checkpoint_id=..., checkpoint_storage=...)``. - if session_store is None: - return SessionStore(target) + Args: + session_id: Opaque app-selected session id. - return session_store(target) + Returns: + The stored or newly created ``CheckpointStorage``. + """ + storage = await self._checkpoint_store.get(session_id) + if storage is None: + storage = InMemoryCheckpointStorage() + await self._checkpoint_store.set(session_id, storage) + return storage diff --git a/python/packages/hosting/tests/hosting/_workflow_fixtures.py b/python/packages/hosting/tests/hosting/_workflow_fixtures.py index d797e743d92..72addb7dff8 100644 --- a/python/packages/hosting/tests/hosting/_workflow_fixtures.py +++ b/python/packages/hosting/tests/hosting/_workflow_fixtures.py @@ -32,6 +32,11 @@ def build_echo_workflow() -> Workflow: return WorkflowBuilder(start_executor=_EchoExecutor(id="echo")).build() +def echo_workflow_builder() -> WorkflowBuilder: + """Return an *unbuilt* echo ``WorkflowBuilder``, for testing builder-shaped targets.""" + return WorkflowBuilder(start_executor=_EchoExecutor(id="echo")) + + class _MultiChunkExecutor(Executor): """Yields three separate ``output`` events so streaming has something to chew on.""" diff --git a/python/packages/hosting/tests/hosting/test_state.py b/python/packages/hosting/tests/hosting/test_state.py index f920803bd2c..f94afdaf65d 100644 --- a/python/packages/hosting/tests/hosting/test_state.py +++ b/python/packages/hosting/tests/hosting/test_state.py @@ -13,12 +13,13 @@ AgentRunInputs, AgentSession, Content, + InMemoryCheckpointStorage, Message, ResponseStream, Workflow, ) -from agent_framework_hosting import AgentFrameworkState, SessionStore +from agent_framework_hosting import AgentState, CheckpointStore, SessionStore, WorkflowState def _workflow_fixture(name: str) -> Any: @@ -100,90 +101,112 @@ async def _get_response() -> AgentResponse[Any]: class TestSessionStore: - async def test_get_reuses_session_for_same_id(self) -> None: - agent = _FakeAgent() - store = SessionStore(agent) + async def test_get_returns_none_for_missing_id(self) -> None: + store = SessionStore() - first = await store.get("session-1") - second = await store.get("session-1") + assert await store.get("session-1") is None - assert first is second - assert first.session_id == "session-1" - assert len(agent.created_sessions) == 1 + async def test_set_then_get_returns_stored_session(self) -> None: + store = SessionStore() + session = AgentSession(session_id="session-1") - async def test_reset_forgets_session(self) -> None: - agent = _FakeAgent() - store = SessionStore(agent) + await store.set("session-1", session) - first = await store.get("session-1") - await store.reset("session-1") - second = await store.get("session-1") + assert await store.get("session-1") is session - assert first is not second - assert len(agent.created_sessions) == 2 + async def test_set_can_store_same_session_under_additional_id(self) -> None: + store = SessionStore() + session = AgentSession(session_id="resp_1") + + await store.set("resp_1", session) + await store.set("resp_2", session) + + assert await store.get("resp_1") is session + assert await store.get("resp_2") is session + + async def test_set_replaces_existing_entry(self) -> None: + store = SessionStore() + first = AgentSession(session_id="session-1") + second = AgentSession(session_id="session-1") + + await store.set("session-1", first) + await store.set("session-1", second) + + assert await store.get("session-1") is second + + async def test_delete_forgets_session(self) -> None: + store = SessionStore() + await store.set("session-1", AgentSession(session_id="session-1")) + + await store.delete("session-1") + + assert await store.get("session-1") is None + + async def test_delete_missing_id_is_a_no_op(self) -> None: + store = SessionStore() + + await store.delete("never-stored") async def test_empty_session_id_raises(self) -> None: - store = SessionStore(_FakeAgent()) + store = SessionStore() + session = AgentSession(session_id="session-1") with pytest.raises(ValueError, match="session_id"): await store.get("") with pytest.raises(ValueError, match="session_id"): - await store.reset("") + await store.set("", session) + with pytest.raises(ValueError, match="session_id"): + await store.delete("") - async def test_put_aliases_new_id_to_existing_session(self) -> None: - agent = _FakeAgent() - store = SessionStore(agent) - session = await store.get("resp_1") - aliased = await store.get("resp_1", alias="resp_2") +class TestCheckpointStore: + async def test_get_returns_none_for_missing_id(self) -> None: + store = CheckpointStore() - assert aliased is session - assert await store.get("resp_2") is session - # Aliasing did not create a second session via the agent. - assert len(agent.created_sessions) == 1 + assert await store.get("session-1") is None - async def test_alias_equal_to_session_id_is_a_no_op(self) -> None: - agent = _FakeAgent() - store = SessionStore(agent) + async def test_set_then_get_returns_stored_storage(self) -> None: + store = CheckpointStore() + storage = InMemoryCheckpointStorage() - session = await store.get("resp_1", alias="resp_1") + await store.set("session-1", storage) - assert session.session_id == "resp_1" - assert len(agent.created_sessions) == 1 + assert await store.get("session-1") is storage + + async def test_delete_forgets_storage(self) -> None: + store = CheckpointStore() + await store.set("session-1", InMemoryCheckpointStorage()) - async def test_put_empty_session_id_raises(self) -> None: - store = SessionStore(_FakeAgent()) + await store.delete("session-1") + assert await store.get("session-1") is None + + async def test_empty_session_id_raises(self) -> None: + store = CheckpointStore() + storage = InMemoryCheckpointStorage() + + with pytest.raises(ValueError, match="session_id"): + await store.get("") with pytest.raises(ValueError, match="session_id"): - await store.get("", alias="resp_2") + await store.set("", storage) + with pytest.raises(ValueError, match="session_id"): + await store.delete("") -class TestAgentFrameworkState: - def test_default_session_store_for_agent(self) -> None: +class TestAgentState: + def test_default_session_store_is_fresh_in_memory_store(self) -> None: agent = _FakeAgent() - state = AgentFrameworkState(agent) + state = AgentState(agent) assert state.target is agent assert isinstance(state.session_store, SessionStore) def test_accepts_session_store_instance(self) -> None: - agent = _FakeAgent() - store = SessionStore(agent) - state = AgentFrameworkState(agent, session_store=store) + store = SessionStore() + state = AgentState(_FakeAgent(), session_store=store) - assert state.target is agent assert state.session_store is store - def test_accepts_session_store_factory(self) -> None: - agent = _FakeAgent() - - def factory(target: Any) -> SessionStore: - return SessionStore(target) - - state = AgentFrameworkState(agent, session_store=factory) - - assert isinstance(state.session_store, SessionStore) - async def test_callable_target_cached_by_default(self) -> None: calls = 0 @@ -192,7 +215,7 @@ def create_agent() -> _FakeAgent: calls += 1 return _FakeAgent() - state = AgentFrameworkState(create_agent) + state = AgentState(create_agent) first = await state.get_target() second = await state.get_target() @@ -208,7 +231,7 @@ def create_agent() -> _FakeAgent: calls += 1 return _FakeAgent() - state = AgentFrameworkState(create_agent, cache_target=False) + state = AgentState(create_agent, cache_target=False) first = await state.get_target() second = await state.get_target() @@ -220,47 +243,117 @@ async def test_async_callable_target(self) -> None: async def create_agent() -> _FakeAgent: return _FakeAgent() - state = AgentFrameworkState(create_agent) + state = AgentState(create_agent) assert isinstance(await state.get_target(), _FakeAgent) - async def test_get_session_resolves_target_and_store(self) -> None: - state = AgentFrameworkState(lambda: _FakeAgent()) + def test_cache_target_false_rejects_bare_awaitable(self) -> None: + async def create_agent() -> _FakeAgent: + return _FakeAgent() + + coro = create_agent() + try: + with pytest.raises(ValueError, match="cache_target=False"): + AgentState(coro, cache_target=False) + finally: + coro.close() + + async def test_get_or_create_session_creates_and_stores_once(self) -> None: + agent = _FakeAgent() + state = AgentState(agent) - session = await state.get_session("session-1") + first = await state.get_or_create_session("session-1") + second = await state.get_or_create_session("session-1") - assert session.session_id == "session-1" + assert first is second + assert first.session_id == "session-1" + assert len(agent.created_sessions) == 1 - async def test_reset_session_forgets_session(self) -> None: + async def test_get_or_create_session_reuses_a_session_set_directly_on_the_store(self) -> None: agent = _FakeAgent() - state = AgentFrameworkState(agent) + state = AgentState(agent) + pre_existing = AgentSession(session_id="session-1") + await state.session_store.set("session-1", pre_existing) - first = await state.get_session("session-1") - await state.reset_session("session-1") - second = await state.get_session("session-1") + session = await state.get_or_create_session("session-1") + + assert session is pre_existing + assert len(agent.created_sessions) == 0 - assert first is not second - assert len(agent.created_sessions) == 2 - def test_session_store_for_non_agent_target_raises_type_error(self) -> None: +class TestWorkflowState: + def test_default_checkpoint_store_is_fresh_in_memory_store(self) -> None: workflow = _workflow_fixture("build_echo_workflow")() + state: WorkflowState[Workflow] = WorkflowState(workflow) - with pytest.raises(TypeError, match="session_store requires an agent target"): - AgentFrameworkState(workflow, session_store=SessionStore) + assert state.target is workflow + assert isinstance(state.checkpoint_store, CheckpointStore) - async def test_workflow_target_has_no_default_session_store(self) -> None: - workflow: Workflow = _workflow_fixture("build_echo_workflow")() - state = AgentFrameworkState(workflow) + def test_accepts_checkpoint_store_instance(self) -> None: + workflow = _workflow_fixture("build_echo_workflow")() + store = CheckpointStore() + state: WorkflowState[Workflow] = WorkflowState(workflow, checkpoint_store=store) - assert await state.get_target() is workflow - assert state.session_store is None - with pytest.raises(TypeError, match="session_store requires an agent target"): - await state.get_session_store() + assert state.checkpoint_store is store async def test_workflow_target_resolved_from_factory(self) -> None: build_echo_workflow = _workflow_fixture("build_echo_workflow") - state = AgentFrameworkState(build_echo_workflow) + state: WorkflowState[Workflow] = WorkflowState(build_echo_workflow) target = await state.get_target() assert isinstance(target, Workflow) + + async def test_accepts_workflow_builder_instance_directly(self) -> None: + """A ``WorkflowBuilder`` is not itself callable or awaitable; the state must + recognize its `build()` method and call it, not cache the raw builder.""" + builder = _workflow_fixture("echo_workflow_builder")() + + state: WorkflowState[Workflow] = WorkflowState(builder) + + target = await state.get_target() + assert isinstance(target, Workflow) + assert state.target is target + + async def test_workflow_builder_is_built_once_and_cached_by_default(self) -> None: + builder = _workflow_fixture("echo_workflow_builder")() + state: WorkflowState[Workflow] = WorkflowState(builder) + + first = await state.get_target() + second = await state.get_target() + + assert first is second + + async def test_accepts_orchestration_style_builder_without_importing_orchestrations(self) -> None: + """``SupportsBuild`` is structural: any object with a zero-arg ``build() -> Workflow`` + is accepted, matching ``agent_framework_orchestrations``' builders without this + package depending on that one.""" + workflow = _workflow_fixture("build_echo_workflow")() + + class _FakeOrchestrationBuilder: + def build(self) -> Workflow: + return workflow + + state: WorkflowState[Workflow] = WorkflowState(_FakeOrchestrationBuilder()) + + assert await state.get_target() is workflow + + async def test_get_or_create_checkpoint_storage_creates_and_stores_once(self) -> None: + workflow = _workflow_fixture("build_echo_workflow")() + state: WorkflowState[Workflow] = WorkflowState(workflow) + + first = await state.get_or_create_checkpoint_storage("session-1") + second = await state.get_or_create_checkpoint_storage("session-1") + + assert first is second + assert isinstance(first, InMemoryCheckpointStorage) + + async def test_get_or_create_checkpoint_storage_reuses_storage_set_directly_on_the_store(self) -> None: + workflow = _workflow_fixture("build_echo_workflow")() + state: WorkflowState[Workflow] = WorkflowState(workflow) + pre_existing = InMemoryCheckpointStorage() + await state.checkpoint_store.set("session-1", pre_existing) + + storage = await state.get_or_create_checkpoint_storage("session-1") + + assert storage is pre_existing diff --git a/python/samples/04-hosting/af-hosting/local_responses/README.md b/python/samples/04-hosting/af-hosting/local_responses/README.md index 943c040d5db..24b552e117d 100644 --- a/python/samples/04-hosting/af-hosting/local_responses/README.md +++ b/python/samples/04-hosting/af-hosting/local_responses/README.md @@ -21,8 +21,9 @@ What the route demonstrates: turn. - Produces the AF messages, options, and session id that the route passes to `agent.run(...)`. -- **Aliases** each newly minted response id to the session it was just - resolved from, via `state.get_session(lookup_id, alias=response_id)`. +- **Stores** each newly minted response id for the session it was just + resolved from, via `state.session_store.set(response_id, session)` after + `agent.run(...)` has updated the session. OpenAI's `previous_response_id` rotates every turn *by design* — it lets a caller continue from any earlier response, not just the latest one — so every response id needs to stay independently resolvable, not just the 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 3b692ec98d9..9aec83fb452 100644 --- a/python/samples/04-hosting/af-hosting/local_responses/app.py +++ b/python/samples/04-hosting/af-hosting/local_responses/app.py @@ -7,7 +7,7 @@ 1. ``agent-framework-hosting-responses`` converts Responses request/response payloads to and from Agent Framework run values. 2. ``agent-framework-hosting`` owns shared execution state via - ``AgentFrameworkState`` and ``SessionStore``. + ``AgentState`` and ``SessionStore``. 3. FastAPI owns the route, request parsing, policy decisions, and response object. @@ -35,12 +35,13 @@ import asyncio import os +from collections.abc import AsyncIterator from pathlib import Path from typing import Annotated, Any, cast from agent_framework import Agent, FileHistoryProvider, ResponseStream, tool from agent_framework_foundry import FoundryChatClient -from agent_framework_hosting import AgentFrameworkState, SessionStore +from agent_framework_hosting import AgentState from agent_framework_hosting_responses import ( create_response_id, responses_from_run, @@ -88,7 +89,7 @@ def create_agent() -> Agent: app = FastAPI() -state = AgentFrameworkState(create_agent, session_store=SessionStore) +state = AgentState(create_agent) @app.post("/responses", response_model=None) @@ -110,13 +111,9 @@ async def responses(body: dict[str, Any] = Body(...)) -> JSONResponse | Streamin options["reasoning"] = {"effort": "medium", "summary": "auto"} options_for_run = cast(Any, options) - target = cast(Agent[Any], await state.get_target()) + target = await state.get_target() lookup_id = session_id or response_id - # `previous_response_id` chaining rotates its id every turn, and OpenAI's - # Responses API deliberately lets a caller continue from *any* earlier - # response, not just the latest one -- so the newly minted response id - # also needs to resolve back to this session on a later request. - session = await state.get_session(lookup_id, alias=response_id) + session = await state.get_or_create_session(lookup_id) if run["stream"]: stream = target.run( run["messages"], @@ -126,12 +123,22 @@ async def responses(body: dict[str, Any] = Body(...)) -> JSONResponse | Streamin ) if not isinstance(stream, ResponseStream): raise HTTPException(status_code=500, detail="agent did not return a response stream") - return StreamingResponse( - responses_stream_events_from_run( + + async def stream_events() -> AsyncIterator[str]: + async for event in responses_stream_events_from_run( stream, response_id=response_id, session_id=session_id, - ), + ): + yield event + # `agent.run(..., stream=True)` updates the session while the stream + # is consumed/finalized. Store it under the newly minted response id + # after finalization so a later `previous_response_id` can restore + # this exact continuation point. + await state.session_store.set(response_id, session) + + return StreamingResponse( + stream_events(), media_type="text/event-stream", ) @@ -140,6 +147,10 @@ async def responses(body: dict[str, Any] = Body(...)) -> JSONResponse | Streamin session=session, options=options_for_run, ) + # `agent.run(...)` updates the session. Store it under the newly minted + # response id after the run so `previous_response_id=response_id` continues + # from this exact point. + await state.session_store.set(response_id, session) return JSONResponse( responses_from_run( result, From 114b1f7043d61d3fc1a31471b6f33f09ecfab1ed Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Fri, 3 Jul 2026 11:34:14 +0200 Subject: [PATCH 05/15] Fix hosting state test protocol fakes Widen fake agents' get_session service_session_id parameter to match the SupportsAgentRun protocol under the Python 3.11 test typing checkers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tests/hosting_responses/test_http_round_trip.py | 2 +- python/packages/hosting/tests/hosting/test_state.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/packages/hosting-responses/tests/hosting_responses/test_http_round_trip.py b/python/packages/hosting-responses/tests/hosting_responses/test_http_round_trip.py index f1efb1616bc..5eefcd3d4f3 100644 --- a/python/packages/hosting-responses/tests/hosting_responses/test_http_round_trip.py +++ b/python/packages/hosting-responses/tests/hosting_responses/test_http_round_trip.py @@ -60,7 +60,7 @@ def __init__(self) -> None: def create_session(self, *, session_id: str | None = None) -> AgentSession: return AgentSession(session_id=session_id) - def get_session(self, service_session_id: str, *, session_id: str | None = None) -> AgentSession: + def get_session(self, service_session_id: Any, *, session_id: str | None = None) -> AgentSession: return AgentSession(session_id=session_id, service_session_id=service_session_id) @overload diff --git a/python/packages/hosting/tests/hosting/test_state.py b/python/packages/hosting/tests/hosting/test_state.py index f94afdaf65d..121bb2c76cd 100644 --- a/python/packages/hosting/tests/hosting/test_state.py +++ b/python/packages/hosting/tests/hosting/test_state.py @@ -53,7 +53,7 @@ def create_session(self, *, session_id: str | None = None) -> AgentSession: self.created_sessions.append(session) return session - def get_session(self, service_session_id: str, *, session_id: str | None = None) -> AgentSession: + def get_session(self, service_session_id: Any, *, session_id: str | None = None) -> AgentSession: return AgentSession(session_id=session_id, service_session_id=service_session_id) @overload From 78c6684cc6562c1e6b6ae6b6ad3c800ba7f1d7bd Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Fri, 3 Jul 2026 14:47:50 +0200 Subject: [PATCH 06/15] Simplify Responses stream helper naming Rename responses_stream_events_from_run to responses_stream_from_run across exports, tests, docs, and the local Responses sample to align with the generic _stream_from_run helper convention. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/hosting-responses/README.md | 2 +- .../agent_framework_hosting_responses/__init__.py | 4 ++-- .../agent_framework_hosting_responses/_parsing.py | 4 ++-- .../tests/hosting_responses/test_http_round_trip.py | 4 ++-- .../tests/hosting_responses/test_parsing.py | 6 +++--- python/samples/04-hosting/af-hosting/local_responses/app.py | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/python/packages/hosting-responses/README.md b/python/packages/hosting-responses/README.md index dd6a121a30e..5ba00d2f470 100644 --- a/python/packages/hosting-responses/README.md +++ b/python/packages/hosting-responses/README.md @@ -11,7 +11,7 @@ This package provides the Responses-specific conversion layer: - `create_response_id(...)` — mint a Responses-shaped response id. - `responses_from_run(...)` — convert an `AgentResponse` into a Responses-compatible JSON payload. -- `responses_stream_events_from_run(...)` — convert an Agent Framework +- `responses_stream_from_run(...)` — convert an Agent Framework `ResponseStream` into Responses-compatible SSE events. FastAPI/Starlette/Django/Azure Functions code owns route registration, diff --git a/python/packages/hosting-responses/agent_framework_hosting_responses/__init__.py b/python/packages/hosting-responses/agent_framework_hosting_responses/__init__.py index eea77e876a8..26ee8da69e7 100644 --- a/python/packages/hosting-responses/agent_framework_hosting_responses/__init__.py +++ b/python/packages/hosting-responses/agent_framework_hosting_responses/__init__.py @@ -12,7 +12,7 @@ parse_responses_request, responses_from_run, responses_session_id, - responses_stream_events_from_run, + responses_stream_from_run, responses_to_run, ) @@ -30,6 +30,6 @@ "parse_responses_request", "responses_from_run", "responses_session_id", - "responses_stream_events_from_run", + "responses_stream_from_run", "responses_to_run", ] 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 c391aeb979a..9af4a1c9fb4 100644 --- a/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py +++ b/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py @@ -884,7 +884,7 @@ def _json_dumps(payload: Mapping[str, Any]) -> str: return json.dumps(payload, separators=(",", ":")) -async def responses_stream_events_from_run( +async def responses_stream_from_run( stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]], *, response_id: str, @@ -954,6 +954,6 @@ async def responses_stream_events_from_run( "parse_responses_request", "responses_from_run", "responses_session_id", - "responses_stream_events_from_run", + "responses_stream_from_run", "responses_to_run", ] diff --git a/python/packages/hosting-responses/tests/hosting_responses/test_http_round_trip.py b/python/packages/hosting-responses/tests/hosting_responses/test_http_round_trip.py index 5eefcd3d4f3..96ea1663ca6 100644 --- a/python/packages/hosting-responses/tests/hosting_responses/test_http_round_trip.py +++ b/python/packages/hosting-responses/tests/hosting_responses/test_http_round_trip.py @@ -35,7 +35,7 @@ create_response_id, responses_from_run, responses_session_id, - responses_stream_events_from_run, + responses_stream_from_run, responses_to_run, ) @@ -136,7 +136,7 @@ async def responses(body: dict[str, Any] = Body(...)) -> JSONResponse | Streamin raise HTTPException(status_code=500, detail="agent did not return a response stream") async def stream_events() -> AsyncIterator[str]: - async for event in responses_stream_events_from_run( + async for event in responses_stream_from_run( stream, response_id=response_id, session_id=session_id, diff --git a/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py b/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py index 5aaebf4b5fe..6fa100415c1 100644 --- a/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py +++ b/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py @@ -17,7 +17,7 @@ parse_responses_request, responses_from_run, responses_session_id, - responses_stream_events_from_run, + responses_stream_from_run, responses_to_run, ) @@ -275,7 +275,7 @@ def test_responses_from_run_omits_previous_response_session(self) -> None: assert "conversation" not in payload - async def test_responses_stream_events_from_run(self) -> None: + async def test_responses_stream_from_run(self) -> None: async def updates() -> AsyncIterator[AgentResponseUpdate]: yield AgentResponseUpdate(contents=[Content.from_text("hel")], role="assistant") yield AgentResponseUpdate(contents=[Content.from_text("lo")], role="assistant") @@ -287,7 +287,7 @@ def finalizer(items: Sequence[AgentResponseUpdate]) -> AgentResponse: events = [ event - async for event in responses_stream_events_from_run( + async for event in responses_stream_from_run( stream, response_id="resp_new", session_id="conv_1", 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 9aec83fb452..ef91927cbf2 100644 --- a/python/samples/04-hosting/af-hosting/local_responses/app.py +++ b/python/samples/04-hosting/af-hosting/local_responses/app.py @@ -46,7 +46,7 @@ create_response_id, responses_from_run, responses_session_id, - responses_stream_events_from_run, + responses_stream_from_run, responses_to_run, ) from azure.identity.aio import DefaultAzureCredential @@ -125,7 +125,7 @@ async def responses(body: dict[str, Any] = Body(...)) -> JSONResponse | Streamin raise HTTPException(status_code=500, detail="agent did not return a response stream") async def stream_events() -> AsyncIterator[str]: - async for event in responses_stream_events_from_run( + async for event in responses_stream_from_run( stream, response_id=response_id, session_id=session_id, From ecc9017989bcd983bda9ab34fecefac382e55f92 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Fri, 3 Jul 2026 15:06:06 +0200 Subject: [PATCH 07/15] Add state-level storage setters Add AgentState.set_session and WorkflowState.set_checkpoint_storage so app code can pair get-or-create helpers with explicit post-run storage without reaching into the underlying stores. Update Responses docs, tests, and sample to use state.set_session. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/hosting-responses/README.md | 2 +- .../hosting_responses/test_http_round_trip.py | 4 ++-- python/packages/hosting/README.md | 2 +- .../hosting/agent_framework_hosting/_state.py | 18 ++++++++++++++++++ .../hosting/tests/hosting/test_state.py | 8 ++++---- .../af-hosting/local_responses/README.md | 2 +- .../af-hosting/local_responses/app.py | 4 ++-- 7 files changed, 29 insertions(+), 11 deletions(-) diff --git a/python/packages/hosting-responses/README.md b/python/packages/hosting-responses/README.md index 5ba00d2f470..47246f02252 100644 --- a/python/packages/hosting-responses/README.md +++ b/python/packages/hosting-responses/README.md @@ -43,7 +43,7 @@ async def responses(body: dict = Body(...)) -> JSONResponse: session=session, options=run["options"], ) - await state.session_store.set(response_id, session) + await state.set_session(response_id, session) return JSONResponse(responses_from_run(result, response_id=response_id, session_id=session_id)) ``` diff --git a/python/packages/hosting-responses/tests/hosting_responses/test_http_round_trip.py b/python/packages/hosting-responses/tests/hosting_responses/test_http_round_trip.py index 96ea1663ca6..8c0039228cb 100644 --- a/python/packages/hosting-responses/tests/hosting_responses/test_http_round_trip.py +++ b/python/packages/hosting-responses/tests/hosting_responses/test_http_round_trip.py @@ -142,7 +142,7 @@ async def stream_events() -> AsyncIterator[str]: session_id=session_id, ): yield event - await state.session_store.set(response_id, session) + await state.set_session(response_id, session) return StreamingResponse( stream_events(), @@ -150,7 +150,7 @@ async def stream_events() -> AsyncIterator[str]: ) result = await target.run(run["messages"], session=session) - await state.session_store.set(response_id, session) + await state.set_session(response_id, session) return JSONResponse(responses_from_run(result, response_id=response_id, session_id=session_id)) return app diff --git a/python/packages/hosting/README.md b/python/packages/hosting/README.md index 5217ac39e94..096762fa0c4 100644 --- a/python/packages/hosting/README.md +++ b/python/packages/hosting/README.md @@ -51,7 +51,7 @@ the post-run object: ```python session = await state.get_or_create_session(previous_response_id) result = await (await state.get_target()).run("Hello", session=session) -await state.session_store.set(response_id, session) +await state.set_session(response_id, session) ``` Targets can be direct instances, synchronous factories, asynchronous factories, diff --git a/python/packages/hosting/agent_framework_hosting/_state.py b/python/packages/hosting/agent_framework_hosting/_state.py index a16a30c4706..bbe07d965c6 100644 --- a/python/packages/hosting/agent_framework_hosting/_state.py +++ b/python/packages/hosting/agent_framework_hosting/_state.py @@ -288,6 +288,15 @@ async def get_or_create_session(self, session_id: str) -> AgentSession: await self._session_store.set(session_id, session) return session + async def set_session(self, session_id: str, session: AgentSession) -> None: + """Store ``session`` under ``session_id`` in this state's session store. + + Args: + session_id: Opaque app-selected session id. + session: Session to store. + """ + await self._session_store.set(session_id, session) + class WorkflowState(Generic[WorkflowT]): """Shared execution state for app-owned workflow hosting routes. @@ -402,3 +411,12 @@ async def get_or_create_checkpoint_storage(self, session_id: str) -> CheckpointS storage = InMemoryCheckpointStorage() await self._checkpoint_store.set(session_id, storage) return storage + + async def set_checkpoint_storage(self, session_id: str, storage: CheckpointStorage) -> None: + """Store ``storage`` under ``session_id`` in this state's checkpoint store. + + Args: + session_id: Opaque app-selected session id. + storage: Checkpoint storage to store. + """ + await self._checkpoint_store.set(session_id, storage) diff --git a/python/packages/hosting/tests/hosting/test_state.py b/python/packages/hosting/tests/hosting/test_state.py index 121bb2c76cd..6f34d214134 100644 --- a/python/packages/hosting/tests/hosting/test_state.py +++ b/python/packages/hosting/tests/hosting/test_state.py @@ -269,11 +269,11 @@ async def test_get_or_create_session_creates_and_stores_once(self) -> None: assert first.session_id == "session-1" assert len(agent.created_sessions) == 1 - async def test_get_or_create_session_reuses_a_session_set_directly_on_the_store(self) -> None: + async def test_get_or_create_session_reuses_a_session_set_on_the_state(self) -> None: agent = _FakeAgent() state = AgentState(agent) pre_existing = AgentSession(session_id="session-1") - await state.session_store.set("session-1", pre_existing) + await state.set_session("session-1", pre_existing) session = await state.get_or_create_session("session-1") @@ -348,11 +348,11 @@ async def test_get_or_create_checkpoint_storage_creates_and_stores_once(self) -> assert first is second assert isinstance(first, InMemoryCheckpointStorage) - async def test_get_or_create_checkpoint_storage_reuses_storage_set_directly_on_the_store(self) -> None: + async def test_get_or_create_checkpoint_storage_reuses_storage_set_on_the_state(self) -> None: workflow = _workflow_fixture("build_echo_workflow")() state: WorkflowState[Workflow] = WorkflowState(workflow) pre_existing = InMemoryCheckpointStorage() - await state.checkpoint_store.set("session-1", pre_existing) + await state.set_checkpoint_storage("session-1", pre_existing) storage = await state.get_or_create_checkpoint_storage("session-1") diff --git a/python/samples/04-hosting/af-hosting/local_responses/README.md b/python/samples/04-hosting/af-hosting/local_responses/README.md index 24b552e117d..9c1c2e8d163 100644 --- a/python/samples/04-hosting/af-hosting/local_responses/README.md +++ b/python/samples/04-hosting/af-hosting/local_responses/README.md @@ -22,7 +22,7 @@ What the route demonstrates: - Produces the AF messages, options, and session id that the route passes to `agent.run(...)`. - **Stores** each newly minted response id for the session it was just - resolved from, via `state.session_store.set(response_id, session)` after + resolved from, via `state.set_session(response_id, session)` after `agent.run(...)` has updated the session. OpenAI's `previous_response_id` rotates every turn *by design* — it lets a caller continue from any earlier response, not just the latest one — so 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 ef91927cbf2..c55c8c84079 100644 --- a/python/samples/04-hosting/af-hosting/local_responses/app.py +++ b/python/samples/04-hosting/af-hosting/local_responses/app.py @@ -135,7 +135,7 @@ async def stream_events() -> AsyncIterator[str]: # is consumed/finalized. Store it under the newly minted response id # after finalization so a later `previous_response_id` can restore # this exact continuation point. - await state.session_store.set(response_id, session) + await state.set_session(response_id, session) return StreamingResponse( stream_events(), @@ -150,7 +150,7 @@ async def stream_events() -> AsyncIterator[str]: # `agent.run(...)` updates the session. Store it under the newly minted # response id after the run so `previous_response_id=response_id` continues # from this exact point. - await state.session_store.set(response_id, session) + await state.set_session(response_id, session) return JSONResponse( responses_from_run( result, From efc65a78c8315b454236980e32c6a966104360a1 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Mon, 6 Jul 2026 12:00:42 +0200 Subject: [PATCH 08/15] Simplify WorkflowState checkpoint handling Remove CheckpointStore from WorkflowState so workflow checkpointing uses the existing CheckpointStorage abstraction directly. Keep WorkflowState focused on resolving workflow targets, including builders, and update hosting docs/tests accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/hosting/README.md | 34 ++--- .../agent_framework_hosting/__init__.py | 2 - .../hosting/agent_framework_hosting/_state.py | 126 ++---------------- .../hosting/tests/hosting/test_state.py | 67 +--------- 4 files changed, 30 insertions(+), 199 deletions(-) diff --git a/python/packages/hosting/README.md b/python/packages/hosting/README.md index 096762fa0c4..00a29678c82 100644 --- a/python/packages/hosting/README.md +++ b/python/packages/hosting/README.md @@ -6,14 +6,16 @@ This package keeps Agent Framework state separate from web-framework concerns: - `AgentState` — pairs an agent target with a `SessionStore` (`session_id -> AgentSession`). -- `WorkflowState` — pairs a workflow target with a `CheckpointStore` - (`session_id -> CheckpointStorage`). +- `WorkflowState` — resolves a workflow target, including direct `Workflow` + instances, workflow factories, `WorkflowBuilder`, and orchestration builders. -Both stores are plain storage: `get`/`set`/`delete` by an app-selected id, -nothing more. Neither one knows how to create a new value for an id it -hasn't seen before — use `AgentState.get_or_create_session(...)` / -`WorkflowState.get_or_create_checkpoint_storage(...)` for that, since only -the state object has both the store and the resolved target. +`SessionStore` is plain storage: `get`/`set`/`delete` by an app-selected id, +nothing more. It does not know how to create a new value for an id it hasn't +seen before — use `AgentState.get_or_create_session(...)` for that, since only +the state object has both the store and the resolved target. Workflow +checkpointing should use the existing `CheckpointStorage` abstraction directly; +if an app needs per-session resume, keep a small app-owned cursor such as +`session_id -> checkpoint_id`. - Existing experimental channel-hosting types remain available while the package is unreleased, but the v1 direction is protocol helpers plus app-owned routes. @@ -21,15 +23,15 @@ the state object has both the store and the resolved target. Use FastAPI, Starlette, Azure Functions, Django, or another framework for route registration, auth, middleware, response construction, and background work. -> The built-in `SessionStore` / `CheckpointStore` are in-memory `dict`s with -> no eviction — every id ever stored stays resolvable for the life of the -> process. That is intentional: protocols such as OpenAI Responses' +> The built-in `SessionStore` is an in-memory `dict` with no eviction — every +> id ever stored stays resolvable for the life of the process. That is +> intentional: protocols such as OpenAI Responses' > `previous_response_id` are designed to let a caller continue from *any* > earlier point in a conversation, not just the latest turn, so every id -> handed out needs to stay independently resolvable. If you back either -> store with real storage (Redis, a database, ...), you are responsible for -> that store's own TTL/eviction policy; these in-memory reference -> implementations do not model that concern. +> handed out needs to stay independently resolvable. If you back the store +> with real storage (Redis, a database, ...), you are responsible for that +> store's own TTL/eviction policy; this in-memory reference implementation +> does not model that concern. ## Quickstart @@ -65,11 +67,13 @@ state = AgentState(create_agent, cache_target=False) `WorkflowState` mirrors this shape for workflow targets: ```python +from agent_framework import InMemoryCheckpointStorage from agent_framework_hosting import WorkflowState state = WorkflowState(create_workflow) -storage = await state.get_or_create_checkpoint_storage("conversation-1") +storage = InMemoryCheckpointStorage() result = await (await state.get_target()).run("Hello", checkpoint_storage=storage) +latest = await storage.get_latest(workflow_name=(await state.get_target()).name) ``` `WorkflowState` also accepts an unbuilt workflow builder directly: diff --git a/python/packages/hosting/agent_framework_hosting/__init__.py b/python/packages/hosting/agent_framework_hosting/__init__.py index 99d98367573..1b6b9e63d88 100644 --- a/python/packages/hosting/agent_framework_hosting/__init__.py +++ b/python/packages/hosting/agent_framework_hosting/__init__.py @@ -23,7 +23,6 @@ from ._state import ( AgentRunArgs, AgentState, - CheckpointStore, SessionStore, SupportsBuild, WorkflowRunArgs, @@ -66,7 +65,6 @@ "ChannelRunHook", "ChannelSession", "ChannelStreamUpdateHook", - "CheckpointStore", "HostStatePaths", "HostedRunResult", "IsolationKeys", diff --git a/python/packages/hosting/agent_framework_hosting/_state.py b/python/packages/hosting/agent_framework_hosting/_state.py index bbe07d965c6..d89a025e009 100644 --- a/python/packages/hosting/agent_framework_hosting/_state.py +++ b/python/packages/hosting/agent_framework_hosting/_state.py @@ -7,13 +7,13 @@ - ``AgentState`` pairs an agent target with a ``SessionStore`` (``session_id -> AgentSession``). -- ``WorkflowState`` pairs a workflow target with a ``CheckpointStore`` - (``session_id -> CheckpointStorage``). +- ``WorkflowState`` resolves a workflow target. Workflow checkpointing uses + the existing ``CheckpointStorage`` abstraction directly; apps can keep their + own ``session_id -> checkpoint_id`` cursor when they need per-session resume. -Both stores are plain storage: they only get/set/delete what they are given. -Neither one knows how to create a new value for a ``session_id`` it hasn't -seen before -- that is the corresponding state object's job (see -``AgentState.get_or_create_session`` / ``WorkflowState.get_or_create_checkpoint_storage``), +``SessionStore`` is plain storage: it only gets/sets/deletes what it is given. +It does not know how to create a new value for a ``session_id`` it hasn't seen +before -- that is ``AgentState``'s job (see ``AgentState.get_or_create_session``), since only the state object has both the store and the resolved target. """ @@ -27,8 +27,6 @@ AgentRunInputs, AgentSession, ChatOptions, - CheckpointStorage, - InMemoryCheckpointStorage, SupportsAgentRun, Workflow, ) @@ -98,68 +96,6 @@ async def delete(self, session_id: str) -> None: self._sessions.pop(session_id, None) -class CheckpointStore: - """Plain in-memory ``session_id -> CheckpointStorage`` lookup. - - Maps an app-selected session id to a :class:`CheckpointStorage` scoped to - that conversation. This store only stores and retrieves; it does not - decide which checkpoint within that storage to resume from. Use - :meth:`WorkflowState.get_or_create_checkpoint_storage` to create a fresh - ``CheckpointStorage`` the first time a given ``session_id`` is seen. - - Resuming a prior run is a separate, run-time decision the route makes: - call ``storage.get_latest(workflow_name=...)`` yourself and pass its - ``checkpoint_id`` into ``workflow.run(checkpoint_id=..., checkpoint_storage=storage)``. - - No eviction, for the same reason as :class:`SessionStore` -- see that - class's docstring. - """ - - def __init__(self) -> None: - """Create an empty checkpoint store.""" - self._storages: dict[str, CheckpointStorage] = {} - - async def get(self, session_id: str) -> CheckpointStorage | None: - """Return the stored checkpoint storage for ``session_id``, or ``None`` if absent. - - Args: - session_id: Opaque app-selected session id. - - Raises: - ValueError: If ``session_id`` is empty. - """ - if not session_id: - raise ValueError("session_id must be a non-empty string") - return self._storages.get(session_id) - - async def set(self, session_id: str, storage: CheckpointStorage) -> None: - """Store ``storage`` under ``session_id``, replacing any existing entry. - - Args: - session_id: Opaque app-selected session id. - storage: The checkpoint storage to store. - - Raises: - ValueError: If ``session_id`` is empty. - """ - if not session_id: - raise ValueError("session_id must be a non-empty string") - self._storages[session_id] = storage - - async def delete(self, session_id: str) -> None: - """Forget the stored checkpoint storage for ``session_id``, if any. - - Args: - session_id: Opaque app-selected session id. - - Raises: - ValueError: If ``session_id`` is empty. - """ - if not session_id: - raise ValueError("session_id must be a non-empty string") - self._storages.pop(session_id, None) - - AgentT = TypeVar("AgentT", bound=SupportsAgentRun) WorkflowT = TypeVar("WorkflowT", bound=Workflow) @@ -301,16 +237,15 @@ async def set_session(self, session_id: str, session: AgentSession) -> None: class WorkflowState(Generic[WorkflowT]): """Shared execution state for app-owned workflow hosting routes. - Holds the Agent Framework workflow target and a :class:`CheckpointStore` - that route code may share. Does not own routes, middleware, protocol - dispatch, or native SDK calls -- web frameworks keep those concerns. + Holds the Agent Framework workflow target. Does not own routes, + middleware, protocol dispatch, native SDK calls, or checkpoint storage -- + web frameworks and workflow/app code keep those concerns. """ def __init__( self, target: WorkflowT | SupportsBuild | Awaitable[WorkflowT] | Callable[[], WorkflowT | Awaitable[WorkflowT]], *, - checkpoint_store: CheckpointStore | None = None, cache_target: bool = True, ) -> None: """Create shared state for ``target``. @@ -323,8 +258,6 @@ def __init__( awaitable target. Keyword Args: - checkpoint_store: Existing store to use. Defaults to a fresh - in-memory :class:`CheckpointStore`. cache_target: Whether to cache a resolved callable/awaitable/built target. Defaults to ``True`` so expensive target setup happens once. @@ -348,9 +281,6 @@ def __init__( self._cached_target: WorkflowT | None = None if not callable(target) and not inspect.isawaitable(target): self._cached_target = target - self._checkpoint_store: CheckpointStore = ( - checkpoint_store if checkpoint_store is not None else CheckpointStore() - ) async def get_target(self) -> WorkflowT: """Return the resolved target. @@ -382,41 +312,3 @@ def target(self) -> WorkflowT: if not callable(self._target_source) and not inspect.isawaitable(self._target_source): return self._target_source raise RuntimeError("target is resolved asynchronously; use `await state.get_target()`") - - @property - def checkpoint_store(self) -> CheckpointStore: - """Return the checkpoint store for this state.""" - return self._checkpoint_store - - async def get_or_create_checkpoint_storage(self, session_id: str) -> CheckpointStorage: - """Return the checkpoint storage for ``session_id``, creating and storing one if missing. - - Unlike an agent, a ``Workflow`` has no ``create_session``-style - factory method, so "creating" one for a new ``session_id`` means - allocating a fresh, empty :class:`InMemoryCheckpointStorage` -- there - is nothing to restore yet. Pass the returned storage into - ``workflow.run(checkpoint_storage=...)``. To resume a prior run for - this ``session_id`` instead of starting fresh, call - ``storage.get_latest(workflow_name=...)`` yourself first and pass its - ``checkpoint_id`` into ``workflow.run(checkpoint_id=..., checkpoint_storage=...)``. - - Args: - session_id: Opaque app-selected session id. - - Returns: - The stored or newly created ``CheckpointStorage``. - """ - storage = await self._checkpoint_store.get(session_id) - if storage is None: - storage = InMemoryCheckpointStorage() - await self._checkpoint_store.set(session_id, storage) - return storage - - async def set_checkpoint_storage(self, session_id: str, storage: CheckpointStorage) -> None: - """Store ``storage`` under ``session_id`` in this state's checkpoint store. - - Args: - session_id: Opaque app-selected session id. - storage: Checkpoint storage to store. - """ - await self._checkpoint_store.set(session_id, storage) diff --git a/python/packages/hosting/tests/hosting/test_state.py b/python/packages/hosting/tests/hosting/test_state.py index 6f34d214134..62ce2067538 100644 --- a/python/packages/hosting/tests/hosting/test_state.py +++ b/python/packages/hosting/tests/hosting/test_state.py @@ -13,13 +13,12 @@ AgentRunInputs, AgentSession, Content, - InMemoryCheckpointStorage, Message, ResponseStream, Workflow, ) -from agent_framework_hosting import AgentState, CheckpointStore, SessionStore, WorkflowState +from agent_framework_hosting import AgentState, SessionStore, WorkflowState def _workflow_fixture(name: str) -> Any: @@ -159,40 +158,6 @@ async def test_empty_session_id_raises(self) -> None: await store.delete("") -class TestCheckpointStore: - async def test_get_returns_none_for_missing_id(self) -> None: - store = CheckpointStore() - - assert await store.get("session-1") is None - - async def test_set_then_get_returns_stored_storage(self) -> None: - store = CheckpointStore() - storage = InMemoryCheckpointStorage() - - await store.set("session-1", storage) - - assert await store.get("session-1") is storage - - async def test_delete_forgets_storage(self) -> None: - store = CheckpointStore() - await store.set("session-1", InMemoryCheckpointStorage()) - - await store.delete("session-1") - - assert await store.get("session-1") is None - - async def test_empty_session_id_raises(self) -> None: - store = CheckpointStore() - storage = InMemoryCheckpointStorage() - - with pytest.raises(ValueError, match="session_id"): - await store.get("") - with pytest.raises(ValueError, match="session_id"): - await store.set("", storage) - with pytest.raises(ValueError, match="session_id"): - await store.delete("") - - class TestAgentState: def test_default_session_store_is_fresh_in_memory_store(self) -> None: agent = _FakeAgent() @@ -282,19 +247,11 @@ async def test_get_or_create_session_reuses_a_session_set_on_the_state(self) -> class TestWorkflowState: - def test_default_checkpoint_store_is_fresh_in_memory_store(self) -> None: + def test_accepts_workflow_target_instance(self) -> None: workflow = _workflow_fixture("build_echo_workflow")() state: WorkflowState[Workflow] = WorkflowState(workflow) assert state.target is workflow - assert isinstance(state.checkpoint_store, CheckpointStore) - - def test_accepts_checkpoint_store_instance(self) -> None: - workflow = _workflow_fixture("build_echo_workflow")() - store = CheckpointStore() - state: WorkflowState[Workflow] = WorkflowState(workflow, checkpoint_store=store) - - assert state.checkpoint_store is store async def test_workflow_target_resolved_from_factory(self) -> None: build_echo_workflow = _workflow_fixture("build_echo_workflow") @@ -337,23 +294,3 @@ def build(self) -> Workflow: state: WorkflowState[Workflow] = WorkflowState(_FakeOrchestrationBuilder()) assert await state.get_target() is workflow - - async def test_get_or_create_checkpoint_storage_creates_and_stores_once(self) -> None: - workflow = _workflow_fixture("build_echo_workflow")() - state: WorkflowState[Workflow] = WorkflowState(workflow) - - first = await state.get_or_create_checkpoint_storage("session-1") - second = await state.get_or_create_checkpoint_storage("session-1") - - assert first is second - assert isinstance(first, InMemoryCheckpointStorage) - - async def test_get_or_create_checkpoint_storage_reuses_storage_set_on_the_state(self) -> None: - workflow = _workflow_fixture("build_echo_workflow")() - state: WorkflowState[Workflow] = WorkflowState(workflow) - pre_existing = InMemoryCheckpointStorage() - await state.set_checkpoint_storage("session-1", pre_existing) - - storage = await state.get_or_create_checkpoint_storage("session-1") - - assert storage is pre_existing From 74b16a104bd71486683417ac6fd69d2a853db445 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 7 Jul 2026 09:36:48 +0200 Subject: [PATCH 09/15] Rename Responses streaming run helper Rename responses_stream_from_run to responses_from_streaming_run across the hosting-responses exports, tests, docs, and local Responses sample. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/hosting-responses/README.md | 2 +- .../agent_framework_hosting_responses/__init__.py | 4 ++-- .../agent_framework_hosting_responses/_parsing.py | 4 ++-- .../tests/hosting_responses/test_http_round_trip.py | 4 ++-- .../tests/hosting_responses/test_parsing.py | 6 +++--- python/samples/04-hosting/af-hosting/local_responses/app.py | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/python/packages/hosting-responses/README.md b/python/packages/hosting-responses/README.md index 47246f02252..797fcd5ca7d 100644 --- a/python/packages/hosting-responses/README.md +++ b/python/packages/hosting-responses/README.md @@ -11,7 +11,7 @@ This package provides the Responses-specific conversion layer: - `create_response_id(...)` — mint a Responses-shaped response id. - `responses_from_run(...)` — convert an `AgentResponse` into a Responses-compatible JSON payload. -- `responses_stream_from_run(...)` — convert an Agent Framework +- `responses_from_streaming_run(...)` — convert an Agent Framework `ResponseStream` into Responses-compatible SSE events. FastAPI/Starlette/Django/Azure Functions code owns route registration, diff --git a/python/packages/hosting-responses/agent_framework_hosting_responses/__init__.py b/python/packages/hosting-responses/agent_framework_hosting_responses/__init__.py index 26ee8da69e7..5898f0a6467 100644 --- a/python/packages/hosting-responses/agent_framework_hosting_responses/__init__.py +++ b/python/packages/hosting-responses/agent_framework_hosting_responses/__init__.py @@ -11,8 +11,8 @@ parse_responses_identity, parse_responses_request, responses_from_run, + responses_from_streaming_run, responses_session_id, - responses_stream_from_run, responses_to_run, ) @@ -29,7 +29,7 @@ "parse_responses_identity", "parse_responses_request", "responses_from_run", + "responses_from_streaming_run", "responses_session_id", - "responses_stream_from_run", "responses_to_run", ] 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 9af4a1c9fb4..fef7dcb1bff 100644 --- a/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py +++ b/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py @@ -884,7 +884,7 @@ def _json_dumps(payload: Mapping[str, Any]) -> str: return json.dumps(payload, separators=(",", ":")) -async def responses_stream_from_run( +async def responses_from_streaming_run( stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]], *, response_id: str, @@ -953,7 +953,7 @@ async def responses_stream_from_run( "parse_responses_identity", "parse_responses_request", "responses_from_run", + "responses_from_streaming_run", "responses_session_id", - "responses_stream_from_run", "responses_to_run", ] diff --git a/python/packages/hosting-responses/tests/hosting_responses/test_http_round_trip.py b/python/packages/hosting-responses/tests/hosting_responses/test_http_round_trip.py index 8c0039228cb..eb8b5b87e34 100644 --- a/python/packages/hosting-responses/tests/hosting_responses/test_http_round_trip.py +++ b/python/packages/hosting-responses/tests/hosting_responses/test_http_round_trip.py @@ -34,8 +34,8 @@ from agent_framework_hosting_responses import ( create_response_id, responses_from_run, + responses_from_streaming_run, responses_session_id, - responses_stream_from_run, responses_to_run, ) @@ -136,7 +136,7 @@ async def responses(body: dict[str, Any] = Body(...)) -> JSONResponse | Streamin raise HTTPException(status_code=500, detail="agent did not return a response stream") async def stream_events() -> AsyncIterator[str]: - async for event in responses_stream_from_run( + async for event in responses_from_streaming_run( stream, response_id=response_id, session_id=session_id, diff --git a/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py b/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py index 6fa100415c1..a5acbdeddd6 100644 --- a/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py +++ b/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py @@ -16,8 +16,8 @@ parse_responses_identity, parse_responses_request, responses_from_run, + responses_from_streaming_run, responses_session_id, - responses_stream_from_run, responses_to_run, ) @@ -275,7 +275,7 @@ def test_responses_from_run_omits_previous_response_session(self) -> None: assert "conversation" not in payload - async def test_responses_stream_from_run(self) -> None: + async def test_responses_from_streaming_run(self) -> None: async def updates() -> AsyncIterator[AgentResponseUpdate]: yield AgentResponseUpdate(contents=[Content.from_text("hel")], role="assistant") yield AgentResponseUpdate(contents=[Content.from_text("lo")], role="assistant") @@ -287,7 +287,7 @@ def finalizer(items: Sequence[AgentResponseUpdate]) -> AgentResponse: events = [ event - async for event in responses_stream_from_run( + async for event in responses_from_streaming_run( stream, response_id="resp_new", session_id="conv_1", 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 c55c8c84079..f119a2c4aab 100644 --- a/python/samples/04-hosting/af-hosting/local_responses/app.py +++ b/python/samples/04-hosting/af-hosting/local_responses/app.py @@ -45,8 +45,8 @@ from agent_framework_hosting_responses import ( create_response_id, responses_from_run, + responses_from_streaming_run, responses_session_id, - responses_stream_from_run, responses_to_run, ) from azure.identity.aio import DefaultAzureCredential @@ -125,7 +125,7 @@ async def responses(body: dict[str, Any] = Body(...)) -> JSONResponse | Streamin raise HTTPException(status_code=500, detail="agent did not return a response stream") async def stream_events() -> AsyncIterator[str]: - async for event in responses_stream_from_run( + async for event in responses_from_streaming_run( stream, response_id=response_id, session_id=session_id, From b8832209de436b3e78e98d66ec0dc7aa482e3514 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 8 Jul 2026 12:06:52 +0200 Subject: [PATCH 10/15] Align Python hosting spec with protocol helpers Rewrite SPEC-002 to match the accepted helper-first hosting ADR and the implementation PR: AgentState, WorkflowState, SessionStore, Responses helpers, app-owned security/state responsibilities, and the minimal FastAPI Responses shape. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/specs/002-python-hosting-channels.md | 483 ++++++++++++---------- 1 file changed, 257 insertions(+), 226 deletions(-) diff --git a/docs/specs/002-python-hosting-channels.md b/docs/specs/002-python-hosting-channels.md index fbfaa0a8145..fcc3ff3da68 100644 --- a/docs/specs/002-python-hosting-channels.md +++ b/docs/specs/002-python-hosting-channels.md @@ -1,320 +1,351 @@ --- status: proposed contact: eavanvalkenburg -date: 2026-06-11 +date: 2026-07-08 deciders: eavanvalkenburg --- -# Python hosting core and pluggable channels +# Python protocol helpers and optional execution state ## Scope -This specification is the Python implementation plan for [ADR-0027](../decisions/0027-hosting-channels.md). It documents the simplified v1 host/channel contract only. +This specification is the Python implementation plan for +[ADR-0027](../decisions/0027-hosting-channels.md). It documents the helper-first v1 contract for Python hosting. The v1 contract is: -- `AgentFrameworkHost` owns one Starlette app, one hostable target, and one or more channels. -- A hostable target is either a `SupportsAgentRun`-compatible agent or a `Workflow`. -- Channels contribute routes, middleware, commands, and lifecycle callbacks. -- Channels parse protocol-native input into `ChannelRequest`. -- Channels render their own originating response. -- Session continuity is explicit: a channel supplies `ChannelSession(isolation_key=...)`, and the host resolves/caches an `AgentSession` for that key. -- The host invokes `ChannelRunHook` and `ChannelResponseHook`; channels provide hook configuration and protocol context. +- protocol packages expose helper functions that convert protocol-native input to Agent Framework run values; +- protocol packages expose helper functions that convert Agent Framework run results or streams back to protocol-native + payloads or operations; +- application/framework code owns routes, native SDK clients, authentication, command policy, webhooks, response status + codes, and outbound sends; +- `agent-framework-hosting` provides small optional state holders for Agent Framework targets; +- state helpers do not own web apps, route contribution, protocol dispatch, command projection, or native SDK calls. -The host does not link identities, route responses to other channels, run background continuations, or multicast in v1. Those enhancements are tracked in [ADR-0028](../decisions/0028-hosting-linking-multicast-enhancements.md). +The old `AgentFrameworkHost` / `Channel` implementation remains present while the package is unreleased, but it is not +the intended v1 direction. New work should target protocol helpers plus app-owned routes. ## Goals -- Let an app expose one agent or workflow on multiple protocols without handwritten Starlette composition. -- Keep protocol parsing and response formatting inside channel packages. -- Provide one session-resolution path shared by all channels. -- Keep the channel authoring surface small enough for new channels to implement. -- Preserve full-fidelity agent and workflow results until a channel decides how to render them. +- Let apps expose agents and workflows from FastAPI, Starlette, Django, Azure Functions, native SDK webhooks, CLIs, and + tests without adopting a host/channel framework. +- Keep protocol parsing and response formatting inside protocol packages. +- Keep session continuity explicit and app-owned at the trust boundary. +- Reuse Agent Framework primitives: `AgentSession`, `CheckpointStorage`, `Agent.run(...)`, `Workflow.run(...)`, and + `ResponseStream`. +- Preserve full-fidelity Agent Framework results until a protocol helper renders them. ## Non-goals for v1 -The following are removed from the v1 implementation pass: +### App-owned in v1 -- `IdentityLinker`, `IdentityAllowlist`, `AuthPolicy`, and `LinkPolicy` -- `ResponseTarget`, active-channel routing, `all_linked`, fan-out, and multicast -- `ChannelPush` and `ChannelPushCodec` -- `DurableTaskRunner`, `InProcessTaskRunner`, and `RetryPolicy` -- continuation tokens and background delivery -- confidentiality tiers -- `agent-framework-hosting-entra` -- `local_identity_link` +The app builder owns these concerns with normal web-framework, SDK, platform, or application code: -These are follow-up design topics, not hidden requirements of the v1 host. +- authentication, authorization policy, and allowlists; +- deciding whether identities across protocols map to the same `session_id`; +- non-originating sends using native SDK clients; +- background work, durable execution, retry, and replay when app code owns the work; +- routing between multiple agents. -## Packages - -| Package | Import surface | Contents | -|---|---|---| -| `agent-framework-hosting` | `agent_framework_hosting` | `AgentFrameworkHost`, channel protocols, key request/result types, hooks, `reset_session`, state-path helpers. | -| `agent-framework-hosting-responses` | `agent_framework_hosting_responses` | `ResponsesChannel`. | -| `agent-framework-hosting-invocations` | `agent_framework_hosting_invocations` | `InvocationsChannel`. | -| `agent-framework-hosting-telegram` | `agent_framework_hosting_telegram` | `TelegramChannel` and Telegram command helpers. | -| `agent-framework-hosting-activity-protocol` | `agent_framework_hosting_activity_protocol` | `ActivityProtocolChannel` for Activity Protocol over Azure Bot Service. | -| `agent-framework-hosting-discord` | `agent_framework_hosting_discord` | `DiscordChannel` and Discord command/interaction helpers. | -| `agent-framework-foundry-hosting` | `agent_framework.foundry_hosting` | Foundry isolation middleware and Foundry-backed hosting helpers usable with the v1 host. | - -Channel packages may depend on their native SDKs. The core hosting package should not depend on channel SDKs or on top-level legacy protocol hosts. - -## Key Types - -### `AgentFrameworkHost` - -The host constructor accepts: - -- `target`: one `SupportsAgentRun`-compatible object or one `Workflow` -- `channels`: one or more `Channel` instances -- optional Starlette middleware -- optional `state_dir` -- optional workflow `checkpoint_location` - -The host exposes: - -- `app`: the canonical Starlette ASGI application -- `serve(...)`: a convenience wrapper for local serving -- `reset_session(isolation_key: str)`: rotate the cached `AgentSession` for a host-tracked conversation - -`state_dir` is narrowed to v1 host-owned local files only: - -- session aliases (`isolation_key` to current `AgentSession` id), and -- workflow checkpoint paths when the app chooses the host-provided file layout. - -It is not a store for identity links, continuations, active-channel state, delivery attempts, or multicast payloads. +The helper-first model makes app-owned linking and non-originating delivery easier than the old host/channel model because +app code already owns the native SDK clients, authenticated caller context, session id selection, and outbound sends. -Externally supplied isolation keys are trusted only after the channel or host middleware has authenticated and authorized the caller. The host uses `isolation_key` as a partition key; the string itself is not proof of identity or ownership. +### Future framework work -### `Channel` +The following require a separate reviewed design before becoming reusable framework features: -A channel implements a small protocol: +- reusable cross-channel identity linking; +- framework-owned proactive or non-originating delivery; +- fan-out, multicast, selected-channel, active-channel, or all-linked delivery; +- framework-owned delivery observability, dead-letter handling, and replay semantics; +- cross-channel confidentiality and link policy. -- declare a stable channel id/name, -- contribute routes, middleware, commands, and lifecycle callbacks, -- parse inbound protocol data into `ChannelRequest`, -- call the host through `ChannelContext.run(...)` or `ChannelContext.run_stream(...)`, and -- serialize the returned result to the originating protocol response. +[ADR-0028](../decisions/0028-hosting-linking-multicast-enhancements.md) tracks possible follow-up work in this area and +must be aligned with the helper-first model before implementation. Old vocabulary such as `IdentityLinker`, +`ResponseTarget`, `ChannelPush`, `ChannelPushCodec`, `DurableTaskRunner`, `RetryPolicy`, and `LinkPolicy` is not v1 API. -Channels own protocol authentication, signature validation, native command registration, and protocol-specific error bodies. - -### `ChannelContribution` - -`ChannelContribution` is the channel's host-facing contribution: - -- Starlette routes and optional middleware, -- native command descriptors, -- startup and shutdown callbacks, and -- any channel-local metadata needed by the package. - -The host aggregates contributions but does not interpret protocol payloads. - -### `ChannelRequest` +## Packages -`ChannelRequest` is the host-neutral request envelope produced by a channel. It carries: +| Package | Import surface | v1 helper-first contents | +|---|---|---| +| `agent-framework-hosting` | `agent_framework_hosting` | `AgentState`, `WorkflowState`, `SessionStore`, run-argument `TypedDict`s, and existing unreleased channel/host types. | +| `agent-framework-hosting-responses` | `agent_framework_hosting_responses` | Responses helpers: request parsing, session id extraction, response id creation, response rendering, streaming rendering. | +| Future protocol packages | e.g. `agent_framework_hosting_telegram` | Protocol-specific helpers such as `telegram_to_run(...)`, `telegram_from_run(...)`, `telegram_session_id(...)`, and command/media helpers when useful. | -- target input, -- optional `ChannelSession`, -- optional `ChannelIdentity`, -- options and attributes produced by the channel, and -- request metadata useful to hooks and context providers. +The core hosting package must not depend on protocol SDKs. Protocol packages may depend on their native protocol SDKs if +needed, but helper functions should stay usable from plain app code and tests. -The host may pass attributes through to context providers and middleware. Channels should treat attributes as a documented extension bag, not as a cross-channel delivery contract. +## Helper naming and families -### `ChannelSession` +Helper names are protocol-specific. Avoid a generic `protocol_to_run(...)` public surface. -`ChannelSession(isolation_key=...)` is the only v1 session-continuity mechanism. +Protocol packages may provide the following helper families when the protocol has the concept: -When a request contains an isolation key: +| Helper family | Shape | Purpose | +| --- | --- | --- | +| Run conversion | `_to_run(...)` | Convert one protocol-native call/update/request into `Agent.run` or `Workflow.run` values. | +| Final rendering | `_from_run(...)` | Convert a final `AgentResponse` or workflow result into protocol-native response payloads or operations. | +| Stream rendering | `_from_streaming_run(...)` | Convert `ResponseStream` or workflow updates into protocol-native events or operations. | +| Session id extraction | `_session_id(...)` | Extract the protocol's natural continuation/partition key from the call, if present. | +| Command/action parsing | `_command(...)` | Parse a protocol-native command/action/operation name without deciding app policy. | -1. The host looks up or creates the cached `AgentSession` for that key. -2. The target runs with that `AgentSession` when the target is an agent. -3. `reset_session(isolation_key)` rotates the alias so the next request starts a new conversation. +Examples: -If two channels produce the same isolation key on the same host, they share the same cached session. If they produce different keys, they do not share session state. +- `responses_to_run(...)`, `responses_from_run(...)`, `responses_from_streaming_run(...)`, + `responses_session_id(...)`; +- `telegram_to_run(...)`, `telegram_from_run(...)`, `telegram_from_streaming_run(...)`, + `telegram_session_id(...)`, `telegram_command(...)`; +- `activity_to_run(...)`, `activity_from_run(...)`, `activity_session_id(...)`, `activity_command(...)`; +- `discord_to_run(...)`, `discord_from_run(...)`, `discord_session_id(...)`, `discord_command(...)`. -### `ChannelIdentity` +This table is a naming guide, not a required checklist. A protocol package should add only the helpers that match native +protocol concepts and current samples. -`ChannelIdentity` is optional request metadata such as channel id, native user id, tenant id, claims, or display attributes. +Protocol-specific helpers may also exist for native details such as `telegram_chat_id(...)`, +`telegram_callback_query_id(...)`, `telegram_media_file_id(...)`, `discord_interaction_id(...)`, `a2a_task_id(...)`, +`a2a_context_id(...)`, or MCP tool/prompt/resource helpers. These helpers should stay side-effect-free. App/native SDK +code performs acknowledgements, sends/edits messages, resolves protected file URLs, applies rate limits, and registers +handlers. -In v1, `ChannelIdentity` does not link channels, authorize callers, select delivery destinations, or imply that two identities should share an `AgentSession`. A channel that wants shared history must still produce the same `ChannelSession.isolation_key`. +## `agent-framework-hosting` state helpers -### Hooks +### `SessionStore` -Hooks are optional and channel-owned: +`SessionStore` is an in-memory async lookup: -- `ChannelRunHook`: runs after channel parsing and before host invocation; returns the `ChannelRequest` to execute. -- `ChannelResponseHook`: runs after target completion and before the originating channel renders a one-shot response. -- `ChannelStreamUpdateHook`: the host applies it to streamed updates before the originating channel serializes the stream. +```python +class SessionStore: + async def get(self, session_id: str) -> AgentSession | None: ... + async def set(self, session_id: str, session: AgentSession) -> None: ... + async def delete(self, session_id: str) -> None: ... +``` -Common uses include adapting chat text into workflow inputs, enforcing deployment-specific options, flattening rich output for text-only protocols, or filtering streamed updates for a protocol. Stream update hooks are update-only; they do not automatically sanitize `get_final_response()` output. Channels choose their response transport from the parsed protocol request before invoking run hooks. +The store does not create sessions. It stores `session_id -> AgentSession` values supplied by callers. -### `HostedRunResult` +The built-in store has no TTL or eviction. This is intentional for local/dev and simple process-local scenarios: protocols +such as OpenAI Responses can continue from any prior response id. Durable or multi-replica deployments should provide a +durable store and their own TTL/eviction policy. -`HostedRunResult[T]` wraps the target's full-fidelity result plus the resolved `AgentSession | None`. +### `AgentState` -- Agent targets produce `HostedRunResult[AgentResponse]`. -- Workflow targets produce `HostedRunResult[WorkflowRunResult]`. +`AgentState` holds an agent target and an optional `SessionStore`: -The host does not flatten, filter, or translate the result. Each channel decides how much of the result its protocol can carry. +```python +state = AgentState(agent) +state = AgentState(create_agent) +state = AgentState(create_agent, cache_target=False) +``` -## Host Behavior +The target may be: -1. `AgentFrameworkHost` builds one Starlette app and asks each channel for its contribution. -2. A channel route receives a protocol-native request. -3. The channel validates/parses the native payload and creates `ChannelRequest`. -4. The channel passes the request, optional `ChannelRunHook`, and protocol-native context to the host. -5. The host invokes `ChannelRunHook`, if configured, and receives the prepared request. -6. The host resolves an `AgentSession` from `ChannelSession.isolation_key` when present. -7. The host invokes the agent or workflow target. -8. The host wraps the result in `HostedRunResult` or the streaming equivalent. -9. The host invokes `ChannelResponseHook`, if configured, for non-streaming/final response shaping. -10. The host applies stream update hooks while the channel consumes streams; the channel renders the originating protocol response. +- a `SupportsAgentRun` instance; +- a synchronous factory; +- an asynchronous factory; +- an awaitable target. -There is no host-level route from one channel's request to another channel's response in v1. +`AgentState` provides: -## Workflow Checkpoints +- `await get_target()`; +- synchronous `target` only after a target is already available/resolved; +- `session_store`; +- `await get_or_create_session(session_id)`; +- `await set_session(session_id, session)`. -Workflow checkpointing is explicit. Apps either configure checkpoint storage on the workflow itself or pass a `checkpoint_location` to the host so the workflow dispatch path can use the intended file location. +`get_or_create_session(...)` resolves the target and calls `target.create_session(session_id=...)` only when the store has +no session for that id. -`state_dir` may provide a conventional location for workflow checkpoint files, but checkpointing is still opt-in and separate from agent session history. Checkpoints are workflow-runtime state, not channel state and not identity-link state. +Apps must store the post-run session explicitly after `agent.run(...)` or stream finalization: -## Foundry Isolation Middleware +```python +session = await state.get_or_create_session(session_id) +target = await state.get_target() +result = await target.run(messages, session=session, options=options) +await state.set_session(response_id, session) +``` -V1 keeps Foundry isolation as middleware rather than as a channel-linking feature. +### `WorkflowState` -The middleware is installed only when the Foundry hosting environment flag is present. In that environment it reads Foundry-provided isolation values at the trusted hosting boundary, exposes them as read-only request context for Foundry-aware history or memory providers, and rejects unsafe session resumes when the live isolation context does not match persisted session context. Outside Foundry, raw isolation headers are ignored unless an app supplies its own trusted middleware. +`WorkflowState` resolves a workflow target. It does not own checkpoint storage. -This middleware does not create cross-channel identity links and does not authorize non-Foundry channels. +The target may be: -## Current Channels +- a `Workflow` instance; +- a `WorkflowBuilder` or other object with `build() -> Workflow`; +- a synchronous factory; +- an asynchronous factory; +- an awaitable target. -### Responses +`WorkflowState` provides: -`ResponsesChannel` exposes the OpenAI-compatible Responses API shape. It maps request body fields such as input, options, and conversation identifiers into `ChannelRequest`, and it renders Responses-compatible one-shot or streaming responses. +- `await get_target()`; +- synchronous `target` only after a target is already available/resolved. -Responses session continuity uses a channel-selected `isolation_key`, commonly derived from a response/conversation id, caller-provided session id, Foundry isolation context, or deployment-specific request metadata. +Workflow checkpointing uses Agent Framework's existing `CheckpointStorage` abstraction directly. Apps that need +per-session workflow resume should keep an app-owned cursor such as `session_id -> checkpoint_id`: -### Invocations +```python +# session_id must already be authenticated and authorized for this caller +target = await workflow_state.get_target() +checkpoint_id = await checkpoint_cursor_store.get(session_id) +if checkpoint_id is None: + result = await target.run(message=workflow_input, checkpoint_storage=checkpoint_storage) +else: + result = await target.run(checkpoint_id=checkpoint_id, checkpoint_storage=checkpoint_storage) +latest = await checkpoint_storage.get_latest(workflow_name=target.name) +if latest is not None: + await checkpoint_cursor_store.set(session_id, latest.checkpoint_id) +``` -`InvocationsChannel` exposes an invocation endpoint for server-side callers and tools. It maps the request body into `ChannelRequest` and renders the invocation result on the same HTTP response. +`Workflow.run(...)` does not currently emit a checkpoint id on `WorkflowRunResult` or normal workflow events by default. +The runner receives checkpoint ids internally from `CheckpointStorage.save(...)`. Apps that own the storage can query +`get_latest(workflow_name=...)` after the run if they need to update a cursor. -Invocations is useful for typed workflow inputs because a `ChannelRunHook` can translate the request body into the workflow's expected input type. +## `agent-framework-hosting-responses` -### Telegram +The Responses package provides the helper-first surface for OpenAI Responses-shaped requests. -`TelegramChannel` supports webhook or polling transport, native command registration, and message rendering back to the originating Telegram chat. +### Request helpers -The channel chooses a default `isolation_key` from Telegram-native data such as chat id, user id, or a configured user/chat scope. A `/new` or equivalent command may call `reset_session` for that isolation key. +- `messages_from_responses_input(input) -> list[Message]` +- `responses_to_run(body) -> AgentRunArgs` +- `responses_session_id(body) -> str | None` +- `create_response_id() -> str` -### Activity Protocol +`responses_to_run(...)` returns values corresponding to `Agent.run(...)`: -`ActivityChannel` supports Activity Protocol requests, typically through Azure Bot Service for Teams, Web Chat, and other Bot Framework-fronted surfaces. +```python +run = responses_to_run(body) +messages = run["messages"] +options = run["options"] +stream = run["stream"] +``` -The channel maps incoming `Activity` objects to `ChannelRequest` and renders a reply activity to the originating conversation. Proactive Activity delivery, active-channel routing, and all-linked fan-out are not v1 host semantics. +It excludes protocol transport/session fields from `options` and remaps known Responses option names such as +`max_output_tokens -> max_tokens`. -### Discord +`responses_session_id(...)` returns: -`DiscordChannel` supports Discord messages, slash commands, and interactions as channel-native input. +- `previous_response_id` when present (`resp_*`); +- otherwise `conversation_id` when present (`conv_*`); +- otherwise `None`. -The channel maps Discord-native user, guild, channel, thread, and interaction data into `ChannelRequest` metadata and a configured `ChannelSession.isolation_key`. It renders the result to the originating Discord response path. +The helper only extracts the candidate key. App code decides whether to trust and use that key. -## High-level Samples +### Response helpers -### One agent on Responses +- `responses_from_run(result, *, response_id, session_id=None) -> dict[str, Any]` +- `responses_from_streaming_run(stream, *, response_id, session_id=None) -> AsyncIterator[str]` -```python -host = AgentFrameworkHost( - target=agent, - channels=[ResponsesChannel()], -) +`responses_from_run(...)` renders a full Responses JSON payload from an `AgentResponse`. It renders the full set of +OpenAI Responses output item types supported by Agent Framework content. -app = host.app -``` +`responses_from_streaming_run(...)` renders Server-Sent Event strings for a `ResponseStream`. It emits a created event, +text deltas, and a completed event. The final completed payload is produced through `responses_from_run(...)`; the helper +also preserves the model id observed on streaming updates when the finalized `AgentResponse` no longer carries raw model +metadata. -### One agent on multiple channels +### Compatibility with old channel code -```python -host = AgentFrameworkHost( - target=agent, - channels=[ - ResponsesChannel(), - InvocationsChannel(), - TelegramChannel(bot_token=os.environ["TELEGRAM_BOT_TOKEN"]), - ], -) +`ResponsesChannel` and the old channel helpers remain present while the package is unreleased. New samples and new code +should use the helper-first surface. -host.serve(host="localhost", port=8000) -``` +## Security responsibilities -The host owns one Starlette app. Each channel contributes its own routes and renders its own response. +Protocol helper packages parse and render. They do not authenticate callers, authorize access to state, or decide which +side effects are allowed. -### Adapting a request before execution +Application code that uses these helpers is responsible for: -```python -from dataclasses import replace +- authenticating the caller through the app's normal mechanism before using protocol-provided ids; +- authorizing any caller-supplied session, checkpoint, task, context, conversation, thread, or response id before loading + state for it; +- binding externally supplied ids to the authenticated user, tenant, workspace, installation, or chat context before + using them as `SessionStore` keys or checkpoint cursor keys; +- treating `_session_id(...)` results as untrusted candidate keys until that ownership check has passed; +- keeping platform-provided isolation helpers fail-closed outside their trusted hosting environment; +- authorizing command/action effects such as reset, cancel, approve, submit, or tool invocation after parsing them; +- opting in explicitly before resolving protected media/resource/file URLs and passing them to a remote model provider; +- persisting post-run session or checkpoint state only after `agent.run(...)`, `workflow.run(...)`, or stream finalization + has updated that state. +## Persistent versus transient hosting -def enforce_options(request: ChannelRequest) -> ChannelRequest: - options = dict(request.options or {}) - options["temperature"] = 0 - return replace(request, options=options) +The application builder decides whether the server is persistent or transient. +- Persistent single-process apps, such as a long-running container or web app, may use in-memory state for local + development or simple deployments. Multi-replica persistent apps still need durable state for continuity. +- Transient apps, such as Azure Functions, Foundry Hosted Agents, or any environment where process memory is not a + reliable boundary, must not rely on in-memory `SessionStore` state between calls. They need a durable session store or + a service-owned continuation id. +- Workflow hosts must choose an explicit `CheckpointStorage` and, when they need per-session resume, a durable + `session_id -> checkpoint_id` cursor. In-process workflow state and in-memory checkpoint cursors do not survive + transient execution. -host = AgentFrameworkHost( - target=agent, - channels=[ResponsesChannel(run_hook=enforce_options)], -) -``` +## Minimal FastAPI Responses shape -### Workflow with explicit checkpoints +This is the shape the local Responses sample should demonstrate. It is not an app framework. ```python -host = AgentFrameworkHost( - target=workflow, - channels=[InvocationsChannel(run_hook=adapt_to_workflow_input)], - checkpoint_location=Path("./.af-hosting/workflow_checkpoints"), +from collections.abc import AsyncIterator + +from agent_framework import ResponseStream +from agent_framework_hosting import AgentState +from agent_framework_hosting_responses import ( + create_response_id, + responses_from_run, + responses_from_streaming_run, + responses_session_id, + responses_to_run, ) +from fastapi import Body, FastAPI, HTTPException +from fastapi.responses import JSONResponse, StreamingResponse + +app = FastAPI() +state = AgentState(create_agent) + + +@app.post("/responses", response_model=None) +async def responses(body: dict = Body(...)) -> JSONResponse | StreamingResponse: + run = responses_to_run(body) + candidate_session_id = responses_session_id(body) + response_id = create_response_id() + + # Verify this caller owns candidate_session_id before loading it. + session_id = candidate_session_id or response_id + session = await state.get_or_create_session(session_id) + target = await state.get_target() + + if run["stream"]: + stream = target.run(run["messages"], stream=True, session=session, options=run["options"]) + if not isinstance(stream, ResponseStream): + raise HTTPException(status_code=500, detail="agent did not return a response stream") + + async def events() -> AsyncIterator[str]: + async for event in responses_from_streaming_run( + stream, + response_id=response_id, + session_id=candidate_session_id, + ): + yield event + await state.set_session(response_id, session) + + return StreamingResponse(events(), media_type="text/event-stream") + + result = await target.run(run["messages"], session=session, options=run["options"]) + await state.set_session(response_id, session) + return JSONResponse(responses_from_run(result, response_id=response_id, session_id=candidate_session_id)) ``` -The hook adapts channel-native input to the workflow's typed input. Checkpoints use the explicit workflow checkpoint location, not identity-link or delivery storage. - -### Message channel reset command - -```python -async def new_chat(context): - if context.request.session is not None: - await context.host.reset_session(context.request.session.isolation_key) - await context.reply("Started a new conversation.") -``` - -Telegram, Activity Protocol, and Discord can expose equivalent native commands when their protocols support them. - -## Follow-up Enhancements - -See [ADR-0028](../decisions/0028-hosting-linking-multicast-enhancements.md) for the deferred design covering: - -- cross-channel identity linking, -- authorization and allowlists, -- non-originating response delivery, -- active-channel routing, -- multicast and all-linked delivery, -- background runs and continuation tokens, -- durable delivery runners, -- retry/replay semantics, and -- payload serialization. - -Those enhancements must layer on top of this v1 contract without requiring v1 users to adopt them. - -## Validation Gates +## Validation -The Python implementation should be considered complete when: +Implementation validation must cover: -- a sample uses one `AgentFrameworkHost` with multiple channels and no manual Starlette route composition, -- each current channel has contract tests for route contribution, lifecycle, request parsing, hooks, and originating response rendering, -- session tests prove shared `isolation_key` values share an `AgentSession` and `reset_session` rotates it, -- workflow tests or samples use explicit `checkpoint_location`, -- Foundry isolation middleware is covered by integration or contract tests, -- no v1 package exposes the removed linking, multicast, durable-runner, or continuation APIs, and -- this spec and ADR-0027 remain aligned. +- `SessionStore` plain get/set/delete behavior; +- `AgentState` target resolution, target caching, and get-or-create session behavior; +- `WorkflowState` target resolution for direct workflows, factories, `WorkflowBuilder`, and orchestration-style builders; +- Responses request parsing and option remapping; +- Responses session id extraction; +- Responses response rendering, including rich output item mapping; +- Responses streaming SSE rendering; +- HTTP round-trip tests showing a native FastAPI route using `AgentState` and Responses helpers; +- sample type checking for the local Responses sample. From ab602aa4f76d008dedf96c011189f8bd54f490f0 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 8 Jul 2026 13:36:48 +0200 Subject: [PATCH 11/15] Remove old Python hosting channel implementation Remove the unreleased AgentFrameworkHost/channel implementation, the old hosting-telegram package, and old host/channel samples. Keep agent-framework-hosting focused on AgentState, WorkflowState, and SessionStore, and keep hosting-responses focused on helper-first Responses conversion. Update SPEC-002 to match the accepted helper-first ADR and the implementation surface. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/specs/002-python-hosting-channels.md | 8 - python/PACKAGE_STATUS.md | 1 - .../__init__.py | 8 +- .../_channel.py | 1384 ------------- .../_parsing.py | 57 +- .../packages/hosting-responses/pyproject.toml | 2 +- .../tests/hosting_responses/test_channel.py | 689 ------- .../tests/hosting_responses/test_parsing.py | 67 - python/packages/hosting-telegram/LICENSE | 21 - python/packages/hosting-telegram/README.md | 29 - .../__init__.py | 7 - .../_channel.py | 888 --------- .../packages/hosting-telegram/pyproject.toml | 89 - .../tests/hosting_telegram/test_channel.py | 591 ------ python/packages/hosting/README.md | 3 - .../agent_framework_hosting/__init__.py | 53 +- .../hosting/agent_framework_hosting/_host.py | 1425 -------------- .../agent_framework_hosting/_isolation.py | 82 - .../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 | 16 +- .../hosting/tests/hosting/test_host.py | 1482 -------------- .../hosting/tests/hosting/test_host_disk.py | 239 --- .../hosting/tests/hosting/test_isolation.py | 318 --- .../hosting/tests/hosting/test_types.py | 50 - python/pyproject.toml | 1 - .../samples/04-hosting/af-hosting/README.md | 50 +- .../af-hosting/local_multi_channel/README.md | 58 - .../af-hosting/local_multi_channel/app.py | 213 -- .../local_multi_channel/call_server.py | 53 - .../local_multi_channel/pyproject.toml | 26 - .../local_responses_workflow/README.md | 83 - .../local_responses_workflow/app.py | 182 -- .../local_responses_workflow/call_server.py | 53 - .../local_responses_workflow/call_server.rest | 48 - .../local_responses_workflow/pyproject.toml | 23 - .../storage/checkpoints/.gitkeep | 0 .../af-hosting/local_telegram/README.md | 42 - .../af-hosting/local_telegram/app.py | 176 -- .../af-hosting/local_telegram/pyproject.toml | 21 - python/uv.lock | 1752 ++++++++--------- 42 files changed, 877 insertions(+), 9899 deletions(-) delete mode 100644 python/packages/hosting-responses/agent_framework_hosting_responses/_channel.py delete mode 100644 python/packages/hosting-responses/tests/hosting_responses/test_channel.py delete mode 100644 python/packages/hosting-telegram/LICENSE delete mode 100644 python/packages/hosting-telegram/README.md delete mode 100644 python/packages/hosting-telegram/agent_framework_hosting_telegram/__init__.py delete mode 100644 python/packages/hosting-telegram/agent_framework_hosting_telegram/_channel.py delete mode 100644 python/packages/hosting-telegram/pyproject.toml delete mode 100644 python/packages/hosting-telegram/tests/hosting_telegram/test_channel.py delete mode 100644 python/packages/hosting/agent_framework_hosting/_host.py delete mode 100644 python/packages/hosting/agent_framework_hosting/_isolation.py delete mode 100644 python/packages/hosting/agent_framework_hosting/_persistence.py delete mode 100644 python/packages/hosting/agent_framework_hosting/_state_store.py delete mode 100644 python/packages/hosting/agent_framework_hosting/_types.py delete mode 100644 python/packages/hosting/tests/hosting/test_host.py delete mode 100644 python/packages/hosting/tests/hosting/test_host_disk.py delete mode 100644 python/packages/hosting/tests/hosting/test_isolation.py delete mode 100644 python/packages/hosting/tests/hosting/test_types.py delete mode 100644 python/samples/04-hosting/af-hosting/local_multi_channel/README.md delete mode 100644 python/samples/04-hosting/af-hosting/local_multi_channel/app.py delete mode 100644 python/samples/04-hosting/af-hosting/local_multi_channel/call_server.py delete mode 100644 python/samples/04-hosting/af-hosting/local_multi_channel/pyproject.toml delete mode 100644 python/samples/04-hosting/af-hosting/local_responses_workflow/README.md delete mode 100644 python/samples/04-hosting/af-hosting/local_responses_workflow/app.py delete mode 100644 python/samples/04-hosting/af-hosting/local_responses_workflow/call_server.py delete mode 100644 python/samples/04-hosting/af-hosting/local_responses_workflow/call_server.rest delete mode 100644 python/samples/04-hosting/af-hosting/local_responses_workflow/pyproject.toml delete mode 100644 python/samples/04-hosting/af-hosting/local_responses_workflow/storage/checkpoints/.gitkeep delete mode 100644 python/samples/04-hosting/af-hosting/local_telegram/README.md delete mode 100644 python/samples/04-hosting/af-hosting/local_telegram/app.py delete mode 100644 python/samples/04-hosting/af-hosting/local_telegram/pyproject.toml diff --git a/docs/specs/002-python-hosting-channels.md b/docs/specs/002-python-hosting-channels.md index fcc3ff3da68..6b261864146 100644 --- a/docs/specs/002-python-hosting-channels.md +++ b/docs/specs/002-python-hosting-channels.md @@ -22,9 +22,6 @@ The v1 contract is: - `agent-framework-hosting` provides small optional state holders for Agent Framework targets; - state helpers do not own web apps, route contribution, protocol dispatch, command projection, or native SDK calls. -The old `AgentFrameworkHost` / `Channel` implementation remains present while the package is unreleased, but it is not -the intended v1 direction. New work should target protocol helpers plus app-owned routes. - ## Goals - Let apps expose agents and workflows from FastAPI, Starlette, Django, Azure Functions, native SDK webhooks, CLIs, and @@ -244,11 +241,6 @@ text deltas, and a completed event. The final completed payload is produced thro also preserves the model id observed on streaming updates when the finalized `AgentResponse` no longer carries raw model metadata. -### Compatibility with old channel code - -`ResponsesChannel` and the old channel helpers remain present while the package is unreleased. New samples and new code -should use the helper-first surface. - ## Security responsibilities Protocol helper packages parse and render. They do not authenticate callers, authorize access to state, or decide which diff --git a/python/PACKAGE_STATUS.md b/python/PACKAGE_STATUS.md index 0cda8f4a1d8..c0080f0cbbf 100644 --- a/python/PACKAGE_STATUS.md +++ b/python/PACKAGE_STATUS.md @@ -36,7 +36,6 @@ Status is grouped into these buckets: | `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-hosting-telegram` | `python/packages/hosting-telegram` | `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/agent_framework_hosting_responses/__init__.py b/python/packages/hosting-responses/agent_framework_hosting_responses/__init__.py index 5898f0a6467..9fc6246abbf 100644 --- a/python/packages/hosting-responses/agent_framework_hosting_responses/__init__.py +++ b/python/packages/hosting-responses/agent_framework_hosting_responses/__init__.py @@ -1,15 +1,12 @@ # Copyright (c) Microsoft. All rights reserved. -"""OpenAI Responses-shaped channel for ``agent-framework-hosting``.""" +"""OpenAI Responses-shaped helpers for app-owned Agent Framework hosting.""" import importlib.metadata -from ._channel import ResponsesChannel from ._parsing import ( create_response_id, messages_from_responses_input, - parse_responses_identity, - parse_responses_request, responses_from_run, responses_from_streaming_run, responses_session_id, @@ -22,12 +19,9 @@ __version__ = "0.0.0" __all__ = [ - "ResponsesChannel", "__version__", "create_response_id", "messages_from_responses_input", - "parse_responses_identity", - "parse_responses_request", "responses_from_run", "responses_from_streaming_run", "responses_session_id", diff --git a/python/packages/hosting-responses/agent_framework_hosting_responses/_channel.py b/python/packages/hosting-responses/agent_framework_hosting_responses/_channel.py deleted file mode 100644 index 973802c33a0..00000000000 --- a/python/packages/hosting-responses/agent_framework_hosting_responses/_channel.py +++ /dev/null @@ -1,1384 +0,0 @@ -# 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 dataclasses -import json -import time -import uuid -from collections.abc import AsyncIterator, Callable, Mapping, Sequence -from typing import Any, cast - -from agent_framework import AgentResponse, AgentResponseUpdate, Content, Message, ResponseStream -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 ( - ResponseCodeInterpreterCallCodeDeltaEvent, - ResponseCodeInterpreterCallCodeDoneEvent, - ResponseCodeInterpreterToolCall, - ResponseCompletedEvent, - ResponseContentPartAddedEvent, - ResponseContentPartDoneEvent, - ResponseCreatedEvent, - ResponseError, - ResponseFailedEvent, - ResponseFunctionCallArgumentsDeltaEvent, - ResponseFunctionCallArgumentsDoneEvent, - ResponseFunctionToolCall, - ResponseFunctionToolCallOutputItem, - ResponseInputFile, - ResponseInputImage, - ResponseInputText, - ResponseMcpCallArgumentsDeltaEvent, - ResponseMcpCallArgumentsDoneEvent, - ResponseOutputItem, - ResponseOutputItemAddedEvent, - ResponseOutputItemDoneEvent, - ResponseOutputMessage, - ResponseOutputText, - ResponseReasoningItem, - ResponseReasoningTextDeltaEvent, - ResponseReasoningTextDoneEvent, - ResponseTextDeltaEvent, - ResponseTextDoneEvent, -) -from openai.types.responses.response_output_item import McpCall -from pydantic import TypeAdapter, ValidationError -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, -) - -_RESPONSE_OUTPUT_ITEM_ADAPTER: TypeAdapter[Any] = TypeAdapter(ResponseOutputItem) - - -def _strip_options_hook(request: ChannelRequest, **_: Any) -> ChannelRequest: - """Default run hook: remove all parsed options before reaching the agent. - - Parsed options (e.g. ``temperature``, ``instructions``) are available to - a custom ``run_hook`` via ``request.options`` and the raw body via - ``protocol_request``. This default prevents untrusted callers from - injecting generation parameters when no custom hook is configured. - Host developers who want to forward specific options should supply their - own ``run_hook`` instead of this default. - """ - return dataclasses.replace(request, options=None) - - -class ResponsesChannel: - """Minimal OpenAI-Responses-shaped surface. - - Mounts one ``POST`` route at ``path``. The default path is ``/responses``; - use ``path=""`` to expose the route at the app root. - """ - - 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 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. - - By default the channel strips all parsed options before - forwarding the request so callers cannot inject generation - parameters (``temperature``, ``instructions``, etc.) unless - the host explicitly allows it. Supplying a custom hook - **replaces** that default entirely — the hook receives the - full ``ChannelRequest`` (with ``options`` populated from the - parsed body) and the raw ``protocol_request``, and is - responsible for deciding what to forward to the agent. - 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. - The callable should 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. 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: ChannelRunHook = run_hook if run_hook is not None else _strip_options_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) - if not isinstance(body, Mapping): - return JSONResponse({"error": "request body must be a JSON object"}, status_code=422) - body = cast("Mapping[str, Any]", body) - - 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 any isolation key the **host** lifted from the request - # context as the channel session id. Some environments provide - # isolation through trusted headers, while other channels derive - # it from body fields, paths, channel-native metadata, or even - # environment-provided context in an ephemeral host. This fallback - # only covers the host-context case; explicit protocol anchors - # still win. - # - # 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. - # A host-provided isolation key need not be a storage anchor: - # multi-turn storage chaining still 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) - 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: - 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, - ) - output = _result_to_output_items(result.result, status="completed") - envelope = self._build_response(body, output, status="completed", response_id=response_id) - return JSONResponse(_response_payload(envelope)) - - def _build_response( - self, - body: Mapping[str, Any], - output: Sequence[ResponseOutputItem], - *, - status: str, - response_id: str | None = None, - output_message_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. - """ - output_items = list(output) - if output_message_id is not None: - for output_item in output_items: - if isinstance(output_item, ResponseOutputMessage): - output_item.id = output_message_id - break - model = body.get("model") - return OpenAIResponse( - id=response_id or self._response_id_factory(None), - object="response", - created_at=int(time.time()), - status=status, # type: ignore[arg-type] - model=model if isinstance(model, str) and model else "agent", - output=output_items, - 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 - - skeleton = self._build_response( - body, - _text_output_items("", status="in_progress", message_id=msg_id), - status="in_progress", - response_id=response_id, - ) - yield _sse_event(ResponseCreatedEvent(type="response.created", response=skeleton, sequence_number=next_seq())) - - next_output_index = 1 - update_stream: ResponseStream[AgentResponseUpdate, list[ResponseOutputItem]] | None = None - 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, - ) - - def update_to_events(update: AgentResponseUpdate) -> list[Any]: - nonlocal next_output_index - events: list[Any] = [] - for content in update.contents: - content_events, uses_output_index = _content_to_stream_events( - content, - message_id=msg_id, - output_index=next_output_index, - next_sequence_number=next_seq, - ) - events.extend(content_events) - if uses_output_index: - next_output_index += 1 - return events - - update_stream = ResponseStream( - stream, - finalizer=lambda updates: _streamed_updates_output( - updates, - status="completed", - message_id=msg_id, - ), - ) - event_stream = update_stream.flat_map(update_to_events, finalizer=lambda _events: None) - - async for event in event_stream: - yield _sse_event(event) - 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_output = ( - _streamed_updates_output(update_stream.updates, status="failed", message_id=msg_id) - if update_stream is not None - else _text_output_items("", status="failed", message_id=msg_id) - ) - failed = self._build_response( - body, - failed_output, - status="failed", - response_id=response_id, - ) - failed.error = ResponseError(code="server_error", message=str(exc)) - yield _sse_event( - ResponseFailedEvent( - type="response.failed", - response=failed, - sequence_number=next_seq(), - ) - ) - return - - completed = self._build_response( - body, - _result_to_output_items(final_response, status="completed") - if final_response is not None - else await update_stream.get_final_response(), - status="completed", - response_id=response_id, - output_message_id=msg_id, - ) - yield _sse_event( - ResponseCompletedEvent( - type="response.completed", - response=completed, - sequence_number=next_seq(), - ) - ) - - -def _sse_event(event: Any) -> str: - return f"event: {event.type}\ndata: {_event_json(event)}\n\n" - - -def _content_to_stream_events( - content: Content, - *, - message_id: str, - output_index: int, - next_sequence_number: Callable[[], int], -) -> tuple[list[Any], bool]: - events: list[Any] = [] - - def add_start(item: ResponseOutputItem) -> None: - events.append( - ResponseOutputItemAddedEvent( - type="response.output_item.added", - item=item, - output_index=output_index, - sequence_number=next_sequence_number(), - ) - ) - - def add_done(item: ResponseOutputItem) -> None: - events.append( - ResponseOutputItemDoneEvent( - type="response.output_item.done", - item=item, - output_index=output_index, - sequence_number=next_sequence_number(), - ) - ) - - def add_item(item: ResponseOutputItem, *, added_item: ResponseOutputItem | None = None) -> None: - add_start(added_item or item) - add_done(item) - - raw_item = _raw_response_output_item(content.raw_representation) - if raw_item is not None: - add_item(raw_item) - return events, not isinstance(raw_item, ResponseOutputMessage) - - match content.type: - case "text": - text_part = ResponseOutputText(type="output_text", text=content.text or "", annotations=[]) - added_part = ResponseOutputText(type="output_text", text="", annotations=[]) - output_item = ResponseOutputMessage( - id=message_id, - type="message", - role="assistant", - status="completed", - content=[text_part], - ) - events.append( - ResponseOutputItemAddedEvent( - type="response.output_item.added", - item=ResponseOutputMessage( - id=message_id, - type="message", - role="assistant", - status="in_progress", - content=[], - ), - output_index=0, - sequence_number=next_sequence_number(), - ) - ) - events.append( - ResponseContentPartAddedEvent( - type="response.content_part.added", - item_id=message_id, - output_index=0, - content_index=0, - part=added_part, - sequence_number=next_sequence_number(), - ) - ) - if text_part.text: - events.append( - ResponseTextDeltaEvent( - type="response.output_text.delta", - item_id=message_id, - output_index=0, - content_index=0, - delta=text_part.text, - logprobs=[], - sequence_number=next_sequence_number(), - ) - ) - events.append( - ResponseTextDoneEvent( - type="response.output_text.done", - item_id=message_id, - output_index=0, - content_index=0, - text=text_part.text, - logprobs=[], - sequence_number=next_sequence_number(), - ) - ) - events.append( - ResponseContentPartDoneEvent( - type="response.content_part.done", - item_id=message_id, - output_index=0, - content_index=0, - part=text_part, - sequence_number=next_sequence_number(), - ) - ) - events.append( - ResponseOutputItemDoneEvent( - type="response.output_item.done", - item=output_item, - output_index=0, - sequence_number=next_sequence_number(), - ) - ) - return events, False - - case "text_reasoning": - reasoning_id = content.id or f"rs_{uuid.uuid4().hex}" - text = content.text or "" - output_item = _reasoning_output_item(content, status="completed") - add_start( - ResponseReasoningItem( - id=reasoning_id, - type="reasoning", - summary=[], - content=[], - status="in_progress", - ) - ) - if text: - events.append( - ResponseReasoningTextDeltaEvent( - type="response.reasoning_text.delta", - item_id=reasoning_id, - output_index=output_index, - content_index=0, - delta=text, - sequence_number=next_sequence_number(), - ), - ) - events.append( - ResponseReasoningTextDoneEvent( - type="response.reasoning_text.done", - item_id=reasoning_id, - output_index=output_index, - content_index=0, - text=text, - sequence_number=next_sequence_number(), - ), - ) - add_done(output_item) - return events, True - - case "function_call": - output_item = _function_call_output_item(content, status="completed") - if not isinstance(output_item, ResponseFunctionToolCall): - add_item(output_item) - return events, True - add_start( - ResponseFunctionToolCall( - id=output_item.id, - type="function_call", - call_id=output_item.call_id, - name=output_item.name, - arguments="", - status="in_progress", - ) - ) - if output_item.arguments: - item_id = output_item.id or output_item.call_id - events.append( - ResponseFunctionCallArgumentsDeltaEvent( - type="response.function_call_arguments.delta", - item_id=item_id, - output_index=output_index, - delta=output_item.arguments, - sequence_number=next_sequence_number(), - ), - ) - events.append( - ResponseFunctionCallArgumentsDoneEvent( - type="response.function_call_arguments.done", - item_id=item_id, - output_index=output_index, - arguments=output_item.arguments, - name=output_item.name, - sequence_number=next_sequence_number(), - ), - ) - add_done(output_item) - return events, True - - case "mcp_server_tool_call": - output_item = _mcp_call_output_item(content, status="completed") - if not isinstance(output_item, McpCall): - add_item(output_item) - return events, True - add_start( - McpCall( - id=output_item.id, - type="mcp_call", - server_label=output_item.server_label, - name=output_item.name, - arguments="", - status="in_progress", - ) - ) - if output_item.arguments: - events.append( - ResponseMcpCallArgumentsDeltaEvent( - type="response.mcp_call_arguments.delta", - item_id=output_item.id, - output_index=output_index, - delta=output_item.arguments, - sequence_number=next_sequence_number(), - ), - ) - events.append( - ResponseMcpCallArgumentsDoneEvent( - type="response.mcp_call_arguments.done", - item_id=output_item.id, - output_index=output_index, - arguments=output_item.arguments, - sequence_number=next_sequence_number(), - ), - ) - add_done(output_item) - return events, True - - case "code_interpreter_tool_call" | "code_interpreter_tool_result": - output_item = _code_interpreter_output_item(content, status="completed") - if not isinstance(output_item, ResponseCodeInterpreterToolCall): - add_item(output_item) - return events, True - add_start( - ResponseCodeInterpreterToolCall( - id=output_item.id, - type="code_interpreter_call", - container_id=output_item.container_id, - code="", - outputs=None, - status="in_progress", - ) - ) - if output_item.code: - events.append( - ResponseCodeInterpreterCallCodeDeltaEvent( - type="response.code_interpreter_call_code.delta", - item_id=output_item.id, - output_index=output_index, - delta=output_item.code, - sequence_number=next_sequence_number(), - ), - ) - events.append( - ResponseCodeInterpreterCallCodeDoneEvent( - type="response.code_interpreter_call_code.done", - item_id=output_item.id, - output_index=output_index, - code=output_item.code, - sequence_number=next_sequence_number(), - ), - ) - add_done(output_item) - return events, True - - case "function_result": - add_item(_function_result_output_item(content, status="completed")) - return events, True - - case "image_generation_tool_call" | "image_generation_tool_result": - add_item(_image_generation_output_item(content, status="completed")) - return events, True - - case "mcp_server_tool_result": - add_item(_mcp_result_output_item(content, status="completed")) - return events, True - - case "shell_tool_call": - add_item(_shell_call_output_item(content, status="completed")) - return events, True - - case "shell_tool_result": - add_item(_shell_result_output_item(content, status="completed")) - return events, True - - case "function_approval_request": - add_item(_function_approval_request_output_item(content)) - return events, True - - case "function_approval_response": - add_item(_function_approval_response_output_item(content)) - return events, True - - case "data" | "uri" | "hosted_file": - add_item(_media_content_output_item(content, status="completed")) - return events, True - - case "error": - error_text = str(content) - text_content = Content.from_text(error_text) - return _content_to_stream_events( - text_content, - message_id=message_id, - output_index=output_index, - next_sequence_number=next_sequence_number, - ) - - case _: - return [], False - - -def _streamed_updates_output( - updates: Sequence[AgentResponseUpdate], - *, - status: str, - message_id: str, -) -> list[ResponseOutputItem]: - if not updates: - return _text_output_items("", status=status, message_id=message_id) - output_items = _result_to_output_items(AgentResponse.from_updates(updates), status=status) - for output_item in output_items: - if isinstance(output_item, ResponseOutputMessage): - output_item.id = message_id - break - return output_items - - -def _result_to_output_items(result: Any, *, status: str) -> list[ResponseOutputItem]: - """Render an agent or workflow result as Responses output items.""" - messages = getattr(result, "messages", None) - if isinstance(messages, Sequence) and not isinstance(messages, (str, bytes, bytearray)): - return _messages_to_output_items(cast("Sequence[Any]", messages), status=status) - - if isinstance(result, Message): - return _messages_to_output_items([result], status=status) - if isinstance(result, Content): - return _contents_to_output_items([result], status=status) - - get_outputs = getattr(result, "get_outputs", None) - if callable(get_outputs): - output_items: list[ResponseOutputItem] = [] - for output in cast("Sequence[Any]", get_outputs()): - output_items.extend(_output_to_output_items(output, status=status)) - return output_items - - text = getattr(result, "text", None) - if isinstance(text, str): - return _text_output_items(text, status=status) - return _text_output_items(_result_to_text(result), status=status) - - -def _output_to_output_items(output: Any, *, status: str) -> list[ResponseOutputItem]: - if isinstance(output, Message): - return _messages_to_output_items([output], status=status) - if isinstance(output, Content): - return _contents_to_output_items([output], status=status) - messages = getattr(output, "messages", None) - if isinstance(messages, Sequence) and not isinstance(messages, (str, bytes, bytearray)): - return _messages_to_output_items(cast("Sequence[Any]", messages), status=status) - text = getattr(output, "text", None) - if isinstance(text, str): - return _text_output_items(text, status=status) - return _text_output_items(str(output), status=status) - - -def _messages_to_output_items(messages: Sequence[Any], *, status: str) -> list[ResponseOutputItem]: - output_items: list[ResponseOutputItem] = [] - message_contents: list[Content] = [] - - for message in messages: - if not isinstance(message, Message): - if message_contents: - output_items.extend(_contents_to_output_items(message_contents, status=status)) - message_contents.clear() - output_items.extend(_output_to_output_items(message, status=status)) - continue - message_contents.extend(message.contents) - - if message_contents: - output_items.extend(_contents_to_output_items(message_contents, status=status)) - - return output_items - - -def _contents_to_output_items( - contents: Sequence[Content], - *, - status: str, - seen_raw_items: dict[tuple[str, str], int] | None = None, -) -> list[ResponseOutputItem]: - output_items: list[ResponseOutputItem] = [] - message_content: list[Any] = [] - seen: dict[tuple[str, str], int] = seen_raw_items if seen_raw_items is not None else {} - - def flush_message() -> None: - if not message_content: - return - output_items.append(_message_output_item(message_content, status=status)) - message_content.clear() - - content_list = list(contents) - index = 0 - while index < len(content_list): - content = content_list[index] - raw_item = _raw_response_output_item(content.raw_representation) - if raw_item is not None: - raw_key = _response_output_item_key(raw_item) - if raw_key in seen: - output_items[seen[raw_key]] = raw_item - else: - flush_message() - seen[raw_key] = len(output_items) - output_items.append(raw_item) - index += 1 - continue - - next_content = content_list[index + 1] if index + 1 < len(content_list) else None - if _is_matching_code_interpreter_result(content, next_content): - flush_message() - output_items.append(_code_interpreter_output_item(content, status=status, result_content=next_content)) - index += 2 - continue - if _is_matching_image_generation_result(content, next_content): - flush_message() - output_items.append(_image_generation_output_item(content, status=status, result_content=next_content)) - index += 2 - continue - if _is_matching_mcp_result(content, next_content): - flush_message() - output_items.append(_mcp_call_output_item(content, status=status, result_content=next_content)) - index += 2 - continue - - match content.type: - case "text": - message_content.append(_message_text_content(content)) - case "text_reasoning": - flush_message() - output_items.append(_reasoning_output_item(content, status=status)) - case "function_call": - flush_message() - output_items.append(_function_call_output_item(content, status=status)) - case "function_result": - flush_message() - output_items.append(_function_result_output_item(content, status=status)) - case "code_interpreter_tool_call" | "code_interpreter_tool_result": - flush_message() - output_items.append(_code_interpreter_output_item(content, status=status)) - case "image_generation_tool_call" | "image_generation_tool_result": - flush_message() - output_items.append(_image_generation_output_item(content, status=status)) - case "mcp_server_tool_call": - flush_message() - output_items.append(_mcp_call_output_item(content, status=status)) - case "mcp_server_tool_result": - flush_message() - output_items.append(_mcp_result_output_item(content, status=status)) - case "shell_tool_call": - flush_message() - output_items.append(_shell_call_output_item(content, status=status)) - case "shell_tool_result": - flush_message() - output_items.append(_shell_result_output_item(content, status=status)) - case "function_approval_request": - flush_message() - output_items.append(_function_approval_request_output_item(content)) - case "function_approval_response": - flush_message() - output_items.append(_function_approval_response_output_item(content)) - case "data" | "uri" | "hosted_file": - flush_message() - output_items.append(_media_content_output_item(content, status=status)) - case "error": - message_content.append(ResponseOutputText(type="output_text", text=str(content), annotations=[])) - case _: - flush_message() - output_items.extend(_text_output_items(json.dumps(content.to_dict(), default=str), status=status)) - index += 1 - - flush_message() - return output_items - - -def _is_matching_code_interpreter_result(content: Content, next_content: Content | None) -> bool: - return ( - content.type == "code_interpreter_tool_call" - and next_content is not None - and next_content.type == "code_interpreter_tool_result" - and content.call_id == next_content.call_id - ) - - -def _is_matching_image_generation_result(content: Content, next_content: Content | None) -> bool: - return ( - content.type == "image_generation_tool_call" - and next_content is not None - and next_content.type == "image_generation_tool_result" - and content.image_id == next_content.image_id - ) - - -def _is_matching_mcp_result(content: Content, next_content: Content | None) -> bool: - return ( - content.type == "mcp_server_tool_call" - and next_content is not None - and next_content.type == "mcp_server_tool_result" - and content.call_id == next_content.call_id - ) - - -def _message_status(status: str) -> str: - return status if status in ("in_progress", "completed", "incomplete") else "incomplete" - - -def _text_output_items(text: str, *, status: str, message_id: str | None = None) -> list[ResponseOutputItem]: - return [ - _message_output_item( - [ResponseOutputText(type="output_text", text=text, annotations=[])], - status=status, - message_id=message_id, - ) - ] - - -def _message_output_item(content: Sequence[Any], *, status: str, message_id: str | None = None) -> ResponseOutputItem: - return cast( - "ResponseOutputItem", - ResponseOutputMessage( - id=message_id or f"msg_{uuid.uuid4().hex}", - type="message", - role="assistant", - status=_message_status(status), # type: ignore[arg-type] - content=list(content), - ), - ) - - -def _message_text_content(content: Content) -> Any: - raw_type = _raw_type(content.raw_representation) - if raw_type in ("output_text", "refusal"): - return content.raw_representation - return ResponseOutputText(type="output_text", text=content.text or "", annotations=[]) - - -def _reasoning_output_item(content: Content, *, status: str) -> ResponseOutputItem: - reasoning_text = content.text or "" - item_id = content.id or f"rs_{uuid.uuid4().hex}" - item_data: dict[str, Any] = { - "id": item_id, - "type": "reasoning", - "summary": [], - "status": _message_status(status), - } - if reasoning_text: - item_data["content"] = [{"type": "reasoning_text", "text": reasoning_text}] - if content.protected_data: - item_data["encrypted_content"] = content.protected_data - return _response_output_item(item_data) - - -def _function_call_output_item(content: Content, *, status: str) -> ResponseOutputItem: - return cast( - "ResponseOutputItem", - ResponseFunctionToolCall( - id=content.additional_properties.get("fc_id") if content.additional_properties else None, - type="function_call", - call_id=content.call_id or f"call_{uuid.uuid4().hex}", - name=content.name or "tool", - arguments=_arguments_to_str(content.arguments), - status=_message_status(status), # type: ignore[arg-type] - ), - ) - - -def _function_result_output_item(content: Content, *, status: str) -> ResponseOutputItem: - output: str | list[Any] - if content.exception: - output = content.exception - elif output_parts := _content_parts_to_input_items(content.items): - output = output_parts - elif isinstance(content.result, str): - output = content.result - elif content.result is None: - output = "" - else: - output = json.dumps(content.result, default=str) - return cast( - "ResponseOutputItem", - ResponseFunctionToolCallOutputItem( - id=f"fcout_{uuid.uuid4().hex}", - type="function_call_output", - call_id=content.call_id or f"call_{uuid.uuid4().hex}", - output=output, - status=_message_status(status), # type: ignore[arg-type] - ), - ) - - -def _code_interpreter_output_item( - content: Content, - *, - status: str, - result_content: Content | None = None, -) -> ResponseOutputItem: - code = _content_sequence_text(content.inputs) - output_parts: list[dict[str, Any]] = [] - outputs_value: Any = result_content.outputs if result_content is not None else content.outputs - if isinstance(outputs_value, Sequence) and not isinstance(outputs_value, (str, bytes, bytearray)): - for item in cast("Sequence[Any]", outputs_value): - if not isinstance(item, Content): - continue - if item.type == "text": - output_parts.append({"type": "logs", "logs": item.text or ""}) - elif item.type in ("data", "uri") and item.uri: - output_parts.append({"type": "image", "url": item.uri}) - - return _response_output_item({ - "id": _content_item_id(content, result_content) or f"ci_{uuid.uuid4().hex}", - "type": "code_interpreter_call", - "code": code, - "container_id": str(_content_property(content, result_content, "container_id") or "agent_framework"), - "outputs": output_parts or None, - "status": _code_interpreter_status(status), - }) - - -def _image_generation_output_item( - content: Content, - *, - status: str, - result_content: Content | None = None, -) -> ResponseOutputItem: - result_source = result_content.outputs if result_content is not None else content.outputs - result = _image_generation_result(result_source) - image_id = content.image_id or (result_content.image_id if result_content is not None else None) - return _response_output_item({ - "id": image_id or f"ig_{uuid.uuid4().hex}", - "type": "image_generation_call", - "result": result, - "status": _image_generation_status(status), - }) - - -def _mcp_call_output_item( - content: Content, - *, - status: str, - result_content: Content | None = None, -) -> ResponseOutputItem: - output = _stringify_output(result_content.output) if result_content is not None else None - return _response_output_item({ - "id": content.call_id or f"mcp_{uuid.uuid4().hex}", - "type": "mcp_call", - "server_label": content.server_name or "default", - "name": content.tool_name or "tool", - "arguments": _arguments_to_str(content.arguments), - "output": output, - "status": _mcp_status(status), - }) - - -def _mcp_result_output_item(content: Content, *, status: str) -> ResponseOutputItem: - return _response_output_item({ - "id": content.call_id or f"mcp_{uuid.uuid4().hex}", - "type": "mcp_call", - "server_label": content.server_name or "default", - "name": content.tool_name or "tool", - "arguments": "", - "output": _stringify_output(content.output), - "status": _mcp_status(status), - }) - - -def _shell_call_output_item(content: Content, *, status: str) -> ResponseOutputItem: - return _response_output_item({ - "id": content.additional_properties.get("item_id") or f"shell_{uuid.uuid4().hex}", - "type": "shell_call", - "call_id": content.call_id or f"call_{uuid.uuid4().hex}", - "action": { - "commands": content.commands or [], - "timeout_ms": content.timeout_ms, - "max_output_length": content.max_output_length, - }, - "environment": {"type": "local"}, - "status": _message_status(status), - }) - - -def _shell_result_output_item(content: Content, *, status: str) -> ResponseOutputItem: - outputs: list[dict[str, Any]] = [] - outputs_value: Any = content.outputs - if isinstance(outputs_value, Sequence) and not isinstance(outputs_value, (str, bytes, bytearray)): - for item in cast("Sequence[Any]", outputs_value): - if not isinstance(item, Content): - continue - outcome = {"type": "timeout"} if item.timed_out else {"type": "exit", "exit_code": item.exit_code or 0} - outputs.append({"stdout": item.stdout or "", "stderr": item.stderr or "", "outcome": outcome}) - - return _response_output_item({ - "id": content.additional_properties.get("item_id") or f"shellout_{uuid.uuid4().hex}", - "type": "shell_call_output", - "call_id": content.call_id or f"call_{uuid.uuid4().hex}", - "output": outputs, - "max_output_length": content.max_output_length, - "status": _message_status(status), - }) - - -def _function_approval_request_output_item(content: Content) -> ResponseOutputItem: - function_call = content.function_call - return _response_output_item({ - "id": content.id or f"approval_{uuid.uuid4().hex}", - "type": "mcp_approval_request", - "server_label": ( - function_call.additional_properties.get("server_label", "agent_framework") - if function_call is not None - else "agent_framework" - ), - "name": function_call.name if function_call is not None and function_call.name else "tool", - "arguments": _arguments_to_str(function_call.arguments if function_call is not None else None), - }) - - -def _function_approval_response_output_item(content: Content) -> ResponseOutputItem: - return _response_output_item({ - "id": content.id or f"approval_{uuid.uuid4().hex}", - "type": "mcp_approval_response", - "approval_request_id": content.id or "", - "approve": bool(content.approved), - }) - - -def _media_content_output_item(content: Content, *, status: str) -> ResponseOutputItem: - parts = _content_parts_to_input_items([content]) - if parts: - return cast( - "ResponseOutputItem", - ResponseFunctionToolCallOutputItem( - id=f"content_{uuid.uuid4().hex}", - type="function_call_output", - call_id=f"content_{uuid.uuid4().hex}", - output=parts, - status=_message_status(status), # type: ignore[arg-type] - ), - ) - return _text_output_items(json.dumps(content.to_dict(), default=str), status=status)[0] - - -def _content_parts_to_input_items(contents: Sequence[Content] | None) -> list[Any]: - if not contents: - return [] - - parts: list[Any] = [] - for content in contents: - match content.type: - case "text": - parts.append(ResponseInputText(type="input_text", text=content.text or "")) - case "data" | "uri": - if not content.uri: - continue - if _is_image_content(content): - parts.append(ResponseInputImage(type="input_image", image_url=content.uri, detail="auto")) - else: - parts.append(ResponseInputFile(type="input_file", file_url=content.uri)) - case "hosted_file": - if content.file_id: - parts.append(ResponseInputFile(type="input_file", file_id=content.file_id)) - case _: - parts.append(ResponseInputText(type="input_text", text=json.dumps(content.to_dict(), default=str))) - return parts - - -def _content_sequence_text(contents: Sequence[Content] | None) -> str | None: - if not contents: - return None - text = "".join(content.text or "" for content in contents if content.type == "text") - return text or None - - -def _is_image_content(content: Content) -> bool: - media_type = content.media_type or "" - if media_type.startswith("image/"): - return True - uri = content.uri or "" - return uri.startswith("data:image/") - - -def _image_generation_result(outputs: Any) -> str | None: - if isinstance(outputs, Content): - return _image_generation_content_result(outputs) - if isinstance(outputs, Sequence) and not isinstance(outputs, (str, bytes, bytearray)): - for output in cast("Sequence[Any]", outputs): - if isinstance(output, Content) and (result := _image_generation_content_result(output)): - return result - if isinstance(outputs, str): - return outputs - return None - - -def _image_generation_content_result(content: Content) -> str | None: - uri = content.uri - if not uri: - return None - if ";base64," in uri: - return uri.split(";base64,", 1)[1] - return uri - - -def _content_item_id(content: Content, result_content: Content | None = None) -> str | None: - item_id = content.additional_properties.get("item_id") - if isinstance(item_id, str) and item_id: - return item_id - if result_content is not None: - result_item_id = result_content.additional_properties.get("item_id") - if isinstance(result_item_id, str) and result_item_id: - return result_item_id - return content.call_id or (result_content.call_id if result_content is not None else None) - - -def _content_property(content: Content, result_content: Content | None, key: str) -> Any: - if key in content.additional_properties: - return content.additional_properties[key] - if result_content is not None and key in result_content.additional_properties: - return result_content.additional_properties[key] - return None - - -def _code_interpreter_status(status: str) -> str: - if status in ("in_progress", "completed", "incomplete", "failed"): - return status - return "incomplete" - - -def _image_generation_status(status: str) -> str: - if status in ("in_progress", "completed", "failed"): - return status - return "failed" - - -def _mcp_status(status: str) -> str: - if status in ("in_progress", "completed", "incomplete", "failed"): - return status - return "incomplete" - - -def _arguments_to_str(arguments: Any | None) -> str: - if arguments is None: - return "" - if isinstance(arguments, str): - return arguments - return json.dumps(arguments, default=str) - - -def _stringify_output(output: Any) -> str: - if output is None: - return "" - if isinstance(output, str): - return output - if isinstance(output, Sequence) and not isinstance(output, (str, bytes, bytearray)): - parts: list[str] = [] - for item in cast("Sequence[Any]", output): - if isinstance(item, Content) and item.type == "text": - parts.append(item.text or "") - else: - parts.append(_stringify_output(item)) - return "".join(parts) - return json.dumps(output, default=str) - - -def _raw_response_output_item(raw: Any) -> ResponseOutputItem | None: - if _raw_type(raw) is None: - return None - try: - return cast("ResponseOutputItem", _RESPONSE_OUTPUT_ITEM_ADAPTER.validate_python(raw)) - except ValidationError: - return None - - -def _response_output_item(value: Mapping[str, Any]) -> ResponseOutputItem: - return cast("ResponseOutputItem", _RESPONSE_OUTPUT_ITEM_ADAPTER.validate_python(value)) - - -def _response_output_item_key(item: ResponseOutputItem) -> tuple[str, str]: - item_type = _raw_type(item) or "unknown" - item_id = getattr(item, "id", None) or getattr(item, "call_id", None) - if isinstance(item_id, str) and item_id: - return item_type, item_id - return item_type, str(id(item)) - - -def _raw_type(raw: Any) -> str | None: - raw_type = getattr(raw, "type", None) - if isinstance(raw_type, str): - return raw_type - if isinstance(raw, Mapping): - raw_mapping = cast("Mapping[str, Any]", raw) - mapping_type = raw_mapping.get("type") - if isinstance(mapping_type, str): - return mapping_type - return None - - -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 fef7dcb1bff..d9333ec7685 100644 --- a/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py +++ b/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py @@ -7,10 +7,8 @@ / ``input_file``) or a message envelope ``{type: "message", role, content: [...]}``. We translate that into an Agent Framework ``Message`` list and remap the generation-control fields the API also carries into -``ChatOptions``-shaped keys. The result is available to the channel's -``run_hook``; a default hook strips them before they reach the agent so -unknown fields from untrusted callers are not forwarded unless the host -developer explicitly opts in. +``ChatOptions``-shaped keys. App-owned route code decides which options to +pass through to ``agent.run(...)`` and which request-owned fields to drop. """ from __future__ import annotations @@ -22,7 +20,7 @@ from typing import Any, cast from agent_framework import AgentResponse, AgentResponseUpdate, ChatOptions, Content, Message, ResponseStream -from agent_framework_hosting import AgentRunArgs, ChannelIdentity, ChannelSession +from agent_framework_hosting import AgentRunArgs from openai.types.responses import ( Response as OpenAIResponse, ) @@ -47,22 +45,9 @@ } # Fields the Responses transport owns; they are consumed separately and must # not also appear in options. -_RESPONSES_TRANSPORT_KEYS = frozenset({"input", "stream", "previous_response_id"}) _RESPONSES_RUN_TRANSPORT_KEYS = frozenset({"input", "stream", "previous_response_id", "conversation_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. @@ -135,40 +120,6 @@ def flush() -> None: 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 remapped - generation-control fields. Known Responses→ChatOptions renames are - applied (e.g. ``max_output_tokens`` → ``max_tokens``); transport/ - session keys are excluded; ``None``-valued fields are dropped. - Unknown fields are forwarded as-is so the channel's ``run_hook`` - can inspect and filter them. The default ``ResponsesChannel`` strips - all options before the agent runs; supply a custom ``run_hook`` to - selectively keep fields. - - ``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 - options[_RESPONSES_OPTION_REMAP.get(key, key)] = value - - session: ChannelSession | None = None - if (prev := body.get("previous_response_id")) and isinstance(prev, str): - session = ChannelSession(isolation_key=prev) - - return messages, options, session - - def create_response_id() -> str: """Create a Responses-shaped response id.""" return f"resp_{uuid.uuid4().hex}" @@ -950,8 +901,6 @@ async def responses_from_streaming_run( __all__ = [ "create_response_id", "messages_from_responses_input", - "parse_responses_identity", - "parse_responses_request", "responses_from_run", "responses_from_streaming_run", "responses_session_id", diff --git a/python/packages/hosting-responses/pyproject.toml b/python/packages/hosting-responses/pyproject.toml index 82806d9ebaa..598741f6447 100644 --- a/python/packages/hosting-responses/pyproject.toml +++ b/python/packages/hosting-responses/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-framework-hosting-responses" -description = "OpenAI Responses-shaped channel for agent-framework-hosting." +description = "OpenAI Responses-shaped helpers for agent-framework-hosting." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" diff --git a/python/packages/hosting-responses/tests/hosting_responses/test_channel.py b/python/packages/hosting-responses/tests/hosting_responses/test_channel.py deleted file mode 100644 index 0332b3f13b5..00000000000 --- a/python/packages/hosting-responses/tests/hosting_responses/test_channel.py +++ /dev/null @@ -1,689 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""End-to-end tests for :class:`ResponsesChannel` via Starlette's ``TestClient``.""" - -from __future__ import annotations - -import json -from collections.abc import AsyncIterator -from dataclasses import dataclass -from typing import Any - -from agent_framework import AgentResponse, AgentResponseUpdate, Content, Message, ServiceSessionId -from agent_framework_hosting import ( - AgentFrameworkHost, - HostedRunResult, -) -from opentelemetry import context as otel_context -from opentelemetry import trace -from starlette.middleware.base import BaseHTTPMiddleware -from starlette.testclient import TestClient - -from agent_framework_hosting_responses import ResponsesChannel -from agent_framework_hosting_responses._channel import ( # pyright: ignore[reportPrivateUsage] - _result_to_output_items, - _result_to_text, -) - -# --------------------------------------------------------------------------- # -# Fakes # -# --------------------------------------------------------------------------- # - - -@dataclass -class _FakeAgentResponse: - 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[AgentResponseUpdate]: - async def _gen() -> AsyncIterator[AgentResponseUpdate]: - for c in self._chunks: - yield AgentResponseUpdate(contents=[Content.from_text(c)], role="assistant") - - return _gen() - - async def get_final_response(self) -> _FakeAgentResponse: - return self._final - - -class _FakeAgent: - def __init__(self, reply: Any = "hello", chunks: list[str] | None = None) -> None: - self.id = "fake-agent" - self.name: str | None = "Fake Agent" - self.description: str | None = "Test fake agent" - 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 get_session(self, service_session_id: str | ServiceSessionId, *, session_id: str | None = None) -> Any: - return {"service_session_id": service_session_id, "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() -> Any: - if not isinstance(self._reply, str): - return self._reply - return _FakeAgentResponse(text=self._reply) - - return _coro() - - -# --------------------------------------------------------------------------- # -# Tests # -# --------------------------------------------------------------------------- # - - -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, response_id_factory=response_id_factory)], - ) - return TestClient(host.app), host, agent - - -def _sse_payload(body: str, event_type: str) -> dict[str, Any]: - current_event: str | None = None - for line in body.splitlines(): - if line.startswith("event: "): - current_event = line[len("event: ") :] - continue - if current_event == event_type and line.startswith("data: "): - return json.loads(line[len("data: ") :]) - raise AssertionError(f"Missing SSE event: {event_type}") - - -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 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: - r = client.post("/", json={"input": "hi"}) - 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: - r = client.post("/responses", content=b"{not json", headers={"content-type": "application/json"}) - assert r.status_code == 400 - - def test_non_object_json_returns_422(self) -> None: - client, *_ = _make_client() - with client: - r = client.post("/responses", json=["not", "an", "object"]) - assert r.status_code == 422 - assert r.json()["error"] == "request body must be a JSON object" - - 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_request_options_are_not_forwarded_by_default(self) -> None: - client, _host, agent = _make_client() - with client: - r = client.post( - "/responses", - json={"input": "x", "temperature": 0.5, "max_output_tokens": 64, "truncation": "auto"}, - ) - assert r.status_code == 200 - assert "options" not in agent.calls[0]["kwargs"] - - def test_custom_run_hook_can_forward_options(self) -> None: - import dataclasses - - def keep_temperature(request: Any, **_: Any) -> Any: - opts = dict(request.options or {}) - return dataclasses.replace(request, options={"temperature": opts.get("temperature")}) - - agent = _FakeAgent() - host = AgentFrameworkHost( - target=agent, - channels=[ResponsesChannel(run_hook=keep_temperature)], - ) - with TestClient(host.app) as client: - r = client.post("/responses", json={"input": "x", "temperature": 0.7, "truncation": "auto"}) - assert r.status_code == 200 - opts = agent.calls[0]["kwargs"]["options"] - assert opts == {"temperature": 0.7} - assert "truncation" not in opts - - def test_multimodal_agent_response_outputs_are_preserved(self) -> None: - response = AgentResponse( - messages=[ - Message( - "assistant", - [ - Content.from_text_reasoning(id="rs_1", text="checking"), - Content.from_function_call("call_1", "collect_media", arguments={"city": "Seattle"}), - Content.from_function_result( - "call_1", - result=[ - Content.from_text("caption"), - Content.from_uri("https://example.com/cat.png", media_type="image/png"), - Content.from_hosted_file("file_pdf", media_type="application/pdf"), - ], - ), - Content.from_text("done"), - ], - ) - ], - ) - client, _host, _agent = _make_client(_FakeAgent(reply=response)) - - with client: - r = client.post("/responses", json={"input": "hi"}) - - assert r.status_code == 200 - output = r.json()["output"] - assert [item["type"] for item in output] == [ - "reasoning", - "function_call", - "function_call_output", - "message", - ] - assert output[0]["content"][0]["text"] == "checking" - assert output[1]["name"] == "collect_media" - assert output[1]["arguments"] == '{"city": "Seattle"}' - assert output[2]["output"] == [ - {"text": "caption", "type": "input_text"}, - {"detail": "auto", "type": "input_image", "image_url": "https://example.com/cat.png"}, - {"type": "input_file", "file_id": "file_pdf"}, - ] - assert output[3]["content"][0]["text"] == "done" - - def test_raw_responses_output_items_are_preserved(self) -> None: - raw_item = { - "id": "ig_1", - "type": "image_generation_call", - "result": "base64-image", - "status": "completed", - } - response = AgentResponse( - messages=[ - Message( - "assistant", - [ - Content.from_image_generation_tool_call(image_id="ig_1", raw_representation=raw_item), - Content.from_image_generation_tool_result( - image_id="ig_1", - outputs=Content.from_uri("data:image/png;base64,base64-image", media_type="image/png"), - raw_representation=raw_item, - ), - ], - ) - ], - ) - client, _host, _agent = _make_client(_FakeAgent(reply=response)) - - with client: - r = client.post("/responses", json={"input": "hi"}) - - assert r.status_code == 200 - assert r.json()["output"] == [raw_item] - - def test_later_raw_responses_output_item_replaces_earlier_partial_item(self) -> None: - partial = { - "id": "mcp_1", - "type": "mcp_call", - "server_label": "weather", - "name": "lookup", - "arguments": "{}", - "status": "in_progress", - } - completed = {**partial, "status": "completed", "output": "sunny"} - response = AgentResponse( - messages=[ - Message( - "assistant", - [ - Content.from_mcp_server_tool_call( - "mcp_1", - "lookup", - server_name="weather", - raw_representation=partial, - ), - Content.from_mcp_server_tool_result("mcp_1", output="sunny", raw_representation=completed), - ], - ) - ], - ) - client, _host, _agent = _make_client(_FakeAgent(reply=response)) - - with client: - r = client.post("/responses", json={"input": "hi"}) - - assert r.status_code == 200 - assert r.json()["output"] == [completed] - - 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_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(response_id_factory=lambda *_: "resp_local") - 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"] == "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. - - 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 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" - - def test_result_output_items_project_workflow_message_and_content_outputs(self) -> None: - class _WorkflowResult: - def get_outputs(self) -> list[Any]: - return [ - Message("assistant", [Content.from_text("one")]), - Content.from_function_result( - "call_1", - result=[Content.from_uri("https://example.com/cat.png", media_type="image/png")], - ), - ] - - output = [ - item.model_dump(mode="json", exclude_none=True) - for item in _result_to_output_items(_WorkflowResult(), status="completed") - ] - assert output[0]["type"] == "message" - assert output[0]["content"][0]["text"] == "one" - assert output[1]["type"] == "function_call_output" - assert output[1]["output"] == [ - {"detail": "auto", "type": "input_image", "image_url": "https://example.com/cat.png"} - ] - - def test_function_result_exception_is_preserved(self) -> None: - output = [ - item.model_dump(mode="json", exclude_none=True) - for item in _result_to_output_items( - Content.from_function_result("call_1", exception="tool failed"), - status="completed", - ) - ] - assert output[0]["output"] == "tool failed" - - def test_stateful_call_and_result_content_coalesce_to_one_output_item(self) -> None: - output = [ - item.model_dump(mode="json", exclude_none=True) - for item in _result_to_output_items( - Message( - "assistant", - [ - Content.from_image_generation_tool_call(image_id="ig_1"), - Content.from_image_generation_tool_result( - image_id="ig_1", - outputs=Content.from_uri("data:image/png;base64,base64-image", media_type="image/png"), - ), - Content.from_mcp_server_tool_call( - "mcp_1", - "lookup", - server_name="weather", - arguments={"city": "Seattle"}, - ), - Content.from_mcp_server_tool_result("mcp_1", output=[Content.from_text("sunny")]), - ], - ), - status="completed", - ) - ] - assert output == [ - { - "id": "ig_1", - "result": "base64-image", - "status": "completed", - "type": "image_generation_call", - }, - { - "id": "mcp_1", - "arguments": '{"city": "Seattle"}', - "name": "lookup", - "output": "sunny", - "server_label": "weather", - "status": "completed", - "type": "mcp_call", - }, - ] - - def test_stateful_call_and_result_content_coalesce_across_messages(self) -> None: - output = [ - item.model_dump(mode="json", exclude_none=True) - for item in _result_to_output_items( - AgentResponse( - messages=[ - Message( - "assistant", - [ - Content.from_mcp_server_tool_call( - "mcp_1", - "lookup", - server_name="weather", - arguments={"city": "Seattle"}, - ) - ], - ), - Message( - "tool", - [Content.from_mcp_server_tool_result("mcp_1", output=[Content.from_text("sunny")])], - ), - ] - ), - status="completed", - ) - ] - assert output == [ - { - "id": "mcp_1", - "arguments": '{"city": "Seattle"}', - "name": "lookup", - "output": "sunny", - "server_label": "weather", - "status": "completed", - "type": "mcp_call", - } - ] - - -class TestResponsesChannelStreaming: - def test_sse_streaming_uses_request_parent_span_context(self) -> None: - observed: dict[str, int] = {} - parent_ctx = trace.SpanContext( - trace_id=0xABCDEF00112233445566778899AABBCC, - span_id=0x1122334455667788, - is_remote=False, - trace_flags=trace.TraceFlags(0x01), - trace_state=trace.TraceState(), - ) - parent_span = trace.NonRecordingSpan(parent_ctx) - - class _SpanAwareAgent(_FakeAgent): - def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any: - self.calls.append({"messages": messages, "stream": stream, "kwargs": kwargs}) - if stream: - observed["run_span_id"] = trace.get_current_span().get_span_context().span_id - return _FakeStream(["chunk"]) - return super().run(messages=messages, stream=stream, **kwargs) - - async def _middleware_dispatch(request: Any, call_next: Any) -> Any: - token = otel_context.attach(trace.set_span_in_context(parent_span)) - try: - return await call_next(request) - finally: - otel_context.detach(token) - - host = AgentFrameworkHost(target=_SpanAwareAgent(), channels=[ResponsesChannel()]) - host.app.add_middleware(BaseHTTPMiddleware, dispatch=_middleware_dispatch) - - with TestClient(host.app) as client: - r = client.post("/responses", json={"input": "hi", "stream": True}) - - assert r.status_code == 200 - assert observed["run_span_id"] == parent_ctx.span_id - - 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: AgentResponseUpdate) -> AgentResponseUpdate: - return AgentResponseUpdate(contents=[Content.from_text(update.text.upper())], role="assistant") - - 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_completed_preserves_streamed_multimodal_updates_when_finalize_fails(self) -> None: - class _MultimodalStream: - def __aiter__(self) -> AsyncIterator[AgentResponseUpdate]: - async def _gen() -> AsyncIterator[AgentResponseUpdate]: - yield AgentResponseUpdate( - contents=[ - Content.from_text("caption"), - Content.from_text_reasoning(id="rs_1", text="thinking"), - Content.from_function_call("call_1", "lookup", arguments={"city": "Seattle"}), - Content.from_uri("https://example.com/cat.png", media_type="image/png"), - ], - role="assistant", - ) - - return _gen() - - async def get_final_response(self) -> _FakeAgentResponse: - raise RuntimeError("finalize unavailable") - - class _MultimodalAgent(_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 _MultimodalStream() - raise AssertionError("non-streaming path not exercised here") - - host = AgentFrameworkHost(target=_MultimodalAgent(), channels=[ResponsesChannel()]) - with TestClient(host.app) as client: - r = client.post("/responses", json={"input": "hi", "stream": True}) - - assert r.status_code == 200 - assert "event: response.output_item.added" in r.text - assert "event: response.output_item.done" in r.text - events = [line[len("event: ") :] for line in r.text.splitlines() if line.startswith("event: ")] - assert "response.content_part.added" in events - assert "response.output_text.done" in events - assert "response.reasoning_text.delta" in events - assert "response.reasoning_text.done" in events - assert "response.function_call_arguments.delta" in events - assert "response.function_call_arguments.done" in events - content_part_added = _sse_payload(r.text, "response.content_part.added") - assert content_part_added["part"] == {"annotations": [], "text": "", "type": "output_text"} - added_items = [ - json.loads(line[len("data: ") :])["item"] - for line in r.text.splitlines() - if line.startswith("data: ") and '"type":"response.output_item.added"' in line - ] - assert [item["type"] for item in added_items] == [ - "message", - "reasoning", - "function_call", - "function_call_output", - ] - assert added_items[0]["content"] == [] - assert added_items[1]["content"] == [] - assert added_items[2]["name"] == "lookup" - assert added_items[2]["arguments"] == "" - assert added_items[3]["output"] == [ - {"detail": "auto", "type": "input_image", "image_url": "https://example.com/cat.png"} - ] - completed = _sse_payload(r.text, "response.completed") - assert completed["response"]["output"][0]["content"][0]["text"] == "caption" - assert completed["response"]["output"][1]["content"][0]["text"] == "thinking" - assert completed["response"]["output"][2]["name"] == "lookup" - assert completed["response"]["output"][3]["output"] == [ - {"detail": "auto", "type": "input_image", "image_url": "https://example.com/cat.png"} - ] - - 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[AgentResponseUpdate]: - async def _gen() -> AsyncIterator[AgentResponseUpdate]: - yield AgentResponseUpdate(contents=[Content.from_text("partial")], role="assistant") - 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/hosting_responses/test_parsing.py b/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py index a5acbdeddd6..c3206a171f2 100644 --- a/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py +++ b/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py @@ -13,8 +13,6 @@ from agent_framework_hosting_responses import ( create_response_id, messages_from_responses_input, - parse_responses_identity, - parse_responses_request, responses_from_run, responses_from_streaming_run, responses_session_id, @@ -113,71 +111,6 @@ def test_image_url_missing_raises(self) -> None: messages_from_responses_input([{"type": "input_image"}]) -class TestParseResponsesRequest: - def test_known_fields_remapped_and_unknown_forwarded(self) -> None: - _, opts, _ = parse_responses_request({ - "input": "hi", - "instructions": "be brief", - "temperature": 0.4, - "top_p": 0.9, - "tool_choice": "auto", - "max_output_tokens": 256, - "parallel_tool_calls": False, - "truncation": "auto", - "reasoning": {"effort": "low"}, - }) - # Known remaps applied. - assert opts["max_tokens"] == 256 - assert opts["allow_multiple_tool_calls"] is False - # Straight-through fields present. - assert opts["temperature"] == 0.4 - assert opts["instructions"] == "be brief" - assert opts["truncation"] == "auto" - # Transport/session keys excluded. - for key in ("input", "stream", "previous_response_id"): - assert key not in opts - - def test_model_passes_through_transport_keys_excluded(self) -> None: - _, opts, _ = parse_responses_request({ - "input": "x", - "model": "gpt-x", - "stream": True, - "previous_response_id": "r", - }) - for key in ("input", "stream", "previous_response_id"): - assert key not in opts - # model passes through — not a transport key; run_hook decides what to do with it. - assert opts["model"] == "gpt-x" - - 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 - - class TestResponsesRunHelpers: def test_create_response_id_shape(self) -> None: response_id = create_response_id() diff --git a/python/packages/hosting-telegram/LICENSE b/python/packages/hosting-telegram/LICENSE deleted file mode 100644 index 9e841e7a26e..00000000000 --- a/python/packages/hosting-telegram/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ - 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-telegram/README.md b/python/packages/hosting-telegram/README.md deleted file mode 100644 index aa97e074173..00000000000 --- a/python/packages/hosting-telegram/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# agent-framework-hosting-telegram - -Telegram channel for [agent-framework-hosting](../hosting). Supports both -**polling** (default — no public URL required, perfect for local dev) and -**webhook** transports, multi-content messages (text + media), command -registration, and end-to-end SSE-style streaming via Telegram message edits. - -## Usage - -```python -from agent_framework_hosting import AgentFrameworkHost -from agent_framework_hosting_telegram import TelegramChannel - -host = AgentFrameworkHost( - target=my_agent, - channels=[TelegramChannel(bot_token="...")], -) -host.serve() -``` - -For production, configure `webhook_url="https://…"` and the channel will -register the webhook on startup and receive updates over HTTPS. - -## Identity & sessions - -Each Telegram chat is mapped to an opaque isolation key -(`telegram:`) so other channels can opt into the same per-chat -session by reusing the same key. The helper `telegram_isolation_key(chat_id)` -is exported for that purpose. diff --git a/python/packages/hosting-telegram/agent_framework_hosting_telegram/__init__.py b/python/packages/hosting-telegram/agent_framework_hosting_telegram/__init__.py deleted file mode 100644 index 0be4a26381b..00000000000 --- a/python/packages/hosting-telegram/agent_framework_hosting_telegram/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Telegram channel for :mod:`agent_framework_hosting`.""" - -from ._channel import TelegramChannel, telegram_isolation_key - -__all__ = ["TelegramChannel", "telegram_isolation_key"] diff --git a/python/packages/hosting-telegram/agent_framework_hosting_telegram/_channel.py b/python/packages/hosting-telegram/agent_framework_hosting_telegram/_channel.py deleted file mode 100644 index 46aea1ac82c..00000000000 --- a/python/packages/hosting-telegram/agent_framework_hosting_telegram/_channel.py +++ /dev/null @@ -1,888 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Built-in channel: Telegram (polling + webhook transports). - -Two transports are supported: - -- ``polling`` (default when no ``webhook_url`` is set): the channel runs a - background ``getUpdates`` long-poll loop. No public URL required — - perfect for local development. This is what ``python-telegram-bot`` - uses by default. -- ``webhook``: when ``webhook_url`` is set, the channel registers it via - ``setWebhook`` on startup and receives updates over HTTPS POSTs to the - mounted ``/webhook`` route. This is the production-recommended mode. -""" - -from __future__ import annotations - -import asyncio -import contextlib -import hmac -import time -from collections.abc import Awaitable, Callable, Mapping, Sequence -from typing import Any, Literal - -import httpx -from agent_framework import ( - AgentResponse, - AgentResponseUpdate, - Content, - Message, - ResponseStream, -) -from agent_framework_hosting import ( - ChannelCommand, - ChannelCommandContext, - ChannelContext, - ChannelContribution, - ChannelIdentity, - ChannelRequest, - ChannelResponseHook, - ChannelRunHook, - ChannelSession, - ChannelStreamUpdateHook, - logger, -) -from starlette.requests import Request -from starlette.responses import JSONResponse, Response -from starlette.routing import BaseRoute, Route - -# Telegram update parsing ------------------------------------------------------ -# -# A Telegram message can carry text, a caption, and one of several media kinds -# (photo, document, voice, audio, video). For media we resolve the file_id -# into a public bot-file URL via ``getFile`` and emit a ``Content.from_uri``; -# the agent then receives a multi-content Message with text + media side by -# side, the same as it would over the Responses API. - -_TELEGRAM_MEDIA_DEFAULT_MIMETYPE = { - "photo": "image/jpeg", - "document": "application/octet-stream", - "voice": "audio/ogg", - "audio": "audio/mpeg", - "video": "video/mp4", -} - -# Telegram's hard limit on a single message body. Past this, sendMessage / -# editMessageText return 400. We truncate interim and final edits at this -# boundary; if the agent emits more, callers can split into a follow-up -# sendMessage in their run hook. -_TELEGRAM_MAX_TEXT_LEN = 4096 - - -def telegram_isolation_key(chat_id: Any) -> str: - """Build the namespaced isolation key the Telegram channel writes under. - - Exposed at module scope so other channels' ``run_hook`` callbacks can opt - into the same per-chat session (e.g. a Responses caller resuming a - Telegram conversation by passing the chat id). - """ - return f"telegram:{chat_id}" - - -def _telegram_media_file_id(message: Mapping[str, Any]) -> tuple[str, str] | None: - """Return ``(file_id, fallback_media_type)`` for any media on the message.""" - photo = message.get("photo") - if isinstance(photo, list) and photo: - # Telegram delivers photos as an array of progressively-larger sizes. - largest = photo[-1] - if isinstance(largest, Mapping) and (fid := largest.get("file_id")): - return str(fid), _TELEGRAM_MEDIA_DEFAULT_MIMETYPE["photo"] - for kind in ("document", "voice", "audio", "video"): - media = message.get(kind) - if media and isinstance(media, Mapping) and (fid := media.get("file_id")): - return str(fid), str(media.get("mime_type") or _TELEGRAM_MEDIA_DEFAULT_MIMETYPE[kind]) - return None - - -async def _parse_telegram_message( - message: Mapping[str, Any], - resolve_file_url: Callable[[str], Awaitable[str | None]], -) -> Message: - """Translate one Telegram ``message`` object into an Agent Framework Message.""" - parts: list[Content] = [] - if (text := message.get("text") or message.get("caption")) and isinstance(text, str): - parts.append(Content.from_text(text=text)) - - if (media := _telegram_media_file_id(message)) is not None: - file_id, media_type = media - if (uri := await resolve_file_url(file_id)) is not None: - parts.append(Content.from_uri(uri=uri, media_type=media_type)) - - if not parts: - # Edge case: no recognizable content — emit an empty placeholder so the - # agent contract still receives a Message and can react gracefully. - parts.append(Content.from_text(text="")) - return Message("user", parts) - - -class TelegramChannel: - """Telegram channel with both polling and webhook transports. - - Update kinds handled (both transports): - - ``message`` / ``edited_message`` — text, captions, and media - (photo/document/voice/audio/video). - - ``callback_query`` — inline-button presses; the ``data`` payload is - treated as the user's next utterance and the click is acknowledged. - - Streaming - --------- - The channel defaults to ``stream=True`` on every ``ChannelRequest``: it - drives ``ChannelContext.run_stream`` and progressively edits a single - Telegram message as ``AgentResponseUpdate`` chunks arrive (Telegram has - no native streaming primitive). Pass ``stream=False`` on the constructor - to opt out for all messages, or override per-request inside the - the constructor. A ``stream_update_hook`` can rewrite or drop individual - updates before they hit the wire — useful for redaction, formatting, or - merging tool-call deltas. - """ - - name = "telegram" - - def __init__( - self, - *, - bot_token: str, - path: str = "/telegram/webhook", - commands: Sequence[ChannelCommand] = (), - register_native_commands: bool = True, - run_hook: ChannelRunHook | None = None, - response_hook: ChannelResponseHook | None = None, - api_base: str = "https://api.telegram.org", - webhook_url: str | None = None, - secret_token: str | None = None, - delete_webhook_on_shutdown: bool = False, - parse_mode: str | None = None, - send_typing_action: bool = True, - transport: Literal["auto", "polling", "webhook"] = "auto", - polling_timeout: int = 30, - stream: bool = True, - stream_update_hook: ChannelStreamUpdateHook | None = None, - stream_edit_min_interval: float = 0.4, - ) -> None: - """Create a Telegram channel. - - Keyword Args: - bot_token: Telegram Bot API token obtained from BotFather. - path: URL path at which the webhook route is mounted (default - ``"/telegram/webhook"``). Only used in webhook transport. - commands: Slash commands to register with the bot via - ``setMyCommands`` on startup. - register_native_commands: When ``True`` (default), the commands - list is sent to Telegram's ``setMyCommands`` API so they - appear in the Telegram UI. - run_hook: Optional :class:`ChannelRunHook` called before each - agent invocation. Use it to pre-process inputs or inject - context (e.g. strip unsupported options). When omitted, a - safe default hook that discards unknown options is applied. - response_hook: Optional :class:`ChannelResponseHook` called - after the agent finishes. Use it to post-process or log - outputs. - api_base: Telegram Bot API base URL. Defaults to - ``"https://api.telegram.org"``. Override for local API - server setups. - webhook_url: Public HTTPS URL to register with Telegram when - using webhook transport. Required when - ``transport="webhook"``; ignored in polling mode. - secret_token: Optional token sent by Telegram as the - ``X-Telegram-Bot-Api-Secret-Token`` header on every webhook - call. When set, the channel rejects requests whose token - does not match. - delete_webhook_on_shutdown: When ``True``, call - ``deleteWebhook`` on shutdown. Useful for graceful - teardown in development. Defaults to ``False``. - parse_mode: Telegram ``parse_mode`` applied to outgoing messages - (e.g. ``"HTML"`` or ``"MarkdownV2"``). Interim streaming - edits always use plain text to avoid parse errors on - partial markup. - send_typing_action: When ``True`` (default), re-issue a - ``sendChatAction("typing")`` every 4 seconds while the - agent is running so the typing indicator stays visible. - transport: Delivery transport. ``"auto"`` (default) selects - ``"webhook"`` when ``webhook_url`` is supplied, otherwise - ``"polling"``. - polling_timeout: Long-poll timeout in seconds for - ``getUpdates``. Defaults to ``30``. - stream: Default streaming mode for agent invocations. When - ``True`` (default), responses are sent progressively via - message edits. When ``False``, the full response is sent - once the agent finishes. - stream_update_hook: Optional hook called with each streaming - update before it is processed. - stream_edit_min_interval: Minimum seconds between consecutive - Telegram ``editMessageText`` calls. Defaults to ``0.4`` to - stay well under Telegram's per-chat rate limits. - """ - self.path = path - self._token = bot_token - self._commands = list(commands) - self._register = register_native_commands - self._hook = run_hook - self.response_hook = response_hook - self._stream_default = stream - self._stream_update_hook = stream_update_hook - self._stream_edit_min_interval = stream_edit_min_interval - self._api = f"{api_base}/bot{bot_token}" - self._webhook_url = webhook_url - self._secret_token = secret_token - self._delete_webhook_on_shutdown = delete_webhook_on_shutdown - self._parse_mode = parse_mode - self._send_typing_action = send_typing_action - if transport == "auto": - transport = "webhook" if webhook_url else "polling" - if transport == "webhook" and not webhook_url: - raise ValueError("transport='webhook' requires webhook_url") - self._transport: Literal["polling", "webhook"] = transport - self._polling_timeout = polling_timeout - self._ctx: ChannelContext | None = None - self._http: httpx.AsyncClient | None = None - self._poll_task: asyncio.Task[None] | None = None - # Tracks all in-flight tasks (per-chat workers + webhook-spawned - # dispatcher tasks). Drained on shutdown. - self._update_tasks: set[asyncio.Task[None]] = set() - # Per-chat serial workers preserve in-chat ordering: each - # chat_id has its own asyncio.Queue + worker task. Updates for - # different chats run in parallel; updates for the same chat - # run strictly in arrival order. - self._chat_queues: dict[int, asyncio.Queue[Mapping[str, Any]]] = {} - self._chat_workers: dict[int, asyncio.Task[None]] = {} - - def contribute(self, context: ChannelContext) -> ChannelContribution: - """Register the webhook route (only in ``webhook`` transport) plus lifecycle hooks. - - Polling-mode hosts intentionally expose no HTTP route — adding one - would just confuse readers who expect inbound HTTP traffic to do - something. - """ - self._ctx = context - routes: list[BaseRoute] = [] - if self._transport == "webhook": - routes.append(Route("/", self._handle, methods=["POST"])) - return ChannelContribution( - routes=routes, - commands=self._commands, - on_startup=[self._on_startup], - on_shutdown=[self._on_shutdown], - ) - - # -- lifecycle --------------------------------------------------------- # - - async def _on_startup(self) -> None: - """Open the HTTP client, optionally register slash commands, and start the transport. - - - Polling: clears any previously-set webhook (Telegram refuses - ``getUpdates`` while one is registered) and launches the - long-poll task. - - Webhook: ``setWebhook`` to the configured URL, including the - optional secret token used to authenticate inbound calls. - """ - # ``getUpdates`` blocks for up to ``polling_timeout`` seconds, so the - # client timeout has to comfortably exceed it. Skip when a client has - # been pre-injected (e.g. by tests). - if self._http is None: - self._http = httpx.AsyncClient(timeout=self._polling_timeout + 15) - if self._register and self._commands: - cmd_payload: dict[str, Any] = { - "commands": [{"command": c.name, "description": c.description} for c in self._commands] - } - await self._http.post(f"{self._api}/setMyCommands", json=cmd_payload) - logger.info("Registered %d Telegram commands", len(self._commands)) - - if self._transport == "webhook": - payload: dict[str, Any] = { - "url": self._webhook_url, - "allowed_updates": ["message", "edited_message", "callback_query"], - } - if self._secret_token: - payload["secret_token"] = self._secret_token - response = await self._http.post(f"{self._api}/setWebhook", json=payload) - response.raise_for_status() - logger.info("Telegram webhook registered: %s", self._webhook_url) - else: - # Telegram refuses getUpdates while a webhook is set, so clear it. - await self._http.post(f"{self._api}/deleteWebhook", json={"drop_pending_updates": False}) - self._poll_task = asyncio.create_task(self._poll_loop(), name="telegram-poll") - logger.info("Telegram polling started (long-poll timeout=%ss)", self._polling_timeout) - - async def _on_shutdown(self) -> None: - """Stop the polling task, drain in-flight workers, close HTTP. - - Drain order: - 1. Cancel the poll task so no new updates are admitted. - 2. Cancel + await per-chat worker tasks so any currently-running - agent invocations can finish before we yank the HTTP client - out from under them. - 3. Cancel + await any webhook-dispatched tasks tracked in - ``_update_tasks`` (the webhook handler returns 200 immediately - and runs the agent in a background task, which the previous - shutdown ignored entirely). - 4. Close the HTTP client. - - The webhook registration is intentionally **left in place** on - shutdown. A Telegram webhook is a single global resource, so - deleting it here races rolling redeploys: the new revision calls - ``setWebhook`` on startup, then the old revision's shutdown would - delete it, silently breaking inbound delivery until the next boot. - ``setWebhook`` is overwriting/idempotent, so the next startup - re-asserts it anyway. Set ``delete_webhook_on_shutdown=True`` to opt - into best-effort teardown (e.g. for a one-off/ephemeral deployment); - failures are logged but never raised so app shutdown can complete. - """ - if self._poll_task is not None: - self._poll_task.cancel() - with contextlib.suppress(asyncio.CancelledError, Exception): - await self._poll_task - self._poll_task = None - # Cancel per-chat workers; their queues are no longer being fed. - for worker in list(self._chat_workers.values()): - worker.cancel() - for worker in list(self._chat_workers.values()): - with contextlib.suppress(asyncio.CancelledError, Exception): - await worker - self._chat_workers.clear() - self._chat_queues.clear() - # Webhook-spawned dispatcher tasks (the ack-before-run path) live - # in _update_tasks alongside any leftover poll-spawned tasks. - for task in list(self._update_tasks): - task.cancel() - for task in list(self._update_tasks): - with contextlib.suppress(asyncio.CancelledError, Exception): - await task - self._update_tasks.clear() - if self._http is not None: - if self._transport == "webhook" and self._delete_webhook_on_shutdown: - try: - await self._http.post(f"{self._api}/deleteWebhook") - except Exception: # pragma: no cover - best-effort cleanup - logger.exception("deleteWebhook failed") - await self._http.aclose() - - # -- polling loop ------------------------------------------------------ # - - async def _poll_loop(self) -> None: - """Long-poll ``getUpdates`` until cancelled. - - Each batch advances the ``offset`` by the highest seen - ``update_id`` so processed updates aren't redelivered. Updates - are routed to per-chat serial workers via :meth:`_enqueue_update` - — this preserves in-chat ordering (Telegram only guarantees - ordering up to ``getUpdates``; the previous fan-out into one - task per update broke that guarantee for adjacent updates). - Different chats still process in parallel because each has its - own worker. Transient errors back off for 2 seconds before - retrying. - """ - if self._http is None: # pragma: no cover - guarded by lifecycle - raise RuntimeError("telegram channel not started") - offset: int | None = None - while True: - try: - params: dict[str, Any] = { - "timeout": self._polling_timeout, - "allowed_updates": '["message","edited_message","callback_query"]', - } - if offset is not None: - params["offset"] = offset - response = await self._http.get(f"{self._api}/getUpdates", params=params) - response.raise_for_status() - payload = response.json() - if not payload.get("ok"): - logger.warning("Telegram getUpdates returned error: %s", payload) - await asyncio.sleep(1.0) - continue - for update in payload.get("result", []) or []: - update_id = update.get("update_id") - if isinstance(update_id, int): - offset = update_id + 1 - self._enqueue_update(update) - except asyncio.CancelledError: - raise - except Exception: - logger.exception("Telegram polling iteration failed; retrying in 2s") - await asyncio.sleep(2.0) - - def _chat_id_for_update(self, update: Mapping[str, Any]) -> int | None: - """Best-effort extraction of the chat id from any supported update shape.""" - message = update.get("message") or update.get("edited_message") - if isinstance(message, Mapping): - chat = message.get("chat") - if isinstance(chat, Mapping): - cid = chat.get("id") - if isinstance(cid, int): - return cid - callback = update.get("callback_query") - if isinstance(callback, Mapping): - inner = callback.get("message") - if isinstance(inner, Mapping): - chat = inner.get("chat") - if isinstance(chat, Mapping): - cid = chat.get("id") - if isinstance(cid, int): - return cid - return None - - def _enqueue_update(self, update: Mapping[str, Any]) -> None: - """Route an update to its per-chat serial worker. - - Updates with no resolvable chat_id (malformed payloads, unknown - update types) fall back to a one-shot dispatcher task so they - can't deadlock the main loop. - """ - chat_id = self._chat_id_for_update(update) - if chat_id is None: - # No chat to serialise on — fire and forget, but still track - # so shutdown can drain. - task = asyncio.create_task(self._safe_process_update(update)) - self._update_tasks.add(task) - task.add_done_callback(self._update_tasks.discard) - return - queue = self._chat_queues.get(chat_id) - if queue is None: - queue = asyncio.Queue() - self._chat_queues[chat_id] = queue - worker = asyncio.create_task( - self._chat_worker(chat_id, queue), - name=f"telegram-chat-worker-{chat_id}", - ) - self._chat_workers[chat_id] = worker - # Ensure shutdown can drain this worker too. - self._update_tasks.add(worker) - worker.add_done_callback(self._update_tasks.discard) - queue.put_nowait(update) - - async def _chat_worker(self, chat_id: int, queue: asyncio.Queue[Mapping[str, Any]]) -> None: - """Drain a single chat's queue serially. - - Per-chat ordering is preserved by processing one update at a - time. Exceptions in :meth:`_safe_process_update` are already - swallowed, so the worker keeps running. The worker is cancelled - on channel shutdown. - """ - try: - while True: - update = await queue.get() - try: - await self._safe_process_update(update) - finally: - queue.task_done() - except asyncio.CancelledError: - raise - - async def _safe_process_update(self, update: Mapping[str, Any]) -> None: - """Wrap :meth:`_process_update` so a failure on one update never escapes a task.""" - try: - await self._process_update(update) - except Exception: - logger.exception("Telegram update processing failed: %s", update.get("update_id")) - - # -- request handling -------------------------------------------------- # - - async def _handle(self, request: Request) -> Response: - """Webhook endpoint — verifies the secret token then queues the update. - - Telegram includes the configured secret in the - ``X-Telegram-Bot-Api-Secret-Token`` header on every webhook delivery; - we reject mismatches so leaked URLs alone aren't enough to inject - traffic. - - **Acks before running the agent.** Telegram redelivers any update - the webhook doesn't 200 within ~60 seconds, so a streamed agent - reply that runs longer than that would otherwise trigger a - retry storm and duplicate replies. We enqueue onto the - per-chat serial worker (preserving ordering with polling-mode) - and immediately return 200; the actual processing happens in - the worker task tracked by ``_update_tasks`` and drained on - shutdown. - """ - if self._secret_token is not None: - received = request.headers.get("x-telegram-bot-api-secret-token") - if not hmac.compare_digest(received or "", self._secret_token): - logger.warning("Telegram webhook secret token mismatch — rejecting update") - return JSONResponse({"ok": False, "error": "invalid secret"}, status_code=401) - - try: - update = await request.json() - except Exception: - logger.warning("Telegram webhook received malformed JSON; returning 400") - return JSONResponse({"ok": False, "error": "invalid json"}, status_code=400) - if not isinstance(update, Mapping): - logger.warning("Telegram webhook received non-object payload; returning 400") - return JSONResponse({"ok": False, "error": "invalid payload"}, status_code=400) - # Ack immediately, route through per-chat worker so ordering with - # polling-mode is identical and shutdown drains all in-flight work. - self._enqueue_update(update) - return JSONResponse({"ok": True}) - - async def _process_update(self, update: Mapping[str, Any]) -> None: - """Convert one Telegram update into a :class:`ChannelRequest` and dispatch. - - Branches: - - ``callback_query`` — inline-button click; handled separately so we - can ack the click and treat the button payload as the next user - utterance. - - ``message`` / ``edited_message`` — the common text-and-attachment - case; runs slash commands when present, otherwise builds a - message and dispatches to the agent. - """ - if self._ctx is None: # pragma: no cover - guarded by lifecycle - raise RuntimeError("telegram channel not started") - - # Inline-button presses: ack the click, treat the payload as input. - if (callback := update.get("callback_query")) is not None: - await self._handle_callback_query(callback) - return - - # message and edited_message share the same shape. - message = update.get("message") or update.get("edited_message") or {} - chat_id = (message.get("chat") or {}).get("id") - text = message.get("text") or message.get("caption") - has_media = any(k in message for k in ("photo", "document", "voice", "audio", "video")) - if not isinstance(chat_id, int) or (not isinstance(text, str) and not has_media): - return # Nothing actionable. - - # Native command dispatch — bypasses the agent. - if isinstance(text, str) and text.startswith("/"): - command_text = text[1:].strip() - if not command_text: - return - command_name = command_text.split()[0].split("@", 1)[0] - handler = next((c for c in self._commands if c.name == command_name), None) - if handler is not None: - channel_request = ChannelRequest( - channel=self.name, - operation="command.invoke", - input=text, - session=ChannelSession(isolation_key=telegram_isolation_key(chat_id)), - attributes={"chat_id": chat_id}, - identity=ChannelIdentity(channel=self.name, native_id=str(chat_id)), - ) - ctx = ChannelCommandContext( - request=channel_request, - reply=lambda body, cid=chat_id: self._send(cid, body), - ) - await handler.handle(ctx) - return - - # Plain message → agent run. Build a multi-content Message with the - # text/caption alongside any attached media (photo, document, ...). - parsed = await _parse_telegram_message(message, self._resolve_file_url) - channel_request = ChannelRequest( - channel=self.name, - operation="message.create", - input=[parsed], - session=ChannelSession(isolation_key=telegram_isolation_key(chat_id)), - attributes={"chat_id": chat_id}, - stream=self._stream_default, - identity=ChannelIdentity(channel=self.name, native_id=str(chat_id)), - ) - await self._dispatch(chat_id, channel_request, protocol_request=update) - - async def _handle_callback_query(self, callback: Mapping[str, Any]) -> None: - """Handle an inline-button click. - - Always answers the callback query to clear the spinner on the user's - client, then treats the button's ``data`` payload as the user's - next utterance and dispatches it as if they had typed it. - Callbacks without a chat or string ``data`` are silently dropped. - """ - if self._ctx is None: # pragma: no cover - guarded by lifecycle - raise RuntimeError("telegram channel not started") - if self._http is None: # pragma: no cover - guarded by lifecycle - raise RuntimeError("telegram channel not started") - callback_id = callback.get("id") - data = callback.get("data") - message = callback.get("message") or {} - chat_id = (message.get("chat") or {}).get("id") - - if callback_id is not None: - # Always answer to remove the loading spinner on the user's client. - try: - await self._http.post(f"{self._api}/answerCallbackQuery", json={"callback_query_id": callback_id}) - except Exception: # pragma: no cover - defensive - logger.exception("answerCallbackQuery failed") - - if not isinstance(chat_id, int) or not isinstance(data, str): - return - - channel_request = ChannelRequest( - channel=self.name, - operation="message.create", - input=data, - session=ChannelSession(isolation_key=telegram_isolation_key(chat_id)), - attributes={"chat_id": chat_id, "callback_query_id": callback_id}, - stream=self._stream_default, - identity=ChannelIdentity(channel=self.name, native_id=str(chat_id)), - ) - await self._dispatch(chat_id, channel_request, protocol_request=callback) - - async def _resolve_file_url(self, file_id: str) -> str | None: - """Resolve a Telegram file_id into an HTTPS URL via getFile.""" - if self._http is None: # pragma: no cover - guarded by lifecycle - raise RuntimeError("telegram channel not started") - try: - response = await self._http.get(f"{self._api}/getFile", params={"file_id": file_id}) - response.raise_for_status() - file_path = response.json().get("result", {}).get("file_path") - except Exception: # pragma: no cover - defensive: bad token, network, etc. - logger.exception("getFile failed for %s", file_id) - return None - return f"{self._api.replace('/bot', '/file/bot')}/{file_path}" if file_path else None - - # -- outbound helpers -------------------------------------------------- # - - async def _dispatch(self, chat_id: int, request: ChannelRequest, *, protocol_request: Any | None = None) -> None: - """Run the request and forward results to ``chat_id``.""" - if self._ctx is None: # pragma: no cover - guarded by lifecycle - raise RuntimeError("telegram channel not started") - if not request.stream: - if self._send_typing_action: - await self._send_chat_action(chat_id, "typing") - result = await self._ctx.run( - request, - run_hook=self._hook, - protocol_request=protocol_request, - response_hook=self.response_hook, - channel_name=self.name, - ) - await self._reply_with_result(chat_id, result.result) - return - - stream = await self._ctx.run_stream( - request, - run_hook=self._hook, - protocol_request=protocol_request, - stream_update_hook=self._stream_update_hook, - response_hook=self.response_hook, - channel_name=self.name, - ) - await self._stream_to_chat(chat_id, request, stream) - - async def _stream_to_chat( - self, - chat_id: int, - request: ChannelRequest, - stream: ResponseStream[AgentResponseUpdate, AgentResponse], - ) -> None: - """Iterate the agent's ResponseStream and progressively edit a Telegram message. - - Smoothness recipe: - - 1. Send the placeholder message up front so the user sees instant - activity (a "…" bubble) instead of waiting for the first edit. - 2. Token consumption never awaits the network — a background - ``edit_worker`` watches an asyncio.Event, coalesces accumulated - text, rate-limits itself to ``stream_edit_min_interval`` (default - 0.4s — well under Telegram's per-chat edit limits), and only sends - when the text actually changed. - 3. Interim edits are sent as **plain text** even if a ``parse_mode`` - is configured. Partial Markdown/HTML mid-stream is invalid and - Telegram rejects it with 400 ``can't parse entities``. The final - edit re-applies the configured ``parse_mode`` so the user ends up - with formatted output. - 4. ``sendChatAction("typing")`` is re-issued every 4s while the - stream is live so the typing bubble doesn't disappear on long - responses (Telegram clears it after ~5s). - """ - if self._http is None: # pragma: no cover - guarded by lifecycle - raise RuntimeError("telegram channel not started") - # Pin to a local so mypy narrows inside the nested closures below. - http = self._http - - accumulated = "" - last_sent = "" - last_edit_at = 0.0 - message_id: int | None = None - worker_done = asyncio.Event() - wake = asyncio.Event() - - async def send_initial_placeholder() -> None: - nonlocal message_id, last_edit_at - try: - response = await http.post( - f"{self._api}/sendMessage", - json={"chat_id": chat_id, "text": "…"}, - ) - response.raise_for_status() - message_id = response.json().get("result", {}).get("message_id") - last_edit_at = time.monotonic() - except Exception: # pragma: no cover - placeholder is best-effort - logger.exception("Telegram placeholder send failed") - - async def edit_worker() -> None: - nonlocal last_sent, last_edit_at - while not (worker_done.is_set() and accumulated[:_TELEGRAM_MAX_TEXT_LEN] == last_sent): - await wake.wait() - wake.clear() - if message_id is None: - if worker_done.is_set(): - return - continue - if accumulated[:_TELEGRAM_MAX_TEXT_LEN] == last_sent: - if worker_done.is_set(): - return - continue - elapsed = time.monotonic() - last_edit_at - if elapsed < self._stream_edit_min_interval: - await asyncio.sleep(self._stream_edit_min_interval - elapsed) - snapshot = accumulated[:_TELEGRAM_MAX_TEXT_LEN] - if snapshot == last_sent: - if worker_done.is_set(): - return - continue - # Interim edits go out as plain text — partial Markdown/HTML - # is invalid mid-stream and Telegram returns 400. - try: - await http.post( - f"{self._api}/editMessageText", - json={"chat_id": chat_id, "message_id": message_id, "text": snapshot}, - ) - except Exception: # pragma: no cover - keep streaming on error - logger.exception("Telegram interim edit failed") - last_sent = snapshot - last_edit_at = time.monotonic() - - async def typing_worker() -> None: - while not worker_done.is_set(): - await self._send_chat_action(chat_id, "typing") - try: - await asyncio.wait_for(worker_done.wait(), timeout=4.0) - except asyncio.TimeoutError: - continue - - await send_initial_placeholder() - edit_task = asyncio.create_task(edit_worker(), name="telegram-edit-worker") - typing_task = ( - asyncio.create_task(typing_worker(), name="telegram-typing-worker") if self._send_typing_action else None - ) - - try: - async for update in stream: - for content in update.contents: - if content.type == "text" and content.text: - accumulated += content.text - wake.set() - except Exception: - logger.exception("Telegram streaming consumption failed") - finally: - worker_done.set() - wake.set() - try: - await edit_task - except Exception: # pragma: no cover - logger.exception("Telegram edit worker crashed") - if typing_task is not None: - typing_task.cancel() - with contextlib.suppress(asyncio.CancelledError, Exception): - await typing_task - - # Always finalize so context providers / history hooks run. - try: - final = await stream.get_final_response() - except Exception: # pragma: no cover - finalize is best-effort - logger.exception("Stream finalize failed") - final = None - - # Final edit applies parse_mode (if configured) to the full text. - final_text = (getattr(final, "text", None) or accumulated or last_sent)[:_TELEGRAM_MAX_TEXT_LEN] - text_sent_via_edit = bool(last_sent) - if message_id is not None and final_text and final_text != last_sent: - text_sent_via_edit = False - payload: dict[str, Any] = { - "chat_id": chat_id, - "message_id": message_id, - "text": final_text, - } - if self._parse_mode: - payload["parse_mode"] = self._parse_mode - try: - response = await http.post(f"{self._api}/editMessageText", json=payload) - # If parse_mode rejected the final edit, retry as plain text - # so the user still sees the answer. - if response.status_code == 400 and self._parse_mode: - payload.pop("parse_mode", None) - response = await http.post(f"{self._api}/editMessageText", json=payload) - status_code = getattr(response, "status_code", None) - if isinstance(status_code, int) and 200 <= status_code < 300: - text_sent_via_edit = True - else: - logger.warning( - "Telegram final edit returned status %s; falling back to sendMessage", - status_code, - ) - except Exception: # pragma: no cover - logger.exception("Telegram final edit failed") - - # Forward final multimodal payload (photos, structured artifacts). When - # we already rendered text via streaming edits, suppress a second text - # sendMessage to avoid duplicate output. - await self._reply_with_result(chat_id, final, send_text=not text_sent_via_edit) - - async def _reply_with_result(self, chat_id: int, result: Any, *, send_text: bool = True) -> None: - """Forward an AgentRunResponse back to Telegram. - - Sends any image attachments on the last assistant message as photos, - then the text body via ``sendMessage``. Falls back to a ``"(no - response)"`` placeholder if neither text nor images are present so - the user is never left hanging. - """ - sent_photo = False - last_message = None - messages = getattr(result, "messages", None) or [] - for msg in reversed(messages): - if getattr(msg, "role", None) == "assistant": - last_message = msg - break - - if last_message is not None: - for content in getattr(last_message, "contents", []) or []: - uri = getattr(content, "uri", None) - media_type = getattr(content, "media_type", "") or "" - if uri and isinstance(media_type, str) and media_type.startswith("image/"): - await self._send_photo(chat_id, uri) - sent_photo = True - - text = getattr(result, "text", None) - if send_text and text: - await self._send(chat_id, text) - elif send_text and not sent_photo: - await self._send(chat_id, "(no response)") - - async def _send(self, chat_id: int, text: str, **extra: Any) -> None: - """POST a ``sendMessage`` to Telegram, applying the configured ``parse_mode`` by default. - - Extra kwargs are merged into the payload after ``parse_mode`` so - callers can override any field per-call (e.g. drop ``parse_mode`` - for a known-unsafe interim text). - """ - if self._http is None: # pragma: no cover - guarded by lifecycle - raise RuntimeError("telegram channel not started") - payload: dict[str, Any] = {"chat_id": chat_id, "text": text} - if self._parse_mode and "parse_mode" not in extra: - payload["parse_mode"] = self._parse_mode - payload.update(extra) - await self._http.post(f"{self._api}/sendMessage", json=payload) - - async def _send_photo(self, chat_id: int, photo_url: str, caption: str | None = None) -> None: - """POST a ``sendPhoto`` to Telegram with an optional caption.""" - if self._http is None: # pragma: no cover - guarded by lifecycle - raise RuntimeError("telegram channel not started") - payload: dict[str, Any] = {"chat_id": chat_id, "photo": photo_url} - if caption: - payload["caption"] = caption - await self._http.post(f"{self._api}/sendPhoto", json=payload) - - async def _send_chat_action(self, chat_id: int, action: str) -> None: - """Fire a ``sendChatAction`` (typing, upload_photo, …); errors are logged and swallowed. - - Chat actions are pure UX hints — Telegram clears them after ~5s - — so failures should never propagate to the caller. - """ - if self._http is None: # pragma: no cover - guarded by lifecycle - raise RuntimeError("telegram channel not started") - try: - await self._http.post(f"{self._api}/sendChatAction", json={"chat_id": chat_id, "action": action}) - except Exception: # pragma: no cover - non-critical UX - logger.exception("sendChatAction failed") - - -__all__ = ["TelegramChannel", "telegram_isolation_key"] diff --git a/python/packages/hosting-telegram/pyproject.toml b/python/packages/hosting-telegram/pyproject.toml deleted file mode 100644 index 2f49a4d3c80..00000000000 --- a/python/packages/hosting-telegram/pyproject.toml +++ /dev/null @@ -1,89 +0,0 @@ -[project] -name = "agent-framework-hosting-telegram" -description = "Telegram channel for agent-framework-hosting." -authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] -readme = "README.md" -requires-python = ">=3.10" -version = "1.0.0a260625" -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.10.0,<2", - "agent-framework-hosting>=1.0.0a260625,<2", - "httpx>=0.27,<1", -] - -[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_telegram"] -exclude = ['tests'] -# Telegram's API delivers loosely-typed JSON-ish maps (chat, message, photo, -# media, callback_query). Strict ``Unknown`` reporting on every ``.get(...)`` -# adds noise without catching real bugs — narrowing happens via runtime -# isinstance checks instead. Other type checks remain strict. -reportUnknownArgumentType = "none" -reportUnknownMemberType = "none" -reportUnknownVariableType = "none" -reportUnknownLambdaType = "none" -reportOptionalMemberAccess = "none" - -[tool.bandit] -targets = ["agent_framework_hosting_telegram"] -exclude_dirs = ["tests"] - -[tool.poe] -executor.type = "uv" -include = "../../shared_tasks.toml" - -[tool.poe.tasks.test] -help = "Run the default unit test suite for this package." -cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_telegram --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-telegram/tests/hosting_telegram/test_channel.py b/python/packages/hosting-telegram/tests/hosting_telegram/test_channel.py deleted file mode 100644 index ba51720ff15..00000000000 --- a/python/packages/hosting-telegram/tests/hosting_telegram/test_channel.py +++ /dev/null @@ -1,591 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Unit tests for :mod:`agent_framework_hosting_telegram`. - -These tests exercise the internal parsing helpers and the webhook entry-point -without spinning up a real Telegram bot. The polling loop and HTTP-side -helpers are excluded from coverage because they require a live bot token. -""" - -from __future__ import annotations - -import asyncio -import contextlib -from collections.abc import Awaitable, Mapping -from dataclasses import dataclass, field -from typing import Any, cast -from unittest.mock import AsyncMock, MagicMock - -from agent_framework import Content, Message, ServiceSessionId -from agent_framework_hosting import ( - AgentFrameworkHost, - ChannelCommand, - ChannelCommandContext, - ChannelRequest, - HostedRunResult, -) -from starlette.testclient import TestClient - -from agent_framework_hosting_telegram import TelegramChannel, telegram_isolation_key -from agent_framework_hosting_telegram._channel import ( - _parse_telegram_message, - _telegram_media_file_id, -) - -# --------------------------------------------------------------------------- # -# Pure helpers # -# --------------------------------------------------------------------------- # - - -def test_telegram_isolation_key_format() -> None: - assert telegram_isolation_key(42) == "telegram:42" - assert telegram_isolation_key("abc") == "telegram:abc" - - -class TestMediaFileId: - def test_no_media(self) -> None: - assert _telegram_media_file_id({"text": "hi"}) is None - - def test_photo_picks_largest(self) -> None: - assert _telegram_media_file_id({"photo": [{"file_id": "small"}, {"file_id": "large"}]}) == ( - "large", - "image/jpeg", - ) - - def test_photo_empty_list(self) -> None: - assert _telegram_media_file_id({"photo": []}) is None - - def test_document_uses_mime_type(self) -> None: - result = _telegram_media_file_id({"document": {"file_id": "f1", "mime_type": "application/pdf"}}) - assert result == ("f1", "application/pdf") - - def test_voice_default_mime(self) -> None: - result = _telegram_media_file_id({"voice": {"file_id": "v1"}}) - assert result == ("v1", "audio/ogg") - - -class TestParseTelegramMessage: - async def test_text_only(self) -> None: - async def resolve(_: str) -> str | None: - return None - - msg = await _parse_telegram_message({"text": "hello"}, resolve) - assert msg.role == "user" - assert msg.text == "hello" - - async def test_text_and_photo(self) -> None: - async def resolve(file_id: str) -> str | None: - return f"https://files.telegram.org/{file_id}" - - msg = await _parse_telegram_message({"caption": "look", "photo": [{"file_id": "p1"}]}, resolve) - assert msg.text == "look" - # Image content present. - assert any((getattr(c, "uri", None) or "").endswith("/p1") for c in msg.contents) - - async def test_unresolvable_media_falls_back_to_text(self) -> None: - async def resolve(_: str) -> str | None: - return None - - msg = await _parse_telegram_message({"text": "x", "voice": {"file_id": "v1"}}, resolve) - # Resolver returned None — the contents should still include the - # text without crashing. - assert msg.text == "x" - - -# --------------------------------------------------------------------------- # -# Webhook entry point # -# --------------------------------------------------------------------------- # - - -@dataclass -class _FakeAgentResponse: - text: str - messages: list[Message] = field(default_factory=list) - - -class _FakeAgent: - def __init__(self, reply: str = "ok") -> None: - self.id = "fake-agent" - self.name: str | None = "Fake Agent" - self.description: str | None = "Test fake agent" - self._reply = reply - self.runs: list[Any] = [] - - def create_session(self, *, session_id: str | None = None) -> Any: - return {"session_id": session_id} - - def get_session(self, service_session_id: str | ServiceSessionId, *, session_id: str | None = None) -> Any: - return {"service_session_id": service_session_id, "session_id": session_id} - - def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any: - self.runs.append({"messages": messages, "stream": stream, "kwargs": kwargs}) - - async def _coro() -> _FakeAgentResponse: - return _FakeAgentResponse(text=self._reply) - - return _coro() - - -def _make_telegram( - stream_default: bool = False, *, path: str = "/telegram/webhook" -) -> tuple[TelegramChannel, _FakeAgent]: - agent = _FakeAgent("hi") - ch = TelegramChannel( - bot_token="123:abc", - path=path, - webhook_url="https://example.com/hook", - secret_token="s3cr3t", - stream=stream_default, - ) - # Replace the internal HTTP client with an AsyncMock so the channel - # never tries to call the real Telegram API. - fake_http = MagicMock() - # post() returns a response object whose raise_for_status() is sync. - response_mock = MagicMock() - response_mock.json = MagicMock(return_value={"ok": True, "result": {}}) - fake_http.post = AsyncMock(return_value=response_mock) - fake_http.get = AsyncMock(return_value=response_mock) - fake_http.aclose = AsyncMock() - object.__setattr__(ch, "_http", fake_http) - return ch, agent - - -class TestTelegramWebhook: - def test_webhook_accepts_text_message_and_dispatches_to_agent(self) -> None: - ch, agent = _make_telegram() - host = AgentFrameworkHost(target=agent, channels=[ch]) - # Skip lifespan so polling/setWebhook are not invoked. - with TestClient(host.app) as client: - r = client.post( - "/telegram/webhook", - json={"update_id": 1, "message": {"chat": {"id": 99}, "text": "hello"}}, - headers={"x-telegram-bot-api-secret-token": "s3cr3t"}, - ) - assert r.status_code == 200 - assert agent.runs, "expected the agent to be invoked" - - def test_slash_only_text_is_ignored(self) -> None: - ch, agent = _make_telegram() - host = AgentFrameworkHost(target=agent, channels=[ch]) - with TestClient(host.app) as client: - r = client.post( - "/telegram/webhook", - json={"update_id": 1, "message": {"chat": {"id": 99}, "text": "/"}}, - headers={"x-telegram-bot-api-secret-token": "s3cr3t"}, - ) - assert r.status_code == 200 - assert not agent.runs - - def test_non_int_chat_id_is_ignored(self) -> None: - ch, agent = _make_telegram() - host = AgentFrameworkHost(target=agent, channels=[ch]) - with TestClient(host.app) as client: - r = client.post( - "/telegram/webhook", - json={"update_id": 1, "message": {"chat": {"id": "99"}, "text": "hello"}}, - headers={"x-telegram-bot-api-secret-token": "s3cr3t"}, - ) - assert r.status_code == 200 - assert not agent.runs - - def test_empty_path_mounts_at_app_root(self) -> None: - ch, agent = _make_telegram(path="") - host = AgentFrameworkHost(target=agent, channels=[ch]) - with TestClient(host.app) as client: - r = client.post( - "/", - json={"update_id": 1, "message": {"chat": {"id": 99}, "text": "hello"}}, - headers={"x-telegram-bot-api-secret-token": "s3cr3t"}, - ) - assert r.status_code == 200 - assert agent.runs, "expected the agent to be invoked" - - def test_webhook_rejects_bad_secret(self) -> None: - ch, agent = _make_telegram() - host = AgentFrameworkHost(target=agent, channels=[ch]) - with TestClient(host.app) as client: - r = client.post( - "/telegram/webhook", - json={"update_id": 1, "message": {"chat": {"id": 99}, "text": "hi"}}, - headers={"x-telegram-bot-api-secret-token": "WRONG"}, - ) - assert r.status_code == 401 - assert not agent.runs - - async 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) - - ch, agent = _make_telegram() - ch.response_hook = hook - - class _Ctx: - target: Any = agent - - async def run( - self, - _request: ChannelRequest, - *, - run_hook: Any | None = None, - protocol_request: Any | None = None, - response_hook: Any | None = None, - channel_name: str | None = None, - ) -> HostedRunResult: - result = HostedRunResult(_FakeAgentResponse(text="hi")) - if response_hook is None: - return result - shaped = response_hook(result, request=_request, channel_name=channel_name or _request.channel) - if isinstance(shaped, Awaitable): - return await shaped - return shaped - - object.__setattr__(ch, "_ctx", _Ctx()) - - request = ChannelRequest(channel="telegram", operation="message.create", input="hi", stream=False) - await ch._dispatch(99, request) # pyright: ignore[reportPrivateUsage] - - http_mock = cast(Any, ch._http) - assert http_mock is not None - args, kwargs = http_mock.post.call_args - assert args[0].endswith("/sendMessage") - assert kwargs["json"]["text"] == "HI" - assert seen_kwargs - assert seen_kwargs[0]["channel_name"] == "telegram" - - -class TestCommand: - async def test_command_handler_invoked(self) -> None: - captured: list[ChannelCommandContext] = [] - - async def handler(ctx: ChannelCommandContext) -> None: - captured.append(ctx) - await ctx.reply("pong") - - ch = TelegramChannel( - bot_token="123:abc", - webhook_url="https://example.com/hook", - commands=[ChannelCommand(name="ping", description="ping", handle=handler)], - register_native_commands=False, - ) - fake_http = MagicMock() - response_mock = MagicMock() - response_mock.json = MagicMock(return_value={"ok": True, "result": {}}) - fake_http.post = AsyncMock(return_value=response_mock) - fake_http.get = AsyncMock(return_value=response_mock) - fake_http.aclose = AsyncMock() - object.__setattr__(ch, "_http", fake_http) - host = AgentFrameworkHost(target=_FakeAgent(), channels=[ch]) - - with TestClient(host.app) as client: - r = client.post( - "/telegram/webhook", - json={"update_id": 2, "message": {"chat": {"id": 7}, "text": "/ping"}}, - ) - assert r.status_code == 200 - assert captured and captured[0].request.operation == "command.invoke" - - -# --------------------------------------------------------------------------- # -# Per-chat serial ordering # -# --------------------------------------------------------------------------- # - - -class TestPerChatOrdering: - async def test_updates_for_same_chat_run_serially(self) -> None: - """Two updates for the same chat must process in arrival order.""" - ch, _ = _make_telegram() - order: list[int] = [] - slow_event = asyncio.Event() - - async def fake_process(update: Mapping[str, Any]) -> None: - uid = update.get("update_id") - assert isinstance(uid, int) - if uid == 1: - # Block the first update so the second is queued behind it. - await slow_event.wait() - order.append(uid) - - object.__setattr__(ch, "_process_update", cast(Any, fake_process)) - - ch._enqueue_update({"update_id": 1, "message": {"chat": {"id": 100}, "text": "first"}}) - ch._enqueue_update({"update_id": 2, "message": {"chat": {"id": 100}, "text": "second"}}) - - # Let the worker start the first update. - await asyncio.sleep(0) - assert order == [] # blocked on slow_event - slow_event.set() - # Drain. - worker = ch._chat_workers[100] - # Wait for the queue to empty. - await ch._chat_queues[100].join() - # Cleanup - worker.cancel() - with contextlib.suppress(asyncio.CancelledError): - await worker - - assert order == [1, 2] - - async def test_updates_for_different_chats_run_in_parallel(self) -> None: - """Different chats get separate workers and can interleave freely.""" - ch, _ = _make_telegram() - started: list[int] = [] - gate_a = asyncio.Event() - - async def fake_process(update: Mapping[str, Any]) -> None: - uid = update.get("update_id") - assert isinstance(uid, int) - started.append(uid) - if uid == 1: - await gate_a.wait() - - object.__setattr__(ch, "_process_update", cast(Any, fake_process)) - - ch._enqueue_update({"update_id": 1, "message": {"chat": {"id": 1}, "text": "a"}}) - ch._enqueue_update({"update_id": 2, "message": {"chat": {"id": 2}, "text": "b"}}) - - # Both should be admitted into their respective workers despite - # update 1 being blocked. - await asyncio.sleep(0) - # Update 2 finishes; update 1 still blocked. - assert 2 in started - gate_a.set() - for cid in (1, 2): - await ch._chat_queues[cid].join() - for w in ch._chat_workers.values(): - w.cancel() - with contextlib.suppress(asyncio.CancelledError): - await w - - -# --------------------------------------------------------------------------- # -# Webhook ack-before-run + shutdown drains workers # -# --------------------------------------------------------------------------- # - - -class TestWebhookAckBeforeRun: - async def test_webhook_returns_200_before_agent_completes(self) -> None: - """The webhook must ack before the agent runs, to dodge Telegram's 60s redelivery.""" - ch, _ = _make_telegram() - from starlette.requests import Request - - agent_started = asyncio.Event() - agent_release = asyncio.Event() - - async def fake_process(update: Mapping[str, Any]) -> None: - agent_started.set() - await agent_release.wait() - - object.__setattr__(ch, "_process_update", cast(Any, fake_process)) - - async def receive() -> Any: - payload = b'{"update_id":1,"message":{"chat":{"id":42},"text":"hi"}}' - return {"type": "http.request", "body": payload, "more_body": False} - - scope = { - "type": "http", - "method": "POST", - "path": "/telegram/webhook", - "headers": [(b"x-telegram-bot-api-secret-token", b"s3cr3t")], - "query_string": b"", - } - request = Request(scope, receive=receive) - - # Drive the webhook handler. Even though the agent won't complete - # (gate_a still cleared) the webhook must still 200 promptly. - resp = await ch._handle(request) - assert resp.status_code == 200 - # The agent task is in flight but not finished — proves ack came first. - await asyncio.wait_for(agent_started.wait(), timeout=1.0) - assert not agent_release.is_set() - - # Cleanup: release the agent and drain. - agent_release.set() - await ch._chat_queues[42].join() - for w in list(ch._chat_workers.values()): - w.cancel() - with contextlib.suppress(asyncio.CancelledError): - await w - - -class TestShutdownDrainsWorkers: - async def test_shutdown_cancels_in_flight_chat_workers(self) -> None: - """`_on_shutdown` must drain per-chat workers, not leak them.""" - ch, _ = _make_telegram() - forever = asyncio.Event() - - async def stuck(update: Mapping[str, Any]) -> None: - await forever.wait() - - object.__setattr__(ch, "_process_update", cast(Any, stuck)) - ch._enqueue_update({"update_id": 9, "message": {"chat": {"id": 1}, "text": "a"}}) - await asyncio.sleep(0) - assert ch._chat_workers and ch._update_tasks - - await ch._on_shutdown() - assert not ch._chat_workers - assert not ch._update_tasks - - -def _deletewebhook_called(http_mock: Any) -> bool: - return any(call.args and str(call.args[0]).endswith("/deleteWebhook") for call in http_mock.post.call_args_list) - - -class TestWebhookShutdownTeardown: - async def test_shutdown_keeps_webhook_by_default(self) -> None: - """Default: shutdown must NOT delete the webhook (avoids redeploy races).""" - ch, _ = _make_telegram() - assert ch._transport == "webhook" - await ch._on_shutdown() - http_mock = cast(Any, ch._http) - assert http_mock is not None - assert not _deletewebhook_called(http_mock) - http_mock.aclose.assert_awaited() - - async def test_shutdown_deletes_webhook_when_opted_in(self) -> None: - """Opt-in: ``delete_webhook_on_shutdown=True`` performs best-effort teardown.""" - ch = TelegramChannel( - bot_token="123:abc", - webhook_url="https://example.com/hook", - secret_token="s3cr3t", - delete_webhook_on_shutdown=True, - stream=False, - ) - fake_http = MagicMock() - response_mock = MagicMock() - response_mock.json = MagicMock(return_value={"ok": True, "result": {}}) - fake_http.post = AsyncMock(return_value=response_mock) - fake_http.get = AsyncMock(return_value=response_mock) - fake_http.aclose = AsyncMock() - object.__setattr__(ch, "_http", fake_http) - await ch._on_shutdown() - assert _deletewebhook_called(fake_http) - fake_http.aclose.assert_awaited() - - -@dataclass -class _FakeStreamUpdate: - contents: list[Content] = field(default_factory=list) - - @classmethod - def from_text(cls, text: str) -> _FakeStreamUpdate: - return cls(contents=[Content.from_text(text=text)]) - - @classmethod - def from_image(cls, uri: str, media_type: str = "image/png") -> _FakeStreamUpdate: - return cls(contents=[Content.from_uri(uri=uri, media_type=media_type)]) - - -class _FakeResponseStream: - def __init__(self, chunks: list[str | _FakeStreamUpdate], final: _FakeAgentResponse) -> None: - self._chunks = chunks - self._final = final - - def __aiter__(self) -> Any: - async def _gen() -> Any: - for chunk in self._chunks: - if isinstance(chunk, str): - yield _FakeStreamUpdate.from_text(chunk) - else: - yield chunk - - return _gen() - - async def get_final_response(self) -> _FakeAgentResponse: - return self._final - - -class TestStreamingBehavior: - async def test_streaming_long_text_does_not_hang(self) -> None: - ch, _ = _make_telegram() - long_text = "x" * 5000 - stream = _FakeResponseStream([long_text], _FakeAgentResponse(text=long_text)) - request = ChannelRequest(channel="telegram", operation="message.create", input="hello", stream=True) - await asyncio.wait_for( - ch._stream_to_chat(1, request, cast(Any, stream)), - timeout=1.0, - ) # pyright: ignore[reportPrivateUsage] - - async def test_streaming_falls_back_to_send_text_when_final_edit_fails(self) -> None: - ch, _ = _make_telegram() - ch._send_typing_action = False # pyright: ignore[reportPrivateUsage] - - placeholder_response = MagicMock() - placeholder_response.raise_for_status = MagicMock() - placeholder_response.json = MagicMock(return_value={"result": {"message_id": 11}}) - failed_edit_response = MagicMock() - failed_edit_response.status_code = 429 - - http_mock = cast(Any, ch._http) - assert http_mock is not None - http_mock.post = AsyncMock(side_effect=[placeholder_response, failed_edit_response]) - - reply_with_result = AsyncMock() - object.__setattr__(ch, "_reply_with_result", reply_with_result) - - final = _FakeAgentResponse(text="final response") - stream = _FakeResponseStream([], final) - request = ChannelRequest(channel="telegram", operation="message.create", input="hello", stream=True) - await ch._stream_to_chat(6, request, cast(Any, stream)) # pyright: ignore[reportPrivateUsage] - - assert reply_with_result.await_count == 1 - await_args = reply_with_result.await_args - assert await_args is not None - assert await_args.kwargs["send_text"] is True - - async def test_streaming_sends_images_from_final_result(self) -> None: - ch, _ = _make_telegram() - send_photo = AsyncMock() - object.__setattr__(ch, "_send_photo", send_photo) - - final = _FakeAgentResponse( - text="hello", - messages=[ - Message( - "assistant", - [Content.from_uri(uri="https://example.com/cat.png", media_type="image/png")], - ) - ], - ) - stream = _FakeResponseStream(["hello"], final) - request = ChannelRequest(channel="telegram", operation="message.create", input="hello", stream=True) - await ch._stream_to_chat(7, request, cast(Any, stream)) # pyright: ignore[reportPrivateUsage] - - send_photo.assert_awaited_once_with(7, "https://example.com/cat.png") - - async def test_streaming_honors_send_typing_action_toggle(self) -> None: - ch, _ = _make_telegram() - ch._send_typing_action = False # pyright: ignore[reportPrivateUsage] - send_chat_action = AsyncMock() - object.__setattr__(ch, "_send_chat_action", send_chat_action) - - stream = _FakeResponseStream(["hello"], _FakeAgentResponse(text="hello")) - request = ChannelRequest(channel="telegram", operation="message.create", input="hello", stream=True) - await ch._stream_to_chat(8, request, cast(Any, stream)) # pyright: ignore[reportPrivateUsage] - - send_chat_action.assert_not_awaited() - - async def test_streaming_multimodal_updates_do_not_accumulate_as_text(self) -> None: - """Non-text stream updates (e.g. images) must not corrupt the text accumulator.""" - ch, _ = _make_telegram() - send_photo = AsyncMock() - object.__setattr__(ch, "_send_photo", send_photo) - - image_update = _FakeStreamUpdate.from_image("https://example.com/img.png") - final = _FakeAgentResponse( - text="caption", - messages=[ - Message( - "assistant", - [Content.from_uri(uri="https://example.com/img.png", media_type="image/png")], - ) - ], - ) - stream = _FakeResponseStream(["text chunk", image_update], final) - request = ChannelRequest(channel="telegram", operation="message.create", input="hi", stream=True) - await ch._stream_to_chat(9, request, cast(Any, stream)) # pyright: ignore[reportPrivateUsage] - - # Image from the final response must be forwarded. - send_photo.assert_awaited_once_with(9, "https://example.com/img.png") diff --git a/python/packages/hosting/README.md b/python/packages/hosting/README.md index 00a29678c82..7dd927dfac8 100644 --- a/python/packages/hosting/README.md +++ b/python/packages/hosting/README.md @@ -17,9 +17,6 @@ checkpointing should use the existing `CheckpointStorage` abstraction directly; if an app needs per-session resume, keep a small app-owned cursor such as `session_id -> checkpoint_id`. -- Existing experimental channel-hosting types remain available while the package - is unreleased, but the v1 direction is protocol helpers plus app-owned routes. - Use FastAPI, Starlette, Azure Functions, Django, or another framework for route registration, auth, middleware, response construction, and background work. diff --git a/python/packages/hosting/agent_framework_hosting/__init__.py b/python/packages/hosting/agent_framework_hosting/__init__.py index 1b6b9e63d88..698ca1d3037 100644 --- a/python/packages/hosting/agent_framework_hosting/__init__.py +++ b/python/packages/hosting/agent_framework_hosting/__init__.py @@ -1,25 +1,9 @@ # 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. -""" +"""Execution-state helpers for app-owned Agent Framework hosting routes.""" 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 ._state import ( AgentRunArgs, AgentState, @@ -28,20 +12,6 @@ WorkflowRunArgs, WorkflowState, ) -from ._types import ( - Channel, - ChannelCommand, - ChannelCommandContext, - ChannelContribution, - ChannelIdentity, - ChannelRequest, - ChannelResponseHook, - ChannelRunHook, - ChannelSession, - ChannelStreamUpdateHook, - HostedRunResult, - HostStatePaths, -) try: __version__ = importlib.metadata.version(__name__) @@ -49,32 +19,11 @@ __version__ = "0.0.0" __all__ = [ - "ISOLATION_HEADER_CHAT", - "ISOLATION_HEADER_USER", - "AgentFrameworkHost", "AgentRunArgs", "AgentState", - "Channel", - "ChannelCommand", - "ChannelCommandContext", - "ChannelContext", - "ChannelContribution", - "ChannelIdentity", - "ChannelRequest", - "ChannelResponseHook", - "ChannelRunHook", - "ChannelSession", - "ChannelStreamUpdateHook", - "HostStatePaths", - "HostedRunResult", - "IsolationKeys", "SessionStore", "SupportsBuild", "WorkflowRunArgs", "WorkflowState", "__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 deleted file mode 100644 index b82d078eef7..00000000000 --- a/python/packages/hosting/agent_framework_hosting/_host.py +++ /dev/null @@ -1,1425 +0,0 @@ -# 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 AsyncGenerator, AsyncIterator, Awaitable, Callable, Mapping, Sequence -from contextlib import AbstractContextManager, ExitStack, asynccontextmanager, contextmanager -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 opentelemetry import context as otel_context -from opentelemetry import trace -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 may come from - host/platform headers, channel-supplied derivations such as - ``telegram:``, body fields parsed by a channel ``run_hook``, - route/path segments, or environment-provided context in an ephemeral host. - 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 _capture_current_otel_context() -> object | None: - """Capture the current OTel context when a valid span is active. - - Streaming channels can defer target iteration until after the route handler - has returned (for example, `StreamingResponse`). Capturing the current OTel - context at stream-construction time lets the host restore strict parent-child - span linkage during deferred pulls and finalization. - """ - current_span_context = trace.get_current_span().get_span_context() - if not current_span_context.is_valid: - return None - return otel_context.get_current() - - -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() -> AsyncGenerator[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, - *, - otel_context_snapshot: object | None = None, - ) -> None: - self._inner = inner - self._stack = stack - self._otel_context_snapshot = otel_context_snapshot - self._closed = False - - def _close(self) -> None: - if self._closed: - return - self._closed = True - self._stack.close() - - @contextmanager - def _activate_otel_context(self) -> Any: - """Re-activate the captured OTel parent context for deferred work.""" - if self._otel_context_snapshot is None: - yield - return - token = otel_context.attach(cast("Any", self._otel_context_snapshot)) - try: - yield - finally: - otel_context.detach(token) - - 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]: - with self._activate_otel_context(): - iterator = self._inner.__aiter__() - try: - while True: - try: - with self._activate_otel_context(): - item = await iterator.__anext__() - except StopAsyncIteration: - break - yield item - finally: - self._close() - - async def get_final_response(self) -> Any: - try: - with self._activate_otel_context(): - 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 platform-provided 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 platform-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._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") - 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 serve as _hypercorn_serve # pyright: ignore[reportUnknownVariableType] - from hypercorn.config import Config - 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() - config.bind = [f"{host}:{port}"] - config.workers = workers - for key, value in config_kwargs.items(): - setattr(config, key, value) - - # 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) -> AsyncGenerator[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. - create_session = cast("Callable[..., Any]", self.target.create_session) # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType] - new_session = create_session(session_id=session_id) - session = self._sessions.setdefault(isolation_key, new_session) - - 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`` - (if the channel or its ``run_hook`` chose to add any), 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, - }, - ) - 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. - - 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. - # 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 - # ``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) - # Capture the request-parent OTel context BEFORE ``target.run``. - # Python evaluates positional args before keyword args, so doing - # this inline in the ``_BoundResponseStream(...)`` call would run - # ``target.run(...)`` first and may capture a shifted context. - otel_context_snapshot = _capture_current_otel_context() - return _BoundResponseStream( # type: ignore[return-value] - self.target.run(self._wrap_input(request), stream=True, **run_kwargs), - binder, - otel_context_snapshot=otel_context_snapshot, - ) - - 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. - 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) - - 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 deleted file mode 100644 index 8459b735fd6..00000000000 --- a/python/packages/hosting/agent_framework_hosting/_isolation.py +++ /dev/null @@ -1,82 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Per-request isolation keys for host/platform-provided request context. - -``ChannelSession.isolation_key`` is the host's generic session partition key, -but different channels and platforms discover that key from different places: -protocol headers, request bodies, URL/path segments, webhook metadata, or -environment-provided context for ephemeral hosts. - -This module covers the request-context case where a platform provides -isolation outside the channel payload. The Foundry Hosted Agents runtime, for -example, injects two well-known headers on requests 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 - -The generic host intentionally reuses those header names so the same isolation -context can be consumed by supporting providers. Reusing the names does **not** -mean ``agent-framework-hosting`` is a supported way to run on Foundry Hosted -Agents; use ``agent-framework-foundry-hosting`` for that hosting surface. - -When those headers are present the host-installed ASGI middleware pushes them -into :data:`current_isolation_keys` for the duration of the request, then -resets it. Channels may still choose a different session key source and pass it -directly via ``ChannelSession(isolation_key=...)``. - -The contextvar holds a plain :class:`IsolationKeys` mapping; conversion to -provider-specific types 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 isolation keys lifted from host/platform context.""" - - 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 deleted file mode 100644 index ae5691ec092..00000000000 --- a/python/packages/hosting/agent_framework_hosting/_persistence.py +++ /dev/null @@ -1,128 +0,0 @@ -# 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 importlib -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: - return importlib.import_module("diskcache") - 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 - - -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 deleted file mode 100644 index 817f9bd52e1..00000000000 --- a/python/packages/hosting/agent_framework_hosting/_state_store.py +++ /dev/null @@ -1,146 +0,0 @@ -# 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 deleted file mode 100644 index 23c172d9a70..00000000000 --- a/python/packages/hosting/agent_framework_hosting/_types.py +++ /dev/null @@ -1,212 +0,0 @@ -# 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 index a87782136fe..23a9ad49fee 100644 --- a/python/packages/hosting/pyproject.toml +++ b/python/packages/hosting/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-framework-hosting" -description = "Multi-channel hosting for Microsoft Agent Framework agents." +description = "Execution-state helpers for app-owned Agent Framework hosting routes." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" @@ -24,15 +24,6 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.10.0,<2", - "starlette>=0.37", -] - -[project.optional-dependencies] -serve = [ - "hypercorn>=0.17", -] -disk = [ - "diskcache>=5.6", ] [tool.uv] @@ -85,8 +76,3 @@ cmd = 'pytest -m "not integration" --cov=agent_framework_hosting --cov-report=te [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/hosting/test_host.py b/python/packages/hosting/tests/hosting/test_host.py deleted file mode 100644 index 781c7340bcc..00000000000 --- a/python/packages/hosting/tests/hosting/test_host.py +++ /dev/null @@ -1,1482 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Tests for :class:`AgentFrameworkHost` invocation, session, and delivery routing.""" - -from __future__ import annotations - -import importlib -from collections.abc import AsyncIterator, Sequence -from dataclasses import dataclass, field -from typing import Any, cast - -import pytest -from agent_framework import ( - AgentResponse, - AgentResponseUpdate, - AgentSession, - Content, - Message, - ResponseStream, - ServiceSessionId, -) -from agent_framework._workflows._events import WorkflowEvent -from opentelemetry import context as otel_context -from opentelemetry import trace -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.id = "fake-agent" - self.name: str | None = "Fake Agent" - self.description: str | None = "Test fake agent" - self._reply = reply - self.calls: list[dict[str, Any]] = [] - self.created_sessions: list[AgentSession] = [] - - def create_session(self, *, session_id: str | None = None) -> AgentSession: - s = AgentSession(session_id=session_id) - self.created_sessions.append(s) - return s - - def get_session(self, service_session_id: str | ServiceSessionId, *, session_id: str | None = None) -> AgentSession: - return AgentSession(session_id=session_id, service_session_id=service_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: - 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)) - - -def _workflow_fixture(name: str) -> Any: - return getattr(importlib.import_module("hosting_workflow_fixtures"), name) - - -@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: - build_upper_workflow = _workflow_fixture("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: - build_echo_workflow = _workflow_fixture("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: - build_multi_chunk_workflow = _workflow_fixture("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 is not None - 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 - - _UpperExecutor = _workflow_fixture("_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: - build_upper_workflow = _workflow_fixture("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: - build_upper_workflow = _workflow_fixture("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: - build_echo_workflow = _workflow_fixture("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 - - build_upper_workflow = _workflow_fixture("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, cast(Any, None)) - - -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 - - build_upper_workflow = _workflow_fixture("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: - build_upper_workflow = _workflow_fixture("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: - id = "multi-modal-agent" - name: str | None = "Multi Modal Agent" - description: str | None = "Test multi-modal agent" - - def create_session(self, *, session_id: str | None = None) -> AgentSession: - return AgentSession(session_id=session_id) - - def get_session( - self, service_session_id: str | ServiceSessionId, *, session_id: str | None = None - ) -> AgentSession: - return AgentSession(session_id=session_id, service_session_id=service_session_id) - - def run(self, *_args: Any, **_kwargs: Any) -> Any: - async def _coro() -> Any: - return _MultiModalResponse() - - return _coro() - - 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.id = "providers-agent" - self.name: str | None = "Providers Agent" - self.description: str | None = "Test providers agent" - self.context_providers = list(providers) - self._reply = reply - self.calls: list[dict[str, Any]] = [] - - def create_session(self, *, session_id: str | None = None) -> AgentSession: - return AgentSession(session_id=session_id) - - def get_session(self, service_session_id: str | ServiceSessionId, *, session_id: str | None = None) -> AgentSession: - return AgentSession(session_id=session_id, service_session_id=service_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 cast(Any, stream).aclose() - await cast(Any, stream).aclose() - 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 cast(Any, stream).aclose() - - # 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(cast(Any, stream).with_result_hook) - finally: - await cast(Any, stream).aclose() - - 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 - - async def test_deferred_streaming_keeps_captured_otel_parent_context(self) -> None: - """`run_stream()` captures the current OTel context and reuses it for deferred pulls. - - Reproduces channel behavior where stream consumption starts later than stream - construction (for example via StreamingResponse body iteration). - """ - - class _SpanRecordingAgent: - id = "span-recorder" - name: str | None = "SpanRecorder" - description: str | None = "Records active span ids during stream pulls/finalization." - - def __init__(self) -> None: - self.seen_span_ids: list[int] = [] - - def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any: - if not stream: - raise AssertionError("non-streaming path not exercised here") - - async def _gen() -> AsyncIterator[AgentResponseUpdate]: - self.seen_span_ids.append(trace.get_current_span().get_span_context().span_id) - yield AgentResponseUpdate(contents=[Content.from_text("chunk")], role="assistant") - - async def _finalize(items: Sequence[AgentResponseUpdate]) -> AgentResponse: # noqa: RUF029 - self.seen_span_ids.append(trace.get_current_span().get_span_context().span_id) - return AgentResponse.from_updates(items) - - return ResponseStream(_gen(), finalizer=_finalize) - - agent = _SpanRecordingAgent() - ch = _RecordingChannel(name="responses") - host = AgentFrameworkHost(target=cast(Any, 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_otel"}, - ) - - parent_ctx = trace.SpanContext( - trace_id=0x123456789ABCDEF0123456789ABCDEF0, - span_id=0x123456789ABCDEF0, - is_remote=False, - trace_flags=trace.TraceFlags(0x01), - trace_state=trace.TraceState(), - ) - parent_span = trace.NonRecordingSpan(parent_ctx) - token = otel_context.attach(trace.set_span_in_context(parent_span)) - try: - stream = await ch.context.run_stream(req) - finally: - otel_context.detach(token) - - # Consumption happens after the caller context has ended. - chunks = [u.text async for u in stream] - final = await stream.get_final_response() - - assert chunks == ["chunk"] - assert final.text == "chunk" - assert agent.seen_span_ids == [parent_ctx.span_id, parent_ctx.span_id] - - async def test_run_stream_captures_otel_context_before_target_run(self, monkeypatch: Any) -> None: - """Guard the evaluation-order pitfall called out in review. - - ``_invoke_stream`` must capture OTel context before calling - ``target.run(...)``. If that order flips, deferred streaming can bind to - the wrong parent context. - """ - - from agent_framework_hosting import _host as host_module - - order: list[str] = [] - - def _capture() -> None: - order.append("capture") - return - - monkeypatch.setattr(host_module, "_capture_current_otel_context", _capture) - - class _OrderAgent: - id = "order-agent" - name: str | None = "OrderAgent" - description: str | None = "Records call order." - - def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any: - order.append("run") - - async def _gen() -> AsyncIterator[AgentResponseUpdate]: - yield AgentResponseUpdate(contents=[Content.from_text("chunk")], role="assistant") - - async def _finalize(items: Sequence[AgentResponseUpdate]) -> AgentResponse: # noqa: RUF029 - return AgentResponse.from_updates(items) - - return ResponseStream(_gen(), finalizer=_finalize) - - ch = _RecordingChannel(name="responses") - host = AgentFrameworkHost(target=cast(Any, _OrderAgent()), channels=[ch]) - _ = host.app - assert ch.context is not None - - stream = await ch.context.run_stream( - ChannelRequest(channel="responses", operation="op", input="hi", stream=True), - ) - await cast(Any, stream).aclose() - - assert order[:2] == ["capture", "run"] - - -# --------------------------------------------------------------------------- # -# `_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: - del context - - 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: - del context - - 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/hosting/test_host_disk.py b/python/packages/hosting/tests/hosting/test_host_disk.py deleted file mode 100644 index 607de72cbce..00000000000 --- a/python/packages/hosting/tests/hosting/test_host_disk.py +++ /dev/null @@ -1,239 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Tests for narrowed ``state_dir`` support in :class:`AgentFrameworkHost`.""" - -from __future__ import annotations - -import importlib -from pathlib import Path -from typing import Any, cast - -import pytest -from agent_framework import AgentSession, ServiceSessionId - -from agent_framework_hosting import AgentFrameworkHost, ChannelContext, ChannelContribution - -pytest.importorskip("diskcache") - - -class _AgentStub: - """Bare-minimum SupportsAgentRun stub for host construction.""" - - id = "agent-stub" - name: str | None = "Agent Stub" - description: str | None = "Test agent stub" - - def create_session(self, *, session_id: str | None = None) -> AgentSession: - return AgentSession(session_id=session_id) - - def get_session(self, service_session_id: str | ServiceSessionId, *, session_id: str | None = None) -> AgentSession: - return AgentSession(service_session_id=service_session_id, session_id=session_id) - - def run(self, *_args: Any, **_kwargs: Any) -> Any: # pragma: no cover - unused - raise RuntimeError("not invoked") - - -class _ChannelStub: - name = "stub" - path = "/stub" - - def contribute(self, context: ChannelContext) -> ChannelContribution: - del context - 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=cast(Any, {key: tmp_path / key}), - ) - - -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.""" - build_upper_workflow = importlib.import_module("hosting_workflow_fixtures").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 - - _UpperExecutor = importlib.import_module("hosting_workflow_fixtures")._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/hosting/test_isolation.py b/python/packages/hosting/tests/hosting/test_isolation.py deleted file mode 100644 index c1c2a776551..00000000000 --- a/python/packages/hosting/tests/hosting/test_isolation.py +++ /dev/null @@ -1,318 +0,0 @@ -# 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 -from typing import Any - -import pytest -from agent_framework import AgentSession, ServiceSessionId -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, - 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: dict[str, str | bool | None] - 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: - del context - return ChannelContribution(routes=self._routes) - - -def _make_host_with_probe() -> tuple[AgentFrameworkHost, _IsolationProbeChannel]: - class _NoopAgent: - id = "noop-agent" - name: str | None = "Noop Agent" - description: str | None = "Test noop agent" - - def create_session(self, *, session_id: str | None = None) -> AgentSession: - return AgentSession(session_id=session_id) - - def get_session( - self, service_session_id: str | ServiceSessionId, *, session_id: str | None = None - ) -> AgentSession: - return AgentSession(service_session_id=service_session_id, session_id=session_id) - - def run(self, *_args: object, **_kwargs: object) -> Any: # pragma: no cover - never called - raise RuntimeError("not invoked") - - probe = _IsolationProbeChannel() - assert isinstance(probe, Channel) - host = AgentFrameworkHost(target=_NoopAgent(), channels=[probe]) - 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/hosting/test_types.py b/python/packages/hosting/tests/hosting/test_types.py deleted file mode 100644 index 2c77cbee6b5..00000000000 --- a/python/packages/hosting/tests/hosting/test_types.py +++ /dev/null @@ -1,50 +0,0 @@ -# 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 046db7cd190..b1d823fd40d 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -93,7 +93,6 @@ agent-framework-gemini = { workspace = true } agent-framework-github-copilot = { workspace = true } agent-framework-hosting = { workspace = true } agent-framework-hosting-responses = { workspace = true } -agent-framework-hosting-telegram = { workspace = true } agent-framework-hyperlight = { workspace = true } agent-framework-lab = { workspace = true } agent-framework-mem0 = { workspace = true } diff --git a/python/samples/04-hosting/af-hosting/README.md b/python/samples/04-hosting/af-hosting/README.md index 367dfcabc71..c75ffd1cb94 100644 --- a/python/samples/04-hosting/af-hosting/README.md +++ b/python/samples/04-hosting/af-hosting/README.md @@ -1,44 +1,32 @@ -# Multi-channel hosting samples +# Agent Framework hosting helper samples -End-to-end samples for serving an `agent-framework` agent (or workflow) -through one or more **channels** with `agent-framework-hosting`. +End-to-end samples for exposing Agent Framework targets through app-owned +hosting routes. -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`. +The helper-first hosting packages provide protocol conversion and optional +execution state. The application still owns the web framework, native SDK +clients, authentication, response construction, and deployment shape. | Sample | What it shows | Packaging | |---|---|---| -| [`local_responses/`](./local_responses) | The minimal shape: one agent + one `@tool` + native FastAPI route + Responses helper functions + `SessionStore`. | **Local only.** Start here to learn the helper 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.** | -| [`local_telegram/`](./local_telegram) | Telegram bot with `@tool`, `FileHistoryProvider`, `run_hook`, and slash commands (`/new`, `/whoami`, `/weather`). Pure Telegram — no HTTP endpoint. | **Local only.** Start here to learn the Telegram channel. | -| [`local_multi_channel/`](./local_multi_channel) | Same agent behind two channels at once: `ResponsesChannel` + `TelegramChannel`. Shared `FileHistoryProvider` enables cross-channel session resumption (resume a Telegram chat from the Responses endpoint by passing the Telegram isolation key as `previous_response_id`). | **Local only.** | +| [`local_responses/`](./local_responses) | One agent + one `@tool` + native FastAPI route + Responses helper functions + `AgentState` / `SessionStore`. | **Local only.** Start here to learn the helper seam. | -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. +Each sample is self-contained with its own `pyproject.toml`, server `app.py`, +calling script(s), and `storage/` directory. Samples use `[tool.uv.sources]` +to wire unreleased hosting packages to the upstream repo while those packages +are still pre-PyPI. Once those packages publish, drop the `[tool.uv.sources]` +block and let the declared dependencies 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**. +contains samples for agents that run inside the Foundry Hosted Agents platform. +Those samples use the Foundry-managed protocol surface 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. +| Server stack | App-owned FastAPI + hosting protocol helpers | Foundry Hosted Agents runtime | +| Protocol surface | The app exposes the route and calls helpers | The platform exposes Responses + Invocations | +| Run target | Local Hypercorn (`local_responses/`) | Hosted Agents or local container targeting the Hosted Agents contract | +| When to pick this | You need custom hosting code or want to learn the helper seam | You want the Foundry-managed hosting surface | diff --git a/python/samples/04-hosting/af-hosting/local_multi_channel/README.md b/python/samples/04-hosting/af-hosting/local_multi_channel/README.md deleted file mode 100644 index c0df45e1f30..00000000000 --- a/python/samples/04-hosting/af-hosting/local_multi_channel/README.md +++ /dev/null @@ -1,58 +0,0 @@ -# local_multi_channel — Responses + Telegram, shared history, cross-channel sessions - -Runs the same agent behind two channels at once: an OpenAI-compatible -**Responses** HTTP endpoint and a **Telegram** bot. Both channels share a -`FileHistoryProvider`, so history is preserved per user/chat across restarts -and the same conversation can be resumed from either surface. - -What this sample shows: - -- A `@tool`-decorated function (`lookup_weather`) exercised end-to-end. -- `FileHistoryProvider(./storage/sessions)` — per-user/per-chat history that - survives restarts. -- `responses_hook` — strips caller-supplied `model`/`store`/`temperature`, keys - each Responses session off the `safety_identifier` field, and supports - resuming a Telegram chat by passing its isolation key as - `previous_response_id`. -- `telegram_hook` — strips `model` and raises reasoning effort for a richer - Telegram persona. -- `/new`, `/whoami`, `/weather ` Telegram commands. - -`app:app` is a module-level Starlette ASGI app, so this sample runs under -Hypercorn (multi-process). - -## Run - -```bash -export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com -export FOUNDRY_MODEL=gpt-4o -export TELEGRAM_BOT_TOKEN=... -az login - -uv sync -uv run hypercorn app:app \ - --bind 0.0.0.0:8000 \ - --workers 4 -``` - -Single-process for quick iteration: - -```bash -uv run python app.py -``` - -## Call via the Responses endpoint - -```bash -uv sync --group dev - -# Plain call: -uv run python call_server.py "What is the weather in Tokyo?" - -# Resume a Telegram chat session from the Responses endpoint: -uv run python call_server.py --previous-response-id telegram:8741188429 "What did we discuss?" -``` - -> This sample is **local-only** — it shows the `agent-framework-hosting` -> server stack as a standalone process. For Foundry-hosted deployment -> guidance see [`../../../../packages/foundry_hosting/README.md`](../../../../packages/foundry_hosting/README.md). diff --git a/python/samples/04-hosting/af-hosting/local_multi_channel/app.py b/python/samples/04-hosting/af-hosting/local_multi_channel/app.py deleted file mode 100644 index 46b2c1b6870..00000000000 --- a/python/samples/04-hosting/af-hosting/local_multi_channel/app.py +++ /dev/null @@ -1,213 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Multi-channel hosting sample: Responses + Telegram. - -Demonstrates running the same agent behind two channels at once: - -- ``ResponsesChannel`` — exposes an OpenAI-compatible ``/responses`` endpoint - so any OpenAI-SDK client can call the agent over HTTP. The ``responses_hook`` - strips caller-supplied options the host owns and keys each session off the - OpenAI ``safety_identifier`` field. - -- ``TelegramChannel`` — connects the same agent to a Telegram bot. The - ``telegram_hook`` raises reasoning effort for a richer Telegram persona. - Because both channels share the same ``FileHistoryProvider``, a Telegram chat - and a Responses caller can resume the *same* conversation: pass the Telegram - isolation key (e.g. ``telegram:8741188429``) as ``previous_response_id`` on - the Responses endpoint. - -Required env: ``FOUNDRY_PROJECT_ENDPOINT``, ``FOUNDRY_MODEL``, -``TELEGRAM_BOT_TOKEN``. Auth uses ``DefaultAzureCredential``. - -Run ---- -``app`` is a module-level Starlette ASGI app. Recommended production launch is -**Hypercorn**:: - - hypercorn app:app --bind 0.0.0.0:8000 --workers 4 - -The ``__main__`` block uses ``host.serve(...)`` (single-process Hypercorn) for -quick local iteration:: - - uv run python app.py - -Note ----- -``FileHistoryProvider`` uses in-process file-write locking. It is fine for this -sample but swap it for a cross-process store in production. -""" - -from __future__ import annotations - -import os -from dataclasses import replace -from pathlib import Path -from typing import Annotated - -from agent_framework import Agent, FileHistoryProvider, tool -from agent_framework_foundry import FoundryChatClient -from agent_framework_hosting import ( - AgentFrameworkHost, - ChannelCommand, - ChannelCommandContext, - ChannelRequest, - ChannelSession, -) -from agent_framework_hosting_responses import ResponsesChannel -from agent_framework_hosting_telegram import TelegramChannel, telegram_isolation_key -from azure.identity.aio import DefaultAzureCredential - -# import logging -# logging.basicConfig(level=logging.DEBUG) - -SESSIONS_DIR = Path(__file__).resolve().parent / "storage" / "sessions" -SESSIONS_DIR.mkdir(parents=True, exist_ok=True) - - -# --------------------------------------------------------------------------- # -# Shared tool -# --------------------------------------------------------------------------- # - - -@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 = 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.", - "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.") - - -# --------------------------------------------------------------------------- # -# Channel hooks -# --------------------------------------------------------------------------- # - - -def responses_hook(request: ChannelRequest, *, protocol_request: dict | None = None, **_: object) -> ChannelRequest: - """Strip caller-supplied options the host should own and key the session. - - - Removes ``model``, ``store``, and ``temperature`` so callers cannot - override the host's choices. - - Keys each session off the inbound ``previous_response_id`` (if present) - so any caller can resume an existing AgentSession — including one written - by the Telegram channel (e.g. ``previous_response_id="telegram:8741188429"``). - - Falls back to a ``responses:`` key when no - ``previous_response_id`` is supplied. - """ - options = dict(request.options or {}) - options.pop("model", None) - options.pop("temperature", None) - options.pop("store", None) - - body = protocol_request or {} - - if request.session is not None and request.session.isolation_key: - session = request.session - else: - safety_id = body.get("safety_identifier") or "anonymous" - session = ChannelSession(isolation_key=f"responses:{safety_id}") - - return replace(request, session=session, options=options or None) - - -def telegram_hook(request: ChannelRequest, **_: object) -> ChannelRequest: - """Raise reasoning effort for the Telegram persona.""" - options = dict(request.options or {}) - options.pop("model", None) - options["reasoning"] = {"effort": "high", "summary": "detailed"} - return replace(request, options=options) - - -# --------------------------------------------------------------------------- # -# Telegram commands -# --------------------------------------------------------------------------- # - - -def _isolation_key(ctx: ChannelCommandContext) -> str: - return telegram_isolation_key(ctx.request.attributes.get("chat_id")) - - -def make_commands(host_ref: dict[str, AgentFrameworkHost]) -> list[ChannelCommand]: - """Build commands that close over the host so ``/new`` can reset state.""" - - async def handle_start(ctx: ChannelCommandContext) -> None: - await ctx.reply("Hi! I'm a multi-channel weather agent.\nCommands: /new, /whoami, /weather , /help.") - - async def handle_help(ctx: ChannelCommandContext) -> None: - await ctx.reply( - "/new — start a fresh conversation\n" - "/whoami — show your isolation key\n" - "/weather — call the weather tool directly\n" - "/help — this message" - ) - - async def handle_new(ctx: ChannelCommandContext) -> None: - host_ref["host"].reset_session(_isolation_key(ctx)) - await ctx.reply("New session started. Previous history is cleared for this chat.") - - async def handle_whoami(ctx: ChannelCommandContext) -> None: - await ctx.reply(f"Your isolation key on this host is: {_isolation_key(ctx)}") - - async def handle_weather(ctx: ChannelCommandContext) -> None: - command_text = ctx.request.input if isinstance(ctx.request.input, str) else "" - _, _, location = command_text.partition(" ") - location = location.strip() or "Seattle" - await ctx.reply(lookup_weather(location=location)) - - return [ - ChannelCommand("start", "Introduce the bot", handle_start), - ChannelCommand("help", "List available commands", handle_help), - ChannelCommand("new", "Start a new session for this chat", handle_new), - ChannelCommand("whoami", "Show the isolation key for this chat", handle_whoami), - ChannelCommand("weather", "Call the weather tool: /weather ", handle_weather), - ] - - -# --------------------------------------------------------------------------- # -# Host wiring -# --------------------------------------------------------------------------- # - - -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}, - ) - - host_ref: dict[str, AgentFrameworkHost] = {} - host = AgentFrameworkHost( - target=agent, - channels=[ - ResponsesChannel(run_hook=responses_hook), - TelegramChannel( - bot_token=os.environ["TELEGRAM_BOT_TOKEN"], - webhook_url=os.environ.get("TELEGRAM_WEBHOOK_URL"), - secret_token=os.environ.get("TELEGRAM_WEBHOOK_SECRET"), - parse_mode="Markdown", - commands=make_commands(host_ref), - run_hook=telegram_hook, - ), - ], - debug=True, - ) - host_ref["host"] = host - return host - - -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_multi_channel/call_server.py b/python/samples/04-hosting/af-hosting/local_multi_channel/call_server.py deleted file mode 100644 index 8a364d74a5b..00000000000 --- a/python/samples/04-hosting/af-hosting/local_multi_channel/call_server.py +++ /dev/null @@ -1,53 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Local Responses-endpoint client for the local_multi_channel sample. - -POSTs to the ``/responses`` endpoint using the OpenAI SDK. - -The ``responses_hook`` on the server keys per-user history off the OpenAI -``safety_identifier`` field. Pass ``--previous-response-id`` to resume an -existing AgentSession by its isolation key — this works across channels, so -you can resume a Telegram chat by passing its isolation key:: - - uv run python call_server.py --previous-response-id telegram:8741188429 "What did we discuss?" - -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?" - uv run python call_server.py --previous-response-id telegram:8741188429 "What did we discuss?" -""" - -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 AgentSession: {previous_response_id}") - prompt = " ".join(args) or "What is the weather in Seattle?" - client = OpenAI(base_url=BASE_URL, api_key="not-needed") - response = client.responses.create( - model="agent", - input=prompt, - safety_identifier="local-dev", - 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_multi_channel/pyproject.toml b/python/samples/04-hosting/af-hosting/local_multi_channel/pyproject.toml deleted file mode 100644 index ca5dcef4a26..00000000000 --- a/python/samples/04-hosting/af-hosting/local_multi_channel/pyproject.toml +++ /dev/null @@ -1,26 +0,0 @@ -[project] -name = "agent-framework-hosting-sample-local-multi-channel" -version = "0.0.1" -description = "Multi-channel hosting sample (Responses + Telegram) with a shared agent, FileHistoryProvider, and cross-channel session resumption." -requires-python = ">=3.10" -dependencies = [ - "agent-framework-foundry", - "agent-framework-hosting", - "agent-framework-hosting-responses", - "agent-framework-hosting-telegram", - "azure-identity", - "aiohttp>=3.13.5", - "hypercorn>=0.17", - "httpx>=0.27", -] - -[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" } -agent-framework-hosting-telegram = { git = "https://github.com/microsoft/agent-framework.git", branch = "main", subdirectory = "python/packages/hosting-telegram" } 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 deleted file mode 100644 index bb9392a7078..00000000000 --- a/python/samples/04-hosting/af-hosting/local_responses_workflow/README.md +++ /dev/null @@ -1,83 +0,0 @@ -# local_responses_workflow — workflow target with run-hook prep + checkpoints - -A `Workflow` (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. The `run_hook` prepares the writer prompt -before the workflow starts. - -## 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 `prepare_writer_prompt` - run hook that **adapts the channel-native input into the workflow start - executor's input**. Responses delivers a `list[Message]`; the hook normalises - it to text and prepares the prompt the writer agent receives. -- 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 starts directly at the writer `AgentExecutor`; no extra intake - executor is needed because the hook performs the one preparation step. -- `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-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"}' - -# The client intentionally omits `model`; the host chooses the backing -# deployment from FOUNDRY_MODEL. - -# 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 deleted file mode 100644 index 91294e40425..00000000000 --- a/python/samples/04-hosting/af-hosting/local_responses_workflow/app.py +++ /dev/null @@ -1,182 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Hosted workflow sample with run-hook input prep + checkpoint location. - -Same three-agent slogan workflow as -``../../foundry-hosted-agents/responses/05_workflows/main.py`` (writer → -legal reviewer → formatter), driven through the ``agent-framework-hosting`` -stack instead of the Foundry-Hosted-Agents runtime. - -Workflow shape --------------- -``writer`` → ``legal_reviewer`` → ``formatter``. A single run hook parses -the Responses input and prepares the prompt the writer agent receives. - -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 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 the prepared - writer prompt. -- ``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-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 replace -from pathlib import Path - -from agent_framework import ( - Agent, - AgentExecutor, - Message, - WorkflowBuilder, -) -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) - - -def prepare_writer_prompt(request: ChannelRequest, **_: object) -> ChannelRequest: - """Prepare the workflow's initial writer prompt from Responses input. - - The channel hands the host either a ``str`` (rare on the Responses - surface) or a list of :class:`Message`. This hook collapses that - input to text, accepts either JSON or plain text, and replaces the - request input with a plain prompt for the writer executor. - """ - - def extract_text(value: object) -> str: - 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 "" - - text = extract_text(request.input).strip() - topic = text or "a generic product" - style = "modern" - audience = "general" - if topic.startswith("{"): - try: - data = json.loads(topic) - except json.JSONDecodeError: - data = None - if isinstance(data, dict) and "topic" in data: - topic = str(data["topic"]) - style = str(data.get("style", style)) - audience = str(data.get("audience", audience)) - - prompt = ( - f"Topic: {topic}\n" - f"Style: {style}\n" - f"Audience: {audience}\n\n" - "Write a single short slogan that fits the topic, style, and audience." - ) - return replace(request, input=prompt) - - -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." - ), - ) - - # ``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=writer_ex, - output_executors=[format_ex], - ) - .add_edge(writer_ex, legal_ex) - .add_edge(legal_ex, format_ex) - .build() - ) - - return AgentFrameworkHost( - target=workflow, - channels=[ - ResponsesChannel(run_hook=prepare_writer_prompt), - ], - # 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 deleted file mode 100644 index 6cd56a244fb..00000000000 --- a/python/samples/04-hosting/af-hosting/local_responses_workflow/call_server.py +++ /dev/null @@ -1,53 +0,0 @@ -# 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( - 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 deleted file mode 100644 index ef30538047c..00000000000 --- a/python/samples/04-hosting/af-hosting/local_responses_workflow/call_server.rest +++ /dev/null @@ -1,48 +0,0 @@ -# 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 deleted file mode 100644 index 915bdb1298f..00000000000 --- a/python/samples/04-hosting/af-hosting/local_responses_workflow/pyproject.toml +++ /dev/null @@ -1,23 +0,0 @@ -[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 deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/python/samples/04-hosting/af-hosting/local_telegram/README.md b/python/samples/04-hosting/af-hosting/local_telegram/README.md deleted file mode 100644 index b3778595ba3..00000000000 --- a/python/samples/04-hosting/af-hosting/local_telegram/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# local_telegram — Telegram bot with `@tool`, file-backed history, slash commands - -Minimal sample for hosting an agent as a Telegram bot using -`agent-framework-hosting` + `agent-framework-hosting-telegram`. - -What this sample shows: - -- A `@tool`-decorated function (`lookup_weather`) exercised end-to-end with streaming. -- `FileHistoryProvider(./storage/sessions)` — per-chat history that survives restarts. -- `run_hook` — strips caller-supplied `model` and raises reasoning effort. -- Slash commands: `/start`, `/help`, `/new`, `/whoami`, `/weather `. - -`app:app` is a module-level Starlette ASGI app. - -## Run - -```bash -export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com -export FOUNDRY_MODEL=gpt-4o -export TELEGRAM_BOT_TOKEN=... -az login - -uv sync -uv run python app.py -``` - -Production launch with Hypercorn (polling transport — no public URL needed): - -```bash -uv run hypercorn app:app --bind 0.0.0.0:8000 --workers 4 -``` - -Webhook transport (requires a public HTTPS URL, e.g. via `ngrok`): - -```bash -export TELEGRAM_WEBHOOK_URL=https:///telegram/webhook -uv run hypercorn app:app --bind 0.0.0.0:8000 -``` - -> This sample is **local-only**. For a multi-channel variant that also exposes -> an OpenAI-compatible Responses endpoint, see -> [`../local_multi_channel/`](../local_multi_channel). diff --git a/python/samples/04-hosting/af-hosting/local_telegram/app.py b/python/samples/04-hosting/af-hosting/local_telegram/app.py deleted file mode 100644 index 154dc642f4b..00000000000 --- a/python/samples/04-hosting/af-hosting/local_telegram/app.py +++ /dev/null @@ -1,176 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Telegram-only hosting sample. - -A single agent connected to a Telegram bot via ``TelegramChannel``. - -- ``lookup_weather`` tool demonstrates streaming and tool invocation end-to-end. -- ``FileHistoryProvider`` persists per-chat history across restarts. -- ``run_hook`` strips caller-supplied model options (the host owns model - selection) and raises reasoning effort for a richer Telegram persona. -- Slash commands: ``/start``, ``/help``, ``/new``, ``/whoami``, - ``/weather ``. - -Required env: ``FOUNDRY_PROJECT_ENDPOINT``, ``FOUNDRY_MODEL``, -``TELEGRAM_BOT_TOKEN``. Auth uses ``DefaultAzureCredential``. - -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-4o - export TELEGRAM_BOT_TOKEN=... - uv run hypercorn app:app --bind 0.0.0.0:8000 - -Or use the ``__main__`` block for quick local iteration:: - - uv run python app.py -""" - -from __future__ import annotations - -import os -from dataclasses import replace -from pathlib import Path -from typing import Annotated - -from agent_framework import Agent, FileHistoryProvider, tool -from agent_framework_foundry import FoundryChatClient -from agent_framework_hosting import ( - AgentFrameworkHost, - ChannelCommand, - ChannelCommandContext, - ChannelRequest, -) -from agent_framework_hosting_telegram import TelegramChannel, telegram_isolation_key -from azure.identity.aio import DefaultAzureCredential - -# import logging -# logging.basicConfig(level=logging.DEBUG) - -SESSIONS_DIR = Path(__file__).resolve().parent / "storage" / "sessions" -SESSIONS_DIR.mkdir(parents=True, exist_ok=True) - - -# --------------------------------------------------------------------------- # -# Tool -# --------------------------------------------------------------------------- # - - -@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 = 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.", - "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.") - - -# --------------------------------------------------------------------------- # -# Run hook -# --------------------------------------------------------------------------- # - - -def run_hook(request: ChannelRequest, **_: object) -> ChannelRequest: - """Strip caller-supplied options the host owns and raise reasoning effort.""" - options = dict(request.options or {}) - options.pop("model", None) - options["reasoning"] = {"effort": "high", "summary": "detailed"} - return replace(request, options=options) - - -# --------------------------------------------------------------------------- # -# Telegram commands -# --------------------------------------------------------------------------- # - - -def _isolation_key(ctx: ChannelCommandContext) -> str: - return telegram_isolation_key(ctx.request.attributes.get("chat_id")) - - -def make_commands(host_ref: dict[str, AgentFrameworkHost]) -> list[ChannelCommand]: - """Build slash commands that close over the host so ``/new`` can reset state.""" - - async def handle_start(ctx: ChannelCommandContext) -> None: - await ctx.reply("Hi! I'm a weather assistant.\nCommands: /new, /whoami, /weather , /help.") - - async def handle_help(ctx: ChannelCommandContext) -> None: - await ctx.reply( - "/new — start a fresh conversation\n" - "/whoami — show your isolation key\n" - "/weather — call the weather tool directly\n" - "/help — this message" - ) - - async def handle_new(ctx: ChannelCommandContext) -> None: - host_ref["host"].reset_session(_isolation_key(ctx)) - await ctx.reply("New session started. Previous history is cleared for this chat.") - - async def handle_whoami(ctx: ChannelCommandContext) -> None: - await ctx.reply(f"Your isolation key on this host is: {_isolation_key(ctx)}") - - async def handle_weather(ctx: ChannelCommandContext) -> None: - command_text = ctx.request.input if isinstance(ctx.request.input, str) else "" - _, _, location = command_text.partition(" ") - location = location.strip() or "Seattle" - await ctx.reply(lookup_weather(location=location)) - - return [ - ChannelCommand("start", "Introduce the bot", handle_start), - ChannelCommand("help", "List available commands", handle_help), - ChannelCommand("new", "Start a new session for this chat", handle_new), - ChannelCommand("whoami", "Show the isolation key for this chat", handle_whoami), - ChannelCommand("weather", "Call the weather tool: /weather ", handle_weather), - ] - - -# --------------------------------------------------------------------------- # -# Host wiring -# --------------------------------------------------------------------------- # - - -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}, - ) - - host_ref: dict[str, AgentFrameworkHost] = {} - host = AgentFrameworkHost( - target=agent, - channels=[ - TelegramChannel( - bot_token=os.environ["TELEGRAM_BOT_TOKEN"], - webhook_url=os.environ.get("TELEGRAM_WEBHOOK_URL"), - secret_token=os.environ.get("TELEGRAM_WEBHOOK_SECRET"), - parse_mode="Markdown", - commands=make_commands(host_ref), - run_hook=run_hook, - ), - ], - debug=True, - ) - host_ref["host"] = host - return host - - -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_telegram/pyproject.toml b/python/samples/04-hosting/af-hosting/local_telegram/pyproject.toml deleted file mode 100644 index a705aff5617..00000000000 --- a/python/samples/04-hosting/af-hosting/local_telegram/pyproject.toml +++ /dev/null @@ -1,21 +0,0 @@ -[project] -name = "agent-framework-hosting-sample-local-telegram" -version = "0.0.1" -description = "Telegram-only hosting sample with a @tool, FileHistoryProvider, and slash commands." -requires-python = ">=3.10" -dependencies = [ - "agent-framework-foundry", - "agent-framework-hosting", - "agent-framework-hosting-telegram", - "azure-identity", - "aiohttp>=3.13.5", - "hypercorn>=0.17", - "httpx>=0.27", -] - -[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-telegram = { git = "https://github.com/microsoft/agent-framework.git", branch = "main", subdirectory = "python/packages/hosting-telegram" } diff --git a/python/uv.lock b/python/uv.lock index b4eb603f0d0..76c1c36638f 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -49,7 +49,6 @@ members = [ "agent-framework-github-copilot", "agent-framework-hosting", "agent-framework-hosting-responses", - "agent-framework-hosting-telegram", "agent-framework-hyperlight", "agent-framework-lab", "agent-framework-mem0", @@ -78,15 +77,15 @@ name = "a2a-sdk" version = "1.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "culsans", marker = "python_full_version < '3.13'" }, - { name = "google-api-core" }, - { name = "googleapis-common-protos" }, - { name = "httpx" }, - { name = "httpx-sse" }, - { name = "json-rpc" }, - { name = "packaging" }, - { name = "protobuf" }, - { name = "pydantic" }, + { name = "culsans", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, + { name = "google-api-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "googleapis-common-protos", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx-sse", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "json-rpc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c7/7e/8ac10bbf8b15b16574355f39b17dbdf617a282c27b41c7ff2116e30336df/a2a_sdk-1.1.0.tar.gz", hash = "sha256:e8102dad1b36709dbdc3d19319e38e6dfa3b3a79c30416030eb2d482576be204", size = 375726, upload-time = "2026-05-29T09:34:43.015Z" } wheels = [ @@ -107,7 +106,7 @@ name = "ag-ui-protocol" version = "0.1.19" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/10/4ad299267a7d04b89935aa99eef62979758fcf95aee9f8bb5d70c35b1be1/ag_ui_protocol-0.1.19.tar.gz", hash = "sha256:43c27f60d41712dcad0e9e0a203cbdf1c8e248b22417374c5c68321c448af4ea", size = 10720, upload-time = "2026-06-02T17:26:15.627Z" } wheels = [ @@ -119,32 +118,32 @@ name = "agent-framework" version = "1.10.0" source = { virtual = "." } dependencies = [ - { name = "agent-framework-core", extra = ["all"] }, + { name = "agent-framework-core", extra = ["all"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.dev-dependencies] dev = [ - { name = "azure-monitor-opentelemetry" }, - { name = "flit" }, - { name = "mcp", extra = ["ws"] }, - { name = "mypy" }, - { name = "opentelemetry-sdk" }, - { name = "poethepoet" }, - { name = "prek" }, - { name = "pyrefly" }, - { name = "pyright" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "pytest-cov" }, - { name = "pytest-retry" }, - { name = "pytest-timeout" }, - { name = "pytest-xdist", extra = ["psutil"] }, - { name = "rich" }, - { name = "ruff" }, - { name = "tomli" }, - { name = "ty" }, - { name = "uv" }, - { name = "zuban" }, + { name = "azure-monitor-opentelemetry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "flit", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "mypy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "poethepoet", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "prek", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyrefly", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyright", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pytest-asyncio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pytest-cov", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pytest-retry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pytest-timeout", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pytest-xdist", extra = ["psutil"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "ruff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tomli", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "ty", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "uv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "zuban", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -180,8 +179,8 @@ name = "agent-framework-a2a" version = "1.0.0b260604" source = { editable = "packages/a2a" } dependencies = [ - { name = "a2a-sdk" }, - { name = "agent-framework-core" }, + { name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -195,16 +194,16 @@ name = "agent-framework-ag-ui" version = "1.0.0rc7" source = { editable = "packages/ag-ui" } dependencies = [ - { name = "ag-ui-protocol" }, - { name = "agent-framework-core" }, - { name = "fastapi" }, - { name = "uvicorn", extra = ["standard"] }, + { name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.optional-dependencies] dev = [ - { name = "httpx" }, - { name = "pytest" }, + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -223,8 +222,8 @@ name = "agent-framework-anthropic" version = "1.0.0b260630" source = { editable = "packages/anthropic" } dependencies = [ - { name = "agent-framework-core" }, - { name = "anthropic" }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "anthropic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -238,8 +237,8 @@ name = "agent-framework-azure-ai-search" version = "1.0.0b260630" source = { editable = "packages/azure-ai-search" } dependencies = [ - { name = "agent-framework-core" }, - { name = "azure-search-documents" }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-search-documents", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -253,11 +252,11 @@ name = "agent-framework-azure-contentunderstanding" version = "1.0.0a260618" source = { editable = "packages/azure-contentunderstanding" } dependencies = [ - { name = "agent-framework-core" }, - { name = "agent-framework-foundry" }, - { name = "aiohttp" }, - { name = "azure-ai-contentunderstanding" }, - { name = "filetype" }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-foundry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-ai-contentunderstanding", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "filetype", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -274,8 +273,8 @@ name = "agent-framework-azure-cosmos" version = "1.0.0b260521" source = { editable = "packages/azure-cosmos" } dependencies = [ - { name = "agent-framework-core" }, - { name = "azure-cosmos" }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-cosmos", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -289,10 +288,10 @@ name = "agent-framework-azurefunctions" version = "1.0.0b260630" source = { editable = "packages/azurefunctions" } dependencies = [ - { name = "agent-framework-core" }, - { name = "agent-framework-durabletask" }, - { name = "azure-functions" }, - { name = "azure-functions-durable" }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-durabletask", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-functions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-functions-durable", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -311,9 +310,9 @@ name = "agent-framework-bedrock" version = "1.0.0b260630" source = { editable = "packages/bedrock" } dependencies = [ - { name = "agent-framework-core" }, - { name = "boto3" }, - { name = "botocore" }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "boto3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "botocore", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -328,8 +327,8 @@ name = "agent-framework-chatkit" version = "1.0.0b260528" source = { editable = "packages/chatkit" } dependencies = [ - { name = "agent-framework-core" }, - { name = "openai-chatkit" }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "openai-chatkit", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -343,8 +342,8 @@ name = "agent-framework-claude" version = "1.0.0b260609" source = { editable = "packages/claude" } dependencies = [ - { name = "agent-framework-core" }, - { name = "claude-agent-sdk" }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "claude-agent-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -358,8 +357,8 @@ name = "agent-framework-copilotstudio" version = "1.0.0b260521" source = { editable = "packages/copilotstudio" } dependencies = [ - { name = "agent-framework-core" }, - { name = "microsoft-agents-copilotstudio-client" }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "microsoft-agents-copilotstudio-client", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -373,44 +372,44 @@ name = "agent-framework-core" version = "1.10.0" source = { editable = "packages/core" } dependencies = [ - { name = "opentelemetry-api" }, - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-extensions" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.optional-dependencies] all = [ - { name = "agent-framework-a2a" }, - { name = "agent-framework-ag-ui" }, - { name = "agent-framework-anthropic" }, - { name = "agent-framework-azure-ai-search" }, - { name = "agent-framework-azure-cosmos" }, - { name = "agent-framework-azurefunctions" }, - { name = "agent-framework-bedrock" }, - { name = "agent-framework-chatkit" }, - { name = "agent-framework-claude" }, - { name = "agent-framework-copilotstudio" }, - { name = "agent-framework-declarative" }, - { name = "agent-framework-devui" }, - { name = "agent-framework-durabletask" }, - { name = "agent-framework-foundry" }, - { name = "agent-framework-foundry-local" }, - { name = "agent-framework-github-copilot", marker = "python_full_version >= '3.11'" }, + { name = "agent-framework-a2a", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-ag-ui", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-anthropic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-azure-ai-search", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-azure-cosmos", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-azurefunctions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-bedrock", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-chatkit", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-claude", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-copilotstudio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-declarative", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-devui", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-durabletask", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-foundry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-foundry-local", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-github-copilot", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, { name = "agent-framework-hyperlight", marker = "(python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.14' and platform_machine == 'AMD64' and sys_platform == 'win32')" }, - { name = "agent-framework-lab" }, - { name = "agent-framework-mem0" }, - { name = "agent-framework-ollama" }, - { name = "agent-framework-openai" }, - { name = "agent-framework-orchestrations" }, - { name = "agent-framework-purview" }, - { name = "agent-framework-redis" }, - { name = "mcp", extra = ["ws"] }, + { name = "agent-framework-lab", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-mem0", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-ollama", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-orchestrations", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-purview", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-redis", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.dev-dependencies] dev = [ - { name = "agent-framework-tools" }, + { name = "agent-framework-tools", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -455,15 +454,15 @@ name = "agent-framework-declarative" version = "1.0.0rc2" source = { editable = "packages/declarative" } dependencies = [ - { name = "agent-framework-core" }, - { name = "httpx" }, - { name = "powerfx", marker = "python_full_version < '3.14'" }, - { name = "pyyaml" }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "powerfx", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, + { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.dev-dependencies] dev = [ - { name = "types-pyyaml" }, + { name = "types-pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -482,22 +481,22 @@ name = "agent-framework-devui" version = "1.0.0b260630" source = { editable = "packages/devui" } dependencies = [ - { name = "agent-framework-core" }, - { name = "fastapi" }, - { name = "openai" }, - { name = "opentelemetry-sdk" }, - { name = "uvicorn", extra = ["standard"] }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "fastapi", 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'" }, + { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.optional-dependencies] all = [ - { name = "pytest" }, - { name = "watchdog" }, + { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "watchdog", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] dev = [ - { name = "agent-framework-orchestrations" }, - { name = "pytest" }, - { name = "watchdog" }, + { name = "agent-framework-orchestrations", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "watchdog", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -520,15 +519,15 @@ name = "agent-framework-durabletask" version = "1.0.0b260630" source = { editable = "packages/durabletask" } dependencies = [ - { name = "agent-framework-core" }, - { name = "durabletask" }, - { name = "durabletask-azuremanaged" }, - { name = "python-dateutil" }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "durabletask", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "durabletask-azuremanaged", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.dev-dependencies] dev = [ - { name = "types-python-dateutil" }, + { name = "types-python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -547,11 +546,11 @@ name = "agent-framework-foundry" version = "1.10.0" source = { editable = "packages/foundry" } dependencies = [ - { name = "agent-framework-core" }, - { name = "agent-framework-openai" }, - { name = "aiohttp" }, - { name = "azure-ai-inference" }, - { name = "azure-ai-projects" }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-ai-inference", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-ai-projects", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -568,12 +567,12 @@ name = "agent-framework-foundry-hosting" version = "1.0.0a260630" source = { editable = "packages/foundry_hosting" } dependencies = [ - { name = "agent-framework-core" }, - { name = "azure-ai-agentserver-core" }, - { name = "azure-ai-agentserver-invocations" }, - { name = "azure-ai-agentserver-responses" }, - { name = "httpx" }, - { name = "mcp", extra = ["ws"] }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-ai-agentserver-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-ai-agentserver-invocations", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-ai-agentserver-responses", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -591,9 +590,9 @@ name = "agent-framework-foundry-local" version = "1.0.0b260521" source = { editable = "packages/foundry_local" } dependencies = [ - { name = "agent-framework-core" }, - { name = "agent-framework-openai" }, - { name = "foundry-local-sdk" }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "foundry-local-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -608,8 +607,8 @@ name = "agent-framework-gemini" version = "1.0.0a260630" source = { editable = "packages/gemini" } dependencies = [ - { name = "agent-framework-core" }, - { name = "google-genai" }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "google-genai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -623,8 +622,8 @@ name = "agent-framework-github-copilot" version = "1.0.0rc2" source = { editable = "packages/github_copilot" } dependencies = [ - { name = "agent-framework-core" }, - { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'" }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "github-copilot-sdk", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, ] [package.metadata] @@ -638,43 +637,20 @@ name = "agent-framework-hosting" version = "1.0.0a260625" source = { editable = "packages/hosting" } dependencies = [ - { name = "agent-framework-core" }, - { name = "starlette" }, -] - -[package.optional-dependencies] -disk = [ - { name = "diskcache" }, -] -serve = [ - { name = "hypercorn" }, -] - -[package.dev-dependencies] -dev = [ - { name = "httpx" }, + { name = "agent-framework-core", 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" }] +requires-dist = [{ name = "agent-framework-core", editable = "packages/core" }] [[package]] name = "agent-framework-hosting-responses" version = "1.0.0a260625" source = { editable = "packages/hosting-responses" } dependencies = [ - { name = "agent-framework-core" }, - { name = "agent-framework-hosting" }, - { name = "openai" }, + { 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.dev-dependencies] @@ -696,32 +672,15 @@ dev = [ { name = "httpx", specifier = ">=0.28.1" }, ] -[[package]] -name = "agent-framework-hosting-telegram" -version = "1.0.0a260625" -source = { editable = "packages/hosting-telegram" } -dependencies = [ - { name = "agent-framework-core" }, - { name = "agent-framework-hosting" }, - { name = "httpx" }, -] - -[package.metadata] -requires-dist = [ - { name = "agent-framework-core", editable = "packages/core" }, - { name = "agent-framework-hosting", editable = "packages/hosting" }, - { name = "httpx", specifier = ">=0.27,<1" }, -] - [[package]] name = "agent-framework-hyperlight" version = "1.0.0b260630" source = { editable = "packages/hyperlight" } dependencies = [ - { name = "agent-framework-core" }, - { name = "hyperlight-sandbox" }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "hyperlight-sandbox", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "hyperlight-sandbox-backend-wasm", marker = "(python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.14' and platform_machine == 'AMD64' and sys_platform == 'win32')" }, - { name = "hyperlight-sandbox-python-guest" }, + { name = "hyperlight-sandbox-python-guest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -737,47 +696,47 @@ name = "agent-framework-lab" version = "1.0.0b260521" source = { editable = "packages/lab" } dependencies = [ - { name = "agent-framework-core" }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.optional-dependencies] gaia = [ - { name = "huggingface-hub" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-sdk" }, - { name = "orjson" }, - { name = "pyarrow" }, - { name = "pydantic" }, - { name = "tqdm" }, + { name = "huggingface-hub", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "orjson", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyarrow", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] lightning = [ - { name = "agentlightning" }, + { name = "agentlightning", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] math = [ - { name = "sympy" }, + { name = "sympy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] tau2 = [ - { name = "loguru" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "pydantic" }, - { name = "tiktoken" }, + { name = "loguru", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version == '3.11.*' and sys_platform == 'darwin') or (python_full_version == '3.11.*' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tiktoken", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.dev-dependencies] dev = [ - { name = "mypy" }, - { name = "poethepoet" }, - { name = "prek" }, - { name = "pyright" }, - { name = "pytest" }, - { name = "rich" }, - { name = "ruff" }, - { name = "tau2" }, - { name = "tomli" }, - { name = "tomli-w" }, - { name = "uv" }, + { name = "mypy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "poethepoet", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "prek", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyright", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "ruff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tau2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tomli", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tomli-w", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "uv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -819,8 +778,8 @@ name = "agent-framework-mem0" version = "1.0.0b260609" source = { editable = "packages/mem0" } dependencies = [ - { name = "agent-framework-core" }, - { name = "mem0ai" }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "mem0ai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -834,8 +793,8 @@ name = "agent-framework-mistral" version = "1.0.0a260604" source = { editable = "packages/mistral" } dependencies = [ - { name = "agent-framework-core" }, - { name = "mistralai" }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "mistralai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -849,8 +808,8 @@ name = "agent-framework-monty" version = "1.0.0a260521" source = { editable = "packages/monty" } dependencies = [ - { name = "agent-framework-core" }, - { name = "pydantic-monty" }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic-monty", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -864,8 +823,8 @@ name = "agent-framework-ollama" version = "1.0.0b260630" source = { editable = "packages/ollama" } dependencies = [ - { name = "agent-framework-core" }, - { name = "ollama" }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "ollama", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -879,8 +838,8 @@ name = "agent-framework-openai" version = "1.10.0" source = { editable = "packages/openai" } dependencies = [ - { name = "agent-framework-core" }, - { name = "openai" }, + { name = "agent-framework-core", 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] @@ -894,7 +853,7 @@ name = "agent-framework-orchestrations" version = "1.0.0" source = { editable = "packages/orchestrations" } dependencies = [ - { name = "agent-framework-core" }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -905,9 +864,9 @@ name = "agent-framework-purview" version = "1.0.0b260630" source = { editable = "packages/purview" } dependencies = [ - { name = "agent-framework-core" }, - { name = "azure-core" }, - { name = "httpx" }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -922,12 +881,12 @@ name = "agent-framework-redis" version = "1.0.0b260521" source = { editable = "packages/redis" } dependencies = [ - { name = "agent-framework-core" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "redis" }, - { name = "redisvl" }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version == '3.11.*' and sys_platform == 'darwin') or (python_full_version == '3.11.*' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, + { name = "redis", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "redisvl", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -943,8 +902,8 @@ name = "agent-framework-tools" version = "1.0.0a260630" source = { editable = "packages/tools" } dependencies = [ - { name = "agent-framework-core" }, - { name = "psutil" }, + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -989,20 +948,20 @@ name = "agentops" version = "0.4.21" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp" }, - { name = "httpx" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-proto-http" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-sdk" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "ordered-set" }, - { name = "packaging" }, - { name = "psutil" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "termcolor" }, - { name = "wrapt" }, + { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "ordered-set", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "termcolor", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0a/c4/023fe976169c57b1edd71f4c08d6dedaf66814f5b25ecf59b3a8540311ab/agentops-0.4.21.tar.gz", hash = "sha256:47759c6dfd6ea58bad2f7764257e4778cb2e34ae180cef642f60f56adced6510", size = 430861, upload-time = "2025-08-29T06:36:55.323Z" } wheels = [ @@ -1023,15 +982,15 @@ name = "aiohttp" version = "3.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohappyeyeballs" }, - { name = "aiosignal" }, - { name = "async-timeout", marker = "python_full_version < '3.11'" }, - { name = "attrs" }, - { name = "frozenlist" }, - { name = "multidict" }, - { name = "propcache" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - { name = "yarl" }, + { name = "aiohappyeyeballs", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "aiosignal", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "async-timeout", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "attrs", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "frozenlist", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "multidict", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "propcache", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, + { name = "yarl", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } wheels = [ @@ -1170,8 +1129,8 @@ name = "aiosignal" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "frozenlist" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "frozenlist", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ @@ -1210,14 +1169,14 @@ name = "anthropic" version = "0.116.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "docstring-parser" }, - { name = "httpx" }, - { name = "jiter" }, - { name = "pydantic" }, - { name = "sniffio" }, - { name = "typing-extensions" }, + { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "distro", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "docstring-parser", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "jiter", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "sniffio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/a2/d31f14e28d49bae983a3634e38dfb4b31c50110b5e403596c5c6a20b23f8/anthropic-0.116.0.tar.gz", hash = "sha256:5fc248fbb9fe03ef686f8a774f81586bca31a043260aab88b387ea3660f4a396", size = 949149, upload-time = "2026-07-02T19:08:10.534Z" } wheels = [ @@ -1229,9 +1188,9 @@ name = "anyio" version = "4.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "exceptiongroup", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" } wheels = [ @@ -1252,7 +1211,7 @@ name = "apscheduler" version = "3.11.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "tzlocal" }, + { name = "tzlocal", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" } wheels = [ @@ -1264,7 +1223,7 @@ name = "asgiref" version = "3.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } wheels = [ @@ -1303,11 +1262,11 @@ name = "azure-ai-agentserver-core" version = "2.0.0b7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "hypercorn" }, - { name = "microsoft-opentelemetry" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-sdk" }, - { name = "starlette" }, + { name = "hypercorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "microsoft-opentelemetry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-sdk", 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'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9a/6c/5e3a796274e70e899eac739bc4e81e7645de6e5146fb18b1a5b3d79297e5/azure_ai_agentserver_core-2.0.0b7.tar.gz", hash = "sha256:272265f7ab6dcda3cb518a5028394b6684992e0abde9cfc2dfc2e851a289eab7", size = 52702, upload-time = "2026-06-28T14:29:52.329Z" } wheels = [ @@ -1319,7 +1278,7 @@ name = "azure-ai-agentserver-invocations" version = "1.0.0b6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-ai-agentserver-core" }, + { name = "azure-ai-agentserver-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0d/f4/c4ff1399795dd92fd56290ac3a1014817125e7fd44eb5a205fce043f0b46/azure_ai_agentserver_invocations-1.0.0b6.tar.gz", hash = "sha256:b8c04aa71dc42491f75443c5123d7ecf9c40318e35a05bb056e9786e98585fbd", size = 60512, upload-time = "2026-06-28T14:52:25.808Z" } wheels = [ @@ -1331,10 +1290,10 @@ name = "azure-ai-agentserver-responses" version = "1.0.0b8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp" }, - { name = "azure-ai-agentserver-core" }, - { name = "azure-core" }, - { name = "isodate" }, + { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-ai-agentserver-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/31/1f/7e7563705100f2d21c952a44d5a2e93a8193cc0a6013941bac9f52ad8874/azure_ai_agentserver_responses-1.0.0b8.tar.gz", hash = "sha256:bc0365fd70b7dabf9c9394dac5bbab08f772b59f865319d401cfde317b6832ef", size = 450099, upload-time = "2026-06-28T14:52:32.155Z" } wheels = [ @@ -1346,9 +1305,9 @@ name = "azure-ai-contentunderstanding" version = "1.2.0b2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core" }, - { name = "isodate" }, - { name = "typing-extensions" }, + { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/6c/f9af836a30b5299b10304d6b5ec645f8dbb1857429fa0191e41f86825d70/azure_ai_contentunderstanding-1.2.0b2.tar.gz", hash = "sha256:0ccef3c8087759ca788aabcc9af7b22cd8ada2df0236bf63563f4974c2d8cfcd", size = 265922, upload-time = "2026-06-11T02:24:56.951Z" } wheels = [ @@ -1360,9 +1319,9 @@ name = "azure-ai-inference" version = "1.0.0b9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core" }, - { name = "isodate" }, - { name = "typing-extensions" }, + { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4e/6a/ed85592e5c64e08c291992f58b1a94dab6869f28fb0f40fd753dced73ba6/azure_ai_inference-1.0.0b9.tar.gz", hash = "sha256:1feb496bd84b01ee2691befc04358fa25d7c344d8288e99364438859ad7cd5a4", size = 182408, upload-time = "2025-02-15T00:37:28.464Z" } wheels = [ @@ -1374,12 +1333,12 @@ name = "azure-ai-projects" version = "2.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core" }, - { name = "azure-identity" }, - { name = "azure-storage-blob" }, - { name = "isodate" }, - { name = "openai" }, - { name = "typing-extensions" }, + { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-storage-blob", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "isodate", 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'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/24342aea74fe75b0a8378b6eff665b9c1cb63f855c1a96f70a0095e474a2/azure_ai_projects-2.2.0.tar.gz", hash = "sha256:58ee31bb031cfb004051145c545294bb0d32de679c670c312ef384845bd72cef", size = 668496, upload-time = "2026-05-30T00:20:59.099Z" } wheels = [ @@ -1391,8 +1350,8 @@ name = "azure-core" version = "1.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "requests" }, - { name = "typing-extensions" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a6/f3/b416179e408990df5db0d516283022dde0f5d0111d98c1a848e41853e81c/azure_core-1.41.0.tar.gz", hash = "sha256:f46ff5dfcd230f25cf1c19e8a34b8dc08a337b2503e268bb600a16c00db8ad5a", size = 381042, upload-time = "2026-05-07T23:30:54.302Z" } wheels = [ @@ -1404,8 +1363,8 @@ name = "azure-core-tracing-opentelemetry" version = "1.0.0b13" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core" }, - { name = "opentelemetry-api" }, + { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ce/ab/a937e4af8afec9d437d55252f2a3a4419fc3fc7d5e5d54022622bd11b2b6/azure_core_tracing_opentelemetry-1.0.0b13.tar.gz", hash = "sha256:6cb2f8dfd5dee6c11843db0205fc92e2434e1a272c169c953afe92483aafc7eb", size = 25832, upload-time = "2026-05-01T00:59:57.941Z" } wheels = [ @@ -1417,8 +1376,8 @@ name = "azure-cosmos" version = "4.16.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core" }, - { name = "typing-extensions" }, + { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fe/2a/0f2bba256e56626ba2cec97ab81dd002ff47ead1329767760b619afd927a/azure_cosmos-4.16.1.tar.gz", hash = "sha256:fa15d13702b470265a67e2dd9c0794021e6b776856dac6c223dcacc4d8e1d8d1", size = 2377651, upload-time = "2026-06-02T01:08:07.656Z" } wheels = [ @@ -1430,7 +1389,7 @@ name = "azure-functions" version = "1.24.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "werkzeug" }, + { name = "werkzeug", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1d/be/5535830e0658e9668093941b3c33b0ea03eceadbf6bd6b7870aa37ef071a/azure_functions-1.24.0.tar.gz", hash = "sha256:18ea1607c7a7268b7a1e1bd0cc28c5cc57a9db6baaacddb39ba0e9f865728187", size = 134495, upload-time = "2025-10-06T19:08:08.612Z" } wheels = [ @@ -1442,13 +1401,13 @@ name = "azure-functions-durable" version = "1.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp" }, - { name = "azure-functions" }, - { name = "furl" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-sdk" }, - { name = "python-dateutil" }, - { name = "requests" }, + { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-functions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "furl", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d0/7c/3654377e7000c4bd6b6edbb959efc4ad867005353843a4d810dfa8fbb72b/azure_functions_durable-1.5.0.tar.gz", hash = "sha256:131fbdf08fa1140d94dc3948fcf9000d8da58aaa5a0ffc4db0ea3be97d5551e2", size = 183733, upload-time = "2026-02-04T20:33:45.788Z" } wheels = [ @@ -1460,11 +1419,11 @@ name = "azure-identity" version = "1.25.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core" }, - { name = "cryptography" }, - { name = "msal" }, - { name = "msal-extensions" }, - { name = "typing-extensions" }, + { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "msal", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "msal-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c2/3a/439a32a5e23e45f6a91f0405949dc66cfe6834aba15a430aebfc063a81e7/azure_identity-1.25.2.tar.gz", hash = "sha256:030dbaa720266c796221c6cdbd1999b408c079032c919fef725fcc348a540fe9", size = 284709, upload-time = "2026-02-11T01:55:42.323Z" } wheels = [ @@ -1476,19 +1435,19 @@ name = "azure-monitor-opentelemetry" version = "1.8.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core" }, - { name = "azure-core-tracing-opentelemetry" }, - { name = "azure-monitor-opentelemetry-exporter" }, - { name = "opentelemetry-instrumentation-django" }, - { name = "opentelemetry-instrumentation-fastapi" }, - { name = "opentelemetry-instrumentation-flask" }, - { name = "opentelemetry-instrumentation-logging" }, - { name = "opentelemetry-instrumentation-psycopg2" }, - { name = "opentelemetry-instrumentation-requests" }, - { name = "opentelemetry-instrumentation-urllib" }, - { name = "opentelemetry-instrumentation-urllib3" }, - { name = "opentelemetry-resource-detector-azure" }, - { name = "opentelemetry-sdk" }, + { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core-tracing-opentelemetry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-monitor-opentelemetry-exporter", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-django", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-flask", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-logging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-psycopg2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-urllib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-resource-detector-azure", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2d/9e/3e63aa6cf8a46d06090b6f0046da6a59c470ffaf9968430867fa4a3c2eac/azure_monitor_opentelemetry-1.8.8.tar.gz", hash = "sha256:c6478cac82939230e9af1004b0a147e39b9046a564f3811d65241797f2f9d41d", size = 77532, upload-time = "2026-05-14T16:21:44.796Z" } wheels = [ @@ -1500,12 +1459,12 @@ name = "azure-monitor-opentelemetry-exporter" version = "1.0.0b53" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core" }, - { name = "azure-identity" }, - { name = "msrest" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-sdk" }, - { name = "psutil" }, + { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "msrest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/05/64/875f13849fe2e3832ceda6a218fa5422a25e72c1b86623a8514f541a8c60/azure_monitor_opentelemetry_exporter-1.0.0b53.tar.gz", hash = "sha256:1274e9008909414a25c6287185a6c5a884209705b6e651a1ffddfbdab3b76e52", size = 335614, upload-time = "2026-06-08T15:54:22.683Z" } wheels = [ @@ -1517,9 +1476,9 @@ name = "azure-search-documents" version = "12.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core" }, - { name = "isodate" }, - { name = "typing-extensions" }, + { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/59/dc/bb4db263381aa5b29414e280a8535a343d877a3831a501ef39332174c85c/azure_search_documents-12.0.0.tar.gz", hash = "sha256:8e6d73ec0ed1623083435b757e34324db65d72d4e09cca061a59fc7e90c8ddbc", size = 386222, upload-time = "2026-05-01T20:28:22.269Z" } wheels = [ @@ -1531,10 +1490,10 @@ name = "azure-storage-blob" version = "12.28.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core" }, - { name = "cryptography" }, - { name = "isodate" }, - { name = "typing-extensions" }, + { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/71/24/072ba8e27b0e2d8fec401e9969b429d4f5fc4c8d4f0f05f4661e11f7234a/azure_storage_blob-12.28.0.tar.gz", hash = "sha256:e7d98ea108258d29aa0efbfd591b2e2075fa1722a2fae8699f0b3c9de11eff41", size = 604225, upload-time = "2026-01-06T23:48:57.282Z" } wheels = [ @@ -1586,9 +1545,9 @@ name = "boto3" version = "1.43.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "botocore" }, - { name = "jmespath" }, - { name = "s3transfer" }, + { name = "botocore", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "jmespath", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "s3transfer", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/36/028c12ed6ed85009a21b5472eb76c27f9b0341c6986f06f83475b40aaf51/boto3-1.43.1.tar.gz", hash = "sha256:9e4f85a7884797ff0f52c257094730ed228aaa07fa8134775ff8f86909cf4f2a", size = 113175, upload-time = "2026-04-30T20:27:04.569Z" } wheels = [ @@ -1600,9 +1559,9 @@ name = "botocore" version = "1.43.34" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jmespath" }, - { name = "python-dateutil" }, - { name = "urllib3" }, + { name = "jmespath", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3c/0d/559cdceb9f6acea6b91404970b7973e28a4434fa8a70eb1416b0af478d86/botocore-1.43.34.tar.gz", hash = "sha256:ccc973cf30c6445b30afe5760f6dc949a80f1f862cb23d9c45747f2c814ece77", size = 15591382, upload-time = "2026-06-19T19:33:28.561Z" } wheels = [ @@ -1742,7 +1701,7 @@ name = "cffi" version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, + { name = "pycparser", marker = "(implementation_name != 'PyPy' and sys_platform == 'darwin') or (implementation_name != 'PyPy' and sys_platform == 'linux') or (implementation_name != 'PyPy' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ @@ -1929,10 +1888,10 @@ name = "claude-agent-sdk" version = "0.2.110" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "mcp", extra = ["ws"] }, - { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "sniffio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bb/98/8fdab35ed9e1a36bc7afab4d390cc5002094a4950996c079da9aa4541cc4/claude_agent_sdk-0.2.110.tar.gz", hash = "sha256:538b548bac07a22f65686abab063a902ac76ba35989d0f073c942f96248e9fa3", size = 255632, upload-time = "2026-06-24T22:11:52.342Z" } wheels = [ @@ -1960,7 +1919,7 @@ name = "clr-loader" version = "0.2.10" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi" }, + { name = "cffi", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/18/24/c12faf3f61614b3131b5c98d3bf0d376b49c7feaa73edca559aeb2aee080/clr_loader-0.2.10.tar.gz", hash = "sha256:81f114afbc5005bafc5efe5af1341d400e22137e275b042a8979f3feb9fc9446", size = 83605, upload-time = "2026-01-03T23:13:06.984Z" } wheels = [ @@ -1986,7 +1945,7 @@ resolution-markers = [ "python_full_version < '3.11' and sys_platform == 'win32'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -2067,8 +2026,8 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform == 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version == '3.11.*' and sys_platform == 'darwin') or (python_full_version == '3.11.*' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -2245,7 +2204,7 @@ wheels = [ [package.optional-dependencies] toml = [ - { name = "tomli", marker = "python_full_version <= '3.11'" }, + { name = "tomli", marker = "(python_full_version <= '3.11' and sys_platform == 'darwin') or (python_full_version <= '3.11' and sys_platform == 'linux') or (python_full_version <= '3.11' and sys_platform == 'win32')" }, ] [[package]] @@ -2253,7 +2212,7 @@ name = "croniter" version = "6.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "python-dateutil" }, + { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab", size = 113762, upload-time = "2026-03-15T08:43:48.112Z" } wheels = [ @@ -2265,8 +2224,8 @@ name = "cryptography" version = "46.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "cffi", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (platform_python_implementation != 'PyPy' and sys_platform == 'win32')" }, + { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } wheels = [ @@ -2325,8 +2284,8 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic" }, - { name = "typing-extensions" }, + { name = "aiologic", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, + { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } wheels = [ @@ -2347,23 +2306,14 @@ name = "deepdiff" version = "9.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cachebox" }, - { name = "orderly-set" }, + { name = "cachebox", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "orderly-set", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f9/6b/6a4a5aaf38535eb332c2856aa08e73ed7c549d0851b1215401af0a2db1a7/deepdiff-9.1.0.tar.gz", hash = "sha256:07e9e366fab4297755153c4eab795ad4ef3cbd0d51660e847f5751c6bd727687", size = 382149, upload-time = "2026-05-15T20:18:05.751Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c7/26/4a2bad8eb430d8d805a4642c4bff25103a37548d74ab346f8b1e024abcc5/deepdiff-9.1.0-py3-none-any.whl", hash = "sha256:80c0460e1993b04f6f0ca79abf25548b129fd218478c4ebb08f80560f5d10610", size = 184662, upload-time = "2026-05-15T20:18:03.956Z" }, ] -[[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" @@ -2405,10 +2355,10 @@ name = "durabletask" version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "asyncio" }, - { name = "grpcio" }, - { name = "packaging" }, - { name = "protobuf" }, + { name = "asyncio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "grpcio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a4/ae/2a9ef2fc99d3103eee04106c8608796a98811168a81423d89224bdeec255/durabletask-1.6.0.tar.gz", hash = "sha256:15ad61e865f4977055430ddf1d72ad667f6b06b8f8b2eef3a37271720b1dfb2a", size = 142541, upload-time = "2026-06-18T19:53:08.797Z" } wheels = [ @@ -2420,8 +2370,8 @@ name = "durabletask-azuremanaged" version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-identity" }, - { name = "durabletask" }, + { name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "durabletask", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f8/09/002e9a5f870e02a060a09d15b82af54676b4fe8636a5c624f9a92bc26168/durabletask_azuremanaged-1.6.0.tar.gz", hash = "sha256:1cfa06035f9a70902b43a2587aaef10f1c1b7735e5f336e6b3b97f2a5fd3805f", size = 18206, upload-time = "2026-06-18T19:52:53.097Z" } wheels = [ @@ -2433,8 +2383,8 @@ name = "email-validator" version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "dnspython" }, - { name = "idna" }, + { name = "dnspython", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } wheels = [ @@ -2455,7 +2405,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -2476,10 +2426,10 @@ name = "fastapi" version = "0.124.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-doc" }, - { name = "pydantic" }, - { name = "starlette" }, - { name = "typing-extensions" }, + { name = "annotated-doc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic", 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'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/21/ade3ff6745a82ea8ad88552b4139d27941549e4f19125879f848ac8f3c3d/fastapi-0.124.4.tar.gz", hash = "sha256:0e9422e8d6b797515f33f500309f6e1c98ee4e85563ba0f2debb282df6343763", size = 378460, upload-time = "2025-12-12T15:00:43.891Z" } wheels = [ @@ -2491,11 +2441,11 @@ name = "fastapi-sso" version = "0.19.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "fastapi" }, - { name = "httpx" }, - { name = "oauthlib" }, - { name = "pydantic", extra = ["email"] }, - { name = "pyjwt" }, + { name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "oauthlib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic", extra = ["email"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyjwt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/74/fc/644bc8f82fc887fffcf9a3eab8eb3dea06ee9ea160ef20441455ed7a0001/fastapi_sso-0.19.0.tar.gz", hash = "sha256:629f00581f72ea7e57f7b8775f8d2c425629c428c194359a2b4ebaa6bcb8e12b", size = 17278, upload-time = "2025-12-17T15:18:06.721Z" } wheels = [ @@ -2588,12 +2538,12 @@ name = "flask" version = "3.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "blinker" }, - { name = "click" }, - { name = "itsdangerous" }, - { name = "jinja2" }, - { name = "markupsafe" }, - { name = "werkzeug" }, + { name = "blinker", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "itsdangerous", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "jinja2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "markupsafe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "werkzeug", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } wheels = [ @@ -2605,11 +2555,11 @@ name = "flit" version = "3.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "docutils" }, - { name = "flit-core" }, - { name = "pip" }, - { name = "requests" }, - { name = "tomli-w" }, + { name = "docutils", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "flit-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pip", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tomli-w", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/9c/0608c91a5b6c013c63548515ae31cff6399cd9ce891bd9daee8c103da09b/flit-3.12.0.tar.gz", hash = "sha256:1c80f34dd96992e7758b40423d2809f48f640ca285d0b7821825e50745ec3740", size = 155038, upload-time = "2025-03-25T08:03:22.505Z" } wheels = [ @@ -2687,9 +2637,9 @@ name = "foundry-local-sdk" version = "0.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "httpx" }, - { name = "pydantic" }, - { name = "tqdm" }, + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/ed/6b/76a7fe8f9f4c52cc84eaa1cd1b66acddf993496d55d6ea587bf0d0854d1c/foundry_local_sdk-0.5.1-py3-none-any.whl", hash = "sha256:f3639a3666bc3a94410004a91671338910ac2e1b8094b1587cc4db0f4a7df07e", size = 14003, upload-time = "2025-11-21T05:39:58.099Z" }, @@ -2821,9 +2771,9 @@ name = "fs" version = "2.4.16" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "appdirs" }, - { name = "setuptools" }, - { name = "six" }, + { name = "appdirs", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "setuptools", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5d/a9/af5bfd5a92592c16cdae5c04f68187a309be8a146b528eac3c6e30edbad2/fs-2.4.16.tar.gz", hash = "sha256:ae97c7d51213f4b70b6a958292530289090de3a7e15841e108fbe144f069d313", size = 187441, upload-time = "2022-05-02T09:25:54.22Z" } wheels = [ @@ -2844,8 +2794,8 @@ name = "furl" version = "2.1.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "orderedmultidict" }, - { name = "six" }, + { name = "orderedmultidict", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/53/e4/203a76fa2ef46cdb0a618295cc115220cbb874229d4d8721068335eb87f0/furl-2.1.4.tar.gz", hash = "sha256:877657501266c929269739fb5f5980534a41abd6bbabcb367c136d1d3b2a6015", size = 57526, upload-time = "2025-03-09T05:36:21.175Z" } wheels = [ @@ -2857,8 +2807,8 @@ name = "github-copilot-sdk" version = "1.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic" }, - { name = "python-dateutil" }, + { name = "pydantic", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "python-dateutil", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/1f/2c/3d3ecfe500c0ba7d3127737b1aa22f19ff1a19e6e86360bfdca3f02a2c09/github_copilot_sdk-1.0.2-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:856dfc8370f36f6efd8a2aa1dd40f82c1a6d0573d0577eaff1f6affb73ed29ad", size = 97329153, upload-time = "2026-06-18T00:56:20.653Z" }, @@ -2874,11 +2824,11 @@ name = "google-api-core" version = "2.31.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "google-auth" }, - { name = "googleapis-common-protos" }, - { name = "proto-plus" }, - { name = "protobuf" }, - { name = "requests" }, + { name = "google-auth", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "googleapis-common-protos", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "proto-plus", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c6/22/155cadf1d49272a9cf48f3168c0f3874fa13397297e611a5ea00cd093880/google_api_core-2.31.0.tar.gz", hash = "sha256:2be84ee0f584c48e6bde1b36766e23348b361fb7e55e56135fc76ce1c397f9c2", size = 176492, upload-time = "2026-06-03T14:52:17.257Z" } wheels = [ @@ -2890,8 +2840,8 @@ name = "google-auth" version = "2.55.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "pyasn1-modules" }, + { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyasn1-modules", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/81/1c/70b23fc52b2bb3c70b379f3bd05c4a60ab3a873e30c6bd21c57e0154848a/google_auth-2.55.0.tar.gz", hash = "sha256:fcd3a130f575fa36403d38774af1c64a4fbfbca09215f0589d2372b5119697cb", size = 349379, upload-time = "2026-06-15T22:33:16.466Z" } wheels = [ @@ -2900,7 +2850,7 @@ wheels = [ [package.optional-dependencies] requests = [ - { name = "requests" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [[package]] @@ -2908,16 +2858,16 @@ name = "google-genai" version = "1.75.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "google-auth", extra = ["requests"] }, - { name = "httpx" }, - { name = "pydantic" }, - { name = "requests" }, - { name = "sniffio" }, - { name = "tenacity" }, - { name = "typing-extensions" }, - { name = "websockets" }, + { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "distro", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "google-auth", extra = ["requests"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "sniffio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tenacity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "websockets", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9d/59/3ed61240ef20b3ae6ed54e82c6f8b6d1f194947bc6679679dd6cdb037594/google_genai-1.75.0.tar.gz", hash = "sha256:56bac3991b311c93f980c0a2abcd287b672146905df1fbd71c92ed633d5a07cf", size = 539039, upload-time = "2026-05-04T22:48:54.857Z" } wheels = [ @@ -2929,7 +2879,7 @@ name = "googleapis-common-protos" version = "1.75.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "protobuf" }, + { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } wheels = [ @@ -2952,7 +2902,7 @@ name = "granian" version = "2.5.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, + { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/db/b1/100c5add0409559ddbbecca5835c17217b7a2e026eff999bfa359a630686/granian-2.5.7.tar.gz", hash = "sha256:4702a7bcc736454803426bd2c4e7a374739ae1e4b11d27bcdc49b691d316fa0c", size = 112206, upload-time = "2025-11-05T12:18:29.258Z" } wheels = [ @@ -3133,7 +3083,7 @@ name = "grpcio" version = "1.81.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b0/b5/1ff353970a87eda4c98251e34d2dfd214abd4982dc89119c9252a2a482d2/grpcio-1.81.1.tar.gz", hash = "sha256:6fa10a767143a5e82e8eaab53918af0cd8909a57a27f8cb2288b80a613ac671b", size = 13026582, upload-time = "2026-06-11T12:46:51.673Z" } wheels = [ @@ -3194,7 +3144,7 @@ name = "gunicorn" version = "23.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "packaging" }, + { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" } wheels = [ @@ -3215,8 +3165,8 @@ name = "h2" version = "4.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "hpack" }, - { name = "hyperframe" }, + { name = "hpack", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "hyperframe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } wheels = [ @@ -3269,8 +3219,8 @@ name = "httpcore" version = "1.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, - { name = "h11" }, + { name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "h11", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ @@ -3332,10 +3282,10 @@ name = "httpx" version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, + { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpcore", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ @@ -3344,7 +3294,7 @@ wheels = [ [package.optional-dependencies] http2 = [ - { name = "h2" }, + { name = "h2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [[package]] @@ -3361,16 +3311,16 @@ name = "huggingface-hub" version = "1.21.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, - { name = "filelock" }, - { name = "fsspec" }, - { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, - { name = "httpx" }, - { name = "packaging" }, - { name = "pyyaml" }, - { name = "tqdm" }, - { name = "typer" }, - { name = "typing-extensions" }, + { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "filelock", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "fsspec", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "hf-xet", marker = "(platform_machine == 'AMD64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'darwin') or (platform_machine == 'amd64' and sys_platform == 'darwin') or (platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'AMD64' and sys_platform == 'linux') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'amd64' and sys_platform == 'linux') or (platform_machine == 'arm64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'win32') or (platform_machine == 'amd64' and sys_platform == 'win32') or (platform_machine == 'arm64' and sys_platform == 'win32') or (platform_machine == 'x86_64' and sys_platform == 'win32')" }, + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typer", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8f/77/ce3331f40cb2d021fe9b24c46c41e72faf74493621138e5eddac12bf5e1c/huggingface_hub-1.21.0.tar.gz", hash = "sha256:a44f222cd8f2f7c2eade30b5e7a04cac984a3235fa61ea87a0a5a31db77d561f", size = 861572, upload-time = "2026-06-25T13:09:26.356Z" } wheels = [ @@ -3382,14 +3332,14 @@ name = "hypercorn" version = "0.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "h11" }, - { name = "h2" }, - { name = "priority" }, - { name = "taskgroup", marker = "python_full_version < '3.11'" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, - { name = "wsproto" }, + { name = "exceptiongroup", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "h11", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "h2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "priority", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "taskgroup", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "tomli", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "wsproto", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/44/01/39f41a014b83dd5c795217362f2ca9071cf243e6a75bdcd6cd5b944658cc/hypercorn-0.18.0.tar.gz", hash = "sha256:d63267548939c46b0247dc8e5b45a9947590e35e64ee73a23c074aa3cf88e9da", size = 68420, upload-time = "2025-11-08T13:54:04.78Z" } wheels = [ @@ -3452,7 +3402,7 @@ name = "importlib-metadata" version = "8.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ @@ -3491,7 +3441,7 @@ name = "jinja2" version = "3.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markupsafe" }, + { name = "markupsafe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ @@ -3663,11 +3613,11 @@ name = "jsonschema" version = "4.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs" }, - { name = "jsonschema-specifications" }, - { name = "referencing" }, - { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "rpds-py", version = "2026.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "attrs", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "jsonschema-specifications", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "referencing", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "rpds-py", version = "2026.5.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ @@ -3679,7 +3629,7 @@ name = "jsonschema-specifications" version = "2025.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "referencing" }, + { name = "referencing", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } wheels = [ @@ -3815,14 +3765,14 @@ name = "langfuse" version = "4.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backoff" }, - { name = "httpx" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-proto-http" }, - { name = "opentelemetry-sdk" }, - { name = "packaging" }, - { name = "pydantic" }, - { name = "wrapt" }, + { name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/9a/c2e4b5c33a225a0158de0e774396823612a6c5beea21cde42e95b196c398/langfuse-4.9.1.tar.gz", hash = "sha256:e3d7162a8004f71abd6ad90fba2f3a7d0088cbdce1c77deea521837d49580ab4", size = 339957, upload-time = "2026-06-19T09:30:49.841Z" } wheels = [ @@ -3919,18 +3869,18 @@ name = "litellm" version = "1.87.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp" }, - { name = "click" }, - { name = "fastuuid" }, - { name = "httpx" }, - { name = "importlib-metadata" }, - { name = "jinja2" }, - { name = "jsonschema" }, - { name = "openai" }, - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "tiktoken" }, - { name = "tokenizers" }, + { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "fastuuid", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "importlib-metadata", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "jinja2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "jsonschema", 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'" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tiktoken", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tokenizers", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a8/d2/60a60c0cdc0d630f20c65e1a03a424aa2486b52f151a355d5c1052c185c7/litellm-1.87.5.tar.gz", hash = "sha256:a12721c97f6928f69920e9fddc8fa05f1c08c19b825002b6f2e8fb731cc5aa64", size = 15501148, upload-time = "2026-06-24T18:26:35.001Z" } wheels = [ @@ -3939,34 +3889,34 @@ wheels = [ [package.optional-dependencies] proxy = [ - { name = "apscheduler" }, - { name = "azure-identity" }, - { name = "azure-storage-blob" }, - { name = "backoff" }, - { name = "boto3" }, - { name = "cryptography" }, - { name = "fastapi" }, - { name = "fastapi-sso" }, - { name = "granian" }, - { name = "gunicorn" }, - { name = "litellm-enterprise" }, - { name = "litellm-proxy-extras" }, - { name = "mcp", extra = ["ws"] }, - { name = "orjson" }, - { name = "polars" }, - { name = "pydantic-settings" }, - { name = "pyjwt" }, - { name = "pynacl" }, - { name = "pyroscope-io", marker = "sys_platform != 'win32'" }, - { name = "python-multipart" }, - { name = "pyyaml" }, - { name = "restrictedpython" }, - { name = "rich" }, - { name = "rq" }, - { name = "soundfile" }, - { name = "uvicorn", extra = ["standard"] }, - { name = "uvloop", marker = "sys_platform != 'win32'" }, - { name = "websockets" }, + { name = "apscheduler", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-storage-blob", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "boto3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "fastapi-sso", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "granian", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "gunicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "litellm-enterprise", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "litellm-proxy-extras", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "orjson", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "polars", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic-settings", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyjwt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pynacl", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyroscope-io", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "python-multipart", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "restrictedpython", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "rq", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "soundfile", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "uvloop", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "websockets", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [[package]] @@ -4005,7 +3955,7 @@ name = "markdown-it-py" version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mdurl" }, + { name = "mdurl", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ @@ -4107,15 +4057,15 @@ resolution-markers = [ "python_full_version < '3.11' and sys_platform == 'win32'", ] dependencies = [ - { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" } }, - { name = "cycler" }, - { name = "fonttools" }, - { name = "kiwisolver" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pillow" }, - { name = "pyparsing" }, - { name = "python-dateutil" }, + { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "cycler", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "fonttools", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "kiwisolver", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "packaging", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "pillow", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "pyparsing", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "python-dateutil", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } wheels = [ @@ -4194,16 +4144,16 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform == 'win32'", ] dependencies = [ - { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" } }, - { name = "cycler" }, - { name = "fonttools" }, - { name = "kiwisolver" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "packaging" }, - { name = "pillow" }, - { name = "pyparsing" }, - { name = "python-dateutil" }, + { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "cycler", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "fonttools", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "kiwisolver", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version == '3.11.*' and sys_platform == 'darwin') or (python_full_version == '3.11.*' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, + { name = "packaging", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "pillow", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "pyparsing", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "python-dateutil", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1f/24/080c99d223d158d3a8902769269ab6da5b50f7a0e6e072513907e02b7a6c/matplotlib-3.11.0.tar.gz", hash = "sha256:68c0c7be01b30dcca3638934f7f591df73401235cbdbf0d1ab1c71e7db7f8b57", size = 33251176, upload-time = "2026-06-12T02:29:15.508Z" } wheels = [ @@ -4259,20 +4209,20 @@ name = "mcp" version = "1.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, - { name = "jsonschema" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "pyjwt", extra = ["crypto"] }, - { name = "python-multipart" }, + { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx-sse", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "jsonschema", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic-settings", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyjwt", extra = ["crypto"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "python-multipart", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, - { name = "sse-starlette" }, - { name = "starlette" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, - { name = "uvicorn", extra = ["standard"] }, + { name = "sse-starlette", 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'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } wheels = [ @@ -4281,7 +4231,7 @@ wheels = [ [package.optional-dependencies] ws = [ - { name = "websockets" }, + { name = "websockets", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [[package]] @@ -4298,13 +4248,13 @@ name = "mem0ai" version = "1.0.11" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "openai" }, - { name = "posthog" }, - { name = "protobuf" }, - { name = "pydantic" }, - { name = "pytz" }, - { name = "qdrant-client" }, - { name = "sqlalchemy" }, + { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "posthog", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pytz", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "qdrant-client", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "sqlalchemy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/91/1e/2f8a8cc4b8e7f6126f3367d27dc65eac5cd4ceb854888faa3a8f62a2c0a0/mem0ai-1.0.11.tar.gz", hash = "sha256:ddb803bedc22bd514606d262407782e88df929f6991b59f6972fb8a25cc06001", size = 201758, upload-time = "2026-04-06T11:31:43.695Z" } wheels = [ @@ -4316,7 +4266,7 @@ name = "microsoft-agents-activity" version = "0.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5a/6a/dfc2fc0316b7dc4f6d24792b4a31a873b026be76792af1e0c3e65f843ef0/microsoft_agents_activity-0.3.1.tar.gz", hash = "sha256:c7567fc30f8e6f2a2d74cd65a1f7f31ade0d7ec9dd94531677d0d7b0648c77ee", size = 44886, upload-time = "2025-09-09T23:19:43.044Z" } wheels = [ @@ -4328,7 +4278,7 @@ name = "microsoft-agents-copilotstudio-client" version = "0.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "microsoft-agents-hosting-core" }, + { name = "microsoft-agents-hosting-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e9/a5/2381ffd14d6a584f9f7ab80c7b6c634f658ea651b38702eb403c930d8396/microsoft_agents_copilotstudio_client-0.3.1.tar.gz", hash = "sha256:c529209241c9d11b7a6e8696f96a3d43121c10b49e44f00e5066f9cf5256f4f3", size = 5024, upload-time = "2025-09-09T23:19:44.833Z" } wheels = [ @@ -4340,11 +4290,11 @@ name = "microsoft-agents-hosting-core" version = "0.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core" }, - { name = "isodate" }, - { name = "microsoft-agents-activity" }, - { name = "pyjwt" }, - { name = "python-dotenv" }, + { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "microsoft-agents-activity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyjwt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6a/14/a1365e0bab1486c2d16aabeb192ca90715794edf4e68be4815c245884420/microsoft_agents_hosting_core-0.3.1.tar.gz", hash = "sha256:0b76bda10e7a54ff3c86e56cbabaad5ac7a4c2a076c9833af3b2f4c86fa85e89", size = 81137, upload-time = "2025-09-09T23:19:46.73Z" } wheels = [ @@ -4356,30 +4306,30 @@ name = "microsoft-opentelemetry" version = "1.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp" }, - { name = "azure-core" }, - { name = "azure-core-tracing-opentelemetry" }, - { name = "azure-monitor-opentelemetry-exporter" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-proto-http" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-instrumentation-django" }, - { name = "opentelemetry-instrumentation-fastapi" }, - { name = "opentelemetry-instrumentation-flask" }, - { name = "opentelemetry-instrumentation-httpx" }, - { name = "opentelemetry-instrumentation-logging" }, - { name = "opentelemetry-instrumentation-openai-agents-v2" }, - { name = "opentelemetry-instrumentation-openai-v2" }, - { name = "opentelemetry-instrumentation-psycopg2" }, - { name = "opentelemetry-instrumentation-requests" }, - { name = "opentelemetry-instrumentation-urllib" }, - { name = "opentelemetry-instrumentation-urllib3" }, - { name = "opentelemetry-resource-detector-azure" }, - { name = "opentelemetry-sdk" }, - { name = "opentelemetry-util-genai" }, - { name = "pyjwt" }, - { name = "requests" }, - { name = "wrapt" }, + { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core-tracing-opentelemetry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-monitor-opentelemetry-exporter", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-django", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-flask", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-logging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-openai-agents-v2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-openai-v2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-psycopg2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-urllib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-resource-detector-azure", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-genai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyjwt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bd/24/b71c8f8b8cc63b968fece89e33cea3e85eb10d8483eed44fe12927b4d679/microsoft_opentelemetry-1.3.4.tar.gz", hash = "sha256:c1c6608a3b48a9be314ef6bcf3d3f46b4303cbbea400124940aa2244f5eada77", size = 186374, upload-time = "2026-06-17T21:12:36.295Z" } wheels = [ @@ -4391,14 +4341,14 @@ name = "mistralai" version = "2.4.13" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "eval-type-backport" }, - { name = "httpx" }, - { name = "jsonpath-python" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "pydantic" }, - { name = "python-dateutil" }, - { name = "typing-inspection" }, + { name = "eval-type-backport", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "jsonpath-python", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/41/b4/a3ac4178a650f56c0132e43b0ac76a3ba7893104850a159715cb2be1d3b8/mistralai-2.4.13.tar.gz", hash = "sha256:881b0e4bd2c6aef0576350cb484a1c875bbd714ca86d18440979b24735356dfb", size = 497685, upload-time = "2026-06-19T11:52:34.539Z" } wheels = [ @@ -4410,9 +4360,9 @@ name = "ml-dtypes" version = "0.5.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version == '3.11.*' and sys_platform == 'darwin') or (python_full_version == '3.11.*' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/4a/c27b42ed9b1c7d13d9ba8b6905dece787d6259152f2309338aed29b2447b/ml_dtypes-0.5.4.tar.gz", hash = "sha256:8ab06a50fb9bf9666dd0fe5dfb4676fa2b0ac0f31ecff72a6c3af8e22c063453", size = 692314, upload-time = "2025-11-17T22:32:31.031Z" } wheels = [ @@ -4466,9 +4416,9 @@ name = "msal" version = "1.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "pyjwt", extra = ["crypto"] }, - { name = "requests" }, + { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyjwt", extra = ["crypto"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9a/99/d840198ecf6e8057bbc937f129ae940404485d736cda73253bbff9537f01/msal-1.37.0.tar.gz", hash = "sha256:1b1672a33ee467c1d70b341bb16cafd51bb3c817147a95b93263794b03971bec", size = 182444, upload-time = "2026-05-29T19:49:05.561Z" } wheels = [ @@ -4480,7 +4430,7 @@ name = "msal-extensions" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "msal" }, + { name = "msal", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" } wheels = [ @@ -4492,11 +4442,11 @@ name = "msrest" version = "0.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core" }, - { name = "certifi" }, - { name = "isodate" }, - { name = "requests" }, - { name = "requests-oauthlib" }, + { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests-oauthlib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/68/77/8397c8fb8fc257d8ea0fa66f8068e073278c65f05acb17dcb22a02bfdc42/msrest-0.7.1.zip", hash = "sha256:6e7661f46f3afd88b75667b7187a92829924446c7ea1d169be8c4bb7eeb788b9", size = 175332, upload-time = "2022-06-13T22:41:25.111Z" } wheels = [ @@ -4508,7 +4458,7 @@ name = "multidict" version = "6.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } wheels = [ @@ -4646,11 +4596,11 @@ name = "mypy" version = "1.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, - { name = "mypy-extensions" }, - { name = "pathspec" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions" }, + { name = "librt", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (platform_python_implementation != 'PyPy' and sys_platform == 'win32')" }, + { name = "mypy-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pathspec", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tomli", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/b0089fe7fef0a994ae5ee07029ced0526082c6cfaaa4c10d40a10e33b097/mypy-1.20.0.tar.gz", hash = "sha256:eb96c84efcc33f0b5e0e04beacf00129dd963b67226b01c00b9dfc8affb464c3", size = 3815028, upload-time = "2026-03-31T16:55:14.959Z" } wheels = [ @@ -4962,8 +4912,8 @@ name = "ollama" version = "0.5.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "httpx" }, - { name = "pydantic" }, + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/91/6d/ae96027416dcc2e98c944c050c492789502d7d7c0b95a740f0bb39268632/ollama-0.5.3.tar.gz", hash = "sha256:40b6dff729df3b24e56d4042fd9d37e231cee8e528677e0d085413a1d6692394", size = 43331, upload-time = "2025-08-07T21:44:10.422Z" } wheels = [ @@ -4975,14 +4925,14 @@ name = "openai" version = "2.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "httpx" }, - { name = "jiter" }, - { name = "pydantic" }, - { name = "sniffio" }, - { name = "tqdm" }, - { name = "typing-extensions" }, + { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "distro", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "jiter", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "sniffio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/fa/88d0c58a0c58df7e6758e66b99c5d028d5e0bb49f8812d7203940cd9dbf1/openai-2.43.0.tar.gz", hash = "sha256:e74d238200a26868977002190fb6631613480a93dfe0c9c982e77021ed60a017", size = 785369, upload-time = "2026-06-17T17:06:56.06Z" } wheels = [ @@ -4994,14 +4944,14 @@ name = "openai-agents" version = "0.17.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "griffelib" }, - { name = "mcp", extra = ["ws"] }, - { name = "openai" }, - { name = "pydantic" }, - { name = "requests" }, - { name = "types-requests" }, - { name = "typing-extensions" }, - { name = "websockets" }, + { name = "griffelib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "mcp", extra = ["ws"], 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'" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "types-requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "websockets", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fb/90/724f368d1f0656dc750a0b6d3ba06ad88efb293be8d43b08e9d32956252b/openai_agents-0.17.6.tar.gz", hash = "sha256:fed94f8cf0eb4c57c63a89ad4b107932d75f2a0b6d9e895c88fa3c217e63c822", size = 5426870, upload-time = "2026-06-19T06:04:11.635Z" } wheels = [ @@ -5013,11 +4963,11 @@ name = "openai-chatkit" version = "1.6.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jinja2" }, - { name = "openai" }, - { name = "openai-agents" }, - { name = "pydantic" }, - { name = "uvicorn", extra = ["standard"] }, + { name = "jinja2", 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'" }, + { name = "openai-agents", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/07/c4b4ea034f34f25e73cf1a872deb349e3acab6c929a5531d547a9a994890/openai_chatkit-1.6.5.tar.gz", hash = "sha256:903e9702bf26cd8a2b23d4e7b199b657bee4379758e0ca11ebaee09362d2889e", size = 65057, upload-time = "2026-05-19T05:05:14.954Z" } wheels = [ @@ -5029,8 +4979,8 @@ name = "opentelemetry-api" version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "importlib-metadata" }, - { name = "typing-extensions" }, + { name = "importlib-metadata", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", size = 70851, upload-time = "2026-03-04T14:17:21.555Z" } wheels = [ @@ -5042,8 +4992,8 @@ name = "opentelemetry-exporter-otlp" version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-exporter-otlp-proto-grpc" }, - { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-exporter-otlp-proto-grpc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d0/37/b6708e0eff5c5fb9aba2e0ea09f7f3bcbfd12a592d2a780241b5f6014df7/opentelemetry_exporter_otlp-1.40.0.tar.gz", hash = "sha256:7caa0870b95e2fcb59d64e16e2b639ecffb07771b6cd0000b5d12e5e4fef765a", size = 6152, upload-time = "2026-03-04T14:17:23.235Z" } wheels = [ @@ -5055,7 +5005,7 @@ name = "opentelemetry-exporter-otlp-proto-common" version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-proto" }, + { name = "opentelemetry-proto", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/51/bc/1559d46557fe6eca0b46c88d4c2676285f1f3be2e8d06bb5d15fbffc814a/opentelemetry_exporter_otlp_proto_common-1.40.0.tar.gz", hash = "sha256:1cbee86a4064790b362a86601ee7934f368b81cd4cc2f2e163902a6e7818a0fa", size = 20416, upload-time = "2026-03-04T14:17:23.801Z" } wheels = [ @@ -5067,13 +5017,13 @@ name = "opentelemetry-exporter-otlp-proto-grpc" version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "googleapis-common-protos" }, - { name = "grpcio" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-proto-common" }, - { name = "opentelemetry-proto" }, - { name = "opentelemetry-sdk" }, - { name = "typing-extensions" }, + { name = "googleapis-common-protos", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "grpcio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-exporter-otlp-proto-common", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-proto", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8f/7f/b9e60435cfcc7590fa87436edad6822240dddbc184643a2a005301cc31f4/opentelemetry_exporter_otlp_proto_grpc-1.40.0.tar.gz", hash = "sha256:bd4015183e40b635b3dab8da528b27161ba83bf4ef545776b196f0fb4ec47740", size = 25759, upload-time = "2026-03-04T14:17:24.4Z" } wheels = [ @@ -5085,13 +5035,13 @@ name = "opentelemetry-exporter-otlp-proto-http" version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "googleapis-common-protos" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-proto-common" }, - { name = "opentelemetry-proto" }, - { name = "opentelemetry-sdk" }, - { name = "requests" }, - { name = "typing-extensions" }, + { name = "googleapis-common-protos", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-exporter-otlp-proto-common", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-proto", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2e/fa/73d50e2c15c56be4d000c98e24221d494674b0cc95524e2a8cb3856d95a4/opentelemetry_exporter_otlp_proto_http-1.40.0.tar.gz", hash = "sha256:db48f5e0f33217588bbc00274a31517ba830da576e59503507c839b38fa0869c", size = 17772, upload-time = "2026-03-04T14:17:25.324Z" } wheels = [ @@ -5103,10 +5053,10 @@ name = "opentelemetry-instrumentation" version = "0.61b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "packaging" }, - { name = "wrapt" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/da/37/6bf8e66bfcee5d3c6515b79cb2ee9ad05fe573c20f7ceb288d0e7eeec28c/opentelemetry_instrumentation-0.61b0.tar.gz", hash = "sha256:cb21b48db738c9de196eba6b805b4ff9de3b7f187e4bbf9a466fa170514f1fc7", size = 32606, upload-time = "2026-03-04T14:20:16.825Z" } wheels = [ @@ -5118,11 +5068,11 @@ name = "opentelemetry-instrumentation-asgi" version = "0.61b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "asgiref" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "opentelemetry-util-http" }, + { name = "asgiref", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/00/3e/143cf5c034e58037307e6a24f06e0dd64b2c49ae60a965fc580027581931/opentelemetry_instrumentation_asgi-0.61b0.tar.gz", hash = "sha256:9d08e127244361dc33976d39dd4ca8f128b5aa5a7ae425208400a80a095019b5", size = 26691, upload-time = "2026-03-04T14:20:21.038Z" } wheels = [ @@ -5134,10 +5084,10 @@ name = "opentelemetry-instrumentation-dbapi" version = "0.61b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "wrapt" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d6/ed/ba91c9e4a3ec65781e9c59982109f0a36de9fa574f622596b33d1985dab5/opentelemetry_instrumentation_dbapi-0.61b0.tar.gz", hash = "sha256:02fa800682c1de87dcad0e59f2092b3b6fb8b8ea0636518f989e1166b418dcb9", size = 16761, upload-time = "2026-03-04T14:20:29.782Z" } wheels = [ @@ -5149,11 +5099,11 @@ name = "opentelemetry-instrumentation-django" version = "0.61b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-instrumentation-wsgi" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "opentelemetry-util-http" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-wsgi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/74/ef/6bc1a6560630f26b1c010af86b28f42bfbe6a601bd1647d1436e0d3436aa/opentelemetry_instrumentation_django-0.61b0.tar.gz", hash = "sha256:9885154dc128578de0e6b5ce49e965c786f8ab071175bec005dcd454510be951", size = 25996, upload-time = "2026-03-04T14:20:30.453Z" } wheels = [ @@ -5165,11 +5115,11 @@ name = "opentelemetry-instrumentation-fastapi" version = "0.61b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-instrumentation-asgi" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "opentelemetry-util-http" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-asgi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/37/35/aa727bb6e6ef930dcdc96a617b83748fece57b43c47d83ba8d83fbeca657/opentelemetry_instrumentation_fastapi-0.61b0.tar.gz", hash = "sha256:3a24f35b07c557ae1bbc483bf8412221f25d79a405f8b047de8b670722e2fa9f", size = 24800, upload-time = "2026-03-04T14:20:32.759Z" } wheels = [ @@ -5181,12 +5131,12 @@ name = "opentelemetry-instrumentation-flask" version = "0.61b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-instrumentation-wsgi" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "opentelemetry-util-http" }, - { name = "packaging" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-wsgi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/33/d6852d8f2c3eef86f2f8c858d6f5315983c7063e07e595519e96d4c31c06/opentelemetry_instrumentation_flask-0.61b0.tar.gz", hash = "sha256:e9faf58dfd9860a1868442d180142645abdafc1a652dd73d469a5efd106a7d49", size = 24071, upload-time = "2026-03-04T14:20:33.437Z" } wheels = [ @@ -5198,11 +5148,11 @@ name = "opentelemetry-instrumentation-httpx" version = "0.61b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "opentelemetry-util-http" }, - { name = "wrapt" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/2a/e2becd55e33c29d1d9ef76e2579040ed1951cb33bacba259f6aff2fdd2a6/opentelemetry_instrumentation_httpx-0.61b0.tar.gz", hash = "sha256:6569ec097946c5551c2a4252f74c98666addd1bf047c1dde6b4ef426719ff8dd", size = 24104, upload-time = "2026-03-04T14:20:34.752Z" } wheels = [ @@ -5214,8 +5164,8 @@ name = "opentelemetry-instrumentation-logging" version = "0.61b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ae/e0/69473f925acfe2d4edf5c23bcced36906ac3627aa7c5722a8e3f60825f3b/opentelemetry_instrumentation_logging-0.61b0.tar.gz", hash = "sha256:feaa30b700acd2a37cc81db5f562ab0c3a5b6cc2453595e98b72c01dcf649584", size = 17906, upload-time = "2026-03-04T14:20:37.398Z" } wheels = [ @@ -5227,10 +5177,10 @@ name = "opentelemetry-instrumentation-openai-agents-v2" version = "0.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "opentelemetry-util-genai" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-genai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/00/15/b6a303454d2800d772cdebc490c1d598d06d0e541619db80195eb9ea85c6/opentelemetry_instrumentation_openai_agents_v2-0.1.0.tar.gz", hash = "sha256:1033f4b261ce07f65d197ac0e9c499302c805eae987a6cc4e7f99bb279363477", size = 22423, upload-time = "2025-10-15T19:04:59.912Z" } wheels = [ @@ -5242,9 +5192,9 @@ name = "opentelemetry-instrumentation-openai-v2" version = "2.3b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/4e/21f8cd16ccb471dd217ed85eb817796a10c4f2718ae2c91e752a57180cf0/opentelemetry_instrumentation_openai_v2-2.3b0.tar.gz", hash = "sha256:5de9d70cc9536eea1fe48ea016e0c5f25735fa9a13709076a64b20657fadb6ba", size = 170838, upload-time = "2025-12-24T13:20:58.33Z" } wheels = [ @@ -5256,9 +5206,9 @@ name = "opentelemetry-instrumentation-psycopg2" version = "0.61b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-instrumentation-dbapi" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-dbapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/28/f28d52b1088e7a09761566f8700507b54d3d83a6f9c93c0ce02f53619e83/opentelemetry_instrumentation_psycopg2-0.61b0.tar.gz", hash = "sha256:863ccf9687b71e73dd489c7bb117278768bdf26aa0dafe7dc974a2425e05b5d7", size = 11676, upload-time = "2026-03-04T14:20:41.269Z" } wheels = [ @@ -5270,10 +5220,10 @@ name = "opentelemetry-instrumentation-requests" version = "0.61b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "opentelemetry-util-http" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5a/c7/7a47cb85c7aa93a9c820552e414889185bcf91245271d12e5d443e5f834d/opentelemetry_instrumentation_requests-0.61b0.tar.gz", hash = "sha256:15f879ce8fb206bd7e6fdc61663ea63481040a845218c0cf42902ce70bd7e9d9", size = 18379, upload-time = "2026-03-04T14:20:46.959Z" } wheels = [ @@ -5285,10 +5235,10 @@ name = "opentelemetry-instrumentation-urllib" version = "0.61b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "opentelemetry-util-http" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/81/37/77cd326b083390e74280c08bbd585153809619dad068e2d1b253fec1164d/opentelemetry_instrumentation_urllib-0.61b0.tar.gz", hash = "sha256:6a15ff862fc1603e0ea5ea75558f76f36436b02e0ae48daecedcb5e574cce160", size = 16894, upload-time = "2026-03-04T14:20:52.726Z" } wheels = [ @@ -5300,11 +5250,11 @@ name = "opentelemetry-instrumentation-urllib3" version = "0.61b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "opentelemetry-util-http" }, - { name = "wrapt" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fa/80/7ad8da30f479c6117768e72d6f2f3f0bd3495338707d6f61de042149578a/opentelemetry_instrumentation_urllib3-0.61b0.tar.gz", hash = "sha256:f00037bc8ff813153c4b79306f55a14618c40469a69c6c03a3add29dc7e8b928", size = 19325, upload-time = "2026-03-04T14:20:53.386Z" } wheels = [ @@ -5316,10 +5266,10 @@ name = "opentelemetry-instrumentation-wsgi" version = "0.61b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "opentelemetry-util-http" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/89/e5/189f2845362cfe78e356ba127eab21456309def411c6874aa4800c3de816/opentelemetry_instrumentation_wsgi-0.61b0.tar.gz", hash = "sha256:380f2ae61714e5303275a80b2e14c58571573cd1fddf496d8c39fb9551c5e532", size = 19898, upload-time = "2026-03-04T14:20:54.068Z" } wheels = [ @@ -5331,7 +5281,7 @@ name = "opentelemetry-proto" version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "protobuf" }, + { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4c/77/dd38991db037fdfce45849491cb61de5ab000f49824a00230afb112a4392/opentelemetry_proto-1.40.0.tar.gz", hash = "sha256:03f639ca129ba513f5819810f5b1f42bcb371391405d99c168fe6937c62febcd", size = 45667, upload-time = "2026-03-04T14:17:31.194Z" } wheels = [ @@ -5343,7 +5293,7 @@ name = "opentelemetry-resource-detector-azure" version = "0.1.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-sdk" }, + { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/67/e4/0d359d48d03d447225b30c3dd889d5d454e3b413763ff721f9b0e4ac2e59/opentelemetry_resource_detector_azure-0.1.5.tar.gz", hash = "sha256:e0ba658a87c69eebc806e75398cd0e9f68a8898ea62de99bc1b7083136403710", size = 11503, upload-time = "2024-05-16T21:54:58.994Z" } wheels = [ @@ -5355,9 +5305,9 @@ name = "opentelemetry-sdk" version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "typing-extensions" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/fd/3c3125b20ba18ce2155ba9ea74acb0ae5d25f8cd39cfd37455601b7955cc/opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2", size = 184252, upload-time = "2026-03-04T14:17:31.87Z" } wheels = [ @@ -5369,8 +5319,8 @@ name = "opentelemetry-semantic-conventions" version = "0.61b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api" }, - { name = "typing-extensions" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6d/c0/4ae7973f3c2cfd2b6e321f1675626f0dab0a97027cc7a297474c9c8f3d04/opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a", size = 145755, upload-time = "2026-03-04T14:17:32.664Z" } wheels = [ @@ -5382,9 +5332,9 @@ name = "opentelemetry-util-genai" version = "0.3b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a2/d8/4dd2fb622d26ec45b10ef63eb87fd512f5d7467c7bd35ce390629bd6dff8/opentelemetry_util_genai-0.3b0.tar.gz", hash = "sha256:83e127789a9ad615b8ca65f05fc36955a67ce257b06142bfd46159a3b7ed73d3", size = 31800, upload-time = "2026-02-20T16:16:14.807Z" } wheels = [ @@ -5414,7 +5364,7 @@ name = "orderedmultidict" version = "1.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six" }, + { name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5c/62/61ad51f6c19d495970230a7747147ce7ed3c3a63c2af4ebfdb1f6d738703/orderedmultidict-1.0.2.tar.gz", hash = "sha256:16a7ae8432e02cc987d2d6d5af2df5938258f87c870675c73ee77a0920e6f4a6", size = 13973, upload-time = "2025-11-18T08:00:42.649Z" } wheels = [ @@ -5530,10 +5480,10 @@ resolution-markers = [ "python_full_version < '3.11' and sys_platform == 'win32'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "python-dateutil" }, - { name = "pytz" }, - { name = "tzdata" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "python-dateutil", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "pytz", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "tzdata", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ @@ -5605,10 +5555,10 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform == 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "python-dateutil" }, - { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version == '3.11.*' and sys_platform == 'darwin') or (python_full_version == '3.11.*' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, + { name = "python-dateutil", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "tzdata", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } wheels = [ @@ -5791,8 +5741,8 @@ name = "plotly" version = "6.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "narwhals" }, - { name = "packaging" }, + { name = "narwhals", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/94/fd/d72c292d78aadb93d1a9bcd76bf3c678271040c7cf10abe5788b33040a39/plotly-6.8.0.tar.gz", hash = "sha256:e088e7ddc68d4f70e3d66659224727a45296d71d2b8284181862d3d8f1f0d88f", size = 6915161, upload-time = "2026-06-03T18:33:40.226Z" } wheels = [ @@ -5813,9 +5763,9 @@ name = "poethepoet" version = "0.46.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pastel" }, - { name = "pyyaml" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "pastel", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tomli", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3b/f5/d501fcb67e450fd3fae9db06050420c0c6043758cfa8c30ba40278211265/poethepoet-0.46.0.tar.gz", hash = "sha256:daf8469031879ef59ef0b34fdba83574d65e41eb9186e20cd0f7c89ce479b030", size = 117276, upload-time = "2026-05-15T15:52:02.548Z" } wheels = [ @@ -5827,7 +5777,7 @@ name = "polars" version = "1.38.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "polars-runtime-32" }, + { name = "polars-runtime-32", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c6/5e/208a24471a433bcd0e9a6889ac49025fd4daad2815c8220c5bd2576e5f1b/polars-1.38.1.tar.gz", hash = "sha256:803a2be5344ef880ad625addfb8f641995cfd777413b08a10de0897345778239", size = 717667, upload-time = "2026-02-06T18:13:23.013Z" } wheels = [ @@ -5879,10 +5829,10 @@ name = "posthog" version = "7.20.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backoff" }, - { name = "distro" }, - { name = "requests" }, - { name = "typing-extensions" }, + { name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "distro", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/69/33/b44963d075a5793cf1bb00cec36ef57b65a699eb9edb54de42ff19c33d7a/posthog-7.20.2.tar.gz", hash = "sha256:3c95f1571230db4e839618500b058d68f5f8abadf1eaabbad5b359f971988ea5", size = 255598, upload-time = "2026-06-22T15:36:46.251Z" } wheels = [ @@ -5894,8 +5844,8 @@ name = "powerfx" version = "0.0.34" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi" }, - { name = "pythonnet" }, + { name = "cffi", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, + { name = "pythonnet", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/fb/6c4bf87e0c74ca1c563921ce89ca1c5785b7576bca932f7255cdf81082a7/powerfx-0.0.34.tar.gz", hash = "sha256:956992e7afd272657ed16d80f4cad24ec95d9e4a79fb9dfa4a068a09e136af32", size = 3237555, upload-time = "2025-12-22T15:50:59.682Z" } wheels = [ @@ -6068,7 +6018,7 @@ name = "proto-plus" version = "1.28.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "protobuf" }, + { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c9/56/e647b0c675392d2da368da7b6f158f7368b18542fd6f7d7400a2f39de000/proto_plus-1.28.0.tar.gz", hash = "sha256:38e5696342835b08fc116f30a25665b29531cda9d5d5643e9b81fc312385abd9", size = 57221, upload-time = "2026-05-07T08:04:50.811Z" } wheels = [ @@ -6176,7 +6126,7 @@ name = "pyasn1-modules" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyasn1" }, + { name = "pyasn1", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } wheels = [ @@ -6197,10 +6147,10 @@ name = "pydantic" version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, + { name = "annotated-types", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ @@ -6209,7 +6159,7 @@ wheels = [ [package.optional-dependencies] email = [ - { name = "email-validator" }, + { name = "email-validator", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [[package]] @@ -6217,7 +6167,7 @@ name = "pydantic-argparse" version = "0.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/94/ea/e63d587294c20d3b83e9c312b5d577c9ec28962ee8490839ca9996672849/pydantic_argparse-0.10.0.tar.gz", hash = "sha256:d57eb0a84c8f0af6605376157d3f445cfd786700f2e596ba9d48d15d557185eb", size = 15928, upload-time = "2025-02-09T08:18:30.425Z" } wheels = [ @@ -6229,7 +6179,7 @@ name = "pydantic-core" version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ @@ -6345,7 +6295,7 @@ name = "pydantic-monty" version = "0.0.18" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/14/5b/bb6a8bfdf13eb9808c966bdac064a40ce9ac881ec6d64dba3e055888f22b/pydantic_monty-0.0.18.tar.gz", hash = "sha256:c43794c7c4664fa1403d4841459d0e23f01b4f552283db638f5b40ced4dac6a1", size = 1197105, upload-time = "2026-05-29T08:31:41.077Z" } wheels = [ @@ -6416,9 +6366,9 @@ name = "pydantic-settings" version = "2.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } wheels = [ @@ -6445,7 +6395,7 @@ wheels = [ [package.optional-dependencies] crypto = [ - { name = "cryptography" }, + { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [[package]] @@ -6453,7 +6403,7 @@ name = "pynacl" version = "1.6.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "cffi", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (platform_python_implementation != 'PyPy' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } wheels = [ @@ -6516,8 +6466,8 @@ name = "pyright" version = "1.1.410" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nodeenv" }, - { name = "typing-extensions" }, + { name = "nodeenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/10/53/e4d8ea1391bd4355231be6f91bf239479aa0014260ed3fb5526eeb12a1f2/pyright-1.1.410.tar.gz", hash = "sha256:07a073b8ba6749826773c1269773efa11b93440d9a6aa60419d9a3172d6dc488", size = 4062013, upload-time = "2026-06-01T17:35:48.894Z" } wheels = [ @@ -6529,7 +6479,7 @@ name = "pyroscope-io" version = "0.8.16" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi" }, + { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a8/50/607b38b120ba8adad954119ba512c53590c793f0cf7f009ba6549e4e1d77/pyroscope_io-0.8.16-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:e07edcfd59f5bdce42948b92c9b118c824edbd551730305f095a6b9af401a9e8", size = 3138869, upload-time = "2026-01-22T06:23:24.664Z" }, @@ -6544,12 +6494,12 @@ version = "9.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "iniconfig", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pluggy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pygments", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tomli", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } wheels = [ @@ -6561,9 +6511,9 @@ name = "pytest-asyncio" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, - { name = "pytest" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "backports-asyncio-runner", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } wheels = [ @@ -6575,9 +6525,9 @@ name = "pytest-cov" version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coverage", extra = ["toml"] }, - { name = "pluggy" }, - { name = "pytest" }, + { name = "coverage", extra = ["toml"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pluggy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ @@ -6589,7 +6539,7 @@ name = "pytest-retry" version = "1.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest" }, + { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c5/5b/607b017994cca28de3a1ad22a3eee8418e5d428dcd8ec25b26b18e995a73/pytest_retry-1.7.0.tar.gz", hash = "sha256:f8d52339f01e949df47c11ba9ee8d5b362f5824dff580d3870ec9ae0057df80f", size = 19977, upload-time = "2025-01-19T01:56:13.115Z" } wheels = [ @@ -6601,7 +6551,7 @@ name = "pytest-timeout" version = "2.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest" }, + { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } wheels = [ @@ -6613,8 +6563,8 @@ name = "pytest-xdist" version = "3.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "execnet" }, - { name = "pytest" }, + { name = "execnet", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } wheels = [ @@ -6623,7 +6573,7 @@ wheels = [ [package.optional-dependencies] psutil = [ - { name = "psutil" }, + { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [[package]] @@ -6631,7 +6581,7 @@ name = "python-dateutil" version = "2.9.0.post0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six" }, + { name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ @@ -6670,7 +6620,7 @@ name = "pythonnet" version = "3.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "clr-loader" }, + { name = "clr-loader", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9a/d6/1afd75edd932306ae9bd2c2d961d603dc2b52fcec51b04afea464f1f6646/pythonnet-3.0.5.tar.gz", hash = "sha256:48e43ca463941b3608b32b4e236db92d8d40db4c58a75ace902985f76dac21cf", size = 239212, upload-time = "2024-12-13T08:30:44.393Z" } wheels = [ @@ -6780,15 +6730,15 @@ name = "qdrant-client" version = "1.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "grpcio" }, - { name = "httpx", extra = ["http2"] }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "portalocker" }, - { name = "protobuf" }, - { name = "pydantic" }, - { name = "urllib3" }, + { name = "grpcio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx", extra = ["http2"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version == '3.11.*' and sys_platform == 'darwin') or (python_full_version == '3.11.*' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, + { name = "portalocker", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/65/45/5b1bdd15a3c7730eefb9c113600829e20d689b82b5a23f9e07d107094004/qdrant_client-1.18.0.tar.gz", hash = "sha256:52e8ece1a7d40519801bf0b70713bfa0f6b7ae28c7275bbe0b0286fbed7f6db4", size = 352580, upload-time = "2026-05-11T14:12:38.702Z" } wheels = [ @@ -6800,7 +6750,7 @@ name = "redis" version = "7.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, + { name = "async-timeout", marker = "(python_full_version < '3.11.3' and sys_platform == 'darwin') or (python_full_version < '3.11.3' and sys_platform == 'linux') or (python_full_version < '3.11.3' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f7/80/2971931d27651affa88a44c0ad7b8c4a19dc29c998abb20b23868d319b59/redis-7.1.1.tar.gz", hash = "sha256:a2814b2bda15b39dad11391cc48edac4697214a8a5a4bd10abe936ab4892eb43", size = 4800064, upload-time = "2026-02-09T18:39:40.292Z" } wheels = [ @@ -6812,16 +6762,16 @@ name = "redisvl" version = "0.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jsonpath-ng" }, - { name = "ml-dtypes" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "pydantic" }, - { name = "python-ulid" }, - { name = "pyyaml" }, - { name = "redis" }, - { name = "tenacity" }, + { name = "jsonpath-ng", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "ml-dtypes", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version == '3.11.*' and sys_platform == 'darwin') or (python_full_version == '3.11.*' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "python-ulid", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "redis", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tenacity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/72/1a/f1f0ff963622c34a9e9a9f2a0c6ad82bfbd05c082ecc89e38e092e3e9069/redisvl-0.15.0.tar.gz", hash = "sha256:0e382e9b6cd8378dfe1515b18f92d125cfba905f6f3c5fe9b8904b3ca840d1ca", size = 861480, upload-time = "2026-02-27T14:02:33.366Z" } wheels = [ @@ -6833,10 +6783,10 @@ name = "referencing" version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs" }, - { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "rpds-py", version = "2026.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "attrs", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "rpds-py", version = "2026.5.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } wheels = [ @@ -6969,10 +6919,10 @@ name = "requests" version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, + { name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "charset-normalizer", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ @@ -6984,8 +6934,8 @@ name = "requests-oauthlib" version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "oauthlib" }, - { name = "requests" }, + { name = "oauthlib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } wheels = [ @@ -7006,9 +6956,9 @@ name = "rich" version = "13.9.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "markdown-it-py", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pygments", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149, upload-time = "2024-11-01T16:43:57.873Z" } wheels = [ @@ -7298,9 +7248,9 @@ name = "rq" version = "2.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, - { name = "croniter" }, - { name = "redis" }, + { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "croniter", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "redis", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c5/9b/93b7180220fe462b4128425e687665bcdeffddc51683d41e7fbe509c2d2e/rq-2.7.0.tar.gz", hash = "sha256:c2156fc7249b5d43dda918c4355cfbf8d0d299a5cdd3963918e9c8daf4b1e0c0", size = 679396, upload-time = "2026-02-22T11:10:50.775Z" } wheels = [ @@ -7337,7 +7287,7 @@ name = "s3transfer" version = "0.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "botocore" }, + { name = "botocore", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/11/b3/bcdc2f58fa92592db511beda154c2c08d28f21f6c4637f06a42a24b10c21/s3transfer-0.17.1.tar.gz", hash = "sha256:042dd5e3b1b512355e35a23f0223e426b7042e80b97830ea2680ddce327fc45e", size = 159439, upload-time = "2026-05-26T19:45:01.714Z" } wheels = [ @@ -7354,10 +7304,10 @@ resolution-markers = [ "python_full_version < '3.11' and sys_platform == 'win32'", ] dependencies = [ - { name = "joblib" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } }, - { name = "threadpoolctl" }, + { name = "joblib", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "threadpoolctl", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" } wheels = [ @@ -7412,13 +7362,13 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform == 'win32'", ] dependencies = [ - { name = "joblib" }, - { name = "narwhals" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "threadpoolctl" }, + { name = "joblib", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "narwhals", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version == '3.11.*' and sys_platform == 'darwin') or (python_full_version == '3.11.*' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version == '3.11.*' and sys_platform == 'darwin') or (python_full_version == '3.11.*' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform == 'win32')" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, + { name = "threadpoolctl", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } wheels = [ @@ -7464,7 +7414,7 @@ resolution-markers = [ "python_full_version < '3.11' and sys_platform == 'win32'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ @@ -7525,7 +7475,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform == 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version == '3.11.*' and sys_platform == 'darwin') or (python_full_version == '3.11.*' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -7607,7 +7557,7 @@ resolution-markers = [ "python_full_version == '3.12.*' and sys_platform == 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } wheels = [ @@ -7658,13 +7608,13 @@ name = "seaborn" version = "0.13.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "matplotlib", version = "3.10.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "matplotlib", version = "3.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "matplotlib", version = "3.10.9", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "matplotlib", version = "3.11.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version == '3.11.*' and sys_platform == 'darwin') or (python_full_version == '3.11.*' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/86/59/a451d7420a77ab0b98f7affa3a1d78a313d2f7281a57afb1a34bae8ab412/seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7", size = 1457696, upload-time = "2024-01-25T13:21:52.551Z" } wheels = [ @@ -7796,7 +7746,7 @@ name = "soundfile" version = "0.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi" }, + { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/96/5ff33900998bad58d5381fd1acfcdac11cbea4f08fc72ac1dc25ffb13f6a/soundfile-0.12.1.tar.gz", hash = "sha256:e8e1017b2cf1dda767aef19d2fd9ee5ebe07e050d430f77a0a7c66ba08b8cdae", size = 43184, upload-time = "2023-02-15T15:37:32.011Z" } wheels = [ @@ -7814,8 +7764,8 @@ name = "sqlalchemy" version = "2.0.51" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, - { name = "typing-extensions" }, + { name = "greenlet", marker = "(platform_machine == 'AMD64' and sys_platform == 'darwin') or (platform_machine == 'WIN32' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'darwin') or (platform_machine == 'amd64' and sys_platform == 'darwin') or (platform_machine == 'ppc64le' and sys_platform == 'darwin') or (platform_machine == 'win32' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'AMD64' and sys_platform == 'linux') or (platform_machine == 'WIN32' and sys_platform == 'linux') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'amd64' and sys_platform == 'linux') or (platform_machine == 'ppc64le' and sys_platform == 'linux') or (platform_machine == 'win32' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'WIN32' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'win32') or (platform_machine == 'amd64' and sys_platform == 'win32') or (platform_machine == 'ppc64le' and sys_platform == 'win32') or (platform_machine == 'win32' and sys_platform == 'win32') or (platform_machine == 'x86_64' and sys_platform == 'win32')" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } wheels = [ @@ -7869,8 +7819,8 @@ name = "sse-starlette" version = "3.4.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "starlette" }, + { name = "anyio", 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'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d2/1b/bc9e3e7a72dcdad7dc7888758f5d00f56f8909ed5cfdff822bd72bb4c520/sse_starlette-3.4.5.tar.gz", hash = "sha256:83072538bc211a2f68b7b0422226c4af3e9b62e106e07034664b832ca019842a", size = 35249, upload-time = "2026-06-20T17:36:58.322Z" } wheels = [ @@ -7882,8 +7832,8 @@ name = "starlette" version = "0.50.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } wheels = [ @@ -7895,7 +7845,7 @@ name = "sympy" version = "1.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mpmath" }, + { name = "mpmath", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } wheels = [ @@ -7916,8 +7866,8 @@ name = "taskgroup" version = "0.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup" }, - { name = "typing-extensions" }, + { name = "exceptiongroup", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f0/8d/e218e0160cc1b692e6e0e5ba34e8865dbb171efeb5fc9a704544b3020605/taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d", size = 11504, upload-time = "2025-01-03T09:24:13.761Z" } wheels = [ @@ -7929,34 +7879,34 @@ name = "tau2" version = "0.0.1" source = { git = "https://github.com/sierra-research/tau2-bench?rev=5ba9e3e56db57c5e4114bf7f901291f09b2c5619#5ba9e3e56db57c5e4114bf7f901291f09b2c5619" } dependencies = [ - { name = "addict" }, - { name = "deepdiff" }, - { name = "docstring-parser" }, - { name = "fastapi" }, - { name = "fs" }, - { name = "langfuse" }, - { name = "litellm" }, - { name = "loguru" }, - { name = "matplotlib", version = "3.10.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "matplotlib", version = "3.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "plotly" }, - { name = "psutil" }, - { name = "pydantic-argparse" }, - { name = "pytest" }, - { name = "pyyaml" }, - { name = "redis" }, - { name = "rich" }, - { name = "ruff" }, - { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "scikit-learn", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "seaborn" }, - { name = "tabulate" }, - { name = "tenacity" }, - { name = "toml" }, - { name = "uvicorn", extra = ["standard"] }, - { name = "watchdog" }, + { name = "addict", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "deepdiff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "docstring-parser", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "fs", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "langfuse", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "litellm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "loguru", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "matplotlib", version = "3.10.9", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "matplotlib", version = "3.11.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "plotly", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic-argparse", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "redis", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "ruff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "scikit-learn", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "seaborn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tabulate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tenacity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "toml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "watchdog", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [[package]] @@ -7991,8 +7941,8 @@ name = "tiktoken" version = "0.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "regex" }, - { name = "requests" }, + { name = "regex", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/e5/5f3cb2159769d0f4324c0e9e87f9de3c4b1cd45848a96b2eb3566ad5ca77/tiktoken-0.13.0.tar.gz", hash = "sha256:c9435714c3a84c2319499de9a300c0e604449dd0799ff246458b3bb6a7f433c1", size = 38986, upload-time = "2026-05-15T04:51:27.153Z" } wheels = [ @@ -8052,7 +8002,7 @@ name = "tokenizers" version = "0.23.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub" }, + { name = "huggingface-hub", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c1/60/21f715d9faba5f5407ff759472ade058ec4a507ad62bcea47cb847239a73/tokenizers-0.23.1.tar.gz", hash = "sha256:1feeeadf865a7915adc25445dea30e9933e593c31bb96c277cee36de227c8bfa", size = 365748, upload-time = "2026-04-27T14:43:25.606Z" } wheels = [ @@ -8188,10 +8138,10 @@ name = "typer" version = "0.25.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-doc" }, - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, + { name = "annotated-doc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "shellingham", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } wheels = [ @@ -8221,7 +8171,7 @@ name = "types-requests" version = "2.33.0.20260518" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "urllib3" }, + { name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e0/01/c5a19253fe1ac159159ddf9a3a07cec8bb5e486ec4d9002ad2821da0e5d2/types_requests-2.33.0.20260518.tar.gz", hash = "sha256:df7bd3bfe0ca8402dfb841e7d9be714bb5578203283d66d7dc4ef69343449a5e", size = 24752, upload-time = "2026-05-18T06:07:37.966Z" } wheels = [ @@ -8242,7 +8192,7 @@ name = "typing-inspection" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ @@ -8310,9 +8260,9 @@ name = "uvicorn" version = "0.49.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, - { name = "h11" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "h11", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } wheels = [ @@ -8322,12 +8272,12 @@ wheels = [ [package.optional-dependencies] standard = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "httptools" }, - { name = "python-dotenv" }, - { name = "pyyaml" }, - { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'win32'" }, - { name = "watchfiles" }, - { name = "websockets" }, + { name = "httptools", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "uvloop", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux')" }, + { name = "watchfiles", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "websockets", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [[package]] @@ -8412,7 +8362,7 @@ name = "watchfiles" version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, + { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } wheels = [ @@ -8597,7 +8547,7 @@ name = "werkzeug" version = "3.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markupsafe" }, + { name = "markupsafe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } wheels = [ @@ -8687,7 +8637,7 @@ name = "wsproto" version = "1.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11" }, + { name = "h11", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } wheels = [ @@ -8699,9 +8649,9 @@ name = "yarl" version = "1.24.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "idna" }, - { name = "multidict" }, - { name = "propcache" }, + { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "multidict", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "propcache", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } wheels = [ From 771592876d0e881cda131c83136c94e4fecf0270 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 8 Jul 2026 14:05:09 +0200 Subject: [PATCH 12/15] Restore helper-first workflow sample Rebuild the local Responses workflow sample on the protocol-helper surface, add production-readiness cautions to the local hosting samples, and align file-backed workflow checkpoint/cursor storage under one sample storage root. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/specs/002-python-hosting-channels.md | 9 +- .../samples/04-hosting/af-hosting/README.md | 3 +- .../af-hosting/local_responses/README.md | 11 + .../af-hosting/local_responses/app.py | 12 + .../local_responses/storage/.gitignore | 2 + .../local_responses_workflow/README.md | 59 ++++ .../local_responses_workflow/app.py | 268 ++++++++++++++++++ .../local_responses_workflow/call_server.py | 48 ++++ .../local_responses_workflow/pyproject.toml | 26 ++ .../storage/.gitignore | 2 + 10 files changed, 436 insertions(+), 4 deletions(-) create mode 100644 python/samples/04-hosting/af-hosting/local_responses/storage/.gitignore 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/pyproject.toml create mode 100644 python/samples/04-hosting/af-hosting/local_responses_workflow/storage/.gitignore diff --git a/docs/specs/002-python-hosting-channels.md b/docs/specs/002-python-hosting-channels.md index 6b261864146..5ed5fcbdb37 100644 --- a/docs/specs/002-python-hosting-channels.md +++ b/docs/specs/002-python-hosting-channels.md @@ -178,7 +178,9 @@ The target may be: - synchronous `target` only after a target is already available/resolved. Workflow checkpointing uses Agent Framework's existing `CheckpointStorage` abstraction directly. Apps that need -per-session workflow resume should keep an app-owned cursor such as `session_id -> checkpoint_id`: +per-session workflow resume should keep an app-owned cursor such as `session_id -> checkpoint_id`. When the app uses +file-backed cursor storage, the file-based checkpoint storage should share the same app storage root, for example +`storage/checkpoints/` beside `storage/checkpoint_cursors.json`: ```python # session_id must already be authenticated and authorized for this caller @@ -270,8 +272,9 @@ The application builder decides whether the server is persistent or transient. reliable boundary, must not rely on in-memory `SessionStore` state between calls. They need a durable session store or a service-owned continuation id. - Workflow hosts must choose an explicit `CheckpointStorage` and, when they need per-session resume, a durable - `session_id -> checkpoint_id` cursor. In-process workflow state and in-memory checkpoint cursors do not survive - transient execution. + `session_id -> checkpoint_id` cursor. File-backed checkpoint storage and file-backed cursor storage should live under + the same app storage root. In-process workflow state and in-memory checkpoint cursors do not survive transient + execution. ## Minimal FastAPI Responses shape diff --git a/python/samples/04-hosting/af-hosting/README.md b/python/samples/04-hosting/af-hosting/README.md index c75ffd1cb94..23ee48cadfd 100644 --- a/python/samples/04-hosting/af-hosting/README.md +++ b/python/samples/04-hosting/af-hosting/README.md @@ -10,6 +10,7 @@ clients, authentication, response construction, and deployment shape. | Sample | What it shows | Packaging | |---|---|---| | [`local_responses/`](./local_responses) | One agent + one `@tool` + native FastAPI route + Responses helper functions + `AgentState` / `SessionStore`. | **Local only.** Start here to learn the helper seam. | +| [`local_responses_workflow/`](./local_responses_workflow) | A workflow target behind a native FastAPI route using Responses helper functions, `WorkflowState`, explicit `CheckpointStorage`, and an app-owned checkpoint cursor. | **Local only.** | Each sample is self-contained with its own `pyproject.toml`, server `app.py`, calling script(s), and `storage/` directory. Samples use `[tool.uv.sources]` @@ -28,5 +29,5 @@ Those samples use the Foundry-managed protocol surface with no |---|---|---| | Server stack | App-owned FastAPI + hosting protocol helpers | Foundry Hosted Agents runtime | | Protocol surface | The app exposes the route and calls helpers | The platform exposes Responses + Invocations | -| Run target | Local Hypercorn (`local_responses/`) | Hosted Agents or local container targeting the Hosted Agents contract | +| Run target | Local Hypercorn (`local_responses/`, `local_responses_workflow/`) | Hosted Agents or local container targeting the Hosted Agents contract | | When to pick this | You need custom hosting code or want to learn the helper seam | You want the Foundry-managed hosting surface | diff --git a/python/samples/04-hosting/af-hosting/local_responses/README.md b/python/samples/04-hosting/af-hosting/local_responses/README.md index 9c1c2e8d163..3b6ab9d5cf0 100644 --- a/python/samples/04-hosting/af-hosting/local_responses/README.md +++ b/python/samples/04-hosting/af-hosting/local_responses/README.md @@ -32,6 +32,17 @@ What the route demonstrates: `app:app` is a module-level FastAPI ASGI app; recommended local launch is Hypercorn. +## Production readiness + +This is not a full-fledged production deployment. Before exposing this pattern +to callers, add authentication and authorization at the infrastructure layer, +the FastAPI app layer, or inside the route body. + +Session continuation deserves particular care: treat `previous_response_id` and +`conversation_id` as untrusted request values, authorize the caller before +loading or storing a session for those ids, and partition any durable session +store by tenant/user as appropriate for your application. + ## Run ```bash 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 f119a2c4aab..98a9652423b 100644 --- a/python/samples/04-hosting/af-hosting/local_responses/app.py +++ b/python/samples/04-hosting/af-hosting/local_responses/app.py @@ -11,6 +11,18 @@ 3. FastAPI owns the route, request parsing, policy decisions, and response object. +Production readiness +--- +This sample is not a full-fledged production deployment. Before exposing this +route to callers, add authentication and authorization at the infrastructure +layer, the FastAPI app layer, or inside the route body. + +Session continuation deserves particular care: treat ``previous_response_id`` +and ``conversation_id`` as untrusted request values, authorize the caller +before loading or storing a session for those ids, and partition durable session +storage by tenant/user as appropriate for your application. See +``README.md#production-readiness``. + Run --- ``app`` is a module-level FastAPI ASGI app. Recommended local launch:: diff --git a/python/samples/04-hosting/af-hosting/local_responses/storage/.gitignore b/python/samples/04-hosting/af-hosting/local_responses/storage/.gitignore new file mode 100644 index 00000000000..d6b7ef32c84 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses/storage/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore 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..420c99b458e --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses_workflow/README.md @@ -0,0 +1,59 @@ +# local_responses_workflow — Responses helpers with a workflow target + +This sample shows the helper-first hosting shape for a local workflow: + +- `responses_to_run(...)` parses the Responses request body. +- `WorkflowState` resolves the workflow target. +- FastAPI owns the route and response construction. +- The app owns file-based checkpoint storage and the + `response_id -> checkpoint_id` cursor used to continue from a previous + response. + +The workflow writes a slogan with one Foundry-backed writer agent and a small +deterministic formatter executor. That keeps the sample focused on native +FastAPI routing, Responses helpers, `WorkflowState`, and app-owned checkpoint +cursor storage. Both workflow checkpoints and the checkpoint cursor file are +stored under the sample's local `storage/` root. + +## Production readiness + +This is not a full-fledged production deployment. Before exposing this pattern +to callers, add authentication and authorization at the infrastructure layer, +the FastAPI app layer, or inside the route body. + +Session continuation deserves particular care: treat `previous_response_id` and +`conversation_id` as untrusted request values, authorize the caller before +restoring or storing a checkpoint cursor for those ids, and partition durable +checkpoint/cursor storage by tenant/user as appropriate for your application. + +## Run + +```bash +export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com +export FOUNDRY_MODEL=gpt-5-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 +uv run python call_server.py '{"topic": "electric SUV", "style": "playful", "audience": "young families"}' +``` + +The script sends a follow-up using the first response id as +`previous_response_id`, so the workflow restores the prior checkpoint before +running the next turn. + +> This sample uses local file storage under `storage/` for both workflow +> checkpoints and checkpoint cursors. Replace it with production-grade durable +> storage for multi-replica or transient hosting. 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..8b8de6598c5 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses_workflow/app.py @@ -0,0 +1,268 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Responses helper sample with a local workflow target and native FastAPI route. + +This sample demonstrates the helper-first hosting shape for workflows: + +1. ``agent-framework-hosting-responses`` converts the Responses request body to + Agent Framework run values and renders the final response payload. +2. ``agent-framework-hosting`` resolves the workflow target via ``WorkflowState``. +3. FastAPI owns the route, request parsing, policy decisions, response object, + and file-backed checkpoint cursor. + +Production readiness +--- +This sample is not a full-fledged production deployment. Before exposing this +route to callers, add authentication and authorization at the infrastructure +layer, the FastAPI app layer, or inside the route body. + +Session continuation deserves particular care: treat ``previous_response_id`` +and ``conversation_id`` as untrusted request values, authorize the caller +before restoring or storing a checkpoint cursor for those ids, and partition +durable checkpoint/cursor storage by tenant/user as appropriate for your +application. See ``README.md#production-readiness``. + +Run +--- +``app`` is a module-level FastAPI ASGI app. Recommended local launch:: + + uv sync + az login + export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com + export FOUNDRY_MODEL=gpt-5-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 with a structured brief:: + + uv run python call_server.py \ + '{"topic": "electric SUV", "style": "playful", "audience": "young families"}' +""" + +from __future__ import annotations + +import asyncio +import json +import os +from collections.abc import Mapping +from pathlib import Path +from typing import Any, cast + +from agent_framework import ( + AgentExecutor, + AgentExecutorResponse, + AgentResponse, + Content, + Executor, + FileCheckpointStorage, + Message, + Workflow, + WorkflowBuilder, + WorkflowContext, + handler, +) +from agent_framework_foundry import FoundryChatClient +from agent_framework_hosting import WorkflowState +from agent_framework_hosting_responses import ( + create_response_id, + responses_from_run, + responses_session_id, + responses_to_run, +) +from azure.identity.aio import DefaultAzureCredential +from fastapi import Body, FastAPI, HTTPException +from fastapi.responses import JSONResponse +from hypercorn.asyncio import serve +from hypercorn.config import Config + +STORAGE_ROOT = Path(__file__).resolve().parent / "storage" +CHECKPOINTS_DIR = STORAGE_ROOT / "checkpoints" +CHECKPOINT_CURSOR_PATH = STORAGE_ROOT / "checkpoint_cursors.json" +CHECKPOINTS_DIR.mkdir(parents=True, exist_ok=True) + +checkpoint_storage = FileCheckpointStorage(str(CHECKPOINTS_DIR)) + + +class CheckpointCursorStore: + """File-backed mapping from Responses ids to workflow checkpoint ids.""" + + def __init__(self, path: Path) -> None: + """Create a cursor store at the given path. + + Args: + path: JSON file containing response-id to checkpoint-id mappings. + """ + self._path = path + + def get(self, key: str) -> str | None: + """Return the checkpoint id for a response or conversation id.""" + return self._load().get(key) + + def set_many(self, cursors: Mapping[str, str]) -> None: + """Persist one or more checkpoint cursors.""" + data = self._load() + data.update(cursors) + self._path.parent.mkdir(parents=True, exist_ok=True) + self._path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + def _load(self) -> dict[str, str]: + if not self._path.exists(): + return {} + + raw = json.loads(self._path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValueError("Checkpoint cursor file must contain a JSON object.") + + data: dict[str, str] = {} + for key, value in raw.items(): + if not isinstance(key, str) or not isinstance(value, str): + raise ValueError("Checkpoint cursor file must map string ids to string checkpoint ids.") + data[key] = value + return data + + +checkpoint_cursor_store = CheckpointCursorStore(CHECKPOINT_CURSOR_PATH) + + +def _workflow_prompt_from_messages(messages: Any) -> str: + """Prepare the workflow's initial writer prompt from Responses input.""" + + def extract_text(value: object) -> str: + 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 "" + + text = extract_text(messages).strip() + topic = text or "a generic product" + style = "modern" + audience = "general" + if topic.startswith("{"): + try: + data = json.loads(topic) + except json.JSONDecodeError: + data = None + if isinstance(data, dict) and "topic" in data: + topic = str(data["topic"]) + style = str(data.get("style", style)) + audience = str(data.get("audience", audience)) + + return ( + f"Topic: {topic}\n" + f"Style: {style}\n" + f"Audience: {audience}\n\n" + "Write a single short slogan that fits the topic, style, and audience." + ) + + +def _response_from_workflow_result(result: Any) -> AgentResponse[Any]: + """Collapse workflow outputs to one assistant response for Responses rendering.""" + outputs = result.get_outputs() if hasattr(result, "get_outputs") else [] + output = outputs[-1] if outputs else "(no workflow output)" + text = output.text if isinstance(output, AgentResponse) else str(output) + return AgentResponse(messages=Message(role="assistant", contents=[Content.from_text(text=text)])) + + +class TerminalFormatter(Executor): + """Format the writer's output as the workflow's final response.""" + + @handler + async def handle(self, response: AgentExecutorResponse, ctx: WorkflowContext[Any, str]) -> None: + """Yield one terminal-friendly slogan string. + + Args: + response: The writer agent's response. + ctx: Workflow context used to yield the final output. + """ + slogan = response.agent_response.text.strip().strip('"') + await ctx.yield_output(f'Slogan: "{slogan}"') + + +def create_workflow() -> Workflow: + """Create the sample slogan workflow.""" + client = FoundryChatClient(credential=DefaultAzureCredential()) + + writer = client.as_agent( + name="writer", + instructions="You are an excellent slogan writer. Create one short slogan from the given brief.", + ) + + writer_ex = AgentExecutor(writer, context_mode="last_agent") + formatter_ex = TerminalFormatter(id="terminal_formatter") + + return ( + WorkflowBuilder( + start_executor=writer_ex, + output_from=[formatter_ex], + ) + .add_edge(writer_ex, formatter_ex) + .build() + ) + + +app = FastAPI() +state = WorkflowState(create_workflow) + + +@app.post("/responses", response_model=None) +async def responses(body: dict[str, Any] = Body(...)) -> JSONResponse: # noqa: B008 + """Handle one OpenAI Responses-shaped request for the workflow.""" + try: + run = responses_to_run(body) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + session_id = responses_session_id(body) + response_id = create_response_id() + lookup_id = session_id or response_id + + target = await state.get_target() + checkpoint_id = checkpoint_cursor_store.get(lookup_id) + if checkpoint_id is not None: + # Restore first. Workflow.run does not allow `message` and + # `checkpoint_id` in the same call. + await target.run(checkpoint_id=checkpoint_id, checkpoint_storage=checkpoint_storage) + + result = await target.run( + message=_workflow_prompt_from_messages(run["messages"]), + checkpoint_storage=checkpoint_storage, + ) + + latest = await checkpoint_storage.get_latest(workflow_name=target.name) + if latest is not None: + # Responses `previous_response_id` can point to any response id. Store + # the current response id as the cursor for this workflow continuation. + cursors = {response_id: latest.checkpoint_id} + if lookup_id.startswith("conv_"): + cursors[lookup_id] = latest.checkpoint_id + checkpoint_cursor_store.set_many(cursors) + + return JSONResponse( + responses_from_run( + _response_from_workflow_result(result), + response_id=response_id, + session_id=session_id, + ) + ) + + +async def main() -> None: + """Run the sample with Hypercorn for local development.""" + config = Config() + config.bind = [f"0.0.0.0:{int(os.environ.get('PORT', '8000'))}"] + await serve(cast(Any, app), config) + + +if __name__ == "__main__": + asyncio.run(main()) + +# Sample output: +# User: {"topic": "electric SUV", "style": "playful", "audience": "young families"} +# Assistant: Slogan: "Big Adventures. Tiny Emissions." +# Response ID: resp_... 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..7d5983a1ca4 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses_workflow/call_server.py @@ -0,0 +1,48 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Local client for the local_responses_workflow sample. + +Posts to ``/responses`` using the standard ``openai`` SDK. + +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"}' +""" + +from __future__ import annotations + +import sys + +from openai import OpenAI + +BASE_URL = "http://127.0.0.1:8000" +DEFAULT_BRIEF = '{"topic": "electric SUV", "style": "playful", "audience": "young families"}' +FOLLOW_UP = "Make it a little more premium, but still family friendly." + + +def main() -> None: + """Send a two-turn workflow conversation to the local server.""" + client = OpenAI(base_url=BASE_URL, api_key="not-needed") + brief = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_BRIEF + + response = client.responses.create(input=brief) + print(f"User: {brief}") + print(f"Workflow: {response.output_text}") + print(f"Response ID: {response.id}") + + follow_up = client.responses.create( + input=FOLLOW_UP, + previous_response_id=response.id, + ) + print() + print(f"User: {FOLLOW_UP}") + print(f"Workflow: {follow_up.output_text}") + print(f"Response ID: {follow_up.id}") + + +if __name__ == "__main__": + main() 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..d6d4e554dc2 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses_workflow/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "agent-framework-hosting-sample-local-responses-workflow" +version = "0.0.1" +description = "Minimal Responses-only local hosting sample with a workflow target and native FastAPI routes." +requires-python = ">=3.10" +dependencies = [ + "agent-framework-foundry", + "agent-framework-hosting", + "agent-framework-hosting-responses", + "azure-identity", + "aiohttp>=3.13.5", + "fastapi>=0.115.0,<0.138.1", + "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/.gitignore b/python/samples/04-hosting/af-hosting/local_responses_workflow/storage/.gitignore new file mode 100644 index 00000000000..d6b7ef32c84 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses_workflow/storage/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore From bbca4ee3c1aea70032c125af7adbe6b809a84d98 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 8 Jul 2026 14:39:40 +0200 Subject: [PATCH 13/15] Address hosting helper review feedback Handle streaming failures as terminal Responses SSE events, guard concurrent target/session initialization, and scope workflow sample checkpoint storage per continuation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/specs/002-python-hosting-channels.md | 12 +-- .../_parsing.py | 82 +++++++++++++------ .../tests/hosting_responses/test_parsing.py | 54 ++++++++++++ .../hosting/agent_framework_hosting/_state.py | 44 +++++++--- .../hosting/tests/hosting/test_state.py | 51 ++++++++++++ .../local_responses_workflow/README.md | 9 +- .../local_responses_workflow/app.py | 61 +++++++++----- 7 files changed, 249 insertions(+), 64 deletions(-) diff --git a/docs/specs/002-python-hosting-channels.md b/docs/specs/002-python-hosting-channels.md index 5ed5fcbdb37..190a192fdff 100644 --- a/docs/specs/002-python-hosting-channels.md +++ b/docs/specs/002-python-hosting-channels.md @@ -65,7 +65,7 @@ must be aligned with the helper-first model before implementation. Old vocabular | Package | Import surface | v1 helper-first contents | |---|---|---| -| `agent-framework-hosting` | `agent_framework_hosting` | `AgentState`, `WorkflowState`, `SessionStore`, run-argument `TypedDict`s, and existing unreleased channel/host types. | +| `agent-framework-hosting` | `agent_framework_hosting` | `AgentState`, `WorkflowState`, `SessionStore`, and run-argument `TypedDict`s. | | `agent-framework-hosting-responses` | `agent_framework_hosting_responses` | Responses helpers: request parsing, session id extraction, response id creation, response rendering, streaming rendering. | | Future protocol packages | e.g. `agent_framework_hosting_telegram` | Protocol-specific helpers such as `telegram_to_run(...)`, `telegram_from_run(...)`, `telegram_session_id(...)`, and command/media helpers when useful. | @@ -179,8 +179,9 @@ The target may be: Workflow checkpointing uses Agent Framework's existing `CheckpointStorage` abstraction directly. Apps that need per-session workflow resume should keep an app-owned cursor such as `session_id -> checkpoint_id`. When the app uses -file-backed cursor storage, the file-based checkpoint storage should share the same app storage root, for example -`storage/checkpoints/` beside `storage/checkpoint_cursors.json`: +file-backed cursor storage, the file-based checkpoint storage should share the same app storage root and should be +scoped to the current authenticated user/tenant/session bucket, for example +`storage/checkpoints//` beside `storage/checkpoint_cursors.json`: ```python # session_id must already be authenticated and authorized for this caller @@ -273,8 +274,9 @@ The application builder decides whether the server is persistent or transient. a service-owned continuation id. - Workflow hosts must choose an explicit `CheckpointStorage` and, when they need per-session resume, a durable `session_id -> checkpoint_id` cursor. File-backed checkpoint storage and file-backed cursor storage should live under - the same app storage root. In-process workflow state and in-memory checkpoint cursors do not survive transient - execution. + the same app storage root, with checkpoints scoped to the current authenticated user/tenant/session bucket so a + "latest checkpoint" lookup cannot cross conversations. In-process workflow state and in-memory checkpoint cursors do + not survive transient execution. ## Minimal FastAPI Responses shape 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 d9333ec7685..d8a5ecfaa9e 100644 --- a/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py +++ b/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py @@ -870,32 +870,62 @@ async def responses_from_streaming_run( ) model: str | None = None - async for update in stream: - if model is None: - model = _model_from_update(update) - if update.text: - yield _sse_event( - "response.output_text.delta", - { - "type": "response.output_text.delta", - "delta": update.text, - }, - ) - - final = await stream.get_final_response() - payload = responses_from_run(final, response_id=response_id, session_id=session_id) - if model is not None: - # The finalized `AgentResponse` never carries a raw representation - # (see `_model_from_update`), so prefer the model observed on the - # stream's own chunks over `responses_from_run`'s "agent" fallback. - payload["model"] = model - yield _sse_event( - "response.completed", - { - "type": "response.completed", - "response": payload, - }, - ) + updates: list[AgentResponseUpdate] = [] + try: + async for update in stream: + updates.append(update) + if model is None: + model = _model_from_update(update) + if update.text: + yield _sse_event( + "response.output_text.delta", + { + "type": "response.output_text.delta", + "delta": update.text, + }, + ) + + final = await stream.get_final_response() + payload = responses_from_run(final, response_id=response_id, session_id=session_id) + if model is not None: + # The finalized `AgentResponse` never carries a raw representation + # (see `_model_from_update`), so prefer the model observed on the + # stream's own chunks over `responses_from_run`'s "agent" fallback. + payload["model"] = model + yield _sse_event( + "response.completed", + { + "type": "response.completed", + "response": payload, + }, + ) + except Exception as exc: + partial_text = "".join(update.text for update in updates if update.text) + response_kwargs: dict[str, Any] = { + "id": response_id, + "object": "response", + "created_at": int(time.time()), + "status": "failed", + "model": model or "agent", + "output": _text_output_items(partial_text, status="failed"), + "parallel_tool_calls": False, + "tool_choice": "auto", + "tools": [], + "metadata": {}, + "error": { + "code": "server_error", + "message": str(exc), + }, + } + if session_id is not None and session_id.startswith("conv_"): + response_kwargs["conversation"] = {"id": session_id} + yield _sse_event( + "response.failed", + { + "type": "response.failed", + "response": _response_payload(OpenAIResponse(**response_kwargs)), + }, + ) __all__ = [ diff --git a/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py b/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py index c3206a171f2..072ac2c20c6 100644 --- a/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py +++ b/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py @@ -4,6 +4,7 @@ from __future__ import annotations +import json from collections.abc import AsyncIterator, Sequence from typing import cast @@ -20,6 +21,11 @@ ) +def _sse_payload(event: str) -> dict[str, object]: + data_line = next(line for line in event.splitlines() if line.startswith("data: ")) + return cast("dict[str, object]", json.loads(data_line.removeprefix("data: "))) + + class TestMessagesFromResponsesInput: def test_string_input_becomes_single_user_message(self) -> None: msgs = messages_from_responses_input("hello") @@ -233,3 +239,51 @@ def finalizer(items: Sequence[AgentResponseUpdate]) -> AgentResponse: assert "lo" in events[2] assert events[-1].startswith("event: response.completed") assert '"conversation":{"id":"conv_1"}' in events[-1] + + async def test_responses_from_streaming_run_emits_failed_when_iteration_raises(self) -> None: + async def updates() -> AsyncIterator[AgentResponseUpdate]: + yield AgentResponseUpdate(contents=[Content.from_text("partial")], role="assistant") + raise RuntimeError("upstream blew up") + + stream = ResponseStream(updates(), finalizer=AgentResponse.from_updates) + + events = [ + event + async for event in responses_from_streaming_run( + stream, + response_id="resp_new", + session_id="conv_1", + ) + ] + + assert events[0].startswith("event: response.created") + assert "response.output_text.delta" in events[1] + assert events[-1].startswith("event: response.failed") + payload = _sse_payload(events[-1]) + response = cast("dict[str, object]", payload["response"]) + error = cast("dict[str, object]", response["error"]) + assert payload["type"] == "response.failed" + assert response["status"] == "failed" + assert response["conversation"] == {"id": "conv_1"} + assert error["message"] == "upstream blew up" + assert "partial" in events[-1] + + async def test_responses_from_streaming_run_emits_failed_when_finalizer_raises(self) -> None: + async def updates() -> AsyncIterator[AgentResponseUpdate]: + yield AgentResponseUpdate(contents=[Content.from_text("partial")], role="assistant") + + def finalizer(items: Sequence[AgentResponseUpdate]) -> AgentResponse: + raise RuntimeError("finalizer blew up") + + stream = ResponseStream(updates(), finalizer=finalizer) + + events = [event async for event in responses_from_streaming_run(stream, response_id="resp_new")] + + assert events[0].startswith("event: response.created") + assert "response.output_text.delta" in events[1] + assert events[-1].startswith("event: response.failed") + payload = _sse_payload(events[-1]) + response = cast("dict[str, object]", payload["response"]) + error = cast("dict[str, object]", response["error"]) + assert response["status"] == "failed" + assert error["message"] == "finalizer blew up" diff --git a/python/packages/hosting/agent_framework_hosting/_state.py b/python/packages/hosting/agent_framework_hosting/_state.py index d89a025e009..ee0c6195a49 100644 --- a/python/packages/hosting/agent_framework_hosting/_state.py +++ b/python/packages/hosting/agent_framework_hosting/_state.py @@ -19,6 +19,7 @@ from __future__ import annotations +import asyncio import inspect from collections.abc import Awaitable, Callable, Mapping from typing import Any, Generic, Protocol, TypedDict, TypeVar, cast, runtime_checkable @@ -168,9 +169,11 @@ def __init__( self._target_source = target self._cache_target = cache_target self._cached_target: AgentT | None = None + self._target_lock = asyncio.Lock() if not callable(target) and not inspect.isawaitable(target): self._cached_target = target self._session_store: SessionStore = session_store if session_store is not None else SessionStore() + self._session_locks: dict[str, asyncio.Lock] = {} async def get_target(self) -> AgentT: """Return the resolved target. @@ -182,11 +185,19 @@ async def get_target(self) -> AgentT: if self._cache_target and self._cached_target is not None: return self._cached_target + if self._cache_target: + async with self._target_lock: + if self._cached_target is not None: + return self._cached_target + target = self._target_source() if callable(self._target_source) else self._target_source + if inspect.isawaitable(target): + target = await target + self._cached_target = target + return target + target = self._target_source() if callable(self._target_source) else self._target_source if inspect.isawaitable(target): target = await target - if self._cache_target: - self._cached_target = target return target @property @@ -217,12 +228,16 @@ async def get_or_create_session(self, session_id: str) -> AgentSession: Returns: The stored or newly created ``AgentSession``. """ - session = await self._session_store.get(session_id) - if session is None: - target = await self.get_target() - session = target.create_session(session_id=session_id) - await self._session_store.set(session_id, session) - return session + if not session_id: + raise ValueError("session_id must be a non-empty string") + session_lock = self._session_locks.setdefault(session_id, asyncio.Lock()) + async with session_lock: + session = await self._session_store.get(session_id) + if session is None: + target = await self.get_target() + session = target.create_session(session_id=session_id) + await self._session_store.set(session_id, session) + return session async def set_session(self, session_id: str, session: AgentSession) -> None: """Store ``session`` under ``session_id`` in this state's session store. @@ -279,6 +294,7 @@ def __init__( self._target_source = target self._cache_target = cache_target self._cached_target: WorkflowT | None = None + self._target_lock = asyncio.Lock() if not callable(target) and not inspect.isawaitable(target): self._cached_target = target @@ -292,11 +308,19 @@ async def get_target(self) -> WorkflowT: if self._cache_target and self._cached_target is not None: return self._cached_target + if self._cache_target: + async with self._target_lock: + if self._cached_target is not None: + return self._cached_target + target = self._target_source() if callable(self._target_source) else self._target_source + if inspect.isawaitable(target): + target = await target + self._cached_target = target + return target + target = self._target_source() if callable(self._target_source) else self._target_source if inspect.isawaitable(target): target = await target - if self._cache_target: - self._cached_target = target return target @property diff --git a/python/packages/hosting/tests/hosting/test_state.py b/python/packages/hosting/tests/hosting/test_state.py index 62ce2067538..b0c8a0c68f4 100644 --- a/python/packages/hosting/tests/hosting/test_state.py +++ b/python/packages/hosting/tests/hosting/test_state.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import importlib from collections.abc import AsyncIterator, Awaitable, Mapping from typing import Any, Literal, overload @@ -212,6 +213,22 @@ async def create_agent() -> _FakeAgent: assert isinstance(await state.get_target(), _FakeAgent) + async def test_bare_awaitable_target_is_awaited_once_for_concurrent_callers(self) -> None: + calls = 0 + + async def create_agent() -> _FakeAgent: + nonlocal calls + calls += 1 + await asyncio.sleep(0) + return _FakeAgent() + + state = AgentState(create_agent()) + + first, second = await asyncio.gather(state.get_target(), state.get_target()) + + assert first is second + assert calls == 1 + def test_cache_target_false_rejects_bare_awaitable(self) -> None: async def create_agent() -> _FakeAgent: return _FakeAgent() @@ -234,6 +251,24 @@ async def test_get_or_create_session_creates_and_stores_once(self) -> None: assert first.session_id == "session-1" assert len(agent.created_sessions) == 1 + async def test_get_or_create_session_creates_once_for_concurrent_callers(self) -> None: + class _YieldingSessionStore(SessionStore): + async def get(self, session_id: str) -> AgentSession | None: + await asyncio.sleep(0) + return await super().get(session_id) + + async def set(self, session_id: str, session: AgentSession) -> None: + await asyncio.sleep(0) + await super().set(session_id, session) + + agent = _FakeAgent() + state = AgentState(agent, session_store=_YieldingSessionStore()) + + sessions = await asyncio.gather(*(state.get_or_create_session("session-1") for _ in range(20))) + + assert all(session is sessions[0] for session in sessions) + assert len(agent.created_sessions) == 1 + async def test_get_or_create_session_reuses_a_session_set_on_the_state(self) -> None: agent = _FakeAgent() state = AgentState(agent) @@ -261,6 +296,22 @@ async def test_workflow_target_resolved_from_factory(self) -> None: target = await state.get_target() assert isinstance(target, Workflow) + async def test_bare_awaitable_workflow_target_is_awaited_once_for_concurrent_callers(self) -> None: + calls = 0 + + async def create_workflow() -> Workflow: + nonlocal calls + calls += 1 + await asyncio.sleep(0) + return _workflow_fixture("build_echo_workflow")() + + state: WorkflowState[Workflow] = WorkflowState(create_workflow()) + + first, second = await asyncio.gather(state.get_target(), state.get_target()) + + assert first is second + assert calls == 1 + async def test_accepts_workflow_builder_instance_directly(self) -> None: """A ``WorkflowBuilder`` is not itself callable or awaitable; the state must recognize its `build()` method and call it, not cache the raw builder.""" 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 index 420c99b458e..f3bbf0614bc 100644 --- a/python/samples/04-hosting/af-hosting/local_responses_workflow/README.md +++ b/python/samples/04-hosting/af-hosting/local_responses_workflow/README.md @@ -13,7 +13,9 @@ The workflow writes a slogan with one Foundry-backed writer agent and a small deterministic formatter executor. That keeps the sample focused on native FastAPI routing, Responses helpers, `WorkflowState`, and app-owned checkpoint cursor storage. Both workflow checkpoints and the checkpoint cursor file are -stored under the sample's local `storage/` root. +stored under the sample's local `storage/` root. Checkpoints are scoped into +per-continuation buckets so a "latest checkpoint" lookup cannot cross +conversations. ## Production readiness @@ -55,5 +57,6 @@ The script sends a follow-up using the first response id as running the next turn. > This sample uses local file storage under `storage/` for both workflow -> checkpoints and checkpoint cursors. Replace it with production-grade durable -> storage for multi-replica or transient hosting. +> checkpoints and checkpoint cursors. The checkpoint bucket names are hashed +> from the continuation id before they are used as directory names. Replace this +> with production-grade durable storage for multi-replica or transient hosting. 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 index 8b8de6598c5..a164ebb355e 100644 --- a/python/samples/04-hosting/af-hosting/local_responses_workflow/app.py +++ b/python/samples/04-hosting/af-hosting/local_responses_workflow/app.py @@ -45,11 +45,12 @@ from __future__ import annotations import asyncio +import hashlib import json import os from collections.abc import Mapping from pathlib import Path -from typing import Any, cast +from typing import Any, TypedDict, cast from agent_framework import ( AgentExecutor, @@ -79,11 +80,16 @@ from hypercorn.config import Config STORAGE_ROOT = Path(__file__).resolve().parent / "storage" -CHECKPOINTS_DIR = STORAGE_ROOT / "checkpoints" +CHECKPOINTS_ROOT = STORAGE_ROOT / "checkpoints" CHECKPOINT_CURSOR_PATH = STORAGE_ROOT / "checkpoint_cursors.json" -CHECKPOINTS_DIR.mkdir(parents=True, exist_ok=True) +CHECKPOINTS_ROOT.mkdir(parents=True, exist_ok=True) -checkpoint_storage = FileCheckpointStorage(str(CHECKPOINTS_DIR)) + +class CheckpointCursor(TypedDict): + """Stored pointer to a workflow checkpoint and its storage bucket.""" + + checkpoint_id: str + storage_id: str class CheckpointCursorStore: @@ -97,18 +103,18 @@ def __init__(self, path: Path) -> None: """ self._path = path - def get(self, key: str) -> str | None: - """Return the checkpoint id for a response or conversation id.""" + def get(self, key: str) -> CheckpointCursor | None: + """Return the checkpoint cursor for a response or conversation id.""" return self._load().get(key) - def set_many(self, cursors: Mapping[str, str]) -> None: + def set_many(self, cursors: Mapping[str, CheckpointCursor]) -> None: """Persist one or more checkpoint cursors.""" data = self._load() data.update(cursors) self._path.parent.mkdir(parents=True, exist_ok=True) self._path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") - def _load(self) -> dict[str, str]: + def _load(self) -> dict[str, CheckpointCursor]: if not self._path.exists(): return {} @@ -116,17 +122,27 @@ def _load(self) -> dict[str, str]: if not isinstance(raw, dict): raise ValueError("Checkpoint cursor file must contain a JSON object.") - data: dict[str, str] = {} + data: dict[str, CheckpointCursor] = {} for key, value in raw.items(): - if not isinstance(key, str) or not isinstance(value, str): - raise ValueError("Checkpoint cursor file must map string ids to string checkpoint ids.") - data[key] = value + if not isinstance(key, str) or not isinstance(value, Mapping): + raise ValueError("Checkpoint cursor file must map string ids to checkpoint cursor objects.") + checkpoint_id = value.get("checkpoint_id") + storage_id = value.get("storage_id") + if not isinstance(checkpoint_id, str) or not isinstance(storage_id, str): + raise ValueError("Checkpoint cursor objects must contain string checkpoint_id and storage_id fields.") + data[key] = CheckpointCursor(checkpoint_id=checkpoint_id, storage_id=storage_id) return data checkpoint_cursor_store = CheckpointCursorStore(CHECKPOINT_CURSOR_PATH) +def _checkpoint_storage_for(storage_id: str) -> FileCheckpointStorage: + """Return file checkpoint storage scoped to a single continuation bucket.""" + storage_key = hashlib.sha256(storage_id.encode("utf-8")).hexdigest() + return FileCheckpointStorage(str(CHECKPOINTS_ROOT / storage_key)) + + def _workflow_prompt_from_messages(messages: Any) -> str: """Prepare the workflow's initial writer prompt from Responses input.""" @@ -198,6 +214,7 @@ def create_workflow() -> Workflow: return ( WorkflowBuilder( + name="local_responses_slogan_workflow", start_executor=writer_ex, output_from=[formatter_ex], ) @@ -207,7 +224,7 @@ def create_workflow() -> Workflow: app = FastAPI() -state = WorkflowState(create_workflow) +state = WorkflowState(create_workflow, cache_target=False) @app.post("/responses", response_model=None) @@ -220,15 +237,18 @@ async def responses(body: dict[str, Any] = Body(...)) -> JSONResponse: # noqa: session_id = responses_session_id(body) response_id = create_response_id() - lookup_id = session_id or response_id target = await state.get_target() - checkpoint_id = checkpoint_cursor_store.get(lookup_id) - if checkpoint_id is not None: + if session_id is not None and (checkpoint_cursor := checkpoint_cursor_store.get(session_id)) is not None: # Restore first. Workflow.run does not allow `message` and # `checkpoint_id` in the same call. - await target.run(checkpoint_id=checkpoint_id, checkpoint_storage=checkpoint_storage) + await target.run( + checkpoint_id=checkpoint_cursor["checkpoint_id"], + checkpoint_storage=_checkpoint_storage_for(checkpoint_cursor["storage_id"]), + ) + storage_id = session_id if session_id is not None and session_id.startswith("conv_") else response_id + checkpoint_storage = _checkpoint_storage_for(storage_id) result = await target.run( message=_workflow_prompt_from_messages(run["messages"]), checkpoint_storage=checkpoint_storage, @@ -238,9 +258,10 @@ async def responses(body: dict[str, Any] = Body(...)) -> JSONResponse: # noqa: if latest is not None: # Responses `previous_response_id` can point to any response id. Store # the current response id as the cursor for this workflow continuation. - cursors = {response_id: latest.checkpoint_id} - if lookup_id.startswith("conv_"): - cursors[lookup_id] = latest.checkpoint_id + cursor = CheckpointCursor(checkpoint_id=latest.checkpoint_id, storage_id=storage_id) + cursors = {response_id: cursor} + if session_id is not None and session_id.startswith("conv_"): + cursors[session_id] = cursor checkpoint_cursor_store.set_many(cursors) return JSONResponse( From d108d9ca4567f43f96f7ba112bde8894d94e95fb Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 8 Jul 2026 15:20:59 +0200 Subject: [PATCH 14/15] Clarify Responses sample continuation behavior Document unknown conversation_id behavior in the agent sample and make the workflow sample explicitly reject conversation_id while continuing to use responses_session_id for previous_response_id. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../af-hosting/local_responses/README.md | 3 + .../af-hosting/local_responses/app.py | 6 ++ .../local_responses_workflow/README.md | 13 +-- .../local_responses_workflow/app.py | 81 +++++++++---------- .../local_responses_workflow/call_server.py | 9 ++- 5 files changed, 64 insertions(+), 48 deletions(-) diff --git a/python/samples/04-hosting/af-hosting/local_responses/README.md b/python/samples/04-hosting/af-hosting/local_responses/README.md index 3b6ab9d5cf0..c61be91230b 100644 --- a/python/samples/04-hosting/af-hosting/local_responses/README.md +++ b/python/samples/04-hosting/af-hosting/local_responses/README.md @@ -28,6 +28,9 @@ What the route demonstrates: caller continue from any earlier response, not just the latest one — so every response id needs to stay independently resolvable, not just the most recent. +- Treats an unknown `conversation_id` as a request to create a new local + session. Your app can choose a stricter policy, such as requiring a separate + API to create new conversations before callers can continue them. `app:app` is a module-level FastAPI ASGI app; recommended local launch is Hypercorn. 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 98a9652423b..9890fd524ef 100644 --- a/python/samples/04-hosting/af-hosting/local_responses/app.py +++ b/python/samples/04-hosting/af-hosting/local_responses/app.py @@ -23,6 +23,10 @@ storage by tenant/user as appropriate for your application. See ``README.md#production-readiness``. +Unknown ``conversation_id`` values create a new local session in this sample. +Your app can choose a different policy, such as requiring a separate API to +create new conversations before callers can continue them. + Run --- ``app`` is a module-level FastAPI ASGI app. Recommended local launch:: @@ -125,6 +129,8 @@ async def responses(body: dict[str, Any] = Body(...)) -> JSONResponse | Streamin target = await state.get_target() lookup_id = session_id or response_id + # An unknown `conversation_id` becomes a new session here. Production apps + # can choose to require a separate "create conversation" API instead. session = await state.get_or_create_session(lookup_id) if run["stream"]: stream = target.run( 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 index f3bbf0614bc..f5961cc36b0 100644 --- a/python/samples/04-hosting/af-hosting/local_responses_workflow/README.md +++ b/python/samples/04-hosting/af-hosting/local_responses_workflow/README.md @@ -8,6 +8,8 @@ This sample shows the helper-first hosting shape for a local workflow: - The app owns file-based checkpoint storage and the `response_id -> checkpoint_id` cursor used to continue from a previous response. +- Continuation is intentionally limited to `previous_response_id`; this sample + rejects `conversation_id` continuity with HTTP 400. The workflow writes a slogan with one Foundry-backed writer agent and a small deterministic formatter executor. That keeps the sample focused on native @@ -23,10 +25,10 @@ This is not a full-fledged production deployment. Before exposing this pattern to callers, add authentication and authorization at the infrastructure layer, the FastAPI app layer, or inside the route body. -Session continuation deserves particular care: treat `previous_response_id` and -`conversation_id` as untrusted request values, authorize the caller before -restoring or storing a checkpoint cursor for those ids, and partition durable -checkpoint/cursor storage by tenant/user as appropriate for your application. +Session continuation deserves particular care: treat `previous_response_id` as +an untrusted request value, authorize the caller before restoring or storing a +checkpoint cursor for that id, and partition durable checkpoint/cursor storage +by tenant/user as appropriate for your application. ## Run @@ -54,7 +56,8 @@ uv run python call_server.py '{"topic": "electric SUV", "style": "playful", "aud The script sends a follow-up using the first response id as `previous_response_id`, so the workflow restores the prior checkpoint before -running the next turn. +running the next turn. It deliberately does not send `conversation_id`, because +this sample rejects `conversation_id` continuation. > This sample uses local file storage under `storage/` for both workflow > checkpoints and checkpoint cursors. The checkpoint bucket names are hashed 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 index a164ebb355e..4d779d66db8 100644 --- a/python/samples/04-hosting/af-hosting/local_responses_workflow/app.py +++ b/python/samples/04-hosting/af-hosting/local_responses_workflow/app.py @@ -16,9 +16,10 @@ route to callers, add authentication and authorization at the infrastructure layer, the FastAPI app layer, or inside the route body. -Session continuation deserves particular care: treat ``previous_response_id`` -and ``conversation_id`` as untrusted request values, authorize the caller -before restoring or storing a checkpoint cursor for those ids, and partition +This sample demonstrates continuation with ``previous_response_id`` only. It +rejects ``conversation_id`` continuity with HTTP 400. Treat every +``previous_response_id`` as an untrusted request value, authorize the caller +before restoring or storing a checkpoint cursor for that id, and partition durable checkpoint/cursor storage by tenant/user as appropriate for your application. See ``README.md#production-readiness``. @@ -53,6 +54,7 @@ from typing import Any, TypedDict, cast from agent_framework import ( + Agent, AgentExecutor, AgentExecutorResponse, AgentResponse, @@ -60,7 +62,6 @@ Executor, FileCheckpointStorage, Message, - Workflow, WorkflowBuilder, WorkflowContext, handler, @@ -104,7 +105,7 @@ def __init__(self, path: Path) -> None: self._path = path def get(self, key: str) -> CheckpointCursor | None: - """Return the checkpoint cursor for a response or conversation id.""" + """Return the checkpoint cursor for a previous response id.""" return self._load().get(key) def set_many(self, cursors: Mapping[str, CheckpointCursor]) -> None: @@ -137,13 +138,13 @@ def _load(self) -> dict[str, CheckpointCursor]: checkpoint_cursor_store = CheckpointCursorStore(CHECKPOINT_CURSOR_PATH) -def _checkpoint_storage_for(storage_id: str) -> FileCheckpointStorage: +def checkpoint_storage_for(storage_id: str) -> FileCheckpointStorage: """Return file checkpoint storage scoped to a single continuation bucket.""" storage_key = hashlib.sha256(storage_id.encode("utf-8")).hexdigest() return FileCheckpointStorage(str(CHECKPOINTS_ROOT / storage_key)) -def _workflow_prompt_from_messages(messages: Any) -> str: +def workflow_prompt_from_messages(messages: Any) -> str: """Prepare the workflow's initial writer prompt from Responses input.""" def extract_text(value: object) -> str: @@ -177,7 +178,7 @@ def extract_text(value: object) -> str: ) -def _response_from_workflow_result(result: Any) -> AgentResponse[Any]: +def response_from_workflow_result(result: Any) -> AgentResponse[Any]: """Collapse workflow outputs to one assistant response for Responses rendering.""" outputs = result.get_outputs() if hasattr(result, "get_outputs") else [] output = outputs[-1] if outputs else "(no workflow output)" @@ -200,31 +201,24 @@ async def handle(self, response: AgentExecutorResponse, ctx: WorkflowContext[Any await ctx.yield_output(f'Slogan: "{slogan}"') -def create_workflow() -> Workflow: - """Create the sample slogan workflow.""" - client = FoundryChatClient(credential=DefaultAzureCredential()) - - writer = client.as_agent( - name="writer", - instructions="You are an excellent slogan writer. Create one short slogan from the given brief.", - ) - - writer_ex = AgentExecutor(writer, context_mode="last_agent") - formatter_ex = TerminalFormatter(id="terminal_formatter") +client = FoundryChatClient(credential=DefaultAzureCredential()) +writer = Agent( + client=client, + name="writer", + instructions="You are an excellent slogan writer. Create one short slogan from the given brief.", +) +writer_ex = AgentExecutor(writer, context_mode="last_agent") +formatter_ex = TerminalFormatter(id="terminal_formatter") - return ( - WorkflowBuilder( - name="local_responses_slogan_workflow", - start_executor=writer_ex, - output_from=[formatter_ex], - ) - .add_edge(writer_ex, formatter_ex) - .build() - ) +workflow_builder = WorkflowBuilder( + name="local_responses_slogan_workflow", + start_executor=writer_ex, + output_from=[formatter_ex], +).add_edge(writer_ex, formatter_ex) app = FastAPI() -state = WorkflowState(create_workflow, cache_target=False) +state = WorkflowState(workflow_builder, cache_target=False) @app.post("/responses", response_model=None) @@ -235,22 +229,30 @@ async def responses(body: dict[str, Any] = Body(...)) -> JSONResponse: # noqa: except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc - session_id = responses_session_id(body) + # This sample demonstrates only Responses `previous_response_id` + # continuation. `responses_session_id` also returns `conversation_id`, so + # reject that shape here instead of treating it as a checkpoint cursor. + previous_response_id = responses_session_id(body) + if previous_response_id and not previous_response_id.startswith("resp_"): + raise HTTPException( + status_code=400, + detail="This server supports previous_response_id continuation only; conversation_id is not implemented.", + ) response_id = create_response_id() target = await state.get_target() - if session_id is not None and (checkpoint_cursor := checkpoint_cursor_store.get(session_id)) is not None: + if previous_response_id and (checkpoint_cursor := checkpoint_cursor_store.get(previous_response_id)) is not None: # Restore first. Workflow.run does not allow `message` and # `checkpoint_id` in the same call. await target.run( checkpoint_id=checkpoint_cursor["checkpoint_id"], - checkpoint_storage=_checkpoint_storage_for(checkpoint_cursor["storage_id"]), + checkpoint_storage=checkpoint_storage_for(checkpoint_cursor["storage_id"]), ) - storage_id = session_id if session_id is not None and session_id.startswith("conv_") else response_id - checkpoint_storage = _checkpoint_storage_for(storage_id) + storage_id = response_id + checkpoint_storage = checkpoint_storage_for(storage_id) result = await target.run( - message=_workflow_prompt_from_messages(run["messages"]), + message=workflow_prompt_from_messages(run["messages"]), checkpoint_storage=checkpoint_storage, ) @@ -259,16 +261,13 @@ async def responses(body: dict[str, Any] = Body(...)) -> JSONResponse: # noqa: # Responses `previous_response_id` can point to any response id. Store # the current response id as the cursor for this workflow continuation. cursor = CheckpointCursor(checkpoint_id=latest.checkpoint_id, storage_id=storage_id) - cursors = {response_id: cursor} - if session_id is not None and session_id.startswith("conv_"): - cursors[session_id] = cursor - checkpoint_cursor_store.set_many(cursors) + checkpoint_cursor_store.set_many({response_id: cursor}) return JSONResponse( responses_from_run( - _response_from_workflow_result(result), + response_from_workflow_result(result), response_id=response_id, - session_id=session_id, + session_id=previous_response_id, ) ) 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 index 7d5983a1ca4..418708c8396 100644 --- 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 @@ -2,7 +2,10 @@ """Local client for the local_responses_workflow sample. -Posts to ``/responses`` using the standard ``openai`` SDK. +Posts to ``/responses`` using the standard ``openai`` SDK. This client +demonstrates the sample's only supported continuation mode: +``previous_response_id``. It deliberately does not send ``conversation_id``, +which the sample server rejects. Start the server first (in another shell):: @@ -25,7 +28,7 @@ def main() -> None: - """Send a two-turn workflow conversation to the local server.""" + """Send a two-turn workflow conversation using ``previous_response_id``.""" client = OpenAI(base_url=BASE_URL, api_key="not-needed") brief = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_BRIEF @@ -34,6 +37,8 @@ def main() -> None: print(f"Workflow: {response.output_text}") print(f"Response ID: {response.id}") + # Continue with the returned response id. The server sample rejects + # `conversation_id` continuity. follow_up = client.responses.create( input=FOLLOW_UP, previous_response_id=response.id, From 47b4975920397702e6bf6e48a1eba670ac791433 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 9 Jul 2026 09:45:30 +0200 Subject: [PATCH 15/15] Clarify Responses sample option policy Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../04-hosting/af-hosting/local_responses/README.md | 7 +++++-- .../04-hosting/af-hosting/local_responses/app.py | 13 +++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/python/samples/04-hosting/af-hosting/local_responses/README.md b/python/samples/04-hosting/af-hosting/local_responses/README.md index c61be91230b..ab0c67fa286 100644 --- a/python/samples/04-hosting/af-hosting/local_responses/README.md +++ b/python/samples/04-hosting/af-hosting/local_responses/README.md @@ -15,8 +15,11 @@ request bodies, response objects, and server startup. What the route demonstrates: -- **Strips** caller-supplied `model` / `temperature` / `store` so the app owns - deployment and persistence settings. +- Uses an explicit request-option allowlist. This sample only allows + `max_tokens` and then overrides `reasoning`; all other caller-supplied + options, including `model`, `temperature`, `store`, `tools`, and + `tool_choice`, are denied by default. Your app decides the exact allowed, + altered, and denied options. - **Forces** a `reasoning` preset (`effort=medium`, `summary=auto`) on every turn. - Produces the AF messages, options, and session id that the route passes to 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 9890fd524ef..da3ae51bda5 100644 --- a/python/samples/04-hosting/af-hosting/local_responses/app.py +++ b/python/samples/04-hosting/af-hosting/local_responses/app.py @@ -107,6 +107,8 @@ def create_agent() -> Agent: app = FastAPI() state = AgentState(create_agent) +ALLOWED_REQUEST_OPTIONS = frozenset({"max_tokens", "reasoning"}) + @app.post("/responses", response_model=None) async def responses(body: dict[str, Any] = Body(...)) -> JSONResponse | StreamingResponse: # noqa: B008 @@ -118,12 +120,11 @@ async def responses(body: dict[str, Any] = Body(...)) -> JSONResponse | Streamin session_id = responses_session_id(body) response_id = create_response_id() - options = dict(run["options"]) - # App-specific policy: caller cannot pick deployment/persistence settings, - # and this sample forces a consistent reasoning preset. - options.pop("model", None) - options.pop("temperature", None) - options.pop("store", None) + # App-specific policy: allow only the request options this route is willing + # to honor. This denies tools, tool_choice, deployment/persistence fields, + # and all other caller-supplied options by default. Your app decides which + # options are allowed, altered, or denied. + options = {key: value for key, value in run["options"].items() if key in ALLOWED_REQUEST_OPTIONS} options["reasoning"] = {"effort": "medium", "summary": "auto"} options_for_run = cast(Any, options)